v0.5.84: matches origin filter, mobile gate, refresh reliability.

Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 18:31:55 +08:00
co-authored by Cursor
parent 7681fdb069
commit b01552ee6e
50 changed files with 3406 additions and 487 deletions
+23
View File
@@ -12,6 +12,10 @@ on:
- cron: "0 22 * * *"
workflow_dispatch:
concurrency:
group: climperor-web-full-refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: self-hosted
@@ -19,6 +23,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Restore refresh cache
uses: actions/cache@v4
with:
path: web/.refresh-cache
key: web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-${{ github.run_id }}
restore-keys: |
web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-
web-refresh-${{ runner.os }}-
- name: Install deps
run: |
set -e
@@ -29,6 +42,8 @@ jobs:
- name: Refresh daily tier
env:
REFRESH_CACHE_ENABLED: "1"
REFRESH_RUN_ID: ${{ github.run_id }}
STRATZ_API_TOKEN: ${{ secrets.STRATZ_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
@@ -39,3 +54,11 @@ jobs:
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
"$PYTHON" web/refresh_web.py --tier daily
- name: Save refresh summary
if: always()
uses: actions/upload-artifact@v4
with:
name: web-daily-refresh-${{ github.run_id }}
path: web/.refresh/summary.json
if-no-files-found: warn
+23
View File
@@ -11,6 +11,10 @@ on:
- cron: "0 */6 * * *"
workflow_dispatch:
concurrency:
group: climperor-web-patch-refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: self-hosted
@@ -18,6 +22,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Restore refresh cache
uses: actions/cache@v4
with:
path: web/.refresh-cache
key: web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-${{ github.run_id }}
restore-keys: |
web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-
web-refresh-${{ runner.os }}-
- name: Install deps
run: |
set -e
@@ -28,6 +41,8 @@ jobs:
- name: Refresh patch tier
env:
REFRESH_CACHE_ENABLED: "1"
REFRESH_RUN_ID: ${{ github.run_id }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
@@ -37,3 +52,11 @@ jobs:
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
"$PYTHON" web/refresh_web.py --tier patch
- name: Save refresh summary
if: always()
uses: actions/upload-artifact@v4
with:
name: web-patch-refresh-${{ github.run_id }}
path: web/.refresh/summary.json
if-no-files-found: warn
+23
View File
@@ -10,6 +10,10 @@ on:
- cron: "0 23 * * 1"
workflow_dispatch:
concurrency:
group: climperor-web-full-refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: self-hosted
@@ -17,6 +21,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Restore refresh cache
uses: actions/cache@v4
with:
path: web/.refresh-cache
key: web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-${{ github.run_id }}
restore-keys: |
web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-
web-refresh-${{ runner.os }}-
- name: Install deps
run: |
set -e
@@ -27,6 +40,8 @@ jobs:
- name: Refresh weekly tier
env:
REFRESH_CACHE_ENABLED: "1"
REFRESH_RUN_ID: ${{ github.run_id }}
STRATZ_API_TOKEN: ${{ secrets.STRATZ_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
@@ -37,3 +52,11 @@ jobs:
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
"$PYTHON" web/refresh_web.py --tier weekly
- name: Save refresh summary
if: always()
uses: actions/upload-artifact@v4
with:
name: web-weekly-refresh-${{ github.run_id }}
path: web/.refresh/summary.json
if-no-files-found: warn
+5
View File
@@ -38,6 +38,8 @@ web/data/hero_matches.json
web/data/pro_matches.json
web/data/pro_builds.json
web/data/_jugg_times_sample.json
web/.refresh/
web/.refresh-cache/
# Large regenerable CDN media (fetch scripts)
web/assets/ability_videos/
@@ -52,5 +54,8 @@ assets/streamer_videos/
# Static site export (export_relations_site.py)
web/dist/
# Local wrangler / Pages Functions runtime
.wrangler/
# Local probes / one-off diagnostics
_probe_item_fears_api.py
+15 -12
View File
@@ -75,8 +75,9 @@ climperor/
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/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``--dry-run` / `--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品;`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears |
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论 + Pages 当日生产部署;`--dry-run` 只打印卡片) |
| `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_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 |
| `.gitea/workflows/web-daily.yml` | 每日 06:00 CST`web/refresh_web.py --tier daily`self-hosted;需 CF + OSS SecretsSTRATZ 可选) |
| `.gitea/workflows/web-weekly.yml` | 周二 07:00 CST`web/refresh_web.py --tier weekly`(需 `STRATZ_API_TOKEN` + CF + OSS |
@@ -87,11 +88,13 @@ climperor/
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
| `web/fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `web/data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend |
| `web/fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `web/data/hero_matches.json``--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby`--public-region china` 优先国服;`--workers` 详情并发 + 凑满即停;可选 `OPENDOTA_API_KEY`;增量补缺失/不足 N 场;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend |
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--players` 覆盖;`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily**仅上分帝 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_streamers.py` | 从抖音主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞/头像(手工名单 + 直播间/主页 URL;支持 `v.douyin.com` 短链;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily |
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json``is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info``live_status==1` 在播,轮播算下播);软失败保留旧 `is_live`、始终 exit 0`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily |
| `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`),Cache API 固定键合并请求5 分钟新鲜窗口,**无 KV**、无需 wrangler 配置);抖音从数据中心 IP 失败属预期 → 单主播沿用旧缓存`stale`,全失败回陈旧快照(`stale-override`)或空表(`error`),永不 500`context.waitUntil` 写缓存;导出时 `export_relations_site.py` 拷贝 `functions/` |
| `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`),`data.json``streamers.streamers`Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 单主播沿用 edge 缓存/daily `is_live` `stale`,全失败回陈旧快照(`stale-override`)或空表(`error`),永不 500导出时拷贝 `functions/`**部署须 `cwd=dist` 跑 wrangler**Functions 相对 cwd 解析) |
| `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py``web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 |
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__``app.js` 跳过桌面 boot |
| `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `web/data/item_shop.json` + 图标 |
| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json``%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py` |
| `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
@@ -105,10 +108,10 @@ climperor/
| `web/data/hero_items.json` | 核心成品装备缓存(Web 站点只读;由 `fetch_hero_items.py` 生成) |
| `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
| `web/data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(CN/EU 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛对局(`by_pro`/`by_hero` + 终局出装/加点;由 `fetch_pro_matches.py` 生成;Web「近期比赛」`hero_matches` 合并展示;进 daily;**不**参与局内推荐) |
| `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 卡片视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上 `/api/live-status` 边缘缓存 5 分钟;`data.json` daily 兜底;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音/B 站 profile 补全;`platform_meta`Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上 `/api/live-status` 边缘缓存 5 分钟;`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` 生成;物品页只读) |
@@ -119,8 +122,8 @@ 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` / `functions/`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` |
| `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]` / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
| `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/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 用) |
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) |
@@ -169,7 +172,7 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
- **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null`
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
- **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**job 内现拉现部署)。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
- **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
## 开发命令
@@ -183,7 +186,7 @@ python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage--workers 6--enrich-item-times
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认读 pro_player_watchlist.json;可 --players Ame,898754153
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist--include-pubs 含天梯;可 --players Ame,898754153
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily--dry-run 只打印)
@@ -220,7 +223,7 @@ python pc/evaluate.py
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`web/serve_relations.py` 无需改;`web/export_relations_site.py` 的导出文件列表须含 `router.js`
-`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles``pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py``SITE_VERSION``web/frontend/config.js` 的同名变量,以及 `index.html``style.css`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md`
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py``SITE_VERSION``web/frontend/config.js` 的同名变量,以及 `index.html``style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md`
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
## 文档分工
+98
View File
@@ -4,10 +4,108 @@
## [Unreleased]
## [0.5.84] - 2026-07-29
### Fixed
- 「比赛」侧栏「全部 / 职业 / 国服」与选手按钮宽度对齐。
## [0.5.83] - 2026-07-29
### Added
- 上分帝 Web「比赛」侧栏顶部增加「全部 / 职业 / 国服」筛选;选手列表随类型联动;
URL `#/matches[/account_id][?origin=pro|china][&page=N]`(默认全部、第 1 页省略参数)。
## [0.5.82] - 2026-07-29
### Changed
- 仓库重组为 `pc/`(局内识别)、`web/`(上分帝 Web)与 `shared/`(共享包)三层
monorepo;保留脚本直跑方式,站点内容与 `SITE_VERSION` 不变。
- 上分帝 Web 飞书日报卡片精简:去掉模块列表;地区/状态码并入「访问」;
「刷新与部署」扁平展示版本、Actions、Pages。
- 上分帝 Web 定时刷新增加跨档位并发锁与可恢复缓存;补丁详情、近期比赛、
STRATZ 等增量数据不再因 checkout 清理而反复全量拉取。
- 刷新部署改为按业务内容判定变化,并统一原子写 JSON;STRATZ 请求失败保留旧
数据并标记陈旧,OSS 未同步时阻止 Pages 发布。
- 飞书日报新增刷新摘要、生产数据新鲜度和 `/api/live-status` 探针;Pages 发布后
自动验证数据快照与 Function,避免“任务绿色但线上未更新”。
## [0.5.81] - 2026-07-29
### Added
- 上分帝 Web「比赛」页分页:每页 20 场,底栏页码;URL `#/matches[/account_id][?page=N]`
- 明星比赛可选拉取天梯(`lobby_type=7``--include-pubs`daily 默认开启);卡片标
「职业 / 天梯 / 国服」。
### Changed
- 明星名单对照 Dotabuff / Liquipedia 补全主要战队与国服昔日明星(如 Somnus/Maybe、
Chalice/查理斯、YB、Falcons 缺席等);`fetch_pro_matches` 按每种 lobby 各保留
`--limit` 场,避免联赛挤掉天梯。
- 「比赛」卡片列宽统一(约 30 级技能条 + 余量),右侧「详情」对齐。
## [0.5.80] - 2026-07-29
### Changed
- 上分帝 Web「主播」卡片资料区定稿:头像行 = 头像 \| 昵称 + 抖音号/获赞/粉丝
(B 站为 UID/关注/粉丝)\|「关注」;签名(主页留言)独立全宽行(最长 3 行),
不再夹在中间栏。
## [0.5.79] - 2026-07-29
### Changed
- 上分帝 Web「主播」直播角标按抖音网页版实测对齐:`bottom: -6px` 叠在粉环底部
(无间距)、粉渐变底、无描边阴影;粉环色改为 `rgb(254, 44, 85)`
## [0.5.78] - 2026-07-29
### Added
- 上分帝 Web:移动端拦截页(手机/平板提示改用 PC 访问)+ 「催更移动端」需求计数
`/api/mobile-demand`;同设备只计一次)。
## [0.5.77] - 2026-07-29
### Changed
- 上分帝 Web「主播」资料行强化数值层级与分隔节奏。
## [0.5.76] - 2026-07-29
### Changed
- 上分帝 Web「主播」取消头像悬浮窗;抖音号 / 获赞 / 粉丝与签名收进头像与「关注」
之间的中间栏;直播角标与粉环间距加大,避免贴边。
## [0.5.75] - 2026-07-29
### Changed
- 上分帝 Web「主播」抖音号 / 获赞 / 粉丝改为头像悬停卡片展示(不再占卡片正文
一行);有直播间时附「直播中 · 点击进入」或「点击前往直播间」。
## [0.5.74] - 2026-07-29
### Fixed
- 上分帝 Web `/api/live-status`:正确读取 `data.json``streamers.streamers`
列表(此前读顶层数组导致探测目标为空);冷启动用 daily `is_live` 作软失败
兜底;同 isolate 并发请求合并探测。
- 部署时从 `dist/` 目录跑 wrangler,确保 `functions/`Pages Function)被编译
上传;此前从仓库根部署会漏掉 Function,线上接口回落到 `index.html`
## [0.5.73] - 2026-07-29
### Changed
- 上分帝 Web 时间展示统一为人性化混排:近时「刚刚 / N分钟前 / N小时前 /
昨天 / N天前 / N周前」,更早显示「6/13 19:00」(跨年带年份);悬停可见完整
绝对时间。覆盖比赛开场、排行/主播/走势/对位「更新于」、版本日期等。
## [0.5.72] - 2026-07-29
+14 -6
View File
@@ -41,7 +41,8 @@ python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → web
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(含购买时间)→ web/data/hero_matches.json
# 既有行补时间:python web/fetch_hero_matches.py --enrich-item-times [--heroes juggernaut]
python web/fetch_pro_matches.py # 明星选手近期联赛/锦标赛 → web/data/pro_matches.json(默认 watchlist
# 指定选手python web/fetch_pro_matches.py --players Ame,Yatoro --limit 12
# 含天梯python web/fetch_pro_matches.py --include-pubs --limit 8
# 指定选手(会整文件覆盖):python web/fetch_pro_matches.py --players Ame,Yatoro --limit 12
python web/fetch_item_shop.py # 官网商店 11 列目录 → web/data/item_shop.json
python web/fetch_items_meta.py # 装备描述 + 机制标签 → web/data/items_meta.json
python web/fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → web/data/hero_abilities.json
@@ -55,7 +56,7 @@ python web/fetch_streamer_live.py # 探测真实在播状态 → is_live/live_p
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
```
Web 站点带 **Hash 路由**`#/heroes/axe/core``#/heroes/axe/trends``#/heroes/axe/matchups``#/heroes/axe/matches``#/trends/legend``#/rankings``#/matches`(明星比赛,可 `#/matches/898754153`)、`#/items/black_king_bar``#/patches/7.41``#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
Web 站点带 **Hash 路由**`#/heroes/axe/core``#/heroes/axe/trends``#/heroes/axe/matchups``#/heroes/axe/matches``#/trends/legend``#/rankings``#/matches`(明星比赛,可 `#/matches/898754153``#/matches?origin=china``#/matches?page=2`)、`#/items/black_king_bar``#/patches/7.41``#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
### 部署到 Cloudflare Pages
@@ -72,7 +73,7 @@ python web/_cf_status.py # 部署后只读核对(项目 / 部署 / 域名
### 定时刷新(Gitea Actions
仓库 [`.gitea/workflows/`](.gitea/workflows/) 在 **self-hosted** runner(与 `site-traffic-notify` 同一 Mac mini)上分层拉数;`web/refresh_web.py` 对 JSON/图标摘要,**有变更才** `_oss_static_assets.py upload` + `deploy_relations.py`。数据-only 刷新**不** bump `SITE_VERSION``data.json` / `index.html` 已不缓存)。
仓库 [`.gitea/workflows/`](.gitea/workflows/) 在 **self-hosted** runner(与 `site-traffic-notify` 同一 Mac mini)上分层拉数;`web/refresh_web.py` 对 JSON 做忽略纯时间戳的业务摘要,**有业务变更才** `_oss_static_assets.py upload` + `deploy_relations.py``web/refresh_cache.py` 在发布锁内通过 runner 持久目录恢复/保存全部定时数据与补丁状态,Actions cache 另作冷启动备份;daily/weekly 串行,patch 与全量刷新冲突时延后到下一轮。数据-only 刷新**不** bump `SITE_VERSION``data.json` / `index.html` 已不缓存)。
| Workflow | 时间(CST | 档位 | 拉取内容 |
|----------|-------------|------|----------|
@@ -82,6 +83,13 @@ python web/_cf_status.py # 部署后只读核对(项目 / 部署 / 域名
**版本检测:** `--check` 只请求 Valve 版本列表,与本地 `web/data/patches.json``details` 比对,stdout 一行 JSON`has_new` / `new_versions` / `latest`)。无新版本则跳过详情与部署。
**可靠性与验收:** 每次刷新都会把 `REFRESH_SUMMARY` 写入
`web/.refresh/summary.json` 并上传为 Actions artifact,记录步骤、耗时、变更、
部署和 stale 状态。飞书日报同时校验 Actions 摘要、生产 `data.json` 新鲜度及
`/api/live-status`;部署后会验证本次 `refresh_run_id`、英雄数据和 Pages
Function。STRATZ / OpenDota 异常空响应不会覆盖上一份有效缓存,OSS 资产未同步时
也不会继续发布 Pages。
**本机试跑:**
```powershell
@@ -103,14 +111,14 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
| `STRATZ_API_TOKEN` | `web-weekly`(及 daily 内若触发连带时不需要) |
| `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` | 图标变更时上传 OSS |
手工数据(`shared/data/relations.json`、overrides)与技能视频(GB 级)**不**进定时;新英雄仍需本机 `pc/fetch_cdn_templates.py` + `web/fetch_hero_portraits.py`。冒烟:Gitea Actions `web-patch`**Run workflow**
手工数据(`shared/data/relations.json`、overrides)与技能视频(GB 级)**不**进定时;新英雄仍需本机 `pc/fetch_cdn_templates.py` + `web/fetch_hero_portraits.py`。冒烟:先对 `web-daily`、再`web-patch`**Run workflow**;第二次无业务变化时应显示 `deployed=false`,且生产探针无超时项
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**走势**(STRATZ 优先:勋章条 + 最近 1 周三卡 + 分路 + 近 8 周列表;OpenDota 兜底)、**对位**STRATZ 克制/被克/搭档 Top)、**近期比赛**(终局出装/加点)、**主播**(常玩该英雄的收录主播)、**版本变更**(该英雄近一年各版本改动)。
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站)抖音式卡片展示昵称、签名、关注/粉丝/获赞、常用英雄可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;「直播」角标与动效只在真实探测在播时显示——线上由访客触发的 `/api/live-status` 边缘函数刷新,缓存 5 分钟合并请求;`data.json``fetch_streamer_live.py` daily 探测值兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`
顶部另有 **比赛** 页(`#/matches[/account_id]`):明星选手 watchlist 近期联赛/锦标赛对局(出装/加点);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py` 拉取(亦进 daily)。
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站)抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新,缓存 5 分钟合并请求;`data.json``fetch_streamer_live.py` daily 探测值兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`
顶部另有 **比赛** 页(`#/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`)。
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json``python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
+32
View File
@@ -7,6 +7,8 @@ timeout, and retry conventions live in exactly one place.
from __future__ import annotations
import json
import os
import tempfile
import time
import urllib.error
import urllib.request
@@ -22,6 +24,36 @@ HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language={lang}"
def write_json_atomic(
path: Path,
payload,
*,
indent: int | None = 2,
separators: tuple[str, str] | None = None,
) -> None:
"""Atomically replace a UTF-8 JSON file after flushing it to disk."""
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(
payload,
ensure_ascii=False,
indent=indent,
separators=separators,
)
if indent is not None:
text += "\n"
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
def _http_open(url: str, *, timeout: int, retries: int, backoff: float):
"""urlopen with retries on 429 / 5xx / transient network errors."""
last_err: BaseException | None = None
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+548 -42
View File
@@ -1,49 +1,555 @@
{
"meta": {
"note_zh": "手工维护的明星选手 OpenDota account_idfetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。",
"source": "opendota/proPlayers",
"note_zh": "手工维护的明星选手 OpenDota account_idfetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。对照 Dotabuff / Liquipedia playerid。外号:查理斯→ChaliceSomnus 即 Maybe(路垚);Faith_bian 现常用 Bach。",
"source": "opendota/proPlayers + dotabuff.com/players",
"updated_at": "2026-07-29"
},
"players": [
{"account_id": 898754153, "name": "Ame", "team_tag": "XG", "region": "cn"},
{"account_id": 173978074, "name": "NothingToSay", "team_tag": "XG", "region": "cn"},
{"account_id": 94296097, "name": "xNova", "team_tag": "XG", "region": "cn"},
{"account_id": 129958758, "name": "Xxs", "team_tag": "XG", "region": "cn"},
{"account_id": 101695162, "name": "fy", "team_tag": "XG", "region": "cn"},
{"account_id": 157475523, "name": "XinQ", "team_tag": "VG", "region": "cn"},
{"account_id": 320252024, "name": "shiro", "team_tag": "VG", "region": "cn"},
{"account_id": 148215639, "name": "Monet", "team_tag": "", "region": "cn"},
{"account_id": 854671154, "name": "XM", "team_tag": "", "region": "cn"},
{"account_id": 292921272, "name": "Wisper", "team_tag": "LGD", "region": "cn"},
{"account_id": 321580662, "name": "Yatoro", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 302214028, "name": "Collapse", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 106305042, "name": "Larl", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 113331514, "name": "Miposhka", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 100058342, "name": "skiter", "team_tag": "FLCN", "region": "eu"},
{"account_id": 183719386, "name": "AMMAR_THE_F", "team_tag": "FLCN", "region": "eu"},
{"account_id": 898455820, "name": "Malr1ne", "team_tag": "FLCN", "region": "eu"},
{"account_id": 10366616, "name": "Sneyking", "team_tag": "FLCN", "region": "eu"},
{"account_id": 201358612, "name": "Nisha", "team_tag": "Liquid", "region": "eu"},
{"account_id": 77490514, "name": "Boxi", "team_tag": "Liquid", "region": "eu"},
{"account_id": 16497807, "name": "tOfu", "team_tag": "Liquid", "region": "eu"},
{"account_id": 54580962, "name": "Insania", "team_tag": "Liquid", "region": "eu"},
{"account_id": 93618577, "name": "bzm", "team_tag": "1w", "region": "eu"},
{"account_id": 331855530, "name": "Pure", "team_tag": "1w", "region": "eu"},
{"account_id": 86698277, "name": "33", "team_tag": "1w", "region": "eu"},
{"account_id": 136829091, "name": "Whitemon", "team_tag": "1w", "region": "eu"},
{"account_id": 124801257, "name": "Nightfall", "team_tag": "Aurora", "region": "eu"},
{"account_id": 858106446, "name": "kiyotaka", "team_tag": "Aurora", "region": "eu"},
{"account_id": 480412663, "name": "gpk~", "team_tag": "BB", "region": "eu"},
{"account_id": 196878136, "name": "Kataomi`", "team_tag": "BB", "region": "eu"},
{"account_id": 1044002267, "name": "Satanic", "team_tag": "VSN", "region": "eu"},
{"account_id": 127617979, "name": "Crystallis", "team_tag": "MOUZ", "region": "eu"},
{"account_id": 431770905, "name": "TORONTOTOKYO", "team_tag": "OG", "region": "eu"},
{"account_id": 111620041, "name": "SumaiL-", "team_tag": "NGX", "region": "eu"},
{"account_id": 103735745, "name": "Saksa", "team_tag": "TY", "region": "eu"}
{
"account_id": 130416036,
"name": "Agressif",
"team_tag": "",
"region": "cn"
},
{
"account_id": 94738847,
"name": "Chalice",
"team_tag": "",
"region": "cn"
},
{
"account_id": 140613225,
"name": "Cty",
"team_tag": "",
"region": "cn"
},
{
"account_id": 143693439,
"name": "Dy",
"team_tag": "",
"region": "cn"
},
{
"account_id": 88585077,
"name": "Ferrari_430",
"team_tag": "",
"region": "cn"
},
{
"account_id": 148215639,
"name": "Monet",
"team_tag": "",
"region": "cn"
},
{
"account_id": 134276083,
"name": "old eLeVeN",
"team_tag": "",
"region": "cn"
},
{
"account_id": 137193239,
"name": "Paparazi灬",
"team_tag": "",
"region": "cn"
},
{
"account_id": 138543123,
"name": "Pyw",
"team_tag": "",
"region": "cn"
},
{
"account_id": 149486894,
"name": "Sccc",
"team_tag": "",
"region": "cn"
},
{
"account_id": 106863163,
"name": "Somnus/Maybe",
"team_tag": "",
"region": "cn"
},
{
"account_id": 108382060,
"name": "Sylar",
"team_tag": "",
"region": "cn"
},
{
"account_id": 98887913,
"name": "xiao8",
"team_tag": "",
"region": "cn"
},
{
"account_id": 854671154,
"name": "XM",
"team_tag": "",
"region": "cn"
},
{
"account_id": 107803494,
"name": "Ori",
"team_tag": "AR",
"region": "cn"
},
{
"account_id": 118134220,
"name": "Bach",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 320252024,
"name": "shiro",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 157475523,
"name": "XinQ",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 137129583,
"name": "Xm",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 111114687,
"name": "y`",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 898754153,
"name": "Ame",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 101695162,
"name": "fy",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 173978074,
"name": "NothingToSay",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 94296097,
"name": "xNova",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 129958758,
"name": "Xxs",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 207829314,
"name": "BoBoKa",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 202217968,
"name": "Emo",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 185908355,
"name": "Lou",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 139822354,
"name": "Setsu",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 147767183,
"name": "Zeal",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 158847773,
"name": "天命",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 86698277,
"name": "33",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 346412363,
"name": "Ari",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 93618577,
"name": "bzm",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 331855530,
"name": "Pure",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 136829091,
"name": "Whitemon",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 320219866,
"name": "kaori",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 858106446,
"name": "kiyotaka",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 256156323,
"name": "Mira",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 124801257,
"name": "Nightfall",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 126842529,
"name": "Ws`",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 480412663,
"name": "gpk~",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 196878136,
"name": "Kataomi`",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 172099728,
"name": "Kiritych~",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 165564598,
"name": "MieRo",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 317880638,
"name": "Save-",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 183719386,
"name": "AMMAR_THE_F",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 25907144,
"name": "Cr1t-",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 898455820,
"name": "Malr1ne",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 100058342,
"name": "skiter",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 10366616,
"name": "Sneyking",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 97590558,
"name": "Ace ♠",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 77490514,
"name": "Boxi",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 54580962,
"name": "Insania",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 152962063,
"name": "m1CKe",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 201358612,
"name": "Nisha",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 16497807,
"name": "tOfu",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 190826739,
"name": "BOOM",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 127617979,
"name": "Crystallis",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 116585378,
"name": "MidOne",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 108958769,
"name": "panto",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 9403474,
"name": "yamich",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 101356886,
"name": "GH",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 140297552,
"name": "No!ob™",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 152168157,
"name": "OmaR",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 111620041,
"name": "SumaiL-",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 355168766,
"name": "Natsumi",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 100594231,
"name": "skem",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 155494381,
"name": "TIMS",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 431770905,
"name": "TORONTOTOKYO",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 324277900,
"name": "Yopaj-",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 302214028,
"name": "Collapse",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 106305042,
"name": "Larl",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 113331514,
"name": "Miposhka",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 847565596,
"name": "rue",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 321580662,
"name": "Yatoro",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 312436974,
"name": "CHIRA_JUNIOR",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 56351509,
"name": "DM",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 93817671,
"name": "Maladych",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 103735745,
"name": "Saksa",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 171262902,
"name": "医者watson`",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 140288368,
"name": "Tobi",
"team_tag": "Tundra",
"region": "eu"
},
{
"account_id": 94054712,
"name": "Topson",
"team_tag": "Tundra",
"region": "eu"
},
{
"account_id": 73401082,
"name": "Dukalis",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 106573901,
"name": "No[o]ne-",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 195108598,
"name": "Noticed",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 1044002267,
"name": "Satanic",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 131303632,
"name": "4nalog01",
"team_tag": "Heroic",
"region": "sa"
},
{
"account_id": 81306398,
"name": "KingJungles",
"team_tag": "Heroic",
"region": "sa"
},
{
"account_id": 292921272,
"name": "Wisper",
"team_tag": "Heroic",
"region": "sa"
},
{
"account_id": 177203952,
"name": "Yuma",
"team_tag": "Heroic",
"region": "sa"
}
]
}
+224 -2
View File
@@ -19,7 +19,8 @@
"profile_url": "https://v.douyin.com/MM3U-EpqI2E/",
"tagline": "天怒绝活哥",
"heroes": [
"skywrath_mage"
"skywrath_mage",
"shredder"
],
"nickname": "DOTA2小王",
"unique_id": "chenshiweidota",
@@ -40,7 +41,8 @@
"live_url": "https://live.douyin.com/892762012034",
"profile_url": "https://v.douyin.com/FcUd6qHXYwc/",
"heroes": [
"juggernaut"
"juggernaut",
"phantom_assassin"
],
"nickname": "Dota2剑心犹在",
"unique_id": "33552682609",
@@ -255,6 +257,226 @@
"video_title": "Dota2沙王直播录像",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:20.981087+00:00"
},
{
"id": "qitiandasheng",
"platform": "douyin",
"profile_url": "https://v.douyin.com/BclSpsH9RJ0/",
"tagline": "万场绝活大圣王",
"heroes": [
"monkey_king"
],
"nickname": "Dota2-yellow",
"unique_id": "381896350",
"signature": "万场绝活大圣王 关注小黄带给你顶级的大圣教学\n辉耀魔晶法师克星巫师之刃开创者\n接定位上车打号yellow6x",
"following_count": 276,
"follower_count": 31000,
"total_favorited": 53000,
"avatar": "streamer_avatars/qitiandasheng.jpg",
"video": "streamer_videos/qitiandasheng.mp4",
"video_aspect": "1080/1920",
"video_fit": "cover",
"video_title": "你从未见过的4v6 4圣剑一刷新超凡局逆天灭世翻盘! 求仙问卜不如自己做主!!这把我要赢!",
"profile_fetched_at": "2026-07-29T08:35:00+00:00"
},
{
"id": "luna",
"platform": "douyin",
"live_url": "https://live.douyin.com/25345747701",
"profile_url": "https://v.douyin.com/qTjo7mybfZA/",
"tagline": "国服第九露娜",
"heroes": [
"luna"
],
"nickname": "DOTA2冰凉皮蛋",
"unique_id": "dy19wol6d5wn",
"signature": "DOTA2万分一号位接上分定位赛陪玩复盘\n主播主玩145,话痨型教学主播,直播间一定能学到技术",
"following_count": 11,
"follower_count": 1025,
"total_favorited": 1496,
"avatar": "streamer_avatars/luna.jpg",
"video": "streamer_videos/luna.mp4",
"video_title": "冠绝局露娜被破三路逆天翻盘 前期三路劣,中单凯经济老六毫无节奏,死活不出bkb各种被抓,全场被压着打,最后破三路一波翻盘",
"profile_fetched_at": "2026-07-29T09:08:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:08:00+00:00"
},
{
"id": "wenyi",
"platform": "douyin",
"live_url": "https://live.douyin.com/273765057602",
"profile_url": "https://v.douyin.com/DOK1nsY-zio/",
"tagline": "瘟疫法师",
"heroes": [
"necrolyte"
],
"nickname": "Dota2教练麟仔",
"unique_id": "qyjjjjj",
"signature": "打号加我:ymshkm ,ok林仔外门弟子\n排名最高前400,抖音林文化传播员\n有点小帅 心态顶级欢迎弹幕随意互动\n刀龄14年 教你最好的上分姿势爬出鱼塘\n直播时间:上午10点开播或下午2点开播\n我的猪宝贝@鱼鱼困了 (臭臭版",
"following_count": 527,
"follower_count": 26000,
"total_favorited": 6391,
"avatar": "streamer_avatars/wenyi.jpg",
"profile_fetched_at": "2026-07-29T09:22:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:22:00+00:00"
},
{
"id": "tumao",
"platform": "douyin",
"live_url": "https://live.douyin.com/134896053323",
"profile_url": "https://v.douyin.com/jhBRzuYLsQo/",
"tagline": "绝活土猫",
"heroes": [
"earth_spirit"
],
"nickname": "Dota2-土猫King",
"unique_id": "dota2_zkm",
"signature": "当我拿出土猫那一刻 阁下该如何应对 激情dota\n优势那肯定是队友强 劣势由土猫保驾护航",
"following_count": 88,
"follower_count": 11000,
"total_favorited": 2060,
"avatar": "streamer_avatars/tumao.jpg",
"video": "streamer_videos/tumao.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "实名点赞我的老板 关键跳羊带我比赛",
"profile_fetched_at": "2026-07-29T09:25:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:25:00+00:00"
},
{
"id": "huskar",
"platform": "douyin",
"live_url": "https://live.douyin.com/146558623901",
"profile_url": "https://v.douyin.com/Vcwyd4ol0ys/",
"tagline": "国服第一神灵武士",
"heroes": [
"huskar"
],
"nickname": "DOTA2 阿黎(只会玩神灵)",
"unique_id": "2056381253",
"signature": "老干爹前职业选手 | 国服第一神灵武士\n残血即是斩杀线,血条清空才是游戏开始\n接pw/代练/教学:yoyo_ice09 私信秒回\n老干爹英魂之刃前职业| 国一神灵认证\nDota2单排9000+ | 胜率78%+\n坐标:岳阳市 | 直播时间:全天不定时",
"following_count": 235,
"follower_count": 1602,
"total_favorited": 362,
"avatar": "streamer_avatars/huskar.jpg",
"video": "streamer_videos/huskar.mp4",
"video_title": "杀戮日常,超凡三黑",
"profile_fetched_at": "2026-07-29T09:28:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:28:00+00:00"
},
{
"id": "fatiao",
"platform": "douyin",
"profile_url": "https://v.douyin.com/ZBmUklVKXm4/",
"tagline": "发条",
"heroes": [
"rattletrap",
"magnataur"
],
"nickname": "刘大独(DOTA2",
"unique_id": "Ycn5505",
"signature": "水瓶老玩家 无公司 不签约 不一样的玩法 不一样的思维",
"following_count": 863,
"follower_count": 115000,
"total_favorited": 1132000,
"avatar": "streamer_avatars/fatiao.jpg",
"video": "streamer_videos/fatiao.mp4",
"video_title": "DOTA2发条 这版本发条是真好玩",
"profile_fetched_at": "2026-07-29T09:40:00+00:00"
},
{
"id": "zhadan",
"platform": "douyin",
"profile_url": "https://v.douyin.com/JTBBqWZFhVo/",
"tagline": "本色风情 · 炸弹人",
"heroes": [
"techies",
"furion"
],
"nickname": "bino",
"unique_id": "69276358589",
"signature": "做好玩的dota2对局,轻松快乐最重要 xwf2783",
"following_count": 224,
"follower_count": 96000,
"total_favorited": 881000,
"avatar": "streamer_avatars/zhadan.jpg",
"video": "streamer_videos/zhadan.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "什么叫20分钟锁了对面5个人在家?",
"profile_fetched_at": "2026-07-29T09:42:00+00:00"
},
{
"id": "kaer",
"platform": "douyin",
"profile_url": "https://v.douyin.com/rSCOHKhoktg/",
"tagline": "绝活卡尔",
"heroes": [
"invoker"
],
"nickname": "Dota2卡尔卡滨kb",
"unique_id": "380362637",
"signature": "2013年刚出天梯时无脑一手卡尔到天梯前10",
"following_count": 220,
"follower_count": 38000,
"total_favorited": 43000,
"avatar": "streamer_avatars/kaer.jpg",
"video": "streamer_videos/kaer.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "万2均分携手雕哥游龙",
"profile_fetched_at": "2026-07-29T09:45:00+00:00"
},
{
"id": "lanmao",
"platform": "douyin",
"live_url": "https://live.bilibili.com/32554189",
"profile_url": "https://v.douyin.com/LKxUBIHCC2c/",
"tagline": "绝活蓝猫",
"heroes": [
"storm_spirit"
],
"nickname": "小铅笔HB",
"unique_id": "786543863",
"signature": "Muteki01(上车,教学,商务)",
"following_count": 644,
"follower_count": 43000,
"total_favorited": 470000,
"avatar": "streamer_avatars/lanmao.jpg",
"video": "streamer_videos/lanmao.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "你的一波失误换来的就是蓝猫时刻!",
"profile_fetched_at": "2026-07-29T09:48:00+00:00",
"is_live": false,
"live_probed_at": "2026-07-29T09:49:35.253522+00:00"
},
{
"id": "huonv",
"platform": "douyin",
"live_url": "https://live.douyin.com/930419992694",
"profile_url": "https://v.douyin.com/TleGRW0DhNo/",
"tagline": "火女",
"heroes": [
"lina"
],
"nickname": "新生Dota2",
"unique_id": "xsdota2",
"signature": "坚持认真做好玩的Dota2攻略。 上车/合作:xsdota2",
"following_count": 280,
"follower_count": 66000,
"total_favorited": 1105000,
"avatar": "streamer_avatars/huonv.jpg",
"video": "streamer_videos/huonv.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "2个镜头告诉你为什么火女必出A",
"profile_fetched_at": "2026-07-29T09:54:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:54:00+00:00"
}
]
}
+85 -6
View File
@@ -34,7 +34,9 @@ import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@@ -239,10 +241,16 @@ def wrangler_deploy(
) -> None:
"""Upload dist via `wrangler pages deploy` (direct upload, non-interactive).
The whole dist directory is uploaded, so dist/functions/ (Pages Functions,
e.g. /api/live-status) is included automatically Pages Functions need
no extra wrangler config or flags for direct-upload projects.
Wrangler looks for ``functions/`` relative to the process cwd (not inside
an absolute asset-directory argument). Run from ``dist`` and deploy ``.``
so ``dist/functions/`` (e.g. /api/live-status) is compiled and uploaded.
"""
functions_dir = dist / "functions"
if not functions_dir.is_dir():
print(
f"warn: {functions_dir} missing — Pages Functions will not deploy "
"(re-run export_relations_site.py)"
)
env = os.environ.copy()
# wrangler reads Global API Key auth from these env vars. They are passed
# to the subprocess only; wrangler does not echo them.
@@ -256,15 +264,76 @@ def wrangler_deploy(
"wrangler@3",
"pages",
"deploy",
str(dist),
".",
"--project-name",
project,
"--commit-dirty",
"--branch",
"main",
]
print(f"uploading {dist} via wrangler ...")
subprocess.run(cmd, cwd=str(ROOT), env=env, check=True)
print(f"uploading {dist} via wrangler (cwd=dist, functions={functions_dir.is_dir()}) ...")
subprocess.run(cmd, cwd=str(dist), env=env, check=True)
def smoke_test_production(
base_url: str,
*,
expected_run_id: str = "",
attempts: int = 6,
delay_s: float = 5.0,
) -> None:
"""Verify the deployed payload and Pages Function after propagation."""
base = base_url.rstrip("/")
marker = expected_run_id or str(int(time.time()))
error = "unknown failure"
for attempt in range(1, attempts + 1):
try:
data_url = f"{base}/data.json?refresh={urllib.parse.quote(marker)}"
req = urllib.request.Request(data_url, headers={"User-Agent": "climperor-smoke"})
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
if not isinstance(payload, dict) or not payload.get("heroes"):
raise RuntimeError("data.json has no heroes")
actual_run_id = str((payload.get("meta") or {}).get("refresh_run_id") or "")
if expected_run_id and actual_run_id != expected_run_id:
raise RuntimeError(
f"stale deployment run_id={actual_run_id!r}, expected={expected_run_id!r}"
)
live_url = f"{base}/api/live-status?refresh={urllib.parse.quote(marker)}"
live_req = urllib.request.Request(
live_url, headers={"User-Agent": "climperor-smoke"}
)
with urllib.request.urlopen(live_req, timeout=30) as resp:
content_type = resp.headers.get_content_type()
live_cache = (resp.headers.get("X-Live-Cache") or "").lower()
live_payload = json.loads(resp.read().decode("utf-8"))
if content_type != "application/json" or not isinstance(live_payload, dict):
raise RuntimeError(
f"/api/live-status is not JSON (content-type={content_type})"
)
if live_cache in ("error", "stale-override"):
print(
f"warn: live-status Function is routed but probe state is {live_cache}"
)
print(
f"production smoke passed: {base} "
f"(heroes={len(payload['heroes'])}, run_id={actual_run_id or '-'}, "
f"live_cache={live_cache or '?'})"
)
return
except (
OSError,
ValueError,
RuntimeError,
json.JSONDecodeError,
urllib.error.URLError,
) as exc:
error = str(exc)
print(f"smoke attempt {attempt}/{attempts} failed: {error}")
if attempt < attempts:
time.sleep(delay_s)
raise SystemExit(f"deployment smoke failed after {attempts} attempts: {error}")
def ensure_cname(email: str, api_key: str, domain: str, project: str) -> None:
@@ -358,6 +427,11 @@ def main() -> None:
help="passed to export as --static-asset-base (default: same as ability-video-base "
"or STATIC_ASSET_BASE env)",
)
ap.add_argument(
"--skip-smoke",
action="store_true",
help="skip post-deploy data.json and /api/live-status verification",
)
args = ap.parse_args()
dist = Path(args.dist).resolve()
@@ -379,6 +453,11 @@ def main() -> None:
check_integrity(dist)
wrangler_deploy(dist, args.project_name, email=email, api_key=api_key, account_id=account_id)
bind_domain(email, api_key, account_id, args.project_name, args.domain)
if not args.skip_smoke:
smoke_test_production(
f"https://{args.project_name}.pages.dev",
expected_run_id=os.environ.get("REFRESH_RUN_ID") or "",
)
print()
print(f"deployment url : https://{args.project_name}.pages.dev")
+15 -7
View File
@@ -32,6 +32,7 @@ import json
import os
import shutil
from shared.http_utils import write_json_atomic
from shared.paths import (
ABILITY_ICONS,
ABILITY_VIDEOS,
@@ -50,7 +51,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.72"
SITE_VERSION = "0.5.84"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -187,11 +188,11 @@ def main() -> None:
shutil.rmtree(out)
out.mkdir(parents=True)
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
for name in ("index.html", "router.js", "app.js", "style.css", "mobile-gate.js", "_headers"):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
# Cloudflare Pages Functions (functions/api/live-status.js -> /api/live-status).
# Cloudflare Pages Functions (functions/api/*.js -> /api/*).
functions_src = WEB_DIR / "functions"
n_functions = 0
if functions_src.is_dir():
@@ -205,9 +206,13 @@ def main() -> None:
)
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
refresh_run_id = os.environ.get("REFRESH_RUN_ID") or ""
payload.setdefault("meta", {})["refresh_run_id"] = refresh_run_id
write_json_atomic(
out / "data.json",
payload,
indent=None,
separators=(",", ":"),
)
if static_base:
@@ -246,7 +251,10 @@ def main() -> None:
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print(
f"local Pages Function check: npx wrangler pages dev . "
f"--port 8789 (run from {out})"
)
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
+5 -18
View File
@@ -29,7 +29,7 @@ from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import download_icons, http_json
from shared.http_utils import download_icons, http_json, write_json_atomic
from shared.paths import ABILITY_ICONS, DATA
from loc_format import format_loc, strip_html
@@ -660,10 +660,7 @@ def main() -> None:
meta["tags_only"] = True
meta["mechanic_tag_order"] = list(QUERY_MECHANIC_ORDER)
payload = {"meta": meta, "by_hero": by_hero}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
print(
f"retag done: {len(by_hero)} heroes, {tagged} abilities with tags -> {args.out}",
flush=True,
@@ -723,10 +720,7 @@ def main() -> None:
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
illus = sum(1 for c in by_hero.values() if (c.get("summary") or {}).get("has_illusion"))
print(f"resummarized {len(by_hero)} heroes (illusion={illus}) -> {args.out}", flush=True)
return
@@ -822,10 +816,7 @@ def main() -> None:
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
time.sleep(args.delay)
tagged = retag_abilities(by_hero, ABILITY_TAG_OVERRIDES_MAP)
@@ -840,11 +831,7 @@ def main() -> None:
},
"by_hero": by_hero,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
print(f"done: {len(by_hero)} heroes -> {args.out}", flush=True)
if args.icons:
+3 -12
View File
@@ -24,7 +24,7 @@ import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import download_icons, http_json, load_itemlist_zh
from shared.http_utils import download_icons, http_json, load_itemlist_zh, write_json_atomic
from shared.paths import DATA, ITEM_ICONS
OPENDOTA = "https://api.opendota.com/api"
@@ -284,21 +284,12 @@ def main() -> None:
used_ids.update(int(row["id"]) for row in core if isinstance(row, dict) and "id" in row)
print(f" [{n}/{len(pending)}] {key}: {len(core)} core items", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(build_payload(by_hero, catalog, used_ids), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
write_json_atomic(args.out, build_payload(by_hero, catalog, used_ids))
time.sleep(args.delay)
used_ids = collect_used_ids(by_hero)
payload = build_payload(by_hero, catalog, used_ids)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
if not args.skip_icons:
keys = {catalog[iid]["key"] for iid in used_ids if iid in catalog}
+2 -5
View File
@@ -57,7 +57,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import download_icons, http_json
from shared.http_utils import download_icons, http_json, write_json_atomic
from shared.paths import DATA, ITEM_ICONS
OPENDOTA = "https://api.opendota.com/api"
@@ -989,10 +989,7 @@ def write_out(
"items": items_out,
"by_hero": by_hero,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
write_json_atomic(path, payload)
def needs_player_enrich(row: dict) -> bool:
+8 -6
View File
@@ -20,11 +20,10 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
OPENDOTA_HERO_STATS = "https://api.opendota.com/api/heroStats"
@@ -173,6 +172,8 @@ def main() -> None:
raw = http_json(OPENDOTA_HERO_STATS)
if not isinstance(raw, list):
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
if not raw:
raise SystemExit("heroStats returned an empty list; keeping previous cache")
window_days = detect_window_days(raw)
by_hero: dict[str, dict] = {}
@@ -191,11 +192,12 @@ def main() -> None:
by_hero[key] = row_to_hero_stats(row)
payload = build_payload(by_hero, window_days=window_days)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
if len(by_hero) < max(100, len(heroes) * 4 // 5):
raise SystemExit(
f"heroStats only contained {len(by_hero)}/{len(heroes)} known heroes; "
"keeping previous cache"
)
write_json_atomic(args.out, payload)
missing = [h["key"] for h in heroes if h["key"] not in by_hero]
print(
f"done: {len(by_hero)} heroes -> {args.out}"
+2 -5
View File
@@ -33,6 +33,7 @@ import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import write_json_atomic
from shared.paths import DATA
API = "https://api.opendota.com/api/explorer"
@@ -281,11 +282,7 @@ def main() -> None:
min_games=args.min_games,
min_cost=args.min_cost,
)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(output, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, output)
nonempty = sum(bool(v) for v in output["by_hero"].values())
print(
f"done: {len(rows)} rows, {nonempty} heroes -> {args.out}",
+8 -6
View File
@@ -23,7 +23,13 @@ import time
import urllib.error
from datetime import datetime, timezone
from shared.http_utils import download_icons, http_bytes, http_json, load_itemlist_zh
from shared.http_utils import (
download_icons,
http_bytes,
http_json,
load_itemlist_zh,
write_json_atomic,
)
from shared.paths import DATA, ITEM_CAT_ICONS, ITEM_ICONS
ITEMS_URL = (
@@ -317,11 +323,7 @@ def main() -> None:
"upgraded": {"sections": up_sections},
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
print(
f"wrote {args.out}: basic={payload['meta']['basic_count']} "
f"upgraded={payload['meta']['upgraded_count']} "
+3 -10
View File
@@ -26,7 +26,7 @@ import time
import urllib.error
from datetime import datetime, timezone
from shared.http_utils import download_icons, http_json, load_itemlist
from shared.http_utils import download_icons, http_json, load_itemlist, write_json_atomic
from shared.paths import DATA, ITEM_ICONS
from loc_format import HAS_PLACEHOLDER, format_loc
@@ -406,10 +406,7 @@ def main() -> None:
},
"items": items_out,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
payload = {
"meta": {
@@ -421,11 +418,7 @@ def main() -> None:
},
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
if not args.skip_icons:
keys = {row["key"] for row in items_out.values()}
+7 -7
View File
@@ -21,10 +21,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
OUT = DATA / "leaderboards.json"
@@ -67,6 +66,11 @@ def fetch_division(division: str) -> dict:
lb = raw.get("leaderboard")
if not isinstance(lb, list):
raise SystemExit(f"{division}: missing leaderboard array")
if len(lb) < TOP_N:
raise SystemExit(
f"{division}: leaderboard only returned {len(lb)} rows; "
"keeping previous cache"
)
top = [slim_entry(e) for e in lb[:TOP_N] if isinstance(e, dict)]
return {
"division": division,
@@ -102,11 +106,7 @@ def main() -> None:
regions[div] = fetch_division(div)
payload = build_payload(regions)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
parts = [
f"{div}={len((regions[div].get('top100') or []))}" for div in REGION_ORDER
]
+5 -9
View File
@@ -38,7 +38,7 @@ import time
import urllib.error
from datetime import datetime, timedelta, timezone
from shared.http_utils import http_bytes, http_json
from shared.http_utils import http_bytes, http_json, write_json_atomic
from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
@@ -430,13 +430,10 @@ def main() -> None:
patches = fetch_patch_list(since_ts)
print(f" {len(patches)} patches in window", flush=True)
if not patches:
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps({"meta": {}, "patches": [], "lookup": {}, "details": {}}, ensure_ascii=False, indent=2),
encoding="utf-8",
raise SystemExit(
"patch list returned no versions in the requested window; "
"keeping previous cache"
)
print("no patches in window; wrote empty file", flush=True)
return
cached = {} if args.force else load_existing_details()
details: dict[str, dict] = {}
@@ -485,8 +482,7 @@ def main() -> None:
"lookup": lookup,
"details": details,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
write_json_atomic(OUT, payload)
print(
f"saved {len(patches)} patches, {len(details)} details -> {OUT}",
flush=True,
+27 -23
View File
@@ -1,16 +1,17 @@
"""Fetch recent league/tournament matches for OpenDota registered pros.
"""Fetch recent league/tournament (+ optional ranked) matches for OpenDota pros.
Default source: web/data/pro_player_watchlist.json (hand-curated star IDs).
Falls back to /proPlayers auto-pick only with --all-pros.
Per player: /players/{id}/matches (lobby practice + tournament), then
/matches/{id} for final items + skill builds.
Per player: /players/{id}/matches per lobby_type (practice/tournament, and
ranked with --include-pubs), up to --limit newest each; then /matches/{id}
for final items + skill builds. League rows do not crowd out ranked pubs.
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
Usage:
python web/fetch_pro_matches.py
python web/fetch_pro_matches.py --limit 12
python web/fetch_pro_matches.py --include-pubs --limit 8
python web/fetch_pro_matches.py --players 898754153,Ame
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
"""
@@ -29,7 +30,7 @@ import urllib.error
from datetime import datetime, timedelta, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
from fetch_hero_matches import (
@@ -220,11 +221,15 @@ def player_match_metas(
*,
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
) -> list[dict]:
"""Recent match list rows for a pro (deduped, newest first)."""
per_lt = max(limit, limit // max(1, len(lobby_types)) + 2)
"""Recent match list rows for a pro (deduped, newest first).
Fetches up to ``limit`` newest matches **per lobby_type**, then merges.
With league + ranked pubs this can return up to ``limit * len(lobby_types)``
rows important so recent league games do not crowd out ranked pubs.
"""
by_id: dict[int, dict] = {}
for lt in lobby_types:
url = f"{OPENDOTA}/players/{account_id}/matches?limit={per_lt}&lobby_type={lt}"
url = f"{OPENDOTA}/players/{account_id}/matches?limit={limit}&lobby_type={lt}"
try:
raw = http_json(url)
except (
@@ -258,11 +263,10 @@ def player_match_metas(
if st_new >= st_old:
by_id[mid] = row
ranked = sorted(
return sorted(
by_id.values(),
key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)),
)
return ranked[:limit]
def fetch_player_matches(
@@ -285,6 +289,12 @@ def fetch_player_matches(
continue
if mid <= 0 or hid <= 0:
continue
lt_raw = meta.get("lobby_type")
try:
lt_i = int(lt_raw) if lt_raw is not None else None
except (TypeError, ValueError):
lt_i = None
row_origin = "public" if lt_i == 7 else "pro"
detail = fetch_match(mid)
if delay > 0:
time.sleep(delay)
@@ -293,7 +303,7 @@ def fetch_player_matches(
slim = extract_player_row(
detail,
hid,
origin="pro",
origin=row_origin,
id_map=id_map,
item_catalog=catalog,
list_meta=meta,
@@ -309,11 +319,7 @@ def fetch_player_matches(
continue
slim["hero_id"] = hid
slim["hero_key"] = id_to_key.get(hid)
lt = meta.get("lobby_type")
try:
slim["lobby_type"] = int(lt) if lt is not None else None
except (TypeError, ValueError):
slim["lobby_type"] = None
slim["lobby_type"] = lt_i
rows.append(slim)
return rows
@@ -390,16 +396,17 @@ def write_out(
"attribution": "https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"player_source": player_source,
"limit_per_pro": limit,
"limit_per_lobby": limit,
"limit_pros": limit_pros,
"lobby_types": list(lobby_types),
"pro_count": len(by_pro),
"match_count": match_count,
"hero_count": len(by_hero),
"note_zh": (
"OpenDota 明星/职业选手近期联赛/锦标赛对局;"
"OpenDota 明星选手近期联赛/锦标赛对局(可选含天梯 lobby_type=7"
"默认名单见 web/data/pro_player_watchlist.json"
"lobby_type 1=训练/practice、2=tournament"
"每种 lobby 各保留最近 limit 场,避免联赛挤掉天梯"
"lobby_type 1=训练/practice、2=tournament、7=ranked"
"含终局出装、加点与联赛名(若有)。"
),
},
@@ -408,10 +415,7 @@ def write_out(
"by_pro": by_pro,
"by_hero": by_hero,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
write_json_atomic(path, payload)
def main() -> None:
+110 -24
View File
@@ -29,13 +29,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import write_json_atomic
from shared.paths import DATA, ROOT
API = "https://api.stratz.com/graphql"
@@ -165,28 +165,6 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def write_json_atomic(path: Path, payload: dict) -> None:
"""Write JSON via temp file then replace, so partial writes never corrupt cache."""
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=str(path.parent),
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp_path, path)
except Exception:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
raise
def fetch_weeks_for_bracket(
token: str, hero_ids: list[int], bracket: str, take: int
) -> list[dict]:
@@ -511,6 +489,47 @@ def build_meta(
}
def preserve_failed_meta_brackets(
fresh: dict,
previous: dict,
*,
failed_weeks: set[str],
failed_positions: set[str],
) -> dict:
"""Keep previous bracket cells when STRATZ returns no usable rows."""
if not failed_weeks and not failed_positions:
fresh["stale"] = False
fresh["stale_brackets"] = []
return fresh
previous_by_hero = previous.get("by_hero") or {}
fresh_by_hero = fresh.get("by_hero") or {}
for key, cell in fresh_by_hero.items():
old = previous_by_hero.get(key) or {}
for bracket in failed_weeks:
cell["weeks"][bracket] = list((old.get("weeks") or {}).get(bracket) or [])
old_latest = (old.get("latest") or {}).get(bracket)
if old_latest:
cell["latest"][bracket] = dict(old_latest)
for bracket in failed_positions:
cell["positions"][bracket] = dict(
(old.get("positions") or {}).get(bracket) or {}
)
for bracket in failed_weeks:
old_total = (previous.get("totals") or {}).get(bracket)
old_board = (previous.get("meta_board") or {}).get(bracket)
if old_total:
fresh["totals"][bracket] = dict(old_total)
if old_board:
fresh["meta_board"][bracket] = list(old_board)
fresh["stale"] = True
fresh["stale_brackets"] = sorted(failed_weeks | failed_positions)
fresh["last_attempt_at"] = _now_iso()
return fresh
def refresh_matchup_tops(
token: str,
id_to_key: dict[int, str],
@@ -526,6 +545,14 @@ def refresh_matchup_tops(
On per-hero failure, keep the previous cell and mark ``stale=True``.
"""
started_at = _now_iso()
previous_file: dict | None = None
if out_path.is_file():
try:
loaded = json.loads(out_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
previous_file = loaded
except (OSError, json.JSONDecodeError):
previous_file = None
prev = load_previous_matchups(out_path)
hero_ids = sorted(id_to_key.keys())
@@ -539,7 +566,8 @@ def refresh_matchup_tops(
)
else:
pending = list(hero_ids)
by_hero_mu = {}
# Keep the complete previous set until each cell is successfully replaced.
by_hero_mu = dict(prev)
print(
f"full matchup refresh: {len(pending)} heroes "
f"(prev cached={len(prev)} as failure fallback)",
@@ -547,6 +575,7 @@ def refresh_matchup_tops(
)
ok = failed = stale_kept = 0
missing_fallback: list[str] = []
for n, hid in enumerate(pending, start=1):
key = id_to_key[hid]
try:
@@ -588,6 +617,7 @@ def refresh_matchup_tops(
)
else:
print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True)
missing_fallback.append(key)
time.sleep(delay * 2)
continue
@@ -641,6 +671,15 @@ def refresh_matchup_tops(
f"stale_kept={stale_kept}{out_path}",
flush=True,
)
if missing_fallback:
if previous_file is not None:
write_json_atomic(out_path, previous_file)
else:
out_path.unlink(missing_ok=True)
raise RuntimeError(
"STRATZ matchup refresh had failures without cached fallback: "
+ ", ".join(missing_fallback[:10])
)
return payload
@@ -712,9 +751,56 @@ def main() -> None:
)
time.sleep(args.delay)
failed_weeks = {b for b, rows in weeks_by_bracket.items() if not rows}
failed_positions = {
b for b, rows in position_rows_by_bracket.items() if not rows
}
previous_meta: dict = {}
if args.out_meta.is_file():
try:
loaded = json.loads(args.out_meta.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
previous_meta = loaded
except (OSError, json.JSONDecodeError):
previous_meta = {}
previous_by_hero = previous_meta.get("by_hero") or {}
missing_week_fallback = [
bracket
for bracket in failed_weeks
if not any(
(cell.get("weeks") or {}).get(bracket)
for cell in previous_by_hero.values()
if isinstance(cell, dict)
)
]
missing_position_fallback = [
bracket
for bracket in failed_positions
if not any(
(cell.get("positions") or {}).get(bracket)
for cell in previous_by_hero.values()
if isinstance(cell, dict)
)
]
if missing_week_fallback or missing_position_fallback:
raise RuntimeError(
"STRATZ meta failed without cached fallback: weeks="
f"{missing_week_fallback}, positions={missing_position_fallback}"
)
meta = build_meta(
weeks_by_bracket, position_rows_by_bracket, id_to_key, args.weeks
)
meta = preserve_failed_meta_brackets(
meta,
previous_meta,
failed_weeks=failed_weeks,
failed_positions=failed_positions,
)
if len(failed_weeks) == len(BRACKET_ORDER):
# Preserve the timestamp of the last successful data snapshot.
meta["fetched_at"] = previous_meta.get("fetched_at")
write_json_atomic(args.out_meta, meta)
print(f"wrote {args.out_meta}", flush=True)
+16 -5
View File
@@ -5,6 +5,9 @@ For every streamer row with ``live_url`` this script writes:
- ``is_live`` True only when the platform confirms the room is live
- ``live_probed_at`` UTC ISO timestamp of the successful probe
Probe backend follows the ``live_url`` host (may differ from profile
``platform``, e.g. Douyin profile + Bilibili room).
Approach (verified 2026-07):
- Bilibili: public API ``api.live.bilibili.com/room/v1/Room/get_info`` with the
@@ -155,6 +158,16 @@ def room_ref_from_url(live_url: str) -> str | None:
return path.split("/")[0] or None
def live_platform_from_url(live_url: str, fallback: str = "") -> str:
"""Probe backend follows the live room host (may differ from profile platform)."""
host = urllib.parse.urlparse(live_url.strip()).netloc.lower()
if "bilibili.com" in host:
return "bilibili"
if "douyin.com" in host:
return "douyin"
return (fallback or "").strip().lower()
def probe_streamers(
payload: dict, *, ids: set[str] | None = None
) -> tuple[int, int, int]:
@@ -172,7 +185,8 @@ def probe_streamers(
live_url = str(row.get("live_url") or "").strip()
if not live_url:
continue
platform = str(row.get("platform") or "").strip().lower()
fallback = str(row.get("platform") or "").strip().lower()
platform = live_platform_from_url(live_url, fallback)
ref = room_ref_from_url(live_url)
if not ref:
print(f"skip {sid}: cannot parse room ref from {live_url!r}", flush=True)
@@ -244,10 +258,7 @@ def main() -> int:
if args.dry_run:
print(f"dry-run: would write {args.out} ({summary})", flush=True)
return 0
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
http_utils.write_json_atomic(args.out, payload)
print(f"wrote {args.out} {summary}", flush=True)
# Soft-fail for CI/refresh_web: always exit 0 after writing.
return 0
+2 -5
View File
@@ -32,6 +32,7 @@ import urllib.request
from datetime import datetime, timezone
from typing import Any
from shared.http_utils import write_json_atomic
from shared.paths import DATA, STREAMER_AVATARS
OUT = DATA / "streamers.json"
@@ -94,11 +95,7 @@ def _load(path: Path) -> dict:
def _save(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(path, payload)
def _opener() -> urllib.request.OpenerDirector:
+3
View File
@@ -16,5 +16,8 @@
/router.js
Cache-Control: public, max-age=60, must-revalidate
/mobile-gate.js
Cache-Control: public, max-age=60, must-revalidate
/style.css
Cache-Control: public, max-age=300, must-revalidate
+422 -119
View File
@@ -88,6 +88,10 @@ const state = {
rankingRegion: "china",
/** Top-level matches page: OpenDota account_id string (null = all stars) */
matchesPlayerId: null,
/** Top-level matches page: all | pro | china */
matchesOrigin: "all",
/** Top-level matches page: 1-based page index */
matchesPage: 1,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
@@ -390,7 +394,7 @@ const BRACKET_RANK_ICON = {
/** Recent matches tab: newest N ladder+league games. */
const MATCHES_DISPLAY_LIMIT = 10;
const PRO_MATCHES_PAGE_LIMIT = 60;
const PRO_MATCHES_PAGE_SIZE = 20;
function heroStatsPack() {
return (
@@ -1056,6 +1060,139 @@ function heroMatchesFor(heroKey) {
return out;
}
/** Pad 09 → "00"…"09" for HH:mm. */
function pad2(n) {
return String(n).padStart(2, "0");
}
/**
* Parse unix sec/ms, ISO string, or Date epoch ms. Date-only YYYY-MM-DD is
* treated as Asia/Shanghai midnight (avoids UTC-day shift for CN dates).
*/
function parseTimeMs(input) {
if (input == null || input === "") return NaN;
if (input instanceof Date) {
const t = input.getTime();
return Number.isFinite(t) ? t : NaN;
}
if (typeof input === "number") {
if (!Number.isFinite(input) || input <= 0) return NaN;
return input > 1e12 ? input : input * 1000;
}
const s = String(input).trim();
if (!s) return NaN;
if (/^\d+(\.\d+)?$/.test(s)) {
const n = Number(s);
if (!Number.isFinite(n) || n <= 0) return NaN;
return n > 1e12 ? n : n * 1000;
}
const dayOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (dayOnly) {
const t = new Date(
`${dayOnly[1]}-${dayOnly[2]}-${dayOnly[3]}T00:00:00+08:00`
).getTime();
return Number.isFinite(t) ? t : NaN;
}
const t = Date.parse(s);
return Number.isFinite(t) ? t : NaN;
}
/** Calendar + clock parts in Asia/Shanghai (site audience). */
function shanghaiParts(ms) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date(ms));
const get = (type) => {
const hit = parts.find((p) => p.type === type);
return hit ? hit.value : "";
};
let hour = get("hour");
// Some engines emit "24" for midnight under hour12:false.
if (hour === "24") hour = "00";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour: Number(hour),
minute: Number(get("minute")),
};
}
/**
* Absolute wall time for CN UI.
* Same year: "6/13 19:00"; other year: "2024/6/13 19:00".
* dateOnly omits the clock.
*/
function formatAbsoluteTime(input, { dateOnly = false } = {}) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return "";
const p = shanghaiParts(ms);
const now = shanghaiParts(Date.now());
const date =
p.year === now.year
? `${p.month}/${p.day}`
: `${p.year}/${p.month}/${p.day}`;
if (dateOnly) return date;
return `${date} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
/** Full absolute for tooltips: "2026/7/29 16:48". */
function formatAbsoluteTimeFull(input) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return "";
const p = shanghaiParts(ms);
return `${p.year}/${p.month}/${p.day} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
/**
* Hybrid friendly time (CN feed convention):
* 刚刚 N分钟前 N小时前 昨天[ HH:mm] N天前 N周前 M/D[ HH:mm].
* Returns { text, title } so callers can set hover absolute.
*/
function formatFriendlyTime(input, { dateOnly = false, now = Date.now() } = {}) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return { text: "", title: "" };
const title = formatAbsoluteTimeFull(ms);
const abs = () => formatAbsoluteTime(ms, { dateOnly });
const diff = now - ms;
// Clock skew / future → absolute.
if (diff < -60_000) return { text: abs(), title };
const sec = Math.floor(Math.max(0, diff) / 1000);
if (sec < 45) return { text: "刚刚", title };
if (sec < 3600) {
return { text: `${Math.max(1, Math.floor(sec / 60))}分钟前`, title };
}
if (sec < 86400) {
return { text: `${Math.max(1, Math.floor(sec / 3600))}小时前`, title };
}
const p = shanghaiParts(ms);
const n = shanghaiParts(now);
const dayStart = (parts) => Date.UTC(parts.year, parts.month - 1, parts.day);
const dayDiff = Math.round((dayStart(n) - dayStart(p)) / 86400000);
if (dayDiff === 1) {
const text = dateOnly
? "昨天"
: `昨天 ${pad2(p.hour)}:${pad2(p.minute)}`;
return { text, title };
}
if (dayDiff >= 2 && dayDiff < 7) {
return { text: `${dayDiff}天前`, title };
}
if (dayDiff >= 7 && dayDiff < 45) {
return { text: `${Math.max(1, Math.floor(dayDiff / 7))}周前`, title };
}
return { text: abs(), title };
}
function formatMatchDuration(sec) {
const n = Number(sec) || 0;
if (n <= 0) return "—";
@@ -1064,21 +1201,6 @@ function formatMatchDuration(sec) {
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatMatchTime(ts) {
const n = Number(ts);
if (!Number.isFinite(n) || n <= 0) return "";
try {
return new Date(n * 1000).toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
const RANK_TIER_MEDALS = [
null,
"herald",
@@ -1297,19 +1419,13 @@ function buildMatchCard(heroKey, row, opts = {}) {
const origin = document.createElement("span");
origin.className = "match-origin";
if (row.origin === "public") {
const cluster = Number(row.cluster);
const region = Number(row.region);
const chinaRegions = new Set([12, 13, 17, 18, 20, 25]);
const chinaClusters = new Set([
221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417,
]);
const isChina =
(Number.isFinite(region) && chinaRegions.has(region)) ||
(Number.isFinite(cluster) && chinaClusters.has(cluster));
origin.textContent = isChina ? "国服" : "天梯";
if (isChina) origin.classList.add("match-origin-china");
} else if (row.origin === "pro") {
const kind = matchOriginKind(row);
if (kind === "china") {
origin.textContent = "国服";
origin.classList.add("match-origin-china");
} else if (kind === "ladder") {
origin.textContent = "天梯";
} else if (kind === "pro") {
origin.textContent = "职业";
origin.classList.add("match-origin-pro");
} else {
@@ -1325,11 +1441,12 @@ function buildMatchCard(heroKey, row, opts = {}) {
head.appendChild(league);
}
const when = formatMatchTime(row.start_time);
if (when) {
const whenInfo = formatFriendlyTime(row.start_time);
if (whenInfo.text) {
const timeEl = document.createElement("span");
timeEl.className = "match-time";
timeEl.textContent = when;
timeEl.textContent = whenInfo.text;
if (whenInfo.title) timeEl.title = whenInfo.title;
head.appendChild(timeEl);
}
@@ -1846,9 +1963,10 @@ function buildStatsPanel(heroKey) {
const foot = document.createElement("div");
foot.className = "detail-muted detail-stats-note";
const pack = heroStatsPack();
const fetched = pack.fetched_at
? `拉取于 ${String(pack.fetched_at).slice(0, 10)}`
: "";
const fetchedInfo = pack.fetched_at
? formatFriendlyTime(pack.fetched_at)
: { text: "", title: "" };
const fetched = fetchedInfo.text ? `拉取于 ${fetchedInfo.text}` : "";
foot.textContent = [
`OpenDota ${heroStatsWindowLabel()}`,
BRACKET_LABELS[bracket] || bracket,
@@ -1856,6 +1974,7 @@ function buildStatsPanel(heroKey) {
]
.filter(Boolean)
.join(" · ");
if (fetchedInfo.title) foot.title = fetchedInfo.title;
block.appendChild(foot);
}
return block;
@@ -1866,12 +1985,6 @@ function formatMatchupWr(wr) {
return `${(Number(wr) * 100).toFixed(1)}%`;
}
function formatMatchupFetched(iso) {
if (!iso) return "";
const s = String(iso);
return s.length >= 10 ? s.slice(0, 10) : s;
}
function matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
@@ -2026,11 +2139,14 @@ function buildMatchupsPanel(heroKey) {
notes.appendChild(line);
}
const fetched = formatMatchupFetched(cell.fetched_at || pack.fetched_at);
if (fetched) {
const fetchedInfo = formatFriendlyTime(
cell.fetched_at || pack.fetched_at
);
if (fetchedInfo.text) {
const time = document.createElement("p");
time.className = "matchup-note-time";
time.textContent = `更新于 ${fetched}`;
time.textContent = `更新于 ${fetchedInfo.text}`;
if (fetchedInfo.title) time.title = fetchedInfo.title;
notes.appendChild(time);
}
@@ -3160,7 +3276,11 @@ function buildPatchChangesPanel(heroKey) {
const hasNotes = (hentry.hero_notes || []).length > 0;
const hasAbilities = (hentry.abilities || []).length > 0;
if (!hasNotes && !hasAbilities) continue;
let html = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(p.date || "")}</span></div>`;
const patchDate =
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
p.date ||
"";
let html = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(patchDate)}</span></div>`;
html += renderHeroNotes(hentry.hero_notes);
for (const ab of (hentry.abilities || [])) {
html += patchAbilityBlockHtml(ab, lookup);
@@ -3206,7 +3326,11 @@ function renderPatches() {
.map((p) => {
const major = p.website ? " ★" : "";
const sel = p.version === state.selectedPatch ? " selected" : "";
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major}${escapeHtml(p.date || "")}</option>`;
const patchDate =
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
p.date ||
"";
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major}${escapeHtml(patchDate)}</option>`;
})
.join("");
select.onchange = () => {
@@ -3390,22 +3514,6 @@ function ensureDefaultRankingRegion() {
}
}
function formatLeaderboardPosted(ts) {
if (ts == null || ts === "") return "";
const n = Number(ts);
if (!Number.isFinite(n) || n <= 0) return "";
// Valve posts unix seconds; guard ms accidentally.
const ms = n > 1e12 ? n : n * 1000;
try {
return new Date(ms).toLocaleString("zh-CN", {
hour12: false,
timeZone: "Asia/Shanghai",
});
} catch {
return "";
}
}
function renderRankings() {
if (!state.data) return;
const regionsEl = $("#rankings-regions");
@@ -3468,9 +3576,11 @@ function renderRankings() {
}
const cur = regions[state.rankingRegion] || {};
const posted = formatLeaderboardPosted(cur.time_posted);
const postedInfo = formatFriendlyTime(cur.time_posted);
if (sub) {
sub.textContent = posted ? `更新于 ${posted}` : "";
sub.textContent = postedInfo.text ? `更新于 ${postedInfo.text}` : "";
if (postedInfo.title) sub.title = postedInfo.title;
else sub.removeAttribute("title");
}
const rows = Array.isArray(cur.top100) ? cur.top100 : [];
@@ -3508,7 +3618,47 @@ function proMatchesData() {
);
}
function proPlayerOptions() {
/** OpenDota China region ids / cluster ids used to label 国服 pubs. */
const MATCH_CHINA_REGIONS = new Set([12, 13, 17, 18, 20, 25]);
const MATCH_CHINA_CLUSTERS = new Set([
221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417,
]);
const MATCH_ORIGIN_FILTERS = [
{ id: "all", label: "全部" },
{ id: "pro", label: "职业" },
{ id: "china", label: "国服" },
];
function isRankedPubMatch(row) {
return row?.origin === "public" || Number(row?.lobby_type) === 7;
}
function isChinaPubMatch(row) {
if (!isRankedPubMatch(row)) return false;
const cluster = Number(row.cluster);
const region = Number(row.region);
return (
(Number.isFinite(region) && MATCH_CHINA_REGIONS.has(region)) ||
(Number.isFinite(cluster) && MATCH_CHINA_CLUSTERS.has(cluster))
);
}
/** Classify a match row for badges / filters: pro | league | china | ladder. */
function matchOriginKind(row) {
if (isRankedPubMatch(row)) return isChinaPubMatch(row) ? "china" : "ladder";
if (row?.origin === "pro") return "pro";
return "league";
}
function matchPassesOriginFilter(row, origin) {
const want = origin || "all";
if (want === "all") return true;
if (want === "pro") return !isRankedPubMatch(row);
if (want === "china") return isChinaPubMatch(row);
return true;
}
function proPlayerOptions(originFilter) {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const prosMeta = pack.pros || {};
@@ -3516,19 +3666,22 @@ function proPlayerOptions() {
for (const [sid, cell] of Object.entries(byPro)) {
if (!cell || typeof cell !== "object") continue;
const matches = Array.isArray(cell.matches) ? cell.matches : [];
if (!matches.length) continue;
const filtered = matches.filter((row) =>
matchPassesOriginFilter(row, originFilter)
);
if (!filtered.length) continue;
const meta = prosMeta[sid] || {};
const name =
cell.name ||
meta.name ||
(matches[0] && matchPlayerDisplayName(matches[0])) ||
(filtered[0] && matchPlayerDisplayName(filtered[0])) ||
sid;
const team = cell.team_tag || meta.team_tag || "";
rows.push({
account_id: String(cell.account_id || sid),
name,
team_tag: team,
match_count: matches.length,
match_count: filtered.length,
});
}
rows.sort(
@@ -3539,7 +3692,7 @@ function proPlayerOptions() {
return rows;
}
function proMatchRows(playerId) {
function proMatchRows(playerId, originFilter) {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const out = [];
@@ -3551,6 +3704,7 @@ function proMatchRows(playerId) {
const matches = Array.isArray(cell.matches) ? cell.matches : [];
for (const row of matches) {
if (!row || typeof row !== "object") continue;
if (!matchPassesOriginFilter(row, originFilter)) continue;
const mid = Number(row.match_id);
if (!mid || seen.has(mid)) continue;
seen.add(mid);
@@ -3576,12 +3730,48 @@ function proMatchRows(playerId) {
function renderMatchesPage() {
const body = $("#matches-body");
const originsEl = $("#matches-origins");
const playersEl = $("#matches-players");
const sub = $("#matches-sub");
if (!body) return;
const options = proPlayerOptions();
const hasData = options.length > 0;
const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin)
? state.matchesOrigin
: "all";
if (origin !== state.matchesOrigin) state.matchesOrigin = origin;
const packHasAny = Object.values(proMatchesData().by_pro || {}).some(
(cell) => Array.isArray(cell?.matches) && cell.matches.length
);
const options = proPlayerOptions(origin);
const hasData = packHasAny;
if (originsEl) {
if (!hasData) {
originsEl.innerHTML = "";
} else {
originsEl.innerHTML = MATCH_ORIGIN_FILTERS.map((o) => {
const active = origin === o.id ? " active" : "";
return `<button type="button" class="rankings-region-btn${active}" data-origin="${escapeHtml(
o.id
)}" role="tab" aria-selected="${origin === o.id}">${escapeHtml(
o.label
)}</button>`;
}).join("");
originsEl.querySelectorAll("[data-origin]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.getAttribute("data-origin") || "all";
if (!MATCH_ORIGIN_FILTERS.some((o) => o.id === next)) return;
state.matchesOrigin = next;
state.matchesPage = 1;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
});
});
}
}
if (playersEl) {
if (!hasData) {
@@ -3610,6 +3800,7 @@ function renderMatchesPage() {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-player") || "";
state.matchesPlayerId = id || null;
state.matchesPage = 1;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
@@ -3633,18 +3824,100 @@ function renderMatchesPage() {
state.matchesPlayerId = null;
}
const rows = proMatchRows(state.matchesPlayerId).slice(
0,
PRO_MATCHES_PAGE_LIMIT
);
if (sub) sub.textContent = "";
const allRows = proMatchRows(state.matchesPlayerId, origin);
const total = allRows.length;
const pageSize = PRO_MATCHES_PAGE_SIZE;
const pageCount = Math.max(1, Math.ceil(total / pageSize) || 1);
let page = Number(state.matchesPage) || 1;
if (page < 1) page = 1;
if (page > pageCount) page = pageCount;
if (page !== state.matchesPage) {
state.matchesPage = page;
syncStateToUrl({ replace: true });
}
const start = (page - 1) * pageSize;
const rows = allRows.slice(start, start + pageSize);
if (sub) {
sub.textContent = total
? `${total} 场 · 第 ${page}/${pageCount}`
: "";
}
if (!rows.length) {
body.innerHTML = '<div class="rankings-empty">该选手暂无近期比赛</div>';
body.innerHTML = state.matchesPlayerId
? '<div class="rankings-empty">该选手暂无符合筛选的近期比赛</div>'
: '<div class="rankings-empty">暂无符合筛选的近期比赛</div>';
return;
}
body.replaceChildren(buildMatchList(null, rows, { showHero: true }));
const wrap = document.createElement("div");
wrap.className = "matches-page-wrap";
wrap.appendChild(buildMatchList(null, rows, { showHero: true }));
if (pageCount > 1) {
wrap.appendChild(
buildMatchesPager(page, pageCount, (next) => {
state.matchesPage = next;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
})
);
}
body.replaceChildren(wrap);
}
function buildMatchesPager(page, pageCount, onGo) {
const nav = document.createElement("nav");
nav.className = "matches-pager";
nav.setAttribute("aria-label", "比赛分页");
const mkBtn = (label, target, disabled, current) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "matches-pager-btn" + (current ? " active" : "");
btn.textContent = label;
btn.disabled = !!disabled;
if (current) btn.setAttribute("aria-current", "page");
if (!disabled && !current) {
btn.addEventListener("click", () => onGo(target));
}
return btn;
};
nav.appendChild(mkBtn("上一页", page - 1, page <= 1, false));
const windowSize = 5;
let from = Math.max(1, page - Math.floor(windowSize / 2));
let to = Math.min(pageCount, from + windowSize - 1);
from = Math.max(1, to - windowSize + 1);
if (from > 1) {
nav.appendChild(mkBtn("1", 1, false, page === 1));
if (from > 2) {
const ell = document.createElement("span");
ell.className = "matches-pager-ell";
ell.textContent = "…";
nav.appendChild(ell);
}
}
for (let i = from; i <= to; i++) {
nav.appendChild(mkBtn(String(i), i, false, i === page));
}
if (to < pageCount) {
if (to < pageCount - 1) {
const ell = document.createElement("span");
ell.className = "matches-pager-ell";
ell.textContent = "…";
nav.appendChild(ell);
}
nav.appendChild(
mkBtn(String(pageCount), pageCount, false, page === pageCount)
);
}
nav.appendChild(mkBtn("下一页", page + 1, page >= pageCount, false));
return nav;
}
function streamersData() {
@@ -4059,45 +4332,33 @@ function formatStreamerCount(n) {
return `${text}亿`;
}
function formatStreamerFetched(iso) {
if (!iso || typeof iso !== "string") return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Account id + social counts for the middle column (between avatar and follow). */
function buildStreamerMetaLine(row) {
const platform = String(row.platform || "").toLowerCase();
const uniqueId = row.unique_id || "";
const parts = [];
if (uniqueId) {
parts.push(
platform === "douyin"
? `抖音号:${uniqueId}`
: platform === "bilibili"
? `UID${uniqueId}`
: String(uniqueId),
);
parts.push({
label: platform === "douyin" ? "抖音号" : platform === "bilibili" ? "UID" : "账号",
value: String(uniqueId),
account: true,
});
}
if (platform === "bilibili") {
if (Number.isFinite(Number(row.following_count))) {
parts.push(`${formatStreamerCount(row.following_count)}关注`);
parts.push({ label: "关注", value: formatStreamerCount(row.following_count) });
}
} else if (Number.isFinite(Number(row.total_favorited))) {
parts.push(`${formatStreamerCount(row.total_favorited)}获赞`);
parts.push({ label: "获赞", value: formatStreamerCount(row.total_favorited) });
}
if (Number.isFinite(Number(row.follower_count))) {
parts.push(`${formatStreamerCount(row.follower_count)}粉丝`);
parts.push({ label: "粉丝", value: formatStreamerCount(row.follower_count) });
}
if (!parts.length) return null;
// Douyin user-search card: item spans + 1px sep with ~8px side margin (not raw "")
const meta = document.createElement("div");
meta.className = "streamer-meta";
parts.forEach((text, i) => {
parts.forEach((part, i) => {
if (i > 0) {
const sep = document.createElement("span");
sep.className = "streamer-meta-sep";
@@ -4105,8 +4366,20 @@ function buildStreamerMetaLine(row) {
meta.appendChild(sep);
}
const item = document.createElement("span");
item.className = "streamer-meta-item";
item.textContent = text;
item.className = part.account
? "streamer-meta-item is-account"
: "streamer-meta-item is-stat";
const label = document.createElement("span");
label.className = "streamer-meta-label";
label.textContent = part.label;
const value = document.createElement("span");
value.className = "streamer-meta-value";
value.textContent = part.value;
if (part.account) {
item.append(label, document.createTextNode(""), value);
} else {
item.append(value, label);
}
meta.appendChild(item);
});
return meta;
@@ -4234,10 +4507,16 @@ function buildStreamerCard(row) {
}
head.appendChild(avatarWrap);
// Middle column: nickname + account/stats only (signature is a full-width row below).
const info = document.createElement("div");
info.className = "streamer-card-info";
const nameEl = document.createElement("h3");
nameEl.className = "streamer-name";
nameEl.textContent = nick;
head.appendChild(nameEl);
info.appendChild(nameEl);
const metaEl = buildStreamerMetaLine(row);
if (metaEl) info.appendChild(metaEl);
head.appendChild(info);
const actions = document.createElement("div");
actions.className = "streamer-actions";
@@ -4253,10 +4532,7 @@ function buildStreamerCard(row) {
if (actions.childNodes.length) head.appendChild(actions);
card.appendChild(head);
// Meta + signature span full card width below the avatar row (Douyin-style)
const metaEl = buildStreamerMetaLine(row);
if (metaEl) card.appendChild(metaEl);
// Signature on its own row — long bios must not squeeze the avatar/follow row.
if (signature) {
const sig = document.createElement("p");
sig.className = "streamer-signature";
@@ -4402,14 +4678,16 @@ function renderStreamers() {
if (!body) return;
const data = streamersData();
const rows = streamerList();
const fetched = formatStreamerFetched(data.fetched_at);
const fetchedInfo = formatFriendlyTime(data.fetched_at);
body.innerHTML = "";
body.appendChild(
buildStreamerCards(rows, { emptyText: "暂无主播数据" })
);
if (foot) {
foot.textContent = fetched ? `更新于 ${fetched}` : "";
foot.setAttribute("aria-hidden", fetched ? "false" : "true");
foot.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : "";
if (fetchedInfo.title) foot.title = fetchedInfo.title;
else foot.removeAttribute("title");
foot.setAttribute("aria-hidden", fetchedInfo.text ? "false" : "true");
}
}
@@ -4449,9 +4727,9 @@ function syncStreamerCardLive(card, row) {
/**
* Visit-triggered live refresh: the Pages Function coalesces concurrent
* visitors through a 5-minute edge cache. Local dev has no /api/live-status
* (serve_relations.py returns an empty stub), so failures keep the data.json
* is_live fallback. Fires at most once per page load.
* visitors through a 5-minute edge cache. Local `serve_relations.py` returns
* an empty stub (no probing), so that path keeps the data.json is_live
* fallback. Fires at most once per page load.
*/
let liveStatusFetched = false;
@@ -4459,10 +4737,17 @@ function refreshStreamerLiveStatus() {
if (liveStatusFetched) return;
liveStatusFetched = true;
fetch("/api/live-status")
.then((res) => (res.ok ? res.json() : null))
.then(async (res) => {
if (!res.ok) return null;
const ctype = (res.headers.get("content-type") || "").toLowerCase();
if (!ctype.includes("application/json")) return null;
return res.json();
})
.then((payload) => {
const probed = payload && payload.streamers;
if (!probed || typeof probed !== "object") return;
// Empty map (local stub / total probe error) → keep data.json fallback.
if (!Object.keys(probed).length) return;
const rows =
(state.data && state.data.streamers && state.data.streamers.streamers) || [];
let changed = false;
@@ -4693,10 +4978,14 @@ function renderTrends() {
});
}
const fetched = pack.fetched_at
? `更新于 ${String(pack.fetched_at).slice(0, 10)}`
: "";
if (sub) sub.textContent = fetched;
const fetchedInfo = pack.fetched_at
? formatFriendlyTime(pack.fetched_at)
: { text: "", title: "" };
if (sub) {
sub.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : "";
if (fetchedInfo.title) sub.title = fetchedInfo.title;
else sub.removeAttribute("title");
}
const rows = buildTrendsBoardRows(state.trendsBracket);
if (!rows.length) {
@@ -5204,13 +5493,25 @@ function applyPatch(patch) {
state.rankingRegion = lb.default_region || order[0] || "china";
}
}
// Star-player filter (matches page).
// Star-player filter + origin + page (matches page).
if (state.page === "matches") {
if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) {
state.matchesPlayerId = String(patch.matchesPlayerId);
} else {
state.matchesPlayerId = null;
}
if (
patch.matchesOrigin === "pro" ||
patch.matchesOrigin === "china" ||
patch.matchesOrigin === "all"
) {
state.matchesOrigin = patch.matchesOrigin;
} else {
state.matchesOrigin = "all";
}
const mp = Number(patch.matchesPage);
state.matchesPage =
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
@@ -5281,6 +5582,8 @@ function bindPageChrome() {
}
async function main() {
// mobile-gate.js sets this before paint; skip desktop data boot on phones/tablets.
if (typeof window !== "undefined" && window.__CLIMPEROR_MOBILE__) return;
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (talentPopoverEl) {
+1 -1
View File
@@ -1,4 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.72";
var SITE_VERSION = "0.5.84";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+116 -30
View File
@@ -4,7 +4,8 @@
* Visit-triggered live-status probing with request coalescing via the edge
* Cache API (no KV, no wrangler config). The first visitor after the 5-minute
* freshness window triggers a re-probe of every streamer with `live_url`;
* concurrent visitors within the window share the cached JSON.
* concurrent visitors within the window share the cached JSON. Concurrent cold
* starts in the same isolate also share one in-flight probe promise.
*
* Probe logic is a JS port of fetch_streamer_live.py:
* - Bilibili: api.live.bilibili.com Room/get_info; data.live_status === 1 is
@@ -16,10 +17,10 @@
*
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a
* single probe fails, the streamer carries over the last known is_live from
* the previous (stale) cache entry with `stale: true`; when every probe fails
* the stale cache entry is served wholesale (X-Live-Cache: stale-override),
* or an empty payload when nothing was ever cached (X-Live-Cache: error).
* The handler never throws a 500 for probe failures.
* data.json / the previous (stale) cache entry with `stale: true`; when every
* probe fails the stale cache entry is served wholesale (X-Live-Cache:
* stale-override), or an empty payload when nothing was ever cached
* (X-Live-Cache: error). The handler never throws a 500 for probe failures.
*
* Named exports double as the local test surface; the Pages runtime only
* routes onRequest* handlers.
@@ -51,6 +52,9 @@ const DOUYIN_ROOMSTORE_RE = /\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\"
const DOUYIN_STATUS_RE = /\\"status\\":\s*(\d)/;
const DOUYIN_WEBRID_RE = /\\"web_rid\\":\s*\\"(\d+)\\"/;
/** Isolate-local coalescing for concurrent cold starts (same Worker isolate). */
let inFlightProbe = null;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -172,16 +176,45 @@ export function roomRefFromUrl(liveUrl) {
return seg || null;
}
/** Probe backend follows the live room host (may differ from profile platform). */
export function livePlatformFromUrl(liveUrl, fallback = "") {
try {
const host = new URL(String(liveUrl).trim()).hostname.toLowerCase();
if (host.includes("bilibili.com")) return "bilibili";
if (host.includes("douyin.com")) return "douyin";
} catch {
/* keep fallback */
}
return String(fallback || "")
.trim()
.toLowerCase();
}
/**
* Extract the streamer row list from a data.json payload.
* Production nests under `streamers.streamers`; also accept a bare array.
*/
export function streamerRowsFromPayload(payload) {
if (!payload || typeof payload !== "object") return [];
if (Array.isArray(payload.streamers)) return payload.streamers;
const nested = payload.streamers;
if (nested && typeof nested === "object" && Array.isArray(nested.streamers)) {
return nested.streamers;
}
return [];
}
/** Extract probe targets (id/platform/room ref) from a data.json payload. */
export function targetsFromPayload(payload) {
const rows = payload && Array.isArray(payload.streamers) ? payload.streamers : [];
const rows = streamerRowsFromPayload(payload);
const targets = [];
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
const liveUrl = String(row.live_url || "").trim();
if (!sid || !liveUrl) continue;
const platform = String(row.platform || "").trim().toLowerCase();
const fallback = String(row.platform || "").trim().toLowerCase();
const platform = livePlatformFromUrl(liveUrl, fallback);
const ref = roomRefFromUrl(liveUrl);
if (!ref) continue;
targets.push({ id: sid, platform, ref });
@@ -189,6 +222,18 @@ export function targetsFromPayload(payload) {
return targets;
}
/** Seed carry-over map from data.json is_live (daily snapshot). */
export function seedLiveFromPayload(payload) {
const out = {};
for (const row of streamerRowsFromPayload(payload)) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
if (!sid || typeof row.is_live !== "boolean") continue;
out[sid] = { is_live: row.is_live };
}
return out;
}
async function probeAll(targets) {
const jar = targets.some((t) => t.platform === "douyin")
? await warmDouyinCookies()
@@ -247,34 +292,49 @@ function emptyPayload() {
return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
async function putCache(body) {
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
await caches.default.put(CACHE_KEY, cached);
}
/**
* Load targets + build a fresh probe payload (or return null when there is
* nothing to probe / total failure with no seed).
* Returns { body, cacheState } where cacheState is miss | stale-override | error.
*/
async function probeFresh(requestUrl, cachedData) {
let dataPayload = null;
let targets = [];
try {
const dataUrl = new URL("/data.json", request.url);
const dataUrl = new URL("/data.json", requestUrl);
const res = await fetchWithTimeout(dataUrl.toString(), {
headers: { Accept: "application/json" },
});
if (res.ok) targets = targetsFromPayload(await res.json());
if (res.ok) {
dataPayload = await res.json();
targets = targetsFromPayload(dataPayload);
}
} catch {
// data.json unreachable: fall through to stale/empty below
}
if (!targets.length) {
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
return { body: emptyPayload(), cacheState: "error" };
}
const staleStreamers =
const seeded = seedLiveFromPayload(dataPayload);
const cachedStreamers =
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
? cachedData.streamers
: {};
// Prefer previous edge cache over the daily snapshot when both exist.
const staleStreamers = { ...seeded, ...cachedStreamers };
const probed = await probeAll(targets);
const streamers = {};
@@ -296,21 +356,47 @@ async function handle(context) {
if (freshCount === 0) {
// Total probe failure (e.g. douyin blocking this colo): serve the stale
// snapshot if one exists, otherwise an explicitly empty payload.
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
if (Object.keys(seeded).length) {
const body = {
probed_at: new Date().toISOString(),
ttl: FRESH_TTL_S,
streamers: Object.fromEntries(
Object.entries(seeded).map(([id, cell]) => [
id,
{ is_live: cell.is_live, stale: true },
])
),
};
return { body, cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" };
}
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
const res = jsonResponse(body, "miss");
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
return { body, cacheState: "miss" };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
// Coalesce concurrent cold starts in this isolate onto one probe run.
if (!inFlightProbe) {
inFlightProbe = probeFresh(request.url, cachedData).finally(() => {
inFlightProbe = null;
});
// Cache write must not block the response.
context.waitUntil(caches.default.put(CACHE_KEY, cached));
return res;
}
const { body, cacheState } = await inFlightProbe;
if (cacheState === "miss") {
context.waitUntil(putCache(body));
}
return jsonResponse(body, cacheState);
}
export async function onRequestGet(context) {
@@ -0,0 +1,70 @@
/**
* Pages Function: GET|POST /api/mobile-demand
*
* Best-effort edge counter for "please add mobile support" demand.
* Stored via the Cache API (no KV / wrangler binding), same pattern as
* live-status. Counts may undercount under concurrent colo races and can
* reset if the edge entry is evicted; good enough as a demand signal.
*
* GET -> { count }
* POST -> increment once, return { count, voted: true }
*/
const CACHE_KEY = "https://mobile-demand.internal/v1";
// Long store TTL so eviction is rare; freshness is not time-gated.
const CACHE_STORE_MAX_AGE_S = 365 * 24 * 60 * 60;
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
async function readCount() {
try {
const cached = await caches.default.match(CACHE_KEY);
if (!cached) return 0;
const data = await cached.json();
const n = Number(data && data.count);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
} catch {
return 0;
}
}
async function writeCount(count) {
const body = {
count,
updated_at: new Date().toISOString(),
};
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
await caches.default.put(CACHE_KEY, cached);
}
export async function onRequestGet() {
try {
const count = await readCount();
return jsonResponse({ count });
} catch {
return jsonResponse({ count: 0 });
}
}
export async function onRequestPost() {
try {
const next = (await readCount()) + 1;
await writeCount(next);
return jsonResponse({ count: next, voted: true });
} catch {
return jsonResponse({ error: "increment_failed" }, 503);
}
}
+22 -5
View File
@@ -5,9 +5,25 @@
<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.72" />
<link rel="stylesheet" href="/style.css?v=0.5.84" />
<script src="/mobile-gate.js?v=0.5.84"></script>
</head>
<body>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
<div class="mobile-gate-card">
<div class="mobile-gate-brand" aria-hidden="true">
<img class="mobile-gate-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="" />
<span class="mobile-gate-brand-title">上分帝</span>
</div>
<h1 id="mobile-gate-title" class="mobile-gate-title">暂不支持移动端</h1>
<p class="mobile-gate-desc">请从 PC 端浏览器访问本站。</p>
<button type="button" id="mobile-demand-btn" class="mobile-demand-btn">催更移动端</button>
<p id="mobile-demand-count" class="mobile-demand-count" hidden>
已有 <span id="mobile-demand-count-n">0</span> 人希望开通
</p>
</div>
</div>
<header class="topbar">
<div class="brand" aria-label="Dota 2 上分帝">
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
@@ -72,7 +88,8 @@
<div class="rankings-cluster">
<div class="rankings-center-wrap matches-center-wrap">
<div class="rankings-body matches-body" id="matches-body"></div>
<aside class="rankings-aside" aria-label="选手筛选">
<aside class="rankings-aside" aria-label="比赛筛选">
<div class="matches-origins" id="matches-origins" role="tablist" aria-label="职业或国服"></div>
<div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div>
<p class="rankings-sub" id="matches-sub"></p>
</aside>
@@ -149,8 +166,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.72"></script>
<script src="/router.js?v=0.5.72"></script>
<script src="/app.js?v=0.5.72"></script>
<script src="/config.js?v=0.5.84"></script>
<script src="/router.js?v=0.5.84"></script>
<script src="/app.js?v=0.5.84"></script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
/**
* Early mobile client gate (loaded from <head>).
* Sets html.mobile-client before paint; wires the demand button after DOM ready.
* Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path.
*/
(function () {
function isMobileClient() {
var ua = navigator.userAgent || "";
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
return true;
}
// iPadOS 13+ reports as MacIntel with touch.
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) {
return true;
}
return false;
}
var mobile = isMobileClient();
window.__CLIMPEROR_MOBILE__ = mobile;
if (!mobile) return;
document.documentElement.classList.add("mobile-client");
var STORAGE_KEY = "climperor_mobile_demand_voted";
function $(id) {
return document.getElementById(id);
}
function markVoted(btn) {
try {
localStorage.setItem(STORAGE_KEY, "1");
} catch (_) {
/* ignore quota / private mode */
}
if (btn) {
btn.disabled = true;
btn.textContent = "已记录催更";
}
}
function alreadyVoted() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (_) {
return false;
}
}
function setCount(n) {
var el = $("mobile-demand-count-n");
if (el) el.textContent = String(n);
var wrap = $("mobile-demand-count");
if (wrap) wrap.hidden = false;
}
async function refreshCount() {
try {
var res = await fetch("/api/mobile-demand");
if (!res.ok) return;
var data = await res.json();
var n = Number(data && data.count);
if (Number.isFinite(n) && n >= 0) setCount(Math.floor(n));
} catch (_) {
/* soft-fail: keep count hidden */
}
}
document.addEventListener("DOMContentLoaded", function () {
var logo = document.querySelector(".mobile-gate-logo");
if (logo && typeof STATIC_ASSET_BASE === "string") {
var base = STATIC_ASSET_BASE.trim().replace(/\/+$/, "");
if (base) logo.src = base + "/ui-icon/dota2_logo_wordmark.png";
}
var btn = $("mobile-demand-btn");
var voted = alreadyVoted();
if (voted) markVoted(btn);
refreshCount();
if (!btn || voted) return;
btn.addEventListener("click", async function () {
btn.disabled = true;
try {
var res = await fetch("/api/mobile-demand", { method: "POST" });
if (!res.ok) {
btn.disabled = false;
return;
}
var data = await res.json();
var n = Number(data && data.count);
if (Number.isFinite(n) && n >= 0) setCount(Math.floor(n));
markVoted(btn);
} catch (_) {
btn.disabled = false;
}
});
});
})();
+27 -1
View File
@@ -10,7 +10,8 @@
* - rankings: Immortal leaderboard region
* #/rankings[/region] (legacy #/rankings/meta[/bracket] players / china)
* - matches: star-player recent matches (pro_matches)
* #/matches[/account_id]
* #/matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - streamers: curated Douyin streamer directory
* #/streamers
* - trends: medal bracket for the 8-week win/pick board
@@ -50,6 +51,8 @@ const DEFAULT_TRENDS_SORT = "wr_end";
const VALID_TRENDS_SORTS = ["wr_end", "pr_end"];
const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" };
const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" };
const DEFAULT_MATCHES_ORIGIN = "all";
const VALID_MATCHES_ORIGINS = ["all", "pro", "china"];
const DEFAULT_MECHANIC_EFFECT = "basic_dispel";
const VALID_MECHANIC_EFFECTS = [
"basic_dispel",
@@ -95,6 +98,8 @@ function parseHash(hash) {
patchVersion: null,
rankingRegion: null,
matchesPlayerId: null,
matchesOrigin: null,
matchesPage: null,
trendsBracket: null,
trendsSort: null,
mechanicEffect: null,
@@ -152,6 +157,17 @@ function parseHash(hash) {
out.trendsSort = TRENDS_SORT_FROM_URL[sortParam];
}
}
if (page === "matches") {
const originParam = params.get("origin");
if (originParam && VALID_MATCHES_ORIGINS.includes(originParam)) {
out.matchesOrigin = originParam;
}
const pageParam = params.get("page");
if (pageParam && /^\d+$/.test(pageParam)) {
const n = parseInt(pageParam, 10);
if (n >= 1) out.matchesPage = n;
}
}
return out;
}
@@ -229,6 +245,16 @@ function serializeHash(state) {
if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString();
if (s) hash += "?" + s;
} else if (state.page === "matches") {
const qs = new URLSearchParams();
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
if (VALID_MATCHES_ORIGINS.includes(origin) && origin !== DEFAULT_MATCHES_ORIGIN) {
qs.set("origin", origin);
}
const pageNum = Number(state.matchesPage) || 1;
if (pageNum > 1) qs.set("page", String(pageNum));
const s = qs.toString();
if (s) hash += "?" + s;
}
return hash;
}
+241 -31
View File
@@ -1873,13 +1873,18 @@ a.match-player-name:hover {
color: rgba(180, 195, 215, 0.8);
}
.match-items,
.match-skills,
.match-backpack {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.match-skills {
display: flex;
flex-wrap: nowrap;
gap: 5px;
align-items: center;
}
.match-inv {
display: flex;
flex-direction: column;
@@ -2603,22 +2608,90 @@ body:has(#items-view:not(.hidden)) {
}
.matches-board .matches-body {
min-height: 0;
width: 100%;
min-width: 0;
}
.matches-center-wrap {
.matches-board .rankings-center-wrap.matches-center-wrap {
align-items: stretch;
/* Fixed column width so cards align; ~lv30 skill row + a little slack. */
width: min(1680px, 100%, calc(100vw - 280px));
}
.matches-board .match-list,
.matches-board .match-card {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.matches-board .match-skills {
max-width: 100%;
}
@media (max-width: 1460px) {
.match-skills {
flex-wrap: wrap;
}
}
.matches-board .rankings-aside {
align-items: stretch;
}
.matches-origins,
.matches-players {
display: flex;
flex-direction: column;
gap: 6px;
max-height: min(70vh, 560px);
overflow: auto;
scrollbar-width: thin;
padding-right: 2px;
width: 100%;
}
.matches-board .rankings-aside .rankings-region-btn {
width: 100%;
box-sizing: border-box;
}
.matches-board .match-list {
gap: 12px;
}
.matches-page-wrap {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.matches-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 0 8px;
}
.matches-pager-btn {
min-width: 36px;
height: 34px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(140, 170, 210, 0.28);
background: rgba(0, 0, 0, 0.28);
color: rgba(220, 230, 245, 0.9);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.matches-pager-btn:hover:not(:disabled):not(.active) {
border-color: rgba(180, 200, 230, 0.55);
color: #fff;
}
.matches-pager-btn.active {
border-color: rgba(232, 200, 120, 0.75);
background: rgba(180, 130, 40, 0.28);
color: rgba(255, 220, 160, 0.98);
cursor: default;
}
.matches-pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
.matches-pager-ell {
color: rgba(160, 175, 195, 0.7);
padding: 0 2px;
user-select: none;
}
.rankings-empty {
padding: 48px 0;
text-align: center;
@@ -2696,6 +2769,9 @@ body:has(#items-view:not(.hidden)) {
gap: 18px;
width: min(640px, 94%);
}
.matches-board .rankings-center-wrap.matches-center-wrap {
width: min(1680px, 94%);
}
.rankings-aside {
position: static;
width: 100%;
@@ -3232,7 +3308,7 @@ body:has(#items-view:not(.hidden)) {
background: rgba(16, 24, 36, 0.92);
box-sizing: border-box;
}
/* Row 1 only: avatar | nickname | actions — meta/signature live below as siblings */
/* Row 1: avatar | info (name/meta/signature) | follow */
.streamer-card-head {
display: flex;
align-items: center;
@@ -3285,11 +3361,9 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
font-weight: 700;
color: var(--muted);
}
/* Douyin search live: outer ring expands+fades; avatar shrinks (1s ease). */
.streamer-avatar-wrap.is-live {
padding-bottom: 10px;
margin-bottom: -10px;
}
/* Douyin live avatar: ring pulse + badge overlaps ring (no gap).
Measured on live.douyin.com: badge bottom:-6px on 54px wrap, ~10px overlap,
gradient pill, no border/shadow. */
.streamer-avatar-wrap.is-live .streamer-avatar {
border-color: rgb(69, 71, 79);
}
@@ -3303,7 +3377,7 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
width: 99px;
height: 99px;
border-radius: 50%;
border: 1px solid rgb(254, 26, 104);
border: 1px solid rgb(254, 44, 85);
box-sizing: border-box;
pointer-events: none;
z-index: 0;
@@ -3335,23 +3409,37 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
.streamer-live-badge {
position: absolute;
left: 50%;
bottom: 0;
/* Douyin: bottom:-6px → badge overlaps ring/avatar bottom, no air gap. */
bottom: -6px;
transform: translateX(-50%);
z-index: 2;
padding: 1px 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 38px;
height: 16px;
padding: 0 6px;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
color: #fff;
background: rgb(254, 26, 104);
background: linear-gradient(131.17deg, rgb(255, 23, 100) 0%, rgb(237, 52, 149) 94.15%);
border: 0;
border-radius: 4px;
box-shadow: none;
white-space: nowrap;
pointer-events: none;
}
.streamer-name {
.streamer-card-info {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
}
.streamer-name {
margin: 0;
font-size: 20px;
font-weight: 700;
@@ -3362,38 +3450,52 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
white-space: nowrap;
}
.streamer-meta {
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
margin: 0;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.6px;
line-height: 18px;
letter-spacing: 0.2px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Douyin search card: 1×10px divider, 8px side margin */
.streamer-meta-sep {
display: inline-block;
flex: 0 0 auto;
width: 1px;
height: 10px;
margin: 0 8px;
vertical-align: baseline;
background: rgba(255, 255, 255, 0.16);
height: 11px;
margin: 0 10px;
background: rgba(255, 255, 255, 0.13);
}
.streamer-meta-item {
display: inline;
flex: 0 0 auto;
}
.streamer-meta-value {
color: #dce5f2;
font-weight: 600;
}
.streamer-meta-item.is-account .streamer-meta-value {
color: #aab8cd;
font-weight: 500;
}
.streamer-meta-item.is-stat .streamer-meta-label {
margin-left: 3px;
}
.streamer-signature {
margin: 0;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.4px;
color: var(--muted);
letter-spacing: 0.3px;
color: #8e9bb0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.streamer-actions {
flex: 0 0 auto;
@@ -3586,9 +3688,21 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
.streamer-avatar-fallback {
font-size: 24px;
}
.streamer-live-badge {
bottom: -5px;
min-width: 34px;
height: 15px;
font-size: 9px;
}
.streamer-name {
font-size: 18px;
}
.streamer-meta {
font-size: 12px;
}
.streamer-meta-sep {
margin: 0 6px;
}
.streamer-actions {
min-width: 76px;
gap: 8px;
@@ -3636,3 +3750,99 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
}
}
/* —— Mobile gate (phones / tablets; set by mobile-gate.js) —— */
#mobile-gate {
display: none;
}
html.mobile-client,
html.mobile-client body {
height: 100%;
max-height: none;
overflow: auto;
}
html.mobile-client body > :not(#mobile-gate) {
display: none !important;
}
html.mobile-client #mobile-gate {
display: flex;
flex: 1 1 auto;
min-height: 100dvh;
align-items: center;
justify-content: center;
padding: 32px 20px;
}
.mobile-gate-card {
width: min(360px, 100%);
text-align: center;
}
.mobile-gate-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
}
.mobile-gate-logo {
height: 28px;
width: auto;
display: block;
}
.mobile-gate-brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--text);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.mobile-gate-title {
margin: 0 0 10px;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.mobile-gate-desc {
margin: 0 0 28px;
font-size: 15px;
line-height: 1.55;
color: var(--muted);
}
.mobile-demand-btn {
appearance: none;
border: 1px solid rgba(94, 200, 255, 0.45);
background: rgba(94, 200, 255, 0.12);
color: var(--sel);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 12px 22px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.mobile-demand-btn:hover:not(:disabled) {
background: rgba(94, 200, 255, 0.2);
border-color: rgba(94, 200, 255, 0.7);
}
.mobile-demand-btn:focus-visible {
outline: 2px solid var(--sel);
outline-offset: 2px;
}
.mobile-demand-btn:disabled {
cursor: default;
opacity: 0.75;
border-color: rgba(140, 170, 210, 0.28);
background: rgba(140, 170, 210, 0.1);
color: var(--muted);
}
.mobile-demand-count {
margin: 16px 0 0;
font-size: 13px;
color: var(--muted);
}
.mobile-demand-count span {
color: var(--text);
font-weight: 600;
}
+2 -6
View File
@@ -18,7 +18,7 @@ from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
ITEMS_META = DATA / "items_meta.json"
@@ -439,11 +439,7 @@ def main() -> None:
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
nonempty = sum(1 for v in by_hero.values() if v)
jugg = [x["item"] for x in by_hero.get("juggernaut") or []]
print(f"done: {nonempty}/{len(by_hero)} heroes have feared items -> {args.out}", flush=True)
+317 -32
View File
@@ -20,12 +20,14 @@ Usage:
from __future__ import annotations
import argparse
import io
import json
import os
import re
import sys
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
@@ -155,6 +157,33 @@ def http_json_soft(
return e.code, raw
def http_response_soft(
url: str,
*,
headers: dict | None = None,
) -> tuple[int, bytes, str, dict[str, str]]:
request_headers = {"User-Agent": "climperor-monitor", "Accept": "*/*"}
request_headers.update(headers or {})
req = urllib.request.Request(url, headers=request_headers)
try:
with urllib.request.urlopen(req, timeout=60) as response:
return (
response.status,
response.read(),
response.headers.get_content_type(),
{k.lower(): v for k, v in response.headers.items()},
)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
if isinstance(exc, urllib.error.HTTPError):
return (
exc.code,
exc.read(),
exc.headers.get_content_type(),
{k.lower(): v for k, v in exc.headers.items()},
)
return 0, str(exc).encode("utf-8", errors="replace"), "", {}
def gql(query: str, variables: dict) -> dict:
data = http_json(
"https://api.cloudflare.com/client/v4/graphql",
@@ -234,6 +263,18 @@ class DigestExtras:
workflows: list[WorkflowDayStatus] = field(default_factory=list)
deploys: list[PagesDeploy] = field(default_factory=list)
gitea_note: str = ""
refresh_summaries: dict[str, dict] = field(default_factory=dict)
production_health: "ProductionHealth | None" = None
@dataclass
class ProductionHealth:
site_version: str = "?"
ages_hours: dict[str, float | None] = field(default_factory=dict)
stale: list[str] = field(default_factory=list)
live_api_ok: bool = False
refresh_run_id: str = ""
note: str = ""
def zone_id() -> str:
@@ -437,6 +478,213 @@ def fetch_workflow_statuses(day: date) -> tuple[list[WorkflowDayStatus], str]:
return out, ""
def fetch_refresh_summaries(workflows: list[WorkflowDayStatus]) -> tuple[dict[str, dict], str]:
"""Download summary.json artifacts for the latest visible refresh runs."""
token = gitea_token()
if not token:
return {}, "无 Gitea token,未读取刷新摘要"
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
code, payload = http_json_soft(
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/artifacts?limit=100",
headers=headers,
)
if code != 200 or not isinstance(payload, dict):
return {}, f"刷新摘要 API HTTP {code}"
artifacts = payload.get("artifacts") or []
by_run: dict[int, dict] = {}
for artifact in artifacts:
if not isinstance(artifact, dict) or artifact.get("expired"):
continue
run = artifact.get("workflow_run") or {}
try:
run_id = int(run.get("id") or artifact.get("workflow_run_id") or 0)
except (TypeError, ValueError):
continue
if run_id:
by_run[run_id] = artifact
summaries: dict[str, dict] = {}
for workflow in workflows:
latest = None
for run in workflow.runs:
try:
run_id = int(run.get("id") or 0)
except (TypeError, ValueError):
continue
if run.get("status") == "completed" and run_id in by_run:
latest = run
break
if not latest:
continue
artifact = by_run[int(latest["id"])]
artifact_id = artifact.get("id")
if not artifact_id:
continue
status, raw, _, _ = http_response_soft(
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/artifacts/"
f"{artifact_id}/zip",
headers=headers,
)
if status != 200:
continue
try:
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
member = next(
name for name in archive.namelist() if name.endswith("summary.json")
)
summary = json.loads(archive.read(member).decode("utf-8"))
if isinstance(summary, dict):
summaries[workflow.file] = summary
except (ValueError, KeyError, StopIteration, zipfile.BadZipFile, json.JSONDecodeError):
continue
return summaries, ""
def _nested_timestamp(payload: dict, *path: str) -> datetime | None:
value: Any = payload
for key in path:
if not isinstance(value, dict):
return None
value = value.get(key)
return parse_gitea_time(value)
def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool:
if not summary.get("ok"):
return False
step_ok = any(
isinstance(row, dict)
and row.get("step") == step
and row.get("ok")
for row in (summary.get("steps") or [])
)
if not step_ok:
return False
health = summary.get("health") or {}
if label == "主播资料":
return not (
int(health.get("streamer_profile_missing") or 0)
or int(health.get("streamer_live_probe_missing") or 0)
)
if label == "STRATZ Meta":
return not health.get("stratz_meta_stale")
if label == "STRATZ 对位":
return int(health.get("stratz_matchup_stale") or 0) == 0
return True
def fetch_production_health(
now: datetime | None = None,
refresh_summaries: dict[str, dict] | None = None,
) -> ProductionHealth:
"""Probe production payload freshness and the live-status Function."""
current = now or datetime.now(timezone.utc)
health = ProductionHealth()
status, config_raw, _, _ = http_response_soft(f"{SITE_URL}/config.js")
if status == 200:
match = re.search(
rb'SITE_VERSION\s*=\s*"([^"]+)"',
config_raw,
)
if match:
health.site_version = match.group(1).decode("utf-8", errors="replace")
status, data_raw, _, _ = http_response_soft(f"{SITE_URL}/data.json")
if status != 200:
health.note = f"生产 data.json HTTP {status}"
health.stale.append("data.json")
return health
try:
payload = json.loads(data_raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
health.note = "生产 data.json 无法解析"
health.stale.append("data.json")
return health
health.refresh_run_id = str((payload.get("meta") or {}).get("refresh_run_id") or "")
summaries = refresh_summaries or {}
checks = {
"英雄统计": (
("hero_stats", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_hero_stats.py",
),
"排行榜": (
("leaderboards", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_leaderboards.py",
),
"近期比赛": (
("hero_matches", "meta", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_hero_matches.py",
),
"明星比赛": (
("pro_matches", "meta", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_pro_matches.py",
),
"主播资料": (
("streamers", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_streamers.py",
),
"STRATZ Meta": (
("stratz_hero_meta", "fetched_at"),
216.0,
"web-weekly.yml",
"fetch_stratz_meta.py",
),
"STRATZ 对位": (
("stratz_matchup_tops", "fetched_at"),
216.0,
"web-weekly.yml",
"fetch_stratz_meta.py",
),
"英雄装备": (
("hero_items", "meta", "fetched_at"),
216.0,
"web-weekly.yml",
"fetch_hero_items.py",
),
}
for label, (path, max_age, workflow_file, step) in checks.items():
timestamp = _nested_timestamp(payload, *path)
age = None
if timestamp is not None:
age = max(0.0, (current - timestamp.astimezone(timezone.utc)).total_seconds() / 3600)
health.ages_hours[label] = age
latest_refresh_ok = _summary_proves_refresh(
summaries.get(workflow_file) or {},
label,
step,
)
if (age is None or age > max_age) and not latest_refresh_ok:
health.stale.append(label)
live_status, live_raw, live_type, live_headers = http_response_soft(
f"{SITE_URL}/api/live-status"
)
try:
live_payload = json.loads(live_raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
live_payload = None
health.live_api_ok = (
live_status == 200
and live_type == "application/json"
and isinstance(live_payload, dict)
and live_headers.get("x-live-cache") not in ("error", "stale-override")
)
if not health.live_api_ok:
health.stale.append("直播状态 API")
return health
def cf_account_id() -> str:
d = http_json(
"https://api.cloudflare.com/client/v4/accounts",
@@ -495,7 +743,7 @@ def fmt_bytes(n: int) -> str:
def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
if w.note and not w.runs:
return f"- **{w.label}**`{w.file}`{w.note}"
return f"- **{w.label}**{w.note}"
parts: list[str] = []
if w.ok_count:
parts.append(f"成功 {w.ok_count}")
@@ -528,49 +776,91 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
estimate = st.human_home_vis
rum = st.rum_human
# Human estimate = known-browser UA hits on `/` (SPA hash routes are not paths).
traffic_md = (
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
f"(已知浏览器 UA 打开 `/`;SPA Hash 路由不计路径;不含爬虫)\n\n"
f"**RUM 页面浏览**{rum}"
+ (f" + {st.rum_other} 其他" if st.rum_other else "")
+ "\n"
f"**边缘总量**{st.all_req} 请求 / {st.all_vis} visits / {fmt_bytes(st.bytes)}\n"
f"**非人类首页命中**{st.other_home_vis} visits"
f"**非人类首页**{st.other_home_vis} visits\n"
f"**地区 Top**{countries}\n"
f"**状态码**{status_line}"
)
wf_lines = [_fmt_workflow_line(w) for w in extras.workflows]
if extras.gitea_note:
wf_lines.append(f"- _{extras.gitea_note}_")
for workflow_file, summary in extras.refresh_summaries.items():
label = next(
(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 = "刷新失败"
stale_count = int(
(summary.get("health") or {}).get("stratz_matchup_stale") or 0
)
live_missing = int(
(summary.get("health") or {}).get("streamer_live_probe_missing") or 0
)
wf_lines.append(
f"- **{label}摘要**{result}"
+ (f" · 对位陈旧 {stale_count}" if stale_count else "")
+ (f" · 直播探测缺失 {live_missing}" if live_missing else "")
)
ok_deploys = [p for p in extras.deploys if p.status == "success"]
fail_deploys = [p for p in extras.deploys if p.status != "success"]
if extras.deploys:
deploy_md = (
f"**Pages 生产部署**:成功 **{len(ok_deploys)}**"
deploy_lines = [
f"**Pages**:成功 **{len(ok_deploys)}**"
+ (f" / 异常 {len(fail_deploys)}" if fail_deploys else "")
+ "\n"
)
]
for p in extras.deploys[:5]:
mark = "[ok]" if p.status == "success" else "[!]"
deploy_md += (
f"- {mark} {p.created_on.strftime('%H:%M')} "
f"`{p.commit or '?'}` {p.status}\n"
mark = "ok" if p.status == "success" else "!"
deploy_lines.append(
f"- [{mark}] {p.created_on.strftime('%H:%M')} "
f"`{p.commit or '?'}` {p.status}"
)
deploy_md = "\n".join(deploy_lines)
else:
deploy_md = "**Pages 生产部署**:当日无新部署(数据未变则 refresh 会跳过 deploy\n"
deploy_md = "**Pages**:当日无新部署"
health = extras.production_health
production_lines: list[str] = []
if health:
newest = [
f"{label} {age:.1f}h"
for label, age in health.ages_hours.items()
if age is not None
]
production_lines.append(
f"**生产探针**{'正常' if not health.stale else '异常'}"
f" · live API {'正常' if health.live_api_ok else '失败'}"
)
if newest:
production_lines.append("- " + " / ".join(newest))
if health.stale:
production_lines.append("- **超时/异常**" + "".join(health.stale))
if health.refresh_run_id:
production_lines.append(f"- run `{health.refresh_run_id}`")
refresh_md = (
f"**站点版本**`v{extras.site_version}`\n\n"
f"**自动刷新(Gitea Actions**\n"
f"**版本**`v{extras.site_version}`\n"
f"**Actions**\n"
+ "\n".join(wf_lines)
+ "\n\n"
+ deploy_md.strip()
+ "\n"
+ deploy_md
+ ("\n" + "\n".join(production_lines) if production_lines else "")
)
any_refresh_ok = any(w.ok_count > 0 for w in extras.workflows) or bool(ok_deploys)
any_refresh_fail = any(w.fail_count > 0 for w in extras.workflows) or bool(
fail_deploys
)
any_refresh_fail = any_refresh_fail or bool(health and health.stale)
if any_refresh_fail:
header_color = "red"
@@ -586,18 +876,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
},
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**自动更新**\n" + refresh_md},
},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**首页地区 Top**{countries}\n"
f"**状态码**{status_line}\n"
f"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_"
),
},
"text": {"tag": "lark_md", "content": "**刷新与部署**\n" + refresh_md},
},
{
"tag": "note",
@@ -605,8 +884,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
{
"tag": "plain_text",
"content": (
f"{HOST} · Free 档无 BotScore · "
f"refresh=web-daily/weekly/patch · {now}"
f"{HOST} · 估数=已知浏览器 UA 打开 / · {now}"
),
}
],
@@ -674,15 +952,22 @@ def main(argv: list[str] | None = None) -> int:
args = ap.parse_args(argv)
day = parse_day(args.day)
refresh_day = datetime.now(CST).date()
zid = zone_id()
st = fetch_day(zid, day)
workflows, gitea_note = fetch_workflow_statuses(day)
workflows, gitea_note = fetch_workflow_statuses(refresh_day)
refresh_summaries, summary_note = fetch_refresh_summaries(workflows)
if summary_note:
gitea_note = "".join(x for x in (gitea_note, summary_note) if x)
deploys = fetch_pages_deploys(day)
production_health = fetch_production_health(refresh_summaries=refresh_summaries)
extras = DigestExtras(
site_version=read_site_version(),
site_version=production_health.site_version,
workflows=workflows,
deploys=deploys,
gitea_note=gitea_note,
refresh_summaries=refresh_summaries,
production_health=production_health,
)
payload = build_card(st, extras)
+197
View File
@@ -0,0 +1,197 @@
"""Restore/save durable Web refresh state around ephemeral Actions checkouts."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from shared.http_utils import write_json_atomic
from shared.paths import DATA
ROOT = Path(__file__).resolve().parent
CACHE_DIR = ROOT / ".refresh-cache"
STATE_DIR = Path(
os.environ.get("CLIMPEROR_REFRESH_STATE")
or (Path.home() / ".cache" / "climperor-web-refresh")
)
CACHE_FILES = (
"patches.json",
"hero_stats.json",
"hero_matches.json",
"pro_matches.json",
"stratz_hero_meta.json",
"stratz_matchup_tops.json",
"item_counter_stats.json",
"leaderboards.json",
"hero_items.json",
"items_meta.json",
"hero_item_fears.json",
"hero_abilities.json",
"item_shop.json",
)
STREAMER_RUNTIME_KEYS = (
"nickname",
"unique_id",
"signature",
"following_count",
"follower_count",
"total_favorited",
"avatar",
"profile_fetched_at",
"is_live",
"live_probed_at",
)
def _copy(src: Path, dst: Path) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_name(f".{dst.name}.restore.tmp")
shutil.copy2(src, tmp)
tmp.replace(dst)
def _payload_time(path: Path) -> datetime | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
raw = payload.get("fetched_at") or (payload.get("meta") or {}).get("fetched_at")
if not raw:
return None
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
def _cache_is_at_least_as_new(cached: Path, checkout: Path) -> bool:
if not checkout.is_file():
return True
cached_at = _payload_time(cached)
checkout_at = _payload_time(checkout)
if cached_at is not None and checkout_at is not None:
return cached_at >= checkout_at
# Ignored runtime files have no checkout copy; for tracked generated files,
# prefer Git when freshness cannot be established.
return cached_at is not None
def restore_streamers(cached_path: Path, checkout_path: Path) -> None:
"""Merge runtime fields without replacing newly committed manual rows."""
if not cached_path.is_file() or not checkout_path.is_file():
return
if not _cache_is_at_least_as_new(cached_path, checkout_path):
return
try:
cached = json.loads(cached_path.read_text(encoding="utf-8"))
checkout = json.loads(checkout_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
cached_by_id = {
str(row.get("id")): row
for row in (cached.get("streamers") or [])
if isinstance(row, dict) and row.get("id")
}
for row in checkout.get("streamers") or []:
if not isinstance(row, dict):
continue
old = cached_by_id.get(str(row.get("id")))
if not old:
continue
for key in STREAMER_RUNTIME_KEYS:
if key in old:
row[key] = old[key]
if cached.get("fetched_at"):
checkout["fetched_at"] = cached["fetched_at"]
write_json_atomic(checkout_path, checkout)
def restore_patches(cached_path: Path, checkout_path: Path) -> None:
"""Merge fetched patch details without reverting newer committed entries."""
if not cached_path.is_file():
return
try:
cached = json.loads(cached_path.read_text(encoding="utf-8"))
checkout = (
json.loads(checkout_path.read_text(encoding="utf-8"))
if checkout_path.is_file()
else {}
)
except (OSError, json.JSONDecodeError):
return
patches_by_version: dict[str, dict] = {}
for source in (cached, checkout):
for row in source.get("patches") or []:
if isinstance(row, dict) and row.get("version"):
patches_by_version[str(row["version"])] = row
patches = sorted(
patches_by_version.values(),
key=lambda row: int(row.get("timestamp") or 0),
reverse=True,
)
details = dict(cached.get("details") or {})
details.update(checkout.get("details") or {})
lookup: dict[str, dict] = {}
lookup_groups = set(cached.get("lookup") or {}) | set(
checkout.get("lookup") or {}
)
for group in lookup_groups:
merged = dict((cached.get("lookup") or {}).get(group) or {})
merged.update((checkout.get("lookup") or {}).get(group) or {})
lookup[group] = merged
meta = dict(cached.get("meta") or {})
meta.update(checkout.get("meta") or {})
meta["count"] = len(patches)
meta["details_count"] = len(details)
write_json_atomic(
checkout_path,
{"meta": meta, "patches": patches, "lookup": lookup, "details": details},
)
def restore() -> None:
source_dir = (
STATE_DIR
if STATE_DIR.is_dir() and any(STATE_DIR.glob("*.json"))
else CACHE_DIR
)
restored = 0
for name in CACHE_FILES:
if name == "patches.json":
continue
src = source_dir / name
if src.is_file() and _cache_is_at_least_as_new(src, DATA / name):
_copy(src, DATA / name)
restored += 1
restore_patches(source_dir / "patches.json", DATA / "patches.json")
restore_streamers(source_dir / "streamers.json", DATA / "streamers.json")
print(f"refresh cache restored: {restored} generated files from {source_dir}")
def save() -> None:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)
saved = 0
for name in (*CACHE_FILES, "streamers.json"):
src = DATA / name
if src.is_file():
_copy(src, CACHE_DIR / name)
_copy(src, STATE_DIR / name)
saved += 1
print(f"refresh cache saved: {saved} files to {STATE_DIR}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("restore", "save"))
args = parser.parse_args()
restore() if args.action == "restore" else save()
if __name__ == "__main__":
main()
+264 -29
View File
@@ -21,11 +21,17 @@ from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT.parent))
from shared.http_utils import write_json_atomic
# Paths whose content change should trigger deploy / OSS.
DATA_WATCH = [
@@ -42,6 +48,7 @@ DATA_WATCH = [
ROOT / "data" / "patches.json",
ROOT / "data" / "hero_abilities.json",
ROOT / "data" / "item_shop.json",
ROOT / "data" / "item_counter_stats.json",
]
ASSET_DIRS = [
ROOT / "assets" / "item_icons",
@@ -51,6 +58,19 @@ ASSET_DIRS = [
ROOT / "assets" / "streamer_avatars",
ROOT / "assets" / "streamer_videos",
]
SUMMARY_PATH = ROOT / ".refresh" / "summary.json"
VOLATILE_JSON_KEYS = frozenset(
{
"fetched_at",
"generated_at",
"last_attempt_at",
"live_probed_at",
"profile_fetched_at",
"updated_at",
}
)
RUN_RESULTS: list[dict] = []
PATCH_RESULT: dict = {}
def _file_digest(path: Path) -> str | None:
@@ -63,6 +83,35 @@ def _file_digest(path: Path) -> str | None:
return h.hexdigest()
def _without_volatile_fields(value):
if isinstance(value, dict):
return {
key: _without_volatile_fields(child)
for key, child in value.items()
if key not in VOLATILE_JSON_KEYS
}
if isinstance(value, list):
return [_without_volatile_fields(child) for child in value]
return value
def _semantic_file_digest(path: Path) -> str | None:
"""Hash JSON business content while ignoring refresh-only timestamps."""
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return _file_digest(path)
canonical = json.dumps(
_without_volatile_fields(payload),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def _dir_digest(path: Path) -> str | None:
if not path.is_dir():
return None
@@ -82,7 +131,7 @@ 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))] = _file_digest(p)
out[str(p.relative_to(ROOT))] = _semantic_file_digest(p)
for d in ASSET_DIRS:
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d)
return out
@@ -106,9 +155,20 @@ def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None])
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)
started = time.monotonic()
if dry_run:
RUN_RESULTS.append({"step": script, "ok": True, "dry_run": True, "duration_s": 0.0})
return True
proc = subprocess.run(cmd, cwd=str(ROOT), check=False)
result = {
"step": script,
"args": list(args),
"ok": proc.returncode == 0,
"soft_fail": soft_fail,
"returncode": proc.returncode,
"duration_s": round(time.monotonic() - started, 3),
}
RUN_RESULTS.append(result)
if proc.returncode == 0:
return True
if soft_fail:
@@ -124,6 +184,7 @@ def patch_check() -> dict:
"""Always hits the network (read-only list); safe under --dry-run."""
cmd = [sys.executable, str(ROOT / "fetch_patches.py"), "--check"]
print(f"+ {' '.join(cmd)}", flush=True)
started = time.monotonic()
proc = subprocess.run(
cmd,
cwd=str(ROOT),
@@ -136,6 +197,17 @@ def patch_check() -> dict:
if not line:
raise RuntimeError("fetch_patches.py --check produced empty stdout")
result = json.loads(line)
PATCH_RESULT.clear()
PATCH_RESULT.update(result)
RUN_RESULTS.append(
{
"step": "fetch_patches.py --check",
"ok": True,
"duration_s": round(time.monotonic() - started, 3),
"has_new": bool(result.get("has_new")),
"new_versions": list(result.get("new_versions") or []),
}
)
print(json.dumps(result, ensure_ascii=False), flush=True)
return result
@@ -160,7 +232,7 @@ def run_daily(*, dry_run: bool = False) -> bool:
run_script("fetch_hero_stats.py", dry_run=dry_run)
run_script("fetch_leaderboards.py", dry_run=dry_run)
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
run_script("fetch_pro_matches.py", 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).
@@ -180,7 +252,7 @@ def run_daily(*, dry_run: bool = False) -> bool:
def run_weekly(*, dry_run: bool = False) -> None:
# Default full matchup refresh (not --resume-matchups); stale cells kept on failure.
run_script("fetch_stratz_meta.py", dry_run=dry_run)
run_script("fetch_hero_items.py", dry_run=dry_run)
run_script("fetch_hero_items.py", "--force", dry_run=dry_run)
run_script("fetch_items_meta.py", dry_run=dry_run)
run_script("fetch_item_counter_stats.py", "--soft-fail", dry_run=dry_run)
run_script("item_fears.py", dry_run=dry_run)
@@ -200,6 +272,130 @@ def run_patch_tier(*, dry_run: bool = False) -> bool:
return True
def _stale_metrics() -> dict:
metrics = {
"stratz_meta_stale": False,
"stratz_matchup_stale": 0,
"streamer_profile_missing": 0,
"streamer_live_probe_missing": 0,
}
meta_path = ROOT / "data" / "stratz_hero_meta.json"
matchup_path = ROOT / "data" / "stratz_matchup_tops.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
metrics["stratz_meta_stale"] = bool(meta.get("stale"))
metrics["stratz_meta_stale_brackets"] = list(meta.get("stale_brackets") or [])
except (OSError, json.JSONDecodeError):
metrics["stratz_meta_missing"] = True
try:
matchup = json.loads(matchup_path.read_text(encoding="utf-8"))
metrics["stratz_matchup_stale"] = sum(
1
for cell in (matchup.get("by_hero") or {}).values()
if isinstance(cell, dict) and cell.get("stale")
)
except (OSError, json.JSONDecodeError):
metrics["stratz_matchup_missing"] = True
try:
streamers = json.loads(
(ROOT / "data" / "streamers.json").read_text(encoding="utf-8")
)
rows = streamers.get("streamers") or []
metrics["streamer_profile_missing"] = sum(
1
for row in rows
if isinstance(row, dict)
and row.get("profile_url")
and not row.get("profile_fetched_at")
)
metrics["streamer_live_probe_missing"] = sum(
1
for row in rows
if isinstance(row, dict)
and row.get("live_url")
and not row.get("live_probed_at")
)
except (OSError, json.JSONDecodeError):
metrics["streamers_missing"] = True
metrics["item_counter_missing"] = not (
ROOT / "data" / "item_counter_stats.json"
).is_file()
return metrics
def _write_summary(summary: dict) -> None:
summary["steps"] = list(RUN_RESULTS)
summary["patch_check"] = dict(PATCH_RESULT)
summary["health"] = _stale_metrics()
summary["completed_at"] = datetime.now(timezone.utc).isoformat()
write_json_atomic(SUMMARY_PATH, summary)
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
def validate_publish_options(
*,
assets_changed: bool,
skip_oss: bool,
skip_deploy: bool,
) -> None:
if assets_changed and skip_oss and not skip_deploy:
raise RuntimeError(
"assets changed but --skip-oss would publish missing OSS assets; "
"also set --skip-deploy for a local-only refresh"
)
def acquire_refresh_lock(tier: str):
"""Serialize refresh/deploy work on a self-hosted runner.
Patch checks are deferred instead of waiting behind a full refresh; the
six-hour schedule will retry them without exporting a stale restored cache.
"""
lock_path = Path(
os.environ.get("CLIMPEROR_REFRESH_LOCK")
or (Path.home() / ".climperor-web-refresh.lock")
)
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+b")
handle.seek(0)
if handle.tell() == 0 and lock_path.stat().st_size == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
try:
if os.name == "nt":
import msvcrt
mode = msvcrt.LK_NBLCK if tier == "patch" else msvcrt.LK_LOCK
msvcrt.locking(handle.fileno(), mode, 1)
else:
import fcntl
flags = fcntl.LOCK_EX | (fcntl.LOCK_NB if tier == "patch" else 0)
fcntl.flock(handle.fileno(), flags)
except (BlockingIOError, OSError):
handle.close()
if tier == "patch":
return None
raise
return handle
def release_refresh_lock(handle) -> None:
try:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
finally:
handle.close()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
@@ -218,57 +414,96 @@ def main() -> None:
)
args = ap.parse_args()
RUN_RESULTS.clear()
PATCH_RESULT.clear()
started = time.monotonic()
summary = {
"ok": False,
"tier": args.tier,
"run_id": os.environ.get("REFRESH_RUN_ID") or "",
"started_at": datetime.now(timezone.utc).isoformat(),
"data_changed": False,
"assets_changed": False,
"changed_paths": [],
"deployed": False,
"force_deploy": args.force_deploy,
}
lock_handle = acquire_refresh_lock(args.tier)
if lock_handle is None:
summary.update(
{
"ok": True,
"skipped": True,
"skip_reason": "full refresh already running; next patch schedule will retry",
"duration_s": round(time.monotonic() - started, 3),
}
)
_write_summary(summary)
return
try:
cache_enabled = os.environ.get("REFRESH_CACHE_ENABLED") == "1"
if cache_enabled:
run_script("refresh_cache.py", "restore")
before = snapshot() if not args.dry_run else {}
did_work = False
if args.tier in ("weekly", "all"):
run_weekly(dry_run=args.dry_run)
did_work = True
if args.tier in ("daily", "all"):
if run_daily(dry_run=args.dry_run):
did_work = True
else:
did_work = True # daily fetch scripts still ran
run_daily(dry_run=args.dry_run)
if args.tier == "patch":
if run_patch_tier(dry_run=args.dry_run):
did_work = True
run_patch_tier(dry_run=args.dry_run)
if args.dry_run:
summary["ok"] = True
summary["dry_run"] = True
print("dry-run complete (no deploy/oss)", flush=True)
return
after = snapshot()
data_changed, assets_changed = diff_snapshots(before, after)
changed_paths = sorted(
key for key in set(before) | set(after) if before.get(key) != after.get(key)
)
summary.update(
{
"data_changed": data_changed,
"assets_changed": assets_changed,
"changed_paths": changed_paths,
}
)
print(
f"changes: data={data_changed} assets={assets_changed} "
f"force_deploy={args.force_deploy} did_work={did_work}",
f"force_deploy={args.force_deploy}",
flush=True,
)
validate_publish_options(
assets_changed=assets_changed,
skip_oss=args.skip_oss,
skip_deploy=args.skip_deploy,
)
if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload")
elif assets_changed and args.skip_oss:
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
elif assets_changed:
print("assets changed but deploy is disabled; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed
deployed = False
if should_deploy and not args.skip_deploy:
run_script("deploy_relations.py")
deployed = True
elif should_deploy and args.skip_deploy:
summary["deployed"] = True
elif should_deploy:
print("deploy needed but --skip-deploy set; skipping", flush=True)
else:
print("no data/asset changes; skip deploy", flush=True)
summary = {
"ok": True,
"tier": args.tier,
"data_changed": data_changed,
"assets_changed": assets_changed,
"deployed": deployed,
"force_deploy": args.force_deploy,
}
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
print("no business data/asset changes; skip deploy", flush=True)
if cache_enabled:
run_script("refresh_cache.py", "save")
summary["ok"] = True
except BaseException as exc:
summary["error"] = f"{type(exc).__name__}: {exc}"
raise
finally:
summary["duration_s"] = round(time.monotonic() - started, 3)
_write_summary(summary)
release_refresh_lock(lock_handle)
if __name__ == "__main__":
+39
View File
@@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import mimetypes
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
@@ -50,6 +51,32 @@ from shared.relations import DEFAULT_RELATIONS, load_relations
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
_MOBILE_DEMAND_LOCK = threading.Lock()
def _read_mobile_demand_count() -> int:
try:
if not MOBILE_DEMAND_PATH.is_file():
return 0
data = json.loads(MOBILE_DEMAND_PATH.read_text(encoding="utf-8"))
n = int(data.get("count", 0))
return n if n >= 0 else 0
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return 0
def _inc_mobile_demand_count() -> int:
with _MOBILE_DEMAND_LOCK:
n = _read_mobile_demand_count() + 1
MOBILE_DEMAND_PATH.parent.mkdir(parents=True, exist_ok=True)
MOBILE_DEMAND_PATH.write_text(
json.dumps({"count": n}, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return n
GRID_ORDER_PATH = DATA / "hero_grid_order.json"
HERO_ITEMS_PATH = DATA / "hero_items.json"
HERO_STATS_PATH = DATA / "hero_stats.json"
@@ -73,6 +100,8 @@ def _first_existing(*candidates: Path) -> Path | None:
if p.is_file():
return p
return None
ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4}
ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"}
ABILITY_ICON_URL = (
@@ -877,6 +906,9 @@ class Handler(BaseHTTPRequestHandler):
# production only); the empty map keeps the UI on data.json is_live.
self._json(200, {"probed_at": None, "ttl": 300, "streamers": {}})
return
if path == "/api/mobile-demand":
self._json(200, {"count": _read_mobile_demand_count()})
return
if path.startswith("/attr/"):
key = path[len("/attr/") :]
if key not in ("str.png", "agi.png", "int.png", "all.png"):
@@ -1057,6 +1089,13 @@ class Handler(BaseHTTPRequestHandler):
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype)
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/mobile-demand":
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
return
self.send_error(404)
def main() -> None:
ap = argparse.ArgumentParser(description="Preview qualitative hero relations (read-only)")
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
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.refresh_cache import restore_patches, restore_streamers
from shared.http_utils import write_json_atomic
class RefreshDigestTests(unittest.TestCase):
def test_atomic_json_replaces_complete_document(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "data.json"
write_json_atomic(path, {"value": [1, 2, 3]})
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), {"value": [1, 2, 3]})
self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), [])
def test_semantic_digest_ignores_refresh_timestamps(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "data.json"
path.write_text(
json.dumps({"fetched_at": "old", "by_hero": {"axe": {"pick": 1}}}),
encoding="utf-8",
)
before = refresh_web._semantic_file_digest(path)
path.write_text(
json.dumps({"fetched_at": "new", "by_hero": {"axe": {"pick": 1}}}),
encoding="utf-8",
)
self.assertEqual(before, refresh_web._semantic_file_digest(path))
path.write_text(
json.dumps({"fetched_at": "new", "by_hero": {"axe": {"pick": 2}}}),
encoding="utf-8",
)
self.assertNotEqual(before, refresh_web._semantic_file_digest(path))
def test_assets_cannot_deploy_when_oss_is_skipped(self) -> None:
with self.assertRaises(RuntimeError):
refresh_web.validate_publish_options(
assets_changed=True,
skip_oss=True,
skip_deploy=False,
)
refresh_web.validate_publish_options(
assets_changed=True,
skip_oss=True,
skip_deploy=True,
)
def test_streamer_cache_preserves_manual_rows_and_restores_runtime_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
cached = Path(tmp) / "cached.json"
checkout = Path(tmp) / "checkout.json"
write_json_atomic(
cached,
{
"fetched_at": "2026-07-29T10:00:00+00:00",
"streamers": [
{
"id": "known",
"nickname": "cached-name",
"follower_count": 99,
"live_url": "must-not-restore",
},
{"id": "deleted", "nickname": "deleted"},
],
},
)
write_json_atomic(
checkout,
{
"fetched_at": "2026-07-29T09:00:00+00:00",
"streamers": [
{
"id": "known",
"nickname": "git-name",
"live_url": "new-manual-url",
},
{"id": "new", "nickname": "new-manual-row"},
],
},
)
restore_streamers(cached, checkout)
payload = json.loads(checkout.read_text(encoding="utf-8"))
rows = {row["id"]: row for row in payload["streamers"]}
self.assertEqual(rows["known"]["nickname"], "cached-name")
self.assertEqual(rows["known"]["follower_count"], 99)
self.assertEqual(rows["known"]["live_url"], "new-manual-url")
self.assertIn("new", rows)
self.assertNotIn("deleted", rows)
def test_patch_cache_merges_runtime_and_committed_versions(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
cached = Path(tmp) / "cached.json"
checkout = Path(tmp) / "checkout.json"
write_json_atomic(
cached,
{
"meta": {"fetched_at": "cached"},
"patches": [{"version": "7.40", "timestamp": 200}],
"lookup": {"heroes": {"1": {"name": "cached"}}},
"details": {"7.40": {"general_notes": ["cached"]}},
},
)
write_json_atomic(
checkout,
{
"meta": {"source": "git"},
"patches": [{"version": "7.41", "timestamp": 300}],
"lookup": {"heroes": {"2": {"name": "committed"}}},
"details": {"7.41": {"general_notes": ["committed"]}},
},
)
restore_patches(cached, checkout)
payload = json.loads(checkout.read_text(encoding="utf-8"))
self.assertEqual(
[row["version"] for row in payload["patches"]],
["7.41", "7.40"],
)
self.assertEqual(set(payload["details"]), {"7.40", "7.41"})
self.assertEqual(set(payload["lookup"]["heroes"]), {"1", "2"})
class StratzFallbackTests(unittest.TestCase):
def test_failed_bracket_keeps_previous_data_and_marks_stale(self) -> None:
fresh = {
"totals": {"immortal": {"pick": 0}},
"meta_board": {"immortal": []},
"by_hero": {
"axe": {
"weeks": {"immortal": []},
"latest": {},
"positions": {"immortal": {}},
}
},
}
previous = {
"totals": {"immortal": {"pick": 100}},
"meta_board": {"immortal": [{"key": "axe"}]},
"by_hero": {
"axe": {
"weeks": {"immortal": [{"week": 1, "pick": 100, "win": 50}]},
"latest": {"immortal": {"pick": 100, "win": 50}},
"positions": {"immortal": {"POSITION_3": {"pick": 40, "win": 20}}},
}
},
}
result = preserve_failed_meta_brackets(
fresh,
previous,
failed_weeks={"immortal"},
failed_positions={"immortal"},
)
self.assertTrue(result["stale"])
self.assertEqual(result["totals"]["immortal"]["pick"], 100)
self.assertEqual(
result["by_hero"]["axe"]["positions"]["immortal"]["POSITION_3"]["pick"],
40,
)
class ProductionHealthTests(unittest.TestCase):
def test_stale_payload_and_invalid_live_api_are_reported(self) -> None:
now = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
old = (now - timedelta(hours=48)).isoformat()
payload = {
"heroes": [{"key": "axe"}],
"meta": {"refresh_run_id": "123"},
"hero_stats": {"fetched_at": old},
"leaderboards": {"fetched_at": old},
"hero_matches": {"meta": {"fetched_at": old}},
"pro_matches": {"meta": {"fetched_at": old}},
"streamers": {"fetched_at": old},
"stratz_hero_meta": {"fetched_at": old},
"stratz_matchup_tops": {"fetched_at": old},
"hero_items": {"meta": {"fetched_at": old}},
}
responses = [
(200, b'var SITE_VERSION = "1.2.3";', "application/javascript", {}),
(200, json.dumps(payload).encode(), "application/json", {}),
(200, b"<html>fallback</html>", "text/html", {}),
]
with patch.object(
notify_site_traffic,
"http_response_soft",
side_effect=responses,
):
health = notify_site_traffic.fetch_production_health(now=now)
self.assertEqual(health.site_version, "1.2.3")
self.assertEqual(health.refresh_run_id, "123")
self.assertIn("英雄统计", health.stale)
self.assertIn("直播状态 API", health.stale)
self.assertFalse(health.live_api_ok)
def test_successful_refresh_summary_prevents_false_freshness_alarm(self) -> None:
now = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
old_daily = (now - timedelta(hours=48)).isoformat()
recent_weekly = (now - timedelta(days=2)).isoformat()
payload = {
"heroes": [{"key": "axe"}],
"meta": {"refresh_run_id": "456"},
"hero_stats": {"fetched_at": old_daily},
"leaderboards": {"fetched_at": old_daily},
"hero_matches": {"meta": {"fetched_at": old_daily}},
"pro_matches": {"meta": {"fetched_at": old_daily}},
"streamers": {"fetched_at": old_daily},
"stratz_hero_meta": {"fetched_at": recent_weekly},
"stratz_matchup_tops": {"fetched_at": recent_weekly},
"hero_items": {"meta": {"fetched_at": recent_weekly}},
}
responses = [
(200, b'var SITE_VERSION = "1.2.3";', "application/javascript", {}),
(200, json.dumps(payload).encode(), "application/json", {}),
(
200,
b'{"ok":true,"streamers":{}}',
"application/json",
{"x-live-cache": "hit"},
),
]
with patch.object(
notify_site_traffic,
"http_response_soft",
side_effect=responses,
):
health = notify_site_traffic.fetch_production_health(
now=now,
refresh_summaries={
"web-daily.yml": {
"ok": True,
"steps": [
{"step": "fetch_hero_stats.py", "ok": True},
{"step": "fetch_leaderboards.py", "ok": True},
{"step": "fetch_hero_matches.py", "ok": True},
{"step": "fetch_pro_matches.py", "ok": True},
{"step": "fetch_streamers.py", "ok": True},
],
"health": {
"streamer_profile_missing": 0,
"streamer_live_probe_missing": 0,
},
}
},
)
self.assertEqual(health.stale, [])
self.assertTrue(health.live_api_ok)
if __name__ == "__main__":
unittest.main()