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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

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