feat(etl): извлечение прайсов по форматам (xlsx/xls/docx/pdf) и распознавание сложных страниц через Gemini Vision
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user