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>
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Secrets;STRATZ 可选) |
|
||||
| `.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;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/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 对位/协同 Top(counters/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 Actions(self-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**(job 内现拉现部署)。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
|
||||
- **上分帝 Web 定时刷新**:Gitea Actions(self-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`。
|
||||
|
||||
## 文档分工
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 调)。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -1,49 +1,555 @@
|
||||
{
|
||||
"meta": {
|
||||
"note_zh": "手工维护的明星选手 OpenDota account_id;fetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。",
|
||||
"source": "opendota/proPlayers",
|
||||
"note_zh": "手工维护的明星选手 OpenDota account_id;fetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。对照 Dotabuff / Liquipedia playerid。外号:查理斯→Chalice;Somnus 即 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": "4nalog丶01",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 / ...)")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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']} "
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 0–9 → "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,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 = "";
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
// Cache write must not block the response.
|
||||
context.waitUntil(caches.default.put(CACHE_KEY, cached));
|
||||
return res;
|
||||
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;
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
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
|
||||
if args.tier == "patch":
|
||||
if run_patch_tier(dry_run=args.dry_run):
|
||||
did_work = True
|
||||
|
||||
if args.dry_run:
|
||||
print("dry-run complete (no deploy/oss)", flush=True)
|
||||
return
|
||||
|
||||
after = snapshot()
|
||||
data_changed, assets_changed = diff_snapshots(before, after)
|
||||
print(
|
||||
f"changes: data={data_changed} assets={assets_changed} "
|
||||
f"force_deploy={args.force_deploy} did_work={did_work}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
print("deploy needed but --skip-deploy set; skipping", flush=True)
|
||||
else:
|
||||
print("no data/asset changes; skip deploy", flush=True)
|
||||
|
||||
RUN_RESULTS.clear()
|
||||
PATCH_RESULT.clear()
|
||||
started = time.monotonic()
|
||||
summary = {
|
||||
"ok": True,
|
||||
"ok": False,
|
||||
"tier": args.tier,
|
||||
"data_changed": data_changed,
|
||||
"assets_changed": assets_changed,
|
||||
"deployed": deployed,
|
||||
"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,
|
||||
}
|
||||
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
|
||||
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 {}
|
||||
if args.tier in ("weekly", "all"):
|
||||
run_weekly(dry_run=args.dry_run)
|
||||
if args.tier in ("daily", "all"):
|
||||
run_daily(dry_run=args.dry_run)
|
||||
if args.tier == "patch":
|
||||
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}",
|
||||
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:
|
||||
print("assets changed but deploy is disabled; skipping OSS upload", flush=True)
|
||||
|
||||
should_deploy = args.force_deploy or data_changed or assets_changed
|
||||
if should_deploy and not args.skip_deploy:
|
||||
run_script("deploy_relations.py")
|
||||
summary["deployed"] = True
|
||||
elif should_deploy:
|
||||
print("deploy needed but --skip-deploy set; skipping", flush=True)
|
||||
else:
|
||||
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__":
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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()
|
||||