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:
voson
2026-07-30 01:48:01 +08:00
co-authored by Cursor
parent 28858c0703
commit fdd926e9bb
11 changed files with 295 additions and 48 deletions
+3 -3
View File
@@ -73,7 +73,7 @@ climperor/
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/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` | 移动端门禁(`<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/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 用) |
+11
View File
@@ -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 缓存或写 summarypatch 因全量刷新锁冲突跳过时飞书摘要显示「已跳过」。
## [0.5.111] - 2026-07-30
### Changed
+2
View File
@@ -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` 裁左右。
### 顶部壳(紧凑单层)
+1 -1
View File
@@ -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"
+12 -6
View File
@@ -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
+1 -1
View File
@@ -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 = "";
+5 -5
View File
@@ -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.111" />
<script src="/mobile-gate.js?v=0.5.111"></script>
<link rel="stylesheet" href="/style.css?v=0.5.112" />
<script src="/mobile-gate.js?v=0.5.112"></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.111"></script>
<script src="/router.js?v=0.5.111"></script>
<script src="/app.js?v=0.5.111"></script>
<script src="/config.js?v=0.5.112"></script>
<script src="/router.js?v=0.5.112"></script>
<script src="/app.js?v=0.5.112"></script>
</body>
</html>
+20 -11
View File
@@ -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;
+14 -4
View File
@@ -550,7 +550,7 @@ def _nested_timestamp(payload: dict, *path: str) -> datetime | None:
def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool:
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
)
+75 -16
View File
@@ -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,6 +553,14 @@ def main() -> None:
raise
finally:
summary["duration_s"] = round(time.monotonic() - started, 3)
if args.dry_run:
# Print for humans; do not persist dry-run artifacts.
_finalize_summary(summary)
print(
"REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False),
flush=True,
)
else:
_write_summary(summary)
release_refresh_lock(lock_handle)
+150
View File
@@ -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 = {