fix(site-finder): Россети-раскрытие — URL-энкоженные href, ссылки на xlsx не находились #2121

Merged
bot-backend merged 1 commit from fix/rosseti-disclosure-links into main 2026-07-02 09:36:15 +00:00
2 changed files with 64 additions and 15 deletions

View file

@ -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)}

View file

@ -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'<a href="{h}">x</a>' 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"])