"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1). No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН HTML-таблицу «Информация о наличии (отсутствии) технической возможности…» (~350 строк) со свободной мощностью газораспределительных станций (ГРС) — проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о расшивке. UPSERT-ит в ``gas_grs_capacity``. Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec) — как rosseti-/vodokanal-лоадеры. Код не предполагает локальный сетевой доступ. Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02): * caption-строка (1 ячейка) → скип; * «по состоянию на DD.MM.YYYY» в первых строках → asof; * строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ caption); * ниже идут суб-заголовки + строка нумерации «0..7» → скипаются как не-данные; * single-cell строка = регион-разделитель («СВЕРДЛОВСКАЯ ОБЛАСТЬ») → задаёт ``current_region``; данные пишутся ТОЛЬКО пока текущий регион — Свердловский; * FULL-строка (len>=10): idx 2=имя, 3=проектная, 4=загрузка, 5=объём по ТУ, 6=свободная, 7=свободная %, 8+=уведомление (основание/срок/параметры); * CONTINUATION-строка (5<=len<=9, доп. выход): сдвиг на 1 — idx 0=имя (с выходом), 1=проектная, 2=загрузка, 3=объём ТУ, 4=свободная, 5=%, 6=уведомление; * имя с суффиксом «(выход №N)» → output_name отдельно; суффикс-число «(5)» — НЕ выход, остаётся в имени. Числа — через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «—» → None). Объём по ТУ хранится ТОЛЬКО в ``raw`` (без миграции колонки). Полная строка целиком → ``raw`` jsonb (неизвестные/будущие колонки не теряются). """ import json import logging import re import ssl from datetime import date from functools import lru_cache from pathlib import Path import httpx from bs4 import BeautifulSoup from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services.site_finder.rosseti_reserve_loader import ( parse_asof_date, parse_reserve_number, ) logger = logging.getLogger(__name__) # Страница расклытия «Газпром трансгаз Екатеринбург» с инлайн HTML-таблицей. # Punycode не требуется — латинский хост. DISCLOSURE_URL = "https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/" _HTTP_TIMEOUT = 60 # Домен ГТЕ использует сертификат российского УЦ (Russian Trusted CA, Минцифры) — # его нет в стандартном CA-bundle → CERTIFICATE_VERIFY_FAILED (прод-инцидент # 2026-07-02). Подключаем публичный бандл (Госуслуги) ТОЛЬКО для этого лоадера, # системные CA остаются в контексте (create_default_context + load_verify_locations). _RU_CA_BUNDLE = ( Path(__file__).resolve().parents[2] / "resources" / "certs" / "russian_trusted_ca_bundle.pem" ) @lru_cache(maxsize=1) def _ru_ssl_context() -> ssl.SSLContext: """SSL-контекст: системные CA + Russian Trusted Root/Sub CA (Минцифры).""" ctx = ssl.create_default_context() ctx.load_verify_locations(cafile=str(_RU_CA_BUNDLE)) return ctx _MULTISPACE_RE = re.compile(r"\s+") # «н/д» / «-» / прочерк / пусто в текстовой ячейке (напр. срок расшивки) → None. _NODATA_TEXT_TOKENS = frozenset( {"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"} ) # Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн». # Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от # суб-заголовков / строки нумерации. _HEADER_MARKERS = ("наименование", "проектн") # Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и # сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром). _SVERDLOVSK_TOKEN = "свердлов" # Фрагмент «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) — # НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент # стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)») # → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1. # Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №, # иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по # ON CONFLICT (grs_name_norm, output_name)). _OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE) # FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6, # свободная %=7, уведомление(основание/срок/параметры)=8,9,10. _FULL_IDX = { "grs_name": 2, "design": 3, "load": 4, "volume_tu": 5, "free": 6, "pct": 7, "notice": (8, 9, 10), } # CONTINUATION-строка (доп. выход, сдвиг на -1): имя=0, проектная=1, загрузка=2, # объём ТУ=3, свободная=4, %=5, уведомление=6+. _CONT_IDX = { "grs_name": 0, "design": 1, "load": 2, "volume_tu": 3, "free": 4, "pct": 5, "notice": (6, 7, 8), } # Границы длины строки: full = данные с полным набором столбцов; continuation = # усечённый доп. выход. Иначе строка не-данные (шапка/суб/нумерация) → скип. _FULL_MIN_LEN = 10 _CONT_MIN_LEN = 5 _CONT_MAX_LEN = 9 def normalize_grs_name(name: str | None) -> str: """Нормализует имя ГРС для матча/дедупа. Правила: lower, ё→е, схлопывание пробелов и дефисов (все виды тире → одиночный дефис без пробелов вокруг), удаление кавычек. Префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ — они различают станции. Пусто → ''. Examples: «ГРС Кольцово» → «грс кольцово» «ГРС Берёзовский» → «грс березовский» «ГРС Северная — 2» → «грс северная-2» """ if not name: return "" s = name.strip().lower().replace("ё", "е") s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "") # Все виды тире (—, –, -) в одиночный дефис, пробелы вокруг убираем. s = re.sub(r"\s*[—–-]\s*", "-", s) s = _MULTISPACE_RE.sub(" ", s).strip() return s def _parse_pct(value: str | None) -> float | None: """Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → None. Ячейка приходит в скобках и с неразрывным пробелом перед «%». Снимаем скобки, «%», пробелы — и парсим числом. Отрицательные (дефицит) сохраняются знаком. """ if value is None: return None stripped = value.strip("()%\xa0 ").strip() if not stripped or stripped in _NODATA_TEXT_TOKENS: return None return parse_reserve_number(stripped) def _split_grs_name(raw_name: str) -> tuple[str, str | None]: """Имя ячейки → (grs_name без выхода, output_name|None). «ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)», «выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы. output_name канонизируется в «выход №N» (нижний регистр, ровно один пробел после «выход», сразу «№N» без пробела) — разнобой пробелов вокруг «№» иначе дал бы разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name). «ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени). """ m = _OUTPUT_SUFFIX_RE.search(raw_name) if not m: return raw_name, None # Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре). output = f"{m.group(1).lower()} №{m.group(2)}" # Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя # двойные пробелы, оставшиеся на месте выреза в середине). grs_name = raw_name[: m.start()] + raw_name[m.end() :] grs_name = _MULTISPACE_RE.sub(" ", grs_name).strip() return grs_name, output def _is_header_row(texts: list[str]) -> bool: """Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)? Отличает header от caption («Информация о наличии…») и суб-заголовков. """ joined = " ".join(texts).lower() return all(marker in joined for marker in _HEADER_MARKERS) def _extract_table(html: str) -> object | None: """Находит наиболее «содержательную» (с макс. числом строк). 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 _row_texts(cells: list[object]) -> list[str]: """Ячейки строки → список очищенных (collapse пробелов, \\xa0→пробел) текстов.""" out: list[str] = [] for cell in cells: raw = cell.get_text(" ").replace("\xa0", " ") # type: ignore[union-attr] out.append(_MULTISPACE_RE.sub(" ", raw).strip()) return out def _cell_at(texts: list[str], idx: int) -> str | None: """Текст ячейки по индексу → None если пусто / «—»-токен / за границей.""" if idx >= len(texts): return None s = texts[idx].strip() if not s or s.lower() in _NODATA_TEXT_TOKENS: return None return s def _build_upgrade_due(texts: list[str], notice_idxs: tuple[int, ...]) -> str | None: """Ячейки уведомления (Основание/Срок/Параметры) → « · »-join непустых. Все «—»/пусто → None (нет уведомления о расшивке). """ parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None] return " · ".join(parts) if parts else None def _build_record( texts: list[str], idx: dict, current_region: str, asof: date | None ) -> dict | None: """Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION). Имя обязательно — иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw. """ raw_name = _cell_at(texts, idx["grs_name"]) if not raw_name: return None grs_name, output_name = _split_grs_name(raw_name) return { "grs_name": grs_name, "output_name": output_name, "region": current_region, "design_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["design"])), "current_load_th_m3_h": parse_reserve_number(_cell_at(texts, idx["load"])), "free_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["free"])), "free_capacity_pct": _parse_pct(_cell_at(texts, idx["pct"])), "upgrade_due": _build_upgrade_due(texts, idx["notice"]), "asof": asof, # Объём газа по ТУ — БЕЗ отдельной колонки в схеме → сохраняем в raw. "raw": {"cells": texts, "volume_by_tu": _cell_at(texts, idx["volume_tu"])}, } def parse_grs_table(html: str) -> list[dict]: """Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам). Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring): - основная = с макс. числом строк; - asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках; - строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до неё (caption / asof) и суб-заголовки/нумерация сразу после — скипаются; - single-cell строка = регион-разделитель → current_region (title-case); - данные пишутся ТОЛЬКО когда current_region содержит «свердлов»; - FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) → фиксированные индексы; иначе строка не-данные → скип с warning-логом. Каждая запись: {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, asof, raw}. """ 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 [] row_texts = [_row_texts(_row_cells(tr)) for tr in rows] # asof: «по состоянию на DD.MM.YYYY» в первых 4 строках (caption/asof-строка). asof = parse_asof_date(*(" ".join(t) for t in row_texts[:4])) # Находим строку-заголовок (маркеры «наименование» + «проектн»). Данные — ниже. header_idx: int | None = None for i, texts in enumerate(row_texts): if _is_header_row(texts): header_idx = i break if header_idx is None: logger.warning("gazprom_grs: строка-заголовок не найдена (маркеры %s)", _HEADER_MARKERS) return [] records: list[dict] = [] current_region: str | None = None in_sverdlovsk = False for texts in row_texts[header_idx + 1 :]: n = len(texts) if n == 0 or not any(t for t in texts): continue # пустая строка → скип if n == 1: # Single-cell → регион-разделитель. title-case, обновляем контекст. region = _cell_at(texts, 0) if region: current_region = region.title() in_sverdlovsk = _SVERDLOVSK_TOKEN in region.lower() continue if not in_sverdlovsk: continue # регион не Свердловский → строку не пишем if n >= _FULL_MIN_LEN: idx_map = _FULL_IDX elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN: idx_map = _CONT_IDX else: logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts) continue rec = _build_record(texts, idx_map, current_region or "", asof) if rec is not None: records.append(rec) logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof) return records def _passes_region_filter(records: list[dict]) -> list[dict]: """Фильтр Свердловской области по полю region записи. Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region- разделителям). Эта функция — второй барьер на случай записей с чужим/пустым region (совместимость с прежним пайплайном load_grs_capacity). Если region есть хоть у одной — оставляем только «свердлов»; иначе всё. """ if any(r.get("region") for r in records): return [r for r in records if _SVERDLOVSK_TOKEN in (r.get("region") or "").lower()] 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"], "asof": rec.get("asof"), "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, asof, raw, fetched_at) VALUES ( :grs_name, :grs_name_norm, :output_name, :region, :design, :load, :free, :pct, :upgrade_due, :asof, 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, asof = EXCLUDED.asof, 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