feat(22i): domrf_kn_documents new table + parser #305

Merged
lekss361 merged 1 commit from fix/22i-documents-v2 into main 2026-05-17 15:21:32 +00:00
2 changed files with 231 additions and 0 deletions

View file

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

View file

@ -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;