Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282029706b | ||
|
|
820c3fb1f1 | ||
|
|
cafd0651b1 | ||
|
|
41d272ba29 | ||
|
|
f5b7011c45 |
@@ -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` 只比对列表与本地 details,stdout JSON) |
|
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
||||||
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
|
| `web/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-op;Steam 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 Functions:Steam 登录 / 回调 / me / logout;Cookie `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,10 +98,13 @@ 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`:校验后投递 Queue(D1/R2 由 Worker 写入);可选 `PLAYER_PAGES_PUBLISH_SECRET`;**不**进主 `data.json` / recommend |
|
||||||
|
| `web/frontend/functions/api/players/me.js` | `GET /api/players/me`:登录本人 D1 档案;过期则 Queue 刷新并标 `stale` |
|
||||||
|
| `web/frontend/functions/api/players/ensure-match.js` | `POST /api/players/ensure-match`:本人或 `public_share` 懒加载比赛详情(OpenDota → 规范化 → R2);静态路由须优先于 `[account_id]`,否则 POST 会 405 |
|
||||||
|
| `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`;合并 `item_alias_overrides.json` → `aliases`) |
|
||||||
| `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
| `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
||||||
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%` 走 `values_shard`/`values_scepter`) |
|
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%` 走 `values_shard`/`values_scepter`) |
|
||||||
| `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
| `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
||||||
@@ -120,17 +126,18 @@ climperor/
|
|||||||
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
||||||
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
||||||
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
||||||
|
| `web/data/item_alias_overrides.json` | 装备中文简称/俗称(合并进 `items_meta.aliases`;物品页搜索与悬停;勿与 `name_loc` 重复) |
|
||||||
| `web/data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
| `web/data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
||||||
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
||||||
| `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 +149,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 +170,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_GAME:player_pages 轮询 OpenDota → pc/player_pages/(可选 publish → OSS players/)
|
→ POST_GAME:player_pages 轮询 OpenDota → pc/player_pages/(可选 publish → Queue → D1/R2)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术约束(修改前必读)
|
## 技术约束(修改前必读)
|
||||||
@@ -179,7 +186,9 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
|
|||||||
- **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。
|
- **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。
|
||||||
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
|
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
|
||||||
- **上分帝 Web 定时刷新**:Gitea Actions(self-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
|
- **上分帝 Web 定时刷新**:Gitea Actions(self-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
|
||||||
- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` 时由边缘 ingest 写 OSS `players/`(PC **不**内置 OSS 密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 OSS;**禁止**塞进主 `data.json`,**禁止**进 recommend。需玩家开启「公开比赛数据」。
|
- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` 时 POST 入队由 Worker 写 D1/R2(PC **不**内置云密钥)。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。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,264 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
||||||
|
- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。
|
||||||
|
|
||||||
|
## [0.6.57] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 物品中文简称:`item_alias_overrides.json`(斯嘉蒂之眼→冰眼,怪蛇之息→蛇矛);物品页可搜简称,悬停/详情显示。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 小小「怕的装备」:手工改为含怪蛇之息(`hydras_breath`),去掉清莲宝珠。
|
||||||
|
|
||||||
|
## [0.6.56] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 生产「比赛详情」:补齐 `POST /api/players/ensure-match`(此前落入动态路由仅支持 GET → HTTP 405);详情规范化写入 R2,与本机 `player_pages` 同形。
|
||||||
|
|
||||||
|
## [0.6.55] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家同步:OpenDota 空/残缺响应不再覆盖已有近 20 场、常用英雄、队友与段位。
|
||||||
|
- 样式:`style.css` 缓存改为 60s,并 bump 版本戳,避免同 `?v=` 内容变更仍命中旧 CSS 导致「我」页双栏错乱。
|
||||||
|
- 补齐 OSS `ui-icon`(含 wordmark),避免顶栏 Logo 404。
|
||||||
|
|
||||||
|
## [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/15(1.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 +275,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
|
||||||
|
|||||||
@@ -267,10 +267,10 @@ components:
|
|||||||
| 档位 | 宽度 | 页面 |
|
| 档位 | 宽度 | 页面 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| full | 100% | 英雄网格 |
|
| full | 100% | 英雄网格 |
|
||||||
| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),sticky + 视口内自滚动 | 版本 |
|
| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),宽度随可用侧栏约 280–420px,sticky + 视口内自滚动 | 版本 |
|
||||||
| 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` 闪「加载中…」。Cloudflare Worker 在 OpenDota 空/残缺时不得覆盖已有近场/英雄/队友/段位。
|
||||||
|
|
||||||
### 导航
|
### 导航
|
||||||
|
|
||||||
- **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 提亮。放在右侧工具区,不绝对悬浮到视口角。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
print(
|
||||||
f"[player_pages] match {mid} has no account {aid} — skip",
|
f"[player_pages] match {mid}: attached account via GSI hero",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return
|
break
|
||||||
|
print(
|
||||||
|
f"[player_pages] match {mid} has no account {aid} — "
|
||||||
|
"will still try enrich from GSI later",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 []),
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Upload web/assets/ui_icons/* to OSS ui-icon/ (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import oss2
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "web" / "assets" / "ui_icons"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_ID"
|
||||||
|
)
|
||||||
|
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_SECRET"
|
||||||
|
)
|
||||||
|
if not ak or not sk:
|
||||||
|
print("missing OSS credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
bucket = oss2.Bucket(oss2.Auth(ak, sk), "https://oss-cn-shanghai.aliyuncs.com", "climperor")
|
||||||
|
n = 0
|
||||||
|
for f in sorted(SRC.iterdir()):
|
||||||
|
if not f.is_file() or f.name.startswith("."):
|
||||||
|
continue
|
||||||
|
key = f"ui-icon/{f.name}"
|
||||||
|
ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
||||||
|
bucket.put_object_from_file(
|
||||||
|
key,
|
||||||
|
str(f),
|
||||||
|
headers={"Content-Type": ctype, "Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
print(f"uploaded {n} ui-icon files", flush=True)
|
||||||
|
for name in ("dota2_logo_wordmark.png", "dota2_logo.png"):
|
||||||
|
print(f" {name} exists={bucket.object_exists('ui-icon/' + name)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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`. Auth secrets on Pages (prod+preview):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python web/cloudflare/_stage_steam_auth.py # via keyzoo refining/steam
|
||||||
|
python web/cloudflare/put_pages_secrets.py # via keyzoo refining/cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional `PLAYER_PAGES_PUBLISH_SECRET`. Worker also needs `STEAM_API_KEY`
|
||||||
|
(`python web/cloudflare/put_worker_secrets.py` after `_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
|
||||||
|
```
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dump Pages env_var names/types and latest deployment id (no secret values)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
|
||||||
|
|
||||||
|
def get(path: str) -> dict:
|
||||||
|
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{path}",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = get(f"/accounts/{ACCT}/pages/projects/{PROJ}")["result"]
|
||||||
|
dc = p.get("deployment_configs") or {}
|
||||||
|
for env in ("production", "preview"):
|
||||||
|
ev = (dc.get(env) or {}).get("env_vars") or {}
|
||||||
|
print(env, "keys", sorted(ev.keys()))
|
||||||
|
for k, v in sorted(ev.items()):
|
||||||
|
print(
|
||||||
|
" ",
|
||||||
|
k,
|
||||||
|
"type=",
|
||||||
|
(v or {}).get("type"),
|
||||||
|
"has_value=",
|
||||||
|
"value" in (v or {}),
|
||||||
|
)
|
||||||
|
deps = get(f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments?per_page=2")[
|
||||||
|
"result"
|
||||||
|
]
|
||||||
|
for d0 in deps or []:
|
||||||
|
print(
|
||||||
|
"deployment",
|
||||||
|
d0.get("id"),
|
||||||
|
d0.get("environment"),
|
||||||
|
d0.get("created_on"),
|
||||||
|
d0.get("url"),
|
||||||
|
)
|
||||||
|
if d0.get("id"):
|
||||||
|
det = get(
|
||||||
|
f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments/{d0['id']}"
|
||||||
|
)["result"]
|
||||||
|
# Some APIs nest env under build_config / env_vars
|
||||||
|
for k in sorted(det.keys()):
|
||||||
|
if "env" in k.lower() or k in ("aliases", "is_skipped", "production_branch"):
|
||||||
|
val = det.get(k)
|
||||||
|
s = json.dumps(val, ensure_ascii=False) if not isinstance(val, str) else val
|
||||||
|
print(f" {k}: {s[:240]}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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())
|
||||||
@@ -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__")
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Stage Steam API key + session secret for Pages/Worker secret put (no echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REFRESH = Path(__file__).resolve().parents[1] / ".refresh"
|
||||||
|
STEAM_TMP = REFRESH / "steam_key.tmp"
|
||||||
|
SESSION_TMP = REFRESH / "session_secret.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, value: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(value, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
steam = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
session = (
|
||||||
|
os.environ.get("SESSION_SECRET")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not steam or not session:
|
||||||
|
print("missing steam key or session secret", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
_write(STEAM_TMP, steam)
|
||||||
|
_write(SESSION_TMP, session)
|
||||||
|
print(
|
||||||
|
f"staged {STEAM_TMP.name} len={len(steam)}; "
|
||||||
|
f"{SESSION_TMP.name} len={len(session)}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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=COALESCE(excluded.rank_tier, player_profiles.rank_tier),
|
||||||
|
leaderboard_rank=COALESCE(excluded.leaderboard_rank, player_profiles.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) {
|
||||||
|
// Empty OpenDota heroes must not wipe a good cache.
|
||||||
|
if (!Array.isArray(heroes) || heroes.length === 0) return;
|
||||||
|
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) {
|
||||||
|
// Empty OpenDota peers must not wipe a good cache.
|
||||||
|
if (!Array.isArray(peers) || peers.length === 0) return;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
normalizeMatch,
|
||||||
|
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 existing = await loadPlayerBundle(env.DB, accountId);
|
||||||
|
const existingRecentN = Array.isArray(existing && existing.recent)
|
||||||
|
? existing.recent.length
|
||||||
|
: 0;
|
||||||
|
const existingR20 =
|
||||||
|
existing && existing.recent_20 ? Number(existing.recent_20.sample) || 0 : 0;
|
||||||
|
const odRecentOk = Array.isArray(recentRaw) && recentRaw.length > 0;
|
||||||
|
|
||||||
|
const fetched = utcNow();
|
||||||
|
const availability = mergeAvailability({
|
||||||
|
opendotaRecentN: odRecentOk ? recentRaw.length : 0,
|
||||||
|
career,
|
||||||
|
steamHistoryStatus: steamStatus,
|
||||||
|
fetchedAt: fetched,
|
||||||
|
});
|
||||||
|
if (profileBlock.fh_unavailable && availability.status === "unknown") {
|
||||||
|
availability.status = "private";
|
||||||
|
availability.note = "未公开比赛数据";
|
||||||
|
}
|
||||||
|
// Cold account + Steam public + empty OpenDota → rate-limit; retry queue.
|
||||||
|
if (
|
||||||
|
!odRecentOk &&
|
||||||
|
!career &&
|
||||||
|
existingRecentN === 0 &&
|
||||||
|
!(existing && existing.career && existing.career.games > 0) &&
|
||||||
|
steamStatus === 1
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
// Partial OpenDota (career ok, recent empty): keep prior freshness so /me
|
||||||
|
// stays soft-stale and do not pretend the row is fully refreshed.
|
||||||
|
if (!odRecentOk && (existingR20 > 0 || existingRecentN > 0)) {
|
||||||
|
profile.enriched_at =
|
||||||
|
(existing && existing.enriched_at) || profile.enriched_at;
|
||||||
|
if (existing && existing.availability && existing.availability.fetched_at) {
|
||||||
|
availability.fetched_at = existing.availability.fetched_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertProfile(env.DB, accountId, profile);
|
||||||
|
if (career) await upsertStats(env.DB, accountId, "career", career);
|
||||||
|
const recentAgg = aggregateFromRows(recent20, 20);
|
||||||
|
if ((recentAgg.sample || 0) > 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
await upsertMatches(env.DB, accountId, recent20);
|
||||||
|
} else if (existingR20 <= 0 && existingRecentN <= 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
}
|
||||||
|
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
|
||||||
|
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||||
|
await replacePeers(env.DB, accountId, peerRows);
|
||||||
|
|
||||||
|
// Optional: store published/ensured match detail (normalized) into R2.
|
||||||
|
if (
|
||||||
|
(msg.kind === "publish_match" || msg.kind === "ensure_match") &&
|
||||||
|
msg.match_id &&
|
||||||
|
env.MATCHES
|
||||||
|
) {
|
||||||
|
const matchId = Number(msg.match_id);
|
||||||
|
const match = await odFetch(`/matches/${matchId}`, env);
|
||||||
|
const detail =
|
||||||
|
match && Array.isArray(match.players)
|
||||||
|
? normalizeMatch(match, heroMap, accountId)
|
||||||
|
: null;
|
||||||
|
if (detail) {
|
||||||
|
const key = `matches/${matchId}.json`;
|
||||||
|
await env.MATCHES.put(key, JSON.stringify(detail), {
|
||||||
|
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,247 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asInt(v, fallback = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemIds(player) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const iid = asInt(player[`item_${i}`], 0);
|
||||||
|
if (iid > 0) out.push(iid);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kda(kills, deaths, assists) {
|
||||||
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mvpScore(p) {
|
||||||
|
const k = asInt(p.kills);
|
||||||
|
const d = asInt(p.deaths);
|
||||||
|
const a = asInt(p.assists);
|
||||||
|
const dmg = asInt(p.hero_damage);
|
||||||
|
const nw = asInt(p.net_worth);
|
||||||
|
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / Math.max(d, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Climperor match-detail JSON (same shape as pc/player_pages.normalize_match). */
|
||||||
|
export function normalizeMatch(match, heroMap, focusAccountId = null) {
|
||||||
|
const playersRaw = match && match.players;
|
||||||
|
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
|
||||||
|
const matchId = asInt(match.match_id, 0);
|
||||||
|
if (matchId <= 0) return null;
|
||||||
|
|
||||||
|
const radiantWin = !!match.radiant_win;
|
||||||
|
const duration = asInt(match.duration);
|
||||||
|
let startTime = null;
|
||||||
|
if (match.start_time != null) {
|
||||||
|
const t = asInt(match.start_time, NaN);
|
||||||
|
startTime = Number.isFinite(t) ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = asInt(p.player_slot);
|
||||||
|
const isRadiant = slot < 128;
|
||||||
|
const side = isRadiant ? 0 : 1;
|
||||||
|
const kills = asInt(p.kills);
|
||||||
|
const deaths = asInt(p.deaths);
|
||||||
|
const assists = asInt(p.assists);
|
||||||
|
const heroDamage = asInt(p.hero_damage);
|
||||||
|
let netWorth = asInt(p.net_worth);
|
||||||
|
if (netWorth <= 0) netWorth = asInt(p.gold) + asInt(p.gold_spent);
|
||||||
|
teamKills[side] += kills;
|
||||||
|
teamNw[side] += netWorth;
|
||||||
|
teamDmg[side] += heroDamage;
|
||||||
|
|
||||||
|
const heroId = asInt(p.hero_id);
|
||||||
|
const hero = (heroMap && heroMap.get(heroId)) || {};
|
||||||
|
let accountId = null;
|
||||||
|
if (p.account_id != null) {
|
||||||
|
const a = asInt(p.account_id, NaN);
|
||||||
|
accountId = Number.isFinite(a) ? a : null;
|
||||||
|
}
|
||||||
|
let personaname = p.personaname;
|
||||||
|
if (typeof personaname === "string") {
|
||||||
|
personaname = personaname.trim() || null;
|
||||||
|
} else {
|
||||||
|
personaname = null;
|
||||||
|
}
|
||||||
|
let partyId = null;
|
||||||
|
if (p.party_id != null) {
|
||||||
|
const pid = asInt(p.party_id, NaN);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) partyId = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
slim.push({
|
||||||
|
account_id: accountId,
|
||||||
|
personaname,
|
||||||
|
hero_id: heroId,
|
||||||
|
hero_key: hero.key || null,
|
||||||
|
hero_name_loc: hero.name_loc || hero.key || null,
|
||||||
|
level: asInt(p.level),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: kda(kills, deaths, assists),
|
||||||
|
hero_damage: heroDamage,
|
||||||
|
net_worth: netWorth,
|
||||||
|
party_id: partyId,
|
||||||
|
party_label: null,
|
||||||
|
items: itemIds(p),
|
||||||
|
is_radiant: isRadiant,
|
||||||
|
won: isRadiant ? radiantWin : !radiantWin,
|
||||||
|
_mvp: mvpScore(p),
|
||||||
|
_side: side,
|
||||||
|
_slot: slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slim.length < 2) return null;
|
||||||
|
|
||||||
|
const partyCounts = new Map();
|
||||||
|
for (const p of slim) {
|
||||||
|
if (typeof p.party_id === "number" && p.party_id > 0) {
|
||||||
|
partyCounts.set(p.party_id, (partyCounts.get(p.party_id) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const partyLabels = new Map();
|
||||||
|
for (const [pid, n] of [...partyCounts.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
if (n >= 2) partyLabels.set(pid, String.fromCharCode(65 + partyLabels.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
p.party_label =
|
||||||
|
typeof p.party_id === "number" ? partyLabels.get(p.party_id) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mvp = slim.reduce((best, p) => (p._mvp > best._mvp ? p : best), slim[0]);
|
||||||
|
const mvpAccount = mvp.account_id;
|
||||||
|
const mvpSlot = mvp._slot;
|
||||||
|
for (const p of slim) {
|
||||||
|
p.is_mvp =
|
||||||
|
mvpAccount != null ? p.account_id === mvpAccount : p._slot === mvpSlot;
|
||||||
|
delete p._mvp;
|
||||||
|
delete p._side;
|
||||||
|
delete p._slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
match_id: matchId,
|
||||||
|
start_time: startTime,
|
||||||
|
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: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||||
|
source: "opendota",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Put STEAM_API_KEY + SESSION_SECRET on climperor-relations Pages (prod+preview).
|
||||||
|
|
||||||
|
Reads secrets from env or staged temp files under web/.refresh/ (no secret echo).
|
||||||
|
Prefers `wrangler pages secret put`; falls back to Pages project PATCH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
FRONTEND = HERE.parents[0] / "frontend"
|
||||||
|
REFRESH = Path(__file__).resolve().parents[1] / ".refresh"
|
||||||
|
STEAM_TMP = REFRESH / "steam_key.tmp"
|
||||||
|
SESSION_TMP = REFRESH / "session_secret.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
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 _secret(name: str, env_keys: list[str], staged: Path) -> str:
|
||||||
|
for k in env_keys:
|
||||||
|
v = (os.environ.get(k) or "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
if staged.is_file():
|
||||||
|
v = staged.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
staged.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
raise SystemExit(f"missing {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def cf(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(
|
||||||
|
f"https://api.cloudflare.com/client/v4{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-put-pages-secrets",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
raise SystemExit(f"CF API {e.code}: {raw[:500]}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _wrangler_put(name: str, value: str) -> None:
|
||||||
|
email, key = _creds()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
|
||||||
|
env["CI"] = "true"
|
||||||
|
print(f"wrangler pages secret put {name} …", flush=True)
|
||||||
|
cmd = (
|
||||||
|
f'npx --yes wrangler@3 pages secret put {name} '
|
||||||
|
f'--project-name {PROJ}'
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
input=value + "\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=env,
|
||||||
|
cwd=str(FRONTEND if FRONTEND.is_dir() else HERE),
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr or proc.stdout, file=sys.stderr)
|
||||||
|
raise SystemExit(proc.returncode)
|
||||||
|
tail = (proc.stdout or "").strip().splitlines()
|
||||||
|
if tail:
|
||||||
|
print(tail[-1], flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
steam = _secret(
|
||||||
|
"STEAM_API_KEY",
|
||||||
|
["STEAM_API_KEY", "KEYZOO_ASSET_SECRET_WEB_API_KEY"],
|
||||||
|
STEAM_TMP,
|
||||||
|
)
|
||||||
|
session = _secret(
|
||||||
|
"SESSION_SECRET",
|
||||||
|
["SESSION_SECRET", "KEYZOO_ASSET_SECRET_SESSION_SECRET"],
|
||||||
|
SESSION_TMP,
|
||||||
|
)
|
||||||
|
# Official path: wrangler pages secret put (production).
|
||||||
|
_wrangler_put("STEAM_API_KEY", steam)
|
||||||
|
_wrangler_put("SESSION_SECRET", session)
|
||||||
|
|
||||||
|
# Also patch preview so *.pages.dev preview deploys work.
|
||||||
|
proj = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}")
|
||||||
|
result = proj.get("result") or {}
|
||||||
|
dc = result.get("deployment_configs") or {}
|
||||||
|
secrets = {
|
||||||
|
"STEAM_API_KEY": {"type": "secret_text", "value": steam},
|
||||||
|
"SESSION_SECRET": {"type": "secret_text", "value": session},
|
||||||
|
}
|
||||||
|
preview_cfg = dc.get("preview") or {}
|
||||||
|
preview_entry: dict = {"env_vars": secrets}
|
||||||
|
wch = preview_cfg.get("wrangler_config_hash")
|
||||||
|
if wch:
|
||||||
|
preview_entry["wrangler_config_hash"] = wch
|
||||||
|
out = cf(
|
||||||
|
"PATCH",
|
||||||
|
f"/accounts/{ACCT}/pages/projects/{PROJ}",
|
||||||
|
{"deployment_configs": {"preview": preview_entry}},
|
||||||
|
)
|
||||||
|
if not out.get("success"):
|
||||||
|
print("preview patch soft-fail", flush=True)
|
||||||
|
|
||||||
|
check = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}")
|
||||||
|
cdc = (check.get("result") or {}).get("deployment_configs") or {}
|
||||||
|
for env_name in ("production", "preview"):
|
||||||
|
ev = ((cdc.get(env_name) or {}).get("env_vars") or {})
|
||||||
|
names = sorted(ev.keys())
|
||||||
|
types = {
|
||||||
|
k: (ev[k] or {}).get("type") for k in ("STEAM_API_KEY", "SESSION_SECRET")
|
||||||
|
}
|
||||||
|
print(f"{env_name}: env_vars={names} secret_types={types}", flush=True)
|
||||||
|
print("ok", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -91,6 +91,12 @@
|
|||||||
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
||||||
],
|
],
|
||||||
"remove": []
|
"remove": []
|
||||||
|
},
|
||||||
|
"tiny": {
|
||||||
|
"add": [
|
||||||
|
{ "item": "hydras_breath", "reason": "瘴毒按最大生命百分比持续消耗高血量小小", "tags": [] }
|
||||||
|
],
|
||||||
|
"remove": ["lotus_orb"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "rules+valve+opendota",
|
"source": "rules+valve+opendota",
|
||||||
"attribution": "derived from items_meta.json + hero_abilities.json",
|
"attribution": "derived from items_meta.json + hero_abilities.json",
|
||||||
"fetched_at": "2026-07-28T16:06:06.680383+00:00",
|
"fetched_at": "2026-07-31T19:14:17.668808+00:00",
|
||||||
"top_n": 8,
|
"top_n": 8,
|
||||||
"heroes": 127,
|
"heroes": 127,
|
||||||
"overrides": "data/hero_fear_overrides.json",
|
"overrides": "data/hero_fear_overrides.json",
|
||||||
@@ -1485,21 +1485,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tiny": [
|
"tiny": [
|
||||||
{
|
|
||||||
"item": "lotus_orb",
|
|
||||||
"name_loc": "清莲宝珠",
|
|
||||||
"tags": [
|
|
||||||
"spell_reflect"
|
|
||||||
],
|
|
||||||
"reason": "反射点目标技能",
|
|
||||||
"stats": {
|
|
||||||
"games": 541,
|
|
||||||
"purchase_rate": 0.164738,
|
|
||||||
"win_rate": 0.57671,
|
|
||||||
"purchase_lift": -0.013412,
|
|
||||||
"win_delta": 0.00486
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"item": "black_king_bar",
|
"item": "black_king_bar",
|
||||||
"name_loc": "黑皇杖",
|
"name_loc": "黑皇杖",
|
||||||
@@ -1515,6 +1500,12 @@
|
|||||||
"win_delta": 0.00552
|
"win_delta": 0.00552
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"item": "hydras_breath",
|
||||||
|
"name_loc": "怪蛇之息",
|
||||||
|
"tags": [],
|
||||||
|
"reason": "瘴毒按最大生命百分比持续消耗高血量小小"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"item": "sphere",
|
"item": "sphere",
|
||||||
"name_loc": "林肯法球",
|
"name_loc": "林肯法球",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"note": "Manual Chinese short names / nicknames merged into items_meta.aliases (search + tooltips). Do not duplicate name_loc."
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"skadi": ["冰眼"],
|
||||||
|
"hydras_breath": ["蛇矛"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "valve+opendota",
|
"source": "valve+opendota",
|
||||||
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
||||||
"fetched_at": "2026-07-31T01:54:18.841099+00:00",
|
"fetched_at": "2026-07-31T19:15:55.325814+00:00",
|
||||||
"tag_order": [
|
"tag_order": [
|
||||||
"basic_dispel",
|
"basic_dispel",
|
||||||
"strong_dispel",
|
"strong_dispel",
|
||||||
@@ -876,7 +876,10 @@
|
|||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
"tags": []
|
"tags": [],
|
||||||
|
"aliases": [
|
||||||
|
"冰眼"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"162": {
|
"162": {
|
||||||
"id": 162,
|
"id": 162,
|
||||||
@@ -2087,7 +2090,10 @@
|
|||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
"tags": []
|
"tags": [],
|
||||||
|
"aliases": [
|
||||||
|
"蛇矛"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -642,6 +642,60 @@
|
|||||||
"video_aspect": "1280/720",
|
"video_aspect": "1280/720",
|
||||||
"video_title": "暴走就是那么简单!哪里有人点哪里!",
|
"video_title": "暴走就是那么简单!哪里有人点哪里!",
|
||||||
"profile_fetched_at": "2026-07-30T12:23:00+00:00"
|
"profile_fetched_at": "2026-07-30T12:23:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "baixi",
|
||||||
|
"platform": "douyin",
|
||||||
|
"live_url": "https://live.douyin.com/433962392415",
|
||||||
|
"profile_url": "https://v.douyin.com/eiq1FHV8gdI/",
|
||||||
|
"tagline": "百戏大王",
|
||||||
|
"heroes": [
|
||||||
|
"ringmaster"
|
||||||
|
],
|
||||||
|
"nickname": "百戏大王",
|
||||||
|
"unique_id": "meng289949554",
|
||||||
|
"signature": "不需要你教我玩百戏。",
|
||||||
|
"following_count": 5351,
|
||||||
|
"follower_count": 559,
|
||||||
|
"total_favorited": 59,
|
||||||
|
"avatar": "streamer_avatars/baixi.jpg",
|
||||||
|
"profile_fetched_at": "2026-08-01T00:00:00+00:00",
|
||||||
|
"is_live": true,
|
||||||
|
"live_probed_at": "2026-08-01T00:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xianzhi",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/Ye1g2PqAbyQ/",
|
||||||
|
"tagline": "先知",
|
||||||
|
"heroes": [
|
||||||
|
"furion"
|
||||||
|
],
|
||||||
|
"nickname": "小小要单排",
|
||||||
|
"unique_id": "rtdfke38051",
|
||||||
|
"signature": "我也有喜欢的人了",
|
||||||
|
"following_count": 1211,
|
||||||
|
"follower_count": 65000,
|
||||||
|
"total_favorited": 78000,
|
||||||
|
"avatar": "streamer_avatars/xianzhi.jpg",
|
||||||
|
"profile_fetched_at": "2026-08-01T00:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bingnv",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/0f4UYn_dfig/",
|
||||||
|
"tagline": "冰女",
|
||||||
|
"heroes": [
|
||||||
|
"crystal_maiden"
|
||||||
|
],
|
||||||
|
"nickname": "烤烤你的鸭",
|
||||||
|
"unique_id": "213145729",
|
||||||
|
"signature": "9000分国服百强辅助,在线讲解所有辅助知识 直播不定时中单",
|
||||||
|
"following_count": 156,
|
||||||
|
"follower_count": 1599,
|
||||||
|
"total_favorited": 410,
|
||||||
|
"avatar": "streamer_avatars/bingnv.jpg",
|
||||||
|
"profile_fetched_at": "2026-08-01T00:00:00+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.57"
|
||||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
|
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
|
||||||
|
|
||||||
|
Also merges Chinese nicknames from data/item_alias_overrides.json → aliases.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- OpenDota items.json (structure, EN ability text)
|
- OpenDota items.json (structure, EN ability text)
|
||||||
- Valve itemlist / itemdata (schinese names + descriptions)
|
- Valve itemlist / itemdata (schinese names + descriptions)
|
||||||
@@ -43,6 +45,7 @@ ICON_URL = (
|
|||||||
)
|
)
|
||||||
OUT = DATA / "items_meta.json"
|
OUT = DATA / "items_meta.json"
|
||||||
OVERRIDES = DATA / "item_tag_overrides.json"
|
OVERRIDES = DATA / "item_tag_overrides.json"
|
||||||
|
ALIAS_OVERRIDES = DATA / "item_alias_overrides.json"
|
||||||
|
|
||||||
MIN_CREATED_COST = 1400
|
MIN_CREATED_COST = 1400
|
||||||
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
|
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
|
||||||
@@ -262,6 +265,38 @@ def apply_overrides(key: str, tags: list[str], overrides: dict[str, dict]) -> li
|
|||||||
return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER)
|
return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def load_alias_overrides() -> dict[str, list[str]]:
|
||||||
|
"""key → Chinese nicknames (冰眼 / 蛇矛); empty if file missing."""
|
||||||
|
if not ALIAS_OVERRIDES.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(ALIAS_OVERRIDES.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for key, aliases in (raw.get("items") or {}).items():
|
||||||
|
if not isinstance(aliases, list):
|
||||||
|
continue
|
||||||
|
clean = [str(a).strip() for a in aliases if str(a).strip()]
|
||||||
|
if clean:
|
||||||
|
out[str(key)] = list(dict.fromkeys(clean))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_alias_overrides(items_out: dict[str, dict], aliases: dict[str, list[str]]) -> None:
|
||||||
|
for row in items_out.values():
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
key = str(row.get("key") or "")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
nick = aliases.get(key)
|
||||||
|
if nick:
|
||||||
|
row["aliases"] = list(nick)
|
||||||
|
else:
|
||||||
|
row.pop("aliases", None)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--delay", type=float, default=0.15)
|
ap.add_argument("--delay", type=float, default=0.15)
|
||||||
@@ -301,6 +336,7 @@ def main() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
overrides = load_overrides()
|
overrides = load_overrides()
|
||||||
|
alias_overrides = load_alias_overrides()
|
||||||
items_out: dict[str, dict] = {}
|
items_out: dict[str, dict] = {}
|
||||||
pending = []
|
pending = []
|
||||||
for iid in sorted(candidates):
|
for iid in sorted(candidates):
|
||||||
@@ -408,6 +444,7 @@ def main() -> None:
|
|||||||
}
|
}
|
||||||
write_json_atomic(args.out, payload)
|
write_json_atomic(args.out, payload)
|
||||||
|
|
||||||
|
apply_alias_overrides(items_out, alias_overrides)
|
||||||
payload = {
|
payload = {
|
||||||
"meta": {
|
"meta": {
|
||||||
"source": "valve+opendota",
|
"source": "valve+opendota",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
Cache-Control: public, max-age=60, must-revalidate
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
/style.css
|
/style.css
|
||||||
Cache-Control: public, max-age=300, must-revalidate
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
/fonts/*
|
/fonts/*
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|||||||
@@ -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.57";
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,40 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/players/:account_id/:match_id — match detail from R2 (authz via D1).
|
||||||
|
* If R2 missing/raw, fetch+normalize from OpenDota (same shape as local ensure-match).
|
||||||
|
*/
|
||||||
|
import { sessionFromRequest } from "../../auth/_steam_common.js";
|
||||||
|
import { jsonResponse } from "../_db.js";
|
||||||
|
import { ensureNormalizedMatch } from "../_match.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);
|
||||||
|
|
||||||
|
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
|
||||||
|
const { match, error } = await ensureNormalizedMatch(env, accountId, matchId);
|
||||||
|
if (!match) {
|
||||||
|
return jsonResponse({ error: error || "match not found" }, 404);
|
||||||
|
}
|
||||||
|
return jsonResponse(match);
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: "match get failed", detail: String((e && e.message) || e) },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/** OpenDota match → Climperor detail JSON (+ R2 helpers). */
|
||||||
|
|
||||||
|
const OPENDOTA = "https://api.opendota.com/api";
|
||||||
|
|
||||||
|
function asInt(v, fallback = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemIds(player) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const iid = asInt(player[`item_${i}`], 0);
|
||||||
|
if (iid > 0) out.push(iid);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kda(kills, deaths, assists) {
|
||||||
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mvpScore(p) {
|
||||||
|
const k = asInt(p.kills);
|
||||||
|
const d = asInt(p.deaths);
|
||||||
|
const a = asInt(p.assists);
|
||||||
|
const dmg = asInt(p.hero_damage);
|
||||||
|
const nw = asInt(p.net_worth);
|
||||||
|
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / Math.max(d, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNormalizedMatch(data) {
|
||||||
|
return !!(
|
||||||
|
data &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
Array.isArray(data.players) &&
|
||||||
|
data.radiant &&
|
||||||
|
data.dire
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && env.OPENDOTA_API_KEY) || "").trim();
|
||||||
|
if (key) url.searchParams.set("api_key", key);
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "climperor-pages-players",
|
||||||
|
};
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Climperor match-detail JSON from OpenDota /matches/{id}. */
|
||||||
|
export function normalizeMatch(match, heroMap, focusAccountId = null) {
|
||||||
|
const playersRaw = match && match.players;
|
||||||
|
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
|
||||||
|
const matchId = asInt(match.match_id, 0);
|
||||||
|
if (matchId <= 0) return null;
|
||||||
|
|
||||||
|
const radiantWin = !!match.radiant_win;
|
||||||
|
const duration = asInt(match.duration);
|
||||||
|
let startTime = null;
|
||||||
|
if (match.start_time != null) {
|
||||||
|
const t = asInt(match.start_time, NaN);
|
||||||
|
startTime = Number.isFinite(t) ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = asInt(p.player_slot);
|
||||||
|
const isRadiant = slot < 128;
|
||||||
|
const side = isRadiant ? 0 : 1;
|
||||||
|
const kills = asInt(p.kills);
|
||||||
|
const deaths = asInt(p.deaths);
|
||||||
|
const assists = asInt(p.assists);
|
||||||
|
const heroDamage = asInt(p.hero_damage);
|
||||||
|
let netWorth = asInt(p.net_worth);
|
||||||
|
if (netWorth <= 0) netWorth = asInt(p.gold) + asInt(p.gold_spent);
|
||||||
|
teamKills[side] += kills;
|
||||||
|
teamNw[side] += netWorth;
|
||||||
|
teamDmg[side] += heroDamage;
|
||||||
|
|
||||||
|
const heroId = asInt(p.hero_id);
|
||||||
|
const hero = (heroMap && heroMap.get(heroId)) || {};
|
||||||
|
let accountId = null;
|
||||||
|
if (p.account_id != null) {
|
||||||
|
const a = asInt(p.account_id, NaN);
|
||||||
|
accountId = Number.isFinite(a) ? a : null;
|
||||||
|
}
|
||||||
|
let personaname = p.personaname;
|
||||||
|
if (typeof personaname === "string") {
|
||||||
|
personaname = personaname.trim() || null;
|
||||||
|
} else {
|
||||||
|
personaname = null;
|
||||||
|
}
|
||||||
|
let partyId = null;
|
||||||
|
if (p.party_id != null) {
|
||||||
|
const pid = asInt(p.party_id, NaN);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) partyId = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
slim.push({
|
||||||
|
account_id: accountId,
|
||||||
|
personaname,
|
||||||
|
hero_id: heroId,
|
||||||
|
hero_key: hero.key || null,
|
||||||
|
hero_name_loc: hero.name_loc || hero.key || null,
|
||||||
|
level: asInt(p.level),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: kda(kills, deaths, assists),
|
||||||
|
hero_damage: heroDamage,
|
||||||
|
net_worth: netWorth,
|
||||||
|
party_id: partyId,
|
||||||
|
party_label: null,
|
||||||
|
items: itemIds(p),
|
||||||
|
is_radiant: isRadiant,
|
||||||
|
won: isRadiant ? radiantWin : !radiantWin,
|
||||||
|
_mvp: mvpScore(p),
|
||||||
|
_side: side,
|
||||||
|
_slot: slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slim.length < 2) return null;
|
||||||
|
|
||||||
|
const partyCounts = new Map();
|
||||||
|
for (const p of slim) {
|
||||||
|
if (typeof p.party_id === "number" && p.party_id > 0) {
|
||||||
|
partyCounts.set(p.party_id, (partyCounts.get(p.party_id) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const partyLabels = new Map();
|
||||||
|
for (const [pid, n] of [...partyCounts.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
if (n >= 2) partyLabels.set(pid, String.fromCharCode(65 + partyLabels.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
p.party_label =
|
||||||
|
typeof p.party_id === "number" ? partyLabels.get(p.party_id) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mvp = slim.reduce((best, p) => (p._mvp > best._mvp ? p : best), slim[0]);
|
||||||
|
const mvpAccount = mvp.account_id;
|
||||||
|
const mvpSlot = mvp._slot;
|
||||||
|
for (const p of slim) {
|
||||||
|
p.is_mvp =
|
||||||
|
mvpAccount != null ? p.account_id === mvpAccount : p._slot === mvpSlot;
|
||||||
|
delete p._mvp;
|
||||||
|
delete p._side;
|
||||||
|
delete p._slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
match_id: matchId,
|
||||||
|
start_time: startTime,
|
||||||
|
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: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||||
|
source: "opendota",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function r2MatchKey(matchId) {
|
||||||
|
return `matches/${matchId}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMatchFromR2(env, matchId) {
|
||||||
|
if (!env.MATCHES) return null;
|
||||||
|
const key = r2MatchKey(matchId);
|
||||||
|
const obj = await env.MATCHES.get(key);
|
||||||
|
if (!obj) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(await obj.text());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeMatchToR2(env, detail) {
|
||||||
|
if (!env.MATCHES || !detail || !detail.match_id) return null;
|
||||||
|
const key = r2MatchKey(detail.match_id);
|
||||||
|
await env.MATCHES.put(key, JSON.stringify(detail), {
|
||||||
|
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureNormalizedMatch(env, accountId, matchId) {
|
||||||
|
let data = await readMatchFromR2(env, matchId);
|
||||||
|
if (isNormalizedMatch(data)) {
|
||||||
|
if (accountId && !data.focus_account_id) {
|
||||||
|
data = { ...data, focus_account_id: accountId };
|
||||||
|
}
|
||||||
|
return { match: data, error: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
data && Array.isArray(data.players) ? data : await odFetch(`/matches/${matchId}`, env);
|
||||||
|
if (!raw || !Array.isArray(raw.players)) {
|
||||||
|
return { match: null, error: "match not ready on OpenDota" };
|
||||||
|
}
|
||||||
|
if (accountId) {
|
||||||
|
const inMatch = raw.players.some(
|
||||||
|
(p) => p && Number(p.account_id) === Number(accountId)
|
||||||
|
);
|
||||||
|
if (!inMatch) {
|
||||||
|
return { match: null, error: "account not in match (private or wrong id)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const heroMap = await loadHeroMap(env);
|
||||||
|
const detail = normalizeMatch(raw, heroMap, accountId || null);
|
||||||
|
if (!detail) return { match: null, error: "normalize failed" };
|
||||||
|
const key = await writeMatchToR2(env, detail);
|
||||||
|
if (env.DB && accountId && key) {
|
||||||
|
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
|
||||||
|
)
|
||||||
|
.bind(key, now, accountId, matchId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
return { match: detail, error: "" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/players/ensure-match
|
||||||
|
* Body: { account_id, match_id }
|
||||||
|
* Auth: logged-in self, or target user has public_share.
|
||||||
|
* Loads Climperor match detail from R2, or fetches+normalizes from OpenDota.
|
||||||
|
*/
|
||||||
|
import { sessionFromRequest } from "../auth/_steam_common.js";
|
||||||
|
import { jsonResponse } from "./_db.js";
|
||||||
|
import { ensureNormalizedMatch } from "./_match.js";
|
||||||
|
|
||||||
|
function intField(v, fallback = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onRequestPost(context) {
|
||||||
|
try {
|
||||||
|
const env = context.env || {};
|
||||||
|
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
|
||||||
|
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await context.request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ error: "invalid json" }, 400);
|
||||||
|
}
|
||||||
|
const accountId = intField(body && body.account_id, -1);
|
||||||
|
const matchId = intField(body && body.match_id, -1);
|
||||||
|
if (accountId <= 0 || matchId <= 0) {
|
||||||
|
return jsonResponse({ error: "account_id and match_id required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 { match, error } = await ensureNormalizedMatch(env, accountId, matchId);
|
||||||
|
if (!match) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: error || "unknown", account_id: accountId, match_id: matchId },
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return jsonResponse({ ok: true, match });
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: "ensure-match failed", detail: String((e && e.message) || e) },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onRequestOptions() {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.57" />
|
||||||
<script src="/mobile-gate.js?v=0.6.16"></script>
|
<script src="/mobile-gate.js?v=0.6.57"></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.57"></script>
|
||||||
<script src="/router.js?v=0.6.16"></script>
|
<script src="/router.js?v=0.6.57"></script>
|
||||||
<script src="/app.js?v=0.6.16"></script>
|
<script src="/app.js?v=0.6.57"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 ~15–20% 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -278,13 +300,21 @@ def load_items_meta_index() -> dict:
|
|||||||
if not isinstance(row, dict) or not row.get("key"):
|
if not isinstance(row, dict) or not row.get("key"):
|
||||||
continue
|
continue
|
||||||
key = str(row["key"])
|
key = str(row["key"])
|
||||||
out[key] = {
|
cell = {
|
||||||
"key": key,
|
"key": key,
|
||||||
"name_loc": row.get("name_loc") or row.get("dname") or key,
|
"name_loc": row.get("name_loc") or row.get("dname") or key,
|
||||||
"cost": row.get("cost"),
|
"cost": row.get("cost"),
|
||||||
"desc_loc": row.get("desc_loc") or "",
|
"desc_loc": row.get("desc_loc") or "",
|
||||||
"tags": list(row.get("tags") or []),
|
"tags": list(row.get("tags") or []),
|
||||||
}
|
}
|
||||||
|
aliases = [
|
||||||
|
str(a).strip()
|
||||||
|
for a in (row.get("aliases") or [])
|
||||||
|
if str(a).strip()
|
||||||
|
]
|
||||||
|
if aliases:
|
||||||
|
cell["aliases"] = aliases
|
||||||
|
out[key] = cell
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -318,6 +348,13 @@ def load_item_shop() -> dict:
|
|||||||
row = dict(row)
|
row = dict(row)
|
||||||
row["tags"] = list(m.get("tags") or [])
|
row["tags"] = list(m.get("tags") or [])
|
||||||
row["desc_loc"] = m.get("desc_loc") or ""
|
row["desc_loc"] = m.get("desc_loc") or ""
|
||||||
|
aliases = [
|
||||||
|
str(a).strip()
|
||||||
|
for a in (m.get("aliases") or [])
|
||||||
|
if str(a).strip()
|
||||||
|
]
|
||||||
|
if aliases:
|
||||||
|
row["aliases"] = aliases
|
||||||
items[key] = row
|
items[key] = row
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
@@ -853,7 +890,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,14 +905,93 @@ 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()
|
||||||
|
if body:
|
||||||
self.wfile.write(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)."""
|
||||||
size = fpath.stat().st_size
|
size = fpath.stat().st_size
|
||||||
@@ -907,7 +1030,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 +1053,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 +1089,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 +1297,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 +1324,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 +1384,31 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
self._json(400, {"error": "bad players path"})
|
self._json(400, {"error": "bad players path"})
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def _read_json_body(self) -> dict | None:
|
||||||
path = urlparse(self.path).path
|
|
||||||
if path == "/api/mobile-demand":
|
|
||||||
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
|
|
||||||
return
|
|
||||||
if path == "/api/players/publish":
|
|
||||||
# Local dev: no OSS write; PC already wrote pc/player_pages/.
|
|
||||||
length = int(self.headers.get("Content-Length", 0) or 0)
|
length = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
try:
|
try:
|
||||||
body = json.loads(raw.decode("utf-8"))
|
body = json.loads(raw.decode("utf-8"))
|
||||||
except (ValueError, UnicodeDecodeError):
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/mobile-demand":
|
||||||
|
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
|
||||||
|
return
|
||||||
|
if path == "/api/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":
|
||||||
|
# Local dev: no OSS write; PC already wrote pc/player_pages/.
|
||||||
|
body = self._read_json_body()
|
||||||
|
if body is None:
|
||||||
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 +1424,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||