Files
climperor/fetch_pro_builds.py
T
vosonandCursor 3ec8007077 v0.5.59: item counter evidence for fears, catch up Web features to site version.
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>
2026-07-29 02:11:49 +08:00

545 lines
16 KiB
Python

"""Aggregate pro-player item + skill builds per hero (Climperor web only).
Uses OpenDota registered pros (/proPlayers) and league match rows from
data/hero_matches.json (run fetch_hero_matches.py --source league first).
Optionally backfill more league samples with --fetch.
Output: data/pro_builds.json — not used by recommend.
Usage:
python fetch_hero_matches.py --source league --limit 12
python fetch_pro_builds.py
python fetch_pro_builds.py --fetch --heroes juggernaut --limit 20
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.error
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from common import DATA
from fetch_hero_items import (
BLESSING_TO_SCEPTER,
TOP_N,
is_core_finished,
load_item_catalog,
)
from fetch_hero_matches import (
extract_player_row,
fetch_match,
league_match_ids,
load_ability_id_map,
)
from grid import hero_table
from http_utils import http_json
OPENDOTA = "https://api.opendota.com/api"
OUT = DATA / "pro_builds.json"
MATCHES_IN = DATA / "hero_matches.json"
PRO_MATCHES_IN = DATA / "pro_matches.json"
TOP_SKILL_ORDERS = 3
def fetch_pro_index() -> dict[int, dict]:
"""account_id → slim pro profile from OpenDota."""
try:
raw = http_json(f"{OPENDOTA}/proPlayers")
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
json.JSONDecodeError,
OSError,
) as e:
raise SystemExit(f"proPlayers fetch failed: {e}") from e
if not isinstance(raw, list):
raise SystemExit("proPlayers: unexpected payload")
out: dict[int, dict] = {}
for row in raw:
if not isinstance(row, dict):
continue
try:
aid = int(row.get("account_id") or 0)
except (TypeError, ValueError):
continue
if aid <= 0:
continue
name = row.get("name")
if isinstance(name, str):
name = name.strip() or None
else:
name = None
team_tag = row.get("team_tag")
if isinstance(team_tag, str):
team_tag = team_tag.strip() or None
else:
team_tag = None
team_name = row.get("team_name")
if isinstance(team_name, str):
team_name = team_name.strip() or None
else:
team_name = None
country = row.get("country_code") or row.get("loccountrycode")
if isinstance(country, str):
country = country.strip().lower() or None
else:
country = None
last_match = row.get("last_match_time")
if isinstance(last_match, str):
last_match = last_match.strip() or None
else:
last_match = None
out[aid] = {
"account_id": aid,
"name": name,
"team_tag": team_tag,
"team_name": team_name,
"country_code": country,
"last_match_time": last_match,
}
return out
def normalize_item_id(iid: int) -> int:
return BLESSING_TO_SCEPTER.get(iid, iid)
def match_item_ids(row: dict) -> list[int]:
ids: list[int] = []
for key in ("items", "backpack"):
part = row.get(key)
if not isinstance(part, list):
continue
for raw in part:
try:
iid = int(raw or 0)
except (TypeError, ValueError):
continue
if iid > 0:
ids.append(normalize_item_id(iid))
neut = row.get("item_neutral")
try:
ni = int(neut or 0)
except (TypeError, ValueError):
ni = 0
if ni > 0:
ids.append(ni)
return ids
def is_pro_match(row: dict, pro_ids: set[int]) -> bool:
origin = str(row.get("origin") or "")
if origin not in ("league", "pro"):
return False
aid = row.get("account_id")
try:
aid_i = int(aid) if aid is not None else 0
except (TypeError, ValueError):
aid_i = 0
if aid_i in pro_ids:
return True
# Registered pro name on league row (OpenDota match detail).
name = row.get("name")
return isinstance(name, str) and bool(name.strip())
def aggregate_items(
rows: list[dict], catalog: dict[int, dict], n: int = TOP_N
) -> list[dict]:
counts: Counter[int] = Counter()
for row in rows:
seen: set[int] = set()
for iid in match_item_ids(row):
if iid in seen:
continue
meta = catalog.get(iid)
if meta is None or not is_core_finished(meta):
continue
seen.add(iid)
counts[iid] += 1
total = len(rows)
ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0]))
out: list[dict] = []
for iid, c in ranked[:n]:
cell: dict = {"id": iid, "count": c}
if total > 0:
cell["pct"] = round(c / total * 1000) / 10
out.append(cell)
return out
def aggregate_skill_orders(
rows: list[dict], *, top: int = TOP_SKILL_ORDERS
) -> list[dict]:
counts: Counter[tuple[str, ...]] = Counter()
for row in rows:
ups = row.get("ability_upgrades")
if not isinstance(ups, list) or not ups:
continue
seq = tuple(str(k) for k in ups if isinstance(k, str) and k.strip())
if not seq:
continue
counts[seq] += 1
total = sum(counts.values())
ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0]))
out: list[dict] = []
for seq, c in ranked[:top]:
cell: dict = {"sequence": list(seq), "count": c}
if total > 0:
cell["pct"] = round(c / total * 1000) / 10
out.append(cell)
return out
def aggregate_pros(
rows: list[dict], pro_index: dict[int, dict]
) -> list[dict]:
by_aid: dict[int, dict] = {}
for row in rows:
aid = row.get("account_id")
try:
aid_i = int(aid) if aid is not None else 0
except (TypeError, ValueError):
aid_i = 0
prof = pro_index.get(aid_i) if aid_i else None
name = (prof or {}).get("name") or row.get("name") or row.get("display_name")
if isinstance(name, str):
name = name.strip() or None
else:
name = None
if not name and not aid_i:
continue
key = aid_i if aid_i else hash(name or "")
cell = by_aid.setdefault(
key,
{
"account_id": aid_i or None,
"name": name,
"team_tag": (prof or {}).get("team_tag"),
"games": 0,
"wins": 0,
"last_match_id": None,
"last_start_time": None,
},
)
cell["games"] += 1
if row.get("won"):
cell["wins"] += 1
mid = row.get("match_id")
st = row.get("start_time")
try:
st_i = int(st) if st is not None else 0
except (TypeError, ValueError):
st_i = 0
prev = cell.get("last_start_time") or 0
if st_i >= prev:
cell["last_start_time"] = st_i or None
cell["last_match_id"] = mid
ranked = sorted(
by_aid.values(),
key=lambda r: (
-(r.get("games") or 0),
-(r.get("last_start_time") or 0),
str(r.get("name") or ""),
),
)
for r in ranked:
r.pop("last_start_time", None)
return ranked[:12]
def load_pro_league_rows(path: Path) -> dict[str, list[dict]]:
"""League/pro rows from pro_matches.json by_hero or by_pro."""
if not path.is_file():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, list[dict]] = {}
by_hero = raw.get("by_hero")
if isinstance(by_hero, dict):
for key, cell in by_hero.items():
if not isinstance(cell, dict):
continue
matches = cell.get("matches")
if isinstance(matches, list) and matches:
out[str(key)] = [m for m in matches if isinstance(m, dict)]
by_pro = raw.get("by_pro")
if isinstance(by_pro, dict):
for cell in by_pro.values():
if not isinstance(cell, dict):
continue
for row in cell.get("matches") or []:
if not isinstance(row, dict):
continue
key = row.get("hero_key")
if not key:
continue
out.setdefault(str(key), []).append(row)
deduped: dict[str, list[dict]] = {}
for key, rows in out.items():
seen: set[int] = set()
merged: list[dict] = []
for row in rows:
try:
mid = int(row.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid in seen:
continue
seen.add(mid)
merged.append(row)
if merged:
deduped[key] = merged
return deduped
def load_league_rows(path: Path) -> dict[str, list[dict]]:
if not path.is_file():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
by_hero = raw.get("by_hero")
if not isinstance(by_hero, dict):
return {}
out: dict[str, list[dict]] = {}
for key, cell in by_hero.items():
if not isinstance(cell, dict):
continue
matches = cell.get("matches")
if not isinstance(matches, list):
continue
league = [m for m in matches if isinstance(m, dict) and m.get("origin") == "league"]
if league:
out[str(key)] = league
return out
def fetch_league_rows_for_hero(
hero_id: int,
*,
limit: int,
id_map: dict[int, str],
catalog: dict[int, dict],
delay: float,
) -> list[dict]:
metas = league_match_ids(hero_id, limit)
rows: list[dict] = []
for meta in metas:
try:
mid = int(meta.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid <= 0:
continue
detail = fetch_match(mid)
if delay > 0:
time.sleep(delay)
if not detail:
continue
slim = extract_player_row(
detail,
hero_id,
origin="league",
id_map=id_map,
item_catalog=catalog,
list_meta=meta,
)
if slim:
rows.append(slim)
return rows
def build_payload(
*,
by_hero_rows: dict[str, list[dict]],
pro_index: dict[int, dict],
catalog: dict[int, dict],
match_source: str,
) -> dict:
pro_ids = set(pro_index)
by_hero: dict[str, dict] = {}
used_item_ids: set[int] = set()
total_matches = 0
for key, all_rows in sorted(by_hero_rows.items()):
pro_rows = [r for r in all_rows if is_pro_match(r, pro_ids)]
if not pro_rows:
continue
total_matches += len(pro_rows)
items = aggregate_items(pro_rows, catalog)
for ent in items:
used_item_ids.add(int(ent["id"]))
by_hero[key] = {
"sample_count": len(pro_rows),
"items": items,
"skill_orders": aggregate_skill_orders(pro_rows),
"pros": aggregate_pros(pro_rows, pro_index),
}
items_out = {
str(iid): {
"key": catalog[iid]["key"],
"dname": catalog[iid]["dname"],
"name_loc": catalog[iid].get("name_loc") or catalog[iid]["dname"],
}
for iid in sorted(used_item_ids)
if iid in catalog
}
pros_out = {
str(aid): prof
for aid, prof in sorted(pro_index.items(), key=lambda t: t[0])
}
return {
"meta": {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "opendota",
"attribution": "https://www.opendota.com",
"match_source": match_source,
"pro_count": len(pro_index),
"hero_count": len(by_hero),
"match_count": total_matches,
"note_zh": (
"职业选手样本:OpenDota 注册选手 + 联赛对局终局出装/加点;"
"出装频率 = 该装备出现在样本终局栏位的比例;"
"加点为完整升级顺序的最常见方案。"
),
},
"pros": pros_out,
"items": items_out,
"by_hero": by_hero,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument(
"--matches",
type=Path,
default=MATCHES_IN,
help="Input hero_matches.json league rows (default: data/hero_matches.json)",
)
ap.add_argument(
"--pro-matches",
type=Path,
default=PRO_MATCHES_IN,
help="Merge pro player rows from this file (default: data/pro_matches.json)",
)
ap.add_argument(
"--fetch",
action="store_true",
help="Fetch league matches from OpenDota instead of only reading --matches",
)
ap.add_argument(
"--limit",
type=int,
default=12,
help="League matches per hero when --fetch (default: 12)",
)
ap.add_argument(
"--heroes",
default="",
help="Comma-separated hero keys (default: all when --fetch; else keys in matches file)",
)
ap.add_argument("--delay", type=float, default=0.35)
args = ap.parse_args()
print("loading pro players ...", flush=True)
pro_index = fetch_pro_index()
print(f" {len(pro_index)} registered pros", flush=True)
catalog, _ = load_item_catalog()
id_map = load_ability_id_map()
heroes = hero_table()
by_key = {h["key"]: h for h in heroes}
hero_filter = [k.strip() for k in args.heroes.split(",") if k.strip()]
by_hero_rows: dict[str, list[dict]] = {}
if args.fetch:
keys = hero_filter or sorted(by_key)
limit = max(1, int(args.limit))
for i, key in enumerate(keys, 1):
hero = by_key.get(key)
if not hero:
print(f" skip unknown hero {key}", flush=True)
continue
hid = int(hero["id"])
print(f"[{i}/{len(keys)}] fetch league {key} ...", flush=True)
rows = fetch_league_rows_for_hero(
hid,
limit=limit,
id_map=id_map,
catalog=catalog,
delay=args.delay,
)
if rows:
by_hero_rows[key] = rows
else:
by_hero_rows = load_league_rows(args.matches)
pro_path = args.pro_matches
if pro_path and pro_path.is_file():
pro_rows = load_pro_league_rows(pro_path)
for key, rows in pro_rows.items():
bucket = by_hero_rows.setdefault(key, [])
seen = {int(r.get("match_id") or 0) for r in bucket if isinstance(r, dict)}
for row in rows:
try:
mid = int(row.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid in seen:
continue
seen.add(mid)
bucket.append(row)
if hero_filter:
by_hero_rows = {k: v for k, v in by_hero_rows.items() if k in hero_filter}
if not by_hero_rows:
raise SystemExit(
f"No league/pro rows in {args.matches} or {args.pro_matches}. "
"Run: python fetch_pro_matches.py or fetch_hero_matches.py --source league"
)
sources = []
if args.fetch:
sources.append("fetch")
else:
sources.append(str(args.matches.name))
if args.pro_matches and args.pro_matches.is_file():
sources.append(str(args.pro_matches.name))
match_source = "+".join(sources)
payload = build_payload(
by_hero_rows=by_hero_rows,
pro_index=pro_index,
catalog=catalog,
match_source=match_source,
)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
meta = payload["meta"]
print(
f"done heroes={meta['hero_count']} matches={meta['match_count']}{args.out}",
flush=True,
)
if __name__ == "__main__":
main()