feat(sf): enumerate изъятия/резервирования с pravo.gov66 → land_reservation (#1062) #1145
4 changed files with 794 additions and 64 deletions
167
backend/app/services/scrapers/pravo_gov66_client.py
Normal file
167
backend/app/services/scrapers/pravo_gov66_client.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""Клиент для поиска и скачивания постановлений с pravo.gov66.ru (#1062).
|
||||||
|
|
||||||
|
Сайт pravo.gov66.ru — реестр правовых актов Правительства Свердловской области.
|
||||||
|
HTML server-rendered (нет JS-рендеринга), httpx достаточно.
|
||||||
|
|
||||||
|
TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
search_documents(query, law_type, since) → list[dict]
|
||||||
|
get_document_pdf_url(doc_id) → str | None
|
||||||
|
fetch_pdf(url) → bytes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE_URL = "https://www.pravo.gov66.ru"
|
||||||
|
_SEARCH_URL = f"{_BASE_URL}/search/"
|
||||||
|
|
||||||
|
# Тайм-аут щедрый: сервер иногда медленный.
|
||||||
|
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
|
||||||
|
|
||||||
|
# Паттерны для извлечения ссылок.
|
||||||
|
# Числовой doc_id в пути: /35496/
|
||||||
|
_RE_DOC_PATH = re.compile(r"^/(\d+)/$")
|
||||||
|
# PDF в /media/pravo/ (не ecp-вариант: ecp — подписанная копия, не всегда парсится).
|
||||||
|
_RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
|
||||||
|
# ecp-вариант как fallback.
|
||||||
|
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def search_documents(
|
||||||
|
query: str,
|
||||||
|
law_type: str = "Постановление",
|
||||||
|
since: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Поиск документов на pravo.gov66.ru.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: поисковый запрос (2-3 слова; 1 или 4+ слов → 0 результатов на сервере).
|
||||||
|
law_type: тип документа, напр. «Постановление».
|
||||||
|
since: дата начала периода в формате «DD.MM.YYYY» (поле signed_start_date).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей {'doc_id': str, 'title': str, 'url': str}.
|
||||||
|
Пустой список при любой сетевой ошибке.
|
||||||
|
"""
|
||||||
|
params: dict[str, str] = {"q": query, "law_type": law_type}
|
||||||
|
if since:
|
||||||
|
params["signed_start_date"] = since
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||||
|
resp = client.get(_SEARCH_URL, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
html = resp.text
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _parse_search_html(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_search_html(html: str) -> list[dict[str, Any]]:
|
||||||
|
"""Извлекает doc_id / title / url из HTML страницы поиска."""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
for a_tag in soup.find_all("a", href=True):
|
||||||
|
href: str = a_tag["href"]
|
||||||
|
m = _RE_DOC_PATH.match(href)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
doc_id = m.group(1)
|
||||||
|
if doc_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(doc_id)
|
||||||
|
title = a_tag.get_text(separator=" ", strip=True)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"title": title,
|
||||||
|
"url": f"{_BASE_URL}{href}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("_parse_search_html: найдено %d документов", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_pdf_url(doc_id: str) -> str | None:
|
||||||
|
"""Получает прямую ссылку на PDF со страницы документа.
|
||||||
|
|
||||||
|
Предпочитает /media/pravo/ (не ecp); если не найдена — берёт ecp-вариант.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc_id: числовой идентификатор документа (из пути /{doc_id}/).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Абсолютный URL PDF или None если не найдена / сетевая ошибка.
|
||||||
|
"""
|
||||||
|
doc_url = f"{_BASE_URL}/{doc_id}/"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||||
|
resp = client.get(doc_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
html = resp.text
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"pravo_gov66_client.get_document_pdf_url: ошибка загрузки doc_id=%s: %s",
|
||||||
|
doc_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _extract_pdf_url(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_url(html: str) -> str | None:
|
||||||
|
"""Извлекает URL PDF из HTML страницы документа."""
|
||||||
|
# Приоритет: /media/pravo/ (не ecp).
|
||||||
|
m = _RE_PDF_PATH.search(html)
|
||||||
|
if m:
|
||||||
|
path = m.group(0)
|
||||||
|
return path if path.startswith("http") else f"{_BASE_URL}{path}"
|
||||||
|
|
||||||
|
# Fallback: ecp-вариант.
|
||||||
|
m2 = _RE_PDF_ECP_PATH.search(html)
|
||||||
|
if m2:
|
||||||
|
path2 = m2.group(0)
|
||||||
|
return path2 if path2.startswith("http") else f"{_BASE_URL}{path2}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pdf(url: str) -> bytes:
|
||||||
|
"""Загружает PDF по URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: абсолютный URL PDF-файла.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Байты PDF.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
|
||||||
|
"""
|
||||||
|
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||||
|
resp = client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"fetch_pdf",
|
||||||
|
"get_document_pdf_url",
|
||||||
|
"search_documents",
|
||||||
|
]
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Celery task: ingest постановлений об изъятии/резервировании ЗУ → ``land_reservation`` (#1091).
|
"""Celery task: ingest постановлений об изъятии/резервировании ЗУ → ``land_reservation`` (#1062).
|
||||||
|
|
||||||
#1062 (ИРД-5), эпик #1067 (GG-форсайт). Загружает PDF-постановления (pravo.gov66.ru / ПАГЕ),
|
#1062 (ИРД-5), эпик #1067 (GG-форсайт). Загружает PDF-постановления с pravo.gov66.ru
|
||||||
извлекает кад-номера через page_reservation_parser, UPSERT-ит в land_reservation (м.136).
|
через pravo_gov66_client, извлекает кад-номера через page_reservation_parser,
|
||||||
|
UPSERT-ит в land_reservation (м.136).
|
||||||
|
|
||||||
Геометрии зон здесь НЕТ — join к cad_parcels.geom на read-side (reservation_lookup.py).
|
Геометрии зон здесь НЕТ — join к cad_parcels.geom на read-side (reservation_lookup.py).
|
||||||
|
|
||||||
|
|
@ -11,41 +12,43 @@ Beat НЕ настроен: постановления выходят редко
|
||||||
Conventions (зеркало ird_harvest.py / backend.md):
|
Conventions (зеркало ird_harvest.py / backend.md):
|
||||||
• SessionLocal() + try/finally close.
|
• SessionLocal() + try/finally close.
|
||||||
• SAVEPOINT per-row (``with db.begin_nested():``) — битая строка не валит батч.
|
• SAVEPOINT per-row (``with db.begin_nested():``) — битая строка не валит батч.
|
||||||
• CAST(:x AS text) — НИКОГДА :x::text (psycopg v3).
|
• CAST(:x AS type) — НИКОГДА :x::text (psycopg v3).
|
||||||
• ON CONFLICT (cad_num, act_number) → обновляет метаданные + fetched_at.
|
• ON CONFLICT (cad_num, act_number) → обновляет метаданные + fetched_at.
|
||||||
• httpx.Client (не requests). Сбой одного дока не валит прогон.
|
• httpx.Client (не requests). Сбой одного дока не валит прогон.
|
||||||
|
• q-запросы — 2-3 слова (движок pravo.gov66 строгий AND/фраза, 1 или 4+ → 0 хитов).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services.scrapers.page_reservation_parser import extract_from_pdf
|
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
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Seed-документы ─────────────────────────────────────────────────────────────
|
# ── Запросы для enumerate ──────────────────────────────────────────────────────
|
||||||
# Единственный известный seed (2026-06): Постановление Правительства Свердловской обл.
|
# 2-3 слова: движок pravo.gov66 строгий AND/фраза; 1 или 4+ → 0 результатов.
|
||||||
# № 509-ПП об изъятии ЗУ под реконструкцию ул. Татищева, ЕКБ.
|
_RESERVATION_QUERIES: list[str] = [
|
||||||
# VERIFY URL: точный PDF-URL нужно уточнить на pravo.gov66.ru — документ может лежать под
|
"изъятие земельных участков",
|
||||||
# разными путями (поиск по «509-ПП» или номеру в реестре). Placeholder установлен намеренно.
|
"резервирование земельных участков",
|
||||||
_SEED_DOCS: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"url": "https://pravo.gov66.ru/documents/view/509-pp", # VERIFY URL — placeholder
|
|
||||||
"kind": "изъятие",
|
|
||||||
"act_number": "509-ПП",
|
|
||||||
"basis_act": "Постановление Правительства Свердловской области № 509-ПП",
|
|
||||||
"purpose": "реконструкция улицы Татищева, г. Екатеринбург",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Фильтр заголовков: документ должен содержать изъят|резерв И Екатеринбург|Свердлов.
|
||||||
|
_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.
|
# UPSERT: ON CONFLICT (cad_num, act_number) → обновляем все поля кроме id.
|
||||||
# CAST(:x AS type) — psycopg v3. DO NOTHING fallback если act_number IS NULL (NULL != NULL → нет
|
# CAST(:x AS type) — psycopg v3. DO NOTHING fallback если act_number IS NULL (NULL != NULL → нет
|
||||||
# конфликта, вставляется дубль — acceptable для NULL-act_number edge-case).
|
# конфликта, вставляется дубль — acceptable для NULL-act_number edge-case).
|
||||||
|
|
@ -80,19 +83,17 @@ _UPSERT_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_pdf(url: str) -> bytes | None:
|
def _is_relevant_title(title: str) -> bool:
|
||||||
"""Загружает PDF по URL. Возвращает None при ошибке (сбой одного дока не валит прогон).
|
"""Проверяет, что заголовок документа касается изъятия/резервирования ЗУ в регионе."""
|
||||||
|
return bool(_RE_RELEVANCE.search(title) and _RE_GEO.search(title))
|
||||||
|
|
||||||
Примечание по TLS: pravo.gov66.ru использует российский УЦ, verify=False. В prod-окружении
|
|
||||||
следует настроить доверенный CA-bundle (аналогично ekb_geoportal_client.py).
|
def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
|
||||||
"""
|
"""Скачивает PDF по URL, graceful при ошибке."""
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=60.0, verify=False, follow_redirects=True) as client:
|
return fetch_pdf(pdf_url)
|
||||||
resp = client.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.content
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("_fetch_pdf: не удалось загрузить %s: %s", url, exc)
|
logger.error("_fetch_pdf_safe: не удалось загрузить %s: %s", pdf_url, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,76 +114,186 @@ def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
@celery_app.task(name="tasks.reservation_ingest.ingest_reservations")
|
||||||
def ingest_reservations(docs: list[dict[str, Any]] | None = None) -> dict[str, int]:
|
def ingest_reservations(
|
||||||
|
docs: list[dict[str, Any]] | None = None,
|
||||||
|
since: str | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
"""Загружает и ингестирует постановления об изъятии/резервировании ЗУ.
|
"""Загружает и ингестирует постановления об изъятии/резервировании ЗУ.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
docs: список документов для обработки. Каждый словарь должен содержать поля:
|
docs: список документов для обработки. Каждый словарь должен содержать:
|
||||||
url (str), kind (str), act_number (str), basis_act (str), purpose (str | None).
|
url (str) — прямая ссылка на PDF или страницу документа,
|
||||||
Если None — используется _SEED_DOCS.
|
act_number (str | None) — номер акта (опц.),
|
||||||
|
basis_act (str) — заголовок/описание акта,
|
||||||
|
purpose (str | None) — цель (опц.).
|
||||||
|
Если None — enumerate с pravo.gov66 по _RESERVATION_QUERIES.
|
||||||
|
since: дата начала периода «DD.MM.YYYY» для инкрементального обхода.
|
||||||
|
Игнорируется если docs задан явно.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{'docs': N, 'parcels': M} — количество обработанных документов и вставленных/
|
{'docs': N_fetched, 'parcels': M_upserted}.
|
||||||
обновлённых записей ЗУ.
|
|
||||||
"""
|
"""
|
||||||
target_docs = docs if docs is not None else _SEED_DOCS
|
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 с ручным вызовом)."""
|
||||||
total_parcels = 0
|
total_parcels = 0
|
||||||
db: Session = SessionLocal()
|
db: Session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
for doc in target_docs:
|
for doc in docs:
|
||||||
url = doc.get("url", "")
|
url = doc.get("url", "")
|
||||||
act_number = doc.get("act_number")
|
act_number = doc.get("act_number")
|
||||||
basis_act = doc.get("basis_act", "")
|
basis_act = doc.get("basis_act", "")
|
||||||
doc_purpose = doc.get("purpose")
|
doc_purpose = doc.get("purpose")
|
||||||
doc_kind = doc.get("kind", "изъятие")
|
doc_kind = doc.get("kind", "изъятие")
|
||||||
|
|
||||||
logger.info("ingest_reservations: загрузка документа url=%s act=%s", url, act_number)
|
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
|
||||||
|
|
||||||
pdf_bytes = _fetch_pdf(url)
|
pdf_bytes = _fetch_pdf_safe(url)
|
||||||
if pdf_bytes is None:
|
if pdf_bytes is None:
|
||||||
logger.warning("ingest_reservations: пропуск документа url=%s (не загружен)", url)
|
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
records = extract_from_pdf(pdf_bytes)
|
records = extract_from_pdf(pdf_bytes)
|
||||||
logger.info(
|
logger.info("ingest_reservations(explicit): url=%s → %d КН", url, len(records))
|
||||||
"ingest_reservations: url=%s → извлечено %d КН",
|
|
||||||
url,
|
|
||||||
len(records),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not records:
|
if not records:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = []
|
rows = [
|
||||||
for rec in records:
|
{
|
||||||
rows.append(
|
"cad_num": rec.cad_num,
|
||||||
{
|
"reservation_kind": rec.reservation_kind or doc_kind,
|
||||||
"cad_num": rec.cad_num,
|
"basis_act": basis_act,
|
||||||
"reservation_kind": rec.reservation_kind or doc_kind,
|
"act_number": rec.act_number or act_number,
|
||||||
"basis_act": basis_act,
|
"act_date": rec.act_date,
|
||||||
"act_number": rec.act_number or act_number,
|
"purpose": rec.purpose or doc_purpose,
|
||||||
"act_date": rec.act_date,
|
"doc_url": url,
|
||||||
"purpose": rec.purpose or doc_purpose,
|
"source": "page_pdf",
|
||||||
"doc_url": url,
|
"raw_excerpt": rec.raw_excerpt,
|
||||||
"source": "page_pdf",
|
}
|
||||||
"raw_excerpt": rec.raw_excerpt,
|
for rec in records
|
||||||
}
|
]
|
||||||
)
|
|
||||||
|
|
||||||
count = _upsert_records(db, rows)
|
count = _upsert_records(db, rows)
|
||||||
db.commit()
|
db.commit()
|
||||||
total_parcels += count
|
total_parcels += count
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"ingest_reservations: готово docs=%d parcels=%d",
|
"ingest_reservations(explicit): готово docs=%d parcels=%d",
|
||||||
len(target_docs),
|
len(docs),
|
||||||
total_parcels,
|
total_parcels,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
return {"docs": len(target_docs), "parcels": total_parcels}
|
return {"docs": len(docs), "parcels": total_parcels}
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
|
||||||
|
"""Ингестирует документы, найденные через enumerate с pravo.gov66."""
|
||||||
|
total_parcels = 0
|
||||||
|
fetched_docs = 0
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
]
|
||||||
|
count = _upsert_records(db, rows)
|
||||||
|
db.commit()
|
||||||
|
total_parcels += count
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ingest_reservations(enum): готово fetched=%d parcels=%d",
|
||||||
|
fetched_docs,
|
||||||
|
total_parcels,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"docs": fetched_docs, "parcels": total_parcels}
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ingest_reservations"]
|
__all__ = ["ingest_reservations"]
|
||||||
|
|
|
||||||
163
backend/tests/scrapers/test_pravo_gov66_client.py
Normal file
163
backend/tests/scrapers/test_pravo_gov66_client.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""Тесты pravo_gov66_client — offline, без живой сети.
|
||||||
|
|
||||||
|
Покрывают: парсинг search-HTML (doc_id/title/url), парсинг страницы документа
|
||||||
|
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.scrapers.pravo_gov66_client import (
|
||||||
|
_extract_pdf_url,
|
||||||
|
_parse_search_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Фикстура HTML страницы поиска ─────────────────────────────────────────────
|
||||||
|
# Реальный фрагмент страницы https://www.pravo.gov66.ru/search/?q=изъятие+земельных+участков
|
||||||
|
# Содержит ссылки вида /NNNNN/ с заголовком постановления.
|
||||||
|
_SEARCH_HTML_FIXTURE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Поиск</title></head>
|
||||||
|
<body>
|
||||||
|
<div class="search-results">
|
||||||
|
<ul>
|
||||||
|
<li class="result-item">
|
||||||
|
<a href="/35496/">
|
||||||
|
Об изъятии земельных участков для государственных нужд
|
||||||
|
Свердловской области в целях реконструкции ул. Татищева
|
||||||
|
в г. Екатеринбурге
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="result-item">
|
||||||
|
<a href="/34812/">
|
||||||
|
О резервировании земельных участков для нужд Екатеринбург
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="result-item">
|
||||||
|
<!-- Ссылка на категорию, не документ — должна игнорироваться -->
|
||||||
|
<a href="/category/law/">Категория</a>
|
||||||
|
</li>
|
||||||
|
<li class="result-item">
|
||||||
|
<!-- Дубль doc_id 35496 — должен дедуплицироваться -->
|
||||||
|
<a href="/35496/">Дубль</a>
|
||||||
|
</li>
|
||||||
|
<li class="result-item">
|
||||||
|
<!-- Нечисловой id — не должен попасть -->
|
||||||
|
<a href="/some-slug/">Текстовый slug</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Фикстура HTML страницы документа с обычным PDF ────────────────────────────
|
||||||
|
_DOC_HTML_WITH_PDF = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Постановление № 509-ПП</h1>
|
||||||
|
<p>
|
||||||
|
Скачать документ:
|
||||||
|
<a href="/media/pravo/509-ПП_ab1234cd.pdf">Скачать PDF</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Подписанная версия:
|
||||||
|
<a href="/media/pravo/ecp/509-ПП.pdf">Скачать подписанный PDF</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Фикстура HTML страницы документа только с ecp PDF ─────────────────────────
|
||||||
|
_DOC_HTML_ECP_ONLY = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Постановление № 312-ПП</h1>
|
||||||
|
<p>
|
||||||
|
<a href="/media/pravo/ecp/312-ПП.pdf">Подписанный PDF</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Фикстура HTML страницы документа без PDF ──────────────────────────────────
|
||||||
|
_DOC_HTML_NO_PDF = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Постановление без PDF</h1>
|
||||||
|
<p>Текст постановления размещён ниже.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSearchHtml:
|
||||||
|
def test_extracts_doc_ids_and_titles(self) -> None:
|
||||||
|
results = _parse_search_html(_SEARCH_HTML_FIXTURE)
|
||||||
|
ids = [r["doc_id"] for r in results]
|
||||||
|
assert "35496" in ids
|
||||||
|
assert "34812" in ids
|
||||||
|
|
||||||
|
def test_ignores_non_numeric_paths(self) -> None:
|
||||||
|
results = _parse_search_html(_SEARCH_HTML_FIXTURE)
|
||||||
|
ids = [r["doc_id"] for r in results]
|
||||||
|
assert "some-slug" not in ids
|
||||||
|
|
||||||
|
def test_deduplicates_doc_ids(self) -> None:
|
||||||
|
results = _parse_search_html(_SEARCH_HTML_FIXTURE)
|
||||||
|
ids = [r["doc_id"] for r in results]
|
||||||
|
assert ids.count("35496") == 1
|
||||||
|
|
||||||
|
def test_ignores_category_links(self) -> None:
|
||||||
|
results = _parse_search_html(_SEARCH_HTML_FIXTURE)
|
||||||
|
# /category/law/ не числовой id — не попадает.
|
||||||
|
ids = [r["doc_id"] for r in results]
|
||||||
|
assert len(ids) == 2
|
||||||
|
|
||||||
|
def test_url_is_absolute(self) -> None:
|
||||||
|
results = _parse_search_html(_SEARCH_HTML_FIXTURE)
|
||||||
|
for r in results:
|
||||||
|
assert r["url"].startswith("https://www.pravo.gov66.ru/")
|
||||||
|
|
||||||
|
def test_title_extracted(self) -> None:
|
||||||
|
results = _parse_search_html(_SEARCH_HTML_FIXTURE)
|
||||||
|
doc = next(r for r in results if r["doc_id"] == "35496")
|
||||||
|
assert "изъятии" in doc["title"].lower()
|
||||||
|
|
||||||
|
def test_empty_html_returns_empty(self) -> None:
|
||||||
|
assert _parse_search_html("") == []
|
||||||
|
|
||||||
|
def test_html_no_doc_links_returns_empty(self) -> None:
|
||||||
|
html = "<html><body><a href='/category/'>Cat</a></body></html>"
|
||||||
|
assert _parse_search_html(html) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractPdfUrl:
|
||||||
|
def test_prefers_non_ecp_pdf(self) -> None:
|
||||||
|
url = _extract_pdf_url(_DOC_HTML_WITH_PDF)
|
||||||
|
assert url is not None
|
||||||
|
assert "ecp" not in url
|
||||||
|
assert url.endswith(".pdf")
|
||||||
|
|
||||||
|
def test_non_ecp_pdf_is_absolute(self) -> None:
|
||||||
|
url = _extract_pdf_url(_DOC_HTML_WITH_PDF)
|
||||||
|
assert url is not None
|
||||||
|
assert url.startswith("https://www.pravo.gov66.ru/media/pravo/")
|
||||||
|
|
||||||
|
def test_falls_back_to_ecp_when_no_regular_pdf(self) -> None:
|
||||||
|
url = _extract_pdf_url(_DOC_HTML_ECP_ONLY)
|
||||||
|
assert url is not None
|
||||||
|
assert "ecp" in url
|
||||||
|
assert url.endswith(".pdf")
|
||||||
|
|
||||||
|
def test_returns_none_when_no_pdf(self) -> None:
|
||||||
|
url = _extract_pdf_url(_DOC_HTML_NO_PDF)
|
||||||
|
assert url is None
|
||||||
|
|
||||||
|
def test_ecp_fallback_is_absolute(self) -> None:
|
||||||
|
url = _extract_pdf_url(_DOC_HTML_ECP_ONLY)
|
||||||
|
assert url is not None
|
||||||
|
assert url.startswith("https://www.pravo.gov66.ru/")
|
||||||
289
backend/tests/workers/test_reservation_ingest.py
Normal file
289
backend/tests/workers/test_reservation_ingest.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"""Тесты ingest_reservations (#1062) — offline, без живой сети и реальной БД.
|
||||||
|
|
||||||
|
Мокаем: pravo_gov66_client (search/get_pdf_url/fetch_pdf) + extract_from_pdf + SessionLocal.
|
||||||
|
Проверяем: фильтрацию заголовков, дедупликацию doc_id, upsert-параметры, graceful на ошибках.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.scrapers.page_reservation_parser import ReservationRecord
|
||||||
|
from app.workers.tasks import reservation_ingest
|
||||||
|
|
||||||
|
# ── Двойники ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Session-двойник: пишет execute-вызовы, begin_nested как no-op CM."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.executed: list[tuple[Any, dict[str, Any] | None]] = []
|
||||||
|
self.commit_count = 0
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def begin_nested(self) -> Any:
|
||||||
|
yield
|
||||||
|
|
||||||
|
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.executed.append((sql, params))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.commit_count += 1
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Фикстуры ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SAMPLE_RECORDS = [
|
||||||
|
ReservationRecord(
|
||||||
|
cad_num="66:41:0101001:1",
|
||||||
|
reservation_kind="изъятие",
|
||||||
|
act_number="509-ПП",
|
||||||
|
act_date=date(2024, 3, 12),
|
||||||
|
purpose="реконструкция ул. Татищева",
|
||||||
|
raw_excerpt="...участок 66:41:0101001:1...",
|
||||||
|
),
|
||||||
|
ReservationRecord(
|
||||||
|
cad_num="66:41:0101001:2",
|
||||||
|
reservation_kind="изъятие",
|
||||||
|
act_number="509-ПП",
|
||||||
|
act_date=date(2024, 3, 12),
|
||||||
|
purpose="реконструкция ул. Татищева",
|
||||||
|
raw_excerpt=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
_RELEVANT_HITS = [
|
||||||
|
{
|
||||||
|
"doc_id": "35496",
|
||||||
|
"title": "Об изъятии земельных участков Екатеринбург",
|
||||||
|
"url": "https://www.pravo.gov66.ru/35496/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"doc_id": "34812",
|
||||||
|
"title": "О резервировании земельных участков Свердловской области",
|
||||||
|
"url": "https://www.pravo.gov66.ru/34812/",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_IRRELEVANT_HIT = {
|
||||||
|
"doc_id": "99999",
|
||||||
|
"title": "О бюджете на 2024 год",
|
||||||
|
"url": "https://www.pravo.gov66.ru/99999/",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PDF_BYTES = b"%PDF-1.4 fake content"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_enumerate(
|
||||||
|
monkeypatch: Any,
|
||||||
|
db: _FakeDB,
|
||||||
|
hits: list[dict[str, Any]],
|
||||||
|
pdf_url: str | None = "https://www.pravo.gov66.ru/media/pravo/509-ПП_abc.pdf",
|
||||||
|
pdf_bytes: bytes = _PDF_BYTES,
|
||||||
|
records: list[ReservationRecord] = _SAMPLE_RECORDS,
|
||||||
|
) -> None:
|
||||||
|
"""Патчит все зависимости ingest_reservations для enumerate-режима."""
|
||||||
|
monkeypatch.setattr(reservation_ingest, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "search_documents", lambda q, **kw: hits)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "get_document_pdf_url", lambda doc_id: pdf_url)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "fetch_pdf", lambda url: pdf_bytes)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "extract_from_pdf", lambda b: records)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Тесты фильтрации заголовков ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsRelevantTitle:
|
||||||
|
def test_relevant_izyatie_ekb(self) -> None:
|
||||||
|
assert reservation_ingest._is_relevant_title(
|
||||||
|
"Об изъятии земельных участков в г. Екатеринбурге"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_relevant_rezervirovanie_sverdlov(self) -> None:
|
||||||
|
assert reservation_ingest._is_relevant_title(
|
||||||
|
"О резервировании земельных участков Свердловской области"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_irrelevant_no_geo(self) -> None:
|
||||||
|
assert not reservation_ingest._is_relevant_title(
|
||||||
|
"Об изъятии земельных участков в г. Нижний Тагил"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_irrelevant_no_operation(self) -> None:
|
||||||
|
assert not reservation_ingest._is_relevant_title(
|
||||||
|
"О бюджете Свердловской области на 2024 год"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_irrelevant_empty(self) -> None:
|
||||||
|
assert not reservation_ingest._is_relevant_title("")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Тесты enumerate-режима ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestIngestEnumerate:
|
||||||
|
def test_returns_docs_and_parcels(self, monkeypatch: Any) -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
_patch_enumerate(monkeypatch, db, hits=_RELEVANT_HITS)
|
||||||
|
result = reservation_ingest.ingest_reservations()
|
||||||
|
assert result["docs"] >= 1
|
||||||
|
assert result["parcels"] >= 1
|
||||||
|
|
||||||
|
def test_filters_irrelevant_titles(self, monkeypatch: Any) -> None:
|
||||||
|
"""Документ с нерелевантным заголовком не должен попасть в ingest."""
|
||||||
|
db = _FakeDB()
|
||||||
|
irrelevant_only = [_IRRELEVANT_HIT]
|
||||||
|
_patch_enumerate(monkeypatch, db, hits=irrelevant_only)
|
||||||
|
result = reservation_ingest.ingest_reservations()
|
||||||
|
assert result["docs"] == 0
|
||||||
|
assert result["parcels"] == 0
|
||||||
|
assert db.executed == []
|
||||||
|
|
||||||
|
def test_deduplicates_doc_id_across_queries(self, monkeypatch: Any) -> None:
|
||||||
|
"""Один и тот же doc_id из двух запросов инжестируется один раз."""
|
||||||
|
db = _FakeDB()
|
||||||
|
same_hit = _RELEVANT_HITS[:1] # один хит
|
||||||
|
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def _search_twice(q: str, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
call_count[0] += 1
|
||||||
|
return same_hit # оба запроса возвращают один и тот же doc_id
|
||||||
|
|
||||||
|
monkeypatch.setattr(reservation_ingest, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "search_documents", _search_twice)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reservation_ingest,
|
||||||
|
"get_document_pdf_url",
|
||||||
|
lambda doc_id: "https://www.pravo.gov66.ru/media/pravo/test.pdf",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "fetch_pdf", lambda url: _PDF_BYTES)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "extract_from_pdf", lambda b: _SAMPLE_RECORDS[:1])
|
||||||
|
|
||||||
|
result = reservation_ingest.ingest_reservations()
|
||||||
|
# 2 запроса, но 1 уникальный doc_id → fetched=1
|
||||||
|
assert result["docs"] == 1
|
||||||
|
|
||||||
|
def test_skips_doc_when_no_pdf_url(self, monkeypatch: Any) -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
_patch_enumerate(monkeypatch, db, hits=_RELEVANT_HITS[:1], pdf_url=None)
|
||||||
|
result = reservation_ingest.ingest_reservations()
|
||||||
|
assert result["docs"] == 0
|
||||||
|
assert result["parcels"] == 0
|
||||||
|
|
||||||
|
def test_skips_doc_when_pdf_fetch_fails(self, monkeypatch: Any) -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
monkeypatch.setattr(reservation_ingest, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reservation_ingest, "search_documents", lambda q, **kw: _RELEVANT_HITS[:1]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reservation_ingest,
|
||||||
|
"get_document_pdf_url",
|
||||||
|
lambda doc_id: "https://www.pravo.gov66.ru/media/pravo/test.pdf",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reservation_ingest, "fetch_pdf", lambda url: (_ for _ in ()).throw(OSError("net err"))
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "extract_from_pdf", lambda b: _SAMPLE_RECORDS[:1])
|
||||||
|
result = reservation_ingest.ingest_reservations()
|
||||||
|
assert result["docs"] == 0
|
||||||
|
|
||||||
|
def test_upsert_params_contain_cad_num(self, monkeypatch: Any) -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
_patch_enumerate(monkeypatch, db, hits=_RELEVANT_HITS[:1], records=_SAMPLE_RECORDS[:1])
|
||||||
|
reservation_ingest.ingest_reservations()
|
||||||
|
assert db.executed, "должен быть хотя бы один execute"
|
||||||
|
_, params = db.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert params["cad_num"] == "66:41:0101001:1"
|
||||||
|
assert params["source"] == "pravo_gov66"
|
||||||
|
|
||||||
|
def test_db_closed_after_run(self, monkeypatch: Any) -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
_patch_enumerate(monkeypatch, db, hits=[])
|
||||||
|
reservation_ingest.ingest_reservations()
|
||||||
|
assert db.closed
|
||||||
|
|
||||||
|
def test_skips_empty_records(self, monkeypatch: Any) -> None:
|
||||||
|
"""PDF без кад-номеров → ни одного execute."""
|
||||||
|
db = _FakeDB()
|
||||||
|
_patch_enumerate(monkeypatch, db, hits=_RELEVANT_HITS[:1], records=[])
|
||||||
|
result = reservation_ingest.ingest_reservations()
|
||||||
|
assert result["parcels"] == 0
|
||||||
|
assert db.executed == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Тесты explicit-docs (back-compat) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestIngestExplicitDocs:
|
||||||
|
def test_explicit_docs_bypass_enumerate(self, monkeypatch: Any) -> None:
|
||||||
|
"""docs=... → enumerate не вызывается, search_documents не дёргается."""
|
||||||
|
db = _FakeDB()
|
||||||
|
monkeypatch.setattr(reservation_ingest, "SessionLocal", lambda: db)
|
||||||
|
|
||||||
|
def _should_not_call(*a: Any, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
raise AssertionError("enumerate не должен вызываться")
|
||||||
|
|
||||||
|
monkeypatch.setattr(reservation_ingest, "search_documents", _should_not_call)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "fetch_pdf", lambda url: _PDF_BYTES)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "extract_from_pdf", lambda b: _SAMPLE_RECORDS[:1])
|
||||||
|
|
||||||
|
explicit = [
|
||||||
|
{
|
||||||
|
"url": "https://www.pravo.gov66.ru/media/pravo/509-ПП_abc.pdf",
|
||||||
|
"act_number": "509-ПП",
|
||||||
|
"basis_act": "Постановление № 509-ПП",
|
||||||
|
"purpose": "реконструкция",
|
||||||
|
"kind": "изъятие",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = reservation_ingest.ingest_reservations(docs=explicit)
|
||||||
|
assert result["docs"] == 1
|
||||||
|
assert result["parcels"] == 1
|
||||||
|
|
||||||
|
def test_explicit_docs_upsert_uses_doc_kind(self, monkeypatch: Any) -> None:
|
||||||
|
"""Если rec.reservation_kind пуст, берётся kind из doc."""
|
||||||
|
db = _FakeDB()
|
||||||
|
monkeypatch.setattr(reservation_ingest, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "fetch_pdf", lambda url: _PDF_BYTES)
|
||||||
|
|
||||||
|
no_kind_record = ReservationRecord(
|
||||||
|
cad_num="66:41:0000001:1",
|
||||||
|
reservation_kind="", # пустой — должен взяться из doc
|
||||||
|
act_number=None,
|
||||||
|
act_date=None,
|
||||||
|
purpose=None,
|
||||||
|
raw_excerpt=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reservation_ingest, "extract_from_pdf", lambda b: [no_kind_record])
|
||||||
|
|
||||||
|
explicit = [
|
||||||
|
{
|
||||||
|
"url": "https://x.ru/test.pdf",
|
||||||
|
"act_number": "1-ПП",
|
||||||
|
"basis_act": "Постановление",
|
||||||
|
"purpose": None,
|
||||||
|
"kind": "резервирование",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
reservation_ingest.ingest_reservations(docs=explicit)
|
||||||
|
assert db.executed
|
||||||
|
_, params = db.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert params["reservation_kind"] == "резервирование"
|
||||||
Loading…
Add table
Reference in a new issue