diff --git a/.gitea/workflows/web-daily.yml b/.gitea/workflows/web-daily.yml index f9623f8..0aa4188 100644 --- a/.gitea/workflows/web-daily.yml +++ b/.gitea/workflows/web-daily.yml @@ -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 diff --git a/.gitea/workflows/web-patch.yml b/.gitea/workflows/web-patch.yml index 610f74f..a6f8033 100644 --- a/.gitea/workflows/web-patch.yml +++ b/.gitea/workflows/web-patch.yml @@ -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 diff --git a/.gitea/workflows/web-weekly.yml b/.gitea/workflows/web-weekly.yml index 38e056e..5b95830 100644 --- a/.gitea/workflows/web-weekly.yml +++ b/.gitea/workflows/web-weekly.yml @@ -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 diff --git a/.gitignore b/.gitignore index 301ca3a..267e454 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 1eef7f4..9661124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | 移动端门禁(`` 早载):`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`。 ## 文档分工 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbbf88..9091988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7b1be40..2237ba7 100644 --- a/README.md +++ b/README.md @@ -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` 调)。 diff --git a/shared/http_utils.py b/shared/http_utils.py index 047a85f..6ce8ce7 100644 --- a/shared/http_utils.py +++ b/shared/http_utils.py @@ -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 diff --git a/web/assets/streamer_avatars/fatiao.jpg b/web/assets/streamer_avatars/fatiao.jpg new file mode 100644 index 0000000..4f3d55f Binary files /dev/null and b/web/assets/streamer_avatars/fatiao.jpg differ diff --git a/web/assets/streamer_avatars/huonv.jpg b/web/assets/streamer_avatars/huonv.jpg new file mode 100644 index 0000000..4b45c54 Binary files /dev/null and b/web/assets/streamer_avatars/huonv.jpg differ diff --git a/web/assets/streamer_avatars/huskar.jpg b/web/assets/streamer_avatars/huskar.jpg new file mode 100644 index 0000000..5ee1c98 Binary files /dev/null and b/web/assets/streamer_avatars/huskar.jpg differ diff --git a/web/assets/streamer_avatars/kaer.jpg b/web/assets/streamer_avatars/kaer.jpg new file mode 100644 index 0000000..3e09940 Binary files /dev/null and b/web/assets/streamer_avatars/kaer.jpg differ diff --git a/web/assets/streamer_avatars/lanmao.jpg b/web/assets/streamer_avatars/lanmao.jpg new file mode 100644 index 0000000..5a63f25 Binary files /dev/null and b/web/assets/streamer_avatars/lanmao.jpg differ diff --git a/web/assets/streamer_avatars/luna.jpg b/web/assets/streamer_avatars/luna.jpg new file mode 100644 index 0000000..cd77c10 Binary files /dev/null and b/web/assets/streamer_avatars/luna.jpg differ diff --git a/web/assets/streamer_avatars/qitiandasheng.jpg b/web/assets/streamer_avatars/qitiandasheng.jpg new file mode 100644 index 0000000..2920e28 Binary files /dev/null and b/web/assets/streamer_avatars/qitiandasheng.jpg differ diff --git a/web/assets/streamer_avatars/tumao.jpg b/web/assets/streamer_avatars/tumao.jpg new file mode 100644 index 0000000..464428b Binary files /dev/null and b/web/assets/streamer_avatars/tumao.jpg differ diff --git a/web/assets/streamer_avatars/wenyi.jpg b/web/assets/streamer_avatars/wenyi.jpg new file mode 100644 index 0000000..77a95ff Binary files /dev/null and b/web/assets/streamer_avatars/wenyi.jpg differ diff --git a/web/assets/streamer_avatars/zhadan.jpg b/web/assets/streamer_avatars/zhadan.jpg new file mode 100644 index 0000000..e9b67ec Binary files /dev/null and b/web/assets/streamer_avatars/zhadan.jpg differ diff --git a/web/data/pro_player_watchlist.json b/web/data/pro_player_watchlist.json index 0a98f75..0dea83f 100644 --- a/web/data/pro_player_watchlist.json +++ b/web/data/pro_player_watchlist.json @@ -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" + } ] } diff --git a/web/data/streamers.json b/web/data/streamers.json index bb88ae3..2a37351 100644 --- a/web/data/streamers.json +++ b/web/data/streamers.json @@ -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" } ] } diff --git a/web/deploy_relations.py b/web/deploy_relations.py index e3bf6e9..a0aefc9 100644 --- a/web/deploy_relations.py +++ b/web/deploy_relations.py @@ -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") diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 1ede1ec..eb9d7f3 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -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 / ...)") diff --git a/web/fetch_hero_abilities.py b/web/fetch_hero_abilities.py index 6d05387..82aeb39 100644 --- a/web/fetch_hero_abilities.py +++ b/web/fetch_hero_abilities.py @@ -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: diff --git a/web/fetch_hero_items.py b/web/fetch_hero_items.py index fbc0433..4501910 100644 --- a/web/fetch_hero_items.py +++ b/web/fetch_hero_items.py @@ -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} diff --git a/web/fetch_hero_matches.py b/web/fetch_hero_matches.py index 5685886..68ed017 100644 --- a/web/fetch_hero_matches.py +++ b/web/fetch_hero_matches.py @@ -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: diff --git a/web/fetch_hero_stats.py b/web/fetch_hero_stats.py index 7890b34..4230d61 100644 --- a/web/fetch_hero_stats.py +++ b/web/fetch_hero_stats.py @@ -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}" diff --git a/web/fetch_item_counter_stats.py b/web/fetch_item_counter_stats.py index a44ef47..b38e8da 100644 --- a/web/fetch_item_counter_stats.py +++ b/web/fetch_item_counter_stats.py @@ -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}", diff --git a/web/fetch_item_shop.py b/web/fetch_item_shop.py index 73320ae..66a5911 100644 --- a/web/fetch_item_shop.py +++ b/web/fetch_item_shop.py @@ -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']} " diff --git a/web/fetch_items_meta.py b/web/fetch_items_meta.py index 3ffc50a..501077e 100644 --- a/web/fetch_items_meta.py +++ b/web/fetch_items_meta.py @@ -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()} diff --git a/web/fetch_leaderboards.py b/web/fetch_leaderboards.py index de1f54a..8e495a3 100644 --- a/web/fetch_leaderboards.py +++ b/web/fetch_leaderboards.py @@ -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 ] diff --git a/web/fetch_patches.py b/web/fetch_patches.py index 5023a31..e1e344d 100644 --- a/web/fetch_patches.py +++ b/web/fetch_patches.py @@ -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, diff --git a/web/fetch_pro_matches.py b/web/fetch_pro_matches.py index 2d3c6db..45278e0 100644 --- a/web/fetch_pro_matches.py +++ b/web/fetch_pro_matches.py @@ -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: diff --git a/web/fetch_stratz_meta.py b/web/fetch_stratz_meta.py index c8c1c58..939a096 100644 --- a/web/fetch_stratz_meta.py +++ b/web/fetch_stratz_meta.py @@ -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) diff --git a/web/fetch_streamer_live.py b/web/fetch_streamer_live.py index 789f5d4..d332cf5 100644 --- a/web/fetch_streamer_live.py +++ b/web/fetch_streamer_live.py @@ -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 diff --git a/web/fetch_streamers.py b/web/fetch_streamers.py index c3be51f..3a7c441 100644 --- a/web/fetch_streamers.py +++ b/web/fetch_streamers.py @@ -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: diff --git a/web/frontend/_headers b/web/frontend/_headers index d3498f1..4633a04 100644 --- a/web/frontend/_headers +++ b/web/frontend/_headers @@ -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 diff --git a/web/frontend/app.js b/web/frontend/app.js index 4f9e3c3..3e2d0f7 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -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 = `
${escapeHtml(p.version)}${escapeHtml(p.date || "")}
`; + const patchDate = + formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) || + p.date || + ""; + let html = `
${escapeHtml(p.version)}${escapeHtml(patchDate)}
`; 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 ``; + const patchDate = + formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) || + p.date || + ""; + return ``; }) .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 ``; + }).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 = '
该选手暂无近期比赛
'; + body.innerHTML = state.matchesPlayerId + ? '
该选手暂无符合筛选的近期比赛
' + : '
暂无符合筛选的近期比赛
'; 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) { diff --git a/web/frontend/config.js b/web/frontend/config.js index c69c88f..21105b8 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -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 = ""; diff --git a/web/frontend/functions/api/live-status.js b/web/frontend/functions/api/live-status.js index 3ae0ab2..e6bfad4 100644 --- a/web/frontend/functions/api/live-status.js +++ b/web/frontend/functions/api/live-status.js @@ -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) { diff --git a/web/frontend/functions/api/mobile-demand.js b/web/frontend/functions/api/mobile-demand.js new file mode 100644 index 0000000..e4d844b --- /dev/null +++ b/web/frontend/functions/api/mobile-demand.js @@ -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); + } +} diff --git a/web/frontend/index.html b/web/frontend/index.html index e2de136..e51f976 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -5,9 +5,25 @@ DOTA2 上分帝 - + + + +
@@ -72,7 +88,8 @@
-