feat(etl): извлечение прайсов по форматам (xlsx/xls/docx/pdf) и распознавание сложных страниц через Gemini Vision

This commit is contained in:
2026-06-26 15:10:00 +05:00
parent 3834e4811d
commit 26283d5432
8 changed files with 849 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
"""Эвристики извлечения, общие для всех форматов.
Все табличные источники (xlsx, xls, docx, таблицы из PDF) сводятся к «сетке» —
списку строк из строковых ячеек — и обрабатываются единым кодом. Здесь собраны
функции, понимающие реальные особенности казахстанских прайсов: разрядные пробелы
в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой
строке таблицы.
"""
from __future__ import annotations
import re
Grid = list[list[str]]
# Ячейка-цена целиком: «14 400», «12600.0», «3 980», «9000 тг», «от 12000».
# Требуем, чтобы вся ячейка была числом (плюс валюта или «от»), иначе цифра внутри
# текста («Раздел 1.Консультации») ошибочно сошла бы за цену.
_PRICE_CELL_RE = re.compile(
r"^(?:от\s+)?(\d[\d .,]*\d|\d)\s*(?:тг|тенге|₸|kzt|руб|\$)?\.?$",
re.IGNORECASE,
)
# Похоже на код услуги: «U1.1», «A02.004.000», «1.0», «В02.110.002».
_CODE_RE = re.compile(r"^[A-ZА-Я]?\.?\d+([.\-]\d+)*[A-ZА-Я]?$")
_NAME_KEYS = ("наименование", "услуг", "название", "перечень")
_PRICE_KEYS = ("цена", "стоимость", "тариф", "тенге")
_UNIT_KEYS = ("ед.", "единица", "ед изм", "ед. изм")
_CODE_KEYS = ("код", "", "тарификатор")
# Заголовок ценовой колонки → тип тарифа. Порядок важен: частные случаи раньше общих.
_TIER_PATTERNS: list[tuple[str, tuple[str, ...]]] = [
("nonresident", ("нерезидент", "без гражданства", "иностран")),
("far", ("дальнего", "дальнее")),
("cis", ("снг", "ближнего", "оралман")),
("insurance", ("страхов",)),
("partner", ("партнер", "партнён", "партнёр")),
("resident", ("резидент", "республики казахстан", " рк", "граждан республики")),
]
def clean(text) -> str:
"""Строковое значение ячейки без хвостовых пробелов и переводов строк."""
if text is None:
return ""
return re.sub(r"\s+", " ", str(text)).strip()
def parse_price(text) -> float | None:
"""Вытащить цену из ячейки, если ВСЯ ячейка — число (плюс валюта или «от»).
Возвращает положительный float или None. Намеренно строгая: «Раздел 1.…» или
«приём (30 минут)» ценой не считаются — это защищает детектор секций.
"""
s = clean(text)
if not s:
return None
m = _PRICE_CELL_RE.match(s)
if not m:
return None
raw = m.group(1).replace(" ", "")
# Есть и точка, и запятая — запятая разрядная; иначе запятая = десятичная.
if "," in raw and "." in raw:
raw = raw.replace(",", "")
else:
raw = raw.replace(",", ".")
try:
value = float(raw)
except ValueError:
return None
return value if value > 0 else None
def looks_like_code(text) -> bool:
"""Похоже ли значение на код услуги (внутренний или тарификатора)."""
s = clean(text)
return bool(s) and len(s) <= 16 and bool(_CODE_RE.match(s))
def is_real_name(text) -> bool:
"""Похоже ли значение на настоящее название услуги, а не на код или число.
Защищает нормализацию от мусора: коды («E04.000.012»), номера и цены, по ошибке
попавшие в колонку названия, не должны уходить в сопоставление со справочником.
Требуем минимум три кириллические буквы и не код-подобную форму.
"""
s = clean(text)
cyrillic = sum(1 for ch in s if "а" <= ch.lower() <= "я" or ch.lower() == "ё")
if cyrillic < 3 or looks_like_code(s):
return False
# Код-мнемоника: цифры + только заглавные буквы + без пробела (напр. «МОЗО.15») — не услуга.
if any(c.isdigit() for c in s) and " " not in s and not any(c.islower() for c in s):
return False
return True
def classify_price_tier(header_text) -> str | None:
"""По тексту заголовка ценовой колонки определить тип тарифа."""
h = clean(header_text).lower()
if not h:
return None
for tier, keys in _TIER_PATTERNS:
if any(k in h for k in keys):
return tier
if any(k in h for k in _PRICE_KEYS):
return "resident" # обобщённая «Цена»/«Стоимость» — основной тариф
return None
def _has_key(cells: list[str], keys: tuple[str, ...]) -> bool:
joined = " | ".join(c.lower() for c in cells)
return any(k in joined for k in keys)
def find_header_row(grid: Grid, scan: int = 30) -> int:
"""Найти индекс строки заголовков (она не всегда первая).
Заголовок — строка, где есть и колонка названия услуги, и хотя бы одна
ценовая колонка. Если явной шапки нет, берётся первая строка, под которой
идут пары «текст + число».
"""
best_idx, best_score = -1, 0
for i, row in enumerate(grid[:scan]):
cells = [clean(c) for c in row]
if not any(cells):
continue
score = 0
if _has_key(cells, _NAME_KEYS):
score += 2
score += sum(1 for c in cells if classify_price_tier(c))
if _has_key(cells, _UNIT_KEYS):
score += 1
if score > best_score:
best_idx, best_score = i, score
if best_idx >= 0 and best_score >= 2:
return best_idx
# Фолбэк: первая строка, под которой есть пара «непустой текст + число».
for i in range(min(scan, len(grid))):
for j in range(i + 1, min(i + 6, len(grid))):
cells = [clean(c) for c in grid[j]]
texts = [c for c in cells if c and not parse_price(c)]
nums = [c for c in cells if parse_price(c)]
if texts and nums:
return i
return 0