451 lines
22 KiB
Python
451 lines
22 KiB
Python
"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#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:
|
||
"""Находит наиболее «содержательную» <table> (с макс. числом строк). 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]:
|
||
"""Ячейки строки (<th> и <td> в порядке появления)."""
|
||
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):
|
||
- основная <table> = с макс. числом строк;
|
||
- 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: <table> не найдена в 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
|