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:
+111
-12
@@ -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/"
|
||||
|
||||
Reference in New Issue
Block a user