"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#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. _OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", 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»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы. «ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени). """ m = _OUTPUT_SUFFIX_RE.search(raw_name) if not m: return raw_name, None output = _MULTISPACE_RE.sub(" ", m.group(1)).strip() # Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя # двойные пробелы, оставшиеся на месте выреза в середине). 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: """Находит наиболее «содержательную»
| и | в порядке появления)."""
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):
- основная |
|---|