448 lines
21 KiB
Python
448 lines
21 KiB
Python
"""Загрузчик резервов тепловой мощности из ФГИС ФАС (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-энкожены (``&``) → чанк строки перед парсом
|
||
прогоняем через ``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-энтити (`&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
|