gendesign/backend/app/services/site_finder/eias_heat_loader.py
bot-backend 15224da3b5
All checks were successful
Deploy / build-backend (push) Successful in 1m48s
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m26s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
fix(eias-heat): ссылка TemplatePrinter из onclick + период «N кв. YYYY» (#2119 B2) (#2172)
2026-07-02 17:24:26 +00:00

448 lines
21 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.

"""Загрузчик резервов тепловой мощности из ФГИС ФАС (ri.eias.ru) (#2119 Phase B2).
Источник — реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
reg=2644). Данные лежат за 3 GET-переходами:
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) — HTML-грид (DevExpress)
со списком публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода.
На проде ссылка живёт НЕ в href (там ``href="#"``), а в JS
``onclick="openTemplateDialog('https://ri-loader.eias.ru/TemplatePrinter.aspx?…', …)"``,
амперсанды бывают HTML-энкожены (``&amp;``) → чанк строки перед парсом
прогоняем через ``html.unescape``. Период — «N кв. YYYY года» (реже «N квартал
YYYY»). Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) — их может
быть несколько (по одной на центральную систему / ЦС).
2. TemplatePrinter (ri-loader.eias.ru) — HTML с ``<div id="dataFile"
data-file="<base64>">``; base64 → ZIP (in-memory) → член ``tpl`` (XLSX).
3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии»
(живой пример: СТ №54 → 3.26 Гкал/час).
Точная сетка листа НЕ снята разведкой → парсинг DEFENSIVE: итерируем ВСЕ строки,
строка-кандидат = текстовая ячейка матчит «СТ №N | систем | зона» И в той же строке
есть парсибельное число; берём ПРАВЕЙШЕЕ число как резерв. Все ячейки листа целиком
→ ``raw`` jsonb (будущие колонки не теряются). Ноль распознанных систем → warning-лог
(первые 20 строк) + parsed=0, НЕ падаем.
Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate → загрузчик
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
``_ru_ssl_context`` (бандл содержит GlobalSign-intermediate для eias.ru). httpx с
явным таймаутом, per-row SAVEPOINT при UPSERT.
Числовой парс переиспользует ``parse_reserve_number`` из rosseti_reserve_loader.
"""
import base64
import binascii
import io
import json
import logging
import re
import zipfile
from html import unescape
import httpx
from openpyxl import load_workbook
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number
logger = logging.getLogger(__name__)
# Реестр ФАС по сфере «теплоснабжение», регион Свердловская область (reg=2644).
_REG = "2644"
_REG_TEMPLATE = "RU.5.66"
# Организации-держатели: (человекочитаемое имя, orgId в реестре ФАС).
ORGS: list[tuple[str, int]] = [
("ЕТК", 30814352),
("Екатеринбургэнерго", 26479450),
]
# Карточка организации: грид с публикациями форм 14 / 4_6 сферы WARM, раздел QUARTER.
_CARD_URL = "https://ri.eias.ru/Discl/PublicDisclosureInfoOrg.aspx"
_CARD_FORMS = "F_W_Q_14;F_W_Q_4_6;"
# Выгрузка публикации: HTML с base64-ZIP в data-file. Хост loader-домена.
_TEMPLATE_URL = "https://ri-loader.eias.ru/TemplatePrinter.aspx"
_HTTP_TIMEOUT = 60
# Лист формы 14 с резервом мощности источников тепла.
_HEAT_SHEET = "ТП"
# Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId).
# TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может
# варьироваться — берём каждый независимым lookahead-регэкспом на href).
# URL живёт либо в старом href="/Discl/TemplatePrinter.aspx?...", либо (реальная
# разметка прода) в JS onclick="openTemplateDialog('https://ri-loader.eias.ru/
# TemplatePrinter.aspx?...', ...)" при href="#" — поэтому ищем URL где угодно в чанке,
# останавливаясь на кавычках / скобках / пробелах (границы JS-строки и href-атрибута).
_PUB_HREF_RE = re.compile(
r"(?P<href>(?:https?://[^\"'()\s]+)?/?[^\"'()\s]*TemplatePrinter\.aspx[^\"'()\s]*)",
re.IGNORECASE,
)
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)
# Период в тексте строки грида → (year, quarter). Реальная разметка прода —
# «1 кв. 2026 года»; встречаются также «N квартал YYYY» / «N квартала YYYY» / «N кв YYYY».
_QUARTER_RE = re.compile(r"(\d)\s*кв(?:артал\w*|\.)?\s*(\d{4})", re.IGNORECASE)
# base64-payload файла: <div id="dataFile" ... data-file="<base64>">.
_DATAFILE_RE = re.compile(
r'<div\b[^>]*id=["\']dataFile["\'][^>]*data-file=["\'](?P<b64>[^"\']+)["\']',
re.IGNORECASE | re.DOTALL,
)
# Строка-кандидат листа: текстовая ячейка про систему теплоснабжения / зону / СТ №N.
_SYSTEM_MARKER_RE = re.compile(r"СТ\s*№\s*\d+|систем|зона", re.IGNORECASE)
# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
_TR_SPLIT_RE = re.compile(r"<tr\b", re.IGNORECASE)
def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
"""«…N квартал YYYY…» → (year, quarter). Квартал вне 1..4 / нет совпадения → None."""
m = _QUARTER_RE.search(row_text)
if not m:
return None
quarter = int(m.group(1))
year = int(m.group(2))
if quarter not in (1, 2, 3, 4):
return None
return (year, quarter)
def build_card_url(org_id: int) -> str:
"""URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
return (
f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
)
def build_template_url(guid: str, pub_id: str) -> str:
"""URL выгрузки публикации (TemplatePrinter, base64-ZIP в data-file)."""
return f"{_TEMPLATE_URL}?reg={_REG_TEMPLATE}&guid={guid}&id={pub_id}"
def extract_publications(card_html: str) -> list[dict]:
"""Из HTML карточки → публикации свежайшего квартала: [{guid, id, year, quarter}].
По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
«N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько — по ЦС).
Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
встречается в нескольких строках грида (по видам деятельности: передача/сбыт) —
дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
"""
pubs: list[dict] = []
for raw_chunk in _TR_SPLIT_RE.split(card_html):
# Атрибуты грида могут содержать HTML-энтити (`&amp;guid=` вместо `&guid=`) —
# раскрываем до поиска, иначе `[?&]guid=` / `[?&]id=` не сматчатся.
chunk = unescape(raw_chunk)
href_m = _PUB_HREF_RE.search(chunk)
if not href_m:
continue
href = href_m.group("href")
guid_m = _GUID_RE.search(href)
pubid_m = _PUBID_RE.search(href)
if not guid_m or not pubid_m:
continue
yq = _quarter_from_text(chunk)
if yq is None:
continue
year, quarter = yq
pubs.append(
{"guid": guid_m.group(1), "id": pubid_m.group(1), "year": year, "quarter": quarter}
)
if not pubs:
logger.warning("eias_heat: публикации не найдены в карточке (0 строк с guid+период)")
return []
freshest = max((p["year"], p["quarter"]) for p in pubs)
latest: list[dict] = []
seen: set[tuple[str, str]] = set()
for p in pubs:
if (p["year"], p["quarter"]) != freshest or (p["guid"], p["id"]) in seen:
continue
seen.add((p["guid"], p["id"]))
latest.append(p)
logger.info(
"eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d%d публикаций",
len(pubs),
freshest[0],
freshest[1],
len(latest),
)
return latest
def extract_tpl_xlsx(template_html: str) -> bytes | None:
"""HTML выгрузки → байты XLSX (член ``tpl`` из base64-ZIP в data-file). None — нет.
``<div id="dataFile" data-file="<base64>">`` → base64decode → ZIP → член ``tpl``.
Битый base64 / отсутствие члена ``tpl`` → warning-лог + None (шаг graceful).
"""
m = _DATAFILE_RE.search(template_html)
if not m:
logger.warning("eias_heat: <div id=dataFile data-file> не найден в TemplatePrinter HTML")
return None
try:
zip_bytes = base64.b64decode(m.group("b64"), validate=False)
except (binascii.Error, ValueError) as e:
logger.warning("eias_heat: base64-декод data-file упал: %s", e)
return None
try:
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = zf.namelist()
member = next((n for n in names if n == "tpl" or n.rsplit("/", 1)[-1] == "tpl"), None)
if member is None:
logger.warning("eias_heat: член 'tpl' не найден в ZIP (члены: %s)", names[:20])
return None
return zf.read(member)
except zipfile.BadZipFile as e:
logger.warning("eias_heat: data-file не является валидным ZIP: %s", e)
return None
def _rightmost_number(cells: list[object]) -> float | None:
"""Правейшее парсибельное число в строке (резерв). Нет чисел → None."""
for value in reversed(cells):
num = parse_reserve_number(value)
if num is not None:
return num
return None
def _first_system_text(cells: list[object]) -> str | None:
"""Левейшая текстовая ячейка, матчащая систему/зону/СТ №N. Нет → None."""
for value in cells:
if value is None:
continue
s = str(value).strip()
if s and _SYSTEM_MARKER_RE.search(s):
return s
return None
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
DEFENSIVE (точная сетка не снята): итерируем ВСЕ строки листа. Строка-кандидат =
есть текстовая ячейка про систему («СТ №N | систем | зона») И в той же строке
есть парсибельное число → system_name = текст (strip), reserve_gcal_h = ПРАВЕЙШЕЕ
число строки. Строки-заголовки (без чисел) пропускаются.
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
Ноль распознанных систем → warning-лог с первыми 20 строками (в ``load_*``).
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active
systems: list[dict] = []
all_cells: list[list[object]] = []
try:
for row in ws.iter_rows(values_only=True):
cells = list(row)
all_cells.append(cells)
system_name = _first_system_text(cells)
if system_name is None:
continue # не строка про систему (или заголовок) → скип
reserve = _rightmost_number(cells)
if reserve is None:
continue # строка-заголовок системы без числа → скип
systems.append({"system_name": system_name, "reserve_gcal_h": reserve})
finally:
wb.close()
return systems, all_cells
def _cells_to_jsonable(all_cells: list[list[object]]) -> list[list[object]]:
"""Ячейки листа → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
out: list[list[object]] = []
for row in all_cells:
json_row: list[object] = []
for value in row:
if value is None or isinstance(value, bool | int | float | str):
json_row.append(value)
else:
json_row.append(str(value))
out.append(json_row)
return out
def _upsert_heat_rows(
db: Session,
org: str,
period: str,
source_guid: str,
systems: list[dict],
all_cells: list[list[object]],
) -> dict[str, int]:
"""UPSERT распознанных систем публикации в heat_system_reserves. Per-row SAVEPOINT.
raw = {cells: <весь лист>} сохраняется у КАЖДОЙ строки публикации (одна выгрузка =
один лист). ON CONFLICT (org, sphere, system_name, period) DO UPDATE.
Returns: счётчики parsed/inserted/updated/skipped.
"""
raw_json = json.dumps({"cells": _cells_to_jsonable(all_cells)}, ensure_ascii=False)
inserted = 0
updated = 0
skipped = 0
for sysrec in systems:
params = {
"org": org,
"system_name": sysrec["system_name"],
"reserve": sysrec["reserve_gcal_h"],
"period": period,
"source_guid": source_guid,
"raw": raw_json,
}
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
result = db.execute(
text("""
INSERT INTO heat_system_reserves
(org, sphere, system_name, reserve_gcal_h,
period, source_guid, raw, fetched_at)
VALUES (
:org, 'heat', :system_name, :reserve,
:period, :source_guid, CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (org, sphere, system_name, period) DO UPDATE
SET reserve_gcal_h = EXCLUDED.reserve_gcal_h,
source_guid = EXCLUDED.source_guid,
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("eias_heat upsert failed for %r/%r: %s", org, sysrec["system_name"], e)
skipped += 1
return {
"parsed": len(systems),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}
def fetch_card_html(org_id: int) -> str:
"""Тянет HTML карточки организации. RUN-ON-PROD (гео-блок + российский УЦ)."""
resp = httpx.get(
build_card_url(org_id),
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
verify=_ru_ssl_context(),
)
resp.raise_for_status()
return resp.text
def fetch_template_html(client: httpx.Client, guid: str, pub_id: str) -> str:
"""Тянет HTML выгрузки публикации (base64-ZIP в data-file). RUN-ON-PROD."""
resp = client.get(build_template_url(guid, pub_id))
resp.raise_for_status()
return resp.text
def load_org_reserves(db: Session, org: str, org_id: int) -> dict[str, int]:
"""Полный прогон одной организации: карточка → публикации → выгрузки → UPSERT.
Аккумулирует счётчики по всем публикациям свежайшего квартала (по ЦС).
Ноль распознанных систем во всех публикациях → warning-лог с первыми 20 строками
последнего листа, parsed=0, НЕ падаем.
"""
counts = {"parsed": 0, "inserted": 0, "updated": 0, "skipped": 0, "publications": 0}
card_html = fetch_card_html(org_id)
pubs = extract_publications(card_html)
if not pubs:
logger.warning("eias_heat[%s]: нет публикаций свежайшего квартала", org)
return counts
period = f"{pubs[0]['year']}-Q{pubs[0]['quarter']}"
last_cells: list[list[object]] = []
with httpx.Client(
timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
) as client:
for pub in pubs:
try:
template_html = fetch_template_html(client, pub["guid"], pub["id"])
xlsx_bytes = extract_tpl_xlsx(template_html)
if xlsx_bytes is None:
continue
systems, all_cells = parse_heat_sheet(xlsx_bytes)
last_cells = all_cells
counts["publications"] += 1
if not systems:
continue
sub = _upsert_heat_rows(db, org, period, pub["guid"], systems, all_cells)
counts["parsed"] += sub["parsed"]
counts["inserted"] += sub["inserted"]
counts["updated"] += sub["updated"]
counts["skipped"] += sub["skipped"]
except Exception as e:
logger.exception("eias_heat[%s]: публикация guid=%s упала: %s", org, pub["guid"], e)
if counts["parsed"] == 0:
logger.warning(
"eias_heat[%s]: 0 систем распознано (period=%s); первые 20 строк листа: %s",
org,
period,
last_cells[:20],
)
counts["period"] = period # type: ignore[assignment]
logger.info("eias_heat[%s] done: %s", org, counts)
return counts
def load_heat_reserves(db: Session | None = None) -> dict[str, dict]:
"""Полный прогон тепло-резервов ФАС по всем организациям (ЕТК, Екатеринбургэнерго).
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal/eesk). Каждая
организация graceful: падение одной не валит вторую. Коммит один в конце
(per-row SAVEPOINT уже изолирует битые строки).
Returns: {org_name: {parsed, inserted, updated, skipped, publications, period}}
или {org_name: {"error": ...}} при падении организации.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
out: dict[str, dict] = {}
try:
for org, org_id in ORGS:
try:
out[org] = load_org_reserves(db, org, org_id)
except Exception as e:
logger.exception("load_heat_reserves: org %s failed: %s", org, e)
out[org] = {"error": str(e)}
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_heat_reserves: outer tx rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
logger.info("load_heat_reserves done: %s", out)
return out