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:
+115
-35
@@ -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)}")
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -215,6 +215,24 @@
|
||||
"is_live": false,
|
||||
"live_probed_at": "2026-07-29T05:14:20.319003+00:00"
|
||||
},
|
||||
{
|
||||
"id": "leize",
|
||||
"platform": "douyin",
|
||||
"profile_url": "https://v.douyin.com/bxYZijBybSE/",
|
||||
"heroes": [
|
||||
"razor"
|
||||
],
|
||||
"nickname": "卡奥兹Khs",
|
||||
"unique_id": "Khaos995",
|
||||
"signature": "万分核心|战斗C位|顶级心态 上车/打号:Khaos…",
|
||||
"following_count": 90,
|
||||
"follower_count": 2921,
|
||||
"total_favorited": 46000,
|
||||
"avatar": "streamer_avatars/leize.jpg",
|
||||
"video": "streamer_videos/leize.mp4",
|
||||
"video_title": "POS 1 雷泽全新分享 · 雷霆爆轰征服天梯",
|
||||
"profile_fetched_at": "2026-07-29T07:20:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "shawang",
|
||||
"platform": "bilibili",
|
||||
|
||||
+309
-76
@@ -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,6 +669,8 @@ 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())
|
||||
|
||||
if not args.matchups_only:
|
||||
print(
|
||||
f"fetching STRATZ meta for {len(hero_ids)} heroes, "
|
||||
f"{args.weeks} weeks × {len(BRACKET_ORDER)} brackets",
|
||||
@@ -412,14 +682,27 @@ def main() -> None:
|
||||
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:
|
||||
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)
|
||||
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(
|
||||
@@ -432,72 +715,22 @@ def main() -> None:
|
||||
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",
|
||||
)
|
||||
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,
|
||||
)
|
||||
for n, hid in enumerate(pending, start=1):
|
||||
key = id_to_key[hid]
|
||||
try:
|
||||
cell = fetch_matchup_tops(
|
||||
refresh_matchup_tops(
|
||||
token,
|
||||
hid,
|
||||
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),
|
||||
)
|
||||
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__":
|
||||
|
||||
+20
-3
@@ -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)
|
||||
|
||||
+110
-11
@@ -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"
|
||||
)
|
||||
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/"
|
||||
|
||||
Reference in New Issue
Block a user