gendesign/backend/app/services/scrapers/ekb_ppt_tep_parser.py
bot-backend 3267120499
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI / backend-tests (pull_request) Successful in 17m21s
docs(ptica): докстрока не обещает защиты, которой нет, и не шлёт на мёртвый хост (#2464)
`_page_contains_table` документировалась как «hint-только режим (оглавление,
перекрёстные ссылки) даёт false positive и подавляется». Кода подавления в
функции никогда не было — это `bool(cap.search(text))`. Обещание защиты,
которой нет, опаснее её отсутствия: читающий не станет её добавлять.

Замер уточнил и сам пункт эпика. Приведённый в нём пример перекрёстной
ссылки «показатели приведены в таблице 12» регекс НЕ ловит: он требует
именительное «Таблица N», поэтому «в таблице 12», «см. Таблицу 12»,
«табл. 12» дают False. Опасно ровно ОГЛАВЛЕНИЕ — там падеж тот же
именительный, и «Таблица 11 Баланс территории ..... 34» неотличима от
подписи. Это зафиксировано характеризующим тестом, чтобы следующая
попытка подавления целилась в признаки оглавления (точки-выноски, номер
страницы в конце), а не в падежи.

Подавление здесь не реализовано сознательно: `ekb_ppt_tep` на проде пуста
(0 строк), URL в `_SEED_DOCS` — заглушка, живых PDF нет. Эвристику отсева
не на чем откалибровать, а правило, придуманное без образцов, ловит ровно
те случаи, которые придумали вместе с ним.

Отдельно исправлен комментарий сида: хост `gisogd.ekburg.ru`, названный
там местом, «где лежит реальный URL», НЕ СУЩЕСТВУЕТ — DNS не резолвит его
ни с рабочей машины, ни с прод-хоста (20.08.2026). Комментарий отправлял
искать документ на портале, которого нет. Назван живой портал
`gisogd66.midural.ru` и способ перечислить его разделы.

Двусторонне: против origin/main два гейта красные — «докстрока обещает
подавление, а в теле только поиск подстроки» и «комментарий сида не
предупреждает, что хост мёртв». Характеризующие тесты зелёные с обеих
сторон: они фиксируют фактическое поведение, а контроль
test_docstring_names_the_actual_behaviour ловит «починку» через
вычёркивание неудобной фразы.

Гейт ищет обещание по слову, поэтому старая формулировка в докстроке
пересказана, а не процитирована — иначе он не отличил бы цитату от
утверждения; это оговорено прямо в тексте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:36:23 +05:00

488 lines
23 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».
Это поиск подстроки, без разбора контекста. Строка ОГЛАВЛЕНИЯ
«Таблица 11 Баланс территории ....... 34» от настоящей подписи не отличается —
падеж тот же, именительный, — и вызывающий код ставит found_start=True прямо на
странице содержания, начиная выдирать таблицы оттуда.
Прежняя редакция этой докстроки утверждала обратное — будто ложные срабатывания
оглавления и перекрёстных ссылок здесь отсеиваются. Такого кода никогда не было
(#2464). Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет
её добавлять. (Старая формулировка тут намеренно пересказана, а не процитирована:
гейт test_2464_tep_docstring_truth ищет обещание по тексту и не отличил бы цитату
от утверждения.)
Замер 20.08.2026 уточняет и границы проблемы: перекрёстные ссылки в косвенных
падежах регекс НЕ ловит — он требует именительное «Таблица N», поэтому
«приведены в таблице 12», «см. Таблицу 12», «табл. 12» дают False. Опасно
ровно оглавление, а не любое упоминание.
Почему подавление не реализовано здесь и сейчас: таблица `ekb_ppt_tep` на проде
пуста (0 строк) — URL в `_SEED_DOCS` заглушка, живых PDF нет, эвристику отсева
оглавления не на чем откалибровать. Правило, придуманное без образцов, ловит
ровно те случаи, которые придумали вместе с ним. Условие для реализации — хотя
бы один настоящий документ (#1136); целиться следует в признаки оглавления
(точки-выноски, номер страницы в конце строки, несколько подписей на одной
странице), а не в падежи.
"""
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