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:
voson
2026-07-29 16:03:49 +08:00
co-authored by Cursor
parent d9c7b4b7b1
commit d2cfcd7461
10 changed files with 1085 additions and 153 deletions
+111 -12
View File
@@ -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": (
"Dota2ProTracker7k+ / 职业)仅作人工复核「来源分歧」条目的高分段参考,"
"不进入自动流水线。"
),
}
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/"