diff --git a/backend/app/services/site_finder/eias_heat_loader.py b/backend/app/services/site_finder/eias_heat_loader.py index 10748885..d4799749 100644 --- a/backend/app/services/site_finder/eias_heat_loader.py +++ b/backend/app/services/site_finder/eias_heat_loader.py @@ -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=&id=`` + текст периода - «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=&id=`` + текст периода. + На проде ссылка живёт НЕ в 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 с ``
``; 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=&id= (порядок параметров может # варьироваться — берём каждый независимым 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[^"\']*TemplatePrinter\.aspx[^"\']*)["\']', + r"(?P(?: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 файла:
. _DATAFILE_RE = re.compile( @@ -125,10 +135,15 @@ def extract_publications(card_html: str) -> list[dict]: По каждой -строке грида ищем ссылку на 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), diff --git a/backend/tests/test_eias_heat_loader.py b/backend/tests/test_eias_heat_loader.py index f35660d2..b8bdc011 100644 --- a/backend/tests/test_eias_heat_loader.py +++ b/backend/tests/test_eias_heat_loader.py @@ -29,6 +29,33 @@ def _pub_row(guid: str, pub_id: str, period_text: str) -> str: return f'выгрузить{period_text}' +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 = ( + "" + '" + "" + "
' + f"' + "выгрузить1 кв. 2026 года
" + ) + + 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 = ( + "" + + "".join( + [ + _pub_row("g-dup", "301", "1 кв. 2026 года"), # передача + _pub_row("g-dup", "301", "1 кв. 2026 года"), # сбыт (та же публикация) + _pub_row("g-other", "302", "1 кв. 2026 года"), + ] + ) + + "
" + ) + + 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