gendesign/backend/app/services/site_finder/gazprom_grs_loader.py
bot-backend 21608eced1
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 3m35s
Deploy / build-worker (push) Successful in 5m31s
Deploy / build-frontend (push) Successful in 7m23s
Deploy / deploy (push) Successful in 2m17s
fix(site-finder): три честности-фикса — B1 ГРС «выход №N» + околонуль + Росреестр-квартал (#2257)
2026-07-03 06:27:27 +00:00

458 lines
23 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.

"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#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:
"""Находит наиболее «содержательную» <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