v0.5.115: add History routing and SEO prerender for Climperor Web.

Path URLs, crawlable hero/mechanics pages, and sitemap make the static site indexable while keeping SPA hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 04:55:33 +08:00
co-authored by Cursor
parent 38f46ad2ea
commit 544ea42d40
16 changed files with 838 additions and 81 deletions
+9 -8
View File
@@ -70,8 +70,9 @@ climperor/
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON | | `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 | | `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` | | `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range | | `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;History 深度路径 SPA fallback 回 `index.html``/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量`web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base``config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) | | `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` / `SITE_ORIGIN` `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` / `--site-origin``config.js`;设 `--static-asset-base` 时不拷贝图标进 dist调用 `seo_prerender.py` 写英雄/机制预渲染 HTML + `sitemap.xml` / `llms.txt`;拷贝 `_redirects` / `robots.txt``--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
| `web/seo_prerender.py` | 导出期 SEO/GEO:注入 title/description/canonical/OG/JSON-LD 与 `#seo-prerender` 正文;生成全英雄 `/heroes/{key}`、机制 `/mechanics/{effect}`、顶层页、`sitemap.xml``llms.txt` |
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env | | `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳与 `is_live` 的业务摘要有变更才 OSS upload + `deploy_relations.py`(亦监视 frontend / `relations.json` / `heroes.json` / 网格顺序);写 `web/.refresh/summary.json`(步骤/耗时/stale/部署;`--dry-run` 只打印不写盘、不 restore/save 缓存);同 runner 文件锁串行发布,patch 遇全量刷新则延后并标 `skipped``--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品 + counter`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears | | `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳与 `is_live` 的业务摘要有变更才 OSS upload + `deploy_relations.py`(亦监视 frontend / `relations.json` / `heroes.json` / 网格顺序);写 `web/.refresh/summary.json`(步骤/耗时/stale/部署;`--dry-run` 只打印不写盘、不 restore/save 缓存);同 runner 文件锁串行发布,patch 遇全量刷新则延后并标 `skipped``--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品 + counter`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears |
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 | | `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
@@ -110,7 +111,7 @@ climperor/
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) | | `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) |
| `web/data/leaderboards.json` | Valve Immortal 四区 Top100`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) | | `web/data/leaderboards.json` | Valve Immortal 四区 Top100`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音 profile 补全;平台含抖音 / B 站 / 斗鱼;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);列表排序:先 `is_live` 再粉丝数降序;视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s`stale`/失败不显示角标;`data.json` daily 仅作首屏兜底直至接口返回;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) | | `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音 profile 补全;平台含抖音 / B 站 / 斗鱼;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);列表排序:先 `is_live` 再粉丝数降序;视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s`stale`/失败不显示角标;`data.json` daily 仅作首屏兜底直至接口返回;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
| `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win`weeks`/`latest`+ **同段位最近 1 周分路**`winWeek`+`positionIds`+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`#/trends` 近 N 周榜;**不**参与局内推荐) | | `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win`weeks`/`latest`+ **同段位最近 1 周分路**`winWeek`+`positionIds`+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`/trends` 近 N 周榜;**不**参与局内推荐) |
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) | | `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) | | `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) | | `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
@@ -120,8 +121,8 @@ climperor/
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` | | `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` |
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations | | `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations |
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) | | `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `functions/`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}`;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截 | | `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `_redirects` / `robots.txt` / `functions/`);`config.js``SITE_VERSION``SITE_ORIGIN``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}`;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截(搜索/AI 爬虫 UA 跳过) |
| `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) | | `web/frontend/router.js` | History 路径路由:`parseHash` / `serializeHash`(操作 pathname+search/ `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层 `/heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal `/rankings[/region]` / 明星比赛 `/matches[/account_id][?origin=pro\|china][&page=N]` / 主播 `/streamers` / 走势 `/trends[/bracket][?sort=pr]` / 机制 `/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `/rankings/meta` 与 hash `#/...` 兼容) |
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) | | `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) | | `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` | | `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` |
@@ -163,9 +164,9 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。 - **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。 - **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board``web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `#/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;平台含抖音 / B 站 / 斗鱼),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。 - **克制 / 搭档数据**:机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board``web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;平台含抖音 / B 站 / 斗鱼),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。 - **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
- **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。 - **机制查询页**:顶层 `/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。 - **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。 - **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。 - **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
@@ -221,7 +222,7 @@ python pc/evaluate.py
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md` - 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。 - 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。 - 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`web/serve_relations.py` 无需改;`web/export_relations_site.py` 导出文件列表`router.js` - 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`Cloudflare)与 `web/serve_relations.py` 的 SPA fallback;预渲染路径变更时同步 `web/seo_prerender.py` 导出文件列表`router.js` / `_redirects` / `robots.txt`
-`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles``pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。 -`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles``pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py``SITE_VERSION``web/frontend/config.js` 的同名变量,以及 `index.html``style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md` - 发版上分帝 Web 时同步 bump `web/export_relations_site.py``SITE_VERSION``web/frontend/config.js` 的同名变量,以及 `index.html``style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md`
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py` - 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
+8 -7
View File
@@ -240,13 +240,14 @@ Dota 2 (-gamestateintegration)
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。 - **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。 - **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
- **上分帝 Web**`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。 - **上分帝 Web**`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。
另有顶级 **排行**、**走势**`#/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json` 另有顶级 **排行**、**走势**`/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json`
英雄详情子标签含 **走势**(见上;`#/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top`#/heroes/<key>/matchups`)、**近期比赛**`#/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。 英雄详情子标签含 **走势**(见上;`/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top`/heroes/<key>/matchups`)、**近期比赛**`/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 / - **Web History 路由 + SEO 预渲染**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe``#/heroes/axe/trends` 等)。选 Hash 标签筛选 / 搜索)双向同步进路径 URL`/heroes/axe/core?tags=核心&q=axe``/heroes/axe/trends` 等)。
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`web/serve_relations.py` 零改动、 `#/...` 书签在装载时 `replaceState` 迁到路径。写 URL 用 `history.pushState`/`replaceState`
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发 (静默,不触发 `popstate`);前进/后退靠 `popstate`。Cloudflare `_redirects`
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。 `serve_relations.py` 对未知深度路径回退 `index.html`。导出(`seo_prerender.py`)为首页、
顶层页、全英雄与机制效果写可抓取 HTML + `sitemap.xml` / `llms.txt`
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在 搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/frontend/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点 `web/frontend/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl` `syncStateToUrl`
+7
View File
@@ -4,6 +4,13 @@
## [Unreleased] ## [Unreleased]
## [0.5.115] - 2026-07-30
### Added
- 上分帝 Web SEO / GEOHistory 路径路由(兼容旧 `#/...` 书签)、首页 meta/OG/JSON-LD、路由态动态 `title`/`description`、爬虫跳过移动端门禁。
- 导出预渲染:各英雄 `/heroes/{key}`、机制 `/mechanics/{effect}` 与顶层页可抓取 HTML;生成 `sitemap.xml` / `robots.txt` / `llms.txt`Cloudflare `_redirects` SPA fallback;本地 `serve_relations` 同步支持深度链接刷新。
## [0.5.114] - 2026-07-30 ## [0.5.114] - 2026-07-30
### Added ### Added
+3 -3
View File
@@ -56,7 +56,7 @@ python web/fetch_streamer_live.py # 探测真实在播状态 → is_live/live_p
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新) python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
``` ```
Web 站点带 **Hash 路由**`#/heroes/axe/core``#/heroes/axe/trends``#/heroes/axe/matchups``#/heroes/axe/matches``#/trends/legend``#/rankings``#/matches`(明星比赛,可 `#/matches/898754153``#/matches?origin=china``#/matches?page=2`)、`#/items/black_king_bar``#/patches/7.41``#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages Web 站点带 **History 路径路由**`/heroes/axe/core``/heroes/axe/trends``/heroes/axe/matchups``/heroes/axe/matches``/trends/legend``/rankings``/matches`(明星比赛,可 `/matches/898754153``/matches?origin=china``/matches?page=2`)、`/items/black_king_bar``/patches/7.41``/patches` = 最新,`/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。`#/...` 书签会自动迁移到路径。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。导出时预渲染英雄/机制等页并生成 `sitemap.xml`SEO);Cloudflare Pages 用 `_redirects` 做 SPA fallback
### 部署到 Cloudflare Pages ### 部署到 Cloudflare Pages
@@ -118,8 +118,8 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。 顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);列表先按是否在播、再按粉丝数降序;抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/` 顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);列表先按是否在播、再按粉丝数降序;抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`
顶部另有 **比赛** 页(`#/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。 顶部另有 **比赛** 页(`/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`STRATZ 周胜率数据)。 顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`/trends[/bracket]`STRATZ 周胜率数据)。
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。 顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json``python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。 顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json``python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 仅用 `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py``web/item_fears.py`。**尚无对局内出装推荐**。 数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 仅用 `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py``web/item_fears.py`。**尚无对局内出装推荐**。
+46 -5
View File
@@ -2,12 +2,15 @@
Usage: Usage:
python export_relations_site.py [--out dist/relations] [--with-videos] python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL] [--static-asset-base URL] [--ability-video-base URL] [--static-asset-base URL] [--site-origin URL]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) + Copies web/frontend/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host the referenced image assets into one directory, ready for any static host
(GitHub Pages, Cloudflare Pages, nginx, ...). (GitHub Pages, Cloudflare Pages, nginx, ...).
Also runs seo_prerender: crawlable HTML for heroes/mechanics/top pages,
plus sitemap.xml / llms.txt / robots.txt / _redirects (History SPA).
Notes: Notes:
- Only already-cached assets are exported. For full ability-icon coverage - Only already-cached assets are exported. For full ability-icon coverage
run `python fetch_hero_abilities.py --icons-only` first. run `python fetch_hero_abilities.py --icons-only` first.
@@ -50,9 +53,10 @@ from shared.paths import (
WEB_DIST, WEB_DIST,
) )
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.114" SITE_VERSION = "0.5.115"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -133,14 +137,17 @@ def write_config_js(
ability_video_base: str, ability_video_base: str,
static_asset_base: str, static_asset_base: str,
site_version: str, site_version: str,
site_origin: str,
) -> None: ) -> None:
"""Write config.js consumed by app.js.""" """Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/") video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/") static = (static_asset_base or "").strip().rstrip("/")
ver = (site_version or "").strip() ver = (site_version or "").strip()
origin = (site_origin or "").strip().rstrip("/")
(out / "config.js").write_text( (out / "config.js").write_text(
"/* generated by export_relations_site.py — do not edit */\n" "/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n" f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
f"var SITE_ORIGIN = {json.dumps(origin, ensure_ascii=False)};\n"
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n" f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n", f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
encoding="utf-8", encoding="utf-8",
@@ -170,6 +177,12 @@ def main() -> None:
"when set, image dirs are not copied into dist; " "when set, image dirs are not copied into dist; "
"falls back to STATIC_ASSET_BASE env, else empty", "falls back to STATIC_ASSET_BASE env, else empty",
) )
ap.add_argument(
"--site-origin",
default=None,
help="canonical site origin for SEO (sitemap / og / config SITE_ORIGIN); "
"falls back to SITE_ORIGIN env, else https://dota2.refining.dev",
)
args = ap.parse_args() args = ap.parse_args()
video_base = ( video_base = (
@@ -182,6 +195,11 @@ def main() -> None:
if args.static_asset_base is not None if args.static_asset_base is not None
else os.environ.get("STATIC_ASSET_BASE", "") else os.environ.get("STATIC_ASSET_BASE", "")
) )
site_origin = (
args.site_origin
if args.site_origin is not None
else os.environ.get("SITE_ORIGIN", DEFAULT_SITE_ORIGIN)
)
out = Path(args.out).resolve() out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out: if out == ROOT.resolve() or out.parent == out:
@@ -190,7 +208,16 @@ def main() -> None:
shutil.rmtree(out) shutil.rmtree(out)
out.mkdir(parents=True) out.mkdir(parents=True)
for name in ("index.html", "router.js", "app.js", "style.css", "mobile-gate.js", "_headers"): for name in (
"index.html",
"router.js",
"app.js",
"style.css",
"mobile-gate.js",
"_headers",
"_redirects",
"robots.txt",
):
src = WEB_DIR / name src = WEB_DIR / name
if src.is_file(): if src.is_file():
shutil.copy2(src, out / name) shutil.copy2(src, out / name)
@@ -205,6 +232,7 @@ def main() -> None:
ability_video_base=video_base, ability_video_base=video_base,
static_asset_base=static_base, static_asset_base=static_base,
site_version=SITE_VERSION, site_version=SITE_VERSION,
site_origin=site_origin,
) )
payload = build_payload() payload = build_payload()
@@ -217,6 +245,14 @@ def main() -> None:
separators=(",", ":"), separators=(",", ":"),
) )
template_html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
seo_counts = write_seo_bundle(
out,
template_html,
payload,
site_origin=site_origin,
)
if static_base: if static_base:
counts = { counts = {
name: 0 name: 0
@@ -244,12 +280,17 @@ def main() -> None:
print(f"exported static site -> {out}") print(f"exported static site -> {out}")
for name, n in counts.items(): for name, n in counts.items():
print(f" {name}/: {n} files") print(f" {name}/: {n} files")
print(
f" seo prerender: top={seo_counts['top']} "
f"heroes={seo_counts['heroes']} mechanics={seo_counts['mechanics']} "
f"(+ sitemap.xml / llms.txt / robots.txt)"
)
if n_functions: if n_functions:
print(f" functions/: {n_functions} files (Pages Functions)") print(f" functions/: {n_functions} files (Pages Functions)")
if args.with_videos: if args.with_videos:
print(f" ability-video/: {n_videos} files") print(f" ability-video/: {n_videos} files")
print( print(
f" config.js SITE_VERSION={SITE_VERSION!r} " f" config.js SITE_VERSION={SITE_VERSION!r} SITE_ORIGIN={site_origin!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}" f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}"
) )
print(f" total: {total / 1e6:.1f} MB") print(f" total: {total / 1e6:.1f} MB")
+3
View File
@@ -0,0 +1,3 @@
# Cloudflare Pages: static files win; these cover History deep links without a file.
/heroes/:key/:tab /heroes/:key/index.html 200
/* /index.html 200
+137 -2
View File
@@ -1,4 +1,4 @@
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */ /* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
function trimBase(raw) { function trimBase(raw) {
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : ""; return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
@@ -76,6 +76,138 @@ function siteVersionLabel() {
return v ? `v${v}` : ""; return v ? `v${v}` : "";
} }
function siteOrigin() {
const configured = typeof SITE_ORIGIN === "string" ? SITE_ORIGIN.trim().replace(/\/+$/, "") : "";
if (configured) return configured;
if (typeof location !== "undefined" && location.origin) return location.origin;
return "";
}
const PAGE_SEO_LABELS = {
heroes: "英雄克制与搭档",
rankings: "Immortal 排行",
streamers: "主播",
matches: "明星比赛",
trends: "近 8 周走势",
mechanics: "机制查询",
items: "物品商店",
patches: "版本更新",
};
const DETAIL_TAB_SEO_LABELS = {
skills: "技能",
core: "核心装",
fears: "怕的装备",
trends: "走势",
matchups: "对位",
matches: "近期比赛",
streamers: "主播",
patches: "版本改动",
};
function setMetaByKey(attr, key, content) {
if (!content) return;
let el = document.querySelector(`meta[${attr}="${key}"]`);
if (!el) {
el = document.createElement("meta");
el.setAttribute(attr, key);
document.head.appendChild(el);
}
el.setAttribute("content", content);
}
function describeStateForSeo(st) {
const brand = "上分帝";
const page = st.page || "heroes";
let title = `${PAGE_SEO_LABELS[page] || "DOTA2"}${brand}`;
let description =
"Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。";
let path = "/" + page;
try {
if (typeof serializeHash === "function") path = serializeHash(st) || "/heroes";
} catch (_) {
/* keep path */
}
if (!st.data) return { title, description, path };
if (page === "heroes" && st.selectedKey) {
const hero = heroByKey(st.selectedKey);
const name = (hero && hero.name_loc) || st.selectedKey;
const aliases = (hero && hero.aliases) || [];
const tab = DETAIL_TAB_SEO_LABELS[st.detailTab] || "详情";
title = `${name} ${tab} / 克制搭档 — ${brand}`;
const aliasBit = aliases.length ? `${aliases.slice(0, 3).join("、")}` : "";
description = `${name}${aliasBit}的 Dota 2 机制克制、被克制与搭档参考,以及技能、出装、走势与对位数据。`;
} else if (page === "mechanics") {
const mq = st.data.mechanic_query || {};
const labels = mq.labels || {};
const effect = st.mechanicEffect || "basic_dispel";
const label = labels[effect] || effect;
title = `${label} — 机制查询 — ${brand}`;
const blurb = (mq.blurbs && mq.blurbs[effect]) || "";
description = blurb || `查询 Dota 2 中施加「${label}」的技能与物品。`;
} else if (page === "items" && st.selectedItemKey) {
const meta = shopItem(st.selectedItemKey);
const name = (meta && (meta.name_loc || meta.dname)) || st.selectedItemKey;
title = `${name} — 物品 — ${brand}`;
description = `${name} 的合成、描述与机制标签(上分帝物品页)。`;
} else if (page === "patches") {
const ver =
st.selectedPatch ||
((st.data.patches && st.data.patches[0]) || {}).version;
if (ver) {
title = `版本 ${ver}${brand}`;
description = `Dota 2 ${ver} 游戏性更新摘要(上分帝版本页)。`;
}
} else if (page === "trends") {
title = `近 8 周走势 — ${brand}`;
description = "各勋章段位近 8 周英雄胜率与上场率走势榜。";
} else if (page === "rankings") {
title = `Immortal 排行榜 — ${brand}`;
description = "Valve Immortal 四区 Top100 选手榜。";
} else if (page === "streamers") {
title = `Dota 2 主播 — ${brand}`;
description = "精选 Dota 2 主播目录与高光(抖音 / B 站 / 斗鱼)。";
} else if (page === "matches") {
title = `明星比赛 — ${brand}`;
description = "明星选手近期职业与国服对局、终局出装与加点。";
} else if (page === "heroes") {
title = `英雄克制与搭档 — ${brand}`;
description =
"按英雄浏览定性克制 / 被克制 / 搭档理由,以及技能、核心装、走势与对位。";
}
return { title, description, path };
}
function updateDocumentMeta() {
if (typeof document === "undefined" || !document.title) return;
const { title, description, path } = describeStateForSeo(state);
document.title = title;
setMetaByKey("name", "description", description);
setMetaByKey("property", "og:title", title);
setMetaByKey("property", "og:description", description);
setMetaByKey("name", "twitter:title", title);
setMetaByKey("name", "twitter:description", description);
const origin = siteOrigin();
if (origin) {
const url = origin + (path.startsWith("/") ? path : `/${path}`);
setMetaByKey("property", "og:url", url);
let link = document.querySelector('link[rel="canonical"]');
if (!link) {
link = document.createElement("link");
link.setAttribute("rel", "canonical");
document.head.appendChild(link);
}
link.setAttribute("href", url);
}
}
function clearSeoPrerender() {
const el = document.getElementById("seo-prerender");
if (el) el.remove();
}
function renderPatchesSiteVersion() { function renderPatchesSiteVersion() {
const el = $("#patches-site-version"); const el = $("#patches-site-version");
if (!el) return; if (!el) return;
@@ -5468,6 +5600,7 @@ function render() {
} else { } else {
renderPatches(); renderPatches();
} }
updateDocumentMeta();
} }
// Search-box URL sync timers (debounced replace so typing does not spam history). // Search-box URL sync timers (debounced replace so typing does not spam history).
@@ -5698,7 +5831,8 @@ async function main() {
if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png"); if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png");
const favicon = document.querySelector('link[rel="icon"]'); const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = assetUrl("/ui-icon/dota2_logo.png"); if (favicon) favicon.href = assetUrl("/ui-icon/dota2_logo.png");
const res = await fetch("data.json"); // Absolute path: History routes like /heroes/axe must not resolve data.json relatively.
const res = await fetch("/data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json(); state.data = await res.json();
state.data.relations = state.data.relations || { counters: [], synergies: [] }; state.data.relations = state.data.relations || { counters: [], synergies: [] };
@@ -5755,6 +5889,7 @@ async function main() {
_innateAbilityKeys = null; _innateAbilityKeys = null;
installRouter({ getState: () => state, applyPatch }); installRouter({ getState: () => state, applyPatch });
applyUrlToState(); applyUrlToState();
clearSeoPrerender();
refreshStreamerLiveStatus(); refreshStreamerLiveStatus();
} }
+2 -1
View File
@@ -1,5 +1,6 @@
/* Local defaults; production export overwrites via export_relations_site.py. */ /* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.114"; var SITE_VERSION = "0.5.115";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = ""; var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = ""; var STATIC_ASSET_BASE = "";
+60 -6
View File
@@ -3,13 +3,67 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title> <title>DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制</title>
<meta
name="description"
content="上分帝(Climperor):Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。定性关系理由 + 公开数据走势,助你上分。"
/>
<meta name="keywords" content="DOTA2,上分帝,Climperor,英雄克制,搭档,胜率走势,机制查询,驱散,版本更新" />
<link rel="canonical" href="https://dota2.refining.dev/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="上分帝" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:title" content="DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制" />
<meta
property="og:description"
content="Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"
/>
<meta property="og:url" content="https://dota2.refining.dev/" />
<meta property="og:image" content="https://climperor.oss-cn-shanghai.aliyuncs.com/ui-icon/dota2_logo_wordmark.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="DOTA2 上分帝" />
<meta
name="twitter:description"
content="Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"
/>
<script type="application/ld+json" id="seo-jsonld">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "上分帝",
"alternateName": ["Climperor", "DOTA2 上分帝"],
"url": "https://dota2.refining.dev/",
"inLanguage": "zh-CN",
"description": "Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
"potentialAction": {
"@type": "SearchAction",
"target": "https://dota2.refining.dev/heroes?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" /> <link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.114" /> <link rel="stylesheet" href="/style.css?v=0.5.115" />
<script src="/mobile-gate.js?v=0.5.114"></script> <script src="/mobile-gate.js?v=0.5.115"></script>
</head> </head>
<body> <body>
<h1 class="sr-only">DOTA2 上分帝</h1> <h1 class="sr-only">DOTA2 上分帝</h1>
<aside id="seo-prerender" class="seo-prerender">
<p>
上分帝(Climperor)是面向 Dota 2 的选将与机制参考站:浏览英雄克制与搭档理由、
段位走势、机制查询(驱散与控制)、物品商店与版本更新。请使用桌面浏览器访问完整交互界面。
</p>
<ul>
<li><a href="/heroes">英雄克制与搭档</a></li>
<li><a href="/mechanics">机制查询</a></li>
<li><a href="/trends">近 8 周走势</a></li>
<li><a href="/items">物品商店</a></li>
<li><a href="/patches">版本更新</a></li>
<li><a href="/rankings">Immortal 排行</a></li>
<li><a href="/streamers">主播</a></li>
<li><a href="/matches">明星比赛</a></li>
</ul>
</aside>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true"> <div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
<div class="mobile-gate-card"> <div class="mobile-gate-card">
<div class="mobile-gate-brand" aria-hidden="true"> <div class="mobile-gate-brand" aria-hidden="true">
@@ -178,8 +232,8 @@
<section class="detail" id="detail" aria-live="polite"></section> <section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.114"></script> <script src="/config.js?v=0.5.115"></script>
<script src="/router.js?v=0.5.114"></script> <script src="/router.js?v=0.5.115"></script>
<script src="/app.js?v=0.5.114"></script> <script src="/app.js?v=0.5.115"></script>
</body> </body>
</html> </html>
+13
View File
@@ -2,8 +2,16 @@
* Early mobile client gate (loaded from <head>). * Early mobile client gate (loaded from <head>).
* Sets html.mobile-client before paint; wires the demand button after DOM ready. * Sets html.mobile-client before paint; wires the demand button after DOM ready.
* Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path. * Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path.
* Search / AI crawlers skip the gate so prerendered HTML stays indexable.
*/ */
(function () { (function () {
function isCrawler() {
var ua = navigator.userAgent || "";
return /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora link preview|pinterest|redditbot|linkedinbot|twitterbot|whatsapp|google-inspectiontool|bytespider|baiduspider|yandex|duckduckbot|applebot|semrush|ahrefs|gptbot|claudebot|anthropic|perplexity|oai-searchbot|chatgpt/i.test(
ua
);
}
function isMobileClient() { function isMobileClient() {
var ua = navigator.userAgent || ""; var ua = navigator.userAgent || "";
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) { if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
@@ -16,6 +24,11 @@
return false; return false;
} }
if (isCrawler()) {
window.__CLIMPEROR_MOBILE__ = false;
return;
}
var mobile = isMobileClient(); var mobile = isMobileClient();
window.__CLIMPEROR_MOBILE__ = mobile; window.__CLIMPEROR_MOBILE__ = mobile;
if (!mobile) return; if (!mobile) return;
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://dota2.refining.dev/sitemap.xml
+65 -44
View File
@@ -1,38 +1,43 @@
/* global window, history, location */ /* global window, history, location */
/** /**
* Hash-based router for the Climperor web site (web/relations). * Path-based router for the Climperor web site (web/frontend).
* *
* Synchronizes the browser URL with app state across these dimensions: * Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches (top-level tab) * - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches
* - hero: selected hero key + detail sub-tab * - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends) * (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends)
* - rankings: Immortal leaderboard region * - rankings: Immortal leaderboard region
* #/rankings[/region] (legacy #/rankings/meta[/bracket] players / china) * /rankings[/region] (legacy /rankings/meta[/bracket] players / china)
* - matches: star-player recent matches (pro_matches) * - matches: star-player recent matches (pro_matches)
* #/matches[/account_id][?origin=pro|china][&page=N] * /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted) * (default origin all omitted; page=1 omitted)
* - streamers: curated Douyin streamer directory * - streamers: curated streamer directory
* #/streamers * /streamers
* - trends: medal bracket for the 8-week win/pick board * - trends: medal bracket for the 8-week win/pick board
* #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted) * /trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
* - mechanics: applies-effect query (dispel / CC) * - mechanics: applies-effect query (dispel / CC)
* #/mechanics[/{effect}] (default effect basic_dispel omitted) * /mechanics[/{effect}] (default effect basic_dispel omitted)
* - item: selected shop item key (items page) * - item: selected shop item key (items page)
* - patch: selected version string (patches page; latest when absent) * - patch: selected version string (patches page; latest when absent)
* - query params: tags=csv (heroes), q=search text (heroes + items) * - query params: tags=csv (heroes), q=search text (heroes + items)
* *
* Legacy hash URLs (#/heroes/...) are migrated once to path URLs via replaceState.
*
* Two-way binding without feedback loops: * Two-way binding without feedback loops:
* - App calls syncStateToUrl() after each state mutation. We use * - App calls syncStateToUrl() after each state mutation. We use
* history.pushState/replaceState, which update the URL silently * history.pushState/replaceState, which update the URL silently
* (no hashchange event fires) so applyUrlToState is never re-entered. * (no popstate) so applyUrlToState is never re-entered.
* - Back/forward navigation fires hashchange applyUrlToState parses * - Back/forward navigation fires popstate applyUrlToState parses
* the new hash, hands a validated patch back to app, and app re-renders. * the new path, hands a validated patch back to app, and app re-renders.
*
* API names parseHash / serializeHash are kept for call-site stability; both
* now operate on pathname + search (not location.hash).
* *
* Depends on app.js for state shape and render(); installed via installRouter(). * Depends on app.js for state shape and render(); installed via installRouter().
*/ */
const ROUTE_DEFAULT = "#/heroes"; const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"]; const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_DETAIL_TABS = [ const VALID_DETAIL_TABS = [
"skills", "skills",
@@ -85,11 +90,20 @@ let _deps = null;
*/ */
function installRouter(deps) { function installRouter(deps) {
_deps = deps; _deps = deps;
window.addEventListener("hashchange", applyUrlToState); migrateLegacyHashRoute();
window.addEventListener("popstate", applyUrlToState);
} }
/** Parse a hash string into a state patch (all fields nullable). */ /** One-shot: #/heroes/... → /heroes/... (bookmarks / shared links). */
function parseHash(hash) { function migrateLegacyHashRoute() {
const hash = window.location.hash || "";
if (!hash.startsWith("#/")) return;
const next = hash.slice(1) || ROUTE_DEFAULT;
history.replaceState(null, "", next);
}
/** Parse a path (+ optional query) into a state patch (all fields nullable). */
function parseHash(hashOrPath) {
const out = { const out = {
page: null, page: null,
heroKey: null, heroKey: null,
@@ -107,14 +121,17 @@ function parseHash(hash) {
query: null, query: null,
itemQuery: null, itemQuery: null,
}; };
let raw = hash || ""; let raw = hashOrPath || "";
if (raw.startsWith("#")) raw = raw.slice(1); if (raw.startsWith("#")) raw = raw.slice(1);
if (!raw.startsWith("/")) raw = "/" + raw; if (!raw.startsWith("/")) raw = "/" + raw;
const qIdx = raw.indexOf("?"); const qIdx = raw.indexOf("?");
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw; const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : ""; const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
const segs = pathPart.split("/").filter(Boolean); const segs = pathPart.split("/").filter(Boolean);
if (!segs.length) return out; if (!segs.length) {
out.page = "heroes";
return out;
}
const page = segs[0]; const page = segs[0];
if (!VALID_PAGES.includes(page)) return out; if (!VALID_PAGES.includes(page)) return out;
out.page = page; out.page = page;
@@ -122,7 +139,7 @@ function parseHash(hash) {
if (segs[1]) out.heroKey = safeDecode(segs[1]); if (segs[1]) out.heroKey = safeDecode(segs[1]);
if (segs[2]) out.detailTab = safeDecode(segs[2]); if (segs[2]) out.detailTab = safeDecode(segs[2]);
} else if (page === "rankings") { } else if (page === "rankings") {
// Legacy #/rankings/meta[/bracket] → Immortal players board (default region). // Legacy /rankings/meta[/bracket] → Immortal players board (default region).
if (segs[1] && segs[1] !== "meta") { if (segs[1] && segs[1] !== "meta") {
out.rankingRegion = safeDecode(segs[1]); out.rankingRegion = safeDecode(segs[1]);
} }
@@ -179,56 +196,56 @@ function safeDecode(s) {
} }
} }
/** Serialize current app state into a hash string (with leading '#'). */ /** Serialize current app state into a path string (leading '/', optional ?query). */
function serializeHash(state) { function serializeHash(state) {
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT; if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
let hash = "#/" + state.page; let path = "/" + state.page;
if (state.page === "heroes") { if (state.page === "heroes") {
if (state.selectedKey) { if (state.selectedKey) {
hash += "/" + encodeURIComponent(state.selectedKey); path += "/" + encodeURIComponent(state.selectedKey);
let tab = state.detailTab; let tab = state.detailTab;
if (tab === "stats") tab = "trends"; // normalize legacy alias in URL if (tab === "stats") tab = "trends"; // normalize legacy alias in URL
if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") { if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") {
hash += "/" + encodeURIComponent(tab); path += "/" + encodeURIComponent(tab);
} }
} }
} else if (state.page === "rankings") { } else if (state.page === "rankings") {
// Omit region when it is the default (china) — bare #/rankings means China. // Omit region when it is the default (china) — bare /rankings means China.
const region = state.rankingRegion || DEFAULT_RANKING_REGION; const region = state.rankingRegion || DEFAULT_RANKING_REGION;
if (region && region !== DEFAULT_RANKING_REGION) { if (region && region !== DEFAULT_RANKING_REGION) {
hash += "/" + encodeURIComponent(region); path += "/" + encodeURIComponent(region);
} }
} else if (state.page === "matches") { } else if (state.page === "matches") {
if (state.matchesPlayerId) { if (state.matchesPlayerId) {
hash += "/" + encodeURIComponent(String(state.matchesPlayerId)); path += "/" + encodeURIComponent(String(state.matchesPlayerId));
} }
} else if (state.page === "trends") { } else if (state.page === "trends") {
// Omit bracket when it is the default (legend) — bare #/trends means legend. // Omit bracket when it is the default (legend) — bare /trends means legend.
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET; const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) { if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) {
hash += "/" + encodeURIComponent(bracket); path += "/" + encodeURIComponent(bracket);
} }
const sort = state.trendsSort || DEFAULT_TRENDS_SORT; const sort = state.trendsSort || DEFAULT_TRENDS_SORT;
if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) { if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) {
hash += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort); path += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort);
} }
} else if (state.page === "mechanics") { } else if (state.page === "mechanics") {
const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT; const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT;
if (effect && effect !== DEFAULT_MECHANIC_EFFECT) { if (effect && effect !== DEFAULT_MECHANIC_EFFECT) {
hash += "/" + encodeURIComponent(effect); path += "/" + encodeURIComponent(effect);
} }
} else if (state.page === "items") { } else if (state.page === "items") {
if (state.selectedItemKey) { if (state.selectedItemKey) {
hash += "/" + encodeURIComponent(state.selectedItemKey); path += "/" + encodeURIComponent(state.selectedItemKey);
} }
} else if (state.page === "patches") { } else if (state.page === "patches") {
// Omit version when it is the latest — bare #/patches means "latest". // Omit version when it is the latest — bare /patches means "latest".
if (state.selectedPatch) { if (state.selectedPatch) {
const patches = (state.data && state.data.patches) || []; const patches = (state.data && state.data.patches) || [];
const isLatest = const isLatest =
patches.length > 0 && patches[0].version === state.selectedPatch; patches.length > 0 && patches[0].version === state.selectedPatch;
if (!isLatest) { if (!isLatest) {
hash += "/" + encodeURIComponent(state.selectedPatch); path += "/" + encodeURIComponent(state.selectedPatch);
} }
} }
} }
@@ -239,12 +256,12 @@ function serializeHash(state) {
} }
if (state.query) qs.set("q", state.query); if (state.query) qs.set("q", state.query);
const s = qs.toString(); const s = qs.toString();
if (s) hash += "?" + s; if (s) path += "?" + s;
} else if (state.page === "items") { } else if (state.page === "items") {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (state.itemQuery) qs.set("q", state.itemQuery); if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString(); const s = qs.toString();
if (s) hash += "?" + s; if (s) path += "?" + s;
} else if (state.page === "matches") { } else if (state.page === "matches") {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN; const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
@@ -254,29 +271,33 @@ function serializeHash(state) {
const pageNum = Number(state.matchesPage) || 1; const pageNum = Number(state.matchesPage) || 1;
if (pageNum > 1) qs.set("page", String(pageNum)); if (pageNum > 1) qs.set("page", String(pageNum));
const s = qs.toString(); const s = qs.toString();
if (s) hash += "?" + s; if (s) path += "?" + s;
} }
return hash; return path;
}
function currentRoutePath() {
return (window.location.pathname || "/") + (window.location.search || "");
} }
/** Write current state into the URL. Use replace:true for high-frequency /** Write current state into the URL. Use replace:true for high-frequency
* updates (search typing, tag toggles) to avoid history-stack spam. */ * updates (search typing, tag toggles) to avoid history-stack spam. */
function syncStateToUrl({ replace = false } = {}) { function syncStateToUrl({ replace = false } = {}) {
if (!_deps) return; if (!_deps) return;
const hash = serializeHash(_deps.getState()); const path = serializeHash(_deps.getState());
if (hash === window.location.hash) return; // no-op when state matches URL if (path === currentRoutePath()) return; // no-op when state matches URL
if (replace) { if (replace) {
history.replaceState(null, "", hash); history.replaceState(null, "", path);
} else { } else {
history.pushState(null, "", hash); history.pushState(null, "", path);
} }
} }
/** Parse current location.hash, hand it to app for validation+merge, render. /** Parse current location path+search, hand it to app for validation+merge.
* Invoked on hashchange (back/forward / direct location.hash writes) and * Invoked on popstate (back/forward) and once at startup for deep-link support. */
* once at startup for deep-link support. */
function applyUrlToState() { function applyUrlToState() {
if (!_deps) return; if (!_deps) return;
const patch = parseHash(window.location.hash); const path = currentRoutePath();
const patch = parseHash(path === "/" ? "/heroes" : path);
_deps.applyPatch(patch); _deps.applyPatch(patch);
} }
+2 -1
View File
@@ -135,7 +135,8 @@ body {
color: var(--muted); color: var(--muted);
line-height: 1.4; line-height: 1.4;
} }
.sr-only { .sr-only,
.seo-prerender {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
+2
View File
@@ -65,6 +65,8 @@ INPUT_WATCH = [
WEB_FRONTEND / "mobile-gate.js", WEB_FRONTEND / "mobile-gate.js",
WEB_FRONTEND / "config.js", WEB_FRONTEND / "config.js",
WEB_FRONTEND / "_headers", WEB_FRONTEND / "_headers",
WEB_FRONTEND / "_redirects",
WEB_FRONTEND / "robots.txt",
] ]
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions" FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
ASSET_DIRS = [ ASSET_DIRS = [
+455
View File
@@ -0,0 +1,455 @@
"""Build SEO prerender HTML, sitemap, and llms.txt for Climperor Web export.
Writes crawlable path pages under dist (heroes / mechanics / top-level tabs)
while keeping the same SPA shell for hydration.
"""
from __future__ import annotations
import html
import json
import re
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
DEFAULT_SITE_ORIGIN = "https://dota2.refining.dev"
TOP_PAGES: list[tuple[str, str, str]] = [
("/", "英雄克制与搭档", "按英雄浏览定性克制、被克制与搭档理由,以及技能与走势。"),
("/heroes", "英雄克制与搭档", "Dota 2 英雄机制克制与搭档目录。"),
("/mechanics", "机制查询", "查询施加驱散与控制等效果的技能与物品。"),
("/trends", "近 8 周走势", "各勋章段位近 8 周英雄胜率与上场率走势榜。"),
("/items", "物品商店", "基础与合成分类物品目录。"),
("/patches", "版本更新", "近一年游戏性更新摘要。"),
("/rankings", "Immortal 排行", "Valve Immortal 四区 Top100。"),
("/streamers", "主播", "精选 Dota 2 主播目录。"),
("/matches", "明星比赛", "明星选手近期职业与国服对局。"),
]
_TITLE_RE = re.compile(r"<title>[^<]*</title>", re.I)
_DESC_RE = re.compile(
r'<meta\s+name="description"\s+content="[^"]*"\s*/?>',
re.I,
)
_CANONICAL_RE = re.compile(
r'<link\s+rel="canonical"\s+href="[^"]*"\s*/?>',
re.I,
)
_OG_TITLE_RE = re.compile(
r'<meta\s+property="og:title"\s+content="[^"]*"\s*/?>',
re.I,
)
_OG_DESC_RE = re.compile(
r'<meta\s+property="og:description"\s+content="[^"]*"\s*/?>',
re.I,
)
_OG_URL_RE = re.compile(
r'<meta\s+property="og:url"\s+content="[^"]*"\s*/?>',
re.I,
)
_TW_TITLE_RE = re.compile(
r'<meta\s+name="twitter:title"\s+content="[^"]*"\s*/?>',
re.I,
)
_TW_DESC_RE = re.compile(
r'<meta\s+name="twitter:description"\s+content="[^"]*"\s*/?>',
re.I,
)
_JSONLD_RE = re.compile(
r'<script type="application/ld\+json" id="seo-jsonld">.*?</script>',
re.I | re.S,
)
_SEO_ASIDE_RE = re.compile(
r'<aside id="seo-prerender" class="seo-prerender">.*?</aside>',
re.I | re.S,
)
def _esc(s: object) -> str:
return html.escape("" if s is None else str(s), quote=True)
def _abs(origin: str, path: str) -> str:
base = (origin or DEFAULT_SITE_ORIGIN).rstrip("/")
if not path.startswith("/"):
path = "/" + path
return base + path
def _hero_name_map(payload: dict) -> dict[str, str]:
out: dict[str, str] = {}
for h in payload.get("heroes") or []:
if not isinstance(h, dict):
continue
key = h.get("key")
if not key:
continue
out[str(key)] = str(h.get("name_loc") or key)
return out
def _relation_lists(payload: dict, hero_key: str) -> tuple[list[str], list[str], list[str]]:
names = _hero_name_map(payload)
rel = payload.get("relations") or {}
counters_out: list[str] = []
countered_out: list[str] = []
syn_out: list[str] = []
for edge in rel.get("counters") or []:
if not isinstance(edge, dict):
continue
a, b = edge.get("a"), edge.get("b")
reason = (edge.get("reason") or "").strip()
if a == hero_key and b:
label = names.get(str(b), str(b))
counters_out.append(f"{label}" + (f"{reason}" if reason else ""))
elif b == hero_key and a:
label = names.get(str(a), str(a))
countered_out.append(f"{label}" + (f"{reason}" if reason else ""))
for edge in rel.get("synergies") or []:
if not isinstance(edge, dict):
continue
a, b = edge.get("a"), edge.get("b")
reason = (edge.get("reason") or "").strip()
peer = None
if a == hero_key and b:
peer = str(b)
elif b == hero_key and a:
peer = str(a)
if peer:
label = names.get(peer, peer)
syn_out.append(f"{label}" + (f"{reason}" if reason else ""))
return counters_out[:12], countered_out[:12], syn_out[:12]
def _ul(items: list[str]) -> str:
if not items:
return "<p>暂无条目</p>"
lis = "".join(f"<li>{_esc(x)}</li>" for x in items)
return f"<ul>{lis}</ul>"
def _hero_seo_body(hero: dict, payload: dict) -> str:
key = str(hero.get("key") or "")
name = str(hero.get("name_loc") or key)
aliases = [str(a) for a in (hero.get("aliases") or []) if a]
tags = [str(t) for t in (hero.get("tags") or []) if t]
counters, countered, syns = _relation_lists(payload, key)
alias_bit = f"(别名:{''.join(_esc(a) for a in aliases)}" if aliases else ""
tag_bit = f"<p>定位:{''.join(_esc(t) for t in tags)}</p>" if tags else ""
return (
f"<article>"
f"<h1>{_esc(name)} — 克制与搭档</h1>"
f"<p>{_esc(name)}{alias_bit}的 Dota 2 机制克制、被克制与搭档参考(上分帝定性关系,非胜率因果)。</p>"
f"{tag_bit}"
f"<h2>克制</h2>{_ul(counters)}"
f"<h2>被克制</h2>{_ul(countered)}"
f"<h2>搭档</h2>{_ul(syns)}"
f"<p><a href=\"/heroes\">返回英雄目录</a> · "
f"<a href=\"/mechanics\">机制查询</a></p>"
f"</article>"
)
def _mechanic_seo_body(effect: str, payload: dict) -> str:
mq = payload.get("mechanic_query") or {}
labels = mq.get("labels") or {}
blurbs = mq.get("blurbs") or {}
label = labels.get(effect) or effect
blurb = blurbs.get(effect) or f"列出施加「{label}」的技能与物品。"
names = _hero_name_map(payload)
abil_lines: list[str] = []
by_hero = ((payload.get("hero_abilities") or {}).get("by_hero")) or {}
for hkey, cell in by_hero.items():
if not isinstance(cell, dict):
continue
hname = names.get(str(hkey), str(hkey))
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
if effect not in (ab.get("tags") or []):
continue
aname = ab.get("name_loc") or ab.get("key") or ""
abil_lines.append(f"{hname} · {aname}")
abil_lines = sorted(set(abil_lines), key=lambda s: s)[:80]
item_lines: list[str] = []
for row in (payload.get("items_meta") or {}).values():
if not isinstance(row, dict):
continue
if effect not in (row.get("tags") or []):
continue
item_lines.append(str(row.get("name_loc") or row.get("key") or ""))
item_lines = sorted({x for x in item_lines if x})[:40]
return (
f"<article>"
f"<h1>{_esc(label)} — 机制查询</h1>"
f"<p>{_esc(blurb)}</p>"
f"<h2>技能({len(abil_lines)}</h2>{_ul(abil_lines)}"
f"<h2>物品({len(item_lines)}</h2>{_ul(item_lines)}"
f"<p><a href=\"/mechanics\">全部机制</a> · <a href=\"/heroes\">英雄</a></p>"
f"</article>"
)
def _top_seo_body(path: str, title: str, description: str, payload: dict) -> str:
hero_links = []
for h in (payload.get("heroes") or [])[:40]:
if not isinstance(h, dict) or not h.get("key"):
continue
key = str(h["key"])
name = str(h.get("name_loc") or key)
hero_links.append(f'<li><a href="/heroes/{_esc(key)}">{_esc(name)}</a></li>')
mq = payload.get("mechanic_query") or {}
labels = mq.get("labels") or {}
mech_links = []
for effect in mq.get("order") or []:
label = labels.get(effect) or effect
href = "/mechanics" if effect == "basic_dispel" else f"/mechanics/{effect}"
mech_links.append(f'<li><a href="{_esc(href)}">{_esc(label)}</a></li>')
extra = ""
if path in ("/", "/heroes"):
extra = f"<h2>英雄目录(部分)</h2><ul>{''.join(hero_links)}</ul>"
if path in ("/", "/mechanics"):
extra += f"<h2>机制效果</h2><ul>{''.join(mech_links)}</ul>"
return (
f"<article>"
f"<h1>{_esc(title)} — 上分帝</h1>"
f"<p>{_esc(description)}</p>"
f"{extra}"
f"</article>"
)
def _jsonld_website(origin: str, title: str, description: str, url: str) -> str:
payload = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"name": "上分帝",
"alternateName": ["Climperor", "DOTA2 上分帝"],
"url": origin.rstrip("/") + "/",
"inLanguage": "zh-CN",
"description": "Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
"potentialAction": {
"@type": "SearchAction",
"target": origin.rstrip("/") + "/heroes?q={search_term_string}",
"query-input": "required name=search_term_string",
},
},
{
"@type": "WebPage",
"name": title,
"description": description,
"url": url,
"isPartOf": {"@type": "WebSite", "name": "上分帝", "url": origin.rstrip("/") + "/"},
"inLanguage": "zh-CN",
},
],
}
body = json.dumps(payload, ensure_ascii=False, indent=2)
return f'<script type="application/ld+json" id="seo-jsonld">\n{body}\n </script>'
def inject_seo(
template: str,
*,
title: str,
description: str,
canonical: str,
seo_body_html: str,
origin: str,
) -> str:
"""Replace head SEO tags and #seo-prerender body in the SPA shell."""
out = template
out = _TITLE_RE.sub(f"<title>{_esc(title)}</title>", out, count=1)
out = _DESC_RE.sub(
f'<meta\n name="description"\n content="{_esc(description)}"\n />',
out,
count=1,
)
out = _CANONICAL_RE.sub(
f'<link rel="canonical" href="{_esc(canonical)}" />',
out,
count=1,
)
out = _OG_TITLE_RE.sub(
f'<meta property="og:title" content="{_esc(title)}" />',
out,
count=1,
)
out = _OG_DESC_RE.sub(
f'<meta\n property="og:description"\n content="{_esc(description)}"\n />',
out,
count=1,
)
out = _OG_URL_RE.sub(
f'<meta property="og:url" content="{_esc(canonical)}" />',
out,
count=1,
)
out = _TW_TITLE_RE.sub(
f'<meta name="twitter:title" content="{_esc(title)}" />',
out,
count=1,
)
out = _TW_DESC_RE.sub(
f'<meta\n name="twitter:description"\n content="{_esc(description)}"\n />',
out,
count=1,
)
out = _JSONLD_RE.sub(
_jsonld_website(origin, title, description, canonical),
out,
count=1,
)
aside = (
f'<aside id="seo-prerender" class="seo-prerender">\n'
f" {seo_body_html}\n"
f" </aside>"
)
out = _SEO_ASIDE_RE.sub(aside, out, count=1)
return out
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
def build_sitemap(urls: list[str], origin: str) -> str:
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for path in urls:
loc = xml_escape(_abs(origin, path))
lines.append(" <url>")
lines.append(f" <loc>{loc}</loc>")
lines.append(" </url>")
lines.append("</urlset>")
lines.append("")
return "\n".join(lines)
def build_llms_txt(urls: list[tuple[str, str]], origin: str) -> str:
lines = [
"# 上分帝 (Climperor)",
"",
"> Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
"",
f"站点:{_abs(origin, '/')}",
"",
"## 主要页面",
"",
]
for path, title in urls:
lines.append(f"- [{title}]({_abs(origin, path)})")
lines.append("")
lines.append("## 说明")
lines.append("")
lines.append("- 克制/搭档为定性机制边(含理由),不是胜率因果结论。")
lines.append("- 走势/对位数据来自公开统计源,页面会标注窗口与段位。")
lines.append("- 完整交互界面面向桌面浏览器。")
lines.append("")
return "\n".join(lines)
def write_seo_bundle(
out: Path,
template_html: str,
payload: dict,
*,
site_origin: str = DEFAULT_SITE_ORIGIN,
) -> dict[str, int]:
"""Write prerendered pages + sitemap.xml + llms.txt into ``out``.
Root ``index.html`` is rewritten in place with home SEO. Nested pages
are written as ``heroes/<key>/index.html`` and ``mechanics/<effect>/index.html``.
"""
origin = (site_origin or DEFAULT_SITE_ORIGIN).rstrip("/")
sitemap_paths: list[str] = []
llms_entries: list[tuple[str, str]] = []
counts = {"top": 0, "heroes": 0, "mechanics": 0}
for path, title, desc in TOP_PAGES:
full_title = f"{title} — 上分帝"
body = _top_seo_body(path, title, desc, payload)
html_doc = inject_seo(
template_html,
title=full_title,
description=desc,
canonical=_abs(origin, path if path != "/" else "/"),
seo_body_html=body,
origin=origin,
)
if path == "/":
write_text(out / "index.html", html_doc)
else:
# /heroes → heroes/index.html etc.
rel = path.strip("/")
write_text(out / rel / "index.html", html_doc)
sitemap_paths.append(path if path != "/" else "/")
llms_entries.append((path if path != "/" else "/", title))
counts["top"] += 1
for hero in payload.get("heroes") or []:
if not isinstance(hero, dict):
continue
key = hero.get("key")
if not key:
continue
key = str(key)
name = str(hero.get("name_loc") or key)
aliases = [str(a) for a in (hero.get("aliases") or []) if a]
alias_bit = f"{''.join(aliases[:3])}" if aliases else ""
title = f"{name} 克制与搭档 — 上分帝"
desc = (
f"{name}{alias_bit}的 Dota 2 机制克制、被克制与搭档参考,"
f"以及技能、出装与走势(上分帝)。"
)
path = f"/heroes/{key}"
html_doc = inject_seo(
template_html,
title=title,
description=desc,
canonical=_abs(origin, path),
seo_body_html=_hero_seo_body(hero, payload),
origin=origin,
)
write_text(out / "heroes" / key / "index.html", html_doc)
sitemap_paths.append(path)
llms_entries.append((path, f"{name} 克制与搭档"))
counts["heroes"] += 1
mq = payload.get("mechanic_query") or {}
labels = mq.get("labels") or {}
for effect in mq.get("order") or []:
effect = str(effect)
label = labels.get(effect) or effect
# Default effect is bare /mechanics (already written as top page).
if effect == "basic_dispel":
continue
path = f"/mechanics/{effect}"
blurb = (mq.get("blurbs") or {}).get(effect) or f"查询施加「{label}」的技能与物品。"
title = f"{label} — 机制查询 — 上分帝"
html_doc = inject_seo(
template_html,
title=title,
description=str(blurb),
canonical=_abs(origin, path),
seo_body_html=_mechanic_seo_body(effect, payload),
origin=origin,
)
write_text(out / "mechanics" / effect / "index.html", html_doc)
sitemap_paths.append(path)
llms_entries.append((path, f"{label}(机制)"))
counts["mechanics"] += 1
# Prefer stable order: tops first, then heroes, then mechanics (already).
write_text(out / "sitemap.xml", build_sitemap(sitemap_paths, origin))
# Keep llms.txt focused: tops + sample of heroes would be huge; include all
# tops + mechanics + first-line note that hero URLs follow /heroes/{key}.
llms_compact = [(p, t) for p, t in llms_entries if not p.startswith("/heroes/")]
llms_compact.append(("/heroes/{key}", "各英雄克制/搭档页(key 为英雄英文键)"))
write_text(out / "llms.txt", build_llms_txt(llms_compact, origin))
return counts
+21 -3
View File
@@ -1115,11 +1115,29 @@ class Handler(BaseHTTPRequestHandler):
return return
rel = path.lstrip("/") rel = path.lstrip("/")
candidate = (WEB_DIR / rel).resolve() candidate = (WEB_DIR / rel).resolve()
if not str(candidate).startswith(str(WEB_DIR.resolve())) or not candidate.is_file(): web_root = WEB_DIR.resolve()
self.send_error(404) if str(candidate).startswith(str(web_root)) and candidate.is_file():
return
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream" ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype) self._send(200, candidate.read_bytes(), ctype)
return
# History SPA fallback: /heroes/axe → index.html (client router).
spa_pages = {
"heroes",
"rankings",
"matches",
"streamers",
"trends",
"mechanics",
"items",
"patches",
}
first = path.strip("/").split("/", 1)[0] if path.strip("/") else ""
if first in spa_pages:
index = WEB_DIR / "index.html"
if index.is_file():
self._send(200, index.read_bytes(), "text/html; charset=utf-8")
return
self.send_error(404)
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path path = urlparse(self.path).path