v0.5.59: item counter evidence for fears, catch up Web features to site version.
Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+401
-10
@@ -1,11 +1,11 @@
|
||||
"""Local read-only preview for qualitative hero relations.
|
||||
"""Local dev server for the Climperor web site (web/relations/).
|
||||
|
||||
Usage:
|
||||
python serve_relations.py
|
||||
python serve_relations.py --port 8765
|
||||
|
||||
Select a hero to see 克制 / 被克制 / 搭档 highlights and feared items.
|
||||
Edit data/relations.json directly, then refresh the page.
|
||||
Hero relations, rankings, streamers, mechanics, items, patches — read-only browser UI.
|
||||
Edit data/*.json directly, then refresh the page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,23 +27,34 @@ from common import (
|
||||
HERO_PORTRAITS,
|
||||
ITEM_CAT_ICONS,
|
||||
ITEM_ICONS,
|
||||
RANK_ICONS,
|
||||
ROOT,
|
||||
STREAMER_AVATARS,
|
||||
STREAMER_VIDEOS,
|
||||
TEMPLATES_CDN,
|
||||
UI_ICONS,
|
||||
)
|
||||
from grid import ATTR_ORDER, hero_table
|
||||
from hero_tags import TAG_ORDER, tags_for_hero
|
||||
from http_utils import http_bytes
|
||||
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
|
||||
from relations import DEFAULT_RELATIONS, load_relations
|
||||
|
||||
WEB_DIR = ROOT / "web" / "relations"
|
||||
GRID_ORDER_PATH = ROOT / "data" / "hero_grid_order.json"
|
||||
HERO_ITEMS_PATH = ROOT / "data" / "hero_items.json"
|
||||
HERO_STATS_PATH = ROOT / "data" / "hero_stats.json"
|
||||
HERO_MATCHES_PATH = ROOT / "data" / "hero_matches.json"
|
||||
HERO_ITEM_FEARS_PATH = ROOT / "data" / "hero_item_fears.json"
|
||||
HERO_ABILITIES_PATH = ROOT / "data" / "hero_abilities.json"
|
||||
ITEM_SHOP_PATH = ROOT / "data" / "item_shop.json"
|
||||
ITEMS_META_PATH = ROOT / "data" / "items_meta.json"
|
||||
PATCHES_PATH = ROOT / "data" / "patches.json"
|
||||
LEADERBOARDS_PATH = ROOT / "data" / "leaderboards.json"
|
||||
PRO_MATCHES_PATH = ROOT / "data" / "pro_matches.json"
|
||||
STREAMERS_PATH = ROOT / "data" / "streamers.json"
|
||||
STRATZ_HERO_META_PATH = ROOT / "data" / "stratz_hero_meta.json"
|
||||
STRATZ_MATCHUP_TOPS_PATH = ROOT / "data" / "stratz_matchup_tops.json"
|
||||
ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4}
|
||||
ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"}
|
||||
ABILITY_ICON_URL = (
|
||||
@@ -71,6 +82,63 @@ def load_hero_items() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def load_hero_matches() -> dict:
|
||||
"""Cached recent matches + builds (see fetch_hero_matches.py). Preview only."""
|
||||
empty: dict = {"meta": {}, "items": {}, "by_hero": {}}
|
||||
if not HERO_MATCHES_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(HERO_MATCHES_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
by_hero = raw.get("by_hero")
|
||||
if not isinstance(by_hero, dict):
|
||||
by_hero = {}
|
||||
return {
|
||||
"meta": dict(raw.get("meta") or {}),
|
||||
"items": dict(raw.get("items") or {}),
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
|
||||
def load_hero_stats() -> dict:
|
||||
"""Cached OpenDota bracket pick/win (see fetch_hero_stats.py). Preview only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"window_days": 7,
|
||||
"window_note": None,
|
||||
"window_label_zh": "近约 7 天公开对局",
|
||||
"brackets": [],
|
||||
"totals": {},
|
||||
"by_hero": {},
|
||||
}
|
||||
if not HERO_STATS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(HERO_STATS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
try:
|
||||
window_days = int(raw.get("window_days") or 7)
|
||||
except (TypeError, ValueError):
|
||||
window_days = 7
|
||||
window_days = max(1, window_days)
|
||||
label = raw.get("window_label_zh") or f"近约 {window_days} 天公开对局"
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "opendota",
|
||||
"attribution": raw.get("attribution") or "https://www.opendota.com",
|
||||
"window_days": window_days,
|
||||
"window_note": raw.get("window_note"),
|
||||
"window_label_zh": label,
|
||||
"brackets": list(raw.get("brackets") or []),
|
||||
"totals": dict(raw.get("totals") or {}),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_hero_item_fears() -> dict:
|
||||
"""Cached rule-based items that counter each hero (see item_fears.py)."""
|
||||
empty = {"meta": {}, "items": {}, "by_hero": {}}
|
||||
@@ -155,7 +223,7 @@ def load_patches() -> dict:
|
||||
"""Patch list + per-patch details + id lookup (see fetch_patches.py).
|
||||
|
||||
Returns {patches, lookup, details}; empty-shaped when the file is missing
|
||||
so the preview UI degrades to "no data" instead of crashing.
|
||||
so the web UI degrades to "no data" instead of crashing.
|
||||
"""
|
||||
empty = {"patches": [], "lookup": {}, "details": {}}
|
||||
if not PATCHES_PATH.is_file():
|
||||
@@ -171,8 +239,179 @@ def load_patches() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def load_leaderboards() -> dict:
|
||||
"""Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "valve",
|
||||
"attribution": "https://www.dota2.com/leaderboards",
|
||||
"note": None,
|
||||
"default_region": "china",
|
||||
"region_order": ["china", "europe", "americas", "se_asia"],
|
||||
"regions": {},
|
||||
}
|
||||
if not LEADERBOARDS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(LEADERBOARDS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
regions = raw.get("regions") if isinstance(raw.get("regions"), dict) else {}
|
||||
order = raw.get("region_order")
|
||||
if not isinstance(order, list) or not order:
|
||||
order = list(empty["region_order"])
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "valve",
|
||||
"attribution": raw.get("attribution") or empty["attribution"],
|
||||
"note": raw.get("note"),
|
||||
"default_region": raw.get("default_region") or "china",
|
||||
"region_order": [str(x) for x in order],
|
||||
"regions": regions,
|
||||
}
|
||||
|
||||
|
||||
def load_pro_matches() -> dict:
|
||||
"""Pro-player recent matches (see fetch_pro_matches.py). Preview only."""
|
||||
empty: dict = {
|
||||
"meta": {},
|
||||
"items": {},
|
||||
"pros": {},
|
||||
"by_pro": {},
|
||||
"by_hero": {},
|
||||
}
|
||||
if not PRO_MATCHES_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(PRO_MATCHES_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
return {
|
||||
"meta": dict(raw.get("meta") or {}),
|
||||
"items": dict(raw.get("items") or {}),
|
||||
"pros": dict(raw.get("pros") or {}),
|
||||
"by_pro": dict(raw.get("by_pro") or {}),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_streamers() -> dict:
|
||||
"""Manual streamer directory + Douyin profile enrichment (fetch_streamers.py).
|
||||
|
||||
Web「主播」only — do not merge into relations/heroes or recommend.
|
||||
"""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "manual+douyin",
|
||||
"platform_meta": {
|
||||
"douyin": {
|
||||
"label_zh": "抖音",
|
||||
"icon": "ui-icon/platform_douyin.png",
|
||||
}
|
||||
},
|
||||
"streamers": [],
|
||||
}
|
||||
if not STREAMERS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(STREAMERS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
rows = raw.get("streamers")
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
platform_meta = raw.get("platform_meta")
|
||||
if not isinstance(platform_meta, dict):
|
||||
platform_meta = dict(empty["platform_meta"])
|
||||
else:
|
||||
platform_meta = {
|
||||
**empty["platform_meta"],
|
||||
**{k: v for k, v in platform_meta.items() if isinstance(v, dict)},
|
||||
}
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "manual+douyin",
|
||||
"platform_meta": platform_meta,
|
||||
"streamers": [r for r in rows if isinstance(r, dict)],
|
||||
}
|
||||
|
||||
|
||||
def load_stratz_hero_meta() -> dict:
|
||||
"""STRATZ weekly WR/pick by bracket + positions (see fetch_stratz_meta.py). Web only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "stratz",
|
||||
"attribution": "https://stratz.com",
|
||||
"weeks_take": 0,
|
||||
"window_label_zh": None,
|
||||
"brackets": [],
|
||||
"positions": [],
|
||||
"bracket_position_note": None,
|
||||
"totals": {},
|
||||
"by_hero": {},
|
||||
"meta_board": {},
|
||||
}
|
||||
if not STRATZ_HERO_META_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(STRATZ_HERO_META_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "stratz",
|
||||
"attribution": raw.get("attribution") or empty["attribution"],
|
||||
"weeks_take": raw.get("weeks_take") or 0,
|
||||
"window_label_zh": raw.get("window_label_zh"),
|
||||
"brackets": list(raw.get("brackets") or []),
|
||||
"positions": list(raw.get("positions") or []),
|
||||
"bracket_position_note": raw.get("bracket_position_note"),
|
||||
"totals": dict(raw.get("totals") or {}),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
"meta_board": dict(raw.get("meta_board") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_stratz_matchup_tops() -> dict:
|
||||
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "stratz",
|
||||
"attribution": "https://stratz.com",
|
||||
"take": 0,
|
||||
"match_limit": 0,
|
||||
"note": None,
|
||||
"by_hero": {},
|
||||
}
|
||||
if not STRATZ_MATCHUP_TOPS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(STRATZ_MATCHUP_TOPS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_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,
|
||||
"note": raw.get("note"),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_hero_abilities() -> dict:
|
||||
"""Slim per-hero abilities / Aghs upgrades / talents for the preview pane."""
|
||||
"""Slim per-hero abilities / Aghs upgrades / talents for the web hero pane."""
|
||||
empty = {"meta": {}, "by_hero": {}}
|
||||
if not HERO_ABILITIES_PATH.is_file():
|
||||
return empty
|
||||
@@ -217,6 +456,11 @@ def load_hero_abilities() -> dict:
|
||||
"mana_cost": ab.get("mana_cost") or "",
|
||||
"specials": specials,
|
||||
"lore_loc": ab.get("lore_loc") or "",
|
||||
"tags": [
|
||||
str(t)
|
||||
for t in (ab.get("tags") or [])
|
||||
if isinstance(t, str) and t in QUERY_MECHANIC_ORDER
|
||||
],
|
||||
}
|
||||
)
|
||||
talents_out = []
|
||||
@@ -246,7 +490,7 @@ def ensure_ability_icon(key: str) -> Path | None:
|
||||
|
||||
``innate.png`` / ``talent_tree.png`` are bundled shared badges (not fetched
|
||||
from CDN). Many innate ability keys 404 on Steam ``dota_react/abilities``;
|
||||
the preview UI falls back to ``innate.png`` when a per-ability icon is missing.
|
||||
the web UI falls back to ``innate.png`` when a per-ability icon is missing.
|
||||
"""
|
||||
if not key or "/" in key or "\\" in key or ".." in key:
|
||||
return None
|
||||
@@ -349,11 +593,18 @@ def build_payload() -> dict:
|
||||
|
||||
rel = load_relations()
|
||||
items = load_hero_items()
|
||||
hero_stats = load_hero_stats()
|
||||
hero_matches = load_hero_matches()
|
||||
fears = load_hero_item_fears()
|
||||
abilities = load_hero_abilities()
|
||||
shop = load_item_shop()
|
||||
items_meta = load_items_meta_index()
|
||||
patches_data = load_patches()
|
||||
leaderboards = load_leaderboards()
|
||||
pro_matches = load_pro_matches()
|
||||
streamers = load_streamers()
|
||||
stratz_meta = load_stratz_hero_meta()
|
||||
stratz_matchups = load_stratz_matchup_tops()
|
||||
# Attach craft graph from shop catalog when available.
|
||||
for key, row in (shop.get("items") or {}).items():
|
||||
if not isinstance(row, dict):
|
||||
@@ -375,6 +626,8 @@ def build_payload() -> dict:
|
||||
"tag_order": list(TAG_ORDER),
|
||||
"relations": rel,
|
||||
"hero_items": items,
|
||||
"hero_stats": hero_stats,
|
||||
"hero_matches": hero_matches,
|
||||
"hero_item_fears": fears,
|
||||
"hero_abilities": abilities,
|
||||
"item_shop": shop,
|
||||
@@ -382,10 +635,20 @@ def build_payload() -> dict:
|
||||
"patches": patches_data.get("patches") or [],
|
||||
"patch_lookup": patches_data.get("lookup") or {},
|
||||
"patch_details": patches_data.get("details") or {},
|
||||
"leaderboards": leaderboards,
|
||||
"pro_matches": pro_matches,
|
||||
"streamers": streamers,
|
||||
"stratz_hero_meta": stratz_meta,
|
||||
"stratz_matchup_tops": stratz_matchups,
|
||||
"mechanic_query": mechanic_query_payload(),
|
||||
"meta": {
|
||||
"relations_path": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
|
||||
"grid_order_path": str(GRID_ORDER_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"hero_items_path": str(HERO_ITEMS_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"hero_stats_path": str(HERO_STATS_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"hero_matches_path": str(HERO_MATCHES_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
"hero_item_fears_path": str(HERO_ITEM_FEARS_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
@@ -393,15 +656,33 @@ def build_payload() -> dict:
|
||||
"\\", "/"
|
||||
),
|
||||
"item_shop_path": str(ITEM_SHOP_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"leaderboards_path": str(LEADERBOARDS_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
"streamers_path": str(STREAMERS_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"stratz_hero_meta_path": str(STRATZ_HERO_META_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
"stratz_matchup_tops_path": str(
|
||||
STRATZ_MATCHUP_TOPS_PATH.relative_to(ROOT)
|
||||
).replace("\\", "/"),
|
||||
"source": (rel.get("meta") or {}).get("source"),
|
||||
"counters": len(rel.get("counters") or []),
|
||||
"synergies": len(rel.get("synergies") or []),
|
||||
"item_heroes": len(items.get("by_hero") or {}),
|
||||
"stats_heroes": len(hero_stats.get("by_hero") or {}),
|
||||
"match_heroes": len(hero_matches.get("by_hero") or {}),
|
||||
"fear_heroes": len(fears.get("by_hero") or {}),
|
||||
"ability_heroes": len(abilities.get("by_hero") or {}),
|
||||
"shop_items": len(shop.get("items") or {}),
|
||||
"patches": len(patches_data.get("patches") or []),
|
||||
"patch_details": len(patches_data.get("details") or {}),
|
||||
"leaderboard_regions": len(leaderboards.get("regions") or {}),
|
||||
"pro_match_players": len(pro_matches.get("by_pro") or {}),
|
||||
"pro_match_heroes": len(pro_matches.get("by_hero") or {}),
|
||||
"streamers": len(streamers.get("streamers") or []),
|
||||
"stratz_meta_heroes": len(stratz_meta.get("by_hero") or {}),
|
||||
"stratz_matchup_heroes": len(stratz_matchups.get("by_hero") or {}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -419,12 +700,69 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; media-src 'self'",
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; "
|
||||
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
|
||||
)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_file(self, fpath: Path, content_type: str) -> None:
|
||||
"""Stream a file with optional HTTP Range (needed for HTML5 video seek)."""
|
||||
size = fpath.stat().st_size
|
||||
range_hdr = self.headers.get("Range") or self.headers.get("range")
|
||||
start, end = 0, size - 1
|
||||
code = 200
|
||||
if range_hdr and range_hdr.startswith("bytes=") and size > 0:
|
||||
spec = range_hdr[len("bytes=") :].strip()
|
||||
if "," not in spec:
|
||||
left, _, right = spec.partition("-")
|
||||
try:
|
||||
if left == "" and right:
|
||||
# suffix bytes: bytes=-N
|
||||
suffix = int(right)
|
||||
start = max(0, size - suffix)
|
||||
else:
|
||||
start = int(left) if left else 0
|
||||
end = int(right) if right else size - 1
|
||||
if start < 0 or end >= size or start > end:
|
||||
self.send_response(416)
|
||||
self.send_header("Content-Range", f"bytes */{size}")
|
||||
self.end_headers()
|
||||
return
|
||||
code = 206
|
||||
except ValueError:
|
||||
start, end = 0, size - 1
|
||||
code = 200
|
||||
length = end - start + 1
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Length", str(length))
|
||||
if code == 206:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; "
|
||||
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
|
||||
)
|
||||
self.end_headers()
|
||||
with fpath.open("rb") as fh:
|
||||
fh.seek(start)
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = fh.read(min(1 << 20, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
def _json(self, code: int, obj: object) -> None:
|
||||
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
@@ -452,9 +790,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/rank/"):
|
||||
key = path[len("/rank/") :]
|
||||
allowed = {f"rank_icon_{i}.png" for i in range(1, 9)}
|
||||
if key not in allowed:
|
||||
self._json(400, {"error": "bad rank icon"})
|
||||
return
|
||||
fpath = RANK_ICONS / key
|
||||
if not fpath.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/ui-icon/"):
|
||||
key = path[len("/ui-icon/") :]
|
||||
if key not in ("cooldown.png", "dota2_logo.png"):
|
||||
allowed_ui = {
|
||||
"cooldown.png",
|
||||
"dota2_logo.png",
|
||||
"dota2_logo_wordmark.png",
|
||||
"platform_douyin.png",
|
||||
}
|
||||
if key not in allowed_ui:
|
||||
self._json(400, {"error": "bad ui icon"})
|
||||
return
|
||||
fpath = UI_ICONS / key
|
||||
@@ -463,6 +819,41 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/streamer-avatar/"):
|
||||
key = path[len("/streamer-avatar/") :]
|
||||
if "/" in key or "\\" in key or ".." in key:
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
if not (key.endswith(".jpg") or key.endswith(".jpeg") or key.endswith(".png") or key.endswith(".webp")):
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
fpath = STREAMER_AVATARS / key
|
||||
if not fpath.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
ctype = mimetypes.guess_type(key)[0] or "image/jpeg"
|
||||
self._send(200, fpath.read_bytes(), ctype)
|
||||
return
|
||||
if path.startswith("/streamer-video/"):
|
||||
key = path[len("/streamer-video/") :]
|
||||
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")):
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
if key.startswith("_"):
|
||||
self.send_error(404)
|
||||
return
|
||||
fpath = STREAMER_VIDEOS / key
|
||||
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)
|
||||
return
|
||||
if path.startswith("/portrait/") or path.startswith("/cdn/"):
|
||||
prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/"
|
||||
key = path[len(prefix) :]
|
||||
@@ -570,7 +961,7 @@ def main() -> None:
|
||||
|
||||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
url = f"http://{args.host}:{args.port}/"
|
||||
print(f"relations preview: {url}")
|
||||
print(f"Climperor web: {url}")
|
||||
print(f"edit JSON then refresh: {DEFAULT_RELATIONS}")
|
||||
if not args.no_browser:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user