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:
voson
2026-07-28 21:17:23 +08:00
co-authored by Cursor
parent 9f484166d4
commit 37769580f5
10 changed files with 889 additions and 68 deletions
+5 -2
View File
@@ -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
# (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
@@ -28,6 +29,8 @@ jobs:
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: https://gitea.refining.dev
run: |
set -e
PYTHON=python3
+41
View File
@@ -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
+39
View File
@@ -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
+39
View File
@@ -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
+39 -19
View File
@@ -11,7 +11,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
- 模板来源:**仅** Steam CDN 官方头像(`templates/cdn/`
- **已放弃** 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()` 供会话复用 |
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 |
| `http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时) |
| `fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量) |
| `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` 重抓全量`--check` 只比对列表与本地 detailsstdout JSON |
| `requirements-web.txt` | Web 刷新额外依赖(`oss2`);Gitea Actions 与 `requirements.txt` 一并 `pip install` |
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `relations.py` | 定性克制/搭档边读写与名称解析 |
| `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_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` 交叉审计) |
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
| `draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI |
| `serve_relations.py` | 上分帝 Web 本地开发服务(`web/relations/`;改 `data/*.json` 后刷新) |
| `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` 可选本地拷贝视频,生产部署勿用) |
| `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` 可选本地拷贝技能/主播视频,生产部署勿用) |
| `deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `notify_site_traffic.py` | 上分帝 Web 日活摘要 → 飞书 webhookCloudflare GraphQL;按已知浏览器 UA + `/` 估真实访问;`--dry-run` 只打印卡片) |
| `.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` |
| `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 |
| `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 SecretsSTRATZ 可选) |
| `.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 注入) |
| `_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_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_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_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_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}` |
| `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `data/hero_abilities.json``has_scepter`/`has_shard` 只信 Valve 显式 flagValve 移除升级后残留的 `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 显式 flagValve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN |
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4Steam 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` |
| `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_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/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 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
| `data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
| `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_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` |
| `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/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/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
@@ -83,7 +92,9 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.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/` 前缀托管) |
运行时产物(**勿提交**,见 `.gitignore`):
@@ -95,6 +106,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
- `failures/``--truth` 调试用错识裁切
- `data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
- `assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
- `assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步)
## 数据流
@@ -113,40 +125,48 @@ Dota 2 GSI → gsi_watch.py (:3223)
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
- **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。
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `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` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
- **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
- **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null`
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
- **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**(job 内现拉现部署)。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
## 开发命令
```powershell
pip install -r requirements.txt
pip install -r requirements-web.txt # 可选:本机 refresh_web / OSSCI 会装)
python fetch_cdn_templates.py
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
python fetch_hero_portraits.py # 官网横版头像(上分帝 Web
python fetch_hero_items.py # OpenDota 热门装备缓存(上分帝 Web)
python fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
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_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
python fetch_item_shop.py # 商店分类目录(物品页)
python fetch_items_meta.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_ability_videos.py # 官网技能演示(限速;默认 webm)
python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
python serve_relations.py # 上分帝 Web 本地开发服务(英雄 / 排行 / 物品 / 版本
python fetch_patches.py --check # 只比对列表与本地 detailsstdout JSONhas_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 --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 notify_site_traffic.py --dry-run # 上分帝 Web 日活摘要(飞书;需 CF + webhook env / keyzoo
python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSSkeyzoo voson-RAM
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
# Gitea Actionsself-hosted):web-daily / web-weekly / web-patchSecrets 见 README「定时刷新」与各 workflow 头注释
python gsi_setup.py --check
python gsi_watch.py --once
python recognize.py samples/raw/<>.png --sheet
+74 -8
View File
@@ -7,12 +7,13 @@
### Added
- 上分帝 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 接入 STRATZ`fetch_stratz_meta.py`token 经 `.env` / keyzoo):
- 「走势」优先 STRATZ 近几周段位胜率/上场率/场次,并展示周折线柱与分路胜率OpenDota 作兜底)
- 新子 Tab「对位」`#/heroes/<key>/matchups`天梯对位优势 / 被克 / 协同 Top(与网格机制「克/怕/搭」分开展示)
- 「排行」新增「英雄 Meta」模式 `#/rankings/meta[/bracket]`(按段位上场排序)
- 英雄详情「走势」优先 STRATZ:勋章条 + **最近 1 周**三卡 + 分路 + **近 8 周**逐周列表OpenDota 作兜底)
- 新子 Tab「对位」`#/heroes/<key>/matchups`克制 / 被克 / 搭档三列 STRATZ Top(与网格机制「克/怕/搭」分开展示)
- 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜
- 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`gitignore;仅 Web,不进 recommend / relations
- 上分帝 Web「近期比赛」Tab`#/heroes/<key>/matches`):同英雄近期局终局出装 +
加点顺序;展示选手昵称/段位勋章与 OpenDota 选手·比赛链接;
@@ -25,19 +26,84 @@
### Changed
- 上分帝 Web「走势」:三卡 / 分路胜率统一为 **当前选中段位 · 最近 1 周**STRATZ `winWeek`);分路不再用合并档 `stats` 接口;段位栏下显示时间窗提示(`0.5.30`
- 上分帝 Web「走势」:近 8 周改为 **逐周列表**(日期 / 胜率 / 横向条 / 场次),Tab 内容区可滚动(`0.5.31`
- 上分帝 Web「走势」8:左对齐条按本英雄 8 周区间缩放、周环比、去掉 50% 中线(`0.5.37`)。
- 上分帝 Web 英雄详情「走势」(`0.5.30``0.5.37`
- 三卡 / 分路统一 **当前选中勋章 · 最近 1 周**STRATZ `winWeek`;分路改 `positionIds`,不再用合并档 `stats`
- 布局:勋章条 →「最1」提示 → 三卡 → 分路 → 近 8 周列表;Tab 内容区可滚动
- 近 8 周:逐周列表(新→旧),含胜率、周环比、按本英雄 8 周区间缩放的横条、场次;去掉 50% 中线与底部刻度文案
- 文档与注释统一称呼:`web/relations/` 子项目改称 **上分帝 Web**(不再称「关系预览」);路径与脚本名(如 `serve_relations.py`)暂保持不变。
- 「近期比赛」公开对局默认优先国服(Perfect World cluster`--public-region china`),联赛+天梯可用 `--source both`;卡片来源显示「国服 / 天梯 / 联赛」。
- 「近期比赛」改为展示最近 10 场胜局(传奇及以上天梯 + 联赛/职业,按时间从新到旧);公开列表过滤机器人/Turbo,仅保留 ranked lobby`0.5.40`)。
- 「近期比赛」外链改 Dotabuff(选手/详情);`fetch_hero_matches.py` 支持 `--workers` 详情并发与不足 N 场增量补拉(`0.5.49`)。
- 选将推荐不再限 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
### Added
- 上分帝 WebSTRATZ 走势 / 对位 Tab / 英雄 Meta 榜(见 Unreleased 明细;站点版本 `0.5.29`)。
- 上分帝 WebSTRATZ 走势 / 对位 Tab(见 Unreleased 明细;站点版本 `0.5.29`)。
## [0.5.28] - 2026-07-28
+9
View File
@@ -15,6 +15,7 @@ import sys
from pathlib import Path
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:
@@ -40,6 +41,14 @@ def run(argv: list[str]) -> int:
except OSError:
pass
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 notify_site_traffic as n
+386 -39
View File
@@ -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
homepage visits (known browser UA on `/`, plus RUM beacons), and posts a
Feishu custom-bot card.
1) Cloudflare edge analytics for dota2.refining.dev (human homepage estimate)
2) Auto-refresh status: Gitea Actions (web-daily / web-weekly / web-patch)
+ Cloudflare Pages production deploys that day
Credentials (env, prefer keyzoo inject names):
CLOUDFLARE_EMAIL / KEYZOO_ASSET_META_USERNAME
CLOUDFLARE_API_KEY / KEYZOO_ASSET_SECRET_GLOBAL_API_KEY
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:
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 --dry-run # print card, do not POST
python notify_site_traffic.py --dry-run
"""
from __future__ import annotations
@@ -20,16 +22,30 @@ from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
HOST = "dota2.refining.dev"
ZONE_NAME = "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(
{
@@ -81,17 +97,64 @@ def feishu_url() -> str:
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()
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
try:
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:
raw = e.read().decode(errors="replace")
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:
data = http_json(
"https://api.cloudflare.com/client/v4/graphql",
@@ -99,11 +162,24 @@ def gql(query: str, variables: dict) -> dict:
body={"query": query, "variables": variables},
headers=cf_headers(),
)
if data.get("errors"):
raise SystemExit("GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500])
if isinstance(data, dict) and data.get("errors"):
raise SystemExit(
"GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500]
)
assert isinstance(data, dict)
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
class DayStats:
day: date
@@ -115,15 +191,57 @@ class DayStats:
rum_human: int = 0
rum_other: 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)
@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:
d = http_json(
f"https://api.cloudflare.com/client/v4/zones?name={ZONE_NAME}",
headers=cf_headers(),
)
assert isinstance(d, dict)
rows = d.get("result") or []
if not rows:
raise SystemExit(f"zone not found: {ZONE_NAME}")
@@ -230,6 +348,142 @@ def fetch_day(zid: str, day: date) -> DayStats:
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:
x = float(n)
for unit in ("B", "KB", "MB", "GB"):
@@ -239,39 +493,110 @@ def fmt_bytes(n: int) -> str:
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)."""
countries = "".join(
f"{name} {vis}访/{req}" for name, req, vis in st.top_countries[:5]
) 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
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] = [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
f"(已知浏览器 UA 打开 `/`;不含 Unknown/Headless/爬虫)\n\n"
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"
),
},
"text": {"tag": "lark_md", "content": "**访问**\n" + traffic_md},
},
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**自动更新**\n" + refresh_md},
},
{
"tag": "div",
"text": {
"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",
"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": "打开站点"},
"type": "primary",
"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": {
"title": {
"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,
},
@@ -315,19 +647,19 @@ def build_card(st: DayStats) -> dict:
def post_feishu(payload: dict) -> dict:
url = feishu_url()
return http_json(
resp = http_json(
url,
method="POST",
body=payload,
headers={"Content-Type": "application/json"},
)
assert isinstance(resp, dict)
return resp
def parse_day(s: str | None) -> date:
if not s:
# Yesterday in China time (site audience).
cst = timezone(timedelta(hours=8))
return (datetime.now(cst) - timedelta(days=1)).date()
return (datetime.now(CST) - timedelta(days=1)).date()
return date.fromisoformat(s)
@@ -344,11 +676,23 @@ def main(argv: list[str] | None = None) -> int:
day = parse_day(args.day)
zid = zone_id()
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(
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,
)
@@ -357,10 +701,13 @@ def main(argv: list[str] | None = None) -> int:
return 0
resp = post_feishu(payload)
# Feishu returns {code:0, msg:success} on OK
code = resp.get("code", resp.get("StatusCode"))
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
print("feishu ok:", json.dumps(resp, ensure_ascii=False)[:200])
return 0
+253
View File
@@ -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()
+4
View File
@@ -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