gendesign/backend/app/services/site_finder/eias_heat_loader.py
Light1YT 497f992c67
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m47s
CI / backend-tests (pull_request) Successful in 14m6s
fix(eias-heat): column-align ТП sheet parse to real grid (drop Flag_Row_Size trap)
parse_heat_sheet брал правейшее число строки — на проде это оказалась
служебная колонка шаблона ЕИАС Flag_Row_Size (высоты строк), и в
heat_system_reserves попали рубрики анкеты («Количество заявок»=74,
кнопка «Добавить систему»=15) как тепло-системы.

Переписан column-aligned по реальной сетке (снята из raw всех 4
публикаций): строка «Централизованная система» даёт имена систем по
колонкам j≥6 (текст с буквой), строка №5 «Резерв мощности…» — Гкал/ч
из тех же колонок. Нулевой резерв сохраняется (честный дефицит),
None/текст — скип системы. Тесты пересобраны из реальной сетки ЕТК
(СТ № 56→0.0, СТ № 54→3.26, СТ № 60→11.28) + регрессия на рубрики.
2026-07-02 22:39:43 +05:00

509 lines
25 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 Гкал/час).
Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны) —
парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»:
* Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем
лежат в колонках ``j ≥ 6`` (текст: «СТ № 56», «СТ № 54 » с trailing-space, «Локальные
зоны теплоснабжения МУП…»). В хвосте той же строки бывают СЛУЖЕБНЫЕ ЧИСЛА (шаблон
ЕИАС) — отсекаем их требованием «имя содержит хотя бы одну букву».
* Строка резерва (№5): ``col3.strip() == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
мощности источников тепловой энергии» — значения Гкал/ч стоят в ТЕХ ЖЕ колонках
``j``, что и имена систем. В хвосте — длинный текст-примечание «Указывается резерв…»
и служебные числа: они не в колонках систем и отсекаются сами.
* ЛОВУШКА ``Flag_Row_Size``: последняя КОЛОНКА листа (col25) — служебные числа-высоты
строк шаблона ЕИАС (74, 52, 15 у рубрик анкеты). Старый «правейшее число строки»
хватал именно их и распознавал рубрики анкеты («Количество заявок…», «Причины
отказа…», кнопку «Добавить систему») как системы. Column-aligned парс их не видит.
Все ячейки листа целиком → ``raw`` jsonb (будущие колонки не теряются). Нет строки
систем ИЛИ строки резерва → warning-лог + 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,
)
# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена
# систем стоят в колонках j ≥ 6 (см. docstring модуля, п.3).
_SYSTEMS_HEADER = "Централизованная система"
# Строка резерва мощности (№5 формы 14): col3 == '5' И/ИЛИ col4 начинается с этого.
_RESERVE_ROW_NUM = "5"
_RESERVE_ROW_PREFIX = "Резерв мощности источников тепловой энергии"
# Первая колонка листа, где могут стоять имена систем (col0..col5 — служебные/№/ед.изм.).
_SYSTEM_COL_START = 6
# Имя системы обязано содержать хотя бы одну букву (кириллица/латиница) — иначе это
# служебное число из хвоста строки-заголовка (Flag_Row_Size шаблона ЕИАС).
_HAS_LETTER_RE = re.compile(r"[А-Яа-яЁёA-Za-z]")
# Разбивка 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 _cell_str(value: object) -> str:
"""Ячейка → strip-строка (None → '')."""
return "" if value is None else str(value).strip()
def _is_systems_header(cells: list[object]) -> bool:
"""Строка-заголовок систем: col4 == 'Централизованная система'."""
return len(cells) > 4 and _cell_str(cells[4]) == _SYSTEMS_HEADER
def _is_reserve_row(cells: list[object]) -> bool:
"""Строка резерва (№5): col3.strip() == '5' И/ИЛИ col4 начинается с «Резерв мощности…»."""
if len(cells) <= 4:
return False
return _cell_str(cells[3]) == _RESERVE_ROW_NUM or _cell_str(cells[4]).startswith(
_RESERVE_ROW_PREFIX
)
def _system_columns(header_cells: list[object]) -> list[tuple[int, str]]:
"""Из строки-заголовка → пары (col_index, system_name) для j ≥ 6.
Имя обязано быть текстовым и содержать хотя бы одну букву — иначе это служебное
число из хвоста строки (Flag_Row_Size шаблона ЕИАС), а не система.
"""
result: list[tuple[int, str]] = []
for j in range(_SYSTEM_COL_START, len(header_cells)):
value = header_cells[j]
if value is None:
continue
name = str(value).strip()
if name and _HAS_LETTER_RE.search(name):
result.append((j, name))
return result
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»):
1. Строка-заголовок систем — ``col4 == 'Централизованная система'``; имена систем
берём из колонок ``j ≥ 6`` (текст, содержит букву — служебные числа хвоста
отсеиваются), имя .strip() (у части систем trailing-space).
2. Строка резерва (№5) — ``col3 == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
мощности источников тепловой энергии»; резерв каждой системы читаем из ТОЙ ЖЕ
колонки ``j``, что и её имя. Нулевой резерв (0.0) — честный дефицит, СОХРАНЯЕМ;
непарсибельная ячейка (текст/пусто) — систему пропускаем.
Нет строки систем ИЛИ строки резерва → warning-лог + ([], all_cells) (graceful).
Служебная колонка 25 ``Flag_Row_Size`` (числа-высоты строк) в парс не попадает —
именно она заставляла старый парсер хватать рубрики анкеты как «системы».
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
"""
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
all_cells: list[list[object]] = []
header_cells: list[object] | None = None
reserve_cells: list[object] | None = None
try:
for row in ws.iter_rows(values_only=True):
cells = list(row)
all_cells.append(cells)
if header_cells is None and _is_systems_header(cells):
header_cells = cells
elif reserve_cells is None and _is_reserve_row(cells):
reserve_cells = cells
finally:
wb.close()
if header_cells is None or reserve_cells is None:
logger.warning(
"eias_heat: сетка не распознана (header=%s, reserve=%s) — 0 систем",
header_cells is not None,
reserve_cells is not None,
)
return [], all_cells
systems: list[dict] = []
for j, name in _system_columns(header_cells):
reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
if reserve is None:
continue # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
systems.append({"system_name": name, "reserve_gcal_h": reserve})
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