v0.5.110: stop carrying stale live badges; probe live status in local serve.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 01:11:13 +08:00
co-authored by Cursor
parent d6212169af
commit fe1b13b7c1
10 changed files with 125 additions and 56 deletions
+3 -3
View File
@@ -70,7 +70,7 @@ climperor/
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON | | `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 | | `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` | | `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/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`);忽略纯时间戳的业务摘要有变更才 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 |
@@ -90,7 +90,7 @@ climperor/
| `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`、始终 exit 0`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily |
| `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 失败属预期 → 单主播沿用 edge 缓存/daily `is_live` `stale`,全失败回陈旧快照(`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 |
| `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `web/data/item_shop.json` + 图标 | | `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `web/data/item_shop.json` + 图标 |
@@ -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` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上 `/api/live-status` 边缘缓存 5 分钟;`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`,无间距);视口分档滚播(远处不拉、近处 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` 生成;物品页只读) |
+6
View File
@@ -4,6 +4,12 @@
## [Unreleased] ## [Unreleased]
## [0.5.110] - 2026-07-30
### Fixed
- 主播直播状态:线上与本地预览均执行平台探测;探测失败或过期状态不再沿用旧的“直播中”结果,只有本轮确认开播才显示直播角标。
## [0.5.109] - 2026-07-30 ## [0.5.109] - 2026-07-30
### Changed ### Changed
+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 分钟合并请求`data.json``fetch_streamer_live.py` 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`)。
+3
View File
@@ -60,6 +60,7 @@
{ {
"id": "yaseguilai", "id": "yaseguilai",
"platform": "douyin", "platform": "douyin",
"live_url": "https://live.douyin.com/146909015971",
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/", "profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
"heroes": [ "heroes": [
"slark" "slark"
@@ -80,6 +81,7 @@
{ {
"id": "gudu", "id": "gudu",
"platform": "douyin", "platform": "douyin",
"live_url": "https://live.douyin.com/750711463576",
"profile_url": "https://v.douyin.com/411MHPZgDeY/", "profile_url": "https://v.douyin.com/411MHPZgDeY/",
"heroes": [ "heroes": [
"kez" "kez"
@@ -119,6 +121,7 @@
{ {
"id": "dadigua", "id": "dadigua",
"platform": "douyin", "platform": "douyin",
"live_url": "https://live.douyin.com/499355244796",
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/", "profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
"heroes": [ "heroes": [
"axe" "axe"
+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.109" SITE_VERSION = "0.5.110"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
+8 -6
View File
@@ -4749,9 +4749,9 @@ function syncStreamerCardLive(card, row) {
/** /**
* Visit-triggered live refresh: the Pages Function coalesces concurrent * Visit-triggered live refresh: the Pages Function coalesces concurrent
* visitors through a 5-minute edge cache. Local `serve_relations.py` returns * visitors through a 5-minute edge cache; the local development server uses
* an empty stub (no probing), so that path keeps the data.json is_live * the same platform probes with a short in-memory cache. Fires at most once
* fallback. Fires at most once per page load. * per page load.
*/ */
let liveStatusFetched = false; let liveStatusFetched = false;
@@ -4776,9 +4776,11 @@ function refreshStreamerLiveStatus() {
for (const row of rows) { for (const row of rows) {
const cell = row && row.id ? probed[row.id] : null; const cell = row && row.id ? probed[row.id] : null;
if (!cell || typeof cell.is_live !== "boolean") continue; if (!cell || typeof cell.is_live !== "boolean") continue;
if (cell.stale) console.info(`live-status: ${row.id} using stale carry-over`); // A failed/stale probe is unknown, never evidence that a room is live.
if (row.is_live !== cell.is_live) { const isLive = cell.stale ? false : cell.is_live;
row.is_live = cell.is_live; if (cell.stale) console.info(`live-status: ${row.id} probe stale; hiding badge`);
if (row.is_live !== isLive) {
row.is_live = isLive;
changed = true; changed = true;
} }
} }
+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.109"; var SITE_VERSION = "0.5.110";
var ABILITY_VIDEO_BASE = ""; var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = ""; var STATIC_ASSET_BASE = "";
+34 -36
View File
@@ -15,12 +15,10 @@
* the SSR pace chunks: roomStore.roomInfo.room.status (2 live / 4 offline); * the SSR pace chunks: roomStore.roomInfo.room.status (2 live / 4 offline);
* the embedded web_rid must match the requested one. * the embedded web_rid must match the requested one.
* *
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a * Soft-fail everywhere: douyin blocks from datacenter IPs are expected. A
* single probe fails, the streamer carries over the last known is_live from * failed probe is reported as offline with `stale: true`; a stale positive is
* data.json / the previous (stale) cache entry with `stale: true`; when every * never carried over because the live badge must only reflect a successful
* probe fails the stale cache entry is served wholesale (X-Live-Cache: * current probe. The handler never throws a 500 for probe failures.
* stale-override), or an empty payload when nothing was ever cached
* (X-Live-Cache: error). The handler never throws a 500 for probe failures.
* *
* Named exports double as the local test surface; the Pages runtime only * Named exports double as the local test surface; the Pages runtime only
* routes onRequest* handlers. * routes onRequest* handlers.
@@ -292,6 +290,23 @@ function emptyPayload() {
return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} }; return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} };
} }
/** Failed probes must never preserve a stale live badge. */
function staleOfflinePayload(source) {
const streamers = {};
const previous =
source && source.streamers && typeof source.streamers === "object"
? source.streamers
: {};
for (const id of Object.keys(previous)) {
streamers[id] = { is_live: false, stale: true };
}
return {
probed_at: (source && source.probed_at) || new Date().toISOString(),
ttl: FRESH_TTL_S,
streamers,
};
}
async function putCache(body) { async function putCache(body) {
const cached = new Response(JSON.stringify(body), { const cached = new Response(JSON.stringify(body), {
headers: { headers: {
@@ -324,17 +339,12 @@ async function probeFresh(requestUrl, cachedData) {
} }
if (!targets.length) { if (!targets.length) {
if (cachedData) return { body: cachedData, cacheState: "stale-override" }; if (cachedData) {
return { body: staleOfflinePayload(cachedData), cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" }; return { body: emptyPayload(), cacheState: "error" };
} }
const seeded = seedLiveFromPayload(dataPayload);
const cachedStreamers =
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
? cachedData.streamers
: {};
// Prefer previous edge cache over the daily snapshot when both exist.
const staleStreamers = { ...seeded, ...cachedStreamers };
const probed = await probeAll(targets); const probed = await probeAll(targets);
const streamers = {}; const streamers = {};
@@ -346,31 +356,19 @@ async function probeFresh(requestUrl, cachedData) {
freshCount += 1; freshCount += 1;
continue; continue;
} }
// Per-streamer soft-fail: carry over the last known state, marked stale. // Unknown is not live: never carry a stale positive badge forward.
const prev = staleStreamers[t.id]; streamers[t.id] = { is_live: false, stale: true };
if (prev && typeof prev.is_live === "boolean") {
streamers[t.id] = { is_live: prev.is_live, stale: true };
}
} }
if (freshCount === 0) { if (freshCount === 0) {
// Total probe failure (e.g. douyin blocking this colo): serve the stale // Total probe failure (e.g. douyin blocking this colo): keep the target
// snapshot if one exists, otherwise an explicitly empty payload. // shape but suppress every unconfirmed live badge.
if (cachedData) return { body: cachedData, cacheState: "stale-override" }; const body = {
if (Object.keys(seeded).length) { probed_at: new Date().toISOString(),
const body = { ttl: FRESH_TTL_S,
probed_at: new Date().toISOString(), streamers,
ttl: FRESH_TTL_S, };
streamers: Object.fromEntries( return { body, cacheState: "stale-override" };
Object.entries(seeded).map(([id, cell]) => [
id,
{ is_live: cell.is_live, stale: true },
])
),
};
return { body, cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" };
} }
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers }; const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
+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.109" /> <link rel="stylesheet" href="/style.css?v=0.5.110" />
<script src="/mobile-gate.js?v=0.5.109"></script> <script src="/mobile-gate.js?v=0.5.110"></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.109"></script> <script src="/config.js?v=0.5.110"></script>
<script src="/router.js?v=0.5.109"></script> <script src="/router.js?v=0.5.110"></script>
<script src="/app.js?v=0.5.109"></script> <script src="/app.js?v=0.5.110"></script>
</body> </body>
</html> </html>
+63 -3
View File
@@ -19,7 +19,9 @@ import argparse
import json import json
import mimetypes import mimetypes
import threading import threading
import time
import webbrowser import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -47,11 +49,15 @@ from shared.paths import (
) )
from shared.relations import DEFAULT_RELATIONS, load_relations from shared.relations import DEFAULT_RELATIONS, load_relations
from fetch_streamer_live import probe_streamers
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
WEB_DIR = WEB_FRONTEND WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json" MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
_MOBILE_DEMAND_LOCK = threading.Lock() _MOBILE_DEMAND_LOCK = threading.Lock()
_LIVE_STATUS_LOCK = threading.Lock()
_LIVE_STATUS_TTL = 60
_LIVE_STATUS_CACHE: tuple[float, dict] | None = None
def _read_mobile_demand_count() -> int: def _read_mobile_demand_count() -> int:
@@ -76,6 +82,62 @@ def _inc_mobile_demand_count() -> int:
return n return n
def _probe_live_status() -> dict:
"""Probe local streamer rooms without mutating streamers.json."""
global _LIVE_STATUS_CACHE
now = time.monotonic()
cached = _LIVE_STATUS_CACHE
if cached and now - cached[0] < _LIVE_STATUS_TTL:
return cached[1]
with _LIVE_STATUS_LOCK:
now = time.monotonic()
cached = _LIVE_STATUS_CACHE
if cached and now - cached[0] < _LIVE_STATUS_TTL:
return cached[1]
try:
payload = json.loads(STREAMERS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
result = {
"probed_at": datetime.now(timezone.utc).isoformat(),
"ttl": _LIVE_STATUS_TTL,
"streamers": {},
}
_LIVE_STATUS_CACHE = (now, result)
return result
rows = payload.get("streamers")
if not isinstance(rows, list):
rows = []
for row in rows:
if isinstance(row, dict) and row.get("live_url"):
# A failed probe is unknown and must not preserve a stale live badge.
row["is_live"] = False
row.pop("live_probed_at", None)
probe_streamers(payload)
streamers = {}
for row in rows:
if not isinstance(row, dict) or not row.get("live_url"):
continue
sid = str(row.get("id") or "").strip()
if not sid:
continue
cell = {"is_live": row.get("is_live") is True}
if not row.get("live_probed_at"):
cell["stale"] = True
streamers[sid] = cell
result = {
"probed_at": datetime.now(timezone.utc).isoformat(),
"ttl": _LIVE_STATUS_TTL,
"streamers": streamers,
}
_LIVE_STATUS_CACHE = (time.monotonic(), result)
return result
GRID_ORDER_PATH = DATA / "hero_grid_order.json" GRID_ORDER_PATH = DATA / "hero_grid_order.json"
HERO_ITEMS_PATH = DATA / "hero_items.json" HERO_ITEMS_PATH = DATA / "hero_items.json"
HERO_STATS_PATH = DATA / "hero_stats.json" HERO_STATS_PATH = DATA / "hero_stats.json"
@@ -842,9 +904,7 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, build_payload()) self._json(200, build_payload())
return return
if path == "/api/live-status": if path == "/api/live-status":
# Local-dev stub for the Pages Function (edge probing runs in self._json(200, _probe_live_status())
# production only); the empty map keeps the UI on data.json is_live.
self._json(200, {"probed_at": None, "ttl": 300, "streamers": {}})
return return
if path == "/api/mobile-demand": if path == "/api/mobile-demand":
self._json(200, {"count": _read_mobile_demand_count()}) self._json(200, {"count": _read_mobile_demand_count()})