"""Загрузчик резервов тепловой мощности из ФГИС ФАС (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=&id=`` + текст периода. На проде ссылка живёт НЕ в href (там ``href="#"``), а в JS ``onclick="openTemplateDialog('https://ri-loader.eias.ru/TemplatePrinter.aspx?…', …)"``, амперсанды бывают HTML-энкожены (``&``) → чанк строки перед парсом прогоняем через ``html.unescape``. Период — «N кв. YYYY года» (реже «N квартал YYYY»). Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) — их может быть несколько (по одной на центральную систему / ЦС). 2. TemplatePrinter (ri-loader.eias.ru) — HTML с ``
``; 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=&id= (порядок параметров может # варьироваться — берём каждый независимым 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(?: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 файла:
. _DATAFILE_RE = re.compile( r']*id=["\']dataFile["\'][^>]*data-file=["\'](?P[^"\']+)["\']', 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 грида на строки () — публикации живут построчно, период и # ссылка в одной строке. Достаточно грубого split по 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}]. По каждой -строке грида ищем ссылку на 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-энтити (`&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 — нет. ``
`` → base64decode → ZIP → член ``tpl``. Битый base64 / отсутствие члена ``tpl`` → warning-лог + None (шаг graceful). """ m = _DATAFILE_RE.search(template_html) if not m: logger.warning("eias_heat:
не найден в 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] = [] name_counts: dict[str, int] = {} 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 # непарсибельная ячейка резерва (текст/пусто) → систему скипаем # Разные системы могут носить БАЙТ-В-БАЙТ одинаковое имя в соседних колонках # (живой пример ЕЭ: две «Локальные зоны … МУП "Екатеринбургэнерго"» с 62.198 # и 0). UPSERT по (org, sphere, system_name, period) схлопнул бы их с потерей # данных — вторым и далее вхождениям добавляем порядковый суффикс « (N)». name_counts[name] = name_counts.get(name, 0) + 1 if name_counts[name] > 1: name = f"{name} ({name_counts[name]})" 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