From e1a75d98d46d2d1f84e4fd94da957b073ac43aee Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 09:36:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=D0=A0=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D0=B5=D1=82=D0=B8-=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=E2=80=94=20URL-=D1=8D=D0=BD=D0=BA=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20href,=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=20=D0=BD=D0=B0=20xlsx=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D0=B8=D1=81=D1=8C=20?= =?UTF-8?q?(#2121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site_finder/rosseti_reserve_loader.py | 35 ++++++++------- .../tests/test_connection_capacity_loaders.py | 44 +++++++++++++++++++ 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/backend/app/services/site_finder/rosseti_reserve_loader.py b/backend/app/services/site_finder/rosseti_reserve_loader.py index c977c66f..458b6163 100644 --- a/backend/app/services/site_finder/rosseti_reserve_loader.py +++ b/backend/app/services/site_finder/rosseti_reserve_loader.py @@ -15,6 +15,7 @@ import io import logging import re from datetime import date +from urllib.parse import unquote import httpx from openpyxl import load_workbook @@ -130,23 +131,26 @@ def fetch_disclosure_links() -> dict[str, str]: hrefs = re.findall(r'href=["\']([^"\']+?\.xlsx)["\']', html, re.IGNORECASE) base = "https://rosseti-ural.ru" - cp_candidates: list[str] = [] - tp_candidates: list[str] = [] + # ВАЖНО (прод-инцидент 2026-07-02): href'ы URL-энкоженные (кириллица как + # %D0%A1...) — фильтр по СЫРОЙ строке никогда не матчился → 'no cp link + # found'. Фильтруем/классифицируем/датируем по ДЕКОДИРОВАННОМУ имени, а + # скачиваем по сырому href (энкоженный URL корректен для httpx). + cp_candidates: list[tuple[str, str]] = [] # (url, decoded_low) + tp_candidates: list[tuple[str, str]] = [] for href in hrefs: - low = href.lower() - # Свердловск-related фильтр (файлы бывают по разным филиалам). - if "свердл" not in low and "sverdl" not in low and "свэс" not in low: - # Если в href нет региона — пропускаем осторожно (может быть общий файл), - # но для нашей задачи берём только явно свердловские. + decoded = unquote(href).lower() + # Свердловск-фильтр: «Свердловской области» (ЦП) / «Свердловэнерго» (ТП/РП). + if "свердлов" not in decoded and "sverdl" not in decoded: continue url = href if href.startswith("http") else base + href - if "по цп" in low or "_цп" in low or "cp" in low: - cp_candidates.append(url) - if "тп" in low and ("рп" in low or "35" in low): - tp_candidates.append(url) + if "по цп" in decoded: + cp_candidates.append((url, decoded)) + elif "для тп" in decoded and ("рп" in decoded or "35" in decoded): + tp_candidates.append((url, decoded)) - def _latest(cands: list[str]) -> str: - return max(cands, key=_file_date_key) if cands else "" + def _latest(cands: list[tuple[str, str]]) -> str: + # Дата «на DD.MM.YYYY» — в декодированном имени файла. + return max(cands, key=lambda c: _file_date_key(c[1]))[0] if cands else "" result = {"cp": _latest(cp_candidates), "tp_rp": _latest(tp_candidates)} logger.info( @@ -378,7 +382,8 @@ def load_all_reserves(db: Session | None = None) -> dict[str, dict]: try: r = client.get(links["cp"]) r.raise_for_status() - out["cp"] = load_cp_reserves(db, r.content, links["cp"]) + # unquote: дата «на DD.MM.YYYY» ищется в ДЕКОДИРОВАННОМ имени. + out["cp"] = load_cp_reserves(db, r.content, unquote(links["cp"])) except Exception as e: logger.exception("load_all_reserves: cp file failed: %s", e) out["cp"] = {"error": str(e)} @@ -389,7 +394,7 @@ def load_all_reserves(db: Session | None = None) -> dict[str, dict]: try: r = client.get(links["tp_rp"]) r.raise_for_status() - out["tp_rp"] = load_tp_rp_reserves(db, r.content, links["tp_rp"]) + out["tp_rp"] = load_tp_rp_reserves(db, r.content, unquote(links["tp_rp"])) except Exception as e: logger.exception("load_all_reserves: tp_rp file failed: %s", e) out["tp_rp"] = {"error": str(e)} diff --git a/backend/tests/test_connection_capacity_loaders.py b/backend/tests/test_connection_capacity_loaders.py index 66efb3fe..a4b22b54 100644 --- a/backend/tests/test_connection_capacity_loaders.py +++ b/backend/tests/test_connection_capacity_loaders.py @@ -363,3 +363,47 @@ def test_load_cp_reserves_unmatched_counted() -> None: result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx") assert result["matched"] == 0 assert result["unmatched"] == 1 + + +# ── fetch_disclosure_links (прод-инцидент 2026-07-02: URL-энкоженные href) ──── + + +def test_fetch_disclosure_links_decodes_urlencoded_hrefs(monkeypatch): + """href'ы на странице раскрытия URL-энкоженные (кириллица как %D0%A1...) — + фильтр обязан работать по декодированному имени и выбирать свежайший файл. + Пермский файл отфильтровывается, из двух ЦП-дат берётся поздняя.""" + from urllib.parse import quote, unquote + + base = "/public/upload/Documents/2026/Transmission/" + cp_tpl = ( + "Информация о наличии объема свободной мощности по ЦП ПАО «Россети Урал» " + "на территории {region} (на {d}).xlsx" + ) + tp_name = ( + "Информация о наличии свободной мощности для ТП по ПС и РП напряжением " + "ниже 35 кВ филиала Свердловэнерго на 30.06.2026.xlsx" + ) + hrefs = [ + base + quote(cp_tpl.format(region="Свердловской области", d="30.03.2026")), + base + quote(cp_tpl.format(region="Свердловской области", d="30.06.2026")), + base + quote(cp_tpl.format(region="Пермского края", d="30.06.2026")), + base + quote(tp_name), + ] + html = "".join(f'x' for h in hrefs) + + class _Resp: + status_code = 200 + text = html + + def raise_for_status(self): + return None + + monkeypatch.setattr( + "app.services.site_finder.rosseti_reserve_loader.httpx.get", + lambda *a, **k: _Resp(), + ) + links = rr.fetch_disclosure_links() + assert "30.06.2026" in unquote(links["cp"]) + assert "Пермск" not in unquote(links["cp"]) + assert "%D0" in links["cp"] # скачиваем по СЫРОМУ (энкоженному) href + assert "Свердловэнерго" in unquote(links["tp_rp"])