Add PC post-match player pages with opt-in public OSS sync.

Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-31 11:01:32 +08:00
co-authored by Cursor
parent d6f7c3f0f5
commit 4a61aeeb26
16 changed files with 1684 additions and 23 deletions
+74 -2
View File
@@ -4,8 +4,9 @@ Usage:
python serve_relations.py
python serve_relations.py --port 8765
Hero relations, rankings, streamers, mechanics, items, patches — read-only browser UI.
Edit data/*.json directly, then refresh the page.
Hero relations, rankings, streamers, mechanics, items, patches, players —
read-only browser UI. Edit data/*.json directly, then refresh the page.
Player pages: GET /api/players/{account_id}[/{match_id}] from pc/player_pages/.
"""
from __future__ import annotations
@@ -38,6 +39,7 @@ from shared.paths import (
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
PC_PLAYER_PAGES,
RANK_ICONS,
ROLE_ICONS,
ROOT,
@@ -943,6 +945,9 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/mobile-demand":
self._json(200, {"count": _read_mobile_demand_count()})
return
if path.startswith("/api/players/"):
self._serve_player_api(path)
return
if path.startswith("/attr/"):
key = path[len("/attr/") :]
if key not in ("str.png", "agi.png", "int.png", "all.png"):
@@ -1172,6 +1177,7 @@ class Handler(BaseHTTPRequestHandler):
"mechanics",
"items",
"patches",
"players",
}
first = path.strip("/").split("/", 1)[0] if path.strip("/") else ""
if first in spa_pages:
@@ -1181,11 +1187,77 @@ class Handler(BaseHTTPRequestHandler):
return
self.send_error(404)
def _serve_player_api(self, path: str) -> None:
"""GET /api/players/{account_id}[/match_id] from pc/player_pages/."""
parts = [p for p in path.strip("/").split("/") if p]
# ["api", "players", account_id] or + match_id
if len(parts) < 3 or parts[0] != "api" or parts[1] != "players":
self._json(400, {"error": "bad players path"})
return
account_id = parts[2]
if not account_id.isdigit():
self._json(400, {"error": "bad account_id"})
return
if len(parts) == 3:
fpath = PC_PLAYER_PAGES / account_id / "profile.json"
if not fpath.is_file():
self._json(404, {"error": "profile not found", "account_id": account_id})
return
try:
self._json(200, json.loads(fpath.read_text(encoding="utf-8")))
except (OSError, ValueError) as e:
self._json(500, {"error": str(e)})
return
if len(parts) == 4:
match_id = parts[3]
if not match_id.isdigit():
self._json(400, {"error": "bad match_id"})
return
fpath = PC_PLAYER_PAGES / account_id / "matches" / f"{match_id}.json"
if not fpath.is_file():
self._json(
404,
{
"error": "match not found",
"account_id": account_id,
"match_id": match_id,
},
)
return
try:
self._json(200, json.loads(fpath.read_text(encoding="utf-8")))
except (OSError, ValueError) as e:
self._json(500, {"error": str(e)})
return
self._json(400, {"error": "bad players path"})
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/mobile-demand":
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
return
if path == "/api/players/publish":
# Local dev: no OSS write; PC already wrote pc/player_pages/.
length = int(self.headers.get("Content-Length", 0) or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
self._json(400, {"error": "invalid json"})
return
account_id = body.get("account_id")
match_id = body.get("match_id")
self._json(
200,
{
"ok": True,
"local": True,
"message": "local publish no-op; files under pc/player_pages/",
"account_id": account_id,
"match_id": match_id,
},
)
return
self.send_error(404)