gendesign/backend/app/services/scrapers/documents.py
lekss361 5bcaa67a6d feat(22i): domrf_kn_documents new table + parser
- 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.
2026-05-17 18:13:52 +03:00

193 lines
8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.")