diff --git a/AGENTS.md b/AGENTS.md index 9c02ed2..5e3929b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d17530e..54712fa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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//trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`#/heroes//matchups`)、**近期比赛**(`#/heroes//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//trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`/heroes//matchups`)、**近期比赛**(`/heroes//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`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2946a..e316b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 244adc7..ef6c5b6 100644 --- a/README.md +++ b/README.md @@ -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`。**尚无对局内出装推荐**。 diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 1edf695..c27694f 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.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") diff --git a/web/frontend/_redirects b/web/frontend/_redirects new file mode 100644 index 0000000..fe1f55c --- /dev/null +++ b/web/frontend/_redirects @@ -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 diff --git a/web/frontend/app.js b/web/frontend/app.js index bd10d23..9d2e18a 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -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(); } diff --git a/web/frontend/config.js b/web/frontend/config.js index 36ae99a..38f3c53 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -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 = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index b5fa8c5..a0b6fa0 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -3,13 +3,67 @@ - DOTA2 上分帝 + DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制 + + + + + + + + + + + + + + - - + +

DOTA2 上分帝

+