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:
@@ -17,6 +17,7 @@ pc/preview/
|
||||
pc/results/
|
||||
pc/failures/
|
||||
pc/samples/raw/
|
||||
pc/player_pages/
|
||||
_tools/
|
||||
_tmp_*
|
||||
_cmp_*
|
||||
|
||||
@@ -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` 只比对列表与本地 details,stdout 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_GAME:player_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 Actions(self-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`。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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, ...).
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -305,7 +308,8 @@ def main() -> None:
|
||||
print(f" ability-video/: {n_videos} files")
|
||||
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"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(
|
||||
|
||||
+371
-4
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user