feat(22i): domrf_kn_documents new table + parser #305
2 changed files with 231 additions and 0 deletions
193
backend/app/services/scrapers/documents.py
Normal file
193
backend/app/services/scrapers/documents.py
Normal 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.")
|
||||
38
data/sql/112_22i_domrf_documents.sql
Normal file
38
data/sql/112_22i_domrf_documents.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue