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>
505 lines
16 KiB
Python
505 lines
16 KiB
Python
"""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()
|