Compare commits

...
2 Commits
Author SHA1 Message Date
vosonandCursor 4a61aeeb26 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>
2026-07-31 11:01:32 +08:00
vosonandCursor d6f7c3f0f5 Keep shop/item meta in git and fix category icon filenames.
Drop the accidental itemcat_*.png.png downloads by stripping the
existing .png suffix before download_icons appends one. Leave daily
leaderboards/streamers and ignored OpenDota/STRATZ caches uncommitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:36:26 +08:00
19 changed files with 1717 additions and 44 deletions
+1
View File
@@ -17,6 +17,7 @@ pc/preview/
pc/results/
pc/failures/
pc/samples/raw/
pc/player_pages/
_tools/
_tmp_*
_cmp_*
+11 -6
View File
@@ -33,7 +33,8 @@ climperor/
| `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载;re-export `shared.paths` 常量 |
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装 |
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装`POST_GAME` 触发 `player_pages` 赛后建页 |
| `pc/player_pages.py` | 赛后轮询 OpenDota → `pc/player_pages/{account_id}/`profile + match JSON);`public_share` 时 POST `/api/players/publish`**不**进 recommend |
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
@@ -53,7 +54,7 @@ climperor/
| 路径 | 职责 |
|------|------|
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`PC_PLAYER_PAGES`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
| `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
| `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
| `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
@@ -71,7 +72,7 @@ climperor/
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;History 深度路径 SPA fallback 回 `index.html``/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;History 深度路径 SPA fallback 回 `index.html``/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`GET /api/players/{account_id}[/{match_id}]``pc/player_pages/``POST /api/players/publish` 本地 no-op`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` / `SITE_ORIGIN``web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` / `--site-origin``config.js`;设 `--static-asset-base` 时不拷贝图标进 dist;调用 `seo_prerender.py` 写英雄/机制预渲染 HTML + `sitemap.xml` / `llms.txt`;拷贝 `_redirects` / `robots.txt``--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
| `web/seo_prerender.py` | 导出期 SEO/GEO:注入 title/description/canonical/OG/JSON-LD 与 `#seo-prerender` 正文;生成全英雄 `/heroes/{key}`、机制 `/mechanics/{effect}`、顶层页、`sitemap.xml``llms.txt` |
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
@@ -94,6 +95,7 @@ climperor/
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json``is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info``live_status==1` 在播,轮播算下播),斗鱼走 `betard/{room_id}``show_status==1` 在播,`videoLoop==1` 轮播算下播);探测失败清为 `is_live:false` 并去掉 `live_probed_at`(与 `/api/live-status` 一致,不沿用旧直播中)、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily(角标以访问触发的 live API 为准,daily 仅作 data.json 兜底) |
| `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`,含抖音 / B 站 / 斗鱼),读 `data.json``streamers.streamers`Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 失败主播一律 `is_live:false` + `stale:true`(**不**沿用旧的直播中);全失败回 `stale-override` 空角标表或 `error`,永不 500;导出时拷贝 `functions/`**部署须 `cwd=dist` 跑 wrangler**Functions 相对 cwd 解析) |
| `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py``web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 |
| `web/frontend/functions/api/players/publish.js` | Pages Function `POST /api/players/publish`:校验场内 `account_id` 后拉 OpenDota、写 OSS `players/{id}/profile.json``matches/{match_id}.json`Secrets`OSS_*`、可选 `PLAYER_PAGES_PUBLISH_SECRET`);**不**进主 `data.json` / recommend |
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__``app.js` 跳过桌面 boot |
| `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `web/data/item_shop.json` + 图标 |
| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json``%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py` |
@@ -123,8 +125,8 @@ climperor/
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` |
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations |
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `_redirects` / `robots.txt` / `functions/`);`config.js``SITE_VERSION``SITE_ORIGIN``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;英雄页底部(无详情时)显示 `v{SITE_VERSION}` 与数据更新时间;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截(搜索/AI 爬虫 UA 跳过) |
| `web/frontend/router.js` | History 路径路由:`parseHash` / `serializeHash`(操作 pathname+search/ `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层 `/heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal `/rankings[/region]` / 明星比赛 `/matches[/account_id][?origin=pro\|china][&page=N]` / 主播 `/streamers` / 走势 `/trends[/bracket][?sort=pr]` / 机制 `/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `/rankings/meta` 与 hash `#/...` 兼容) |
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `_redirects` / `robots.txt` / `functions/`);`config.js``SITE_VERSION``SITE_ORIGIN``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE``PLAYERS_ASSET_BASE`;英雄页底部(无详情时)显示 `v{SITE_VERSION}` 与数据更新时间;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截(搜索/AI 爬虫 UA 跳过) |
| `web/frontend/router.js` | History 路径路由:`parseHash` / `serializeHash`(操作 pathname+search/ `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层 `/heroes\|rankings\|streamers\|matches\|players\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal `/rankings[/region]` / 明星比赛 `/matches[/account_id][?origin=pro\|china][&page=N]` / PC 赛后玩家页 `/players/{account_id}[/{match_id}]`(本机 API 或 OSS `players/`;默认私有) / 主播 `/streamers` / 走势 `/trends[/bracket][?sort=pr]` / 机制 `/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `/rankings/meta` 与 hash `#/...` 兼容) |
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` |
@@ -140,6 +142,7 @@ climperor/
运行时产物(**勿提交**,见 `.gitignore`):
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后另存 OSS `players/`
- `pc/preview/` — 标定 / sheet 预览
- `web/dist/` — 静态站点导出(`export_relations_site.py`
- `pc/results/` — 每局 JSON
@@ -160,6 +163,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
→ overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
→ pc/results/draft_*.json + 终端时间线
→ POST_GAMEplayer_pages 轮询 OpenDota → pc/player_pages/(可选 publish → OSS players/
```
## 技术约束(修改前必读)
@@ -175,6 +179,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
- **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null`
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
- **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` 时由边缘 ingest 写 OSS `players/`PC **不**内置 OSS 密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 OSS**禁止**塞进主 `data.json`**禁止**进 recommend。需玩家开启「公开比赛数据」。
## 开发命令
@@ -224,7 +229,7 @@ python pc/evaluate.py
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`Cloudflare)与 `web/serve_relations.py` 的 SPA fallback;预渲染路径变更时同步 `web/seo_prerender.py` 与导出文件列表(含 `router.js` / `_redirects` / `robots.txt`)。
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`Cloudflare)与 `web/serve_relations.py` 的 SPA fallback(含 `players`;预渲染路径变更时同步 `web/seo_prerender.py` 与导出文件列表(含 `router.js` / `_redirects` / `robots.txt`)。
-`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` / `item_suggest` 缺口与应对装共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py``SITE_VERSION``web/frontend/config.js` 的同名变量,以及 `index.html``style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md``SITE_VERSION` 语义:末位 = 增量 UI/修复;中段 = 壳层 / 路由 / 可索引或其它阶段性能力成型(如 `0.5.x``0.6.0`);数据-only 刷新不 bump。
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
+6
View File
@@ -17,6 +17,12 @@
- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。
## [0.6.16] - 2026-07-31
### Added
- PC 赛后玩家页:进入 `POST_GAME` 后异步拉取 OpenDota,本机生成 `/players/{account_id}` 主页与 `/players/{account_id}/{match_id}` 十人战绩详情(参战/伤害/KDA/装备/MVP);默认私有(`pc/player_pages/`),`player_pages.public_share=true` 时经 `/api/players/publish` 同步到 OSS 公开。
## [0.6.15] - 2026-07-31
### Added
+11
View File
@@ -146,6 +146,17 @@
"target_slots": 10,
"dump_payloads": true
},
"player_pages": {
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true also POSTs /api/players/publish (OSS). Default private.",
"enabled": true,
"public_share": false,
"recent_limit": 30,
"poll_attempts": 12,
"poll_base_seconds": 30,
"publish_url": "https://dota2.refining.dev/api/players/publish",
"publish_secret": "",
"local_web_origin": "http://127.0.0.1:8765"
},
"overlay": {
"comment": "克/搭/补 marks + lineup analysis banner + post-lock item icon bar (no role tags under top-bar).",
"enabled": true,
+19
View File
@@ -37,6 +37,7 @@ from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_
from common import ROOT, load_config, load_template_library
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
from overlay import DraftOverlay
from player_pages import POST_GAME, schedule_post_game
from recognize import recognize_image
from roles import detect_roles
@@ -81,6 +82,8 @@ class Watcher:
except Exception as e: # noqa: BLE001
print(f"[draft] overlay disabled: {e}", flush=True)
self.overlay = None
# Dedup post-game player-page jobs per match (in addition to module lock).
self._player_page_matches: set[str] = set()
def on_payload(self, payload: dict) -> None:
if not self.connected:
@@ -123,7 +126,11 @@ class Watcher:
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
prev = self.last_state
self.last_state = state
# First transition into POST_GAME → build local /players pages.
if state == POST_GAME and prev != POST_GAME:
self._maybe_schedule_player_page(match_id)
if state not in self.trigger_states:
return
@@ -144,6 +151,18 @@ class Watcher:
except OSError as e:
print(f"[gsi] dump failed: {e}", flush=True)
def _maybe_schedule_player_page(self, match_id: str) -> None:
if not match_id or match_id == "no-match":
return
if match_id in self._player_page_matches:
return
self._player_page_matches.add(match_id)
schedule_post_game(
self.cfg,
match_id=match_id,
account_id=self.self_info.get("accountid"),
)
def track(self, match_id: str, state: str, key: str) -> dict | None:
"""Follow the draft from here to the end, recording every reveal."""
if not self.busy.acquire(blocking=False):
+493
View File
@@ -0,0 +1,493 @@
"""Post-match player pages: OpenDota → local profile/match JSON (+ optional publish).
Triggered from gsi_watch on POST_GAME. Writes:
pc/player_pages/{account_id}/profile.json
pc/player_pages/{account_id}/matches/{match_id}.json
When player_pages.public_share is true, also POSTs to the site ingest so OSS
serves the same paths for /players/{account_id}[/{match_id}].
Not used by recommend / item_suggest.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import Any
from shared.grid import hero_table
from shared.http_utils import UA, write_json_atomic
from shared.paths import PC_PLAYER_PAGES
OPENDOTA = "https://api.opendota.com/api"
POST_GAME = "DOTA_GAMERULES_STATE_POST_GAME"
_lock = threading.Lock()
_in_flight: set[str] = set()
_done: set[str] = set()
_hero_by_id: dict[int, dict] | None = None
def _utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _pp_cfg(cfg: dict) -> dict:
raw = cfg.get("player_pages") or {}
return raw if isinstance(raw, dict) else {}
def _hero_lookup() -> dict[int, dict]:
global _hero_by_id
if _hero_by_id is None:
out: dict[int, dict] = {}
for h in hero_table():
try:
hid = int(h.get("id") or 0)
except (TypeError, ValueError):
continue
if hid > 0:
out[hid] = h
_hero_by_id = out
return _hero_by_id
def _int(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def _item_ids(player: dict) -> list[int]:
out: list[int] = []
for i in range(6):
iid = _int(player.get(f"item_{i}"), 0)
if iid > 0:
out.append(iid)
return out
def _kda(kills: int, deaths: int, assists: int) -> float:
return round((kills + assists) / max(deaths, 1), 1)
def _mvp_score(p: dict) -> float:
"""Simple weighted score for MVP badge (not Valve's formula)."""
k = _int(p.get("kills"))
d = _int(p.get("deaths"))
a = _int(p.get("assists"))
dmg = _int(p.get("hero_damage"))
nw = _int(p.get("net_worth"))
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / max(d, 1)
def normalize_match(
match: dict,
*,
focus_account_id: int | None = None,
) -> dict | None:
"""Build Climperor match-detail JSON from an OpenDota /matches/{id} payload."""
players_raw = match.get("players")
if not isinstance(players_raw, list) or not players_raw:
return None
match_id = _int(match.get("match_id"), 0)
if match_id <= 0:
return None
heroes = _hero_lookup()
radiant_win = bool(match.get("radiant_win"))
duration = _int(match.get("duration"))
start_time = match.get("start_time")
try:
start_time_i = int(start_time) if start_time is not None else None
except (TypeError, ValueError):
start_time_i = None
team_kills = [0, 0]
team_nw = [0, 0]
team_dmg = [0, 0]
slim_players: list[dict] = []
for p in players_raw:
if not isinstance(p, dict):
continue
slot = _int(p.get("player_slot"))
is_radiant = slot < 128
side = 0 if is_radiant else 1
kills = _int(p.get("kills"))
deaths = _int(p.get("deaths"))
assists = _int(p.get("assists"))
hero_damage = _int(p.get("hero_damage"))
net_worth = _int(p.get("net_worth"))
if net_worth <= 0:
net_worth = _int(p.get("gold")) + _int(p.get("gold_spent"))
team_kills[side] += kills
team_nw[side] += net_worth
team_dmg[side] += hero_damage
hero_id = _int(p.get("hero_id"))
hero = heroes.get(hero_id) or {}
account_id = p.get("account_id")
try:
account_id_i = int(account_id) if account_id is not None else None
except (TypeError, ValueError):
account_id_i = None
personaname = p.get("personaname")
if isinstance(personaname, str):
personaname = personaname.strip() or None
else:
personaname = None
slim_players.append(
{
"account_id": account_id_i,
"personaname": personaname,
"hero_id": hero_id,
"hero_key": hero.get("key"),
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
"level": _int(p.get("level")),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": _kda(kills, deaths, assists),
"hero_damage": hero_damage,
"net_worth": net_worth,
"items": _item_ids(p),
"is_radiant": is_radiant,
"won": radiant_win if is_radiant else not radiant_win,
"_mvp": _mvp_score(p),
"_side": side,
}
)
if len(slim_players) < 2:
return None
for p in slim_players:
side = p.pop("_side")
tk = team_kills[side] or 1
td = team_dmg[side] or 1
p["participation"] = round((p["kills"] + p["assists"]) / tk, 3)
p["damage_share"] = round(p["hero_damage"] / td, 3)
mvp = max(slim_players, key=lambda r: r["_mvp"])
mvp_account = mvp.get("account_id")
for p in slim_players:
p["is_mvp"] = bool(mvp_account is not None and p.get("account_id") == mvp_account)
del p["_mvp"]
return {
"match_id": match_id,
"start_time": start_time_i,
"duration": duration,
"radiant_win": radiant_win,
"radiant": {"kills": team_kills[0], "net_worth": team_nw[0]},
"dire": {"kills": team_kills[1], "net_worth": team_nw[1]},
"mvp_account_id": mvp_account,
"players": slim_players,
"focus_account_id": focus_account_id,
"fetched_at": _utc_now(),
"source": "opendota",
}
def match_summary_for_profile(detail: dict, account_id: int) -> dict | None:
"""One recent-match row for the player homepage."""
focus = None
for p in detail.get("players") or []:
if p.get("account_id") == account_id:
focus = p
break
if focus is None:
return None
return {
"match_id": detail["match_id"],
"start_time": detail.get("start_time"),
"duration": detail.get("duration"),
"won": bool(focus.get("won")),
"hero_id": focus.get("hero_id"),
"hero_key": focus.get("hero_key"),
"hero_name_loc": focus.get("hero_name_loc"),
"kills": focus.get("kills"),
"deaths": focus.get("deaths"),
"assists": focus.get("assists"),
"kda": focus.get("kda"),
}
def account_in_match(match: dict, account_id: int) -> bool:
for p in match.get("players") or []:
if not isinstance(p, dict):
continue
try:
if int(p.get("account_id")) == account_id:
return True
except (TypeError, ValueError):
continue
return False
def _fetch_opendota_match(match_id: int) -> dict | None:
url = f"{OPENDOTA}/matches/{match_id}"
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=45) as resp:
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None
if not isinstance(data, dict) or not data.get("players"):
return None
return data
def profile_path(account_id: int) -> Path:
return PC_PLAYER_PAGES / str(account_id) / "profile.json"
def match_path(account_id: int, match_id: int) -> Path:
return PC_PLAYER_PAGES / str(account_id) / "matches" / f"{match_id}.json"
def load_profile(account_id: int) -> dict:
path = profile_path(account_id)
if not path.is_file():
return {
"account_id": account_id,
"personaname": None,
"public_share": False,
"updated_at": None,
"recent": [],
}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
data = {}
if not isinstance(data, dict):
data = {}
data.setdefault("account_id", account_id)
data.setdefault("recent", [])
return data
def upsert_profile(
account_id: int,
*,
summary: dict,
personaname: str | None,
public_share: bool,
recent_limit: int,
) -> dict:
profile = load_profile(account_id)
recent = [r for r in (profile.get("recent") or []) if isinstance(r, dict)]
mid = summary["match_id"]
recent = [r for r in recent if r.get("match_id") != mid]
recent.insert(0, summary)
profile["recent"] = recent[: max(1, recent_limit)]
if personaname:
profile["personaname"] = personaname
profile["public_share"] = bool(public_share)
profile["updated_at"] = _utc_now()
profile["account_id"] = account_id
write_json_atomic(profile_path(account_id), profile)
return profile
def publish_remote(
*,
account_id: int,
match_id: int,
publish_url: str,
publish_secret: str = "",
) -> tuple[bool, str]:
"""POST ingest; returns (ok, message)."""
url = (publish_url or "").strip()
if not url:
return False, "publish_url empty"
body = json.dumps({"account_id": account_id, "match_id": match_id}).encode("utf-8")
headers = {
"User-Agent": UA,
"Content-Type": "application/json",
"Accept": "application/json",
}
secret = (publish_secret or "").strip()
if secret:
headers["X-Climperor-Publish-Secret"] = secret
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode("utf-8", errors="replace")
code = getattr(resp, "status", 200)
except urllib.error.HTTPError as e:
try:
raw = e.read().decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
raw = str(e)
return False, f"HTTP {e.code}: {raw[:200]}"
except (urllib.error.URLError, TimeoutError, OSError) as e:
return False, str(e)
if code in (200, 201, 202):
return True, raw[:200] or f"HTTP {code}"
return False, f"HTTP {code}: {raw[:200]}"
def process_post_game(
cfg: dict,
*,
match_id: str | int,
account_id: str | int | None,
) -> None:
"""Poll OpenDota, write local pages, optionally publish. Runs in a worker thread."""
pp = _pp_cfg(cfg)
if not bool(pp.get("enabled", True)):
return
try:
mid = int(match_id)
except (TypeError, ValueError):
print(f"[player_pages] skip bad match_id={match_id!r}", flush=True)
return
if mid <= 0:
return
try:
aid = int(account_id) if account_id is not None else 0
except (TypeError, ValueError):
aid = 0
if aid <= 0:
print(f"[player_pages] skip match {mid}: no accountid (anonymous?)", flush=True)
return
key = f"{aid}:{mid}"
with _lock:
if key in _done or key in _in_flight:
return
_in_flight.add(key)
try:
attempts = max(1, _int(pp.get("poll_attempts"), 12))
base = max(5, _int(pp.get("poll_base_seconds"), 30))
recent_limit = max(1, _int(pp.get("recent_limit"), 30))
public_share = bool(pp.get("public_share", False))
print(
f"[player_pages] fetching match {mid} for account {aid} "
f"(up to {attempts} tries)…",
flush=True,
)
match: dict | None = None
for i in range(attempts):
if i > 0:
delay = min(300, base * (2 ** min(i - 1, 3)))
time.sleep(delay)
try:
match = _fetch_opendota_match(mid)
except Exception as e: # noqa: BLE001
print(f"[player_pages] OpenDota error: {e}", flush=True)
match = None
if match and account_in_match(match, aid):
break
if match and not account_in_match(match, aid):
print(
f"[player_pages] match {mid} has no account {aid} — skip",
flush=True,
)
return
match = None
print(
f"[player_pages] match {mid} not ready ({i + 1}/{attempts})",
flush=True,
)
if not match:
print(f"[player_pages] gave up waiting for match {mid}", flush=True)
return
detail = normalize_match(match, focus_account_id=aid)
if not detail:
print(f"[player_pages] normalize failed for {mid}", flush=True)
return
write_json_atomic(match_path(aid, mid), detail)
summary = match_summary_for_profile(detail, aid)
if not summary:
print(f"[player_pages] focus player missing in {mid}", flush=True)
return
personaname = None
for p in detail["players"]:
if p.get("account_id") == aid and p.get("personaname"):
personaname = p["personaname"]
break
profile = upsert_profile(
aid,
summary=summary,
personaname=personaname,
public_share=public_share,
recent_limit=recent_limit,
)
local_origin = str(pp.get("local_web_origin") or "http://127.0.0.1:8765").rstrip(
"/"
)
print(
f"[player_pages] saved {profile_path(aid)} + match {mid}",
flush=True,
)
print(
f"[player_pages] local: {local_origin}/players/{aid}/{mid}",
flush=True,
)
if not public_share:
print(
"[player_pages] private (set player_pages.public_share=true to sync to site)",
flush=True,
)
else:
pub_url = str(pp.get("publish_url") or "").strip()
ok, msg = publish_remote(
account_id=aid,
match_id=mid,
publish_url=pub_url,
publish_secret=str(pp.get("publish_secret") or ""),
)
if ok:
print(f"[player_pages] published: {msg}", flush=True)
else:
print(f"[player_pages] publish failed: {msg}", flush=True)
with _lock:
_done.add(key)
finally:
with _lock:
_in_flight.discard(key)
def schedule_post_game(
cfg: dict,
*,
match_id: str | int,
account_id: str | int | None,
) -> None:
"""Fire-and-forget worker; safe to call from the GSI HTTP thread."""
pp = _pp_cfg(cfg)
if not bool(pp.get("enabled", True)):
return
threading.Thread(
target=process_post_game,
kwargs={"cfg": cfg, "match_id": match_id, "account_id": account_id},
daemon=True,
name=f"player_pages-{match_id}",
).start()
+2
View File
@@ -21,6 +21,8 @@ RELATIONS_JSON = SHARED_DATA / "relations.json"
PC_DIR = ROOT / "pc"
PC_CONFIG = PC_DIR / "config.json"
TEMPLATES_CDN = PC_DIR / "templates" / "cdn"
# Post-match player pages (profile + match JSON; gitignored; local/OSS).
PC_PLAYER_PAGES = PC_DIR / "player_pages"
# Web subproject locations. DATA keeps its historical name: every web-side
# JSON cache lives here (hero_stats, patches, streamers, ...).
+1 -1
View File
@@ -2,7 +2,7 @@
"meta": {
"source": "dota2.com.cn/itemscategory+opendota+valve",
"layout": "cn_shop_columns",
"fetched_at": "2026-07-26T16:51:48.394780+00:00",
"fetched_at": "2026-07-31T01:51:04.979414+00:00",
"basic_count": 75,
"upgraded_count": 108,
"craft_refs": 171
+22 -19
View File
@@ -2,7 +2,7 @@
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": "2026-07-28T07:51:14.752604+00:00",
"fetched_at": "2026-07-31T01:54:18.841099+00:00",
"tag_order": [
"basic_dispel",
"strong_dispel",
@@ -601,7 +601,8 @@
"desc_loc": "切换:转变\n在25%技能增强加成和250攻击力加成之间切换。\n\n被动:永恒\n 死亡后掉落,而且无法被摧毁。\n\n当拥有者的队友拾取后,仅在返还给拥有者后才会有效。一旦被敌人拾取,圣剑将不再受此限制,信使无法拾取掉落的圣剑。",
"desc_en": "Toggle: Transmute\n Toggle to gain either 25% bonus spell amplification or 250 bonus attack damage.\n\nPassive: Everlasting\n Dropped on death, and cannot be destroyed.\n\nBecomes unusable if picked up by an ally of its owner until it is returned to its owner. It is immediately usable by anybody if an enemy of the owner picks it up and is killed. A dropped Rapier cannot be picked up by a courier.",
"notes_loc": [
"如果圣剑掉落后被拥有者的敌人拾取,那么只有在敌人死亡后才会再次掉落。"
"如果圣剑掉落后被拥有者的敌人拾取,那么只有在敌人死亡后才会再次掉落。",
"多个圣剑的技能增强不会叠加。"
],
"dispellable": 0,
"immunity": 0,
@@ -669,11 +670,13 @@
"dname": "Daedalus",
"name_loc": "代达罗斯之殇",
"cost": 5100,
"desc_loc": "",
"desc_en": "Passive: Critical Strike\nGrants each attack a ?% chance to deal ?% damage.",
"notes_loc": [],
"dispellable": null,
"immunity": null,
"desc_loc": "被动:致命一击\n普通攻击有30%几率造成225%伤害。",
"desc_en": "Passive: Critical Strike\nGrants each attack a 30% chance to deal 225% damage.",
"notes_loc": [
"致命一击对建筑无效。"
],
"dispellable": 0,
"immunity": 0,
"ability_kinds": [
"passive"
],
@@ -865,8 +868,8 @@
"dname": "Eye of Skadi",
"name_loc": "斯嘉蒂之眼",
"cost": 5900,
"desc_loc": "被动:霜冻攻击\n攻击对敌方远程目标会减缓-50%移动速度,对敌方近战目标会减缓-25%移动速度。攻击还会降低敌人-20%攻击速度和50%生命回复。持续3秒。",
"desc_en": "Passive: Cold Attack\n Attacks lower enemy movement by -25% if they are melee and -50% if they are ranged. Attacks also lower enemy attack speed by -20% and Health Restoration by 50%. Lasts for 3 seconds.",
"desc_loc": "被动:霜冻攻击\n攻击对敌方远程目标会减缓-50%移动速度,对敌方近战目标会减缓-25%移动速度。攻击还会降低敌人-25%攻击速度和50%生命回复。持续3秒。",
"desc_en": "Passive: Cold Attack\n Attacks lower enemy movement by -25% if they are melee and -50% if they are ranged. Attacks also lower enemy attack speed by -25% and Health Restoration by 50%. Lasts for 3 seconds.",
"notes_loc": [],
"dispellable": 0,
"immunity": 0,
@@ -969,8 +972,8 @@
"dname": "Mask of Madness",
"name_loc": "疯狂面具",
"cost": 1900,
"desc_loc": "主动:狂热\n攻击速度提升100,移动速度提升8%/12%(远程/近战),减速抗性提升30%,但是护甲降低7点,并且被沉默。持续6秒。\n\n被动:吸血\n攻击者每次攻击都将根据造成伤害的一定百分比回复生命值。",
"desc_en": "Active: Berserk\nGives 100 attack speed, 8% / 12% movement speed (ranged/melee), and 30% slow resistance, but reduces your armor by 7 and silences you. Lasts 6 seconds.\n\nPassive: Lifesteal\nHeals the attacker for a percentage of physical damage dealt.",
"desc_loc": "主动:狂热\n攻击速度提升100,移动速度提升6%/12%(远程/近战),减速抗性提升15%/30%(远程/近战),但是护甲降低7点,并且被沉默。持续6秒。\n\n被动:吸血\n攻击者每次攻击都将根据造成伤害的一定百分比回复生命值。",
"desc_en": "Active: Berserk\nGives 100 attack speed, 6% / 12% movement speed (ranged/melee), and 15%/30% slow resistance (ranged/melee), but reduces your armor by 7 and silences you. Lasts 6 seconds.\n\nPassive: Lifesteal\nHeals the attacker for a percentage of physical damage dealt.",
"notes_loc": [],
"dispellable": 2,
"immunity": 0,
@@ -1251,7 +1254,7 @@
"dname": "Heaven's Halberd",
"name_loc": "天堂之戟",
"cost": 3400,
"desc_loc": "主动:缴械\n缴械目标在3.5秒内进行攻击。",
"desc_loc": "主动:缴械\n阻止目标在3.5秒内进行攻击。",
"desc_en": "Active: Disarm\nPrevents a target from attacking for 3.5 seconds.",
"notes_loc": [],
"dispellable": 1,
@@ -1555,8 +1558,8 @@
"dname": "Hurricane Pike",
"name_loc": "飓风长戟",
"cost": 4450,
"desc_loc": "主动:飓风之力\n 将自己和敌方目标朝相反方向各推开425距离,并且接下来对目标的5次攻击将不受英雄攻击距离的限制,同时获得+100攻击速度,持续6秒。\n\n对自身或友军使用,向面对方向推进600距离。\n对敌军施法距离:425",
"desc_en": "Active: Hurricane Thrust\n Pushes you and target enemy 425 units away from each other, and for 6 seconds, allows you to make 5 attacks against the target without range restrictions and with +100 attack speed.\n\nCan be cast on self or allies to push the target 600 units in the direction it is facing.\nEnemy Range: 425",
"desc_loc": "主动:飓风之力\n 将自己和敌方目标朝相反方向各推开425距离,并且接下来对目标的5次攻击将不受英雄攻击距离的限制,同时获得+100攻击速度,持续5秒。\n\n对自身或友军使用,向面对方向推进600距离。\n对敌军施法距离:425",
"desc_en": "Active: Hurricane Thrust\n Pushes you and target enemy 425 units away from each other, and for 5 seconds, allows you to make 5 attacks against the target without range restrictions and with +100 attack speed.\n\nCan be cast on self or allies to push the target 600 units in the direction it is facing.\nEnemy Range: 425",
"notes_loc": [
"对自身施法会对自己使用飓风长戟。",
"飓风长戟不会打断目标的动作。",
@@ -2033,10 +2036,10 @@
"dname": "Consecrated Wraps",
"name_loc": "圣化护服",
"cost": 2600,
"desc_loc": "被动:神圣化\n每4秒获得一点能量,最多为3点。获得一点能量时,移动速度提升15%,持续5秒。\n\n只要受到来自玩家控制单位或肉山的伤害,所有能量都会被消耗,并获得全伤害护盾,持续5秒,每点能量可以吸收120点伤害(最高为360点)。",
"desc_en": "Passive: Hallowed\n Gain a charge every 4s, up to a maximum of 3 charges. Upon gaining a charge, your movement speed is increased by 15% for 5s.\n\nWhenever you take damage from a player-controlled unit or Roshan, consume all charges to gain an all damage barrier for 5s that absorbs 120 damage per charge (360 max).",
"desc_loc": "被动:神圣化\n每4秒获得一点能量,最多为3点。获得一点能量时,移动速度提升15%,持续5秒。\n\n只要受到来自玩家控制来源或肉山的伤害,所有能量都会被消耗,并获得全伤害护盾,持续5秒,每点能量可以吸收120点伤害(最高为360点)。",
"desc_en": "Passive: Hallowed\n Gain a charge every 4s, up to a maximum of 3 charges. Upon gaining a charge, your movement speed is increased by 15% for 5s.\n\nWhenever you take damage from a player-controlled source or Roshan, consume all charges to gain an all damage barrier for 5s that absorbs 120 damage per charge (360 max).",
"notes_loc": [
"受到来自玩家控制单位或肉山的伤害后%stack_gain_time%秒内无法获得能量。",
"受到来自玩家控制来源或肉山的伤害后%stack_gain_time%秒内无法获得能量。",
"对自身的伤害和生命流失伤害无法移除能量。",
"移动速度加成效果不会叠加。"
],
@@ -2053,8 +2056,8 @@
"dname": "Crella's Crozier",
"name_loc": "克莱拉牧杖",
"cost": 4800,
"desc_loc": "主动:鲁姆斯克仪式\n进入鬼魂形态,持续4秒,对物理伤害免疫,但是无法攻击,并且承受的魔法伤害增加-30%。\n\n每秒从900范围内的敌方英雄窃取6%移动速度。移动速度的窃取效果持续1.5秒。\n\n腐化光环效果提升至75%。每秒所有减少的生命回复都会转移到身上。\n\n被动:腐化光环\n附近敌方英雄的生命回复减少30%。\n\n作用范围:900",
"desc_en": "Active: Rite of Rumusque\n You enter ghost form for 4 seconds, becoming immune to physical damage, but are unable to attack and -30% more vulnerable to magic damage.\n\nSteal 6% movement speed from enemy heroes in 900 range every second. Movement speed steal lasts 1.5s.\n\nPutrefaction Aura's effect is increased to 75%. All of the lost Health Restoration is redirected to you every second.\n\nPassive: Putrefaction Aura\nReduces nearby enemy heroes' Health Restoration by 30%.\n\nRadius: 900",
"desc_loc": "主动:鲁姆斯克仪式\n进入鬼魂形态,持续4秒,对物理伤害免疫,但是无法攻击,并且承受的魔法伤害增加-30%。\n\n每秒从900范围内的敌方英雄窃取6%移动速度。移动速度的窃取效果持续2秒。\n\n腐化光环效果提升至90%。每秒所有减少的生命回复都会转移到身上。\n\n被动:腐化光环\n附近敌方英雄的生命回复减少30%。\n\n作用范围:900",
"desc_en": "Active: Rite of Rumusque\n You enter ghost form for 4 seconds, becoming immune to physical damage, but are unable to attack and -30% more vulnerable to magic damage.\n\nSteal 6% movement speed from enemy heroes in 900 range every second. Movement speed steal lasts 2s.\n\nPutrefaction Aura's effect is increased to 90%. All of the lost Health Restoration is redirected to you every second.\n\nPassive: Putrefaction Aura\nReduces nearby enemy heroes' Health Restoration by 30%.\n\nRadius: 900",
"notes_loc": [
"如果在开启期间进入减益免疫状态,幽魂效果将会中止。如果在减益免疫状态下开启权杖将不会拥有幽魂效果。",
"和幽魂权杖以及升级物品共享冷却时间。",
+6 -2
View File
@@ -55,7 +55,7 @@ from shared.paths import (
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.6.15"
SITE_VERSION = "0.6.16"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -149,10 +149,12 @@ def write_config_js(
static_asset_base: str,
site_version: str,
site_origin: str,
players_asset_base: str = "",
) -> None:
"""Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/")
players = (players_asset_base or "").strip().rstrip("/") or static
ver = (site_version or "").strip()
origin = (site_origin or "").strip().rstrip("/")
(out / "config.js").write_text(
@@ -160,7 +162,8 @@ def write_config_js(
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
f"var SITE_ORIGIN = {json.dumps(origin, ensure_ascii=False)};\n"
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n"
f"var PLAYERS_ASSET_BASE = {json.dumps(players, ensure_ascii=False)};\n",
encoding="utf-8",
)
@@ -306,6 +309,7 @@ def main() -> None:
print(
f" config.js SITE_VERSION={SITE_VERSION!r} SITE_ORIGIN={site_origin!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r} "
f"PLAYERS_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
print(
+10 -1
View File
@@ -337,8 +337,17 @@ def main() -> None:
saved, skipped, fail = download_shop_icons(icon_keys, force=args.force_icons)
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
print(f"downloading {len(cat_files)} category icons...", flush=True)
# CAT_ICON_BY_LABEL values already include ".png"; download_icons appends
# another ".png", so pass bare stems + a full URL template.
cat_keys = {
name[:-4] if name.endswith(".png") else name for name in cat_files
}
c_saved, c_skipped, c_fail = download_icons(
cat_files, CAT_ICON_BASE + "{key}", ITEM_CAT_ICONS, force=args.force_icons, delay=0.05
cat_keys,
CAT_ICON_BASE + "{key}.png",
ITEM_CAT_ICONS,
force=args.force_icons,
delay=0.05,
)
print(f" cat icons saved={c_saved} skipped={c_skipped} fail={c_fail}", flush=True)
+371 -4
View File
@@ -1,4 +1,4 @@
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, PLAYERS_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
function trimBase(raw) {
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
@@ -135,6 +135,7 @@ const PAGE_SEO_LABELS = {
rankings: "Immortal 排行",
streamers: "主播",
matches: "明星比赛",
players: "玩家战绩",
trends: "近 8 周走势",
mechanics: "机制查询",
items: "物品商店",
@@ -218,6 +219,18 @@ function describeStateForSeo(st) {
} else if (page === "matches") {
title = `明星比赛 — ${brand}`;
description = "明星选手近期职业与国服对局、终局出装与加点。";
} else if (page === "players") {
const aid = st.playerAccountId;
if (aid && st.playerMatchId) {
title = `比赛 ${st.playerMatchId}${brand}`;
description = `玩家 ${aid} 的比赛 ${st.playerMatchId} 战绩详情。`;
} else if (aid) {
title = `玩家 ${aid}${brand}`;
description = `玩家 ${aid} 的近期比赛与战绩(上分帝 PC 赛后生成)。`;
} else {
title = `玩家战绩 — ${brand}`;
description = "PC 上分帝赛后生成的玩家主页与比赛详情。";
}
} else if (page === "heroes") {
title = `英雄克制与搭档 — ${brand}`;
description =
@@ -257,7 +270,7 @@ function clearSeoPrerender() {
const state = {
data: null,
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
selectedKey: null,
selectedItemKey: null,
/** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */
@@ -278,6 +291,13 @@ const state = {
matchesOrigin: "all",
/** Top-level matches page: 1-based page index */
matchesPage: 1,
/** PC post-match player pages: account_id / match_id */
playerAccountId: null,
playerMatchId: null,
/** In-memory cache for /players fetches */
_playerProfile: null,
_playerMatch: null,
_playerLoadKey: null,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
@@ -741,6 +761,51 @@ function heroByKey(key) {
return (state.data.heroes || []).find((h) => h.key === key) || null;
}
function heroById(id) {
if (id == null) return null;
const n = Number(id);
return (state.data.heroes || []).find((h) => Number(h.id) === n) || null;
}
/** OSS/local base for players/*.json; empty PLAYERS_ASSET_BASE → STATIC_ASSET_BASE. */
function playersAssetBase() {
const dedicated =
typeof PLAYERS_ASSET_BASE !== "undefined" ? trimBase(PLAYERS_ASSET_BASE) : "";
return dedicated || staticAssetBase();
}
/**
* Load player profile or match detail.
* kind: "profile" | "match"
* Local: /api/players/{account}[/match]
* OSS: {base}/players/{account}/profile.json
* {base}/players/{account}/matches/{match}.json
*/
async function fetchPlayerJson(accountId, matchId) {
const aid = String(accountId || "");
if (!/^\d+$/.test(aid)) return null;
const mid = matchId != null ? String(matchId) : "";
const localPath = mid ? `/api/players/${aid}/${mid}` : `/api/players/${aid}`;
try {
const local = await fetch(localPath);
if (local.ok) return await local.json();
} catch (_) {
/* fall through to OSS */
}
const base = playersAssetBase();
if (!base) return null;
const ossPath = mid
? `${base}/players/${aid}/matches/${mid}.json`
: `${base}/players/${aid}/profile.json`;
try {
const res = await fetch(ossPath);
if (res.ok) return await res.json();
} catch (_) {
/* missing / private */
}
return null;
}
function itemMeta(id) {
const items = state.data.hero_items?.items || {};
return items[String(id)] || null;
@@ -5998,6 +6063,262 @@ function renderMechanics() {
}
function fmtDuration(sec) {
const s = Math.max(0, Number(sec) || 0);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function pctLabel(v) {
if (v == null || !Number.isFinite(Number(v))) return "—";
return `${Math.round(Number(v) * 100)}%`;
}
function appendPlayerItemIcons(row, itemIds) {
const wrap = document.createElement("div");
wrap.className = "player-match-items";
(Array.isArray(itemIds) ? itemIds : []).forEach((id) => {
const meta = itemMetaFromId(id);
const cell = document.createElement("span");
cell.className = "player-match-item";
if (meta && meta.key) {
const img = document.createElement("img");
img.alt = meta.name_loc || meta.key;
img.title = meta.name_loc || meta.key;
setItemIcon(img, meta.key);
cell.appendChild(img);
} else {
cell.classList.add("empty");
cell.title = id != null ? String(id) : "";
}
wrap.appendChild(cell);
});
row.appendChild(wrap);
}
function buildPlayerScoreboardTeam(detail, isRadiant) {
const side = document.createElement("section");
side.className = `player-team ${isRadiant ? "radiant" : "dire"}`;
const team = isRadiant ? detail.radiant || {} : detail.dire || {};
const won = Boolean(detail.radiant_win) === isRadiant;
const head = document.createElement("header");
head.className = "player-team-head";
head.innerHTML = `
<span class="player-team-name">${isRadiant ? "天辉" : "夜魇"}${won ? " · 胜利" : " · 失败"}</span>
<span class="player-team-stats">击杀 ${team.kills ?? "—"} · 经济 ${Number(team.net_worth || 0).toLocaleString("zh-CN")}</span>
`;
side.appendChild(head);
const list = document.createElement("div");
list.className = "player-team-rows";
const players = (detail.players || []).filter((p) => !!p.is_radiant === isRadiant);
for (const p of players) {
const row = document.createElement("div");
row.className = "player-match-row";
if (p.is_mvp) row.classList.add("is-mvp");
if (
state.playerAccountId &&
String(p.account_id) === String(state.playerAccountId)
) {
row.classList.add("is-focus");
}
const heroKey = p.hero_key || (heroById(p.hero_id) || {}).key;
const heroName =
p.hero_name_loc ||
(heroById(p.hero_id) || {}).name_loc ||
heroKey ||
"—";
const left = document.createElement("div");
left.className = "player-match-hero";
if (heroKey) {
const img = document.createElement("img");
img.className = "player-match-portrait";
img.src = portraitSrc(heroKey);
img.alt = heroName;
left.appendChild(img);
}
const meta = document.createElement("div");
meta.className = "player-match-meta";
const nameLine = document.createElement("div");
nameLine.className = "player-match-name";
nameLine.textContent = p.personaname || heroName;
if (p.is_mvp) {
const badge = document.createElement("span");
badge.className = "player-mvp-badge";
badge.textContent = "MVP";
nameLine.appendChild(badge);
}
meta.appendChild(nameLine);
const sub = document.createElement("div");
sub.className = "player-match-sub";
sub.textContent = `Lv.${p.level ?? "—"} · ${heroName}`;
meta.appendChild(sub);
left.appendChild(meta);
row.appendChild(left);
const metrics = document.createElement("div");
metrics.className = "player-match-metrics";
metrics.innerHTML = `
<span title="参战率"><em>参战</em>${pctLabel(p.participation)}</span>
<span title="伤害占比"><em>伤害</em>${pctLabel(p.damage_share)}</span>
<span title="K/D/A"><em>KDA</em>${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0}</span>
<span title="KDA 比"><em></em>${p.kda ?? ""}</span>
`;
row.appendChild(metrics);
appendPlayerItemIcons(row, p.items);
list.appendChild(row);
}
side.appendChild(list);
return side;
}
function renderPlayerMatchDetail(root, detail) {
root.replaceChildren();
const back = document.createElement("button");
back.type = "button";
back.className = "players-back";
back.textContent = "← 返回主页";
back.addEventListener("click", () => {
state.playerMatchId = null;
state._playerMatch = null;
syncStateToUrl();
render();
});
root.appendChild(back);
const head = document.createElement("header");
head.className = "players-match-head";
const dur = fmtDuration(detail.duration);
head.innerHTML = `
<h2 class="page-title">比赛 ${detail.match_id}</h2>
<p class="page-sub">时长 ${dur}${detail.radiant_win ? " · 天辉胜" : " · 夜魇胜"}</p>
`;
root.appendChild(head);
const board = document.createElement("div");
board.className = "player-scoreboard";
board.appendChild(buildPlayerScoreboardTeam(detail, true));
board.appendChild(buildPlayerScoreboardTeam(detail, false));
root.appendChild(board);
}
function renderPlayerProfile(root, profile) {
root.replaceChildren();
const head = document.createElement("header");
head.className = "players-profile-head";
const name = profile.personaname || `玩家 ${profile.account_id}`;
head.innerHTML = `
<h2 class="page-title">${name}</h2>
<p class="page-sub">ID ${profile.account_id}${profile.public_share ? " · 已公开" : " · 本机/未公开"}</p>
`;
root.appendChild(head);
const recent = Array.isArray(profile.recent) ? profile.recent : [];
if (!recent.length) {
const empty = document.createElement("div");
empty.className = "rankings-empty";
empty.textContent = "暂无近期比赛";
root.appendChild(empty);
return;
}
const list = document.createElement("div");
list.className = "players-recent";
for (const row of recent) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `players-recent-row ${row.won ? "won" : "lost"}`;
const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key;
const heroName =
row.hero_name_loc ||
(heroById(row.hero_id) || {}).name_loc ||
heroKey ||
"—";
if (heroKey) {
const img = document.createElement("img");
img.className = "players-recent-portrait";
img.src = portraitSrc(heroKey);
img.alt = heroName;
btn.appendChild(img);
}
const body = document.createElement("div");
body.className = "players-recent-body";
body.innerHTML = `
<div class="players-recent-top">
<span class="players-recent-hero">${heroName}</span>
<span class="players-recent-wl">${row.won ? "胜利" : "失败"}</span>
</div>
<div class="players-recent-bot">
<span>${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}</span>
<span>${fmtDuration(row.duration)}</span>
<span>#${row.match_id}</span>
</div>
`;
btn.appendChild(body);
btn.addEventListener("click", () => {
state.playerMatchId = String(row.match_id);
state._playerMatch = null;
syncStateToUrl();
render();
});
list.appendChild(btn);
}
root.appendChild(list);
}
function renderPlayersPage() {
const root = $("#players-body");
if (!root) return;
const accountId = state.playerAccountId;
if (!accountId) {
root.innerHTML =
'<div class="rankings-empty">打开 /players/{account_id} 查看 PC 赛后生成的玩家主页。<br/>默认仅本机可见;在 pc/config.json 将 player_pages.public_share 设为 true 后可同步到站点。</div>';
return;
}
const loadKey = `${accountId}:${state.playerMatchId || ""}`;
if (state._playerLoadKey === loadKey) {
if (state.playerMatchId) {
if (state._playerMatch) renderPlayerMatchDetail(root, state._playerMatch);
else
root.innerHTML =
'<div class="rankings-empty">未找到该场比赛(未公开或尚未同步)。</div>';
} else if (state._playerProfile) {
renderPlayerProfile(root, state._playerProfile);
} else {
root.innerHTML =
'<div class="rankings-empty">未找到玩家主页(未公开或尚未同步)。</div>';
}
return;
}
root.innerHTML = '<div class="rankings-empty">加载中…</div>';
const requested = loadKey;
(async () => {
let profile = null;
let match = null;
try {
if (state.playerMatchId) {
match = await fetchPlayerJson(accountId, state.playerMatchId);
} else {
profile = await fetchPlayerJson(accountId);
}
} catch (_) {
/* empty */
}
if (`${state.playerAccountId}:${state.playerMatchId || ""}` !== requested) {
return;
}
state._playerLoadKey = requested;
state._playerProfile = profile;
state._playerMatch = match;
renderPlayersPage();
})();
}
function setPage(page) {
if (
page !== "heroes" &&
@@ -6007,7 +6328,8 @@ function setPage(page) {
page !== "trends" &&
page !== "mechanics" &&
page !== "items" &&
page !== "patches"
page !== "patches" &&
page !== "players"
)
return;
closeTalentPopover();
@@ -6022,6 +6344,10 @@ function setPage(page) {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "players") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "streamers") {
state.selectedKey = null;
state.selectedItemKey = null;
@@ -6057,6 +6383,7 @@ function syncChrome() {
const heroesView = $("#heroes-view");
const rankingsView = $("#rankings-view");
const matchesView = $("#matches-view");
const playersView = $("#players-view");
const streamersView = $("#streamers-view");
const trendsView = $("#trends-view");
const mechanicsView = $("#mechanics-view");
@@ -6069,6 +6396,7 @@ function syncChrome() {
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
if (playersView) playersView.classList.toggle("hidden", state.page !== "players");
if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers");
if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends");
if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics");
@@ -6088,6 +6416,8 @@ function render() {
renderRankings();
} else if (state.page === "matches") {
renderMatchesPage();
} else if (state.page === "players") {
renderPlayersPage();
} else if (state.page === "streamers") {
renderStreamers();
} else if (state.page === "trends") {
@@ -6122,7 +6452,17 @@ function applyPatch(patch) {
// Page (default heroes on bad/missing).
if (
patch.page &&
["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
[
"heroes",
"rankings",
"matches",
"streamers",
"trends",
"mechanics",
"items",
"patches",
"players",
].includes(patch.page)
) {
state.page = patch.page;
} else {
@@ -6200,6 +6540,33 @@ function applyPatch(patch) {
state.matchesPage =
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
}
// PC post-match player pages.
if (state.page === "players") {
const nextAccount =
patch.playerAccountId && /^\d+$/.test(String(patch.playerAccountId))
? String(patch.playerAccountId)
: null;
const nextMatch =
patch.playerMatchId && /^\d+$/.test(String(patch.playerMatchId))
? String(patch.playerMatchId)
: null;
if (
nextAccount !== state.playerAccountId ||
nextMatch !== state.playerMatchId
) {
state._playerLoadKey = null;
state._playerProfile = null;
state._playerMatch = null;
}
state.playerAccountId = nextAccount;
state.playerMatchId = nextMatch;
} else {
state.playerAccountId = null;
state.playerMatchId = null;
state._playerLoadKey = null;
state._playerProfile = null;
state._playerMatch = null;
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
state.selectedItemKey = patch.itemKey;
+3 -1
View File
@@ -1,6 +1,8 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.6.15";
var SITE_VERSION = "0.6.16";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
/* Player pages JSON (OSS players/); empty → STATIC_ASSET_BASE, then local /api/players. */
var PLAYERS_ASSET_BASE = "";
@@ -0,0 +1,411 @@
/**
* Pages Function: POST /api/players/publish
*
* Body: { account_id, match_id }
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
*
* Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style
* JSON, merges profile.recent, PUTs to Aliyun OSS:
* players/{account_id}/profile.json
* players/{account_id}/matches/{match_id}.json
*
* Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET,
* optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY.
*
* Soft-fail: match not ready 202; bad membership 403; never echo secrets.
*/
const OPENDOTA = "https://api.opendota.com/api";
const DEFAULT_BUCKET = "climperor";
const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com";
const RECENT_LIMIT = 30;
function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
function envOf(context) {
return (context && context.env) || {};
}
function intField(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
function mvpScore(p) {
const k = intField(p.kills);
const d = intField(p.deaths);
const a = intField(p.assists);
const dmg = intField(p.hero_damage);
const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent);
return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1);
}
function itemIds(player) {
const out = [];
for (let i = 0; i < 6; i++) {
const id = intField(player[`item_${i}`]);
if (id > 0) out.push(id);
}
return out;
}
function accountInMatch(match, accountId) {
const players = match.players || [];
for (const p of players) {
if (p && intField(p.account_id, -1) === accountId) return true;
}
return false;
}
function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function fetchJson(url, { headers } = {}) {
const res = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) },
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
async function loadHeroMap(opendotaKey) {
const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : "";
try {
const rows = await fetchJson(`${OPENDOTA}/heroes${q}`);
const map = new Map();
if (Array.isArray(rows)) {
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(intField(h.id), {
key,
name_loc: h.localized_name || key,
});
}
}
return map;
} catch {
return new Map();
}
}
function normalizeMatch(match, focusAccountId, heroMap) {
const playersRaw = match.players;
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
const matchId = intField(match.match_id);
if (matchId <= 0) return null;
const radiantWin = !!match.radiant_win;
const teamKills = [0, 0];
const teamNw = [0, 0];
const teamDmg = [0, 0];
const slim = [];
for (const p of playersRaw) {
if (!p || typeof p !== "object") continue;
const slot = intField(p.player_slot);
const isRadiant = slot < 128;
const side = isRadiant ? 0 : 1;
const kills = intField(p.kills);
const deaths = intField(p.deaths);
const assists = intField(p.assists);
const heroDamage = intField(p.hero_damage);
let netWorth = intField(p.net_worth);
if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent);
teamKills[side] += kills;
teamNw[side] += netWorth;
teamDmg[side] += heroDamage;
const heroId = intField(p.hero_id);
const hero = heroMap.get(heroId) || {};
let accountId = null;
if (p.account_id != null) {
const a = intField(p.account_id, -1);
accountId = a >= 0 ? a : null;
}
let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null;
if (!personaname) personaname = null;
slim.push({
account_id: accountId,
personaname,
hero_id: heroId,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
level: intField(p.level),
kills,
deaths,
assists,
kda: kda(kills, deaths, assists),
hero_damage: heroDamage,
net_worth: netWorth,
items: itemIds(p),
is_radiant: isRadiant,
won: isRadiant ? radiantWin : !radiantWin,
_mvp: mvpScore(p),
_side: side,
});
}
if (slim.length < 2) return null;
for (const p of slim) {
const side = p._side;
const tk = teamKills[side] || 1;
const td = teamDmg[side] || 1;
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
}
let mvp = slim[0];
for (const p of slim) {
if (p._mvp > mvp._mvp) mvp = p;
}
const mvpAccount = mvp.account_id;
for (const p of slim) {
p.is_mvp = mvpAccount != null && p.account_id === mvpAccount;
delete p._mvp;
delete p._side;
}
let startTime = null;
if (match.start_time != null) {
const t = intField(match.start_time, -1);
startTime = t >= 0 ? t : null;
}
return {
match_id: matchId,
start_time: startTime,
duration: intField(match.duration),
radiant_win: radiantWin,
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
dire: { kills: teamKills[1], net_worth: teamNw[1] },
mvp_account_id: mvpAccount,
players: slim,
focus_account_id: focusAccountId,
fetched_at: utcNow(),
source: "opendota",
};
}
function summaryForProfile(detail, accountId) {
const focus = (detail.players || []).find((p) => p.account_id === accountId);
if (!focus) return null;
return {
match_id: detail.match_id,
start_time: detail.start_time,
duration: detail.duration,
won: !!focus.won,
hero_id: focus.hero_id,
hero_key: focus.hero_key,
hero_name_loc: focus.hero_name_loc,
kills: focus.kills,
deaths: focus.deaths,
assists: focus.assists,
kda: focus.kda,
};
}
async function ossGetJson(env, key) {
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const url = `https://${bucket}.${endpoint}/${key}`;
try {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function hmacSha1Base64(secret, stringToSign) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign));
const bytes = new Uint8Array(sig);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
async function ossPutJson(env, key, obj) {
const accessKeyId = env.OSS_ACCESS_KEY_ID;
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET;
if (!accessKeyId || !accessKeySecret) {
const err = new Error("OSS credentials missing");
err.status = 503;
throw err;
}
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const body = JSON.stringify(obj);
const contentType = "application/json; charset=utf-8";
const date = new Date().toUTCString();
const resource = `/${bucket}/${key}`;
// Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL).
const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`;
const signature = await hmacSha1Base64(accessKeySecret, stringToSign);
const url = `https://${bucket}.${endpoint}/${key}`;
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": contentType,
Date: date,
Authorization: `OSS ${accessKeyId}:${signature}`,
"Cache-Control": "public, max-age=60",
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`);
err.status = 502;
throw err;
}
}
function mergeProfile(existing, accountId, summary, personaname) {
const profile =
existing && typeof existing === "object"
? { ...existing }
: { account_id: accountId, personaname: null, recent: [] };
let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : [];
recent = recent.filter((r) => intField(r.match_id) !== summary.match_id);
recent.unshift(summary);
profile.recent = recent.slice(0, RECENT_LIMIT);
profile.account_id = accountId;
if (personaname) profile.personaname = personaname;
profile.public_share = true;
profile.updated_at = utcNow();
return profile;
}
export async function onRequestPost(context) {
try {
const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) {
const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim();
if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403);
}
}
let body;
try {
body = await context.request.json();
} catch {
return jsonResponse({ error: "invalid json" }, 400);
}
const accountId = intField(body && body.account_id, -1);
const matchId = intField(body && body.match_id, -1);
if (accountId <= 0 || matchId <= 0) {
return jsonResponse({ error: "account_id and match_id required" }, 400);
}
const odKey = (env.OPENDOTA_API_KEY || "").trim();
const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : "";
let match;
try {
match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`);
} catch (e) {
if (e && e.status === 404) {
return jsonResponse(
{ ok: false, pending: true, message: "match not ready on OpenDota" },
202
);
}
return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502);
}
if (!match || !Array.isArray(match.players) || !match.players.length) {
return jsonResponse(
{ ok: false, pending: true, message: "match incomplete on OpenDota" },
202
);
}
if (!accountInMatch(match, accountId)) {
return jsonResponse({ error: "account not in match" }, 403);
}
const heroMap = await loadHeroMap(odKey);
const detail = normalizeMatch(match, accountId, heroMap);
if (!detail) {
return jsonResponse({ error: "normalize failed" }, 500);
}
const summary = summaryForProfile(detail, accountId);
if (!summary) {
return jsonResponse({ error: "focus player missing" }, 500);
}
let personaname = null;
for (const p of detail.players) {
if (p.account_id === accountId && p.personaname) {
personaname = p.personaname;
break;
}
}
const profileKey = `players/${accountId}/profile.json`;
const matchKey = `players/${accountId}/matches/${matchId}.json`;
const existing = await ossGetJson(env, profileKey);
const profile = mergeProfile(existing, accountId, summary, personaname);
await ossPutJson(env, matchKey, detail);
await ossPutJson(env, profileKey, profile);
return jsonResponse({
ok: true,
account_id: accountId,
match_id: matchId,
profile_key: profileKey,
match_key: matchKey,
});
} catch (e) {
const status = (e && e.status) || 500;
return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) },
status >= 400 && status < 600 ? status : 500
);
}
}
export async function onRequestOptions() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Climperor-Publish-Secret",
"Access-Control-Max-Age": "86400",
},
});
}
+12 -5
View File
@@ -43,8 +43,8 @@
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.15" />
<script src="/mobile-gate.js?v=0.6.15"></script>
<link rel="stylesheet" href="/style.css?v=0.6.16" />
<script src="/mobile-gate.js?v=0.6.16"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -62,6 +62,7 @@
<li><a href="/rankings">Immortal 排行</a></li>
<li><a href="/streamers">主播</a></li>
<li><a href="/matches">明星比赛</a></li>
<li><a href="/players">玩家战绩</a></li>
</ul>
</aside>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
@@ -155,6 +156,12 @@
</div>
</main>
<main id="players-view" class="board rankings-board players-board hidden">
<div class="players-cluster">
<div class="players-body" id="players-body" aria-live="polite"></div>
</div>
</main>
<main id="streamers-view" class="board rankings-board streamers-board hidden">
<div class="rankings-cluster">
<div class="rankings-center-wrap streamers-center-wrap">
@@ -240,8 +247,8 @@
</div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.15"></script>
<script src="/router.js?v=0.6.15"></script>
<script src="/app.js?v=0.6.15"></script>
<script src="/config.js?v=0.6.16"></script>
<script src="/router.js?v=0.6.16"></script>
<script src="/app.js?v=0.6.16"></script>
</body>
</html>
+30 -2
View File
@@ -4,7 +4,7 @@
* Path-based router for the Climperor web site (web/frontend).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends)
* - rankings: Immortal leaderboard region
@@ -12,6 +12,8 @@
* - matches: star-player recent matches (pro_matches)
* /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - players: PC post-match player home + match detail (local/OSS JSON)
* /players/{account_id}[/{match_id}]
* - streamers: curated streamer directory
* /streamers
* - trends: medal bracket for the 8-week win/pick board
@@ -38,7 +40,17 @@
*/
const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_PAGES = [
"heroes",
"rankings",
"matches",
"streamers",
"trends",
"mechanics",
"items",
"patches",
"players",
];
const VALID_DETAIL_TABS = [
"skills",
"core",
@@ -114,6 +126,8 @@ function parseHash(hashOrPath) {
matchesPlayerId: null,
matchesOrigin: null,
matchesPage: null,
playerAccountId: null,
playerMatchId: null,
trendsBracket: null,
trendsSort: null,
mechanicEffect: null,
@@ -147,6 +161,13 @@ function parseHash(hashOrPath) {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.matchesPlayerId = segs[1];
}
} else if (page === "players") {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerAccountId = segs[1];
}
if (segs[2] && /^\d+$/.test(segs[2])) {
out.playerMatchId = segs[2];
}
} else if (page === "trends") {
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
} else if (page === "mechanics") {
@@ -219,6 +240,13 @@ function serializeHash(state) {
if (state.matchesPlayerId) {
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "players") {
if (state.playerAccountId) {
path += "/" + encodeURIComponent(String(state.playerAccountId));
if (state.playerMatchId) {
path += "/" + encodeURIComponent(String(state.playerMatchId));
}
}
} else if (state.page === "trends") {
// Omit bracket when it is the default (legend) — bare /trends means legend.
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
+232
View File
@@ -4421,3 +4421,235 @@ html.mobile-client #mobile-gate {
font-weight: 600;
}
/* —— PC post-match player pages (/players) —— */
.players-board .players-cluster {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md) 48px;
}
.players-body {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-back {
appearance: none;
align-self: flex-start;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-md);
cursor: pointer;
}
.players-back:hover {
color: var(--text);
border-color: rgba(94, 200, 255, 0.45);
}
.players-profile-head,
.players-match-head {
margin-bottom: var(--space-sm);
}
.players-recent {
display: flex;
flex-direction: column;
gap: 8px;
}
.players-recent-row {
appearance: none;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text);
border-radius: var(--radius-md);
padding: 10px 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.players-recent-row:hover {
border-color: rgba(94, 200, 255, 0.4);
}
.players-recent-row.won {
border-left: 3px solid var(--good);
}
.players-recent-row.lost {
border-left: 3px solid var(--danger);
}
.players-recent-portrait {
width: 64px;
height: 36px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.players-recent-body {
flex: 1;
min-width: 0;
}
.players-recent-top,
.players-recent-bot {
display: flex;
justify-content: space-between;
gap: 12px;
}
.players-recent-top {
font-weight: 700;
}
.players-recent-bot {
margin-top: 4px;
font-size: 12px;
color: var(--muted);
}
.players-recent-wl {
font-size: 13px;
letter-spacing: 0.04em;
}
.player-scoreboard {
display: flex;
flex-direction: column;
gap: 18px;
}
.player-team {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel-soft);
overflow: hidden;
}
.player-team.radiant {
border-color: rgba(61, 206, 122, 0.35);
}
.player-team.dire {
border-color: rgba(232, 106, 106, 0.35);
}
.player-team-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 10px 14px;
font-size: 14px;
}
.player-team.radiant .player-team-head {
background: rgba(61, 206, 122, 0.12);
}
.player-team.dire .player-team-head {
background: rgba(232, 106, 106, 0.12);
}
.player-team-name {
font-weight: 800;
letter-spacing: 0.06em;
}
.player-team-stats {
color: var(--muted);
font-size: 13px;
}
.player-team-rows {
display: flex;
flex-direction: column;
}
.player-match-row {
display: grid;
grid-template-columns: minmax(160px, 1.2fr) minmax(220px, 1.4fr) auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--border);
}
.player-match-row.is-focus {
background: rgba(94, 200, 255, 0.06);
}
.player-match-row.is-mvp .player-match-name {
color: var(--gold);
}
.player-match-hero {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.player-match-portrait {
width: 72px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.player-match-meta {
min-width: 0;
}
.player-match-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-mvp-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
color: #1a1408;
background: linear-gradient(180deg, #f0d78a, #c9a24a);
border-radius: 3px;
padding: 1px 5px;
}
.player-match-sub {
margin-top: 2px;
font-size: 12px;
color: var(--muted);
}
.player-match-metrics {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 13px;
}
.player-match-metrics em {
font-style: normal;
color: var(--muted);
margin-right: 4px;
font-size: 11px;
}
.player-match-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-end;
}
.player-match-item {
width: 36px;
height: 28px;
border-radius: 3px;
background: rgba(8, 12, 20, 0.55);
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
}
.player-match-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.player-match-item.empty {
opacity: 0.35;
}
@media (max-width: 900px) {
.player-match-row {
grid-template-columns: 1fr;
gap: 8px;
}
.player-match-items {
justify-content: flex-start;
}
}
+1
View File
@@ -24,6 +24,7 @@ TOP_PAGES: list[tuple[str, str, str]] = [
("/rankings", "Immortal 排行", "Valve Immortal 四区 Top100。"),
("/streamers", "主播", "精选 Dota 2 主播目录。"),
("/matches", "明星比赛", "明星选手近期职业与国服对局。"),
("/players", "玩家战绩", "PC 上分帝赛后生成的玩家主页与比赛详情。"),
]
_TITLE_RE = re.compile(r"<title>[^<]*</title>", re.I)
+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)