(с макс. числом строк). None — нет."""
soup = BeautifulSoup(html, "html.parser")
tables = soup.find_all("table")
if not tables:
return None
# Основная таблица — с наибольшим числом строк (шапки/навигация мелкие).
return max(tables, key=lambda t: len(t.find_all("tr")))
def _row_cells(tr: object) -> list[object]:
"""Ячейки строки (| и | в порядке появления)."""
return tr.find_all(["th", "td"]) # type: ignore[union-attr]
def _build_column_map(header_cells: list[object]) -> tuple[dict[int, str], dict[int, str]]:
"""Из ячеек строки-заголовка строит col_idx→logical_key + col_idx→raw_header.
Возвращает (mapping, headers): mapping только для распознанных колонок
(grs_name/output_name/design/load/free/pct/upgrade_due/region); headers — сырые
тексты ВСЕХ колонок (для raw + region-детекта). «%»-колонка → ключ 'pct'.
"""
mapping: dict[int, str] = {}
headers: dict[int, str] = {}
for idx, cell in enumerate(header_cells):
htext = _clean_text(cell.get_text(" ")) or "" # type: ignore[union-attr]
headers[idx] = htext
if _is_region_header(htext):
mapping[idx] = "region"
continue
if _is_pct_header(htext):
mapping[idx] = "pct"
continue
key = _classify_header(htext)
if key is not None:
mapping[idx] = key
return mapping, headers
def parse_grs_table(html: str) -> list[dict]:
"""Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).
DEFENSIVE:
- основная = с макс. числом строк;
- первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (contains);
- rowspan имени ГРС → forward-fill на строки-выходы (несколько выходов у ГРС).
ДОПУЩЕНИЕ: offset-коррекция работает только когда колонка имени — ПЕРВАЯ
(в текущей таблице ГТЕ так); rowspan не-первой колонки даст пропуск строк
(не порчу данных — безымянные строки скипаются, raw сохраняется);
- числа через parse_reserve_number (запятая, «н/д», знак);
- неизвестные колонки → raw (не теряются).
Каждая запись:
{grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h,
free_capacity_th_m3_h, free_capacity_pct, upgrade_due, raw}.
grs_name пустой (нет forward-fill и нет своего имени) → строка пропускается.
"""
table = _extract_table(html)
if table is None:
logger.warning("gazprom_grs: не найдена в HTML")
return []
rows = table.find_all("tr") # type: ignore[union-attr]
if not rows:
return []
mapping, headers = _build_column_map(_row_cells(rows[0]))
if "grs_name" not in mapping.values():
logger.warning("gazprom_grs: колонка имени ГРС не распознана по заголовкам (%s)", headers)
return []
records: list[dict] = []
# forward-fill имени ГРС по rowspan: если ячейка имени отсутствует в строке
# (schrunk из-за rowspan предыдущей), наследуем последнее известное имя.
last_grs_name: str | None = None
for tr in rows[1:]:
cells = _row_cells(tr)
if not cells:
continue
# Матчим ячейки к логическим ключам по позиции колонки-заголовка. При
# rowspan имени в строке-выходе ячейки имени НЕТ → индексы сдвигаются; мы
# определяем это по числу ячеек < числу заголовков и forward-fill'им имя.
values: dict[str, str | None] = {}
raw_cells: list[str] = []
name_col = next(i for i, k in mapping.items() if k == "grs_name")
shifted = len(cells) < len(headers) and name_col == 0
# Если строка короче заголовков и имя-колонка первая — считаем что
# rowspan «съел» первую ячейку: логические индексы сдвигаем на +1.
offset = 1 if shifted else 0
for idx, cell in enumerate(cells):
# raw_cells — сырой текст ячейки (в т.ч. «н/д»), чтобы raw jsonb не терял
# исходное значение; values — очищенный (_clean_text: «н/д» → None).
raw_text = _MULTISPACE_RE.sub(
" ",
cell.get_text(" ").replace("\xa0", " "), # type: ignore[union-attr]
).strip()
raw_cells.append(raw_text)
logical_idx = idx + offset
key = mapping.get(logical_idx)
if key is not None:
values[key] = _clean_text(raw_text)
# values уже очищены (_clean_text применён при заполнении: str | None).
grs_name = values.get("grs_name")
if grs_name:
last_grs_name = grs_name # type: ignore[assignment]
else:
grs_name = last_grs_name # forward-fill (rowspan имени ГРС)
if not grs_name:
continue # шапка/итоговая строка без имени и без forward-fill
rec = {
"grs_name": grs_name,
"output_name": values.get("output_name"),
"region": values.get("region"),
"design_capacity_th_m3_h": parse_reserve_number(values.get("design")),
"current_load_th_m3_h": parse_reserve_number(values.get("load")),
"free_capacity_th_m3_h": parse_reserve_number(values.get("free")),
"free_capacity_pct": parse_reserve_number(values.get("pct")),
"upgrade_due": values.get("upgrade_due"),
"raw": {"headers": list(headers.values()), "cells": raw_cells},
}
records.append(rec)
logger.info("gazprom_grs: распознано записей ГРС=%d", len(records))
return records
def _passes_region_filter(records: list[dict]) -> list[dict]:
"""Если хоть у одной записи есть region — фильтруем по «свердлов»; иначе всё.
В таблице ГТЕ обычно НЕТ колонки региона (весь ГТЕ = Свердловская + сопред.
области) → грузим всё с region=NULL. Если ГТЕ добавит регион — оставляем только
свердловские строки.
"""
if any(r.get("region") for r in records):
return [r for r in records if (r.get("region") or "").lower().find(_SVERDLOVSK_TOKEN) >= 0]
return records
def fetch_grs_html() -> str:
"""Тянет HTML страницы раскрытия ГТЕ. RUN-ON-PROD (гео-блок). httpx с таймаутом."""
resp = httpx.get(
DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
)
resp.raise_for_status()
return resp.text
def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
"""UPSERT записей ГРС в gas_grs_capacity. Per-row SAVEPOINT (битая строка не валит батч).
ON CONFLICT (grs_name_norm, output_name) — NULLS NOT DISTINCT в схеме (output_name
nullable). Returns: счётчики rows/inserted/updated/skipped.
"""
inserted = 0
updated = 0
skipped = 0
for rec in records:
grs_name = rec["grs_name"]
params = {
"grs_name": grs_name,
"grs_name_norm": normalize_grs_name(grs_name),
"output_name": rec["output_name"],
"region": rec["region"],
"design": rec["design_capacity_th_m3_h"],
"load": rec["current_load_th_m3_h"],
"free": rec["free_capacity_th_m3_h"],
"pct": rec["free_capacity_pct"],
"upgrade_due": rec["upgrade_due"],
"raw": json.dumps(rec["raw"], ensure_ascii=False),
}
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
result = db.execute(
text("""
INSERT INTO gas_grs_capacity
(grs_name, grs_name_norm, output_name, region,
design_capacity_th_m3_h, current_load_th_m3_h,
free_capacity_th_m3_h, free_capacity_pct,
upgrade_due, raw, fetched_at)
VALUES (
:grs_name, :grs_name_norm, :output_name, :region,
:design, :load, :free, :pct,
:upgrade_due, CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (grs_name_norm, output_name) DO UPDATE
SET grs_name = EXCLUDED.grs_name,
region = EXCLUDED.region,
design_capacity_th_m3_h = EXCLUDED.design_capacity_th_m3_h,
current_load_th_m3_h = EXCLUDED.current_load_th_m3_h,
free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h,
free_capacity_pct = EXCLUDED.free_capacity_pct,
upgrade_due = EXCLUDED.upgrade_due,
raw = EXCLUDED.raw,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("gas_grs upsert failed for %r/%r: %s", grs_name, rec["output_name"], e)
skipped += 1
return {"rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped}
def load_grs_capacity(db: Session | None = None) -> dict[str, int]:
"""Тянет страницу ГТЕ + парсит HTML-таблицу + UPSERT свободной мощности ГРС.
RUN-ON-PROD (гео-блок источника). ``db`` для совместимости сигнатуры; None →
своя SessionLocal (fetch + запись sync, как rosseti_wfs_loader).
Returns: счётчики {rows, inserted, updated, skipped}.
"""
html = fetch_grs_html()
records = _passes_region_filter(parse_grs_table(html))
owns_session = db is None
if db is None:
db = SessionLocal()
try:
counts = _upsert_grs_rows(db, records)
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_grs_capacity: outer tx rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
logger.info("load_grs_capacity done: %s", counts)
return counts
|