Add daily Feishu digest for dota2.refining.dev traffic.

Gitea Actions runs notify_site_traffic.py at 09:00 CST using Cloudflare GraphQL and a Feishu webhook (secrets from keyzoo).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-28 15:26:07 +08:00
co-authored by Cursor
parent 9898dd39fb
commit 9f484166d4
6 changed files with 755 additions and 65 deletions
+39
View File
@@ -0,0 +1,39 @@
# Daily Feishu digest for dota2.refining.dev traffic.
# Repo secrets: CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY, FEISHU_WEBHOOK_URL
# (copy from keyzoo refining/cloudflare + refining/feishu_webhook)
name: site-traffic-notify
on:
schedule:
# 09:00 China = 01:00 UTC
- cron: "0 1 * * *"
workflow_dispatch:
inputs:
day:
description: "YYYY-MM-DD (empty = yesterday CST)"
required: false
type: string
jobs:
notify:
# Mac mini Gitea runner (labels: self-hosted, macOS, ARM64, darwin-arm64)
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Post Feishu digest
env:
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
run: |
set -e
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
if [ -n "${{ github.event.inputs.day }}" ]; then
"$PYTHON" notify_site_traffic.py --day "${{ github.event.inputs.day }}"
else
"$PYTHON" notify_site_traffic.py
fi
+48 -28
View File
@@ -11,6 +11,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
- 模板来源:**仅** Steam CDN 官方头像(`templates/cdn/`
- **已放弃** real 实拍模板库补充;不要再引入 `templates/real/` 或入库流水线
**上分帝 Web**`web/relations/`)是独立子项目:英雄克制/搭档、排行、物品、版本等内容站点,与局内选将识别解耦。文档与注释统称「上分帝 Web」,勿再称「关系预览」;历史路径/脚本名(如 `serve_relations.py``dist/relations/`)暂保留。
## 目录与模块
| 路径 | 职责 |
@@ -24,17 +26,24 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `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` |
| `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_matchups.py` | 拉取 OpenDota 对位优势 → `data/matchups.json`(供 `audit_relations.py` 交叉审计) |
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `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` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `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` 可选本地拷贝视频,生产部署勿用 |
| `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` |
| `_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/`关系预览 |
| `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_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend |
| `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 填数) |
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
@@ -43,31 +52,37 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
| `capture.py` | 屏幕捕获 |
| `overlay.py` | 顶栏角色标签 + 选将网格左上角青标 Top-3(点击穿透) |
| `overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) |
| `evaluate.py` | 按 `samples/labels.json` 批量评测 |
| `roles.py` / `grid.py` / `modes.py` | 位置字、禁用网格、模式字 |
| `config.json` | 相对坐标、阈值、GSI、recommend 参数 |
| `data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `fetch_cdn_templates.py` 生成) |
| `data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,预览版本页只读) |
| `data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,预览页只读) |
| `data/hero_grid_order.json` | 预览页四列网格顺序 |
| `data/hero_items.json` | 核心成品装备缓存(预览页只读;由 `fetch_hero_items.py` 生成) |
| `data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) |
| `data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
| `data/hero_grid_order.json` | Web 站点四列网格顺序 |
| `data/hero_items.json` | 核心成品装备缓存(Web 站点只读;由 `fetch_hero_items.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/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/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/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源(`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 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
| `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` 别名兼容 |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
| `assets/hero_portraits/` | 官网横版头像(关系预览;默认 wide 面部构图,非匹配模板) |
| `assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,关系预览用) |
| `assets/item_icons/` | Steam CDN 装备图标(关系预览常用装备 |
| `assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器 |
| `assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`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/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管) |
@@ -89,8 +104,8 @@ Dota 2 GSI → gsi_watch.py (:3223)
→ DraftSession 轮询截屏 → samples/raw/<matchid>/
→ recognize_image (CDN 模板 + 可选天梯遮罩)
→ roles(分路字)/ grid(禁用 + 单元格)/ modes
→ recommend Top-3data/relations.json 定性边,本人槽位只信 GSI
→ overlay 角色标签 + 网格青标(可选)
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI
→ overlay 角色标签 + 网格克/搭/补 + 阵容分析条(可选)
→ results/draft_*.json + 终端时间线
```
@@ -98,8 +113,8 @@ Dota 2 GSI → gsi_watch.py (:3223)
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
- **克制 / 搭档数据**:定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`预览三视图:克制 / 被克制 / 搭档。
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`。本阶段仅关系预览展示,**不对局内出装推荐**。
- **克制 / 搭档数据**机制克制/搭档只用定性边存 `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` 列表内相对热度归一化,非绝对出场率、无段位维度。
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
@@ -112,8 +127,12 @@ Dota 2 GSI → gsi_watch.py (:3223)
pip install -r requirements.txt
python fetch_cdn_templates.py
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
python fetch_hero_portraits.py # 官网横版头像(关系预览
python fetch_hero_items.py # OpenDota 热门装备缓存(关系预览
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_leaderboards.py # Valve Immortal 四区 Top100(排行页)
python fetch_item_shop.py # 商店分类目录(物品页)
python fetch_items_meta.py # 装备描述 + 机制标签
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总
@@ -121,10 +140,11 @@ python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/abil
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
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 部署 / 自定义域名状态
python gsi_setup.py --check
@@ -139,9 +159,9 @@ python evaluate.py
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
-预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
- 发版关系预览时同步 bump `export_relations_site.py``SITE_VERSION``web/relations/config.js` 的同名变量,以及 `index.html``config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md`
- Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
- 发版上分帝 Web 时同步 bump `export_relations_site.py``SITE_VERSION``web/relations/config.js` 的同名变量,以及 `index.html``style.css`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md`
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
## 文档分工
+192 -37
View File
@@ -4,11 +4,166 @@
## [Unreleased]
### Added
- 上分帝 Web 日活飞书推送:`notify_site_traffic.py` + Gitea Actions
`site-traffic-notify`(每日 09:00 CST;真实访问按已知浏览器 UA 打开 `/` 估算)。
- 上分帝 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]`(按段位上场排序)
- 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`gitignore;仅 Web,不进 recommend / relations
- 上分帝 Web「近期比赛」Tab`#/heroes/<key>/matches`):同英雄近期局终局出装 +
加点顺序;展示选手昵称/段位勋章与 OpenDota 选手·比赛链接;
`fetch_hero_matches.py``data/hero_matches.json`
`--source league|public|both``--enrich-players` 可补昵称段位;
`--enrich-item-times` 可补购买时间;仅 Web 站点,不进局内推荐)。
- 选将规则阵容画像(不接 AI):识别敌方推进/全球流、敌我缺口(缺控制/爆发/输出/先手);
全网格标 **克 / 搭 / 补**;叠加层显示一句阵容分析;每英雄短推荐原因写入日志与
`results` JSON`draft_archetypes.py` + `recommend.suggest_marks`)。
### Changed
- 上分帝 Web「走势」:三卡 / 分路胜率统一为 **当前选中段位 · 最近 1 周**STRATZ `winWeek`);分路不再用合并档 `stats` 接口;段位栏下显示时间窗提示(`0.5.30`)。
- 上分帝 Web「走势」:近 8 周改为 **逐周列表**(日期 / 胜率 / 横向条 / 场次),Tab 内容区可滚动(`0.5.31`)。
- 上分帝 Web「走势」近 8 周:左对齐条按本英雄 8 周区间缩放、周环比、去掉 50% 中线(`0.5.37`)。
- 文档与注释统一称呼:`web/relations/` 子项目改称 **上分帝 Web**(不再称「关系预览」);路径与脚本名(如 `serve_relations.py`)暂保持不变。
- 「近期比赛」公开对局默认优先国服(Perfect World cluster`--public-region china`),联赛+天梯可用 `--source both`;卡片来源显示「国服 / 天梯 / 联赛」。
- 「近期比赛」改为展示最近 10 场胜局(传奇及以上天梯 + 联赛/职业,按时间从新到旧);公开列表过滤机器人/Turbo,仅保留 ranked lobby`0.5.40`)。
- 选将推荐不再限 Top-3 名次青标;非定位局也可推克/搭/补(无分路字则不过滤)。
## [0.5.29] - 2026-07-28
### Added
- 上分帝 WebSTRATZ 走势 / 对位 Tab / 英雄 Meta 榜(见 Unreleased 明细;站点版本 `0.5.29`)。
## [0.5.28] - 2026-07-28
### Added
- 「近期比赛」出装图标叠加购买时间(MM:SS,支持负值);数据来自 OpenDota
`purchase_log`(未解析对局无时间)。中立装用 `neutral_item_history`
- `fetch_hero_matches.py --enrich-item-times`:只补时间字段,无需全量重拉列表。
### Changed
- 「近期比赛」出装布局对齐 OpenDota:主栏一行、背包第二行(背包轮廓图标),去掉竖分隔线。
## [0.5.27] - 2026-07-28
### Changed
- 「近期比赛」卡片头:昵称旁不再显示段位文字(如「冠绝」);保留勋章图标,排行榜 # 放进勋章 tooltip。
## [0.5.19] - 2026-07-28
### Fixed
- 排行页标题「Immortal 排行榜」与 info 图标保持同一行不换行。
## [0.5.18] - 2026-07-28
### Changed
- 排行页「Immortal 排行榜」标题旁增加与走势页一致的 info 气泡提示(官方 Immortal 地区榜 · 各区分数不可比)。
## [0.5.13] - 2026-07-28
### Changed
- 排行榜按视口居中(不含右侧说明);说明绝对定位贴在榜单右侧。
## [0.5.12] - 2026-07-28
### Fixed
- 排行表分割线错位(去掉选手列 flex)。
## [0.5.11] - 2026-07-28
### Changed
- 排行页右侧说明贴紧榜单(与榜单成组居中),不再贴屏幕右缘。
## [0.5.10] - 2026-07-28
### Changed
- 排行页国籍列显示中文国名(未知代码仍回退为原代码)。
## [0.5.9] - 2026-07-28
### Changed
- 排行页战队 / 国籍无数据时留空,不再显示「—」。
## [0.5.8] - 2026-07-28
### Changed
- 排行页:榜单单独居中,右侧说明贴屏幕右缘(不再与榜单成组居中)。
## [0.5.7] - 2026-07-28
### Changed
- 排行页新增「战队」列(原选手行内战队标签拆出)。
## [0.5.6] - 2026-07-28
### Changed
- 排行页新增「国籍」列(原选手行内国旗代码拆出)。
## [0.5.5] - 2026-07-28
### Changed
- 排行页去掉官方榜说明文案。
## [0.5.4] - 2026-07-28
### Changed
- 排行页副标题去掉地区名与「Top 100」,仅保留更新时间。
## [0.5.3] - 2026-07-28
### Changed
- 排行页:榜单居中,标题 / 地区切换 / 说明贴在右侧。
## [0.5.1] - 2026-07-28
### Added
- 上分帝 Web「排行」页(`#/rankings`,插在英雄之后):Valve Immortal 四区榜
Top 100(中国 / 欧洲 / 美洲 / 东南亚);`fetch_leaderboards.py`
`data/leaderboards.json`(仅排名+昵称等,无 MMR/账号 ID)。
## [0.5.0] - 2026-07-28
### Added
- 上分帝 Web「走势」Tab`#/heroes/<key>/trends`):OpenDota 近约 7 天各段位
胜率 / 上场率 / 场次;顶部勋章条(先锋→冠绝)切换段位。
- `fetch_hero_stats.py``data/hero_stats.json`(含 `totals` / `window_*`);
仅 Web 站点,不进 `relations.json` / 局内推荐。
- 核心装备图标显示列表内相对使用率 %OpenDota popularity 归一化)。
- 冠绝勋章旁说明:样本过小则与超凡合并展示。
### Changed
- 走势布局居中:勋章条 + 三卡,无段位列表 / 被禁率;勋章约 90px。
- 静态资源增加 `/rank/``assets/rank_icons/rank_icon_1..8.png`
## [0.4.2] - 2026-07-27
### Changed
- 关系预览静态图标(头像 / 装备 / 技能 / 属性等)改由阿里云 OSS 同桶托管;
- 上分帝 Web静态图标(头像 / 装备 / 技能 / 属性等)改由阿里云 OSS 同桶托管;
`config.js` 新增 `STATIC_ASSET_BASE`,生产 Pages 包体约 **2 MB**(仅
HTML/JS/data),换机部署无需本地 fetch 图标。同步脚本
`_oss_static_assets.py upload`
@@ -21,7 +176,7 @@
### Changed
- 线上关系预览技能演示视频改由阿里云 OSS 托管(桶 `climperor`
- 线上上分帝 Web技能演示视频改由阿里云 OSS 托管(桶 `climperor`
`https://climperor.oss-cn-shanghai.aliyuncs.com/ability-video/...`),不再打进
Cloudflare Pages;本地 `serve_relations.py` 仍读 `assets/ability_videos/`
导出写入 `config.js``ABILITY_VIDEO_BASE``export_relations_site.py
@@ -39,7 +194,7 @@
### Added
- 新增 `deploy_relations.py`:一键把关系预览静态站点部署到 Cloudflare Pages
- 新增 `deploy_relations.py`:一键把上分帝 Web静态站点部署到 Cloudflare Pages
—— 串联导出(`export_relations_site.py`)→ 资产完整性预检(缺图则中止)
`wrangler pages deploy` 直传 `dist/relations/` → 绑定自定义域名。凭据
经 keyzoo 注入(`refining/cloudflare` 的 Global API Key)或手动
@@ -60,24 +215,24 @@
- 修复 `serializeHash` 生成 `#heroes`(缺 `/`)导致刷新后路由解析异常的
问题,现统一为 `#/heroes` 形态。
- 版本日志中未知单位 id 不再显示 `# 1961` 原始数字,回退为「其它单位」。
- 本地预览 `serve_relations.py` 增加 CSP 响应头(`default-src 'self'` 等),
- 本地开发服务 `serve_relations.py` 增加 CSP 响应头(`default-src 'self'` 等),
限制资源只从本站加载。
- `data.json` 加载失败时显式抛出 HTTP 状态错误,不再静默进入异常渲染。
### Performance
- 关系预览缓存英雄技能条目(`skillEntriesFor`)与血蓝条量程
- 上分帝 Web缓存英雄技能条目(`skillEntriesFor`)与血蓝条量程
`resourceBarScale`)计算结果,数据重载时失效,减少重复遍历。
## [0.3.0] - 2026-07-27
### Added
- 关系预览「版本」页改为默认展示**最新版本**的完整改动详情(综合 / 物品 / 中立物品 /
- 上分帝 Web「版本」页改为默认展示**最新版本**的完整改动详情(综合 / 物品 / 中立物品 /
英雄),顶部下拉可切换近一年内的其它版本;`fetch_patches.py` 重写为抓取近一年
(默认 365 天,`--days` / `--since` 可调)版本列表 + 逐版本 `patchnotes` 详情,
构建 `lookup`ability_id / hero_id → 名称 + 图标 key)并下载引用到的物品 / 技能
图标到 `assets/`,静态导出与本地预览均自包含、不联网。
图标到 `assets/`,静态导出与本地开发服务均自包含、不联网。
- 版本页排版对齐官网:左大标题「游戏性更新 {版本}」+ 右选择框,内容居中窄列;
物品图标按 Steam CDN 88×64 比例(66×48)显示(修复正方形框上下黑边);英雄头像
复用英雄页 16:9 宽卡(`?v=wide``object-fit:contain`)不再裁切面部;图标放大
@@ -88,19 +243,19 @@
技能页 bundled `innate.png`(从 `hero_abilities.is_innate` 构建先天 key 集合判断),
覆盖 117 个被引用的先天技能。
- `hero_notes` 属性标签补全 `movement` → 「移速」(修复原始英文 `movement` 漏显示)。
- 新增 `export_relations_site.py`:一键导出关系预览为纯静态站点(`dist/relations/`),
- 新增 `export_relations_site.py`:一键导出上分帝 Web为纯静态站点(`dist/relations/`),
内含 `data.json` 快照 + 前端 + 图片资源,可直接部署到任意静态托管;
`--with-videos` 可选拷贝技能演示视频(体积数 GB)。
- 关系预览前端资源路径改为相对路径(`data.json` / `attr/` / `portrait/` / `item/` 等),
- 上分帝 Web前端资源路径改为相对路径(`data.json` / `attr/` / `portrait/` / `item/` 等),
静态托管在子路径下也能正常工作;`serve_relations.py` 同时提供 `/data.json` 路由,
本地预览行为不变。
- 关系预览技能详情:左侧播放官网技能演示视频(`assets/ability_videos/` 本地缓存,
本地开发服务行为不变。
- 上分帝 Web技能详情:左侧播放官网技能演示视频(`assets/ability_videos/` 本地缓存,
`/ability-video/<英雄>/<技能>.webm` 路由),右侧为技能名与描述(对齐官网左右分栏);
未缓存的技能自动隐藏视频,描述列占满整行。
- 英雄详情的装备详情区不再重复显示大图(上方装备行已有图标)。
- 关系预览技能详情新增「施法前摇」「吟唱时间」两行(取自 Valve `cast_points` /
- 上分帝 Web技能详情新增「施法前摇」「吟唱时间」两行(取自 Valve `cast_points` /
`channel_times`,逐级相同则合并为单值);`fetch_hero_abilities.py` 全量重抓后生效。
- 关系预览技能详情按 dota2.com.cn 版式重排:移除右上重复的技能大图标(上方技能列表
- 上分帝 Web技能详情按 dota2.com.cn 版式重排:移除右上重复的技能大图标(上方技能列表
已高亮选中项);属性值(技能/伤害类型/免疫/驱散/施法距离等)双列网格、特殊数值
自适应多列网格(消除右列大片空白);冷却/蓝量移到带图标的底栏——冷却用官方
`cooldown.png`(新增 `assets/ui_icons/` + `/ui-icon/` 路由,静态导出一并拷贝),
@@ -112,10 +267,10 @@
不再被牵连出滚轮;压缩参数行距使典型技能在高屏零滚轮(无头 Chrome + CDP 实测
`scrollHeight == clientHeight` 验证)。整块宽度上限 1000→1320px、视频 700→820px、
详情列 720→760px。
- 关系预览顶栏左上角新增 Dota 2 logo +「上分帝」标题(`assets/ui_icons/dota2_logo.png`
- 上分帝 Web顶栏左上角新增 Dota 2 logo +「上分帝」标题(`assets/ui_icons/dota2_logo.png`
透明 emblem),经 `/ui-icon/` 路由与静态导出统一分发;窄屏自动隐藏标题或整组标识,
不影响居中分页布局。
- 关系预览新增 **Hash 路由**:顶层标签(英雄 / 物品 / 版本)、选中英雄 + 详情子标签
- 上分帝 Web新增 **Hash 路由**:顶层标签(英雄 / 物品 / 版本)、选中英雄 + 详情子标签
(技能 / 核心装备 / 被克装备 / 版本变更)、物品页选中物品、版本页选中版本均同步进
URL;英雄页的定位标签筛选与搜索框文本以 query 参数(`?tags=核心,辅助&q=axe`)进
URL。形态如 `#/heroes/axe/core``#/items/black_king_bar``#/patches/7.41`
@@ -179,15 +334,15 @@
### Added
- 关系预览英雄详情:基础属性条(力量/敏捷/智力 + 成长、初始生命/魔法与恢复;
- 上分帝 Web英雄详情:基础属性条(力量/敏捷/智力 + 成长、初始生命/魔法与恢复;
附攻击/护甲/移速/攻击距离)。数据来自 OpenDota(`fetch_cdn_templates.py`
`data/heroes.json`),主属性高亮。
- `fetch_ability_videos.py`:从 Steam CDN 批量拉取官网技能演示(webm/mp4),
默认限速约 1.5s+抖动,支持断点续传与 404 缓存;输出 `assets/ability_videos/`(不入库)。
- 装备说明填充 Valve `%token%` 数值(如飓风长戟「飓风之力」);
`fetch_items_meta.py --reformat-desc`,共用 `loc_format.py`
- 关系预览英雄详情底部分页:**技能** / **核心装备** / **被克装备**
- 关系预览新增顶级 **物品** 页(与英雄同级):对齐 dota2.com.cn 商店物品
- 上分帝 Web英雄详情底部分页:**技能** / **核心装备** / **被克装备**
- 上分帝 Web新增顶级 **物品** 页(与英雄同级):对齐 dota2.com.cn 商店物品
**11 列竖排**(基础分类 / 合成分类),详情在右侧;目录来自官网
`itemscategory/json``fetch_item_shop.py``data/item_shop.json`),
并展示合成组件(含卷轴)与「可升级为」。
@@ -195,16 +350,16 @@
详情(含装备合成)展示在下方详情区(非弹窗)。
- 「害怕」装备规则完善:莲花 `spell_reflect`、点目标技能自动关联莲花/林肯;
`data/hero_fear_overrides.json` 补经典克制(如剑圣→莲花)。
- 关系预览技能区对齐官网:可点选技能 / 魔晶 / 神杖升级,右侧显示描述;
- 上分帝 Web技能区对齐官网:可点选技能 / 魔晶 / 神杖升级,右侧显示描述;
下方展示天赋树(25→10)。数据来自 Valve herodata`fetch_hero_abilities.py`)。
- 关系预览选中英雄后展示技能(图标 + 名称;可驱散角标;悬停看描述);
- 上分帝 Web选中英雄后展示技能(图标 + 名称;可驱散角标;悬停看描述);
数据来自 `data/hero_abilities.json`,图标按需缓存到 `assets/ability_icons/`
- 关系预览「怕的装备」:`fetch_items_meta.py`Valve/OpenDota 描述 → 机制标签)、
- 上分帝 Web「怕的装备」:`fetch_items_meta.py`Valve/OpenDota 描述 → 机制标签)、
`fetch_hero_abilities.py`(技能驱散等汇总)、`item_fears.py`(规则映射)→
`data/items_meta.json` / `hero_abilities.json` / `hero_item_fears.json`
详情区在「核心」下增加「怕」行。例外标签见 `data/item_tag_overrides.json`
本阶段仅预览,无对局内出装推荐。
- 关系预览选中英雄后,下方展示中文定位 `tags` 与核心成品装备(不再分阶段);
本阶段仅 Web 站点,无对局内出装推荐。
- 上分帝 Web选中英雄后,下方展示中文定位 `tags` 与核心成品装备(不再分阶段);
`fetch_hero_items.py`OpenDota 热度合并 + Valve 中文名,Steam CDN 图标 →
`data/hero_items.json` / `assets/item_icons/`(过滤消耗品/中立/廉价件;
坚韧球/夜叉等中间件若该英雄常继续合成则隐藏,停在该件则保留)。
@@ -218,34 +373,34 @@
- `data/heroes.json` 增加中文口语别名 `aliases`(如斯拉达→大鱼人、主宰→剑圣);
`fetch_cdn_templates.py` 重拉时按 `key` 合并保留,不覆盖手工收录。
- `data/heroes.json` 增加英文缩写 `abbr`(小写列表,如 nec / vs / pa);
关系预览搜索支持缩写且不区分大小写;重拉模板时保留该字段。
上分帝 Web搜索支持缩写且不区分大小写;重拉模板时保留该字段。
- `fetch_stratz_matchups.py` + `audit_relations.py`:拉取 STRATZ 对位并与 OpenDota
交叉审计定性关系,输出 `data/relations_audit.json`
- 定性关系库 `data/relations.json`(克制有向边 + 搭档 + 理由);
`import_relations_xlsx.py` 可从选将笔记导入;`serve_relations.py` 三视图只读预览(改 JSON 后刷新)。
- `fetch_hero_portraits.py` 下载 dota2.com/heroes 竖版头像到 `assets/hero_portraits/`
关系预览网格使用(与匹配用 `templates/cdn/` 分离)。
上分帝 Web网格使用(与匹配用 `templates/cdn/` 分离)。
- 英雄中文定位标签 `tags`(核心/辅助/推进/逃生/爆发/先手/控制/耐久/幻象);
关系预览页可筛选,不参与推荐打分。
- 关系预览属性图标改用 dota2.com 官方素材(`assets/attr_icons/`),不再手绘。
上分帝 Web 页可筛选,不参与推荐打分。
- 上分帝 Web属性图标改用 dota2.com 官方素材(`assets/attr_icons/`),不再手绘。
- 选将 Top-3 推荐改为读定性关系:+克制敌方 / −被敌方克 / +己方搭档;
叠层左上角青方标显示名次 1/2/3。
- draft JSON / GSI `self` 透传 `accountid``steamid`
### Changed
- 关系预览英雄详情(`#detail`):贴视口底部展示(`margin-top: auto`,高度仍按内容收缩);
- 上分帝 Web英雄详情(`#detail`):贴视口底部展示(`margin-top: auto`,高度仍按内容收缩);
与上方英雄选择区保留约 16px 间距;标题 / 属性条 / 分页 / 技能与装备行 / 检视区整体水平居中(最大宽约 820px),长描述在居中容器内仍左对齐。物品页布局未改。
- 关系预览技能详情区不再重复放大技能图标;顶栏选中态即可识别,详情只保留名称 / 标签 / 驱散 / 描述(核心/怕装详情仍显示装备大图)。
- 关系预览先天技能统一使用共用金色水滴图标 `assets/ability_icons/innate.png`
- 上分帝 Web技能详情区不再重复放大技能图标;顶栏选中态即可识别,详情只保留名称 / 标签 / 驱散 / 描述(核心/怕装详情仍显示装备大图)。
- 上分帝 Web先天技能统一使用共用金色水滴图标 `assets/ability_icons/innate.png`
(多数 innate 在 Steam `dota_react/abilities` 上 404),技能行与详情区不再破图占位。
- 关系预览英雄详情:技能图标与核心/怕装列表图标加大(技能 68px、装备 72×54),
- 上分帝 Web英雄详情:技能图标与核心/怕装列表图标加大(技能 68px、装备 72×54),
便于点选;商店页 `.shop-item` 尺寸未改。
- 物品页详情面板改为与 11 列商店并排居中集群(`sticky`),贴在商店右侧约 16px
不再固定贴视口右缘;窄屏仍堆叠置顶。
- 物品页:`#items-view` 限制为视口内滚动容器,避免 `sticky` 详情随窗口滚动盖住商店列;
空状态去掉过大 `min-height`,面板高度随内容收缩。
- 关系预览天赋树改为官网弹层风格(居中「天赋树」、25→10 圆形等级徽标、左右选项行)。
- 上分帝 Web天赋树改为官网弹层风格(居中「天赋树」、25→10 圆形等级徽标、左右选项行)。
- `heroes.json` 迁至 `data/heroes.json`;可再生成的对位/审计/报告文件改由 `.gitignore` 忽略。
- `assets/role_icons/` 改用 Valve 游戏内选人筛选图标(`filter_*`);灰蓝底板 + 深色
符号,透明角填品红 chroma,叠层不再出现黑边。
@@ -268,24 +423,24 @@
### Fixed
- 关系预览英雄卡片 title 垃圾清理:`data/relations.json` 的 counters 被
- 上分帝 Web英雄卡片 title 垃圾清理:`data/relations.json` 的 counters 被
`relations_patch_draft.json` 注入 100 条 reason 为 `opendota_adv/stratz_adv`
胜率数值的边(违反 `meta.note: no winrate` 与 AGENTS.md「不要用胜率/场次表达
机制克制」),拼进 hover title 显示为「opendota_adv=0.13; stratz_adv=4.47」
之类。删除这 100 条 numeric-reason 边(含水人 axe/hoodwink/windrunner →
morphling 三条),保留 5 条定性 + 149 条空 reason counters + 11 条
synergies(空 reason 经 `.filter(Boolean)` 不进 title)。
- 关系预览英雄页:`#detail` 不再 `flex: 1` 撑满网格下方剩余视口(短内容时大片黑底空白);
- 上分帝 Web英雄页:`#detail` 不再 `flex: 1` 撑满网格下方剩余视口(短内容时大片黑底空白);
改为按内容收缩(`fit-content`),并用 `max-height` 限制长文案在面板内滚动。
- 关系预览技能行对齐官网:左起「天赋树图标 → 先天圆形图标 → 方形技能」;天赋详情改为
- 上分帝 Web技能行对齐官网:左起「天赋树图标 → 先天圆形图标 → 方形技能」;天赋详情改为
悬停/点击浮动弹层(不再内联占高);图标见 `assets/ability_icons/talent_tree.png`
- 关系预览天赋树数值修复:天赋 `name_loc``{s:bonus_<sv>}` token 原全部
- 上分帝 Web天赋树数值修复:天赋 `name_loc``{s:bonus_<sv>}` token 原全部
显示为 `+?`/`-?``format_loc` 只读 `values_float``talent_rows` 只传天赋
自身空 `special_values`)。改为跨技能按 talent key 索引 `bonuses` + sv 名
/单 token 配对两层兜底填 `bonus.value`;重生成 `data/hero_abilities.json`
1016 条全部恢复。naga_siren「捕捞速度」上游缺 bonus,经
`data/talent_overrides.json` 手工补 `+125`
- 关系预览核心装备「阿哈利姆福佑」→「阿哈利姆神杖」:`ultimate_scepter_2`
- 上分帝 Web核心装备「阿哈利姆福佑」→「阿哈利姆神杖」:`ultimate_scepter_2`
(id 271) 是神杖 (id 108) 的激活形态,OpenDota 分开计数导致 37 个英雄
核心装备列表出现「福佑」甚至神杖+福佑并存。`fetch_hero_items.py` 新增
`fold_blessing_into_scepter` 把 blessing 计数按 max 合并入 scepter
+68
View File
@@ -0,0 +1,68 @@
"""Two-pass keyzoo helper: stash Feishu URL then run notify with Cloudflare.
Pass 1 (feishu asset): python _notify_pass_feishu.py stash
Pass 2 (cloudflare): python _notify_pass_feishu.py run [--day ...] [--dry-run]
Temp file is user-only and deleted after run.
"""
from __future__ import annotations
import argparse
import os
import stat
import sys
from pathlib import Path
STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_feishu_wh.url"
def stash() -> None:
url = os.environ.get("KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL") or os.environ.get(
"FEISHU_WEBHOOK_URL"
)
if not url:
raise SystemExit("no feishu webhook in env")
STASH.write_text(url, encoding="utf-8")
try:
os.chmod(STASH, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
print(f"stashed webhook -> {STASH.name} ({STASH.exists()})")
def run(argv: list[str]) -> int:
if not STASH.exists():
raise SystemExit(f"missing stash {STASH}; run pass1 first")
url = STASH.read_text(encoding="utf-8").strip()
try:
STASH.unlink()
except OSError:
pass
os.environ["FEISHU_WEBHOOK_URL"] = url
# Import after env is set
import notify_site_traffic as n
return n.main(argv)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=("stash", "run"))
ap.add_argument("--day")
ap.add_argument("--dry-run", action="store_true")
args, rest = ap.parse_known_args()
if args.cmd == "stash":
stash()
return 0
argv: list[str] = []
if args.day:
argv += ["--day", args.day]
if args.dry_run:
argv += ["--dry-run"]
argv += rest
return run(argv)
if __name__ == "__main__":
raise SystemExit(main())
+370
View File
@@ -0,0 +1,370 @@
"""Daily site traffic 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.
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
Usage:
python notify_site_traffic.py # yesterday (UTC date)
python notify_site_traffic.py --day 2026-07-27
python notify_site_traffic.py --dry-run # print card, do not POST
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from typing import Any
HOST = "dota2.refining.dev"
ZONE_NAME = "refining.dev"
SITE_URL = "https://dota2.refining.dev"
HUMAN_BROWSERS = frozenset(
{
"Chrome",
"Firefox",
"Safari",
"Edge",
"Opera",
"MobileSafari",
"ChromeMobile",
"ChromeMobileWebview",
"FirefoxMobile",
"SamsungInternet",
"EdgeMobile",
}
)
def _env(*names: str) -> str | None:
for n in names:
v = os.environ.get(n)
if v:
return v
return None
def cf_headers() -> dict[str, str]:
email = _env("CLOUDFLARE_EMAIL", "KEYZOO_ASSET_META_USERNAME")
key = _env("CLOUDFLARE_API_KEY", "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
if not email or not key:
raise SystemExit(
"missing Cloudflare credentials "
"(CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY, or keyzoo inject)"
)
return {
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
}
def feishu_url() -> str:
url = _env("FEISHU_WEBHOOK_URL", "KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL")
if not url:
raise SystemExit(
"missing Feishu webhook "
"(FEISHU_WEBHOOK_URL or KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL)"
)
return url
def http_json(url: str, *, method: str = "GET", body: dict | None = None, headers: dict | None = None) -> dict:
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())
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
raise SystemExit(f"HTTP {e.code} {url}: {raw[:800]}") from e
def gql(query: str, variables: dict) -> dict:
data = http_json(
"https://api.cloudflare.com/client/v4/graphql",
method="POST",
body={"query": query, "variables": variables},
headers=cf_headers(),
)
if data.get("errors"):
raise SystemExit("GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500])
return data
@dataclass
class DayStats:
day: date
all_req: int = 0
all_vis: int = 0
human_home_req: int = 0
human_home_vis: int = 0
other_home_vis: int = 0
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
status: list[tuple[Any, int]] = field(default_factory=list)
def zone_id() -> str:
d = http_json(
f"https://api.cloudflare.com/client/v4/zones?name={ZONE_NAME}",
headers=cf_headers(),
)
rows = d.get("result") or []
if not rows:
raise SystemExit(f"zone not found: {ZONE_NAME}")
return rows[0]["id"]
def fetch_day(zid: str, day: date) -> DayStats:
q = """
query($zoneTag: string!, $day: Date!, $host: string!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
all: httpRequestsAdaptiveGroups(
limit: 1
filter: { date: $day, clientRequestHTTPHost: $host }
) { count sum { visits edgeResponseBytes } }
homeByUA: httpRequestsAdaptiveGroups(
limit: 40
filter: {
date: $day
clientRequestHTTPHost: $host
clientRequestPath: "/"
}
orderBy: [count_DESC]
) {
dimensions { userAgentBrowser }
count
sum { visits }
}
rumByUA: httpRequestsAdaptiveGroups(
limit: 20
filter: {
date: $day
clientRequestHTTPHost: $host
clientRequestPath: "/cdn-cgi/rum"
}
orderBy: [count_DESC]
) {
dimensions { userAgentBrowser }
count
}
homeByCountry: httpRequestsAdaptiveGroups(
limit: 8
filter: {
date: $day
clientRequestHTTPHost: $host
clientRequestPath: "/"
}
orderBy: [count_DESC]
) {
dimensions { clientCountryName }
count
sum { visits }
}
byStatus: httpRequestsAdaptiveGroups(
limit: 8
filter: { date: $day, clientRequestHTTPHost: $host }
orderBy: [count_DESC]
) {
dimensions { edgeResponseStatus }
count
}
}
}
}
"""
data = gql(q, {"zoneTag": zid, "day": day.isoformat(), "host": HOST})
z = data["data"]["viewer"]["zones"][0]
st = DayStats(day=day)
all_rows = z.get("all") or []
if all_rows:
r = all_rows[0]
st.all_req = r.get("count") or 0
st.all_vis = (r.get("sum") or {}).get("visits") or 0
st.bytes = (r.get("sum") or {}).get("edgeResponseBytes") or 0
for r in z.get("homeByUA") or []:
ua = (r.get("dimensions") or {}).get("userAgentBrowser")
c = r.get("count") or 0
v = (r.get("sum") or {}).get("visits") or 0
if ua in HUMAN_BROWSERS:
st.human_home_req += c
st.human_home_vis += v
else:
st.other_home_vis += v
for r in z.get("rumByUA") or []:
ua = (r.get("dimensions") or {}).get("userAgentBrowser")
c = r.get("count") or 0
if ua in HUMAN_BROWSERS:
st.rum_human += c
else:
st.rum_other += c
for r in z.get("homeByCountry") or []:
name = (r.get("dimensions") or {}).get("clientCountryName") or "?"
v = (r.get("sum") or {}).get("visits") or 0
st.top_countries.append((name, r.get("count") or 0, v))
for r in z.get("byStatus") or []:
code = (r.get("dimensions") or {}).get("edgeResponseStatus")
st.status.append((code, r.get("count") or 0))
return st
def fmt_bytes(n: int) -> str:
x = float(n)
for unit in ("B", "KB", "MB", "GB"):
if abs(x) < 1024:
return f"{x:.1f} {unit}"
x /= 1024
return f"{x:.1f} TB"
def build_card(st: DayStats) -> 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")
# Free plan: no BotScore. Human ≈ known-browser homepage visits.
estimate = st.human_home_vis
rum = st.rum_human
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"
),
},
},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"**首页地区 Top**{countries}\n**状态码**{status_line}",
},
},
{
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": (
f"{HOST} · Free 档无 BotScore,按 UA+路径估算 · {now}"
),
}
],
},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "打开站点"},
"type": "primary",
"url": SITE_URL,
}
],
},
]
return {
"msg_type": "interactive",
"card": {
"header": {
"title": {
"tag": "plain_text",
"content": f"上分帝 Web 日活 · {st.day.isoformat()}",
},
"template": "blue" if estimate > 0 else "grey",
},
"elements": elements,
},
}
def post_feishu(payload: dict) -> dict:
url = feishu_url()
return http_json(
url,
method="POST",
body=payload,
headers={"Content-Type": "application/json"},
)
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 date.fromisoformat(s)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--day", help="YYYY-MM-DD (default: yesterday CST)")
ap.add_argument(
"--dry-run",
action="store_true",
help="print JSON card only, do not call Feishu",
)
args = ap.parse_args(argv)
day = parse_day(args.day)
zid = zone_id()
st = fetch_day(zid, day)
payload = build_card(st)
print(
f"{HOST} {day}: human_home_vis={st.human_home_vis} "
f"rum_human={st.rum_human} all_req={st.all_req}",
flush=True,
)
if args.dry_run:
print(json.dumps(payload, ensure_ascii=False, indent=2))
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)
return 1
print("feishu ok:", json.dumps(resp, ensure_ascii=False)[:200])
return 0
if __name__ == "__main__":
raise SystemExit(main())
+38
View File
@@ -0,0 +1,38 @@
# Local / scheduled runner: inject both keyzoo secrets then post Feishu digest.
#
# Requires Keyzoo desktop daemon (MCP asset_exec). From an agent:
# run this script is not enough alone — use notify_site_traffic.py via
# dual asset_exec, or set env manually:
#
# $env:CLOUDFLARE_EMAIL = "..."
# $env:CLOUDFLARE_API_KEY = "..."
# $env:FEISHU_WEBHOOK_URL = "..."
# python notify_site_traffic.py
#
# Windows Task Scheduler example (after env is available in the task):
# Program: python
# Arguments: notify_site_traffic.py
# Start in: <repo>
# Trigger: Daily 09:00
param(
[string]$Day = "",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
$argsList = @()
if ($Day) { $argsList += @("--day", $Day) }
if ($DryRun) { $argsList += "--dry-run" }
if (-not $env:CLOUDFLARE_API_KEY -and -not $env:KEYZOO_ASSET_SECRET_GLOBAL_API_KEY) {
Write-Error "Missing Cloudflare API key in env (set CLOUDFLARE_* or run via keyzoo)."
}
if (-not $env:FEISHU_WEBHOOK_URL -and -not $env:KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL) {
Write-Error "Missing Feishu webhook in env (set FEISHU_WEBHOOK_URL or run via keyzoo)."
}
python notify_site_traffic.py @argsList