v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 |
|
||||||
@@ -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 用) |
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.112] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 技能详情演示视频:有横向空间时加宽(上限约 720px),按官网 16:9 原比例显示;去掉左右 `cover` 裁切与过窄的 480px 上限。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Web 刷新可靠性:前端 / `relations.json` / 英雄表 / 网格顺序变更也会触发部署;主播 `is_live` 翻转不再单独触发整站部署。
|
||||||
|
- 主播直播探测失败统一清为未开播(不再沿用旧的“直播中”兜底);`--dry-run` 不再 restore/save 缓存或写 summary;patch 因全量刷新锁冲突跳过时飞书摘要显示「已跳过」。
|
||||||
|
|
||||||
## [0.5.111] - 2026-07-30
|
## [0.5.111] - 2026-07-30
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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` 裁左右。
|
||||||
|
|
||||||
### 顶部壳(紧凑单层)
|
### 顶部壳(紧凑单层)
|
||||||
|
|
||||||
|
|||||||
@@ -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.111"
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.111";
|
var SITE_VERSION = "0.5.112";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|
||||||
|
|||||||
@@ -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.111" />
|
<link rel="stylesheet" href="/style.css?v=0.5.112" />
|
||||||
<script src="/mobile-gate.js?v=0.5.111"></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.111"></script>
|
<script src="/config.js?v=0.5.112"></script>
|
||||||
<script src="/router.js?v=0.5.111"></script>
|
<script src="/router.js?v=0.5.112"></script>
|
||||||
<script src="/app.js?v=0.5.111"></script>
|
<script src="/app.js?v=0.5.112"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+20
-11
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+76
-17
@@ -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,7 +553,15 @@ def main() -> None:
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
summary["duration_s"] = round(time.monotonic() - started, 3)
|
summary["duration_s"] = round(time.monotonic() - started, 3)
|
||||||
_write_summary(summary)
|
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)
|
||||||
release_refresh_lock(lock_handle)
|
release_refresh_lock(lock_handle)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user