fix(eias-heat): ссылка TemplatePrinter из onclick + период «N кв. YYYY» (#2119 B2) #2172
2 changed files with 85 additions and 11 deletions
|
|
@ -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-энкожены (``&``) → чанк строки перед парсом
|
||||||
|
прогоняем через ``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-энтити (`&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),
|
||||||
|
|
|
||||||
|
|
@ -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-энкожены (&), период «1 кв. 2026 года» в тексте строки."""
|
||||||
|
url = (
|
||||||
|
"https://ri-loader.eias.ru/TemplatePrinter.aspx?reg=RU.5.66"
|
||||||
|
"&guid=40665f4f-c7e7-4121-ac15-0bff24c39f81&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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue