v0.2.0: relations preview, item shop, abilities, overlay recommend, GSI enhancements

- Add relations/item/abilities preview (serve_relations.py + web/relations/)
- Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities,
  ability_videos, patches, stratz, matchups, portraits
- Add overlay.py (role tags + Top-3 cyan marks), recommend.py
- Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py
- GSI: full payload JSONL dump, foreground window detection
- Drop real template library; CDN-only matching
- Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table
- .gitignore: exclude large regenerable assets (icons/portraits/videos)
This commit is contained in:
voson
2026-07-27 11:56:51 +08:00
parent e567a5cdfc
commit a91789b72f
76 changed files with 109860 additions and 996 deletions
+118
View File
@@ -0,0 +1,118 @@
"""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
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language={lang}"
def http_json(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> dict | list:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def http_bytes(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) 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