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>
This commit is contained in:
voson
2026-07-29 14:29:08 +08:00
co-authored by Cursor
parent 96a9312194
commit 9c5aa5b610
280 changed files with 1451 additions and 450 deletions
+504
View File
@@ -0,0 +1,504 @@
"""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()