From 37769580f566c00a9de3d14ba30cf50fe27ca90d Mon Sep 17 00:00:00 2001 From: voson Date: Tue, 28 Jul 2026 21:17:23 +0800 Subject: [PATCH] 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 --- .gitea/workflows/site-traffic-notify.yml | 7 +- .gitea/workflows/web-daily.yml | 41 +++ .gitea/workflows/web-patch.yml | 39 +++ .gitea/workflows/web-weekly.yml | 39 +++ AGENTS.md | 58 +++- CHANGELOG.md | 82 ++++- _notify_pass_feishu.py | 9 + notify_site_traffic.py | 425 ++++++++++++++++++++--- refresh_web.py | 253 ++++++++++++++ requirements-web.txt | 4 + 10 files changed, 889 insertions(+), 68 deletions(-) create mode 100644 .gitea/workflows/web-daily.yml create mode 100644 .gitea/workflows/web-patch.yml create mode 100644 .gitea/workflows/web-weekly.yml create mode 100644 refresh_web.py create mode 100644 requirements-web.txt diff --git a/.gitea/workflows/site-traffic-notify.yml b/.gitea/workflows/site-traffic-notify.yml index 4dd4e1a..9d89a5a 100644 --- a/.gitea/workflows/site-traffic-notify.yml +++ b/.gitea/workflows/site-traffic-notify.yml @@ -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 diff --git a/.gitea/workflows/web-daily.yml b/.gitea/workflows/web-daily.yml new file mode 100644 index 0000000..82759bf --- /dev/null +++ b/.gitea/workflows/web-daily.yml @@ -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 diff --git a/.gitea/workflows/web-patch.yml b/.gitea/workflows/web-patch.yml new file mode 100644 index 0000000..2f8ee47 --- /dev/null +++ b/.gitea/workflows/web-patch.yml @@ -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 diff --git a/.gitea/workflows/web-weekly.yml b/.gitea/workflows/web-weekly.yml new file mode 100644 index 0000000..b9bfc2a --- /dev/null +++ b/.gitea/workflows/web-weekly.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 670c54f..ba28f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 只比对列表与本地 details,stdout 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 日活摘要 → 飞书 webhook(Cloudflare 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 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 注入) | | `_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 显式 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 名用 `_aghanims_scepter`/`_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 对位/协同 Top(counters/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 Actions(self-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 / OSS(CI 会装) 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 # 只比对列表与本地 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 --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 变更后同步 OSS(keyzoo voson-RAM) 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_watch.py --once python recognize.py samples/raw/<帧>.png --sheet diff --git a/CHANGELOG.md b/CHANGELOG.md index b62b0d0..d08afae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//matchups`:天梯对位优势 / 被克 / 协同 Top(与网格机制「克/怕/搭」分开展示) - - 「排行」新增「英雄 Meta」模式 `#/rankings/meta[/bracket]`(按段位上场排序) + - 英雄详情「走势」优先 STRATZ:勋章条 + **最近 1 周**三卡 + 分路 + **近 8 周**逐周列表(OpenDota 作兜底) + - 新子 Tab「对位」`#/heroes//matchups`:克制 / 被克 / 搭档三列 STRATZ Top(与网格机制「克/怕/搭」分开展示) + - 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜 - 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(gitignore;仅 Web,不进 recommend / relations) - 上分帝 Web「近期比赛」Tab(`#/heroes//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 -- 上分帝 Web:STRATZ 走势 / 对位 Tab / 英雄 Meta 榜(见 Unreleased 明细;站点版本 `0.5.29`)。 +- 上分帝 Web:STRATZ 走势 / 对位 Tab(见 Unreleased 明细;站点版本 `0.5.29`)。 ## [0.5.28] - 2026-07-28 diff --git a/_notify_pass_feishu.py b/_notify_pass_feishu.py index 026bbdf..eeb3f6a 100644 --- a/_notify_pass_feishu.py +++ b/_notify_pass_feishu.py @@ -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 diff --git a/notify_site_traffic.py b/notify_site_traffic.py index 18df534..7e97496 100644 --- a/notify_site_traffic.py +++ b/notify_site_traffic.py @@ -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 diff --git a/refresh_web.py b/refresh_web.py new file mode 100644 index 0000000..54bba04 --- /dev/null +++ b/refresh_web.py @@ -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() diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..54fbe0b --- /dev/null +++ b/requirements-web.txt @@ -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