Files
medtech-hackathon/etl/extractors/common.py
T

146 lines
6.9 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.
"""Эвристики извлечения, общие для всех форматов.
Все табличные источники (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