fix(eias-heat): parse TemplatePrinter link from onclick + «N кв. YYYY» period
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 14m31s

Карточка PublicDisclosureInfoOrg.aspx на проде держит ссылку в JS
onclick=openTemplateDialog(...) при href="#" (не в href), амперсанды
HTML-энкожены, а период — «1 кв. 2026 года». Старые регэкспы давали
0 публикаций для обеих организаций (PR #2165).

- _PUB_HREF_RE: матч URL TemplatePrinter где угодно в чанке (onclick + href)
- extract_publications: html.unescape чанка перед поиском (&guid=)
- _QUARTER_RE: поддержка «N кв. YYYY» / «N кв YYYY» помимо «N квартал YYYY»
- дедуп публикаций по (guid, id) — одна публикация в 3 строках грида
- тесты: реальная onclick-форма + дедуп + новые кейсы _quarter_from_text
This commit is contained in:
Light1YT 2026-07-02 22:08:10 +05:00
parent e501dedf78
commit a5e8208fcb
2 changed files with 85 additions and 11 deletions

View file

@ -3,11 +3,15 @@
Источник реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru, Источник реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
reg=2644). Данные лежат за 3 GET-переходами: reg=2644). Данные лежат за 3 GET-переходами:
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) HTML-грид со списком 1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) HTML-грид (DevExpress)
публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки со списком публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода ``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода.
«N квартал YYYY». Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) На проде ссылка живёт НЕ в 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" 2. TemplatePrinter (ri-loader.eias.ru) HTML с ``<div id="dataFile"
data-file="<base64>">``; base64 ZIP (in-memory) член ``tpl`` (XLSX). data-file="<base64>">``; base64 ZIP (in-memory) член ``tpl`` (XLSX).
3. Лист ``ТП`` формы 14 «Резерв мощности источников тепловой энергии» 3. Лист ``ТП`` формы 14 «Резерв мощности источников тепловой энергии»
@ -34,6 +38,7 @@ import json
import logging import logging
import re import re
import zipfile import zipfile
from html import unescape
import httpx import httpx
from openpyxl import load_workbook from openpyxl import load_workbook
@ -71,15 +76,20 @@ _HEAT_SHEET = "ТП"
# Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId). # Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId).
# TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может # TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может
# варьироваться — берём каждый независимым lookahead-регэкспом на href). # варьироваться — берём каждый независимым 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( _PUB_HREF_RE = re.compile(
r'href=["\'](?P<href>[^"\']*TemplatePrinter\.aspx[^"\']*)["\']', r"(?P<href>(?:https?://[^\"'()\s]+)?/?[^\"'()\s]*TemplatePrinter\.aspx[^\"'()\s]*)",
re.IGNORECASE, re.IGNORECASE,
) )
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE) _GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE) _PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)
# «N квартал YYYY» в тексте строки грида → (year, quarter). # Период в тексте строки грида → (year, quarter). Реальная разметка прода —
_QUARTER_RE = re.compile(r"(\d)\s*квартал\w*\s*(\d{4})", re.IGNORECASE) # «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>">. # base64-payload файла: <div id="dataFile" ... data-file="<base64>">.
_DATAFILE_RE = re.compile( _DATAFILE_RE = re.compile(
@ -125,10 +135,15 @@ def extract_publications(card_html: str) -> list[dict]:
По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
«N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и «N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько по ЦС). возвращаем ВСЕ публикации именно этого квартала (их бывает несколько по ЦС).
Строки без ссылки ИЛИ без распознанного периода игнорируются. Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
встречается в нескольких строках грида (по видам деятельности: передача/сбыт)
дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
""" """
pubs: list[dict] = [] 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) href_m = _PUB_HREF_RE.search(chunk)
if not href_m: if not href_m:
continue continue
@ -150,7 +165,13 @@ def extract_publications(card_html: str) -> list[dict]:
return [] return []
freshest = max((p["year"], p["quarter"]) for p in pubs) 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( logger.info(
"eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d%d публикаций", "eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d%d публикаций",
len(pubs), 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>' 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: def test_extract_publications_freshest_quarter_all_pubs() -> None:
"""Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем.""" """Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
html = ( html = (
@ -54,6 +81,30 @@ def test_extract_publications_freshest_quarter_all_pubs() -> None:
assert by_guid["g-new-b"]["id"] == "202" 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: def test_extract_publications_year_beats_quarter() -> None:
"""4 квартал 2025 старее 1 квартала 2026 — берём 2026.""" """4 квартал 2025 старее 1 квартала 2026 — берём 2026."""
html = ( html = (
@ -88,6 +139,8 @@ def test_extract_publications_skips_rows_without_period_or_link() -> None:
def test_quarter_from_text_helper() -> None: def test_quarter_from_text_helper() -> None:
assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3) assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
assert eh._quarter_from_text("… 2 квартала 2026 …") == (2026, 2) 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("5 квартал 2026") is None # квартал вне 1..4
assert eh._quarter_from_text("без квартала 2026") is None assert eh._quarter_from_text("без квартала 2026") is None