v0.5.59: item counter evidence for fears, catch up Web features to site version.
Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user