"""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 sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import argparse import json import os import time import urllib.error import urllib.request from datetime import datetime, timezone from shared.grid import hero_table from shared.paths import DATA, ROOT 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()