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>
This commit is contained in:
voson
2026-07-29 02:11:49 +08:00
co-authored by Cursor
parent 37769580f5
commit 3ec8007077
72 changed files with 20669 additions and 2105 deletions
+12
View File
@@ -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=
+9
View File
@@ -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/
+5 -2
View File
@@ -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 显式 flagValve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN |
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4Steam 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 # 只比对列表与本地 detailsstdout JSONhas_new
+24 -12
View File
@@ -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
+31 -19
View File
@@ -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` | 分路→角色标签过滤(15 号位;非定位局不过滤 | 见 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
---
+54 -7
View File
@@ -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.jsonWeb 走势兜底)
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,不能代替读顶栏
+179
View File
@@ -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())
+153
View File
@@ -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())
+12 -2
View File
@@ -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]:
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+9 -3
View File
@@ -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"
+16 -5
View File
@@ -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"
}
+832
View File
@@ -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": []
}
}
}
+1858 -736
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -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": []
}
}
}
+4596 -1039
View File
File diff suppressed because it is too large Load Diff
+306 -35
View File
@@ -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": []
}
}
}
+42 -13
View File
@@ -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": {
File diff suppressed because it is too large Load Diff
+228
View File
@@ -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主播,万分小鱼王! 上车+v375874361",
"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沙王直播录像"
}
]
}
+2 -2
View File
@@ -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)
+260
View File
@@ -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",
]
+73 -15
View File
@@ -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
+38 -5
View File
@@ -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 -1
View File
@@ -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"/>
+84
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -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()
+299
View File
@@ -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()
+15 -59
View File
@@ -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:
+113
View File
@@ -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()
+37 -2
View File
@@ -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:
+544
View File
@@ -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()
+434
View File
@@ -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()
+11 -4
View File
@@ -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)."
)
+500
View File
@@ -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()
+469
View File
@@ -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("&amp;", "&")
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())
+2 -2
View File
@@ -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 = [
"核心",
"辅助",
+56 -6
View File
@@ -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()
+63 -2
View File
@@ -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,
+284
View File
@@ -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
],
}
+107 -28
View File
@@ -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"),
)
+201 -36
View File
@@ -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",
]
+1
View File
@@ -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)
+401 -10
View File
@@ -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:
+2756 -41
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 = "";
+73 -7
View File
@@ -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>
+94 -7
View File
@@ -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);
+1711 -2
View File
File diff suppressed because it is too large Load Diff