From 5bcaa67a6d8fd73dcb1cae11a97ca4eaff976f16 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 18:13:52 +0300 Subject: [PATCH] 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. --- backend/app/services/scrapers/documents.py | 193 +++++++++++++++++++++ data/sql/112_22i_domrf_documents.sql | 38 ++++ 2 files changed, 231 insertions(+) create mode 100644 backend/app/services/scrapers/documents.py create mode 100644 data/sql/112_22i_domrf_documents.sql diff --git a/backend/app/services/scrapers/documents.py b/backend/app/services/scrapers/documents.py new file mode 100644 index 00000000..a6d38f71 --- /dev/null +++ b/backend/app/services/scrapers/documents.py @@ -0,0 +1,193 @@ +"""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.") diff --git a/data/sql/112_22i_domrf_documents.sql b/data/sql/112_22i_domrf_documents.sql new file mode 100644 index 00000000..e6a65c99 --- /dev/null +++ b/data/sql/112_22i_domrf_documents.sql @@ -0,0 +1,38 @@ +-- Migration 100: domrf_kn_documents — PDF documents scraped from DOM.РФ object pages +-- Issue #297, sub-task 22i. Phase 3 new tables. +-- Stores declarations, permits, project docs, reports, misc PDFs per obj_id. + +BEGIN; + +CREATE TABLE IF NOT EXISTS domrf_kn_documents ( + id bigserial PRIMARY KEY, + obj_id bigint NOT NULL, + doc_type text NOT NULL, -- декларация/разрешение/проектная/отчётность/прочее + doc_num text, + posted_at date, + file_url text NOT NULL, + size_bytes bigint, + local_path text, + downloaded_at timestamptz, + scraped_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (obj_id, doc_type, doc_num, file_url) +); + +CREATE INDEX IF NOT EXISTS domrf_kn_documents_obj_type_idx + ON domrf_kn_documents (obj_id, doc_type); + +COMMENT ON TABLE domrf_kn_documents IS + 'PDF docs scraped from DOM.RF obj page. Issue #297 22i.'; + +COMMENT ON COLUMN domrf_kn_documents.doc_type IS + 'Canonical type: декларация / разрешение / проектная / отчётность / прочее'; +COMMENT ON COLUMN domrf_kn_documents.doc_num IS + 'Document number as shown on DOM.RF (e.g. permit number, declaration number)'; +COMMENT ON COLUMN domrf_kn_documents.file_url IS + 'Full URL to the PDF on наш.дом.рф (CDN or api/ext/file/...)'; +COMMENT ON COLUMN domrf_kn_documents.local_path IS + 'Relative path inside data/raw/domrf_docs/ after download (populated by future task)'; +COMMENT ON COLUMN domrf_kn_documents.downloaded_at IS + 'Timestamp when PDF was successfully downloaded locally (NULL = not yet downloaded)'; + +COMMIT;