302 lines
13 KiB
Python
302 lines
13 KiB
Python
"""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"]
|