diff --git a/AGENTS.md b/AGENTS.md index 71b6a07..ec98df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ climperor/ | `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 | | `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 | | `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/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 | | `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/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 | | `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`)+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` | -| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;History 深度路径 SPA fallback 回 `index.html`;`/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`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/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) | @@ -95,7 +98,9 @@ climperor/ | `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json` 的 `is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info`(`live_status==1` 在播,轮播算下播),斗鱼走 `betard/{room_id}`(`show_status==1` 在播,`videoLoop==1` 轮播算下播);探测失败清为 `is_live:false` 并去掉 `live_probed_at`(与 `/api/live-status` 一致,不沿用旧直播中)、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily(角标以访问触发的 live API 为准,daily 仅作 data.json 兜底) | | `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`,含抖音 / B 站 / 斗鱼),读 `data.json` 的 `streamers.streamers`;Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 失败主播一律 `is_live:false` + `stale:true`(**不**沿用旧的直播中);全失败回 `stale-override` 空角标表或 `error`,永不 500;导出时拷贝 `functions/`;**部署须 `cwd=dist` 跑 wrangler**(Functions 相对 cwd 解析) | | `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py` 写 `web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 | -| `web/frontend/functions/api/players/publish.js` | Pages Function `POST /api/players/publish`:校验场内 `account_id` 后拉 OpenDota、写 OSS `players/{id}/profile.json` 与 `matches/{match_id}.json`(Secrets:`OSS_*`、可选 `PLAYER_PAGES_PUBLISH_SECRET`);**不**进主 `data.json` / recommend | +| `web/frontend/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/cloudflare/` | 多用户数据层:D1 migrations、`player-sync` Worker、`provision.py` / `deploy_worker.py`;资源名见 `web/cloudflare/README.md` | | `web/frontend/mobile-gate.js` | 移动端门禁(`` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__` 供 `app.js` 跳过桌面 boot | | `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `web/data/item_shop.json` + 图标 | | `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json`(`%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`) | @@ -126,11 +131,11 @@ climperor/ | `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) | | `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) | | `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `_redirects` / `robots.txt` / `functions/`);`config.js` 含 `SITE_VERSION`、`SITE_ORIGIN`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`、`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/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) | | `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_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) | | `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) | @@ -142,7 +147,7 @@ climperor/ 运行时产物(**勿提交**,见 `.gitignore`): - `pc/samples/raw//` — 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 预览 - `web/dist/` — 静态站点导出(`export_relations_site.py`) - `pc/results/` — 每局 JSON @@ -163,7 +168,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223) → recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI) → overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选) → pc/results/draft_*.json + 终端时间线 - → POST_GAME:player_pages 轮询 OpenDota → pc/player_pages/(可选 publish → OSS players/) + → POST_GAME:player_pages 轮询 OpenDota → pc/player_pages/(可选 publish → Queue → D1/R2) ``` ## 技术约束(修改前必读) @@ -179,7 +184,9 @@ Dota 2 GSI → pc/gsi_watch.py (:3223) - **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。 - **平台**:面向 Windows;截屏依赖无边框/窗口模式。 - **上分帝 Web 定时刷新**:Gitea Actions(self-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。 -- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` 时由边缘 ingest 写 OSS `players/`(PC **不**内置 OSS 密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 OSS;**禁止**塞进主 `data.json`,**禁止**进 recommend。需玩家开启「公开比赛数据」。 +- **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。 ## 开发命令 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c793a6..e0496d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,240 @@ ### Added - PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。 +- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。 + +## [0.6.54] - 2026-07-31 + +### Fixed + +- 「我」/玩家页:有战绩时不再因 TTL `stale` 轮询十余秒或闪「加载中…」;仅空档案才同步等待。 + +## [0.6.53] - 2026-07-31 + +### Fixed + +- 近期比赛:去掉行中弹性空隙;时长/相对时间与胜负/#ID 收成右侧紧簇,避免宽屏悬空。 + +## [0.6.52] - 2026-07-31 + +### Changed + +- 「我」/玩家页:缓存优先——首次可慢,约 10 分钟 TTL 内后续直出(生产 D1 + Queue;本机 `profile.json` + 后台 enrich);前端有新鲜数据时不再强制全量重拉。 + +## [0.6.51] - 2026-07-31 + +### Fixed + +- 近期比赛:弹性空白改到时长与相对时间之间,避免两者挤在一起、右侧大片空。 + +## [0.6.50] - 2026-07-31 + +### Changed + +- 十人详情:返回按钮移到比赛摘要卡上方,改为 36px 描边控件 + SVG 左箭头 +「返回玩家主页」。 + +## [0.6.49] - 2026-07-31 + +### Fixed + +- 十人详情:昵称列封顶约 16rem,中间剩余宽度四等分给参战/伤害/经济/KDA,避免昵称挤占指标空间。 + +## [0.6.48] - 2026-07-31 + +### Changed + +- 十人详情:去掉独立「返回主页」描边按钮,改为摘要头右侧文字链「← 玩家主页」。 + +## [0.6.47] - 2026-07-31 + +### Fixed + +- 十人详情:参战/伤害/经济/KDA 作为居中指标组,不再贴靠右侧装备列。 + +## [0.6.46] - 2026-07-31 + +### Changed + +- 玩家页分析区标题「常用队友」改为「队友」。 + +## [0.6.45] - 2026-07-31 + +### Fixed + +- 近期比赛:时长与相对时间改为水平并排(如 `41:54 · 3小时前`),并拉开与英雄列、胜负列的间距。 + +## [0.6.44] - 2026-07-31 + +### Fixed + +- 玩家身份条段位勋章放大到 64px(对齐 56px 头像的光学尺寸;OpenDota 图标含画布留白)。 + +## [0.6.43] - 2026-07-31 + +### Removed + +- 「我」/玩家页摘要区去掉近 20 场英雄肖像条(胜负已由下方近期比赛列表表达)。 + +## [0.6.42] - 2026-07-31 + +### Fixed + +- 玩家仪表盘恢复真实 1200px 数据主列;900–1200px 保持五列摘要和十人详情横向比较,近 20 场英雄条改为无滚动条双排网格,180 天活动改为按周排列并补强度图例。 +- 十人详情增加共享列头,胜负统一使用绿/红语义色;组队/MVP 徽章移至昵称次行,避免挤压长昵称。 + +## [0.6.41] - 2026-07-31 + +### Changed + +- 「我」/玩家页改为紧凑数据仪表盘:身份条 + 生涯/近 N 场并排摘要 + 分析双栏(常用英雄 / 180 天与队友)+ 固定列近期比赛;十人详情摘要头与昵称/指标/装备列对齐统一。 + +## [0.6.40] - 2026-07-31 + +### Fixed + +- 「我」近期比赛:时长/时间与英雄/KDA 收成左侧紧凑双列表格(上:英雄+时长,下:KDA+相对时间),胜负靠右,消除中间悬空。 + +## [0.6.39] - 2026-07-31 + +### Fixed + +- 「我」近期比赛:时长/时间与英雄名/KDA、胜负/#ID 改为统一双行网格,同行同字号对齐。 + +## [0.6.38] - 2026-07-31 + +### Fixed + +- 比赛十人详情:加宽昵称列,空间足够时尽量完整显示玩家名(组队/MVP 徽章不抢缩名称)。 + +## [0.6.37] - 2026-07-31 + +### Changed + +- 玩家段位改为勋章图标 + 星级叠层(如传奇四),不再并排显示「传奇4」文字;冠绝榜位仍显示 `#N`。 + +## [0.6.36] - 2026-07-31 + +### Fixed + +- 「我」近期比赛:时长与相对时间改为双行列(上时长、下时间),与英雄/胜负对齐,紧跟英雄信息。 + +## [0.6.35] - 2026-07-31 + +### Fixed + +- 「我」近期比赛:时长与相对时间贴近英雄信息,不再挤在右侧。 + +## [0.6.34] - 2026-07-31 + +### Fixed + +- 「我」近期比赛:时长与相对时间同列垂直居中对齐;「胜利」/`失败` 用绿/红字;身份条去掉「本机/未公开」。 + +## [0.6.33] - 2026-07-31 + +### Added + +- 「我」主页丰富:生涯场次/胜率/KDA、近 20 场摘要与英雄条、常用英雄、180 天活动热力图与样本最高、公开常用队友;空数据不显示伪 0%。 +- 生产多用户数据层:Cloudflare D1 `climperor-users` + Queue `climperor-player-sync` + Worker 异步同步;Pages 已绑定 `DB` / `SYNC_QUEUE`;`GET /api/players/me` 读库并排队刷新。R2 比赛详情桶待 Dashboard 启用后接入。 +- 本机 enrich 同步写入 `career` / `recent_20` / `top_heroes` / `activity_180` / `peers` / `availability`。 + +### Fixed + +- `/home` 点击近期比赛可进入详情(此前被 `page !== players` 早退)。 + +## [0.6.32] - 2026-07-31 + +### Changed + +- 顶栏登录后 Tab「我的主页」改为「我」。 + +### Added + +- 玩家主页补拉 OpenDota 段位(`rank_tier`)与昵称;「我」页展示勋章。近期比赛仍依赖「公开比赛数据」或本机 GSI。 + +## [0.6.31] - 2026-07-31 + +### Added + +- Steam 登录:顶栏「Steam 登录」;登录后显示头像/昵称与「退出」,并在「英雄」前出现「我」(`/home`,复用玩家战绩页看本人近 2 天比赛)。本机与 Pages 需配置 `STEAM_API_KEY` + `SESSION_SECRET`。 + +## [0.6.30] - 2026-07-31 + +### Fixed + +- 玩家主页:补拉 API 不可用时不再闪「补拉跳过」文案;已有列表则静默失败。 + +## [0.6.29] - 2026-07-31 + +### Changed + +- 玩家比赛详情主行显示 Steam 昵称(有公开 ID 时补拉 OpenDota;本人可从 GSI 取名);无昵称显示「匿名」/「玩家 {id}」,不再用英雄名冒充玩家名。 + +## [0.6.28] - 2026-07-31 + +### Added + +- 玩家页:本场有公开 `account_id` 的昵称可点进 `/players/{id}`;进入主页补拉近 2 天比赛列表(OpenDota + 本机 GSI 目录);点进某场再懒加载十人详情。 + +## [0.6.27] - 2026-07-31 + +### Changed + +- 玩家比赛详情参战/伤害/经济/KDA 强制同一行显示。 + +## [0.6.26] - 2026-07-31 + +### Changed + +- 玩家比赛详情个人经济改为完整数值(如 `25,903`),不再用「万」缩写。 + +## [0.6.25] - 2026-07-31 + +### Added + +- 玩家比赛详情:每人显示个人经济(净身价);OpenDota 有 `party_id` 且 ≥2 人同组时标注「组A/B…」。 + +## [0.6.24] - 2026-07-31 + +### Fixed + +- 玩家比赛详情:OpenDota 隐去 `account_id` 时仍按场内槽位标注 MVP(此前会整场不显示)。 + +## [0.6.23] - 2026-07-31 + +### Changed + +- 玩家比赛详情:参战/伤害/KDA 居中于英雄与装备之间。 + +## [0.6.22] - 2026-07-31 + +### Changed + +- 玩家比赛详情:去掉易混淆的「比」列,KDA 改为 `5/14/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 @@ -17,6 +251,12 @@ - PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。 +## [0.6.17] - 2026-07-31 + +### Changed + +- 版本页宽屏「AI 解读」侧栏随可用右侧空隙加宽(约 280–420px),窄于 1440px 仍上下堆叠。 + ## [0.6.16] - 2026-07-31 ### Added diff --git a/DESIGN.md b/DESIGN.md index dc3ab01..0d6ec76 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -267,10 +267,10 @@ components: | 档位 | 宽度 | 页面 | |------|------|------| | full | 100% | 英雄网格 | -| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),sticky + 视口内自滚动 | 版本 | +| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),宽度随可用侧栏约 280–420px,sticky + 视口内自滚动 | 版本 | | standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 | | wide | `min(1080px, …)` | 走势 | -| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛 | +| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛、玩家「我」主页 | 间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。 @@ -306,9 +306,22 @@ components: ## Components +### 玩家主页(Identity / Snapshot / Dashboard) + +登录后「我」与 `/players/{id}` 共用**紧凑数据仪表盘**语言,不做小黑盒式社交 feed: + +- **Identity strip**:整行;`panel-soft` + 细边框 + `radius-lg`;头像 `full` 圆形 56px;昵称 + 段位勋章同行。勋章容器约 64px(OpenDota 图标含画布留白,光学尺寸对齐头像),具体星级用 `rank_icon_*` + `rank_star_*` 叠层表达(如传奇四),不并排写「传奇4」文字;冠绝榜位可保留 `#N`。 +- **Snapshot 摘要区**:生涯与近 N 场并排(窄屏堆叠);指标卡 `radius-md`、数字 `tabular-nums`、紧凑五列网格;空数据整组隐藏,禁止显示伪 0% 胜率。 +- **Analysis 次级区**:宽屏双栏——左常用英雄列表,右 180 天活动 + 队友;`panel-soft` 分块,避免每块独占整页高度。 +- **近期比赛列表**:左英雄/KDA(弹性占满),右「时长/相对时间」与「胜负/#ID」两列双行紧簇右对齐(上:时长·胜负,下:相对时间·#ID);禁止再插 `1fr` 空隙把时长悬在行中;胜负语义色 + 左侧 3px 细边;整行可点。 +- **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。 +- **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。 +- 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。 +- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。 + ### 导航 -- **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。 +- **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。登录后「我」出现在「英雄」前。 - **Detail tabs**:下划线式,字号 `body-md`。 - **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。 - **顶栏邮件**:40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。 diff --git a/pc/config.json b/pc/config.json index dfa8cbb..4b30e32 100644 --- a/pc/config.json +++ b/pc/config.json @@ -147,10 +147,12 @@ "dump_payloads": true }, "player_pages": { - "comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true also POSTs /api/players/publish (OSS). Default private.", + "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, "public_share": false, - "recent_limit": 30, + "recent_limit": 20, + "recent_days": 14, + "enrich_ttl_seconds": 600, "poll_attempts": 12, "poll_base_seconds": 30, "publish_url": "https://dota2.refining.dev/api/players/publish", diff --git a/pc/player_pages.py b/pc/player_pages.py index a7f4243..28b5ca2 100644 --- a/pc/player_pages.py +++ b/pc/player_pages.py @@ -19,23 +19,53 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import json +import os import threading import time import urllib.error +import urllib.parse import urllib.request from datetime import datetime, timezone from typing import Any from shared.grid import hero_table from shared.http_utils import UA, write_json_atomic -from shared.paths import PC_PLAYER_PAGES +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" +STEAM_API = "https://api.steampowered.com" 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() _in_flight: set[str] = set() _done: set[str] = set() +_enrich_bg_inflight: set[int] = set() _hero_by_id: dict[int, dict] | None = None @@ -151,6 +181,15 @@ def normalize_match( else: 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( { "account_id": account_id_i, @@ -165,29 +204,52 @@ def normalize_match( "kda": _kda(kills, deaths, assists), "hero_damage": hero_damage, "net_worth": net_worth, + "party_id": party_id, + "party_label": None, "items": _item_ids(p), "is_radiant": is_radiant, "won": radiant_win if is_radiant else not radiant_win, "_mvp": _mvp_score(p), "_side": side, + "_slot": slot, } ) if len(slim_players) < 2: 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: side = p.pop("_side") tk = team_kills[side] or 1 td = team_dmg[side] or 1 p["participation"] = round((p["kills"] + p["assists"]) / tk, 3) p["damage_share"] = round(p["hero_damage"] / td, 3) + 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_account = mvp.get("account_id") + mvp_slot = mvp.get("_slot") 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["_slot"] return { "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: - 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}) try: - with urllib.request.urlopen(req, timeout=45) as resp: - data = json.loads(resp.read().decode()) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: - if e.code == 404: + if e.code in (403, 404, 429): return None raise except (urllib.error.URLError, TimeoutError, OSError, ValueError): 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 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: @@ -286,6 +607,145 @@ def load_profile(account_id: int) -> dict: 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( account_id: int, *, @@ -309,6 +769,227 @@ def upsert_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( *, account_id: int, @@ -401,11 +1082,20 @@ def process_post_game( if match and account_in_match(match, aid): break if match and not account_in_match(match, aid): + hero_id, gsi_name = _gsi_self_info(mid, aid) + if _attach_focus_account(match, aid, hero_id, personaname=gsi_name): + print( + f"[player_pages] match {mid}: attached account via GSI hero", + flush=True, + ) + break print( - f"[player_pages] match {mid} has no account {aid} — skip", + f"[player_pages] match {mid} has no account {aid} — " + "will still try enrich from GSI later", flush=True, ) - return + match = None + break match = None print( f"[player_pages] match {mid} not ready ({i + 1}/{attempts})", @@ -413,13 +1103,21 @@ def process_post_game( ) 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 detail = normalize_match(match, focus_account_id=aid) if not detail: print(f"[player_pages] normalize failed for {mid}", flush=True) return + fill_player_personanames(detail) write_json_atomic(match_path(aid, mid), detail) 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"): personaname = p["personaname"] break + if not personaname: + _, gsi_name = _gsi_self_info(mid, aid) + personaname = gsi_name or _fetch_opendota_persona(aid) profile = upsert_profile( aid, summary=summary, @@ -468,6 +1169,11 @@ def process_post_game( else: 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: _done.add(key) finally: diff --git a/pc/player_stats.py b/pc/player_stats.py new file mode 100644 index 0000000..a238027 --- /dev/null +++ b/pc/player_stats.py @@ -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 diff --git a/pc/tests/test_player_pages_cache.py b/pc/tests/test_player_pages_cache.py new file mode 100644 index 0000000..18fe860 --- /dev/null +++ b/pc/tests/test_player_pages_cache.py @@ -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() diff --git a/pc/tests/test_player_stats.py b/pc/tests/test_player_stats.py new file mode 100644 index 0000000..2966fb9 --- /dev/null +++ b/pc/tests/test_player_stats.py @@ -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() diff --git a/shared/data/relations.json b/shared/data/relations.json index 61a48dd..3371502 100644 --- a/shared/data/relations.json +++ b/shared/data/relations.json @@ -49,6 +49,11 @@ "b": "spirit_breaker", "reason": "" }, + { + "a": "ancient_apparition", + "b": "necrolyte", + "reason": "冰晶爆轰禁疗,克死亡脉冲续航" + }, { "a": "antimage", "b": "lina", diff --git a/web/_enrich_one_player.py b/web/_enrich_one_player.py new file mode 100644 index 0000000..be951cd --- /dev/null +++ b/web/_enrich_one_player.py @@ -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 []), +) diff --git a/web/_probe_player_sources.py b/web/_probe_player_sources.py new file mode 100644 index 0000000..f4e319f --- /dev/null +++ b/web/_probe_player_sources.py @@ -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() diff --git a/web/_serve_with_steam_auth.py b/web/_serve_with_steam_auth.py new file mode 100644 index 0000000..27aa763 --- /dev/null +++ b/web/_serve_with_steam_auth.py @@ -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()) diff --git a/web/_start_steam_serve.py b/web/_start_steam_serve.py new file mode 100644 index 0000000..521cddc --- /dev/null +++ b/web/_start_steam_serve.py @@ -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()) diff --git a/web/_steam_key_check.py b/web/_steam_key_check.py new file mode 100644 index 0000000..140041f --- /dev/null +++ b/web/_steam_key_check.py @@ -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") diff --git a/web/assets/rank_icons/rank_star_1.png b/web/assets/rank_icons/rank_star_1.png new file mode 100644 index 0000000..0f5f555 Binary files /dev/null and b/web/assets/rank_icons/rank_star_1.png differ diff --git a/web/assets/rank_icons/rank_star_2.png b/web/assets/rank_icons/rank_star_2.png new file mode 100644 index 0000000..3aa6f66 Binary files /dev/null and b/web/assets/rank_icons/rank_star_2.png differ diff --git a/web/assets/rank_icons/rank_star_3.png b/web/assets/rank_icons/rank_star_3.png new file mode 100644 index 0000000..9bbddd5 Binary files /dev/null and b/web/assets/rank_icons/rank_star_3.png differ diff --git a/web/assets/rank_icons/rank_star_4.png b/web/assets/rank_icons/rank_star_4.png new file mode 100644 index 0000000..9b1688e Binary files /dev/null and b/web/assets/rank_icons/rank_star_4.png differ diff --git a/web/assets/rank_icons/rank_star_5.png b/web/assets/rank_icons/rank_star_5.png new file mode 100644 index 0000000..c6af804 Binary files /dev/null and b/web/assets/rank_icons/rank_star_5.png differ diff --git a/web/cloudflare/.resources.json b/web/cloudflare/.resources.json new file mode 100644 index 0000000..630ba30 --- /dev/null +++ b/web/cloudflare/.resources.json @@ -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" +} diff --git a/web/cloudflare/README.md b/web/cloudflare/README.md new file mode 100644 index 0000000..e18b2c4 --- /dev/null +++ b/web/cloudflare/README.md @@ -0,0 +1,55 @@ +# Climperor Cloudflare player data layer + +Production multi-user storage for Steam-login player pages: + +| Resource | Name | Role | +|----------|------|------| +| D1 | `climperor-users` | users, stats, heroes, match index, sync jobs | +| R2 | `climperor-player-data` | private full match JSON (`matches/{id}.json`) | +| Queue | `climperor-player-sync` | async OpenDota/Steam refresh | +| DLQ | `climperor-player-sync-dlq` | failed sync messages | +| Worker | `climperor-player-sync` | queue consumer | + +## Provision + +```powershell +# via keyzoo refining/cloudflare asset +python web/cloudflare/provision.py +``` + +Then deploy the worker: + +```powershell +cd web/cloudflare/player-sync +npx wrangler@3 deploy +npx wrangler@3 secret put STEAM_API_KEY +# optional: +npx wrangler@3 secret put OPENDOTA_API_KEY +``` + +Pages project `climperor-relations` needs bindings `DB`, `MATCHES`, `SYNC_QUEUE` +(see `web/frontend/wrangler.toml`). `provision.py` binds production **and** preview +with matching `fail_open`. Secrets already used by auth: +`STEAM_API_KEY`, `SESSION_SECRET`, optional `PLAYER_PAGES_PUBLISH_SECRET`. +Worker also needs `STEAM_API_KEY` (`python web/cloudflare/put_worker_secrets.py` +after staging via `_stage_steam_key.py`). + +**R2**: if create returns “enable R2”, open +https://dash.cloudflare.com/?to=/:account/r2 once, then re-run `provision.py` +and uncomment the `[[r2_buckets]]` block in `player-sync/wrangler.toml`, redeploy Worker. +Until then, match list/stats still work via D1; full match JSON download is deferred. + +Ids are recorded in `.resources.json` (no secrets). + +## Local + +`python web/serve_relations.py` continues to use `pc/player_pages/` JSON + enrich +(`GET /api/players/me` runs `enrich_profile_recent`). +Production `/home` uses `GET /api/players/me` (D1 + queue). + +## Tests + +```powershell +python -m unittest pc.tests.test_player_stats -v +node web/cloudflare/player-sync/test_stats.mjs +``` diff --git a/web/cloudflare/_backfill_from_local.py b/web/cloudflare/_backfill_from_local.py new file mode 100644 index 0000000..ac3f32a --- /dev/null +++ b/web/cloudflare/_backfill_from_local.py @@ -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()) diff --git a/web/cloudflare/_cf_diag.py b/web/cloudflare/_cf_diag.py new file mode 100644 index 0000000..855f28f --- /dev/null +++ b/web/cloudflare/_cf_diag.py @@ -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()) diff --git a/web/cloudflare/_cf_ping.py b/web/cloudflare/_cf_ping.py new file mode 100644 index 0000000..7aa287f --- /dev/null +++ b/web/cloudflare/_cf_ping.py @@ -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()) diff --git a/web/cloudflare/_curl_r2.py b/web/cloudflare/_curl_r2.py new file mode 100644 index 0000000..a3644f4 --- /dev/null +++ b/web/cloudflare/_curl_r2.py @@ -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()) diff --git a/web/cloudflare/_dump_pages.py b/web/cloudflare/_dump_pages.py new file mode 100644 index 0000000..3a50b92 --- /dev/null +++ b/web/cloudflare/_dump_pages.py @@ -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()) diff --git a/web/cloudflare/_enable_r2.py b/web/cloudflare/_enable_r2.py new file mode 100644 index 0000000..8cc32a1 --- /dev/null +++ b/web/cloudflare/_enable_r2.py @@ -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()) diff --git a/web/cloudflare/_force_http_sync.py b/web/cloudflare/_force_http_sync.py new file mode 100644 index 0000000..c5b8c57 --- /dev/null +++ b/web/cloudflare/_force_http_sync.py @@ -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__") diff --git a/web/cloudflare/_probe_account_d1.py b/web/cloudflare/_probe_account_d1.py new file mode 100644 index 0000000..fb10b74 --- /dev/null +++ b/web/cloudflare/_probe_account_d1.py @@ -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()) diff --git a/web/cloudflare/_probe_apis.py b/web/cloudflare/_probe_apis.py new file mode 100644 index 0000000..edb0273 --- /dev/null +++ b/web/cloudflare/_probe_apis.py @@ -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()) diff --git a/web/cloudflare/_retry_r2_only.py b/web/cloudflare/_retry_r2_only.py new file mode 100644 index 0000000..e18c31d --- /dev/null +++ b/web/cloudflare/_retry_r2_only.py @@ -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()) diff --git a/web/cloudflare/_run_with_staged_cf.py b/web/cloudflare/_run_with_staged_cf.py new file mode 100644 index 0000000..06e0f96 --- /dev/null +++ b/web/cloudflare/_run_with_staged_cf.py @@ -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 [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()) diff --git a/web/cloudflare/_stage_cf_creds.py b/web/cloudflare/_stage_cf_creds.py new file mode 100644 index 0000000..ae782aa --- /dev/null +++ b/web/cloudflare/_stage_cf_creds.py @@ -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()) diff --git a/web/cloudflare/_stage_steam_key.py b/web/cloudflare/_stage_steam_key.py new file mode 100644 index 0000000..a5e7a8d --- /dev/null +++ b/web/cloudflare/_stage_steam_key.py @@ -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()) diff --git a/web/cloudflare/_trigger_player_sync.py b/web/cloudflare/_trigger_player_sync.py new file mode 100644 index 0000000..1938e2a --- /dev/null +++ b/web/cloudflare/_trigger_player_sync.py @@ -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()) diff --git a/web/cloudflare/deploy_worker.py b/web/cloudflare/deploy_worker.py new file mode 100644 index 0000000..7da92ce --- /dev/null +++ b/web/cloudflare/deploy_worker.py @@ -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()) diff --git a/web/cloudflare/migrations/0001_init.sql b/web/cloudflare/migrations/0001_init.sql new file mode 100644 index 0000000..fdda42c --- /dev/null +++ b/web/cloudflare/migrations/0001_init.sql @@ -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); diff --git a/web/cloudflare/player-sync/src/db.js b/web/cloudflare/player-sync/src/db.js new file mode 100644 index 0000000..ea02878 --- /dev/null +++ b/web/cloudflare/player-sync/src/db.js @@ -0,0 +1,296 @@ +export function utcNow() { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export async function upsertUser(db, user) { + const now = utcNow(); + await db + .prepare( + `INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at) + VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + steamid=excluded.steamid, + personaname=COALESCE(excluded.personaname, users.personaname), + avatar=COALESCE(excluded.avatar, users.avatar), + public_share=COALESCE(excluded.public_share, users.public_share), + last_login_at=excluded.last_login_at` + ) + .bind( + user.account_id, + String(user.steamid), + user.personaname || null, + user.avatar || null, + user.public_share == null ? null : user.public_share ? 1 : 0, + now, + now + ) + .run(); +} + +export async function upsertProfile(db, accountId, profile) { + const now = utcNow(); + const avail = profile.availability || {}; + await db + .prepare( + `INSERT INTO player_profiles ( + account_id, rank_tier, leaderboard_rank, availability_status, availability_note, + availability_complete, source, fetched_at, enriched_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + rank_tier=excluded.rank_tier, + leaderboard_rank=excluded.leaderboard_rank, + availability_status=excluded.availability_status, + availability_note=excluded.availability_note, + availability_complete=excluded.availability_complete, + source=excluded.source, + fetched_at=excluded.fetched_at, + enriched_at=excluded.enriched_at, + updated_at=excluded.updated_at` + ) + .bind( + accountId, + profile.rank_tier ?? null, + profile.leaderboard_rank ?? null, + avail.status || null, + avail.note || null, + avail.complete ? 1 : 0, + avail.source || "opendota", + avail.fetched_at || now, + profile.enriched_at || now, + now + ) + .run(); +} + +export async function upsertStats(db, accountId, scope, stats) { + if (!stats) return; + const now = utcNow(); + await db + .prepare( + `INSERT INTO player_stats ( + account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda, + avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id, scope) DO UPDATE SET + sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate, + kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, + avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists, + avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage, + payload_json=excluded.payload_json, updated_at=excluded.updated_at` + ) + .bind( + accountId, + scope, + stats.sample ?? stats.games ?? 0, + stats.wins ?? 0, + stats.losses ?? 0, + stats.winrate ?? null, + stats.kills ?? null, + stats.deaths ?? null, + stats.assists ?? null, + stats.kda ?? null, + stats.avg_kills ?? null, + stats.avg_deaths ?? null, + stats.avg_assists ?? null, + stats.avg_gpm ?? null, + stats.avg_xpm ?? null, + stats.avg_hero_damage ?? null, + JSON.stringify(stats), + now + ) + .run(); +} + +export async function replaceHeroes(db, accountId, heroes) { + const now = utcNow(); + await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run(); + for (const h of heroes || []) { + await db + .prepare( + `INSERT INTO player_heroes ( + account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + accountId, + h.hero_id, + h.hero_key || null, + h.hero_name_loc || null, + h.games || 0, + h.wins || 0, + h.winrate ?? null, + h.last_played ?? null, + now + ) + .run(); + } +} + +export async function replacePeers(db, accountId, peers) { + const now = utcNow(); + await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run(); + for (const p of peers || []) { + await db + .prepare( + `INSERT INTO player_peers ( + account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + accountId, + p.account_id, + p.personaname || null, + p.avatar || null, + p.games || 0, + p.wins || 0, + p.winrate ?? null, + now + ) + .run(); + } +} + +export async function upsertMatches(db, accountId, recent) { + const now = utcNow(); + for (const r of recent || []) { + await db + .prepare( + `INSERT INTO player_matches ( + account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc, + kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id, match_id) DO UPDATE SET + start_time=excluded.start_time, duration=excluded.duration, won=excluded.won, + hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc, + kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, + gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage, + game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at` + ) + .bind( + accountId, + r.match_id, + r.start_time ?? null, + r.duration ?? null, + r.won ? 1 : 0, + r.hero_id ?? null, + r.hero_key || null, + r.hero_name_loc || null, + r.kills ?? null, + r.deaths ?? null, + r.assists ?? null, + r.kda ?? null, + r.gpm ?? null, + r.xpm ?? null, + r.hero_damage ?? null, + r.game_mode ?? null, + r.lobby_type ?? null, + r.r2_key || null, + now + ) + .run(); + } +} + +export async function loadPlayerBundle(db, accountId) { + const user = await db + .prepare(`SELECT * FROM users WHERE account_id = ?`) + .bind(accountId) + .first(); + if (!user) return null; + const profile = await db + .prepare(`SELECT * FROM player_profiles WHERE account_id = ?`) + .bind(accountId) + .first(); + const statsRows = await db + .prepare(`SELECT * FROM player_stats WHERE account_id = ?`) + .bind(accountId) + .all(); + const heroes = await db + .prepare( + `SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8` + ) + .bind(accountId) + .all(); + const peers = await db + .prepare( + `SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8` + ) + .bind(accountId) + .all(); + const recent = await db + .prepare( + `SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20` + ) + .bind(accountId) + .all(); + + const statsByScope = {}; + for (const row of (statsRows && statsRows.results) || []) { + try { + statsByScope[row.scope] = row.payload_json + ? JSON.parse(row.payload_json) + : row; + } catch { + statsByScope[row.scope] = row; + } + } + + return { + account_id: accountId, + personaname: user.personaname, + avatar: user.avatar, + public_share: !!user.public_share, + rank_tier: profile && profile.rank_tier, + leaderboard_rank: profile && profile.leaderboard_rank, + availability: profile + ? { + status: profile.availability_status, + note: profile.availability_note, + complete: !!profile.availability_complete, + source: profile.source, + fetched_at: profile.fetched_at, + stale: false, + } + : null, + career: statsByScope.career || null, + recent_20: statsByScope.recent20 || null, + activity_180: statsByScope.recent180 || null, + top_heroes: ((heroes && heroes.results) || []).map((h) => ({ + hero_id: h.hero_id, + hero_key: h.hero_key, + hero_name_loc: h.hero_name_loc, + games: h.games, + wins: h.wins, + winrate: h.winrate, + last_played: h.last_played, + })), + peers: ((peers && peers.results) || []).map((p) => ({ + account_id: p.peer_account_id, + personaname: p.personaname, + avatar: p.avatar, + games: p.games, + wins: p.wins, + winrate: p.winrate, + })), + recent: ((recent && recent.results) || []).map((r) => ({ + match_id: r.match_id, + start_time: r.start_time, + duration: r.duration, + won: !!r.won, + hero_id: r.hero_id, + hero_key: r.hero_key, + hero_name_loc: r.hero_name_loc, + kills: r.kills, + deaths: r.deaths, + assists: r.assists, + kda: r.kda, + gpm: r.gpm, + xpm: r.xpm, + hero_damage: r.hero_damage, + game_mode: r.game_mode, + lobby_type: r.lobby_type, + })), + updated_at: (profile && profile.updated_at) || user.last_login_at, + enriched_at: profile && profile.enriched_at, + }; +} diff --git a/web/cloudflare/player-sync/src/index.js b/web/cloudflare/player-sync/src/index.js new file mode 100644 index 0000000..e632f80 --- /dev/null +++ b/web/cloudflare/player-sync/src/index.js @@ -0,0 +1,326 @@ +/** + * Queue consumer: refresh player stats from OpenDota into D1 (+ optional R2 match detail). + * + * Message shapes: + * { kind: "login_refresh"|"backfill", account_id, steamid?, personaname?, avatar? } + * { kind: "publish_match", account_id, match_id } + */ +import { + loadPlayerBundle, + replaceHeroes, + replacePeers, + upsertMatches, + upsertProfile, + upsertStats, + upsertUser, + utcNow, +} from "./db.js"; +import { + loadHeroMap, + odFetch, + steamMatchHistoryStatus, + summaryFromRecentRow, +} from "./opendota.js"; +import { + aggregateFromRows, + careerFromOpenDota, + mergeAvailability, + winrate, +} from "./stats.js"; + +function winrateGames(wins, games) { + return winrate(wins, Math.max(0, games - wins)); +} + +async function syncAccount(env, msg) { + const accountId = Number(msg.account_id); + if (!Number.isFinite(accountId) || accountId <= 0) { + throw new Error("bad account_id"); + } + const steamid = + msg.steamid || String(BigInt(accountId) + 76561197960265728n); + await upsertUser(env.DB, { + account_id: accountId, + steamid, + personaname: msg.personaname || null, + avatar: msg.avatar || null, + public_share: msg.public_share, + }); + + const heroMap = await loadHeroMap(env); + // Serial OpenDota calls — CF edge IPs hit 429 hard under Promise.all. + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const player = await odFetch(`/players/${accountId}`, env); + await sleep(200); + const wl = await odFetch(`/players/${accountId}/wl`, env); + await sleep(200); + const totals = await odFetch(`/players/${accountId}/totals`, env); + await sleep(200); + const heroes = await odFetch(`/players/${accountId}/heroes`, env); + await sleep(200); + const peers = await odFetch(`/players/${accountId}/peers`, env); + await sleep(200); + const recentRaw = await odFetch(`/players/${accountId}/recentMatches`, env); + await sleep(200); + const matches180 = await odFetch(`/players/${accountId}/matches`, env, { + date: 180, + significant: 0, + }); + const steamStatus = await steamMatchHistoryStatus(accountId, env); + + const profileBlock = player && player.profile ? player.profile : {}; + const personaname = profileBlock.personaname || msg.personaname || null; + const avatar = + profileBlock.avatarfull || + profileBlock.avatarmedium || + profileBlock.avatar || + msg.avatar || + null; + await upsertUser(env.DB, { + account_id: accountId, + steamid, + personaname, + avatar, + }); + + const recent = []; + if (Array.isArray(recentRaw)) { + for (const row of recentRaw) { + const summary = summaryFromRecentRow(row, heroMap); + if (summary) recent.push(summary); + } + } + recent.sort((a, b) => (b.start_time || 0) - (a.start_time || 0)); + const recent20 = recent.slice(0, 20); + + let career = careerFromOpenDota(wl, totals); + // Do not wipe previous career on empty OpenDota response. + if (!career) { + const existing = await loadPlayerBundle(env.DB, accountId); + if (existing && existing.career && existing.career.games > 0) { + career = existing.career; + } + } + + const topHeroes = []; + if (Array.isArray(heroes)) { + const scored = heroes + .filter((h) => h && Number(h.games) > 0) + .sort((a, b) => Number(b.games) - Number(a.games)) + .slice(0, 5); + for (const h of scored) { + const hid = Number(h.hero_id) || 0; + const meta = heroMap.get(hid) || {}; + const games = Number(h.games) || 0; + const wins = Number(h.win) || 0; + topHeroes.push({ + hero_id: hid || null, + hero_key: meta.key || null, + hero_name_loc: meta.name_loc || meta.key || null, + games, + wins, + winrate: winrateGames(wins, games), + last_played: h.last_played != null ? Number(h.last_played) : null, + }); + } + } + + const peerRows = []; + if (Array.isArray(peers)) { + for (const p of peers.slice(0, 8)) { + if (!p || !p.account_id) continue; + const games = Number(p.games) || 0; + if (games <= 0) continue; + const wins = Number(p.win) || 0; + peerRows.push({ + account_id: Number(p.account_id), + personaname: p.personaname || `玩家 ${p.account_id}`, + avatar: p.avatarfull || p.avatar || null, + games, + wins, + winrate: winrateGames(wins, games), + }); + } + } + + let activity180 = null; + if (Array.isArray(matches180) && matches180.length) { + const byDay = new Map(); + let wins = 0; + let losses = 0; + let maxKills = null; + let maxAssists = null; + let maxGpm = null; + for (const row of matches180) { + if (!row || row.start_time == null) continue; + const st = Number(row.start_time); + const day = new Date(st * 1000).toISOString().slice(0, 10); + const cell = byDay.get(day) || { games: 0, wins: 0 }; + cell.games += 1; + const slot = Number(row.player_slot) || 0; + const won = slot < 128 ? !!row.radiant_win : !row.radiant_win; + if (won) { + cell.wins += 1; + wins += 1; + } else losses += 1; + byDay.set(day, cell); + const kills = Number(row.kills) || 0; + const assists = Number(row.assists) || 0; + const gpm = Number(row.gold_per_min) || 0; + const heroId = Number(row.hero_id) || null; + const mid = Number(row.match_id) || 0; + if (!maxKills || kills > maxKills.value) { + maxKills = { value: kills, hero_id: heroId, match_id: mid }; + } + if (!maxAssists || assists > maxAssists.value) { + maxAssists = { value: assists, hero_id: heroId, match_id: mid }; + } + if (gpm > 0 && (!maxGpm || gpm > maxGpm.value)) { + maxGpm = { value: gpm, hero_id: heroId, match_id: mid }; + } + } + activity180 = { + days: 180, + sample: wins + losses, + wins, + losses, + winrate: winrate(wins, losses), + heatmap: [...byDay.entries()] + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .map(([date, v]) => ({ date, games: v.games, wins: v.wins })), + highs: { kills: maxKills, assists: maxAssists, gpm: maxGpm }, + label: "最近 180 天样本", + }; + } + + const fetched = utcNow(); + const availability = mergeAvailability({ + opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0, + career, + steamHistoryStatus: steamStatus, + fetchedAt: fetched, + }); + if (profileBlock.fh_unavailable && availability.status === "unknown") { + availability.status = "private"; + availability.note = "未公开比赛数据"; + } + // Steam public but OpenDota empty → almost always rate-limit; retry queue. + if ( + availability.status === "syncing" && + (!Array.isArray(recentRaw) || recentRaw.length === 0) && + !career + ) { + throw new Error("OpenDota empty while Steam public — retry"); + } + + const profile = { + rank_tier: player && player.rank_tier != null ? Number(player.rank_tier) : null, + leaderboard_rank: + player && player.leaderboard_rank != null + ? Number(player.leaderboard_rank) + : null, + availability, + enriched_at: fetched, + }; + + await upsertProfile(env.DB, accountId, profile); + if (career) await upsertStats(env.DB, accountId, "career", career); + await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20)); + if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180); + await replaceHeroes(env.DB, accountId, topHeroes); + await replacePeers(env.DB, accountId, peerRows); + await upsertMatches(env.DB, accountId, recent20); + + // Optional: store a published match detail into R2 (deduped by match_id). + if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) { + const matchId = Number(msg.match_id); + const match = await odFetch(`/matches/${matchId}`, env); + if (match && Array.isArray(match.players)) { + const key = `matches/${matchId}.json`; + await env.MATCHES.put(key, JSON.stringify(match), { + httpMetadata: { contentType: "application/json; charset=utf-8" }, + }); + await env.DB.prepare( + `UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?` + ) + .bind(key, utcNow(), accountId, matchId) + .run(); + } + } + + return loadPlayerBundle(env.DB, accountId); +} + +async function markJob(env, jobId, patch) { + if (!jobId) return; + const now = utcNow(); + await env.DB.prepare( + `UPDATE sync_jobs SET status = ?, attempts = COALESCE(attempts, 0) + ?, + error = ?, lease_until = ?, updated_at = ? + WHERE id = ?` + ) + .bind( + patch.status, + patch.bumpAttempts ? 1 : 0, + patch.error || null, + patch.lease_until || null, + now, + jobId + ) + .run(); +} + +export default { + async queue(batch, env) { + for (const message of batch.messages) { + let body = message.body; + if (typeof body === "string") { + try { + body = JSON.parse(body); + } catch { + message.ack(); + continue; + } + } + const jobId = body && body.job_id; + try { + if (jobId) { + await markJob(env, jobId, { + status: "running", + lease_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + }); + } + await syncAccount(env, body || {}); + if (jobId) await markJob(env, jobId, { status: "done" }); + message.ack(); + } catch (e) { + const err = String((e && e.message) || e); + if (jobId) { + await markJob(env, jobId, { + status: "error", + bumpAttempts: true, + error: err.slice(0, 500), + }); + } + message.retry(); + } + } + }, + + // Manual HTTP trigger for smoke tests (requires SYNC_HTTP_TOKEN secret). + async fetch(request, env) { + if (request.method !== "POST") { + return new Response("climperor-player-sync", { status: 200 }); + } + const token = (env.SYNC_HTTP_TOKEN || "").trim(); + const auth = (request.headers.get("Authorization") || "").trim(); + if (!token || auth !== `Bearer ${token}`) { + return new Response("unauthorized", { status: 401 }); + } + const body = await request.json().catch(() => ({})); + const out = await syncAccount(env, body); + return new Response(JSON.stringify(out), { + headers: { "Content-Type": "application/json" }, + }); + }, +}; diff --git a/web/cloudflare/player-sync/src/opendota.js b/web/cloudflare/player-sync/src/opendota.js new file mode 100644 index 0000000..b5e3a76 --- /dev/null +++ b/web/cloudflare/player-sync/src/opendota.js @@ -0,0 +1,93 @@ +const OPENDOTA = "https://api.opendota.com/api"; +const STEAM_API = "https://api.steampowered.com"; + +export async function odFetch(path, env, query = {}) { + const url = new URL(`${OPENDOTA}${path}`); + for (const [k, v] of Object.entries(query)) { + if (v != null) url.searchParams.set(k, String(v)); + } + const key = (env.OPENDOTA_API_KEY || "").trim(); + if (key) url.searchParams.set("api_key", key); + const headers = { + Accept: "application/json", + "User-Agent": "climperor-player-sync", + }; + // CF edge IPs are often rate-limited; retry 429/5xx before giving up. + let lastStatus = 0; + for (let attempt = 0; attempt < 4; attempt++) { + if (attempt > 0) { + await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1))); + } + const res = await fetch(url.toString(), { headers }); + lastStatus = res.status; + if (res.status === 403 || res.status === 404) return null; + if (res.status === 429 || res.status >= 500) continue; + if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`); + return res.json(); + } + if (lastStatus === 429 || lastStatus >= 500) return null; + throw new Error(`OpenDota ${lastStatus} ${path}`); +} + +export async function steamMatchHistoryStatus(accountId, env) { + const key = (env.STEAM_API_KEY || "").trim(); + if (!key) return null; + const url = new URL(`${STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/`); + url.searchParams.set("key", key); + url.searchParams.set("account_id", String(accountId)); + url.searchParams.set("matches_requested", "1"); + try { + const res = await fetch(url.toString(), { + headers: { "User-Agent": "climperor-player-sync" }, + }); + if (!res.ok) return null; + const data = await res.json(); + const status = data && data.result && data.result.status; + return status == null ? null : Number(status); + } catch { + return null; + } +} + +export function summaryFromRecentRow(row, heroMap) { + const mid = Number(row.match_id) || 0; + if (mid <= 0) return null; + const heroId = Number(row.hero_id) || 0; + const hero = heroMap.get(heroId) || {}; + const kills = Number(row.kills) || 0; + const deaths = Number(row.deaths) || 0; + const assists = Number(row.assists) || 0; + const playerSlot = Number(row.player_slot) || 0; + const radiantWin = !!row.radiant_win; + const isRadiant = playerSlot < 128; + return { + match_id: mid, + start_time: row.start_time != null ? Number(row.start_time) : null, + duration: Number(row.duration) || 0, + won: isRadiant ? radiantWin : !radiantWin, + hero_id: heroId || null, + hero_key: hero.key || null, + hero_name_loc: hero.name_loc || hero.key || null, + kills, + deaths, + assists, + kda: Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10, + gpm: row.gold_per_min != null ? Number(row.gold_per_min) || 0 : null, + xpm: row.xp_per_min != null ? Number(row.xp_per_min) || 0 : null, + hero_damage: row.hero_damage != null ? Number(row.hero_damage) || 0 : null, + game_mode: row.game_mode != null ? Number(row.game_mode) : null, + lobby_type: row.lobby_type != null ? Number(row.lobby_type) : null, + }; +} + +export async function loadHeroMap(env) { + const rows = await odFetch("/heroes", env); + const map = new Map(); + if (!Array.isArray(rows)) return map; + for (const h of rows) { + if (!h || h.id == null) continue; + const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null; + map.set(Number(h.id), { key, name_loc: h.localized_name || key }); + } + return map; +} diff --git a/web/cloudflare/player-sync/src/stats.js b/web/cloudflare/player-sync/src/stats.js new file mode 100644 index 0000000..e01de41 --- /dev/null +++ b/web/cloudflare/player-sync/src/stats.js @@ -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, + }; +} diff --git a/web/cloudflare/player-sync/test_stats.mjs b/web/cloudflare/player-sync/test_stats.mjs new file mode 100644 index 0000000..7f7c6fe --- /dev/null +++ b/web/cloudflare/player-sync/test_stats.mjs @@ -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"); diff --git a/web/cloudflare/player-sync/wrangler.toml b/web/cloudflare/player-sync/wrangler.toml new file mode 100644 index 0000000..f3940cd --- /dev/null +++ b/web/cloudflare/player-sync/wrangler.toml @@ -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) diff --git a/web/cloudflare/provision.py b/web/cloudflare/provision.py new file mode 100644 index 0000000..188ea32 --- /dev/null +++ b/web/cloudflare/provision.py @@ -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()) diff --git a/web/cloudflare/put_worker_secrets.py b/web/cloudflare/put_worker_secrets.py new file mode 100644 index 0000000..1a7e765 --- /dev/null +++ b/web/cloudflare/put_worker_secrets.py @@ -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()) diff --git a/web/deploy_relations.py b/web/deploy_relations.py index 352e646..aa53d2e 100644 --- a/web/deploy_relations.py +++ b/web/deploy_relations.py @@ -409,6 +409,42 @@ def bind_domain( 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: 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") @@ -441,6 +477,7 @@ def main() -> None: print(f"account_id: {account_id}") 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: run_export(args.ability_video_base, args.static_asset_base) diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 003db1b..db2925b 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -55,7 +55,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.16" +SITE_VERSION = "0.6.54" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" diff --git a/web/frontend/app.js b/web/frontend/app.js index 61c8acd..7eb2535 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -131,6 +131,7 @@ function siteOrigin() { } const PAGE_SEO_LABELS = { + home: "我", heroes: "英雄克制与搭档", rankings: "Immortal 排行", streamers: "主播", @@ -219,6 +220,9 @@ function describeStateForSeo(st) { } else if (page === "matches") { title = `明星比赛 — ${brand}`; description = "明星选手近期职业与国服对局、终局出装与加点。"; + } else if (page === "home") { + title = `我 — ${brand}`; + description = "Steam 登录后查看本人近期比赛与战绩。"; } else if (page === "players") { const aid = st.playerAccountId; if (aid && st.playerMatchId) { @@ -270,7 +274,9 @@ function clearSeoPrerender() { const state = { data: null, - page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches | players + /** Steam session from /api/auth/me; null until first probe. */ + auth: null, + page: "heroes", // home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players selectedKey: null, selectedItemKey: null, /** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */ @@ -298,6 +304,7 @@ const state = { _playerProfile: null, _playerMatch: null, _playerLoadKey: null, + _playerEnrichKey: null, /** Top-level 走势 page medal bracket */ trendsBracket: "legend", /** Sort key for trends board: wr_end | pr_end */ @@ -806,6 +813,271 @@ async function fetchPlayerJson(accountId, matchId) { return null; } +/** True when the profile has career or recent rows worth painting. */ +function playerProfileHasStats(profile) { + if (!profile || profile.error) return false; + const hasCareer = profile.career && Number(profile.career.games) > 0; + const hasRecent = + (Array.isArray(profile.recent) && profile.recent.length > 0) || + (profile.recent_20 && Number(profile.recent_20.sample) > 0); + return !!(hasCareer || hasRecent); +} + +/** Empty shell → hard sync. TTL stale alone is not empty. */ +function playerProfileNeedsRefresh(profile) { + return !playerProfileHasStats(profile); +} + +function playerProfileIsSoftStale(profile) { + if (!profile) return false; + if (profile.stale) return true; + if (profile.availability && profile.availability.stale) return true; + return false; +} + +/** Local/Pages: backfill profile. force=false uses server TTL cache. */ +async function enrichPlayerProfile( + accountId, + { includeGsi = true, force = false } = {} +) { + const aid = String(accountId || ""); + if (!/^\d+$/.test(aid)) return null; + try { + const res = await fetch("/api/players/enrich", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + account_id: Number(aid), + include_gsi: !!includeGsi, + force: !!force, + }), + }); + if (!res.ok) return null; + const data = await res.json(); + return data && data.profile ? data.profile : null; + } catch (_) { + return null; + } +} + +/** Production: GET /api/players/me (D1). Local serve uses disk TTL cache. */ +async function fetchMyPlayerProfile() { + try { + const res = await fetch("/api/players/me", { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + if (!res.ok) return null; + const data = await res.json(); + if (!data || data.error) return null; + return data; + } catch (_) { + return null; + } +} + +/** Poll /me while Worker/local background enrich fills an empty/stale shell. */ +async function pollMyPlayerProfile({ + attempts = 6, + delayMs = 2500, + accountId = null, +} = {}) { + let last = null; + for (let i = 0; i < attempts; i++) { + if ( + accountId != null && + String(state.playerAccountId || "") !== String(accountId) + ) { + return last; + } + last = await fetchMyPlayerProfile(); + if (last && !playerProfileNeedsRefresh(last)) return last; + if (i + 1 < attempts) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + return last; +} + +const LOBBY_LABELS = { + 0: "普通", + 1: "练习", + 2: "联赛", + 7: "天梯", + 9: "勇士联赛", +}; + +function lobbyLabel(row) { + if (!row) return ""; + const lt = row.lobby_type; + if (lt != null && LOBBY_LABELS[lt]) return LOBBY_LABELS[lt]; + return ""; +} + +function appendStatCards(host, items) { + if (!items.length) return; + const row = document.createElement("div"); + row.className = "players-stat-row"; + for (const it of items) { + if (it.value == null || it.value === "") continue; + const card = document.createElement("div"); + card.className = "players-stat-card"; + card.innerHTML = `${escapeHtml( + it.label + )}${escapeHtml( + String(it.value) + )}`; + row.appendChild(card); + } + if (row.childElementCount) host.appendChild(row); +} + +/** Local-only: ensure match detail JSON exists, then return it. */ +async function ensurePlayerMatch(accountId, matchId) { + const aid = String(accountId || ""); + const mid = String(matchId || ""); + if (!/^\d+$/.test(aid) || !/^\d+$/.test(mid)) { + return { match: null, error: "bad id" }; + } + const existing = await fetchPlayerJson(aid, mid); + if (existing) return { match: existing, error: "" }; + try { + const res = await fetch("/api/players/ensure-match", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ account_id: Number(aid), match_id: Number(mid) }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + return { match: null, error: (data && data.error) || `HTTP ${res.status}` }; + } + return { match: data.match || null, error: data.match ? "" : "empty" }; + } catch (e) { + return { match: null, error: String((e && e.message) || e) }; + } +} + +function openPlayerPage(accountId, matchId) { + const aid = String(accountId || ""); + if (!/^\d+$/.test(aid)) return; + state.page = "players"; + state.playerAccountId = aid; + state.playerMatchId = + matchId != null && /^\d+$/.test(String(matchId)) ? String(matchId) : null; + state._playerLoadKey = null; + state._playerProfile = null; + state._playerMatch = null; + syncStateToUrl(); + render(); +} + +function authAccountId() { + const a = state.auth; + if (!a || !a.authenticated) return null; + const id = a.account_id; + return id != null && /^\d+$/.test(String(id)) ? String(id) : null; +} + +async function fetchAuthMe() { + try { + const res = await fetch("/api/auth/me", { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + state.auth = { authenticated: false }; + return state.auth; + } + const data = await res.json(); + state.auth = + data && data.authenticated + ? { + authenticated: true, + steamid: data.steamid || null, + account_id: data.account_id, + personaname: data.personaname || null, + avatar: data.avatar || null, + } + : { authenticated: false }; + return state.auth; + } catch (_) { + state.auth = { authenticated: false }; + return state.auth; + } +} + +function syncAuthChrome() { + const loginBtn = $("#steam-login-btn"); + const userEl = $("#auth-user"); + const avatar = $("#auth-avatar"); + const nameEl = $("#auth-name"); + const homeTab = $("#tab-home"); + const authed = !!(state.auth && state.auth.authenticated); + if (loginBtn) loginBtn.classList.toggle("hidden", authed); + if (userEl) userEl.classList.toggle("hidden", !authed); + if (homeTab) homeTab.classList.toggle("hidden", !authed); + if (authed && userEl) { + const name = state.auth.personaname || `玩家 ${state.auth.account_id}`; + if (nameEl) nameEl.textContent = name; + if (avatar) { + if (state.auth.avatar) { + avatar.src = state.auth.avatar; + avatar.alt = name; + avatar.classList.remove("hidden"); + } else { + avatar.removeAttribute("src"); + avatar.alt = ""; + avatar.classList.add("hidden"); + } + } + } +} + +async function logoutAuth() { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + } catch (_) { + /* ignore */ + } + state.auth = { authenticated: false }; + if (state.page === "home") { + state.page = "heroes"; + state.playerAccountId = null; + state.playerMatchId = null; + state._playerLoadKey = null; + state._playerProfile = null; + state._playerMatch = null; + state._playerEnrichKey = null; + } + syncAuthChrome(); + syncStateToUrl(); + render(); +} + +function bindAuthChrome() { + const logoutBtn = $("#auth-logout"); + if (logoutBtn) { + logoutBtn.addEventListener("click", (e) => { + e.preventDefault(); + logoutAuth(); + }); + } +} + +function renderHomeGate(root) { + root.replaceChildren(); + const box = document.createElement("div"); + box.className = "rankings-empty auth-home-gate"; + box.innerHTML = + '

登录 Steam 后可查看本人近期比赛与战绩。

' + + '

'; + root.appendChild(box); +} + function itemMeta(id) { const items = state.data.hero_items?.items || {}; return items[String(id)] || null; @@ -1753,12 +2025,12 @@ const RANK_TIER_MEDALS = [ "immortal", ]; -/** OpenDota rank_tier → { medalKey, stars, label, iconFile }. */ +/** OpenDota rank_tier → { medalKey, stars, label, iconFile, starFile }. */ function parseRankTier(tier) { const t = Number(tier); if (!Number.isFinite(t) || t < 10) return null; const medal = Math.min(8, Math.max(1, Math.floor(t / 10))); - const stars = Math.max(0, t % 10); + const stars = Math.max(0, Math.min(5, t % 10)); const medalKey = RANK_TIER_MEDALS[medal]; if (!medalKey) return null; const base = BRACKET_LABELS[medalKey] || medalKey; @@ -1766,16 +2038,50 @@ function parseRankTier(tier) { if (medalKey === "immortal") { label = base; } else if (stars > 0) { - label = `${base} ${stars}`; + label = `${base}${stars}`; } return { medalKey, stars, label, iconFile: BRACKET_RANK_ICON[medalKey] || null, + // OpenDota star overlay (rank_star_1..5); immortal has no stars. + starFile: + medalKey !== "immortal" && stars >= 1 && stars <= 5 + ? `rank_star_${stars}.png` + : null, }; } +/** + * Medal icon (+ optional star overlay) for a concrete rank_tier. + * Text label goes in title/alt only — stars convey 传奇四 etc. visually. + */ +function createRankMedalEl(rank, className = "rank-medal") { + if (!rank || !rank.iconFile) return null; + const wrap = document.createElement("span"); + wrap.className = className; + wrap.title = rank.label; + const base = document.createElement("img"); + base.className = "rank-medal-base"; + base.src = rankIconSrc(rank.iconFile); + base.alt = rank.label; + base.loading = "lazy"; + base.decoding = "async"; + wrap.appendChild(base); + if (rank.starFile) { + const stars = document.createElement("img"); + stars.className = "rank-medal-stars"; + stars.src = rankIconSrc(rank.starFile); + stars.alt = ""; + stars.setAttribute("aria-hidden", "true"); + stars.loading = "lazy"; + stars.decoding = "async"; + wrap.appendChild(stars); + } + return wrap; +} + function matchPlayerDisplayName(row) { // Never show account id. Prefer display_name / pro name (jikroy) / Steam persona. for (const key of ["display_name", "name", "personaname"]) { @@ -1916,16 +2222,11 @@ function buildMatchCard(heroKey, row, opts = {}) { const rankTier = row.rank_tier ?? row.avg_rank_tier; const rank = parseRankTier(rankTier); if (rank?.iconFile) { - const medal = document.createElement("img"); - medal.className = "match-rank-icon"; - medal.src = rankIconSrc(rank.iconFile); let tip = rank.label; if (row.leaderboard_rank) tip += ` #${row.leaderboard_rank}`; if (!row.rank_tier && row.avg_rank_tier) tip += " · 局均段位"; - medal.alt = tip; - medal.title = tip; - medal.loading = "lazy"; - player.appendChild(medal); + const medal = createRankMedalEl({ ...rank, label: tip }, "rank-medal match-rank-medal"); + if (medal) player.appendChild(medal); } const nameEl = document.createElement(row.account_id ? "a" : "span"); @@ -6075,11 +6376,20 @@ function pctLabel(v) { return `${Math.round(Number(v) * 100)}%`; } +function fmtNetWorth(n) { + const v = Number(n); + if (!Number.isFinite(v) || v < 0) return "—"; + return Math.round(v).toLocaleString("zh-CN"); +} + function appendPlayerItemIcons(row, itemIds) { const wrap = document.createElement("div"); wrap.className = "player-match-items"; - (Array.isArray(itemIds) ? itemIds : []).forEach((id) => { - const meta = itemMetaFromId(id); + const ids = Array.isArray(itemIds) ? itemIds.slice(0, 6) : []; + // Always 6 slots so the items column width (and metrics alignment) stays stable. + while (ids.length < 6) ids.push(0); + ids.forEach((id) => { + const meta = id ? itemMetaFromId(id) : null; const cell = document.createElement("span"); cell.className = "player-match-item"; if (meta && meta.key) { @@ -6090,7 +6400,7 @@ function appendPlayerItemIcons(row, itemIds) { cell.appendChild(img); } else { cell.classList.add("empty"); - cell.title = id != null ? String(id) : ""; + cell.title = id ? String(id) : ""; } wrap.appendChild(cell); }); @@ -6099,9 +6409,11 @@ function appendPlayerItemIcons(row, itemIds) { function buildPlayerScoreboardTeam(detail, isRadiant) { const side = document.createElement("section"); - side.className = `player-team ${isRadiant ? "radiant" : "dire"}`; const team = isRadiant ? detail.radiant || {} : detail.dire || {}; const won = Boolean(detail.radiant_win) === isRadiant; + side.className = `player-team ${isRadiant ? "radiant" : "dire"} ${ + won ? "won" : "lost" + }`; const head = document.createElement("header"); head.className = "player-team-head"; head.innerHTML = ` @@ -6143,29 +6455,77 @@ function buildPlayerScoreboardTeam(detail, isRadiant) { meta.className = "player-match-meta"; const nameLine = document.createElement("div"); nameLine.className = "player-match-name"; - nameLine.textContent = p.personaname || heroName; - if (p.is_mvp) { - const badge = document.createElement("span"); - badge.className = "player-mvp-badge"; - badge.textContent = "MVP"; - nameLine.appendChild(badge); + // Prefer Steam persona; never fall back to hero name (hero is in the sub line). + const displayName = p.personaname + ? String(p.personaname) + : p.account_id + ? `玩家 ${p.account_id}` + : "匿名"; + if (p.account_id) { + const link = document.createElement("a"); + link.className = "player-match-name-link"; + link.href = `/players/${p.account_id}`; + link.textContent = displayName; + link.title = p.personaname + ? `${p.personaname} · ${p.account_id}` + : `查看玩家 ${p.account_id}`; + link.addEventListener("click", (ev) => { + ev.preventDefault(); + openPlayerPage(p.account_id); + }); + nameLine.appendChild(link); + } else { + const span = document.createElement("span"); + span.className = "player-match-name-anon"; + span.textContent = displayName; + span.title = "未公开 Steam 昵称"; + nameLine.appendChild(span); } meta.appendChild(nameLine); const sub = document.createElement("div"); sub.className = "player-match-sub"; - sub.textContent = `Lv.${p.level ?? "—"} · ${heroName}`; + const subText = document.createElement("span"); + subText.textContent = `Lv.${p.level ?? "—"} · ${heroName}`; + sub.appendChild(subText); + const badges = document.createElement("span"); + badges.className = "player-match-badges"; + if (p.party_label) { + const party = document.createElement("span"); + party.className = `player-party-badge party-${String(p.party_label).toLowerCase()}`; + party.textContent = `组${p.party_label}`; + party.title = "开黑组队(同 party_id)"; + badges.appendChild(party); + } + if (p.is_mvp) { + const badge = document.createElement("span"); + badge.className = "player-mvp-badge"; + badge.textContent = "MVP"; + badges.appendChild(badge); + } + if (badges.childElementCount) sub.appendChild(badges); meta.appendChild(sub); left.appendChild(meta); row.appendChild(left); const metrics = document.createElement("div"); metrics.className = "player-match-metrics"; - metrics.innerHTML = ` - 参战${pctLabel(p.participation)} - 伤害${pctLabel(p.damage_share)} - KDA${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0} - ${p.kda ?? "—"} - `; + const kdaText = `${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0}`; + const ratio = p.kda != null && p.kda !== "" ? `(${p.kda})` : ""; + const metricBits = [ + ["参战率", "参战", pctLabel(p.participation)], + ["伤害占比", "伤害", pctLabel(p.damage_share)], + ["个人经济(净身价)", "经济", fmtNetWorth(p.net_worth)], + ["击杀/死亡/助攻;(K+A)/D", "KDA", `${kdaText}${ratio}`], + ]; + for (const [title, label, value] of metricBits) { + const span = document.createElement("span"); + span.title = title; + const em = document.createElement("em"); + em.textContent = label; + span.appendChild(em); + span.appendChild(document.createTextNode(String(value))); + metrics.appendChild(span); + } row.appendChild(metrics); appendPlayerItemIcons(row, p.items); list.appendChild(row); @@ -6176,102 +6536,506 @@ function buildPlayerScoreboardTeam(detail, isRadiant) { function renderPlayerMatchDetail(root, detail) { root.replaceChildren(); + const shell = document.createElement("div"); + shell.className = "players-match-shell"; + const back = document.createElement("button"); back.type = "button"; back.className = "players-back"; - back.textContent = "← 返回主页"; + back.setAttribute("aria-label", "返回玩家主页"); + back.innerHTML = ` + + 返回玩家主页 + `; back.addEventListener("click", () => { state.playerMatchId = null; state._playerMatch = null; + state._playerLoadKey = null; syncStateToUrl(); render(); }); - root.appendChild(back); + shell.appendChild(back); const head = document.createElement("header"); head.className = "players-match-head"; - const dur = fmtDuration(detail.duration); - head.innerHTML = ` -

比赛 ${detail.match_id}

-

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

+ const when = formatFriendlyTime(detail.start_time); + const winner = detail.radiant_win ? "天辉胜" : "夜魇胜"; + const summary = document.createElement("div"); + summary.className = "players-match-summary"; + const h2 = document.createElement("h2"); + h2.className = "page-title"; + h2.textContent = `比赛 ${detail.match_id}`; + summary.appendChild(h2); + const sub = document.createElement("p"); + sub.className = "page-sub"; + if (when.title) sub.title = when.title; + sub.innerHTML = ` + ${when.text ? escapeHtml(when.text) : "—"} + 时长 ${escapeHtml(fmtDuration(detail.duration))} + ${winner} `; - root.appendChild(head); + summary.appendChild(sub); + head.appendChild(summary); + shell.appendChild(head); const board = document.createElement("div"); board.className = "player-scoreboard"; + const columns = document.createElement("div"); + columns.className = "player-scoreboard-columns"; + columns.innerHTML = ` + 玩家 / 英雄 + + 参战伤害经济KDA + + 装备 + `; + board.appendChild(columns); board.appendChild(buildPlayerScoreboardTeam(detail, true)); board.appendChild(buildPlayerScoreboardTeam(detail, false)); - root.appendChild(board); + shell.appendChild(board); + root.appendChild(shell); } function renderPlayerProfile(root, profile) { root.replaceChildren(); + const wrap = document.createElement("div"); + wrap.className = "players-home"; + + // --- Identity strip --- const head = document.createElement("header"); - head.className = "players-profile-head"; - const name = profile.personaname || `玩家 ${profile.account_id}`; - head.innerHTML = ` -

${name}

-

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

- `; - root.appendChild(head); - - const recent = Array.isArray(profile.recent) ? profile.recent : []; - if (!recent.length) { - const empty = document.createElement("div"); - empty.className = "rankings-empty"; - empty.textContent = "暂无近期比赛"; - root.appendChild(empty); - return; + head.className = "players-identity"; + const authName = + state.page === "home" && state.auth && state.auth.personaname + ? String(state.auth.personaname).trim() + : ""; + const authAvatar = + state.page === "home" && state.auth && state.auth.avatar + ? String(state.auth.avatar) + : ""; + const name = + (profile.personaname && String(profile.personaname).trim()) || + authName || + `玩家 ${profile.account_id}`; + const avatarUrl = profile.avatar || authAvatar; + if (avatarUrl) { + const av = document.createElement("img"); + av.className = "players-identity-avatar"; + av.src = avatarUrl; + av.alt = name; + av.width = 56; + av.height = 56; + head.appendChild(av); } - - const list = document.createElement("div"); - list.className = "players-recent"; - for (const row of recent) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = `players-recent-row ${row.won ? "won" : "lost"}`; - const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key; - const heroName = - row.hero_name_loc || - (heroById(row.hero_id) || {}).name_loc || - heroKey || - "—"; - if (heroKey) { - const img = document.createElement("img"); - img.className = "players-recent-portrait"; - img.src = portraitSrc(heroKey); - img.alt = heroName; - btn.appendChild(img); + const idBody = document.createElement("div"); + idBody.className = "players-identity-body"; + const titleRow = document.createElement("div"); + titleRow.className = "players-profile-title"; + const h2 = document.createElement("h2"); + h2.className = "page-title"; + h2.textContent = name; + titleRow.appendChild(h2); + const rank = parseRankTier(profile.rank_tier); + if (rank && rank.iconFile) { + let tip = rank.label; + if (profile.leaderboard_rank) tip += ` · 榜 #${profile.leaderboard_rank}`; + const medal = createRankMedalEl( + { ...rank, label: tip }, + "rank-medal players-rank-medal" + ); + if (medal) titleRow.appendChild(medal); + if (rank.medalKey === "immortal" && profile.leaderboard_rank) { + const board = document.createElement("span"); + board.className = "players-rank-board"; + board.textContent = `#${profile.leaderboard_rank}`; + board.title = tip; + titleRow.appendChild(board); } - const body = document.createElement("div"); - body.className = "players-recent-body"; - body.innerHTML = ` -
- ${heroName} - ${row.won ? "胜利" : "失败"} -
-
- ${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0} - ${fmtDuration(row.duration)} - #${row.match_id} -
- `; - btn.appendChild(body); - btn.addEventListener("click", () => { - state.playerMatchId = String(row.match_id); - state._playerMatch = null; - syncStateToUrl(); - render(); - }); - list.appendChild(btn); } - root.appendChild(list); + idBody.appendChild(titleRow); + const avail = profile.availability || {}; + const sub = document.createElement("p"); + sub.className = "page-sub"; + const bits = [`ID ${profile.account_id}`]; + if (profile.public_share) bits.push("已公开主页"); + if (avail.note) bits.push(avail.note); + else if (avail.status === "syncing") bits.push("Steam 已公开,OpenDota 同步中"); + if (profile.stale || (avail && avail.stale)) bits.push("刷新中"); + sub.textContent = bits.join(" · "); + idBody.appendChild(sub); + head.appendChild(idBody); + wrap.appendChild(head); + + // --- Snapshot: career + recent_20 side by side --- + const career = profile.career; + const r20 = profile.recent_20; + const hasCareer = career && career.games > 0; + const hasR20 = r20 && r20.sample > 0; + if (hasCareer || hasR20) { + const snap = document.createElement("section"); + snap.className = "players-snapshot"; + if (hasCareer) { + const col = document.createElement("div"); + col.className = "players-snapshot-col"; + col.innerHTML = `

生涯

`; + appendStatCards(col, [ + { label: "场次", value: career.games }, + { + label: "胜率", + value: career.winrate != null ? `${career.winrate}%` : null, + }, + { label: "KDA", value: career.kda }, + { label: "场均GPM", value: career.avg_gpm }, + { label: "场均XPM", value: career.avg_xpm }, + ]); + snap.appendChild(col); + } + if (hasR20) { + const col = document.createElement("div"); + col.className = "players-snapshot-col"; + col.innerHTML = `

近 ${r20.sample} 场

`; + appendStatCards(col, [ + { label: "胜负", value: `${r20.wins}-${r20.losses}` }, + { + label: "胜率", + value: r20.winrate != null ? `${r20.winrate}%` : null, + }, + { label: "KDA", value: r20.kda }, + { label: "场均GPM", value: r20.avg_gpm }, + { label: "场均XPM", value: r20.avg_xpm }, + ]); + snap.appendChild(col); + } + wrap.appendChild(snap); + } + + // --- Analysis: top heroes | activity / peers --- + const topHeroes = Array.isArray(profile.top_heroes) ? profile.top_heroes : []; + const activity = profile.activity_180; + const peers = Array.isArray(profile.peers) ? profile.peers : []; + const hasActivity = activity && activity.sample > 0; + if (topHeroes.length || hasActivity || peers.length) { + const analysis = document.createElement("section"); + analysis.className = "players-analysis"; + + if (topHeroes.length) { + const sec = document.createElement("div"); + sec.className = "players-analysis-block"; + sec.innerHTML = `

常用英雄

`; + const list = document.createElement("div"); + list.className = "players-top-heroes"; + for (const h of topHeroes.slice(0, 5)) { + const key = h.hero_key || (heroById(h.hero_id) || {}).key; + const row = document.createElement("div"); + row.className = "players-top-hero"; + if (key) { + const img = document.createElement("img"); + img.src = portraitSrc(key); + img.alt = h.hero_name_loc || key; + row.appendChild(img); + } + const meta = document.createElement("div"); + meta.innerHTML = `${escapeHtml( + h.hero_name_loc || key || "—" + )}${h.games} 场 · 胜率 ${ + h.winrate != null ? h.winrate + "%" : "—" + }`; + row.appendChild(meta); + list.appendChild(row); + } + sec.appendChild(list); + analysis.appendChild(sec); + } + + const side = document.createElement("div"); + side.className = "players-analysis-side"; + if (hasActivity) { + const sec = document.createElement("div"); + sec.className = "players-analysis-block"; + sec.innerHTML = `

${escapeHtml( + activity.label || "最近 180 天" + )}

`; + appendStatCards(sec, [ + { label: "样本场次", value: activity.sample }, + { + label: "胜率", + value: activity.winrate != null ? `${activity.winrate}%` : null, + }, + ]); + if (activity.highs) { + const highs = document.createElement("div"); + highs.className = "players-highs"; + const hb = []; + if (activity.highs.kills) hb.push(`最高击杀 ${activity.highs.kills.value}`); + if (activity.highs.assists) { + hb.push(`最高助攻 ${activity.highs.assists.value}`); + } + if (activity.highs.gpm) hb.push(`最高GPM ${activity.highs.gpm.value}`); + highs.textContent = hb.join(" · "); + if (hb.length) sec.appendChild(highs); + } + if (Array.isArray(activity.heatmap) && activity.heatmap.length) { + const heatWrap = document.createElement("div"); + heatWrap.className = "players-heatmap-wrap"; + const heat = document.createElement("div"); + heat.className = "players-heatmap"; + heat.title = "每天场次(最近 180 天)"; + heat.setAttribute("aria-label", "最近 180 天每天比赛场次"); + const maxG = Math.max( + 1, + ...activity.heatmap.map((d) => Number(d.games) || 0) + ); + for (const d of activity.heatmap) { + const cell = document.createElement("span"); + const g = Number(d.games) || 0; + const level = g <= 0 ? 0 : Math.min(4, Math.ceil((g / maxG) * 4)); + cell.className = `players-heat-cell lv${level}`; + cell.title = `${d.date}: ${g} 场`; + heat.appendChild(cell); + } + heatWrap.appendChild(heat); + const legend = document.createElement("div"); + legend.className = "players-heatmap-legend"; + legend.innerHTML = ` + + + + + + + + `; + heatWrap.appendChild(legend); + sec.appendChild(heatWrap); + } + side.appendChild(sec); + } + if (peers.length) { + const sec = document.createElement("div"); + sec.className = "players-analysis-block"; + sec.innerHTML = `

队友

`; + const list = document.createElement("div"); + list.className = "players-peers"; + for (const p of peers.slice(0, 6)) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "players-peer"; + if (p.avatar) { + const img = document.createElement("img"); + img.src = p.avatar; + img.alt = ""; + row.appendChild(img); + } + const meta = document.createElement("div"); + meta.innerHTML = `${escapeHtml( + p.personaname || "玩家" + )}${p.games} 场 · 胜率 ${ + p.winrate != null ? p.winrate + "%" : "—" + }`; + row.appendChild(meta); + if (p.account_id) { + row.addEventListener("click", () => openPlayerPage(p.account_id)); + } + list.appendChild(row); + } + sec.appendChild(list); + side.appendChild(sec); + } + if (side.childElementCount) analysis.appendChild(side); + wrap.appendChild(analysis); + } + + const status = document.createElement("div"); + status.className = "players-enrich-status rankings-empty"; + status.hidden = true; + wrap.appendChild(status); + + // --- Recent matches (primary list) --- + const listSec = document.createElement("section"); + listSec.className = "players-section players-recent-section"; + listSec.innerHTML = `

近期比赛

`; + const listHost = document.createElement("div"); + listHost.className = "players-recent-host"; + listSec.appendChild(listHost); + wrap.appendChild(listSec); + root.appendChild(wrap); + + const paintRecent = (recentRows) => { + listHost.replaceChildren(); + const recent = Array.isArray(recentRows) ? recentRows : []; + if (!recent.length) { + const empty = document.createElement("div"); + empty.className = "rankings-empty"; + empty.textContent = + (profile.availability && profile.availability.note) || + "暂无近期比赛。请在 Dota 2 设置中开启「公开比赛数据」;隐私局仍需本机 GSI 录像。"; + listHost.appendChild(empty); + return; + } + const list = document.createElement("div"); + list.className = "players-recent"; + for (const row of recent) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `players-recent-row ${row.won ? "won" : "lost"}`; + const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key; + const heroName = + row.hero_name_loc || + (heroById(row.hero_id) || {}).name_loc || + heroKey || + "—"; + if (heroKey) { + const img = document.createElement("img"); + img.className = "players-recent-portrait"; + img.src = portraitSrc(heroKey); + img.alt = heroName; + btn.appendChild(img); + } + const body = document.createElement("div"); + body.className = "players-recent-body"; + const when = formatFriendlyTime(row.start_time); + const kdaText = `${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}${ + row.kda != null ? `(${row.kda})` : "" + }`; + const tipBits = [kdaText]; + const mode = lobbyLabel(row); + if (mode) tipBits.push(mode); + if (row.gpm != null) tipBits.push(`GPM ${row.gpm}`); + if (row.xpm != null) tipBits.push(`XPM ${row.xpm}`); + const kdaTip = tipBits.join(" · "); + // hero/KDA (grows left) | duration/when | result/id (tight right cluster) + body.innerHTML = ` +
+ ${escapeHtml(heroName)} + ${escapeHtml(kdaText)} +
+
+ ${fmtDuration(row.duration)} + ${when.text ? escapeHtml(when.text) : "—"} +
+
+ ${ + row.won ? "胜利" : "失败" + } + #${row.match_id} +
+ `; + btn.appendChild(body); + btn.addEventListener("click", async () => { + status.hidden = false; + status.textContent = "加载比赛详情…"; + const aid = String(profile.account_id); + const { match, error } = await ensurePlayerMatch(aid, row.match_id); + if ( + String(state.playerAccountId) !== aid || + (state.page !== "players" && state.page !== "home") + ) { + return; + } + if (!match) { + status.textContent = error + ? `无法加载:${error}` + : "无法加载该场(OpenDota 暂无或隐私)"; + return; + } + status.hidden = true; + state.playerMatchId = String(row.match_id); + state._playerMatch = match; + state._playerLoadKey = `${aid}:${row.match_id}`; + syncStateToUrl(); + render(); + }); + list.appendChild(btn); + } + listHost.appendChild(list); + }; + + paintRecent(profile.recent); + + const enrichKey = `enrich:${profile.account_id}`; + if (state._playerEnrichKey !== enrichKey) { + state._playerEnrichKey = enrichKey; + const aid = profile.account_id; + // Has stats: keep first paint. Soft TTL refresh is silent (Queue / local bg). + if (!playerProfileNeedsRefresh(profile)) { + status.hidden = true; + if (playerProfileIsSoftStale(profile) && state.page === "home") { + void (async () => { + const updated = await fetchMyPlayerProfile(); + if (!updated || !playerProfileHasStats(updated)) return; + if (String(state.playerAccountId) !== String(aid)) return; + if (state.playerMatchId) return; + if ( + updated.enriched_at === profile.enriched_at && + (updated.recent || []).length === (profile.recent || []).length + ) { + return; + } + state._playerProfile = updated; + renderPlayerProfile(root, updated); + })(); + } + return; + } + // Empty shell only: poll Worker/local fill, then one local enrich. + status.hidden = false; + status.textContent = "正在同步战绩…"; + (async () => { + let updated = null; + if (state.page === "home") { + updated = await pollMyPlayerProfile({ accountId: aid }); + } + if (!updated || playerProfileNeedsRefresh(updated)) { + updated = await enrichPlayerProfile(aid, { + includeGsi: true, + force: false, + }); + } + if (String(state.playerAccountId) !== String(aid)) return; + if (state.playerMatchId) return; + if (updated && playerProfileHasStats(updated)) { + state._playerProfile = updated; + // Keep loadKey — never flash「加载中…」for a background sync. + renderPlayerProfile(root, updated); + return; + } + status.hidden = true; + })(); + } } function renderPlayersPage() { const root = $("#players-body"); if (!root) return; + if (state.page === "home") { + const aid = authAccountId(); + if (!aid) { + renderHomeGate(root); + return; + } + if (String(state.playerAccountId || "") !== aid) { + state.playerAccountId = aid; + state._playerLoadKey = null; + state._playerProfile = null; + state._playerMatch = null; + state._playerEnrichKey = null; + } + } + const accountId = state.playerAccountId; if (!accountId) { root.innerHTML = @@ -6302,9 +7066,25 @@ function renderPlayersPage() { let match = null; try { if (state.playerMatchId) { - match = await fetchPlayerJson(accountId, state.playerMatchId); + const ensured = await ensurePlayerMatch(accountId, state.playerMatchId); + match = ensured.match; + if (!match) { + match = await fetchPlayerJson(accountId, state.playerMatchId); + } + } else if (state.page === "home") { + // Leave「加载中」as soon as /me returns (even an empty syncing shell). + // Hard enrich runs after first paint inside renderPlayerProfile. + profile = await fetchMyPlayerProfile(); } else { profile = await fetchPlayerJson(accountId); + if (!profile) { + // Cold miss on /players/{id}: sync once (TTL on later visits). + profile = await enrichPlayerProfile(accountId, { + includeGsi: true, + force: false, + }); + if (profile && profile.error) profile = null; + } } } catch (_) { /* empty */ @@ -6321,6 +7101,7 @@ function renderPlayersPage() { function setPage(page) { if ( + page !== "home" && page !== "heroes" && page !== "rankings" && page !== "matches" && @@ -6344,6 +7125,19 @@ function setPage(page) { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; + } else if (page === "home") { + state.selectedKey = null; + state.selectedItemKey = null; + state.inspect = null; + const aid = authAccountId(); + if (String(state.playerAccountId || "") !== String(aid || "")) { + state._playerLoadKey = null; + state._playerProfile = null; + state._playerMatch = null; + state._playerEnrichKey = null; + } + state.playerAccountId = aid; + state.playerMatchId = null; } else if (page === "players") { state.selectedKey = null; state.selectedItemKey = null; @@ -6373,6 +7167,7 @@ function setPage(page) { } function syncChrome() { + syncAuthChrome(); document.querySelectorAll(".main-tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.page === state.page); }); @@ -6389,6 +7184,7 @@ function syncChrome() { const mechanicsView = $("#mechanics-view"); const itemsView = $("#items-view"); const patchesView = $("#patches-view"); + const showPlayers = state.page === "players" || state.page === "home"; if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes"); if (detailDrawer) detailDrawer.classList.toggle("hidden", state.page !== "heroes"); if (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes"); @@ -6396,7 +7192,7 @@ function syncChrome() { if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes"); if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings"); if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches"); - if (playersView) playersView.classList.toggle("hidden", state.page !== "players"); + if (playersView) playersView.classList.toggle("hidden", !showPlayers); if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers"); if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends"); if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics"); @@ -6416,7 +7212,7 @@ function render() { renderRankings(); } else if (state.page === "matches") { renderMatchesPage(); - } else if (state.page === "players") { + } else if (state.page === "players" || state.page === "home") { renderPlayersPage(); } else if (state.page === "streamers") { renderStreamers(); @@ -6453,6 +7249,7 @@ function applyPatch(patch) { if ( patch.page && [ + "home", "heroes", "rankings", "matches", @@ -6540,12 +7337,17 @@ function applyPatch(patch) { state.matchesPage = Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1; } - // PC post-match player pages. - if (state.page === "players") { - const nextAccount = - patch.playerAccountId && /^\d+$/.test(String(patch.playerAccountId)) - ? String(patch.playerAccountId) - : null; + // PC post-match player pages + logged-in /home. + if (state.page === "players" || state.page === "home") { + let nextAccount = null; + if (state.page === "home") { + nextAccount = authAccountId(); + } else if ( + patch.playerAccountId && + /^\d+$/.test(String(patch.playerAccountId)) + ) { + nextAccount = String(patch.playerAccountId); + } const nextMatch = patch.playerMatchId && /^\d+$/.test(String(patch.playerMatchId)) ? String(patch.playerMatchId) @@ -6557,6 +7359,9 @@ function applyPatch(patch) { state._playerLoadKey = null; state._playerProfile = null; state._playerMatch = null; + if (nextAccount !== state.playerAccountId) { + state._playerEnrichKey = null; + } } state.playerAccountId = nextAccount; state.playerMatchId = nextMatch; @@ -6566,6 +7371,7 @@ function applyPatch(patch) { state._playerLoadKey = null; state._playerProfile = null; state._playerMatch = null; + state._playerEnrichKey = null; } // Item (items page) — must exist in the shop catalog. if (patch.itemKey && shopItem(patch.itemKey)) { @@ -6721,6 +7527,7 @@ async function main() { ); bindSearch(); bindPageChrome(); + bindAuthChrome(); bindHeroesDismiss(); const brandLogo = document.querySelector(".brand-logo"); if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png"); @@ -6730,6 +7537,8 @@ async function main() { const res = await fetch("/data.json"); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); state.data = await res.json(); + await fetchAuthMe(); + syncAuthChrome(); state.data.relations = state.data.relations || { counters: [], synergies: [] }; state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} }; state.data.hero_stats = state.data.hero_stats || { diff --git a/web/frontend/config.js b/web/frontend/config.js index 44b21b9..555afa3 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.16"; +var SITE_VERSION = "0.6.54"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/functions/api/auth/_steam_common.js b/web/frontend/functions/api/auth/_steam_common.js new file mode 100644 index 0000000..5de2822 --- /dev/null +++ b/web/frontend/functions/api/auth/_steam_common.js @@ -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); +} diff --git a/web/frontend/functions/api/auth/logout.js b/web/frontend/functions/api/auth/logout.js new file mode 100644 index 0000000..6e4afa2 --- /dev/null +++ b/web/frontend/functions/api/auth/logout.js @@ -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); +} diff --git a/web/frontend/functions/api/auth/me.js b/web/frontend/functions/api/auth/me.js new file mode 100644 index 0000000..9d52dab --- /dev/null +++ b/web/frontend/functions/api/auth/me.js @@ -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 }); + } +} diff --git a/web/frontend/functions/api/auth/steam.js b/web/frontend/functions/api/auth/steam.js new file mode 100644 index 0000000..4eea0b8 --- /dev/null +++ b/web/frontend/functions/api/auth/steam.js @@ -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); +} diff --git a/web/frontend/functions/api/auth/steam/callback.js b/web/frontend/functions/api/auth/steam/callback.js new file mode 100644 index 0000000..0d7aacc --- /dev/null +++ b/web/frontend/functions/api/auth/steam/callback.js @@ -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", + }, + }); +} diff --git a/web/frontend/functions/api/players/[account_id].js b/web/frontend/functions/api/players/[account_id].js new file mode 100644 index 0000000..8a4efa0 --- /dev/null +++ b/web/frontend/functions/api/players/[account_id].js @@ -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 + ); + } +} diff --git a/web/frontend/functions/api/players/[account_id]/[match_id].js b/web/frontend/functions/api/players/[account_id]/[match_id].js new file mode 100644 index 0000000..869b932 --- /dev/null +++ b/web/frontend/functions/api/players/[account_id]/[match_id].js @@ -0,0 +1,49 @@ +/** + * GET /api/players/:account_id/:match_id — match detail from R2 (authz via D1). + */ +import { sessionFromRequest } from "../../auth/_steam_common.js"; +import { jsonResponse } from "../_db.js"; + +export async function onRequestGet(context) { + try { + const env = context.env || {}; + if (!env.DB) return jsonResponse({ error: "database not configured" }, 503); + const accountId = Number(context.params && context.params.account_id); + const matchId = Number(context.params && context.params.match_id); + if (!Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(matchId)) { + return jsonResponse({ error: "bad ids" }, 400); + } + const session = await sessionFromRequest(context); + const self = session && Number(session.account_id) === accountId; + const user = await env.DB.prepare( + `SELECT public_share FROM users WHERE account_id = ?` + ) + .bind(accountId) + .first(); + if (!user) return jsonResponse({ error: "not found" }, 404); + if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404); + + const row = await env.DB.prepare( + `SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?` + ) + .bind(accountId, matchId) + .first(); + const key = (row && row.r2_key) || `matches/${matchId}.json`; + if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503); + const obj = await env.MATCHES.get(key); + if (!obj) return jsonResponse({ error: "match not found" }, 404); + const text = await obj.text(); + return new Response(text, { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } catch (e) { + return jsonResponse( + { error: "match get failed", detail: String((e && e.message) || e) }, + 500 + ); + } +} \ No newline at end of file diff --git a/web/frontend/functions/api/players/_db.js b/web/frontend/functions/api/players/_db.js new file mode 100644 index 0000000..b78284d --- /dev/null +++ b/web/frontend/functions/api/players/_db.js @@ -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; +} diff --git a/web/frontend/functions/api/players/me.js b/web/frontend/functions/api/players/me.js new file mode 100644 index 0000000..2ad8eb6 --- /dev/null +++ b/web/frontend/functions/api/players/me.js @@ -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 + ); + } +} diff --git a/web/frontend/functions/api/players/publish.js b/web/frontend/functions/api/players/publish.js index 3139d92..8cd86a9 100644 --- a/web/frontend/functions/api/players/publish.js +++ b/web/frontend/functions/api/players/publish.js @@ -1,35 +1,12 @@ /** - * Pages Function: POST /api/players/publish + * POST /api/players/publish * * 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 - * 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. + * Enqueues Cloudflare Queue sync (D1 + R2). No longer writes OSS profile JSON. */ - -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, - }, - }); -} +import { enqueueRefresh, jsonResponse } from "./_db.js"; function envOf(context) { return (context && context.env) || {}; @@ -40,278 +17,14 @@ function intField(v, fallback = 0) { return Number.isFinite(n) ? Math.trunc(n) : fallback; } -function kda(kills, deaths, assists) { - return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10; -} - -function mvpScore(p) { - const k = intField(p.kills); - const d = intField(p.deaths); - const a = intField(p.assists); - const dmg = intField(p.hero_damage); - const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent); - return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1); -} - -function itemIds(player) { - const out = []; - for (let i = 0; i < 6; i++) { - const id = intField(player[`item_${i}`]); - if (id > 0) out.push(id); - } - return out; -} - -function accountInMatch(match, accountId) { - const players = match.players || []; - for (const p of players) { - if (p && intField(p.account_id, -1) === accountId) return true; - } - return false; -} - -function utcNow() { - return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); -} - -async function fetchJson(url, { headers } = {}) { - const res = await fetch(url, { - headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) }, - }); - if (!res.ok) { - const err = new Error(`HTTP ${res.status}`); - err.status = res.status; - throw err; - } - return res.json(); -} - -async function loadHeroMap(opendotaKey) { - const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : ""; - try { - const rows = await fetchJson(`${OPENDOTA}/heroes${q}`); - const map = new Map(); - if (Array.isArray(rows)) { - for (const h of rows) { - if (!h || h.id == null) continue; - const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null; - map.set(intField(h.id), { - key, - name_loc: h.localized_name || key, - }); - } - } - return map; - } catch { - return new Map(); - } -} - -function normalizeMatch(match, focusAccountId, heroMap) { - const playersRaw = match.players; - if (!Array.isArray(playersRaw) || !playersRaw.length) return null; - const matchId = intField(match.match_id); - if (matchId <= 0) return null; - - const radiantWin = !!match.radiant_win; - const teamKills = [0, 0]; - const teamNw = [0, 0]; - const teamDmg = [0, 0]; - const slim = []; - - for (const p of playersRaw) { - if (!p || typeof p !== "object") continue; - const slot = intField(p.player_slot); - const isRadiant = slot < 128; - const side = isRadiant ? 0 : 1; - const kills = intField(p.kills); - const deaths = intField(p.deaths); - const assists = intField(p.assists); - const heroDamage = intField(p.hero_damage); - let netWorth = intField(p.net_worth); - if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent); - teamKills[side] += kills; - teamNw[side] += netWorth; - teamDmg[side] += heroDamage; - - const heroId = intField(p.hero_id); - const hero = heroMap.get(heroId) || {}; - let accountId = null; - if (p.account_id != null) { - const a = intField(p.account_id, -1); - accountId = a >= 0 ? a : null; - } - let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null; - if (!personaname) personaname = null; - - slim.push({ - account_id: accountId, - personaname, - hero_id: heroId, - hero_key: hero.key || null, - hero_name_loc: hero.name_loc || hero.key || null, - level: intField(p.level), - kills, - deaths, - assists, - kda: kda(kills, deaths, assists), - hero_damage: heroDamage, - net_worth: netWorth, - items: itemIds(p), - is_radiant: isRadiant, - won: isRadiant ? radiantWin : !radiantWin, - _mvp: mvpScore(p), - _side: side, - }); - } - - if (slim.length < 2) return null; - - for (const p of slim) { - const side = p._side; - const tk = teamKills[side] || 1; - const td = teamDmg[side] || 1; - p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000; - p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000; - } - - let mvp = slim[0]; - for (const p of slim) { - if (p._mvp > mvp._mvp) mvp = p; - } - const mvpAccount = mvp.account_id; - for (const p of slim) { - p.is_mvp = mvpAccount != null && p.account_id === mvpAccount; - delete p._mvp; - delete p._side; - } - - let startTime = null; - if (match.start_time != null) { - const t = intField(match.start_time, -1); - startTime = t >= 0 ? t : null; - } - - return { - match_id: matchId, - start_time: startTime, - duration: intField(match.duration), - radiant_win: radiantWin, - radiant: { kills: teamKills[0], net_worth: teamNw[0] }, - dire: { kills: teamKills[1], net_worth: teamNw[1] }, - mvp_account_id: mvpAccount, - players: slim, - focus_account_id: focusAccountId, - fetched_at: utcNow(), - source: "opendota", - }; -} - -function summaryForProfile(detail, accountId) { - const focus = (detail.players || []).find((p) => p.account_id === accountId); - if (!focus) return null; - return { - match_id: detail.match_id, - start_time: detail.start_time, - duration: detail.duration, - won: !!focus.won, - hero_id: focus.hero_id, - hero_key: focus.hero_key, - hero_name_loc: focus.hero_name_loc, - kills: focus.kills, - deaths: focus.deaths, - assists: focus.assists, - kda: focus.kda, - }; -} - -async function ossGetJson(env, key) { - const bucket = env.OSS_BUCKET || DEFAULT_BUCKET; - const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT; - const url = `https://${bucket}.${endpoint}/${key}`; - try { - const res = await fetch(url, { headers: { Accept: "application/json" } }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } -} - -async function hmacSha1Base64(secret, stringToSign) { - const enc = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - enc.encode(secret), - { name: "HMAC", hash: "SHA-1" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign)); - const bytes = new Uint8Array(sig); - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); - return btoa(bin); -} - -async function ossPutJson(env, key, obj) { - const accessKeyId = env.OSS_ACCESS_KEY_ID; - const accessKeySecret = env.OSS_ACCESS_KEY_SECRET; - if (!accessKeyId || !accessKeySecret) { - const err = new Error("OSS credentials missing"); - err.status = 503; - throw err; - } - const bucket = env.OSS_BUCKET || DEFAULT_BUCKET; - const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT; - const body = JSON.stringify(obj); - const contentType = "application/json; charset=utf-8"; - const date = new Date().toUTCString(); - const resource = `/${bucket}/${key}`; - // Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL). - const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`; - const signature = await hmacSha1Base64(accessKeySecret, stringToSign); - const url = `https://${bucket}.${endpoint}/${key}`; - const res = await fetch(url, { - method: "PUT", - headers: { - "Content-Type": contentType, - Date: date, - Authorization: `OSS ${accessKeyId}:${signature}`, - "Cache-Control": "public, max-age=60", - }, - body, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`); - err.status = 502; - throw err; - } -} - -function mergeProfile(existing, accountId, summary, personaname) { - const profile = - existing && typeof existing === "object" - ? { ...existing } - : { account_id: accountId, personaname: null, recent: [] }; - let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : []; - recent = recent.filter((r) => intField(r.match_id) !== summary.match_id); - recent.unshift(summary); - profile.recent = recent.slice(0, RECENT_LIMIT); - profile.account_id = accountId; - if (personaname) profile.personaname = personaname; - profile.public_share = true; - profile.updated_at = utcNow(); - return profile; -} - export async function onRequestPost(context) { try { const env = envOf(context); const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim(); if (expected) { - const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim(); + const got = ( + context.request.headers.get("X-Climperor-Publish-Secret") || "" + ).trim(); if (got !== expected) { return jsonResponse({ error: "forbidden" }, 403); } @@ -330,70 +43,42 @@ export async function onRequestPost(context) { return jsonResponse({ error: "account_id and match_id required" }, 400); } - const odKey = (env.OPENDOTA_API_KEY || "").trim(); - const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : ""; - let match; - try { - match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`); - } catch (e) { - if (e && e.status === 404) { - return jsonResponse( - { ok: false, pending: true, message: "match not ready on OpenDota" }, - 202 - ); - } - return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502); + if (!env.SYNC_QUEUE) { + return jsonResponse({ error: "sync queue not configured" }, 503); } - if (!match || !Array.isArray(match.players) || !match.players.length) { - return jsonResponse( - { ok: false, pending: true, message: "match incomplete on OpenDota" }, - 202 - ); + // Mark public_share when PC publishes intentionally. + if (env.DB) { + const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + 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)) { - return jsonResponse({ error: "account not in match" }, 403); - } - - const heroMap = await loadHeroMap(odKey); - const detail = normalizeMatch(match, accountId, heroMap); - if (!detail) { - return jsonResponse({ error: "normalize failed" }, 500); - } - const summary = summaryForProfile(detail, accountId); - if (!summary) { - return jsonResponse({ error: "focus player missing" }, 500); - } - - let personaname = null; - for (const p of detail.players) { - if (p.account_id === accountId && p.personaname) { - personaname = p.personaname; - break; - } - } - - const profileKey = `players/${accountId}/profile.json`; - const matchKey = `players/${accountId}/matches/${matchId}.json`; - const existing = await ossGetJson(env, profileKey); - const profile = mergeProfile(existing, accountId, summary, personaname); - - await ossPutJson(env, matchKey, detail); - await ossPutJson(env, profileKey, profile); - - return jsonResponse({ - ok: true, + const queued = await enqueueRefresh(env, { + kind: "publish_match", + account_id: accountId, + match_id: matchId, + public_share: true, + }); + if (!queued) { + return jsonResponse({ error: "enqueue failed" }, 503); + } + return jsonResponse({ + ok: true, + queued: true, account_id: accountId, match_id: matchId, - profile_key: profileKey, - match_key: matchKey, }); } catch (e) { - const status = (e && e.status) || 500; return jsonResponse( { error: "publish failed", detail: String((e && e.message) || e) }, - status >= 400 && status < 600 ? status : 500 + 500 ); } } diff --git a/web/frontend/index.html b/web/frontend/index.html index f99c49e..5042b11 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

DOTA2 上分帝

@@ -87,6 +87,7 @@ 上分帝