This commit is contained in:
parent
8a177df0e2
commit
15224da3b5
2 changed files with 85 additions and 11 deletions
|
|
@ -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-энкожены (``&``) → чанк строки перед парсом
|
||||
прогоняем через ``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-энтити (`&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),
|
||||
|
|
|
|||
|
|
@ -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-энкожены (&), период «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:
|
||||
"""Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue