fix(site-finder): Россети-раскрытие — URL-энкоженные href, ссылки на xlsx не находились #2121
2 changed files with 64 additions and 15 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue