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