diff --git a/AGENTS.md b/AGENTS.md index 5091031..1eef7f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 只比对列表与本地 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) | -| `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 对位/协同 Top(counters/countered/synergies;由 `fetch_stratz_meta.py` 生成;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) | +| `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` 生成) | | `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 只打印) diff --git a/CHANGELOG.md b/CHANGELOG.md index 232b6e6..8546295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/.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 diff --git a/README.md b/README.md index a6761ba..7b1be40 100644 --- a/README.md +++ b/README.md @@ -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.json(Web 走势兜底) -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 自动跟踪) diff --git a/web/data/pro_player_watchlist.json b/web/data/pro_player_watchlist.json new file mode 100644 index 0000000..0a98f75 --- /dev/null +++ b/web/data/pro_player_watchlist.json @@ -0,0 +1,49 @@ +{ + "meta": { + "note_zh": "手工维护的明星选手 OpenDota account_id;fetch_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"} + ] +} diff --git a/web/export_relations_site.py b/web/export_relations_site.py index c31e422..cf368e7 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -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 diff --git a/web/fetch_pro_matches.py b/web/fetch_pro_matches.py index f8aba9c..2d3c6db 100644 --- a/web/fetch_pro_matches.py +++ b/web/fetch_pro_matches.py @@ -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,22 +43,84 @@ 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) - if isinstance(val, str) and val.strip(): - name_to_id[val.strip().lower()] = aid + val = prof.get("name") + if isinstance(val, str) and val.strip(): + name_to_id[val.strip().lower()] = aid out: list[int] = [] for part in raw.split(","): token = part.strip() @@ -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}" diff --git a/web/frontend/app.js b/web/frontend/app.js index 372720e..d6c724f 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -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() { `; } +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 = [ + ``, + ]; + 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( + `` + ); + } + 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 = + '
暂无比赛数据(请运行 python web/fetch_pro_matches.py)
'; + 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 = '
该选手暂无近期比赛
'; + 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,37 +3825,88 @@ 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} mid-band visibility ratio */ +const streamerVideoVisibility = new Map(); +/** @type {Set} 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 p = video.play(); - if (p && typeof p.catch === "function") { - p.catch(() => { - video.muted = true; - video.setAttribute("muted", ""); - delete video.dataset.streamerSoundOn; - const p2 = video.play(); - if (p2 && typeof p2.catch === "function") p2.catch(() => {}); - }); + const tryPlay = () => { + const p = video.play(); + if (p && typeof p.catch === "function") { + p.catch(() => { + video.muted = true; + video.setAttribute("muted", ""); + delete video.dataset.streamerSoundOn; + const p2 = video.play(); + if (p2 && typeof p2.catch === "function") p2.catch(() => {}); + }); + } + }; + // HAVE_FUTURE_DATA — enough to start; do not wait for canplaythrough. + if (video.readyState >= 3) { + tryPlay(); + return; } -} - -/** Play streamer clips when scrolled near viewport center (tall cards). */ -let streamerVideoObserver = null; -/** @type {Map} */ -const streamerVideoVisibility = new Map(); - -function resetStreamerVideoObserver() { - if (streamerVideoObserver) { - streamerVideoObserver.disconnect(); - streamerVideoObserver = null; - } - 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; + } + 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, + ); + } + queueReconcileStreamerVideoLoads(); + }, + { + root: null, + rootMargin: "-12% 0px -32% 0px", + threshold: [0, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1], + }, + ); + } + 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, + }, + ); } - streamerVideoObserver = 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 - ); - } - syncStreamerVideoPlayback(); - }, - { - // 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; } 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; diff --git a/web/frontend/config.js b/web/frontend/config.js index 91f8ba5..68053ec 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -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 = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index c21d85c..d3a0d57 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -5,7 +5,7 @@ DOTA2 上分帝 - +
@@ -27,6 +27,7 @@ + @@ -67,6 +68,18 @@ +
+
+
+
+ +
+
+
+
@@ -136,8 +149,8 @@
- - - + + + diff --git a/web/frontend/router.js b/web/frontend/router.js index d8e9012..2f1aa82 100644 --- a/web/frontend/router.js +++ b/web/frontend/router.js @@ -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; diff --git a/web/frontend/style.css b/web/frontend/style.css index d514467..6c9268e 100644 --- a/web/frontend/style.css +++ b/web/frontend/style.css @@ -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%; }