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

505 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()