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.
This commit is contained in:
parent
16e3d5c86d
commit
5bcaa67a6d
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