fix(eias-heat): ссылка TemplatePrinter из onclick + период «N кв. YYYY» (#2119 B2) #2172

Merged
bot-backend merged 1 commit from fix/eias-heat-card-parse into main 2026-07-02 17:24:27 +00:00
2 changed files with 85 additions and 11 deletions

View file

@ -3,11 +3,15 @@
Источник реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
reg=2644). Данные лежат за 3 GET-переходами:
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) HTML-грид со списком
публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода
«N квартал YYYY». Берём ВСЕ публикации свежайшего квартала (max(year, quarter))
их может быть несколько (по одной на центральную систему / ЦС).
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 «Резерв мощности источников тепловой энергии»
@ -34,6 +38,7 @@ import json
import logging
import re
import zipfile
from html import unescape
import httpx
from openpyxl import load_workbook
@ -71,15 +76,20 @@ _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'href=["\'](?P<href>[^"\']*TemplatePrinter\.aspx[^"\']*)["\']',
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)
# «N квартал YYYY» в тексте строки грида → (year, quarter).
_QUARTER_RE = re.compile(r"(\d)\s*квартал\w*\s*(\d{4})", 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(
@ -125,10 +135,15 @@ def extract_publications(card_html: str) -> list[dict]:
По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
«N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько по ЦС).
Строки без ссылки ИЛИ без распознанного периода игнорируются.
Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
встречается в нескольких строках грида (по видам деятельности: передача/сбыт)
дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
"""
pubs: list[dict] = []
for chunk in _TR_SPLIT_RE.split(card_html):
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
@ -150,7 +165,13 @@ def extract_publications(card_html: str) -> list[dict]:
return []
freshest = max((p["year"], p["quarter"]) for p in pubs)
latest = [p for p in pubs if (p["year"], p["quarter"]) == freshest]
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),

View file

@ -29,6 +29,33 @@ def _pub_row(guid: str, pub_id: str, period_text: str) -> str:
return f'<tr><td><a href="{href}">выгрузить</a></td><td>{period_text}</td></tr>'
def test_extract_publications_real_onclick_form() -> None:
"""РЕАЛЬНАЯ разметка прода: ссылка в onclick=openTemplateDialog(...), href="#",
амперсанды HTML-энкожены (&amp;), период «1 кв. 2026 года» в тексте строки."""
url = (
"https://ri-loader.eias.ru/TemplatePrinter.aspx?reg=RU.5.66"
"&amp;guid=40665f4f-c7e7-4121-ac15-0bff24c39f81&amp;id=9125184878"
)
html = (
"<table><tr>"
'<td id="ASPxGridViewDet_tccell0_15" class="dxgv">'
f"<a class='get_template' onclick=\"openTemplateDialog('{url}', "
"'Акционерное общество «Екатеринбургская теплосетевая компания» "
'(ИНН 6671019770, КПП 667101001)\');return false;" href="#">'
"выгрузить</a></td>"
"<td>1 кв. 2026 года</td>"
"</tr></table>"
)
pubs = eh.extract_publications(html)
assert len(pubs) == 1
assert pubs[0]["guid"] == "40665f4f-c7e7-4121-ac15-0bff24c39f81"
assert pubs[0]["id"] == "9125184878"
assert pubs[0]["year"] == 2026
assert pubs[0]["quarter"] == 1
def test_extract_publications_freshest_quarter_all_pubs() -> None:
"""Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
html = (
@ -54,6 +81,30 @@ def test_extract_publications_freshest_quarter_all_pubs() -> None:
assert by_guid["g-new-b"]["id"] == "202"
def test_extract_publications_dedupes_repeated_guid_id() -> None:
"""Одна публикация в нескольких строках грида (передача/сбыт) → одна запись.
На проде каждый (guid, id) повторяется ~3 раза без дедупа лоадер качал бы
один и тот же ZIP многократно.
"""
html = (
"<table>"
+ "".join(
[
_pub_row("g-dup", "301", "1 кв. 2026 года"), # передача
_pub_row("g-dup", "301", "1 кв. 2026 года"), # сбыт (та же публикация)
_pub_row("g-other", "302", "1 кв. 2026 года"),
]
)
+ "</table>"
)
pubs = eh.extract_publications(html)
assert len(pubs) == 2
assert [(p["guid"], p["id"]) for p in pubs] == [("g-dup", "301"), ("g-other", "302")]
def test_extract_publications_year_beats_quarter() -> None:
"""4 квартал 2025 старее 1 квартала 2026 — берём 2026."""
html = (
@ -88,6 +139,8 @@ def test_extract_publications_skips_rows_without_period_or_link() -> None:
def test_quarter_from_text_helper() -> None:
assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
assert eh._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
assert eh._quarter_from_text("1 кв. 2026 года") == (2026, 1) # реальная форма прода
assert eh._quarter_from_text("1 кв 2026") == (2026, 1) # без точки
assert eh._quarter_from_text("5 квартал 2026") is None # квартал вне 1..4
assert eh._quarter_from_text("без квартала 2026") is None