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:
@@ -70,8 +70,9 @@ climperor/
|
||||
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
||||
| `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/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/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/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` / `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/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 中手工名单 |
|
||||
@@ -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/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/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 对位/协同 Top(counters/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/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/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) |
|
||||
| `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/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/` | 上分帝 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` | 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/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
|
||||
| `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 范围**:普通玩家视角拿不到双方 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。
|
||||
- **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
||||
- **机制查询页**:顶层 `/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
||||
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
||||
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
||||
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
|
||||
@@ -221,7 +222,7 @@ python pc/evaluate.py
|
||||
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`。
|
||||
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
|
||||
- 改 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 详情条,勿塞机制文案。
|
||||
- 发版上分帝 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`。
|
||||
|
||||
+8
-7
@@ -240,13 +240,14 @@ Dota 2 (-gamestateintegration)
|
||||
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
|
||||
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
|
||||
- **上分帝 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`。
|
||||
英雄详情子标签含 **走势**(见上;`#/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`#/heroes/<key>/matchups`)、**近期比赛**(`#/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
|
||||
- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
||||
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe`、`#/heroes/axe/trends` 等)。选 Hash
|
||||
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`web/serve_relations.py` 零改动、
|
||||
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
|
||||
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
|
||||
另有顶级 **排行**、**走势**(`/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 四区选手榜。
|
||||
- **Web History 路由 + SEO 预渲染**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
||||
标签筛选 / 搜索)双向同步进路径 URL(`/heroes/axe/core?tags=核心&q=axe`、`/heroes/axe/trends` 等)。
|
||||
旧 `#/...` 书签在装载时 `replaceState` 迁到路径。写 URL 用 `history.pushState`/`replaceState`
|
||||
(静默,不触发 `popstate`);前进/后退靠 `popstate`。Cloudflare `_redirects` 与
|
||||
`serve_relations.py` 对未知深度路径回退 `index.html`。导出(`seo_prerender.py`)为首页、
|
||||
顶层页、全英雄与机制效果写可抓取 HTML + `sitemap.xml` / `llms.txt`。
|
||||
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
|
||||
`web/frontend/router.js`,`app.js` 只在 `main()` 装 `installRouter` + 各 state 变更点
|
||||
调 `syncStateToUrl`。
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.115] - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- 上分帝 Web SEO / GEO:History 路径路由(兼容旧 `#/...` 书签)、首页 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 后刷新)
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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`)。
|
||||
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + 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)。
|
||||
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`;STRATZ 周胜率数据)。
|
||||
顶部另有 **比赛** 页(`/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 周胜率数据)。
|
||||
顶部另有 **物品** 页:对齐 [官网商店物品](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` 调)。
|
||||
数据在 `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`。**尚无对局内出装推荐**。
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
Usage:
|
||||
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
|
||||
(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:
|
||||
- Only already-cached assets are exported. For full ability-icon coverage
|
||||
run `python fetch_hero_abilities.py --icons-only` first.
|
||||
@@ -50,9 +53,10 @@ from shared.paths import (
|
||||
WEB_DIST,
|
||||
)
|
||||
|
||||
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||
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"
|
||||
|
||||
|
||||
@@ -133,14 +137,17 @@ def write_config_js(
|
||||
ability_video_base: str,
|
||||
static_asset_base: str,
|
||||
site_version: str,
|
||||
site_origin: str,
|
||||
) -> None:
|
||||
"""Write config.js consumed by app.js."""
|
||||
video = (ability_video_base or "").strip().rstrip("/")
|
||||
static = (static_asset_base or "").strip().rstrip("/")
|
||||
ver = (site_version or "").strip()
|
||||
origin = (site_origin or "").strip().rstrip("/")
|
||||
(out / "config.js").write_text(
|
||||
"/* generated by export_relations_site.py — do not edit */\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 STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
|
||||
encoding="utf-8",
|
||||
@@ -170,6 +177,12 @@ def main() -> None:
|
||||
"when set, image dirs are not copied into dist; "
|
||||
"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()
|
||||
|
||||
video_base = (
|
||||
@@ -182,6 +195,11 @@ def main() -> None:
|
||||
if args.static_asset_base is not None
|
||||
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()
|
||||
if out == ROOT.resolve() or out.parent == out:
|
||||
@@ -190,7 +208,16 @@ def main() -> None:
|
||||
shutil.rmtree(out)
|
||||
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
|
||||
if src.is_file():
|
||||
shutil.copy2(src, out / name)
|
||||
@@ -205,6 +232,7 @@ def main() -> None:
|
||||
ability_video_base=video_base,
|
||||
static_asset_base=static_base,
|
||||
site_version=SITE_VERSION,
|
||||
site_origin=site_origin,
|
||||
)
|
||||
|
||||
payload = build_payload()
|
||||
@@ -217,6 +245,14 @@ def main() -> None:
|
||||
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:
|
||||
counts = {
|
||||
name: 0
|
||||
@@ -244,12 +280,17 @@ def main() -> None:
|
||||
print(f"exported static site -> {out}")
|
||||
for name, n in counts.items():
|
||||
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:
|
||||
print(f" functions/: {n_functions} files (Pages Functions)")
|
||||
if args.with_videos:
|
||||
print(f" ability-video/: {n_videos} files")
|
||||
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}"
|
||||
)
|
||||
print(f" total: {total / 1e6:.1f} MB")
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
|
||||
@@ -76,6 +76,138 @@ function siteVersionLabel() {
|
||||
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() {
|
||||
const el = $("#patches-site-version");
|
||||
if (!el) return;
|
||||
@@ -5468,6 +5600,7 @@ function render() {
|
||||
} else {
|
||||
renderPatches();
|
||||
}
|
||||
updateDocumentMeta();
|
||||
}
|
||||
|
||||
// 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");
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
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}`);
|
||||
state.data = await res.json();
|
||||
state.data.relations = state.data.relations || { counters: [], synergies: [] };
|
||||
@@ -5755,6 +5889,7 @@ async function main() {
|
||||
_innateAbilityKeys = null;
|
||||
installRouter({ getState: () => state, applyPatch });
|
||||
applyUrlToState();
|
||||
clearSeoPrerender();
|
||||
refreshStreamerLiveStatus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* 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 STATIC_ASSET_BASE = "";
|
||||
|
||||
|
||||
+60
-6
@@ -3,13 +3,67 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="stylesheet" href="/style.css?v=0.5.114" />
|
||||
<script src="/mobile-gate.js?v=0.5.114"></script>
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.115" />
|
||||
<script src="/mobile-gate.js?v=0.5.115"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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 class="mobile-gate-card">
|
||||
<div class="mobile-gate-brand" aria-hidden="true">
|
||||
@@ -178,8 +232,8 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.5.114"></script>
|
||||
<script src="/router.js?v=0.5.114"></script>
|
||||
<script src="/app.js?v=0.5.114"></script>
|
||||
<script src="/config.js?v=0.5.115"></script>
|
||||
<script src="/router.js?v=0.5.115"></script>
|
||||
<script src="/app.js?v=0.5.115"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
* Early mobile client gate (loaded from <head>).
|
||||
* 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.
|
||||
* Search / AI crawlers skip the gate so prerendered HTML stays indexable.
|
||||
*/
|
||||
(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() {
|
||||
var ua = navigator.userAgent || "";
|
||||
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
|
||||
@@ -16,6 +24,11 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCrawler()) {
|
||||
window.__CLIMPEROR_MOBILE__ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var mobile = isMobileClient();
|
||||
window.__CLIMPEROR_MOBILE__ = mobile;
|
||||
if (!mobile) return;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://dota2.refining.dev/sitemap.xml
|
||||
+65
-44
@@ -1,38 +1,43 @@
|
||||
/* 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:
|
||||
* - 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
|
||||
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends)
|
||||
* - 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[/account_id][?origin=pro|china][&page=N]
|
||||
* /matches[/account_id][?origin=pro|china][&page=N]
|
||||
* (default origin all omitted; page=1 omitted)
|
||||
* - streamers: curated Douyin streamer directory
|
||||
* #/streamers
|
||||
* - streamers: curated streamer directory
|
||||
* /streamers
|
||||
* - 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[/{effect}] (default effect basic_dispel omitted)
|
||||
* /mechanics[/{effect}] (default effect basic_dispel omitted)
|
||||
* - item: selected shop item key (items page)
|
||||
* - patch: selected version string (patches page; latest when absent)
|
||||
* - 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:
|
||||
* - App calls syncStateToUrl() after each state mutation. We use
|
||||
* history.pushState/replaceState, which update the URL silently
|
||||
* (no hashchange event fires) — so applyUrlToState is never re-entered.
|
||||
* - Back/forward navigation fires hashchange → applyUrlToState parses
|
||||
* the new hash, hands a validated patch back to app, and app re-renders.
|
||||
* (no popstate) — so applyUrlToState is never re-entered.
|
||||
* - Back/forward navigation fires popstate → applyUrlToState parses
|
||||
* 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().
|
||||
*/
|
||||
|
||||
const ROUTE_DEFAULT = "#/heroes";
|
||||
const ROUTE_DEFAULT = "/heroes";
|
||||
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
|
||||
const VALID_DETAIL_TABS = [
|
||||
"skills",
|
||||
@@ -85,11 +90,20 @@ let _deps = null;
|
||||
*/
|
||||
function installRouter(deps) {
|
||||
_deps = deps;
|
||||
window.addEventListener("hashchange", applyUrlToState);
|
||||
migrateLegacyHashRoute();
|
||||
window.addEventListener("popstate", applyUrlToState);
|
||||
}
|
||||
|
||||
/** Parse a hash string into a state patch (all fields nullable). */
|
||||
function parseHash(hash) {
|
||||
/** One-shot: #/heroes/... → /heroes/... (bookmarks / shared links). */
|
||||
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 = {
|
||||
page: null,
|
||||
heroKey: null,
|
||||
@@ -107,14 +121,17 @@ function parseHash(hash) {
|
||||
query: null,
|
||||
itemQuery: null,
|
||||
};
|
||||
let raw = hash || "";
|
||||
let raw = hashOrPath || "";
|
||||
if (raw.startsWith("#")) raw = raw.slice(1);
|
||||
if (!raw.startsWith("/")) raw = "/" + raw;
|
||||
const qIdx = raw.indexOf("?");
|
||||
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
|
||||
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
|
||||
const segs = pathPart.split("/").filter(Boolean);
|
||||
if (!segs.length) return out;
|
||||
if (!segs.length) {
|
||||
out.page = "heroes";
|
||||
return out;
|
||||
}
|
||||
const page = segs[0];
|
||||
if (!VALID_PAGES.includes(page)) return out;
|
||||
out.page = page;
|
||||
@@ -122,7 +139,7 @@ function parseHash(hash) {
|
||||
if (segs[1]) out.heroKey = safeDecode(segs[1]);
|
||||
if (segs[2]) out.detailTab = safeDecode(segs[2]);
|
||||
} 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") {
|
||||
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) {
|
||||
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
|
||||
let hash = "#/" + state.page;
|
||||
let path = "/" + state.page;
|
||||
if (state.page === "heroes") {
|
||||
if (state.selectedKey) {
|
||||
hash += "/" + encodeURIComponent(state.selectedKey);
|
||||
path += "/" + encodeURIComponent(state.selectedKey);
|
||||
let tab = state.detailTab;
|
||||
if (tab === "stats") tab = "trends"; // normalize legacy alias in URL
|
||||
if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") {
|
||||
hash += "/" + encodeURIComponent(tab);
|
||||
path += "/" + encodeURIComponent(tab);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
if (region && region !== DEFAULT_RANKING_REGION) {
|
||||
hash += "/" + encodeURIComponent(region);
|
||||
path += "/" + encodeURIComponent(region);
|
||||
}
|
||||
} else if (state.page === "matches") {
|
||||
if (state.matchesPlayerId) {
|
||||
hash += "/" + encodeURIComponent(String(state.matchesPlayerId));
|
||||
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
|
||||
}
|
||||
} 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;
|
||||
if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) {
|
||||
hash += "/" + encodeURIComponent(bracket);
|
||||
path += "/" + encodeURIComponent(bracket);
|
||||
}
|
||||
const sort = state.trendsSort || 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") {
|
||||
const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT;
|
||||
if (effect && effect !== DEFAULT_MECHANIC_EFFECT) {
|
||||
hash += "/" + encodeURIComponent(effect);
|
||||
path += "/" + encodeURIComponent(effect);
|
||||
}
|
||||
} else if (state.page === "items") {
|
||||
if (state.selectedItemKey) {
|
||||
hash += "/" + encodeURIComponent(state.selectedItemKey);
|
||||
path += "/" + encodeURIComponent(state.selectedItemKey);
|
||||
}
|
||||
} 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) {
|
||||
const patches = (state.data && state.data.patches) || [];
|
||||
const isLatest =
|
||||
patches.length > 0 && patches[0].version === state.selectedPatch;
|
||||
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);
|
||||
const s = qs.toString();
|
||||
if (s) hash += "?" + s;
|
||||
if (s) path += "?" + s;
|
||||
} else if (state.page === "items") {
|
||||
const qs = new URLSearchParams();
|
||||
if (state.itemQuery) qs.set("q", state.itemQuery);
|
||||
const s = qs.toString();
|
||||
if (s) hash += "?" + s;
|
||||
if (s) path += "?" + s;
|
||||
} else if (state.page === "matches") {
|
||||
const qs = new URLSearchParams();
|
||||
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
|
||||
@@ -254,29 +271,33 @@ function serializeHash(state) {
|
||||
const pageNum = Number(state.matchesPage) || 1;
|
||||
if (pageNum > 1) qs.set("page", String(pageNum));
|
||||
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
|
||||
* updates (search typing, tag toggles) to avoid history-stack spam. */
|
||||
function syncStateToUrl({ replace = false } = {}) {
|
||||
if (!_deps) return;
|
||||
const hash = serializeHash(_deps.getState());
|
||||
if (hash === window.location.hash) return; // no-op when state matches URL
|
||||
const path = serializeHash(_deps.getState());
|
||||
if (path === currentRoutePath()) return; // no-op when state matches URL
|
||||
if (replace) {
|
||||
history.replaceState(null, "", hash);
|
||||
history.replaceState(null, "", path);
|
||||
} else {
|
||||
history.pushState(null, "", hash);
|
||||
history.pushState(null, "", path);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse current location.hash, hand it to app for validation+merge, render.
|
||||
* Invoked on hashchange (back/forward / direct location.hash writes) and
|
||||
* once at startup for deep-link support. */
|
||||
/** Parse current location path+search, hand it to app for validation+merge.
|
||||
* Invoked on popstate (back/forward) and once at startup for deep-link support. */
|
||||
function applyUrlToState() {
|
||||
if (!_deps) return;
|
||||
const patch = parseHash(window.location.hash);
|
||||
const path = currentRoutePath();
|
||||
const patch = parseHash(path === "/" ? "/heroes" : path);
|
||||
_deps.applyPatch(patch);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,8 @@ body {
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sr-only {
|
||||
.sr-only,
|
||||
.seo-prerender {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
@@ -65,6 +65,8 @@ INPUT_WATCH = [
|
||||
WEB_FRONTEND / "mobile-gate.js",
|
||||
WEB_FRONTEND / "config.js",
|
||||
WEB_FRONTEND / "_headers",
|
||||
WEB_FRONTEND / "_redirects",
|
||||
WEB_FRONTEND / "robots.txt",
|
||||
]
|
||||
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
|
||||
ASSET_DIRS = [
|
||||
|
||||
@@ -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
|
||||
+22
-4
@@ -1115,11 +1115,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
rel = path.lstrip("/")
|
||||
candidate = (WEB_DIR / rel).resolve()
|
||||
if not str(candidate).startswith(str(WEB_DIR.resolve())) or not candidate.is_file():
|
||||
self.send_error(404)
|
||||
web_root = WEB_DIR.resolve()
|
||||
if str(candidate).startswith(str(web_root)) and candidate.is_file():
|
||||
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
|
||||
self._send(200, candidate.read_bytes(), ctype)
|
||||
return
|
||||
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
|
||||
self._send(200, candidate.read_bytes(), ctype)
|
||||
# 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
|
||||
path = urlparse(self.path).path
|
||||
|
||||
Reference in New Issue
Block a user