v0.5.59: item counter evidence for fears, catch up Web features to site version.
Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -1,2 +1,14 @@
|
||||
# Copy to .env (gitignored). Get a free token at https://stratz.com/api
|
||||
# Or inject via keyzoo asset `refining/stratz` (api_key → KEYZOO_ASSET_API_KEY).
|
||||
# Gitea Actions web-weekly uses the same name as a repo Actions secret.
|
||||
STRATZ_API_TOKEN=
|
||||
|
||||
# Optional OpenDota API key (higher rate limit for fetch_hero_matches.py --workers).
|
||||
# OPENDOTA_API_KEY=
|
||||
|
||||
# Optional local deploy / OSS (or keyzoo inject). Gitea web-* workflows use
|
||||
# CLOUDFLARE_* + OSS_* as Actions secrets (see README「定时刷新」).
|
||||
# CLOUDFLARE_EMAIL=
|
||||
# CLOUDFLARE_API_KEY=
|
||||
# OSS_ACCESS_KEY_ID=
|
||||
# OSS_ACCESS_KEY_SECRET=
|
||||
|
||||
@@ -27,6 +27,13 @@ plans/
|
||||
data/matchups.json
|
||||
data/synergies.json
|
||||
data/stratz_matchups.json
|
||||
data/stratz_hero_meta.json
|
||||
data/stratz_matchup_tops.json
|
||||
data/item_counter_stats.json
|
||||
data/hero_stats.json
|
||||
data/hero_matches.json
|
||||
data/pro_matches.json
|
||||
data/pro_builds.json
|
||||
data/relations_audit.json
|
||||
data/relations_import_report.json
|
||||
data/relations_patch_*
|
||||
@@ -36,6 +43,8 @@ assets/ability_videos/
|
||||
assets/ability_icons/
|
||||
assets/hero_portraits/
|
||||
assets/item_icons/
|
||||
# Curated streamer highlight clips (large; sync via OSS, not git)
|
||||
assets/streamer_videos/
|
||||
|
||||
# Static site export (export_relations_site.py)
|
||||
dist/
|
||||
|
||||
@@ -56,7 +56,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
||||
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
|
||||
| `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
||||
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4(Steam CDN;限速)→ `assets/ability_videos/`;A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`)CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
|
||||
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
|
||||
| `fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) |
|
||||
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides |
|
||||
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
|
||||
| `capture.py` | 屏幕捕获 |
|
||||
| `overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) |
|
||||
@@ -80,6 +81,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
||||
| `data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
||||
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
||||
| `data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) |
|
||||
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
||||
| `web/relations/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` |
|
||||
| `web/relations/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` 别名兼容) |
|
||||
@@ -126,7 +128,7 @@ Dota 2 GSI → gsi_watch.py (:3223)
|
||||
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
||||
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
|
||||
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `#/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `recommend.py` 读取。同英雄近期比赛出装/加点单独存 `data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `data/streamers.json`(手工名单 + 抖音 profile 补全;首版仅抖音),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
|
||||
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
|
||||
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
|
||||
- **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
||||
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
||||
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
||||
@@ -155,6 +157,7 @@ python fetch_hero_abilities.py # 英雄技能 / 驱散汇总
|
||||
python fetch_hero_abilities.py --tags-only # 只重算技能「施加」类 tags
|
||||
python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/
|
||||
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
|
||||
python fetch_item_counter_stats.py # OpenDota 对阵装备相对全局基线(可再生成缓存)
|
||||
python item_fears.py # 英雄怕的装备
|
||||
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
||||
python fetch_patches.py --check # 只比对列表与本地 details,stdout JSON(has_new)
|
||||
|
||||
@@ -4,38 +4,50 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.59] - 2026-07-29
|
||||
|
||||
### Added
|
||||
|
||||
- 上分帝 Web「被克装备」观测证据:`fetch_item_counter_stats.py`(OpenDota Explorer
|
||||
对阵购买率/条件胜率减同装备全局队伍基线)→ `data/item_counter_stats.json`;
|
||||
`item_fears.py` 对全部英雄已有机制候选小幅调序;显著新组合须机制复核后写入 overrides。
|
||||
- 上分帝 Web 日活飞书推送:`notify_site_traffic.py` + Gitea Actions
|
||||
`site-traffic-notify`(每日 09:00 CST;真实访问按已知浏览器 UA 打开 `/` 估算;
|
||||
卡片含自动刷新:`web-daily`/`weekly`/`patch` 结论 + 当日 Pages 生产部署)。
|
||||
- 上分帝 Web 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜(初期 / 迷你走势 / 末期 / 变化;STRATZ;无禁用率;`0.5.41`)。
|
||||
- 上分帝 Web 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜(初期 / 迷你走势 / 末期 / 变化;STRATZ;无禁用率)。
|
||||
- 上分帝 Web 接入 STRATZ(`fetch_stratz_meta.py`,token 经 `.env` / keyzoo):
|
||||
- 英雄详情「走势」优先 STRATZ:勋章条 + **最近 1 周**三卡 + 分路 + **近 8 周**逐周列表(OpenDota 作兜底)
|
||||
- 新子 Tab「对位」`#/heroes/<key>/matchups`:克制 / 被克 / 搭档三列 STRATZ Top(与网格机制「克/怕/搭」分开展示)
|
||||
- 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜
|
||||
- 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(gitignore;仅 Web,不进 recommend / relations)
|
||||
- 上分帝 Web「近期比赛」Tab(`#/heroes/<key>/matches`):同英雄近期局终局出装 +
|
||||
加点顺序;展示选手昵称/段位勋章与 OpenDota 选手·比赛链接;
|
||||
加点顺序;展示选手昵称/段位勋章与外链;
|
||||
`fetch_hero_matches.py` → `data/hero_matches.json`
|
||||
(`--source league|public|both`;`--enrich-players` 可补昵称段位;
|
||||
`--enrich-item-times` 可补购买时间;仅 Web 站点,不进局内推荐)。
|
||||
(`--source league|public|both`;`--workers`;`--enrich-item-times`;仅 Web,不进局内推荐)。
|
||||
- 选将规则阵容画像(不接 AI):识别敌方推进/全球流、敌我缺口(缺控制/爆发/输出/先手);
|
||||
全网格标 **克 / 搭 / 补**;叠加层显示一句阵容分析;每英雄短推荐原因写入日志与
|
||||
`results` JSON(`draft_archetypes.py` + `recommend.suggest_marks`)。
|
||||
|
||||
### Changed
|
||||
|
||||
- 上分帝 Web 英雄详情「走势」(`0.5.30`–`0.5.37`):
|
||||
- 三卡 / 分路统一 **当前选中勋章 · 最近 1 周**(STRATZ `winWeek`;分路改 `positionIds`,不再用合并档 `stats`)
|
||||
- 布局:勋章条 →「最近 1 周」提示 → 三卡 → 分路 → 近 8 周列表;Tab 内容区可滚动
|
||||
- 近 8 周:逐周列表(新→旧),含胜率、周环比、按本英雄 8 周区间缩放的横条、场次;去掉 50% 中线与底部刻度文案
|
||||
- 上分帝 Web「被克装备」卡片有对阵证据时显示购买率角标;显著新组合补入 overrides
|
||||
(帕克紫怨、幻影刺客/编织者/石鳞剑士鬼杖、编织者 Eul、石鳞剑士/虚无之灵羊刀);
|
||||
剑圣移除林肯法球并补入幽魂权杖。
|
||||
- 上分帝 Web 英雄详情「走势」:三卡 / 分路统一 **当前选中勋章 · 最近 1 周**;
|
||||
布局为勋章条 → 提示 → 三卡 → 分路 → 近 8 周列表。
|
||||
- 文档与注释统一称呼:`web/relations/` 子项目改称 **上分帝 Web**(不再称「关系预览」);路径与脚本名(如 `serve_relations.py`)暂保持不变。
|
||||
- 「近期比赛」公开对局默认优先国服(Perfect World cluster;`--public-region china`),联赛+天梯可用 `--source both`;卡片来源显示「国服 / 天梯 / 联赛」。
|
||||
- 「近期比赛」改为展示最近 10 场胜局(传奇及以上天梯 + 联赛/职业,按时间从新到旧);公开列表过滤机器人/Turbo,仅保留 ranked lobby(`0.5.40`)。
|
||||
- 「近期比赛」外链改 Dotabuff(选手/详情);`fetch_hero_matches.py` 支持 `--workers` 详情并发与不足 N 场增量补拉(`0.5.49`)。
|
||||
- 「近期比赛」公开对局默认优先国服;展示最近 10 场胜局;外链改 Dotabuff。
|
||||
- 选将推荐不再限 Top-3 名次青标;非定位局也可推克/搭/补(无分路字则不过滤)。
|
||||
|
||||
## [0.5.58] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- 上分帝 Web「主播」新增 B 站沙王主播,收录主页、直播间、关注与粉丝数。
|
||||
|
||||
### Changed
|
||||
|
||||
- 主播卡片元信息按平台显示:抖音显示获赞/粉丝,B 站显示 UID/关注/粉丝。
|
||||
|
||||
## [0.5.57] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -167,15 +167,17 @@ climperor/
|
||||
├── fetch_cdn_templates.py / fetch_hero_portraits.py / fetch_hero_items.py
|
||||
├── fetch_item_shop.py / fetch_items_meta.py / fetch_hero_abilities.py
|
||||
├── fetch_ability_videos.py / fetch_stratz.py / audit_relations.py
|
||||
├── fetch_patches.py / import_relations_xlsx.py / serve_relations.py
|
||||
├── fetch_patches.py / refresh_web.py / import_relations_xlsx.py / serve_relations.py
|
||||
├── export_relations_site.py / deploy_relations.py
|
||||
├── .gitea/workflows/ # site-traffic-notify + web-daily/weekly/patch
|
||||
├── autocalibrate.py / calibrate.py / capture.py / evaluate.py
|
||||
├── web/relations/ # 关系 / 物品 / 版本只读预览页
|
||||
├── web/relations/ # 上分帝 Web(英雄/排行/物品/版本)
|
||||
├── templates/cdn|roles|modes/
|
||||
├── assets/ # portraits / icons / videos(部分 gitignore)
|
||||
└── samples/labels.json # 评测真值;raw/<matchid>/ 见 .gitignore
|
||||
```
|
||||
|
||||
运行时目录 `preview/`、`results/`、`failures/`、`samples/raw/`(含按对局子目录)不入库。
|
||||
运行时目录 `preview/`、`results/`、`failures/`、`samples/raw/`(含按对局子目录)不入库。上分帝 Web 易变数据(STRATZ meta、hero_stats 等)可由 Gitea Actions 定时拉取后直部 Pages,不必入库。
|
||||
|
||||
### 主要配置
|
||||
|
||||
@@ -190,14 +192,18 @@ climperor/
|
||||
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
|
||||
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
|
||||
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
|
||||
| `recommend.enabled` | 选将 Top-3 推荐 | true |
|
||||
| `recommend.top_n` | 推荐数量 | 3 |
|
||||
| `recommend.enabled` | 选将全网格克/搭/补推荐 | true |
|
||||
| `recommend.top_n` | 标记数量上限;`≤0` 不截断 | 0 |
|
||||
| `recommend.min_enemies` | 开始推荐所需敌方锁人数 | 1 |
|
||||
| `recommend.min_heroes_for_gaps` | 缺口/补位注入所需锁人数 | 2 |
|
||||
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
|
||||
| `recommend.relations_path` | 定性关系文件 | `data/relations.json` |
|
||||
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位) | 见 config.json |
|
||||
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json |
|
||||
| `overlay.enabled` | 选将顶栏角色标签悬浮层 | true |
|
||||
| `overlay.y_gap_rel` / `icon_h_rel` / `icon_gap_rel` | 标签相对屏幕高的间距与尺寸 | 0.008 / 0.016 / 0.002 |
|
||||
| `overlay.mark_size_rel` / `mark_pad_rel` | 网格青标方标尺寸与内边距 | 0.018 / 0.004 |
|
||||
| `overlay.rec_color` / `rec_text_color` | 青标底色 / 名次文字色 | `#2ec4b6` / `#0b1220` |
|
||||
| `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
|
||||
| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
|
||||
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
|
||||
|
||||
---
|
||||
|
||||
@@ -220,24 +226,30 @@ Dota 2 (-gamestateintegration)
|
||||
|
||||
---
|
||||
|
||||
## 6. 选将 Top-3 推荐
|
||||
## 6. 选将全网格「克 / 搭 / 补」推荐
|
||||
|
||||
- **分路**:定位匹配画面字(`roles.py`)+ GSI `team_slot` 定位本人;不做截屏认自己。
|
||||
- **关系模型**:机制克制/搭档(`data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。
|
||||
- **三视图**:克制(我克谁)/ 被克制(谁克我)/ 搭档;被克制由有向克制边反查。
|
||||
- **打分**:候选 +1×克到的敌 / −1×被敌克 / +1×己方搭档;再按分路角色标签过滤。
|
||||
- **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。
|
||||
- **预览**:`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。
|
||||
另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `data/item_shop.json`。
|
||||
- **预览 Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
||||
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe` 等)。选 Hash
|
||||
- **触发**:敌方至少锁定 1 人(`min_enemies`)后开始;本人锁人后清空标记。
|
||||
- **分路**:定位匹配画面字(`roles.py`)+ GSI `team_slot` 定位本人;不做截屏认自己。有分路则按 `role_tags` 过滤候选;非定位局(读不到分路字)则全英雄表。
|
||||
- **关系模型**:机制克制/搭档(`data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。Web 英雄详情「走势」Tab 优先 STRATZ(`data/stratz_hero_meta.json`):勋章条切换 8 档 → **最近 1 周**三卡(胜率/上场率/场次)→ 1~5 号位分路胜率 → **近 8 周**逐周列表(日期、胜率、周环比、区间缩放横条、场次;新→旧);OpenDota(`data/hero_stats.json`,近约 7 天)作兜底。「对位」Tab 展示 STRATZ 数值 Top(`data/stratz_matchup_tops.json`),与网格定性「克/怕/搭」分开展示;均不进局内推荐。
|
||||
- **阵容画像(规则,不接 AI)**:[`draft_archetypes.py`](draft_archetypes.py) 识别敌方推进/全球流、敌我 tag 缺口(缺控制/爆发/输出/先手);输出一句 `analysis`(如 `敌:缺控制·偏推进 | 我:缺爆发`)与每英雄短 `reasons`。
|
||||
- **纳入**:
|
||||
- **克**:关系克制边;推进/全球流应对表(如美杜莎对推进);惩戒敌方缺口
|
||||
- **搭**:与已锁己有搭档边
|
||||
- **补**:填我方缺口(`min_heroes_for_gaps` 默认 2 才注入缺口类标记)
|
||||
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
|
||||
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
|
||||
- **上分帝 Web**:`serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `data/relations.json` 或从 xlsx 导入。
|
||||
另有顶级 **排行**、**走势**(`#/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `data/item_shop.json` / `data/patches.json` / `data/leaderboards.json`。
|
||||
英雄详情子标签含 **走势**(见上;`#/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`#/heroes/<key>/matchups`)、**近期比赛**(`#/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
|
||||
- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
||||
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe`、`#/heroes/axe/trends` 等)。选 Hash
|
||||
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`serve_relations.py` 零改动、
|
||||
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
|
||||
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
|
||||
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
|
||||
`web/relations/router.js`,`app.js` 只在 `main()` 装 `installRouter` + 各 state 变更点
|
||||
调 `syncStateToUrl`。
|
||||
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`。
|
||||
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`;不接实时 AI。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,21 +27,30 @@ python fetch_cdn_templates.py # 一次性:data/heroes.json(含基础属性
|
||||
# python import_relations_xlsx.py path\to\dota2.xlsx
|
||||
```
|
||||
|
||||
## 克制 / 被克制 / 搭档(游戏外)
|
||||
## 上分帝 Web(游戏外)
|
||||
|
||||
上分帝 Web 是独立子项目(`web/relations/`):英雄克制/搭档、排行、主播、物品、版本等内容站点,与局内选将识别解耦。
|
||||
|
||||
```powershell
|
||||
python fetch_hero_portraits.py # 官网竖版头像 → assets/hero_portraits/
|
||||
python fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → data/hero_items.json
|
||||
python fetch_hero_stats.py # OpenDota 各段位胜率/场次 → data/hero_stats.json(Web 走势兜底)
|
||||
python fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → stratz_hero_meta.json(需 token;见 .env.example)
|
||||
python fetch_hero_matches.py # 同英雄近期比赛出装/加点(含购买时间)→ data/hero_matches.json
|
||||
# 既有行补时间:python fetch_hero_matches.py --enrich-item-times [--heroes juggernaut]
|
||||
python fetch_item_shop.py # 官网商店 11 列目录 → data/item_shop.json
|
||||
python fetch_items_meta.py # 装备描述 + 机制标签 → data/items_meta.json
|
||||
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → data/hero_abilities.json
|
||||
python fetch_ability_videos.py # 官网技能演示 webm(限速)→ assets/ability_videos/
|
||||
python fetch_item_counter_stats.py # OpenDota 对阵购买率/胜率相对全局基线 → 可再生成缓存
|
||||
python item_fears.py # 规则推导「怕的装备」→ data/hero_item_fears.json
|
||||
python fetch_patches.py # 近一年版本日志 + 详情 → data/patches.json(版本页)
|
||||
python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新)
|
||||
python fetch_leaderboards.py # Valve Immortal 四区 Top100 → data/leaderboards.json(排行页)
|
||||
python fetch_streamers.py # 抖音主播主页补全 → data/streamers.json(主播页;手工名单)
|
||||
python serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
|
||||
```
|
||||
|
||||
预览页带 **Hash 路由**:`#/heroes/axe/core`、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python 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`、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
|
||||
|
||||
### 部署到 Cloudflare Pages
|
||||
|
||||
@@ -56,12 +65,50 @@ python _oss_static_assets.py upload # patch/新英雄后同步图标到 OSS
|
||||
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
|
||||
```
|
||||
|
||||
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
|
||||
### 定时刷新(Gitea Actions)
|
||||
|
||||
仓库 [`.gitea/workflows/`](.gitea/workflows/) 在 **self-hosted** runner(与 `site-traffic-notify` 同一 Mac mini)上分层拉数;`refresh_web.py` 比对 JSON/图标摘要,**有变更才** `_oss_static_assets.py upload` + `deploy_relations.py`。数据-only 刷新**不** bump `SITE_VERSION`(`data.json` / `index.html` 已不缓存)。
|
||||
|
||||
| Workflow | 时间(CST) | 档位 | 拉取内容 |
|
||||
|----------|-------------|------|----------|
|
||||
| `web-daily` | 每天 06:00 | `daily` | `hero_stats`、`leaderboards`、`hero_matches`(league)、`pro_matches`、`streamers`(抖音粉丝等,失败保留旧值);再做 patch check |
|
||||
| `web-weekly` | 周二 07:00 | `weekly` | `stratz_meta`、`hero_items`、`items_meta`、OpenDota 对阵装备证据、`item_fears` |
|
||||
| `web-patch` | 每 6 小时 | `patch` | `fetch_patches.py --check`;仅 `has_new` 时拉详情 + abilities/商店/items_meta/fears |
|
||||
|
||||
**版本检测:** `--check` 只请求 Valve 版本列表,与本地 `data/patches.json` 的 `details` 比对,stdout 一行 JSON(`has_new` / `new_versions` / `latest`)。无新版本则跳过详情与部署。
|
||||
|
||||
**本机试跑:**
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt -r requirements-web.txt
|
||||
python fetch_patches.py --check
|
||||
python refresh_web.py --tier patch --dry-run
|
||||
python refresh_web.py --tier patch --skip-deploy --skip-oss
|
||||
# 真实拉数+部署(需 CF / OSS env 或 keyzoo):
|
||||
# python refresh_web.py --tier daily
|
||||
```
|
||||
|
||||
**Runner 要求:** `python3`(或 `python`)、`node`/`npx`(wrangler)、出网访问 OpenDota / STRATZ / Valve / Cloudflare / 阿里云 OSS。
|
||||
|
||||
**Gitea Secrets**(仓库 Settings → Actions → Secrets):
|
||||
|
||||
| Secret | 用途 |
|
||||
|--------|------|
|
||||
| `CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` | 部署 Pages(与 `site-traffic-notify` 共用;可用 `_gitea_actions_secrets.py` 写入) |
|
||||
| `STRATZ_API_TOKEN` | `web-weekly`(及 daily 内若触发连带时不需要) |
|
||||
| `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` | 图标变更时上传 OSS |
|
||||
|
||||
手工数据(`relations.json`、overrides)与技能视频(GB 级)**不**进定时;新英雄仍需本机 `fetch_cdn_templates.py` + `fetch_hero_portraits.py`。冒烟:Gitea Actions 对 `web-patch` 点 **Run workflow**。
|
||||
|
||||
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**走势**(STRATZ 优先:勋章条 + 最近 1 周三卡 + 分路 + 近 8 周列表;OpenDota 兜底)、**对位**(STRATZ 克制/被克/搭档 Top)、**近期比赛**(终局出装/加点)、**主播**(常玩该英雄的收录主播)、**版本变更**(该英雄近一年各版本改动)。
|
||||
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
||||
|
||||
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `data/leaderboards.json`(`python fetch_leaderboards.py`)。
|
||||
顶部另有 **主播** 页:手工收录直播间/主页(首版仅抖音),抖音式卡片展示昵称、签名、关注/粉丝/获赞、常用英雄与可选精选高光视频(滚入视口中部自动播放;有 `live_url` 时点头像进直播间并显示直播动效;**角标不代表真实在播**,待接入 `is_live`);数据来自 `data/streamers.json`(`python fetch_streamers.py` 补全;亦由 `refresh_web --tier daily` 每日软失败刷新)。视频文件放 `assets/streamer_videos/`(gitignore),本地开发经 `/streamer-video/` 提供,部署前用 `_oss_static_assets.py upload` 同步至 OSS `streamer-video/`。
|
||||
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`;STRATZ 周胜率数据)。
|
||||
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `data/item_shop.json`(`python fetch_item_shop.py`)。
|
||||
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `data/patches.json`(`python fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
|
||||
数据在 `data/relations.json`(英雄关系,不用胜率);选将 Top-3 推荐读同一份文件。热门装备来自 OpenDota 统计;被克装备由描述标签 + 规则推导(可改 `data/item_tag_overrides.json` 后重跑 `fetch_items_meta.py` 与 `item_fears.py`)。**尚无对局内出装推荐**。
|
||||
数据在 `data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ(`data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 在 `data/stratz_matchup_tops.json`(与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `data/item_tag_overrides.json` 后重跑 `fetch_items_meta.py` 与 `item_fears.py`。**尚无对局内出装推荐**。
|
||||
|
||||
## 快速开始(GSI 自动跟踪)
|
||||
|
||||
@@ -71,7 +118,7 @@ python gsi_setup.py # 写入 Dota GSI 配置
|
||||
python gsi_watch.py
|
||||
```
|
||||
|
||||
进入英雄选择后自动跟踪;定位匹配下会按本局分路 + 敌方/己方关系在英雄网格左上角标出 **Top 3** 青色名次方标(需 `data/relations.json`)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。
|
||||
进入英雄选择后自动跟踪;敌方锁 ≥1 人后按关系与阵容画像在英雄网格标出 **克 / 搭 / 补**,并显示一句阵容分析(需 `data/relations.json`;定位局按分路过滤)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。
|
||||
会话截图按对局落在 `samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl`(`--no-dump-gsi` 可关)。
|
||||
|
||||
> 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。
|
||||
@@ -116,7 +163,7 @@ python evaluate.py # 按 samples/labels.json 批量评测
|
||||
|
||||
- 宽高比差异大(如 21:9)时可能需重标定
|
||||
- 客户端改网格排序后 `grid.py` 会 `ok=False`,不会瞎报禁用名单
|
||||
- 新英雄上线后重跑 `fetch_cdn_templates.py`;版本更新后重跑 `fetch_patches.py`
|
||||
- 新英雄上线后重跑 `fetch_cdn_templates.py`(不在定时任务内);版本列表由 Gitea `web-patch` 自动检测,有更新才拉 `fetch_patches.py` 详情
|
||||
- 位置文字模板取自简体中文客户端
|
||||
- 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填
|
||||
- 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Stage / apply Gitea Actions secrets for site-traffic-notify.
|
||||
|
||||
Pass 1 (feishu): python _gitea_actions_secrets.py stash-feishu
|
||||
Pass 2 (cloudflare): python _gitea_actions_secrets.py stash-cf
|
||||
Pass 3 (gitea PAT): python _gitea_actions_secrets.py apply
|
||||
|
||||
Staging file is user-only and deleted after apply. Never prints secret values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_actions_secrets.json"
|
||||
GITEA_URL = os.environ.get("GITEA_URL") or os.environ.get("KEYZOO_ASSET_META_URL") or "https://gitea.refining.dev"
|
||||
OWNER = "refining"
|
||||
REPO = "climperor"
|
||||
SECRET_NAMES = (
|
||||
"CLOUDFLARE_EMAIL",
|
||||
"CLOUDFLARE_API_KEY",
|
||||
"FEISHU_WEBHOOK_URL",
|
||||
)
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if not STASH.exists():
|
||||
return {}
|
||||
return json.loads(STASH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
STASH.write_text(json.dumps(data), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(STASH, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def stash_feishu() -> None:
|
||||
url = os.environ.get("KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL") or os.environ.get(
|
||||
"FEISHU_WEBHOOK_URL"
|
||||
)
|
||||
if not url:
|
||||
raise SystemExit("missing FEISHU webhook in env")
|
||||
data = _load()
|
||||
data["FEISHU_WEBHOOK_URL"] = url
|
||||
_save(data)
|
||||
print(f"stashed FEISHU_WEBHOOK_URL ({STASH.name})")
|
||||
|
||||
|
||||
def stash_cf() -> None:
|
||||
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||
"KEYZOO_ASSET_META_USERNAME"
|
||||
)
|
||||
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||
)
|
||||
if not email or not key:
|
||||
raise SystemExit("missing Cloudflare email/key in env")
|
||||
data = _load()
|
||||
data["CLOUDFLARE_EMAIL"] = email
|
||||
data["CLOUDFLARE_API_KEY"] = key
|
||||
_save(data)
|
||||
print(f"stashed CLOUDFLARE_* ({STASH.name})")
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
# Prefer keyzoo-injected secrets over a possibly stale GITEA_TOKEN in the shell.
|
||||
candidates = (
|
||||
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1",
|
||||
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1",
|
||||
"KEYZOO_ASSET_TOKEN",
|
||||
"KEYZOO_ASSET_API_KEY",
|
||||
"GITEA_TOKEN",
|
||||
)
|
||||
for name in candidates:
|
||||
tok = os.environ.get(name)
|
||||
if tok:
|
||||
print(f"using token from {name} (len={len(tok)})")
|
||||
return tok
|
||||
raise SystemExit("missing Gitea token in env")
|
||||
|
||||
|
||||
def _put_secret(name: str, value: str, token: str) -> None:
|
||||
# Gitea Actions: PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}
|
||||
url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{OWNER}/{REPO}/actions/secrets/{name}"
|
||||
body = json.dumps({"data": value}).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="PUT",
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
code = r.status
|
||||
_ = r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(errors="replace")
|
||||
raise SystemExit(f"PUT {name} HTTP {e.code}: {raw[:500]}") from e
|
||||
if code not in (201, 204, 200):
|
||||
raise SystemExit(f"PUT {name} unexpected status {code}")
|
||||
print(f"ok: {name}")
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
data = _load()
|
||||
present = [n for n in SECRET_NAMES if data.get(n)]
|
||||
missing = [n for n in SECRET_NAMES if n not in present]
|
||||
if not present:
|
||||
raise SystemExit(f"stash empty; expected one of {SECRET_NAMES}")
|
||||
if missing:
|
||||
print(f"note: not in stash (left unchanged on server): {missing}")
|
||||
token = _token()
|
||||
base = GITEA_URL.rstrip("/")
|
||||
# Sanity: can we see the repo?
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/v1/repos/{OWNER}/{REPO}",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
repo = json.loads(r.read().decode())
|
||||
print(f"repo: {repo.get('full_name')} (id={repo.get('id')})")
|
||||
|
||||
for name in present:
|
||||
_put_secret(name, data[name], token)
|
||||
|
||||
try:
|
||||
STASH.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
print("stash deleted; secrets applied")
|
||||
|
||||
|
||||
def list_secrets() -> None:
|
||||
token = _token()
|
||||
url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{OWNER}/{REPO}/actions/secrets"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
rows = json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"list secrets HTTP {e.code}: {e.read().decode()[:500]}") from e
|
||||
names = sorted(x.get("name") for x in (rows or []))
|
||||
print("secrets:", names)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit("usage: stash-feishu | stash-cf | apply | list")
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "stash-feishu":
|
||||
stash_feishu()
|
||||
elif cmd == "stash-cf":
|
||||
stash_cf()
|
||||
elif cmd == "apply":
|
||||
apply()
|
||||
elif cmd == "list":
|
||||
list_secrets()
|
||||
else:
|
||||
raise SystemExit(f"unknown cmd: {cmd}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Push climperor and dispatch site-traffic-notify via Gitea API.
|
||||
|
||||
Env: KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1 (or GITEA_TOKEN),
|
||||
optional KEYZOO_ASSET_META_URL.
|
||||
Never prints the token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
OWNER = "refining"
|
||||
REPO = "climperor"
|
||||
WORKFLOW_FILE = "site-traffic-notify.yml"
|
||||
|
||||
|
||||
def token() -> str:
|
||||
for name in (
|
||||
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1",
|
||||
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1",
|
||||
"KEYZOO_ASSET_TOKEN",
|
||||
"GITEA_TOKEN",
|
||||
):
|
||||
v = os.environ.get(name)
|
||||
if v:
|
||||
return v
|
||||
raise SystemExit("missing Gitea token")
|
||||
|
||||
|
||||
def base() -> str:
|
||||
return (
|
||||
os.environ.get("GITEA_URL")
|
||||
or os.environ.get("KEYZOO_ASSET_META_URL")
|
||||
or "https://gitea.refining.dev"
|
||||
).rstrip("/")
|
||||
|
||||
|
||||
def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | list | str]:
|
||||
url = base() + path
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"token {token()}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
raw = r.read().decode()
|
||||
if not raw:
|
||||
return r.status, {}
|
||||
try:
|
||||
return r.status, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return r.status, raw
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(errors="replace")
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return e.code, raw
|
||||
|
||||
|
||||
def push() -> None:
|
||||
# Prefer HTTPS with token in URL for non-interactive push.
|
||||
tok = token()
|
||||
remote = f"https://oauth2:{tok}@gitea.refining.dev/{OWNER}/{REPO}.git"
|
||||
env = os.environ.copy()
|
||||
# Avoid leaking via git trace
|
||||
env.pop("GIT_TRACE", None)
|
||||
r = subprocess.run(
|
||||
["git", "push", remote, "HEAD:main"],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__)) or ".",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=180,
|
||||
)
|
||||
# Redact token if it somehow appears
|
||||
out = (r.stdout or "") + (r.stderr or "")
|
||||
out = out.replace(tok, "***")
|
||||
print(out)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit(f"git push failed: {r.returncode}")
|
||||
|
||||
|
||||
def dispatch(day: str = "") -> None:
|
||||
# Gitea: POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches
|
||||
# workflow_id can be filename
|
||||
body: dict = {"ref": "main"}
|
||||
if day:
|
||||
body["inputs"] = {"day": day}
|
||||
code, resp = api(
|
||||
"POST",
|
||||
f"/api/v1/repos/{OWNER}/{REPO}/actions/workflows/{WORKFLOW_FILE}/dispatches",
|
||||
body,
|
||||
)
|
||||
print(f"dispatch HTTP {code}: {resp}")
|
||||
if code not in (200, 201, 204):
|
||||
# Fallback: list workflows to find id
|
||||
c2, workflows = api("GET", f"/api/v1/repos/{OWNER}/{REPO}/actions/workflows")
|
||||
print(f"workflows HTTP {c2}: {workflows}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def latest_runs() -> None:
|
||||
code, resp = api("GET", f"/api/v1/repos/{OWNER}/{REPO}/actions/runs?limit=5")
|
||||
print(f"runs HTTP {code}")
|
||||
if isinstance(resp, dict):
|
||||
rows = resp.get("workflow_runs") or resp.get("runs") or []
|
||||
elif isinstance(resp, list):
|
||||
rows = resp
|
||||
else:
|
||||
print(resp)
|
||||
return
|
||||
for r in rows[:5]:
|
||||
if isinstance(r, dict):
|
||||
print(
|
||||
f" - id={r.get('id')} name={r.get('name') or r.get('workflow_id')} "
|
||||
f"status={r.get('status')} conclusion={r.get('conclusion')} "
|
||||
f"event={r.get('event')} created={r.get('created_at')}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
if cmd == "push":
|
||||
push()
|
||||
elif cmd == "dispatch":
|
||||
day = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
dispatch(day)
|
||||
elif cmd == "runs":
|
||||
latest_runs()
|
||||
elif cmd == "all":
|
||||
push()
|
||||
dispatch("2026-07-27")
|
||||
latest_runs()
|
||||
else:
|
||||
raise SystemExit("usage: push | dispatch [day] | runs | all")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Sync relations preview static images to Aliyun OSS (keyzoo inject).
|
||||
"""Sync Climperor web site static images to Aliyun OSS (keyzoo inject).
|
||||
|
||||
Builds the same portrait/item/ability/... tree as export_relations_site and
|
||||
uploads under oss://<bucket>/{attr,item,portrait,...}/.
|
||||
@@ -32,7 +32,17 @@ from serve_relations import build_payload
|
||||
|
||||
ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
|
||||
REGION = "cn-shanghai"
|
||||
ASSET_DIRS = ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")
|
||||
ASSET_DIRS = (
|
||||
"attr",
|
||||
"rank",
|
||||
"item",
|
||||
"item-cat",
|
||||
"ability",
|
||||
"ui-icon",
|
||||
"portrait",
|
||||
"streamer-avatar",
|
||||
"streamer-video",
|
||||
)
|
||||
|
||||
|
||||
def _creds() -> tuple[str, str]:
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -15,14 +15,20 @@ TEMPLATES_CDN = ROOT / "templates" / "cdn"
|
||||
HERO_PORTRAITS = ROOT / "assets" / "hero_portraits"
|
||||
# Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only.
|
||||
ATTR_ICONS = ROOT / "assets" / "attr_icons"
|
||||
# Item icons from Steam CDN (dota_react/items/{key}.png); relations preview only.
|
||||
# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only.
|
||||
ITEM_ICONS = ROOT / "assets" / "item_icons"
|
||||
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
||||
ITEM_CAT_ICONS = ROOT / "assets" / "item_cat_icons"
|
||||
# Ability icons from Steam CDN (dota_react/abilities/{key}.png); relations preview only.
|
||||
# Ability icons from Steam CDN (dota_react/abilities/{key}.png); Climperor web site only.
|
||||
ABILITY_ICONS = ROOT / "assets" / "ability_icons"
|
||||
# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); relations preview only.
|
||||
# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); Climperor web site only.
|
||||
UI_ICONS = ROOT / "assets" / "ui_icons"
|
||||
# Rank medal icons (OpenDota rank_icon_0..8); Climperor web trends tab only.
|
||||
RANK_ICONS = ROOT / "assets" / "rank_icons"
|
||||
# Streamer avatars cached by fetch_streamers.py; Climperor web「主播」only.
|
||||
STREAMER_AVATARS = ROOT / "assets" / "streamer_avatars"
|
||||
# Curated Douyin highlight clips for streamer cards; Climperor web「主播」only.
|
||||
STREAMER_VIDEOS = ROOT / "assets" / "streamer_videos"
|
||||
# Official ability demo clips from dota2.com (dota_react/abilities/{hero}/{ability}.webm).
|
||||
ABILITY_VIDEOS = ROOT / "assets" / "ability_videos"
|
||||
|
||||
|
||||
@@ -102,9 +102,12 @@
|
||||
"min_iou": 0.55
|
||||
},
|
||||
"recommend": {
|
||||
"comment": "Top-3 from qualitative relations.json: +counter / -countered / +synergy.",
|
||||
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). top_n<=0 = no cap. No AI.",
|
||||
"enabled": true,
|
||||
"top_n": 3,
|
||||
"top_n": 0,
|
||||
"min_enemies": 1,
|
||||
"min_heroes_for_gaps": 2,
|
||||
"archetypes": true,
|
||||
"relations_path": "data/relations.json",
|
||||
"role_tags": {
|
||||
"1": ["Carry"],
|
||||
@@ -135,15 +138,23 @@
|
||||
"dump_payloads": true
|
||||
},
|
||||
"overlay": {
|
||||
"comment": "Role tags under top-bar + cyan Top-3 recommend marks on the hero grid.",
|
||||
"comment": "Role tags under top-bar + 克/搭/补 marks + lineup analysis banner.",
|
||||
"enabled": true,
|
||||
"y_gap_rel": 0.008,
|
||||
"icon_h_rel": 0.016,
|
||||
"icon_gap_rel": 0.002,
|
||||
"mark_size_rel": 0.018,
|
||||
"mark_pad_rel": 0.004,
|
||||
"rec_color": "#2ec4b6",
|
||||
"rec_text_color": "#0b1220"
|
||||
"mark_gap_rel": 0.002,
|
||||
"counter_color": "#2ec4b6",
|
||||
"synergy_color": "#e9a825",
|
||||
"fill_color": "#9b7ebd",
|
||||
"mark_text_color": "#0b1220",
|
||||
"analysis_y_rel": 0.12,
|
||||
"analysis_h_rel": 0.028,
|
||||
"analysis_font_rel": 0.014,
|
||||
"analysis_bg": "#1a2332",
|
||||
"analysis_fg": "#e8eef7"
|
||||
},
|
||||
"calibrated_from": "draft_141704.png"
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
{
|
||||
"meta": {
|
||||
"note": "Manual add/remove tags merged after auto-tagging in fetch_hero_abilities.py. Tags mean the ability applies the effect (not that it is dispellable)."
|
||||
},
|
||||
"abilities": {
|
||||
"abaddon_aphotic_shield": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"abaddon_borrowed_time": {
|
||||
"add": [
|
||||
"strong_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"legion_commander_press_the_attack": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"oracle_false_promise": {
|
||||
"add": [
|
||||
"strong_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"oracle_fates_edict": {
|
||||
"add": [
|
||||
"disarm"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"keeper_of_the_light_chakra_magic": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"shadow_demon_demonic_purge": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"shadow_demon_demonic_cleanse": {
|
||||
"add": [
|
||||
"strong_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"brewmaster_storm_dispel_magic": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"tidehunter_kraken_shell": {
|
||||
"add": [
|
||||
"strong_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"enchantress_enchant": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"phoenix_supernova": {
|
||||
"add": [
|
||||
"strong_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"viper_nethertoxin": {
|
||||
"add": [
|
||||
"break"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"huskar_inner_fire": {
|
||||
"add": [
|
||||
"disarm"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"troll_warlord_whirling_axes_melee": {
|
||||
"add": [
|
||||
"disarm"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"doom_bringer_doom": {
|
||||
"add": [
|
||||
"mute",
|
||||
"break"
|
||||
],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"lion_voodoo": {
|
||||
"add": [
|
||||
"hex"
|
||||
],
|
||||
"remove": [
|
||||
"silence",
|
||||
"disarm",
|
||||
"mute"
|
||||
]
|
||||
},
|
||||
"shadow_shaman_voodoo": {
|
||||
"add": [
|
||||
"hex"
|
||||
],
|
||||
"remove": [
|
||||
"silence",
|
||||
"disarm",
|
||||
"mute"
|
||||
]
|
||||
},
|
||||
"omniknight_purification": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"silencer_glaives_of_wisdom": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"silencer_brain_drain": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"silencer_curse_of_the_silent": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"spirit_breaker_bull_rush": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"lone_druid_spirit_bear": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"root"
|
||||
]
|
||||
},
|
||||
"ringmaster_the_box": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm",
|
||||
"silence",
|
||||
"mute"
|
||||
]
|
||||
},
|
||||
"largo_amphibian_rhapsody": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"kez_shodo_sai": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"bane_enfeeble": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"axe_culling_blade": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel"
|
||||
]
|
||||
},
|
||||
"chen_hand_of_god": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel"
|
||||
]
|
||||
},
|
||||
"omniknight_martyr": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel"
|
||||
]
|
||||
},
|
||||
"winter_wyvern_cold_embrace": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"disruptor_glimpse": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"legion_commander_duel": {
|
||||
"add": [
|
||||
"taunt"
|
||||
],
|
||||
"remove": [
|
||||
"stun",
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"axe_berserkers_call": {
|
||||
"add": [
|
||||
"taunt"
|
||||
],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"doom_bringer_infernal_blade": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"elder_titan_earth_splitter": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"ursa_earthshock": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"brewmaster_thunder_clap": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"batrider_sticky_napalm": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"root"
|
||||
]
|
||||
},
|
||||
"templar_assassin_trap": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"root"
|
||||
]
|
||||
},
|
||||
"enchantress_untouchable": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm",
|
||||
"blind"
|
||||
]
|
||||
},
|
||||
"keeper_of_the_light_blinding_light": {
|
||||
"add": [
|
||||
"blind"
|
||||
],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"muerta_pierce_the_veil": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm",
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"mars_gods_rebuke": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"pangolier_shield_crash": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"bristleback_bristleback": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"centaur_hoof_stomp": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"queenofpain_blink": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"dazzle_nothl_projection": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"hex",
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"naga_siren_ensnare": {
|
||||
"add": [
|
||||
"root"
|
||||
],
|
||||
"remove": [
|
||||
"break"
|
||||
]
|
||||
},
|
||||
"sven_storm_bolt": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": [
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"juggernaut_omni_slash": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel"
|
||||
]
|
||||
},
|
||||
"juggernaut_blade_fury": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel"
|
||||
]
|
||||
},
|
||||
"bounty_hunter_wind_walk": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"riki_blink_strike": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"clinkz_wind_walk": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"clinkz_skeleton_walk": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"weaver_shukuchi": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"mirana_invis": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"invoker_ghost_walk": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"nyx_assassin_vendetta": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"sandking_sand_storm": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"templar_assassin_meld": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"treant_natures_guise": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"windrunner_windrun": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"sniper_assassinate": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"drow_ranger_wave_of_silence": {
|
||||
"add": [
|
||||
"silence"
|
||||
],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"phantom_lancer_juxtapose": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"slardar_amplify_damage": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel",
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"riki_backstab": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"riki_smoke_screen": {
|
||||
"add": [
|
||||
"silence"
|
||||
],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"clinkz_strafe": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"bounty_hunter_track": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"lycan_summon_wolves": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"treant_overgrowth": {
|
||||
"add": [
|
||||
"root"
|
||||
],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"nyx_assassin_spiked_carapace": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"nyx_assassin_burrow": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"arc_warden_tempest_double": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"monkey_king_wukongs_command": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"hoodwink_decoy": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"ringmaster_spotlight": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"kez_ravens_veil": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"slark_shadow_dance": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"visage_silent_as_the_grave": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"bounty_hunter_wind_walk_ally": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"bane_nightmare": {
|
||||
"add": [
|
||||
"sleep"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"naga_siren_song_of_the_siren": {
|
||||
"add": [
|
||||
"sleep"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"dark_willow_terrorize": {
|
||||
"add": [
|
||||
"fear"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"lone_druid_savage_roar": {
|
||||
"add": [
|
||||
"fear"
|
||||
],
|
||||
"remove": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"nevermore_requiem": {
|
||||
"add": [
|
||||
"fear"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"brewmaster_cinder_brew": {
|
||||
"add": [
|
||||
"blind"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"brewmaster_drunken_haze": {
|
||||
"add": [
|
||||
"blind"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"tinker_laser": {
|
||||
"add": [
|
||||
"blind"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"troll_warlord_whirling_axes_ranged": {
|
||||
"add": [
|
||||
"blind"
|
||||
],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"disruptor_kinetic_field": {
|
||||
"add": [
|
||||
"leash"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"grimstroke_soul_chain": {
|
||||
"add": [
|
||||
"leash"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"puck_dream_coil": {
|
||||
"add": [
|
||||
"leash",
|
||||
"stun"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"mars_arena_of_blood": {
|
||||
"add": [
|
||||
"leash"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"pugna_decrepify": {
|
||||
"add": [
|
||||
"ethereal"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"necrolyte_ghost_shroud": {
|
||||
"add": [
|
||||
"ethereal"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"obsidian_destroyer_astral_imprisonment": {
|
||||
"add": [
|
||||
"cyclone"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"invoker_tornado": {
|
||||
"add": [
|
||||
"cyclone"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"brewmaster_storm_cyclone": {
|
||||
"add": [
|
||||
"cyclone"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"shadow_demon_disruption": {
|
||||
"add": [
|
||||
"cyclone"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"bane_fiends_grip": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": [
|
||||
"sleep"
|
||||
]
|
||||
},
|
||||
"bane_ichor_of_nyctasha": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"fear"
|
||||
]
|
||||
},
|
||||
"death_prophet_spirit_siphon": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"fear"
|
||||
]
|
||||
},
|
||||
"muerta_dead_shot": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"fear"
|
||||
]
|
||||
},
|
||||
"night_stalker_crippling_fear": {
|
||||
"add": [
|
||||
"silence"
|
||||
],
|
||||
"remove": [
|
||||
"fear"
|
||||
]
|
||||
},
|
||||
"ringmaster_tame_the_beasts": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"fear"
|
||||
]
|
||||
},
|
||||
"spectre_haunt": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"fear"
|
||||
]
|
||||
},
|
||||
"meepo_earthbind": {
|
||||
"add": [
|
||||
"root"
|
||||
],
|
||||
"remove": [
|
||||
"leash"
|
||||
]
|
||||
},
|
||||
"meepo_petrify": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"root"
|
||||
]
|
||||
},
|
||||
"meepo_megameepo": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"leash"
|
||||
]
|
||||
},
|
||||
"nevermore_necromastery": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"leash"
|
||||
]
|
||||
},
|
||||
"windrunner_shackleshot": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": [
|
||||
"leash"
|
||||
]
|
||||
},
|
||||
"leshrac_greater_lightning_storm": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"muerta_spectral_slug": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"void_spirit_aether_remnant": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"void_spirit_astral_step": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"void_spirit_dissimilate": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"void_spirit_intrinsic_edge": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"void_spirit_resonant_pulse": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"marci_special_delivery": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"cyclone"
|
||||
]
|
||||
},
|
||||
"slark_pounce": {
|
||||
"add": [
|
||||
"leash"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"tidehunter_dead_in_the_water": {
|
||||
"add": [
|
||||
"leash"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"terrorblade_terror_wave": {
|
||||
"add": [
|
||||
"fear"
|
||||
],
|
||||
"remove": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@
|
||||
"juggernaut": {
|
||||
"add": [
|
||||
{ "item": "lotus_orb", "reason": "反射无敌斩/迅捷斩", "tags": ["spell_reflect"] },
|
||||
{ "item": "ghost", "reason": "虚无免疫无敌斩的物理攻击", "tags": ["ethereal"] },
|
||||
{ "item": "abyssal_blade", "reason": "打断输出与无敌斩", "tags": [] },
|
||||
{ "item": "butterfly", "reason": "闪避克制普攻与斩击", "tags": [] }
|
||||
],
|
||||
"remove": []
|
||||
"remove": ["sphere"]
|
||||
},
|
||||
"phantom_assassin": {
|
||||
"add": [
|
||||
{ "item": "monkey_king_bar", "reason": "无视模糊闪避", "tags": ["true_strike"] }
|
||||
{ "item": "monkey_king_bar", "reason": "无视模糊闪避", "tags": ["true_strike"] },
|
||||
{ "item": "ghost", "reason": "虚无规避幻影刺客的物理爆发", "tags": ["ethereal"] }
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
@@ -63,6 +65,32 @@
|
||||
{ "item": "lotus_orb", "reason": "反射末日", "tags": ["spell_reflect"] }
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"puck": {
|
||||
"add": [
|
||||
{ "item": "orchid", "reason": "沉默限制技能逃生与反打", "tags": ["silence"] }
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"weaver": {
|
||||
"add": [
|
||||
{ "item": "ghost", "reason": "虚无规避连击与普攻爆发", "tags": ["ethereal"] },
|
||||
{ "item": "cyclone", "reason": "驱散缩地状态并限制逃生", "tags": ["basic_dispel", "cyclone"] }
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"pangolier": {
|
||||
"add": [
|
||||
{ "item": "ghost", "reason": "虚无规避剑舞与普攻伤害", "tags": ["ethereal"] },
|
||||
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"void_spirit": {
|
||||
"add": [
|
||||
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
||||
],
|
||||
"remove": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,40 +3,311 @@
|
||||
"note": "Manual add/remove tags merged after auto-tagging in fetch_items_meta.py. Prefer offensive mechanics for fear mapping."
|
||||
},
|
||||
"items": {
|
||||
"diffusal_blade": { "add": ["basic_dispel", "mana_burn"], "remove": ["illusion_clear"] },
|
||||
"disperser": { "add": ["basic_dispel", "mana_burn"], "remove": ["illusion_clear"] },
|
||||
"nullifier": { "add": ["basic_dispel"], "remove": [] },
|
||||
"cyclone": { "add": ["basic_dispel"], "remove": [] },
|
||||
"wind_waker": { "add": ["basic_dispel"], "remove": [] },
|
||||
"satanic": { "add": [], "remove": ["basic_dispel"] },
|
||||
"lotus_orb": { "add": ["spell_reflect"], "remove": ["basic_dispel", "mana_burn"] },
|
||||
"guardian_greaves": { "add": [], "remove": ["basic_dispel"] },
|
||||
"manta": { "add": [], "remove": ["basic_dispel", "illusion_clear"] },
|
||||
"black_king_bar": { "add": ["magic_immune"], "remove": ["basic_dispel", "strong_dispel"] },
|
||||
"aeon_disk": { "add": [], "remove": ["strong_dispel", "basic_dispel"] },
|
||||
"sphere": { "add": ["spell_block"], "remove": [] },
|
||||
"monkey_king_bar": { "add": ["true_strike"], "remove": [] },
|
||||
"silver_edge": { "add": ["break", "invis_break"], "remove": [] },
|
||||
"spirit_vessel": { "add": ["heal_reduce"], "remove": [] },
|
||||
"bfury": { "add": ["illusion_clear"], "remove": [] },
|
||||
"radiance": { "add": ["illusion_clear"], "remove": [] },
|
||||
"maelstrom": { "add": ["illusion_clear"], "remove": ["true_strike"] },
|
||||
"mjollnir": { "add": ["illusion_clear"], "remove": ["true_strike"] },
|
||||
"gungir": { "add": ["root", "illusion_clear"], "remove": [] },
|
||||
"gem": { "add": ["invis_detect"], "remove": [] },
|
||||
"dust": { "add": ["invis_detect"], "remove": [] },
|
||||
"desolator": { "add": ["armor_reduce"], "remove": [] },
|
||||
"solar_crest": { "add": ["armor_reduce"], "remove": [] },
|
||||
"orchid": { "add": ["silence"], "remove": ["mana_burn"] },
|
||||
"bloodthorn": { "add": ["silence", "true_strike"], "remove": [] },
|
||||
"sheepstick": { "add": ["hex"], "remove": ["illusion_clear", "silence"] },
|
||||
"rod_of_atos": { "add": ["root"], "remove": [] },
|
||||
"mask_of_madness": { "add": [], "remove": ["silence"] },
|
||||
"necronomicon": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] },
|
||||
"necronomicon_2": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] },
|
||||
"necronomicon_3": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] },
|
||||
"witch_blade": { "add": [], "remove": ["true_strike"] },
|
||||
"devastator": { "add": ["armor_reduce"], "remove": ["true_strike"] },
|
||||
"essence_distiller": { "add": [], "remove": ["invis_detect"] }
|
||||
"diffusal_blade": {
|
||||
"add": [
|
||||
"basic_dispel",
|
||||
"mana_burn"
|
||||
],
|
||||
"remove": [
|
||||
"illusion_clear"
|
||||
]
|
||||
},
|
||||
"disperser": {
|
||||
"add": [
|
||||
"basic_dispel",
|
||||
"mana_burn"
|
||||
],
|
||||
"remove": [
|
||||
"illusion_clear"
|
||||
]
|
||||
},
|
||||
"nullifier": {
|
||||
"add": [
|
||||
"basic_dispel"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"cyclone": {
|
||||
"add": [
|
||||
"basic_dispel",
|
||||
"cyclone"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"wind_waker": {
|
||||
"add": [
|
||||
"basic_dispel",
|
||||
"cyclone"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"satanic": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"lotus_orb": {
|
||||
"add": [
|
||||
"spell_reflect"
|
||||
],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"mana_burn"
|
||||
]
|
||||
},
|
||||
"guardian_greaves": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"manta": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"illusion_clear"
|
||||
]
|
||||
},
|
||||
"black_king_bar": {
|
||||
"add": [
|
||||
"magic_immune"
|
||||
],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"strong_dispel"
|
||||
]
|
||||
},
|
||||
"aeon_disk": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"strong_dispel",
|
||||
"basic_dispel"
|
||||
]
|
||||
},
|
||||
"sphere": {
|
||||
"add": [
|
||||
"spell_block"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"monkey_king_bar": {
|
||||
"add": [
|
||||
"true_strike"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"silver_edge": {
|
||||
"add": [
|
||||
"break",
|
||||
"invis_break",
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"invis_sword": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"spirit_vessel": {
|
||||
"add": [
|
||||
"heal_reduce"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"bfury": {
|
||||
"add": [
|
||||
"illusion_clear"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"radiance": {
|
||||
"add": [
|
||||
"illusion_clear"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"maelstrom": {
|
||||
"add": [
|
||||
"illusion_clear"
|
||||
],
|
||||
"remove": [
|
||||
"true_strike"
|
||||
]
|
||||
},
|
||||
"mjollnir": {
|
||||
"add": [
|
||||
"illusion_clear"
|
||||
],
|
||||
"remove": [
|
||||
"true_strike"
|
||||
]
|
||||
},
|
||||
"gungir": {
|
||||
"add": [
|
||||
"root",
|
||||
"illusion_clear"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"gem": {
|
||||
"add": [
|
||||
"invis_detect"
|
||||
],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"dust": {
|
||||
"add": [
|
||||
"invis_detect"
|
||||
],
|
||||
"remove": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"glimmer_cape": {
|
||||
"add": [
|
||||
"invis"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"desolator": {
|
||||
"add": [
|
||||
"armor_reduce"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"solar_crest": {
|
||||
"add": [
|
||||
"armor_reduce"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"orchid": {
|
||||
"add": [
|
||||
"silence"
|
||||
],
|
||||
"remove": [
|
||||
"mana_burn"
|
||||
]
|
||||
},
|
||||
"bloodthorn": {
|
||||
"add": [
|
||||
"silence",
|
||||
"true_strike"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"sheepstick": {
|
||||
"add": [
|
||||
"hex"
|
||||
],
|
||||
"remove": [
|
||||
"illusion_clear",
|
||||
"silence",
|
||||
"disarm",
|
||||
"mute"
|
||||
]
|
||||
},
|
||||
"abyssal_blade": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"basher": {
|
||||
"add": [
|
||||
"stun"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"heavens_halberd": {
|
||||
"add": [
|
||||
"disarm"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"ethereal_blade": {
|
||||
"add": [
|
||||
"ethereal"
|
||||
],
|
||||
"remove": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"rod_of_atos": {
|
||||
"add": [
|
||||
"root"
|
||||
],
|
||||
"remove": []
|
||||
},
|
||||
"mask_of_madness": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"silence"
|
||||
]
|
||||
},
|
||||
"necronomicon": {
|
||||
"add": [
|
||||
"invis_detect"
|
||||
],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"mana_burn",
|
||||
"break",
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"necronomicon_2": {
|
||||
"add": [
|
||||
"invis_detect"
|
||||
],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"mana_burn",
|
||||
"break",
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"necronomicon_3": {
|
||||
"add": [
|
||||
"invis_detect"
|
||||
],
|
||||
"remove": [
|
||||
"basic_dispel",
|
||||
"mana_burn",
|
||||
"break",
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"witch_blade": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"true_strike"
|
||||
]
|
||||
},
|
||||
"devastator": {
|
||||
"add": [
|
||||
"armor_reduce"
|
||||
],
|
||||
"remove": [
|
||||
"true_strike"
|
||||
]
|
||||
},
|
||||
"essence_distiller": {
|
||||
"add": [],
|
||||
"remove": [
|
||||
"invis_detect"
|
||||
]
|
||||
},
|
||||
"ghost": {
|
||||
"add": [
|
||||
"ethereal"
|
||||
],
|
||||
"remove": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"meta": {
|
||||
"source": "valve+opendota",
|
||||
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
||||
"fetched_at": "2026-07-26T14:47:19.634527+00:00",
|
||||
"fetched_at": "2026-07-28T07:51:14.752604+00:00",
|
||||
"tag_order": [
|
||||
"basic_dispel",
|
||||
"strong_dispel",
|
||||
@@ -19,7 +19,18 @@
|
||||
"armor_reduce",
|
||||
"silence",
|
||||
"hex",
|
||||
"root"
|
||||
"root",
|
||||
"stun",
|
||||
"disarm",
|
||||
"mute",
|
||||
"invis",
|
||||
"sleep",
|
||||
"fear",
|
||||
"taunt",
|
||||
"blind",
|
||||
"leash",
|
||||
"ethereal",
|
||||
"cyclone"
|
||||
],
|
||||
"count": 112
|
||||
},
|
||||
@@ -80,7 +91,9 @@
|
||||
"ability_kinds": [
|
||||
"active"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"40": {
|
||||
"id": 40,
|
||||
@@ -315,7 +328,8 @@
|
||||
"active"
|
||||
],
|
||||
"tags": [
|
||||
"basic_dispel"
|
||||
"basic_dispel",
|
||||
"cyclone"
|
||||
]
|
||||
},
|
||||
"102": {
|
||||
@@ -683,7 +697,9 @@
|
||||
"ability_kinds": [
|
||||
"passive"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"145": {
|
||||
"id": 145,
|
||||
@@ -783,7 +799,8 @@
|
||||
"active"
|
||||
],
|
||||
"tags": [
|
||||
"invis_break"
|
||||
"invis_break",
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"154": {
|
||||
@@ -1002,7 +1019,9 @@
|
||||
"ability_kinds": [
|
||||
"active"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"ethereal"
|
||||
]
|
||||
},
|
||||
"180": {
|
||||
"id": 180,
|
||||
@@ -1222,7 +1241,9 @@
|
||||
"active",
|
||||
"passive"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"210": {
|
||||
"id": 210,
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"ability_kinds": [
|
||||
"active"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"disarm"
|
||||
]
|
||||
},
|
||||
"220": {
|
||||
"id": 220,
|
||||
@@ -1270,7 +1293,9 @@
|
||||
"ability_kinds": [
|
||||
"active"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"stun"
|
||||
]
|
||||
},
|
||||
"225": {
|
||||
"id": 225,
|
||||
@@ -1434,7 +1459,8 @@
|
||||
],
|
||||
"tags": [
|
||||
"break",
|
||||
"invis_break"
|
||||
"invis_break",
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"250": {
|
||||
@@ -1487,7 +1513,9 @@
|
||||
"ability_kinds": [
|
||||
"active"
|
||||
],
|
||||
"tags": []
|
||||
"tags": [
|
||||
"invis"
|
||||
]
|
||||
},
|
||||
"256": {
|
||||
"id": 256,
|
||||
@@ -1761,7 +1789,8 @@
|
||||
"active"
|
||||
],
|
||||
"tags": [
|
||||
"basic_dispel"
|
||||
"basic_dispel",
|
||||
"cyclone"
|
||||
]
|
||||
},
|
||||
"635": {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"fetched_at": "2026-07-28T14:20:00+00:00",
|
||||
"source": "manual+douyin+bilibili",
|
||||
"platform_meta": {
|
||||
"douyin": {
|
||||
"label_zh": "抖音",
|
||||
"icon": "ui-icon/platform_douyin.png"
|
||||
},
|
||||
"bilibili": {
|
||||
"label_zh": "哔哩哔哩",
|
||||
"icon": "ui-icon/platform_bilibili.png"
|
||||
}
|
||||
},
|
||||
"streamers": [
|
||||
{
|
||||
"id": "xiaowang",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/343871812758",
|
||||
"profile_url": "https://v.douyin.com/MM3U-EpqI2E/",
|
||||
"tagline": "天怒绝活哥",
|
||||
"heroes": [
|
||||
"skywrath_mage"
|
||||
],
|
||||
"nickname": "DOTA2小王",
|
||||
"unique_id": "chenshiweidota",
|
||||
"signature": "有事请私信,最高东南亚前100,上车玩加同名绿泡泡抖音号",
|
||||
"following_count": 87,
|
||||
"follower_count": 14000,
|
||||
"total_favorited": 591,
|
||||
"avatar": "streamer_avatars/xiaowang.jpg",
|
||||
"video": "streamer_videos/xiaowang.mp4",
|
||||
"video_title": "7000分单排天怒如何乱杀",
|
||||
"profile_fetched_at": "2026-07-28T08:30:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "jianxin",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/892762012034",
|
||||
"profile_url": "https://v.douyin.com/FcUd6qHXYwc/",
|
||||
"heroes": [
|
||||
"juggernaut"
|
||||
],
|
||||
"nickname": "Dota2剑心犹在",
|
||||
"unique_id": "33552682609",
|
||||
"signature": "万分选手,前国服第一,绝活剑圣。土木双灵根,先天打灰圣体。",
|
||||
"following_count": 353,
|
||||
"follower_count": 35000,
|
||||
"total_favorited": 4255,
|
||||
"avatar": "streamer_avatars/jianxin.jpg",
|
||||
"video": "streamer_videos/jianxin.mp4",
|
||||
"video_title": "落后四十人头的惊天大翻盘!剑心犹在!!",
|
||||
"profile_fetched_at": "2026-07-28T10:10:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "yaseguilai",
|
||||
"platform": "douyin",
|
||||
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
|
||||
"heroes": [
|
||||
"slark"
|
||||
],
|
||||
"nickname": "亚瑟归来(Dota2)",
|
||||
"unique_id": "yaseguilai",
|
||||
"signature": "dota2主播,万分小鱼王! 上车+v:375874361",
|
||||
"following_count": 1024,
|
||||
"follower_count": 23000,
|
||||
"total_favorited": 5302,
|
||||
"avatar": "streamer_avatars/yaseguilai.jpg",
|
||||
"video": "streamer_videos/yaseguilai.mp4",
|
||||
"video_aspect": "1920/2317",
|
||||
"video_fit": "cover",
|
||||
"video_title": "狂战小鱼6连暴走",
|
||||
"profile_fetched_at": "2026-07-28T10:12:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "gudu",
|
||||
"platform": "douyin",
|
||||
"profile_url": "https://v.douyin.com/411MHPZgDeY/",
|
||||
"heroes": [
|
||||
"kez"
|
||||
],
|
||||
"nickname": "Dota2孤独",
|
||||
"unique_id": "71129362227",
|
||||
"signature": "和dota2结婚的小哥一枚 12点左右直播,最高东南亚200",
|
||||
"following_count": 7,
|
||||
"follower_count": 2147,
|
||||
"total_favorited": 4016,
|
||||
"avatar": "streamer_avatars/gudu.jpg",
|
||||
"video": "streamer_videos/gudu.mp4",
|
||||
"video_title": "场均杀20个,双倍了一晚上没输过的神秘中单出现在对面? 看来只有我来终结你的无解连胜了",
|
||||
"profile_fetched_at": "2026-07-28T10:14:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "yangfanqiguo",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/546982489107",
|
||||
"profile_url": "https://v.douyin.com/bodeOPLFFuo/",
|
||||
"heroes": [
|
||||
"riki"
|
||||
],
|
||||
"nickname": "Dota2杨帆起锅",
|
||||
"unique_id": "26535099603",
|
||||
"signature": "完美官方主播 每天早9点到12点,晚6点半到10点",
|
||||
"following_count": 14,
|
||||
"follower_count": 8259,
|
||||
"total_favorited": 270,
|
||||
"avatar": "streamer_avatars/yangfanqiguo.jpg",
|
||||
"video": "streamer_videos/yangfanqiguo.mp4",
|
||||
"video_title": "冠绝中单力丸对线保姆级思路",
|
||||
"profile_fetched_at": "2026-07-28T10:16:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "dadigua",
|
||||
"platform": "douyin",
|
||||
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
|
||||
"heroes": [
|
||||
"axe"
|
||||
],
|
||||
"nickname": "DOTA2 大地瓜",
|
||||
"unique_id": "189764720",
|
||||
"signature": "6月10号播",
|
||||
"following_count": 805,
|
||||
"follower_count": 8006,
|
||||
"total_favorited": 8015,
|
||||
"avatar": "streamer_avatars/dadigua.jpg",
|
||||
"video": "streamer_videos/dadigua.mp4",
|
||||
"video_aspect": "1080/1440",
|
||||
"video_fit": "cover",
|
||||
"video_title": "电风扇斧王10秒肉山",
|
||||
"profile_fetched_at": "2026-07-28T10:28:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "dota25830",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/336796244386",
|
||||
"profile_url": "https://v.douyin.com/Bk6PZIs7kCE/",
|
||||
"heroes": [
|
||||
"lion"
|
||||
],
|
||||
"nickname": "5830 Dota2",
|
||||
"unique_id": "583093455jj",
|
||||
"signature": "dota2 国服第一lion 上牢车:dota258",
|
||||
"following_count": 172,
|
||||
"follower_count": 8466,
|
||||
"total_favorited": 7074,
|
||||
"avatar": "streamer_avatars/dota25830.jpg",
|
||||
"video": "streamer_videos/dota25830.mp4",
|
||||
"video_title": "Lion亡养成班,中单lion新手教学",
|
||||
"profile_fetched_at": "2026-07-28T10:30:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "mayi",
|
||||
"platform": "douyin",
|
||||
"profile_url": "https://v.douyin.com/9KwWjZPjB3U/",
|
||||
"heroes": [
|
||||
"weaver"
|
||||
],
|
||||
"nickname": "橙子dota2",
|
||||
"unique_id": "72388849434",
|
||||
"signature": "泡泡 : chengzidota (不只是带你赢,更是教会你赢)",
|
||||
"following_count": 1424,
|
||||
"follower_count": 2049,
|
||||
"total_favorited": 300,
|
||||
"avatar": "streamer_avatars/mayi.jpg",
|
||||
"video": "streamer_videos/mayi.mp4",
|
||||
"video_title": "蚂蚁暴走",
|
||||
"profile_fetched_at": "2026-07-28T10:50:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "huomao",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/401878837924",
|
||||
"profile_url": "https://v.douyin.com/GG79CCGREhc/",
|
||||
"heroes": [
|
||||
"ember_spirit"
|
||||
],
|
||||
"nickname": "dota2火影猫",
|
||||
"unique_id": "44772013956",
|
||||
"signature": "打号、上车、陪后台踢踢",
|
||||
"following_count": 188,
|
||||
"follower_count": 1781,
|
||||
"total_favorited": 524,
|
||||
"avatar": "streamer_avatars/huomao.jpg",
|
||||
"video": "streamer_videos/huomao.mp4",
|
||||
"video_title": "7500单排局火猫如何应对哈斯卡",
|
||||
"profile_fetched_at": "2026-07-28T10:52:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "gugong",
|
||||
"platform": "douyin",
|
||||
"live_url": "https://live.douyin.com/517090873115",
|
||||
"profile_url": "https://v.douyin.com/T0eNOgDLB1g/",
|
||||
"heroes": [
|
||||
"clinkz"
|
||||
],
|
||||
"nickname": "爪爪熊想遇见樱岛麻衣",
|
||||
"unique_id": "21811462417",
|
||||
"signature": "dota2天梯最高9500分",
|
||||
"following_count": 30,
|
||||
"follower_count": 1499,
|
||||
"total_favorited": 637,
|
||||
"avatar": "streamer_avatars/gugong.jpg",
|
||||
"video": "streamer_videos/gugong.mp4",
|
||||
"video_title": "小骷髅后期教科书,大吹风魅力时刻",
|
||||
"profile_fetched_at": "2026-07-28T10:55:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "shawang",
|
||||
"platform": "bilibili",
|
||||
"live_url": "https://live.bilibili.com/1865937232",
|
||||
"profile_url": "https://space.bilibili.com/1439688338",
|
||||
"tagline": "三号位 · 天梯8500分",
|
||||
"heroes": [
|
||||
"doom_bringer",
|
||||
"axe",
|
||||
"sand_king",
|
||||
"spirit_breaker"
|
||||
],
|
||||
"nickname": "沙王",
|
||||
"unique_id": "1439688338",
|
||||
"signature": "主播擅长三号位,天梯8500分。今年36岁,20年刀龄,真三dota,dota2 四泰斗末日斧王沙王白牛 直播时间 10.00-18.00",
|
||||
"following_count": 231,
|
||||
"follower_count": 7081,
|
||||
"avatar": "streamer_avatars/shawang.jpg",
|
||||
"video": "streamer_videos/shawang.mp4",
|
||||
"video_title": "Dota2沙王直播录像"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Deploy the relations preview static site to Cloudflare Pages.
|
||||
"""Deploy the Climperor web site static bundle to Cloudflare Pages.
|
||||
|
||||
Bundles export + integrity check + wrangler direct upload + custom domain
|
||||
binding into one local command. Designed to run with credentials injected
|
||||
@@ -336,7 +336,7 @@ def bind_domain(
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Deploy relations site to Cloudflare Pages")
|
||||
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
|
||||
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
|
||||
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
|
||||
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Rule-based draft lineup archetypes and gap analysis (no AI).
|
||||
|
||||
Detects push / global enemy shapes, enemy & ally tag gaps, answer heroes,
|
||||
and short Chinese analysis / reason strings for recommend marks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Iterable
|
||||
|
||||
from hero_tags import TAG_ORDER
|
||||
|
||||
# Strong push cores: one hit can flag push even before 2+ Pusher tags.
|
||||
PUSH_CORE = frozenset({
|
||||
"lycan",
|
||||
"furion",
|
||||
"broodmother",
|
||||
"chen",
|
||||
"enchantress",
|
||||
"visage",
|
||||
"beastmaster",
|
||||
"naga_siren",
|
||||
"lone_druid",
|
||||
"undying",
|
||||
})
|
||||
|
||||
GLOBAL_SET = frozenset({
|
||||
"furion",
|
||||
"spectre",
|
||||
"wisp",
|
||||
"abyssal_underlord",
|
||||
"zuus",
|
||||
"ancient_apparition",
|
||||
"spirit_breaker",
|
||||
"storm_spirit",
|
||||
"rattletrap",
|
||||
})
|
||||
|
||||
HARD_GLOBAL = frozenset({
|
||||
"furion",
|
||||
"spectre",
|
||||
"wisp",
|
||||
})
|
||||
|
||||
# Archetype -> answer hero keys (marked 克 with reason 对推进 / 对全球流).
|
||||
ARCHETYPE_ANSWERS: dict[str, tuple[str, ...]] = {
|
||||
"push": (
|
||||
"medusa",
|
||||
"terrorblade",
|
||||
"naga_siren",
|
||||
"jakiro",
|
||||
"gyrocopter",
|
||||
"dragon_knight",
|
||||
"shredder",
|
||||
),
|
||||
"global": (
|
||||
"storm_spirit",
|
||||
"anti_mage",
|
||||
"riki",
|
||||
"bounty_hunter",
|
||||
"queenofpain",
|
||||
"ember_spirit",
|
||||
),
|
||||
}
|
||||
|
||||
ARCHETYPE_REASON = {
|
||||
"push": "对推进",
|
||||
"global": "对全球流",
|
||||
}
|
||||
|
||||
ARCHETYPE_LABEL = {
|
||||
"push": "偏推进",
|
||||
"global": "全球流",
|
||||
}
|
||||
|
||||
# Gaps we report (user-facing). 输出 is proxied by 核心.
|
||||
GAP_TAGS = ("控制", "爆发", "核心", "先手")
|
||||
|
||||
GAP_DISPLAY = {
|
||||
"控制": "控制",
|
||||
"爆发": "爆发",
|
||||
"核心": "输出",
|
||||
"先手": "先手",
|
||||
}
|
||||
|
||||
# Enemy gap -> candidate tags that punish it (marked 克).
|
||||
ENEMY_GAP_PUNISH: dict[str, tuple[str, ...]] = {
|
||||
"控制": ("控制", "先手"),
|
||||
"爆发": ("耐久", "核心"),
|
||||
"核心": ("爆发", "控制"),
|
||||
"先手": ("先手", "爆发"),
|
||||
}
|
||||
|
||||
MAX_REASONS = 3
|
||||
MAX_REASON_LEN = 12
|
||||
|
||||
|
||||
def tag_profile(keys: Iterable[str], tags_by_key: dict[str, list[str]]) -> dict[str, int]:
|
||||
counts: Counter[str] = Counter()
|
||||
for key in keys:
|
||||
for tag in tags_by_key.get(key) or []:
|
||||
if tag in TAG_ORDER:
|
||||
counts[tag] += 1
|
||||
return {t: counts[t] for t in TAG_ORDER if counts.get(t)}
|
||||
|
||||
|
||||
def detect_archetypes(
|
||||
enemies: list[str],
|
||||
tags_by_key: dict[str, list[str]],
|
||||
*,
|
||||
push_tag_min: int = 2,
|
||||
) -> list[str]:
|
||||
"""Return ordered archetype ids present in the enemy lineup."""
|
||||
out: list[str] = []
|
||||
push_n = sum(1 for e in enemies if "推进" in (tags_by_key.get(e) or []))
|
||||
if push_n >= push_tag_min or any(e in PUSH_CORE for e in enemies):
|
||||
out.append("push")
|
||||
global_hits = [e for e in enemies if e in GLOBAL_SET]
|
||||
if len(global_hits) >= 2 or any(e in HARD_GLOBAL for e in enemies):
|
||||
out.append("global")
|
||||
return out
|
||||
|
||||
|
||||
def detect_gaps(
|
||||
profile: dict[str, int],
|
||||
*,
|
||||
hero_count: int,
|
||||
min_heroes: int = 2,
|
||||
) -> list[str]:
|
||||
"""Return missing GAP_TAGS when enough heroes are locked."""
|
||||
if hero_count < max(1, int(min_heroes)):
|
||||
return []
|
||||
missing = []
|
||||
for tag in GAP_TAGS:
|
||||
if int(profile.get(tag) or 0) <= 0:
|
||||
missing.append(tag)
|
||||
return missing
|
||||
|
||||
|
||||
def format_analysis(
|
||||
*,
|
||||
archetypes: list[str],
|
||||
enemy_gaps: list[str],
|
||||
ally_gaps: list[str],
|
||||
ally_count: int,
|
||||
) -> str:
|
||||
"""One short Chinese lineup summary (may be empty)."""
|
||||
enemy_bits: list[str] = []
|
||||
for arch in archetypes:
|
||||
lab = ARCHETYPE_LABEL.get(arch)
|
||||
if lab and lab not in enemy_bits:
|
||||
enemy_bits.append(lab)
|
||||
for gap in enemy_gaps:
|
||||
disp = GAP_DISPLAY.get(gap, gap)
|
||||
bit = f"缺{disp}"
|
||||
if bit not in enemy_bits:
|
||||
enemy_bits.append(bit)
|
||||
|
||||
ally_bits: list[str] = []
|
||||
if ally_count <= 0:
|
||||
if enemy_bits:
|
||||
ally_bits.append("缺口尚不明")
|
||||
else:
|
||||
for gap in ally_gaps:
|
||||
disp = GAP_DISPLAY.get(gap, gap)
|
||||
bit = f"缺{disp}"
|
||||
if bit not in ally_bits:
|
||||
ally_bits.append(bit)
|
||||
|
||||
parts: list[str] = []
|
||||
if enemy_bits:
|
||||
parts.append("敌:" + "·".join(enemy_bits))
|
||||
if ally_bits:
|
||||
parts.append("我:" + "·".join(ally_bits))
|
||||
text = " | ".join(parts)
|
||||
if len(text) > 40:
|
||||
text = text[:39] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _trim_reason(s: str) -> str:
|
||||
s = (s or "").strip()
|
||||
if len(s) <= MAX_REASON_LEN:
|
||||
return s
|
||||
return s[: MAX_REASON_LEN - 1] + "…"
|
||||
|
||||
|
||||
def collect_reasons(
|
||||
*,
|
||||
names: dict[str, str],
|
||||
beats: list[dict],
|
||||
with_allies: list[dict],
|
||||
archetype_hits: list[str],
|
||||
punish_gaps: list[str],
|
||||
fill_gaps: list[str],
|
||||
) -> list[str]:
|
||||
"""Build up to MAX_REASONS short reason phrases for one candidate."""
|
||||
reasons: list[str] = []
|
||||
|
||||
def add(phrase: str) -> None:
|
||||
p = _trim_reason(phrase)
|
||||
if p and p not in reasons and len(reasons) < MAX_REASONS:
|
||||
reasons.append(p)
|
||||
|
||||
# Prefer one signal per mark type (克 / 补 / 搭) before extras.
|
||||
for edge in beats[:1]:
|
||||
add(f"克{names.get(edge['enemy'], edge['enemy'])}")
|
||||
for gap in fill_gaps[:1]:
|
||||
add(f"补{GAP_DISPLAY.get(gap, gap)}")
|
||||
for edge in with_allies[:1]:
|
||||
add(f"搭{names.get(edge['ally'], edge['ally'])}")
|
||||
for arch in archetype_hits:
|
||||
add(ARCHETYPE_REASON.get(arch, arch))
|
||||
for gap in punish_gaps:
|
||||
add(f"打缺{GAP_DISPLAY.get(gap, gap)}")
|
||||
for edge in beats[1:]:
|
||||
add(f"克{names.get(edge['enemy'], edge['enemy'])}")
|
||||
for gap in fill_gaps[1:]:
|
||||
add(f"补{GAP_DISPLAY.get(gap, gap)}")
|
||||
for edge in with_allies[1:]:
|
||||
add(f"搭{names.get(edge['ally'], edge['ally'])}")
|
||||
return reasons
|
||||
|
||||
|
||||
def answer_for_candidate(
|
||||
key: str,
|
||||
cand_tags: list[str],
|
||||
*,
|
||||
archetypes: list[str],
|
||||
enemy_gaps: list[str],
|
||||
ally_gaps: list[str],
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (archetype_hits, punish_gaps, fill_gaps) that apply to this hero."""
|
||||
tag_set = set(cand_tags or [])
|
||||
arch_hits = [a for a in archetypes if key in ARCHETYPE_ANSWERS.get(a, ())]
|
||||
punish = []
|
||||
for gap in enemy_gaps:
|
||||
wanted = ENEMY_GAP_PUNISH.get(gap) or ()
|
||||
if tag_set.intersection(wanted):
|
||||
punish.append(gap)
|
||||
fill = [g for g in ally_gaps if g in tag_set]
|
||||
# 核心 gap displays as 输出; candidate must have 核心 tag to fill.
|
||||
return arch_hits, punish, fill
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ARCHETYPE_ANSWERS",
|
||||
"ARCHETYPE_LABEL",
|
||||
"ARCHETYPE_REASON",
|
||||
"ENEMY_GAP_PUNISH",
|
||||
"GAP_DISPLAY",
|
||||
"GAP_TAGS",
|
||||
"answer_for_candidate",
|
||||
"collect_reasons",
|
||||
"detect_archetypes",
|
||||
"detect_gaps",
|
||||
"format_analysis",
|
||||
"tag_profile",
|
||||
]
|
||||
@@ -38,7 +38,7 @@ from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_fram
|
||||
from grid import bans, hero_table, read_grid
|
||||
from modes import detect_mode, load_mode_templates
|
||||
from recognize import recognize_image
|
||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_top
|
||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
||||
from roles import ROLES, detect_roles, load_role_templates
|
||||
|
||||
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
|
||||
@@ -84,11 +84,16 @@ class DraftSession:
|
||||
self.frame_dir = raw_dir_for_match(None)
|
||||
rec = cfg.get("recommend") or {}
|
||||
self.recommend_enabled = bool(rec.get("enabled", True))
|
||||
self.recommend_top_n = int(rec.get("top_n", 3))
|
||||
self.recommend_top_n = int(rec.get("top_n", 0))
|
||||
self.recommend_min_enemies = int(rec.get("min_enemies", 1))
|
||||
self.recommend_min_heroes_for_gaps = int(rec.get("min_heroes_for_gaps", 2))
|
||||
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
||||
self.recommend_role_tags = rec.get("role_tags")
|
||||
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
|
||||
self._rec_warned = False
|
||||
self._last_rec_sig: tuple | None = None
|
||||
self._last_enemy_profile: dict = {}
|
||||
self._last_rec_meta: dict = {}
|
||||
|
||||
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
||||
if self.overlay is None:
|
||||
@@ -98,12 +103,19 @@ class DraftSession:
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay update failed: {e}")
|
||||
|
||||
def _push_rec_overlay(self, cells: dict | None, picks: list[dict]) -> None:
|
||||
def _push_rec_overlay(
|
||||
self,
|
||||
cells: dict | None,
|
||||
picks: list[dict],
|
||||
analysis: str = "",
|
||||
) -> None:
|
||||
if self.overlay is None:
|
||||
return
|
||||
try:
|
||||
marks = {p["key"]: p["rank"] for p in picks}
|
||||
marks = {p["key"]: list(p.get("labels") or []) for p in picks if p.get("key")}
|
||||
self.overlay.set_grid_marks(cells or {}, marks)
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis(analysis or "")
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] rec overlay update failed: {e}")
|
||||
|
||||
@@ -115,6 +127,8 @@ class DraftSession:
|
||||
try:
|
||||
self.overlay.set_roster({})
|
||||
self.overlay.set_grid_marks({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
self.overlay.show()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay show failed: {e}")
|
||||
@@ -255,6 +269,8 @@ class DraftSession:
|
||||
if self.overlay is not None:
|
||||
try:
|
||||
self.overlay.set_grid_marks({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
self.overlay.hide()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"[draft] overlay hide failed: {e}")
|
||||
@@ -365,7 +381,7 @@ class DraftSession:
|
||||
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
|
||||
if not self.recommend_enabled:
|
||||
return
|
||||
if not self.relations:
|
||||
if not self.relations and not self.recommend_archetypes:
|
||||
return
|
||||
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
||||
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
||||
@@ -376,8 +392,10 @@ class DraftSession:
|
||||
if self_slot and confirmed.get(self_slot):
|
||||
if info.get("recommendations") or self._last_rec_sig is not None:
|
||||
info["recommendations"] = []
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._last_rec_sig = ("locked",)
|
||||
self._push_rec_overlay(info.get("cells"), [])
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
return
|
||||
enemies = enemy_keys(confirmed, self_team)
|
||||
allies = ally_keys(confirmed, self_team, self_slot)
|
||||
@@ -388,11 +406,13 @@ class DraftSession:
|
||||
if not force and sig == self._last_rec_sig:
|
||||
return
|
||||
self._last_rec_sig = sig
|
||||
if position is None or (not enemies and not allies):
|
||||
if len(enemies) < self.recommend_min_enemies:
|
||||
info["recommendations"] = []
|
||||
self._push_rec_overlay(info.get("cells"), [])
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
return
|
||||
picks = suggest_top(
|
||||
result = suggest_marks(
|
||||
position=position,
|
||||
enemies=enemies,
|
||||
allies=allies,
|
||||
@@ -400,14 +420,38 @@ class DraftSession:
|
||||
relations=self.relations,
|
||||
top_n=self.recommend_top_n,
|
||||
role_tags=self.recommend_role_tags,
|
||||
min_enemies=self.recommend_min_enemies,
|
||||
min_heroes_for_gaps=self.recommend_min_heroes_for_gaps,
|
||||
archetypes_enabled=self.recommend_archetypes,
|
||||
)
|
||||
picks = result.get("marks") or []
|
||||
profile = result.get("enemy_profile") or {}
|
||||
analysis = result.get("analysis") or ""
|
||||
info["recommendations"] = picks
|
||||
self._push_rec_overlay(info.get("cells"), picks)
|
||||
if picks:
|
||||
names = ", ".join(f"{p['rank']}.{p['name_loc']}({p['score']:+d})" for p in picks)
|
||||
self._last_enemy_profile = profile
|
||||
self._last_rec_meta = {
|
||||
"analysis": analysis,
|
||||
"enemy_archetypes": list(result.get("enemy_archetypes") or []),
|
||||
"enemy_gaps": list(result.get("enemy_gaps") or []),
|
||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||
"ally_profile": dict(result.get("ally_profile") or {}),
|
||||
}
|
||||
self._push_rec_overlay(info.get("cells"), picks, analysis)
|
||||
if picks or analysis:
|
||||
bits = []
|
||||
for p in picks[:12]:
|
||||
labs = "".join(p.get("labels") or [])
|
||||
why = "、".join(p.get("reasons") or [])
|
||||
extra = f":{why}" if why else ""
|
||||
bits.append(f"{p['name_loc']}[{labs}]{extra}")
|
||||
pos_s = f"pos{position}" if position is not None else "all"
|
||||
ally_n = [self.hero_names.get(a, a) for a in allies]
|
||||
enemy_n = [self.hero_names.get(e, e) for e in enemies]
|
||||
self.log(f"[rec] pos{position} with {ally_n} vs {enemy_n}: {names}")
|
||||
analysis_s = analysis or "-"
|
||||
self.log(
|
||||
f"[rec] {pos_s} {analysis_s} | with {ally_n} vs {enemy_n}: "
|
||||
f"{len(picks)} marks — {', '.join(bits)}"
|
||||
)
|
||||
|
||||
def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
|
||||
scores: dict, *, allow_revise: bool = True,
|
||||
@@ -541,6 +585,12 @@ class DraftSession:
|
||||
"position": role["position"] if role else None,
|
||||
"enemies": enemies,
|
||||
"allies": allies,
|
||||
"enemy_profile": dict(self._last_enemy_profile or {}),
|
||||
"ally_profile": dict((self._last_rec_meta or {}).get("ally_profile") or {}),
|
||||
"enemy_archetypes": list((self._last_rec_meta or {}).get("enemy_archetypes") or []),
|
||||
"enemy_gaps": list((self._last_rec_meta or {}).get("enemy_gaps") or []),
|
||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
||||
"picks": info.get("recommendations") or [],
|
||||
},
|
||||
}
|
||||
@@ -613,10 +663,18 @@ def describe(summary: dict) -> list[str]:
|
||||
if summary.get("bans_loc"):
|
||||
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
|
||||
rec = summary.get("recommendations") or {}
|
||||
analysis = rec.get("analysis") or ""
|
||||
if analysis:
|
||||
lines.append(f"draft : {analysis}")
|
||||
picks = rec.get("picks") or []
|
||||
if picks:
|
||||
bits = ", ".join(f"{p['rank']}.{p.get('name_loc') or loc(p['key'], names)}" for p in picks)
|
||||
lines.append(f"rec : {bits}")
|
||||
bits = []
|
||||
for p in picks[:15]:
|
||||
labs = "".join(p.get("labels") or []) or "?"
|
||||
why = "、".join(p.get("reasons") or [])
|
||||
name = p.get("name_loc") or loc(p["key"], names)
|
||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Export the relations preview as a static site (no server needed).
|
||||
"""Export the Climperor web site as a static bundle (no server needed).
|
||||
|
||||
Usage:
|
||||
python export_relations_site.py [--out dist/relations] [--with-videos]
|
||||
@@ -35,13 +35,16 @@ from common import (
|
||||
HERO_PORTRAITS,
|
||||
ITEM_CAT_ICONS,
|
||||
ITEM_ICONS,
|
||||
RANK_ICONS,
|
||||
ROOT,
|
||||
STREAMER_AVATARS,
|
||||
STREAMER_VIDEOS,
|
||||
TEMPLATES_CDN,
|
||||
UI_ICONS,
|
||||
)
|
||||
from serve_relations import WEB_DIR, build_payload
|
||||
|
||||
SITE_VERSION = "0.4.2"
|
||||
SITE_VERSION = "0.5.59"
|
||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||
|
||||
|
||||
@@ -58,14 +61,32 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
|
||||
|
||||
|
||||
def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
|
||||
"""Copy preview image dirs into ``out`` (portrait/item/ability/...)."""
|
||||
"""Copy web site image dirs into ``out`` (portrait/item/ability/...)."""
|
||||
counts = {
|
||||
"attr": copy_glob(ATTR_ICONS, out / "attr"),
|
||||
"rank": copy_glob(RANK_ICONS, out / "rank", "rank*.png"),
|
||||
"item": copy_glob(ITEM_ICONS, out / "item"),
|
||||
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
|
||||
"ability": copy_glob(ABILITY_ICONS, out / "ability"),
|
||||
"ui-icon": copy_glob(UI_ICONS, out / "ui-icon"),
|
||||
"streamer-avatar": 0,
|
||||
"streamer-video": 0,
|
||||
}
|
||||
if STREAMER_AVATARS.is_dir():
|
||||
n_av = 0
|
||||
for pattern in ("*.jpg", "*.jpeg", "*.png", "*.webp"):
|
||||
n_av += copy_glob(STREAMER_AVATARS, out / "streamer-avatar", pattern)
|
||||
counts["streamer-avatar"] = n_av
|
||||
if STREAMER_VIDEOS.is_dir():
|
||||
n_vid = 0
|
||||
dst = out / "streamer-video"
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for f in sorted(STREAMER_VIDEOS.glob("*.mp4")):
|
||||
if not f.is_file() or f.name.startswith("_"):
|
||||
continue
|
||||
shutil.copy2(f, dst / f.name)
|
||||
n_vid += 1
|
||||
counts["streamer-video"] = n_vid
|
||||
|
||||
portrait_dst = out / "portrait"
|
||||
portrait_dst.mkdir(exist_ok=True)
|
||||
@@ -114,7 +135,7 @@ def write_config_js(
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Export relations preview as a static site"
|
||||
description="Export Climperor web site as a static bundle"
|
||||
)
|
||||
ap.add_argument("--out", default=str(ROOT / "dist" / "relations"))
|
||||
ap.add_argument(
|
||||
@@ -173,7 +194,19 @@ def main() -> None:
|
||||
)
|
||||
|
||||
if static_base:
|
||||
counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")}
|
||||
counts = {
|
||||
name: 0
|
||||
for name in (
|
||||
"attr",
|
||||
"item",
|
||||
"item-cat",
|
||||
"ability",
|
||||
"ui-icon",
|
||||
"portrait",
|
||||
"streamer-avatar",
|
||||
"streamer-video",
|
||||
)
|
||||
}
|
||||
print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)")
|
||||
else:
|
||||
counts = populate_static_assets(out, payload)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fetch the official Dota 2 emblem icon (no wordmark) for the preview header.
|
||||
"""Fetch the official Dota 2 emblem icon (no wordmark) for the web site header.
|
||||
|
||||
Source (discovered, not guessed) from the dota2.com.cn site HTML:
|
||||
<link rel="Shortcut Icon" href="//www.dota2.com.cn/favicon.ico"/>
|
||||
|
||||
@@ -10,6 +10,7 @@ Usage:
|
||||
python fetch_hero_abilities.py --delay 0.2
|
||||
python fetch_hero_abilities.py --icons # also cache ability PNGs
|
||||
python fetch_hero_abilities.py --icons-only # icons from existing JSON
|
||||
python fetch_hero_abilities.py --tags-only # recompute mechanic tags only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +28,11 @@ from grid import hero_table
|
||||
from hero_tags import ILLUSION_KEYS
|
||||
from http_utils import download_icons, http_json
|
||||
from loc_format import format_loc, strip_html
|
||||
from mechanic_tags import (
|
||||
QUERY_MECHANIC_ORDER,
|
||||
apply_mechanic_tags,
|
||||
merge_tag_overrides,
|
||||
)
|
||||
|
||||
HERODATA_URL = (
|
||||
"https://www.dota2.com/datafeed/herodata?language=schinese&hero_id={hero_id}"
|
||||
@@ -38,6 +44,7 @@ ABILITY_ICON_URL = (
|
||||
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
|
||||
)
|
||||
OUT = DATA / "hero_abilities.json"
|
||||
ABILITY_TAG_OVERRIDES = DATA / "ability_tag_overrides.json"
|
||||
DESC_MAX = 600
|
||||
UPGRADE_MAX = 500
|
||||
TALENT_LEVELS = (10, 10, 15, 15, 20, 20, 25, 25)
|
||||
@@ -45,6 +52,50 @@ TALENT_LEVELS = (10, 10, 15, 15, 20, 20, 25, 25)
|
||||
SKIP_ICON_KEYS = frozenset({"innate", "talent_tree"})
|
||||
|
||||
|
||||
def _load_ability_tag_overrides() -> dict[str, dict]:
|
||||
if not ABILITY_TAG_OVERRIDES.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(ABILITY_TAG_OVERRIDES.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
items = raw.get("abilities") or {}
|
||||
return {str(k): v for k, v in items.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def ability_mechanic_tags(ab: dict, overrides: dict[str, dict] | None = None) -> list[str]:
|
||||
"""Compute applies-* mechanic tags for one ability row."""
|
||||
key = str(ab.get("key") or "")
|
||||
blob = " ".join(
|
||||
[
|
||||
str(ab.get("name_loc") or ""),
|
||||
str(ab.get("desc_loc") or ""),
|
||||
str(ab.get("shard_loc") or ""),
|
||||
str(ab.get("scepter_loc") or ""),
|
||||
]
|
||||
)
|
||||
auto = apply_mechanic_tags(blob, key=key)
|
||||
ov = (overrides or {}).get(key)
|
||||
return merge_tag_overrides(auto, ov, QUERY_MECHANIC_ORDER)
|
||||
|
||||
|
||||
def retag_abilities(by_hero: dict, overrides: dict[str, dict] | None = None) -> int:
|
||||
"""Write ``tags`` onto every ability; return number of abilities with tags."""
|
||||
ov = overrides if overrides is not None else _load_ability_tag_overrides()
|
||||
tagged = 0
|
||||
for cell in by_hero.values():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
for ab in cell.get("abilities") or []:
|
||||
if not isinstance(ab, dict) or not ab.get("key"):
|
||||
continue
|
||||
tags = ability_mechanic_tags(ab, ov)
|
||||
ab["tags"] = tags
|
||||
if tags:
|
||||
tagged += 1
|
||||
return tagged
|
||||
|
||||
|
||||
def _load_talent_overrides() -> dict[str, str]:
|
||||
"""Load manual name_loc overrides for talents Valve omits bonus data for."""
|
||||
p = DATA / "talent_overrides.json"
|
||||
@@ -64,6 +115,7 @@ def _load_talent_overrides() -> dict[str, str]:
|
||||
|
||||
|
||||
TALENT_OVERRIDES = _load_talent_overrides()
|
||||
ABILITY_TAG_OVERRIDES_MAP = _load_ability_tag_overrides()
|
||||
|
||||
# Valve dispellable int → label
|
||||
DISPEL_MAP = {
|
||||
@@ -452,6 +504,7 @@ def ability_row(raw: dict, odota: dict[str, dict]) -> dict | None:
|
||||
"mana_cost": mana_cost,
|
||||
"specials": special_rows(sv),
|
||||
"lore_loc": strip_html(raw.get("lore_loc") or "").strip(),
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -580,11 +633,38 @@ def main() -> None:
|
||||
action="store_true",
|
||||
help="Re-download ability icons even when a local PNG already exists",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--tags-only",
|
||||
action="store_true",
|
||||
help="Recompute ability mechanic tags from cached JSON without refetching",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
heroes = hero_table()
|
||||
heroes_by_key = {h["key"]: h for h in heroes}
|
||||
|
||||
if args.tags_only:
|
||||
if not args.out.is_file():
|
||||
raise SystemExit(f"missing {args.out}; run without --tags-only first")
|
||||
prev = json.loads(args.out.read_text(encoding="utf-8"))
|
||||
by_hero = dict(prev.get("by_hero") or {})
|
||||
overrides = _load_ability_tag_overrides()
|
||||
tagged = retag_abilities(by_hero, overrides)
|
||||
meta = dict(prev.get("meta") or {})
|
||||
meta["fetched_at"] = datetime.now(timezone.utc).isoformat()
|
||||
meta["tags_only"] = True
|
||||
meta["mechanic_tag_order"] = list(QUERY_MECHANIC_ORDER)
|
||||
payload = {"meta": meta, "by_hero": by_hero}
|
||||
args.out.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
f"retag done: {len(by_hero)} heroes, {tagged} abilities with tags -> {args.out}",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
|
||||
if args.icons_only:
|
||||
if not args.out.is_file():
|
||||
raise SystemExit(f"missing {args.out}; run without --icons-only first")
|
||||
@@ -693,6 +773,7 @@ def main() -> None:
|
||||
continue
|
||||
ab = ability_row(row, odota)
|
||||
if ab:
|
||||
ab["tags"] = ability_mechanic_tags(ab, ABILITY_TAG_OVERRIDES_MAP)
|
||||
abilities.append(ab)
|
||||
|
||||
talents = talent_rows(data.get("talents"), odota, abs_raw)
|
||||
@@ -742,12 +823,15 @@ def main() -> None:
|
||||
)
|
||||
time.sleep(args.delay)
|
||||
|
||||
tagged = retag_abilities(by_hero, ABILITY_TAG_OVERRIDES_MAP)
|
||||
payload = {
|
||||
"meta": {
|
||||
"source": "valve+opendota",
|
||||
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"heroes": len(by_hero),
|
||||
"mechanic_tag_order": list(QUERY_MECHANIC_ORDER),
|
||||
"abilities_with_tags": tagged,
|
||||
},
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Fetch OpenDota hero pick/win stats (all brackets) into data/hero_stats.json.
|
||||
|
||||
One GET /api/heroStats call covers every hero. OpenDota aggregates *recent*
|
||||
matches: pub_pick equals the sum of pub_pick_trend (typically 7 daily buckets),
|
||||
and bracket fields share that same recent window — not all-time / full patch.
|
||||
|
||||
Output is for the Climperor web site only — never merge into relations.json /
|
||||
heroes.json or recommend.
|
||||
|
||||
Usage:
|
||||
python fetch_hero_stats.py
|
||||
python fetch_hero_stats.py --out data/hero_stats.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import DATA
|
||||
from grid import hero_table
|
||||
from http_utils import http_json
|
||||
|
||||
OPENDOTA_HERO_STATS = "https://api.opendota.com/api/heroStats"
|
||||
OUT = DATA / "hero_stats.json"
|
||||
|
||||
# OpenDota skill brackets: 1=Herald … 8=Immortal
|
||||
BRACKET_ORDER = (
|
||||
"herald",
|
||||
"guardian",
|
||||
"crusader",
|
||||
"archon",
|
||||
"legend",
|
||||
"ancient",
|
||||
"divine",
|
||||
"immortal",
|
||||
)
|
||||
BRACKET_NUM = {name: i for i, name in enumerate(BRACKET_ORDER, start=1)}
|
||||
# Fallback when a heroStats row has no pub_*_trend arrays.
|
||||
DEFAULT_WINDOW_DAYS = 7
|
||||
|
||||
|
||||
def _pw(pick: object, win: object) -> dict[str, int]:
|
||||
try:
|
||||
p = int(pick or 0)
|
||||
except (TypeError, ValueError):
|
||||
p = 0
|
||||
try:
|
||||
w = int(win or 0)
|
||||
except (TypeError, ValueError):
|
||||
w = 0
|
||||
return {"pick": max(0, p), "win": max(0, w)}
|
||||
|
||||
|
||||
def row_to_hero_stats(row: dict) -> dict:
|
||||
brackets: dict[str, dict[str, int]] = {}
|
||||
for name, num in BRACKET_NUM.items():
|
||||
brackets[name] = _pw(row.get(f"{num}_pick"), row.get(f"{num}_win"))
|
||||
pro = _pw(row.get("pro_pick"), row.get("pro_win"))
|
||||
try:
|
||||
ban = int(row.get("pro_ban") or 0)
|
||||
except (TypeError, ValueError):
|
||||
ban = 0
|
||||
pro["ban"] = max(0, ban)
|
||||
return {
|
||||
"pub": _pw(row.get("pub_pick"), row.get("pub_win")),
|
||||
"brackets": brackets,
|
||||
"pro": pro,
|
||||
"turbo": _pw(row.get("turbo_picks"), row.get("turbo_wins")),
|
||||
}
|
||||
|
||||
|
||||
def detect_window_days(rows: list) -> int:
|
||||
"""Infer recent-window length from OpenDota pub_*_trend bucket counts."""
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for key in ("pub_pick_trend", "pub_win_trend"):
|
||||
trend = row.get(key)
|
||||
if isinstance(trend, list) and trend:
|
||||
return len(trend)
|
||||
return DEFAULT_WINDOW_DAYS
|
||||
|
||||
|
||||
def sum_picks(by_hero: dict[str, dict], path: tuple[str, ...]) -> int:
|
||||
total = 0
|
||||
for cell in by_hero.values():
|
||||
cur: object = cell
|
||||
for key in path:
|
||||
if not isinstance(cur, dict):
|
||||
cur = None
|
||||
break
|
||||
cur = cur.get(key)
|
||||
if isinstance(cur, dict):
|
||||
try:
|
||||
total += int(cur.get("pick") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def sum_bans(by_hero: dict[str, dict], path: tuple[str, ...]) -> int:
|
||||
total = 0
|
||||
for cell in by_hero.values():
|
||||
cur: object = cell
|
||||
for key in path:
|
||||
if not isinstance(cur, dict):
|
||||
cur = None
|
||||
break
|
||||
cur = cur.get(key)
|
||||
if isinstance(cur, dict):
|
||||
try:
|
||||
total += int(cur.get("ban") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def build_totals(by_hero: dict[str, dict]) -> dict:
|
||||
"""Aggregate pick (and pro ban) counts so the UI can derive pick/ban rates.
|
||||
|
||||
Pick rate ≈ hero_pick / (sum_picks / 10) because each match contributes 10 picks.
|
||||
OpenDota exposes public ranked bans only for the pro scene (pro_ban).
|
||||
"""
|
||||
brackets = {
|
||||
name: {"pick": sum_picks(by_hero, ("brackets", name))} for name in BRACKET_ORDER
|
||||
}
|
||||
return {
|
||||
"pub": {"pick": sum_picks(by_hero, ("pub",))},
|
||||
"brackets": brackets,
|
||||
"pro": {
|
||||
"pick": sum_picks(by_hero, ("pro",)),
|
||||
"ban": sum_bans(by_hero, ("pro",)),
|
||||
},
|
||||
"turbo": {"pick": sum_picks(by_hero, ("turbo",))},
|
||||
}
|
||||
|
||||
|
||||
def build_payload(by_hero: dict[str, dict], *, window_days: int) -> dict:
|
||||
days = max(1, int(window_days))
|
||||
return {
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"window_days": days,
|
||||
"window_note": (
|
||||
f"OpenDota recent matches (~{days} days); "
|
||||
"pub_pick ≈ sum(pub_pick_trend); brackets share the same window; "
|
||||
"pick_rate = pick / (sum_picks/10); ranked ban rates not in heroStats"
|
||||
),
|
||||
"window_label_zh": f"近约 {days} 天公开对局",
|
||||
"brackets": list(BRACKET_ORDER),
|
||||
"totals": build_totals(by_hero),
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
args = ap.parse_args()
|
||||
|
||||
heroes = hero_table()
|
||||
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
||||
|
||||
print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True)
|
||||
raw = http_json(OPENDOTA_HERO_STATS)
|
||||
if not isinstance(raw, list):
|
||||
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
|
||||
|
||||
window_days = detect_window_days(raw)
|
||||
by_hero: dict[str, dict] = {}
|
||||
unknown = 0
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
hid = int(row.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
key = id_to_key.get(hid)
|
||||
if key is None:
|
||||
unknown += 1
|
||||
continue
|
||||
by_hero[key] = row_to_hero_stats(row)
|
||||
|
||||
payload = build_payload(by_hero, window_days=window_days)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
missing = [h["key"] for h in heroes if h["key"] not in by_hero]
|
||||
print(
|
||||
f"done: {len(by_hero)} heroes -> {args.out}"
|
||||
+ (f", {unknown} unknown ids" if unknown else "")
|
||||
+ (f", {len(missing)} roster keys missing" if missing else ""),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Fetch adjusted enemy-item evidence for each hero from OpenDota Explorer.
|
||||
|
||||
For every hero, this compares enemy-team final-inventory item presence against
|
||||
the same item's baseline across all teams in the same recent match window:
|
||||
|
||||
buy_lift = P(item | against hero) - P(item | any team)
|
||||
win_delta = P(win | item, against hero) - P(win | item, any team)
|
||||
|
||||
These are observational signals, not causal counter claims. The output is an
|
||||
optional cache consumed by item_fears.py to corroborate and gently rerank its
|
||||
mechanism-based candidates.
|
||||
|
||||
Usage:
|
||||
python fetch_item_counter_stats.py
|
||||
python fetch_item_counter_stats.py --matches 20000 --min-games 100
|
||||
python fetch_item_counter_stats.py --print-sql
|
||||
python fetch_item_counter_stats.py --soft-fail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import DATA
|
||||
from grid import hero_table
|
||||
|
||||
API = "https://api.opendota.com/api/explorer"
|
||||
ITEMS_META = DATA / "items_meta.json"
|
||||
OUT = DATA / "item_counter_stats.json"
|
||||
|
||||
|
||||
def build_sql(matches: int, min_games: int) -> str:
|
||||
"""Build one bounded aggregate query; integer args are caller-validated."""
|
||||
return f"""
|
||||
WITH recent_ids AS (
|
||||
SELECT DISTINCT match_id
|
||||
FROM player_matches
|
||||
ORDER BY match_id DESC
|
||||
LIMIT {matches}
|
||||
),
|
||||
recent AS (
|
||||
SELECT m.match_id, m.radiant_win
|
||||
FROM matches m
|
||||
JOIN recent_ids r ON r.match_id = m.match_id
|
||||
),
|
||||
players AS (
|
||||
SELECT
|
||||
p.match_id,
|
||||
p.hero_id,
|
||||
(p.player_slot < 128) AS is_radiant,
|
||||
p.item_0, p.item_1, p.item_2,
|
||||
p.item_3, p.item_4, p.item_5
|
||||
FROM player_matches p
|
||||
JOIN recent r ON r.match_id = p.match_id
|
||||
WHERE p.hero_id IS NOT NULL
|
||||
),
|
||||
teams AS (
|
||||
SELECT DISTINCT match_id, is_radiant
|
||||
FROM players
|
||||
),
|
||||
team_outcomes AS (
|
||||
SELECT
|
||||
t.match_id,
|
||||
t.is_radiant,
|
||||
CASE WHEN t.is_radiant THEN r.radiant_win ELSE NOT r.radiant_win END AS won
|
||||
FROM teams t
|
||||
JOIN recent r ON r.match_id = t.match_id
|
||||
),
|
||||
team_items AS (
|
||||
SELECT DISTINCT
|
||||
p.match_id,
|
||||
p.is_radiant,
|
||||
x.item_id
|
||||
FROM players p
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES (p.item_0), (p.item_1), (p.item_2),
|
||||
(p.item_3), (p.item_4), (p.item_5)
|
||||
) AS x(item_id)
|
||||
WHERE x.item_id IS NOT NULL AND x.item_id > 0
|
||||
),
|
||||
hero_totals AS (
|
||||
SELECT hero_id, COUNT(*) AS target_games
|
||||
FROM players
|
||||
GROUP BY hero_id
|
||||
),
|
||||
global_total AS (
|
||||
SELECT COUNT(*) AS team_games
|
||||
FROM team_outcomes
|
||||
),
|
||||
global_items AS (
|
||||
SELECT
|
||||
ti.item_id,
|
||||
COUNT(*) AS item_games,
|
||||
SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins
|
||||
FROM team_items ti
|
||||
JOIN team_outcomes o
|
||||
ON o.match_id = ti.match_id
|
||||
AND o.is_radiant = ti.is_radiant
|
||||
GROUP BY ti.item_id
|
||||
),
|
||||
target_items AS (
|
||||
SELECT
|
||||
p.hero_id,
|
||||
ti.item_id,
|
||||
COUNT(*) AS item_games,
|
||||
SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins
|
||||
FROM players p
|
||||
JOIN team_items ti
|
||||
ON ti.match_id = p.match_id
|
||||
AND ti.is_radiant <> p.is_radiant
|
||||
JOIN team_outcomes o
|
||||
ON o.match_id = ti.match_id
|
||||
AND o.is_radiant = ti.is_radiant
|
||||
GROUP BY p.hero_id, ti.item_id
|
||||
HAVING COUNT(*) >= {min_games}
|
||||
)
|
||||
SELECT
|
||||
t.hero_id,
|
||||
t.item_id,
|
||||
h.target_games,
|
||||
t.item_games,
|
||||
t.item_wins,
|
||||
g.team_games AS global_team_games,
|
||||
gi.item_games AS global_item_games,
|
||||
gi.item_wins AS global_item_wins
|
||||
FROM target_items t
|
||||
JOIN hero_totals h ON h.hero_id = t.hero_id
|
||||
JOIN global_items gi ON gi.item_id = t.item_id
|
||||
CROSS JOIN global_total g
|
||||
ORDER BY t.hero_id, t.item_games DESC
|
||||
""".strip()
|
||||
|
||||
|
||||
def explorer(sql: str, *, timeout: int = 240) -> dict:
|
||||
params = {"sql": sql}
|
||||
api_key = os.environ.get("OPENDOTA_API_KEY", "").strip()
|
||||
if api_key:
|
||||
params["api_key"] = api_key
|
||||
url = API + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "climperor"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("OpenDota Explorer returned a non-object payload")
|
||||
if payload.get("error") or payload.get("err"):
|
||||
raise RuntimeError(str(payload.get("error") or payload.get("err")))
|
||||
return payload
|
||||
|
||||
|
||||
def item_catalog() -> dict[int, dict]:
|
||||
raw = json.loads(ITEMS_META.read_text(encoding="utf-8"))
|
||||
out: dict[int, dict] = {}
|
||||
for raw_id, row in (raw.get("items") or {}).items():
|
||||
if not isinstance(row, dict) or not row.get("key"):
|
||||
continue
|
||||
item_id = int(row.get("id") or raw_id)
|
||||
out[item_id] = {
|
||||
"key": str(row["key"]),
|
||||
"name_loc": str(row.get("name_loc") or row.get("dname") or row["key"]),
|
||||
"cost": int(row.get("cost") or 0),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def build_payload(
|
||||
rows: list[dict],
|
||||
*,
|
||||
matches: int,
|
||||
min_games: int,
|
||||
min_cost: int,
|
||||
) -> dict:
|
||||
heroes = {int(row["id"]): row["key"] for row in hero_table()}
|
||||
items = item_catalog()
|
||||
by_hero: dict[str, list[dict]] = {key: [] for key in heroes.values()}
|
||||
|
||||
for row in rows:
|
||||
hero_key = heroes.get(int(row["hero_id"]))
|
||||
item = items.get(int(row["item_id"]))
|
||||
if not hero_key or not item or item["cost"] < min_cost:
|
||||
continue
|
||||
|
||||
target_games = int(row["target_games"])
|
||||
item_games = int(row["item_games"])
|
||||
item_wins = int(row["item_wins"])
|
||||
global_team_games = int(row["global_team_games"])
|
||||
global_item_games = int(row["global_item_games"])
|
||||
global_item_wins = int(row["global_item_wins"])
|
||||
if not all((target_games, item_games, global_team_games, global_item_games)):
|
||||
continue
|
||||
|
||||
buy_rate = item_games / target_games
|
||||
global_buy_rate = global_item_games / global_team_games
|
||||
win_rate = item_wins / item_games
|
||||
global_win_rate = global_item_wins / global_item_games
|
||||
by_hero[hero_key].append(
|
||||
{
|
||||
"item": item["key"],
|
||||
"name_loc": item["name_loc"],
|
||||
"games": item_games,
|
||||
"wins": item_wins,
|
||||
"target_games": target_games,
|
||||
"global_team_games": global_team_games,
|
||||
"global_item_games": global_item_games,
|
||||
"global_item_wins": global_item_wins,
|
||||
"buy_rate": round(buy_rate, 6),
|
||||
"global_buy_rate": round(global_buy_rate, 6),
|
||||
"buy_lift": round(buy_rate - global_buy_rate, 6),
|
||||
"win_rate": round(win_rate, 6),
|
||||
"global_win_rate": round(global_win_rate, 6),
|
||||
"win_delta": round(win_rate - global_win_rate, 6),
|
||||
}
|
||||
)
|
||||
|
||||
for entries in by_hero.values():
|
||||
entries.sort(
|
||||
key=lambda row: (
|
||||
-float(row["buy_lift"]),
|
||||
-float(row["win_delta"]),
|
||||
-int(row["games"]),
|
||||
str(row["item"]),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"source": "opendota_explorer",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"window_matches": matches,
|
||||
"min_games": min_games,
|
||||
"min_cost": min_cost,
|
||||
"method": "enemy final inventory vs same-item global team baseline",
|
||||
"caveat": "observational; adjusted for item baseline, not duration, rank, role, or economy",
|
||||
},
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--matches", type=int, default=20_000)
|
||||
ap.add_argument("--min-games", type=int, default=100)
|
||||
ap.add_argument("--min-cost", type=int, default=1_400)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--print-sql", action="store_true")
|
||||
ap.add_argument(
|
||||
"--soft-fail",
|
||||
action="store_true",
|
||||
help="keep an existing cache and exit 0 when OpenDota is unavailable",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.matches < 1 or args.min_games < 1 or args.min_cost < 0:
|
||||
raise SystemExit("matches/min-games must be positive and min-cost non-negative")
|
||||
sql = build_sql(args.matches, args.min_games)
|
||||
if args.print_sql:
|
||||
print(sql)
|
||||
return
|
||||
if not ITEMS_META.is_file():
|
||||
raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py")
|
||||
|
||||
try:
|
||||
payload = explorer(sql)
|
||||
rows = payload.get("rows") or []
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise RuntimeError("OpenDota Explorer returned no rows")
|
||||
output = build_payload(
|
||||
rows,
|
||||
matches=args.matches,
|
||||
min_games=args.min_games,
|
||||
min_cost=args.min_cost,
|
||||
)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(
|
||||
json.dumps(output, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
nonempty = sum(bool(v) for v in output["by_hero"].values())
|
||||
print(
|
||||
f"done: {len(rows)} rows, {nonempty} heroes -> {args.out}",
|
||||
flush=True,
|
||||
)
|
||||
except (OSError, TimeoutError, urllib.error.URLError, RuntimeError) as exc:
|
||||
if args.soft_fail:
|
||||
state = "keeping existing cache" if args.out.is_file() else "no cache available"
|
||||
print(f"warn: item counter stats unavailable ({exc}); {state}", flush=True)
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,6 +25,7 @@ from pathlib import Path
|
||||
from common import DATA, ITEM_ICONS
|
||||
from http_utils import download_icons, http_json, load_itemlist
|
||||
from loc_format import HAS_PLACEHOLDER, format_loc
|
||||
from mechanic_tags import apply_mechanic_tags, merge_tag_overrides
|
||||
|
||||
ITEMS_URL = (
|
||||
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
|
||||
@@ -58,6 +59,17 @@ TAG_ORDER = [
|
||||
"silence",
|
||||
"hex",
|
||||
"root",
|
||||
"stun",
|
||||
"disarm",
|
||||
"mute",
|
||||
"invis",
|
||||
"sleep",
|
||||
"fear",
|
||||
"taunt",
|
||||
"blind",
|
||||
"leash",
|
||||
"ethereal",
|
||||
"cyclone",
|
||||
]
|
||||
|
||||
# key -> extra tags from curated whitelist (also covered by overrides)
|
||||
@@ -73,12 +85,6 @@ H1_RE = re.compile(
|
||||
r"<h1>\s*(主动|被动|使用|开关|升级|Active|Passive|Use|Toggle|Upgrade)\s*[::]?\s*([^<]*)</h1>",
|
||||
re.I,
|
||||
)
|
||||
DISPEL_TYPE_RE = re.compile(
|
||||
r"(?:驱散类型|Dispel\s*Type)\s*[::]\s*([^<\n]+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def is_core_finished(meta: dict) -> bool:
|
||||
key = meta.get("key") or ""
|
||||
if meta.get("tier") is not None:
|
||||
@@ -174,42 +180,11 @@ def parse_ability_kinds(desc_loc: str, od_abilities: list) -> list[str]:
|
||||
return sorted(kinds)
|
||||
|
||||
|
||||
def extract_dispel_from_text(text: str) -> set[str]:
|
||||
tags: set[str] = set()
|
||||
if not text:
|
||||
return tags
|
||||
for m in DISPEL_TYPE_RE.finditer(text):
|
||||
chunk = m.group(1).lower()
|
||||
if "强" in chunk or "strong" in chunk:
|
||||
tags.add("strong_dispel")
|
||||
if "基础" in chunk or "basic" in chunk:
|
||||
tags.add("basic_dispel")
|
||||
if "强" not in chunk and "strong" not in chunk and ("驱散" in chunk or "dispel" in chunk):
|
||||
tags.add("basic_dispel")
|
||||
low = text.lower()
|
||||
if "strong dispel" in low or "强驱散" in text:
|
||||
tags.add("strong_dispel")
|
||||
if "basic dispel" in low or "基础驱散" in text:
|
||||
tags.add("basic_dispel")
|
||||
# Explicit purge / 阻止 (diffusal) — avoid bare 「幻象不会」lines
|
||||
if re.search(
|
||||
r"(?:施加|应用|造成)?.{0,8}(?:基础)?驱散|applies?\s+(?:a\s+)?(?:basic\s+)?dispel|"
|
||||
r"\bpurge\b|阻止敌|Inhibit",
|
||||
text,
|
||||
re.I,
|
||||
):
|
||||
if "strong" in low or "强驱散" in text:
|
||||
tags.add("strong_dispel")
|
||||
else:
|
||||
tags.add("basic_dispel")
|
||||
return tags
|
||||
|
||||
|
||||
def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable: object) -> list[str]:
|
||||
blob = " ".join([desc_zh or "", desc_en or "", en_od or "", key])
|
||||
low = blob.lower()
|
||||
tags: set[str] = set()
|
||||
tags |= extract_dispel_from_text(blob)
|
||||
# Query-facing applies-* tags (dispel / CC); shared with ability tagging.
|
||||
tags |= apply_mechanic_tags(blob, key=key)
|
||||
|
||||
if od_dispellable and str(od_dispellable).lower() in ("yes", "both"):
|
||||
# Item itself being dispellable is not "applies dispel"
|
||||
@@ -221,8 +196,6 @@ def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable:
|
||||
re.I,
|
||||
):
|
||||
tags.add("mana_burn")
|
||||
if re.search(r"(?:施加|造成|应用).{0,6}破坏|\bbreaks?\b(?:\s+passives?)?|Break\b", blob):
|
||||
tags.add("break")
|
||||
if key == "monkey_king_bar" or re.search(
|
||||
r"true strike|无视闪避|必定命中|attacks? cannot miss",
|
||||
blob,
|
||||
@@ -265,12 +238,6 @@ def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable:
|
||||
tags.add("heal_reduce")
|
||||
if re.search(r"armor reduction|reduce(?:s)? armor|减甲|降低护甲", blob, re.I):
|
||||
tags.add("armor_reduce")
|
||||
if re.search(r"\bsilence[sd]?\b|沉默", blob, re.I):
|
||||
tags.add("silence")
|
||||
if re.search(r"\bhex(?:es|ed)?\b|变羊|妖术", blob, re.I):
|
||||
tags.add("hex")
|
||||
if re.search(r"\broot(?:s|ed)?\b|缠绕|定身", blob, re.I):
|
||||
tags.add("root")
|
||||
|
||||
return [t for t in TAG_ORDER if t in tags]
|
||||
|
||||
@@ -287,18 +254,7 @@ def load_overrides() -> dict[str, dict]:
|
||||
|
||||
|
||||
def apply_overrides(key: str, tags: list[str], overrides: dict[str, dict]) -> list[str]:
|
||||
row = overrides.get(key) or {}
|
||||
s = set(tags)
|
||||
for t in row.get("add") or []:
|
||||
if isinstance(t, str):
|
||||
s.add(t)
|
||||
for t in row.get("remove") or []:
|
||||
if isinstance(t, str):
|
||||
s.discard(t)
|
||||
# Keep known order, then any extras
|
||||
ordered = [t for t in TAG_ORDER if t in s]
|
||||
extras = sorted(s - set(TAG_ORDER))
|
||||
return ordered + extras
|
||||
return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Fetch Valve Immortal regional leaderboards into data/leaderboards.json.
|
||||
|
||||
Pulls the official division boards (americas / europe / se_asia / china) and
|
||||
keeps Top 100 per region for the Climperor web「排行」page.
|
||||
|
||||
Valve does not expose MMR or account_id on this endpoint — only rank, name,
|
||||
and optional team/country. Divisions use separate MMR scales (no global board).
|
||||
|
||||
Preview only — do not merge into relations/heroes or recommend.
|
||||
|
||||
Usage:
|
||||
python fetch_leaderboards.py
|
||||
python fetch_leaderboards.py --out data/leaderboards.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import DATA
|
||||
from http_utils import http_json
|
||||
|
||||
OUT = DATA / "leaderboards.json"
|
||||
API = (
|
||||
"https://www.dota2.com/webapi/ILeaderboard/GetDivisionLeaderboard/v0001"
|
||||
"?division={division}&leaderboard=0"
|
||||
)
|
||||
|
||||
# Display order for the web rankings tab (China first for CN audience).
|
||||
REGION_ORDER = ("china", "europe", "americas", "se_asia")
|
||||
REGION_LABELS = {
|
||||
"china": "中国",
|
||||
"europe": "欧洲",
|
||||
"americas": "美洲",
|
||||
"se_asia": "东南亚",
|
||||
}
|
||||
TOP_N = 100
|
||||
|
||||
|
||||
def slim_entry(row: dict) -> dict:
|
||||
out: dict = {
|
||||
"rank": int(row.get("rank") or 0),
|
||||
"name": str(row.get("name") or ""),
|
||||
}
|
||||
team = row.get("team_tag")
|
||||
if isinstance(team, str) and team.strip():
|
||||
out["team_tag"] = team.strip()
|
||||
country = row.get("country")
|
||||
if isinstance(country, str) and country.strip():
|
||||
out["country"] = country.strip().lower()
|
||||
return out
|
||||
|
||||
|
||||
def fetch_division(division: str) -> dict:
|
||||
url = API.format(division=division)
|
||||
print(f"fetching {division} ...", flush=True)
|
||||
raw = http_json(url)
|
||||
if not isinstance(raw, dict):
|
||||
raise SystemExit(f"{division}: unexpected payload type {type(raw).__name__}")
|
||||
lb = raw.get("leaderboard")
|
||||
if not isinstance(lb, list):
|
||||
raise SystemExit(f"{division}: missing leaderboard array")
|
||||
top = [slim_entry(e) for e in lb[:TOP_N] if isinstance(e, dict)]
|
||||
return {
|
||||
"division": division,
|
||||
"label_zh": REGION_LABELS.get(division, division),
|
||||
"time_posted": raw.get("time_posted"),
|
||||
"count": len(lb),
|
||||
"top100": top,
|
||||
}
|
||||
|
||||
|
||||
def build_payload(regions: dict[str, dict]) -> dict:
|
||||
return {
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "valve",
|
||||
"attribution": "https://www.dota2.com/leaderboards",
|
||||
"note": (
|
||||
"Official Immortal division boards; no MMR/account_id in payload; "
|
||||
"region MMR scales are not comparable across divisions"
|
||||
),
|
||||
"default_region": "china",
|
||||
"region_order": list(REGION_ORDER),
|
||||
"regions": regions,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
args = ap.parse_args()
|
||||
|
||||
regions: dict[str, dict] = {}
|
||||
for div in REGION_ORDER:
|
||||
regions[div] = fetch_division(div)
|
||||
|
||||
payload = build_payload(regions)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
parts = [
|
||||
f"{div}={len((regions[div].get('top100') or []))}" for div in REGION_ORDER
|
||||
]
|
||||
print(f"done: {', '.join(parts)} -> {args.out}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,7 +11,7 @@ Sources:
|
||||
Only patches within the past year (default 365 days from today) are kept,
|
||||
descending. Per-patch detail keeps the raw ability_id / hero_id values; a
|
||||
shared `lookup` resolves only the referenced ids to {key, name_loc} so the
|
||||
read-only preview can render names + icons with no runtime network calls.
|
||||
read-only Climperor web site can render names + icons with no runtime network calls.
|
||||
|
||||
Referenced item / ability icons are downloaded into assets/item_icons and
|
||||
assets/ability_icons so the static export stays self-contained.
|
||||
@@ -22,6 +22,7 @@ Usage:
|
||||
python fetch_patches.py --since 2025-07-27
|
||||
python fetch_patches.py --no-icons
|
||||
python fetch_patches.py --force # refetch every patch detail
|
||||
python fetch_patches.py --check # list vs cache; JSON on stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -58,7 +59,7 @@ DEFAULT_WINDOW_DAYS = 365
|
||||
# Bundled shared badges — never fetched from CDN (many innate keys 404).
|
||||
SKIP_ABILITY_ICON_KEYS = frozenset({"innate", "talent_tree"})
|
||||
# Non-hero units that Valve places in patchnotes heroes[] under a pseudo hero_id
|
||||
# absent from herolist. Resolve to a Chinese name + portrait key so the preview
|
||||
# absent from herolist. Resolve to a Chinese name + portrait key so the web site
|
||||
# never shows #1961. Portrait files live in assets/hero_portraits/<key>.png.
|
||||
UNIT_NAMES = {1961: {"key": "spirit_bear", "name_loc": "熊灵"}}
|
||||
|
||||
@@ -372,6 +373,30 @@ def load_existing_details() -> dict[str, dict]:
|
||||
return out
|
||||
|
||||
|
||||
def check_for_new_patches(*, days: int, since: str | None) -> dict:
|
||||
"""Compare Valve patch list to local details cache; do not write files.
|
||||
|
||||
Returns a JSON-serializable dict with has_new / new_versions / latest / …
|
||||
"""
|
||||
if since:
|
||||
since_dt = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
since_dt = datetime.now(tz=timezone.utc) - timedelta(days=days)
|
||||
since_ts = int(since_dt.timestamp())
|
||||
patches = fetch_patch_list(since_ts)
|
||||
cached = load_existing_details()
|
||||
new_versions = [p["version"] for p in patches if p["version"] not in cached]
|
||||
latest = patches[0]["version"] if patches else None
|
||||
return {
|
||||
"has_new": bool(new_versions),
|
||||
"new_versions": new_versions,
|
||||
"latest": latest,
|
||||
"window_count": len(patches),
|
||||
"cached_details": len(cached),
|
||||
"since": since_dt.date().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--days", type=int, default=DEFAULT_WINDOW_DAYS, help="window in days from today")
|
||||
@@ -379,8 +404,18 @@ def main() -> None:
|
||||
ap.add_argument("--delay", type=float, default=0.3, help="seconds between detail/icon requests")
|
||||
ap.add_argument("--no-icons", action="store_true", help="skip icon downloads")
|
||||
ap.add_argument("--force", action="store_true", help="refetch every patch detail")
|
||||
ap.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="only compare patch list to local details; print JSON to stdout",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.check:
|
||||
result = check_for_new_patches(days=args.days, since=args.since)
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return
|
||||
|
||||
if args.since:
|
||||
since_dt = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Aggregate pro-player item + skill builds per hero (Climperor web only).
|
||||
|
||||
Uses OpenDota registered pros (/proPlayers) and league match rows from
|
||||
data/hero_matches.json (run fetch_hero_matches.py --source league first).
|
||||
Optionally backfill more league samples with --fetch.
|
||||
|
||||
Output: data/pro_builds.json — not used by recommend.
|
||||
|
||||
Usage:
|
||||
python fetch_hero_matches.py --source league --limit 12
|
||||
python fetch_pro_builds.py
|
||||
python fetch_pro_builds.py --fetch --heroes juggernaut --limit 20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import DATA
|
||||
from fetch_hero_items import (
|
||||
BLESSING_TO_SCEPTER,
|
||||
TOP_N,
|
||||
is_core_finished,
|
||||
load_item_catalog,
|
||||
)
|
||||
from fetch_hero_matches import (
|
||||
extract_player_row,
|
||||
fetch_match,
|
||||
league_match_ids,
|
||||
load_ability_id_map,
|
||||
)
|
||||
from grid import hero_table
|
||||
from http_utils import http_json
|
||||
|
||||
OPENDOTA = "https://api.opendota.com/api"
|
||||
OUT = DATA / "pro_builds.json"
|
||||
MATCHES_IN = DATA / "hero_matches.json"
|
||||
PRO_MATCHES_IN = DATA / "pro_matches.json"
|
||||
TOP_SKILL_ORDERS = 3
|
||||
|
||||
|
||||
def fetch_pro_index() -> dict[int, dict]:
|
||||
"""account_id → slim pro profile from OpenDota."""
|
||||
try:
|
||||
raw = http_json(f"{OPENDOTA}/proPlayers")
|
||||
except (
|
||||
urllib.error.HTTPError,
|
||||
urllib.error.URLError,
|
||||
TimeoutError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
raise SystemExit(f"proPlayers fetch failed: {e}") from e
|
||||
if not isinstance(raw, list):
|
||||
raise SystemExit("proPlayers: unexpected payload")
|
||||
out: dict[int, dict] = {}
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
aid = int(row.get("account_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if aid <= 0:
|
||||
continue
|
||||
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
|
||||
country = row.get("country_code") or row.get("loccountrycode")
|
||||
if isinstance(country, str):
|
||||
country = country.strip().lower() or None
|
||||
else:
|
||||
country = None
|
||||
last_match = row.get("last_match_time")
|
||||
if isinstance(last_match, str):
|
||||
last_match = last_match.strip() or None
|
||||
else:
|
||||
last_match = None
|
||||
out[aid] = {
|
||||
"account_id": aid,
|
||||
"name": name,
|
||||
"team_tag": team_tag,
|
||||
"team_name": team_name,
|
||||
"country_code": country,
|
||||
"last_match_time": last_match,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def normalize_item_id(iid: int) -> int:
|
||||
return BLESSING_TO_SCEPTER.get(iid, iid)
|
||||
|
||||
|
||||
def match_item_ids(row: dict) -> list[int]:
|
||||
ids: list[int] = []
|
||||
for key in ("items", "backpack"):
|
||||
part = row.get(key)
|
||||
if not isinstance(part, list):
|
||||
continue
|
||||
for raw in part:
|
||||
try:
|
||||
iid = int(raw or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if iid > 0:
|
||||
ids.append(normalize_item_id(iid))
|
||||
neut = row.get("item_neutral")
|
||||
try:
|
||||
ni = int(neut or 0)
|
||||
except (TypeError, ValueError):
|
||||
ni = 0
|
||||
if ni > 0:
|
||||
ids.append(ni)
|
||||
return ids
|
||||
|
||||
|
||||
def is_pro_match(row: dict, pro_ids: set[int]) -> bool:
|
||||
origin = str(row.get("origin") or "")
|
||||
if origin not in ("league", "pro"):
|
||||
return False
|
||||
aid = row.get("account_id")
|
||||
try:
|
||||
aid_i = int(aid) if aid is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
aid_i = 0
|
||||
if aid_i in pro_ids:
|
||||
return True
|
||||
# Registered pro name on league row (OpenDota match detail).
|
||||
name = row.get("name")
|
||||
return isinstance(name, str) and bool(name.strip())
|
||||
|
||||
|
||||
def aggregate_items(
|
||||
rows: list[dict], catalog: dict[int, dict], n: int = TOP_N
|
||||
) -> list[dict]:
|
||||
counts: Counter[int] = Counter()
|
||||
for row in rows:
|
||||
seen: set[int] = set()
|
||||
for iid in match_item_ids(row):
|
||||
if iid in seen:
|
||||
continue
|
||||
meta = catalog.get(iid)
|
||||
if meta is None or not is_core_finished(meta):
|
||||
continue
|
||||
seen.add(iid)
|
||||
counts[iid] += 1
|
||||
total = len(rows)
|
||||
ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0]))
|
||||
out: list[dict] = []
|
||||
for iid, c in ranked[:n]:
|
||||
cell: dict = {"id": iid, "count": c}
|
||||
if total > 0:
|
||||
cell["pct"] = round(c / total * 1000) / 10
|
||||
out.append(cell)
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_skill_orders(
|
||||
rows: list[dict], *, top: int = TOP_SKILL_ORDERS
|
||||
) -> list[dict]:
|
||||
counts: Counter[tuple[str, ...]] = Counter()
|
||||
for row in rows:
|
||||
ups = row.get("ability_upgrades")
|
||||
if not isinstance(ups, list) or not ups:
|
||||
continue
|
||||
seq = tuple(str(k) for k in ups if isinstance(k, str) and k.strip())
|
||||
if not seq:
|
||||
continue
|
||||
counts[seq] += 1
|
||||
total = sum(counts.values())
|
||||
ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0]))
|
||||
out: list[dict] = []
|
||||
for seq, c in ranked[:top]:
|
||||
cell: dict = {"sequence": list(seq), "count": c}
|
||||
if total > 0:
|
||||
cell["pct"] = round(c / total * 1000) / 10
|
||||
out.append(cell)
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_pros(
|
||||
rows: list[dict], pro_index: dict[int, dict]
|
||||
) -> list[dict]:
|
||||
by_aid: dict[int, dict] = {}
|
||||
for row in rows:
|
||||
aid = row.get("account_id")
|
||||
try:
|
||||
aid_i = int(aid) if aid is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
aid_i = 0
|
||||
prof = pro_index.get(aid_i) if aid_i else None
|
||||
name = (prof or {}).get("name") or row.get("name") or row.get("display_name")
|
||||
if isinstance(name, str):
|
||||
name = name.strip() or None
|
||||
else:
|
||||
name = None
|
||||
if not name and not aid_i:
|
||||
continue
|
||||
key = aid_i if aid_i else hash(name or "")
|
||||
cell = by_aid.setdefault(
|
||||
key,
|
||||
{
|
||||
"account_id": aid_i or None,
|
||||
"name": name,
|
||||
"team_tag": (prof or {}).get("team_tag"),
|
||||
"games": 0,
|
||||
"wins": 0,
|
||||
"last_match_id": None,
|
||||
"last_start_time": None,
|
||||
},
|
||||
)
|
||||
cell["games"] += 1
|
||||
if row.get("won"):
|
||||
cell["wins"] += 1
|
||||
mid = row.get("match_id")
|
||||
st = row.get("start_time")
|
||||
try:
|
||||
st_i = int(st) if st is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
st_i = 0
|
||||
prev = cell.get("last_start_time") or 0
|
||||
if st_i >= prev:
|
||||
cell["last_start_time"] = st_i or None
|
||||
cell["last_match_id"] = mid
|
||||
ranked = sorted(
|
||||
by_aid.values(),
|
||||
key=lambda r: (
|
||||
-(r.get("games") or 0),
|
||||
-(r.get("last_start_time") or 0),
|
||||
str(r.get("name") or ""),
|
||||
),
|
||||
)
|
||||
for r in ranked:
|
||||
r.pop("last_start_time", None)
|
||||
return ranked[:12]
|
||||
|
||||
|
||||
def load_pro_league_rows(path: Path) -> dict[str, list[dict]]:
|
||||
"""League/pro rows from pro_matches.json by_hero or by_pro."""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
out: dict[str, list[dict]] = {}
|
||||
|
||||
by_hero = raw.get("by_hero")
|
||||
if isinstance(by_hero, dict):
|
||||
for key, cell in by_hero.items():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
matches = cell.get("matches")
|
||||
if isinstance(matches, list) and matches:
|
||||
out[str(key)] = [m for m in matches if isinstance(m, dict)]
|
||||
|
||||
by_pro = raw.get("by_pro")
|
||||
if isinstance(by_pro, dict):
|
||||
for cell in by_pro.values():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
for row in cell.get("matches") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
key = row.get("hero_key")
|
||||
if not key:
|
||||
continue
|
||||
out.setdefault(str(key), []).append(row)
|
||||
|
||||
deduped: dict[str, list[dict]] = {}
|
||||
for key, rows in out.items():
|
||||
seen: set[int] = set()
|
||||
merged: list[dict] = []
|
||||
for row in rows:
|
||||
try:
|
||||
mid = int(row.get("match_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
merged.append(row)
|
||||
if merged:
|
||||
deduped[key] = merged
|
||||
return deduped
|
||||
|
||||
|
||||
def load_league_rows(path: Path) -> dict[str, list[dict]]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
by_hero = raw.get("by_hero")
|
||||
if not isinstance(by_hero, dict):
|
||||
return {}
|
||||
out: dict[str, list[dict]] = {}
|
||||
for key, cell in by_hero.items():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
matches = cell.get("matches")
|
||||
if not isinstance(matches, list):
|
||||
continue
|
||||
league = [m for m in matches if isinstance(m, dict) and m.get("origin") == "league"]
|
||||
if league:
|
||||
out[str(key)] = league
|
||||
return out
|
||||
|
||||
|
||||
def fetch_league_rows_for_hero(
|
||||
hero_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
id_map: dict[int, str],
|
||||
catalog: dict[int, dict],
|
||||
delay: float,
|
||||
) -> list[dict]:
|
||||
metas = league_match_ids(hero_id, limit)
|
||||
rows: list[dict] = []
|
||||
for meta in metas:
|
||||
try:
|
||||
mid = int(meta.get("match_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid <= 0:
|
||||
continue
|
||||
detail = fetch_match(mid)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
if not detail:
|
||||
continue
|
||||
slim = extract_player_row(
|
||||
detail,
|
||||
hero_id,
|
||||
origin="league",
|
||||
id_map=id_map,
|
||||
item_catalog=catalog,
|
||||
list_meta=meta,
|
||||
)
|
||||
if slim:
|
||||
rows.append(slim)
|
||||
return rows
|
||||
|
||||
|
||||
def build_payload(
|
||||
*,
|
||||
by_hero_rows: dict[str, list[dict]],
|
||||
pro_index: dict[int, dict],
|
||||
catalog: dict[int, dict],
|
||||
match_source: str,
|
||||
) -> dict:
|
||||
pro_ids = set(pro_index)
|
||||
by_hero: dict[str, dict] = {}
|
||||
used_item_ids: set[int] = set()
|
||||
total_matches = 0
|
||||
|
||||
for key, all_rows in sorted(by_hero_rows.items()):
|
||||
pro_rows = [r for r in all_rows if is_pro_match(r, pro_ids)]
|
||||
if not pro_rows:
|
||||
continue
|
||||
total_matches += len(pro_rows)
|
||||
items = aggregate_items(pro_rows, catalog)
|
||||
for ent in items:
|
||||
used_item_ids.add(int(ent["id"]))
|
||||
by_hero[key] = {
|
||||
"sample_count": len(pro_rows),
|
||||
"items": items,
|
||||
"skill_orders": aggregate_skill_orders(pro_rows),
|
||||
"pros": aggregate_pros(pro_rows, pro_index),
|
||||
}
|
||||
|
||||
items_out = {
|
||||
str(iid): {
|
||||
"key": catalog[iid]["key"],
|
||||
"dname": catalog[iid]["dname"],
|
||||
"name_loc": catalog[iid].get("name_loc") or catalog[iid]["dname"],
|
||||
}
|
||||
for iid in sorted(used_item_ids)
|
||||
if iid in catalog
|
||||
}
|
||||
|
||||
pros_out = {
|
||||
str(aid): prof
|
||||
for aid, prof in sorted(pro_index.items(), key=lambda t: t[0])
|
||||
}
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"match_source": match_source,
|
||||
"pro_count": len(pro_index),
|
||||
"hero_count": len(by_hero),
|
||||
"match_count": total_matches,
|
||||
"note_zh": (
|
||||
"职业选手样本:OpenDota 注册选手 + 联赛对局终局出装/加点;"
|
||||
"出装频率 = 该装备出现在样本终局栏位的比例;"
|
||||
"加点为完整升级顺序的最常见方案。"
|
||||
),
|
||||
},
|
||||
"pros": pros_out,
|
||||
"items": items_out,
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument(
|
||||
"--matches",
|
||||
type=Path,
|
||||
default=MATCHES_IN,
|
||||
help="Input hero_matches.json league rows (default: data/hero_matches.json)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--pro-matches",
|
||||
type=Path,
|
||||
default=PRO_MATCHES_IN,
|
||||
help="Merge pro player rows from this file (default: data/pro_matches.json)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--fetch",
|
||||
action="store_true",
|
||||
help="Fetch league matches from OpenDota instead of only reading --matches",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=12,
|
||||
help="League matches per hero when --fetch (default: 12)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--heroes",
|
||||
default="",
|
||||
help="Comma-separated hero keys (default: all when --fetch; else keys in matches file)",
|
||||
)
|
||||
ap.add_argument("--delay", type=float, default=0.35)
|
||||
args = ap.parse_args()
|
||||
|
||||
print("loading pro players ...", flush=True)
|
||||
pro_index = fetch_pro_index()
|
||||
print(f" {len(pro_index)} registered pros", flush=True)
|
||||
|
||||
catalog, _ = load_item_catalog()
|
||||
id_map = load_ability_id_map()
|
||||
heroes = hero_table()
|
||||
by_key = {h["key"]: h for h in heroes}
|
||||
hero_filter = [k.strip() for k in args.heroes.split(",") if k.strip()]
|
||||
|
||||
by_hero_rows: dict[str, list[dict]] = {}
|
||||
|
||||
if args.fetch:
|
||||
keys = hero_filter or sorted(by_key)
|
||||
limit = max(1, int(args.limit))
|
||||
for i, key in enumerate(keys, 1):
|
||||
hero = by_key.get(key)
|
||||
if not hero:
|
||||
print(f" skip unknown hero {key}", flush=True)
|
||||
continue
|
||||
hid = int(hero["id"])
|
||||
print(f"[{i}/{len(keys)}] fetch league {key} ...", flush=True)
|
||||
rows = fetch_league_rows_for_hero(
|
||||
hid,
|
||||
limit=limit,
|
||||
id_map=id_map,
|
||||
catalog=catalog,
|
||||
delay=args.delay,
|
||||
)
|
||||
if rows:
|
||||
by_hero_rows[key] = rows
|
||||
else:
|
||||
by_hero_rows = load_league_rows(args.matches)
|
||||
pro_path = args.pro_matches
|
||||
if pro_path and pro_path.is_file():
|
||||
pro_rows = load_pro_league_rows(pro_path)
|
||||
for key, rows in pro_rows.items():
|
||||
bucket = by_hero_rows.setdefault(key, [])
|
||||
seen = {int(r.get("match_id") or 0) for r in bucket if isinstance(r, dict)}
|
||||
for row in rows:
|
||||
try:
|
||||
mid = int(row.get("match_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
bucket.append(row)
|
||||
if hero_filter:
|
||||
by_hero_rows = {k: v for k, v in by_hero_rows.items() if k in hero_filter}
|
||||
if not by_hero_rows:
|
||||
raise SystemExit(
|
||||
f"No league/pro rows in {args.matches} or {args.pro_matches}. "
|
||||
"Run: python fetch_pro_matches.py or fetch_hero_matches.py --source league"
|
||||
)
|
||||
|
||||
sources = []
|
||||
if args.fetch:
|
||||
sources.append("fetch")
|
||||
else:
|
||||
sources.append(str(args.matches.name))
|
||||
if args.pro_matches and args.pro_matches.is_file():
|
||||
sources.append(str(args.pro_matches.name))
|
||||
match_source = "+".join(sources)
|
||||
payload = build_payload(
|
||||
by_hero_rows=by_hero_rows,
|
||||
pro_index=pro_index,
|
||||
catalog=catalog,
|
||||
match_source=match_source,
|
||||
)
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
meta = payload["meta"]
|
||||
print(
|
||||
f"done heroes={meta['hero_count']} matches={meta['match_count']} → {args.out}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,434 @@
|
||||
"""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.
|
||||
|
||||
Output: 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
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import DATA
|
||||
from fetch_hero_matches import (
|
||||
collect_item_ids,
|
||||
extract_player_row,
|
||||
fetch_match,
|
||||
load_ability_id_map,
|
||||
)
|
||||
from fetch_hero_items import load_item_catalog
|
||||
from fetch_pro_builds import fetch_pro_index
|
||||
from grid import hero_table
|
||||
from http_utils import http_json
|
||||
|
||||
OPENDOTA = "https://api.opendota.com/api"
|
||||
OUT = DATA / "pro_matches.json"
|
||||
DEFAULT_LIMIT = 8
|
||||
DEFAULT_LIMIT_PROS = 40
|
||||
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
|
||||
LOBBY_LEAGUE = (1, 2)
|
||||
|
||||
|
||||
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
|
||||
out: list[int] = []
|
||||
for part in raw.split(","):
|
||||
token = part.strip()
|
||||
if not token:
|
||||
continue
|
||||
if token.isdigit():
|
||||
out.append(int(token))
|
||||
continue
|
||||
aid = name_to_id.get(token.lower())
|
||||
if aid:
|
||||
out.append(aid)
|
||||
else:
|
||||
print(f" warn: unknown pro {token!r}", flush=True)
|
||||
return out
|
||||
|
||||
|
||||
def filter_pros(
|
||||
pro_index: dict[int, dict],
|
||||
*,
|
||||
with_team: bool,
|
||||
active_days: int | None,
|
||||
limit_pros: int,
|
||||
player_ids: list[int],
|
||||
) -> 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))
|
||||
return rows
|
||||
|
||||
cutoff = None
|
||||
if active_days is not None and active_days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=active_days)
|
||||
|
||||
candidates: list[tuple[int, dict, float]] = []
|
||||
for aid, prof in pro_index.items():
|
||||
if with_team and not prof.get("team_tag") and not prof.get("team_name"):
|
||||
continue
|
||||
last = prof.get("last_match_time")
|
||||
score = 0.0
|
||||
if isinstance(last, str) and last.strip():
|
||||
try:
|
||||
ts = datetime.fromisoformat(last.replace("Z", "+00:00"))
|
||||
if cutoff and ts < cutoff:
|
||||
continue
|
||||
score = ts.timestamp()
|
||||
except ValueError:
|
||||
if cutoff:
|
||||
continue
|
||||
candidates.append((aid, prof, score))
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def player_match_metas(
|
||||
account_id: int,
|
||||
limit: int,
|
||||
*,
|
||||
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
|
||||
) -> list[dict]:
|
||||
"""Recent match list rows for a pro (deduped, newest first)."""
|
||||
per_lt = max(limit, limit // max(1, len(lobby_types)) + 2)
|
||||
by_id: dict[int, dict] = {}
|
||||
for lt in lobby_types:
|
||||
url = f"{OPENDOTA}/players/{account_id}/matches?limit={per_lt}&lobby_type={lt}"
|
||||
try:
|
||||
raw = http_json(url)
|
||||
except (
|
||||
urllib.error.HTTPError,
|
||||
urllib.error.URLError,
|
||||
TimeoutError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
):
|
||||
continue
|
||||
if not isinstance(raw, list):
|
||||
continue
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
mid = int(row.get("match_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid <= 0:
|
||||
continue
|
||||
prev = by_id.get(mid)
|
||||
if prev is None:
|
||||
by_id[mid] = row
|
||||
continue
|
||||
try:
|
||||
st_new = int(row.get("start_time") or 0)
|
||||
st_old = int(prev.get("start_time") or 0)
|
||||
except (TypeError, ValueError):
|
||||
st_new = st_old = 0
|
||||
if st_new >= st_old:
|
||||
by_id[mid] = row
|
||||
|
||||
ranked = sorted(
|
||||
by_id.values(),
|
||||
key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)),
|
||||
)
|
||||
return ranked[:limit]
|
||||
|
||||
|
||||
def fetch_player_matches(
|
||||
account_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
id_map: dict[int, str],
|
||||
catalog: dict[int, dict],
|
||||
id_to_key: dict[int, str],
|
||||
delay: float,
|
||||
lobby_types: tuple[int, ...],
|
||||
) -> list[dict]:
|
||||
metas = player_match_metas(account_id, limit, lobby_types=lobby_types)
|
||||
rows: list[dict] = []
|
||||
for meta in metas:
|
||||
try:
|
||||
mid = int(meta.get("match_id") or 0)
|
||||
hid = int(meta.get("hero_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid <= 0 or hid <= 0:
|
||||
continue
|
||||
detail = fetch_match(mid)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
if not detail:
|
||||
continue
|
||||
slim = extract_player_row(
|
||||
detail,
|
||||
hid,
|
||||
origin="pro",
|
||||
id_map=id_map,
|
||||
item_catalog=catalog,
|
||||
list_meta=meta,
|
||||
)
|
||||
if not slim:
|
||||
continue
|
||||
row_aid = slim.get("account_id")
|
||||
try:
|
||||
row_aid_i = int(row_aid) if row_aid is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
row_aid_i = 0
|
||||
if row_aid_i and row_aid_i != account_id:
|
||||
continue
|
||||
slim["hero_id"] = hid
|
||||
slim["hero_key"] = id_to_key.get(hid)
|
||||
lt = meta.get("lobby_type")
|
||||
try:
|
||||
slim["lobby_type"] = int(lt) if lt is not None else None
|
||||
except (TypeError, ValueError):
|
||||
slim["lobby_type"] = None
|
||||
rows.append(slim)
|
||||
return rows
|
||||
|
||||
|
||||
def build_indexes(
|
||||
by_pro: dict[str, dict],
|
||||
id_to_key: dict[int, str],
|
||||
) -> dict[str, dict]:
|
||||
by_hero: dict[str, list[dict]] = {}
|
||||
for cell in by_pro.values():
|
||||
for row in cell.get("matches") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
key = row.get("hero_key")
|
||||
if not key:
|
||||
hid = row.get("hero_id")
|
||||
try:
|
||||
key = id_to_key.get(int(hid)) if hid is not None else None
|
||||
except (TypeError, ValueError):
|
||||
key = None
|
||||
if not key:
|
||||
continue
|
||||
by_hero.setdefault(str(key), []).append(row)
|
||||
out: dict[str, dict] = {}
|
||||
for key, rows in by_hero.items():
|
||||
seen: set[int] = set()
|
||||
deduped: list[dict] = []
|
||||
for row in sorted(
|
||||
rows,
|
||||
key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)),
|
||||
):
|
||||
try:
|
||||
mid = int(row.get("match_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
deduped.append(row)
|
||||
out[key] = {"matches": deduped}
|
||||
return out
|
||||
|
||||
|
||||
def write_out(
|
||||
path: Path,
|
||||
*,
|
||||
by_pro: dict[str, dict],
|
||||
by_hero: dict[str, dict],
|
||||
pros_meta: dict[str, dict],
|
||||
item_catalog: dict[int, dict],
|
||||
limit: int,
|
||||
limit_pros: int,
|
||||
lobby_types: tuple[int, ...],
|
||||
) -> None:
|
||||
used = collect_item_ids(by_hero)
|
||||
items_out = {
|
||||
str(iid): {
|
||||
"key": item_catalog[iid]["key"],
|
||||
"dname": item_catalog[iid]["dname"],
|
||||
"name_loc": item_catalog[iid].get("name_loc") or item_catalog[iid]["dname"],
|
||||
}
|
||||
for iid in sorted(used)
|
||||
if iid in item_catalog
|
||||
}
|
||||
match_count = sum(
|
||||
len(cell.get("matches") or [])
|
||||
for cell in by_pro.values()
|
||||
if isinstance(cell, dict)
|
||||
)
|
||||
payload = {
|
||||
"meta": {
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"limit_per_pro": limit,
|
||||
"limit_pros": limit_pros,
|
||||
"lobby_types": list(lobby_types),
|
||||
"pro_count": len(by_pro),
|
||||
"match_count": match_count,
|
||||
"hero_count": len(by_hero),
|
||||
"note_zh": (
|
||||
"OpenDota 注册职业选手近期联赛/锦标赛对局;"
|
||||
"lobby_type 1=训练/practice、2=tournament;"
|
||||
"含终局出装、加点与联赛名(若有)。"
|
||||
),
|
||||
},
|
||||
"items": items_out,
|
||||
"pros": pros_meta,
|
||||
"by_pro": by_pro,
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=DEFAULT_LIMIT,
|
||||
help=f"Matches per pro (default: {DEFAULT_LIMIT})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--limit-pros",
|
||||
type=int,
|
||||
default=DEFAULT_LIMIT_PROS,
|
||||
help=f"Max pros when --players omitted (default: {DEFAULT_LIMIT_PROS})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--players",
|
||||
default="",
|
||||
help="Comma-separated account_id or registered pro name (overrides --limit-pros)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--with-team",
|
||||
action="store_true",
|
||||
help="Only pros with a team_tag/team_name when picking from /proPlayers",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--active-days",
|
||||
type=int,
|
||||
default=60,
|
||||
help="Skip pros with no last_match_time within N days (0=disable; default: 60)",
|
||||
)
|
||||
ap.add_argument("--delay", type=float, default=0.35)
|
||||
ap.add_argument(
|
||||
"--include-pubs",
|
||||
action="store_true",
|
||||
help="Also include ranked pub lobby_type=7 (high-MMR scrims)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
limit = max(1, int(args.limit))
|
||||
limit_pros = max(1, int(args.limit_pros))
|
||||
active_days = int(args.active_days) if args.active_days > 0 else None
|
||||
lobby_types: tuple[int, ...] = LOBBY_LEAGUE
|
||||
if args.include_pubs:
|
||||
lobby_types = LOBBY_LEAGUE + (7,)
|
||||
|
||||
_log("loading pro players ...")
|
||||
pro_index = fetch_pro_index()
|
||||
_log(f" {len(pro_index)} registered pros")
|
||||
|
||||
player_ids = parse_pro_filter(args.players, pro_index)
|
||||
picked = filter_pros(
|
||||
pro_index,
|
||||
with_team=args.with_team,
|
||||
active_days=active_days,
|
||||
limit_pros=limit_pros,
|
||||
player_ids=player_ids,
|
||||
)
|
||||
if not picked:
|
||||
raise SystemExit("No pros matched filters")
|
||||
|
||||
heroes = hero_table()
|
||||
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
||||
catalog, _ = load_item_catalog()
|
||||
id_map = load_ability_id_map()
|
||||
|
||||
by_pro: dict[str, dict] = {}
|
||||
pros_meta: dict[str, dict] = {}
|
||||
total_matches = 0
|
||||
|
||||
for i, (aid, prof) in enumerate(picked, 1):
|
||||
label = prof.get("name") or prof.get("team_tag") or str(aid)
|
||||
_log(f"[{i}/{len(picked)}] {label} ({aid}) ...")
|
||||
matches = fetch_player_matches(
|
||||
aid,
|
||||
limit=limit,
|
||||
id_map=id_map,
|
||||
catalog=catalog,
|
||||
id_to_key=id_to_key,
|
||||
delay=args.delay,
|
||||
lobby_types=lobby_types,
|
||||
)
|
||||
sid = str(aid)
|
||||
by_pro[sid] = {
|
||||
"account_id": aid,
|
||||
"name": prof.get("name"),
|
||||
"team_tag": prof.get("team_tag"),
|
||||
"team_name": prof.get("team_name"),
|
||||
"country_code": prof.get("country_code"),
|
||||
"match_count": len(matches),
|
||||
"matches": matches,
|
||||
}
|
||||
pros_meta[sid] = {
|
||||
"account_id": aid,
|
||||
"name": prof.get("name"),
|
||||
"team_tag": prof.get("team_tag"),
|
||||
"team_name": prof.get("team_name"),
|
||||
"country_code": prof.get("country_code"),
|
||||
}
|
||||
total_matches += len(matches)
|
||||
_log(f" {len(matches)} matches")
|
||||
|
||||
by_hero = build_indexes(by_pro, id_to_key)
|
||||
write_out(
|
||||
args.out,
|
||||
by_pro=by_pro,
|
||||
by_hero=by_hero,
|
||||
pros_meta=pros_meta,
|
||||
item_catalog=catalog,
|
||||
limit=limit,
|
||||
limit_pros=len(picked),
|
||||
lobby_types=lobby_types,
|
||||
)
|
||||
_log(
|
||||
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)} → {args.out}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -65,9 +65,15 @@ query($id: Short!) {{
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
env = os.environ.get("STRATZ_API_TOKEN", "").strip()
|
||||
if env:
|
||||
return env
|
||||
for key in (
|
||||
"STRATZ_API_TOKEN",
|
||||
"KEYZOO_ASSET_API_KEY",
|
||||
"KEYZOO_ASSET_SECRET_API_KEY",
|
||||
"KEYZOO_ASSET_TOKEN",
|
||||
):
|
||||
env = os.environ.get(key, "").strip()
|
||||
if env:
|
||||
return env
|
||||
path = ROOT / ".env"
|
||||
if path.is_file():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
@@ -78,7 +84,8 @@ def load_token() -> str:
|
||||
if k.strip() == "STRATZ_API_TOKEN":
|
||||
return v.strip().strip('"').strip("'")
|
||||
raise SystemExit(
|
||||
"STRATZ_API_TOKEN missing. Set env or add it to .env (see .env.example)."
|
||||
"STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env "
|
||||
"(see .env.example)."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Fetch STRATZ hero meta (weekly WR/pick by bracket + position) and matchup tops.
|
||||
|
||||
Requires STRATZ_API_TOKEN (or KEYZOO_ASSET_API_KEY) in the environment / .env.
|
||||
Output is for the Climperor web site only — never merge into relations.json
|
||||
or recommend.
|
||||
|
||||
Data sources (heroStats GraphQL):
|
||||
- winWeek(take=N, bracketIds): per-medal weekly pick/win → `weeks` / `latest`
|
||||
- winWeek(take=1, bracketIds, positionIds): per-medal per-position latest week
|
||||
→ `positions` (same time window as headline cards; exact medal, not basic merge)
|
||||
- matchUp: counter / countered / synergy tops → stratz_matchup_tops.json
|
||||
|
||||
Usage:
|
||||
python fetch_stratz_meta.py
|
||||
python fetch_stratz_meta.py --weeks 8 --delay 0.25
|
||||
python fetch_stratz_meta.py --skip-matchups
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import DATA, ROOT
|
||||
from grid import hero_table
|
||||
|
||||
API = "https://api.stratz.com/graphql"
|
||||
OUT_META = DATA / "stratz_hero_meta.json"
|
||||
OUT_MATCHUPS = DATA / "stratz_matchup_tops.json"
|
||||
|
||||
# UI medal keys ↔ STRATZ RankBracket enum
|
||||
BRACKET_ORDER = (
|
||||
"herald",
|
||||
"guardian",
|
||||
"crusader",
|
||||
"archon",
|
||||
"legend",
|
||||
"ancient",
|
||||
"divine",
|
||||
"immortal",
|
||||
)
|
||||
BRACKET_ENUM = {
|
||||
"herald": "HERALD",
|
||||
"guardian": "GUARDIAN",
|
||||
"crusader": "CRUSADER",
|
||||
"archon": "ARCHON",
|
||||
"legend": "LEGEND",
|
||||
"ancient": "ANCIENT",
|
||||
"divine": "DIVINE",
|
||||
"immortal": "IMMORTAL",
|
||||
}
|
||||
|
||||
# Medal → RankBracketBasicEnum (legacy; position stats now use exact medal via winWeek)
|
||||
BRACKET_TO_BASIC = {
|
||||
"herald": "HERALD_GUARDIAN",
|
||||
"guardian": "HERALD_GUARDIAN",
|
||||
"crusader": "CRUSADER_ARCHON",
|
||||
"archon": "CRUSADER_ARCHON",
|
||||
"legend": "LEGEND_ANCIENT",
|
||||
"ancient": "LEGEND_ANCIENT",
|
||||
"divine": "DIVINE_IMMORTAL",
|
||||
"immortal": "DIVINE_IMMORTAL",
|
||||
}
|
||||
|
||||
POSITION_ORDER = (
|
||||
"POSITION_1",
|
||||
"POSITION_2",
|
||||
"POSITION_3",
|
||||
"POSITION_4",
|
||||
"POSITION_5",
|
||||
)
|
||||
|
||||
ATTRIBUTION = "https://stratz.com"
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
for key in (
|
||||
"STRATZ_API_TOKEN",
|
||||
"KEYZOO_ASSET_API_KEY",
|
||||
"KEYZOO_ASSET_SECRET_API_KEY",
|
||||
"KEYZOO_ASSET_TOKEN",
|
||||
):
|
||||
env = os.environ.get(key, "").strip()
|
||||
if env:
|
||||
return env
|
||||
path = ROOT / ".env"
|
||||
if path.is_file():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
if k.strip() == "STRATZ_API_TOKEN":
|
||||
return v.strip().strip('"').strip("'")
|
||||
raise SystemExit(
|
||||
"STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env "
|
||||
"(see .env.example)."
|
||||
)
|
||||
|
||||
|
||||
def gql(token: str, query: str, variables: dict | None = None) -> dict:
|
||||
body: dict = {"query": query}
|
||||
if variables:
|
||||
body["variables"] = variables
|
||||
req = urllib.request.Request(
|
||||
API,
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "climperor",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
if payload.get("errors"):
|
||||
raise RuntimeError(str(payload["errors"][:2]))
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def _pw(pick: int, win: int) -> dict:
|
||||
pick = max(0, int(pick or 0))
|
||||
win = max(0, int(win or 0))
|
||||
wr = (win / pick) if pick > 0 else None
|
||||
return {"pick": pick, "win": win, "wr": wr}
|
||||
|
||||
|
||||
def fetch_weeks_for_bracket(
|
||||
token: str, hero_ids: list[int], bracket: str, take: int
|
||||
) -> list[dict]:
|
||||
enum = BRACKET_ENUM[bracket]
|
||||
# GraphQL list variable as inline enums (Short! list for heroIds).
|
||||
ids_lit = ", ".join(str(i) for i in hero_ids)
|
||||
query = f"""
|
||||
query {{
|
||||
heroStats {{
|
||||
winWeek(heroIds: [{ids_lit}], take: {int(take)}, bracketIds: [{enum}]) {{
|
||||
heroId
|
||||
week
|
||||
matchCount
|
||||
winCount
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
data = gql(token, query)
|
||||
return (((data or {}).get("heroStats") or {}).get("winWeek")) or []
|
||||
|
||||
|
||||
def fetch_latest_positions_for_bracket(
|
||||
token: str, hero_ids: list[int], bracket: str
|
||||
) -> list[dict]:
|
||||
"""Latest-week winWeek per position; same bracket + week as headline cards."""
|
||||
enum = BRACKET_ENUM[bracket]
|
||||
ids_lit = ", ".join(str(i) for i in hero_ids)
|
||||
out: list[dict] = []
|
||||
for pos in POSITION_ORDER:
|
||||
query = f"""
|
||||
query {{
|
||||
heroStats {{
|
||||
winWeek(
|
||||
heroIds: [{ids_lit}]
|
||||
take: 1
|
||||
bracketIds: [{enum}]
|
||||
positionIds: [{pos}]
|
||||
) {{
|
||||
heroId
|
||||
week
|
||||
matchCount
|
||||
winCount
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
data = gql(token, query)
|
||||
for row in (((data or {}).get("heroStats") or {}).get("winWeek")) or []:
|
||||
out.append({**row, "position": pos, "bracket": bracket})
|
||||
return out
|
||||
|
||||
|
||||
MATCHUP_QUERY = """
|
||||
query($id: Short!, $take: Int!, $limit: Int!) {
|
||||
heroStats {
|
||||
matchUp(heroId: $id, take: $take, matchLimit: $limit) {
|
||||
heroId
|
||||
matchCountVs
|
||||
matchCountWith
|
||||
vs {
|
||||
heroId2
|
||||
matchCount
|
||||
winCount
|
||||
synergy
|
||||
winsAverage
|
||||
}
|
||||
with {
|
||||
heroId2
|
||||
matchCount
|
||||
winCount
|
||||
synergy
|
||||
winsAverage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_matchup_tops(
|
||||
token: str, hero_id: int, *, take: int, match_limit: int
|
||||
) -> dict:
|
||||
data = gql(
|
||||
token,
|
||||
MATCHUP_QUERY,
|
||||
{"id": hero_id, "take": take, "limit": match_limit},
|
||||
)
|
||||
rows = (((data or {}).get("heroStats") or {}).get("matchUp")) or []
|
||||
row = rows[0] if rows else {}
|
||||
vs_out = []
|
||||
with_out = []
|
||||
for pair in row.get("vs") or []:
|
||||
other = pair.get("heroId2")
|
||||
games = int(pair.get("matchCount") or 0)
|
||||
if other is None or games <= 0:
|
||||
continue
|
||||
wins = int(pair.get("winCount") or 0)
|
||||
vs_out.append(
|
||||
{
|
||||
"hero_id": int(other),
|
||||
"games": games,
|
||||
"wins": wins,
|
||||
"advantage": float(pair.get("synergy") or 0.0),
|
||||
"wr": float(pair.get("winsAverage") or (wins / games)),
|
||||
}
|
||||
)
|
||||
for pair in row.get("with") or []:
|
||||
other = pair.get("heroId2")
|
||||
games = int(pair.get("matchCount") or 0)
|
||||
if other is None or games <= 0:
|
||||
continue
|
||||
wins = int(pair.get("winCount") or 0)
|
||||
with_out.append(
|
||||
{
|
||||
"hero_id": int(other),
|
||||
"games": games,
|
||||
"wins": wins,
|
||||
"synergy": float(pair.get("synergy") or 0.0),
|
||||
"wr": float(pair.get("winsAverage") or (wins / games)),
|
||||
}
|
||||
)
|
||||
# vs advantage: positive = hero wins more vs other → counters other.
|
||||
# Also derive "disadvantage" as others with most negative advantage for hero.
|
||||
disadvantage = [
|
||||
{
|
||||
"hero_id": e["hero_id"],
|
||||
"games": e["games"],
|
||||
"wins": e["wins"],
|
||||
"advantage": -float(e["advantage"]),
|
||||
"wr": 1.0 - float(e["wr"]) if e["wr"] is not None else None,
|
||||
}
|
||||
for e in sorted(vs_out, key=lambda x: x["advantage"])[:take]
|
||||
]
|
||||
counters = sorted(vs_out, key=lambda x: -x["advantage"])[:take]
|
||||
synergies = sorted(with_out, key=lambda x: -x["synergy"])[:take]
|
||||
return {
|
||||
"counters": counters,
|
||||
"countered": disadvantage,
|
||||
"synergies": synergies,
|
||||
}
|
||||
|
||||
|
||||
def build_meta(
|
||||
weeks_rows_by_bracket: dict[str, list[dict]],
|
||||
position_rows_by_bracket: dict[str, list[dict]],
|
||||
id_to_key: dict[int, str],
|
||||
weeks_take: int,
|
||||
) -> dict:
|
||||
by_hero: dict[str, dict] = {}
|
||||
for hid, key in id_to_key.items():
|
||||
by_hero[key] = {
|
||||
"id": hid,
|
||||
"weeks": {b: [] for b in BRACKET_ORDER},
|
||||
"latest": {},
|
||||
"positions": {b: {} for b in BRACKET_ORDER},
|
||||
}
|
||||
|
||||
for bracket, rows in weeks_rows_by_bracket.items():
|
||||
# Group by hero, sort weeks desc, keep take
|
||||
by_id: dict[int, list[dict]] = {}
|
||||
for row in rows:
|
||||
hid = int(row.get("heroId") or 0)
|
||||
if hid not in id_to_key:
|
||||
continue
|
||||
by_id.setdefault(hid, []).append(row)
|
||||
for hid, rows_h in by_id.items():
|
||||
key = id_to_key[hid]
|
||||
rows_h.sort(key=lambda r: int(r.get("week") or 0), reverse=True)
|
||||
weeks = []
|
||||
for r in rows_h[:weeks_take]:
|
||||
pick = int(r.get("matchCount") or 0)
|
||||
win = int(r.get("winCount") or 0)
|
||||
weeks.append(
|
||||
{
|
||||
"week": int(r.get("week") or 0),
|
||||
"pick": pick,
|
||||
"win": win,
|
||||
"wr": (win / pick) if pick > 0 else None,
|
||||
}
|
||||
)
|
||||
by_hero[key]["weeks"][bracket] = weeks
|
||||
if weeks:
|
||||
latest = weeks[0]
|
||||
by_hero[key]["latest"][bracket] = _pw(latest["pick"], latest["win"])
|
||||
|
||||
for bracket, pos_rows in position_rows_by_bracket.items():
|
||||
for row in pos_rows:
|
||||
hid = int(row.get("heroId") or 0)
|
||||
key = id_to_key.get(hid)
|
||||
if not key:
|
||||
continue
|
||||
pos = str(row.get("position") or "")
|
||||
if pos not in POSITION_ORDER:
|
||||
continue
|
||||
cell = _pw(int(row.get("matchCount") or 0), int(row.get("winCount") or 0))
|
||||
by_hero[key]["positions"][bracket][pos] = cell
|
||||
|
||||
# Totals (latest week pick sum per bracket) for pick-rate denominator
|
||||
totals: dict[str, dict] = {}
|
||||
for bracket in BRACKET_ORDER:
|
||||
total_pick = 0
|
||||
for cell in by_hero.values():
|
||||
latest = (cell.get("latest") or {}).get(bracket) or {}
|
||||
total_pick += int(latest.get("pick") or 0)
|
||||
totals[bracket] = {"pick": total_pick}
|
||||
|
||||
# Meta board: top heroes by pick (latest week) per bracket
|
||||
meta_board: dict[str, list[dict]] = {}
|
||||
for bracket in BRACKET_ORDER:
|
||||
denom = max(1, int((totals.get(bracket) or {}).get("pick") or 0))
|
||||
rows_board = []
|
||||
for key, cell in by_hero.items():
|
||||
latest = (cell.get("latest") or {}).get(bracket)
|
||||
if not latest or int(latest.get("pick") or 0) <= 0:
|
||||
continue
|
||||
pick = int(latest["pick"])
|
||||
win = int(latest["win"])
|
||||
wr = win / pick if pick else None
|
||||
# Same convention as OpenDota web: pick / (Σpick / 10)
|
||||
pr = pick / (denom / 10.0) if denom else None
|
||||
rows_board.append(
|
||||
{
|
||||
"key": key,
|
||||
"id": cell["id"],
|
||||
"pick": pick,
|
||||
"win": win,
|
||||
"wr": wr,
|
||||
"pr": pr,
|
||||
}
|
||||
)
|
||||
rows_board.sort(key=lambda r: (-r["pick"], -(r["wr"] or 0), r["key"]))
|
||||
meta_board[bracket] = rows_board
|
||||
|
||||
return {
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "stratz",
|
||||
"attribution": ATTRIBUTION,
|
||||
"weeks_take": weeks_take,
|
||||
"window_label_zh": f"近 {weeks_take} 周天梯(按段位)",
|
||||
"latest_window_label_zh": "最近 1 周",
|
||||
"brackets": list(BRACKET_ORDER),
|
||||
"positions": list(POSITION_ORDER),
|
||||
"bracket_position_note": None,
|
||||
"totals": totals,
|
||||
"by_hero": by_hero,
|
||||
"meta_board": meta_board,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--weeks", type=int, default=8, help="weekly buckets to keep")
|
||||
ap.add_argument("--delay", type=float, default=0.25)
|
||||
ap.add_argument("--out-meta", type=Path, default=OUT_META)
|
||||
ap.add_argument("--out-matchups", type=Path, default=OUT_MATCHUPS)
|
||||
ap.add_argument("--skip-matchups", action="store_true")
|
||||
ap.add_argument("--matchup-take", type=int, default=12)
|
||||
ap.add_argument("--matchup-min-games", type=int, default=50)
|
||||
args = ap.parse_args()
|
||||
|
||||
token = load_token()
|
||||
heroes = hero_table()
|
||||
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
||||
hero_ids = sorted(id_to_key.keys())
|
||||
print(
|
||||
f"fetching STRATZ meta for {len(hero_ids)} heroes, "
|
||||
f"{args.weeks} weeks × {len(BRACKET_ORDER)} brackets",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
weeks_by_bracket: dict[str, list[dict]] = {}
|
||||
position_rows_by_bracket: dict[str, list[dict]] = {}
|
||||
for i, bracket in enumerate(BRACKET_ORDER, start=1):
|
||||
try:
|
||||
rows = fetch_weeks_for_bracket(token, hero_ids, bracket, args.weeks)
|
||||
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
|
||||
print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} failed: {e}", flush=True)
|
||||
rows = []
|
||||
weeks_by_bracket[bracket] = rows
|
||||
try:
|
||||
pos_rows = fetch_latest_positions_for_bracket(token, hero_ids, bracket)
|
||||
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
|
||||
print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} positions failed: {e}", flush=True)
|
||||
pos_rows = []
|
||||
position_rows_by_bracket[bracket] = pos_rows
|
||||
print(
|
||||
f" [{i}/{len(BRACKET_ORDER)}] {bracket}: {len(rows)} week rows, "
|
||||
f"{len(pos_rows)} position rows",
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(args.delay)
|
||||
|
||||
meta = build_meta(
|
||||
weeks_by_bracket, position_rows_by_bracket, id_to_key, args.weeks
|
||||
)
|
||||
args.out_meta.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out_meta.write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {args.out_meta}", flush=True)
|
||||
|
||||
if args.skip_matchups:
|
||||
print("skip matchup tops", flush=True)
|
||||
return
|
||||
|
||||
by_hero_mu: dict[str, dict] = {}
|
||||
if args.out_matchups.is_file():
|
||||
try:
|
||||
prev = json.loads(args.out_matchups.read_text(encoding="utf-8"))
|
||||
by_hero_mu = dict(prev.get("by_hero") or {})
|
||||
print(f"resuming matchups with {len(by_hero_mu)} heroes", flush=True)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
pending = [hid for hid in hero_ids if id_to_key[hid] not in by_hero_mu]
|
||||
print(
|
||||
f"fetching matchup tops {len(pending)}/{len(hero_ids)} "
|
||||
f"(take={args.matchup_take}, min_games={args.matchup_min_games})",
|
||||
flush=True,
|
||||
)
|
||||
for n, hid in enumerate(pending, start=1):
|
||||
key = id_to_key[hid]
|
||||
try:
|
||||
cell = fetch_matchup_tops(
|
||||
token,
|
||||
hid,
|
||||
take=args.matchup_take,
|
||||
match_limit=args.matchup_min_games,
|
||||
)
|
||||
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
|
||||
print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True)
|
||||
time.sleep(args.delay * 2)
|
||||
continue
|
||||
by_hero_mu[key] = cell
|
||||
print(
|
||||
f" [{n}/{len(pending)}] {key}: "
|
||||
f"vs={len(cell['counters'])} fear={len(cell['countered'])} "
|
||||
f"with={len(cell['synergies'])}",
|
||||
flush=True,
|
||||
)
|
||||
payload = {
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "stratz",
|
||||
"attribution": ATTRIBUTION,
|
||||
"take": args.matchup_take,
|
||||
"match_limit": args.matchup_min_games,
|
||||
"note": (
|
||||
"counters = positive vs advantage; countered = heroes this hero "
|
||||
"loses to (negated advantage); synergies = with synergy. "
|
||||
"Web-only; do not merge into relations.json."
|
||||
),
|
||||
"by_hero": by_hero_mu,
|
||||
}
|
||||
args.out_matchups.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
time.sleep(args.delay)
|
||||
|
||||
print(f"done: matchups {len(by_hero_mu)} heroes → {args.out_matchups}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Fetch Douyin profile fields into data/streamers.json.
|
||||
|
||||
Manual seed fields (id / platform / live_url / profile_url / heroes / tagline)
|
||||
are preserved. Profile enrichment (nickname, signature, counts, avatar) is
|
||||
best-effort via Douyin HTML RENDER_DATA + text fallback; failures keep the
|
||||
previous values.
|
||||
|
||||
Preview only — do not merge into relations/heroes or recommend.
|
||||
Part of refresh_web ``daily`` (soft-fail: never aborts the tier).
|
||||
|
||||
Usage:
|
||||
python fetch_streamers.py
|
||||
python fetch_streamers.py --ids xiaowang
|
||||
python fetch_streamers.py --out data/streamers.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import DATA, ROOT
|
||||
|
||||
OUT = DATA / "streamers.json"
|
||||
AVATAR_DIR = ROOT / "assets" / "streamer_avatars"
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
RENDER_DATA_RE = re.compile(
|
||||
r'<script[^>]+id=["\']RENDER_DATA["\'][^>]*>([^<]+)</script>',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HTML_COUNTS_RE = re.compile(
|
||||
r"关注\s*([\d.]+万?亿?)\s*粉丝\s*([\d.]+万?亿?)\s*获赞\s*([\d.]+万?亿?)",
|
||||
re.DOTALL,
|
||||
)
|
||||
HTML_UNIQUE_RE = re.compile(r"抖音号[::]\s*([A-Za-z0-9_.-]+)")
|
||||
HTML_TITLE_RE = re.compile(r"<title>\s*([^<]+?)\s*的抖音", re.IGNORECASE)
|
||||
HTML_AVATAR_RE = re.compile(
|
||||
r'<img[^>]+alt="[^"]*头像"[^>]+src="([^"]+)"|'
|
||||
r'<img[^>]+src="([^"]+)"[^>]+alt="[^"]*头像"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SEC_UID_RE = re.compile(r"/user/(MS4wLjABAAAA[A-Za-z0-9_-]+)")
|
||||
# Fields fetch may overwrite; manual seed keys are never removed.
|
||||
PROFILE_KEYS = (
|
||||
"nickname",
|
||||
"unique_id",
|
||||
"signature",
|
||||
"following_count",
|
||||
"follower_count",
|
||||
"total_favorited",
|
||||
"avatar",
|
||||
"profile_fetched_at",
|
||||
)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
if not path.is_file():
|
||||
return {
|
||||
"fetched_at": None,
|
||||
"source": "manual+douyin",
|
||||
"platform_meta": {
|
||||
"douyin": {
|
||||
"label_zh": "抖音",
|
||||
"icon": "ui-icon/platform_douyin.png",
|
||||
}
|
||||
},
|
||||
"streamers": [],
|
||||
}
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise SystemExit(f"{path}: expected object")
|
||||
return raw
|
||||
|
||||
|
||||
def _save(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _opener() -> urllib.request.OpenerDirector:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def _get(
|
||||
opener: urllib.request.OpenerDirector, url: str, *, timeout: int = 30
|
||||
) -> tuple[str, str]:
|
||||
"""Return (final_url, html)."""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Referer": "https://www.douyin.com/",
|
||||
},
|
||||
)
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
final = resp.geturl()
|
||||
html = resp.read().decode("utf-8", "replace")
|
||||
return final, html
|
||||
|
||||
|
||||
def parse_cn_count(raw: str) -> int | None:
|
||||
s = (raw or "").strip().replace(",", "").replace("\n", "")
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("亿"):
|
||||
return int(float(s[:-1]) * 100_000_000)
|
||||
if s.endswith("万"):
|
||||
return int(float(s[:-1]) * 10_000)
|
||||
return int(float(s))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_render_data(html: str) -> dict | None:
|
||||
m = RENDER_DATA_RE.search(html)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
decoded = urllib.parse.unquote(m.group(1))
|
||||
data = json.loads(decoded)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _walk_user_nodes(obj: Any, found: list[dict]) -> None:
|
||||
if isinstance(obj, dict):
|
||||
if "nickname" in obj and (
|
||||
"follower_count" in obj
|
||||
or "followerCount" in obj
|
||||
or "mplatform_followers_count" in obj
|
||||
):
|
||||
found.append(obj)
|
||||
for v in obj.values():
|
||||
_walk_user_nodes(v, found)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
_walk_user_nodes(v, found)
|
||||
|
||||
|
||||
def _first_int(*vals: Any) -> int | None:
|
||||
for v in vals:
|
||||
if isinstance(v, bool):
|
||||
continue
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
if isinstance(v, float):
|
||||
return int(v)
|
||||
if isinstance(v, str):
|
||||
n = parse_cn_count(v)
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _first_str(*vals: Any) -> str | None:
|
||||
for v in vals:
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _avatar_url(user: dict) -> str | None:
|
||||
for key in (
|
||||
"avatar_larger",
|
||||
"avatar_medium",
|
||||
"avatar_thumb",
|
||||
"avatarUrl",
|
||||
"avatar_url",
|
||||
):
|
||||
cell = user.get(key)
|
||||
if isinstance(cell, str) and cell.startswith("http"):
|
||||
return cell
|
||||
if isinstance(cell, dict):
|
||||
urls = cell.get("url_list") or cell.get("urlList") or []
|
||||
if isinstance(urls, list):
|
||||
for u in urls:
|
||||
if isinstance(u, str) and u.startswith("http"):
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
def extract_profile_from_render(render: dict) -> dict | None:
|
||||
candidates: list[dict] = []
|
||||
_walk_user_nodes(render, candidates)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
def score(u: dict) -> int:
|
||||
n = _first_int(
|
||||
u.get("follower_count"),
|
||||
u.get("followerCount"),
|
||||
u.get("mplatform_followers_count"),
|
||||
)
|
||||
return n if n is not None else -1
|
||||
|
||||
user = max(candidates, key=score)
|
||||
return {
|
||||
"nickname": _first_str(user.get("nickname"), user.get("nickName")),
|
||||
"unique_id": _first_str(
|
||||
user.get("unique_id"),
|
||||
user.get("uniqueId"),
|
||||
user.get("short_id"),
|
||||
user.get("shortId"),
|
||||
),
|
||||
"signature": _first_str(user.get("signature"), user.get("desc")),
|
||||
"following_count": _first_int(
|
||||
user.get("following_count"), user.get("followingCount")
|
||||
),
|
||||
"follower_count": _first_int(
|
||||
user.get("follower_count"),
|
||||
user.get("followerCount"),
|
||||
user.get("mplatform_followers_count"),
|
||||
),
|
||||
"total_favorited": _first_int(
|
||||
user.get("total_favorited"),
|
||||
user.get("totalFavorited"),
|
||||
user.get("favoriting_count"),
|
||||
),
|
||||
"avatar_url": _avatar_url(user),
|
||||
}
|
||||
|
||||
|
||||
def extract_profile_from_html(html: str) -> dict:
|
||||
"""Best-effort parse when RENDER_DATA user blob is missing/blocked."""
|
||||
out: dict[str, Any] = {}
|
||||
m = HTML_COUNTS_RE.search(html)
|
||||
if m:
|
||||
out["following_count"] = parse_cn_count(m.group(1))
|
||||
out["follower_count"] = parse_cn_count(m.group(2))
|
||||
out["total_favorited"] = parse_cn_count(m.group(3))
|
||||
uid = HTML_UNIQUE_RE.search(html)
|
||||
if uid:
|
||||
out["unique_id"] = uid.group(1)
|
||||
title = HTML_TITLE_RE.search(html)
|
||||
if title:
|
||||
out["nickname"] = title.group(1).strip()
|
||||
av = HTML_AVATAR_RE.search(html)
|
||||
if av:
|
||||
url = av.group(1) or av.group(2)
|
||||
if url and url.startswith("http"):
|
||||
out["avatar_url"] = url.replace("&", "&")
|
||||
return out
|
||||
|
||||
|
||||
def merge_profile_dicts(primary: dict | None, fallback: dict) -> dict:
|
||||
out = dict(fallback)
|
||||
if primary:
|
||||
for k, v in primary.items():
|
||||
if v is None or v == "":
|
||||
continue
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def canonicalize_douyin_user_url(url: str) -> str:
|
||||
"""Map share / short-link destinations to www.douyin.com/user/<sec_uid>."""
|
||||
m = SEC_UID_RE.search(url)
|
||||
if m:
|
||||
return f"https://www.douyin.com/user/{m.group(1)}"
|
||||
return url
|
||||
|
||||
|
||||
def resolve_profile_url(
|
||||
opener: urllib.request.OpenerDirector, profile_url: str
|
||||
) -> tuple[str, str | None]:
|
||||
"""Follow redirects; return (canonical_url, html_or_None if not yet fetched)."""
|
||||
url = profile_url.strip()
|
||||
host = urllib.parse.urlparse(url).netloc.lower()
|
||||
if "v.douyin.com" in host or "iesdouyin.com" in host:
|
||||
final, html = _get(opener, url)
|
||||
canon = canonicalize_douyin_user_url(final)
|
||||
if canon != final and "douyin.com/user/" in canon:
|
||||
# Re-fetch canonical profile for RENDER_DATA when possible.
|
||||
try:
|
||||
_, html2 = _get(opener, canon)
|
||||
return canon, html2
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return canon, html
|
||||
return canon, html
|
||||
return url, None
|
||||
|
||||
|
||||
def download_avatar(url: str, dest: Path) -> bool:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Prefer a larger CDN variant when the URL embeds a size token.
|
||||
candidates = [url]
|
||||
if "/100x100/" in url:
|
||||
candidates.insert(0, url.replace("/100x100/", "/720x720/"))
|
||||
if "300x300" in url:
|
||||
candidates.insert(0, url.replace("300x300", "720x720"))
|
||||
for candidate in candidates:
|
||||
req = urllib.request.Request(
|
||||
candidate,
|
||||
headers={
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = resp.read()
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
print(f" avatar download failed: {e}", flush=True)
|
||||
continue
|
||||
if not data or len(data) < 64:
|
||||
continue
|
||||
dest.write_bytes(data)
|
||||
return True
|
||||
print(" avatar download empty", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def fetch_douyin_profile(
|
||||
opener: urllib.request.OpenerDirector, profile_url: str
|
||||
) -> dict:
|
||||
try:
|
||||
_get(opener, "https://www.douyin.com/")
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
print(f" douyin homepage warm-up failed: {e}", flush=True)
|
||||
|
||||
canon, html = resolve_profile_url(opener, profile_url)
|
||||
if html is None:
|
||||
_, html = _get(opener, canon)
|
||||
|
||||
primary = None
|
||||
render = _parse_render_data(html)
|
||||
if render is not None:
|
||||
primary = extract_profile_from_render(render)
|
||||
fallback = extract_profile_from_html(html)
|
||||
profile = merge_profile_dicts(primary, fallback)
|
||||
if not any(
|
||||
profile.get(k) is not None
|
||||
for k in (
|
||||
"nickname",
|
||||
"follower_count",
|
||||
"following_count",
|
||||
"total_favorited",
|
||||
"unique_id",
|
||||
"avatar_url",
|
||||
)
|
||||
):
|
||||
raise ValueError("no profile fields parsed (blocked or layout changed)")
|
||||
return profile
|
||||
|
||||
|
||||
def merge_profile(row: dict, profile: dict, *, streamer_id: str) -> None:
|
||||
for key in (
|
||||
"nickname",
|
||||
"unique_id",
|
||||
"signature",
|
||||
"following_count",
|
||||
"follower_count",
|
||||
"total_favorited",
|
||||
):
|
||||
val = profile.get(key)
|
||||
if val is None or val == "":
|
||||
continue
|
||||
row[key] = val
|
||||
avatar_url = profile.get("avatar_url")
|
||||
if isinstance(avatar_url, str) and avatar_url:
|
||||
dest = AVATAR_DIR / f"{streamer_id}.jpg"
|
||||
if download_avatar(avatar_url, dest):
|
||||
row["avatar"] = f"streamer_avatars/{streamer_id}.jpg"
|
||||
print(f" avatar saved {dest.relative_to(ROOT)}", flush=True)
|
||||
row["profile_fetched_at"] = _now_iso()
|
||||
|
||||
|
||||
def enrich_streamers(
|
||||
payload: dict, *, ids: set[str] | None = None
|
||||
) -> tuple[int, int, int]:
|
||||
rows = payload.get("streamers")
|
||||
if not isinstance(rows, list):
|
||||
raise SystemExit("streamers.json: missing streamers array")
|
||||
opener = _opener()
|
||||
ok = skip = fail = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
sid = str(row.get("id") or "").strip()
|
||||
if not sid:
|
||||
continue
|
||||
if ids is not None and sid not in ids:
|
||||
continue
|
||||
platform = str(row.get("platform") or "").strip().lower()
|
||||
if platform != "douyin":
|
||||
print(f"skip {sid}: platform={platform!r} (only douyin supported)", flush=True)
|
||||
skip += 1
|
||||
continue
|
||||
profile_url = str(row.get("profile_url") or "").strip()
|
||||
if not profile_url:
|
||||
print(f"skip {sid}: missing profile_url", flush=True)
|
||||
skip += 1
|
||||
continue
|
||||
print(f"fetching {sid} ...", flush=True)
|
||||
try:
|
||||
profile = fetch_douyin_profile(opener, profile_url)
|
||||
merge_profile(row, profile, streamer_id=sid)
|
||||
nick = row.get("nickname") or "?"
|
||||
print(
|
||||
f" ok {nick} followers={row.get('follower_count')} "
|
||||
f"likes={row.get('total_favorited')}",
|
||||
flush=True,
|
||||
)
|
||||
ok += 1
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||
print(f" FAIL {sid}: {e} (keeping previous values)", flush=True)
|
||||
fail += 1
|
||||
time.sleep(0.8)
|
||||
if ok > 0:
|
||||
payload["fetched_at"] = _now_iso()
|
||||
payload["source"] = payload.get("source") or "manual+douyin"
|
||||
meta = payload.get("platform_meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta.setdefault(
|
||||
"douyin",
|
||||
{"label_zh": "抖音", "icon": "ui-icon/platform_douyin.png"},
|
||||
)
|
||||
payload["platform_meta"] = meta
|
||||
_ = PROFILE_KEYS
|
||||
return ok, skip, fail
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Enrich streamers.json from Douyin profiles")
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument(
|
||||
"--ids",
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="only refresh these streamer ids",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
payload = _load(args.out)
|
||||
id_set = set(args.ids) if args.ids else None
|
||||
ok, skip, fail = enrich_streamers(payload, ids=id_set)
|
||||
_save(args.out, payload)
|
||||
print(f"wrote {args.out} ok={ok} skip={skip} fail={fail}", flush=True)
|
||||
# Soft-fail for CI/refresh_web: always exit 0 after writing (keep old values).
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Chinese draft tags (定位) for relation preview filter/display.
|
||||
"""Chinese draft tags (定位) for Climperor web filter/display.
|
||||
|
||||
Mapped from OpenDota English roles + a curated illusion set.
|
||||
Not used by recommend scoring.
|
||||
@@ -6,7 +6,7 @@ Not used by recommend scoring.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Display / filter order on the preview page.
|
||||
# Display / filter order on the web heroes page.
|
||||
TAG_ORDER = [
|
||||
"核心",
|
||||
"辅助",
|
||||
|
||||
@@ -14,20 +14,70 @@ from pathlib import Path
|
||||
|
||||
UA = "climperor"
|
||||
DEFAULT_TIMEOUT = 60
|
||||
# OpenDota and similar APIs occasionally 429; back off before failing CI/refresh.
|
||||
DEFAULT_RETRIES = 4
|
||||
DEFAULT_RETRY_BACKOFF = 5.0
|
||||
|
||||
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
|
||||
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language={lang}"
|
||||
|
||||
|
||||
def http_json(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> dict | list:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
def _http_open(url: str, *, timeout: int, retries: int, backoff: float):
|
||||
"""urlopen with retries on 429 / 5xx / transient network errors."""
|
||||
last_err: BaseException | None = None
|
||||
for attempt in range(retries + 1):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
try:
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code not in (429, 500, 502, 503, 504) or attempt >= retries:
|
||||
raise
|
||||
sleep_s = backoff * (2**attempt)
|
||||
retry_after = e.headers.get("Retry-After") if e.headers else None
|
||||
if retry_after:
|
||||
try:
|
||||
sleep_s = max(sleep_s, float(retry_after))
|
||||
except ValueError:
|
||||
pass
|
||||
print(
|
||||
f"HTTP {e.code} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s",
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(sleep_s)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
last_err = e
|
||||
if attempt >= retries:
|
||||
raise
|
||||
sleep_s = backoff * (2**attempt)
|
||||
print(
|
||||
f"HTTP error {e} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s",
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(sleep_s)
|
||||
assert last_err is not None
|
||||
raise last_err
|
||||
|
||||
|
||||
def http_json(
|
||||
url: str,
|
||||
*,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = DEFAULT_RETRIES,
|
||||
backoff: float = DEFAULT_RETRY_BACKOFF,
|
||||
) -> dict | list:
|
||||
with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def http_bytes(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
def http_bytes(
|
||||
url: str,
|
||||
*,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = DEFAULT_RETRIES,
|
||||
backoff: float = DEFAULT_RETRY_BACKOFF,
|
||||
) -> bytes:
|
||||
with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from http_utils import http_json
|
||||
ITEMS_META = DATA / "items_meta.json"
|
||||
HERO_ABILITIES = DATA / "hero_abilities.json"
|
||||
HERO_FEAR_OVERRIDES = DATA / "hero_fear_overrides.json"
|
||||
ITEM_COUNTER_STATS = DATA / "item_counter_stats.json"
|
||||
OUT = DATA / "hero_item_fears.json"
|
||||
ABILITIES_URL = (
|
||||
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
|
||||
@@ -243,12 +244,51 @@ def load_hero_overrides() -> dict[str, dict]:
|
||||
return {str(k): v for k, v in heroes.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def load_counter_stats() -> tuple[dict, dict[str, dict[str, dict]]]:
|
||||
"""Load optional adjusted OpenDota evidence indexed by hero and item."""
|
||||
if not ITEM_COUNTER_STATS.is_file():
|
||||
return {}, {}
|
||||
try:
|
||||
raw = load_json(ITEM_COUNTER_STATS)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}, {}
|
||||
indexed: dict[str, dict[str, dict]] = {}
|
||||
for hero_key, rows in (raw.get("by_hero") or {}).items():
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
indexed[str(hero_key)] = {
|
||||
str(row["item"]): row
|
||||
for row in rows
|
||||
if isinstance(row, dict) and row.get("item")
|
||||
}
|
||||
return dict(raw.get("meta") or {}), indexed
|
||||
|
||||
|
||||
def evidence_adjustment(evidence: dict | None) -> int:
|
||||
"""Return a deliberately small corroboration bonus/penalty."""
|
||||
if not evidence:
|
||||
return 0
|
||||
games = int(evidence.get("games") or 0)
|
||||
if games < 100:
|
||||
return 0
|
||||
buy_lift_pp = float(evidence.get("buy_lift") or 0) * 100
|
||||
win_delta_pp = float(evidence.get("win_delta") or 0) * 100
|
||||
# Mixed signs are inconclusive. Requiring agreement avoids promoting generic
|
||||
# expensive winner items based on conditional win rate alone.
|
||||
if buy_lift_pp * win_delta_pp <= 0:
|
||||
return 0
|
||||
raw = 0.6 * buy_lift_pp + 0.4 * win_delta_pp
|
||||
reliability = min(1.0, games / 500)
|
||||
return round(max(-6.0, min(6.0, raw)) * reliability)
|
||||
|
||||
|
||||
def fears_for_hero(
|
||||
hero_key: str,
|
||||
summary: dict,
|
||||
by_tag: dict[str, list[dict]],
|
||||
items_by_key: dict[str, dict],
|
||||
overrides: dict[str, dict],
|
||||
counter_stats: dict[str, dict[str, dict]],
|
||||
top_n: int,
|
||||
) -> list[dict]:
|
||||
need = needed_tags(summary)
|
||||
@@ -304,12 +344,21 @@ def fears_for_hero(
|
||||
|
||||
ranked = []
|
||||
for row in acc.values():
|
||||
evidence = (counter_stats.get(hero_key) or {}).get(row["item"]["key"])
|
||||
sc, entry = score_item(
|
||||
row["item"],
|
||||
row["matched"],
|
||||
row["reasons"],
|
||||
bonus=int(row.get("bonus") or 0),
|
||||
bonus=int(row.get("bonus") or 0) + evidence_adjustment(evidence),
|
||||
)
|
||||
if evidence:
|
||||
entry["stats"] = {
|
||||
"games": int(evidence.get("games") or 0),
|
||||
"purchase_rate": float(evidence.get("buy_rate") or 0),
|
||||
"win_rate": float(evidence.get("win_rate") or 0),
|
||||
"purchase_lift": float(evidence.get("buy_lift") or 0),
|
||||
"win_delta": float(evidence.get("win_delta") or 0),
|
||||
}
|
||||
ranked.append((sc, entry))
|
||||
ranked.sort(key=lambda t: (-t[0], t[1]["item"]))
|
||||
|
||||
@@ -333,6 +382,7 @@ def main() -> None:
|
||||
|
||||
items_meta = load_json(ITEMS_META)
|
||||
abilities_db = load_json(HERO_ABILITIES)
|
||||
counter_meta, counter_stats = load_counter_stats()
|
||||
items = items_meta.get("items") or {}
|
||||
items_by_key = index_items_by_key(items)
|
||||
overrides = load_hero_overrides()
|
||||
@@ -350,7 +400,7 @@ def main() -> None:
|
||||
tags = list(h.get("tags") or [])
|
||||
summary = hero_summary(key, abilities_db, tags, unit_target_keys)
|
||||
by_hero[key] = fears_for_hero(
|
||||
key, summary, by_tag, items_by_key, overrides, args.top
|
||||
key, summary, by_tag, items_by_key, overrides, counter_stats, args.top
|
||||
)
|
||||
|
||||
items_out = {
|
||||
@@ -369,6 +419,17 @@ def main() -> None:
|
||||
"top_n": args.top,
|
||||
"heroes": len(by_hero),
|
||||
"overrides": str(HERO_FEAR_OVERRIDES.relative_to(DATA.parent)).replace("\\", "/"),
|
||||
"counter_stats": (
|
||||
{
|
||||
"path": str(ITEM_COUNTER_STATS.relative_to(DATA.parent)).replace("\\", "/"),
|
||||
"source": counter_meta.get("source"),
|
||||
"fetched_at": counter_meta.get("fetched_at"),
|
||||
"method": counter_meta.get("method"),
|
||||
"caveat": counter_meta.get("caveat"),
|
||||
}
|
||||
if counter_meta
|
||||
else None
|
||||
),
|
||||
},
|
||||
"by_hero": by_hero,
|
||||
"items": items_out,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Shared "applies effect" mechanic tags for items and abilities (Web query page).
|
||||
|
||||
These tags mean the skill/item *applies* the effect (e.g. applies silence),
|
||||
NOT that the effect itself is dispellable (see ability ``dispellable`` field).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Sidebar / query page order (驱散 first, then control, then other).
|
||||
QUERY_MECHANIC_ORDER: list[str] = [
|
||||
"basic_dispel",
|
||||
"strong_dispel",
|
||||
"root",
|
||||
"disarm",
|
||||
"silence",
|
||||
"mute",
|
||||
"stun",
|
||||
"hex",
|
||||
"break",
|
||||
"sleep",
|
||||
"fear",
|
||||
"taunt",
|
||||
"blind",
|
||||
"leash",
|
||||
"invis",
|
||||
"ethereal",
|
||||
"cyclone",
|
||||
]
|
||||
|
||||
QUERY_MECHANIC_LABELS: dict[str, str] = {
|
||||
"basic_dispel": "弱驱散",
|
||||
"strong_dispel": "强驱散",
|
||||
"root": "缠绕",
|
||||
"disarm": "缴械",
|
||||
"silence": "沉默",
|
||||
"mute": "锁闭",
|
||||
"stun": "眩晕",
|
||||
"hex": "妖术",
|
||||
"break": "破坏",
|
||||
"sleep": "睡眠",
|
||||
"fear": "恐惧",
|
||||
"taunt": "嘲讽",
|
||||
"blind": "致盲",
|
||||
"leash": "束缚",
|
||||
"invis": "隐身",
|
||||
"ethereal": "虚无",
|
||||
"cyclone": "吹风",
|
||||
}
|
||||
|
||||
# Short gameplay blurbs for the mechanics query page (ZH).
|
||||
QUERY_MECHANIC_BLURBS: dict[str, str] = {
|
||||
"basic_dispel": "基础驱散(弱驱散)。可移除多数可用弱驱散清除的增益/减益;无法驱散标注为「仅强驱散」的效果。",
|
||||
"strong_dispel": "强驱散。可移除弱驱散清不掉、但可被强驱散清除的效果(如部分强控、特殊增益)。",
|
||||
"root": "缠绕 / 定身。目标无法移动,通常仍可攻击与施法;多数情况下也无法使用位移技能。",
|
||||
"disarm": "缴械。目标无法进行普通攻击,仍可移动与施法。",
|
||||
"silence": "沉默。目标无法施放技能,仍可移动与普通攻击;物品主动一般不受影响。",
|
||||
"mute": "锁闭。目标无法使用物品主动技能;通常仍可移动、攻击与施放英雄技能。",
|
||||
"stun": "眩晕。目标完全无法行动(不能移动、攻击或施法),是最直接的硬控之一。",
|
||||
"hex": "妖术(变羊等)。强制变形并大幅限制行动,通常伴随沉默、锁闭与缴械等组合限制。",
|
||||
"break": "破坏。使目标的被动技能暂时失效(不移除身上已有 buff,但被动效果不再生效)。",
|
||||
"sleep": "睡眠。目标陷入沉睡,无法行动;受到伤害时通常会醒来(具体以技能为准)。",
|
||||
"fear": "恐惧。目标被迫失控移动(通常远离来源),期间难以自主操作。",
|
||||
"taunt": "嘲讽。强制目标攻击施法者(或指定单位),暂时无法按自己的意愿选目标。",
|
||||
"blind": "致盲。普通攻击有几率(或必定)落空,不直接禁止攻击指令本身。",
|
||||
"leash": "束缚。限制目标使用位移/传送类技能(如闪烁、强制位移道具),与缠绕的「不能走路」不同。",
|
||||
"invis": "隐身。对无真实视域的敌人不可见;攻击或部分行为常会显形(以技能/物品为准)。",
|
||||
"ethereal": "虚无(灵体)。通常大幅降低魔法抗性、无法普通攻击,且自身常变为对物理攻击免疫或特殊交互。",
|
||||
"cyclone": "吹风 / 驱逐。目标被卷入气旋或移出战场一段时间,期间通常无法行动,落地后恢复。",
|
||||
}
|
||||
|
||||
QUERY_MECHANIC_GROUPS: list[tuple[str, list[str]]] = [
|
||||
("驱散", ["basic_dispel", "strong_dispel"]),
|
||||
(
|
||||
"控制",
|
||||
[
|
||||
"root",
|
||||
"disarm",
|
||||
"silence",
|
||||
"mute",
|
||||
"stun",
|
||||
"hex",
|
||||
"break",
|
||||
"sleep",
|
||||
"fear",
|
||||
"taunt",
|
||||
"blind",
|
||||
"leash",
|
||||
],
|
||||
),
|
||||
("其他", ["invis", "ethereal", "cyclone"]),
|
||||
]
|
||||
|
||||
DISPEL_TYPE_RE = re.compile(
|
||||
r"(?:驱散类型|Dispel\s*Type)\s*[::]\s*([^<\n]+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# Phrases that describe "this buff/debuff can be dispelled" — not "applies dispel".
|
||||
DISPELLABLE_NOISE_RE = re.compile(
|
||||
r"可被.{0,8}驱散|可以被.{0,8}驱散|无法被驱散|不可驱散|"
|
||||
r"dispellable|undispellable|cannot be dispelled|can be dispelled|"
|
||||
r"strong dispels? only|仅强驱散|只有强驱散",
|
||||
re.I,
|
||||
)
|
||||
|
||||
APPLIES_BASIC_DISPEL_RE = re.compile(
|
||||
r"(?:施加|应用|造成|提供|进行|立即).{0,12}(?:基础)?驱散|"
|
||||
r"applies?\s+(?:a\s+)?(?:basic\s+)?dispel|"
|
||||
r"\bpurge\b|阻止敌方|Inhibit",
|
||||
re.I,
|
||||
)
|
||||
APPLIES_STRONG_DISPEL_RE = re.compile(
|
||||
r"(?:施加|应用|造成|提供|进行|立即).{0,12}强驱散|"
|
||||
r"applies?\s+(?:a\s+)?strong\s+dispel",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# Phrases that mention an effect without applying it to enemies.
|
||||
SILENCE_INTERACTION_RE = re.compile(
|
||||
r"沉默术士|"
|
||||
r"目标被沉默时|被沉默的目标|被沉默单位|被沉默时|"
|
||||
r"while silenced|when silenced|silenced target|silenced unit",
|
||||
re.I,
|
||||
)
|
||||
STUN_INTERACTION_RE = re.compile(
|
||||
r"只要眩晕敌人|自身被眩晕|多个眩晕的效果|when you stun an enemy",
|
||||
re.I,
|
||||
)
|
||||
DISARM_SELF_RE = re.compile(
|
||||
r"他被缴械|自身被缴械|被缴械,但可以",
|
||||
re.I,
|
||||
)
|
||||
ALLY_SELF_DEBUFF_RE = re.compile(
|
||||
r"被锁闭、沉默和缴械|无法成为目标,被锁闭",
|
||||
re.I,
|
||||
)
|
||||
ABILITY_CATALOG_RE = re.compile(
|
||||
r"拥有[^。]{0,120}技能",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _strip_interaction_noise(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
t = text
|
||||
t = SILENCE_INTERACTION_RE.sub(" ", t)
|
||||
t = STUN_INTERACTION_RE.sub(" ", t)
|
||||
t = DISARM_SELF_RE.sub(" ", t)
|
||||
t = ALLY_SELF_DEBUFF_RE.sub(" ", t)
|
||||
return t
|
||||
|
||||
|
||||
def _strip_dispellable_noise(text: str) -> str:
|
||||
"""Remove 'is dispellable' clauses and dispel-type metadata lines."""
|
||||
if not text:
|
||||
return ""
|
||||
t = DISPEL_TYPE_RE.sub(" ", text)
|
||||
return DISPELLABLE_NOISE_RE.sub(" ", t)
|
||||
|
||||
|
||||
def extract_dispel_tags(text: str) -> set[str]:
|
||||
"""Tags for abilities/items that *apply* basic or strong dispel."""
|
||||
tags: set[str] = set()
|
||||
if not text:
|
||||
return tags
|
||||
cleaned = _strip_dispellable_noise(text)
|
||||
if APPLIES_STRONG_DISPEL_RE.search(cleaned):
|
||||
tags.add("strong_dispel")
|
||||
if APPLIES_BASIC_DISPEL_RE.search(cleaned):
|
||||
# Strong takes precedence when both fire on the same "强驱散" phrase.
|
||||
if "strong_dispel" not in tags and "强驱散" not in cleaned and "strong dispel" not in cleaned.lower():
|
||||
tags.add("basic_dispel")
|
||||
elif "strong_dispel" not in tags:
|
||||
# "施加驱散" without 强 → basic; with 强 already handled above.
|
||||
if not re.search(r"强驱散|strong\s+dispel", cleaned, re.I):
|
||||
tags.add("basic_dispel")
|
||||
return tags
|
||||
|
||||
|
||||
def apply_mechanic_tags(blob: str, *, key: str = "") -> set[str]:
|
||||
"""Extract QUERY mechanic tags from Chinese/English description text."""
|
||||
del key # reserved for key-specific whitelist callers
|
||||
tags: set[str] = set()
|
||||
if not blob:
|
||||
return tags
|
||||
cleaned = _strip_interaction_noise(blob)
|
||||
tags |= extract_dispel_tags(cleaned)
|
||||
|
||||
root_blob = ABILITY_CATALOG_RE.sub(" ", cleaned)
|
||||
|
||||
if re.search(r"\broot(?:s|ed)?\b|缠绕|定身", root_blob, re.I):
|
||||
tags.add("root")
|
||||
if re.search(r"\bdisarm(?:s|ed)?\b|缴械", cleaned, re.I):
|
||||
tags.add("disarm")
|
||||
if re.search(r"\bsilence[sd]?\b|沉默", cleaned, re.I):
|
||||
tags.add("silence")
|
||||
if re.search(r"\bmute[sd]?\b|锁闭", cleaned, re.I):
|
||||
tags.add("mute")
|
||||
if re.search(r"\bstun(?:s|ned)?\b|眩晕", cleaned, re.I):
|
||||
tags.add("stun")
|
||||
if re.search(r"\bhex(?:es|ed)?\b|变羊|妖术", cleaned, re.I):
|
||||
tags.add("hex")
|
||||
if re.search(
|
||||
r"(?:施加|造成|应用).{0,6}破坏|"
|
||||
r"\bbreaks?\s+passives?\b|"
|
||||
r"(?<![A-Za-z_])Break(?![A-Za-z_])|"
|
||||
r"破坏会禁用|禁用被动",
|
||||
cleaned,
|
||||
):
|
||||
tags.add("break")
|
||||
if re.search(r"\bsleep(?:s|ing|t)?\b|睡眠|催眠|梦魇", cleaned, re.I):
|
||||
tags.add("sleep")
|
||||
if re.search(r"\bfear(?:s|ed|ing)?\b|恐惧|恐慌", cleaned, re.I):
|
||||
tags.add("fear")
|
||||
if re.search(r"\btaunt(?:s|ed|ing)?\b|嘲讽", cleaned, re.I):
|
||||
tags.add("taunt")
|
||||
if re.search(r"\bblind(?:s|ed|ing)?\b|致盲|失明", cleaned, re.I):
|
||||
tags.add("blind")
|
||||
if re.search(r"\bleash(?:es|ed|ing)?\b|束缚|拴住|无法使用移动类", cleaned, re.I):
|
||||
tags.add("leash")
|
||||
if re.search(
|
||||
r"\bethereal\b|虚无|灵体化|变为虚无|幽魂形态|幽灵形态",
|
||||
cleaned,
|
||||
re.I,
|
||||
):
|
||||
tags.add("ethereal")
|
||||
if re.search(
|
||||
r"\bcyclone[sd]?\b|\bbanish(?:es|ed|ing)?\b|吹起|吹风|卷入气流|"
|
||||
r"龙卷风|气旋|驱逐出",
|
||||
cleaned,
|
||||
re.I,
|
||||
):
|
||||
tags.add("cyclone")
|
||||
# Applies invisibility (not detect/break invis).
|
||||
invis_blob = re.sub(
|
||||
r"破隐|反隐|真实视域|看见隐身|对隐身|true\s*sight|"
|
||||
r"break(?:s|ing)?\s+invis|detect(?:s|ing)?\s+invis|"
|
||||
r"reveal(?:s|ed)?\s+invis",
|
||||
" ",
|
||||
cleaned,
|
||||
flags=re.I,
|
||||
)
|
||||
if re.search(
|
||||
r"隐身|invisible|invisibility|渐隐|shadow\s*walk|fade\s*time",
|
||||
invis_blob,
|
||||
re.I,
|
||||
):
|
||||
tags.add("invis")
|
||||
return tags
|
||||
|
||||
|
||||
def merge_tag_overrides(
|
||||
auto: list[str] | set[str],
|
||||
override_row: dict | None,
|
||||
order: list[str],
|
||||
) -> list[str]:
|
||||
"""Merge add/remove override onto auto tags; keep ``order`` then extras."""
|
||||
s = set(auto)
|
||||
row = override_row or {}
|
||||
for t in row.get("add") or []:
|
||||
if isinstance(t, str):
|
||||
s.add(t)
|
||||
for t in row.get("remove") or []:
|
||||
if isinstance(t, str):
|
||||
s.discard(t)
|
||||
ordered = [t for t in order if t in s]
|
||||
extras = sorted(s - set(order))
|
||||
return ordered + extras
|
||||
|
||||
|
||||
def mechanic_query_payload() -> dict:
|
||||
"""Slim dict for serve_relations / static data.json."""
|
||||
return {
|
||||
"order": list(QUERY_MECHANIC_ORDER),
|
||||
"labels": dict(QUERY_MECHANIC_LABELS),
|
||||
"blurbs": dict(QUERY_MECHANIC_BLURBS),
|
||||
"groups": [
|
||||
{"label": label, "keys": list(keys)}
|
||||
for label, keys in QUERY_MECHANIC_GROUPS
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Transparent click-through overlay: role tags + Top-3 recommend badges.
|
||||
"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar.
|
||||
|
||||
Runs a Tk root on a background thread. DraftSession calls set_roster() and
|
||||
set_grid_marks(); geometry uses the same relative coords as recognition.
|
||||
Runs a Tk root on a background thread. DraftSession calls set_roster(),
|
||||
set_grid_marks(), and set_analysis(); geometry uses relative coords.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,7 +10,6 @@ import json
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
|
||||
import mss
|
||||
|
||||
@@ -30,8 +29,18 @@ ROLE_ORDER = [
|
||||
]
|
||||
|
||||
CHROMA = "#ff00ff"
|
||||
DEFAULT_REC_COLOR = "#2ec4b6"
|
||||
DEFAULT_REC_TEXT = "#0b1220"
|
||||
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
||||
DEFAULT_SYNERGY_COLOR = "#e9a825"
|
||||
DEFAULT_FILL_COLOR = "#9b7ebd"
|
||||
DEFAULT_MARK_TEXT = "#0b1220"
|
||||
DEFAULT_ANALYSIS_BG = "#1a2332"
|
||||
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
||||
LABEL_ORDER = ("克", "搭", "补")
|
||||
LABEL_COLORS = {
|
||||
"克": "counter",
|
||||
"搭": "synergy",
|
||||
"补": "fill",
|
||||
}
|
||||
|
||||
|
||||
def _load_roles_by_key() -> dict[str, list[str]]:
|
||||
@@ -75,12 +84,21 @@ class DraftOverlay:
|
||||
self.icon_gap_rel = float(o.get("icon_gap_rel", 0.002))
|
||||
self.mark_size_rel = float(o.get("mark_size_rel", 0.018))
|
||||
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
||||
self.rec_color = str(o.get("rec_color", DEFAULT_REC_COLOR))
|
||||
self.rec_text = str(o.get("rec_text_color", DEFAULT_REC_TEXT))
|
||||
self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
|
||||
self.counter_color = str(o.get("counter_color", o.get("rec_color", DEFAULT_COUNTER_COLOR)))
|
||||
self.synergy_color = str(o.get("synergy_color", DEFAULT_SYNERGY_COLOR))
|
||||
self.fill_color = str(o.get("fill_color", DEFAULT_FILL_COLOR))
|
||||
self.mark_text = str(o.get("mark_text_color", o.get("rec_text_color", DEFAULT_MARK_TEXT)))
|
||||
self.analysis_y_rel = float(o.get("analysis_y_rel", 0.12))
|
||||
self.analysis_h_rel = float(o.get("analysis_h_rel", 0.028))
|
||||
self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014))
|
||||
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
||||
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
||||
self.roles_by_key = _load_roles_by_key()
|
||||
self._roster: dict[int, str] = {}
|
||||
self._cells: dict[str, dict] = {}
|
||||
self._marks: dict[str, int] = {}
|
||||
self._marks: dict[str, list[str]] = {}
|
||||
self._analysis = ""
|
||||
self._ready = threading.Event()
|
||||
self._closed = False
|
||||
self._root: tk.Tk | None = None
|
||||
@@ -91,6 +109,14 @@ class DraftOverlay:
|
||||
self._thread.start()
|
||||
self._ready.wait(timeout=5.0)
|
||||
|
||||
def _label_fill(self, label: str) -> str:
|
||||
kind = LABEL_COLORS.get(label)
|
||||
if kind == "synergy":
|
||||
return self.synergy_color
|
||||
if kind == "fill":
|
||||
return self.fill_color
|
||||
return self.counter_color
|
||||
|
||||
def _run(self) -> None:
|
||||
sw, sh = _primary_monitor_size()
|
||||
root = tk.Tk()
|
||||
@@ -160,23 +186,47 @@ class DraftOverlay:
|
||||
self._roster = dict(roster)
|
||||
self._schedule_redraw()
|
||||
|
||||
def set_analysis(self, text: str | None) -> None:
|
||||
"""Short lineup analysis banner (empty clears)."""
|
||||
value = (text or "").strip()
|
||||
if value == self._analysis:
|
||||
return
|
||||
self._analysis = value
|
||||
self._schedule_redraw()
|
||||
|
||||
def set_grid_marks(
|
||||
self,
|
||||
cells: dict[str, dict] | None,
|
||||
marks: dict[str, int] | list[dict] | None,
|
||||
marks: dict[str, list[str] | int] | list[dict] | None,
|
||||
) -> None:
|
||||
"""Cyan Top-3 badges on hero-grid cells. marks: key->rank or suggest_top list."""
|
||||
"""克/搭/补 badges on hero-grid cells.
|
||||
|
||||
marks: key->labels list, key->legacy rank int, or suggest_marks list.
|
||||
"""
|
||||
cells = {str(k): dict(v) for k, v in (cells or {}).items()}
|
||||
parsed: dict[str, int] = {}
|
||||
parsed: dict[str, list[str]] = {}
|
||||
if isinstance(marks, list):
|
||||
for item in marks:
|
||||
key = item.get("key")
|
||||
rank = item.get("rank")
|
||||
if key and rank:
|
||||
parsed[str(key)] = int(rank)
|
||||
if not key:
|
||||
continue
|
||||
labels = item.get("labels")
|
||||
if labels:
|
||||
parsed[str(key)] = [str(x) for x in labels if x in LABEL_ORDER]
|
||||
elif item.get("rank"):
|
||||
parsed[str(key)] = ["克"]
|
||||
elif marks:
|
||||
for key, rank in marks.items():
|
||||
parsed[str(key)] = int(rank)
|
||||
for key, val in marks.items():
|
||||
if isinstance(val, (list, tuple)):
|
||||
labs = [str(x) for x in val if x in LABEL_ORDER]
|
||||
elif isinstance(val, int) and val > 0:
|
||||
labs = ["克"]
|
||||
elif isinstance(val, str) and val in LABEL_ORDER:
|
||||
labs = [val]
|
||||
else:
|
||||
labs = []
|
||||
if labs:
|
||||
parsed[str(key)] = labs
|
||||
if cells == self._cells and parsed == self._marks:
|
||||
return
|
||||
self._cells = cells
|
||||
@@ -210,6 +260,7 @@ class DraftOverlay:
|
||||
if self._canvas is not None:
|
||||
self._canvas.delete("all")
|
||||
self._photos.clear()
|
||||
self._analysis = ""
|
||||
root.withdraw()
|
||||
|
||||
try:
|
||||
@@ -252,6 +303,26 @@ class DraftOverlay:
|
||||
gap = max(0, int(round(self.icon_gap_rel * sh)))
|
||||
y_gap = int(round(self.y_gap_rel * sh))
|
||||
|
||||
if self._analysis:
|
||||
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
||||
bar_y = int(round(self.analysis_y_rel * sh))
|
||||
font_size = max(10, int(round(self.analysis_font_rel * sh)))
|
||||
pad_x = max(12, int(round(0.01 * sw)))
|
||||
# Estimate text width roughly; keep banner centered and readable.
|
||||
approx_w = min(sw - 2 * pad_x, max(200, int(len(self._analysis) * font_size * 0.95) + 2 * pad_x))
|
||||
x0 = (sw - approx_w) // 2
|
||||
y0 = bar_y
|
||||
x1 = x0 + approx_w
|
||||
y1 = y0 + bar_h
|
||||
canvas.create_rectangle(x0, y0, x1, y1, fill=self.analysis_bg, outline=self.analysis_bg)
|
||||
canvas.create_text(
|
||||
(x0 + x1) / 2,
|
||||
(y0 + y1) / 2,
|
||||
text=self._analysis,
|
||||
fill=self.analysis_fg,
|
||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||
)
|
||||
|
||||
for slot in self.cfg.get("slots") or []:
|
||||
idx = int(slot["index"])
|
||||
hero = self._roster.get(idx)
|
||||
@@ -275,22 +346,30 @@ class DraftOverlay:
|
||||
|
||||
mark = max(12, int(round(self.mark_size_rel * sh)))
|
||||
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
||||
mark_gap = max(1, int(round(self.mark_gap_rel * sh)))
|
||||
font_size = max(8, int(round(mark * 0.55)))
|
||||
for key, rank in self._marks.items():
|
||||
for key, labels in self._marks.items():
|
||||
cell = self._cells.get(key)
|
||||
if not cell:
|
||||
continue
|
||||
ordered = [lab for lab in LABEL_ORDER if lab in labels]
|
||||
if not ordered:
|
||||
continue
|
||||
x0 = int(cell["x0"])
|
||||
y0 = int(cell["y0"])
|
||||
x1 = x0 + pad
|
||||
y1 = y0 + pad
|
||||
x2 = x1 + mark
|
||||
y2 = y1 + mark
|
||||
canvas.create_rectangle(x1, y1, x2, y2, fill=self.rec_color, outline=self.rec_color)
|
||||
canvas.create_text(
|
||||
(x1 + x2) / 2,
|
||||
(y1 + y2) / 2,
|
||||
text=str(rank),
|
||||
fill=self.rec_text,
|
||||
font=("Segoe UI", font_size, "bold"),
|
||||
)
|
||||
for i, lab in enumerate(ordered):
|
||||
bx1 = x1 + i * (mark + mark_gap)
|
||||
by1 = y1
|
||||
bx2 = bx1 + mark
|
||||
by2 = by1 + mark
|
||||
fill = self._label_fill(lab)
|
||||
canvas.create_rectangle(bx1, by1, bx2, by2, fill=fill, outline=fill)
|
||||
canvas.create_text(
|
||||
(bx1 + bx2) / 2,
|
||||
(by1 + by2) / 2,
|
||||
text=lab,
|
||||
fill=self.mark_text,
|
||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
"""Draft suggestions from qualitative hero relations.
|
||||
"""Draft suggestions from qualitative hero relations + lineup archetypes.
|
||||
|
||||
Score candidates for the player's role-queue position using:
|
||||
+1 per enemy the candidate counters
|
||||
-1 per enemy that counters the candidate
|
||||
+1 per already-locked ally synergy
|
||||
Mark available heroes with 克 / 搭 / 补:
|
||||
克 — relation counters, push/global answers, punish enemy gaps
|
||||
搭 — synergy with locked allies
|
||||
补 — fill ally tag gaps
|
||||
|
||||
Data: data/relations.json (not OpenDota winrates).
|
||||
Role-queue filters by position tags; otherwise all heroes are candidates.
|
||||
Also returns a short analysis string and per-mark reasons (no AI).
|
||||
|
||||
Data: data/relations.json + draft_archetypes rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from common import ROOT
|
||||
from draft_archetypes import (
|
||||
answer_for_candidate,
|
||||
collect_reasons,
|
||||
detect_archetypes,
|
||||
detect_gaps,
|
||||
format_analysis,
|
||||
tag_profile,
|
||||
)
|
||||
from grid import hero_table
|
||||
from hero_tags import tags_for_hero
|
||||
from relations import DEFAULT_RELATIONS, indexes, load_relations
|
||||
|
||||
DEFAULT_ROLE_TAGS = {
|
||||
@@ -24,12 +33,31 @@ DEFAULT_ROLE_TAGS = {
|
||||
"5": ["Support"],
|
||||
}
|
||||
|
||||
# Soft boosts when candidate tags address a prominent enemy profile face.
|
||||
# Never creates marks alone.
|
||||
_PROFILE_BOOSTS: dict[str, tuple[str, ...]] = {
|
||||
"爆发": ("耐久", "逃生"),
|
||||
"推进": ("控制", "先手"),
|
||||
"先手": ("逃生", "控制"),
|
||||
"控制": ("逃生", "耐久"),
|
||||
"核心": ("控制", "先手"),
|
||||
"辅助": ("核心", "先手"),
|
||||
}
|
||||
|
||||
def _maps() -> tuple[dict[str, str], dict[str, list[str]]]:
|
||||
_SOFT_BOOST = 0.25
|
||||
_ARCH_BOOST = 0.5
|
||||
_GAP_BOOST = 0.35
|
||||
|
||||
|
||||
def _maps() -> tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]]:
|
||||
table = hero_table()
|
||||
names = {h["key"]: h["name_loc"] for h in table}
|
||||
roles = {h["key"]: list(h.get("roles") or []) for h in table}
|
||||
return names, roles
|
||||
tags = {
|
||||
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
|
||||
for h in table
|
||||
}
|
||||
return names, roles, tags
|
||||
|
||||
|
||||
def enemy_keys(confirmed: dict[int, str], self_team: str | None) -> list[str]:
|
||||
@@ -65,9 +93,10 @@ def candidates_for_position(
|
||||
roles_by_key: dict[str, list[str]],
|
||||
role_tags: dict | None = None,
|
||||
) -> list[str]:
|
||||
tags_map = role_tags or DEFAULT_ROLE_TAGS
|
||||
"""Role-queue filter. position=None means all heroes (non-role queue)."""
|
||||
if position is None:
|
||||
return []
|
||||
return list(roles_by_key.keys())
|
||||
tags_map = role_tags or DEFAULT_ROLE_TAGS
|
||||
wanted = set(tags_map.get(str(position)) or tags_map.get(position) or [])
|
||||
if not wanted:
|
||||
return []
|
||||
@@ -78,40 +107,101 @@ def candidates_for_position(
|
||||
return out
|
||||
|
||||
|
||||
def suggest_top(
|
||||
def enemy_profile(enemies: list[str], tags_by_key: dict[str, list[str]] | None = None) -> dict[str, int]:
|
||||
"""Count Chinese draft tags across locked enemies."""
|
||||
if tags_by_key is None:
|
||||
_, _, tags_by_key = _maps()
|
||||
return tag_profile(enemies, tags_by_key)
|
||||
|
||||
|
||||
def _profile_soft_boost(cand_tags: list[str], profile: dict[str, int]) -> float:
|
||||
if not profile or not cand_tags:
|
||||
return 0.0
|
||||
cand = set(cand_tags)
|
||||
boost = 0.0
|
||||
for face, n in sorted(profile.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||
if n <= 0:
|
||||
continue
|
||||
wanted = _PROFILE_BOOSTS.get(face)
|
||||
if not wanted:
|
||||
continue
|
||||
if cand.intersection(wanted):
|
||||
boost += _SOFT_BOOST * n
|
||||
return boost
|
||||
|
||||
|
||||
def _empty_result() -> dict:
|
||||
return {
|
||||
"enemy_profile": {},
|
||||
"ally_profile": {},
|
||||
"enemy_archetypes": [],
|
||||
"enemy_gaps": [],
|
||||
"ally_gaps": [],
|
||||
"analysis": "",
|
||||
"marks": [],
|
||||
}
|
||||
|
||||
|
||||
def suggest_marks(
|
||||
*,
|
||||
position: int | None,
|
||||
enemies: list[str],
|
||||
allies: list[str] | None = None,
|
||||
exclude: set[str] | list[str],
|
||||
relations: dict | None = None,
|
||||
top_n: int = 3,
|
||||
top_n: int | None = 0,
|
||||
role_tags: dict | None = None,
|
||||
min_enemies: int = 1,
|
||||
min_heroes_for_gaps: int = 2,
|
||||
archetypes_enabled: bool = True,
|
||||
**_ignored,
|
||||
) -> list[dict]:
|
||||
"""Return Top-N picks from qualitative counters / synergies.
|
||||
) -> dict:
|
||||
"""Return 克/搭/补 marks plus lineup analysis.
|
||||
|
||||
Extra kwargs (matchups, synergies, min_games) accepted and ignored for
|
||||
backward compatibility with older call sites.
|
||||
Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means
|
||||
no truncation. ``position`` None = non-role queue (all heroes).
|
||||
"""
|
||||
if position is None:
|
||||
return []
|
||||
allies = list(allies or [])
|
||||
enemies = list(enemies or [])
|
||||
if not enemies and not allies:
|
||||
return []
|
||||
rel = relations if relations is not None else load_relations()
|
||||
if not rel.get("counters") and not rel.get("synergies"):
|
||||
return []
|
||||
if len(enemies) < max(1, int(min_enemies)):
|
||||
return _empty_result()
|
||||
|
||||
names, roles_by_key = _maps()
|
||||
counters_of, countered_by, synergies_of = indexes(rel)
|
||||
rel = relations if relations is not None else load_relations()
|
||||
has_rel = bool(rel.get("counters") or rel.get("synergies"))
|
||||
if not has_rel and not archetypes_enabled:
|
||||
return _empty_result()
|
||||
|
||||
names, roles_by_key, tags_by_key = _maps()
|
||||
counters_of, countered_by, synergies_of = indexes(rel) if has_rel else ({}, {}, {})
|
||||
exclude_set = {e for e in exclude if e}
|
||||
|
||||
scored: list[tuple[int, str, dict]] = []
|
||||
e_profile = tag_profile(enemies, tags_by_key)
|
||||
a_profile = tag_profile(allies, tags_by_key)
|
||||
|
||||
archetypes: list[str] = []
|
||||
enemy_gaps: list[str] = []
|
||||
ally_gaps: list[str] = []
|
||||
if archetypes_enabled:
|
||||
archetypes = detect_archetypes(enemies, tags_by_key)
|
||||
enemy_gaps = detect_gaps(
|
||||
e_profile, hero_count=len(enemies), min_heroes=min_heroes_for_gaps
|
||||
)
|
||||
ally_gaps = detect_gaps(
|
||||
a_profile, hero_count=len(allies), min_heroes=min_heroes_for_gaps
|
||||
)
|
||||
|
||||
analysis = format_analysis(
|
||||
archetypes=archetypes,
|
||||
enemy_gaps=enemy_gaps,
|
||||
ally_gaps=ally_gaps,
|
||||
ally_count=len(allies),
|
||||
)
|
||||
|
||||
scored: list[tuple[float, str, dict]] = []
|
||||
for cand in candidates_for_position(position, roles_by_key=roles_by_key, role_tags=role_tags):
|
||||
if cand in exclude_set:
|
||||
continue
|
||||
cand_tags = tags_by_key.get(cand) or []
|
||||
beats = []
|
||||
beaten_by = []
|
||||
with_allies = []
|
||||
@@ -126,26 +216,99 @@ def suggest_top(
|
||||
for edge in synergies_of.get(cand) or []:
|
||||
if edge["key"] == ak:
|
||||
with_allies.append({"ally": ak, "reason": edge.get("reason") or ""})
|
||||
score = len(beats) - len(beaten_by) + len(with_allies)
|
||||
if score == 0 and not beats and not beaten_by and not with_allies:
|
||||
|
||||
arch_hits, punish_gaps, fill_gaps = answer_for_candidate(
|
||||
cand,
|
||||
cand_tags,
|
||||
archetypes=archetypes,
|
||||
enemy_gaps=enemy_gaps,
|
||||
ally_gaps=ally_gaps,
|
||||
)
|
||||
|
||||
labels: list[str] = []
|
||||
if beats or arch_hits or punish_gaps:
|
||||
labels.append("克")
|
||||
if with_allies:
|
||||
labels.append("搭")
|
||||
if fill_gaps:
|
||||
labels.append("补")
|
||||
if not labels:
|
||||
continue
|
||||
|
||||
reasons = collect_reasons(
|
||||
names=names,
|
||||
beats=beats,
|
||||
with_allies=with_allies,
|
||||
archetype_hits=arch_hits,
|
||||
punish_gaps=punish_gaps,
|
||||
fill_gaps=fill_gaps,
|
||||
)
|
||||
soft = _profile_soft_boost(cand_tags, e_profile)
|
||||
score = (
|
||||
float(len(beats) - len(beaten_by) + len(with_allies))
|
||||
+ soft
|
||||
+ _ARCH_BOOST * len(arch_hits)
|
||||
+ _GAP_BOOST * (len(punish_gaps) + len(fill_gaps))
|
||||
)
|
||||
scored.append((score, cand, {
|
||||
"labels": labels,
|
||||
"beats": beats,
|
||||
"beaten_by": beaten_by,
|
||||
"with": with_allies,
|
||||
"reasons": reasons,
|
||||
"score": round(score, 3),
|
||||
}))
|
||||
|
||||
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
||||
out = []
|
||||
for rank, (score, key, detail) in enumerate(scored[: max(0, top_n)], start=1):
|
||||
out.append({
|
||||
limit = None if top_n is None or int(top_n) <= 0 else int(top_n)
|
||||
sliced = scored if limit is None else scored[:limit]
|
||||
marks = []
|
||||
for rank, (_score, key, detail) in enumerate(sliced, start=1):
|
||||
marks.append({
|
||||
"key": key,
|
||||
"name_loc": names.get(key, key),
|
||||
"rank": rank,
|
||||
"score": score,
|
||||
**detail,
|
||||
})
|
||||
return out
|
||||
return {
|
||||
"enemy_profile": e_profile,
|
||||
"ally_profile": a_profile,
|
||||
"enemy_archetypes": archetypes,
|
||||
"enemy_gaps": enemy_gaps,
|
||||
"ally_gaps": ally_gaps,
|
||||
"analysis": analysis,
|
||||
"marks": marks,
|
||||
}
|
||||
|
||||
|
||||
def suggest_top(
|
||||
*,
|
||||
position: int | None,
|
||||
enemies: list[str],
|
||||
allies: list[str] | None = None,
|
||||
exclude: set[str] | list[str],
|
||||
relations: dict | None = None,
|
||||
top_n: int | None = 0,
|
||||
role_tags: dict | None = None,
|
||||
min_enemies: int = 1,
|
||||
min_heroes_for_gaps: int = 2,
|
||||
archetypes_enabled: bool = True,
|
||||
**_ignored,
|
||||
) -> list[dict]:
|
||||
"""Compatibility wrapper: return mark list from ``suggest_marks``."""
|
||||
return suggest_marks(
|
||||
position=position,
|
||||
enemies=enemies,
|
||||
allies=allies,
|
||||
exclude=exclude,
|
||||
relations=relations,
|
||||
top_n=top_n,
|
||||
role_tags=role_tags,
|
||||
min_enemies=min_enemies,
|
||||
min_heroes_for_gaps=min_heroes_for_gaps,
|
||||
archetypes_enabled=archetypes_enabled,
|
||||
**_ignored,
|
||||
)["marks"]
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -154,6 +317,8 @@ __all__ = [
|
||||
"ally_keys",
|
||||
"candidates_for_position",
|
||||
"enemy_keys",
|
||||
"enemy_profile",
|
||||
"load_relations",
|
||||
"suggest_marks",
|
||||
"suggest_top",
|
||||
]
|
||||
|
||||
@@ -161,6 +161,7 @@ def run_weekly(*, dry_run: bool = False) -> None:
|
||||
run_script("fetch_stratz_meta.py", dry_run=dry_run)
|
||||
run_script("fetch_hero_items.py", dry_run=dry_run)
|
||||
run_script("fetch_items_meta.py", dry_run=dry_run)
|
||||
run_script("fetch_item_counter_stats.py", "--soft-fail", dry_run=dry_run)
|
||||
run_script("item_fears.py", dry_run=dry_run)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Local read-only preview for qualitative hero relations.
|
||||
"""Local dev server for the Climperor web site (web/relations/).
|
||||
|
||||
Usage:
|
||||
python serve_relations.py
|
||||
python serve_relations.py --port 8765
|
||||
|
||||
Select a hero to see 克制 / 被克制 / 搭档 highlights and feared items.
|
||||
Edit data/relations.json directly, then refresh the page.
|
||||
Hero relations, rankings, streamers, mechanics, items, patches — read-only browser UI.
|
||||
Edit data/*.json directly, then refresh the page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,23 +27,34 @@ from common import (
|
||||
HERO_PORTRAITS,
|
||||
ITEM_CAT_ICONS,
|
||||
ITEM_ICONS,
|
||||
RANK_ICONS,
|
||||
ROOT,
|
||||
STREAMER_AVATARS,
|
||||
STREAMER_VIDEOS,
|
||||
TEMPLATES_CDN,
|
||||
UI_ICONS,
|
||||
)
|
||||
from grid import ATTR_ORDER, hero_table
|
||||
from hero_tags import TAG_ORDER, tags_for_hero
|
||||
from http_utils import http_bytes
|
||||
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
|
||||
from relations import DEFAULT_RELATIONS, load_relations
|
||||
|
||||
WEB_DIR = ROOT / "web" / "relations"
|
||||
GRID_ORDER_PATH = ROOT / "data" / "hero_grid_order.json"
|
||||
HERO_ITEMS_PATH = ROOT / "data" / "hero_items.json"
|
||||
HERO_STATS_PATH = ROOT / "data" / "hero_stats.json"
|
||||
HERO_MATCHES_PATH = ROOT / "data" / "hero_matches.json"
|
||||
HERO_ITEM_FEARS_PATH = ROOT / "data" / "hero_item_fears.json"
|
||||
HERO_ABILITIES_PATH = ROOT / "data" / "hero_abilities.json"
|
||||
ITEM_SHOP_PATH = ROOT / "data" / "item_shop.json"
|
||||
ITEMS_META_PATH = ROOT / "data" / "items_meta.json"
|
||||
PATCHES_PATH = ROOT / "data" / "patches.json"
|
||||
LEADERBOARDS_PATH = ROOT / "data" / "leaderboards.json"
|
||||
PRO_MATCHES_PATH = ROOT / "data" / "pro_matches.json"
|
||||
STREAMERS_PATH = ROOT / "data" / "streamers.json"
|
||||
STRATZ_HERO_META_PATH = ROOT / "data" / "stratz_hero_meta.json"
|
||||
STRATZ_MATCHUP_TOPS_PATH = ROOT / "data" / "stratz_matchup_tops.json"
|
||||
ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4}
|
||||
ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"}
|
||||
ABILITY_ICON_URL = (
|
||||
@@ -71,6 +82,63 @@ def load_hero_items() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def load_hero_matches() -> dict:
|
||||
"""Cached recent matches + builds (see fetch_hero_matches.py). Preview only."""
|
||||
empty: dict = {"meta": {}, "items": {}, "by_hero": {}}
|
||||
if not HERO_MATCHES_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(HERO_MATCHES_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
by_hero = raw.get("by_hero")
|
||||
if not isinstance(by_hero, dict):
|
||||
by_hero = {}
|
||||
return {
|
||||
"meta": dict(raw.get("meta") or {}),
|
||||
"items": dict(raw.get("items") or {}),
|
||||
"by_hero": by_hero,
|
||||
}
|
||||
|
||||
|
||||
def load_hero_stats() -> dict:
|
||||
"""Cached OpenDota bracket pick/win (see fetch_hero_stats.py). Preview only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"window_days": 7,
|
||||
"window_note": None,
|
||||
"window_label_zh": "近约 7 天公开对局",
|
||||
"brackets": [],
|
||||
"totals": {},
|
||||
"by_hero": {},
|
||||
}
|
||||
if not HERO_STATS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(HERO_STATS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
try:
|
||||
window_days = int(raw.get("window_days") or 7)
|
||||
except (TypeError, ValueError):
|
||||
window_days = 7
|
||||
window_days = max(1, window_days)
|
||||
label = raw.get("window_label_zh") or f"近约 {window_days} 天公开对局"
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "opendota",
|
||||
"attribution": raw.get("attribution") or "https://www.opendota.com",
|
||||
"window_days": window_days,
|
||||
"window_note": raw.get("window_note"),
|
||||
"window_label_zh": label,
|
||||
"brackets": list(raw.get("brackets") or []),
|
||||
"totals": dict(raw.get("totals") or {}),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_hero_item_fears() -> dict:
|
||||
"""Cached rule-based items that counter each hero (see item_fears.py)."""
|
||||
empty = {"meta": {}, "items": {}, "by_hero": {}}
|
||||
@@ -155,7 +223,7 @@ def load_patches() -> dict:
|
||||
"""Patch list + per-patch details + id lookup (see fetch_patches.py).
|
||||
|
||||
Returns {patches, lookup, details}; empty-shaped when the file is missing
|
||||
so the preview UI degrades to "no data" instead of crashing.
|
||||
so the web UI degrades to "no data" instead of crashing.
|
||||
"""
|
||||
empty = {"patches": [], "lookup": {}, "details": {}}
|
||||
if not PATCHES_PATH.is_file():
|
||||
@@ -171,8 +239,179 @@ def load_patches() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def load_leaderboards() -> dict:
|
||||
"""Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "valve",
|
||||
"attribution": "https://www.dota2.com/leaderboards",
|
||||
"note": None,
|
||||
"default_region": "china",
|
||||
"region_order": ["china", "europe", "americas", "se_asia"],
|
||||
"regions": {},
|
||||
}
|
||||
if not LEADERBOARDS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(LEADERBOARDS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
regions = raw.get("regions") if isinstance(raw.get("regions"), dict) else {}
|
||||
order = raw.get("region_order")
|
||||
if not isinstance(order, list) or not order:
|
||||
order = list(empty["region_order"])
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "valve",
|
||||
"attribution": raw.get("attribution") or empty["attribution"],
|
||||
"note": raw.get("note"),
|
||||
"default_region": raw.get("default_region") or "china",
|
||||
"region_order": [str(x) for x in order],
|
||||
"regions": regions,
|
||||
}
|
||||
|
||||
|
||||
def load_pro_matches() -> dict:
|
||||
"""Pro-player recent matches (see fetch_pro_matches.py). Preview only."""
|
||||
empty: dict = {
|
||||
"meta": {},
|
||||
"items": {},
|
||||
"pros": {},
|
||||
"by_pro": {},
|
||||
"by_hero": {},
|
||||
}
|
||||
if not PRO_MATCHES_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(PRO_MATCHES_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
return {
|
||||
"meta": dict(raw.get("meta") or {}),
|
||||
"items": dict(raw.get("items") or {}),
|
||||
"pros": dict(raw.get("pros") or {}),
|
||||
"by_pro": dict(raw.get("by_pro") or {}),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_streamers() -> dict:
|
||||
"""Manual streamer directory + Douyin profile enrichment (fetch_streamers.py).
|
||||
|
||||
Web「主播」only — do not merge into relations/heroes or recommend.
|
||||
"""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "manual+douyin",
|
||||
"platform_meta": {
|
||||
"douyin": {
|
||||
"label_zh": "抖音",
|
||||
"icon": "ui-icon/platform_douyin.png",
|
||||
}
|
||||
},
|
||||
"streamers": [],
|
||||
}
|
||||
if not STREAMERS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(STREAMERS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
rows = raw.get("streamers")
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
platform_meta = raw.get("platform_meta")
|
||||
if not isinstance(platform_meta, dict):
|
||||
platform_meta = dict(empty["platform_meta"])
|
||||
else:
|
||||
platform_meta = {
|
||||
**empty["platform_meta"],
|
||||
**{k: v for k, v in platform_meta.items() if isinstance(v, dict)},
|
||||
}
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "manual+douyin",
|
||||
"platform_meta": platform_meta,
|
||||
"streamers": [r for r in rows if isinstance(r, dict)],
|
||||
}
|
||||
|
||||
|
||||
def load_stratz_hero_meta() -> dict:
|
||||
"""STRATZ weekly WR/pick by bracket + positions (see fetch_stratz_meta.py). Web only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "stratz",
|
||||
"attribution": "https://stratz.com",
|
||||
"weeks_take": 0,
|
||||
"window_label_zh": None,
|
||||
"brackets": [],
|
||||
"positions": [],
|
||||
"bracket_position_note": None,
|
||||
"totals": {},
|
||||
"by_hero": {},
|
||||
"meta_board": {},
|
||||
}
|
||||
if not STRATZ_HERO_META_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(STRATZ_HERO_META_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "stratz",
|
||||
"attribution": raw.get("attribution") or empty["attribution"],
|
||||
"weeks_take": raw.get("weeks_take") or 0,
|
||||
"window_label_zh": raw.get("window_label_zh"),
|
||||
"brackets": list(raw.get("brackets") or []),
|
||||
"positions": list(raw.get("positions") or []),
|
||||
"bracket_position_note": raw.get("bracket_position_note"),
|
||||
"totals": dict(raw.get("totals") or {}),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
"meta_board": dict(raw.get("meta_board") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_stratz_matchup_tops() -> dict:
|
||||
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"source": "stratz",
|
||||
"attribution": "https://stratz.com",
|
||||
"take": 0,
|
||||
"match_limit": 0,
|
||||
"note": None,
|
||||
"by_hero": {},
|
||||
}
|
||||
if not STRATZ_MATCHUP_TOPS_PATH.is_file():
|
||||
return empty
|
||||
try:
|
||||
raw = json.loads(STRATZ_MATCHUP_TOPS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return empty
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"source": raw.get("source") or "stratz",
|
||||
"attribution": raw.get("attribution") or empty["attribution"],
|
||||
"take": raw.get("take") or 0,
|
||||
"match_limit": raw.get("match_limit") or 0,
|
||||
"note": raw.get("note"),
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
|
||||
def load_hero_abilities() -> dict:
|
||||
"""Slim per-hero abilities / Aghs upgrades / talents for the preview pane."""
|
||||
"""Slim per-hero abilities / Aghs upgrades / talents for the web hero pane."""
|
||||
empty = {"meta": {}, "by_hero": {}}
|
||||
if not HERO_ABILITIES_PATH.is_file():
|
||||
return empty
|
||||
@@ -217,6 +456,11 @@ def load_hero_abilities() -> dict:
|
||||
"mana_cost": ab.get("mana_cost") or "",
|
||||
"specials": specials,
|
||||
"lore_loc": ab.get("lore_loc") or "",
|
||||
"tags": [
|
||||
str(t)
|
||||
for t in (ab.get("tags") or [])
|
||||
if isinstance(t, str) and t in QUERY_MECHANIC_ORDER
|
||||
],
|
||||
}
|
||||
)
|
||||
talents_out = []
|
||||
@@ -246,7 +490,7 @@ def ensure_ability_icon(key: str) -> Path | None:
|
||||
|
||||
``innate.png`` / ``talent_tree.png`` are bundled shared badges (not fetched
|
||||
from CDN). Many innate ability keys 404 on Steam ``dota_react/abilities``;
|
||||
the preview UI falls back to ``innate.png`` when a per-ability icon is missing.
|
||||
the web UI falls back to ``innate.png`` when a per-ability icon is missing.
|
||||
"""
|
||||
if not key or "/" in key or "\\" in key or ".." in key:
|
||||
return None
|
||||
@@ -349,11 +593,18 @@ def build_payload() -> dict:
|
||||
|
||||
rel = load_relations()
|
||||
items = load_hero_items()
|
||||
hero_stats = load_hero_stats()
|
||||
hero_matches = load_hero_matches()
|
||||
fears = load_hero_item_fears()
|
||||
abilities = load_hero_abilities()
|
||||
shop = load_item_shop()
|
||||
items_meta = load_items_meta_index()
|
||||
patches_data = load_patches()
|
||||
leaderboards = load_leaderboards()
|
||||
pro_matches = load_pro_matches()
|
||||
streamers = load_streamers()
|
||||
stratz_meta = load_stratz_hero_meta()
|
||||
stratz_matchups = load_stratz_matchup_tops()
|
||||
# Attach craft graph from shop catalog when available.
|
||||
for key, row in (shop.get("items") or {}).items():
|
||||
if not isinstance(row, dict):
|
||||
@@ -375,6 +626,8 @@ def build_payload() -> dict:
|
||||
"tag_order": list(TAG_ORDER),
|
||||
"relations": rel,
|
||||
"hero_items": items,
|
||||
"hero_stats": hero_stats,
|
||||
"hero_matches": hero_matches,
|
||||
"hero_item_fears": fears,
|
||||
"hero_abilities": abilities,
|
||||
"item_shop": shop,
|
||||
@@ -382,10 +635,20 @@ def build_payload() -> dict:
|
||||
"patches": patches_data.get("patches") or [],
|
||||
"patch_lookup": patches_data.get("lookup") or {},
|
||||
"patch_details": patches_data.get("details") or {},
|
||||
"leaderboards": leaderboards,
|
||||
"pro_matches": pro_matches,
|
||||
"streamers": streamers,
|
||||
"stratz_hero_meta": stratz_meta,
|
||||
"stratz_matchup_tops": stratz_matchups,
|
||||
"mechanic_query": mechanic_query_payload(),
|
||||
"meta": {
|
||||
"relations_path": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
|
||||
"grid_order_path": str(GRID_ORDER_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"hero_items_path": str(HERO_ITEMS_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"hero_stats_path": str(HERO_STATS_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"hero_matches_path": str(HERO_MATCHES_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
"hero_item_fears_path": str(HERO_ITEM_FEARS_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
@@ -393,15 +656,33 @@ def build_payload() -> dict:
|
||||
"\\", "/"
|
||||
),
|
||||
"item_shop_path": str(ITEM_SHOP_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"leaderboards_path": str(LEADERBOARDS_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
"streamers_path": str(STREAMERS_PATH.relative_to(ROOT)).replace("\\", "/"),
|
||||
"stratz_hero_meta_path": str(STRATZ_HERO_META_PATH.relative_to(ROOT)).replace(
|
||||
"\\", "/"
|
||||
),
|
||||
"stratz_matchup_tops_path": str(
|
||||
STRATZ_MATCHUP_TOPS_PATH.relative_to(ROOT)
|
||||
).replace("\\", "/"),
|
||||
"source": (rel.get("meta") or {}).get("source"),
|
||||
"counters": len(rel.get("counters") or []),
|
||||
"synergies": len(rel.get("synergies") or []),
|
||||
"item_heroes": len(items.get("by_hero") or {}),
|
||||
"stats_heroes": len(hero_stats.get("by_hero") or {}),
|
||||
"match_heroes": len(hero_matches.get("by_hero") or {}),
|
||||
"fear_heroes": len(fears.get("by_hero") or {}),
|
||||
"ability_heroes": len(abilities.get("by_hero") or {}),
|
||||
"shop_items": len(shop.get("items") or {}),
|
||||
"patches": len(patches_data.get("patches") or []),
|
||||
"patch_details": len(patches_data.get("details") or {}),
|
||||
"leaderboard_regions": len(leaderboards.get("regions") or {}),
|
||||
"pro_match_players": len(pro_matches.get("by_pro") or {}),
|
||||
"pro_match_heroes": len(pro_matches.get("by_hero") or {}),
|
||||
"streamers": len(streamers.get("streamers") or []),
|
||||
"stratz_meta_heroes": len(stratz_meta.get("by_hero") or {}),
|
||||
"stratz_matchup_heroes": len(stratz_matchups.get("by_hero") or {}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -419,12 +700,69 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; media-src 'self'",
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; "
|
||||
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
|
||||
)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_file(self, fpath: Path, content_type: str) -> None:
|
||||
"""Stream a file with optional HTTP Range (needed for HTML5 video seek)."""
|
||||
size = fpath.stat().st_size
|
||||
range_hdr = self.headers.get("Range") or self.headers.get("range")
|
||||
start, end = 0, size - 1
|
||||
code = 200
|
||||
if range_hdr and range_hdr.startswith("bytes=") and size > 0:
|
||||
spec = range_hdr[len("bytes=") :].strip()
|
||||
if "," not in spec:
|
||||
left, _, right = spec.partition("-")
|
||||
try:
|
||||
if left == "" and right:
|
||||
# suffix bytes: bytes=-N
|
||||
suffix = int(right)
|
||||
start = max(0, size - suffix)
|
||||
else:
|
||||
start = int(left) if left else 0
|
||||
end = int(right) if right else size - 1
|
||||
if start < 0 or end >= size or start > end:
|
||||
self.send_response(416)
|
||||
self.send_header("Content-Range", f"bytes */{size}")
|
||||
self.end_headers()
|
||||
return
|
||||
code = 206
|
||||
except ValueError:
|
||||
start, end = 0, size - 1
|
||||
code = 200
|
||||
length = end - start + 1
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Length", str(length))
|
||||
if code == 206:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; "
|
||||
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
|
||||
)
|
||||
self.end_headers()
|
||||
with fpath.open("rb") as fh:
|
||||
fh.seek(start)
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = fh.read(min(1 << 20, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
def _json(self, code: int, obj: object) -> None:
|
||||
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
@@ -452,9 +790,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/rank/"):
|
||||
key = path[len("/rank/") :]
|
||||
allowed = {f"rank_icon_{i}.png" for i in range(1, 9)}
|
||||
if key not in allowed:
|
||||
self._json(400, {"error": "bad rank icon"})
|
||||
return
|
||||
fpath = RANK_ICONS / key
|
||||
if not fpath.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/ui-icon/"):
|
||||
key = path[len("/ui-icon/") :]
|
||||
if key not in ("cooldown.png", "dota2_logo.png"):
|
||||
allowed_ui = {
|
||||
"cooldown.png",
|
||||
"dota2_logo.png",
|
||||
"dota2_logo_wordmark.png",
|
||||
"platform_douyin.png",
|
||||
}
|
||||
if key not in allowed_ui:
|
||||
self._json(400, {"error": "bad ui icon"})
|
||||
return
|
||||
fpath = UI_ICONS / key
|
||||
@@ -463,6 +819,41 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/streamer-avatar/"):
|
||||
key = path[len("/streamer-avatar/") :]
|
||||
if "/" in key or "\\" in key or ".." in key:
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
if not (key.endswith(".jpg") or key.endswith(".jpeg") or key.endswith(".png") or key.endswith(".webp")):
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
fpath = STREAMER_AVATARS / key
|
||||
if not fpath.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
ctype = mimetypes.guess_type(key)[0] or "image/jpeg"
|
||||
self._send(200, fpath.read_bytes(), ctype)
|
||||
return
|
||||
if path.startswith("/streamer-video/"):
|
||||
key = path[len("/streamer-video/") :]
|
||||
if "/" in key or "\\" in key or ".." in key:
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
if not (key.endswith(".mp4") or key.endswith(".webm")):
|
||||
self._json(400, {"error": "bad path"})
|
||||
return
|
||||
if key.startswith("_"):
|
||||
self.send_error(404)
|
||||
return
|
||||
fpath = STREAMER_VIDEOS / key
|
||||
if not fpath.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
ctype = (
|
||||
"video/webm" if key.endswith(".webm") else "video/mp4"
|
||||
)
|
||||
self._send_file(fpath, ctype)
|
||||
return
|
||||
if path.startswith("/portrait/") or path.startswith("/cdn/"):
|
||||
prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/"
|
||||
key = path[len(prefix) :]
|
||||
@@ -570,7 +961,7 @@ def main() -> None:
|
||||
|
||||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
url = f"http://{args.host}:{args.port}/"
|
||||
print(f"relations preview: {url}")
|
||||
print(f"Climperor web: {url}")
|
||||
print(f"edit JSON then refresh: {DEFAULT_RELATIONS}")
|
||||
if not args.no_browser:
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||
var SITE_VERSION = "0.4.2";
|
||||
var SITE_VERSION = "0.5.59";
|
||||
var ABILITY_VIDEO_BASE = "";
|
||||
var STATIC_ASSET_BASE = "";
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>上分帝</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<title>DOTA2 上分帝</title>
|
||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.59" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
|
||||
<div class="brand" aria-label="Dota 2 上分帝">
|
||||
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
|
||||
<span class="brand-title">上分帝</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -24,6 +25,10 @@
|
||||
</a>
|
||||
<nav class="main-tabs" aria-label="主分页">
|
||||
<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="trends">走势</button>
|
||||
<button type="button" class="main-tab" data-page="mechanics">机制</button>
|
||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||
<button type="button" class="main-tab" data-page="patches">版本</button>
|
||||
</nav>
|
||||
@@ -44,6 +49,67 @@
|
||||
<div class="columns" id="columns"></div>
|
||||
</main>
|
||||
|
||||
<main id="rankings-view" class="board rankings-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap">
|
||||
<div class="rankings-body" id="rankings-body"></div>
|
||||
<aside class="rankings-aside" aria-label="排行筛选">
|
||||
<h2 class="rankings-title">
|
||||
<span class="rankings-title-row">
|
||||
<span class="rankings-kicker" id="rankings-kicker">Immortal 排行榜</span>
|
||||
<span id="rankings-tip-slot"></span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="rankings-regions" id="rankings-regions" role="tablist" aria-label="选择地区"></div>
|
||||
<p class="rankings-sub" id="rankings-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">
|
||||
<div class="rankings-body streamers-body" id="streamers-body"></div>
|
||||
<footer class="streamers-foot" id="streamers-foot" aria-hidden="true"></footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="trends-view" class="board rankings-board trends-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap trends-center-wrap">
|
||||
<div class="rankings-body" id="trends-body"></div>
|
||||
<aside class="rankings-aside" aria-label="走势筛选">
|
||||
<h2 class="rankings-title">
|
||||
<span class="rankings-title-row">
|
||||
<span class="rankings-kicker" id="trends-kicker">过去 8 周走势</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="trends-brackets" id="trends-brackets" aria-label="选择段位"></div>
|
||||
<div class="trends-sort" id="trends-sort" role="tablist" aria-label="排序"></div>
|
||||
<p class="rankings-sub" id="trends-sub"></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="mechanics-view" class="board rankings-board mechanics-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap mechanics-center-wrap">
|
||||
<div class="rankings-body mechanics-body" id="mechanics-body"></div>
|
||||
<aside class="rankings-aside" aria-label="机制筛选">
|
||||
<h2 class="rankings-title">
|
||||
<span class="rankings-title-row">
|
||||
<span class="rankings-kicker">机制查询</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="mechanics-effects" id="mechanics-effects" aria-label="选择状态效果"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="items-view" class="board items-board hidden">
|
||||
<div class="items-cluster">
|
||||
<div class="items-main">
|
||||
@@ -70,8 +136,8 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.4.2"></script>
|
||||
<script src="/router.js?v=0.4.2"></script>
|
||||
<script src="/app.js?v=0.4.2"></script>
|
||||
<script src="/config.js?v=0.5.59"></script>
|
||||
<script src="/router.js?v=0.5.59"></script>
|
||||
<script src="/app.js?v=0.5.59"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
/* global window, history, location */
|
||||
|
||||
/**
|
||||
* Hash-based router for the relations preview (web/relations).
|
||||
* Hash-based router for the Climperor web site (web/relations).
|
||||
*
|
||||
* Synchronizes the browser URL with app state across these dimensions:
|
||||
* - page: heroes | items | patches (top-level tab)
|
||||
* - hero: selected hero key + detail sub-tab (skills|core|fears|patches)
|
||||
* - page: heroes | rankings | 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)
|
||||
* - streamers: curated Douyin streamer directory
|
||||
* #/streamers
|
||||
* - trends: medal bracket for the 8-week win/pick board
|
||||
* #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
|
||||
* - mechanics: applies-effect query (dispel / CC)
|
||||
* #/mechanics[/{effect}] (default effect basic_dispel omitted)
|
||||
* - item: selected shop item key (items page)
|
||||
* - patch: selected version string (patches page; latest when absent)
|
||||
* - query params: tags=csv (heroes), q=search text (heroes + items)
|
||||
@@ -21,8 +30,44 @@
|
||||
*/
|
||||
|
||||
const ROUTE_DEFAULT = "#/heroes";
|
||||
const VALID_PAGES = ["heroes", "items", "patches"];
|
||||
const VALID_DETAIL_TABS = ["skills", "core", "fears", "patches"];
|
||||
const VALID_PAGES = ["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"];
|
||||
const VALID_DETAIL_TABS = [
|
||||
"skills",
|
||||
"core",
|
||||
"fears",
|
||||
"trends",
|
||||
"stats",
|
||||
"matchups",
|
||||
"matches",
|
||||
"streamers",
|
||||
"patches",
|
||||
];
|
||||
const DEFAULT_RANKING_REGION = "china";
|
||||
const DEFAULT_TRENDS_BRACKET = "legend";
|
||||
const DEFAULT_TRENDS_SORT = "wr_end";
|
||||
const VALID_TRENDS_SORTS = ["wr_end", "pr_end"];
|
||||
const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" };
|
||||
const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" };
|
||||
const DEFAULT_MECHANIC_EFFECT = "basic_dispel";
|
||||
const VALID_MECHANIC_EFFECTS = [
|
||||
"basic_dispel",
|
||||
"strong_dispel",
|
||||
"root",
|
||||
"disarm",
|
||||
"silence",
|
||||
"mute",
|
||||
"stun",
|
||||
"hex",
|
||||
"break",
|
||||
"sleep",
|
||||
"fear",
|
||||
"taunt",
|
||||
"blind",
|
||||
"leash",
|
||||
"invis",
|
||||
"ethereal",
|
||||
"cyclone",
|
||||
];
|
||||
|
||||
let _deps = null;
|
||||
|
||||
@@ -46,6 +91,10 @@ function parseHash(hash) {
|
||||
detailTab: null,
|
||||
itemKey: null,
|
||||
patchVersion: null,
|
||||
rankingRegion: null,
|
||||
trendsBracket: null,
|
||||
trendsSort: null,
|
||||
mechanicEffect: null,
|
||||
tags: null,
|
||||
query: null,
|
||||
itemQuery: null,
|
||||
@@ -64,6 +113,15 @@ function parseHash(hash) {
|
||||
if (page === "heroes") {
|
||||
if (segs[1]) out.heroKey = safeDecode(segs[1]);
|
||||
if (segs[2]) out.detailTab = safeDecode(segs[2]);
|
||||
} else if (page === "rankings") {
|
||||
// Legacy #/rankings/meta[/bracket] → Immortal players board (default region).
|
||||
if (segs[1] && segs[1] !== "meta") {
|
||||
out.rankingRegion = safeDecode(segs[1]);
|
||||
}
|
||||
} else if (page === "trends") {
|
||||
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
|
||||
} else if (page === "mechanics") {
|
||||
if (segs[1]) out.mechanicEffect = safeDecode(segs[1]);
|
||||
} else if (page === "items") {
|
||||
if (segs[1]) out.itemKey = safeDecode(segs[1]);
|
||||
} else if (page === "patches") {
|
||||
@@ -81,6 +139,12 @@ function parseHash(hash) {
|
||||
if (page === "items") out.itemQuery = qParam;
|
||||
else out.query = qParam;
|
||||
}
|
||||
if (page === "trends") {
|
||||
const sortParam = params.get("sort");
|
||||
if (sortParam && TRENDS_SORT_FROM_URL[sortParam]) {
|
||||
out.trendsSort = TRENDS_SORT_FROM_URL[sortParam];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -99,10 +163,33 @@ function serializeHash(state) {
|
||||
if (state.page === "heroes") {
|
||||
if (state.selectedKey) {
|
||||
hash += "/" + encodeURIComponent(state.selectedKey);
|
||||
if (state.detailTab && VALID_DETAIL_TABS.includes(state.detailTab)) {
|
||||
hash += "/" + encodeURIComponent(state.detailTab);
|
||||
let tab = state.detailTab;
|
||||
if (tab === "stats") tab = "trends"; // normalize legacy alias in URL
|
||||
if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") {
|
||||
hash += "/" + encodeURIComponent(tab);
|
||||
}
|
||||
}
|
||||
} else if (state.page === "rankings") {
|
||||
// Omit region when it is the default (china) — bare #/rankings means China.
|
||||
const region = state.rankingRegion || DEFAULT_RANKING_REGION;
|
||||
if (region && region !== DEFAULT_RANKING_REGION) {
|
||||
hash += "/" + encodeURIComponent(region);
|
||||
}
|
||||
} else if (state.page === "trends") {
|
||||
// Omit bracket when it is the default (legend) — bare #/trends means legend.
|
||||
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
|
||||
if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) {
|
||||
hash += "/" + encodeURIComponent(bracket);
|
||||
}
|
||||
const sort = state.trendsSort || DEFAULT_TRENDS_SORT;
|
||||
if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) {
|
||||
hash += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort);
|
||||
}
|
||||
} else if (state.page === "mechanics") {
|
||||
const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT;
|
||||
if (effect && effect !== DEFAULT_MECHANIC_EFFECT) {
|
||||
hash += "/" + encodeURIComponent(effect);
|
||||
}
|
||||
} else if (state.page === "items") {
|
||||
if (state.selectedItemKey) {
|
||||
hash += "/" + encodeURIComponent(state.selectedItemKey);
|
||||
|
||||