From fdd926e9bb6c8ab207f4b59a3e37862999dc5187 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 30 Jul 2026 01:48:01 +0800 Subject: [PATCH] v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers. Co-authored-by: Cursor --- AGENTS.md | 6 +- CHANGELOG.md | 11 ++ DESIGN.md | 2 + web/export_relations_site.py | 2 +- web/fetch_streamer_live.py | 18 ++-- web/frontend/config.js | 2 +- web/frontend/index.html | 10 +- web/frontend/style.css | 31 ++++-- web/notify_site_traffic.py | 18 +++- web/refresh_web.py | 93 +++++++++++++--- web/tests/test_refresh_reliability.py | 150 ++++++++++++++++++++++++++ 11 files changed, 295 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 608992f..a73ca66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/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 | +| `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/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 | @@ -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_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/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/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` | 移动端门禁(`` 早载):`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/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) | | `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) | -| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `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/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) | | `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) | diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f1e15..19749e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ## [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 ### Changed diff --git a/DESIGN.md b/DESIGN.md index ab40f38..170b3e9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -226,6 +226,8 @@ components: 桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail` 之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`), Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。 +技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%), +`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。 ### 顶部壳(紧凑单层) diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 6c14af7..6cc4b10 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -52,7 +52,7 @@ from shared.paths import ( 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" diff --git a/web/fetch_streamer_live.py b/web/fetch_streamer_live.py index d332cf5..bab579c 100644 --- a/web/fetch_streamer_live.py +++ b/web/fetch_streamer_live.py @@ -22,11 +22,13 @@ Approach (verified 2026-07): ``webcast/room/web/enter`` API was considered but returns empty bodies 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 -previous ``is_live`` value and never abort a refresh tier (exit code is -always 0). Only the two probe fields are touched; all other keys (including -``live_url``) are preserved. Note the ``daily`` tier means the badge trails -reality by up to a day — truly real-time would need a higher-frequency job. +Everything is soft-fail: network errors, empty or non-JSON responses clear +``is_live`` to False and drop ``live_probed_at`` (so consumers treat the +badge as stale/unknown) and never abort a refresh tier (exit code is always +0). Only the two probe fields are touched; all other keys (including +``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. @@ -212,7 +214,11 @@ def probe_streamers( print(f"skip {sid}: platform={platform!r} unsupported", flush=True) continue 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 continue row["is_live"] = is_live diff --git a/web/frontend/config.js b/web/frontend/config.js index 43d6047..276c649 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.5.111"; +var SITE_VERSION = "0.5.112"; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index 1873911..1a8a419 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -5,8 +5,8 @@ DOTA2 上分帝 - - + +

DOTA2 上分帝

@@ -178,8 +178,8 @@
- - - + + + diff --git a/web/frontend/style.css b/web/frontend/style.css index 37cde85..735afe5 100644 --- a/web/frontend/style.css +++ b/web/frontend/style.css @@ -1514,11 +1514,16 @@ body { margin-left: 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 { - max-width: 1320px; - margin-left: auto; - margin-right: auto; + max-width: none; + width: 100%; + margin-left: 0; + margin-right: 0; + flex: 1 1 auto; + min-height: 0; } .hero-inspect .item-detail-large-icon { width: 96px; @@ -1700,17 +1705,21 @@ body { max-width: none; flex: 1 1 0; } -/* Stretch to the body's definite height (flex chain from #detail) so the - 16:9 intrinsic size cannot push past the panel; cover crops to fill. */ +/* Official demos are 16:9. Prefer a real widescreen frame (grow toward + 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 { - flex: 0 1 42%; - width: 42%; - max-width: 480px; + flex: 0 1 auto; + align-self: center; min-width: 220px; min-height: 0; - height: 100%; + width: min(720px, 58%); + max-width: min(720px, 58%); + height: auto; max-height: 100%; - object-fit: cover; + aspect-ratio: 16 / 9; + object-fit: contain; border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.08); background: #000; diff --git a/web/notify_site_traffic.py b/web/notify_site_traffic.py index f530e84..6d1a122 100644 --- a/web/notify_site_traffic.py +++ b/web/notify_site_traffic.py @@ -550,7 +550,7 @@ def _nested_timestamp(payload: dict, *path: str) -> datetime | None: 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 step_ok = any( isinstance(row, dict) @@ -765,6 +765,18 @@ def _fmt_workflow_line(w: WorkflowDayStatus) -> str: 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: """Feishu interactive card (msg_type=interactive).""" 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), workflow_file, ) - result = "已部署" if summary.get("deployed") else "无需部署" - if not summary.get("ok"): - result = "刷新失败" + result = _fmt_refresh_summary_result(summary) stale_count = int( (summary.get("health") or {}).get("stratz_matchup_stale") or 0 ) diff --git a/web/refresh_web.py b/web/refresh_web.py index da28b8b..bcf6443 100644 --- a/web/refresh_web.py +++ b/web/refresh_web.py @@ -3,9 +3,9 @@ Tiers (see AGENTS.md / Gitea Actions workflows): daily — OpenDota stats, leaderboards, matches, pro matches, streamers, streamer live probe; patch check - (live badge still trails reality by up to a day — truly real-time - would need a dedicated higher-frequency workflow, e.g. 15-min) - weekly — STRATZ meta, hero items, items_meta, item_fears + (live badge is served by /api/live-status on visit; daily probe is + only a data.json fallback until the edge API returns) + weekly — STRATZ meta, hero items, items_meta, item_counter_stats, item_fears patch — patch list check; on has_new fetch details + version-linked scripts all — weekly then daily (patch check included in daily/patch) @@ -29,9 +29,11 @@ from datetime import datetime, timezone from pathlib import Path 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.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND # Paths whose content change should trigger deploy / OSS. DATA_WATCH = [ @@ -50,6 +52,21 @@ DATA_WATCH = [ ROOT / "data" / "item_shop.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 = [ ROOT / "assets" / "item_icons", ROOT / "assets" / "ability_icons", @@ -67,12 +84,24 @@ VOLATILE_JSON_KEYS = frozenset( "live_probed_at", "profile_fetched_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] = [] 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: if not path.is_file(): return None @@ -131,27 +160,39 @@ def _dir_digest(path: Path) -> str | None: def snapshot() -> dict[str, str | None]: out: dict[str, str | None] = {} 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: - out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d) + out[_watch_key(d) + "/"] = _dir_digest(d) return out 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 assets_changed = False + asset_keys = {_watch_key(d) + "/" for d in ASSET_DIRS} keys = set(before) | set(after) for k in keys: if before.get(k) == after.get(k): continue - if k.endswith("/"): + if k in asset_keys: assets_changed = True else: data_changed = True return data_changed, assets_changed - def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool: cmd = [sys.executable, str(ROOT / script), *args] 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_pro_matches.py", "--include-pubs", dry_run=dry_run) # Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss). - run_script("fetch_streamers.py", dry_run=dry_run) - # Soft-fail live probe (exits 0; probe failures keep previous is_live). - run_script("fetch_streamer_live.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 clear is_live / live_probed_at). + run_script("fetch_streamer_live.py", dry_run=dry_run, soft_fail=True) check = patch_check() if check.get("has_new"): print( @@ -323,11 +364,16 @@ def _stale_metrics() -> dict: return metrics -def _write_summary(summary: dict) -> None: +def _finalize_summary(summary: dict) -> dict: summary["steps"] = list(RUN_RESULTS) summary["patch_check"] = dict(PATCH_RESULT) summary["health"] = _stale_metrics() 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) print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True) @@ -441,7 +487,9 @@ def main() -> None: _write_summary(summary) return 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: run_script("refresh_cache.py", "restore") before = snapshot() if not args.dry_run else {} @@ -455,7 +503,10 @@ def main() -> None: if args.dry_run: summary["ok"] = 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 after = snapshot() @@ -484,7 +535,7 @@ def main() -> None: if assets_changed and not args.skip_oss: run_script("_oss_static_assets.py", "upload") 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 if should_deploy and not args.skip_deploy: @@ -502,7 +553,15 @@ def main() -> None: raise finally: 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) diff --git a/web/tests/test_refresh_reliability.py b/web/tests/test_refresh_reliability.py index 10fdc20..50cf880 100644 --- a/web/tests/test_refresh_reliability.py +++ b/web/tests/test_refresh_reliability.py @@ -1,9 +1,11 @@ from __future__ import annotations import json +import os import sys import tempfile import unittest +import urllib.error from datetime import datetime, timedelta, timezone from pathlib import Path 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.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 shared.http_utils import write_json_atomic +from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND class RefreshDigestTests(unittest.TestCase): @@ -45,6 +49,82 @@ class RefreshDigestTests(unittest.TestCase): ) 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: with self.assertRaises(RuntimeError): refresh_web.validate_publish_options( @@ -58,6 +138,31 @@ class RefreshDigestTests(unittest.TestCase): 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: with tempfile.TemporaryDirectory() as tmp: cached = Path(tmp) / "cached.json" @@ -132,6 +237,51 @@ class RefreshDigestTests(unittest.TestCase): 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): def test_failed_bracket_keeps_previous_data_and_marks_stale(self) -> None: fresh = {