gendesign/backend/app/workers/tasks/izyatie_ocr_ingest.py
bot-backend 5da4271235
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m19s
feat(sf): OCR-пайплайн изъятия ЕКБ (Tesseract rus) → land_reservation (#1062)
- Dockerfile: добавлены tesseract-ocr tesseract-ocr-rus в runner apt-слой
- pyproject.toml: pymupdf>=1.24 (рендер PDF без poppler) + pytesseract>=0.3.13
- izyatie_client.py: list_izyatie_documents() + fetch_pdf() с екатеринбург.рф
- izyatie_ocr.py: ocr_pdf_text() (PyMuPDF→PIL→Tesseract rus) + extract_izyatie_records()
  с нормализацией OCR-шума (пробелы в кад-номерах, кирилл. О→0, б→6)
- izyatie_ocr_ingest.py: Celery task → land_reservation UPSERT (SAVEPOINT per-row)
- beat_schedule.py: izyatie-ocr-ingest-weekly (пятница 07:00 МСК)
- 33 теста, все зелёные; без реального Tesseract/сети в CI
2026-06-07 18:07:06 +03:00

201 lines
8.2 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: OCR-пайплайн изъятия ЕКБ → land_reservation (#1062).
Загружает PDF-сканы «Сообщений о планируемом изъятии…» с раздела екатеринбург.рф,
выполняет OCR через Tesseract rus, извлекает кад-номера 66:41:NNNNNNN:NN,
UPSERT-ит в land_reservation (м.136). Reservation_lookup / analyze-wiring (#1118)
подхватывает данные автоматически — дополнительного wire-up не требуется.
Дедуп-ключ:
ON CONFLICT (cad_num, act_number) — унаследован из reservation_ingest.py.
Если act_number IS NULL (не извлечён из сканов) → конфликт НЕ возникает при NULL-UPSERT
(NULL != NULL в SQL). Чтобы предотвратить дубли при act_number IS NULL, дедуплицируем
по (cad_num, doc_url) на уровне Python перед UPSERT: один URL = один батч,
повторный запуск с тем же URL обновит существующую строку через source+fetched_at
(где act_number IS NULL используем DO NOTHING вместо DO UPDATE — нет stable key).
Решение: для строк с act_number IS NULL добавляем в ON CONFLICT УНИКАЛЬНОСТЬ через
отдельный UPSERT с COALESCE-fallback: если запись с (cad_num, doc_url) уже есть —
UPDATE, иначе INSERT. Реализовано через двухшаговый UPSERT ниже.
Beat: еженедельно (пятница 07:00 МСК) — изъятия выходят редко.
Conventions (зеркало reservation_ingest.py / backend.md):
• SessionLocal() + try/finally close.
• SAVEPOINT per-row (with db.begin_nested()).
• CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
• httpx, не requests.
• Сбой одного документа не валит прогон (try/except с logger.error).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# UPSERT — два варианта в зависимости от наличия act_number.
#
# Вариант A (act_number IS NOT NULL): ON CONFLICT (cad_num, act_number) → DO UPDATE.
# Stable key = (cad_num, act_number). Идемпотентно при повторном прогоне.
#
# Вариант B (act_number IS NULL): INSERT ... ON CONFLICT DO NOTHING.
# NULL != NULL → (cad_num, NULL) никогда не конфликтует по индексу.
# Python-дедуп per-batch предотвращает дубли в рамках одного прогона.
# Повторные прогоны добавят дубли если строки нет — acceptable (rare, data audit OK).
# Альтернатива (partial unique index на NULL) — задача database-expert, не здесь.
_UPSERT_WITH_ACT_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 = COALESCE(EXCLUDED.raw_excerpt, land_reservation.raw_excerpt),
fetched_at = now()
"""
)
_UPSERT_NO_ACT_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),
NULL,
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 DO NOTHING
"""
)
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
count = 0
for row in records:
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
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 doc_url=%s: %s",
row.get("cad_num"),
row.get("doc_url"),
exc,
)
return count
@celery_app.task(name="tasks.izyatie_ocr_ingest.ingest_izyatie_ocr")
def ingest_izyatie_ocr() -> dict[str, int]:
"""OCR-пайплайн изъятия ЕКБ: раздел сайта → PDF → Tesseract → land_reservation.
Returns:
{'docs': N, 'parcels': M} — обработано документов и вставлено/обновлено ЗУ.
"""
# Ленивые импорты — модули не нужны при старте celery_app.
from app.services.scrapers.izyatie_client import fetch_pdf, list_izyatie_documents
from app.services.scrapers.izyatie_ocr import extract_izyatie_records, ocr_pdf_text
docs = list_izyatie_documents()
if not docs:
logger.warning("ingest_izyatie_ocr: документов не найдено — прогон завершён без данных")
return {"docs": 0, "parcels": 0}
logger.info("ingest_izyatie_ocr: запуск пайплайна, %d документов", len(docs))
total_parcels = 0
processed_docs = 0
db: Session = SessionLocal()
try:
for doc in docs:
doc_title: str = doc.get("title", "")
doc_url: str = doc.get("url", "")
logger.info("ingest_izyatie_ocr: загружаем %r%s", doc_title[:60], doc_url)
try:
pdf_bytes = fetch_pdf(doc_url)
except Exception as exc:
logger.error(
"ingest_izyatie_ocr: не удалось загрузить PDF %s: %s",
doc_url,
exc,
)
continue
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
finally:
db.close()
logger.info(
"ingest_izyatie_ocr: завершено docs=%d parcels=%d",
processed_docs,
total_parcels,
)
return {"docs": processed_docs, "parcels": total_parcels}
__all__ = ["ingest_izyatie_ocr"]