v0.5.71: matches tab, streamer viewport video load, matchup cross-check.
Add pro watchlist matches page; load streamer clips by viewport tier with posters; harden STRATZ matchup refresh and OpenDota cross hints. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+336
-103
@@ -9,11 +9,14 @@ Data sources (heroStats GraphQL):
|
||||
- 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
|
||||
(global aggregate — no bracket / position / week filter)
|
||||
|
||||
Usage:
|
||||
python fetch_stratz_meta.py
|
||||
python fetch_stratz_meta.py --weeks 8 --delay 0.25
|
||||
python fetch_stratz_meta.py --skip-matchups
|
||||
python web/fetch_stratz_meta.py
|
||||
python web/fetch_stratz_meta.py --weeks 8 --delay 0.25
|
||||
python web/fetch_stratz_meta.py --skip-matchups
|
||||
python web/fetch_stratz_meta.py --matchups-only
|
||||
python web/fetch_stratz_meta.py --resume-matchups # interrupt resume only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,6 +29,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -82,6 +86,27 @@ POSITION_ORDER = (
|
||||
|
||||
ATTRIBUTION = "https://stratz.com"
|
||||
|
||||
MATCHUP_SCOPE = {
|
||||
"kind": "global_aggregate",
|
||||
"bracket": None,
|
||||
"position": None,
|
||||
"week": None,
|
||||
"label_zh": "全局聚合(未按段位 / 分路 / 周过滤)",
|
||||
"note": (
|
||||
"STRATZ heroStats.matchUp without bracketIds/positionIds/week. "
|
||||
"advantage is upstream synergy (relative), not raw win-rate pp. "
|
||||
"Web-only; do not merge into relations.json."
|
||||
),
|
||||
}
|
||||
|
||||
MATCHUP_NOTE = (
|
||||
"counters = positive vs advantage (STRATZ synergy); "
|
||||
"countered = heroes this hero loses to (negated advantage, mirrored wr); "
|
||||
"synergies = with synergy. "
|
||||
"advantage ≠ win-rate percentage points. "
|
||||
"Web-only; do not merge into relations.json."
|
||||
)
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
for key in (
|
||||
@@ -136,6 +161,32 @@ def _pw(pick: int, win: int) -> dict:
|
||||
return {"pick": pick, "win": win, "wr": wr}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, payload: dict) -> None:
|
||||
"""Write JSON via temp file then replace, so partial writes never corrupt cache."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def fetch_weeks_for_bracket(
|
||||
token: str, hero_ids: list[int], bracket: str, take: int
|
||||
) -> list[dict]:
|
||||
@@ -216,18 +267,10 @@ query($id: Short!, $take: Int!, $limit: Int!) {
|
||||
"""
|
||||
|
||||
|
||||
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 = []
|
||||
def parse_matchup_pairs(row: dict) -> tuple[list[dict], list[dict]]:
|
||||
"""Parse raw STRATZ matchUp row into vs / with pair lists."""
|
||||
vs_out: list[dict] = []
|
||||
with_out: list[dict] = []
|
||||
for pair in row.get("vs") or []:
|
||||
other = pair.get("heroId2")
|
||||
games = int(pair.get("matchCount") or 0)
|
||||
@@ -258,8 +301,21 @@ def fetch_matchup_tops(
|
||||
"wr": float(pair.get("winsAverage") or (wins / games)),
|
||||
}
|
||||
)
|
||||
return vs_out, with_out
|
||||
|
||||
|
||||
def rank_matchup_lists(
|
||||
vs_out: list[dict], with_out: list[dict], take: int
|
||||
) -> dict:
|
||||
"""Sort counters / countered / synergies from parsed vs / with pairs.
|
||||
|
||||
Positive ``advantage`` means this hero's STRATZ relative score vs the
|
||||
peer is favorable — not raw win-rate percentage points. A hero can appear
|
||||
under counters with wr < 0.5 when advantage is still positive vs baseline.
|
||||
"""
|
||||
take = max(0, int(take))
|
||||
# vs advantage: positive = hero wins more vs other → counters other.
|
||||
# Also derive "disadvantage" as others with most negative advantage for hero.
|
||||
# countered is the same vs list mirrored (negated advantage, 1-wr).
|
||||
disadvantage = [
|
||||
{
|
||||
"hero_id": e["hero_id"],
|
||||
@@ -279,6 +335,75 @@ def fetch_matchup_tops(
|
||||
}
|
||||
|
||||
|
||||
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 = parse_matchup_pairs(row)
|
||||
return rank_matchup_lists(vs_out, with_out, take)
|
||||
|
||||
|
||||
def annotate_matchup_cell(
|
||||
cell: dict, *, fetched_at: str, stale: bool = False
|
||||
) -> dict:
|
||||
"""Attach per-hero fetch metadata; preserve list payloads."""
|
||||
out = {
|
||||
"counters": list(cell.get("counters") or []),
|
||||
"countered": list(cell.get("countered") or []),
|
||||
"synergies": list(cell.get("synergies") or []),
|
||||
"fetched_at": fetched_at,
|
||||
"stale": bool(stale),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def build_matchup_file_payload(
|
||||
by_hero: dict[str, dict],
|
||||
*,
|
||||
take: int,
|
||||
match_limit: int,
|
||||
started_at: str,
|
||||
finished_at: str | None = None,
|
||||
stats: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"fetched_at": finished_at or started_at,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"source": "stratz",
|
||||
"attribution": ATTRIBUTION,
|
||||
"take": take,
|
||||
"match_limit": match_limit,
|
||||
"scope": dict(MATCHUP_SCOPE),
|
||||
"note": MATCHUP_NOTE,
|
||||
"stats": stats
|
||||
or {
|
||||
"heroes": len(by_hero),
|
||||
"ok": 0,
|
||||
"failed": 0,
|
||||
"stale_kept": 0,
|
||||
},
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
|
||||
def load_previous_matchups(path: Path) -> dict[str, dict]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
prev = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
by_hero = prev.get("by_hero") or {}
|
||||
return {k: v for k, v in by_hero.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def build_meta(
|
||||
weeks_rows_by_bracket: dict[str, list[dict]],
|
||||
position_rows_by_bracket: dict[str, list[dict]],
|
||||
@@ -371,7 +496,7 @@ def build_meta(
|
||||
meta_board[bracket] = rows_board
|
||||
|
||||
return {
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"fetched_at": _now_iso(),
|
||||
"source": "stratz",
|
||||
"attribution": ATTRIBUTION,
|
||||
"weeks_take": weeks_take,
|
||||
@@ -386,6 +511,139 @@ def build_meta(
|
||||
}
|
||||
|
||||
|
||||
def refresh_matchup_tops(
|
||||
token: str,
|
||||
id_to_key: dict[int, str],
|
||||
*,
|
||||
take: int,
|
||||
match_limit: int,
|
||||
delay: float,
|
||||
out_path: Path,
|
||||
resume: bool,
|
||||
) -> dict:
|
||||
"""Full-refresh (default) or resume-only matchup tops.
|
||||
|
||||
On per-hero failure, keep the previous cell and mark ``stale=True``.
|
||||
"""
|
||||
started_at = _now_iso()
|
||||
prev = load_previous_matchups(out_path)
|
||||
hero_ids = sorted(id_to_key.keys())
|
||||
|
||||
if resume:
|
||||
pending = [hid for hid in hero_ids if id_to_key[hid] not in prev]
|
||||
by_hero_mu: dict[str, dict] = dict(prev)
|
||||
print(
|
||||
f"resuming matchups: {len(pending)} pending / {len(hero_ids)} "
|
||||
f"(cached={len(prev)})",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
pending = list(hero_ids)
|
||||
by_hero_mu = {}
|
||||
print(
|
||||
f"full matchup refresh: {len(pending)} heroes "
|
||||
f"(prev cached={len(prev)} as failure fallback)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
ok = failed = stale_kept = 0
|
||||
for n, hid in enumerate(pending, start=1):
|
||||
key = id_to_key[hid]
|
||||
try:
|
||||
cell = fetch_matchup_tops(
|
||||
token,
|
||||
hid,
|
||||
take=take,
|
||||
match_limit=match_limit,
|
||||
)
|
||||
by_hero_mu[key] = annotate_matchup_cell(
|
||||
cell, fetched_at=_now_iso(), stale=False
|
||||
)
|
||||
ok += 1
|
||||
print(
|
||||
f" [{n}/{len(pending)}] {key}: "
|
||||
f"vs={len(cell['counters'])} fear={len(cell['countered'])} "
|
||||
f"with={len(cell['synergies'])}",
|
||||
flush=True,
|
||||
)
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
TimeoutError,
|
||||
RuntimeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
failed += 1
|
||||
old = prev.get(key)
|
||||
if old:
|
||||
kept = annotate_matchup_cell(
|
||||
old,
|
||||
fetched_at=str(old.get("fetched_at") or started_at),
|
||||
stale=True,
|
||||
)
|
||||
by_hero_mu[key] = kept
|
||||
stale_kept += 1
|
||||
print(
|
||||
f" [{n}/{len(pending)}] {key} failed (kept stale): {e}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True)
|
||||
time.sleep(delay * 2)
|
||||
continue
|
||||
|
||||
finished_partial = _now_iso()
|
||||
payload = build_matchup_file_payload(
|
||||
by_hero_mu,
|
||||
take=take,
|
||||
match_limit=match_limit,
|
||||
started_at=started_at,
|
||||
finished_at=finished_partial,
|
||||
stats={
|
||||
"heroes": len(by_hero_mu),
|
||||
"ok": ok,
|
||||
"failed": failed,
|
||||
"stale_kept": stale_kept,
|
||||
"pending_left": len(pending) - n,
|
||||
},
|
||||
)
|
||||
write_json_atomic(out_path, payload)
|
||||
time.sleep(delay)
|
||||
|
||||
# Resume mode: ensure heroes already present stay; full mode already has all ok/stale.
|
||||
if resume:
|
||||
for hid in hero_ids:
|
||||
key = id_to_key[hid]
|
||||
if key not in by_hero_mu and key in prev:
|
||||
by_hero_mu[key] = annotate_matchup_cell(
|
||||
prev[key],
|
||||
fetched_at=str(prev[key].get("fetched_at") or started_at),
|
||||
stale=bool(prev[key].get("stale")),
|
||||
)
|
||||
|
||||
finished_at = _now_iso()
|
||||
payload = build_matchup_file_payload(
|
||||
by_hero_mu,
|
||||
take=take,
|
||||
match_limit=match_limit,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
stats={
|
||||
"heroes": len(by_hero_mu),
|
||||
"ok": ok,
|
||||
"failed": failed,
|
||||
"stale_kept": stale_kept,
|
||||
"pending_left": 0,
|
||||
},
|
||||
)
|
||||
write_json_atomic(out_path, payload)
|
||||
print(
|
||||
f"done: matchups heroes={len(by_hero_mu)} ok={ok} failed={failed} "
|
||||
f"stale_kept={stale_kept} → {out_path}",
|
||||
flush=True,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--weeks", type=int, default=8, help="weekly buckets to keep")
|
||||
@@ -393,6 +651,16 @@ def main() -> None:
|
||||
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(
|
||||
"--matchups-only",
|
||||
action="store_true",
|
||||
help="skip winWeek meta; only refresh matchup tops",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--resume-matchups",
|
||||
action="store_true",
|
||||
help="only fetch heroes missing from existing matchup cache (interrupt resume)",
|
||||
)
|
||||
ap.add_argument("--matchup-take", type=int, default=12)
|
||||
ap.add_argument("--matchup-min-games", type=int, default=50)
|
||||
args = ap.parse_args()
|
||||
@@ -401,103 +669,68 @@ def main() -> None:
|
||||
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
|
||||
if not args.matchups_only:
|
||||
print(
|
||||
f" [{i}/{len(BRACKET_ORDER)}] {bracket}: {len(rows)} week rows, "
|
||||
f"{len(pos_rows)} position rows",
|
||||
f"fetching STRATZ meta for {len(hero_ids)} heroes, "
|
||||
f"{args.weeks} weeks × {len(BRACKET_ORDER)} brackets",
|
||||
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)
|
||||
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
|
||||
)
|
||||
write_json_atomic(args.out_meta, meta)
|
||||
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,
|
||||
refresh_matchup_tops(
|
||||
token,
|
||||
id_to_key,
|
||||
take=args.matchup_take,
|
||||
match_limit=args.matchup_min_games,
|
||||
delay=args.delay,
|
||||
out_path=args.out_matchups,
|
||||
resume=bool(args.resume_matchups),
|
||||
)
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user