gendesign/backend/app/workers/tasks/reservation_ingest.py
bot-backend d1fa450f48
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
fix(backend): reservation_ingest пейсинг + короткоживущая DB-сессия (#2445-D4) (#2458)
2026-07-05 20:09:00 +00:00

322 lines
14 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.

"""Celery task: ingest постановлений об изъятии/резервировании ЗУ → ``land_reservation`` (#1062).
#1062 (ИРД-5), эпик #1067 (GG-форсайт). Загружает PDF-постановления с pravo.gov66.ru
через pravo_gov66_client, извлекает кад-номера через page_reservation_parser,
UPSERT-ит в land_reservation (м.136).
Геометрии зон здесь НЕТ — join к cad_parcels.geom на read-side (reservation_lookup.py).
Beat НЕ настроен: постановления выходят редко, запуск ручной/по требованию.
celery call tasks.reservation_ingest.ingest_reservations
Conventions (зеркало ird_harvest.py / backend.md):
• Session lifecycle (#2445 D4): PDF fetch происходит БЕЗ открытой сессии — pravo.gov66
иногда медленный (таймаут до 60с), держать connection-pool slot на всё время внешнего
запроса нельзя. Короткоживущий ``SessionLocal()`` открывается ТОЛЬКО на запись строк
одного документа (open → upsert → commit → close per item), не на весь цикл.
• SAVEPOINT per-row (``with db.begin_nested():``) — битая строка не валит батч.
• CAST(:x AS type) — НИКОГДА :x::text (psycopg v3).
• ON CONFLICT (cad_num, act_number) → обновляет метаданные + fetched_at.
• httpx.Client (не requests). Сбой одного дока не валит прогон.
• q-запросы — 2-3 слова (движок pravo.gov66 строгий AND/фраза, 1 или 4+ → 0 хитов).
• Pacing/backoff к pravo.gov66.ru — внутри pravo_gov66_client (_get_with_backoff),
НЕ здесь: каждый search/page/pdf запрос уже пейсится на уровне клиента.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.scrapers.page_reservation_parser import extract_from_pdf
from app.services.scrapers.pravo_gov66_client import (
fetch_pdf,
get_document_pdf_url,
search_documents,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── Запросы для enumerate ──────────────────────────────────────────────────────
# 2-3 слова: движок pravo.gov66 строгий AND/фраза; 1 или 4+ → 0 результатов.
_RESERVATION_QUERIES: list[str] = [
"изъятие земельных участков",
"резервирование земельных участков",
]
# Фильтр заголовков: документ должен содержать изъят|резерв И Екатеринбург|Свердлов.
_RE_RELEVANCE = re.compile(r"изъят|резерв", re.IGNORECASE | re.UNICODE)
_RE_GEO = re.compile(r"Екатеринбург|Свердлов", re.IGNORECASE | re.UNICODE)
# UPSERT: ON CONFLICT (cad_num, act_number) → обновляем все поля кроме id.
# CAST(:x AS type) — psycopg v3. DO NOTHING fallback если act_number IS NULL (NULL != NULL → нет
# конфликта, вставляется дубль — acceptable для NULL-act_number edge-case).
_UPSERT_SQL = text(
"""
INSERT INTO land_reservation (
cad_num, reservation_kind, basis_act, act_number, act_date,
purpose, doc_url, source, is_active, raw_excerpt, fetched_at
) VALUES (
CAST(:cad_num AS text),
CAST(:reservation_kind AS text),
CAST(:basis_act AS text),
CAST(:act_number AS text),
CAST(:act_date AS date),
CAST(:purpose AS text),
CAST(:doc_url AS text),
CAST(:source AS text),
true,
CAST(:raw_excerpt AS text),
now()
)
ON CONFLICT (cad_num, act_number) DO UPDATE SET
reservation_kind = EXCLUDED.reservation_kind,
basis_act = EXCLUDED.basis_act,
act_date = EXCLUDED.act_date,
purpose = EXCLUDED.purpose,
doc_url = EXCLUDED.doc_url,
source = EXCLUDED.source,
raw_excerpt = EXCLUDED.raw_excerpt,
fetched_at = now()
"""
)
def _is_relevant_title(title: str) -> bool:
"""Проверяет, что заголовок документа касается изъятия/резервирования ЗУ в регионе."""
return bool(_RE_RELEVANCE.search(title) and _RE_GEO.search(title))
def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
"""Скачивает PDF по URL, graceful при ошибке."""
try:
return fetch_pdf(pdf_url)
except Exception as exc:
logger.error("_fetch_pdf_safe: не удалось загрузить %s: %s", pdf_url, exc)
return None
def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation на уже открытой сессии.
Возвращает число upsert'нутых строк.
"""
count = 0
for row in records_meta:
try:
with db.begin_nested(): # SAVEPOINT per-row
db.execute(_UPSERT_SQL, row)
count += 1
except Exception as exc:
logger.warning(
"_upsert_records: пропуск cad_num=%s: %s",
row.get("cad_num"),
exc,
)
return count
def _upsert_records(records_meta: list[dict[str, Any]]) -> int:
"""Открывает короткоживущую сессию ТОЛЬКО на запись одного документа → commit → close.
Session lifecycle (#2445 D4): вызывается ПОСЛЕ внешнего PDF-fetch, никогда во время
него — держать открытую транзакцию/connection-pool slot на время сетевого запроса
к pravo.gov66.ru (таймаут до 60с) недопустимо (см. WAF-инцидент #2443).
"""
if not records_meta:
return 0
db: Session = SessionLocal()
try:
count = _upsert_records_in_session(db, records_meta)
db.commit()
return count
except Exception:
db.rollback()
raise
finally:
db.close()
def _enumerate_documents(since: str | None = None) -> list[dict[str, Any]]:
"""Обходит все поисковые запросы и возвращает дедуплицированный список документов.
Args:
since: дата начала периода «DD.MM.YYYY» для инкрементального обхода.
Returns:
Список {'doc_id', 'title', 'url'}, дедуплицированный по doc_id.
"""
seen_ids: set[str] = set()
docs: list[dict[str, Any]] = []
for query in _RESERVATION_QUERIES:
hits = search_documents(query, since=since)
logger.info(
"_enumerate_documents: query=%r%d хитов (до фильтрации)",
query,
len(hits),
)
for hit in hits:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
if doc_id in seen_ids:
continue
if not _is_relevant_title(title):
logger.debug(
"_enumerate_documents: пропуск doc_id=%s (нерелевантный заголовок): %r",
doc_id,
title,
)
continue
seen_ids.add(doc_id)
docs.append(hit)
logger.info("_enumerate_documents: итого %d релевантных документов", len(docs))
return docs
@celery_app.task(name="tasks.reservation_ingest.ingest_reservations")
def ingest_reservations(
docs: list[dict[str, Any]] | None = None,
since: str | None = None,
) -> dict[str, int]:
"""Загружает и ингестирует постановления об изъятии/резервировании ЗУ.
Args:
docs: список документов для обработки. Каждый словарь должен содержать:
url (str) — прямая ссылка на PDF или страницу документа,
act_number (str | None) — номер акта (опц.),
basis_act (str) — заголовок/описание акта,
purpose (str | None) — цель (опц.).
Если None — enumerate с pravo.gov66 по _RESERVATION_QUERIES.
since: дата начала периода «DD.MM.YYYY» для инкрементального обхода.
Игнорируется если docs задан явно.
Returns:
{'docs': N_fetched, 'parcels': M_upserted}.
"""
if docs is not None:
# Ручной override: docs передан явно, enumerate не делаем.
return _ingest_explicit_docs(docs)
# Enumerate с pravo.gov66.
enumerated = _enumerate_documents(since=since)
return _ingest_enumerated_docs(enumerated)
def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом).
Session lifecycle (#2445 D4): PDF fetch (сетевой, может занять до 60с) выполняется
БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою короткоживущую сессию
только на запись строк одного документа (commit+close сразу после).
"""
total_parcels = 0
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
pdf_bytes = _fetch_pdf_safe(url) # без открытой сессии — только сетевой запрос
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
return {"docs": len(docs), "parcels": total_parcels}
def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует документы, найденные через enumerate с pravo.gov66.
Session lifecycle (#2445 D4): оба сетевых вызова на документ (get_document_pdf_url,
fetch_pdf) выполняются БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою
короткоживущую сессию только на запись строк одного документа.
"""
total_parcels = 0
fetched_docs = 0
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
pdf_url = get_document_pdf_url(doc_id) # сеть, без открытой сессии
if pdf_url is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
)
continue
pdf_bytes = _fetch_pdf_safe(pdf_url) # сеть, без открытой сессии
if pdf_bytes is None:
logger.warning("ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id)
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
return {"docs": fetched_docs, "parcels": total_parcels}
__all__ = ["ingest_reservations"]