"""Fetch STRATZ hero meta (weekly WR/pick by bracket + position) and matchup tops. Requires STRATZ_API_TOKEN (or KEYZOO_ASSET_API_KEY) in the environment / .env. Output is for the Climperor web site only — never merge into relations.json or recommend. Data sources (heroStats GraphQL): - winWeek(take=N, bracketIds): per-medal weekly pick/win → `weeks` / `latest` - winWeek(take=1, bracketIds, positionIds): per-medal per-position latest week → `positions` (same time window as headline cards; exact medal, not basic merge) - matchUp: counter / countered / synergy tops → stratz_matchup_tops.json Usage: python fetch_stratz_meta.py python fetch_stratz_meta.py --weeks 8 --delay 0.25 python fetch_stratz_meta.py --skip-matchups """ from __future__ import annotations import argparse import json import os import time import urllib.error import urllib.request from datetime import datetime, timezone from pathlib import Path from common import DATA, ROOT from grid import hero_table API = "https://api.stratz.com/graphql" OUT_META = DATA / "stratz_hero_meta.json" OUT_MATCHUPS = DATA / "stratz_matchup_tops.json" # UI medal keys ↔ STRATZ RankBracket enum BRACKET_ORDER = ( "herald", "guardian", "crusader", "archon", "legend", "ancient", "divine", "immortal", ) BRACKET_ENUM = { "herald": "HERALD", "guardian": "GUARDIAN", "crusader": "CRUSADER", "archon": "ARCHON", "legend": "LEGEND", "ancient": "ANCIENT", "divine": "DIVINE", "immortal": "IMMORTAL", } # Medal → RankBracketBasicEnum (legacy; position stats now use exact medal via winWeek) BRACKET_TO_BASIC = { "herald": "HERALD_GUARDIAN", "guardian": "HERALD_GUARDIAN", "crusader": "CRUSADER_ARCHON", "archon": "CRUSADER_ARCHON", "legend": "LEGEND_ANCIENT", "ancient": "LEGEND_ANCIENT", "divine": "DIVINE_IMMORTAL", "immortal": "DIVINE_IMMORTAL", } POSITION_ORDER = ( "POSITION_1", "POSITION_2", "POSITION_3", "POSITION_4", "POSITION_5", ) ATTRIBUTION = "https://stratz.com" def load_token() -> str: for key in ( "STRATZ_API_TOKEN", "KEYZOO_ASSET_API_KEY", "KEYZOO_ASSET_SECRET_API_KEY", "KEYZOO_ASSET_TOKEN", ): env = os.environ.get(key, "").strip() if env: return env path = ROOT / ".env" if path.is_file(): for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) if k.strip() == "STRATZ_API_TOKEN": return v.strip().strip('"').strip("'") raise SystemExit( "STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env " "(see .env.example)." ) def gql(token: str, query: str, variables: dict | None = None) -> dict: body: dict = {"query": query} if variables: body["variables"] = variables req = urllib.request.Request( API, data=json.dumps(body).encode(), headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "climperor", }, method="POST", ) with urllib.request.urlopen(req, timeout=120) as resp: payload = json.loads(resp.read().decode()) if payload.get("errors"): raise RuntimeError(str(payload["errors"][:2])) return payload["data"] def _pw(pick: int, win: int) -> dict: pick = max(0, int(pick or 0)) win = max(0, int(win or 0)) wr = (win / pick) if pick > 0 else None return {"pick": pick, "win": win, "wr": wr} def fetch_weeks_for_bracket( token: str, hero_ids: list[int], bracket: str, take: int ) -> list[dict]: enum = BRACKET_ENUM[bracket] # GraphQL list variable as inline enums (Short! list for heroIds). ids_lit = ", ".join(str(i) for i in hero_ids) query = f""" query {{ heroStats {{ winWeek(heroIds: [{ids_lit}], take: {int(take)}, bracketIds: [{enum}]) {{ heroId week matchCount winCount }} }} }} """ data = gql(token, query) return (((data or {}).get("heroStats") or {}).get("winWeek")) or [] def fetch_latest_positions_for_bracket( token: str, hero_ids: list[int], bracket: str ) -> list[dict]: """Latest-week winWeek per position; same bracket + week as headline cards.""" enum = BRACKET_ENUM[bracket] ids_lit = ", ".join(str(i) for i in hero_ids) out: list[dict] = [] for pos in POSITION_ORDER: query = f""" query {{ heroStats {{ winWeek( heroIds: [{ids_lit}] take: 1 bracketIds: [{enum}] positionIds: [{pos}] ) {{ heroId week matchCount winCount }} }} }} """ data = gql(token, query) for row in (((data or {}).get("heroStats") or {}).get("winWeek")) or []: out.append({**row, "position": pos, "bracket": bracket}) return out MATCHUP_QUERY = """ query($id: Short!, $take: Int!, $limit: Int!) { heroStats { matchUp(heroId: $id, take: $take, matchLimit: $limit) { heroId matchCountVs matchCountWith vs { heroId2 matchCount winCount synergy winsAverage } with { heroId2 matchCount winCount synergy winsAverage } } } } """ def fetch_matchup_tops( token: str, hero_id: int, *, take: int, match_limit: int ) -> dict: data = gql( token, MATCHUP_QUERY, {"id": hero_id, "take": take, "limit": match_limit}, ) rows = (((data or {}).get("heroStats") or {}).get("matchUp")) or [] row = rows[0] if rows else {} vs_out = [] with_out = [] for pair in row.get("vs") or []: other = pair.get("heroId2") games = int(pair.get("matchCount") or 0) if other is None or games <= 0: continue wins = int(pair.get("winCount") or 0) vs_out.append( { "hero_id": int(other), "games": games, "wins": wins, "advantage": float(pair.get("synergy") or 0.0), "wr": float(pair.get("winsAverage") or (wins / games)), } ) for pair in row.get("with") or []: other = pair.get("heroId2") games = int(pair.get("matchCount") or 0) if other is None or games <= 0: continue wins = int(pair.get("winCount") or 0) with_out.append( { "hero_id": int(other), "games": games, "wins": wins, "synergy": float(pair.get("synergy") or 0.0), "wr": float(pair.get("winsAverage") or (wins / games)), } ) # vs advantage: positive = hero wins more vs other → counters other. # Also derive "disadvantage" as others with most negative advantage for hero. disadvantage = [ { "hero_id": e["hero_id"], "games": e["games"], "wins": e["wins"], "advantage": -float(e["advantage"]), "wr": 1.0 - float(e["wr"]) if e["wr"] is not None else None, } for e in sorted(vs_out, key=lambda x: x["advantage"])[:take] ] counters = sorted(vs_out, key=lambda x: -x["advantage"])[:take] synergies = sorted(with_out, key=lambda x: -x["synergy"])[:take] return { "counters": counters, "countered": disadvantage, "synergies": synergies, } def build_meta( weeks_rows_by_bracket: dict[str, list[dict]], position_rows_by_bracket: dict[str, list[dict]], id_to_key: dict[int, str], weeks_take: int, ) -> dict: by_hero: dict[str, dict] = {} for hid, key in id_to_key.items(): by_hero[key] = { "id": hid, "weeks": {b: [] for b in BRACKET_ORDER}, "latest": {}, "positions": {b: {} for b in BRACKET_ORDER}, } for bracket, rows in weeks_rows_by_bracket.items(): # Group by hero, sort weeks desc, keep take by_id: dict[int, list[dict]] = {} for row in rows: hid = int(row.get("heroId") or 0) if hid not in id_to_key: continue by_id.setdefault(hid, []).append(row) for hid, rows_h in by_id.items(): key = id_to_key[hid] rows_h.sort(key=lambda r: int(r.get("week") or 0), reverse=True) weeks = [] for r in rows_h[:weeks_take]: pick = int(r.get("matchCount") or 0) win = int(r.get("winCount") or 0) weeks.append( { "week": int(r.get("week") or 0), "pick": pick, "win": win, "wr": (win / pick) if pick > 0 else None, } ) by_hero[key]["weeks"][bracket] = weeks if weeks: latest = weeks[0] by_hero[key]["latest"][bracket] = _pw(latest["pick"], latest["win"]) for bracket, pos_rows in position_rows_by_bracket.items(): for row in pos_rows: hid = int(row.get("heroId") or 0) key = id_to_key.get(hid) if not key: continue pos = str(row.get("position") or "") if pos not in POSITION_ORDER: continue cell = _pw(int(row.get("matchCount") or 0), int(row.get("winCount") or 0)) by_hero[key]["positions"][bracket][pos] = cell # Totals (latest week pick sum per bracket) for pick-rate denominator totals: dict[str, dict] = {} for bracket in BRACKET_ORDER: total_pick = 0 for cell in by_hero.values(): latest = (cell.get("latest") or {}).get(bracket) or {} total_pick += int(latest.get("pick") or 0) totals[bracket] = {"pick": total_pick} # Meta board: top heroes by pick (latest week) per bracket meta_board: dict[str, list[dict]] = {} for bracket in BRACKET_ORDER: denom = max(1, int((totals.get(bracket) or {}).get("pick") or 0)) rows_board = [] for key, cell in by_hero.items(): latest = (cell.get("latest") or {}).get(bracket) if not latest or int(latest.get("pick") or 0) <= 0: continue pick = int(latest["pick"]) win = int(latest["win"]) wr = win / pick if pick else None # Same convention as OpenDota web: pick / (Σpick / 10) pr = pick / (denom / 10.0) if denom else None rows_board.append( { "key": key, "id": cell["id"], "pick": pick, "win": win, "wr": wr, "pr": pr, } ) rows_board.sort(key=lambda r: (-r["pick"], -(r["wr"] or 0), r["key"])) meta_board[bracket] = rows_board return { "fetched_at": datetime.now(timezone.utc).isoformat(), "source": "stratz", "attribution": ATTRIBUTION, "weeks_take": weeks_take, "window_label_zh": f"近 {weeks_take} 周天梯(按段位)", "latest_window_label_zh": "最近 1 周", "brackets": list(BRACKET_ORDER), "positions": list(POSITION_ORDER), "bracket_position_note": None, "totals": totals, "by_hero": by_hero, "meta_board": meta_board, } def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--weeks", type=int, default=8, help="weekly buckets to keep") ap.add_argument("--delay", type=float, default=0.25) ap.add_argument("--out-meta", type=Path, default=OUT_META) ap.add_argument("--out-matchups", type=Path, default=OUT_MATCHUPS) ap.add_argument("--skip-matchups", action="store_true") ap.add_argument("--matchup-take", type=int, default=12) ap.add_argument("--matchup-min-games", type=int, default=50) args = ap.parse_args() token = load_token() heroes = hero_table() id_to_key = {int(h["id"]): h["key"] for h in heroes} hero_ids = sorted(id_to_key.keys()) print( f"fetching STRATZ meta for {len(hero_ids)} heroes, " f"{args.weeks} weeks × {len(BRACKET_ORDER)} brackets", flush=True, ) weeks_by_bracket: dict[str, list[dict]] = {} position_rows_by_bracket: dict[str, list[dict]] = {} for i, bracket in enumerate(BRACKET_ORDER, start=1): try: rows = fetch_weeks_for_bracket(token, hero_ids, bracket, args.weeks) except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e: print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} failed: {e}", flush=True) rows = [] weeks_by_bracket[bracket] = rows try: pos_rows = fetch_latest_positions_for_bracket(token, hero_ids, bracket) except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e: print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} positions failed: {e}", flush=True) pos_rows = [] position_rows_by_bracket[bracket] = pos_rows print( f" [{i}/{len(BRACKET_ORDER)}] {bracket}: {len(rows)} week rows, " f"{len(pos_rows)} position rows", flush=True, ) time.sleep(args.delay) meta = build_meta( weeks_by_bracket, position_rows_by_bracket, id_to_key, args.weeks ) args.out_meta.parent.mkdir(parents=True, exist_ok=True) args.out_meta.write_text( json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(f"wrote {args.out_meta}", flush=True) if args.skip_matchups: print("skip matchup tops", flush=True) return by_hero_mu: dict[str, dict] = {} if args.out_matchups.is_file(): try: prev = json.loads(args.out_matchups.read_text(encoding="utf-8")) by_hero_mu = dict(prev.get("by_hero") or {}) print(f"resuming matchups with {len(by_hero_mu)} heroes", flush=True) except (OSError, json.JSONDecodeError): pass pending = [hid for hid in hero_ids if id_to_key[hid] not in by_hero_mu] print( f"fetching matchup tops {len(pending)}/{len(hero_ids)} " f"(take={args.matchup_take}, min_games={args.matchup_min_games})", flush=True, ) for n, hid in enumerate(pending, start=1): key = id_to_key[hid] try: cell = fetch_matchup_tops( token, hid, take=args.matchup_take, match_limit=args.matchup_min_games, ) except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e: print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True) time.sleep(args.delay * 2) continue by_hero_mu[key] = cell print( f" [{n}/{len(pending)}] {key}: " f"vs={len(cell['counters'])} fear={len(cell['countered'])} " f"with={len(cell['synergies'])}", flush=True, ) payload = { "fetched_at": datetime.now(timezone.utc).isoformat(), "source": "stratz", "attribution": ATTRIBUTION, "take": args.matchup_take, "match_limit": args.matchup_min_games, "note": ( "counters = positive vs advantage; countered = heroes this hero " "loses to (negated advantage); synergies = with synergy. " "Web-only; do not merge into relations.json." ), "by_hero": by_hero_mu, } args.out_matchups.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) time.sleep(args.delay) print(f"done: matchups {len(by_hero_mu)} heroes → {args.out_matchups}", flush=True) if __name__ == "__main__": main()