gendesign/backend/app/services/scrapers/izyatie_ocr.py

302 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""OCR-пайплайн извлечения кадастровых номеров из PDF-сканов изъятия ЕКБ (#1062).
PDF-документы «Сообщений о планируемом изъятии…» с екатеринбург.рф — растровые сканы:
pdfplumber даёт пустой текст. Используем PyMuPDF для растеризации (без poppler) +
Tesseract rus для распознавания.
OCR-шум: сканы содержат артефакты распознавания:
- «О» вместо «0» в кад-номере: «66:41:07О2048:26»
- пробелы внутри кад-паттерна: «66: 41 :0702048 :26»
- «б» вместо «6» в первых цифрах: «6б:41:...»
Нормализация выполняется ДО поиска регуляркой кад-номеров.
"""
from __future__ import annotations
import io
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# ── Константы ──────────────────────────────────────────────────────────────────
_DPI_DEFAULT = 300
_MAX_PAGES_DEFAULT = 30
_EXCERPT_RADIUS = 100 # символов вокруг первого кад-номера
# Строгий паттерн кад-номера ПОСЛЕ нормализации текста.
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
_RE_ACT_NUMBER = re.compile(
r"\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
re.UNICODE | re.IGNORECASE,
)
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
# Паттерн цели: «в целях…», «для …», «под строительство …» — best-effort.
_RE_PURPOSE = re.compile(
r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})",
re.IGNORECASE | re.UNICODE,
)
# ── Нормализация OCR-шума ──────────────────────────────────────────────────────
def _normalize_ocr_text(text: str) -> str:
"""Нормализует OCR-шум в кадастровых номерах перед поиском по регулярке.
Стратегия:
1. Убираем пробелы внутри цифро-двоеточных последовательностей вида «66: 41 :».
Паттерн: пробелы после цифры или двоеточия перед цифрой или двоеточием.
2. Заменяем «О» (кириллическая) → «0» (ноль) в позициях, окружённых цифрами/«:».
3. Заменяем «б» → «6» только в начале кад-паттерна (если предшествует граница слова
и после стоит «6:» или цифра с двоеточием).
4. Убираем мягкий дефис (U+00AD) и другие невидимые разделители.
Нормализация намеренно консервативна: применяется только в контексте,
где риск ложной замены минимален.
"""
# Шаг 1: убрать пробелы/переносы строк внутри «66: 41 :0702048 :26» →
# используем итеративную замену пробелов между [\d:] и [\d:].
# Итерируем до схождения (максимум 5 проходов — запас против вложенных пробелов).
for _ in range(5):
new_text = re.sub(r"(?<=[\d:])[ \t]+(?=[\d:])", "", text)
if new_text == text:
break
text = new_text
# Шаг 2: «О» (кириллическая, U+041E) → «0» между цифрами/двоеточиями.
# Контекст: цифра/«:» ПЕРЕД и цифра/«:» ПОСЛЕ замены.
text = re.sub(r"(?<=[\d:])О(?=[\d:])", "0", text)
# Также в начале кад-паттерна «6б:41» → «66:41»:
# б (кирилл.) → 6, если справа стоит «6:» (т.е. первые два символа кад-номера).
text = re.sub(r"\bб(?=6:)", "6", text, flags=re.IGNORECASE)
# Симметрично «6б:» → «66:» (OCR съел второй символ кад-номера МСК-66). См. #1228.
text = re.sub(r"(?<=6)б(?=:)", "6", text, flags=re.IGNORECASE)
# Шаг 3: мягкий дефис и нулевая ширина.
text = text.replace("­", "").replace("", "")
return text
# ── OCR PDF ────────────────────────────────────────────────────────────────────
def ocr_pdf_text(
pdf_bytes: bytes,
*,
dpi: int = _DPI_DEFAULT,
max_pages: int = _MAX_PAGES_DEFAULT,
) -> str:
"""Растеризует PDF и выполняет OCR каждой страницы через Tesseract rus.
Использует PyMuPDF (fitz) для рендера без poppler.
При отсутствии tesseract или битом PDF возвращает пустую строку (graceful).
Args:
pdf_bytes: сырые байты PDF-файла.
dpi: разрешение рендера (300 dpi — баланс качество/скорость для OCR).
max_pages: максимальное число страниц для обработки.
Returns:
Конкатенированный OCR-текст всех страниц (разделитель «\\n\\n»).
Пустая строка при ошибке.
"""
try:
import fitz # type: ignore[import-untyped] # PyMuPDF
except ImportError:
logger.error("ocr_pdf_text: PyMuPDF не установлен — добавь pymupdf в pyproject.toml")
return ""
try:
import pytesseract # type: ignore[import-untyped]
from PIL import Image
except ImportError:
logger.error(
"ocr_pdf_text: pytesseract или PIL не установлен — "
"добавь pytesseract, pillow в pyproject.toml"
)
return ""
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as exc:
logger.warning("ocr_pdf_text: не удалось открыть PDF (%d байт): %s", len(pdf_bytes), exc)
return ""
pages_total = min(len(doc), max_pages)
logger.info(
"ocr_pdf_text: PDF %d стр. → обрабатываем %d стр. при dpi=%d",
len(doc),
pages_total,
dpi,
)
text_parts: list[str] = []
mat = fitz.Matrix(dpi / 72, dpi / 72) # масштаб: 72 dpi → target dpi
for page_num in range(pages_total):
page = doc[page_num]
try:
pix = page.get_pixmap(matrix=mat, alpha=False)
img_bytes = pix.tobytes("png")
img = Image.open(io.BytesIO(img_bytes))
except Exception as exc:
logger.warning("ocr_pdf_text: страница %d — ошибка рендера: %s", page_num + 1, exc)
text_parts.append("")
continue
try:
page_text: str = pytesseract.image_to_string(img, lang="rus")
except pytesseract.pytesseract.TesseractNotFoundError:
logger.error(
"ocr_pdf_text: Tesseract не найден в системе. "
"Установи tesseract-ocr + tesseract-ocr-rus (apt-get)."
)
return ""
except Exception as exc:
logger.warning("ocr_pdf_text: страница %d — OCR ошибка: %s", page_num + 1, exc)
page_text = ""
text_parts.append(page_text)
full_text = "\n\n".join(text_parts)
logger.info(
"ocr_pdf_text: OCR завершён: %d стр. → %d символов",
pages_total,
len(full_text),
)
return full_text
# ── Извлечение записей из OCR-текста ──────────────────────────────────────────
def extract_izyatie_records(
ocr_text: str,
doc_title: str,
doc_url: str,
) -> list[dict[str, Any]]:
"""Извлекает записи об изъятии ЗУ из OCR-текста документа.
Нормализует OCR-шум (пробелы в кад-номерах, «О»→«0», «б»→«6»), находит
кад-номера 66:41:NNNNNNN:NN, дедуплицирует, извлекает реквизиты акта и цель.
Args:
ocr_text: сырой текст от OCR (может содержать артефакты).
doc_title: заголовок документа (из HTML-страницы раздела).
doc_url: URL PDF-файла (сохраняется в поле doc_url).
Returns:
Список словарей — по одному на каждый уникальный кад-номер. Поля:
cad_num, reservation_kind, basis_act, act_number, act_date,
purpose, doc_url, source, raw_excerpt.
Пустой список если кад-номеров нет.
"""
if not ocr_text or not ocr_text.strip():
return []
# Нормализуем OCR-шум ДО поиска кад-номеров.
normalized = _normalize_ocr_text(ocr_text)
# Реквизиты акта из заголовка или текста.
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
act_date = _extract_act_date(doc_title) or _extract_act_date(normalized)
purpose = _extract_purpose(doc_title) or _extract_purpose(normalized)
# Поиск кад-номеров.
seen: set[str] = set()
records: list[dict[str, Any]] = []
first_pos: int | None = None
for m in _RE_CAD_NUM.finditer(normalized):
cad = m.group(1)
if cad in seen:
continue
seen.add(cad)
if first_pos is None:
first_pos = m.start()
raw_excerpt = _build_excerpt(normalized, m.start())
else:
raw_excerpt = None
records.append(
{
"cad_num": cad,
"reservation_kind": "изъятие",
"basis_act": doc_title,
"act_number": act_number,
"act_date": act_date,
"purpose": purpose,
"doc_url": doc_url,
"source": "izyatie_ekb_ocr",
"raw_excerpt": raw_excerpt,
}
)
if records:
logger.info(
"extract_izyatie_records: документ %r%d кад-номеров (act=%s)",
doc_title[:60],
len(records),
act_number,
)
else:
logger.debug(
"extract_izyatie_records: документ %r → кад-номеров не найдено",
doc_title[:60],
)
return records
# ── Вспомогательные функции ────────────────────────────────────────────────────
def _extract_act_number(text: str) -> str | None:
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
m = _RE_ACT_NUMBER.search(text)
if not m:
return None
return re.sub(r"\s+", "", m.group(1))
def _extract_act_date(text: str) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE."""
m = _RE_ACT_DATE.search(text)
if not m:
return None
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
except ValueError:
pass
return None
def _extract_purpose(text: str) -> str | None:
"""Извлекает цель изъятия из текста (best-effort)."""
m = _RE_PURPOSE.search(text)
if m:
return re.sub(r"\s+", " ", m.group(1)).strip().rstrip(".,;")
return None
def _build_excerpt(text: str, pos: int) -> str:
"""Возвращает ±100 символов вокруг позиции первого кад-номера."""
start = max(0, pos - _EXCERPT_RADIUS)
end = min(len(text), pos + _EXCERPT_RADIUS)
return text[start:end]
__all__ = ["_normalize_ocr_text", "extract_izyatie_records", "ocr_pdf_text"]