Compare commits

..
2 Commits
Author SHA1 Message Date
vosonandCursor fdd926e9bb v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:48:01 +08:00
vosonandCursor 28858c0703 v0.5.111: sort streamers by live status then follower count.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:32:01 +08:00
13 changed files with 324 additions and 58 deletions
+4 -4
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 |
@@ -109,7 +109,7 @@ climperor/
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→ChaliceSomnus 即 MaybeCN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
| `web/data/pro_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` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s`stale`/失败不显示角标;`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`,无间距);列表排序:先 `is_live` 再粉丝数降序;视口分档滚播(远处不拉、近处 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 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
@@ -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 用) |
+17
View File
@@ -4,6 +4,23 @@
## [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
- 主播页排序:先按是否在播(在播靠前),再按粉丝数降序;直播状态刷新后同步重排。
## [0.5.110] - 2026-07-30
### Fixed
+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
@@ -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 分钟合并请求),本地 `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/`
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + 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`)。
+1 -1
View File
@@ -52,7 +52,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.110"
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
+15 -2
View File
@@ -3957,6 +3957,9 @@ function streamerList() {
const rows = streamersData().streamers;
if (!Array.isArray(rows)) return [];
return [...rows].sort((a, b) => {
const la = a && a.is_live === true ? 1 : 0;
const lb = b && b.is_live === true ? 1 : 0;
if (la !== lb) return lb - la;
const fa = Number(a.follower_count);
const fb = Number(b.follower_count);
const na = Number.isFinite(fa) && fa >= 0 ? fa : -1;
@@ -4785,14 +4788,24 @@ function refreshStreamerLiveStatus() {
}
}
if (!changed) return;
// Update rendered cards in place (top-level page + hero detail panel);
// rows already merged, so any later re-render picks up the new state.
// Re-render list views so live streamers bubble to the front; otherwise
// sync any leftover cards in place (rows already merged for later paints).
if (state.page === "streamers") {
renderStreamers();
} else if (
state.page === "heroes" &&
state.detailTab === "streamers" &&
state.selectedKey
) {
renderDetail();
} else {
document
.querySelectorAll(".streamer-card[data-streamer-id]")
.forEach((card) => {
const row = rows.find((r) => r && r.id === card.dataset.streamerId);
if (row) syncStreamerCardLive(card, row);
});
}
})
.catch(() => {
/* endpoint missing/unreachable: keep data.json 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.110";
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.110" />
<script src="/mobile-gate.js?v=0.5.110"></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.110"></script>
<script src="/router.js?v=0.5.110"></script>
<script src="/app.js?v=0.5.110"></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 = {