From d2cfcd74610116e9fc08fae0a9b42a3d6a356d2e Mon Sep 17 00:00:00 2001 From: voson Date: Wed, 29 Jul 2026 16:03:49 +0800 Subject: [PATCH] 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 --- shared/audit_relations.py | 150 +++++++-- shared/matchup_cross.py | 281 +++++++++++++++++ shared/tests/__init__.py | 0 shared/tests/test_matchup_cross.py | 204 ++++++++++++ web/__init__.py | 0 web/assets/streamer_avatars/leize.jpg | Bin 0 -> 42442 bytes web/data/streamers.json | 18 ++ web/fetch_stratz_meta.py | 439 ++++++++++++++++++++------ web/refresh_web.py | 23 +- web/serve_relations.py | 123 +++++++- 10 files changed, 1085 insertions(+), 153 deletions(-) create mode 100644 shared/matchup_cross.py create mode 100644 shared/tests/__init__.py create mode 100644 shared/tests/test_matchup_cross.py create mode 100644 web/__init__.py create mode 100644 web/assets/streamer_avatars/leize.jpg diff --git a/shared/audit_relations.py b/shared/audit_relations.py index 3ae9430..d17d29b 100644 --- a/shared/audit_relations.py +++ b/shared/audit_relations.py @@ -1,13 +1,14 @@ """Audit qualitative relations.json against OpenDota + STRATZ. Sources: - - data/matchups.json OpenDota vs winrates - - data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups) - - data/synergies.json STRATZ teammate synergies + - shared/data/matchups.json OpenDota vs winrates + - shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups) + - shared/data/synergies.json STRATZ teammate synergies + - web/data/stratz_matchup_tops.json Web 对位 Top (optional cross-check section) Usage: - python audit_relations.py - python audit_relations.py --min-games 80 --out data/relations_audit.json + python shared/audit_relations.py + python shared/audit_relations.py --min-games 80 --out shared/data/relations_audit.json """ from __future__ import annotations @@ -22,12 +23,23 @@ import json from datetime import datetime, timezone from shared.grid import hero_table -from shared.paths import SHARED_DATA +from shared.matchup_cross import audit_matchup_tops +from shared.paths import DATA, ROOT, SHARED_DATA from shared.relations import DEFAULT_RELATIONS, load_relations MATCHUPS = SHARED_DATA / "matchups.json" STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json" SYNERGIES = SHARED_DATA / "synergies.json" +STRATZ_MATCHUP_TOPS = DATA / "stratz_matchup_tops.json" +# Legacy monorepo-move leftovers (repo-root data/). +_LEGACY_DATA = ROOT / "data" + + +def _resolve_cache(*candidates: Path) -> Path: + for p in candidates: + if p.is_file(): + return p + return candidates[0] def _load_json_safe(path: Path) -> dict: @@ -109,12 +121,18 @@ def main() -> None: ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json") args = ap.parse_args() + matchups_path = _resolve_cache(MATCHUPS, _LEGACY_DATA / "matchups.json") + stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json") + syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json") + tops_path = _resolve_cache(STRATZ_MATCHUP_TOPS, _LEGACY_DATA / "stratz_matchup_tops.json") + id_to_key, key_to_id, names = load_id_maps() all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k]) rel = load_relations() - odota = _load_json_safe(MATCHUPS) - stratz = _load_json_safe(STRATZ_MATCHUPS) - syn_file = _load_json_safe(SYNERGIES) + odota = _load_json_safe(matchups_path) + stratz = _load_json_safe(stratz_path) + syn_file = _load_json_safe(syn_path) + tops = _load_json_safe(tops_path) by_o = odota.get("by_hero") or {} by_s = stratz.get("by_hero") or {} by_syn = syn_file.get("by_hero") or {} @@ -123,7 +141,6 @@ def main() -> None: counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []] syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []] known_counters = {(a, b) for a, b, _ in counters} - known_syn = {_pair_key(a, b) for a, b, _ in syns} counter_ok, counter_conflict, counter_weak, counter_no_data = [], [], [], [] for a, b, reason in counters: @@ -154,7 +171,7 @@ def main() -> None: s_bad = s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree if o_adv is None and s_adv is None: counter_no_data.append({**row, "why": "missing_both"}) - elif (o_games < args.min_games and (s_games < args.min_games or s_adv is None)): + elif o_games < args.min_games and (s_games < args.min_games or s_adv is None): counter_no_data.append({**row, "why": "low_games"}) elif o_ok or s_ok: sources = [] @@ -182,13 +199,26 @@ def main() -> None: continue cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (by_syn.get(str(bid)) or {}).get(str(aid)) if not cell: - syn_missing.append({"a": a, "b": b, "a_loc": names.get(a), "b_loc": names.get(b), "why": "no_stratz"}) + syn_missing.append( + { + "a": a, + "b": b, + "a_loc": names.get(a), + "b_loc": names.get(b), + "why": "no_stratz", + } + ) continue score = float(cell.get("synergy") or 0) games = int(cell.get("games") or 0) row = { - "a": a, "a_loc": names.get(a, a), "b": b, "b_loc": names.get(b, b), - "reason": reason, "stratz_synergy": round(score, 3), "stratz_games": games, + "a": a, + "a_loc": names.get(a, a), + "b": b, + "b_loc": names.get(b, b), + "reason": reason, + "stratz_synergy": round(score, 3), + "stratz_games": games, } if games < args.min_games: syn_missing.append({**row, "why": "low_games"}) @@ -199,7 +229,6 @@ def main() -> None: else: syn_missing.append({**row, "why": "weak_synergy"}) - # Cross-source suggestions: both OpenDota and STRATZ agree A counters B, missing from relations suggestions: list[dict] = [] for akey in all_keys: aid = key_to_id[akey] @@ -210,19 +239,28 @@ def main() -> None: o_adv, o_games = odota_adv(by_o, baseline, aid, bid) s_adv, s_games = stratz_adv(by_s, aid, bid) if ( - o_adv is not None and o_games >= args.min_games and o_adv >= 0.03 - and s_adv is not None and s_games >= args.min_games and s_adv >= 2.0 + o_adv is not None + and o_games >= args.min_games + and o_adv >= 0.03 + and s_adv is not None + and s_games >= args.min_games + and s_adv >= 2.0 ): - suggestions.append({ - "a": akey, "a_loc": names[akey], - "b": bkey, "b_loc": names[bkey], - "opendota_adv": round(o_adv, 4), "opendota_games": o_games, - "stratz_adv": round(s_adv, 3), "stratz_games": s_games, - }) + suggestions.append( + { + "a": akey, + "a_loc": names[akey], + "b": bkey, + "b_loc": names[bkey], + "opendota_adv": round(o_adv, 4), + "opendota_games": o_games, + "stratz_adv": round(s_adv, 3), + "stratz_games": s_games, + } + ) suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"])) suggestions = suggestions[:100] - # Per-hero coverage in qualitative relations counters_from: dict[str, int] = {k: 0 for k in all_keys} counters_to: dict[str, int] = {k: 0 for k in all_keys} syn_count: dict[str, int] = {k: 0 for k in all_keys} @@ -249,20 +287,18 @@ def main() -> None: ], } - # Qualitative gaps: no counter-out, no counter-in, no synergy no_counter_out = [k for k in all_keys if counters_from[k] == 0] no_counter_in = [k for k in all_keys if counters_to[k] == 0] no_synergy = [k for k in all_keys if syn_count[k] == 0] thin = [ - k for k in all_keys - if counters_from[k] + counters_to[k] + syn_count[k] <= 1 + k for k in all_keys if counters_from[k] + counters_to[k] + syn_count[k] <= 1 ] empty = [ - k for k in all_keys + k + for k in all_keys if counters_from[k] == 0 and counters_to[k] == 0 and syn_count[k] == 0 ] - # Heroes with strong cross-source suggestions but empty/thin qualitative coverage suggest_by_hero: dict[str, int] = {} for row in suggestions: suggest_by_hero[row["a"]] = suggest_by_hero.get(row["a"], 0) + 1 @@ -278,31 +314,60 @@ def main() -> None: } for k in empty + [x for x in thin if x not in empty] ], - key=lambda r: (r["counters_out"] + r["counters_in"] + r["synergies"], -r["cross_source_suggestions"], r["key"]), + key=lambda r: ( + r["counters_out"] + r["counters_in"] + r["synergies"], + -r["cross_source_suggestions"], + r["key"], + ), ) + web_tops_audit = None + if tops.get("by_hero"): + web_tops_audit = audit_matchup_tops( + tops, + odota, + id_to_key=id_to_key, + key_to_id=key_to_id, + names=names, + min_games=args.min_games, + ) + + def _rel(path: Path) -> str | None: + if not path.is_file(): + return None + try: + return str(path.relative_to(ROOT)).replace("\\", "/") + except ValueError: + return str(path).replace("\\", "/") + report = { "generated_at": datetime.now(timezone.utc).isoformat(), "sources": { "relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"), "opendota_matchups": { - "path": str(MATCHUPS.relative_to(ROOT)).replace("\\", "/") if MATCHUPS.is_file() else None, + "path": _rel(matchups_path), "source": odota.get("source"), "fetched_at": odota.get("fetched_at"), "heroes": len(by_o), }, "stratz_matchups": { - "path": str(STRATZ_MATCHUPS.relative_to(ROOT)).replace("\\", "/") if STRATZ_MATCHUPS.is_file() else None, + "path": _rel(stratz_path), "source": stratz.get("source"), "fetched_at": stratz.get("fetched_at"), "heroes": len(by_s), }, "stratz_synergies": { - "path": str(SYNERGIES.relative_to(ROOT)).replace("\\", "/") if SYNERGIES.is_file() else None, + "path": _rel(syn_path), "source": syn_file.get("source"), "fetched_at": syn_file.get("fetched_at"), "heroes": len(by_syn), }, + "stratz_matchup_tops": { + "path": _rel(tops_path), + "source": tops.get("source"), + "fetched_at": tops.get("fetched_at"), + "heroes": len(tops.get("by_hero") or {}), + }, }, "thresholds": { "min_games": args.min_games, @@ -338,13 +403,20 @@ def main() -> None: counter_conflict, key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)), ), - "counter_agree_both": [r for r in counter_ok if set(r.get("agree_sources") or []) == {"opendota", "stratz"}], + "counter_agree_both": [ + r + for r in counter_ok + if set(r.get("agree_sources") or []) == {"opendota", "stratz"} + ], "suggest_add_counters_cross_source": suggestions, "synergy_conflicts": syn_conflict, + "web_matchup_tops_cross": web_tops_audit, } args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + args.out.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) s = report["summary"] print("=== relations audit ===") @@ -361,6 +433,14 @@ def main() -> None: f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} " f"/ heroes={s['heroes_total']}" ) + if web_tops_audit: + ws = web_tops_audit["summary"] + print( + "web matchup tops × OpenDota: " + f"agree={ws['agree']} conflict={ws['conflict']} weak={ws['weak']} " + f"/ rows={ws['counter_rows']} heroes={ws['heroes']}" + ) + print(f" note: {web_tops_audit['manual_review_note']}") print("cache gaps:") for k, v in missing_cache.items(): print(f" {k}: {len(v)}") diff --git a/shared/matchup_cross.py b/shared/matchup_cross.py new file mode 100644 index 0000000..d5f356f --- /dev/null +++ b/shared/matchup_cross.py @@ -0,0 +1,281 @@ +"""Cross-check STRATZ web matchup tops against OpenDota hero matchups. + +Web-only observation evidence — never merge into relations.json or recommend. + +Statuses: + agree — OpenDota baseline-adjusted advantage agrees with STRATZ direction + conflict — OpenDota disagrees with STRATZ direction (enough games) + weak — OpenDota sample too small or advantage near zero +""" + +from __future__ import annotations + +from typing import Any + +# Defaults aligned with shared/audit_relations.py thresholds. +DEFAULT_MIN_GAMES = 80 +DEFAULT_ODOTA_AGREE = 0.015 +DEFAULT_ODOTA_DISAGREE = -0.015 + + +def matchup_wr(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]: + cell = (by_hero.get(str(aid)) or {}).get(str(bid)) + if not cell: + return None, 0 + games = int(cell.get("games") or 0) + wins = int(cell.get("wins") or 0) + if games <= 0: + return None, 0 + return wins / games, games + + +def build_baseline(by_hero: dict) -> dict[int, float]: + out: dict[int, float] = {} + for hid_s, opps in by_hero.items(): + tw = tg = 0 + for cell in opps.values(): + g = int(cell.get("games") or 0) + w = int(cell.get("wins") or 0) + tw += w + tg += g + if tg > 0: + out[int(hid_s)] = tw / tg + return out + + +def odota_adv( + by_hero: dict, baseline: dict[int, float], aid: int, bid: int +) -> tuple[float | None, int]: + wr, games = matchup_wr(by_hero, aid, bid) + if wr is None: + return None, 0 + base = baseline.get(aid) + if base is None: + return None, games + return wr - base, games + + +def classify_cross_source( + *, + stratz_signed: float, + odota_adv_val: float | None, + odota_games: int, + min_games: int = DEFAULT_MIN_GAMES, + odota_agree: float = DEFAULT_ODOTA_AGREE, + odota_disagree: float = DEFAULT_ODOTA_DISAGREE, +) -> str: + """Classify whether OpenDota agrees with a STRATZ signed advantage. + + ``stratz_signed`` > 0 means STRATZ says A is favored vs B (counters). + For countered rows the caller should pass the original vs advantage + (positive = A still favored), not the negated display value. + """ + if odota_adv_val is None or odota_games < min_games: + return "weak" + if stratz_signed >= 0: + if odota_adv_val >= odota_agree: + return "agree" + if odota_adv_val <= odota_disagree: + return "conflict" + return "weak" + if odota_adv_val <= -odota_agree: + return "agree" + if odota_adv_val >= -odota_disagree: + return "conflict" + return "weak" + + +def enrich_entry_cross( + entry: dict, + *, + hero_id: int, + kind: str, + by_odota: dict, + baseline: dict[int, float], + min_games: int = DEFAULT_MIN_GAMES, +) -> dict: + """Attach ``cross`` quality blob to one counters/countered/synergies row. + + For ``countered`` rows, STRATZ stores negated advantage for display; we + restore the original vs-sign for classification (``-advantage``). + Synergies skip OpenDota (no teammate WR in matchups.json). + """ + out = dict(entry) + peer = int(entry.get("hero_id") or 0) + if kind == "synergies" or peer <= 0 or hero_id <= 0: + out["cross"] = {"status": "weak", "reason": "no_odota_synergy"} + return out + + o_adv, o_games = odota_adv(by_odota, baseline, hero_id, peer) + raw_adv = float(entry.get("advantage") or 0.0) + # countered display advantage is already negated; restore original vs sign. + stratz_signed = -raw_adv if kind == "countered" else raw_adv + status = classify_cross_source( + stratz_signed=stratz_signed, + odota_adv_val=o_adv, + odota_games=o_games, + min_games=min_games, + ) + out["cross"] = { + "status": status, + "opendota_adv": None if o_adv is None else round(o_adv, 4), + "opendota_games": o_games, + "min_games": min_games, + } + return out + + +def enrich_hero_matchups_cross( + cell: dict, + *, + hero_id: int, + by_odota: dict, + baseline: dict[int, float], + min_games: int = DEFAULT_MIN_GAMES, +) -> dict: + """Return a shallow-copied hero matchup cell with per-row ``cross`` fields.""" + out = { + "counters": [ + enrich_entry_cross( + e, + hero_id=hero_id, + kind="counters", + by_odota=by_odota, + baseline=baseline, + min_games=min_games, + ) + for e in (cell.get("counters") or []) + if isinstance(e, dict) + ], + "countered": [ + enrich_entry_cross( + e, + hero_id=hero_id, + kind="countered", + by_odota=by_odota, + baseline=baseline, + min_games=min_games, + ) + for e in (cell.get("countered") or []) + if isinstance(e, dict) + ], + "synergies": [ + enrich_entry_cross( + e, + hero_id=hero_id, + kind="synergies", + by_odota=by_odota, + baseline=baseline, + min_games=min_games, + ) + for e in (cell.get("synergies") or []) + if isinstance(e, dict) + ], + } + for k in ("fetched_at", "stale"): + if k in cell: + out[k] = cell[k] + return out + + +def summarize_cross_rows(rows: list[dict]) -> dict[str, int]: + counts = {"agree": 0, "conflict": 0, "weak": 0} + for e in rows: + status = ((e.get("cross") or {}).get("status")) or "weak" + if status not in counts: + status = "weak" + counts[status] += 1 + return counts + + +def audit_matchup_tops( + tops: dict, + odota: dict, + *, + id_to_key: dict[int, str], + key_to_id: dict[str, int], + names: dict[str, str], + min_games: int = DEFAULT_MIN_GAMES, +) -> dict[str, Any]: + """Audit STRATZ web tops vs OpenDota; return report section (no file IO).""" + by_odota = odota.get("by_hero") or {} + baseline = build_baseline(by_odota) + by_hero = tops.get("by_hero") or {} + + pairs: list[dict] = [] + summary = { + "heroes": 0, + "counter_rows": 0, + "agree": 0, + "conflict": 0, + "weak": 0, + } + + for key, cell in sorted(by_hero.items()): + if not isinstance(cell, dict): + continue + hid = key_to_id.get(key) + if hid is None: + # Prefer embedded id when key map lags new heroes. + hid = int(cell.get("id") or 0) or None + if hid is None: + continue + summary["heroes"] += 1 + enriched = enrich_hero_matchups_cross( + cell, + hero_id=hid, + by_odota=by_odota, + baseline=baseline, + min_games=min_games, + ) + for kind in ("counters", "countered"): + for e in enriched.get(kind) or []: + summary["counter_rows"] += 1 + cross = e.get("cross") or {} + status = cross.get("status") or "weak" + summary[status] = summary.get(status, 0) + 1 + peer_id = int(e.get("hero_id") or 0) + peer_key = id_to_key.get(peer_id, str(peer_id)) + pairs.append( + { + "hero": key, + "hero_loc": names.get(key, key), + "peer": peer_key, + "peer_loc": names.get(peer_key, peer_key), + "kind": kind, + "stratz_advantage": e.get("advantage"), + "stratz_wr": e.get("wr"), + "stratz_games": e.get("games"), + "status": status, + "opendota_adv": cross.get("opendota_adv"), + "opendota_games": cross.get("opendota_games"), + } + ) + + conflicts = [p for p in pairs if p["status"] == "conflict"] + conflicts.sort( + key=lambda r: ( + abs(float(r.get("stratz_advantage") or 0)), + -(int(r.get("opendota_games") or 0)), + ), + reverse=True, + ) + agrees = [p for p in pairs if p["status"] == "agree"] + agrees.sort( + key=lambda r: ( + abs(float(r.get("stratz_advantage") or 0)), + -(int(r.get("opendota_games") or 0)), + ), + reverse=True, + ) + + return { + "summary": summary, + "thresholds": {"min_games": min_games}, + "manual_review_note": ( + "Dota2ProTracker (7k+ MMR / pro) is a manual high-MMR reference for " + "conflict rows; not automated (login-gated)." + ), + "conflicts": conflicts[:100], + "agrees_sample": agrees[:40], + } diff --git a/shared/tests/__init__.py b/shared/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/tests/test_matchup_cross.py b/shared/tests/test_matchup_cross.py new file mode 100644 index 0000000..c34663c --- /dev/null +++ b/shared/tests/test_matchup_cross.py @@ -0,0 +1,204 @@ +"""Unit tests for STRATZ matchup ranking + OpenDota cross classification. + +Run from repo root: + python -m unittest shared.tests.test_matchup_cross -v +""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from shared.matchup_cross import ( # noqa: E402 + classify_cross_source, + enrich_entry_cross, + enrich_hero_matchups_cross, +) +from web.fetch_stratz_meta import ( # noqa: E402 + annotate_matchup_cell, + build_matchup_file_payload, + load_previous_matchups, + rank_matchup_lists, + write_json_atomic, +) + + +class RankMatchupListsTests(unittest.TestCase): + def test_counters_sorted_by_advantage_not_raw_wr(self) -> None: + # Wraith King: wr < 0.5 but positive relative advantage — still a counter. + vs = [ + { + "hero_id": 42, + "games": 3286, + "wins": 1597, + "advantage": 4.444, + "wr": 0.486, + }, + { + "hero_id": 94, + "games": 1070, + "wins": 649, + "advantage": 12.994, + "wr": 0.607, + }, + { + "hero_id": 34, + "games": 1171, + "wins": 723, + "advantage": 7.318, + "wr": 0.617, + }, + ] + ranked = rank_matchup_lists(vs, [], take=3) + self.assertEqual( + [e["hero_id"] for e in ranked["counters"]], + [94, 34, 42], + ) + wk = ranked["counters"][2] + self.assertLess(wk["wr"], 0.5) + self.assertGreater(wk["advantage"], 0) + + def test_countered_mirrors_lowest_advantage(self) -> None: + vs = [ + {"hero_id": 1, "games": 100, "wins": 60, "advantage": 5.0, "wr": 0.6}, + {"hero_id": 2, "games": 100, "wins": 40, "advantage": -8.0, "wr": 0.4}, + {"hero_id": 3, "games": 50, "wins": 20, "advantage": -3.0, "wr": 0.4}, + ] + ranked = rank_matchup_lists(vs, [], take=2) + fear = ranked["countered"] + self.assertEqual([e["hero_id"] for e in fear], [2, 3]) + self.assertAlmostEqual(fear[0]["advantage"], 8.0) + self.assertAlmostEqual(fear[0]["wr"], 0.6) + + def test_synergies_sorted_desc(self) -> None: + with_rows = [ + {"hero_id": 10, "games": 10, "wins": 5, "synergy": 1.0, "wr": 0.5}, + {"hero_id": 11, "games": 10, "wins": 6, "synergy": 4.5, "wr": 0.6}, + ] + ranked = rank_matchup_lists([], with_rows, take=2) + self.assertEqual([e["hero_id"] for e in ranked["synergies"]], [11, 10]) + + +class MatchupCacheHelpersTests(unittest.TestCase): + def test_annotate_and_payload_metadata(self) -> None: + cell = annotate_matchup_cell( + {"counters": [{"hero_id": 1}], "countered": [], "synergies": []}, + fetched_at="2026-07-28T00:00:00+00:00", + stale=True, + ) + self.assertTrue(cell["stale"]) + self.assertEqual(cell["fetched_at"], "2026-07-28T00:00:00+00:00") + payload = build_matchup_file_payload( + {"antimage": cell}, + take=12, + match_limit=50, + started_at="2026-07-28T00:00:00+00:00", + finished_at="2026-07-28T01:00:00+00:00", + stats={"heroes": 1, "ok": 0, "failed": 1, "stale_kept": 1}, + ) + self.assertEqual(payload["scope"]["kind"], "global_aggregate") + self.assertIn("global", payload["scope"]["kind"]) + self.assertTrue(payload["scope"]["label_zh"]) + self.assertEqual(payload["stats"]["stale_kept"], 1) + + def test_atomic_write_and_resume_load(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "stratz_matchup_tops.json" + payload = build_matchup_file_payload( + { + "antimage": annotate_matchup_cell( + {"counters": [], "countered": [], "synergies": []}, + fetched_at="t0", + ) + }, + take=12, + match_limit=50, + started_at="t0", + finished_at="t0", + ) + write_json_atomic(path, payload) + loaded = load_previous_matchups(path) + self.assertIn("antimage", loaded) + + +class CrossClassifyTests(unittest.TestCase): + def test_agree_conflict_weak(self) -> None: + self.assertEqual( + classify_cross_source( + stratz_signed=5.0, odota_adv_val=0.04, odota_games=200 + ), + "agree", + ) + self.assertEqual( + classify_cross_source( + stratz_signed=5.0, odota_adv_val=-0.04, odota_games=200 + ), + "conflict", + ) + self.assertEqual( + classify_cross_source( + stratz_signed=5.0, odota_adv_val=0.04, odota_games=10 + ), + "weak", + ) + + def test_countered_uses_restored_sign(self) -> None: + # Display advantage for countered is +8 (negated); original vs was -8. + by_odota = { + "1": { + "2": {"games": 200, "wins": 80}, # AM wr 40% vs peer + } + } + # Baseline for hero 1 ≈ 0.4 from only this matchup. + from shared.matchup_cross import build_baseline + + baseline = build_baseline(by_odota) + entry = enrich_entry_cross( + { + "hero_id": 2, + "games": 100, + "wins": 40, + "advantage": 8.0, + "wr": 0.6, + }, + hero_id=1, + kind="countered", + by_odota=by_odota, + baseline=baseline, + min_games=80, + ) + # odota_adv = 0.4 - 0.4 = 0 → weak + self.assertEqual(entry["cross"]["status"], "weak") + + def test_enrich_hero_keeps_stale_flag(self) -> None: + cell = { + "counters": [ + { + "hero_id": 94, + "games": 100, + "wins": 60, + "advantage": 3.0, + "wr": 0.6, + } + ], + "countered": [], + "synergies": [], + "fetched_at": "t0", + "stale": True, + } + out = enrich_hero_matchups_cross( + cell, + hero_id=1, + by_odota={}, + baseline={}, + ) + self.assertTrue(out["stale"]) + self.assertEqual(out["cross"] if False else out["counters"][0]["cross"]["status"], "weak") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/assets/streamer_avatars/leize.jpg b/web/assets/streamer_avatars/leize.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34cb8d538ab3933874275dbeb6f2e5b585951f0f GIT binary patch literal 42442 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 h 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__": diff --git a/web/refresh_web.py b/web/refresh_web.py index 112163c..06b0172 100644 --- a/web/refresh_web.py +++ b/web/refresh_web.py @@ -103,12 +103,21 @@ def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) return data_changed, assets_changed -def run_script(script: str, *args: str, dry_run: bool = False) -> None: +def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool: cmd = [sys.executable, str(ROOT / script), *args] print(f"+ {' '.join(cmd)}", flush=True) if dry_run: - return - subprocess.run(cmd, cwd=str(ROOT), check=True) + return True + proc = subprocess.run(cmd, cwd=str(ROOT), check=False) + if proc.returncode == 0: + return True + if soft_fail: + print( + f"soft-fail: {script} exited {proc.returncode}; continuing", + flush=True, + ) + return False + raise subprocess.CalledProcessError(proc.returncode, cmd) def patch_check() -> dict: @@ -137,6 +146,13 @@ def run_patch_linked(*, dry_run: bool = False) -> None: run_script("fetch_item_shop.py", dry_run=dry_run) run_script("fetch_items_meta.py", dry_run=dry_run) run_script("item_fears.py", dry_run=dry_run) + # Soft: patch workflow may lack STRATZ token; weekly still does the hard refresh. + run_script( + "fetch_stratz_meta.py", + "--matchups-only", + dry_run=dry_run, + soft_fail=True, + ) def run_daily(*, dry_run: bool = False) -> bool: @@ -162,6 +178,7 @@ def run_daily(*, dry_run: bool = False) -> bool: def run_weekly(*, dry_run: bool = False) -> None: + # Default full matchup refresh (not --resume-matchups); stale cells kept on failure. run_script("fetch_stratz_meta.py", dry_run=dry_run) run_script("fetch_hero_items.py", dry_run=dry_run) run_script("fetch_items_meta.py", dry_run=dry_run) diff --git a/web/serve_relations.py b/web/serve_relations.py index a61559e..19f6c70 100644 --- a/web/serve_relations.py +++ b/web/serve_relations.py @@ -27,6 +27,7 @@ import urllib.error from shared.grid import ATTR_ORDER, hero_table from shared.hero_tags import TAG_ORDER, tags_for_hero from shared.http_utils import http_bytes +from shared.matchup_cross import build_baseline, enrich_hero_matchups_cross from shared.paths import ( ABILITY_ICONS, ABILITY_VIDEOS, @@ -37,6 +38,7 @@ from shared.paths import ( ITEM_ICONS, RANK_ICONS, ROOT, + SHARED_DATA, STREAMER_AVATARS, STREAMER_VIDEOS, TEMPLATES_CDN, @@ -62,6 +64,15 @@ PRO_MATCHES_PATH = DATA / "pro_matches.json" STREAMERS_PATH = DATA / "streamers.json" STRATZ_HERO_META_PATH = DATA / "stratz_hero_meta.json" STRATZ_MATCHUP_TOPS_PATH = DATA / "stratz_matchup_tops.json" +OPENDOTA_MATCHUPS_PATH = SHARED_DATA / "matchups.json" +_LEGACY_DATA = ROOT / "data" + + +def _first_existing(*candidates: Path) -> Path | None: + for p in candidates: + if p.is_file(): + return p + return None ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4} ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"} ABILITY_ICON_URL = ( @@ -364,10 +375,11 @@ def load_stratz_hero_meta() -> dict: "by_hero": {}, "meta_board": {}, } - if not STRATZ_HERO_META_PATH.is_file(): + path = _first_existing(STRATZ_HERO_META_PATH, _LEGACY_DATA / "stratz_hero_meta.json") + if path is None: return empty try: - raw = json.loads(STRATZ_HERO_META_PATH.read_text(encoding="utf-8")) + raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return empty if not isinstance(raw, dict): @@ -387,35 +399,109 @@ def load_stratz_hero_meta() -> dict: } +def _load_opendota_matchups() -> dict: + path = _first_existing(OPENDOTA_MATCHUPS_PATH, _LEGACY_DATA / "matchups.json") + if path is None: + return {} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return raw if isinstance(raw, dict) else {} + + def load_stratz_matchup_tops() -> dict: - """STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only.""" + """STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only. + + When OpenDota matchups.json is present, each counter/countered row gets a + ``cross`` quality blob (agree / conflict / weak). Observation only. + """ empty: dict = { "fetched_at": None, + "started_at": None, + "finished_at": None, "source": "stratz", "attribution": "https://stratz.com", "take": 0, "match_limit": 0, + "scope": { + "kind": "global_aggregate", + "label_zh": "全局聚合(未按段位 / 分路 / 周过滤)", + }, "note": None, + "stats": {}, + "cross_source": None, "by_hero": {}, } - if not STRATZ_MATCHUP_TOPS_PATH.is_file(): + path = _first_existing( + STRATZ_MATCHUP_TOPS_PATH, _LEGACY_DATA / "stratz_matchup_tops.json" + ) + if path is None: return empty try: - raw = json.loads(STRATZ_MATCHUP_TOPS_PATH.read_text(encoding="utf-8")) + raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return empty if not isinstance(raw, dict): return empty - return { + + by_hero_raw = dict(raw.get("by_hero") or {}) + scope = raw.get("scope") if isinstance(raw.get("scope"), dict) else empty["scope"] + out: dict = { "fetched_at": raw.get("fetched_at"), + "started_at": raw.get("started_at"), + "finished_at": raw.get("finished_at"), "source": raw.get("source") or "stratz", "attribution": raw.get("attribution") or empty["attribution"], "take": raw.get("take") or 0, "match_limit": raw.get("match_limit") or 0, + "scope": scope, "note": raw.get("note"), - "by_hero": dict(raw.get("by_hero") or {}), + "stats": dict(raw.get("stats") or {}), + "cross_source": None, + "by_hero": by_hero_raw, } + odota = _load_opendota_matchups() + by_odota = odota.get("by_hero") or {} + if not by_odota: + out["cross_source"] = { + "available": False, + "label_zh": "暂无可靠交叉结论", + "reason": "missing_opendota_matchups", + } + return out + + key_to_id = {h["key"]: int(h["id"]) for h in hero_table()} + baseline = build_baseline(by_odota) + enriched: dict[str, dict] = {} + for key, cell in by_hero_raw.items(): + if not isinstance(cell, dict): + continue + hid = key_to_id.get(key) + if hid is None: + enriched[key] = cell + continue + enriched[key] = enrich_hero_matchups_cross( + cell, + hero_id=hid, + by_odota=by_odota, + baseline=baseline, + ) + out["by_hero"] = enriched + out["cross_source"] = { + "available": True, + "source": "opendota", + "attribution": odota.get("attribution") or "https://opendota.com", + "fetched_at": odota.get("fetched_at"), + "label_zh": "OpenDota 独立交叉(基线校正)", + "manual_review_note": ( + "Dota2ProTracker(7k+ / 职业)仅作人工复核「来源分歧」条目的高分段参考," + "不进入自动流水线。" + ), + } + return out + def load_hero_abilities() -> dict: """Slim per-hero abilities / Aghs upgrades / talents for the web hero pane.""" @@ -851,7 +937,14 @@ class Handler(BaseHTTPRequestHandler): if "/" in key or "\\" in key or ".." in key: self._json(400, {"error": "bad path"}) return - if not (key.endswith(".mp4") or key.endswith(".webm")): + if not ( + key.endswith(".mp4") + or key.endswith(".webm") + or key.endswith(".jpg") + or key.endswith(".jpeg") + or key.endswith(".webp") + or key.endswith(".png") + ): self._json(400, {"error": "bad path"}) return if key.startswith("_"): @@ -861,10 +954,16 @@ class Handler(BaseHTTPRequestHandler): if not fpath.is_file(): self.send_error(404) return - ctype = ( - "video/webm" if key.endswith(".webm") else "video/mp4" - ) - self._send_file(fpath, ctype) + if key.endswith(".webm"): + ctype = "video/webm" + elif key.endswith(".mp4"): + ctype = "video/mp4" + else: + ctype = mimetypes.guess_type(key)[0] or "image/jpeg" + if ctype.startswith("video/"): + self._send_file(fpath, ctype) + else: + self._send(200, fpath.read_bytes(), ctype) return if path.startswith("/portrait/") or path.startswith("/cdn/"): prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/"