Add star-player matches tab and watchlist-driven fetch.

Ship top-level #/matches with pro watchlist defaults, bump site to 0.5.71, and document the flow in AGENTS/README.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 16:03:45 +08:00
co-authored by Cursor
parent 09bd5aabb3
commit d9c7b4b7b1
11 changed files with 1073 additions and 130 deletions
+12 -7
View File
@@ -60,7 +60,8 @@ climperor/
| `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` |
| `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token`--mode matchups\|synergies`;供 `audit_relations.py` |
| `shared/fetch_matchups.py` | 拉取 OpenDota 对位优势 → `shared/data/matchups.json`(供 `audit_relations.py` 交叉审计) |
| `shared/audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `shared/audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄;另含 Web 对位 Top × OpenDota 的 `web_matchup_tops_cross` 质量段(agree/conflict/weak;不改写 relations |
| `shared/matchup_cross.py` | Web 对位 Top 与 OpenDota 的基线校正交叉分类(供 audit / serve 富化;观测证据,不作机制结论) |
| `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成) |
| `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
@@ -70,8 +71,8 @@ climperor/
|------|------|
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
| `web/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 |
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/streamer-video/` 提供主播高光 mp4,支持 HTTP Range |
| `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` 后刷新;`/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/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);摘要比对后有变更才 OSS upload + `deploy_relations.py``--dry-run` / `--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品;`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears |
@@ -86,6 +87,7 @@ climperor/
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
| `web/fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `web/data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend |
| `web/fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `web/data/hero_matches.json``--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby`--public-region china` 优先国服;`--workers` 详情并发 + 凑满即停;可选 `OPENDOTA_API_KEY`;增量补缺失/不足 N 场;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend |
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--players` 覆盖;`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily;**仅上分帝 Web「近期比赛」**;勿进 recommend |
| `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend |
| `web/fetch_streamers.py` | 从抖音主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞/头像(手工名单 + 直播间/主页 URL;支持 `v.douyin.com` 短链;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily |
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json``is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info``live_status==1` 在播,轮播算下播);软失败保留旧 `is_live`、始终 exit 0`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily |
@@ -103,10 +105,12 @@ climperor/
| `web/data/hero_items.json` | 核心成品装备缓存(Web 站点只读;由 `fetch_hero_items.py` 生成) |
| `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
| `web/data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(CN/EU 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛对局(`by_pro`/`by_hero` + 终局出装/加点;由 `fetch_pro_matches.py` 生成;Web「近期比赛」与 `hero_matches` 合并展示;进 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_fit`/`video_crop`/`video_aspect` 控制裁切 + 抖音 profile 补全;`platform_meta`首版仅抖音;Web 卡片滚播视频、`live_url` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上由访客触发的 `/api/live-status` Pages Function 实时合并刷新,边缘缓存 5 分钟;`data.json` `is_live` 为 daily 兜底,接口不可用时前端回退`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音/B 站 profile 补全;`platform_meta`Web 卡片视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;`live_url` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上 `/api/live-status` 边缘缓存 5 分钟;`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_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta |
@@ -116,7 +120,7 @@ climperor/
| `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` / `functions/`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` |
| `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
| `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]` / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) |
@@ -125,7 +129,7 @@ climperor/
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标)+ 平台 logo(`platform_douyin.png`,主播页) |
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
| `web/assets/streamer_videos/` | 主播精选高光 mp4(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/` |
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/` |
| `web/assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管) |
运行时产物(**勿提交**,见 `.gitignore`):
@@ -179,6 +183,7 @@ python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage--workers 6--enrich-item-times
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认读 pro_player_watchlist.json;可 --players Ame,898754153
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily--dry-run 只打印)
+92
View File
@@ -9,6 +9,98 @@
- 仓库重组为 `pc/`(局内识别)、`web/`(上分帝 Web)与 `shared/`(共享包)三层
monorepo;保留脚本直跑方式,站点内容与 `SITE_VERSION` 不变。
## [0.5.71] - 2026-07-29
### Added
- 上分帝 Web:明星选手 watchlist`web/data/pro_player_watchlist.json`);
`fetch_pro_matches.py` 默认按名单拉近期联赛/锦标赛对局,写入
`pro_matches.json`gitignore,daily 现拉);英雄详情「近期比赛」与顶层
「比赛」页合并展示。
- 上分帝 Web 顶层「比赛」Tab`#/matches[/account_id]`):侧栏筛选选手,
卡片含出装/加点(同英雄详情),可点英雄头像跳转详情。
- 主播高光同名 JPG 封面(`streamer_videos/<id>.jpg` → OSS `streamer-video/`);
未开播先显示封面,避免整块黑屏。
### Changed
- 顶栏顺序改为:英雄 → 排行 → 主播 → 比赛 → …
- 「主播」视频改为视口分档加载:远处不请求、近约 1 屏只 `metadata`、中部
`canplay` 即播且同时仅 1 路 `preload=auto`(取代整页队列式
`canplaythrough`);超大 mp4 再压缩以缩短首播等待。
- 收录抖音主播「雷泽」(`leize`)及精选高光。
## [0.5.69] - 2026-07-29
### Changed
- 上分帝 Web「主播」常用英雄头像同样 `eager` + `fetchPriority=high`,并与主播
头像一并等加载完成后再启动视频预载,避免只露出 `alt` 中文名、图未出。
## [0.5.68] - 2026-07-29
### Changed
- 上分帝 Web「主播」常用英雄只保留头像,去掉头像下方中文名(悬停 `title` /
`alt` 仍可辨认)。
## [0.5.67] - 2026-07-29
### Changed
- 上分帝 Web「主播」头像改为 `eager` + `fetchPriority=high`,等头像加载完成
(或 2.5s 超时)后再启动视频逐个预载,避免大 mp4 占满连接导致头像只显示
`alt` 文字;视频骨架占位改为标准 16:9 横屏高度。
## [0.5.66] - 2026-07-29
### Changed
- 上分帝 Web「主播」视频未就绪时改为矮骨架占位(约 88px +「视频加载中」),
不再默认铺满 9:16 大黑框;元数据到达后再按真实比例展开,并隐藏占位期原生播放钮。
## [0.5.65] - 2026-07-29
### Fixed
- 上分帝 Web「主播」视频队列:建卡时网格尚未挂到 DOM,`isConnected` 为假会把
队列条目全部丢掉,导致既不预载也不自动播放;改为未挂载时下一帧重试。
## [0.5.64] - 2026-07-29
### Changed
- 上分帝 Web「主播」精选视频改为队列逐个预载:按卡片顺序先拉第一个,
`canplaythrough`(或失败)后再拉下一个;播放仍跟视口中心卡。避免十余个
大 mp4 同时抢带宽导致首屏长时间转圈。
## [0.5.63] - 2026-07-29
### Changed
- 上分帝 Web「主播」精选视频改为延迟挂载 `src``preload="none"`),为逐个
预载做准备(随 0.5.64 队列逻辑完善)。
## [0.5.62] - 2026-07-29
### Fixed
- 上分帝 Web「对位」STRATZ 抓取默认改为全量刷新(`--resume-matchups` 仅中断续跑);
单英雄失败保留上一版并标 `stale`,原子写入避免半截缓存。版本档软触发
`--matchups-only` 刷新(无 token 不阻断)。
### Changed
- 「对位」面板标明全局聚合口径(不跟随走势页段位/分路/周),展示 STRATZ 相对优势
与原始对局胜率、场次、拉取日期;胜率低于 50% 但相对优势为正时不再误读为错误。
- 有 OpenDota `matchups.json` 时对克制/被克行做基线校正交叉提示(来源一致/分歧);
样本不足标为暂无可靠交叉结论。Dota2ProTracker 仅作文档化人工复核参考。
### Added
- `shared/matchup_cross.py` + `audit_relations``web_matchup_tops_cross` 报告段;
单元测试 `shared/tests/test_matchup_cross.py`
## [0.5.61] - 2026-07-29
### Added
+7 -4
View File
@@ -37,9 +37,11 @@ python pc/fetch_cdn_templates.py # 一次性:shared/data/heroes.json(含
python web/fetch_hero_portraits.py # 官网竖版头像 → web/assets/hero_portraits/
python web/fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → web/data/hero_items.json
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次 → web/data/hero_stats.jsonWeb 走势兜底)
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → stratz_hero_meta.json(需 token;见 .env.example
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → web/data/(需 token;对位默认全量刷新,--resume-matchups 仅续跑
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(含购买时间)→ web/data/hero_matches.json
# 既有行补时间:python web/fetch_hero_matches.py --enrich-item-times [--heroes juggernaut]
python web/fetch_pro_matches.py # 明星选手近期联赛/锦标赛 → web/data/pro_matches.json(默认 watchlist
# 指定选手:python web/fetch_pro_matches.py --players Ame,Yatoro --limit 12
python web/fetch_item_shop.py # 官网商店 11 列目录 → web/data/item_shop.json
python web/fetch_items_meta.py # 装备描述 + 机制标签 → web/data/items_meta.json
python web/fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → web/data/hero_abilities.json
@@ -53,7 +55,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``#/items/black_king_bar``#/patches/7.41``#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
Web 站点带 **Hash 路由**`#/heroes/axe/core``#/heroes/axe/trends``#/heroes/axe/matchups``#/heroes/axe/matches``#/trends/legend``#/rankings``#/matches`(明星比赛,可 `#/matches/898754153`)、`#/items/black_king_bar``#/patches/7.41``#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
### 部署到 Cloudflare Pages
@@ -107,11 +109,12 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。
顶部另有 **主播** 页:手工收录直播间/主页(首版仅抖音),抖音式卡片展示昵称、签名、关注/粉丝/获赞、常用英雄与可选精选高光视频(滚入视口中部自动播放;有 `live_url` 时点头像进直播间;「直播」角标与动效只在真实探测在播时显示线上由访客触发的 `/api/live-status` 边缘函数实时刷新,边缘缓存 5 分钟合并请求;`data.json``fetch_streamer_live.py` daily 探测值兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频文件`web/assets/streamer_videos/`gitignore),本地开发`/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站),抖音式卡片展示昵称、签名、关注/粉丝/获赞、常用英雄与可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;「直播」角标与动效只在真实探测在播时显示——线上由访客触发的 `/api/live-status` 边缘函数刷新,缓存 5 分钟合并请求;`data.json``fetch_streamer_live.py` 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]`):明星选手 watchlist 近期联赛/锦标赛对局(出装/加点);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py` 拉取(亦进 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`与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py``web/item_fears.py`。**尚无对局内出装推荐**。
数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 在 `web/data/stratz_matchup_tops.json`STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示;有 OpenDota matchups 时页面可提示来源一致/分歧)。被克装备以描述标签 + 规则为主;`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`。**尚无对局内出装推荐**。
## 快速开始(GSI 自动跟踪)
+49
View File
@@ -0,0 +1,49 @@
{
"meta": {
"note_zh": "手工维护的明星选手 OpenDota account_idfetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。",
"source": "opendota/proPlayers",
"updated_at": "2026-07-29"
},
"players": [
{"account_id": 898754153, "name": "Ame", "team_tag": "XG", "region": "cn"},
{"account_id": 173978074, "name": "NothingToSay", "team_tag": "XG", "region": "cn"},
{"account_id": 94296097, "name": "xNova", "team_tag": "XG", "region": "cn"},
{"account_id": 129958758, "name": "Xxs", "team_tag": "XG", "region": "cn"},
{"account_id": 101695162, "name": "fy", "team_tag": "XG", "region": "cn"},
{"account_id": 157475523, "name": "XinQ", "team_tag": "VG", "region": "cn"},
{"account_id": 320252024, "name": "shiro", "team_tag": "VG", "region": "cn"},
{"account_id": 148215639, "name": "Monet", "team_tag": "", "region": "cn"},
{"account_id": 854671154, "name": "XM", "team_tag": "", "region": "cn"},
{"account_id": 292921272, "name": "Wisper", "team_tag": "LGD", "region": "cn"},
{"account_id": 321580662, "name": "Yatoro", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 302214028, "name": "Collapse", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 106305042, "name": "Larl", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 113331514, "name": "Miposhka", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 100058342, "name": "skiter", "team_tag": "FLCN", "region": "eu"},
{"account_id": 183719386, "name": "AMMAR_THE_F", "team_tag": "FLCN", "region": "eu"},
{"account_id": 898455820, "name": "Malr1ne", "team_tag": "FLCN", "region": "eu"},
{"account_id": 10366616, "name": "Sneyking", "team_tag": "FLCN", "region": "eu"},
{"account_id": 201358612, "name": "Nisha", "team_tag": "Liquid", "region": "eu"},
{"account_id": 77490514, "name": "Boxi", "team_tag": "Liquid", "region": "eu"},
{"account_id": 16497807, "name": "tOfu", "team_tag": "Liquid", "region": "eu"},
{"account_id": 54580962, "name": "Insania", "team_tag": "Liquid", "region": "eu"},
{"account_id": 93618577, "name": "bzm", "team_tag": "1w", "region": "eu"},
{"account_id": 331855530, "name": "Pure", "team_tag": "1w", "region": "eu"},
{"account_id": 86698277, "name": "33", "team_tag": "1w", "region": "eu"},
{"account_id": 136829091, "name": "Whitemon", "team_tag": "1w", "region": "eu"},
{"account_id": 124801257, "name": "Nightfall", "team_tag": "Aurora", "region": "eu"},
{"account_id": 858106446, "name": "kiyotaka", "team_tag": "Aurora", "region": "eu"},
{"account_id": 480412663, "name": "gpk~", "team_tag": "BB", "region": "eu"},
{"account_id": 196878136, "name": "Kataomi`", "team_tag": "BB", "region": "eu"},
{"account_id": 1044002267, "name": "Satanic", "team_tag": "VSN", "region": "eu"},
{"account_id": 127617979, "name": "Crystallis", "team_tag": "MOUZ", "region": "eu"},
{"account_id": 431770905, "name": "TORONTOTOKYO", "team_tag": "OG", "region": "eu"},
{"account_id": 111620041, "name": "SumaiL-", "team_tag": "NGX", "region": "eu"},
{"account_id": 103735745, "name": "Saksa", "team_tag": "TY", "region": "eu"}
]
}
+7 -2
View File
@@ -50,7 +50,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.61"
SITE_VERSION = "0.5.71"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -87,9 +87,14 @@ def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
n_vid = 0
dst = out / "streamer-video"
dst.mkdir(parents=True, exist_ok=True)
for f in sorted(STREAMER_VIDEOS.glob("*.mp4")):
for f in sorted(STREAMER_VIDEOS.iterdir()):
if not f.is_file() or f.name.startswith("_"):
continue
name = f.name.lower()
if not name.endswith(
(".mp4", ".webm", ".jpg", ".jpeg", ".webp", ".png")
):
continue
shutil.copy2(f, dst / f.name)
n_vid += 1
counts["streamer-video"] = n_vid
+152 -25
View File
@@ -1,14 +1,18 @@
"""Fetch recent league/tournament matches for OpenDota registered pros.
Pulls /proPlayers, then per player /players/{id}/matches (lobby practice +
tournament), enriches with /matches/{id} for final items + skill builds.
Default source: web/data/pro_player_watchlist.json (hand-curated star IDs).
Falls back to /proPlayers auto-pick only with --all-pros.
Output: data/pro_matches.json (Climperor web only; not used by recommend).
Per player: /players/{id}/matches (lobby practice + tournament), then
/matches/{id} for final items + skill builds.
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
Usage:
python fetch_pro_matches.py --limit-pros 20 --limit 8
python fetch_pro_matches.py --players 1296625,117421467
python fetch_pro_matches.py --with-team --active-days 45
python web/fetch_pro_matches.py
python web/fetch_pro_matches.py --limit 12
python web/fetch_pro_matches.py --players 898754153,Ame
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
"""
from __future__ import annotations
@@ -39,20 +43,82 @@ from fetch_pro_builds import fetch_pro_index
OPENDOTA = "https://api.opendota.com/api"
OUT = DATA / "pro_matches.json"
WATCHLIST = DATA / "pro_player_watchlist.json"
DEFAULT_LIMIT = 8
DEFAULT_LIMIT_PROS = 40
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
LOBBY_LEAGUE = (1, 2)
def _log(msg: str) -> None:
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True)
def load_watchlist(path: Path) -> list[dict]:
"""Load hand-curated star players from pro_player_watchlist.json."""
if not path.is_file():
return []
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise SystemExit(f"watchlist read failed: {path}: {e}") from e
players = raw.get("players") if isinstance(raw, dict) else None
if not isinstance(players, list):
raise SystemExit(f"watchlist missing players[]: {path}")
out: list[dict] = []
seen: set[int] = set()
for row in players:
if not isinstance(row, dict):
continue
try:
aid = int(row.get("account_id") or 0)
except (TypeError, ValueError):
continue
if aid <= 0 or aid in seen:
continue
seen.add(aid)
name = row.get("name")
if isinstance(name, str):
name = name.strip() or None
else:
name = None
team_tag = row.get("team_tag")
if isinstance(team_tag, str):
team_tag = team_tag.strip() or None
else:
team_tag = None
team_name = row.get("team_name")
if isinstance(team_name, str):
team_name = team_name.strip() or None
else:
team_name = None
region = row.get("region")
if isinstance(region, str):
region = region.strip().lower() or None
else:
region = None
out.append(
{
"account_id": aid,
"name": name,
"team_tag": team_tag,
"team_name": team_name,
"region": region,
}
)
return out
def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
"""Comma-separated account ids or registered pro names."""
if not raw.strip():
return []
name_to_id: dict[str, int] = {}
for aid, prof in pro_index.items():
for key in ("name",):
val = prof.get(key)
val = prof.get("name")
if isinstance(val, str) and val.strip():
name_to_id[val.strip().lower()] = aid
out: list[int] = []
@@ -67,7 +133,36 @@ def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
if aid:
out.append(aid)
else:
print(f" warn: unknown pro {token!r}", flush=True)
_log(f" warn: unknown pro {token!r}")
return out
def merge_prof(aid: int, base: dict | None, override: dict | None = None) -> dict:
"""Prefer OpenDota live fields, keep watchlist name/team as fallback."""
out: dict = {
"account_id": aid,
"name": None,
"team_tag": None,
"team_name": None,
"country_code": None,
"last_match_time": None,
}
if isinstance(base, dict):
for key in out:
if key == "account_id":
continue
val = base.get(key)
if val is not None and val != "":
out[key] = val
if isinstance(override, dict):
for key in ("name", "team_tag", "team_name"):
if out.get(key):
continue
val = override.get(key)
if isinstance(val, str) and val.strip():
out[key] = val.strip()
elif val:
out[key] = val
return out
@@ -78,13 +173,19 @@ def filter_pros(
active_days: int | None,
limit_pros: int,
player_ids: list[int],
watchlist: list[dict] | None = None,
) -> list[tuple[int, dict]]:
if player_ids:
rows: list[tuple[int, dict]] = []
for aid in player_ids:
prof = pro_index.get(aid)
if prof:
rows.append((aid, prof))
rows.append((aid, merge_prof(aid, pro_index.get(aid))))
return rows
if watchlist:
rows = []
for entry in watchlist:
aid = int(entry["account_id"])
rows.append((aid, merge_prof(aid, pro_index.get(aid), entry)))
return rows
cutoff = None
@@ -110,7 +211,7 @@ def filter_pros(
candidates.sort(key=lambda t: (-t[2], str(t[1].get("name") or ""), t[0]))
picked = candidates[: max(1, limit_pros)]
return [(aid, prof) for aid, prof, _ in picked]
return [(aid, merge_prof(aid, prof)) for aid, prof, _ in picked]
def player_match_metas(
@@ -266,6 +367,7 @@ def write_out(
limit: int,
limit_pros: int,
lobby_types: tuple[int, ...],
player_source: str,
) -> None:
used = collect_item_ids(by_hero)
items_out = {
@@ -287,6 +389,7 @@ def write_out(
"source": "opendota",
"attribution": "https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"player_source": player_source,
"limit_per_pro": limit,
"limit_pros": limit_pros,
"lobby_types": list(lobby_types),
@@ -294,7 +397,8 @@ def write_out(
"match_count": match_count,
"hero_count": len(by_hero),
"note_zh": (
"OpenDota 注册职业选手近期联赛/锦标赛对局;"
"OpenDota 明星/职业选手近期联赛/锦标赛对局;"
"默认名单见 web/data/pro_player_watchlist.json"
"lobby_type 1=训练/practice、2=tournament"
"含终局出装、加点与联赛名(若有)。"
),
@@ -310,16 +414,15 @@ def write_out(
)
def _log(msg: str) -> None:
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument(
"--watchlist",
type=Path,
default=WATCHLIST,
help=f"Star player list JSON (default: {WATCHLIST})",
)
ap.add_argument(
"--limit",
type=int,
@@ -330,23 +433,28 @@ def main() -> None:
"--limit-pros",
type=int,
default=DEFAULT_LIMIT_PROS,
help=f"Max pros when --players omitted (default: {DEFAULT_LIMIT_PROS})",
help=f"Max pros when --all-pros (default: {DEFAULT_LIMIT_PROS})",
)
ap.add_argument(
"--players",
default="",
help="Comma-separated account_id or registered pro name (overrides --limit-pros)",
help="Comma-separated account_id or registered pro name (overrides watchlist)",
)
ap.add_argument(
"--all-pros",
action="store_true",
help="Ignore watchlist; auto-pick from /proPlayers (see --limit-pros/--with-team)",
)
ap.add_argument(
"--with-team",
action="store_true",
help="Only pros with a team_tag/team_name when picking from /proPlayers",
help="With --all-pros: only pros with a team_tag/team_name",
)
ap.add_argument(
"--active-days",
type=int,
default=60,
help="Skip pros with no last_match_time within N days (0=disable; default: 60)",
help="With --all-pros: skip pros idle > N days (0=disable; default: 60)",
)
ap.add_argument("--delay", type=float, default=0.35)
ap.add_argument(
@@ -368,12 +476,30 @@ def main() -> None:
_log(f" {len(pro_index)} registered pros")
player_ids = parse_pro_filter(args.players, pro_index)
watchlist: list[dict] | None = None
player_source = "cli"
if player_ids:
player_source = "cli"
elif not args.all_pros:
watchlist = load_watchlist(args.watchlist)
if not watchlist:
raise SystemExit(
f"Empty/missing watchlist {args.watchlist}. "
"Add players or pass --players / --all-pros."
)
player_source = f"watchlist:{args.watchlist.name}"
_log(f" watchlist {len(watchlist)} players ← {args.watchlist}")
else:
player_source = "proPlayers"
picked = filter_pros(
pro_index,
with_team=args.with_team,
active_days=active_days,
limit_pros=limit_pros,
player_ids=player_ids,
watchlist=watchlist,
)
if not picked:
raise SystemExit("No pros matched filters")
@@ -429,6 +555,7 @@ def main() -> None:
limit=limit,
limit_pros=len(picked),
lobby_types=lobby_types,
player_source=player_source,
)
_log(
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)}{args.out}"
+552 -55
View File
@@ -71,7 +71,7 @@ function renderPatchesSiteVersion() {
const state = {
data: null,
page: "heroes", // heroes | rankings | streamers | trends | mechanics | items | patches
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches
selectedKey: null,
selectedItemKey: null,
/** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */
@@ -86,6 +86,8 @@ const state = {
selectedPatch: null,
/** Immortal leaderboard region: china | europe | americas | se_asia */
rankingRegion: "china",
/** Top-level matches page: OpenDota account_id string (null = all stars) */
matchesPlayerId: null,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
@@ -388,6 +390,7 @@ const BRACKET_RANK_ICON = {
/** Recent matches tab: newest N ladder+league games. */
const MATCHES_DISPLAY_LIMIT = 10;
const PRO_MATCHES_PAGE_LIMIT = 60;
function heroStatsPack() {
return (
@@ -1202,7 +1205,7 @@ function appendMatchAbilityIcon(list, heroKey, abilityKey, level) {
list.appendChild(el);
}
function buildMatchCard(heroKey, row) {
function buildMatchCard(heroKey, row, opts = {}) {
const card = document.createElement("article");
card.className = "match-card" + (row.won ? " is-win" : " is-loss");
@@ -1214,6 +1217,37 @@ function buildMatchCard(heroKey, row) {
result.textContent = row.won ? "胜" : "负";
head.appendChild(result);
if (opts.showHero) {
const hKey = heroKey || row.hero_key || null;
const hero = hKey ? heroByKey(hKey) : null;
const heroBtn = document.createElement(hKey ? "button" : "span");
heroBtn.className = "match-hero";
heroBtn.type = hKey ? "button" : undefined;
const himg = document.createElement("img");
himg.className = "match-hero-icon";
himg.alt = hero?.name_loc || hKey || "";
himg.loading = "lazy";
if (hKey) himg.src = portraitSrc(hKey);
heroBtn.appendChild(himg);
const hname = document.createElement("span");
hname.className = "match-hero-name";
hname.textContent = hero?.name_loc || hKey || "未知英雄";
heroBtn.appendChild(hname);
if (hKey) {
heroBtn.title = `查看 ${hero?.name_loc || hKey}`;
heroBtn.addEventListener("click", () => {
state.page = "heroes";
state.selectedKey = hKey;
state.detailTab = "matches";
state.selectedItemKey = null;
state.inspect = null;
syncStateToUrl();
render();
});
}
head.appendChild(heroBtn);
}
const player = document.createElement("div");
player.className = "match-player";
@@ -1361,11 +1395,11 @@ function buildMatchCard(heroKey, row) {
return card;
}
function buildMatchList(heroKey, rows) {
function buildMatchList(heroKey, rows, opts = {}) {
const list = document.createElement("div");
list.className = "match-list";
for (const row of rows) {
list.appendChild(buildMatchCard(heroKey, row));
list.appendChild(buildMatchCard(heroKey || row.hero_key, row, opts));
}
return list;
}
@@ -1516,7 +1550,15 @@ function stratzMatchupPack() {
state.data?.stratz_matchup_tops || {
by_hero: {},
fetched_at: null,
started_at: null,
finished_at: null,
attribution: "https://stratz.com",
scope: {
kind: "global_aggregate",
label_zh: "全局聚合(未按段位 / 分路 / 周过滤)",
},
cross_source: null,
stats: {},
}
);
}
@@ -1730,8 +1772,7 @@ function buildStatsPanel(heroKey) {
if (!stratzCell && !odotaCell) {
const miss = document.createElement("div");
miss.className = "detail-muted";
miss.textContent =
"暂无走势数据";
miss.textContent = "暂无走势数据";
block.appendChild(miss);
return block;
}
@@ -1820,6 +1861,24 @@ function buildStatsPanel(heroKey) {
return block;
}
function formatMatchupWr(wr) {
if (wr == null || Number.isNaN(Number(wr))) return "—";
return `${(Number(wr) * 100).toFixed(1)}%`;
}
function formatMatchupFetched(iso) {
if (!iso) return "";
const s = String(iso);
return s.length >= 10 ? s.slice(0, 10) : s;
}
function matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
if (status === "conflict") return "来源分歧";
return null;
}
function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
const col = document.createElement("div");
col.className = "matchup-col";
@@ -1860,20 +1919,52 @@ function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
img.alt = hero?.name_loc || peerKey || "";
img.loading = "lazy";
img.decoding = "async";
const mid = document.createElement("div");
mid.className = "matchup-mid";
const name = document.createElement("span");
name.className = "matchup-name";
name.textContent = hero?.name_loc || peerKey || `#${row.hero_id}`;
const meta = document.createElement("span");
meta.className = "matchup-meta detail-muted";
const wrText = formatMatchupWr(row.wr);
const crossLab = matchupCrossLabel(row.cross);
meta.textContent = [
`胜率 ${wrText}`,
`${formatPickCount(row.games)}`,
crossLab,
]
.filter(Boolean)
.join(" · ");
mid.appendChild(name);
mid.appendChild(meta);
const score = document.createElement("span");
score.className = "matchup-score";
score.textContent = formatAdvantage(row[scoreKey]);
score.title = `${scoreLabel} ${formatAdvantage(row[scoreKey])} · ${formatPickCount(row.games)}`;
const games = document.createElement("span");
games.className = "matchup-games detail-muted";
games.textContent = formatPickCount(row.games);
const crossStatus = row.cross?.status;
if (crossStatus === "agree") score.classList.add("is-agree");
if (crossStatus === "conflict") score.classList.add("is-conflict");
const odotaTip =
row.cross && row.cross.opendota_games != null
? ` · OpenDota 基线差 ${
row.cross.opendota_adv == null
? "—"
: formatAdvantage(row.cross.opendota_adv * 100)
}pp / ${formatPickCount(row.cross.opendota_games)}`
: "";
score.title = [
`STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`,
`对局胜率 ${wrText}`,
`${formatPickCount(row.games)}`,
crossLab ? `交叉:${crossLab}` : "",
odotaTip,
]
.filter(Boolean)
.join(" · ");
item.appendChild(img);
item.appendChild(name);
item.appendChild(mid);
item.appendChild(score);
item.appendChild(games);
list.appendChild(item);
}
col.appendChild(list);
@@ -1885,6 +1976,7 @@ function buildMatchupsPanel(heroKey) {
block.className = "detail-matchups-panel detail-tab-panel";
block.setAttribute("aria-label", "数据对位");
const pack = stratzMatchupPack();
const cell = stratzMatchupsFor(heroKey);
if (!cell) {
const miss = document.createElement("div");
@@ -1897,16 +1989,52 @@ function buildMatchupsPanel(heroKey) {
const grid = document.createElement("div");
grid.className = "matchup-grid";
grid.appendChild(
buildMatchupColumn("克制", cell.counters, "advantage", "advantage")
buildMatchupColumn("克制", cell.counters, "advantage", "STRATZ 相对优势")
);
grid.appendChild(
buildMatchupColumn("被克", cell.countered, "advantage", "劣势")
buildMatchupColumn("被克", cell.countered, "advantage", "STRATZ 相对劣势")
);
grid.appendChild(
buildMatchupColumn("搭档", cell.synergies, "synergy", "synergy")
buildMatchupColumn("搭档", cell.synergies, "synergy", "STRATZ synergy")
);
block.appendChild(grid);
const notes = document.createElement("div");
notes.className = "matchup-notes detail-muted";
const hintBits = [
"数字是相对表现(综合胜率与自身强弱),不是直接胜率;也不分段位、分路,和「走势」页不是同一套数据。",
];
const cross = pack.cross_source;
if (cross && cross.available === false) {
hintBits.push("暂无其他数据源可对照,交叉结论仅供参考。");
} else if (cross && cross.available) {
hintBits.push(
"「来源一致 / 分歧」表示是否与 OpenDota 公开统计方向相同,仅作对照。"
);
}
const hint = document.createElement("p");
hint.className = "matchup-note-line";
hint.textContent = hintBits.join(" ");
notes.appendChild(hint);
const stale = cell.stale === true;
if (stale) {
const line = document.createElement("p");
line.className = "matchup-note-line is-stale";
line.textContent = "本轮更新失败,当前仍是上一版数据,可能已过时。";
notes.appendChild(line);
}
const fetched = formatMatchupFetched(cell.fetched_at || pack.fetched_at);
if (fetched) {
const time = document.createElement("p");
time.className = "matchup-note-time";
time.textContent = `更新于 ${fetched}`;
notes.appendChild(time);
}
block.appendChild(notes);
return block;
}
@@ -3368,6 +3496,157 @@ function renderRankings() {
</table>`;
}
function proMatchesData() {
return (
(state.data && state.data.pro_matches) || {
meta: {},
items: {},
pros: {},
by_pro: {},
by_hero: {},
}
);
}
function proPlayerOptions() {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const prosMeta = pack.pros || {};
const rows = [];
for (const [sid, cell] of Object.entries(byPro)) {
if (!cell || typeof cell !== "object") continue;
const matches = Array.isArray(cell.matches) ? cell.matches : [];
if (!matches.length) continue;
const meta = prosMeta[sid] || {};
const name =
cell.name ||
meta.name ||
(matches[0] && matchPlayerDisplayName(matches[0])) ||
sid;
const team = cell.team_tag || meta.team_tag || "";
rows.push({
account_id: String(cell.account_id || sid),
name,
team_tag: team,
match_count: matches.length,
});
}
rows.sort(
(a, b) =>
String(a.name).localeCompare(String(b.name), "zh") ||
String(a.account_id).localeCompare(String(b.account_id))
);
return rows;
}
function proMatchRows(playerId) {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const out = [];
const seen = new Set();
const want = playerId ? String(playerId) : null;
for (const [sid, cell] of Object.entries(byPro)) {
if (!cell || typeof cell !== "object") continue;
if (want && String(cell.account_id || sid) !== want) continue;
const matches = Array.isArray(cell.matches) ? cell.matches : [];
for (const row of matches) {
if (!row || typeof row !== "object") continue;
const mid = Number(row.match_id);
if (!mid || seen.has(mid)) continue;
seen.add(mid);
out.push({
...row,
account_id: row.account_id || cell.account_id || Number(sid) || null,
name: row.name || cell.name || null,
display_name:
row.display_name ||
row.name ||
cell.name ||
row.personaname ||
null,
hero_key: row.hero_key || null,
});
}
}
out.sort(
(a, b) => (Number(b.start_time) || 0) - (Number(a.start_time) || 0)
);
return out;
}
function renderMatchesPage() {
const body = $("#matches-body");
const playersEl = $("#matches-players");
const sub = $("#matches-sub");
if (!body) return;
const options = proPlayerOptions();
const hasData = options.length > 0;
if (playersEl) {
if (!hasData) {
playersEl.innerHTML = "";
} else {
const chips = [
`<button type="button" class="rankings-region-btn${
!state.matchesPlayerId ? " active" : ""
}" data-player="" role="tab" aria-selected="${!state.matchesPlayerId}">全部</button>`,
];
for (const p of options) {
const active = state.matchesPlayerId === p.account_id ? " active" : "";
const label = p.team_tag
? `${escapeHtml(p.name)} · ${escapeHtml(p.team_tag)}`
: escapeHtml(p.name);
chips.push(
`<button type="button" class="rankings-region-btn${active}" data-player="${escapeHtml(
p.account_id
)}" role="tab" aria-selected="${
state.matchesPlayerId === p.account_id
}">${label}</button>`
);
}
playersEl.innerHTML = chips.join("");
playersEl.querySelectorAll("[data-player]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-player") || "";
state.matchesPlayerId = id || null;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
});
});
}
}
if (!hasData) {
body.innerHTML =
'<div class="rankings-empty">暂无比赛数据(请运行 python web/fetch_pro_matches.py</div>';
if (sub) sub.textContent = "";
return;
}
if (
state.matchesPlayerId &&
!options.some((p) => p.account_id === state.matchesPlayerId)
) {
state.matchesPlayerId = null;
}
const rows = proMatchRows(state.matchesPlayerId).slice(
0,
PRO_MATCHES_PAGE_LIMIT
);
if (sub) sub.textContent = "";
if (!rows.length) {
body.innerHTML = '<div class="rankings-empty">该选手暂无近期比赛</div>';
return;
}
body.replaceChildren(buildMatchList(null, rows, { showHero: true }));
}
function streamersData() {
return (
(state.data && state.data.streamers) || {
@@ -3407,6 +3686,24 @@ function streamerVideoSrc(row) {
return assetUrl(`streamer-video/${encodeURIComponent(base)}`);
}
/** Poster next to the clip: foo.mp4 → foo.jpg (optional override `video_poster`). */
function streamerVideoPosterSrc(row) {
const explicit = row && row.video_poster;
if (explicit && typeof explicit === "string") {
const base = explicit.replace(/^.*[\\/]/, "");
if (base && !base.startsWith("_")) {
return assetUrl(`streamer-video/${encodeURIComponent(base)}`);
}
}
const v = row && row.video;
if (!v || typeof v !== "string") return "";
const base = v.replace(/^.*[\\/]/, "");
if (!base || base.startsWith("_")) return "";
const stem = base.replace(/\.(mp4|webm)$/i, "");
if (!stem) return "";
return assetUrl(`streamer-video/${encodeURIComponent(`${stem}.jpg`)}`);
}
/** Parse optional streamers.json `video_aspect` ("1916/2317" or [w, h]). */
function parseStreamerVideoAspect(row) {
const raw = row && row.video_aspect;
@@ -3482,8 +3779,10 @@ function fitStreamerVideoFrame(video, frame, row) {
const fitRaw = row && row.video_fit;
const forceContain = fitRaw === "contain";
const forceCrop = fitRaw === "cover" || row?.video_crop === true;
frame.classList.remove("is-letterbox-crop");
frame.classList.remove("is-letterbox-crop", "is-pending");
delete frame.dataset.letterboxCrop;
frame.style.height = "";
frame.style.backgroundImage = "";
video.style.objectPosition = "";
frame.style.aspectRatio = `${w} / ${h}`;
frame.classList.toggle("is-landscape", w >= h);
@@ -3526,14 +3825,57 @@ function bindStreamerVideoSoundToggle(video) {
});
}
/**
* Viewport-tiered clip loading:
* - far: no src (cancel download)
* - near (~1 screen): attach src + preload=metadata
* - play (mid-screen band): preload=auto + play on canplay (not canplaythrough)
* At most one clip uses preload=auto so large mp4s do not contend.
*/
/** @type {Map<HTMLVideoElement, number>} mid-band visibility ratio */
const streamerVideoVisibility = new Map();
/** @type {Set<HTMLVideoElement>} within ~1 viewport of screen */
const streamerVideoNear = new Set();
let streamerVideoPlayObserver = null;
let streamerVideoNearObserver = null;
let streamerVideoReconcileQueued = false;
/** Attach / upgrade deferred clip URL. */
function ensureStreamerVideoSrc(video, { preload = "metadata" } = {}) {
if (!(video instanceof HTMLVideoElement)) return false;
const src = video.dataset.streamerSrc || "";
if (!src) return false;
if (video.dataset.streamerSrcAttached !== "1") {
video.src = src;
video.dataset.streamerSrcAttached = "1";
}
if (video.preload !== preload) video.preload = preload;
return true;
}
/** Drop src so the browser can cancel an in-flight fetch. */
function unloadStreamerVideoSrc(video) {
if (!(video instanceof HTMLVideoElement)) return;
if (video.dataset.streamerSrcAttached !== "1") return;
video.pause();
video.removeAttribute("src");
video.load();
delete video.dataset.streamerSrcAttached;
video.preload = "none";
const frame = video.closest(".streamer-video-frame");
if (frame && video.videoWidth === 0) frame.classList.add("is-pending");
}
function playStreamerVideo(video) {
if (!(video instanceof HTMLVideoElement)) return;
ensureStreamerVideoSrc(video, { preload: "auto" });
pauseOtherStreamerVideos(video);
// Scroll autoplay stays muted unless the user explicitly unmuted this clip.
if (!streamerVideoUserSoundOn(video)) {
video.muted = true;
video.setAttribute("muted", "");
}
const tryPlay = () => {
const p = video.play();
if (p && typeof p.catch === "function") {
p.catch(() => {
@@ -3544,19 +3886,27 @@ function playStreamerVideo(video) {
if (p2 && typeof p2.catch === "function") p2.catch(() => {});
});
}
}
/** Play streamer clips when scrolled near viewport center (tall cards). */
let streamerVideoObserver = null;
/** @type {Map<HTMLVideoElement, number>} */
const streamerVideoVisibility = new Map();
function resetStreamerVideoObserver() {
if (streamerVideoObserver) {
streamerVideoObserver.disconnect();
streamerVideoObserver = null;
};
// HAVE_FUTURE_DATA — enough to start; do not wait for canplaythrough.
if (video.readyState >= 3) {
tryPlay();
return;
}
streamerVideoVisibility.clear();
if (video.dataset.streamerCanplayBound === "1") return;
video.dataset.streamerCanplayBound = "1";
const onReady = () => {
delete video.dataset.streamerCanplayBound;
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onErr);
if (pickActiveStreamerVideo() === video) tryPlay();
};
const onErr = () => {
delete video.dataset.streamerCanplayBound;
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onErr);
};
video.addEventListener("canplay", onReady);
video.addEventListener("error", onErr);
}
function pickActiveStreamerVideo() {
@@ -3571,7 +3921,6 @@ function pickActiveStreamerVideo() {
if (rect.height <= 0 || rect.width <= 0) continue;
const videoMid = (rect.top + rect.bottom) / 2;
const dist = Math.abs(videoMid - mid);
// Prefer more visible + closer to the visual center band.
const score = ratio * vh - dist;
if (score > bestScore) {
bestScore = score;
@@ -3581,47 +3930,102 @@ function pickActiveStreamerVideo() {
return best;
}
function syncStreamerVideoPlayback() {
/** Apply near / play / far tiers; only the active clip gets preload=auto. */
function reconcileStreamerVideoLoads() {
const active = pickActiveStreamerVideo();
document.querySelectorAll("video.streamer-video").forEach((v) => {
if (!(v instanceof HTMLVideoElement) || !v.isConnected) return;
if (!v.dataset.streamerSrc) return;
const near = streamerVideoNear.has(v) || v === active;
if (v === active) {
ensureStreamerVideoSrc(v, { preload: "auto" });
if (v.paused) playStreamerVideo(v);
} else if (!v.paused) {
v.pause();
return;
}
if (!v.paused) v.pause();
if (near) {
ensureStreamerVideoSrc(v, { preload: "metadata" });
} else {
unloadStreamerVideoSrc(v);
}
});
}
function ensureStreamerVideoObserver() {
if (streamerVideoObserver || typeof IntersectionObserver !== "function") {
return streamerVideoObserver;
function queueReconcileStreamerVideoLoads() {
if (streamerVideoReconcileQueued) return;
streamerVideoReconcileQueued = true;
requestAnimationFrame(() => {
streamerVideoReconcileQueued = false;
reconcileStreamerVideoLoads();
});
}
function syncStreamerVideoPlayback() {
queueReconcileStreamerVideoLoads();
}
function resetStreamerVideoObserver() {
if (streamerVideoPlayObserver) {
streamerVideoPlayObserver.disconnect();
streamerVideoPlayObserver = null;
}
streamerVideoObserver = new IntersectionObserver(
if (streamerVideoNearObserver) {
streamerVideoNearObserver.disconnect();
streamerVideoNearObserver = null;
}
streamerVideoVisibility.clear();
streamerVideoNear.clear();
streamerVideoReconcileQueued = false;
}
function ensureStreamerVideoObservers() {
if (typeof IntersectionObserver !== "function") return;
if (!streamerVideoPlayObserver) {
streamerVideoPlayObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target;
if (!(video instanceof HTMLVideoElement)) continue;
streamerVideoVisibility.set(
video,
entry.isIntersecting ? entry.intersectionRatio : 0
entry.isIntersecting ? entry.intersectionRatio : 0,
);
}
syncStreamerVideoPlayback();
queueReconcileStreamerVideoLoads();
},
{
// Shrink root to a mid-screen band so tall cards don't need 45% of video on screen.
root: null,
rootMargin: "-12% 0px -32% 0px",
threshold: [0, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1],
}
},
);
return streamerVideoObserver;
}
if (!streamerVideoNearObserver) {
streamerVideoNearObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target;
if (!(video instanceof HTMLVideoElement)) continue;
if (entry.isIntersecting) streamerVideoNear.add(video);
else streamerVideoNear.delete(video);
}
queueReconcileStreamerVideoLoads();
},
{
// ~1 viewport above/below: warm metadata only, no full download.
root: null,
rootMargin: "100% 0px 100% 0px",
threshold: 0,
},
);
}
}
function observeStreamerVideo(video) {
bindStreamerVideoSoundToggle(video);
const obs = ensureStreamerVideoObserver();
if (obs) obs.observe(video);
ensureStreamerVideoObservers();
if (streamerVideoNearObserver) streamerVideoNearObserver.observe(video);
if (streamerVideoPlayObserver) streamerVideoPlayObserver.observe(video);
}
/** Compact Chinese count: 1234 / 1.2万 / 1.2亿 */
@@ -3705,11 +4109,15 @@ function buildStreamerHeroTags(heroes) {
const himg = document.createElement("img");
himg.src = portraitSrc(key);
himg.alt = (h && h.name_loc) || key;
himg.loading = "lazy";
// Eager + high: same priority as streamer avatars vs video preloads.
himg.loading = "eager";
himg.decoding = "async";
try {
himg.fetchPriority = "high";
} catch {
/* older engines */
}
chip.appendChild(himg);
const label = document.createElement("span");
label.textContent = (h && h.name_loc) || key;
chip.appendChild(label);
chip.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
@@ -3777,7 +4185,24 @@ function buildStreamerCard(row) {
img.className = "streamer-avatar";
img.src = avatarSrc;
img.alt = nick;
img.loading = "lazy";
// Eager + high: avatars must win over sequential video preloads on HTTP/1.1.
img.loading = "eager";
img.decoding = "async";
try {
img.fetchPriority = "high";
} catch {
/* older engines */
}
img.addEventListener(
"error",
() => {
const ph = document.createElement("div");
ph.className = "streamer-avatar streamer-avatar-fallback";
ph.textContent = (nick || "?").slice(0, 1);
img.replaceWith(ph);
},
{ once: true },
);
avatarInner.appendChild(img);
} else {
const ph = document.createElement("div");
@@ -3835,19 +4260,32 @@ function buildStreamerCard(row) {
const vwrap = document.createElement("div");
vwrap.className = "streamer-video-wrap";
const frame = document.createElement("div");
frame.className = "streamer-video-frame";
// Compact skeleton until metadata; avoid full-bleed 9:16 black void.
frame.className = "streamer-video-frame is-pending";
const earlyAspect = parseStreamerVideoAspect(row);
if (earlyAspect) {
frame.style.aspectRatio = `${earlyAspect.w} / ${earlyAspect.h}`;
frame.classList.toggle("is-landscape", earlyAspect.w >= earlyAspect.h);
}
const video = document.createElement("video");
video.className = "streamer-video";
video.src = videoSrc;
// Defer src until viewport near / play tier attaches it.
video.dataset.streamerSrc = videoSrc;
video.preload = "none";
video.loop = true;
video.controls = true;
video.playsInline = true;
video.preload = "auto";
video.setAttribute("controls", "");
video.setAttribute("playsinline", "");
// Autoplay muted; user unmutes via native controls when they want sound.
video.muted = true;
video.setAttribute("muted", "");
const posterSrc = streamerVideoPosterSrc(row);
if (posterSrc) {
video.poster = posterSrc;
frame.classList.add("has-poster");
frame.style.backgroundImage = `url("${posterSrc}")`;
}
if (row.video_title) video.setAttribute("aria-label", String(row.video_title));
// Canvas letterbox scan needs CORS when clips are served from OSS.
if (staticAssetBase()) video.crossOrigin = "anonymous";
@@ -3855,9 +4293,11 @@ function buildStreamerCard(row) {
video.addEventListener("loadedmetadata", fitFrameToVideo);
video.addEventListener("loadeddata", fitFrameToVideo);
video.addEventListener("canplay", fitFrameToVideo);
if (video.readyState >= 2) fitFrameToVideo();
else if (video.readyState >= 1) fitFrameToVideo();
video.addEventListener("error", () => vwrap.remove());
video.addEventListener("error", () => {
// Ignore empties before the viewport tier attaches src.
if (video.dataset.streamerSrcAttached !== "1") return;
vwrap.remove();
});
frame.appendChild(video);
vwrap.appendChild(frame);
observeStreamerVideo(video);
@@ -3881,9 +4321,49 @@ function buildStreamerCards(rows, { emptyText } = {}) {
for (const row of rows) {
wrap.appendChild(buildStreamerCard(row));
}
// Avatars + hero portraits first; then viewport-tier video attach.
waitStreamerImagesThenSyncVideos(wrap);
return wrap;
}
/** Prefer images before reconciling which clip to warm/play. */
function waitStreamerImagesThenSyncVideos(wrap) {
const imgs = [
...wrap.querySelectorAll("img.streamer-avatar"),
...wrap.querySelectorAll(".streamer-hero-chip img"),
];
const start = () => {
if (!wrap.isConnected) return;
queueReconcileStreamerVideoLoads();
};
if (!imgs.length) {
start();
return;
}
let settled = 0;
let started = false;
const kick = () => {
if (started) return;
started = true;
start();
};
const onOne = () => {
settled += 1;
if (settled >= imgs.length) kick();
};
for (const img of imgs) {
// complete + broken (naturalWidth 0) still counts as settled.
if (img.complete) {
onOne();
continue;
}
img.addEventListener("load", onOne, { once: true });
img.addEventListener("error", onOne, { once: true });
}
// Soft cap so a hung image cannot block video forever.
setTimeout(kick, 2500);
}
function buildHeroStreamersPanel(heroKey) {
const panel = document.createElement("div");
panel.className = "detail-streamers-panel streamers-center-wrap";
@@ -4536,6 +5016,7 @@ function setPage(page) {
if (
page !== "heroes" &&
page !== "rankings" &&
page !== "matches" &&
page !== "streamers" &&
page !== "trends" &&
page !== "mechanics" &&
@@ -4551,6 +5032,10 @@ function setPage(page) {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "matches") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "streamers") {
state.selectedKey = null;
state.selectedItemKey = null;
@@ -4583,6 +5068,7 @@ function syncChrome() {
const itemsTb = $("#items-toolbar");
const heroesView = $("#heroes-view");
const rankingsView = $("#rankings-view");
const matchesView = $("#matches-view");
const streamersView = $("#streamers-view");
const trendsView = $("#trends-view");
const mechanicsView = $("#mechanics-view");
@@ -4593,6 +5079,7 @@ function syncChrome() {
if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items");
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers");
if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends");
if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics");
@@ -4609,6 +5096,8 @@ function render() {
renderDetail();
} else if (state.page === "rankings") {
renderRankings();
} else if (state.page === "matches") {
renderMatchesPage();
} else if (state.page === "streamers") {
renderStreamers();
} else if (state.page === "trends") {
@@ -4641,7 +5130,7 @@ function applyPatch(patch) {
// Page (default heroes on bad/missing).
if (
patch.page &&
["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
) {
state.page = patch.page;
} else {
@@ -4699,6 +5188,14 @@ function applyPatch(patch) {
state.rankingRegion = lb.default_region || order[0] || "china";
}
}
// Star-player filter (matches page).
if (state.page === "matches") {
if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) {
state.matchesPlayerId = String(patch.matchesPlayerId);
} else {
state.matchesPlayerId = null;
}
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
state.selectedItemKey = patch.itemKey;
+1 -1
View File
@@ -1,4 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.61";
var SITE_VERSION = "0.5.71";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+17 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.61" />
<link rel="stylesheet" href="/style.css?v=0.5.71" />
</head>
<body>
<header class="topbar">
@@ -27,6 +27,7 @@
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="rankings">排行</button>
<button type="button" class="main-tab" data-page="streamers">主播</button>
<button type="button" class="main-tab" data-page="matches">比赛</button>
<button type="button" class="main-tab" data-page="trends">走势</button>
<button type="button" class="main-tab" data-page="mechanics">机制</button>
<button type="button" class="main-tab" data-page="items">物品</button>
@@ -67,6 +68,18 @@
</div>
</main>
<main id="matches-view" class="board rankings-board matches-board hidden">
<div class="rankings-cluster">
<div class="rankings-center-wrap matches-center-wrap">
<div class="rankings-body matches-body" id="matches-body"></div>
<aside class="rankings-aside" aria-label="选手筛选">
<div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div>
<p class="rankings-sub" id="matches-sub"></p>
</aside>
</div>
</div>
</main>
<main id="streamers-view" class="board rankings-board streamers-board hidden">
<div class="rankings-cluster">
<div class="rankings-center-wrap streamers-center-wrap">
@@ -136,8 +149,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.61"></script>
<script src="/router.js?v=0.5.61"></script>
<script src="/app.js?v=0.5.61"></script>
<script src="/config.js?v=0.5.71"></script>
<script src="/router.js?v=0.5.71"></script>
<script src="/app.js?v=0.5.71"></script>
</body>
</html>
+13 -2
View File
@@ -4,11 +4,13 @@
* Hash-based router for the Climperor web site (web/relations).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | streamers | trends | mechanics | items | patches (top-level tab)
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches (top-level tab)
* - 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)
* - matches: star-player recent matches (pro_matches)
* #/matches[/account_id]
* - streamers: curated Douyin streamer directory
* #/streamers
* - trends: medal bracket for the 8-week win/pick board
@@ -30,7 +32,7 @@
*/
const ROUTE_DEFAULT = "#/heroes";
const VALID_PAGES = ["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_DETAIL_TABS = [
"skills",
"core",
@@ -92,6 +94,7 @@ function parseHash(hash) {
itemKey: null,
patchVersion: null,
rankingRegion: null,
matchesPlayerId: null,
trendsBracket: null,
trendsSort: null,
mechanicEffect: null,
@@ -118,6 +121,10 @@ function parseHash(hash) {
if (segs[1] && segs[1] !== "meta") {
out.rankingRegion = safeDecode(segs[1]);
}
} else if (page === "matches") {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.matchesPlayerId = segs[1];
}
} else if (page === "trends") {
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
} else if (page === "mechanics") {
@@ -175,6 +182,10 @@ function serializeHash(state) {
if (region && region !== DEFAULT_RANKING_REGION) {
hash += "/" + encodeURIComponent(region);
}
} else if (state.page === "matches") {
if (state.matchesPlayerId) {
hash += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "trends") {
// Omit bracket when it is the default (legend) — bare #/trends means legend.
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
+145 -4
View File
@@ -700,7 +700,7 @@ body {
}
.matchup-row {
display: grid;
grid-template-columns: 64px 1fr auto auto;
grid-template-columns: 64px 1fr auto;
align-items: center;
gap: 10px;
width: 100%;
@@ -726,24 +726,73 @@ body {
border-radius: 3px;
flex-shrink: 0;
}
.matchup-mid {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.matchup-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.matchup-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.matchup-score {
font-variant-numeric: tabular-nums;
font-size: 14px;
color: #7eb6ff;
white-space: nowrap;
}
.matchup-score.is-agree {
color: #7ecf9a;
}
.matchup-score.is-conflict {
color: #e0a06a;
}
.matchup-games {
font-size: 12px;
min-width: 2.75rem;
text-align: right;
white-space: nowrap;
}
.matchup-scope-hint,
.matchup-freshness,
.matchup-notes {
font-size: 12px;
line-height: 1.45;
}
.matchup-notes {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.matchup-note-line,
.matchup-note-time {
margin: 0;
}
.matchup-note-line {
white-space: nowrap;
}
.matchup-note-time {
margin-top: 2px;
opacity: 0.85;
}
.matchup-note-line.is-stale,
.matchup-freshness.is-stale {
color: #e0a06a;
}
.meta-hero {
display: flex;
align-items: center;
@@ -1716,6 +1765,36 @@ body {
.match-card.is-loss .match-result {
color: rgba(230, 130, 130, 0.95);
}
.match-hero {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
margin: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
max-width: 9.5em;
}
.match-hero:hover .match-hero-name {
color: rgba(255, 220, 160, 0.95);
}
.match-hero-icon {
width: 36px;
height: 20px;
object-fit: cover;
border-radius: 3px;
flex: 0 0 auto;
background: rgba(0, 0, 0, 0.25);
}
.match-hero-name {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match-kda {
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
@@ -2522,6 +2601,24 @@ body:has(#items-view:not(.hidden)) {
outline: 2px solid var(--sel);
outline-offset: 1px;
}
.matches-board .matches-body {
min-height: 0;
}
.matches-center-wrap {
align-items: stretch;
}
.matches-players {
display: flex;
flex-direction: column;
gap: 6px;
max-height: min(70vh, 560px);
overflow: auto;
scrollbar-width: thin;
padding-right: 2px;
}
.matches-board .match-list {
gap: 12px;
}
.rankings-empty {
padding: 48px 0;
text-align: center;
@@ -3344,7 +3441,7 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
gap: 0;
padding: 0;
border: none;
background: transparent;
@@ -3391,14 +3488,58 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
padding-top: 2px;
}
.streamer-video-frame {
/* Default 9:16; JS may set aspect-ratio from video metadata */
/* Real ratio set by JS after metadata; pending uses compact skeleton. */
width: 100%;
aspect-ratio: 9 / 16;
overflow: hidden;
border-radius: 8px;
background: #0a0a0a;
box-sizing: border-box;
}
.streamer-video-frame.is-pending {
aspect-ratio: 16 / 9;
height: auto;
max-height: none;
position: relative;
background: linear-gradient(90deg, #121212 0%, #1a1a1a 45%, #121212 100%);
background-size: 200% 100%;
animation: streamer-video-skeleton 1.15s ease-in-out infinite;
}
.streamer-video-frame.is-pending.has-poster {
background-color: #0a0a0a;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
animation: none;
}
.streamer-video-frame.is-pending::after {
content: "视频加载中";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.38);
pointer-events: none;
}
.streamer-video-frame.is-pending.has-poster::after {
content: "";
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.35) 0%,
rgba(0, 0, 0, 0.08) 45%,
transparent 100%
);
}
.streamer-video-frame.is-pending .streamer-video {
opacity: 0;
pointer-events: none;
}
@keyframes streamer-video-skeleton {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.streamer-video-frame.is-landscape {
width: 100%;
}