gendesign/backend/app/services/scrapers/ekb_ppt_tep_parser.py
Light1YT 3573690ebc
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Successful in 6m32s
CI / backend-tests (push) Successful in 6m33s
fix(sf): ekb_ppt_tep post-фильтры + врезка в analyze (#1136)
Post-фильтры парсера (по образцу ppt2018_22823):
- Табл.11: отбрасываем строки без zone_name (преамбульные артефакты
  merged-cells давали 2 фантома при 12 реальных зонах).
- Табл.13: dedup по (phase, composition, zone, area_ha) + фильтры
  короткого-без-зоны и нумерационного шума (~364 строк → ~30-40 реальных).

Analyze-врезка: новый ppt_tep_lookup.parcel_ppt_tep — JOIN
planning_projects ⋈ ekb_ppt_tep по doc_ref↔source_key/doc_full_name
(best-effort, без FK). Wired в build_ird_analyze_block рядом с
planning_projects/krt_requisites — DB-источники, graceful.

Seed-URL остаётся placeholder с # VERIFY (ingest пропускает с WARNING).

Tests: 27 в parser (3 новых), 7 lookup, 22 wiring (2 новых).
All ruff/syntax green.

Closes #1136
2026-06-13 14:28:41 +05:00

468 lines
21 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.

"""Парсер ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067).
Источник: PDF-документы планировочных проектов (ППТ/ПМТ) Екатеринбурга.
Геометрия ППТ/ПМТ уже в БД (planning_projects, 1170 объектов, WFS).
Три целевые таблицы:
Табл.11 — Баланс территории по зонам (га, %);
Табл.12 — Основные технико-экономические показатели;
Табл.13 — Очередность строительства (зона / состав / финансирование).
Особенности:
- Таблицы обнаруживаются по заголовку-caption через extract_text() на каждой странице.
- Каждая таблица может занимать несколько страниц (особенно Табл.12 и 13).
- Структура ППТ разнится — парсер работает best-effort:
не найдена таблица → соответствующее поле = [] (не исключение).
- Кириллица: используем ТОЛЬКО pdfplumber (pdftotext/poppler даёт mojibake).
- Нормализация числовых значений: «15,41 га», «(42,61 %)» → float.
"""
from __future__ import annotations
import io
import logging
import re
from dataclasses import dataclass, field
from typing import Any
import pdfplumber
logger = logging.getLogger(__name__)
# ── Нормализация значений ────────────────────────────────────────────────────
_RE_NUM = re.compile(r"[\d\s]+(?:[.,]\d+)?")
def _norm_value(raw: str | None) -> str:
"""Очищаем ячейку: убираем лишние пробелы, переносы, управляющие символы."""
if raw is None:
return ""
# Убираем управляющие глифы (например  — «»)
text = re.sub(r"[-]", "", raw)
# Нормализуем переносы строк внутри ячейки → пробел
text = re.sub(r"\s*\n\s*", " ", text)
return text.strip()
def _try_float(value: str) -> float | None:
"""Пытаемся превратить строку вида '15,41 га (42,61 %)' в float первого числа."""
m = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)", value)
if not m:
return None
num_str = m.group(1).replace(" ", "").replace(",", ".")
try:
return float(num_str)
except ValueError:
return None
# ── Обнаружение таблиц ───────────────────────────────────────────────────────
# Паттерны для определения номера таблицы в тексте страницы.
# Ищем «Таблица 11», «Таблица 12», «Таблица 13» (латинская «а» вместо кириллической
# тоже встречается в некоторых PDF — поэтому без локали).
_TABLE_CAPTION_RE = {
11: re.compile(r"Таблица\s+11\b", re.IGNORECASE),
12: re.compile(r"Таблица\s+12\b", re.IGNORECASE),
13: re.compile(r"Таблица\s+13\b", re.IGNORECASE),
}
def _page_contains_table(text: str, table_no: int) -> bool:
"""Возвращает True если текст страницы содержит заголовок таблицы N.
Требуем явный caption «Таблица N» — hint-только режим (оглавление, перекрёстные ссылки)
даёт false positive и подавляется. Если caption присутствует — достаточно.
"""
cap = _TABLE_CAPTION_RE[table_no]
return bool(cap.search(text))
# ── Парсинг конкретных таблиц ────────────────────────────────────────────────
def _parse_table11(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
"""Баланс территории по зонам (Табл.11).
Структура: № | Наименование зоны | Сущ., га (%) | Проект, га (%)
Заголовок (первые 2 строки) пропускается по признаку: №/None в col[0].
Post-фильтр (#1136 follow-up #1133): отбрасываем строки с пустым
``zone_name`` (преамбульные артефакты merged-cells на образце ppt2018_22823
давали 2 «фантомные» строки без названия зоны при 12 реальных). «Итого»-
строка остаётся (распознаётся по тексту, не по zone_name).
"""
result: list[dict[str, Any]] = []
header_skipped = False
for row in raw_rows:
if not row:
continue
no = _norm_value(row[0] if len(row) > 0 else None)
zone = _norm_value(row[1] if len(row) > 1 else None)
existing = _norm_value(row[2] if len(row) > 2 else None)
project = _norm_value(row[3] if len(row) > 3 else None)
# Строка-заголовок: col[0] в {№, None, ''} и col[1] содержит
# «Наименование» или «Площадь» — пропускаем.
if not header_skipped:
zone_lo = zone.lower()
if no in ("", "", None) and ("наименование" in zone_lo or "площадь" in zone_lo):
header_skipped = True
continue
if no in ("", "", None) and not zone:
continue
# Пропускаем строки без номера — вложенные подзаголовки.
# Итоговую строку включаем (распознаём по «Итого»).
is_total = "итого" in (no + zone).lower()
if not no and not is_total:
continue
# Post-фильтр (#1136): отбрасываем строки без осмысленного zone_name
# (преамбульный шум merged-cells). Исключение — Итого-строка: у неё
# zone_name может быть пуст (значение часто в col[0]).
if not zone and not is_total:
continue
record: dict[str, Any] = {
"no": no,
"zone_name": zone,
"existing_ha": _norm_value(existing),
"existing_ha_num": _try_float(existing),
"project_ha": _norm_value(project),
"project_ha_num": _try_float(project),
}
if is_total:
record["is_total"] = True
result.append(record)
return result
def _parse_table12(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
"""Основные ТЭП (Табл.12).
Структура: № | Наименование показателей | Единица измерения |
Современное состояние | Расчётный срок.
Таблица может содержать дополнительный столбец «I этап»
(на страницах 112-113 этапы разнесены).
"""
result: list[dict[str, Any]] = []
header_skipped = False
for row in raw_rows:
if not row:
continue
# Чистим все ячейки
cells = [_norm_value(c) for c in row]
if not any(cells):
continue
no = cells[0] if cells else ""
name = cells[1] if len(cells) > 1 else ""
unit = cells[2] if len(cells) > 2 else ""
current = cells[3] if len(cells) > 3 else ""
target = cells[4] if len(cells) > 4 else ""
# Пропуск строки-заголовка таблицы
if not header_skipped:
if "наименование" in name.lower() or "показател" in name.lower():
header_skipped = True
continue
if no in ("", "") and ("единица" in unit.lower() or "измерени" in unit.lower()):
header_skipped = True
continue
# Пропускаем полностью пустые строки
if not (no or name or current or target):
continue
record: dict[str, Any] = {
"no": no,
"indicator": name,
"unit": unit,
"current": current,
"target": target,
}
# Если 6 колонок — есть этапная разбивка (I этап / II этап)
if len(cells) > 5:
record["stage1"] = cells[4]
record["target"] = cells[5]
result.append(record)
return result
def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
"""Очередность строительства (Табл.13).
Структура сложная (20 колонок из-за merged cells):
Нас интересуют: фаза (col[0]), состав объекта (col[1..2]), зона (col[13]),
площадь (col[15]), финансирование (col[17]), программа (col[19]).
Извлекаем только строки с непустым полем «зона» или «состав объекта».
Post-фильтры (#1136 follow-up #1133): на образце ppt2018_22823 merged-cells
давали ~364 строк continuation-шума при ~30-40 реальных. Применяем:
1. Дедуп по ключу (phase, composition, zone, area_ha_norm) — повторы
continuation-строк merged-cells.
2. Отбрасываем «короткий-без-зоны» шум: composition < 4 символов И zone пуст
(артефакты разрыва ячеек).
3. Отбрасываем строки, у которых composition — одна цифра/один знак
(нумерация осколков merged-cells).
"""
rows_raw: list[dict[str, Any]] = []
current_phase: str = ""
header_skipped = False
for row in raw_rows:
if not row:
continue
cells = [_norm_value(c) for c in row]
# Пропуск заголовка (первые 2 строки)
if not header_skipped:
first_meaningful = next((c for c in cells if c), "")
if "номер" in first_meaningful.lower() or "состав" in first_meaningful.lower():
header_skipped = True
continue
# Строка с «1 2 4 5 6 7» (номера колонок)
if first_meaningful in ("1", ""):
continue
# Определяем фазу/этап строительства (ячейка 0, начинается с «I», «II», «III»).
col0 = cells[0] if cells else ""
if re.match(r"^(I{1,3}V?|IV|V)\s*(этап|очередь)?", col0, re.IGNORECASE) and col0:
current_phase = col0
# Состав объекта — из колонок 1 или 3 (после слияния).
# При 20-колоночной таблице ищем непустой текст в диапазоне 1..5.
composition = ""
for ci in range(1, min(6, len(cells))):
if cells[ci]:
composition = cells[ci]
break
# Зона планируемого размещения.
zone = ""
area_ha_str = ""
funding = ""
program = ""
if len(cells) >= 14:
zone = cells[13]
if len(cells) >= 16:
area_ha_str = cells[15]
if len(cells) >= 18:
funding = cells[17]
if len(cells) >= 20:
program = cells[19]
# Пропускаем строки без осмысленного содержания
if not (composition or zone):
continue
# Пропускаем строки-секционные заголовки (нет зоны, состав = подзаголовок раздела)
if not zone and "объект" in composition.lower() and len(composition) < 50:
continue
rows_raw.append(
{
"phase": current_phase,
"composition": composition,
"zone": zone,
"area_ha": area_ha_str,
"area_ha_num": _try_float(area_ha_str),
"funding": funding,
"program": program,
}
)
# Post-фильтр (#1136): шум merged-cells.
# 1. Короткий-без-зоны: composition короткий (< 4 симв.) И zone пуст —
# типично осколок ячейки («1», «-», «—»).
# 2. Composition — одна цифра/знак-пунктуация: «1», «2», «-» и т.п.
def _is_noise(rec: dict[str, Any]) -> bool:
comp = rec["composition"]
zone = rec["zone"]
# Короткий composition без зоны — осколок merged-cell.
if not zone and len(comp) < 4:
return True
# Composition — только цифра/знак (один знак, или комбинация цифр/пунктуации).
if comp and not re.search(r"[A-Za-zА-Яа-я]", comp) and len(comp) < 6:
return True
return False
rows_clean = [r for r in rows_raw if not _is_noise(r)]
# Post-фильтр (#1136): дедуп по (phase, composition, zone, area_ha_norm).
# area_ha сравниваем по числовому значению (если есть) — иначе по строке.
seen: set[tuple[str, str, str, str]] = set()
result: list[dict[str, Any]] = []
for rec in rows_clean:
area_key = (
f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"]
)
key = (rec["phase"], rec["composition"], rec["zone"], area_key)
if key in seen:
continue
seen.add(key)
result.append(rec)
return result
# ── Сборка сырых строк со всех страниц блока таблицы ────────────────────────
def _collect_table_rows(
pages: list[pdfplumber.page.Page],
start_page_idx: int,
table_no: int,
next_table_no: int | None,
) -> tuple[list[list[str | None]], list[dict[str, Any]]]:
"""Собирает строки таблицы N начиная со страницы start_page_idx.
Останавливается когда встречает заголовок следующей таблицы (next_table_no)
или «Рис.» / другой раздел.
Returns:
(all_rows, raw_table_records) — строки для парсинга и сырые данные для
поля raw_tables.
"""
all_rows: list[list[str | None]] = []
raw_records: list[dict[str, Any]] = []
found_start = False
for pi in range(start_page_idx, len(pages)):
page = pages[pi]
page_text = page.extract_text() or ""
# Обнаружение начала нашей таблицы
if not found_start:
if _page_contains_table(page_text, table_no):
found_start = True
else:
continue
# Стоп-условие: начался следующий раздел/таблица
if found_start and pi > start_page_idx:
if next_table_no and _page_contains_table(page_text, next_table_no):
break
# Извлекаем таблицы со страницы
page_tables = page.extract_tables()
for ti, tbl in enumerate(page_tables):
if not tbl:
continue
# Берём только таблицы с ≥2 строками и ≥2 колонками
if len(tbl) < 2 or (tbl[0] and len(tbl[0]) < 2):
continue
# На первой странице таблицы пропускаем маленькие таблицы (заголовки/рамки)
# — они идут ДО основной таблицы данных.
if pi == start_page_idx and len(tbl) < 4:
continue
all_rows.extend(tbl)
raw_records.append(
{
"page": pi + 1, # 1-based для отладки
"table_index": ti,
"rows": [[_norm_value(c) for c in row] for row in tbl[:10]], # cap 10 rows
}
)
return all_rows, raw_records
# ── Публичный API ────────────────────────────────────────────────────────────
@dataclass
class PptTep:
"""Результат парсинга пояснительной записки ППТ/ПМТ.
Каждое поле — list[dict] с нормализованными строками таблицы.
Пустой список означает «таблица не найдена» (best-effort, не ошибка).
"""
zone_balance: list[dict[str, Any]] = field(default_factory=list)
tep: list[dict[str, Any]] = field(default_factory=list)
phasing: list[dict[str, Any]] = field(default_factory=list)
raw_tables: list[dict[str, Any]] = field(default_factory=list)
def parse_ppt_tep(pdf_bytes: bytes) -> PptTep:
"""Парсит PDF пояснительной записки ППТ/ПМТ → ТЭП (Табл.11/12/13).
Чистая функция от bytes; сеть/fetch — вне этой функции.
Graceful при отсутствии таблиц: поле = [] вместо исключения.
Args:
pdf_bytes: сырые байты PDF-документа.
Returns:
PptTep с заполненными полями zone_balance / tep / phasing / raw_tables.
"""
result = PptTep()
try:
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
pages = pdf.pages
n_pages = len(pages)
logger.info("parse_ppt_tep: opened PDF, %d pages", n_pages)
# Находим страницы-начала Табл.11/12/13
page_11 = page_12 = page_13 = None
for pi, page in enumerate(pages):
text = page.extract_text() or ""
if page_11 is None and _page_contains_table(text, 11):
page_11 = pi
if page_12 is None and _page_contains_table(text, 12):
page_12 = pi
if page_13 is None and _page_contains_table(text, 13):
page_13 = pi
logger.info(
"parse_ppt_tep: Табл.11 на стр.%s, Табл.12 на стр.%s, Табл.13 на стр.%s",
page_11 and page_11 + 1,
page_12 and page_12 + 1,
page_13 and page_13 + 1,
)
# Табл.11 — Баланс территории
if page_11 is not None:
rows_11, raw_11 = _collect_table_rows(pages, page_11, 11, 12)
result.zone_balance = _parse_table11(rows_11)
result.raw_tables.extend(raw_11)
logger.info("parse_ppt_tep: Табл.11 → %d строк", len(result.zone_balance))
else:
logger.warning("parse_ppt_tep: Таблица 11 не найдена в PDF")
# Табл.12 — ТЭП
if page_12 is not None:
rows_12, raw_12 = _collect_table_rows(pages, page_12, 12, 13)
result.tep = _parse_table12(rows_12)
result.raw_tables.extend(raw_12)
logger.info("parse_ppt_tep: Табл.12 → %d строк", len(result.tep))
else:
logger.warning("parse_ppt_tep: Таблица 12 не найдена в PDF")
# Табл.13 — Очередность строительства
if page_13 is not None:
rows_13, raw_13 = _collect_table_rows(pages, page_13, 13, None)
result.phasing = _parse_table13(rows_13)
result.raw_tables.extend(raw_13)
logger.info("parse_ppt_tep: Табл.13 → %d строк", len(result.phasing))
else:
logger.warning("parse_ppt_tep: Таблица 13 не найдена в PDF")
except Exception as exc:
logger.error("parse_ppt_tep: ошибка парсинга PDF: %s", exc)
raise
return result