From 4a61aeeb265e91fe8f43a9e0cfd5a49e7f6d0b1e Mon Sep 17 00:00:00 2001 From: voson Date: Fri, 31 Jul 2026 11:01:32 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + AGENTS.md | 17 +- CHANGELOG.md | 6 + pc/config.json | 11 + pc/gsi_watch.py | 19 + pc/player_pages.py | 493 ++++++++++++++++++ shared/paths.py | 2 + web/export_relations_site.py | 10 +- web/frontend/app.js | 375 ++++++++++++- web/frontend/config.js | 4 +- web/frontend/functions/api/players/publish.js | 411 +++++++++++++++ web/frontend/index.html | 17 +- web/frontend/router.js | 32 +- web/frontend/style.css | 232 +++++++++ web/seo_prerender.py | 1 + web/serve_relations.py | 76 ++- 16 files changed, 1684 insertions(+), 23 deletions(-) create mode 100644 pc/player_pages.py create mode 100644 web/frontend/functions/api/players/publish.js diff --git a/.gitignore b/.gitignore index a04acae..b01e3d8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ pc/preview/ pc/results/ pc/failures/ pc/samples/raw/ +pc/player_pages/ _tools/ _tmp_* _cmp_* diff --git a/AGENTS.md b/AGENTS.md index ed15a53..71b6a07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | 移动端门禁(`` 早载):`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//` — 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`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 440cc63..5c793a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pc/config.json b/pc/config.json index bfaeab9..dfa8cbb 100644 --- a/pc/config.json +++ b/pc/config.json @@ -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, diff --git a/pc/gsi_watch.py b/pc/gsi_watch.py index 467fbaf..ebb9fbc 100644 --- a/pc/gsi_watch.py +++ b/pc/gsi_watch.py @@ -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): diff --git a/pc/player_pages.py b/pc/player_pages.py new file mode 100644 index 0000000..a7f4243 --- /dev/null +++ b/pc/player_pages.py @@ -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() diff --git a/shared/paths.py b/shared/paths.py index 52f4be8..b0d3ef8 100644 --- a/shared/paths.py +++ b/shared/paths.py @@ -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, ...). diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 80d956a..003db1b 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -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( diff --git a/web/frontend/app.js b/web/frontend/app.js index 45a8585..61c8acd 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -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 = ` + ${isRadiant ? "天辉" : "夜魇"}${won ? " · 胜利" : " · 失败"} + 击杀 ${team.kills ?? "—"} · 经济 ${Number(team.net_worth || 0).toLocaleString("zh-CN")} + `; + 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 = ` + 参战${pctLabel(p.participation)} + 伤害${pctLabel(p.damage_share)} + KDA${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0} + ${p.kda ?? "—"} + `; + 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 = ` +

比赛 ${detail.match_id}

+

时长 ${dur}${detail.radiant_win ? " · 天辉胜" : " · 夜魇胜"}

+ `; + 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 = ` +

${name}

+

ID ${profile.account_id}${profile.public_share ? " · 已公开" : " · 本机/未公开"}

+ `; + 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 = ` +
+ ${heroName} + ${row.won ? "胜利" : "失败"} +
+
+ ${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0} + ${fmtDuration(row.duration)} + #${row.match_id} +
+ `; + 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 = + '
打开 /players/{account_id} 查看 PC 赛后生成的玩家主页。
默认仅本机可见;在 pc/config.json 将 player_pages.public_share 设为 true 后可同步到站点。
'; + return; + } + + const loadKey = `${accountId}:${state.playerMatchId || ""}`; + if (state._playerLoadKey === loadKey) { + if (state.playerMatchId) { + if (state._playerMatch) renderPlayerMatchDetail(root, state._playerMatch); + else + root.innerHTML = + '
未找到该场比赛(未公开或尚未同步)。
'; + } else if (state._playerProfile) { + renderPlayerProfile(root, state._playerProfile); + } else { + root.innerHTML = + '
未找到玩家主页(未公开或尚未同步)。
'; + } + return; + } + + root.innerHTML = '
加载中…
'; + 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; diff --git a/web/frontend/config.js b/web/frontend/config.js index c011163..44b21b9 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -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 = ""; diff --git a/web/frontend/functions/api/players/publish.js b/web/frontend/functions/api/players/publish.js new file mode 100644 index 0000000..3139d92 --- /dev/null +++ b/web/frontend/functions/api/players/publish.js @@ -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", + }, + }); +} diff --git a/web/frontend/index.html b/web/frontend/index.html index ac78087..f99c49e 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

DOTA2 上分帝

@@ -62,6 +62,7 @@
  • Immortal 排行
  • 主播
  • 明星比赛
  • +
  • 玩家战绩
  • +
    +
    +
    +
    +
    +
    @@ -240,8 +247,8 @@
    - - - + + + diff --git a/web/frontend/router.js b/web/frontend/router.js index 423d431..e10449e 100644 --- a/web/frontend/router.js +++ b/web/frontend/router.js @@ -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; diff --git a/web/frontend/style.css b/web/frontend/style.css index 979786d..6e54843 100644 --- a/web/frontend/style.css +++ b/web/frontend/style.css @@ -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; + } +} + diff --git a/web/seo_prerender.py b/web/seo_prerender.py index 4ccf9c8..d393036 100644 --- a/web/seo_prerender.py +++ b/web/seo_prerender.py @@ -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"[^<]*", re.I) diff --git a/web/serve_relations.py b/web/serve_relations.py index d4d49e3..a98998f 100644 --- a/web/serve_relations.py +++ b/web/serve_relations.py @@ -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)