Enrich Feishu daily digest with auto-refresh status.
Include Gitea web-daily/weekly/patch conclusions and Cloudflare Pages deploys; ship the refresh workflows so those signals exist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Daily Feishu digest for dota2.refining.dev traffic.
|
# Daily Feishu digest for dota2.refining.dev traffic + auto-refresh status.
|
||||||
# Repo secrets: CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY, FEISHU_WEBHOOK_URL
|
# Repo secrets: CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY, FEISHU_WEBHOOK_URL
|
||||||
# (copy from keyzoo refining/cloudflare + refining/feishu_webhook)
|
# Built-in secrets.GITEA_TOKEN is used to list same-repo Actions runs (no custom
|
||||||
|
# GITEA_* secret — that prefix is reserved).
|
||||||
|
|
||||||
name: site-traffic-notify
|
name: site-traffic-notify
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ jobs:
|
|||||||
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
|
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
|
||||||
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
|
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
|
||||||
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
|
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_URL: https://gitea.refining.dev
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Daily Climperor web data refresh + deploy when changed.
|
||||||
|
# Repo secrets: STRATZ_API_TOKEN (optional for daily), CLOUDFLARE_EMAIL,
|
||||||
|
# CLOUDFLARE_API_KEY, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET
|
||||||
|
# Runner: same self-hosted Mac mini as site-traffic-notify (needs python3, node/npx).
|
||||||
|
# daily also runs fetch_streamers.py (Douyin soft-fail; keeps previous counts on miss).
|
||||||
|
|
||||||
|
name: web-daily
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 06:00 China = 22:00 UTC previous calendar day
|
||||||
|
- cron: "0 22 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
PYTHON=python3
|
||||||
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
|
"$PYTHON" -m pip install -q -r requirements.txt -r requirements-web.txt
|
||||||
|
|
||||||
|
- name: Refresh daily tier
|
||||||
|
env:
|
||||||
|
STRATZ_API_TOKEN: ${{ secrets.STRATZ_API_TOKEN }}
|
||||||
|
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
|
||||||
|
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
|
||||||
|
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||||
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
PYTHON=python3
|
||||||
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
|
"$PYTHON" refresh_web.py --tier daily
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Periodic patch-list check; fetch details + version-linked data only when new.
|
||||||
|
# Repo secrets: CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY,
|
||||||
|
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET
|
||||||
|
# (STRATZ not required for this tier)
|
||||||
|
|
||||||
|
name: web-patch
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Every 6 hours UTC
|
||||||
|
- cron: "0 */6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
PYTHON=python3
|
||||||
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
|
"$PYTHON" -m pip install -q -r requirements.txt -r requirements-web.txt
|
||||||
|
|
||||||
|
- name: Refresh patch tier
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
|
||||||
|
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
|
||||||
|
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||||
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
PYTHON=python3
|
||||||
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
|
"$PYTHON" refresh_web.py --tier patch
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Weekly Climperor web meta refresh (STRATZ + items) + deploy when changed.
|
||||||
|
# Repo secrets: STRATZ_API_TOKEN, CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY,
|
||||||
|
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET
|
||||||
|
|
||||||
|
name: web-weekly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Tuesday 07:00 China = Monday 23:00 UTC
|
||||||
|
- cron: "0 23 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
PYTHON=python3
|
||||||
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
|
"$PYTHON" -m pip install -q -r requirements.txt -r requirements-web.txt
|
||||||
|
|
||||||
|
- name: Refresh weekly tier
|
||||||
|
env:
|
||||||
|
STRATZ_API_TOKEN: ${{ secrets.STRATZ_API_TOKEN }}
|
||||||
|
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
|
||||||
|
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
|
||||||
|
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||||
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
PYTHON=python3
|
||||||
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
|
"$PYTHON" refresh_web.py --tier weekly
|
||||||
@@ -11,7 +11,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
- 模板来源:**仅** Steam CDN 官方头像(`templates/cdn/`)
|
- 模板来源:**仅** Steam CDN 官方头像(`templates/cdn/`)
|
||||||
- **已放弃** real 实拍模板库补充;不要再引入 `templates/real/` 或入库流水线
|
- **已放弃** real 实拍模板库补充;不要再引入 `templates/real/` 或入库流水线
|
||||||
|
|
||||||
**上分帝 Web**(`web/relations/`)是独立子项目:英雄克制/搭档、排行、物品、版本等内容站点,与局内选将识别解耦。文档与注释统称「上分帝 Web」,勿再称「关系预览」;历史路径/脚本名(如 `serve_relations.py`、`dist/relations/`)暂保留。
|
**上分帝 Web**(`web/relations/`)是独立子项目:英雄克制/搭档、排行、主播、走势、机制、物品、版本等内容站点,与局内选将识别解耦。文档与注释统称「上分帝 Web」,勿再称「关系预览」;历史路径/脚本名(如 `serve_relations.py`、`dist/relations/`)暂保留。
|
||||||
|
|
||||||
## 目录与模块
|
## 目录与模块
|
||||||
|
|
||||||
@@ -21,33 +21,40 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
| `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
||||||
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
||||||
| `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 |
|
| `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 |
|
||||||
| `http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时) |
|
| `http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) |
|
||||||
| `fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量) |
|
| `fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
||||||
|
| `requirements-web.txt` | Web 刷新额外依赖(`oss2`);Gitea Actions 与 `requirements.txt` 一并 `pip install` |
|
||||||
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
||||||
| `relations.py` | 定性克制/搭档边读写与名称解析 |
|
| `relations.py` | 定性克制/搭档边读写与名称解析 |
|
||||||
| `import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `data/relations.json` |
|
| `import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `data/relations.json` |
|
||||||
| `fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `data/stratz_matchups.json` / `data/synergies.json`(需 token;`--mode matchups\|synergies`;供 `audit_relations.py`) |
|
| `fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `data/stratz_matchups.json` / `data/synergies.json`(需 token;`--mode matchups\|synergies`;供 `audit_relations.py`) |
|
||||||
| `fetch_stratz_meta.py` | 拉取 STRATZ 各段位周胜率/上场率 + 分路统计 + 对位 Top → `data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations) |
|
| `fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`)+ 对位 Top → `data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations) |
|
||||||
| `fetch_matchups.py` | 拉取 OpenDota 对位优势 → `data/matchups.json`(供 `audit_relations.py` 交叉审计) |
|
| `fetch_matchups.py` | 拉取 OpenDota 对位优势 → `data/matchups.json`(供 `audit_relations.py` 交叉审计) |
|
||||||
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
|
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
|
||||||
| `recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
| `recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
||||||
| `draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
| `draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
||||||
| `serve_relations.py` | 上分帝 Web 本地开发服务(`web/relations/`;改 `data/*.json` 后刷新) |
|
| `serve_relations.py` | 上分帝 Web 本地开发服务(`web/relations/`;改 `data/*.json` 后刷新;`/streamer-video/` 提供主播高光 mp4,支持 HTTP Range) |
|
||||||
| `export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` / `--static-asset-base` 写 `config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist;`--with-videos` 可选本地拷贝视频,生产部署勿用) |
|
| `export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` / `--static-asset-base` 写 `config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist;`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
|
||||||
| `deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
| `deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
||||||
| `notify_site_traffic.py` | 上分帝 Web 日活摘要 → 飞书 webhook(Cloudflare GraphQL;按已知浏览器 UA + `/` 估真实访问;`--dry-run` 只打印卡片) |
|
| `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 |
|
||||||
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 在 self-hosted runner 跑 `notify_site_traffic.py`;Secrets:`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`(自 keyzoo `refining/cloudflare` + `refining/feishu_webhook`) |
|
| `notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论 + Pages 当日生产部署;`--dry-run` 只打印卡片) |
|
||||||
|
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `notify_site_traffic.py`;Secrets:`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
|
||||||
|
| `.gitea/workflows/web-daily.yml` | 每日 06:00 CST:`refresh_web.py --tier daily`(self-hosted;需 CF + OSS Secrets;STRATZ 可选) |
|
||||||
|
| `.gitea/workflows/web-weekly.yml` | 周二 07:00 CST:`refresh_web.py --tier weekly`(需 `STRATZ_API_TOKEN` + CF + OSS) |
|
||||||
|
| `.gitea/workflows/web-patch.yml` | 每 6 小时:`refresh_web.py --tier patch`(`fetch_patches.py --check`,有新版本才拉详情与连带;需 CF + OSS) |
|
||||||
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
|
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
|
||||||
| `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/` → `ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_static_assets.py` 同步图标等静态图;`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 |
|
| `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/` → `ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_static_assets.py` 同步图标等静态图;`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 |
|
||||||
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(上分帝 Web) |
|
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(上分帝 Web) |
|
||||||
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
|
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
|
||||||
| `fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend) |
|
| `fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend) |
|
||||||
| `fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `data/hero_matches.json`(`--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby;`--public-region china` 优先国服;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend) |
|
| `fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `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) |
|
||||||
| `fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend) |
|
| `fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend) |
|
||||||
|
| `fetch_streamers.py` | 从抖音主页补全 `data/streamers.json` 的昵称/签名/关注粉丝获赞/头像(手工名单 + 直播间/主页 URL;支持 `v.douyin.com` 短链;失败保留旧值;**不**探测开播/`is_live`;**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily) |
|
||||||
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `data/item_shop.json` + 图标 |
|
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `data/item_shop.json` + 图标 |
|
||||||
| `fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `data/items_meta.json`(`%token%` 用 special_values 填数) |
|
| `fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `data/items_meta.json`(`%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`) |
|
||||||
|
| `mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
||||||
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
|
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
|
||||||
| `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `data/hero_abilities.json`;`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
| `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
||||||
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4(Steam CDN;限速)→ `assets/ability_videos/`;A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`)CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
|
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4(Steam CDN;限速)→ `assets/ability_videos/`;A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`)CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
|
||||||
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
|
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
|
||||||
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
|
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
|
||||||
@@ -64,16 +71,18 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`(OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
|
| `data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`(OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
|
||||||
| `data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`;Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
|
| `data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`;Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
|
||||||
| `data/leaderboards.json` | Valve Immortal 四区 Top100(`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
|
| `data/leaderboards.json` | Valve Immortal 四区 Top100(`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
|
||||||
| `data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win + **同段位最近 1 周分路** + `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」近 N 周高胜率榜 +「排行 → 英雄 Meta」;**不**参与局内推荐) |
|
| `data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_fit`/`video_crop`/`video_aspect` 控制裁切 + 抖音 profile 补全;`platform_meta`;首版仅抖音;Web 卡片滚播视频、有 `live_url` 时点头像进直播并显示动效(**角标暂以 `live_url` 代理,非真实在播**;将来 `is_live`);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
|
||||||
|
| `data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win(`weeks`/`latest`)+ **同段位最近 1 周分路**(`winWeek`+`positionIds`)+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`#/trends` 近 N 周榜;**不**参与局内推荐) |
|
||||||
| `data/stratz_matchup_tops.json` | STRATZ 对位/协同 Top(counters/countered/synergies;由 `fetch_stratz_meta.py` 生成;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
|
| `data/stratz_matchup_tops.json` | STRATZ 对位/协同 Top(counters/countered/synergies;由 `fetch_stratz_meta.py` 生成;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
|
||||||
| `data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
| `data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
||||||
| `data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
| `data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
||||||
| `data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
| `data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
||||||
|
| `data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
||||||
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
|
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
||||||
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
||||||
| `web/relations/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` |
|
| `web/relations/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` |
|
||||||
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|trends\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|patches` / Immortal 地区或 Meta 段位 `#/rankings/meta[/bracket]` / 近 8 周走势榜 `#/trends[/bracket]` / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` 别名兼容) |
|
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
|
||||||
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
||||||
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
||||||
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
|
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
|
||||||
@@ -83,7 +92,9 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
| `assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
||||||
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
||||||
| `assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
| `assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
||||||
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
|
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标)+ 平台 logo(`platform_douyin.png`,主播页) |
|
||||||
|
| `assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
|
||||||
|
| `assets/streamer_videos/` | 主播精选高光 mp4(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
||||||
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) |
|
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) |
|
||||||
|
|
||||||
运行时产物(**勿提交**,见 `.gitignore`):
|
运行时产物(**勿提交**,见 `.gitignore`):
|
||||||
@@ -95,6 +106,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
- `failures/` — `--truth` 调试用错识裁切
|
- `failures/` — `--truth` 调试用错识裁切
|
||||||
- `data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
|
- `data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
|
||||||
- `assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
|
- `assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
|
||||||
|
- `assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步)
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
@@ -113,40 +125,48 @@ Dota 2 GSI → gsi_watch.py (:3223)
|
|||||||
|
|
||||||
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
||||||
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
|
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
|
||||||
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」兜底展示。STRATZ 周胜率/分路/`meta_board` 存 `data/stratz_hero_meta.json`,对位 Top 存 `data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `#/trends` 优先用 STRATZ;「对位」Tab 与「排行 → 英雄 Meta」只读),**禁止**合并进 relations/heroes,禁止 `recommend.py` 读取。同英雄近期比赛出装/加点单独存 `data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。
|
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `#/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `recommend.py` 读取。同英雄近期比赛出装/加点单独存 `data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `data/streamers.json`(手工名单 + 抖音 profile 补全;首版仅抖音),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
|
||||||
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。
|
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
|
||||||
|
- **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
||||||
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
||||||
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
||||||
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
|
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
|
||||||
- **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。
|
- **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。
|
||||||
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
|
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
|
||||||
|
- **上分帝 Web 定时刷新**:Gitea Actions(self-hosted)跑 `refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**(job 内现拉现部署)。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-web.txt # 可选:本机 refresh_web / OSS(CI 会装)
|
||||||
python fetch_cdn_templates.py
|
python fetch_cdn_templates.py
|
||||||
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
|
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
|
||||||
python fetch_hero_portraits.py # 官网横版头像(上分帝 Web)
|
python fetch_hero_portraits.py # 官网横版头像(上分帝 Web)
|
||||||
python fetch_hero_items.py # OpenDota 热门装备缓存(上分帝 Web)
|
python fetch_hero_items.py # OpenDota 热门装备缓存(上分帝 Web)
|
||||||
python fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
|
python fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
|
||||||
python fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token)
|
python fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token)
|
||||||
python fetch_hero_matches.py # 同英雄近期比赛出装/加点/购买时间(上分帝 Web;可 --heroes antimage;--enrich-item-times)
|
python fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage;--workers 6;--enrich-item-times)
|
||||||
python fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
|
python fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
|
||||||
|
python fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
|
||||||
python fetch_item_shop.py # 商店分类目录(物品页)
|
python fetch_item_shop.py # 商店分类目录(物品页)
|
||||||
python fetch_items_meta.py # 装备描述 + 机制标签
|
python fetch_items_meta.py # 装备描述 + 机制标签
|
||||||
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总
|
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总
|
||||||
|
python fetch_hero_abilities.py --tags-only # 只重算技能「施加」类 tags
|
||||||
python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/
|
python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/
|
||||||
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
|
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
|
||||||
python item_fears.py # 英雄怕的装备
|
python item_fears.py # 英雄怕的装备
|
||||||
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
||||||
python serve_relations.py # 上分帝 Web 本地开发服务(英雄 / 排行 / 物品 / 版本)
|
python fetch_patches.py --check # 只比对列表与本地 details,stdout JSON(has_new)
|
||||||
|
python refresh_web.py --tier patch --skip-deploy --skip-oss # 本机试跑某档(daily/weekly/patch/all)
|
||||||
|
python serve_relations.py # 上分帝 Web 本地开发服务(英雄 / 排行 / 主播 / 走势 / 机制 / 物品 / 版本)
|
||||||
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages)
|
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages)
|
||||||
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com --static-asset-base https://climperor.oss-cn-shanghai.aliyuncs.com
|
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com --static-asset-base https://climperor.oss-cn-shanghai.aliyuncs.com
|
||||||
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS)
|
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS)
|
||||||
python notify_site_traffic.py --dry-run # 上分帝 Web 日活摘要(飞书;需 CF + webhook env / keyzoo)
|
python notify_site_traffic.py --dry-run # 上分帝 Web 日活摘要(飞书;需 CF + webhook env / keyzoo)
|
||||||
python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSS(keyzoo voson-RAM)
|
python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSS(keyzoo voson-RAM)
|
||||||
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
||||||
|
# Gitea Actions(self-hosted):web-daily / web-weekly / web-patch;Secrets 见 README「定时刷新」与各 workflow 头注释
|
||||||
python gsi_setup.py --check
|
python gsi_setup.py --check
|
||||||
python gsi_watch.py --once
|
python gsi_watch.py --once
|
||||||
python recognize.py samples/raw/<帧>.png --sheet
|
python recognize.py samples/raw/<帧>.png --sheet
|
||||||
|
|||||||
+74
-8
@@ -7,12 +7,13 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 上分帝 Web 日活飞书推送:`notify_site_traffic.py` + Gitea Actions
|
- 上分帝 Web 日活飞书推送:`notify_site_traffic.py` + Gitea Actions
|
||||||
`site-traffic-notify`(每日 09:00 CST;真实访问按已知浏览器 UA 打开 `/` 估算)。
|
`site-traffic-notify`(每日 09:00 CST;真实访问按已知浏览器 UA 打开 `/` 估算;
|
||||||
|
卡片含自动刷新:`web-daily`/`weekly`/`patch` 结论 + 当日 Pages 生产部署)。
|
||||||
- 上分帝 Web 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜(初期 / 迷你走势 / 末期 / 变化;STRATZ;无禁用率;`0.5.41`)。
|
- 上分帝 Web 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜(初期 / 迷你走势 / 末期 / 变化;STRATZ;无禁用率;`0.5.41`)。
|
||||||
- 上分帝 Web 接入 STRATZ(`fetch_stratz_meta.py`,token 经 `.env` / keyzoo):
|
- 上分帝 Web 接入 STRATZ(`fetch_stratz_meta.py`,token 经 `.env` / keyzoo):
|
||||||
- 「走势」优先用 STRATZ 近几周段位胜率/上场率/场次,并展示周折线柱与分路胜率(OpenDota 作兜底)
|
- 英雄详情「走势」优先 STRATZ:勋章条 + **最近 1 周**三卡 + 分路 + **近 8 周**逐周列表(OpenDota 作兜底)
|
||||||
- 新子 Tab「对位」`#/heroes/<key>/matchups`:天梯对位优势 / 被克 / 协同 Top(与网格机制「克/怕/搭」分开展示)
|
- 新子 Tab「对位」`#/heroes/<key>/matchups`:克制 / 被克 / 搭档三列 STRATZ Top(与网格机制「克/怕/搭」分开展示)
|
||||||
- 「排行」新增「英雄 Meta」模式 `#/rankings/meta[/bracket]`(按段位上场排序)
|
- 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜
|
||||||
- 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(gitignore;仅 Web,不进 recommend / relations)
|
- 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(gitignore;仅 Web,不进 recommend / relations)
|
||||||
- 上分帝 Web「近期比赛」Tab(`#/heroes/<key>/matches`):同英雄近期局终局出装 +
|
- 上分帝 Web「近期比赛」Tab(`#/heroes/<key>/matches`):同英雄近期局终局出装 +
|
||||||
加点顺序;展示选手昵称/段位勋章与 OpenDota 选手·比赛链接;
|
加点顺序;展示选手昵称/段位勋章与 OpenDota 选手·比赛链接;
|
||||||
@@ -25,19 +26,84 @@
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 上分帝 Web「走势」:三卡 / 分路胜率统一为 **当前选中段位 · 最近 1 周**(STRATZ `winWeek`);分路不再用合并档 `stats` 接口;段位栏下显示时间窗提示(`0.5.30`)。
|
- 上分帝 Web 英雄详情「走势」(`0.5.30`–`0.5.37`):
|
||||||
- 上分帝 Web「走势」:近 8 周改为 **逐周列表**(日期 / 胜率 / 横向条 / 场次),Tab 内容区可滚动(`0.5.31`)。
|
- 三卡 / 分路统一 **当前选中勋章 · 最近 1 周**(STRATZ `winWeek`;分路改 `positionIds`,不再用合并档 `stats`)
|
||||||
- 上分帝 Web「走势」近 8 周:左对齐条按本英雄 8 周区间缩放、周环比、去掉 50% 中线(`0.5.37`)。
|
- 布局:勋章条 →「最近 1 周」提示 → 三卡 → 分路 → 近 8 周列表;Tab 内容区可滚动
|
||||||
|
- 近 8 周:逐周列表(新→旧),含胜率、周环比、按本英雄 8 周区间缩放的横条、场次;去掉 50% 中线与底部刻度文案
|
||||||
- 文档与注释统一称呼:`web/relations/` 子项目改称 **上分帝 Web**(不再称「关系预览」);路径与脚本名(如 `serve_relations.py`)暂保持不变。
|
- 文档与注释统一称呼:`web/relations/` 子项目改称 **上分帝 Web**(不再称「关系预览」);路径与脚本名(如 `serve_relations.py`)暂保持不变。
|
||||||
- 「近期比赛」公开对局默认优先国服(Perfect World cluster;`--public-region china`),联赛+天梯可用 `--source both`;卡片来源显示「国服 / 天梯 / 联赛」。
|
- 「近期比赛」公开对局默认优先国服(Perfect World cluster;`--public-region china`),联赛+天梯可用 `--source both`;卡片来源显示「国服 / 天梯 / 联赛」。
|
||||||
- 「近期比赛」改为展示最近 10 场胜局(传奇及以上天梯 + 联赛/职业,按时间从新到旧);公开列表过滤机器人/Turbo,仅保留 ranked lobby(`0.5.40`)。
|
- 「近期比赛」改为展示最近 10 场胜局(传奇及以上天梯 + 联赛/职业,按时间从新到旧);公开列表过滤机器人/Turbo,仅保留 ranked lobby(`0.5.40`)。
|
||||||
|
- 「近期比赛」外链改 Dotabuff(选手/详情);`fetch_hero_matches.py` 支持 `--workers` 详情并发与不足 N 场增量补拉(`0.5.49`)。
|
||||||
- 选将推荐不再限 Top-3 名次青标;非定位局也可推克/搭/补(无分路字则不过滤)。
|
- 选将推荐不再限 Top-3 名次青标;非定位局也可推克/搭/补(无分路字则不过滤)。
|
||||||
|
|
||||||
|
## [0.5.57] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 上分帝 Web「主播」竖屏精选视频黑边:不再依赖 canvas 扫边;竖屏统一用内容框 +
|
||||||
|
`object-fit: cover`,按上下黑边比例设 `object-position`;亚瑟归来 / 大地瓜写入
|
||||||
|
`video_aspect`(横屏仍 `contain`)。
|
||||||
|
|
||||||
|
## [0.5.56] - 2026-07-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web「主播」精选视频:滚动自动播放默认静音;不再因页面任意点击/按键全局开声,需在各视频原生控件中主动取消静音。
|
||||||
|
|
||||||
|
## [0.5.55] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 上分帝 Web「主播」竖屏精选视频黑边:修正 `object-position` 方向;`preload=auto` +
|
||||||
|
首帧检测失败时对竖屏启用 16:9 启发式裁切(canvas / OSS CORS 不可用时仍生效)。
|
||||||
|
|
||||||
|
## [0.5.54] - 2026-07-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web 英雄详情「主播」子 Tab:卡片宽度与顶层 `#/streamers` 一致(`880px` 居中),竖屏/横屏精选视频比例不变。
|
||||||
|
|
||||||
|
## [0.5.53] - 2026-07-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web「主播」精选视频:竖屏且文件内嵌上下黑边时,首帧自动检测内容区并裁切显示;
|
||||||
|
横屏视频仍为 `contain`。可选 `video_fit` / `video_crop` / `video_aspect` 手工覆盖。
|
||||||
|
|
||||||
|
## [0.5.52] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 上分帝 Web「主播」页卡片精选高光视频:手工 `video`/`video_title` +
|
||||||
|
`assets/streamer_videos/`(本地 `/streamer-video/`,OSS `streamer-video/`);
|
||||||
|
滚入视口中部自动播放(原生 controls;多卡时播最靠近中心者);10 人收录。
|
||||||
|
- 直播态头像:抖音式粉红外圈脉冲 + 头像呼吸动画;有 `live_url`(或将来
|
||||||
|
`is_live`)时点击头像进直播间;角标「直播」。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web「主播」卡片改版:抖音式资料区(昵称行 + 全宽元信息/签名)、
|
||||||
|
约 880px 宽、元信息竖线分隔、放大英雄标签;右侧仅「关注」;不显示视频标题。
|
||||||
|
|
||||||
|
## [0.5.51] - 2026-07-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web 英雄详情:定位标签从标题下方移到「技能」Tab 中间列顶部(技能图标上方)。
|
||||||
|
|
||||||
|
## [0.5.50] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 上分帝 Web 顶层「主播」`#/streamers` + 英雄详情「主播」子 Tab:手工维护直播间/主页与常用英雄,
|
||||||
|
`fetch_streamers.py` 从抖音主页补全昵称/签名/关注粉丝获赞/头像;首版仅抖音;
|
||||||
|
`data/streamers.json`;仅 Web,不进 recommend;进 `refresh_web` daily 软失败刷新。
|
||||||
|
|
||||||
## [0.5.29] - 2026-07-28
|
## [0.5.29] - 2026-07-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 上分帝 Web:STRATZ 走势 / 对位 Tab / 英雄 Meta 榜(见 Unreleased 明细;站点版本 `0.5.29`)。
|
- 上分帝 Web:STRATZ 走势 / 对位 Tab(见 Unreleased 明细;站点版本 `0.5.29`)。
|
||||||
|
|
||||||
## [0.5.28] - 2026-07-28
|
## [0.5.28] - 2026-07-28
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_feishu_wh.url"
|
STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_feishu_wh.url"
|
||||||
|
GITEA_STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_gitea.tok"
|
||||||
|
|
||||||
|
|
||||||
def stash() -> None:
|
def stash() -> None:
|
||||||
@@ -40,6 +41,14 @@ def run(argv: list[str]) -> int:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
os.environ["FEISHU_WEBHOOK_URL"] = url
|
os.environ["FEISHU_WEBHOOK_URL"] = url
|
||||||
|
if GITEA_STASH.exists():
|
||||||
|
tok = GITEA_STASH.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
GITEA_STASH.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if tok:
|
||||||
|
os.environ["GITEA_TOKEN"] = tok
|
||||||
# Import after env is set
|
# Import after env is set
|
||||||
import notify_site_traffic as n
|
import notify_site_traffic as n
|
||||||
|
|
||||||
|
|||||||
+386
-39
@@ -1,18 +1,20 @@
|
|||||||
"""Daily site traffic digest -> Feishu webhook.
|
"""Daily site digest -> Feishu webhook.
|
||||||
|
|
||||||
Pulls Cloudflare edge analytics for dota2.refining.dev, estimates human
|
1) Cloudflare edge analytics for dota2.refining.dev (human homepage estimate)
|
||||||
homepage visits (known browser UA on `/`, plus RUM beacons), and posts a
|
2) Auto-refresh status: Gitea Actions (web-daily / web-weekly / web-patch)
|
||||||
Feishu custom-bot card.
|
+ Cloudflare Pages production deploys that day
|
||||||
|
|
||||||
Credentials (env, prefer keyzoo inject names):
|
Credentials (env, prefer keyzoo inject names):
|
||||||
CLOUDFLARE_EMAIL / KEYZOO_ASSET_META_USERNAME
|
CLOUDFLARE_EMAIL / KEYZOO_ASSET_META_USERNAME
|
||||||
CLOUDFLARE_API_KEY / KEYZOO_ASSET_SECRET_GLOBAL_API_KEY
|
CLOUDFLARE_API_KEY / KEYZOO_ASSET_SECRET_GLOBAL_API_KEY
|
||||||
FEISHU_WEBHOOK_URL / KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL
|
FEISHU_WEBHOOK_URL / KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL
|
||||||
|
GITEA_TOKEN / KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1 (optional;
|
||||||
|
without it the refresh section shows \"未配置 token\")
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python notify_site_traffic.py # yesterday (UTC date)
|
python notify_site_traffic.py # yesterday CST
|
||||||
python notify_site_traffic.py --day 2026-07-27
|
python notify_site_traffic.py --day 2026-07-27
|
||||||
python notify_site_traffic.py --dry-run # print card, do not POST
|
python notify_site_traffic.py --dry-run
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,16 +22,30 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
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 typing import Any
|
from typing import Any
|
||||||
|
|
||||||
HOST = "dota2.refining.dev"
|
HOST = "dota2.refining.dev"
|
||||||
ZONE_NAME = "refining.dev"
|
ZONE_NAME = "refining.dev"
|
||||||
SITE_URL = "https://dota2.refining.dev"
|
SITE_URL = "https://dota2.refining.dev"
|
||||||
|
PAGES_PROJECT = "climperor-relations"
|
||||||
|
GITEA_URL_DEFAULT = "https://gitea.refining.dev"
|
||||||
|
GITEA_OWNER = "refining"
|
||||||
|
GITEA_REPO = "climperor"
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
# (workflow file basename, short label)
|
||||||
|
REFRESH_WORKFLOWS: tuple[tuple[str, str], ...] = (
|
||||||
|
("web-daily.yml", "每日数据"),
|
||||||
|
("web-weekly.yml", "每周 Meta"),
|
||||||
|
("web-patch.yml", "版本检测"),
|
||||||
|
)
|
||||||
|
|
||||||
HUMAN_BROWSERS = frozenset(
|
HUMAN_BROWSERS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -81,17 +97,64 @@ def feishu_url() -> str:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
def http_json(url: str, *, method: str = "GET", body: dict | None = None, headers: dict | None = None) -> dict:
|
def gitea_token() -> str | None:
|
||||||
|
return _env(
|
||||||
|
"GITEA_TOKEN",
|
||||||
|
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1",
|
||||||
|
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1",
|
||||||
|
"KEYZOO_ASSET_TOKEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_base() -> str:
|
||||||
|
return (
|
||||||
|
_env("GITEA_URL", "KEYZOO_ASSET_META_URL") or GITEA_URL_DEFAULT
|
||||||
|
).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
body: dict | None = None,
|
||||||
|
headers: dict | None = None,
|
||||||
|
) -> dict | list:
|
||||||
data = None if body is None else json.dumps(body).encode()
|
data = None if body is None else json.dumps(body).encode()
|
||||||
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as r:
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
return json.loads(r.read().decode())
|
raw = r.read().decode()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
return json.loads(raw)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
raw = e.read().decode(errors="replace")
|
raw = e.read().decode(errors="replace")
|
||||||
raise SystemExit(f"HTTP {e.code} {url}: {raw[:800]}") from e
|
raise SystemExit(f"HTTP {e.code} {url}: {raw[:800]}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def http_json_soft(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
body: dict | None = None,
|
||||||
|
headers: dict | None = None,
|
||||||
|
) -> tuple[int, Any]:
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read().decode()
|
||||||
|
if not raw:
|
||||||
|
return r.status, {}
|
||||||
|
return r.status, json.loads(raw)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode(errors="replace")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return e.code, raw
|
||||||
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -99,11 +162,24 @@ def gql(query: str, variables: dict) -> dict:
|
|||||||
body={"query": query, "variables": variables},
|
body={"query": query, "variables": variables},
|
||||||
headers=cf_headers(),
|
headers=cf_headers(),
|
||||||
)
|
)
|
||||||
if data.get("errors"):
|
if isinstance(data, dict) and data.get("errors"):
|
||||||
raise SystemExit("GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500])
|
raise SystemExit(
|
||||||
|
"GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500]
|
||||||
|
)
|
||||||
|
assert isinstance(data, dict)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def read_site_version() -> str:
|
||||||
|
path = Path(__file__).resolve().parent / "web" / "relations" / "config.js"
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return "?"
|
||||||
|
m = re.search(r'SITE_VERSION\s*=\s*"([^"]+)"', text)
|
||||||
|
return m.group(1) if m else "?"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DayStats:
|
class DayStats:
|
||||||
day: date
|
day: date
|
||||||
@@ -115,15 +191,57 @@ class DayStats:
|
|||||||
rum_human: int = 0
|
rum_human: int = 0
|
||||||
rum_other: int = 0
|
rum_other: int = 0
|
||||||
bytes: int = 0
|
bytes: int = 0
|
||||||
top_countries: list[tuple[str, int, int]] = field(default_factory=list) # name, req, vis
|
top_countries: list[tuple[str, int, int]] = field(default_factory=list)
|
||||||
status: list[tuple[Any, int]] = field(default_factory=list)
|
status: list[tuple[Any, int]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkflowDayStatus:
|
||||||
|
file: str
|
||||||
|
label: str
|
||||||
|
runs: list[dict] = field(default_factory=list) # status/conclusion/started_at
|
||||||
|
note: str = "" # e.g. missing token / none
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok_count(self) -> int:
|
||||||
|
return sum(1 for r in self.runs if r.get("conclusion") == "success")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fail_count(self) -> int:
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for r in self.runs
|
||||||
|
if r.get("conclusion") not in (None, "success")
|
||||||
|
and r.get("status") == "completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running_count(self) -> int:
|
||||||
|
return sum(1 for r in self.runs if r.get("status") in ("queued", "in_progress", "waiting"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagesDeploy:
|
||||||
|
created_on: datetime
|
||||||
|
status: str
|
||||||
|
commit: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DigestExtras:
|
||||||
|
site_version: str
|
||||||
|
workflows: list[WorkflowDayStatus] = field(default_factory=list)
|
||||||
|
deploys: list[PagesDeploy] = field(default_factory=list)
|
||||||
|
gitea_note: str = ""
|
||||||
|
|
||||||
|
|
||||||
def zone_id() -> str:
|
def zone_id() -> str:
|
||||||
d = http_json(
|
d = http_json(
|
||||||
f"https://api.cloudflare.com/client/v4/zones?name={ZONE_NAME}",
|
f"https://api.cloudflare.com/client/v4/zones?name={ZONE_NAME}",
|
||||||
headers=cf_headers(),
|
headers=cf_headers(),
|
||||||
)
|
)
|
||||||
|
assert isinstance(d, dict)
|
||||||
rows = d.get("result") or []
|
rows = d.get("result") or []
|
||||||
if not rows:
|
if not rows:
|
||||||
raise SystemExit(f"zone not found: {ZONE_NAME}")
|
raise SystemExit(f"zone not found: {ZONE_NAME}")
|
||||||
@@ -230,6 +348,142 @@ def fetch_day(zid: str, day: date) -> DayStats:
|
|||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def day_window_utc(day: date) -> tuple[datetime, datetime]:
|
||||||
|
"""Inclusive start / exclusive end of CST calendar day, as aware UTC datetimes."""
|
||||||
|
start_cst = datetime(day.year, day.month, day.day, tzinfo=CST)
|
||||||
|
end_cst = start_cst + timedelta(days=1)
|
||||||
|
return start_cst.astimezone(timezone.utc), end_cst.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gitea_time(s: str | None) -> datetime | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Gitea may return +08:00 or Z
|
||||||
|
if s.endswith("Z"):
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_workflow_statuses(day: date) -> tuple[list[WorkflowDayStatus], str]:
|
||||||
|
tok = gitea_token()
|
||||||
|
if not tok:
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
WorkflowDayStatus(file=f, label=lab, note="未配置 GITEA_TOKEN")
|
||||||
|
for f, lab in REFRESH_WORKFLOWS
|
||||||
|
],
|
||||||
|
"未配置 GITEA_TOKEN,跳过 Actions 查询",
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {tok}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
start_utc, end_utc = day_window_utc(day)
|
||||||
|
code, payload = http_json_soft(
|
||||||
|
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/runs?limit=50",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if code != 200:
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
WorkflowDayStatus(file=f, label=lab, note=f"API {code}")
|
||||||
|
for f, lab in REFRESH_WORKFLOWS
|
||||||
|
],
|
||||||
|
f"Gitea runs API HTTP {code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
items = payload.get("workflow_runs") or payload.get("runs") or []
|
||||||
|
elif isinstance(payload, list):
|
||||||
|
items = payload
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
|
||||||
|
by_file: dict[str, list[dict]] = {f: [] for f, _ in REFRESH_WORKFLOWS}
|
||||||
|
for x in items:
|
||||||
|
path = str(x.get("path") or "")
|
||||||
|
# path like "web-daily.yml@refs/heads/main"
|
||||||
|
base = path.split("@", 1)[0]
|
||||||
|
if base not in by_file:
|
||||||
|
continue
|
||||||
|
ts = parse_gitea_time(x.get("started_at") or x.get("completed_at"))
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
ts_utc = ts.astimezone(timezone.utc)
|
||||||
|
if not (start_utc <= ts_utc < end_utc):
|
||||||
|
continue
|
||||||
|
by_file[base].append(
|
||||||
|
{
|
||||||
|
"id": x.get("id"),
|
||||||
|
"status": x.get("status"),
|
||||||
|
"conclusion": x.get("conclusion"),
|
||||||
|
"event": x.get("event"),
|
||||||
|
"started_at": x.get("started_at"),
|
||||||
|
"completed_at": x.get("completed_at"),
|
||||||
|
"html_url": x.get("html_url"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
out: list[WorkflowDayStatus] = []
|
||||||
|
for f, lab in REFRESH_WORKFLOWS:
|
||||||
|
runs = by_file.get(f) or []
|
||||||
|
note = "" if runs else "当日无运行"
|
||||||
|
out.append(WorkflowDayStatus(file=f, label=lab, runs=runs, note=note))
|
||||||
|
return out, ""
|
||||||
|
|
||||||
|
|
||||||
|
def cf_account_id() -> str:
|
||||||
|
d = http_json(
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts",
|
||||||
|
headers=cf_headers(),
|
||||||
|
)
|
||||||
|
assert isinstance(d, dict)
|
||||||
|
rows = d.get("result") or []
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit("no Cloudflare accounts")
|
||||||
|
return rows[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pages_deploys(day: date) -> list[PagesDeploy]:
|
||||||
|
aid = cf_account_id()
|
||||||
|
d = http_json(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{aid}/pages/projects/"
|
||||||
|
f"{PAGES_PROJECT}/deployments",
|
||||||
|
headers=cf_headers(),
|
||||||
|
)
|
||||||
|
assert isinstance(d, dict)
|
||||||
|
start_utc, end_utc = day_window_utc(day)
|
||||||
|
out: list[PagesDeploy] = []
|
||||||
|
for x in d.get("result") or []:
|
||||||
|
created = parse_gitea_time(x.get("created_on"))
|
||||||
|
if created is None:
|
||||||
|
continue
|
||||||
|
created_utc = created.astimezone(timezone.utc)
|
||||||
|
if not (start_utc <= created_utc < end_utc):
|
||||||
|
continue
|
||||||
|
if (x.get("environment") or "") != "production":
|
||||||
|
continue
|
||||||
|
stage = x.get("latest_stage") or {}
|
||||||
|
trig = x.get("deployment_trigger") or {}
|
||||||
|
meta = trig.get("metadata") or {}
|
||||||
|
commit = str(meta.get("commit_hash") or "")[:10]
|
||||||
|
out.append(
|
||||||
|
PagesDeploy(
|
||||||
|
created_on=created.astimezone(CST),
|
||||||
|
status=str(stage.get("status") or "?"),
|
||||||
|
commit=commit,
|
||||||
|
url=str(x.get("url") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out.sort(key=lambda p: p.created_on, reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def fmt_bytes(n: int) -> str:
|
def fmt_bytes(n: int) -> str:
|
||||||
x = float(n)
|
x = float(n)
|
||||||
for unit in ("B", "KB", "MB", "GB"):
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
@@ -239,39 +493,110 @@ def fmt_bytes(n: int) -> str:
|
|||||||
return f"{x:.1f} TB"
|
return f"{x:.1f} TB"
|
||||||
|
|
||||||
|
|
||||||
def build_card(st: DayStats) -> dict:
|
def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
|
||||||
|
if w.note and not w.runs:
|
||||||
|
return f"- **{w.label}**(`{w.file}`):{w.note}"
|
||||||
|
parts: list[str] = []
|
||||||
|
if w.ok_count:
|
||||||
|
parts.append(f"成功 {w.ok_count}")
|
||||||
|
if w.fail_count:
|
||||||
|
parts.append(f"失败 {w.fail_count}")
|
||||||
|
if w.running_count:
|
||||||
|
parts.append(f"进行中 {w.running_count}")
|
||||||
|
if not parts:
|
||||||
|
parts.append(w.note or "无结论")
|
||||||
|
# Show latest conclusion time if any
|
||||||
|
latest = ""
|
||||||
|
if w.runs:
|
||||||
|
r0 = w.runs[0]
|
||||||
|
t = r0.get("completed_at") or r0.get("started_at") or ""
|
||||||
|
if t:
|
||||||
|
dt = parse_gitea_time(t)
|
||||||
|
if dt:
|
||||||
|
latest = " · 最近 " + dt.astimezone(CST).strftime("%H:%M")
|
||||||
|
return f"- **{w.label}**:{' / '.join(parts)}{latest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_card(st: DayStats, extras: DigestExtras) -> dict:
|
||||||
"""Feishu interactive card (msg_type=interactive)."""
|
"""Feishu interactive card (msg_type=interactive)."""
|
||||||
countries = "、".join(
|
countries = "、".join(
|
||||||
f"{name} {vis}访/{req}次" for name, req, vis in st.top_countries[:5]
|
f"{name} {vis}访/{req}次" for name, req, vis in st.top_countries[:5]
|
||||||
) or "—"
|
) or "—"
|
||||||
status_line = "、".join(f"{code}×{n}" for code, n in st.status[:6]) or "—"
|
status_line = "、".join(f"{code}×{n}" for code, n in st.status[:6]) or "—"
|
||||||
now = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M CST")
|
now = datetime.now(CST).strftime("%Y-%m-%d %H:%M CST")
|
||||||
|
|
||||||
# Free plan: no BotScore. Human ≈ known-browser homepage visits.
|
|
||||||
estimate = st.human_home_vis
|
estimate = st.human_home_vis
|
||||||
rum = st.rum_human
|
rum = st.rum_human
|
||||||
|
|
||||||
|
traffic_md = (
|
||||||
|
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
|
||||||
|
f"(已知浏览器 UA 打开 `/`;SPA Hash 路由不计路径;不含爬虫)\n\n"
|
||||||
|
f"**RUM 页面浏览**:{rum}"
|
||||||
|
+ (f" + {st.rum_other} 其他" if st.rum_other else "")
|
||||||
|
+ "\n"
|
||||||
|
f"**边缘总量**:{st.all_req} 请求 / {st.all_vis} visits / {fmt_bytes(st.bytes)}\n"
|
||||||
|
f"**非人类首页命中**:{st.other_home_vis} visits"
|
||||||
|
)
|
||||||
|
|
||||||
|
wf_lines = [_fmt_workflow_line(w) for w in extras.workflows]
|
||||||
|
if extras.gitea_note:
|
||||||
|
wf_lines.append(f"- _{extras.gitea_note}_")
|
||||||
|
|
||||||
|
ok_deploys = [p for p in extras.deploys if p.status == "success"]
|
||||||
|
fail_deploys = [p for p in extras.deploys if p.status != "success"]
|
||||||
|
if extras.deploys:
|
||||||
|
deploy_md = (
|
||||||
|
f"**Pages 生产部署**:成功 **{len(ok_deploys)}**"
|
||||||
|
+ (f" / 异常 {len(fail_deploys)}" if fail_deploys else "")
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
for p in extras.deploys[:5]:
|
||||||
|
mark = "[ok]" if p.status == "success" else "[!]"
|
||||||
|
deploy_md += (
|
||||||
|
f"- {mark} {p.created_on.strftime('%H:%M')} "
|
||||||
|
f"`{p.commit or '?'}` {p.status}\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deploy_md = "**Pages 生产部署**:当日无新部署(数据未变则 refresh 会跳过 deploy)\n"
|
||||||
|
|
||||||
|
refresh_md = (
|
||||||
|
f"**站点版本**:`v{extras.site_version}`\n\n"
|
||||||
|
f"**自动刷新(Gitea Actions)**\n"
|
||||||
|
+ "\n".join(wf_lines)
|
||||||
|
+ "\n\n"
|
||||||
|
+ deploy_md.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
any_refresh_ok = any(w.ok_count > 0 for w in extras.workflows) or bool(ok_deploys)
|
||||||
|
any_refresh_fail = any(w.fail_count > 0 for w in extras.workflows) or bool(
|
||||||
|
fail_deploys
|
||||||
|
)
|
||||||
|
|
||||||
|
if any_refresh_fail:
|
||||||
|
header_color = "red"
|
||||||
|
elif estimate > 0 or any_refresh_ok:
|
||||||
|
header_color = "blue"
|
||||||
|
else:
|
||||||
|
header_color = "grey"
|
||||||
|
|
||||||
elements: list[dict] = [
|
elements: list[dict] = [
|
||||||
{
|
{
|
||||||
"tag": "div",
|
"tag": "div",
|
||||||
"text": {
|
"text": {"tag": "lark_md", "content": "**访问**\n" + traffic_md},
|
||||||
"tag": "lark_md",
|
},
|
||||||
"content": (
|
{
|
||||||
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
|
"tag": "div",
|
||||||
f"(已知浏览器 UA 打开 `/`;不含 Unknown/Headless/爬虫)\n\n"
|
"text": {"tag": "lark_md", "content": "**自动更新**\n" + refresh_md},
|
||||||
f"**RUM 页面浏览**:{rum}(人类浏览器执行 JS)"
|
|
||||||
+ (f" + {st.rum_other} 其他" if st.rum_other else "")
|
|
||||||
+ "\n"
|
|
||||||
f"**边缘总量**:{st.all_req} 请求 / {st.all_vis} visits / {fmt_bytes(st.bytes)}\n"
|
|
||||||
f"**非人类首页命中**:{st.other_home_vis} visits"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tag": "div",
|
"tag": "div",
|
||||||
"text": {
|
"text": {
|
||||||
"tag": "lark_md",
|
"tag": "lark_md",
|
||||||
"content": f"**首页地区 Top**:{countries}\n**状态码**:{status_line}",
|
"content": (
|
||||||
|
f"**首页地区 Top**:{countries}\n"
|
||||||
|
f"**状态码**:{status_line}\n"
|
||||||
|
f"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -280,7 +605,8 @@ def build_card(st: DayStats) -> dict:
|
|||||||
{
|
{
|
||||||
"tag": "plain_text",
|
"tag": "plain_text",
|
||||||
"content": (
|
"content": (
|
||||||
f"{HOST} · Free 档无 BotScore,按 UA+路径估算 · {now}"
|
f"{HOST} · Free 档无 BotScore · "
|
||||||
|
f"refresh=web-daily/weekly/patch · {now}"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -293,7 +619,13 @@ def build_card(st: DayStats) -> dict:
|
|||||||
"text": {"tag": "plain_text", "content": "打开站点"},
|
"text": {"tag": "plain_text", "content": "打开站点"},
|
||||||
"type": "primary",
|
"type": "primary",
|
||||||
"url": SITE_URL,
|
"url": SITE_URL,
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": "Gitea Actions"},
|
||||||
|
"type": "default",
|
||||||
|
"url": f"{gitea_base()}/{GITEA_OWNER}/{GITEA_REPO}/actions",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -304,9 +636,9 @@ def build_card(st: DayStats) -> dict:
|
|||||||
"header": {
|
"header": {
|
||||||
"title": {
|
"title": {
|
||||||
"tag": "plain_text",
|
"tag": "plain_text",
|
||||||
"content": f"上分帝 Web 日活 · {st.day.isoformat()}",
|
"content": f"上分帝 Web 日报 · {st.day.isoformat()}",
|
||||||
},
|
},
|
||||||
"template": "blue" if estimate > 0 else "grey",
|
"template": header_color,
|
||||||
},
|
},
|
||||||
"elements": elements,
|
"elements": elements,
|
||||||
},
|
},
|
||||||
@@ -315,19 +647,19 @@ def build_card(st: DayStats) -> dict:
|
|||||||
|
|
||||||
def post_feishu(payload: dict) -> dict:
|
def post_feishu(payload: dict) -> dict:
|
||||||
url = feishu_url()
|
url = feishu_url()
|
||||||
return http_json(
|
resp = http_json(
|
||||||
url,
|
url,
|
||||||
method="POST",
|
method="POST",
|
||||||
body=payload,
|
body=payload,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
assert isinstance(resp, dict)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def parse_day(s: str | None) -> date:
|
def parse_day(s: str | None) -> date:
|
||||||
if not s:
|
if not s:
|
||||||
# Yesterday in China time (site audience).
|
return (datetime.now(CST) - timedelta(days=1)).date()
|
||||||
cst = timezone(timedelta(hours=8))
|
|
||||||
return (datetime.now(cst) - timedelta(days=1)).date()
|
|
||||||
return date.fromisoformat(s)
|
return date.fromisoformat(s)
|
||||||
|
|
||||||
|
|
||||||
@@ -344,11 +676,23 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
day = parse_day(args.day)
|
day = parse_day(args.day)
|
||||||
zid = zone_id()
|
zid = zone_id()
|
||||||
st = fetch_day(zid, day)
|
st = fetch_day(zid, day)
|
||||||
payload = build_card(st)
|
workflows, gitea_note = fetch_workflow_statuses(day)
|
||||||
|
deploys = fetch_pages_deploys(day)
|
||||||
|
extras = DigestExtras(
|
||||||
|
site_version=read_site_version(),
|
||||||
|
workflows=workflows,
|
||||||
|
deploys=deploys,
|
||||||
|
gitea_note=gitea_note,
|
||||||
|
)
|
||||||
|
payload = build_card(st, extras)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"{HOST} {day}: human_home_vis={st.human_home_vis} "
|
f"{HOST} {day}: human_home_vis={st.human_home_vis} "
|
||||||
f"rum_human={st.rum_human} all_req={st.all_req}",
|
f"rum_human={st.rum_human} all_req={st.all_req} "
|
||||||
|
f"deploys={len(deploys)} "
|
||||||
|
f"wf_ok={sum(w.ok_count for w in workflows)} "
|
||||||
|
f"wf_fail={sum(w.fail_count for w in workflows)} "
|
||||||
|
f"v={extras.site_version}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -357,10 +701,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
resp = post_feishu(payload)
|
resp = post_feishu(payload)
|
||||||
# Feishu returns {code:0, msg:success} on OK
|
|
||||||
code = resp.get("code", resp.get("StatusCode"))
|
code = resp.get("code", resp.get("StatusCode"))
|
||||||
if code not in (0, None) and code != 200:
|
if code not in (0, None) and code != 200:
|
||||||
print("Feishu error:", json.dumps(resp, ensure_ascii=False)[:800], file=sys.stderr)
|
print(
|
||||||
|
"Feishu error:",
|
||||||
|
json.dumps(resp, ensure_ascii=False)[:800],
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
print("feishu ok:", json.dumps(resp, ensure_ascii=False)[:200])
|
print("feishu ok:", json.dumps(resp, ensure_ascii=False)[:200])
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
"""Orchestrate Climperor web data refresh tiers, then optional OSS + deploy.
|
||||||
|
|
||||||
|
Tiers (see AGENTS.md / Gitea Actions workflows):
|
||||||
|
daily — OpenDota stats, leaderboards, matches, pro matches, streamers;
|
||||||
|
patch check
|
||||||
|
weekly — STRATZ meta, hero items, items_meta, item_fears
|
||||||
|
patch — patch list check; on has_new fetch details + version-linked scripts
|
||||||
|
all — weekly then daily (patch check included in daily/patch)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python refresh_web.py --tier patch --skip-deploy --skip-oss
|
||||||
|
python refresh_web.py --tier daily --skip-deploy
|
||||||
|
python refresh_web.py --tier weekly
|
||||||
|
python refresh_web.py --tier all --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Paths whose content change should trigger deploy / OSS.
|
||||||
|
DATA_WATCH = [
|
||||||
|
ROOT / "data" / "hero_stats.json",
|
||||||
|
ROOT / "data" / "leaderboards.json",
|
||||||
|
ROOT / "data" / "hero_matches.json",
|
||||||
|
ROOT / "data" / "pro_matches.json",
|
||||||
|
ROOT / "data" / "streamers.json",
|
||||||
|
ROOT / "data" / "stratz_hero_meta.json",
|
||||||
|
ROOT / "data" / "stratz_matchup_tops.json",
|
||||||
|
ROOT / "data" / "hero_items.json",
|
||||||
|
ROOT / "data" / "items_meta.json",
|
||||||
|
ROOT / "data" / "hero_item_fears.json",
|
||||||
|
ROOT / "data" / "patches.json",
|
||||||
|
ROOT / "data" / "hero_abilities.json",
|
||||||
|
ROOT / "data" / "item_shop.json",
|
||||||
|
]
|
||||||
|
ASSET_DIRS = [
|
||||||
|
ROOT / "assets" / "item_icons",
|
||||||
|
ROOT / "assets" / "ability_icons",
|
||||||
|
ROOT / "assets" / "item_cat_icons",
|
||||||
|
ROOT / "assets" / "hero_portraits",
|
||||||
|
ROOT / "assets" / "streamer_avatars",
|
||||||
|
ROOT / "assets" / "streamer_videos",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _file_digest(path: Path) -> str | None:
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_digest(path: Path) -> str | None:
|
||||||
|
if not path.is_dir():
|
||||||
|
return None
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for p in sorted(path.rglob("*")):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
rel = p.relative_to(path).as_posix().encode()
|
||||||
|
h.update(rel)
|
||||||
|
h.update(b"\0")
|
||||||
|
digest = _file_digest(p)
|
||||||
|
if digest:
|
||||||
|
h.update(digest.encode())
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot() -> dict[str, str | None]:
|
||||||
|
out: dict[str, str | None] = {}
|
||||||
|
for p in DATA_WATCH:
|
||||||
|
out[str(p.relative_to(ROOT))] = _file_digest(p)
|
||||||
|
for d in ASSET_DIRS:
|
||||||
|
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]:
|
||||||
|
"""Return (data_changed, assets_changed)."""
|
||||||
|
data_changed = False
|
||||||
|
assets_changed = False
|
||||||
|
keys = set(before) | set(after)
|
||||||
|
for k in keys:
|
||||||
|
if before.get(k) == after.get(k):
|
||||||
|
continue
|
||||||
|
if k.endswith("/"):
|
||||||
|
assets_changed = True
|
||||||
|
else:
|
||||||
|
data_changed = True
|
||||||
|
return data_changed, assets_changed
|
||||||
|
|
||||||
|
|
||||||
|
def run_script(script: str, *args: str, dry_run: bool = False) -> None:
|
||||||
|
cmd = [sys.executable, str(ROOT / script), *args]
|
||||||
|
print(f"+ {' '.join(cmd)}", flush=True)
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
subprocess.run(cmd, cwd=str(ROOT), check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_check() -> dict:
|
||||||
|
"""Always hits the network (read-only list); safe under --dry-run."""
|
||||||
|
cmd = [sys.executable, str(ROOT / "fetch_patches.py"), "--check"]
|
||||||
|
print(f"+ {' '.join(cmd)}", flush=True)
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(ROOT),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
line = (proc.stdout or "").strip().splitlines()[-1] if (proc.stdout or "").strip() else ""
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("fetch_patches.py --check produced empty stdout")
|
||||||
|
result = json.loads(line)
|
||||||
|
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run_patch_linked(*, dry_run: bool = False) -> None:
|
||||||
|
run_script("fetch_patches.py", dry_run=dry_run)
|
||||||
|
run_script("fetch_hero_abilities.py", "--icons", dry_run=dry_run)
|
||||||
|
run_script("fetch_item_shop.py", dry_run=dry_run)
|
||||||
|
run_script("fetch_items_meta.py", dry_run=dry_run)
|
||||||
|
run_script("item_fears.py", dry_run=dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily(*, dry_run: bool = False) -> bool:
|
||||||
|
"""Return True if patch-linked fetch ran."""
|
||||||
|
run_script("fetch_hero_stats.py", dry_run=dry_run)
|
||||||
|
run_script("fetch_leaderboards.py", dry_run=dry_run)
|
||||||
|
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
|
||||||
|
run_script("fetch_pro_matches.py", dry_run=dry_run)
|
||||||
|
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
|
||||||
|
run_script("fetch_streamers.py", dry_run=dry_run)
|
||||||
|
check = patch_check()
|
||||||
|
if check.get("has_new"):
|
||||||
|
print(
|
||||||
|
f"new patches detected: {check.get('new_versions')}; running version-linked fetch",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
run_patch_linked(dry_run=dry_run)
|
||||||
|
return True
|
||||||
|
print("no new patches", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_weekly(*, dry_run: bool = False) -> None:
|
||||||
|
run_script("fetch_stratz_meta.py", dry_run=dry_run)
|
||||||
|
run_script("fetch_hero_items.py", dry_run=dry_run)
|
||||||
|
run_script("fetch_items_meta.py", dry_run=dry_run)
|
||||||
|
run_script("item_fears.py", dry_run=dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def run_patch_tier(*, dry_run: bool = False) -> bool:
|
||||||
|
"""Return True if version-linked fetch ran (or would run under dry-run)."""
|
||||||
|
check = patch_check()
|
||||||
|
if not check.get("has_new"):
|
||||||
|
print("no new patches; skip detail fetch", flush=True)
|
||||||
|
return False
|
||||||
|
print(
|
||||||
|
f"new patches detected: {check.get('new_versions')}; running version-linked fetch",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
run_patch_linked(dry_run=dry_run)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument(
|
||||||
|
"--tier",
|
||||||
|
choices=("daily", "weekly", "patch", "all"),
|
||||||
|
required=True,
|
||||||
|
help="which refresh tier to run",
|
||||||
|
)
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="print commands only")
|
||||||
|
ap.add_argument("--skip-deploy", action="store_true", help="do not call deploy_relations.py")
|
||||||
|
ap.add_argument("--skip-oss", action="store_true", help="do not upload static assets to OSS")
|
||||||
|
ap.add_argument(
|
||||||
|
"--force-deploy",
|
||||||
|
action="store_true",
|
||||||
|
help="deploy even when snapshot digests are unchanged",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
before = snapshot() if not args.dry_run else {}
|
||||||
|
did_work = False
|
||||||
|
|
||||||
|
if args.tier in ("weekly", "all"):
|
||||||
|
run_weekly(dry_run=args.dry_run)
|
||||||
|
did_work = True
|
||||||
|
if args.tier in ("daily", "all"):
|
||||||
|
if run_daily(dry_run=args.dry_run):
|
||||||
|
did_work = True
|
||||||
|
else:
|
||||||
|
did_work = True # daily fetch scripts still ran
|
||||||
|
if args.tier == "patch":
|
||||||
|
if run_patch_tier(dry_run=args.dry_run):
|
||||||
|
did_work = True
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("dry-run complete (no deploy/oss)", flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
after = snapshot()
|
||||||
|
data_changed, assets_changed = diff_snapshots(before, after)
|
||||||
|
print(
|
||||||
|
f"changes: data={data_changed} assets={assets_changed} "
|
||||||
|
f"force_deploy={args.force_deploy} did_work={did_work}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if assets_changed and not args.skip_oss:
|
||||||
|
run_script("_oss_static_assets.py", "upload")
|
||||||
|
elif assets_changed and args.skip_oss:
|
||||||
|
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
|
||||||
|
|
||||||
|
should_deploy = args.force_deploy or data_changed or assets_changed
|
||||||
|
deployed = False
|
||||||
|
if should_deploy and not args.skip_deploy:
|
||||||
|
run_script("deploy_relations.py")
|
||||||
|
deployed = True
|
||||||
|
elif should_deploy and args.skip_deploy:
|
||||||
|
print("deploy needed but --skip-deploy set; skipping", flush=True)
|
||||||
|
else:
|
||||||
|
print("no data/asset changes; skip deploy", flush=True)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"ok": True,
|
||||||
|
"tier": args.tier,
|
||||||
|
"data_changed": data_changed,
|
||||||
|
"assets_changed": assets_changed,
|
||||||
|
"deployed": deployed,
|
||||||
|
"force_deploy": args.force_deploy,
|
||||||
|
}
|
||||||
|
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Extra deps for Web refresh / OSS upload (Gitea Actions + local refresh_web.py).
|
||||||
|
# CI also installs requirements.txt — fetch scripts import common (opencv/numpy)
|
||||||
|
# and fetch_patches portrait resize needs cv2.
|
||||||
|
oss2>=2.18
|
||||||
Reference in New Issue
Block a user