- data/sql/112_22i_domrf_documents.sql: CREATE TABLE domrf_kn_documents with UNIQUE(obj_id, doc_type, doc_num, file_url) + index (obj_id, doc_type). Renamed from original 100_* to 112 to avoid NN collision (100 = mv_layout weighted_avg PR #290 merged; 111 = 22f obj_checks PR #303). - backend/app/services/scrapers/documents.py: extract_documents() + upsert_documents() with SAVEPOINT per-row + canonical doc_type normalisation + download stub. Payload audit (obj_id=65136): kn/object bulk list does NOT contain documents field. Documents live on per-object endpoint /object/{obj_id}/documents (not yet scraped). Follow-up PR needed to wire endpoint into domrf_kn.py scrape loop. Issue #297 22i.
193 lines
8 KiB
Python
193 lines
8 KiB
Python
"""Parser и upsert для PDF-документов DOM.РФ (декларации, разрешения, проектная,
|
||
отчётность, прочее).
|
||
|
||
Issue #297, sub-task 22i.
|
||
|
||
Источник данных: /сервисы/api/object/{obj_id}/documents (per-object endpoint,
|
||
аналогичный /infrastructure и /photos — не входит в bulk kn/object list).
|
||
|
||
Ключи payload (наблюдаемые через chrome-devtools, структура DOM.РФ kn-API v1):
|
||
|
||
[
|
||
{
|
||
"docTypeId": 1, # int — тип по справочнику DOM.РФ
|
||
"docTypeName": "Декларация", # text
|
||
"docNum": "66-001686-Д", # text или null
|
||
"postedDate": "2024-03-15", # date string "YYYY-MM-DD" или null
|
||
"fileUrl": "https://xn--80az8a.../api/ext/file/...",
|
||
"fileSize": 1234567, # bytes, может быть null
|
||
},
|
||
...
|
||
]
|
||
|
||
Поле doc_type нормализуется к одному из канонических значений:
|
||
декларация / разрешение / проектная / отчётность / прочее
|
||
|
||
NOTE: download PDF-файлов — отдельная Celery-задача (future PR).
|
||
Этот модуль только парсит и upsert-ит метаданные в domrf_kn_documents.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Canonical doc_type mapping ────────────────────────────────────────────────
|
||
|
||
_DOC_TYPE_MAP: dict[str, str] = {
|
||
# Exact substrings → canonical type (case-insensitive, checked in order).
|
||
"деклар": "декларация",
|
||
"разреш": "разрешение",
|
||
"проект": "проектная",
|
||
"отчёт": "отчётность",
|
||
"отчет": "отчётность", # without ё
|
||
}
|
||
_DOC_TYPE_FALLBACK = "прочее"
|
||
|
||
_UPSERT_DOC_SQL = text(
|
||
"""
|
||
INSERT INTO domrf_kn_documents
|
||
(obj_id, doc_type, doc_num, posted_at, file_url, size_bytes)
|
||
VALUES
|
||
(:obj_id, :doc_type, :doc_num, :posted_at, :file_url, :size_bytes)
|
||
ON CONFLICT (obj_id, doc_type, doc_num, file_url) DO UPDATE
|
||
SET size_bytes = EXCLUDED.size_bytes,
|
||
local_path = COALESCE(domrf_kn_documents.local_path, EXCLUDED.local_path),
|
||
downloaded_at = COALESCE(domrf_kn_documents.downloaded_at, EXCLUDED.downloaded_at),
|
||
scraped_at = NOW()
|
||
"""
|
||
)
|
||
|
||
|
||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _canonical_doc_type(raw_type: str | None) -> str:
|
||
"""Нормализовать docTypeName DOM.РФ к одному из канонических значений."""
|
||
if not raw_type:
|
||
return _DOC_TYPE_FALLBACK
|
||
lower = raw_type.strip().lower()
|
||
for substr, canonical in _DOC_TYPE_MAP.items():
|
||
if substr in lower:
|
||
return canonical
|
||
return _DOC_TYPE_FALLBACK
|
||
|
||
|
||
def _parse_date(v: Any) -> date | None:
|
||
"""Coerce date-like value to date. Accepts 'YYYY-MM-DD' or datetime."""
|
||
if v is None or v == "":
|
||
return None
|
||
if isinstance(v, date) and not isinstance(v, datetime):
|
||
return v
|
||
if isinstance(v, datetime):
|
||
return v.date()
|
||
if isinstance(v, str):
|
||
s = v.strip()
|
||
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y"):
|
||
try:
|
||
return datetime.strptime(s, fmt).date()
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
# ── Public API ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def extract_documents(raw_payload: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""Извлечь список документов из payload endpoint /object/{obj_id}/documents.
|
||
|
||
Возвращает список dict, готовых для передачи в upsert_documents:
|
||
obj_id — передаётся отдельно в upsert_documents, здесь NOT SET
|
||
doc_type — канонический тип (декларация/разрешение/…)
|
||
doc_num — номер документа или None
|
||
posted_at — дата публикации или None
|
||
file_url — URL PDF (обязательно, строки без URL пропускаются)
|
||
size_bytes — размер файла в байтах или None
|
||
|
||
Записи без file_url пропускаются с warning.
|
||
"""
|
||
result: list[dict[str, Any]] = []
|
||
for item in raw_payload:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
file_url: str | None = item.get("fileUrl") or item.get("file_url") or None
|
||
if not file_url or not isinstance(file_url, str) or not file_url.startswith("http"):
|
||
logger.warning("domrf documents: skipping item without valid fileUrl: %s", item)
|
||
continue
|
||
raw_type = item.get("docTypeName") or item.get("doc_type_name") or None
|
||
doc_num_raw = item.get("docNum") or item.get("doc_num") or None
|
||
doc_num = str(doc_num_raw).strip() if doc_num_raw is not None else None
|
||
size_raw = item.get("fileSize") or item.get("file_size") or None
|
||
try:
|
||
size_bytes: int | None = int(size_raw) if size_raw is not None else None
|
||
except (TypeError, ValueError):
|
||
size_bytes = None
|
||
result.append(
|
||
{
|
||
"doc_type": _canonical_doc_type(raw_type),
|
||
"doc_num": doc_num or None,
|
||
"posted_at": _parse_date(item.get("postedDate") or item.get("posted_date")),
|
||
"file_url": file_url.strip(),
|
||
"size_bytes": size_bytes,
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tuple[int, int]:
|
||
"""INSERT/UPDATE документов объекта в domrf_kn_documents.
|
||
|
||
Использует SAVEPOINT (begin_nested) per-row чтобы один битый URL
|
||
не откатывал всю транзакцию.
|
||
|
||
Возвращает (inserted_or_updated, skipped).
|
||
"""
|
||
if not docs:
|
||
return 0, 0
|
||
ok = 0
|
||
skip = 0
|
||
for doc in docs:
|
||
params: dict[str, Any] = {
|
||
"obj_id": obj_id,
|
||
"doc_type": doc["doc_type"],
|
||
"doc_num": doc.get("doc_num"),
|
||
"posted_at": doc.get("posted_at"),
|
||
"file_url": doc["file_url"],
|
||
"size_bytes": doc.get("size_bytes"),
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
db.execute(_UPSERT_DOC_SQL, params)
|
||
ok += 1
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"upsert document obj=%s url=%s failed: %s",
|
||
obj_id,
|
||
doc.get("file_url"),
|
||
exc,
|
||
)
|
||
skip += 1
|
||
db.commit()
|
||
logger.info("domrf documents obj=%s: upserted=%d skipped=%d", obj_id, ok, skip)
|
||
return ok, skip
|
||
|
||
|
||
# ── Stub for future Celery download task ──────────────────────────────────────
|
||
|
||
|
||
def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None:
|
||
"""Placeholder для будущей Celery-задачи download_domrf_documents.
|
||
|
||
Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename},
|
||
обновляет domrf_kn_documents.local_path + downloaded_at.
|
||
|
||
Реализация — отдельный PR (future task).
|
||
"""
|
||
raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")
|