"""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) # Весь per-document body (fetch → OCR → extract → upsert → commit) — в # одном try/except. Сбой ЛЮБОГО шага (битый PDF на fetch, Tesseract-crash # на OCR, баг парсера на неожиданном OCR-тексте, ошибка upsert/commit) # логируется и переходит к следующему документу вместо падения всей # еженедельной пачки (issue #2445 D5). db.rollback() откатывает # незакоммиченные изменения текущей итерации, чтобы они не отравили # commit следующего документа. try: pdf_bytes = fetch_pdf(doc_url) 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 except Exception as exc: logger.warning( "ingest_izyatie_ocr: пропуск документа %r (%s): %s", doc_title[:60], doc_url, exc, ) try: db.rollback() except Exception as rollback_exc: logger.warning( "ingest_izyatie_ocr: rollback после сбоя документа %s тоже упал: %s", doc_url, rollback_exc, ) continue 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"]