Files
climperor/web/fetch_hero_items.py
T
vosonandCursor 9c5aa5b610 Reorganize repository into pc web shared monorepo
Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:29:08 +08:00

319 lines
11 KiB
Python

"""Fetch OpenDota hero item popularity into data/hero_items.json.
Stores a flat Top-N of core finished items per hero (no start/early/mid/late).
Item names (Chinese): Valve dota2.com datafeed (schinese).
Item icons: Steam CDN dota_react/items/{key}.png → assets/item_icons/.
Usage:
python fetch_hero_items.py
python fetch_hero_items.py --force
python fetch_hero_items.py --delay 0.3 --skip-icons
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import download_icons, http_json, load_itemlist_zh
from shared.paths import DATA, ITEM_ICONS
OPENDOTA = "https://api.opendota.com/api"
ITEMS_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
)
ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
)
OUT = DATA / "hero_items.json"
PHASE_API = (
"start_game_items",
"early_game_items",
"mid_game_items",
"late_game_items",
)
# Crafted boots / mid-game cores (phase, treads, vanguard); skip bracer/wand.
MIN_CREATED_COST = 1400
# Non-crafted shop cores that are still finished pickups (blink / 跳刀).
ALWAYS_CORE = frozenset({"blink", "aghanims_shard"})
# Aghanim's Blessing (ultimate_scepter_2, id 271) is the activated form of
# Aghanim's Scepter (ultimate_scepter, id 108). Merge blessing counts into
# scepter so heroes show "阿哈利姆神杖" once, never "阿哈利姆福佑".
BLESSING_TO_SCEPTER = {271: 108}
TOP_N = 12
# If any upgrade's popularity >= this fraction of the intermediate's, hide it
# (e.g. AM yasha→manta). If upgrades are rare (Jugg yasha only), keep it.
UPGRADE_RATIO = 0.35
def load_item_catalog() -> tuple[dict[int, dict], dict[int, list[int]]]:
"""Return (id -> meta, id -> upgrade item ids that list it as a component)."""
raw = http_json(ITEMS_URL)
zh = load_itemlist_zh()
key_to_id: dict[str, int] = {}
out: dict[int, dict] = {}
for key, row in raw.items():
if not isinstance(row, dict):
continue
iid = row.get("id")
if iid is None:
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
dname = str(row.get("dname") or key_s)
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
comps = [str(c) for c in (row.get("components") or []) if c]
out[int(iid)] = {
"key": key_s,
"dname": dname,
"name_loc": zh.get(int(iid)) or dname,
"created": bool(row.get("created")),
"cost": cost,
"tier": row.get("tier"),
"qual": row.get("qual"),
"components": comps,
}
key_to_id[key_s] = int(iid)
upgrades_of: dict[int, list[int]] = {iid: [] for iid in out}
for iid, meta in out.items():
for comp_key in meta["components"]:
cid = key_to_id.get(comp_key)
if cid is None or cid == iid:
continue
upgrades_of.setdefault(cid, []).append(iid)
return out, upgrades_of
def is_core_finished(meta: dict) -> bool:
"""Core finished items — skip consumables, neutrals, secret-shop parts, cheap early."""
key = meta.get("key") or ""
if meta.get("tier") is not None:
return False
if meta.get("qual") == "consumable":
return False
cost = int(meta.get("cost") or 0)
if cost <= 0:
return False
if key in ALWAYS_CORE:
return True
# Only recipe-assembled items (phase boots, bfury, bkb, …).
if not meta.get("created"):
return False
return cost >= MIN_CREATED_COST
def merge_phase_counts(raw: dict) -> dict[int, int]:
"""Max count per item id across OpenDota phases."""
merged: dict[int, int] = {}
for api_key in PHASE_API:
counts = raw.get(api_key) or {}
if not isinstance(counts, dict):
continue
for sid, cnt in counts.items():
try:
iid = int(sid)
c = int(cnt)
except (TypeError, ValueError):
continue
if c <= 0:
continue
prev = merged.get(iid, 0)
if c > prev:
merged[iid] = c
return merged
def fold_blessing_into_scepter(counts: dict[int, int]) -> dict[int, int]:
"""Merge Aghanim's Blessing (id 271) counts into Aghanim's Scepter (id 108).
Blessing is the activated/synthesized form of Scepter; OpenDota records
them under separate ids. Take max (not sum): the same game can register
both the purchase and the activation, which would double-count.
"""
out = dict(counts)
for bid, sid in BLESSING_TO_SCEPTER.items():
b = out.pop(bid, 0)
s = out.get(sid, 0)
if b or s:
out[sid] = max(s, b)
return out
def is_terminal_for_hero(
iid: int,
count: int,
all_counts: dict[int, int],
upgrades_of: dict[int, list[int]],
ratio: float = UPGRADE_RATIO,
) -> bool:
"""True if hero rarely upgrades this item further (keep yasha for Jugg, drop for AM)."""
parents = upgrades_of.get(iid) or []
if not parents:
return True
threshold = max(1, int(count * ratio))
for pid in parents:
if all_counts.get(pid, 0) >= threshold:
return False
return True
def core_from_popularity(
raw: dict,
catalog: dict[int, dict],
upgrades_of: dict[int, list[int]],
n: int = TOP_N,
) -> list[dict]:
"""Merge phases → core items → drop intermediates that this hero upgrades."""
all_counts = fold_blessing_into_scepter(merge_phase_counts(raw))
merged: dict[int, int] = {}
for iid, c in all_counts.items():
meta = catalog.get(iid)
if meta is None or not is_core_finished(meta):
continue
if not is_terminal_for_hero(iid, c, all_counts, upgrades_of):
continue
merged[iid] = c
ranked = sorted(merged.items(), key=lambda t: (-t[1], t[0]))
return [{"id": iid, "count": c} for iid, c in ranked[:n]]
def fetch_popularity(hero_id: int) -> dict:
return http_json(f"{OPENDOTA}/heroes/{hero_id}/itemPopularity") # type: ignore[return-value]
def build_payload(by_hero: dict, catalog: dict[int, dict], used_ids: set[int]) -> dict:
items_out = {
str(iid): {
"key": catalog[iid]["key"],
"dname": catalog[iid]["dname"],
"name_loc": catalog[iid]["name_loc"],
}
for iid in sorted(used_ids)
if iid in catalog
}
return {
"meta": {
"source": "opendota+valve",
"attribution": "https://www.opendota.com ; https://www.dota2.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"top_n": TOP_N,
"mode": "core_finished_terminal",
"upgrade_ratio": UPGRADE_RATIO,
"icons": "steam_cdn",
},
"items": items_out,
"by_hero": by_hero,
}
def collect_used_ids(by_hero: dict) -> set[int]:
used: set[int] = set()
for cell in by_hero.values():
if not isinstance(cell, list):
continue
for row in cell:
if isinstance(row, dict) and "id" in row:
used.add(int(row["id"]))
return used
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.3)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
ap.add_argument(
"--force",
action="store_true",
help="Refetch all heroes (ignore existing by_hero cache)",
)
args = ap.parse_args()
print("fetching item catalog (dotaconstants + Valve schinese)...", flush=True)
catalog, upgrades_of = load_item_catalog()
print(f" {len(catalog)} items", flush=True)
heroes = hero_table()
by_key = {h["key"]: int(h["id"]) for h in heroes}
by_hero: dict[str, list] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, cell in (prev.get("by_hero") or {}).items():
if isinstance(cell, list):
by_hero[str(k)] = cell
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
elif args.force:
print("force: refetching all heroes", flush=True)
pending = [k for k in sorted(by_key) if k not in by_hero]
print(f"fetching {len(pending)} / {len(by_key)} heroes -> {args.out}", flush=True)
used_ids = collect_used_ids(by_hero)
for n, key in enumerate(pending, start=1):
hid = by_key[key]
try:
raw = fetch_popularity(hid)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, TypeError) as e:
print(f" [{n}/{len(pending)}] {key} ({hid}) failed: {e}", flush=True)
time.sleep(max(args.delay, 0.1) * 2)
continue
core = core_from_popularity(raw, catalog, upgrades_of)
by_hero[key] = core
used_ids.update(int(row["id"]) for row in core if isinstance(row, dict) and "id" in row)
print(f" [{n}/{len(pending)}] {key}: {len(core)} core items", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(build_payload(by_hero, catalog, used_ids), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
time.sleep(args.delay)
used_ids = collect_used_ids(by_hero)
payload = build_payload(by_hero, catalog, used_ids)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if not args.skip_icons:
keys = {catalog[iid]["key"] for iid in used_ids if iid in catalog}
print(f"downloading {len(keys)} item icons from Steam CDN -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(keys, ICON_URL, ITEM_ICONS, force=args.force_icons)
print(f" icons saved={saved} skipped={skipped} fail={fail}", flush=True)
missing = [k for k in by_key if k not in by_hero]
print(
f"done: {len(by_hero)} heroes, {len(payload['items'])} items"
+ (f", {len(missing)} still missing" if missing else ""),
flush=True,
)
if __name__ == "__main__":
main()