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:
@@ -70,7 +70,7 @@ 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` 后刷新;`/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/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 |
|
||||
@@ -90,7 +90,7 @@ climperor/
|
||||
| `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_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/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` + 图标 |
|
||||
@@ -109,7 +109,7 @@ climperor/
|
||||
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/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/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_matchup_tops.json` | STRATZ 对位/协同 Top(counters/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` 生成;物品页只读) |
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.110] - 2026-07-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- 主播直播状态:线上与本地预览均执行平台探测;探测失败或过期状态不再沿用旧的“直播中”结果,只有本轮确认开播才显示直播角标。
|
||||
|
||||
## [0.5.109] - 2026-07-30
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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`)。
|
||||
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + 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)。
|
||||
顶部另有 **走势** 页:近 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`)。
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
{
|
||||
"id": "yaseguilai",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/146909015971",
|
||||
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
|
||||
"heroes": [
|
||||
"slark"
|
||||
@@ -80,6 +81,7 @@
|
||||
{
|
||||
"id": "gudu",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/750711463576",
|
||||
"profile_url": "https://v.douyin.com/411MHPZgDeY/",
|
||||
"heroes": [
|
||||
"kez"
|
||||
@@ -119,6 +121,7 @@
|
||||
{
|
||||
"id": "dadigua",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/499355244796",
|
||||
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
|
||||
"heroes": [
|
||||
"axe"
|
||||
|
||||
@@ -52,7 +52,7 @@ from shared.paths import (
|
||||
|
||||
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"
|
||||
|
||||
|
||||
|
||||
+8
-6
@@ -4749,9 +4749,9 @@ function syncStreamerCardLive(card, row) {
|
||||
|
||||
/**
|
||||
* Visit-triggered live refresh: the Pages Function coalesces concurrent
|
||||
* visitors through a 5-minute edge cache. Local `serve_relations.py` returns
|
||||
* an empty stub (no probing), so that path keeps the data.json is_live
|
||||
* fallback. Fires at most once per page load.
|
||||
* visitors through a 5-minute edge cache; the local development server uses
|
||||
* the same platform probes with a short in-memory cache. Fires at most once
|
||||
* per page load.
|
||||
*/
|
||||
let liveStatusFetched = false;
|
||||
|
||||
@@ -4776,9 +4776,11 @@ function refreshStreamerLiveStatus() {
|
||||
for (const row of rows) {
|
||||
const cell = row && row.id ? probed[row.id] : null;
|
||||
if (!cell || typeof cell.is_live !== "boolean") continue;
|
||||
if (cell.stale) console.info(`live-status: ${row.id} using stale carry-over`);
|
||||
if (row.is_live !== cell.is_live) {
|
||||
row.is_live = cell.is_live;
|
||||
// A failed/stale probe is unknown, never evidence that a room is live.
|
||||
const isLive = cell.stale ? false : 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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 STATIC_ASSET_BASE = "";
|
||||
|
||||
|
||||
@@ -15,12 +15,10 @@
|
||||
* the SSR pace chunks: roomStore.roomInfo.room.status (2 live / 4 offline);
|
||||
* the embedded web_rid must match the requested one.
|
||||
*
|
||||
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a
|
||||
* single probe fails, the streamer carries over the last known is_live from
|
||||
* data.json / the previous (stale) cache entry with `stale: true`; when every
|
||||
* probe fails the stale cache entry is served wholesale (X-Live-Cache:
|
||||
* stale-override), or an empty payload when nothing was ever cached
|
||||
* (X-Live-Cache: error). The handler never throws a 500 for probe failures.
|
||||
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. A
|
||||
* failed probe is reported as offline with `stale: true`; a stale positive is
|
||||
* never carried over because the live badge must only reflect a successful
|
||||
* current probe. The handler never throws a 500 for probe failures.
|
||||
*
|
||||
* Named exports double as the local test surface; the Pages runtime only
|
||||
* routes onRequest* handlers.
|
||||
@@ -292,6 +290,23 @@ function emptyPayload() {
|
||||
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) {
|
||||
const cached = new Response(JSON.stringify(body), {
|
||||
headers: {
|
||||
@@ -324,17 +339,12 @@ async function probeFresh(requestUrl, cachedData) {
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
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 streamers = {};
|
||||
@@ -346,32 +356,20 @@ async function probeFresh(requestUrl, cachedData) {
|
||||
freshCount += 1;
|
||||
continue;
|
||||
}
|
||||
// Per-streamer soft-fail: carry over the last known state, marked stale.
|
||||
const prev = staleStreamers[t.id];
|
||||
if (prev && typeof prev.is_live === "boolean") {
|
||||
streamers[t.id] = { is_live: prev.is_live, stale: true };
|
||||
}
|
||||
// Unknown is not live: never carry a stale positive badge forward.
|
||||
streamers[t.id] = { is_live: false, stale: true };
|
||||
}
|
||||
|
||||
if (freshCount === 0) {
|
||||
// Total probe failure (e.g. douyin blocking this colo): serve the stale
|
||||
// snapshot if one exists, otherwise an explicitly empty payload.
|
||||
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
|
||||
if (Object.keys(seeded).length) {
|
||||
// Total probe failure (e.g. douyin blocking this colo): keep the target
|
||||
// shape but suppress every unconfirmed live badge.
|
||||
const body = {
|
||||
probed_at: new Date().toISOString(),
|
||||
ttl: FRESH_TTL_S,
|
||||
streamers: Object.fromEntries(
|
||||
Object.entries(seeded).map(([id, cell]) => [
|
||||
id,
|
||||
{ is_live: cell.is_live, stale: true },
|
||||
])
|
||||
),
|
||||
streamers,
|
||||
};
|
||||
return { body, cacheState: "stale-override" };
|
||||
}
|
||||
return { body: emptyPayload(), cacheState: "error" };
|
||||
}
|
||||
|
||||
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
|
||||
return { body, cacheState: "miss" };
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DOTA2 上分帝</title>
|
||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.109" />
|
||||
<script src="/mobile-gate.js?v=0.5.109"></script>
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.110" />
|
||||
<script src="/mobile-gate.js?v=0.5.110"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||
@@ -178,8 +178,8 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.5.109"></script>
|
||||
<script src="/router.js?v=0.5.109"></script>
|
||||
<script src="/app.js?v=0.5.109"></script>
|
||||
<script src="/config.js?v=0.5.110"></script>
|
||||
<script src="/router.js?v=0.5.110"></script>
|
||||
<script src="/app.js?v=0.5.110"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+63
-3
@@ -19,7 +19,9 @@ import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -47,11 +49,15 @@ from shared.paths import (
|
||||
)
|
||||
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
|
||||
|
||||
WEB_DIR = WEB_FRONTEND
|
||||
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
|
||||
_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:
|
||||
@@ -76,6 +82,62 @@ def _inc_mobile_demand_count() -> int:
|
||||
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"
|
||||
HERO_ITEMS_PATH = DATA / "hero_items.json"
|
||||
HERO_STATS_PATH = DATA / "hero_stats.json"
|
||||
@@ -842,9 +904,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(200, build_payload())
|
||||
return
|
||||
if path == "/api/live-status":
|
||||
# Local-dev stub for the Pages Function (edge probing runs in
|
||||
# production only); the empty map keeps the UI on data.json is_live.
|
||||
self._json(200, {"probed_at": None, "ttl": 300, "streamers": {}})
|
||||
self._json(200, _probe_live_status())
|
||||
return
|
||||
if path == "/api/mobile-demand":
|
||||
self._json(200, {"count": _read_mobile_demand_count()})
|
||||
|
||||
Reference in New Issue
Block a user