Files
climperor/web/fetch_pro_builds.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

550 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 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 collections import Counter, defaultdict
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.paths 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,
)
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()