"""Загрузчик резервов тепловой мощности из ФГИС ФАС (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 Гкал/час). Точная сетка листа НЕ снята разведкой → парсинг 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=&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, ) # Строка-кандидат листа: текстовая ячейка про систему теплоснабжения / зону / СТ №N. _SYSTEM_MARKER_RE = re.compile(r"СТ\s*№\s*\d+|систем|зона", re.IGNORECASE) # Разбивка 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 _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