Compare commits

..
2 Commits
Author SHA1 Message Date
vosonandCursor fdd926e9bb v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:48:01 +08:00
vosonandCursor 28858c0703 v0.5.111: sort streamers by live status then follower count.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:32:01 +08:00
13 changed files with 324 additions and 58 deletions
+4 -4
View File
@@ -73,7 +73,7 @@ climperor/
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range | | `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base``config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) | | `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base``config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env | | `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳的业务摘要有变更才 OSS upload + `deploy_relations.py`;写 `web/.refresh/summary.json`(步骤/耗时/stale/部署);同 runner 文件锁串行发布,patch 遇全量刷新则延后`--dry-run` / `--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品;`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears | | `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳`is_live` 的业务摘要有变更才 OSS upload + `deploy_relations.py`(亦监视 frontend / `relations.json` / `heroes.json` / 网格顺序);写 `web/.refresh/summary.json`(步骤/耗时/stale/部署`--dry-run` 只打印不写盘、不 restore/save 缓存);同 runner 文件锁串行发布,patch 遇全量刷新则延后并标 `skipped``--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品 + counter`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears |
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 | | `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) | | `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`Secrets`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions | | `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`Secrets`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
@@ -89,7 +89,7 @@ climperor/
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--players` 覆盖整文件;`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs`;**仅上分帝 Web「比赛」**;勿进 recommend | | `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--players` 覆盖整文件;`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs`;**仅上分帝 Web「比赛」**;勿进 recommend |
| `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend | | `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend |
| `web/fetch_streamers.py` | 从抖音主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞/头像(手工名单 + 直播间/主页 URL;支持 `v.douyin.com` 短链;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily | | `web/fetch_streamers.py` | 从抖音主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞/头像(手工名单 + 直播间/主页 URL;支持 `v.douyin.com` 短链;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily |
| `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` 在播,轮播算下播);软失败保留旧 `is_live`、始终 exit 0`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily | | `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` 在播,轮播算下播);探测失败清为 `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`),读 `data.json``streamers.streamers`Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 失败主播一律 `is_live:false` + `stale:true`(**不**沿用旧的直播中);全失败回 `stale-override` 空角标表或 `error`,永不 500;导出时拷贝 `functions/`**部署须 `cwd=dist` 跑 wrangler**Functions 相对 cwd 解析) | | `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`),读 `data.json``streamers.streamers`Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 失败主播一律 `is_live:false` + `stale:true`(**不**沿用旧的直播中);全失败回 `stale-override` 空角标表或 `error`,永不 500;导出时拷贝 `functions/`**部署须 `cwd=dist` 跑 wrangler**Functions 相对 cwd 解析) |
| `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py``web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 | | `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py``web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 |
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__``app.js` 跳过桌面 boot | | `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__``app.js` 跳过桌面 boot |
@@ -109,7 +109,7 @@ climperor/
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→ChaliceSomnus 即 MaybeCN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend | | `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→ChaliceSomnus 即 MaybeCN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) | | `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) |
| `web/data/leaderboards.json` | Valve Immortal 四区 Top100`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) | | `web/data/leaderboards.json` | Valve Immortal 四区 Top100`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音/B 站 profile 补全;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s`stale`/失败不显示角标;`data.json` daily 仅作首屏兜底直至接口返回;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) | | `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音/B 站 profile 补全;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);列表排序:先 `is_live` 再粉丝数降序;视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s`stale`/失败不显示角标;`data.json` daily 仅作首屏兜底直至接口返回;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
| `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win`weeks`/`latest`+ **同段位最近 1 周分路**`winWeek`+`positionIds`+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`#/trends` 近 N 周榜;**不**参与局内推荐) | | `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win`weeks`/`latest`+ **同段位最近 1 周分路**`winWeek`+`positionIds`+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`#/trends` 近 N 周榜;**不**参与局内推荐) |
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) | | `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) | | `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
@@ -120,7 +120,7 @@ climperor/
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` | | `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` |
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations | | `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations |
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) | | `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `functions/`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}`;移动端由 `mobile-gate.js` 拦截 | | `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `functions/`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}`技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截 |
| `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) | | `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) | | `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) | | `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
+17
View File
@@ -4,6 +4,23 @@
## [Unreleased] ## [Unreleased]
## [0.5.112] - 2026-07-30
### Changed
- 技能详情演示视频:有横向空间时加宽(上限约 720px),按官网 16:9 原比例显示;去掉左右 `cover` 裁切与过窄的 480px 上限。
### Fixed
- Web 刷新可靠性:前端 / `relations.json` / 英雄表 / 网格顺序变更也会触发部署;主播 `is_live` 翻转不再单独触发整站部署。
- 主播直播探测失败统一清为未开播(不再沿用旧的“直播中”兜底);`--dry-run` 不再 restore/save 缓存或写 summarypatch 因全量刷新锁冲突跳过时飞书摘要显示「已跳过」。
## [0.5.111] - 2026-07-30
### Changed
- 主播页排序:先按是否在播(在播靠前),再按粉丝数降序;直播状态刷新后同步重排。
## [0.5.110] - 2026-07-30 ## [0.5.110] - 2026-07-30
### Fixed ### Fixed
+2
View File
@@ -226,6 +226,8 @@ components:
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail` 桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`), 之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`),
Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。 Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。
技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%),
`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。
### 顶部壳(紧凑单层) ### 顶部壳(紧凑单层)
+1 -1
View File
@@ -117,7 +117,7 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。 英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。 顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/` 顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);列表先按是否在播、再按粉丝数降序;抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`
顶部另有 **比赛** 页(`#/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。 顶部另有 **比赛** 页(`#/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`STRATZ 周胜率数据)。 顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`STRATZ 周胜率数据)。
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。 顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。
+1 -1
View File
@@ -52,7 +52,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.110" SITE_VERSION = "0.5.112"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
+12 -6
View File
@@ -22,11 +22,13 @@ Approach (verified 2026-07):
``webcast/room/web/enter`` API was considered but returns empty bodies ``webcast/room/web/enter`` API was considered but returns empty bodies
without request signing, so the SSR page is the source of truth.) without request signing, so the SSR page is the source of truth.)
Everything is soft-fail: network errors, empty or non-JSON responses keep the Everything is soft-fail: network errors, empty or non-JSON responses clear
previous ``is_live`` value and never abort a refresh tier (exit code is ``is_live`` to False and drop ``live_probed_at`` (so consumers treat the
always 0). Only the two probe fields are touched; all other keys (including badge as stale/unknown) and never abort a refresh tier (exit code is always
``live_url``) are preserved. Note the ``daily`` tier means the badge trails 0). Only the two probe fields are touched; all other keys (including
reality by up to a day — truly real-time would need a higher-frequency job. ``live_url``) are preserved. Production live badges are owned by the
visit-triggered ``/api/live-status`` edge probe; this daily write is only a
``data.json`` fallback until that API returns.
Preview only — do not merge into relations/heroes or recommend. Preview only — do not merge into relations/heroes or recommend.
@@ -212,7 +214,11 @@ def probe_streamers(
print(f"skip {sid}: platform={platform!r} unsupported", flush=True) print(f"skip {sid}: platform={platform!r} unsupported", flush=True)
continue continue
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
print(f" FAIL {sid}: {e} (keeping previous is_live)", flush=True) # Align with /api/live-status and local serve_relations: unknown is
# not live, and must not preserve a stale positive badge.
row["is_live"] = False
row.pop("live_probed_at", None)
print(f" FAIL {sid}: {e} (is_live=false, stale)", flush=True)
fail += 1 fail += 1
continue continue
row["is_live"] = is_live row["is_live"] = is_live
+15 -2
View File
@@ -3957,6 +3957,9 @@ function streamerList() {
const rows = streamersData().streamers; const rows = streamersData().streamers;
if (!Array.isArray(rows)) return []; if (!Array.isArray(rows)) return [];
return [...rows].sort((a, b) => { return [...rows].sort((a, b) => {
const la = a && a.is_live === true ? 1 : 0;
const lb = b && b.is_live === true ? 1 : 0;
if (la !== lb) return lb - la;
const fa = Number(a.follower_count); const fa = Number(a.follower_count);
const fb = Number(b.follower_count); const fb = Number(b.follower_count);
const na = Number.isFinite(fa) && fa >= 0 ? fa : -1; const na = Number.isFinite(fa) && fa >= 0 ? fa : -1;
@@ -4785,14 +4788,24 @@ function refreshStreamerLiveStatus() {
} }
} }
if (!changed) return; if (!changed) return;
// Update rendered cards in place (top-level page + hero detail panel); // Re-render list views so live streamers bubble to the front; otherwise
// rows already merged, so any later re-render picks up the new state. // sync any leftover cards in place (rows already merged for later paints).
if (state.page === "streamers") {
renderStreamers();
} else if (
state.page === "heroes" &&
state.detailTab === "streamers" &&
state.selectedKey
) {
renderDetail();
} else {
document document
.querySelectorAll(".streamer-card[data-streamer-id]") .querySelectorAll(".streamer-card[data-streamer-id]")
.forEach((card) => { .forEach((card) => {
const row = rows.find((r) => r && r.id === card.dataset.streamerId); const row = rows.find((r) => r && r.id === card.dataset.streamerId);
if (row) syncStreamerCardLive(card, row); if (row) syncStreamerCardLive(card, row);
}); });
}
}) })
.catch(() => { .catch(() => {
/* endpoint missing/unreachable: keep data.json is_live */ /* endpoint missing/unreachable: keep data.json is_live */
+1 -1
View File
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */ /* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.110"; var SITE_VERSION = "0.5.112";
var ABILITY_VIDEO_BASE = ""; var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = ""; var STATIC_ASSET_BASE = "";
+5 -5
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title> <title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" /> <link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.110" /> <link rel="stylesheet" href="/style.css?v=0.5.112" />
<script src="/mobile-gate.js?v=0.5.110"></script> <script src="/mobile-gate.js?v=0.5.112"></script>
</head> </head>
<body> <body>
<h1 class="sr-only">DOTA2 上分帝</h1> <h1 class="sr-only">DOTA2 上分帝</h1>
@@ -178,8 +178,8 @@
<section class="detail" id="detail" aria-live="polite"></section> <section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.110"></script> <script src="/config.js?v=0.5.112"></script>
<script src="/router.js?v=0.5.110"></script> <script src="/router.js?v=0.5.112"></script>
<script src="/app.js?v=0.5.110"></script> <script src="/app.js?v=0.5.112"></script>
</body> </body>
</html> </html>
+20 -11
View File
@@ -1514,11 +1514,16 @@ body {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
/* Skill detail: official-page split — demo clip left, name/desc right. */ /* Skill detail: official-page split demo clip left, name/desc right.
Use the full inspect width when the center column has room (no artificial
1320px cap that leaves empty bands beside a cropped square video). */
.hero-inspect .skill-info-body { .hero-inspect .skill-info-body {
max-width: 1320px; max-width: none;
margin-left: auto; width: 100%;
margin-right: auto; margin-left: 0;
margin-right: 0;
flex: 1 1 auto;
min-height: 0;
} }
.hero-inspect .item-detail-large-icon { .hero-inspect .item-detail-large-icon {
width: 96px; width: 96px;
@@ -1700,17 +1705,21 @@ body {
max-width: none; max-width: none;
flex: 1 1 0; flex: 1 1 0;
} }
/* Stretch to the body's definite height (flex chain from #detail) so the /* Official demos are 16:9. Prefer a real widescreen frame (grow toward
16:9 intrinsic size cannot push past the panel; cover crops to fill. */ 720px / ~58% when the inspect column has room); never cover-crop sides.
Height follows width via aspect-ratio, capped by the panel so tall
#detail no longer stretches the box into a near-square. */
.skill-info-video { .skill-info-video {
flex: 0 1 42%; flex: 0 1 auto;
width: 42%; align-self: center;
max-width: 480px;
min-width: 220px; min-width: 220px;
min-height: 0; min-height: 0;
height: 100%; width: min(720px, 58%);
max-width: min(720px, 58%);
height: auto;
max-height: 100%; max-height: 100%;
object-fit: cover; aspect-ratio: 16 / 9;
object-fit: contain;
border-radius: 6px; border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
background: #000; background: #000;
+14 -4
View File
@@ -550,7 +550,7 @@ def _nested_timestamp(payload: dict, *path: str) -> datetime | None:
def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool: def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool:
if not summary.get("ok"): if not summary.get("ok") or summary.get("skipped") or summary.get("dry_run"):
return False return False
step_ok = any( step_ok = any(
isinstance(row, dict) isinstance(row, dict)
@@ -765,6 +765,18 @@ def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
return f"- **{w.label}**{' / '.join(parts)}{latest}" return f"- **{w.label}**{' / '.join(parts)}{latest}"
def _fmt_refresh_summary_result(summary: dict) -> str:
"""Human label for a refresh_web.py summary.json payload."""
if summary.get("skipped"):
reason = str(summary.get("skip_reason") or "").strip()
return f"已跳过({reason}" if reason else "已跳过"
if not summary.get("ok"):
return "刷新失败"
if summary.get("deployed"):
return "已部署"
return "无需部署"
def build_card(st: DayStats, extras: DigestExtras) -> dict: def build_card(st: DayStats, extras: DigestExtras) -> dict:
"""Feishu interactive card (msg_type=interactive).""" """Feishu interactive card (msg_type=interactive)."""
countries = "".join( countries = "".join(
@@ -796,9 +808,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
(label for file, label in REFRESH_WORKFLOWS if file == workflow_file), (label for file, label in REFRESH_WORKFLOWS if file == workflow_file),
workflow_file, workflow_file,
) )
result = "已部署" if summary.get("deployed") else "无需部署" result = _fmt_refresh_summary_result(summary)
if not summary.get("ok"):
result = "刷新失败"
stale_count = int( stale_count = int(
(summary.get("health") or {}).get("stratz_matchup_stale") or 0 (summary.get("health") or {}).get("stratz_matchup_stale") or 0
) )
+75 -16
View File
@@ -3,9 +3,9 @@
Tiers (see AGENTS.md / Gitea Actions workflows): Tiers (see AGENTS.md / Gitea Actions workflows):
daily OpenDota stats, leaderboards, matches, pro matches, streamers, daily OpenDota stats, leaderboards, matches, pro matches, streamers,
streamer live probe; patch check streamer live probe; patch check
(live badge still trails reality by up to a day truly real-time (live badge is served by /api/live-status on visit; daily probe is
would need a dedicated higher-frequency workflow, e.g. 15-min) only a data.json fallback until the edge API returns)
weekly STRATZ meta, hero items, items_meta, item_fears weekly STRATZ meta, hero items, items_meta, item_counter_stats, item_fears
patch patch list check; on has_new fetch details + version-linked scripts patch patch list check; on has_new fetch details + version-linked scripts
all weekly then daily (patch check included in daily/patch) all weekly then daily (patch check included in daily/patch)
@@ -29,9 +29,11 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT.parent)) REPO_ROOT = ROOT.parent
sys.path.insert(0, str(REPO_ROOT))
from shared.http_utils import write_json_atomic from shared.http_utils import write_json_atomic
from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND
# Paths whose content change should trigger deploy / OSS. # Paths whose content change should trigger deploy / OSS.
DATA_WATCH = [ DATA_WATCH = [
@@ -50,6 +52,21 @@ DATA_WATCH = [
ROOT / "data" / "item_shop.json", ROOT / "data" / "item_shop.json",
ROOT / "data" / "item_counter_stats.json", ROOT / "data" / "item_counter_stats.json",
] ]
# Static site inputs that are not rewritten by fetch tiers but still ship in
# data.json / dist (frontend + qualitative relations + hero table / grid).
INPUT_WATCH = [
RELATIONS_JSON,
HEROES_JSON,
ROOT / "data" / "hero_grid_order.json",
WEB_FRONTEND / "index.html",
WEB_FRONTEND / "app.js",
WEB_FRONTEND / "style.css",
WEB_FRONTEND / "router.js",
WEB_FRONTEND / "mobile-gate.js",
WEB_FRONTEND / "config.js",
WEB_FRONTEND / "_headers",
]
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
ASSET_DIRS = [ ASSET_DIRS = [
ROOT / "assets" / "item_icons", ROOT / "assets" / "item_icons",
ROOT / "assets" / "ability_icons", ROOT / "assets" / "ability_icons",
@@ -67,12 +84,24 @@ VOLATILE_JSON_KEYS = frozenset(
"live_probed_at", "live_probed_at",
"profile_fetched_at", "profile_fetched_at",
"updated_at", "updated_at",
# Live badge is owned by /api/live-status; flipping is_live alone must
# not trigger a full static redeploy.
"is_live",
} }
) )
RUN_RESULTS: list[dict] = [] RUN_RESULTS: list[dict] = []
PATCH_RESULT: dict = {} PATCH_RESULT: dict = {}
def _watch_key(path: Path) -> str:
"""Stable snapshot key relative to the repo root when possible."""
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT.resolve()).as_posix()
except ValueError:
return resolved.as_posix()
def _file_digest(path: Path) -> str | None: def _file_digest(path: Path) -> str | None:
if not path.is_file(): if not path.is_file():
return None return None
@@ -131,27 +160,39 @@ def _dir_digest(path: Path) -> str | None:
def snapshot() -> dict[str, str | None]: def snapshot() -> dict[str, str | None]:
out: dict[str, str | None] = {} out: dict[str, str | None] = {}
for p in DATA_WATCH: for p in DATA_WATCH:
out[str(p.relative_to(ROOT))] = _semantic_file_digest(p) out[_watch_key(p)] = _semantic_file_digest(p)
for p in INPUT_WATCH:
# Frontend/source files use byte digests; JSON inputs use semantic hash.
if p.suffix.lower() == ".json":
out[_watch_key(p)] = _semantic_file_digest(p)
else:
out[_watch_key(p)] = _file_digest(p)
out[_watch_key(FRONTEND_FUNCTIONS_DIR) + "/"] = _dir_digest(FRONTEND_FUNCTIONS_DIR)
for d in ASSET_DIRS: for d in ASSET_DIRS:
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d) out[_watch_key(d) + "/"] = _dir_digest(d)
return out return out
def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]: def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]:
"""Return (data_changed, assets_changed).""" """Return (data_changed, assets_changed).
``data_changed`` covers generated JSON, frontend/source inputs, and Pages
Functions. Only ``web/assets/...`` directory digests count as assets
(OSS upload).
"""
data_changed = False data_changed = False
assets_changed = False assets_changed = False
asset_keys = {_watch_key(d) + "/" for d in ASSET_DIRS}
keys = set(before) | set(after) keys = set(before) | set(after)
for k in keys: for k in keys:
if before.get(k) == after.get(k): if before.get(k) == after.get(k):
continue continue
if k.endswith("/"): if k in asset_keys:
assets_changed = True assets_changed = True
else: else:
data_changed = True data_changed = True
return data_changed, assets_changed return data_changed, assets_changed
def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool: def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool:
cmd = [sys.executable, str(ROOT / script), *args] cmd = [sys.executable, str(ROOT / script), *args]
print(f"+ {' '.join(cmd)}", flush=True) print(f"+ {' '.join(cmd)}", flush=True)
@@ -234,9 +275,9 @@ def run_daily(*, dry_run: bool = False) -> bool:
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run) run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
run_script("fetch_pro_matches.py", "--include-pubs", dry_run=dry_run) run_script("fetch_pro_matches.py", "--include-pubs", dry_run=dry_run)
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss). # Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
run_script("fetch_streamers.py", dry_run=dry_run) run_script("fetch_streamers.py", dry_run=dry_run, soft_fail=True)
# Soft-fail live probe (exits 0; probe failures keep previous is_live). # Soft-fail live probe (exits 0; probe failures clear is_live / live_probed_at).
run_script("fetch_streamer_live.py", dry_run=dry_run) run_script("fetch_streamer_live.py", dry_run=dry_run, soft_fail=True)
check = patch_check() check = patch_check()
if check.get("has_new"): if check.get("has_new"):
print( print(
@@ -323,11 +364,16 @@ def _stale_metrics() -> dict:
return metrics return metrics
def _write_summary(summary: dict) -> None: def _finalize_summary(summary: dict) -> dict:
summary["steps"] = list(RUN_RESULTS) summary["steps"] = list(RUN_RESULTS)
summary["patch_check"] = dict(PATCH_RESULT) summary["patch_check"] = dict(PATCH_RESULT)
summary["health"] = _stale_metrics() summary["health"] = _stale_metrics()
summary["completed_at"] = datetime.now(timezone.utc).isoformat() summary["completed_at"] = datetime.now(timezone.utc).isoformat()
return summary
def _write_summary(summary: dict) -> None:
_finalize_summary(summary)
write_json_atomic(SUMMARY_PATH, summary) write_json_atomic(SUMMARY_PATH, summary)
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True) print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
@@ -441,7 +487,9 @@ def main() -> None:
_write_summary(summary) _write_summary(summary)
return return
try: try:
cache_enabled = os.environ.get("REFRESH_CACHE_ENABLED") == "1" cache_enabled = (
os.environ.get("REFRESH_CACHE_ENABLED") == "1" and not args.dry_run
)
if cache_enabled: if cache_enabled:
run_script("refresh_cache.py", "restore") run_script("refresh_cache.py", "restore")
before = snapshot() if not args.dry_run else {} before = snapshot() if not args.dry_run else {}
@@ -455,7 +503,10 @@ def main() -> None:
if args.dry_run: if args.dry_run:
summary["ok"] = True summary["ok"] = True
summary["dry_run"] = True summary["dry_run"] = True
print("dry-run complete (no deploy/oss)", flush=True) print(
"dry-run complete (no cache restore/save, deploy, or oss)",
flush=True,
)
return return
after = snapshot() after = snapshot()
@@ -484,7 +535,7 @@ def main() -> None:
if assets_changed and not args.skip_oss: if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload") run_script("_oss_static_assets.py", "upload")
elif assets_changed: elif assets_changed:
print("assets changed but deploy is disabled; skipping OSS upload", flush=True) print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed should_deploy = args.force_deploy or data_changed or assets_changed
if should_deploy and not args.skip_deploy: if should_deploy and not args.skip_deploy:
@@ -502,6 +553,14 @@ def main() -> None:
raise raise
finally: finally:
summary["duration_s"] = round(time.monotonic() - started, 3) summary["duration_s"] = round(time.monotonic() - started, 3)
if args.dry_run:
# Print for humans; do not persist dry-run artifacts.
_finalize_summary(summary)
print(
"REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False),
flush=True,
)
else:
_write_summary(summary) _write_summary(summary)
release_refresh_lock(lock_handle) release_refresh_lock(lock_handle)
+150
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import tempfile import tempfile
import unittest import unittest
import urllib.error
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -14,8 +16,10 @@ sys.path.insert(0, str(ROOT / "web"))
from web import notify_site_traffic, refresh_web from web import notify_site_traffic, refresh_web
from web.fetch_stratz_meta import preserve_failed_meta_brackets from web.fetch_stratz_meta import preserve_failed_meta_brackets
from web.fetch_streamer_live import probe_streamers
from web.refresh_cache import restore_patches, restore_streamers from web.refresh_cache import restore_patches, restore_streamers
from shared.http_utils import write_json_atomic from shared.http_utils import write_json_atomic
from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND
class RefreshDigestTests(unittest.TestCase): class RefreshDigestTests(unittest.TestCase):
@@ -45,6 +49,82 @@ class RefreshDigestTests(unittest.TestCase):
) )
self.assertNotEqual(before, refresh_web._semantic_file_digest(path)) self.assertNotEqual(before, refresh_web._semantic_file_digest(path))
def test_semantic_digest_ignores_streamer_is_live(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "streamers.json"
path.write_text(
json.dumps(
{
"fetched_at": "old",
"streamers": [
{
"id": "a",
"nickname": "A",
"is_live": False,
"live_probed_at": "t1",
}
],
}
),
encoding="utf-8",
)
before = refresh_web._semantic_file_digest(path)
path.write_text(
json.dumps(
{
"fetched_at": "new",
"streamers": [
{
"id": "a",
"nickname": "A",
"is_live": True,
"live_probed_at": "t2",
}
],
}
),
encoding="utf-8",
)
self.assertEqual(before, refresh_web._semantic_file_digest(path))
path.write_text(
json.dumps(
{
"fetched_at": "new",
"streamers": [
{
"id": "a",
"nickname": "B",
"is_live": True,
"live_probed_at": "t2",
}
],
}
),
encoding="utf-8",
)
self.assertNotEqual(before, refresh_web._semantic_file_digest(path))
def test_snapshot_watches_frontend_and_shared_inputs(self) -> None:
snap = refresh_web.snapshot()
self.assertIn(refresh_web._watch_key(RELATIONS_JSON), snap)
self.assertIn(refresh_web._watch_key(HEROES_JSON), snap)
self.assertIn(refresh_web._watch_key(WEB_FRONTEND / "app.js"), snap)
self.assertIn(
refresh_web._watch_key(WEB_FRONTEND / "functions") + "/",
snap,
)
self.assertIn(
refresh_web._watch_key(refresh_web.ROOT / "data" / "hero_grid_order.json"),
snap,
)
def test_frontend_change_is_data_not_asset(self) -> None:
before = {"web/frontend/app.js": "a", "web/assets/item_icons/": "x"}
after = {"web/frontend/app.js": "b", "web/assets/item_icons/": "x"}
data_changed, assets_changed = refresh_web.diff_snapshots(before, after)
self.assertTrue(data_changed)
self.assertFalse(assets_changed)
def test_assets_cannot_deploy_when_oss_is_skipped(self) -> None: def test_assets_cannot_deploy_when_oss_is_skipped(self) -> None:
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
refresh_web.validate_publish_options( refresh_web.validate_publish_options(
@@ -58,6 +138,31 @@ class RefreshDigestTests(unittest.TestCase):
skip_deploy=True, skip_deploy=True,
) )
def test_dry_run_skips_cache_restore_and_summary_write(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
summary_path = Path(tmp) / "summary.json"
calls: list[tuple] = []
def fake_run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False):
calls.append((script, args, dry_run))
return True
env = {"REFRESH_CACHE_ENABLED": "1"}
with (
patch.object(refresh_web, "SUMMARY_PATH", summary_path),
patch.object(refresh_web, "acquire_refresh_lock", return_value=object()),
patch.object(refresh_web, "release_refresh_lock"),
patch.object(refresh_web, "run_script", side_effect=fake_run_script),
patch.object(refresh_web, "run_weekly"),
patch.object(refresh_web, "run_daily", return_value=False),
patch.object(refresh_web, "run_patch_tier", return_value=False),
patch.dict(os.environ, env, clear=False),
patch.object(sys, "argv", ["refresh_web.py", "--tier", "weekly", "--dry-run"]),
):
refresh_web.main()
self.assertFalse(summary_path.is_file())
self.assertFalse(any(script == "refresh_cache.py" for script, *_ in calls))
def test_streamer_cache_preserves_manual_rows_and_restores_runtime_fields(self) -> None: def test_streamer_cache_preserves_manual_rows_and_restores_runtime_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
cached = Path(tmp) / "cached.json" cached = Path(tmp) / "cached.json"
@@ -132,6 +237,51 @@ class RefreshDigestTests(unittest.TestCase):
self.assertEqual(set(payload["lookup"]["heroes"]), {"1", "2"}) self.assertEqual(set(payload["lookup"]["heroes"]), {"1", "2"})
class LiveProbeFallbackTests(unittest.TestCase):
def test_probe_failure_clears_stale_live_badge(self) -> None:
payload = {
"streamers": [
{
"id": "a",
"live_url": "https://live.bilibili.com/123",
"is_live": True,
"live_probed_at": "2026-07-29T00:00:00+00:00",
}
]
}
def boom(_room_id: str) -> bool:
raise urllib.error.URLError("blocked")
with patch("web.fetch_streamer_live.probe_bilibili", side_effect=boom):
live, offline, fail = probe_streamers(payload)
self.assertEqual((live, offline, fail), (0, 0, 1))
row = payload["streamers"][0]
self.assertFalse(row["is_live"])
self.assertNotIn("live_probed_at", row)
class NotifySummaryTests(unittest.TestCase):
def test_skipped_summary_is_labeled(self) -> None:
self.assertEqual(
notify_site_traffic._fmt_refresh_summary_result(
{
"ok": True,
"skipped": True,
"skip_reason": "full refresh already running",
}
),
"已跳过(full refresh already running",
)
self.assertFalse(
notify_site_traffic._summary_proves_refresh(
{"ok": True, "skipped": True, "steps": [{"step": "fetch_hero_stats.py", "ok": True}]},
"英雄统计",
"fetch_hero_stats.py",
)
)
class StratzFallbackTests(unittest.TestCase): class StratzFallbackTests(unittest.TestCase):
def test_failed_bracket_keeps_previous_data_and_marks_stale(self) -> None: def test_failed_bracket_keeps_previous_data_and_marks_stale(self) -> None:
fresh = { fresh = {