Ship Steam login, D1 player sync, and cached「我」dashboard.

Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
+15 -8
View File
@@ -34,7 +34,8 @@ climperor/
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 | | `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 | | `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装;`POST_GAME` 触发 `player_pages` 赛后建页 | | `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/player_pages.py` | 赛后轮询 OpenDota → `pc/player_pages/{account_id}/`profile + match JSON);`enrich_profile_recent` 写近 20 场 + `career`/`recent_20`/`top_heroes`/`activity_180`/`peers`/`availability``get_profile_for_web` 缓存优先(`enrich_ttl_seconds` 默认 600,与 Pages `isStale` 对齐:未过期直出、过期后台刷新、冷启动同步一次);详情 `ensure_match_detail` 懒加载;`public_share` 时 POST `/api/players/publish` 入队**不**进 recommend |
| `pc/player_stats.py` | 玩家聚合公式(KDA/胜率/生涯/近 N 场/180 天热力图);空 OpenDota 不覆盖旧 career |
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` | | `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 | | `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) | | `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
@@ -72,7 +73,9 @@ climperor/
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON | | `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 | | `web/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/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`/不显示直播角标;`GET /api/players/{account_id}[/{match_id}]``pc/player_pages/``POST /api/players/publish` 本地 no-op`/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/me` 登录本人 enrich`GET /api/players/{account_id}[/{match_id}]``pc/player_pages/``POST /api/players/enrich` / `ensure-match` 本机补拉近期与详情;`POST /api/players/publish` 本地 no-opSteam OpenID`/api/auth/steam` / `callback` / `me` / `logout`(需 `STEAM_API_KEY` + `SESSION_SECRET`,镜像 `web/steam_auth.py``/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/steam_auth.py` | Steam OpenID + 签名 Cookie 会话(本地 serve 用;与 `web/frontend/functions/api/auth/*` 对齐) |
| `web/frontend/functions/api/auth/` | Pages FunctionsSteam 登录 / 回调 / me / logoutCookie `climperor_steam`Env`STEAM_API_KEY``SESSION_SECRET` |
| `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/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/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 | | `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
@@ -95,7 +98,9 @@ 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/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/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/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/functions/api/players/publish.js` | Pages Function `POST /api/players/publish`:校验后投递 QueueD1/R2 由 Worker 写入);可选 `PLAYER_PAGES_PUBLISH_SECRET`**不**进主 `data.json` / recommend |
| `web/frontend/functions/api/players/me.js` | `GET /api/players/me`:登录本人 D1 档案;过期则 Queue 刷新并标 `stale` |
| `web/cloudflare/` | 多用户数据层:D1 migrations、`player-sync` Worker、`provision.py` / `deploy_worker.py`;资源名见 `web/cloudflare/README.md` |
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__``app.js` 跳过桌面 boot | | `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_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` | | `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json``%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py` |
@@ -126,11 +131,11 @@ climperor/
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations | | `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations |
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) | | `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``PLAYERS_ASSET_BASE`;英雄页底部(无详情时)显示 `v{SITE_VERSION}` 与数据更新时间;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截(搜索/AI 爬虫 UA 跳过) | | `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/frontend/router.js` | History 路径路由:`parseHash` / `serializeHash`(操作 pathname+search/ `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层 `/home\|heroes\|rankings\|streamers\|matches\|players\|trends\|mechanics\|items\|patches` / 登录后「我」`/home[/{match_id}]` / 英雄 + 子标签 `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/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) | | `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` | | `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` |
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) | | `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝 + `rank_star_1..5` 星级叠层;玩家页具体段位;走势段位选择器只用勋章 |
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) | | `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) | | `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) | | `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) |
@@ -142,7 +147,7 @@ climperor/
运行时产物(**勿提交**,见 `.gitignore`): 运行时产物(**勿提交**,见 `.gitignore`):
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下 - `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后另存 OSS `players/` - `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后入队 Cloudflare D1/R2,不进主 data.json
- `pc/preview/` — 标定 / sheet 预览 - `pc/preview/` — 标定 / sheet 预览
- `web/dist/` — 静态站点导出(`export_relations_site.py` - `web/dist/` — 静态站点导出(`export_relations_site.py`
- `pc/results/` — 每局 JSON - `pc/results/` — 每局 JSON
@@ -163,7 +168,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI) → recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
→ overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选) → overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
→ pc/results/draft_*.json + 终端时间线 → pc/results/draft_*.json + 终端时间线
→ POST_GAMEplayer_pages 轮询 OpenDota → pc/player_pages/(可选 publish → OSS players/ → POST_GAMEplayer_pages 轮询 OpenDota → pc/player_pages/(可选 publish → Queue → D1/R2
``` ```
## 技术约束(修改前必读) ## 技术约束(修改前必读)
@@ -179,7 +184,9 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
- **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null` - **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null`
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。 - **平台**:面向 Windows;截屏依赖无边框/窗口模式。
- **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。 - **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true`由边缘 ingest 写 OSS `players/`PC **不**内置 OSS 密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 OSS**禁止**塞进主 `data.json`**禁止**进 recommend。需玩家开启「公开比赛数据」。 - **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` POST 入队由 Worker 写 D1/R2PC **不**内置密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 D1+R2 详情);登录「我」`/home``/api/players/me`(生产 D1 缓存 + Queue 刷新;本机 `profile.json` TTL 缓存,语义对齐)。首次同步可慢,TTL 内后续直出缓存;R2 仅完整比赛详情,不承担主页加速。有公开 `account_id` 的昵称可点进主页并补拉近 20 场,详情懒加载;隐私局可借本机 GSI `samples/raw` 发现**禁止**塞进主 `data.json`**禁止**进 recommend。需玩家开启「公开比赛数据」才能稳定拿到他人 ID
- **Steam 登录**OpenID 确认本人 `account_id`;顶栏登录后出现「我」`/home`(复用玩家页)。本机 `serve_relations` 与 CF Pages 均需 `STEAM_API_KEY` + `SESSION_SECRET`;登录只解锁本人身份/历史,不能绕过他人隐私。勿把会话密钥写进 git / `data.json`
- **多用户玩家数据**:生产用 Cloudflare **D1**(档案/统计索引)+ **Queue/Worker**OpenDota 同步)+ 可选 **R2**(完整比赛 JSON);本机仍写 `pc/player_pages/`。他人主页须 `public_share`;勿把玩家战绩打进主 `data.json` / recommend。
## 开发命令 ## 开发命令
+240
View File
@@ -7,6 +7,240 @@
### Added ### Added
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items` - PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`
- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。
## [0.6.54] - 2026-07-31
### Fixed
- 「我」/玩家页:有战绩时不再因 TTL `stale` 轮询十余秒或闪「加载中…」;仅空档案才同步等待。
## [0.6.53] - 2026-07-31
### Fixed
- 近期比赛:去掉行中弹性空隙;时长/相对时间与胜负/#ID 收成右侧紧簇,避免宽屏悬空。
## [0.6.52] - 2026-07-31
### Changed
- 「我」/玩家页:缓存优先——首次可慢,约 10 分钟 TTL 内后续直出(生产 D1 + Queue;本机 `profile.json` + 后台 enrich);前端有新鲜数据时不再强制全量重拉。
## [0.6.51] - 2026-07-31
### Fixed
- 近期比赛:弹性空白改到时长与相对时间之间,避免两者挤在一起、右侧大片空。
## [0.6.50] - 2026-07-31
### Changed
- 十人详情:返回按钮移到比赛摘要卡上方,改为 36px 描边控件 + SVG 左箭头 +「返回玩家主页」。
## [0.6.49] - 2026-07-31
### Fixed
- 十人详情:昵称列封顶约 16rem,中间剩余宽度四等分给参战/伤害/经济/KDA,避免昵称挤占指标空间。
## [0.6.48] - 2026-07-31
### Changed
- 十人详情:去掉独立「返回主页」描边按钮,改为摘要头右侧文字链「← 玩家主页」。
## [0.6.47] - 2026-07-31
### Fixed
- 十人详情:参战/伤害/经济/KDA 作为居中指标组,不再贴靠右侧装备列。
## [0.6.46] - 2026-07-31
### Changed
- 玩家页分析区标题「常用队友」改为「队友」。
## [0.6.45] - 2026-07-31
### Fixed
- 近期比赛:时长与相对时间改为水平并排(如 `41:54 · 3小时前`),并拉开与英雄列、胜负列的间距。
## [0.6.44] - 2026-07-31
### Fixed
- 玩家身份条段位勋章放大到 64px(对齐 56px 头像的光学尺寸;OpenDota 图标含画布留白)。
## [0.6.43] - 2026-07-31
### Removed
- 「我」/玩家页摘要区去掉近 20 场英雄肖像条(胜负已由下方近期比赛列表表达)。
## [0.6.42] - 2026-07-31
### Fixed
- 玩家仪表盘恢复真实 1200px 数据主列;900–1200px 保持五列摘要和十人详情横向比较,近 20 场英雄条改为无滚动条双排网格,180 天活动改为按周排列并补强度图例。
- 十人详情增加共享列头,胜负统一使用绿/红语义色;组队/MVP 徽章移至昵称次行,避免挤压长昵称。
## [0.6.41] - 2026-07-31
### Changed
- 「我」/玩家页改为紧凑数据仪表盘:身份条 + 生涯/近 N 场并排摘要 + 分析双栏(常用英雄 / 180 天与队友)+ 固定列近期比赛;十人详情摘要头与昵称/指标/装备列对齐统一。
## [0.6.40] - 2026-07-31
### Fixed
- 「我」近期比赛:时长/时间与英雄/KDA 收成左侧紧凑双列表格(上:英雄+时长,下:KDA+相对时间),胜负靠右,消除中间悬空。
## [0.6.39] - 2026-07-31
### Fixed
- 「我」近期比赛:时长/时间与英雄名/KDA、胜负/#ID 改为统一双行网格,同行同字号对齐。
## [0.6.38] - 2026-07-31
### Fixed
- 比赛十人详情:加宽昵称列,空间足够时尽量完整显示玩家名(组队/MVP 徽章不抢缩名称)。
## [0.6.37] - 2026-07-31
### Changed
- 玩家段位改为勋章图标 + 星级叠层(如传奇四),不再并排显示「传奇4」文字;冠绝榜位仍显示 `#N`
## [0.6.36] - 2026-07-31
### Fixed
- 「我」近期比赛:时长与相对时间改为双行列(上时长、下时间),与英雄/胜负对齐,紧跟英雄信息。
## [0.6.35] - 2026-07-31
### Fixed
- 「我」近期比赛:时长与相对时间贴近英雄信息,不再挤在右侧。
## [0.6.34] - 2026-07-31
### Fixed
- 「我」近期比赛:时长与相对时间同列垂直居中对齐;「胜利」/`失败` 用绿/红字;身份条去掉「本机/未公开」。
## [0.6.33] - 2026-07-31
### Added
- 「我」主页丰富:生涯场次/胜率/KDA、近 20 场摘要与英雄条、常用英雄、180 天活动热力图与样本最高、公开常用队友;空数据不显示伪 0%。
- 生产多用户数据层:Cloudflare D1 `climperor-users` + Queue `climperor-player-sync` + Worker 异步同步;Pages 已绑定 `DB` / `SYNC_QUEUE``GET /api/players/me` 读库并排队刷新。R2 比赛详情桶待 Dashboard 启用后接入。
- 本机 enrich 同步写入 `career` / `recent_20` / `top_heroes` / `activity_180` / `peers` / `availability`
### Fixed
- `/home` 点击近期比赛可进入详情(此前被 `page !== players` 早退)。
## [0.6.32] - 2026-07-31
### Changed
- 顶栏登录后 Tab「我的主页」改为「我」。
### Added
- 玩家主页补拉 OpenDota 段位(`rank_tier`)与昵称;「我」页展示勋章。近期比赛仍依赖「公开比赛数据」或本机 GSI。
## [0.6.31] - 2026-07-31
### Added
- Steam 登录:顶栏「Steam 登录」;登录后显示头像/昵称与「退出」,并在「英雄」前出现「我」(`/home`,复用玩家战绩页看本人近 2 天比赛)。本机与 Pages 需配置 `STEAM_API_KEY` + `SESSION_SECRET`
## [0.6.30] - 2026-07-31
### Fixed
- 玩家主页:补拉 API 不可用时不再闪「补拉跳过」文案;已有列表则静默失败。
## [0.6.29] - 2026-07-31
### Changed
- 玩家比赛详情主行显示 Steam 昵称(有公开 ID 时补拉 OpenDota;本人可从 GSI 取名);无昵称显示「匿名」/「玩家 {id}」,不再用英雄名冒充玩家名。
## [0.6.28] - 2026-07-31
### Added
- 玩家页:本场有公开 `account_id` 的昵称可点进 `/players/{id}`;进入主页补拉近 2 天比赛列表(OpenDota + 本机 GSI 目录);点进某场再懒加载十人详情。
## [0.6.27] - 2026-07-31
### Changed
- 玩家比赛详情参战/伤害/经济/KDA 强制同一行显示。
## [0.6.26] - 2026-07-31
### Changed
- 玩家比赛详情个人经济改为完整数值(如 `25,903`),不再用「万」缩写。
## [0.6.25] - 2026-07-31
### Added
- 玩家比赛详情:每人显示个人经济(净身价);OpenDota 有 `party_id` 且 ≥2 人同组时标注「组A/B…」。
## [0.6.24] - 2026-07-31
### Fixed
- 玩家比赛详情:OpenDota 隐去 `account_id` 时仍按场内槽位标注 MVP(此前会整场不显示)。
## [0.6.23] - 2026-07-31
### Changed
- 玩家比赛详情:参战/伤害/KDA 居中于英雄与装备之间。
## [0.6.22] - 2026-07-31
### Changed
- 玩家比赛详情:去掉易混淆的「比」列,KDA 改为 `5/14/151.4`;指标紧贴英雄,空白留给装备前。
## [0.6.21] - 2026-07-31
### Added
- 玩家比赛详情与近期列表显示开赛时间(相对时间,悬停看完整时间)。
## [0.6.20] - 2026-07-31
### Fixed
- 玩家比赛详情:收紧「参战/伤害/KDA/比」列宽并加大与装备区间距,避免「比」与物品图标重叠。
## [0.6.19] - 2026-07-31
### Fixed
- 玩家比赛详情:装备位固定补齐 6 格并锁定列宽,避免物品数量不同把参战/伤害/KDA 整列挤歪。
## [0.6.18] - 2026-07-31
### Fixed
- 玩家比赛详情十人战绩:参战 / 伤害 / KDA / 比改为固定列宽网格对齐,避免数字长短导致错位。
### Changed ### Changed
@@ -17,6 +251,12 @@
- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。 - PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。
## [0.6.17] - 2026-07-31
### Changed
- 版本页宽屏「AI 解读」侧栏随可用右侧空隙加宽(约 280–420px),窄于 1440px 仍上下堆叠。
## [0.6.16] - 2026-07-31 ## [0.6.16] - 2026-07-31
### Added ### Added
+16 -3
View File
@@ -267,10 +267,10 @@ components:
| 档位 | 宽度 | 页面 | | 档位 | 宽度 | 页面 |
|------|------|------| |------|------|------|
| full | 100% | 英雄网格 | | full | 100% | 英雄网格 |
| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),sticky + 视口内自滚动 | 版本 | | read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),宽度随可用侧栏约 280420pxsticky + 视口内自滚动 | 版本 |
| standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 | | standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
| wide | `min(1080px, …)` | 走势 | | wide | `min(1080px, …)` | 走势 |
| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛 | | data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛、玩家「我」主页 |
间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。 间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
@@ -306,9 +306,22 @@ components:
## Components ## Components
### 玩家主页(Identity / Snapshot / Dashboard
登录后「我」与 `/players/{id}` 共用**紧凑数据仪表盘**语言,不做小黑盒式社交 feed:
- **Identity strip**:整行;`panel-soft` + 细边框 + `radius-lg`;头像 `full` 圆形 56px;昵称 + 段位勋章同行。勋章容器约 64px(OpenDota 图标含画布留白,光学尺寸对齐头像),具体星级用 `rank_icon_*` + `rank_star_*` 叠层表达(如传奇四),不并排写「传奇4」文字;冠绝榜位可保留 `#N`
- **Snapshot 摘要区**:生涯与近 N 场并排(窄屏堆叠);指标卡 `radius-md`、数字 `tabular-nums`、紧凑五列网格;空数据整组隐藏,禁止显示伪 0% 胜率。
- **Analysis 次级区**:宽屏双栏——左常用英雄列表,右 180 天活动 + 队友;`panel-soft` 分块,避免每块独占整页高度。
- **近期比赛列表**:左英雄/KDA(弹性占满),右「时长/相对时间」与「胜负/#ID」两列双行紧簇右对齐(上:时长·胜负,下:相对时间·#ID);禁止再插 `1fr` 空隙把时长悬在行中;胜负语义色 + 左侧 3px 细边;整行可点。
- **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。
- **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。
- 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。
- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。
### 导航 ### 导航
- **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。 - **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。登录后「我」出现在「英雄」前。
- **Detail tabs**:下划线式,字号 `body-md` - **Detail tabs**:下划线式,字号 `body-md`
- **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。 - **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。
- **顶栏邮件**40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。 - **顶栏邮件**40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。
+4 -2
View File
@@ -147,10 +147,12 @@
"dump_payloads": true "dump_payloads": true
}, },
"player_pages": { "player_pages": {
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true also POSTs /api/players/publish (OSS). Default private.", "comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true POSTs /api/players/publish (queue→D1/R2). recent_limit=recent N matches; recent_days=GSI discovery window; enrich_ttl_seconds=web cache TTL (default 600, same as Pages). Default private.",
"enabled": true, "enabled": true,
"public_share": false, "public_share": false,
"recent_limit": 30, "recent_limit": 20,
"recent_days": 14,
"enrich_ttl_seconds": 600,
"poll_attempts": 12, "poll_attempts": 12,
"poll_base_seconds": 30, "poll_base_seconds": 30,
"publish_url": "https://dota2.refining.dev/api/players/publish", "publish_url": "https://dota2.refining.dev/api/players/publish",
+717 -11
View File
@@ -19,23 +19,53 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json import json
import os
import threading import threading
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from shared.grid import hero_table from shared.grid import hero_table
from shared.http_utils import UA, write_json_atomic from shared.http_utils import UA, write_json_atomic
from shared.paths import PC_PLAYER_PAGES from shared.paths import PC_DIR, PC_PLAYER_PAGES
try:
from player_stats import ( # type: ignore
activity_from_matches,
aggregate_from_rows,
career_from_opendota,
merge_availability,
peers_from_opendota,
should_keep_old_career,
top_heroes_from_opendota,
)
except ImportError: # when imported as pc.player_pages
from pc.player_stats import ( # type: ignore
activity_from_matches,
aggregate_from_rows,
career_from_opendota,
merge_availability,
peers_from_opendota,
should_keep_old_career,
top_heroes_from_opendota,
)
OPENDOTA = "https://api.opendota.com/api" OPENDOTA = "https://api.opendota.com/api"
STEAM_API = "https://api.steampowered.com"
POST_GAME = "DOTA_GAMERULES_STATE_POST_GAME" POST_GAME = "DOTA_GAMERULES_STATE_POST_GAME"
PC_SAMPLES_RAW = PC_DIR / "samples" / "raw"
_THROTTLE_SEC = 0.4
DEFAULT_RECENT_LIMIT = 20
# Align with Pages `/api/players/me` isStale (10 minutes).
DEFAULT_ENRICH_TTL_SEC = 600
_lock = threading.Lock() _lock = threading.Lock()
_in_flight: set[str] = set() _in_flight: set[str] = set()
_done: set[str] = set() _done: set[str] = set()
_enrich_bg_inflight: set[int] = set()
_hero_by_id: dict[int, dict] | None = None _hero_by_id: dict[int, dict] | None = None
@@ -151,6 +181,15 @@ def normalize_match(
else: else:
personaname = None personaname = None
# OpenDota: same party_id (>0) = stacked; 0/null often means solo or unknown.
party_raw = p.get("party_id")
try:
party_id = int(party_raw) if party_raw is not None else None
except (TypeError, ValueError):
party_id = None
if party_id is not None and party_id <= 0:
party_id = None
slim_players.append( slim_players.append(
{ {
"account_id": account_id_i, "account_id": account_id_i,
@@ -165,29 +204,52 @@ def normalize_match(
"kda": _kda(kills, deaths, assists), "kda": _kda(kills, deaths, assists),
"hero_damage": hero_damage, "hero_damage": hero_damage,
"net_worth": net_worth, "net_worth": net_worth,
"party_id": party_id,
"party_label": None,
"items": _item_ids(p), "items": _item_ids(p),
"is_radiant": is_radiant, "is_radiant": is_radiant,
"won": radiant_win if is_radiant else not radiant_win, "won": radiant_win if is_radiant else not radiant_win,
"_mvp": _mvp_score(p), "_mvp": _mvp_score(p),
"_side": side, "_side": side,
"_slot": slot,
} }
) )
if len(slim_players) < 2: if len(slim_players) < 2:
return None return None
# Label stacks of 2+ that share party_id (A/B/C…); solo stays null.
party_counts: dict[int, int] = {}
for p in slim_players:
pid = p.get("party_id")
if isinstance(pid, int) and pid > 0:
party_counts[pid] = party_counts.get(pid, 0) + 1
party_labels: dict[int, str] = {}
for pid, n in sorted(party_counts.items()):
if n >= 2:
party_labels[pid] = chr(ord("A") + len(party_labels))
for p in slim_players: for p in slim_players:
side = p.pop("_side") side = p.pop("_side")
tk = team_kills[side] or 1 tk = team_kills[side] or 1
td = team_dmg[side] or 1 td = team_dmg[side] or 1
p["participation"] = round((p["kills"] + p["assists"]) / tk, 3) p["participation"] = round((p["kills"] + p["assists"]) / tk, 3)
p["damage_share"] = round(p["hero_damage"] / td, 3) p["damage_share"] = round(p["hero_damage"] / td, 3)
pid = p.get("party_id")
p["party_label"] = party_labels.get(pid) if isinstance(pid, int) else None
# Prefer account_id when present; fall back to slot so anonymous/private
# lobbies still get an MVP badge (OpenDota often omits account_id).
mvp = max(slim_players, key=lambda r: r["_mvp"]) mvp = max(slim_players, key=lambda r: r["_mvp"])
mvp_account = mvp.get("account_id") mvp_account = mvp.get("account_id")
mvp_slot = mvp.get("_slot")
for p in slim_players: for p in slim_players:
p["is_mvp"] = bool(mvp_account is not None and p.get("account_id") == mvp_account) if mvp_account is not None:
p["is_mvp"] = p.get("account_id") == mvp_account
else:
p["is_mvp"] = p.get("_slot") == mvp_slot
del p["_mvp"] del p["_mvp"]
del p["_slot"]
return { return {
"match_id": match_id, "match_id": match_id,
@@ -241,20 +303,279 @@ def account_in_match(match: dict, account_id: int) -> bool:
def _fetch_opendota_match(match_id: int) -> dict | None: def _fetch_opendota_match(match_id: int) -> dict | None:
url = f"{OPENDOTA}/matches/{match_id}" data = _fetch_opendota_json(f"/matches/{match_id}", timeout=45)
if not isinstance(data, dict) or not data.get("players"):
return None
return data
def _opendota_url(path: str, *, query: dict[str, Any] | None = None) -> str:
url = f"{OPENDOTA}{path}"
params = dict(query or {})
key = (os.environ.get("OPENDOTA_API_KEY") or "").strip()
if key:
params["api_key"] = key
if params:
url += "?" + urllib.parse.urlencode(params)
return url
def _fetch_opendota_json(path: str, *, query: dict[str, Any] | None = None, timeout: int = 45) -> Any:
url = _opendota_url(path, query=query)
req = urllib.request.Request(url, headers={"User-Agent": UA}) req = urllib.request.Request(url, headers={"User-Agent": UA})
try: try:
with urllib.request.urlopen(req, timeout=45) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode()) return json.loads(resp.read().decode())
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 404: if e.code in (403, 404, 429):
return None return None
raise raise
except (urllib.error.URLError, TimeoutError, OSError, ValueError): except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None return None
if not isinstance(data, dict) or not data.get("players"):
def _fetch_opendota_recent(account_id: int) -> list[dict]:
"""OpenDota /players/{id}/recentMatches (empty when profile is private)."""
data = _fetch_opendota_json(f"/players/{account_id}/recentMatches")
if not isinstance(data, list):
return []
return [r for r in data if isinstance(r, dict)]
def _fetch_steam_match_history_status(account_id: int) -> int | None:
"""Steam GetMatchHistory status: 1=ok, 15=private, None=unavailable."""
key = (os.environ.get("STEAM_API_KEY") or "").strip()
if not key:
return None return None
return data q = urllib.parse.urlencode(
{"key": key, "account_id": account_id, "matches_requested": 1}
)
url = f"{STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/?{q}"
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode())
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, ValueError):
return None
result = (data or {}).get("result") if isinstance(data, dict) else None
if not isinstance(result, dict):
return None
try:
return int(result.get("status"))
except (TypeError, ValueError):
return None
def _recent_from_gsi(since_ts: int) -> set[int]:
"""Match ids from pc/samples/raw/{match_id}/ with mtime >= since_ts."""
out: set[int] = set()
root = PC_SAMPLES_RAW
if not root.is_dir():
return out
for child in root.iterdir():
if not child.is_dir() or not child.name.isdigit():
continue
mid = _int(child.name, 0)
if mid <= 0:
continue
try:
mtime = int(child.stat().st_mtime)
except OSError:
continue
if mtime >= since_ts:
out.add(mid)
return out
def _gsi_self_info(match_id: int, account_id: int) -> tuple[int | None, str | None]:
"""Best-effort (hero_id, personaname) from samples/raw/{match}/gsi.jsonl."""
path = PC_SAMPLES_RAW / str(match_id) / "gsi.jsonl"
if not path.is_file():
return None, None
hero_key: str | None = None
persona: str | None = None
try:
with path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
o = json.loads(line)
except ValueError:
continue
payload = o.get("payload") if isinstance(o.get("payload"), dict) else o
if not isinstance(payload, dict):
continue
player = payload.get("player") or {}
hero = payload.get("hero") or {}
try:
aid = int(player.get("accountid") or 0)
except (TypeError, ValueError):
aid = 0
if aid != account_id:
continue
pname = player.get("name")
if isinstance(pname, str) and pname.strip():
persona = pname.strip()
name = hero.get("name")
if isinstance(name, str) and name.startswith("npc_dota_hero_"):
hero_key = name[len("npc_dota_hero_") :]
except OSError:
return None, None
hero_id = None
if hero_key:
for h in _hero_lookup().values():
if h.get("key") == hero_key:
try:
hero_id = int(h.get("id") or 0) or None
except (TypeError, ValueError):
hero_id = None
break
return hero_id, persona
def _gsi_self_hero_id(match_id: int, account_id: int) -> int | None:
hero_id, _ = _gsi_self_info(match_id, account_id)
return hero_id
def _attach_focus_account(
match: dict,
account_id: int,
hero_id: int | None,
*,
personaname: str | None = None,
) -> bool:
"""If account hidden, bind account_id onto hero_id player for local pages."""
if account_in_match(match, account_id):
if personaname:
for p in match.get("players") or []:
if not isinstance(p, dict):
continue
try:
if int(p.get("account_id")) == account_id and not p.get("personaname"):
p["personaname"] = personaname
except (TypeError, ValueError):
continue
return True
if not hero_id:
return False
for p in match.get("players") or []:
if not isinstance(p, dict):
continue
if _int(p.get("hero_id")) == hero_id and p.get("account_id") in (None, 0, "0"):
p["account_id"] = account_id
if personaname and not p.get("personaname"):
p["personaname"] = personaname
return True
return False
def _fetch_opendota_player(account_id: int) -> dict:
"""OpenDota /players/{id} → personaname / rank_tier / leaderboard_rank / avatar."""
data = _fetch_opendota_json(f"/players/{account_id}", timeout=30)
if not isinstance(data, dict):
return {}
profile = data.get("profile") if isinstance(data.get("profile"), dict) else data
name = profile.get("personaname") if isinstance(profile, dict) else None
out: dict = {}
if isinstance(name, str) and name.strip():
out["personaname"] = name.strip()
avatar = None
if isinstance(profile, dict):
avatar = profile.get("avatarfull") or profile.get("avatarmedium") or profile.get("avatar")
if isinstance(avatar, str) and avatar.strip():
out["avatar"] = avatar.strip()
try:
tier = int(data.get("rank_tier")) if data.get("rank_tier") is not None else None
except (TypeError, ValueError):
tier = None
if tier and tier >= 10:
out["rank_tier"] = tier
try:
lb = (
int(data.get("leaderboard_rank"))
if data.get("leaderboard_rank") is not None
else None
)
except (TypeError, ValueError):
lb = None
if lb and lb > 0:
out["leaderboard_rank"] = lb
if data.get("profile") and isinstance(data["profile"], dict):
out["fh_unavailable"] = bool(data["profile"].get("fh_unavailable"))
return out
def _fetch_opendota_persona(account_id: int) -> str | None:
"""Steam persona from OpenDota /players/{id} (None if private/missing)."""
return _fetch_opendota_player(account_id).get("personaname")
def fill_player_personanames(detail: dict) -> None:
"""Fill missing personaname for players that have a public account_id."""
players = detail.get("players")
if not isinstance(players, list):
return
for i, p in enumerate(players):
if not isinstance(p, dict):
continue
if p.get("personaname"):
continue
aid = p.get("account_id")
try:
aid_i = int(aid) if aid is not None else 0
except (TypeError, ValueError):
aid_i = 0
if aid_i <= 0:
continue
if i > 0:
time.sleep(_THROTTLE_SEC)
persona = _fetch_opendota_persona(aid_i)
if persona:
p["personaname"] = persona
def summary_from_recent_row(row: dict) -> dict | None:
"""Build a profile.recent row from OpenDota recentMatches entry."""
mid = _int(row.get("match_id"), 0)
if mid <= 0:
return None
hero_id = _int(row.get("hero_id"), 0)
hero = _hero_lookup().get(hero_id) or {}
kills = _int(row.get("kills"))
deaths = _int(row.get("deaths"))
assists = _int(row.get("assists"))
player_slot = _int(row.get("player_slot"))
radiant_win = bool(row.get("radiant_win"))
is_radiant = player_slot < 128
start_time = row.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
gpm = row.get("gold_per_min")
xpm = row.get("xp_per_min")
dmg = row.get("hero_damage")
return {
"match_id": mid,
"start_time": start_time_i,
"duration": _int(row.get("duration")),
"won": radiant_win if is_radiant else not radiant_win,
"hero_id": hero_id or None,
"hero_key": hero.get("key"),
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": _kda(kills, deaths, assists),
"gpm": _int(gpm) if gpm is not None else None,
"xpm": _int(xpm) if xpm is not None else None,
"hero_damage": _int(dmg) if dmg is not None else None,
"game_mode": _int(row.get("game_mode")) if row.get("game_mode") is not None else None,
"lobby_type": _int(row.get("lobby_type")) if row.get("lobby_type") is not None else None,
}
def profile_path(account_id: int) -> Path: def profile_path(account_id: int) -> Path:
@@ -286,6 +607,145 @@ def load_profile(account_id: int) -> dict:
return data return data
def enrich_ttl_seconds(cfg: dict) -> int:
pp = _pp_cfg(cfg)
return max(30, _int(pp.get("enrich_ttl_seconds"), DEFAULT_ENRICH_TTL_SEC))
def profile_fetched_at(profile: dict | None) -> str | None:
if not isinstance(profile, dict):
return None
avail = profile.get("availability")
if isinstance(avail, dict) and avail.get("fetched_at"):
return str(avail["fetched_at"])
if profile.get("enriched_at"):
return str(profile["enriched_at"])
if profile.get("updated_at"):
return str(profile["updated_at"])
return None
def profile_has_payload(profile: dict | None) -> bool:
"""True when cached profile is worth serving without a full OpenDota round-trip."""
if not isinstance(profile, dict):
return False
career = profile.get("career")
if isinstance(career, dict) and _int(career.get("games"), 0) > 0:
return True
recent = profile.get("recent")
if isinstance(recent, list) and any(isinstance(r, dict) for r in recent):
return True
r20 = profile.get("recent_20")
if isinstance(r20, dict) and _int(r20.get("sample"), 0) > 0:
return True
return False
def profile_is_stale(profile: dict | None, ttl_sec: int) -> bool:
fetched = profile_fetched_at(profile)
if not fetched:
return True
try:
# Accept trailing Z.
ts = datetime.fromisoformat(fetched.replace("Z", "+00:00")).timestamp()
except ValueError:
return True
return (time.time() - ts) > max(30, int(ttl_sec))
def _schedule_enrich_background(
cfg: dict,
account_id: int,
*,
include_gsi: bool = True,
) -> None:
"""Fire-and-forget local refresh (mirrors production Queue)."""
with _lock:
if account_id in _enrich_bg_inflight:
return
_enrich_bg_inflight.add(account_id)
def _run() -> None:
try:
enrich_profile_recent(cfg, account_id, include_gsi=include_gsi)
except Exception as e: # noqa: BLE001
print(f"[player_pages] background enrich {account_id}: {e}", flush=True)
finally:
with _lock:
_enrich_bg_inflight.discard(account_id)
threading.Thread(
target=_run,
name=f"player-enrich-{account_id}",
daemon=True,
).start()
def get_profile_for_web(
cfg: dict,
account_id: int | str,
*,
force: bool = False,
include_gsi: bool = True,
) -> dict:
"""Serve player homepage: cache-first, first miss sync, stale refresh async.
Matches production semantics: first load may wait on OpenDota; later loads
return local profile.json immediately and refresh in the background when
older than enrich_ttl_seconds (default 600).
"""
try:
aid = int(account_id)
except (TypeError, ValueError):
aid = 0
if aid <= 0:
return {
"account_id": 0,
"personaname": None,
"public_share": False,
"updated_at": None,
"recent": [],
"error": "bad account_id",
}
ttl = enrich_ttl_seconds(cfg)
cached = load_profile(aid)
has_payload = profile_has_payload(cached)
stale = profile_is_stale(cached, ttl)
if has_payload and not force and not stale:
out = dict(cached)
out["stale"] = False
avail = out.get("availability")
if isinstance(avail, dict):
avail = dict(avail)
avail["stale"] = False
out["availability"] = avail
return out
if has_payload and not force and stale:
_schedule_enrich_background(cfg, aid, include_gsi=include_gsi)
out = dict(cached)
out["stale"] = True
avail = out.get("availability")
if isinstance(avail, dict):
avail = dict(avail)
avail["stale"] = True
out["availability"] = avail
else:
out["availability"] = {
"status": "unknown",
"note": "刷新中",
"complete": False,
"stale": True,
"fetched_at": profile_fetched_at(cached),
}
return out
# Cold miss (or force): block once so the first page view gets real data.
return enrich_profile_recent(cfg, aid, include_gsi=include_gsi)
def upsert_profile( def upsert_profile(
account_id: int, account_id: int,
*, *,
@@ -309,6 +769,227 @@ def upsert_profile(
return profile return profile
def enrich_profile_recent(
cfg: dict,
account_id: int | str,
*,
days: int | None = None,
include_gsi: bool = True,
) -> dict:
"""Refresh profile: recent 20 + career/top heroes/peers/activity.
OpenDota is the stats source. Optional GSI raw dirs fill private lobbies.
Empty OpenDota responses do not wipe previously stored career stats.
"""
pp = _pp_cfg(cfg)
try:
aid = int(account_id)
except (TypeError, ValueError):
aid = 0
if aid <= 0:
return {
"account_id": 0,
"personaname": None,
"public_share": False,
"updated_at": None,
"recent": [],
"error": "bad account_id",
}
# Legacy `days` still gates GSI discovery window; list itself is recent N.
window_days = max(1, _int(days if days is not None else pp.get("recent_days"), 14))
recent_limit = max(1, _int(pp.get("recent_limit"), DEFAULT_RECENT_LIMIT))
public_share = bool(pp.get("public_share", False))
since_ts = int(time.time()) - window_days * 86400
by_mid: dict[int, dict] = {}
try:
rows = _fetch_opendota_recent(aid)
except Exception as e: # noqa: BLE001
print(f"[player_pages] recentMatches error for {aid}: {e}", flush=True)
rows = []
for row in rows:
summary = summary_from_recent_row(row)
if summary:
by_mid[summary["match_id"]] = summary
gsi_mids: set[int] = set()
if include_gsi:
gsi_mids = _recent_from_gsi(since_ts)
need_fetch = sorted(m for m in gsi_mids if m not in by_mid)
for i, mid in enumerate(need_fetch):
if i > 0:
time.sleep(_THROTTLE_SEC)
try:
match = _fetch_opendota_match(mid)
except Exception as e: # noqa: BLE001
print(f"[player_pages] enrich match {mid}: {e}", flush=True)
continue
if not match:
continue
hero_id, gsi_name = _gsi_self_info(mid, aid)
if not _attach_focus_account(match, aid, hero_id, personaname=gsi_name):
continue
detail = normalize_match(match, focus_account_id=aid)
if not detail:
continue
fill_player_personanames(detail)
summary = match_summary_for_profile(detail, aid)
if summary:
by_mid[mid] = summary
if not match_path(aid, mid).is_file():
write_json_atomic(match_path(aid, mid), detail)
profile = load_profile(aid)
existing = [r for r in (profile.get("recent") or []) if isinstance(r, dict)]
for r in existing:
mid = _int(r.get("match_id"), 0)
if mid > 0 and mid not in by_mid:
by_mid[mid] = r
merged = sorted(
by_mid.values(),
key=lambda r: (_int(r.get("start_time"), 0), _int(r.get("match_id"), 0)),
reverse=True,
)
profile["recent"] = merged[:recent_limit]
profile["account_id"] = aid
profile["public_share"] = public_share
meta = _fetch_opendota_player(aid)
if meta.get("personaname"):
profile["personaname"] = meta["personaname"]
if meta.get("avatar"):
profile["avatar"] = meta["avatar"]
if meta.get("rank_tier"):
profile["rank_tier"] = meta["rank_tier"]
if meta.get("leaderboard_rank"):
profile["leaderboard_rank"] = meta["leaderboard_rank"]
wl = _fetch_opendota_json(f"/players/{aid}/wl")
totals = _fetch_opendota_json(f"/players/{aid}/totals")
heroes = _fetch_opendota_json(f"/players/{aid}/heroes")
peers = _fetch_opendota_json(f"/players/{aid}/peers")
matches_180 = _fetch_opendota_json(
f"/players/{aid}/matches",
query={"date": 180, "significant": 0},
)
steam_status = _fetch_steam_match_history_status(aid)
new_career = career_from_opendota(
wl if isinstance(wl, dict) else None,
totals if isinstance(totals, list) else None,
)
profile["career"] = should_keep_old_career(profile.get("career"), new_career)
profile["recent_20"] = aggregate_from_rows(profile["recent"], limit=20)
profile["top_heroes"] = top_heroes_from_opendota(
heroes if isinstance(heroes, list) else None,
hero_lookup=_hero_lookup(),
limit=5,
) or profile.get("top_heroes") or []
new_peers = peers_from_opendota(peers if isinstance(peers, list) else None, limit=8)
if new_peers:
profile["peers"] = new_peers
elif not isinstance(profile.get("peers"), list):
profile["peers"] = []
new_activity = activity_from_matches(
matches_180 if isinstance(matches_180, list) else None,
days=180,
)
if new_activity:
profile["activity_180"] = new_activity
elif not isinstance(profile.get("activity_180"), dict):
profile["activity_180"] = None
fetched = _utc_now()
profile["availability"] = merge_availability(
opendota_recent_n=len(rows),
career=profile.get("career"),
steam_history_status=steam_status,
fetched_at=fetched,
)
if meta.get("fh_unavailable") and profile["availability"]["status"] == "unknown":
profile["availability"]["status"] = "private"
profile["availability"]["note"] = "未公开比赛数据"
profile["updated_at"] = fetched
profile["enriched_at"] = fetched
profile["stale"] = False
if isinstance(profile.get("availability"), dict):
profile["availability"]["stale"] = False
write_json_atomic(profile_path(aid), profile)
print(
f"[player_pages] enrich account {aid}: {len(profile['recent'])} recent "
f"(limit={recent_limit}, gsi={len(gsi_mids)}, rank={profile.get('rank_tier')}, "
f"avail={profile['availability'].get('status')})",
flush=True,
)
return profile
def ensure_match_detail(
cfg: dict,
account_id: int | str,
match_id: int | str,
) -> tuple[dict | None, str]:
"""Return local match JSON; fetch+normalize from OpenDota if missing.
Returns (detail, error_message). error_message empty on success.
"""
pp = _pp_cfg(cfg)
try:
aid = int(account_id)
mid = int(match_id)
except (TypeError, ValueError):
return None, "bad account_id or match_id"
if aid <= 0 or mid <= 0:
return None, "bad account_id or match_id"
path = match_path(aid, mid)
if path.is_file():
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
return None, str(e)
if isinstance(data, dict) and data.get("players"):
return data, ""
try:
match = _fetch_opendota_match(mid)
except Exception as e: # noqa: BLE001
return None, f"opendota: {e}"
if not match:
return None, "match not ready on OpenDota"
hero_id, gsi_name = _gsi_self_info(mid, aid)
if not _attach_focus_account(match, aid, hero_id, personaname=gsi_name):
return None, "account not in match (private or wrong id)"
detail = normalize_match(match, focus_account_id=aid)
if not detail:
return None, "normalize failed"
fill_player_personanames(detail)
write_json_atomic(path, detail)
summary = match_summary_for_profile(detail, aid)
if summary:
personaname = gsi_name
for p in detail["players"]:
if p.get("account_id") == aid and p.get("personaname"):
personaname = p["personaname"]
break
if not personaname:
personaname = _fetch_opendota_persona(aid)
upsert_profile(
aid,
summary=summary,
personaname=personaname,
public_share=bool(pp.get("public_share", False)),
recent_limit=max(1, _int(pp.get("recent_limit"), 30)),
)
return detail, ""
def publish_remote( def publish_remote(
*, *,
account_id: int, account_id: int,
@@ -401,11 +1082,20 @@ def process_post_game(
if match and account_in_match(match, aid): if match and account_in_match(match, aid):
break break
if match and not account_in_match(match, aid): if match and not account_in_match(match, aid):
hero_id, gsi_name = _gsi_self_info(mid, aid)
if _attach_focus_account(match, aid, hero_id, personaname=gsi_name):
print(
f"[player_pages] match {mid}: attached account via GSI hero",
flush=True,
)
break
print( print(
f"[player_pages] match {mid} has no account {aid}skip", f"[player_pages] match {mid} has no account {aid}"
"will still try enrich from GSI later",
flush=True, flush=True,
) )
return match = None
break
match = None match = None
print( print(
f"[player_pages] match {mid} not ready ({i + 1}/{attempts})", f"[player_pages] match {mid} not ready ({i + 1}/{attempts})",
@@ -413,13 +1103,21 @@ def process_post_game(
) )
if not match: if not match:
print(f"[player_pages] gave up waiting for match {mid}", flush=True) print(
f"[player_pages] match {mid} detail unavailable; enriching recent…",
flush=True,
)
try:
enrich_profile_recent(cfg, aid, include_gsi=True)
except Exception as e: # noqa: BLE001
print(f"[player_pages] enrich failed: {e}", flush=True)
return return
detail = normalize_match(match, focus_account_id=aid) detail = normalize_match(match, focus_account_id=aid)
if not detail: if not detail:
print(f"[player_pages] normalize failed for {mid}", flush=True) print(f"[player_pages] normalize failed for {mid}", flush=True)
return return
fill_player_personanames(detail)
write_json_atomic(match_path(aid, mid), detail) write_json_atomic(match_path(aid, mid), detail)
summary = match_summary_for_profile(detail, aid) summary = match_summary_for_profile(detail, aid)
@@ -431,6 +1129,9 @@ def process_post_game(
if p.get("account_id") == aid and p.get("personaname"): if p.get("account_id") == aid and p.get("personaname"):
personaname = p["personaname"] personaname = p["personaname"]
break break
if not personaname:
_, gsi_name = _gsi_self_info(mid, aid)
personaname = gsi_name or _fetch_opendota_persona(aid)
profile = upsert_profile( profile = upsert_profile(
aid, aid,
summary=summary, summary=summary,
@@ -468,6 +1169,11 @@ def process_post_game(
else: else:
print(f"[player_pages] publish failed: {msg}", flush=True) print(f"[player_pages] publish failed: {msg}", flush=True)
try:
enrich_profile_recent(cfg, aid, include_gsi=True)
except Exception as e: # noqa: BLE001
print(f"[player_pages] enrich after post-game failed: {e}", flush=True)
with _lock: with _lock:
_done.add(key) _done.add(key)
finally: finally:
+327
View File
@@ -0,0 +1,327 @@
"""Player homepage aggregates (career / recent20 / heroes / activity / peers).
Pure helpers used by pc/player_pages.py. Not used by recommend / item_suggest.
"""
from __future__ import annotations
from typing import Any
def _int(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def _float(v: Any, default: float = 0.0) -> float:
try:
return float(v)
except (TypeError, ValueError):
return default
def kda(kills: int, deaths: int, assists: int) -> float:
return round((kills + assists) / max(deaths, 1), 1)
def winrate(wins: int, losses: int) -> float | None:
total = wins + losses
if total <= 0:
return None
return round(wins / total * 1000) / 10
def aggregate_from_rows(rows: list[dict], *, limit: int | None = None) -> dict:
"""Aggregate match summary rows into a stats snapshot."""
sample = list(rows)
if limit is not None:
sample = sample[: max(0, limit)]
wins = 0
losses = 0
kills = deaths = assists = 0
gpm_sum = xpm_sum = dmg_sum = 0
gpm_n = xpm_n = dmg_n = 0
heroes: list[dict] = []
for r in sample:
if not isinstance(r, dict):
continue
if r.get("won"):
wins += 1
else:
losses += 1
k = _int(r.get("kills"))
d = _int(r.get("deaths"))
a = _int(r.get("assists"))
kills += k
deaths += d
assists += a
if r.get("gpm") is not None:
gpm_sum += _int(r.get("gpm"))
gpm_n += 1
if r.get("xpm") is not None:
xpm_sum += _int(r.get("xpm"))
xpm_n += 1
if r.get("hero_damage") is not None:
dmg_sum += _int(r.get("hero_damage"))
dmg_n += 1
heroes.append(
{
"match_id": _int(r.get("match_id")),
"hero_id": r.get("hero_id"),
"hero_key": r.get("hero_key"),
"hero_name_loc": r.get("hero_name_loc"),
"won": bool(r.get("won")),
}
)
n = wins + losses
out = {
"sample": n,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": kda(kills, deaths, assists) if n else None,
"avg_kills": round(kills / n, 1) if n else None,
"avg_deaths": round(deaths / n, 1) if n else None,
"avg_assists": round(assists / n, 1) if n else None,
"avg_gpm": round(gpm_sum / gpm_n) if gpm_n else None,
"avg_xpm": round(xpm_sum / xpm_n) if xpm_n else None,
"avg_hero_damage": round(dmg_sum / dmg_n) if dmg_n else None,
"heroes": heroes,
}
return out
def career_from_opendota(wl: dict | None, totals: list | None) -> dict | None:
"""Build career block from OpenDota /wl + /totals. None when empty/private."""
wl = wl if isinstance(wl, dict) else {}
wins = _int(wl.get("win"))
losses = _int(wl.get("lose"))
if wins <= 0 and losses <= 0:
return None
by_field: dict[str, dict] = {}
if isinstance(totals, list):
for row in totals:
if isinstance(row, dict) and row.get("field"):
by_field[str(row["field"])] = row
def sum_of(field: str) -> int:
return _int((by_field.get(field) or {}).get("sum"))
def n_of(field: str) -> int:
return _int((by_field.get(field) or {}).get("n"))
n = wins + losses
kills = sum_of("kills")
deaths = sum_of("deaths")
assists = sum_of("assists")
gpm_n = n_of("gold_per_min")
xpm_n = n_of("xp_per_min")
dmg_n = n_of("hero_damage")
return {
"games": n,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": kda(kills, deaths, assists) if n else None,
"avg_kills": round(kills / n, 1) if n else None,
"avg_deaths": round(deaths / n, 1) if n else None,
"avg_assists": round(assists / n, 1) if n else None,
"avg_gpm": round(sum_of("gold_per_min") / gpm_n) if gpm_n else None,
"avg_xpm": round(sum_of("xp_per_min") / xpm_n) if xpm_n else None,
"avg_hero_damage": round(sum_of("hero_damage") / dmg_n) if dmg_n else None,
"source": "opendota",
}
def top_heroes_from_opendota(
rows: list | None,
*,
hero_lookup: dict[int, dict],
limit: int = 5,
) -> list[dict]:
if not isinstance(rows, list):
return []
scored: list[tuple[int, dict]] = []
for row in rows:
if not isinstance(row, dict):
continue
games = _int(row.get("games"))
if games <= 0:
continue
hid = _int(row.get("hero_id"))
hero = hero_lookup.get(hid) or {}
wins = _int(row.get("win"))
last = row.get("last_played")
try:
last_i = int(last) if last is not None else None
except (TypeError, ValueError):
last_i = None
scored.append(
(
games,
{
"hero_id": hid or None,
"hero_key": hero.get("key"),
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
"games": games,
"wins": wins,
"winrate": winrate(wins, max(0, games - wins)),
"last_played": last_i,
},
)
)
scored.sort(key=lambda t: (-t[0], -(_int(t[1].get("last_played")))))
return [item for _, item in scored[: max(1, limit)]]
def peers_from_opendota(rows: list | None, *, limit: int = 8) -> list[dict]:
if not isinstance(rows, list):
return []
out: list[dict] = []
for row in rows:
if not isinstance(row, dict):
continue
aid = _int(row.get("account_id"))
games = _int(row.get("games"))
if aid <= 0 or games <= 0:
continue
wins = _int(row.get("win"))
name = row.get("personaname")
if not isinstance(name, str) or not name.strip():
name = f"玩家 {aid}"
avatar = row.get("avatarfull") or row.get("avatar")
if not isinstance(avatar, str):
avatar = None
out.append(
{
"account_id": aid,
"personaname": name.strip(),
"avatar": avatar,
"games": games,
"wins": wins,
"winrate": winrate(wins, max(0, games - wins)),
}
)
if len(out) >= limit:
break
return out
def activity_from_matches(rows: list | None, *, days: int = 180) -> dict | None:
"""Build 180-day activity heatmap + sample highs from OpenDota /matches."""
if not isinstance(rows, list) or not rows:
return None
by_day: dict[str, dict] = {}
max_kills = max_assists = max_gpm = None
wins = losses = 0
for row in rows:
if not isinstance(row, dict):
continue
st = row.get("start_time")
try:
st_i = int(st) if st is not None else 0
except (TypeError, ValueError):
st_i = 0
if st_i <= 0:
continue
# UTC day key YYYY-MM-DD
from datetime import datetime, timezone
day = datetime.fromtimestamp(st_i, tz=timezone.utc).strftime("%Y-%m-%d")
cell = by_day.setdefault(day, {"games": 0, "wins": 0})
cell["games"] += 1
player_slot = _int(row.get("player_slot"))
radiant_win = bool(row.get("radiant_win"))
won = radiant_win if player_slot < 128 else not radiant_win
if won:
cell["wins"] += 1
wins += 1
else:
losses += 1
kills = _int(row.get("kills"))
assists = _int(row.get("assists"))
gpm = _int(row.get("gold_per_min"))
hero_id = _int(row.get("hero_id")) or None
if max_kills is None or kills > max_kills["value"]:
max_kills = {"value": kills, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
if max_assists is None or assists > max_assists["value"]:
max_assists = {
"value": assists,
"hero_id": hero_id,
"match_id": _int(row.get("match_id")),
}
if gpm > 0 and (max_gpm is None or gpm > max_gpm["value"]):
max_gpm = {"value": gpm, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
days_list = [
{"date": d, "games": v["games"], "wins": v["wins"]}
for d, v in sorted(by_day.items())
]
return {
"days": days,
"sample": wins + losses,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"heatmap": days_list,
"highs": {
"kills": max_kills,
"assists": max_assists,
"gpm": max_gpm,
},
"label": f"最近 {days} 天样本",
}
def merge_availability(
*,
opendota_recent_n: int,
career: dict | None,
steam_history_status: int | None,
fetched_at: str,
) -> dict:
"""Describe whether match history is public / syncing / private."""
od_public = opendota_recent_n > 0 or bool(career and career.get("games"))
steam_allowed = steam_history_status in (1,) # 1 = success
steam_denied = steam_history_status == 15
if od_public:
status = "public"
complete = True
note = None
elif steam_allowed and not od_public:
status = "syncing"
complete = False
note = "Steam 已公开,OpenDota 同步中"
elif steam_denied:
status = "private"
complete = False
note = "未公开比赛数据"
else:
status = "unknown"
complete = False
note = "暂无公开战绩"
return {
"status": status,
"complete": complete,
"opendota_public": od_public,
"steam_history_status": steam_history_status,
"source": "opendota+steam",
"fetched_at": fetched_at,
"note": note,
"stale": False,
}
def should_keep_old_career(old: dict | None, new: dict | None) -> dict | None:
"""HTTP 200 empty must not wipe a previously populated career."""
if new:
return new
if old and isinstance(old, dict) and _int(old.get("games")) > 0:
return old
return new
+50
View File
@@ -0,0 +1,50 @@
"""TTL helpers for player homepage cache-first serving."""
from __future__ import annotations
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
PC = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PC))
from player_pages import ( # noqa: E402
profile_fetched_at,
profile_has_payload,
profile_is_stale,
)
def _iso_ago(seconds: int) -> str:
t = datetime.now(timezone.utc) - timedelta(seconds=seconds)
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
class PlayerPagesCacheTests(unittest.TestCase):
def test_has_payload_career(self):
self.assertTrue(profile_has_payload({"career": {"games": 10}}))
self.assertFalse(profile_has_payload({"career": {"games": 0}}))
def test_has_payload_recent(self):
self.assertTrue(profile_has_payload({"recent": [{"match_id": 1}]}))
self.assertFalse(profile_has_payload({"recent": []}))
def test_fetched_at_prefers_availability(self):
p = {
"availability": {"fetched_at": "2026-01-01T00:00:00Z"},
"enriched_at": "2026-01-02T00:00:00Z",
}
self.assertEqual(profile_fetched_at(p), "2026-01-01T00:00:00Z")
def test_stale_ttl(self):
fresh = {"enriched_at": _iso_ago(60)}
old = {"enriched_at": _iso_ago(1200)}
self.assertFalse(profile_is_stale(fresh, 600))
self.assertTrue(profile_is_stale(old, 600))
self.assertTrue(profile_is_stale({}, 600))
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""Unit tests for player homepage aggregates."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
PC = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PC))
from player_stats import ( # noqa: E402
aggregate_from_rows,
career_from_opendota,
kda,
merge_availability,
should_keep_old_career,
top_heroes_from_opendota,
winrate,
)
class PlayerStatsTests(unittest.TestCase):
def test_kda_zero_deaths(self):
self.assertEqual(kda(5, 0, 5), 10.0)
def test_winrate_empty(self):
self.assertIsNone(winrate(0, 0))
self.assertEqual(winrate(1, 1), 50.0)
def test_aggregate_recent(self):
rows = [
{"won": True, "kills": 10, "deaths": 2, "assists": 8, "gpm": 500, "hero_id": 1},
{"won": False, "kills": 0, "deaths": 10, "assists": 2, "gpm": 300, "hero_id": 2},
]
out = aggregate_from_rows(rows, limit=20)
self.assertEqual(out["sample"], 2)
self.assertEqual(out["wins"], 1)
self.assertEqual(out["winrate"], 50.0)
self.assertEqual(out["avg_gpm"], 400)
def test_career_empty_private(self):
self.assertIsNone(career_from_opendota({"win": 0, "lose": 0}, []))
def test_career_populated(self):
career = career_from_opendota(
{"win": 10, "lose": 10},
[
{"field": "kills", "n": 20, "sum": 100},
{"field": "deaths", "n": 20, "sum": 50},
{"field": "assists", "n": 20, "sum": 150},
{"field": "gold_per_min", "n": 20, "sum": 8000},
],
)
self.assertEqual(career["games"], 20)
self.assertEqual(career["kda"], 5.0)
self.assertEqual(career["avg_gpm"], 400)
def test_keep_old_career_on_empty(self):
old = {"games": 100, "wins": 50, "losses": 50}
self.assertEqual(should_keep_old_career(old, None), old)
new = {"games": 101, "wins": 51, "losses": 50}
self.assertEqual(should_keep_old_career(old, new), new)
def test_top_heroes(self):
heroes = top_heroes_from_opendota(
[
{"hero_id": 1, "games": 5, "win": 3, "last_played": 100},
{"hero_id": 2, "games": 10, "win": 4, "last_played": 90},
],
hero_lookup={1: {"key": "antimage", "name_loc": "敌法"}, 2: {"key": "axe", "name_loc": "斧王"}},
limit=1,
)
self.assertEqual(len(heroes), 1)
self.assertEqual(heroes[0]["hero_key"], "axe")
def test_availability_syncing(self):
avail = merge_availability(
opendota_recent_n=0,
career=None,
steam_history_status=1,
fetched_at="t",
)
self.assertEqual(avail["status"], "syncing")
private = merge_availability(
opendota_recent_n=0,
career=None,
steam_history_status=15,
fetched_at="t",
)
self.assertEqual(private["status"], "private")
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -49,6 +49,11 @@
"b": "spirit_breaker", "b": "spirit_breaker",
"reason": "" "reason": ""
}, },
{
"a": "ancient_apparition",
"b": "necrolyte",
"reason": "冰晶爆轰禁疗,克死亡脉冲续航"
},
{ {
"a": "antimage", "a": "antimage",
"b": "lina", "b": "lina",
+27
View File
@@ -0,0 +1,27 @@
"""One-off: enrich a player profile (no secrets printed)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "pc"))
from player_pages import enrich_profile_recent # noqa: E402
AID = int(sys.argv[1]) if len(sys.argv) > 1 else 143712136
cfg = json.loads((ROOT / "pc" / "config.json").read_text(encoding="utf-8"))
p = enrich_profile_recent(cfg, AID, include_gsi=True)
print(
"name",
p.get("personaname"),
"rank",
p.get("rank_tier"),
"lb",
p.get("leaderboard_rank"),
"recent",
len(p.get("recent") or []),
)
+119
View File
@@ -0,0 +1,119 @@
"""Probe OpenDota + Steam for a player (no secret echo)."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
AID = 143712136
SID = AID + 76561197960265728
UA = "climperor-probe"
def _get(url: str) -> tuple[int, object]:
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=25) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]
return e.code, {"error": body}
except Exception as e: # noqa: BLE001
return 0, {"error": str(e)}
def main() -> None:
key = (
os.environ.get("STEAM_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
or ""
).strip()
od_key = (os.environ.get("OPENDOTA_API_KEY") or "").strip()
print(f"steam_key={bool(key)} opendota_key={bool(od_key)}")
od = f"https://api.opendota.com/api/players/{AID}"
if od_key:
od += f"?api_key={urllib.parse.quote(od_key)}"
code, data = _get(od)
if isinstance(data, dict) and "error" not in data:
profile = data.get("profile") if isinstance(data.get("profile"), dict) else {}
print(
"opendota player",
code,
"name=",
profile.get("personaname") or data.get("personaname"),
"rank_tier=",
data.get("rank_tier"),
"lb=",
data.get("leaderboard_rank"),
)
else:
print("opendota player", code, data)
od_r = f"https://api.opendota.com/api/players/{AID}/recentMatches"
if od_key:
od_r += f"?api_key={urllib.parse.quote(od_key)}"
code, data = _get(od_r)
if isinstance(data, list):
print("opendota recent", code, "n=", len(data))
if data:
print(" first", data[0].get("match_id"), data[0].get("start_time"))
else:
print("opendota recent", code, data)
if key:
q = urllib.parse.urlencode({"key": key, "steamids": str(SID)})
code, data = _get(
f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?{q}"
)
players = (((data or {}).get("response") or {}).get("players")) or []
if players:
p = players[0]
print(
"steam summary",
code,
"name=",
p.get("personaname"),
"loc=",
p.get("loccountrycode"),
)
else:
print("steam summary", code, data)
q = urllib.parse.urlencode(
{
"key": key,
"account_id": AID,
"matches_requested": 10,
}
)
code, data = _get(
"https://api.steampowered.com/IDOTA2Match_570/GetMatchHistory/v1/?" + q
)
if isinstance(data, dict):
result = (data.get("result") or {})
print(
"steam match_history",
code,
"status=",
result.get("status"),
"statusDetail=",
result.get("statusDetail"),
"num=",
result.get("num_results"),
"total=",
result.get("total_results"),
)
matches = result.get("matches") or []
if matches:
m0 = matches[0]
print(" first match_id", m0.get("match_id"), "start", m0.get("start_time"))
else:
print("steam match_history", code, data)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
"""Launch serve_relations with STEAM_API_KEY / SESSION_SECRET from keyzoo env.
Maps KEYZOO_ASSET_SECRET_WEB_API_KEY / KEYZOO_ASSET_SECRET_SESSION_SECRET
when the plain names are unset. Does not print secret values.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _map_env() -> None:
if not (os.environ.get("STEAM_API_KEY") or "").strip():
alt = (os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
if alt:
os.environ["STEAM_API_KEY"] = alt
if not (os.environ.get("SESSION_SECRET") or "").strip():
alt = (os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
if alt:
os.environ["SESSION_SECRET"] = alt
def main() -> int:
_map_env()
steam_ok = bool((os.environ.get("STEAM_API_KEY") or "").strip())
sess_ok = bool((os.environ.get("SESSION_SECRET") or "").strip())
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
if not steam_ok or not sess_ok:
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
return 2
cmd = [sys.executable, "-u", str(ROOT / "web" / "serve_relations.py"), *sys.argv[1:]]
return subprocess.call(cmd, cwd=str(ROOT))
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
"""Background-start serve_relations with keyzoo-mapped Steam auth env."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PORT = "8765"
def _map_env() -> dict[str, str]:
env = os.environ.copy()
if not (env.get("STEAM_API_KEY") or "").strip():
alt = (env.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
if alt:
env["STEAM_API_KEY"] = alt
if not (env.get("SESSION_SECRET") or "").strip():
alt = (env.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
if alt:
env["SESSION_SECRET"] = alt
return env
def main() -> int:
env = _map_env()
steam_ok = bool((env.get("STEAM_API_KEY") or "").strip())
sess_ok = bool((env.get("SESSION_SECRET") or "").strip())
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
if not steam_ok or not sess_ok:
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
return 2
# Free the port if an old serve is still listening.
try:
subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
(
f"$c=Get-NetTCPConnection -LocalPort {PORT} -ErrorAction SilentlyContinue;"
"if($c){$c.OwningProcess|Sort-Object -Unique|ForEach-Object{"
"Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue}}"
),
],
check=False,
capture_output=True,
text=True,
)
except OSError:
pass
log_path = ROOT / "web" / ".refresh" / "serve_steam.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
sys.executable,
"-u",
str(ROOT / "web" / "serve_relations.py"),
"--port",
PORT,
"--no-browser",
]
flags = 0
if sys.platform == "win32":
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
with log_path.open("ab") as log:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=log,
stderr=subprocess.STDOUT,
creationflags=flags,
close_fds=True,
)
print(f"started pid={proc.pid} port={PORT}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
"""Smoke-check STEAM_API_KEY / KEYZOO_ASSET_SECRET_WEB_API_KEY (no secret echo)."""
from __future__ import annotations
import json
import os
import urllib.request
k = (os.environ.get("STEAM_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
if not k:
raise SystemExit("missing key")
url = (
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
f"?key={k}&steamids=76561198000000000"
)
with urllib.request.urlopen(url, timeout=20) as resp:
data = json.loads(resp.read().decode())
print("ok" if isinstance(data, dict) and "response" in data else "bad")
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"account_id": "510534f7f6284344aadaf2f5a0794d48",
"d1": {
"name": "climperor-users",
"id": "9eeb24ba-acc5-4520-b4e7-754ea776394e"
},
"r2": {
"name": "climperor-player-data",
"ready": true
},
"queue": {
"name": "climperor-player-sync",
"id": "371f11c7b4114f9b99ab10062a38ecd7"
},
"dlq": {
"name": "climperor-player-sync-dlq",
"id": "f7d1d45494f744e59effafb75ec62249"
},
"worker": "climperor-player-sync",
"pages_project": "climperor-relations"
}
+55
View File
@@ -0,0 +1,55 @@
# Climperor Cloudflare player data layer
Production multi-user storage for Steam-login player pages:
| Resource | Name | Role |
|----------|------|------|
| D1 | `climperor-users` | users, stats, heroes, match index, sync jobs |
| R2 | `climperor-player-data` | private full match JSON (`matches/{id}.json`) |
| Queue | `climperor-player-sync` | async OpenDota/Steam refresh |
| DLQ | `climperor-player-sync-dlq` | failed sync messages |
| Worker | `climperor-player-sync` | queue consumer |
## Provision
```powershell
# via keyzoo refining/cloudflare asset
python web/cloudflare/provision.py
```
Then deploy the worker:
```powershell
cd web/cloudflare/player-sync
npx wrangler@3 deploy
npx wrangler@3 secret put STEAM_API_KEY
# optional:
npx wrangler@3 secret put OPENDOTA_API_KEY
```
Pages project `climperor-relations` needs bindings `DB`, `MATCHES`, `SYNC_QUEUE`
(see `web/frontend/wrangler.toml`). `provision.py` binds production **and** preview
with matching `fail_open`. Secrets already used by auth:
`STEAM_API_KEY`, `SESSION_SECRET`, optional `PLAYER_PAGES_PUBLISH_SECRET`.
Worker also needs `STEAM_API_KEY` (`python web/cloudflare/put_worker_secrets.py`
after staging via `_stage_steam_key.py`).
**R2**: if create returns “enable R2”, open
https://dash.cloudflare.com/?to=/:account/r2 once, then re-run `provision.py`
and uncomment the `[[r2_buckets]]` block in `player-sync/wrangler.toml`, redeploy Worker.
Until then, match list/stats still work via D1; full match JSON download is deferred.
Ids are recorded in `.resources.json` (no secrets).
## Local
`python web/serve_relations.py` continues to use `pc/player_pages/` JSON + enrich
(`GET /api/players/me` runs `enrich_profile_recent`).
Production `/home` uses `GET /api/players/me` (D1 + queue).
## Tests
```powershell
python -m unittest pc.tests.test_player_stats -v
node web/cloudflare/player-sync/test_stats.mjs
```
+219
View File
@@ -0,0 +1,219 @@
"""Backfill D1 from local pc/player_pages profile (+ live OpenDota if needed).
Used when Worker edge cannot reach OpenDota (429). No secret echo.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
PROFILE = ROOT / "pc" / "player_pages" / str(AID) / "profile.json"
def _creds() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing Cloudflare credentials")
return email, key
def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def d1_batch(statements: list[dict]) -> None:
email, key = _creds()
for st in statements:
payload = {"sql": st["sql"]}
if "params" in st:
payload["params"] = st["params"]
data = json.dumps(payload).encode()
req = urllib.request.Request(
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"X-Auth-Email": email,
"X-Auth-Key": key,
},
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
out = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"D1 {e.code}: {raw[:500]}\nSQL: {st['sql'][:200]}") from e
if not out.get("success"):
raise SystemExit(json.dumps(out, ensure_ascii=False)[:800])
def esc(v) -> str:
if v is None:
return "NULL"
if isinstance(v, bool):
return "1" if v else "0"
if isinstance(v, (int, float)) and not isinstance(v, bool):
if isinstance(v, float) and (v != v): # NaN
return "NULL"
return str(v)
s = str(v).replace("'", "''")
return f"'{s}'"
def main() -> int:
if not PROFILE.is_file():
raise SystemExit(f"missing {PROFILE}")
p = json.loads(PROFILE.read_text(encoding="utf-8"))
now = utc_now()
steamid = str(AID + 76561197960265728)
personaname = p.get("personaname") or "refining"
avatar = p.get("avatar")
public_share = 1 if p.get("public_share") else 0
avail = p.get("availability") or {}
career = p.get("career") or {}
recent20 = p.get("recent_20") or {}
activity = p.get("activity_180") or {}
top_heroes = p.get("top_heroes") or []
peers = p.get("peers") or []
recent = (p.get("recent") or [])[:20]
stmts: list[dict] = []
stmts.append(
{
"sql": (
f"INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at) "
f"VALUES ({AID}, {esc(steamid)}, {esc(personaname)}, {esc(avatar)}, {public_share}, {esc(now)}, {esc(now)}) "
f"ON CONFLICT(account_id) DO UPDATE SET "
f"personaname=excluded.personaname, avatar=COALESCE(excluded.avatar, users.avatar), "
f"last_login_at=excluded.last_login_at"
)
}
)
stmts.append(
{
"sql": (
f"INSERT INTO player_profiles ("
f"account_id, rank_tier, leaderboard_rank, availability_status, availability_note, "
f"availability_complete, source, fetched_at, enriched_at, updated_at) VALUES ("
f"{AID}, {esc(p.get('rank_tier'))}, {esc(p.get('leaderboard_rank'))}, "
f"{esc(avail.get('status') or 'public')}, {esc(avail.get('note'))}, "
f"{1 if avail.get('complete', True) else 0}, "
f"{esc(avail.get('source') or 'opendota+steam')}, "
f"{esc(avail.get('fetched_at') or now)}, {esc(p.get('enriched_at') or now)}, {esc(now)}) "
f"ON CONFLICT(account_id) DO UPDATE SET "
f"rank_tier=excluded.rank_tier, leaderboard_rank=excluded.leaderboard_rank, "
f"availability_status=excluded.availability_status, availability_note=excluded.availability_note, "
f"availability_complete=excluded.availability_complete, source=excluded.source, "
f"fetched_at=excluded.fetched_at, enriched_at=excluded.enriched_at, updated_at=excluded.updated_at"
)
}
)
def stats_sql(scope: str, stats: dict) -> dict:
payload = json.dumps(stats, ensure_ascii=False).replace("'", "''")
sample = stats.get("sample") if stats.get("sample") is not None else stats.get("games") or 0
return {
"sql": (
f"INSERT INTO player_stats ("
f"account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda, "
f"avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at) "
f"VALUES ({AID}, {esc(scope)}, {int(sample)}, {int(stats.get('wins') or 0)}, "
f"{int(stats.get('losses') or 0)}, {esc(stats.get('winrate'))}, "
f"{esc(stats.get('kills'))}, {esc(stats.get('deaths'))}, {esc(stats.get('assists'))}, "
f"{esc(stats.get('kda'))}, {esc(stats.get('avg_kills'))}, {esc(stats.get('avg_deaths'))}, "
f"{esc(stats.get('avg_assists'))}, {esc(stats.get('avg_gpm'))}, {esc(stats.get('avg_xpm'))}, "
f"{esc(stats.get('avg_hero_damage'))}, '{payload}', {esc(now)}) "
f"ON CONFLICT(account_id, scope) DO UPDATE SET "
f"sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate, "
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
f"avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists, "
f"avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage, "
f"payload_json=excluded.payload_json, updated_at=excluded.updated_at"
)
}
if career:
stmts.append(stats_sql("career", career))
if recent20:
stmts.append(stats_sql("recent20", recent20))
if activity:
stmts.append(stats_sql("recent180", activity))
stmts.append({"sql": f"DELETE FROM player_heroes WHERE account_id={AID}"})
for h in top_heroes[:8]:
stmts.append(
{
"sql": (
f"INSERT INTO player_heroes ("
f"account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at) "
f"VALUES ({AID}, {int(h.get('hero_id') or 0)}, {esc(h.get('hero_key'))}, "
f"{esc(h.get('hero_name_loc'))}, {int(h.get('games') or 0)}, {int(h.get('wins') or 0)}, "
f"{esc(h.get('winrate'))}, {esc(h.get('last_played'))}, {esc(now)})"
)
}
)
stmts.append({"sql": f"DELETE FROM player_peers WHERE account_id={AID}"})
for peer in peers[:8]:
stmts.append(
{
"sql": (
f"INSERT INTO player_peers ("
f"account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at) "
f"VALUES ({AID}, {int(peer.get('account_id') or 0)}, {esc(peer.get('personaname'))}, "
f"{esc(peer.get('avatar'))}, {int(peer.get('games') or 0)}, {int(peer.get('wins') or 0)}, "
f"{esc(peer.get('winrate'))}, {esc(now)})"
)
}
)
for r in recent:
mid = int(r.get("match_id") or 0)
if mid <= 0:
continue
stmts.append(
{
"sql": (
f"INSERT INTO player_matches ("
f"account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc, "
f"kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at) "
f"VALUES ({AID}, {mid}, {esc(r.get('start_time'))}, {esc(r.get('duration'))}, "
f"{1 if r.get('won') else 0}, {esc(r.get('hero_id'))}, {esc(r.get('hero_key'))}, "
f"{esc(r.get('hero_name_loc'))}, {esc(r.get('kills'))}, {esc(r.get('deaths'))}, "
f"{esc(r.get('assists'))}, {esc(r.get('kda'))}, {esc(r.get('gpm'))}, {esc(r.get('xpm'))}, "
f"{esc(r.get('hero_damage'))}, {esc(r.get('game_mode'))}, {esc(r.get('lobby_type'))}, "
f"NULL, {esc(now)}) "
f"ON CONFLICT(account_id, match_id) DO UPDATE SET "
f"start_time=excluded.start_time, duration=excluded.duration, won=excluded.won, "
f"hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc, "
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
f"gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage, "
f"game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at"
)
}
)
print(f"backfill {AID} from {PROFILE.name}: {len(stmts)} statements …", flush=True)
d1_batch(stmts)
print("done", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
"""Diagnose Cloudflare API auth without printing secrets."""
from __future__ import annotations
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
def load_staged() -> None:
if not STAGED.is_file():
return
for line in STAGED.read_text(encoding="utf-8").splitlines():
if "=" not in line or line.startswith("#"):
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
def probe(path: str, email: str, key: str) -> None:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4" + path,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"User-Agent": "climperor-cf-diag",
},
)
try:
with urllib.request.urlopen(req, timeout=45) as resp:
body = resp.read(120).decode("utf-8", errors="replace")
print(f"{path} -> {resp.status} ray={resp.headers.get('cf-ray')} body={body[:80]!r}")
except urllib.error.HTTPError as e:
raw = e.read(200).decode("utf-8", errors="replace")
print(
f"{path} -> HTTP {e.code} ray={e.headers.get('cf-ray') if e.headers else None} "
f"body={raw[:120]!r}"
)
except Exception as e:
print(f"{path} -> {type(e).__name__}: {e}")
def main() -> int:
load_staged()
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
print("missing credentials")
return 2
print(f"email_len={len(email)} key_len={len(key)} email_has_at={'@' in email}")
for path in ("/user", "/accounts", "/user/tokens/verify"):
probe(path, email, key)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
"""Quick Cloudflare API reachability check (no secrets printed)."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
def main() -> int:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
print("missing credentials")
return 2
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4/user",
headers={"X-Auth-Email": email, "X-Auth-Key": key},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
print(f"ok status={resp.status}")
return 0
except urllib.error.HTTPError as e:
print(f"http {e.code}")
return 1
except Exception as e:
print(f"{type(e).__name__}: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
"""Try R2 create via curl + Global API Key from staged env (no secret echo)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
ACCT = "510534f7f6284344aadaf2f5a0794d48"
BUCKET = "climperor-player-data"
def load() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL")
key = os.environ.get("CLOUDFLARE_API_KEY")
if STAGED.is_file():
for line in STAGED.read_text(encoding="utf-8").splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == "CLOUDFLARE_EMAIL":
email = v.strip()
elif k.strip() == "CLOUDFLARE_API_KEY":
key = v.strip()
if not email or not key:
raise SystemExit("missing credentials")
return email, key
def curl_json(method: str, url: str, email: str, key: str, body: dict | None = None) -> tuple[int, str]:
cmd = [
"curl.exe",
"-sS",
"-w",
"\nHTTP_CODE:%{http_code}",
"--max-time",
"60",
"-X",
method,
url,
"-H",
f"X-Auth-Email: {email}",
"-H",
f"X-Auth-Key: {key}",
"-H",
"Content-Type: application/json",
]
if body is not None:
cmd.extend(["-d", json.dumps(body)])
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
out = proc.stdout or ""
code = 0
if "HTTP_CODE:" in out:
body_text, _, code_s = out.rpartition("HTTP_CODE:")
try:
code = int(code_s.strip())
except ValueError:
code = 0
out = body_text.strip()
else:
out = (proc.stderr or out)[:300]
return code, out[:400]
def main() -> int:
email, key = load()
base = f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/r2/buckets"
code, body = curl_json("GET", f"{base}/{BUCKET}", email, key)
print(f"GET bucket -> {code} {body[:160]!r}")
if code == 200:
print("r2 already exists")
return 0
code, body = curl_json("POST", base, email, key, {"name": BUCKET})
print(f"POST bucket -> {code} {body[:200]!r}")
return 0 if code in (200, 201) or "already exists" in body.lower() else 1
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
"""Print Pages deployment_configs keys relevant to bindings (no secrets)."""
from __future__ import annotations
import json
import os
import urllib.request
ACCT = "510534f7f6284344aadaf2f5a0794d48"
PROJ = "climperor-relations"
def main() -> int:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
req = urllib.request.Request(
f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/pages/projects/{PROJ}",
headers={"X-Auth-Email": email, "X-Auth-Key": key},
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.load(resp)
dc = (data.get("result") or {}).get("deployment_configs") or {}
for env in ("production", "preview"):
cfg = dc.get(env) or {}
print(
env,
"fail_open=",
cfg.get("fail_open"),
"placement=",
cfg.get("placement"),
"d1=",
sorted((cfg.get("d1_databases") or {}).keys()),
"r2=",
sorted((cfg.get("r2_buckets") or {}).keys()),
"queues=",
sorted((cfg.get("queue_producers") or {}).keys()),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+177
View File
@@ -0,0 +1,177 @@
"""Create R2 bucket, bind Pages, redeploy Worker using keyzoo-injected CF creds."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WORKER = Path(__file__).resolve().parent / "player-sync"
ACCT = "510534f7f6284344aadaf2f5a0794d48"
BUCKET = "climperor-player-data"
PAGES = "climperor-relations"
QUEUE = "climperor-player-sync"
D1_ID = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
API = "https://api.cloudflare.com/client/v4"
RESOURCES = Path(__file__).resolve().parent / ".resources.json"
def creds() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing CF credentials")
return email, key
def api(method: str, path: str, body: dict | None = None, retries: int = 6) -> dict:
email, key = creds()
data = None if body is None else json.dumps(body).encode("utf-8")
last = None
for i in range(retries):
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-enable-r2",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
last = f"{e.code}: {raw[:200]}"
if e.code in (521, 522, 523, 524, 525, 429, 503) and i + 1 < retries:
wait = 4 + i * 3
print(f"retry {i+1}/{retries} after {e.code}, sleep {wait}s", flush=True)
time.sleep(wait)
continue
raise SystemExit(f"CF API {method} {path} -> {last}") from e
except Exception as e:
last = str(e)
if i + 1 < retries:
wait = 4 + i * 3
print(f"retry {i+1}/{retries} after {type(e).__name__}, sleep {wait}s", flush=True)
time.sleep(wait)
continue
raise SystemExit(f"CF API {method} {path} -> {last}") from e
raise SystemExit(f"CF API failed: {last}")
def ensure_r2() -> None:
try:
api("GET", f"/accounts/{ACCT}/r2/buckets/{BUCKET}", retries=3)
print(f"r2 exists: {BUCKET}", flush=True)
return
except SystemExit:
pass
payload = api("POST", f"/accounts/{ACCT}/r2/buckets", {"name": BUCKET})
if not payload.get("success"):
err = str(payload.get("errors") or "")
if "already exists" in err.lower() or "10004" in err:
print(f"r2 exists: {BUCKET}", flush=True)
return
raise SystemExit(f"r2 create failed: {payload.get('errors')}")
print(f"r2 created: {BUCKET}", flush=True)
def bind_pages() -> None:
path = f"/accounts/{ACCT}/pages/projects/{PAGES}"
project = api("GET", path)["result"]
dc = project.get("deployment_configs") or {}
prod = dict(dc.get("production") or {})
preview = dict(dc.get("preview") or {})
fail_open = prod.get("fail_open")
if fail_open is None:
fail_open = preview.get("fail_open")
if fail_open is None:
fail_open = False
def one(base: dict) -> dict:
out = {
k: v
for k, v in base.items()
if k not in ("d1_databases", "queue_producers", "r2_buckets", "fail_open")
}
d1 = dict(base.get("d1_databases") or {})
d1["DB"] = {"id": D1_ID}
out["d1_databases"] = d1
producers = dict(base.get("queue_producers") or {})
producers["SYNC_QUEUE"] = {"name": QUEUE}
out["queue_producers"] = producers
buckets = dict(base.get("r2_buckets") or {})
buckets["MATCHES"] = {"name": BUCKET}
out["r2_buckets"] = buckets
out["fail_open"] = bool(fail_open)
return out
api(
"PATCH",
path,
{
"deployment_configs": {
"production": one(prod),
"preview": one(preview),
}
},
)
print("pages bindings: DB + SYNC_QUEUE + MATCHES", flush=True)
def update_resources() -> None:
data = {}
if RESOURCES.is_file():
data = json.loads(RESOURCES.read_text(encoding="utf-8"))
data["account_id"] = ACCT
data["r2"] = {"name": BUCKET, "ready": True}
data.setdefault("d1", {"name": "climperor-users", "id": D1_ID})
data.setdefault("queue", {"name": QUEUE})
data.setdefault("pages_project", PAGES)
data.setdefault("worker", "climperor-player-sync")
RESOURCES.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
print(f"updated {RESOURCES}", flush=True)
def deploy_worker() -> None:
email, key = creds()
env = os.environ.copy()
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = key
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
print("deploying worker…", flush=True)
proc = subprocess.run(
"npx --yes wrangler@3 deploy",
cwd=str(WORKER),
env=env,
shell=True,
)
if proc.returncode != 0:
raise SystemExit(f"worker deploy failed: {proc.returncode}")
print("worker deployed", flush=True)
def main() -> int:
ensure_r2()
bind_pages()
update_resources()
deploy_worker()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -0,0 +1,8 @@
"""Force Worker HTTP login_refresh for TARGET account."""
import os
import runpy
from pathlib import Path
os.environ["CLIMPEROR_FORCE_HTTP_SYNC"] = "1"
runpy.run_path(str(Path(__file__).with_name("_trigger_player_sync.py")), run_name="__main__")
+147
View File
@@ -0,0 +1,147 @@
"""Deep-probe D1 + OpenDota for one account (no secret echo)."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
def _creds() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing Cloudflare credentials")
return email, key
def d1(sql: str) -> list:
email, key = _creds()
body = json.dumps({"sql": sql}).encode()
req = urllib.request.Request(
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"X-Auth-Email": email,
"X-Auth-Key": key,
},
)
with urllib.request.urlopen(req, timeout=30) as r:
out = json.loads(r.read().decode())
results = out.get("result") or []
if results:
return results[0].get("results") or []
return []
def od(path: str):
req = urllib.request.Request(
f"https://api.opendota.com/api{path}",
headers={"User-Agent": "climperor-probe"},
)
try:
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return {"_error": e.code, "_body": e.read()[:200].decode("utf-8", "replace")}
def main() -> int:
print("users", json.dumps(d1(f"SELECT * FROM users WHERE account_id={AID}"), ensure_ascii=False))
print(
"stats",
json.dumps(
d1(
f"SELECT scope, sample, wins, losses, winrate, length(payload_json) AS plen "
f"FROM player_stats WHERE account_id={AID}"
),
ensure_ascii=False,
),
)
print(
"matches",
json.dumps(
d1(f"SELECT COUNT(*) AS n FROM player_matches WHERE account_id={AID}"),
ensure_ascii=False,
),
)
print(
"heroes",
json.dumps(
d1(f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={AID}"),
ensure_ascii=False,
),
)
print(
"peers",
json.dumps(
d1(f"SELECT COUNT(*) AS n FROM player_peers WHERE account_id={AID}"),
ensure_ascii=False,
),
)
print(
"profile",
json.dumps(
d1(
f"SELECT account_id, rank_tier, leaderboard_rank, "
f"availability_status, availability_note, "
f"availability_complete, source, fetched_at, enriched_at "
f"FROM player_profiles WHERE account_id={AID}"
),
ensure_ascii=False,
),
)
print(
"stats_all",
json.dumps(
d1(
f"SELECT scope, sample, wins, losses, kda, avg_gpm "
f"FROM player_stats WHERE account_id={AID} ORDER BY scope"
),
ensure_ascii=False,
),
)
player = od(f"/players/{AID}")
if isinstance(player, dict) and "profile" in player:
p = player.get("profile") or {}
print(
"OD player",
json.dumps(
{
"personaname": p.get("personaname"),
"rank_tier": player.get("rank_tier"),
"fh_unavailable": player.get("fh_unavailable"),
},
ensure_ascii=False,
),
)
else:
print("OD player", player)
wl = od(f"/players/{AID}/wl")
print("OD wl", wl)
recent = od(f"/players/{AID}/recentMatches")
if isinstance(recent, list):
print("OD recentMatches", len(recent))
if recent:
print("OD recent[0].match_id", recent[0].get("match_id"))
else:
print("OD recentMatches", recent)
return 0
if __name__ == "__main__":
sys.exit(main())
+48
View File
@@ -0,0 +1,48 @@
"""Probe which CF API paths work with Global API Key (no secrets printed)."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
ACCT = "510534f7f6284344aadaf2f5a0794d48"
PATHS = [
"/user",
"/accounts",
f"/accounts/{ACCT}/d1/database",
f"/accounts/{ACCT}/queues",
f"/accounts/{ACCT}/r2/buckets",
f"/accounts/{ACCT}/pages/projects/climperor-relations",
]
def main() -> int:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
for path in PATHS:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4" + path,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"User-Agent": "climperor-probe",
},
)
try:
with urllib.request.urlopen(req, timeout=40) as resp:
print(f"{path} -> {resp.status}")
except urllib.error.HTTPError as e:
snippet = e.read(80).decode("utf-8", errors="replace").replace("\n", " ")
print(f"{path} -> HTTP {e.code} {snippet[:60]!r}")
except Exception as e:
print(f"{path} -> {type(e).__name__}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
"""Hammer R2 create/list until Cloudflare API stops returning 52x."""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
ACCT = "510534f7f6284344aadaf2f5a0794d48"
BUCKET = "climperor-player-data"
API = "https://api.cloudflare.com/client/v4"
def creds():
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing credentials")
return email, key
def call(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]:
email, key = creds()
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-retry-r2",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw[:120]
def main() -> int:
list_path = f"/accounts/{ACCT}/r2/buckets"
get_path = f"/accounts/{ACCT}/r2/buckets/{BUCKET}"
for i in range(20):
code, payload = call("GET", get_path)
print(f"[{i+1}] GET {BUCKET} -> {code}", flush=True)
if code == 200:
print("bucket ready", flush=True)
return 0
if code == 404 or (isinstance(payload, dict) and not payload.get("success")):
# not found → create
code2, payload2 = call("POST", list_path, {"name": BUCKET})
print(f"[{i+1}] POST create -> {code2}", flush=True)
if code2 in (200, 201):
print("created", flush=True)
return 0
text = str(payload2).lower()
if "already exists" in text or "10004" in text:
print("exists", flush=True)
return 0
# also try list
code3, payload3 = call("GET", list_path)
names = []
if isinstance(payload3, dict):
buckets = (payload3.get("result") or {}).get("buckets") or payload3.get(
"result"
)
if isinstance(buckets, list):
for b in buckets:
if isinstance(b, dict):
names.append(b.get("name"))
print(f"[{i+1}] LIST -> {code3} names={names}", flush=True)
if BUCKET in names:
print("bucket ready via list", flush=True)
return 0
time.sleep(5 + (i % 5))
return 1
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Load staged CF creds into env, run argv command, then delete the staging file."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
def main() -> int:
if len(sys.argv) < 2:
print("usage: _run_with_staged_cf.py <cmd> [args...]", file=sys.stderr)
return 2
if not STAGED.is_file():
print("missing staged cf_creds.env", file=sys.stderr)
return 2
env = os.environ.copy()
for line in STAGED.read_text(encoding="utf-8").splitlines():
if "=" not in line or line.startswith("#"):
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
try:
STAGED.unlink()
except OSError:
pass
env.setdefault("CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48")
proc = subprocess.run(sys.argv[1:], cwd=str(Path.cwd()), env=env)
return proc.returncode
if __name__ == "__main__":
raise SystemExit(main())
+40
View File
@@ -0,0 +1,40 @@
"""Stage Cloudflare email+key for the next local provision (no echo of secrets)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
def main() -> int:
email = (
os.environ.get("CLOUDFLARE_EMAIL")
or os.environ.get("KEYZOO_ASSET_META_USERNAME")
or ""
).strip()
key = (
os.environ.get("CLOUDFLARE_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
or ""
).strip()
if not email or not key:
print("missing credentials", file=sys.stderr)
return 2
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
f"CLOUDFLARE_EMAIL={email}\nCLOUDFLARE_API_KEY={key}\n",
encoding="utf-8",
)
try:
os.chmod(OUT, 0o600)
except OSError:
pass
print(f"staged {OUT.name}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
"""Stage Steam API key to a temp file for the next CF secret put (no echo)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
def main() -> int:
key = (
os.environ.get("STEAM_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
or ""
).strip()
if not key:
print("missing steam key", file=sys.stderr)
return 2
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(key, encoding="utf-8")
try:
os.chmod(OUT, 0o600)
except OSError:
pass
print(f"staged {OUT.name} len={len(key)}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+264
View File
@@ -0,0 +1,264 @@
"""Probe D1 for an account, enqueue login_refresh, wait, probe again.
Uses Cloudflare Global API Key env (no secret echo).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
WORKER = HERE / "player-sync"
ACCOUNT_ID = "510534f7f6284344aadaf2f5a0794d48"
QUEUE_ID = "371f11c7b4114f9b99ab10062a38ecd7"
TARGET = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
def _cf_env() -> dict[str, str]:
env = os.environ.copy()
email = env.get("CLOUDFLARE_EMAIL") or env.get("KEYZOO_ASSET_META_USERNAME")
key = env.get("CLOUDFLARE_API_KEY") or env.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing Cloudflare credentials")
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = key
env["CLOUDFLARE_ACCOUNT_ID"] = ACCOUNT_ID
return env
def d1_query(sql: str, env: dict[str, str]) -> list:
cmd = (
f'npx --yes wrangler@3 d1 execute climperor-users --remote --json '
f'--command "{sql}"'
)
r = subprocess.run(
cmd,
cwd=str(WORKER),
env=env,
shell=True,
capture_output=True,
text=True,
)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
raise SystemExit(r.returncode)
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
print(r.stdout)
return []
if isinstance(data, list) and data:
return data[0].get("results") or []
return []
class CfApiError(RuntimeError):
def __init__(self, code: int, body: str):
super().__init__(f"CF API {code}")
self.code = code
self.body = body
def cf_api(method: str, path: str, env: dict[str, str], body: dict | None = None) -> dict:
url = f"https://api.cloudflare.com/client/v4{path}"
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"X-Auth-Email": env["CLOUDFLARE_EMAIL"],
"X-Auth-Key": env["CLOUDFLARE_API_KEY"],
"Content-Type": "application/json",
"User-Agent": "climperor-trigger-sync",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise CfApiError(e.code, raw[:1200]) from e
def probe(env: dict[str, str], label: str) -> None:
print(f"\n=== {label} account_id={TARGET} ===", flush=True)
users = d1_query(
f"SELECT account_id, personaname, last_login_at FROM users "
f"WHERE account_id={TARGET}",
env,
)
print("users:", json.dumps(users, ensure_ascii=False), flush=True)
jobs = d1_query(
f"SELECT kind, status, substr(COALESCE(error,''),1,160) AS error, "
f"updated_at FROM sync_jobs WHERE account_id={TARGET} "
f"ORDER BY updated_at DESC LIMIT 5",
env,
)
print("sync_jobs:", json.dumps(jobs, ensure_ascii=False), flush=True)
matches = d1_query(
f"SELECT COUNT(*) AS n, "
f"SUM(CASE WHEN r2_key IS NOT NULL AND r2_key != '' THEN 1 ELSE 0 END) AS r2 "
f"FROM player_matches WHERE account_id={TARGET}",
env,
)
print("matches:", json.dumps(matches, ensure_ascii=False), flush=True)
stats = d1_query(
f"SELECT scope, sample, winrate FROM player_stats "
f"WHERE account_id={TARGET}",
env,
)
print("stats:", json.dumps(stats, ensure_ascii=False), flush=True)
heroes = d1_query(
f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={TARGET}",
env,
)
print("heroes:", json.dumps(heroes, ensure_ascii=False), flush=True)
def worker_http_sync(env: dict[str, str], msg: dict) -> str:
"""POST Worker fetch handler. Returns 'http'."""
print("Worker HTTP sync …", flush=True)
try:
cf_api(
"POST",
f"/accounts/{ACCOUNT_ID}/workers/scripts/climperor-player-sync/subdomain",
env,
{"enabled": True},
)
except CfApiError as e:
print(f"enable subdomain: {e.code} {e.body[:400]}", flush=True)
sub_name = ""
try:
sub = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/subdomain", env)
sub_name = ((sub.get("result") or {}).get("subdomain") or "").strip()
except CfApiError as e:
print(f"get subdomain: {e.code} {e.body[:400]}", flush=True)
if not sub_name:
raise SystemExit("cannot resolve workers.dev subdomain")
url = f"https://climperor-player-sync.{sub_name}.workers.dev/"
print(f"POST {url}", flush=True)
data = json.dumps(msg).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"User-Agent": "climperor-trigger-sync",
},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8", errors="replace")
print(f"worker HTTP {resp.status} len={len(raw)}", flush=True)
print(raw[:800], flush=True)
return "http"
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
print(f"worker HTTP {e.code}: {raw[:800]}", file=sys.stderr)
raise SystemExit(1) from e
except urllib.error.URLError as e:
print(f"worker URL error: {e}", file=sys.stderr)
raise SystemExit(1) from e
def enqueue(env: dict[str, str]) -> str:
"""Enqueue or HTTP-sync. Returns 'queue' | 'http'."""
steamid = str(TARGET + 76561197960265728)
msg = {
"kind": "login_refresh",
"account_id": TARGET,
"steamid": steamid,
"personaname": "refining",
}
if os.environ.get("CLIMPEROR_FORCE_HTTP_SYNC", "").strip() in (
"1",
"true",
"yes",
):
return worker_http_sync(env, msg)
print("\nenqueue login_refresh …", flush=True)
# https://developers.cloudflare.com/queues/configuration/javascript-apis/#producer
# HTTP: POST /accounts/:account_id/queues/:queue_id/messages
attempts = [
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
{"body": msg},
),
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
{"messages": [{"body": json.dumps(msg)}]},
),
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
{"messages": [{"body": msg}]},
),
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
{"messages": [{"body": json.dumps(msg)}]},
),
]
for path, body in attempts:
try:
out = cf_api("POST", path, env, body)
except CfApiError as e:
print(f"try {path.split('/')[-1]} HTTP {e.code}: {e.body[:400]}", flush=True)
continue
ok = bool(out.get("success"))
print(f"try {path.split('/')[-1]} success={ok}", flush=True)
if ok:
return "queue"
print(json.dumps(out, ensure_ascii=False)[:600], flush=True)
# Last resort: enable workers.dev and POST the Worker fetch handler.
return worker_http_sync(env, msg)
def check_worker(env: dict[str, str]) -> None:
out = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/scripts", env)
names = [r.get("id") or r.get("name") for r in (out.get("result") or [])]
print("workers:", ", ".join(n for n in names if n)[:500], flush=True)
has = "climperor-player-sync" in names
print(f"climperor-player-sync deployed={has}", flush=True)
def main() -> int:
env = _cf_env()
check_worker(env)
probe(env, "before")
mode = enqueue(env)
waits = 2 if mode == "http" else 8
for i in range(1, waits + 1):
delay = 3 if mode == "http" else 8
time.sleep(delay)
probe(env, f"after wait #{i} ({i * delay}s)")
users = d1_query(
f"SELECT account_id FROM users WHERE account_id={TARGET}", env
)
stats = d1_query(
f"SELECT scope FROM player_stats WHERE account_id={TARGET}", env
)
filled = d1_query(
f"SELECT scope, sample FROM player_stats WHERE account_id={TARGET} "
f"AND sample > 0",
env,
)
if users and filled:
print("\nSYNC OK — user + non-empty stats", flush=True)
return 0
print("\nSYNC PENDING/FAILED — no non-empty stats after waits", flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Deploy climperor-player-sync Worker via wrangler (no secret echo)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
WORKER = HERE / "player-sync"
def main() -> int:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
print("missing Cloudflare credentials", file=sys.stderr)
return 2
env = os.environ.copy()
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = key
env["CLOUDFLARE_ACCOUNT_ID"] = env.get(
"CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48"
)
cmd = "npx --yes wrangler@3 deploy"
print("deploying climperor-player-sync …", flush=True)
return subprocess.call(cmd, cwd=str(WORKER), env=env, shell=True)
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
-- Climperor multi-user player data (D1 climperor-users)
CREATE TABLE IF NOT EXISTS users (
account_id INTEGER PRIMARY KEY,
steamid TEXT NOT NULL UNIQUE,
personaname TEXT,
avatar TEXT,
public_share INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS player_profiles (
account_id INTEGER PRIMARY KEY REFERENCES users(account_id),
rank_tier INTEGER,
leaderboard_rank INTEGER,
availability_status TEXT,
availability_note TEXT,
availability_complete INTEGER NOT NULL DEFAULT 0,
source TEXT,
fetched_at TEXT,
enriched_at TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS player_stats (
account_id INTEGER NOT NULL REFERENCES users(account_id),
scope TEXT NOT NULL, -- career | recent20 | recent180
sample INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
winrate REAL,
kills INTEGER,
deaths INTEGER,
assists INTEGER,
kda REAL,
avg_kills REAL,
avg_deaths REAL,
avg_assists REAL,
avg_gpm REAL,
avg_xpm REAL,
avg_hero_damage REAL,
payload_json TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, scope)
);
CREATE TABLE IF NOT EXISTS player_heroes (
account_id INTEGER NOT NULL REFERENCES users(account_id),
hero_id INTEGER NOT NULL,
hero_key TEXT,
hero_name_loc TEXT,
games INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
winrate REAL,
last_played INTEGER,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, hero_id)
);
CREATE TABLE IF NOT EXISTS player_matches (
account_id INTEGER NOT NULL REFERENCES users(account_id),
match_id INTEGER NOT NULL,
start_time INTEGER,
duration INTEGER,
won INTEGER,
hero_id INTEGER,
hero_key TEXT,
hero_name_loc TEXT,
kills INTEGER,
deaths INTEGER,
assists INTEGER,
kda REAL,
gpm INTEGER,
xpm INTEGER,
hero_damage INTEGER,
game_mode INTEGER,
lobby_type INTEGER,
r2_key TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, match_id)
);
CREATE INDEX IF NOT EXISTS idx_player_matches_start
ON player_matches(account_id, start_time DESC);
CREATE TABLE IF NOT EXISTS player_peers (
account_id INTEGER NOT NULL REFERENCES users(account_id),
peer_account_id INTEGER NOT NULL,
personaname TEXT,
avatar TEXT,
games INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
winrate REAL,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, peer_account_id)
);
CREATE TABLE IF NOT EXISTS sync_jobs (
id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
kind TEXT NOT NULL, -- login_refresh | publish_match | backfill
match_id INTEGER,
status TEXT NOT NULL, -- queued | running | done | error
attempts INTEGER NOT NULL DEFAULT 0,
lease_until TEXT,
error TEXT,
next_retry_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sync_jobs_status
ON sync_jobs(status, next_retry_at);
+296
View File
@@ -0,0 +1,296 @@
export function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export async function upsertUser(db, user) {
const now = utcNow();
await db
.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
steamid=excluded.steamid,
personaname=COALESCE(excluded.personaname, users.personaname),
avatar=COALESCE(excluded.avatar, users.avatar),
public_share=COALESCE(excluded.public_share, users.public_share),
last_login_at=excluded.last_login_at`
)
.bind(
user.account_id,
String(user.steamid),
user.personaname || null,
user.avatar || null,
user.public_share == null ? null : user.public_share ? 1 : 0,
now,
now
)
.run();
}
export async function upsertProfile(db, accountId, profile) {
const now = utcNow();
const avail = profile.availability || {};
await db
.prepare(
`INSERT INTO player_profiles (
account_id, rank_tier, leaderboard_rank, availability_status, availability_note,
availability_complete, source, fetched_at, enriched_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
rank_tier=excluded.rank_tier,
leaderboard_rank=excluded.leaderboard_rank,
availability_status=excluded.availability_status,
availability_note=excluded.availability_note,
availability_complete=excluded.availability_complete,
source=excluded.source,
fetched_at=excluded.fetched_at,
enriched_at=excluded.enriched_at,
updated_at=excluded.updated_at`
)
.bind(
accountId,
profile.rank_tier ?? null,
profile.leaderboard_rank ?? null,
avail.status || null,
avail.note || null,
avail.complete ? 1 : 0,
avail.source || "opendota",
avail.fetched_at || now,
profile.enriched_at || now,
now
)
.run();
}
export async function upsertStats(db, accountId, scope, stats) {
if (!stats) return;
const now = utcNow();
await db
.prepare(
`INSERT INTO player_stats (
account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda,
avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, scope) DO UPDATE SET
sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate,
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists,
avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage,
payload_json=excluded.payload_json, updated_at=excluded.updated_at`
)
.bind(
accountId,
scope,
stats.sample ?? stats.games ?? 0,
stats.wins ?? 0,
stats.losses ?? 0,
stats.winrate ?? null,
stats.kills ?? null,
stats.deaths ?? null,
stats.assists ?? null,
stats.kda ?? null,
stats.avg_kills ?? null,
stats.avg_deaths ?? null,
stats.avg_assists ?? null,
stats.avg_gpm ?? null,
stats.avg_xpm ?? null,
stats.avg_hero_damage ?? null,
JSON.stringify(stats),
now
)
.run();
}
export async function replaceHeroes(db, accountId, heroes) {
const now = utcNow();
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
for (const h of heroes || []) {
await db
.prepare(
`INSERT INTO player_heroes (
account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
accountId,
h.hero_id,
h.hero_key || null,
h.hero_name_loc || null,
h.games || 0,
h.wins || 0,
h.winrate ?? null,
h.last_played ?? null,
now
)
.run();
}
}
export async function replacePeers(db, accountId, peers) {
const now = utcNow();
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
for (const p of peers || []) {
await db
.prepare(
`INSERT INTO player_peers (
account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
accountId,
p.account_id,
p.personaname || null,
p.avatar || null,
p.games || 0,
p.wins || 0,
p.winrate ?? null,
now
)
.run();
}
}
export async function upsertMatches(db, accountId, recent) {
const now = utcNow();
for (const r of recent || []) {
await db
.prepare(
`INSERT INTO player_matches (
account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc,
kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, match_id) DO UPDATE SET
start_time=excluded.start_time, duration=excluded.duration, won=excluded.won,
hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc,
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage,
game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at`
)
.bind(
accountId,
r.match_id,
r.start_time ?? null,
r.duration ?? null,
r.won ? 1 : 0,
r.hero_id ?? null,
r.hero_key || null,
r.hero_name_loc || null,
r.kills ?? null,
r.deaths ?? null,
r.assists ?? null,
r.kda ?? null,
r.gpm ?? null,
r.xpm ?? null,
r.hero_damage ?? null,
r.game_mode ?? null,
r.lobby_type ?? null,
r.r2_key || null,
now
)
.run();
}
}
export async function loadPlayerBundle(db, accountId) {
const user = await db
.prepare(`SELECT * FROM users WHERE account_id = ?`)
.bind(accountId)
.first();
if (!user) return null;
const profile = await db
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
.bind(accountId)
.first();
const statsRows = await db
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
.bind(accountId)
.all();
const heroes = await db
.prepare(
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const peers = await db
.prepare(
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const recent = await db
.prepare(
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
)
.bind(accountId)
.all();
const statsByScope = {};
for (const row of (statsRows && statsRows.results) || []) {
try {
statsByScope[row.scope] = row.payload_json
? JSON.parse(row.payload_json)
: row;
} catch {
statsByScope[row.scope] = row;
}
}
return {
account_id: accountId,
personaname: user.personaname,
avatar: user.avatar,
public_share: !!user.public_share,
rank_tier: profile && profile.rank_tier,
leaderboard_rank: profile && profile.leaderboard_rank,
availability: profile
? {
status: profile.availability_status,
note: profile.availability_note,
complete: !!profile.availability_complete,
source: profile.source,
fetched_at: profile.fetched_at,
stale: false,
}
: null,
career: statsByScope.career || null,
recent_20: statsByScope.recent20 || null,
activity_180: statsByScope.recent180 || null,
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
hero_id: h.hero_id,
hero_key: h.hero_key,
hero_name_loc: h.hero_name_loc,
games: h.games,
wins: h.wins,
winrate: h.winrate,
last_played: h.last_played,
})),
peers: ((peers && peers.results) || []).map((p) => ({
account_id: p.peer_account_id,
personaname: p.personaname,
avatar: p.avatar,
games: p.games,
wins: p.wins,
winrate: p.winrate,
})),
recent: ((recent && recent.results) || []).map((r) => ({
match_id: r.match_id,
start_time: r.start_time,
duration: r.duration,
won: !!r.won,
hero_id: r.hero_id,
hero_key: r.hero_key,
hero_name_loc: r.hero_name_loc,
kills: r.kills,
deaths: r.deaths,
assists: r.assists,
kda: r.kda,
gpm: r.gpm,
xpm: r.xpm,
hero_damage: r.hero_damage,
game_mode: r.game_mode,
lobby_type: r.lobby_type,
})),
updated_at: (profile && profile.updated_at) || user.last_login_at,
enriched_at: profile && profile.enriched_at,
};
}
+326
View File
@@ -0,0 +1,326 @@
/**
* Queue consumer: refresh player stats from OpenDota into D1 (+ optional R2 match detail).
*
* Message shapes:
* { kind: "login_refresh"|"backfill", account_id, steamid?, personaname?, avatar? }
* { kind: "publish_match", account_id, match_id }
*/
import {
loadPlayerBundle,
replaceHeroes,
replacePeers,
upsertMatches,
upsertProfile,
upsertStats,
upsertUser,
utcNow,
} from "./db.js";
import {
loadHeroMap,
odFetch,
steamMatchHistoryStatus,
summaryFromRecentRow,
} from "./opendota.js";
import {
aggregateFromRows,
careerFromOpenDota,
mergeAvailability,
winrate,
} from "./stats.js";
function winrateGames(wins, games) {
return winrate(wins, Math.max(0, games - wins));
}
async function syncAccount(env, msg) {
const accountId = Number(msg.account_id);
if (!Number.isFinite(accountId) || accountId <= 0) {
throw new Error("bad account_id");
}
const steamid =
msg.steamid || String(BigInt(accountId) + 76561197960265728n);
await upsertUser(env.DB, {
account_id: accountId,
steamid,
personaname: msg.personaname || null,
avatar: msg.avatar || null,
public_share: msg.public_share,
});
const heroMap = await loadHeroMap(env);
// Serial OpenDota calls — CF edge IPs hit 429 hard under Promise.all.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const player = await odFetch(`/players/${accountId}`, env);
await sleep(200);
const wl = await odFetch(`/players/${accountId}/wl`, env);
await sleep(200);
const totals = await odFetch(`/players/${accountId}/totals`, env);
await sleep(200);
const heroes = await odFetch(`/players/${accountId}/heroes`, env);
await sleep(200);
const peers = await odFetch(`/players/${accountId}/peers`, env);
await sleep(200);
const recentRaw = await odFetch(`/players/${accountId}/recentMatches`, env);
await sleep(200);
const matches180 = await odFetch(`/players/${accountId}/matches`, env, {
date: 180,
significant: 0,
});
const steamStatus = await steamMatchHistoryStatus(accountId, env);
const profileBlock = player && player.profile ? player.profile : {};
const personaname = profileBlock.personaname || msg.personaname || null;
const avatar =
profileBlock.avatarfull ||
profileBlock.avatarmedium ||
profileBlock.avatar ||
msg.avatar ||
null;
await upsertUser(env.DB, {
account_id: accountId,
steamid,
personaname,
avatar,
});
const recent = [];
if (Array.isArray(recentRaw)) {
for (const row of recentRaw) {
const summary = summaryFromRecentRow(row, heroMap);
if (summary) recent.push(summary);
}
}
recent.sort((a, b) => (b.start_time || 0) - (a.start_time || 0));
const recent20 = recent.slice(0, 20);
let career = careerFromOpenDota(wl, totals);
// Do not wipe previous career on empty OpenDota response.
if (!career) {
const existing = await loadPlayerBundle(env.DB, accountId);
if (existing && existing.career && existing.career.games > 0) {
career = existing.career;
}
}
const topHeroes = [];
if (Array.isArray(heroes)) {
const scored = heroes
.filter((h) => h && Number(h.games) > 0)
.sort((a, b) => Number(b.games) - Number(a.games))
.slice(0, 5);
for (const h of scored) {
const hid = Number(h.hero_id) || 0;
const meta = heroMap.get(hid) || {};
const games = Number(h.games) || 0;
const wins = Number(h.win) || 0;
topHeroes.push({
hero_id: hid || null,
hero_key: meta.key || null,
hero_name_loc: meta.name_loc || meta.key || null,
games,
wins,
winrate: winrateGames(wins, games),
last_played: h.last_played != null ? Number(h.last_played) : null,
});
}
}
const peerRows = [];
if (Array.isArray(peers)) {
for (const p of peers.slice(0, 8)) {
if (!p || !p.account_id) continue;
const games = Number(p.games) || 0;
if (games <= 0) continue;
const wins = Number(p.win) || 0;
peerRows.push({
account_id: Number(p.account_id),
personaname: p.personaname || `玩家 ${p.account_id}`,
avatar: p.avatarfull || p.avatar || null,
games,
wins,
winrate: winrateGames(wins, games),
});
}
}
let activity180 = null;
if (Array.isArray(matches180) && matches180.length) {
const byDay = new Map();
let wins = 0;
let losses = 0;
let maxKills = null;
let maxAssists = null;
let maxGpm = null;
for (const row of matches180) {
if (!row || row.start_time == null) continue;
const st = Number(row.start_time);
const day = new Date(st * 1000).toISOString().slice(0, 10);
const cell = byDay.get(day) || { games: 0, wins: 0 };
cell.games += 1;
const slot = Number(row.player_slot) || 0;
const won = slot < 128 ? !!row.radiant_win : !row.radiant_win;
if (won) {
cell.wins += 1;
wins += 1;
} else losses += 1;
byDay.set(day, cell);
const kills = Number(row.kills) || 0;
const assists = Number(row.assists) || 0;
const gpm = Number(row.gold_per_min) || 0;
const heroId = Number(row.hero_id) || null;
const mid = Number(row.match_id) || 0;
if (!maxKills || kills > maxKills.value) {
maxKills = { value: kills, hero_id: heroId, match_id: mid };
}
if (!maxAssists || assists > maxAssists.value) {
maxAssists = { value: assists, hero_id: heroId, match_id: mid };
}
if (gpm > 0 && (!maxGpm || gpm > maxGpm.value)) {
maxGpm = { value: gpm, hero_id: heroId, match_id: mid };
}
}
activity180 = {
days: 180,
sample: wins + losses,
wins,
losses,
winrate: winrate(wins, losses),
heatmap: [...byDay.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.map(([date, v]) => ({ date, games: v.games, wins: v.wins })),
highs: { kills: maxKills, assists: maxAssists, gpm: maxGpm },
label: "最近 180 天样本",
};
}
const fetched = utcNow();
const availability = mergeAvailability({
opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0,
career,
steamHistoryStatus: steamStatus,
fetchedAt: fetched,
});
if (profileBlock.fh_unavailable && availability.status === "unknown") {
availability.status = "private";
availability.note = "未公开比赛数据";
}
// Steam public but OpenDota empty → almost always rate-limit; retry queue.
if (
availability.status === "syncing" &&
(!Array.isArray(recentRaw) || recentRaw.length === 0) &&
!career
) {
throw new Error("OpenDota empty while Steam public — retry");
}
const profile = {
rank_tier: player && player.rank_tier != null ? Number(player.rank_tier) : null,
leaderboard_rank:
player && player.leaderboard_rank != null
? Number(player.leaderboard_rank)
: null,
availability,
enriched_at: fetched,
};
await upsertProfile(env.DB, accountId, profile);
if (career) await upsertStats(env.DB, accountId, "career", career);
await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20));
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
await replaceHeroes(env.DB, accountId, topHeroes);
await replacePeers(env.DB, accountId, peerRows);
await upsertMatches(env.DB, accountId, recent20);
// Optional: store a published match detail into R2 (deduped by match_id).
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
const matchId = Number(msg.match_id);
const match = await odFetch(`/matches/${matchId}`, env);
if (match && Array.isArray(match.players)) {
const key = `matches/${matchId}.json`;
await env.MATCHES.put(key, JSON.stringify(match), {
httpMetadata: { contentType: "application/json; charset=utf-8" },
});
await env.DB.prepare(
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
)
.bind(key, utcNow(), accountId, matchId)
.run();
}
}
return loadPlayerBundle(env.DB, accountId);
}
async function markJob(env, jobId, patch) {
if (!jobId) return;
const now = utcNow();
await env.DB.prepare(
`UPDATE sync_jobs SET status = ?, attempts = COALESCE(attempts, 0) + ?,
error = ?, lease_until = ?, updated_at = ?
WHERE id = ?`
)
.bind(
patch.status,
patch.bumpAttempts ? 1 : 0,
patch.error || null,
patch.lease_until || null,
now,
jobId
)
.run();
}
export default {
async queue(batch, env) {
for (const message of batch.messages) {
let body = message.body;
if (typeof body === "string") {
try {
body = JSON.parse(body);
} catch {
message.ack();
continue;
}
}
const jobId = body && body.job_id;
try {
if (jobId) {
await markJob(env, jobId, {
status: "running",
lease_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
});
}
await syncAccount(env, body || {});
if (jobId) await markJob(env, jobId, { status: "done" });
message.ack();
} catch (e) {
const err = String((e && e.message) || e);
if (jobId) {
await markJob(env, jobId, {
status: "error",
bumpAttempts: true,
error: err.slice(0, 500),
});
}
message.retry();
}
}
},
// Manual HTTP trigger for smoke tests (requires SYNC_HTTP_TOKEN secret).
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("climperor-player-sync", { status: 200 });
}
const token = (env.SYNC_HTTP_TOKEN || "").trim();
const auth = (request.headers.get("Authorization") || "").trim();
if (!token || auth !== `Bearer ${token}`) {
return new Response("unauthorized", { status: 401 });
}
const body = await request.json().catch(() => ({}));
const out = await syncAccount(env, body);
return new Response(JSON.stringify(out), {
headers: { "Content-Type": "application/json" },
});
},
};
@@ -0,0 +1,93 @@
const OPENDOTA = "https://api.opendota.com/api";
const STEAM_API = "https://api.steampowered.com";
export async function odFetch(path, env, query = {}) {
const url = new URL(`${OPENDOTA}${path}`);
for (const [k, v] of Object.entries(query)) {
if (v != null) url.searchParams.set(k, String(v));
}
const key = (env.OPENDOTA_API_KEY || "").trim();
if (key) url.searchParams.set("api_key", key);
const headers = {
Accept: "application/json",
"User-Agent": "climperor-player-sync",
};
// CF edge IPs are often rate-limited; retry 429/5xx before giving up.
let lastStatus = 0;
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1)));
}
const res = await fetch(url.toString(), { headers });
lastStatus = res.status;
if (res.status === 403 || res.status === 404) return null;
if (res.status === 429 || res.status >= 500) continue;
if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`);
return res.json();
}
if (lastStatus === 429 || lastStatus >= 500) return null;
throw new Error(`OpenDota ${lastStatus} ${path}`);
}
export async function steamMatchHistoryStatus(accountId, env) {
const key = (env.STEAM_API_KEY || "").trim();
if (!key) return null;
const url = new URL(`${STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/`);
url.searchParams.set("key", key);
url.searchParams.set("account_id", String(accountId));
url.searchParams.set("matches_requested", "1");
try {
const res = await fetch(url.toString(), {
headers: { "User-Agent": "climperor-player-sync" },
});
if (!res.ok) return null;
const data = await res.json();
const status = data && data.result && data.result.status;
return status == null ? null : Number(status);
} catch {
return null;
}
}
export function summaryFromRecentRow(row, heroMap) {
const mid = Number(row.match_id) || 0;
if (mid <= 0) return null;
const heroId = Number(row.hero_id) || 0;
const hero = heroMap.get(heroId) || {};
const kills = Number(row.kills) || 0;
const deaths = Number(row.deaths) || 0;
const assists = Number(row.assists) || 0;
const playerSlot = Number(row.player_slot) || 0;
const radiantWin = !!row.radiant_win;
const isRadiant = playerSlot < 128;
return {
match_id: mid,
start_time: row.start_time != null ? Number(row.start_time) : null,
duration: Number(row.duration) || 0,
won: isRadiant ? radiantWin : !radiantWin,
hero_id: heroId || null,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
kills,
deaths,
assists,
kda: Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10,
gpm: row.gold_per_min != null ? Number(row.gold_per_min) || 0 : null,
xpm: row.xp_per_min != null ? Number(row.xp_per_min) || 0 : null,
hero_damage: row.hero_damage != null ? Number(row.hero_damage) || 0 : null,
game_mode: row.game_mode != null ? Number(row.game_mode) : null,
lobby_type: row.lobby_type != null ? Number(row.lobby_type) : null,
};
}
export async function loadHeroMap(env) {
const rows = await odFetch("/heroes", env);
const map = new Map();
if (!Array.isArray(rows)) return map;
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(Number(h.id), { key, name_loc: h.localized_name || key });
}
return map;
}
+143
View File
@@ -0,0 +1,143 @@
/** Shared aggregate helpers for the player-sync Worker (mirrors pc/player_stats.py). */
export function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
export function winrate(wins, losses) {
const total = wins + losses;
if (total <= 0) return null;
return Math.round((wins / total) * 1000) / 10;
}
export function aggregateFromRows(rows, limit = 20) {
const sample = (Array.isArray(rows) ? rows : []).slice(0, limit);
let wins = 0;
let losses = 0;
let kills = 0;
let deaths = 0;
let assists = 0;
let gpmSum = 0;
let xpmSum = 0;
let dmgSum = 0;
let gpmN = 0;
let xpmN = 0;
let dmgN = 0;
const heroes = [];
for (const r of sample) {
if (!r || typeof r !== "object") continue;
if (r.won) wins += 1;
else losses += 1;
const k = Number(r.kills) || 0;
const d = Number(r.deaths) || 0;
const a = Number(r.assists) || 0;
kills += k;
deaths += d;
assists += a;
if (r.gpm != null) {
gpmSum += Number(r.gpm) || 0;
gpmN += 1;
}
if (r.xpm != null) {
xpmSum += Number(r.xpm) || 0;
xpmN += 1;
}
if (r.hero_damage != null) {
dmgSum += Number(r.hero_damage) || 0;
dmgN += 1;
}
heroes.push({
match_id: Number(r.match_id) || 0,
hero_id: r.hero_id ?? null,
hero_key: r.hero_key || null,
hero_name_loc: r.hero_name_loc || null,
won: !!r.won,
});
}
const n = wins + losses;
return {
sample: n,
wins,
losses,
winrate: winrate(wins, losses),
kills,
deaths,
assists,
kda: n ? kda(kills, deaths, assists) : null,
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
avg_gpm: gpmN ? Math.round(gpmSum / gpmN) : null,
avg_xpm: xpmN ? Math.round(xpmSum / xpmN) : null,
avg_hero_damage: dmgN ? Math.round(dmgSum / dmgN) : null,
heroes,
};
}
export function careerFromOpenDota(wl, totals) {
const wins = Number(wl && wl.win) || 0;
const losses = Number(wl && wl.lose) || 0;
if (wins <= 0 && losses <= 0) return null;
const byField = new Map();
if (Array.isArray(totals)) {
for (const row of totals) {
if (row && row.field) byField.set(String(row.field), row);
}
}
const sumOf = (f) => Number((byField.get(f) || {}).sum) || 0;
const nOf = (f) => Number((byField.get(f) || {}).n) || 0;
const n = wins + losses;
const kills = sumOf("kills");
const deaths = sumOf("deaths");
const assists = sumOf("assists");
const gpmN = nOf("gold_per_min");
const xpmN = nOf("xp_per_min");
const dmgN = nOf("hero_damage");
return {
games: n,
wins,
losses,
winrate: winrate(wins, losses),
kills,
deaths,
assists,
kda: n ? kda(kills, deaths, assists) : null,
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
avg_gpm: gpmN ? Math.round(sumOf("gold_per_min") / gpmN) : null,
avg_xpm: xpmN ? Math.round(sumOf("xp_per_min") / xpmN) : null,
avg_hero_damage: dmgN ? Math.round(sumOf("hero_damage") / dmgN) : null,
source: "opendota",
};
}
export function mergeAvailability({ opendotaRecentN, career, steamHistoryStatus, fetchedAt }) {
const odPublic = opendotaRecentN > 0 || !!(career && career.games);
const steamAllowed = steamHistoryStatus === 1;
const steamDenied = steamHistoryStatus === 15;
let status = "unknown";
let complete = false;
let note = "暂无公开战绩";
if (odPublic) {
status = "public";
complete = true;
note = null;
} else if (steamAllowed) {
status = "syncing";
note = "Steam 已公开,OpenDota 同步中";
} else if (steamDenied) {
status = "private";
note = "未公开比赛数据";
}
return {
status,
complete,
opendota_public: odPublic,
steam_history_status: steamHistoryStatus ?? null,
source: "opendota+steam",
fetched_at: fetchedAt,
note,
stale: false,
};
}
+58
View File
@@ -0,0 +1,58 @@
/** Node smoke tests for Worker stats helpers (no wrangler). */
import assert from "node:assert/strict";
import {
aggregateFromRows,
careerFromOpenDota,
kda,
mergeAvailability,
winrate,
} from "./src/stats.js";
assert.equal(kda(10, 0, 5), 15);
assert.equal(winrate(0, 0), null);
assert.equal(winrate(1, 1), 50);
const emptyCareer = careerFromOpenDota({ win: 0, lose: 0 }, []);
assert.equal(emptyCareer, null);
const career = careerFromOpenDota(
{ win: 2, lose: 1 },
[
{ field: "kills", sum: 30, n: 3 },
{ field: "deaths", sum: 6, n: 3 },
{ field: "assists", sum: 15, n: 3 },
]
);
assert.equal(career.games, 3);
assert.equal(career.winrate, 66.7);
assert.ok(career.kda > 0);
const recent = aggregateFromRows(
[
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 },
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 }, // duplicate row ok in unit
{ match_id: 2, won: false, kills: 0, deaths: 0, assists: 2, gpm: 400 },
],
20
);
assert.equal(recent.sample, 3);
assert.equal(recent.wins, 2);
const syncing = mergeAvailability({
opendotaRecentN: 0,
career: null,
steamHistoryStatus: 1,
fetchedAt: "2026-07-31T00:00:00Z",
});
assert.equal(syncing.status, "syncing");
assert.match(syncing.note, /OpenDota/);
const priv = mergeAvailability({
opendotaRecentN: 0,
career: null,
steamHistoryStatus: 15,
fetchedAt: "2026-07-31T00:00:00Z",
});
assert.equal(priv.status, "private");
console.log("stats.js ok");
+27
View File
@@ -0,0 +1,27 @@
name = "climperor-player-sync"
main = "src/index.js"
compatibility_date = "2024-11-01"
workers_dev = false
[[d1_databases]]
binding = "DB"
database_name = "climperor-users"
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
[[r2_buckets]]
binding = "MATCHES"
bucket_name = "climperor-player-data"
[[queues.consumers]]
queue = "climperor-player-sync"
max_batch_size = 5
max_retries = 5
dead_letter_queue = "climperor-player-sync-dlq"
[[queues.producers]]
binding = "SYNC_QUEUE"
queue = "climperor-player-sync"
# Secrets (wrangler secret put):
# STEAM_API_KEY
# OPENDOTA_API_KEY (optional)
+278
View File
@@ -0,0 +1,278 @@
"""Create Cloudflare D1 / R2 / Queues for Climperor player data and bind Pages.
Credentials: CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY (or keyzoo refining/cloudflare).
Writes web/cloudflare/.resources.json with ids (no secrets).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = Path(__file__).resolve().parent / ".resources.json"
MIGRATION = Path(__file__).resolve().parent / "migrations" / "0001_init.sql"
PAGES_PROJECT = "climperor-relations"
D1_NAME = "climperor-users"
R2_NAME = "climperor-player-data"
QUEUE_NAME = "climperor-player-sync"
DLQ_NAME = "climperor-player-sync-dlq"
WORKER_NAME = "climperor-player-sync"
API = "https://api.cloudflare.com/client/v4"
def creds() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY")
return email, key
def api(method: str, path: str, body: dict | None = None) -> dict:
email, key = creds()
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-provision",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"CF API {method} {path} -> {e.code}: {raw[:400]}") from e
if not payload.get("success"):
raise SystemExit(f"CF API failed: {payload.get('errors')}")
return payload
def account_id() -> str:
rows = api("GET", "/accounts")["result"]
if not rows:
raise SystemExit("no Cloudflare accounts")
return rows[0]["id"]
def ensure_d1(acct: str) -> str:
listed = api("GET", f"/accounts/{acct}/d1/database")["result"] or []
for row in listed:
if row.get("name") == D1_NAME:
print(f"d1 exists: {row['uuid']}")
return row["uuid"]
created = api(
"POST",
f"/accounts/{acct}/d1/database",
{"name": D1_NAME},
)["result"]
print(f"d1 created: {created['uuid']}")
return created["uuid"]
def run_migration(acct: str, db_id: str) -> None:
sql = MIGRATION.read_text(encoding="utf-8")
api(
"POST",
f"/accounts/{acct}/d1/database/{db_id}/query",
{"sql": sql},
)
print("d1 migration applied")
def ensure_r2(acct: str) -> bool:
"""Return True if bucket exists/created. False if R2 not enabled on account."""
try:
api("GET", f"/accounts/{acct}/r2/buckets/{R2_NAME}")
print(f"r2 exists: {R2_NAME}")
return True
except SystemExit:
pass
try:
api("POST", f"/accounts/{acct}/r2/buckets", {"name": R2_NAME})
print(f"r2 created: {R2_NAME}")
return True
except SystemExit as e:
msg = str(e)
if "already exists" in msg.lower() or "10004" in msg:
print(f"r2 exists: {R2_NAME}")
return True
if "10042" in msg or "enable R2" in msg:
print(
"r2 skipped: enable R2 in Cloudflare Dashboard "
"(https://dash.cloudflare.com/?to=/:account/r2), then re-run"
)
return False
raise
def ensure_queue(acct: str, name: str) -> str:
listed = api("GET", f"/accounts/{acct}/queues")["result"] or []
# API shape may be {result: [...]} or {result: {queues: [...]}}
rows = listed if isinstance(listed, list) else (listed.get("queues") or [])
for row in rows:
if row.get("queue_name") == name or row.get("name") == name:
qid = row.get("queue_id") or row.get("id")
print(f"queue exists: {name} ({qid})")
return qid
created = api("POST", f"/accounts/{acct}/queues", {"queue_name": name})["result"]
qid = created.get("queue_id") or created.get("id")
print(f"queue created: {name} ({qid})")
return qid
def patch_wrangler(d1_id: str) -> None:
path = Path(__file__).resolve().parent / "player-sync" / "wrangler.toml"
text = path.read_text(encoding="utf-8")
text = text.replace("REPLACE_D1_ID", d1_id)
path.write_text(text, encoding="utf-8")
print(f"updated {path}")
def write_resources(
acct: str,
d1_id: str,
queue_id: str | None,
dlq_id: str | None,
*,
r2_ok: bool,
) -> None:
payload = {
"account_id": acct,
"d1": {"name": D1_NAME, "id": d1_id},
"r2": {"name": R2_NAME, "ready": r2_ok},
"queue": {"name": QUEUE_NAME, "id": queue_id},
"dlq": {"name": DLQ_NAME, "id": dlq_id},
"worker": WORKER_NAME,
"pages_project": PAGES_PROJECT,
}
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"wrote {OUT}")
def ensure_queue_soft(acct: str, name: str) -> str | None:
try:
return ensure_queue(acct, name)
except SystemExit as e:
print(f"queue skipped ({name}): {e}")
return None
def _env_bindings(
base: dict,
*,
d1_id: str,
queue_id: str | None,
r2_ok: bool,
fail_open: bool | None,
) -> dict:
"""Build one environment's deployment_config with required bindings."""
out = {
k: v
for k, v in base.items()
if k
not in (
"d1_databases",
"queue_producers",
"r2_buckets",
"fail_open",
)
}
d1_bindings = dict(base.get("d1_databases") or {})
d1_bindings["DB"] = {"id": d1_id}
out["d1_databases"] = d1_bindings
if queue_id:
producers = dict(base.get("queue_producers") or {})
producers["SYNC_QUEUE"] = {"name": QUEUE_NAME}
out["queue_producers"] = producers
if r2_ok:
buckets = dict(base.get("r2_buckets") or {})
buckets["MATCHES"] = {"name": R2_NAME}
out["r2_buckets"] = buckets
# Cloudflare requires fail_open equal on production and preview.
if fail_open is not None:
out["fail_open"] = bool(fail_open)
return out
def bind_pages(
acct: str,
d1_id: str,
queue_id: str | None,
*,
r2_ok: bool = False,
) -> None:
"""Attach D1 (+ Queue / R2) to Pages production and preview bindings."""
path = f"/accounts/{acct}/pages/projects/{PAGES_PROJECT}"
try:
project = api("GET", path)["result"]
except SystemExit as e:
print(f"pages bind skipped (project missing?): {e}")
return
dc = project.get("deployment_configs") or {}
prod = dict(dc.get("production") or {})
preview = dict(dc.get("preview") or {})
fail_open = prod.get("fail_open")
if fail_open is None:
fail_open = preview.get("fail_open")
if fail_open is None:
fail_open = False
body = {
"deployment_configs": {
"production": _env_bindings(
prod,
d1_id=d1_id,
queue_id=queue_id,
r2_ok=r2_ok,
fail_open=fail_open,
),
"preview": _env_bindings(
preview,
d1_id=d1_id,
queue_id=queue_id,
r2_ok=r2_ok,
fail_open=fail_open,
),
}
}
try:
api("PATCH", path, body)
print(f"pages bindings updated on {PAGES_PROJECT} (prod+preview)")
except SystemExit as e:
print(f"pages bind soft-fail (set manually): {e}")
def main() -> int:
acct = account_id()
print(f"account_id: {acct}")
d1_id = ensure_d1(acct)
run_migration(acct, d1_id)
r2_ok = ensure_r2(acct)
queue_id = ensure_queue_soft(acct, QUEUE_NAME)
dlq_id = ensure_queue_soft(acct, DLQ_NAME)
patch_wrangler(d1_id)
bind_pages(acct, d1_id, queue_id, r2_ok=r2_ok)
write_resources(acct, d1_id, queue_id, dlq_id, r2_ok=r2_ok)
print(
"next: deploy worker with wrangler + bind D1/R2/Queue to Pages project "
f"{PAGES_PROJECT} (see web/cloudflare/README.md)"
)
return 0 if d1_id else 1
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
"""Put STEAM_API_KEY on climperor-player-sync from staged temp or env."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
WORKER = Path(__file__).resolve().parent / "player-sync"
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
def put_secret(name: str, value: str, env: dict) -> int:
print(f"putting secret {name}", flush=True)
proc = subprocess.run(
f"npx --yes wrangler@3 secret put {name}",
cwd=str(WORKER),
env=env,
shell=True,
input=value + "\n",
text=True,
capture_output=True,
)
if proc.returncode != 0:
print(proc.stderr or proc.stdout, file=sys.stderr)
return proc.returncode
def main() -> int:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
cf_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
steam = (
os.environ.get("STEAM_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
or ""
).strip()
if not steam and STAGED.is_file():
steam = STAGED.read_text(encoding="utf-8").strip()
try:
STAGED.unlink()
except OSError:
pass
if not email or not cf_key:
print("missing Cloudflare credentials", file=sys.stderr)
return 2
if not steam:
print("missing STEAM_API_KEY (stage via _stage_steam_key.py first)", file=sys.stderr)
return 2
env = os.environ.copy()
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = cf_key
env["CLOUDFLARE_ACCOUNT_ID"] = "510534f7f6284344aadaf2f5a0794d48"
code = put_secret("STEAM_API_KEY", steam, env)
print("ok" if code == 0 else "failed", flush=True)
return code
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -409,6 +409,42 @@ def bind_domain(
ensure_cname(email, api_key, domain, project) ensure_cname(email, api_key, domain, project)
def check_player_bindings(email: str, api_key: str, account_id: str, project: str) -> None:
"""Warn (do not abort) if D1/Queue bindings for player pages are missing."""
data = cf_api(
"GET",
f"/accounts/{account_id}/pages/projects/{project}",
email=email,
api_key=api_key,
)
if not data.get("success"):
print("warning: could not verify Pages player bindings")
return
prod = ((data.get("result") or {}).get("deployment_configs") or {}).get(
"production"
) or {}
d1 = prod.get("d1_databases") or {}
queues = prod.get("queue_producers") or {}
r2 = prod.get("r2_buckets") or {}
missing = []
if "DB" not in d1:
missing.append("D1:DB")
if "SYNC_QUEUE" not in queues:
missing.append("Queue:SYNC_QUEUE")
if missing:
print(
"warning: Pages missing player bindings "
f"{', '.join(missing)} — run python web/cloudflare/provision.py"
)
else:
print("pages player bindings: DB + SYNC_QUEUE ok")
if "MATCHES" not in r2:
print(
"note: R2 MATCHES not bound yet "
"(enable R2 in Dashboard, then re-run provision.py)"
)
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages") ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist") ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
@@ -441,6 +477,7 @@ def main() -> None:
print(f"account_id: {account_id}") print(f"account_id: {account_id}")
ensure_project(email, api_key, account_id, args.project_name) ensure_project(email, api_key, account_id, args.project_name)
check_player_bindings(email, api_key, account_id, args.project_name)
if not args.no_export: if not args.no_export:
run_export(args.ability_video_base, args.static_asset_base) run_export(args.ability_video_base, args.static_asset_base)
+1 -1
View File
@@ -55,7 +55,7 @@ from shared.paths import (
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.6.16" SITE_VERSION = "0.6.54"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
+910 -101
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */ /* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.6.16"; var SITE_VERSION = "0.6.54";
var SITE_ORIGIN = ""; var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = ""; var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = ""; var STATIC_ASSET_BASE = "";
@@ -0,0 +1,209 @@
/**
* Shared Steam OpenID + signed-session helpers for Pages Functions.
*
* Env: STEAM_API_KEY, SESSION_SECRET (required for login/me).
* Cookie: climperor_steam (HttpOnly, signed payload).
*/
const COOKIE_NAME = "climperor_steam";
const SESSION_DAYS = 30;
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
const STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/";
export 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,
},
});
}
export function envOf(context) {
return (context && context.env) || {};
}
function b64urlEncode(bytes) {
let bin = "";
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function b64urlDecode(str) {
const pad = "=".repeat((4 - (str.length % 4)) % 4);
const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmacSign(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return b64urlEncode(sig);
}
export function steamId64ToAccountId(steamId64) {
try {
const n = BigInt(String(steamId64));
const account = n - 76561197960265728n;
if (account <= 0n) return null;
return Number(account);
} catch {
return null;
}
}
export function parseSteamIdFromClaimedId(claimedId) {
if (!claimedId || typeof claimedId !== "string") return null;
if (!claimedId.startsWith(STEAM_ID_PREFIX)) return null;
const id = claimedId.slice(STEAM_ID_PREFIX.length).replace(/\/$/, "");
if (!/^\d{17}$/.test(id)) return null;
return id;
}
export async function createSessionToken(secret, payload) {
const body = {
...payload,
exp: Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400,
};
const raw = b64urlEncode(new TextEncoder().encode(JSON.stringify(body)));
const sig = await hmacSign(secret, raw);
return `${raw}.${sig}`;
}
export async function verifySessionToken(secret, token) {
if (!secret || !token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 2) return null;
const [raw, sig] = parts;
const expect = await hmacSign(secret, raw);
if (sig.length !== expect.length) return null;
let ok = 0;
for (let i = 0; i < sig.length; i++) ok |= sig.charCodeAt(i) ^ expect.charCodeAt(i);
if (ok !== 0) return null;
try {
const json = new TextDecoder().decode(b64urlDecode(raw));
const data = JSON.parse(json);
if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null;
if (!data.steamid || !data.account_id) return null;
return data;
} catch {
return null;
}
}
export function readCookie(request, name = COOKIE_NAME) {
const header = request.headers.get("Cookie") || "";
const parts = header.split(";").map((s) => s.trim());
for (const p of parts) {
if (p.startsWith(name + "=")) {
return decodeURIComponent(p.slice(name.length + 1));
}
}
return null;
}
function sessionCookieHeader(token, { clear = false } = {}) {
if (clear) {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
const maxAge = SESSION_DAYS * 86400;
return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
}
/** Add Secure on non-localhost. */
export function sessionCookieHeaderForRequest(request, token, { clear = false } = {}) {
let base = sessionCookieHeader(token, { clear });
const host = new URL(request.url).hostname;
if (host !== "127.0.0.1" && host !== "localhost") {
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax");
}
return base;
}
export function steamLoginRedirectUrl(origin) {
const returnTo = `${origin}/api/auth/steam/callback`;
const params = new URLSearchParams({
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": returnTo,
"openid.realm": origin,
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
});
return `${STEAM_OPENID}?${params.toString()}`;
}
export async function verifySteamOpenId(query) {
const mode = query.get("openid.mode");
if (mode !== "id_res") return { ok: false, error: "bad mode" };
const claimed = query.get("openid.claimed_id");
const steamid = parseSteamIdFromClaimedId(claimed);
if (!steamid) return { ok: false, error: "bad claimed_id" };
const body = new URLSearchParams();
for (const [k, v] of query.entries()) {
if (k.startsWith("openid.")) body.set(k, v);
}
body.set("openid.mode", "check_authentication");
const res = await fetch(STEAM_OPENID, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "climperor-steam-auth",
},
body,
});
const text = await res.text();
if (!/is_valid\s*:\s*true/i.test(text)) {
return { ok: false, error: "openid invalid" };
}
return { ok: true, steamid };
}
export async function fetchSteamPersona(apiKey, steamid) {
if (!apiKey) return { personaname: null, avatar: null };
const url = new URL(
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
);
url.searchParams.set("key", apiKey);
url.searchParams.set("steamids", steamid);
try {
const res = await fetch(url.toString(), {
headers: { "User-Agent": "climperor-steam-auth" },
});
if (!res.ok) return { personaname: null, avatar: null };
const data = await res.json();
const players = data && data.response && data.response.players;
const p = Array.isArray(players) && players[0] ? players[0] : null;
if (!p) return { personaname: null, avatar: null };
return {
personaname: p.personaname || null,
avatar: p.avatarfull || p.avatarmedium || p.avatar || null,
};
} catch {
return { personaname: null, avatar: null };
}
}
export async function sessionFromRequest(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
if (!secret) return null;
const token = readCookie(context.request);
if (!token) return null;
return verifySessionToken(secret, token);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* POST|GET /api/auth/logout clear session cookie.
*/
import { jsonResponse, sessionCookieHeaderForRequest } from "./_steam_common.js";
function clear(context) {
const origin = new URL(context.request.url).origin;
const wantsHtml = (context.request.headers.get("Accept") || "").includes("text/html");
if (wantsHtml || context.request.method === "GET") {
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
"Cache-Control": "no-store",
},
});
}
return jsonResponse(
{ ok: true },
200,
{
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
}
);
}
export async function onRequestGet(context) {
return clear(context);
}
export async function onRequestPost(context) {
return clear(context);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* GET /api/auth/me current Steam session (or { authenticated: false }).
*/
import { jsonResponse, sessionFromRequest } from "./_steam_common.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session) {
return jsonResponse({ authenticated: false });
}
return jsonResponse({
authenticated: true,
steamid: session.steamid,
account_id: session.account_id,
personaname: session.personaname || null,
avatar: session.avatar || null,
});
} catch {
return jsonResponse({ authenticated: false });
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* GET /api/auth/steam redirect to Steam OpenID login.
*/
import { envOf, steamLoginRedirectUrl } from "./_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
if (!(env.SESSION_SECRET || "").trim() || !(env.STEAM_API_KEY || "").trim()) {
return new Response("Steam login not configured", { status: 503 });
}
const origin = new URL(context.request.url).origin;
return Response.redirect(steamLoginRedirectUrl(origin), 302);
}
@@ -0,0 +1,56 @@
/**
* GET /api/auth/steam/callback Steam OpenID return_to.
*/
import {
createSessionToken,
envOf,
fetchSteamPersona,
sessionCookieHeaderForRequest,
steamId64ToAccountId,
verifySteamOpenId,
} from "../_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
const apiKey = (env.STEAM_API_KEY || "").trim();
const origin = new URL(context.request.url).origin;
if (!secret || !apiKey) {
return Response.redirect(`${origin}/?auth=unconfigured`, 302);
}
const url = new URL(context.request.url);
let verified;
try {
verified = await verifySteamOpenId(url.searchParams);
} catch {
return Response.redirect(`${origin}/?auth=error`, 302);
}
if (!verified.ok) {
return Response.redirect(`${origin}/?auth=denied`, 302);
}
const steamid = verified.steamid;
const accountId = steamId64ToAccountId(steamid);
if (!accountId) {
return Response.redirect(`${origin}/?auth=error`, 302);
}
const persona = await fetchSteamPersona(apiKey, steamid);
const token = await createSessionToken(secret, {
steamid,
account_id: accountId,
personaname: persona.personaname,
avatar: persona.avatar,
});
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/home`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, token),
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,29 @@
/**
* GET /api/players/:account_id public or self profile from D1.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import { jsonResponse, loadPlayerBundle } from "./_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
if (!Number.isFinite(accountId) || accountId <= 0) {
return jsonResponse({ error: "bad account_id" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const bundle = await loadPlayerBundle(env.DB, accountId);
if (!bundle) return jsonResponse({ error: "not found" }, 404);
if (!self && !bundle.public_share) {
return jsonResponse({ error: "private" }, 404);
}
return jsonResponse(bundle);
} catch (e) {
return jsonResponse(
{ error: "player get failed", detail: String((e && e.message) || e) },
500
);
}
}
@@ -0,0 +1,49 @@
/**
* GET /api/players/:account_id/:match_id match detail from R2 (authz via D1).
*/
import { sessionFromRequest } from "../../auth/_steam_common.js";
import { jsonResponse } from "../_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
const matchId = Number(context.params && context.params.match_id);
if (!Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(matchId)) {
return jsonResponse({ error: "bad ids" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const user = await env.DB.prepare(
`SELECT public_share FROM users WHERE account_id = ?`
)
.bind(accountId)
.first();
if (!user) return jsonResponse({ error: "not found" }, 404);
if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404);
const row = await env.DB.prepare(
`SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?`
)
.bind(accountId, matchId)
.first();
const key = (row && row.r2_key) || `matches/${matchId}.json`;
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
const obj = await env.MATCHES.get(key);
if (!obj) return jsonResponse({ error: "match not found" }, 404);
const text = await obj.text();
return new Response(text, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
} catch (e) {
return jsonResponse(
{ error: "match get failed", detail: String((e && e.message) || e) },
500
);
}
}
+151
View File
@@ -0,0 +1,151 @@
/** D1 helpers for Pages Functions (subset of cloudflare/player-sync/src/db.js). */
export function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function jsonResponse(body, status = 200, extra = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extra,
},
});
}
export async function loadPlayerBundle(db, accountId) {
if (!db) return null;
const user = await db
.prepare(`SELECT * FROM users WHERE account_id = ?`)
.bind(accountId)
.first();
if (!user) return null;
const profile = await db
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
.bind(accountId)
.first();
const statsRows = await db
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
.bind(accountId)
.all();
const heroes = await db
.prepare(
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const peers = await db
.prepare(
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const recent = await db
.prepare(
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
)
.bind(accountId)
.all();
const statsByScope = {};
for (const row of (statsRows && statsRows.results) || []) {
try {
statsByScope[row.scope] = row.payload_json
? JSON.parse(row.payload_json)
: row;
} catch {
statsByScope[row.scope] = row;
}
}
return {
account_id: accountId,
personaname: user.personaname,
avatar: user.avatar,
public_share: !!user.public_share,
rank_tier: profile && profile.rank_tier,
leaderboard_rank: profile && profile.leaderboard_rank,
availability: profile
? {
status: profile.availability_status,
note: profile.availability_note,
complete: !!profile.availability_complete,
source: profile.source,
fetched_at: profile.fetched_at,
stale: false,
}
: null,
career: statsByScope.career || null,
recent_20: statsByScope.recent20 || null,
activity_180: statsByScope.recent180 || null,
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
hero_id: h.hero_id,
hero_key: h.hero_key,
hero_name_loc: h.hero_name_loc,
games: h.games,
wins: h.wins,
winrate: h.winrate,
last_played: h.last_played,
})),
peers: ((peers && peers.results) || []).map((p) => ({
account_id: p.peer_account_id,
personaname: p.personaname,
avatar: p.avatar,
games: p.games,
wins: p.wins,
winrate: p.winrate,
})),
recent: ((recent && recent.results) || []).map((r) => ({
match_id: r.match_id,
start_time: r.start_time,
duration: r.duration,
won: !!r.won,
hero_id: r.hero_id,
hero_key: r.hero_key,
hero_name_loc: r.hero_name_loc,
kills: r.kills,
deaths: r.deaths,
assists: r.assists,
kda: r.kda,
gpm: r.gpm,
xpm: r.xpm,
hero_damage: r.hero_damage,
game_mode: r.game_mode,
lobby_type: r.lobby_type,
})),
updated_at: (profile && profile.updated_at) || user.last_login_at,
enriched_at: profile && profile.enriched_at,
};
}
export function isStale(fetchedAt, maxAgeMs = 10 * 60 * 1000) {
if (!fetchedAt) return true;
const t = Date.parse(fetchedAt);
if (!Number.isFinite(t)) return true;
return Date.now() - t > maxAgeMs;
}
export async function enqueueRefresh(env, payload) {
if (!env.SYNC_QUEUE) return false;
const jobId = crypto.randomUUID();
const now = utcNow();
if (env.DB) {
await env.DB.prepare(
`INSERT INTO sync_jobs (id, account_id, kind, match_id, status, attempts, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', 0, ?, ?)`
)
.bind(
jobId,
payload.account_id,
payload.kind || "login_refresh",
payload.match_id || null,
now,
now
)
.run();
}
await env.SYNC_QUEUE.send({ ...payload, job_id: jobId });
return true;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/players/me logged-in user's profile from D1; enqueue refresh if stale.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import {
enqueueRefresh,
isStale,
jsonResponse,
loadPlayerBundle,
} from "./_db.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session || !session.account_id) {
return jsonResponse({ authenticated: false }, 401);
}
const env = context.env || {};
const accountId = Number(session.account_id);
if (!env.DB) {
return jsonResponse(
{ error: "database not configured", account_id: accountId },
503
);
}
// Ensure user row exists for first login.
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
personaname=COALESCE(excluded.personaname, users.personaname),
avatar=COALESCE(excluded.avatar, users.avatar),
last_login_at=excluded.last_login_at`
)
.bind(
accountId,
String(session.steamid || ""),
session.personaname || null,
session.avatar || null,
now,
now
)
.run();
let bundle = await loadPlayerBundle(env.DB, accountId);
const fetchedAt =
(bundle && bundle.availability && bundle.availability.fetched_at) ||
(bundle && bundle.enriched_at) ||
null;
let stale = !bundle || isStale(fetchedAt);
if (stale) {
await enqueueRefresh(env, {
kind: "login_refresh",
account_id: accountId,
steamid: session.steamid,
personaname: session.personaname,
avatar: session.avatar,
});
}
if (!bundle) {
return jsonResponse({
authenticated: true,
account_id: accountId,
personaname: session.personaname || null,
avatar: session.avatar || null,
public_share: false,
recent: [],
career: null,
recent_20: null,
top_heroes: [],
peers: [],
activity_180: null,
availability: {
status: "unknown",
note: "正在同步…",
complete: false,
stale: true,
},
stale: true,
});
}
if (bundle.availability) bundle.availability.stale = stale;
return jsonResponse({ ...bundle, authenticated: true, stale });
} catch (e) {
return jsonResponse(
{ error: "me failed", detail: String((e && e.message) || e) },
500
);
}
}
+33 -348
View File
@@ -1,35 +1,12 @@
/** /**
* Pages Function: POST /api/players/publish * POST /api/players/publish
* *
* Body: { account_id, match_id } * Body: { account_id, match_id }
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set). * Optional header: X-Climperor-Publish-Secret
* *
* Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style * Enqueues Cloudflare Queue sync (D1 + R2). No longer writes OSS profile JSON.
* 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.
*/ */
import { enqueueRefresh, jsonResponse } from "./_db.js";
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) { function envOf(context) {
return (context && context.env) || {}; return (context && context.env) || {};
@@ -40,278 +17,14 @@ function intField(v, fallback = 0) {
return Number.isFinite(n) ? Math.trunc(n) : fallback; 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) { export async function onRequestPost(context) {
try { try {
const env = envOf(context); const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim(); const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) { if (expected) {
const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim(); const got = (
context.request.headers.get("X-Climperor-Publish-Secret") || ""
).trim();
if (got !== expected) { if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403); return jsonResponse({ error: "forbidden" }, 403);
} }
@@ -330,70 +43,42 @@ export async function onRequestPost(context) {
return jsonResponse({ error: "account_id and match_id required" }, 400); return jsonResponse({ error: "account_id and match_id required" }, 400);
} }
const odKey = (env.OPENDOTA_API_KEY || "").trim(); if (!env.SYNC_QUEUE) {
const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : ""; return jsonResponse({ error: "sync queue not configured" }, 503);
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) { // Mark public_share when PC publishes intentionally.
return jsonResponse( if (env.DB) {
{ ok: false, pending: true, message: "match incomplete on OpenDota" }, const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
202 const steamid = String(BigInt(accountId) + 76561197960265728n);
); await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, NULL, NULL, 1, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET public_share = 1, last_login_at = excluded.last_login_at`
)
.bind(accountId, steamid, now, now)
.run();
} }
if (!accountInMatch(match, accountId)) { const queued = await enqueueRefresh(env, {
return jsonResponse({ error: "account not in match" }, 403); kind: "publish_match",
} account_id: accountId,
match_id: matchId,
const heroMap = await loadHeroMap(odKey); public_share: true,
const detail = normalizeMatch(match, accountId, heroMap); });
if (!detail) { if (!queued) {
return jsonResponse({ error: "normalize failed" }, 500); return jsonResponse({ error: "enqueue failed" }, 503);
} }
const summary = summaryForProfile(detail, accountId); return jsonResponse({
if (!summary) { ok: true,
return jsonResponse({ error: "focus player missing" }, 500); queued: true,
}
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, account_id: accountId,
match_id: matchId, match_id: matchId,
profile_key: profileKey,
match_key: matchKey,
}); });
} catch (e) { } catch (e) {
const status = (e && e.status) || 500;
return jsonResponse( return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) }, { error: "publish failed", detail: String((e && e.message) || e) },
status >= 400 && status < 600 ? status : 500 500
); );
} }
} }
+14 -5
View File
@@ -43,8 +43,8 @@
} }
</script> </script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" /> <link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.16" /> <link rel="stylesheet" href="/style.css?v=0.6.54" />
<script src="/mobile-gate.js?v=0.6.16"></script> <script src="/mobile-gate.js?v=0.6.54"></script>
</head> </head>
<body> <body>
<h1 class="sr-only">DOTA2 上分帝</h1> <h1 class="sr-only">DOTA2 上分帝</h1>
@@ -87,6 +87,7 @@
<span class="brand-title">上分帝</span> <span class="brand-title">上分帝</span>
</div> </div>
<nav class="main-tabs" aria-label="主分页"> <nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab hidden" data-page="home" id="tab-home"></button>
<button type="button" class="main-tab active" data-page="heroes">英雄</button> <button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="rankings">排行</button> <button type="button" class="main-tab" data-page="rankings">排行</button>
<button type="button" class="main-tab" data-page="streamers">主播</button> <button type="button" class="main-tab" data-page="streamers">主播</button>
@@ -99,6 +100,14 @@
<div class="topbar-tools"> <div class="topbar-tools">
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" /> <input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
<input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" /> <input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
<div class="auth-box" id="auth-box">
<a class="steam-login-btn" id="steam-login-btn" href="/api/auth/steam">Steam 登录</a>
<div class="auth-user hidden" id="auth-user">
<img class="auth-avatar" id="auth-avatar" alt="" width="28" height="28" />
<span class="auth-name" id="auth-name"></span>
<button type="button" class="auth-logout" id="auth-logout" title="退出登录">退出</button>
</div>
</div>
<a <a
class="contact-mail" class="contact-mail"
href="mailto:c9mhs8vfmuyv@outlook.com" href="mailto:c9mhs8vfmuyv@outlook.com"
@@ -247,8 +256,8 @@
</div> </div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer> <footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.16"></script> <script src="/config.js?v=0.6.54"></script>
<script src="/router.js?v=0.6.16"></script> <script src="/router.js?v=0.6.54"></script>
<script src="/app.js?v=0.6.16"></script> <script src="/app.js?v=0.6.54"></script>
</body> </body>
</html> </html>
+13 -1
View File
@@ -4,7 +4,7 @@
* Path-based router for the Climperor web site (web/frontend). * Path-based router for the Climperor web site (web/frontend).
* *
* Synchronizes the browser URL with app state across these dimensions: * Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches | players * - page: home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - hero: selected hero key + detail sub-tab * - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends) * (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends)
* - rankings: Immortal leaderboard region * - rankings: Immortal leaderboard region
@@ -12,6 +12,8 @@
* - matches: star-player recent matches (pro_matches) * - matches: star-player recent matches (pro_matches)
* /matches[/account_id][?origin=pro|china][&page=N] * /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted) * (default origin all omitted; page=1 omitted)
* - home: logged-in Steam self homepage (players view + auth.account_id)
* /home[/{match_id}]
* - players: PC post-match player home + match detail (local/OSS JSON) * - players: PC post-match player home + match detail (local/OSS JSON)
* /players/{account_id}[/{match_id}] * /players/{account_id}[/{match_id}]
* - streamers: curated streamer directory * - streamers: curated streamer directory
@@ -41,6 +43,7 @@
const ROUTE_DEFAULT = "/heroes"; const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = [ const VALID_PAGES = [
"home",
"heroes", "heroes",
"rankings", "rankings",
"matches", "matches",
@@ -161,6 +164,11 @@ function parseHash(hashOrPath) {
if (segs[1] && /^\d+$/.test(segs[1])) { if (segs[1] && /^\d+$/.test(segs[1])) {
out.matchesPlayerId = segs[1]; out.matchesPlayerId = segs[1];
} }
} else if (page === "home") {
// Logged-in self homepage; optional match detail: /home/{match_id}
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerMatchId = segs[1];
}
} else if (page === "players") { } else if (page === "players") {
if (segs[1] && /^\d+$/.test(segs[1])) { if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerAccountId = segs[1]; out.playerAccountId = segs[1];
@@ -240,6 +248,10 @@ function serializeHash(state) {
if (state.matchesPlayerId) { if (state.matchesPlayerId) {
path += "/" + encodeURIComponent(String(state.matchesPlayerId)); path += "/" + encodeURIComponent(String(state.matchesPlayerId));
} }
} else if (state.page === "home") {
if (state.playerMatchId) {
path += "/" + encodeURIComponent(String(state.playerMatchId));
}
} else if (state.page === "players") { } else if (state.page === "players") {
if (state.playerAccountId) { if (state.playerAccountId) {
path += "/" + encodeURIComponent(String(state.playerAccountId)); path += "/" + encodeURIComponent(String(state.playerAccountId));
+656 -61
View File
@@ -65,6 +65,8 @@
/* Matches list + right filter aside (centered column must leave aside room). */ /* Matches list + right filter aside (centered column must leave aside room). */
--content-data: 1200px; --content-data: 1200px;
--aside-gap: 20px; --aside-gap: 20px;
/* Patches "AI 解读": grows with right gutter up to this cap. */
--patches-aside-max: 420px;
--focus-ring: 2px solid var(--sel); --focus-ring: 2px solid var(--sel);
} }
@@ -106,6 +108,8 @@ body {
.tagbar button:focus-visible, .tagbar button:focus-visible,
.search:focus-visible, .search:focus-visible,
.rankings-region-btn:focus-visible, .rankings-region-btn:focus-visible,
.steam-login-btn:focus-visible,
.auth-logout:focus-visible,
.contact-mail:focus-visible, .contact-mail:focus-visible,
.mobile-demand-btn:focus-visible, .mobile-demand-btn:focus-visible,
#patch-select:focus-visible { #patch-select:focus-visible {
@@ -232,6 +236,79 @@ body {
width: 44px; width: 44px;
height: 44px; height: 44px;
} }
.auth-box {
display: flex;
align-items: center;
gap: var(--space-xs);
flex: 0 0 auto;
min-height: 44px;
}
.steam-login-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font: inherit;
font-size: var(--fs-body-sm);
line-height: var(--lh-body);
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.steam-login-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(140, 170, 210, 0.35);
}
.auth-user {
display: flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.auth-avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
}
.auth-name {
max-width: 9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: var(--fs-body-sm);
line-height: var(--lh-body);
}
.auth-logout {
min-height: 32px;
padding: 0 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: var(--fs-label-sm);
letter-spacing: 0.04em;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.auth-logout:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.auth-home-gate .steam-login-btn {
margin-top: var(--space-sm);
}
.contact-mail { .contact-mail {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -278,7 +355,7 @@ body {
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s;
} }
.main-tab + .main-tab::before { .main-tab:not(.hidden) + .main-tab:not(.hidden)::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 10px; top: 10px;
@@ -443,6 +520,7 @@ body.detail-drawer-open .hero-role-toolbar {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.brand-title { display: none; } .brand-title { display: none; }
.auth-name { display: none; }
.main-tab { .main-tab {
min-width: 56px; min-width: 56px;
padding: 10px 10px; padding: 10px 10px;
@@ -2919,7 +2997,12 @@ body:has(#items-view:not(.hidden)) {
left: calc(100% + var(--aside-gap)); left: calc(100% + var(--aside-gap));
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 280px; /* Fill available right gutter (centered 820px main); floor 280, cap 420. */
width: clamp(
280px,
calc((100vw - var(--content-read)) / 2 - var(--aside-gap) - 24px),
var(--patches-aside-max)
);
pointer-events: none; pointer-events: none;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -3047,7 +3130,7 @@ button.patch-summary-chip.is-clickable:hover .patch-summary-chip-name {
color: var(--muted); color: var(--muted);
line-height: 1.4; line-height: 1.4;
} }
/* Need ~820 main + 300 aside gutter on each side of optical center. */ /* Need ~820 main + ~300 aside gutter on each side of optical center. */
@media (max-width: 1440px) { @media (max-width: 1440px) {
.patches-center-wrap { .patches-center-wrap {
display: flex; display: flex;
@@ -4423,9 +4506,11 @@ html.mobile-client #mobile-gate {
/* —— PC post-match player pages (/players) —— */ /* —— PC post-match player pages (/players) —— */
.players-board .players-cluster { .players-board .players-cluster {
max-width: 1100px; width: 100%;
max-width: 1224px;
margin: 0 auto; margin: 0 auto;
padding: var(--space-lg) var(--space-md) 48px; padding: var(--space-lg) var(--space-md) 48px;
box-sizing: border-box;
} }
.players-body { .players-body {
display: flex; display: flex;
@@ -4435,27 +4520,301 @@ html.mobile-client #mobile-gate {
.players-back { .players-back {
appearance: none; appearance: none;
align-self: flex-start; align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
margin: 0;
padding: 0 14px 0 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; background: var(--surface-raised);
}
.players-back:hover {
color: var(--text); color: var(--text);
border-color: rgba(94, 200, 255, 0.45); font: inherit;
font-size: 14px;
font-weight: var(--fw-semibold);
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s, background 0.15s;
} }
.players-profile-head, .players-back-icon {
.players-match-head { flex: 0 0 auto;
display: block;
opacity: 0.9;
}
.players-back:hover,
.players-back:focus-visible {
color: var(--accent, #5ec8ff);
border-color: rgba(94, 200, 255, 0.5);
background: rgba(94, 200, 255, 0.08);
outline: none;
}
.players-profile-head {
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
} }
.players-home {
width: 100%;
margin: 0 auto;
padding: var(--space-md) 0 var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-identity {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-identity-avatar {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
}
.players-identity-body {
min-width: 0;
flex: 1 1 auto;
}
.players-profile-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.players-profile-title .page-title {
margin: 0;
}
.players-identity .page-sub {
margin: 4px 0 0;
}
.rank-medal {
position: relative;
display: inline-block;
width: 36px;
height: 36px;
flex: 0 0 auto;
line-height: 0;
}
.rank-medal-base,
.rank-medal-stars {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
/* OpenDota medals keep ~1520% canvas padding; size the box to match the 56px avatar so the glyph reads at similar optical weight. */
.players-rank-medal {
width: 64px;
height: 64px;
margin-inline: -4px;
}
.players-rank-board {
color: var(--muted);
font-size: var(--fs-body-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
.match-rank-medal {
width: 28px;
height: 28px;
}
.players-section-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: var(--fw-semibold);
letter-spacing: 0.06em;
color: var(--muted);
text-transform: none;
}
.players-snapshot {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-snapshot-col {
min-width: 0;
}
.players-stat-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.players-stat-card {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
display: flex;
flex-direction: column;
gap: 2px;
}
.players-stat-label {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.players-stat-value {
font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: var(--fw-semibold);
color: var(--text);
line-height: 1.2;
}
.players-analysis {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-md);
}
.players-analysis-side {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-width: 0;
}
.players-analysis-block {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
min-width: 0;
}
.players-top-heroes {
display: flex;
flex-direction: column;
gap: 6px;
}
.players-peers {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 6px;
}
.players-top-hero,
.players-peer {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--surface-raised);
color: inherit;
text-align: left;
font: inherit;
cursor: default;
min-width: 0;
}
.players-peer {
cursor: pointer;
border-color: var(--border);
}
.players-peer:hover {
border-color: rgba(94, 200, 255, 0.4);
}
.players-top-hero img {
width: 48px;
height: 27px;
object-fit: cover;
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
.players-peer img {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
}
.players-top-hero > div,
.players-peer > div {
min-width: 0;
}
.players-top-hero strong,
.players-peer strong {
display: block;
font-size: var(--fs-body-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-top-hero span,
.players-peer span {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.players-highs {
color: var(--muted);
font-size: 12px;
margin: 0 0 8px;
font-variant-numeric: tabular-nums;
}
.players-heatmap {
display: grid;
grid-template-rows: repeat(7, 9px);
grid-auto-flow: column;
grid-auto-columns: 9px;
gap: 2px;
max-width: 100%;
overflow: hidden;
}
.players-heatmap-wrap {
display: flex;
align-items: flex-end;
justify-content: flex-start;
gap: 12px;
}
.players-heatmap-legend {
display: flex;
align-items: center;
gap: 3px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.players-heatmap-legend .players-heat-cell {
display: inline-block;
flex: 0 0 auto;
}
.players-heat-cell {
width: 9px;
height: 9px;
border-radius: 2px;
background: rgba(140, 170, 210, 0.12);
}
.players-heat-cell.lv1 { background: rgba(61, 206, 122, 0.25); }
.players-heat-cell.lv2 { background: rgba(61, 206, 122, 0.45); }
.players-heat-cell.lv3 { background: rgba(61, 206, 122, 0.7); }
.players-heat-cell.lv4 { background: rgba(61, 206, 122, 0.95); }
.players-recent-section {
margin-top: 4px;
}
.players-recent-section > .players-section-title {
margin-bottom: 10px;
color: var(--text);
font-size: 16px;
letter-spacing: 0;
}
.players-recent { .players-recent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 4px;
} }
.players-recent-row { .players-recent-row {
appearance: none; appearance: none;
@@ -4468,12 +4827,14 @@ html.mobile-client #mobile-gate {
background: var(--surface-raised); background: var(--surface-raised);
color: var(--text); color: var(--text);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 10px 12px; padding: 8px 12px;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, background 0.15s; transition: border-color 0.15s, background 0.15s;
} }
.players-recent-row:hover { .players-recent-row:hover,
border-color: rgba(94, 200, 255, 0.4); .players-recent-row:focus-visible {
border-color: rgba(94, 200, 255, 0.45);
outline: none;
} }
.players-recent-row.won { .players-recent-row.won {
border-left: 3px solid var(--good); border-left: 3px solid var(--good);
@@ -4482,49 +4843,186 @@ html.mobile-client #mobile-gate {
border-left: 3px solid var(--danger); border-left: 3px solid var(--danger);
} }
.players-recent-portrait { .players-recent-portrait {
width: 64px; width: 56px;
height: 36px; height: 32px;
object-fit: cover; object-fit: cover;
border-radius: 4px; border-radius: var(--radius-sm);
flex-shrink: 0; flex-shrink: 0;
} }
.players-recent-body { .players-recent-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: grid;
/* hero/KDA grows left | duration/when + result/id tight on the right */
grid-template-columns: minmax(0, 1fr) auto auto;
column-gap: 20px;
align-items: center;
} }
.players-recent-top, .players-recent-hero-col,
.players-recent-bot { .players-recent-meta,
display: flex; .players-recent-end {
justify-content: space-between; display: grid;
gap: 12px; grid-template-rows: 1.3em 1.15em;
row-gap: 2px;
align-items: center;
min-width: 0;
} }
.players-recent-top { .players-recent-hero-col {
min-width: 0;
}
.players-recent-meta {
justify-items: end;
flex-shrink: 0;
}
.players-recent-end {
justify-items: end;
flex-shrink: 0;
}
.players-recent-hero {
font-size: 14px;
font-weight: 700; font-weight: 700;
line-height: 1.3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.players-recent-bot { .players-recent-kda {
margin-top: 4px;
font-size: 12px; font-size: 12px;
line-height: 1.15em;
color: var(--muted); color: var(--muted);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-recent-dur {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text);
}
.players-recent-when {
font-size: 12px;
line-height: 1.2;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.players-recent-wl { .players-recent-wl {
font-size: 13px; font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.3em;
white-space: nowrap;
}
.players-recent-id {
font-size: 12px;
line-height: 1.15em;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.players-recent-wl.won {
color: var(--good);
}
.players-recent-wl.lost {
color: var(--danger);
}
@media (max-width: 980px) {
.players-snapshot {
grid-template-columns: 1fr;
}
.players-analysis {
grid-template-columns: 1fr;
}
.players-stat-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.players-board .players-cluster {
padding-inline: 12px;
}
.players-recent-body {
column-gap: 14px;
}
}
.players-match-shell {
width: 100%;
margin: 0 auto;
padding: var(--space-md) 0 var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-match-shell > .players-back {
margin-bottom: -4px;
}
.players-match-head {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-match-summary .page-title {
margin: 0;
font-size: 18px;
}
.players-match-summary .page-sub {
margin: 6px 0 0;
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-variant-numeric: tabular-nums;
}
.players-match-winner {
color: var(--good);
font-weight: 700;
} }
.player-scoreboard { .player-scoreboard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 12px;
}
.player-scoreboard-columns,
.player-match-row {
/* name capped | metrics fill remaining | items fixed */
grid-template-columns: minmax(11rem, 16rem) minmax(0, 1fr) 14.5rem;
}
.player-scoreboard-columns {
display: grid;
column-gap: 12px;
align-items: center;
padding: 0 12px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
}
.player-scoreboard-metric-labels,
.player-match-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 12px;
width: 100%;
justify-self: stretch;
box-sizing: border-box;
}
.player-scoreboard-items-label {
text-align: right;
} }
.player-team { .player-team {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
background: var(--panel-soft); background: var(--panel-soft);
overflow: hidden; overflow: hidden;
} }
.player-team.radiant { .player-team.won {
border-color: rgba(61, 206, 122, 0.35); border-color: rgba(61, 206, 122, 0.35);
} }
.player-team.dire { .player-team.lost {
border-color: rgba(232, 106, 106, 0.35); border-color: rgba(232, 106, 106, 0.35);
} }
.player-team-head { .player-team-head {
@@ -4532,13 +5030,13 @@ html.mobile-client #mobile-gate {
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 12px;
padding: 10px 14px; padding: 8px 12px;
font-size: 14px; font-size: 13px;
} }
.player-team.radiant .player-team-head { .player-team.won .player-team-head {
background: rgba(61, 206, 122, 0.12); background: rgba(61, 206, 122, 0.12);
} }
.player-team.dire .player-team-head { .player-team.lost .player-team-head {
background: rgba(232, 106, 106, 0.12); background: rgba(232, 106, 106, 0.12);
} }
.player-team-name { .player-team-name {
@@ -4547,7 +5045,8 @@ html.mobile-client #mobile-gate {
} }
.player-team-stats { .player-team-stats {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
font-variant-numeric: tabular-nums;
} }
.player-team-rows { .player-team-rows {
display: flex; display: flex;
@@ -4555,10 +5054,9 @@ html.mobile-client #mobile-gate {
} }
.player-match-row { .player-match-row {
display: grid; display: grid;
grid-template-columns: minmax(160px, 1.2fr) minmax(220px, 1.4fr) auto; column-gap: 12px;
gap: 12px;
align-items: center; align-items: center;
padding: 10px 14px; padding: 8px 12px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.player-match-row.is-focus { .player-match-row.is-focus {
@@ -4574,24 +5072,48 @@ html.mobile-client #mobile-gate {
min-width: 0; min-width: 0;
} }
.player-match-portrait { .player-match-portrait {
width: 72px; width: 64px;
height: 40px; height: 36px;
object-fit: cover; object-fit: cover;
border-radius: 4px; border-radius: var(--radius-sm);
flex-shrink: 0; flex-shrink: 0;
} }
.player-match-meta { .player-match-meta {
min-width: 0; min-width: 0;
flex: 1 1 auto;
} }
.player-match-name { .player-match-name {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
font-weight: 700; font-weight: 700;
min-width: 0;
}
a.player-match-name-link,
.player-match-name-anon {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
a.player-match-name-link {
color: inherit;
text-decoration: none;
border-bottom: 1px solid transparent;
}
a.player-match-name-link:hover {
color: var(--accent, #5ec8ff);
border-bottom-color: rgba(94, 200, 255, 0.45);
}
.player-match-name-anon {
color: var(--muted, #8fa3bc);
font-weight: 600;
}
.players-enrich-status {
margin: 0;
font-size: 13px;
}
.player-mvp-badge { .player-mvp-badge {
flex-shrink: 0; flex-shrink: 0;
font-size: 10px; font-size: 10px;
@@ -4606,28 +5128,78 @@ html.mobile-client #mobile-gate {
margin-top: 2px; margin-top: 2px;
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.player-match-sub > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-match-badges {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.player-party-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
border-radius: 3px;
padding: 1px 5px;
color: #0b1018;
background: #8fa3bc;
}
.player-party-badge.party-a {
background: #5ec8ff;
}
.player-party-badge.party-b {
background: #e8c878;
}
.player-party-badge.party-c {
background: #9b7ebd;
}
.player-party-badge.party-d {
background: #3dce7a;
} }
.player-match-metrics { .player-match-metrics {
display: flex; align-items: baseline;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 13px; font-size: 13px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.player-match-metrics > span {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 4px;
white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.player-scoreboard-metric-labels > span {
text-align: center;
} }
.player-match-metrics em { .player-match-metrics em {
font-style: normal; display: none;
color: var(--muted);
margin-right: 4px;
font-size: 11px;
} }
.player-match-items { .player-match-items {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(6, 34px);
gap: 4px; gap: 3px;
justify-content: flex-end; justify-content: end;
width: 14.5rem;
box-sizing: border-box;
} }
.player-match-item { .player-match-item {
width: 36px; width: 34px;
height: 28px; height: 26px;
border-radius: 3px; border-radius: 3px;
background: rgba(8, 12, 20, 0.55); background: rgba(8, 12, 20, 0.55);
overflow: hidden; overflow: hidden;
@@ -4643,11 +5215,34 @@ html.mobile-client #mobile-gate {
.player-match-item.empty { .player-match-item.empty {
opacity: 0.35; opacity: 0.35;
} }
@media (max-width: 900px) { @media (max-width: 1100px) {
.player-scoreboard-columns,
.player-match-row {
grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr) 14.5rem;
}
}
@media (max-width: 760px) {
.player-scoreboard-columns {
display: none;
}
.player-match-row { .player-match-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
} }
.player-match-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 10px;
row-gap: 4px;
width: 100%;
justify-self: stretch;
}
.player-match-metrics em {
display: inline;
font-style: normal;
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.player-match-items { .player-match-items {
justify-content: flex-start; justify-content: flex-start;
} }
+20
View File
@@ -0,0 +1,20 @@
# Cloudflare Pages project bindings for Climperor Web.
# Deploy still uses web/deploy_relations.py (direct upload). Bindings below
# are applied via `wrangler pages project ...` / dashboard / provision script.
name = "climperor-relations"
compatibility_date = "2024-11-01"
pages_build_output_dir = "../dist/relations"
[[d1_databases]]
binding = "DB"
database_name = "climperor-users"
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
[[r2_buckets]]
binding = "MATCHES"
bucket_name = "climperor-player-data"
[[queues.producers]]
binding = "SYNC_QUEUE"
queue = "climperor-player-sync"
+223 -10
View File
@@ -6,7 +6,8 @@ Usage:
Hero relations, rankings, streamers, mechanics, items, patches, players Hero relations, rankings, streamers, mechanics, items, patches, players
read-only browser UI. Edit data/*.json directly, then refresh the page. 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/. Player pages: GET /api/players/{account_id}[/{match_id}] from pc/player_pages/;
POST /api/players/enrich and /api/players/ensure-match (local OpenDota backfill).
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,6 +26,7 @@ import webbrowser
from datetime import datetime, timezone from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse from urllib.parse import urlparse
import urllib.parse
import urllib.error import urllib.error
@@ -39,6 +41,7 @@ from shared.paths import (
HERO_PORTRAITS, HERO_PORTRAITS,
ITEM_CAT_ICONS, ITEM_CAT_ICONS,
ITEM_ICONS, ITEM_ICONS,
PC_DIR,
PC_PLAYER_PAGES, PC_PLAYER_PAGES,
RANK_ICONS, RANK_ICONS,
ROLE_ICONS, ROLE_ICONS,
@@ -53,6 +56,25 @@ from shared.relations import DEFAULT_RELATIONS, load_relations
from fetch_streamer_live import probe_streamers from fetch_streamer_live import probe_streamers
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
from steam_auth import (
cookie_header as _steam_cookie_header,
create_session_token as _steam_create_token,
env_secrets as _steam_env_secrets,
fetch_steam_persona as _steam_fetch_persona,
read_cookie as _steam_read_cookie,
steam_id64_to_account_id as _steam_id64_to_account,
steam_login_redirect_url as _steam_login_url,
verify_session_token as _steam_verify_token,
verify_steam_openid as _steam_verify_openid,
)
sys.path.insert(0, str(PC_DIR))
from common import load_config as _load_pc_config # noqa: E402
from player_pages import ( # noqa: E402
enrich_profile_recent as _enrich_profile_recent,
get_profile_for_web as _get_profile_for_web,
ensure_match_detail as _ensure_match_detail,
)
WEB_DIR = WEB_FRONTEND WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json" MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
@@ -853,7 +875,14 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None: def log_message(self, fmt: str, *args) -> None:
print(f"[relations] {self.address_string()} {fmt % args}") print(f"[relations] {self.address_string()} {fmt % args}")
def _send(self, code: int, body: bytes, content_type: str) -> None: def _send(
self,
code: int,
body: bytes,
content_type: str,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -861,13 +890,92 @@ class Handler(BaseHTTPRequestHandler):
self.send_header( self.send_header(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " "default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; " "img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com "
"https://avatars.steamstatic.com; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"script-src 'self'; " "script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com", "media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
) )
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.end_headers() self.end_headers()
self.wfile.write(body) if body:
self.wfile.write(body)
def _redirect(self, location: str, *, set_cookie: str | None = None) -> None:
headers = {"Location": location}
if set_cookie:
headers["Set-Cookie"] = set_cookie
self._send(302, b"", "text/plain; charset=utf-8", extra_headers=headers)
def _request_origin(self) -> str:
host = self.headers.get("Host") or "127.0.0.1:8765"
# Local serve is always http.
return f"http://{host}"
def _serve_steam_auth_get(self, path: str) -> bool:
"""Handle Steam auth GETs. Returns True if handled."""
api_key, session_secret = _steam_env_secrets()
origin = self._request_origin()
if path == "/api/auth/steam":
if not api_key or not session_secret:
self._send(503, b"Steam login not configured", "text/plain; charset=utf-8")
return True
self._redirect(_steam_login_url(origin))
return True
if path == "/api/auth/steam/callback":
if not api_key or not session_secret:
self._redirect(f"{origin}/?auth=unconfigured")
return True
qs = urllib.parse.parse_qs(urlparse(self.path).query)
ok, steamid = _steam_verify_openid(qs)
if not ok or not steamid:
self._redirect(f"{origin}/?auth=denied")
return True
account_id = _steam_id64_to_account(steamid)
if not account_id:
self._redirect(f"{origin}/?auth=error")
return True
persona = _steam_fetch_persona(api_key, steamid)
token = _steam_create_token(
session_secret,
{
"steamid": steamid,
"account_id": account_id,
"personaname": persona.get("personaname"),
"avatar": persona.get("avatar"),
},
)
self._redirect(
f"{origin}/home",
set_cookie=_steam_cookie_header(token, secure=False),
)
return True
if path == "/api/auth/me":
token = _steam_read_cookie(self.headers.get("Cookie"))
session = _steam_verify_token(session_secret, token) if session_secret else None
if not session:
self._json(200, {"authenticated": False})
return True
self._json(
200,
{
"authenticated": True,
"steamid": session.get("steamid"),
"account_id": session.get("account_id"),
"personaname": session.get("personaname"),
"avatar": session.get("avatar"),
},
)
return True
if path == "/api/auth/logout":
self._redirect(
f"{origin}/",
set_cookie=_steam_cookie_header("", clear=True, secure=False),
)
return True
return False
def _send_file(self, fpath: Path, content_type: str) -> None: def _send_file(self, fpath: Path, content_type: str) -> None:
"""Stream a file with optional HTTP Range (needed for HTML5 video seek).""" """Stream a file with optional HTTP Range (needed for HTML5 video seek)."""
@@ -907,7 +1015,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_header( self.send_header(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " "default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; " "img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com "
"https://avatars.steamstatic.com; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"script-src 'self'; " "script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com", "media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
@@ -929,6 +1038,9 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path path = urlparse(self.path).path
if path.startswith("/api/auth/"):
if self._serve_steam_auth_get(path):
return
if path in ("/", "/index.html"): if path in ("/", "/index.html"):
index = WEB_DIR / "index.html" index = WEB_DIR / "index.html"
if not index.is_file(): if not index.is_file():
@@ -962,6 +1074,7 @@ class Handler(BaseHTTPRequestHandler):
if path.startswith("/rank/"): if path.startswith("/rank/"):
key = path[len("/rank/") :] key = path[len("/rank/") :]
allowed = {f"rank_icon_{i}.png" for i in range(1, 9)} allowed = {f"rank_icon_{i}.png" for i in range(1, 9)}
allowed |= {f"rank_star_{i}.png" for i in range(1, 6)}
if key not in allowed: if key not in allowed:
self._json(400, {"error": "bad rank icon"}) self._json(400, {"error": "bad rank icon"})
return return
@@ -1169,6 +1282,7 @@ class Handler(BaseHTTPRequestHandler):
return return
# History SPA fallback: /heroes/axe → index.html (client router). # History SPA fallback: /heroes/axe → index.html (client router).
spa_pages = { spa_pages = {
"home",
"heroes", "heroes",
"rankings", "rankings",
"matches", "matches",
@@ -1195,6 +1309,30 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"error": "bad players path"}) self._json(400, {"error": "bad players path"})
return return
account_id = parts[2] account_id = parts[2]
if account_id == "me" and len(parts) == 3:
api_key, session_secret = _steam_env_secrets()
token = _steam_read_cookie(self.headers.get("Cookie"))
session = (
_steam_verify_token(session_secret, token) if session_secret else None
)
if not session or not session.get("account_id"):
self._json(401, {"authenticated": False})
return
aid = int(session["account_id"])
try:
cfg = _load_pc_config()
# Cache-first (TTL); cold miss syncs once, stale refresh is async.
profile = _get_profile_for_web(cfg, aid, force=False, include_gsi=True)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "enrich failed", "detail": str(e)})
return
if not profile.get("personaname") and session.get("personaname"):
profile["personaname"] = session.get("personaname")
if not profile.get("avatar") and session.get("avatar"):
profile["avatar"] = session.get("avatar")
profile["authenticated"] = True
self._json(200, profile)
return
if not account_id.isdigit(): if not account_id.isdigit():
self._json(400, {"error": "bad account_id"}) self._json(400, {"error": "bad account_id"})
return return
@@ -1231,18 +1369,31 @@ class Handler(BaseHTTPRequestHandler):
return return
self._json(400, {"error": "bad players path"}) self._json(400, {"error": "bad players path"})
def _read_json_body(self) -> dict | None:
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):
return None
return body if isinstance(body, dict) else {}
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path path = urlparse(self.path).path
if path == "/api/mobile-demand": if path == "/api/mobile-demand":
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True}) self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
return return
if path == "/api/auth/logout":
origin = self._request_origin()
self._redirect(
f"{origin}/",
set_cookie=_steam_cookie_header("", clear=True, secure=False),
)
return
if path == "/api/players/publish": if path == "/api/players/publish":
# Local dev: no OSS write; PC already wrote pc/player_pages/. # Local dev: no OSS write; PC already wrote pc/player_pages/.
length = int(self.headers.get("Content-Length", 0) or 0) body = self._read_json_body()
raw = self.rfile.read(length) if length else b"{}" if body is None:
try:
body = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
self._json(400, {"error": "invalid json"}) self._json(400, {"error": "invalid json"})
return return
account_id = body.get("account_id") account_id = body.get("account_id")
@@ -1258,6 +1409,68 @@ class Handler(BaseHTTPRequestHandler):
}, },
) )
return return
if path == "/api/players/enrich":
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
account_id = body.get("account_id")
try:
aid = int(account_id)
except (TypeError, ValueError):
aid = 0
if aid <= 0:
self._json(400, {"error": "account_id required"})
return
include_gsi = body.get("include_gsi")
if include_gsi is None:
include_gsi = True
force = bool(body.get("force", True))
try:
cfg = _load_pc_config()
if force:
profile = _enrich_profile_recent(
cfg, aid, include_gsi=bool(include_gsi)
)
else:
profile = _get_profile_for_web(
cfg, aid, force=False, include_gsi=bool(include_gsi)
)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "enrich failed", "detail": str(e)})
return
if profile.get("error"):
self._json(400, profile)
return
self._json(200, {"ok": True, "profile": profile})
return
if path == "/api/players/ensure-match":
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
try:
aid = int(body.get("account_id"))
mid = int(body.get("match_id"))
except (TypeError, ValueError):
aid, mid = 0, 0
if aid <= 0 or mid <= 0:
self._json(400, {"error": "account_id and match_id required"})
return
try:
cfg = _load_pc_config()
detail, err = _ensure_match_detail(cfg, aid, mid)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "ensure-match failed", "detail": str(e)})
return
if err or not detail:
self._json(
404 if err else 500,
{"error": err or "unknown", "account_id": aid, "match_id": mid},
)
return
self._json(200, {"ok": True, "match": detail})
return
self.send_error(404) self.send_error(404)
+179
View File
@@ -0,0 +1,179 @@
"""Steam OpenID login + signed cookie sessions for local serve_relations.
Env: STEAM_API_KEY, SESSION_SECRET.
Mirrors web/frontend/functions/api/auth/* Pages Functions.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
COOKIE_NAME = "climperor_steam"
SESSION_DAYS = 30
STEAM_OPENID = "https://steamcommunity.com/openid/login"
STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/"
UA = "climperor-steam-auth"
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def steam_id64_to_account_id(steam_id64: str | int) -> int | None:
try:
n = int(steam_id64)
except (TypeError, ValueError):
return None
account = n - 76561197960265728
return account if account > 0 else None
def parse_steam_id_from_claimed(claimed_id: str | None) -> str | None:
if not claimed_id or not claimed_id.startswith(STEAM_ID_PREFIX):
return None
sid = claimed_id[len(STEAM_ID_PREFIX) :].rstrip("/")
return sid if sid.isdigit() and len(sid) == 17 else None
def create_session_token(secret: str, payload: dict) -> str:
body = dict(payload)
body["exp"] = int(time.time()) + SESSION_DAYS * 86400
raw = _b64url_encode(json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode())
sig = _b64url_encode(hmac.new(secret.encode(), raw.encode(), hashlib.sha256).digest())
return f"{raw}.{sig}"
def verify_session_token(secret: str, token: str | None) -> dict | None:
if not secret or not token or "." not in token:
return None
raw, sig = token.split(".", 1)
expect = _b64url_encode(hmac.new(secret.encode(), raw.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(sig, expect):
return None
try:
data = json.loads(_b64url_decode(raw).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None
if not isinstance(data, dict):
return None
if int(data.get("exp") or 0) < int(time.time()):
return None
if not data.get("steamid") or not data.get("account_id"):
return None
return data
def read_cookie(cookie_header: str | None, name: str = COOKIE_NAME) -> str | None:
if not cookie_header:
return None
for part in cookie_header.split(";"):
part = part.strip()
if part.startswith(name + "="):
return urllib.parse.unquote(part[len(name) + 1 :])
return None
def cookie_header(token: str, *, clear: bool = False, secure: bool = False) -> str:
if clear:
base = f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
else:
max_age = SESSION_DAYS * 86400
base = (
f"{COOKIE_NAME}={urllib.parse.quote(token)}; Path=/; HttpOnly; "
f"SameSite=Lax; Max-Age={max_age}"
)
if secure:
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax")
return base
def steam_login_redirect_url(origin: str) -> str:
return_to = f"{origin.rstrip('/')}/api/auth/steam/callback"
params = {
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": return_to,
"openid.realm": origin.rstrip("/"),
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
}
return f"{STEAM_OPENID}?{urllib.parse.urlencode(params)}"
def verify_steam_openid(query: dict[str, list[str] | str]) -> tuple[bool, str | None]:
def one(key: str) -> str | None:
v = query.get(key)
if isinstance(v, list):
return v[0] if v else None
return v
if one("openid.mode") != "id_res":
return False, None
steamid = parse_steam_id_from_claimed(one("openid.claimed_id"))
if not steamid:
return False, None
body: dict[str, str] = {}
for k, v in query.items():
if not k.startswith("openid."):
continue
body[k] = v[0] if isinstance(v, list) else str(v)
body["openid.mode"] = "check_authentication"
data = urllib.parse.urlencode(body).encode()
req = urllib.request.Request(
STEAM_OPENID,
data=data,
headers={"User-Agent": UA, "Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
text = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError):
return False, None
normalized = "".join(text.split()).lower()
if "is_valid:true" not in normalized:
return False, None
return True, steamid
def fetch_steam_persona(api_key: str, steamid: str) -> dict[str, Any]:
if not api_key:
return {"personaname": None, "avatar": None}
url = (
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
f"?key={urllib.parse.quote(api_key)}&steamids={urllib.parse.quote(steamid)}"
)
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode())
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return {"personaname": None, "avatar": None}
players = (((data or {}).get("response") or {}).get("players")) or []
if not players:
return {"personaname": None, "avatar": None}
p = players[0]
return {
"personaname": p.get("personaname") or None,
"avatar": p.get("avatarfull") or p.get("avatarmedium") or p.get("avatar"),
}
def env_secrets() -> tuple[str, str]:
key = (os.environ.get("STEAM_API_KEY") or "").strip()
secret = (os.environ.get("SESSION_SECRET") or "").strip()
return key, secret