Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches. Co-authored-by: Cursor <cursoragent@cursor.com>
169 lines
5.8 KiB
Python
169 lines
5.8 KiB
Python
"""Shared HTTP helpers: fetch JSON/bytes, download icons, load Valve datafeeds.
|
|
|
|
Used by all fetch_*.py scripts and serve_relations.py so HTTP logic, User-Agent,
|
|
timeout, and retry conventions live in exactly one place.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
UA = "climperor"
|
|
DEFAULT_TIMEOUT = 60
|
|
# OpenDota and similar APIs occasionally 429; back off before failing CI/refresh.
|
|
DEFAULT_RETRIES = 4
|
|
DEFAULT_RETRY_BACKOFF = 5.0
|
|
|
|
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
|
|
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language={lang}"
|
|
|
|
|
|
def _http_open(url: str, *, timeout: int, retries: int, backoff: float):
|
|
"""urlopen with retries on 429 / 5xx / transient network errors."""
|
|
last_err: BaseException | None = None
|
|
for attempt in range(retries + 1):
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
try:
|
|
return urllib.request.urlopen(req, timeout=timeout)
|
|
except urllib.error.HTTPError as e:
|
|
last_err = e
|
|
if e.code not in (429, 500, 502, 503, 504) or attempt >= retries:
|
|
raise
|
|
sleep_s = backoff * (2**attempt)
|
|
retry_after = e.headers.get("Retry-After") if e.headers else None
|
|
if retry_after:
|
|
try:
|
|
sleep_s = max(sleep_s, float(retry_after))
|
|
except ValueError:
|
|
pass
|
|
print(
|
|
f"HTTP {e.code} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s",
|
|
flush=True,
|
|
)
|
|
time.sleep(sleep_s)
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
last_err = e
|
|
if attempt >= retries:
|
|
raise
|
|
sleep_s = backoff * (2**attempt)
|
|
print(
|
|
f"HTTP error {e} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s",
|
|
flush=True,
|
|
)
|
|
time.sleep(sleep_s)
|
|
assert last_err is not None
|
|
raise last_err
|
|
|
|
|
|
def http_json(
|
|
url: str,
|
|
*,
|
|
timeout: int = DEFAULT_TIMEOUT,
|
|
retries: int = DEFAULT_RETRIES,
|
|
backoff: float = DEFAULT_RETRY_BACKOFF,
|
|
) -> dict | list:
|
|
with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp:
|
|
return json.loads(resp.read().decode())
|
|
|
|
|
|
def http_bytes(
|
|
url: str,
|
|
*,
|
|
timeout: int = DEFAULT_TIMEOUT,
|
|
retries: int = DEFAULT_RETRIES,
|
|
backoff: float = DEFAULT_RETRY_BACKOFF,
|
|
) -> bytes:
|
|
with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp:
|
|
return resp.read()
|
|
|
|
|
|
def download_icons(
|
|
keys,
|
|
url_template: str,
|
|
dest_dir: Path,
|
|
*,
|
|
force: bool = False,
|
|
delay: float = 0.0,
|
|
min_size: int = 32,
|
|
skip_keys: frozenset[str] | set[str] | None = None,
|
|
) -> tuple[int, int, int]:
|
|
"""Download PNG icons from a CDN. Returns (saved, skipped_existing, fail).
|
|
|
|
keys — iterable of template substitution values (hero/item/ability keys).
|
|
url_template — e.g. "https://cdn.../abilities/{key}.png".
|
|
dest_dir — target directory (created if missing).
|
|
force — re-download even if the file exists.
|
|
delay — seconds to sleep between requests (rate limiting).
|
|
min_size — files smaller than this are treated as empty and re-downloaded.
|
|
skip_keys — keys to ignore entirely (e.g. bundled icons that 404 on CDN).
|
|
"""
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
skip = set(skip_keys or ())
|
|
saved = skipped = fail = 0
|
|
for key in sorted(keys):
|
|
if key in skip or "/" in key or "\\" in key or ".." in key:
|
|
continue
|
|
dest = dest_dir / f"{key}.png"
|
|
if dest.is_file() and dest.stat().st_size >= min_size and not force:
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
data = http_bytes(url_template.format(key=key))
|
|
if not data or len(data) < min_size:
|
|
raise ValueError("empty icon")
|
|
dest.write_bytes(data)
|
|
saved += 1
|
|
print(f" icon saved {key}.png ({len(data)} bytes)", flush=True)
|
|
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
|
|
print(f" icon {key}: {e}", flush=True)
|
|
fail += 1
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
return saved, skipped, fail
|
|
|
|
|
|
def load_itemlist(lang: str = "schinese") -> dict[int, dict[str, str]]:
|
|
"""Valve datafeed item list: item id -> {name_loc, name}."""
|
|
raw = http_json(ITEMLIST_URL.format(lang=lang))
|
|
rows = (((raw or {}).get("result") or {}).get("data") or {}).get("itemabilities") or []
|
|
out: dict[int, dict[str, str]] = {}
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
iid = row.get("id")
|
|
if iid is None:
|
|
continue
|
|
out[int(iid)] = {
|
|
"name_loc": (row.get("name_loc") or "").strip(),
|
|
"name": (row.get("name") or "").strip(),
|
|
}
|
|
return out
|
|
|
|
|
|
def load_itemlist_zh() -> dict[int, str]:
|
|
"""Convenience: item id -> Chinese localized name."""
|
|
return {iid: v["name_loc"] for iid, v in load_itemlist().items() if v["name_loc"]}
|
|
|
|
|
|
def fetch_hero_list(lang: str = "schinese") -> list[dict]:
|
|
"""Hero list from Valve's datafeed (id, key, name_loc, primary_attr)."""
|
|
data = http_json(HEROES_URL.format(lang=lang))
|
|
heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes")
|
|
if not heroes:
|
|
raise SystemExit("hero list came back empty")
|
|
return heroes
|
|
|
|
|
|
def fetch_hero_keys() -> list[str]:
|
|
"""Hero keys (e.g. antimage, earthshaker) from the English datafeed."""
|
|
heroes = fetch_hero_list(lang="english")
|
|
keys = []
|
|
for h in heroes:
|
|
name = h.get("name") or ""
|
|
keys.append(name.removeprefix("npc_dota_hero_"))
|
|
return keys
|