v0.4.1: OSS skill videos, site version footer, and mail contact.
Host ability demos on Aliyun OSS instead of Pages bundles; add patches-page version label and top-right mailto link; improve spirit bear portrait export. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,8 +29,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
|
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
|
||||||
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
|
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
|
||||||
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json`) |
|
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json`) |
|
||||||
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`(data.json 快照 + 前端 + 图片;`--with-videos` 含视频) |
|
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` 写 `config.js` 指向 OSS 技能视频;`--with-videos` 可选本地拷贝视频,生产部署勿用) |
|
||||||
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;凭据经 keyzoo 注入或 env) |
|
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS;凭据经 keyzoo 注入或 env) |
|
||||||
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
|
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
|
||||||
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
|
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
|
||||||
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
|
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
|
||||||
@@ -57,7 +57,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
|
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
|
||||||
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
|
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
|
||||||
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `app.js` / `style.css` / `router.js`) |
|
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION` 与 `ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` |
|
||||||
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
|
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
|
||||||
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
||||||
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
||||||
@@ -68,7 +68,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
||||||
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
||||||
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
|
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
|
||||||
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取) |
|
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) |
|
||||||
|
|
||||||
运行时产物(**勿提交**,见 `.gitignore`):
|
运行时产物(**勿提交**,见 `.gitignore`):
|
||||||
|
|
||||||
@@ -122,7 +122,8 @@ python item_fears.py # 英雄怕的装备
|
|||||||
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
||||||
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
|
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
|
||||||
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages)
|
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages)
|
||||||
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev)
|
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com
|
||||||
|
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;技能视频走 OSS)
|
||||||
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
||||||
python gsi_setup.py --check
|
python gsi_setup.py --check
|
||||||
python gsi_watch.py --once
|
python gsi_watch.py --once
|
||||||
@@ -138,6 +139,7 @@ python evaluate.py
|
|||||||
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
||||||
- 改预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`。
|
- 改预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`。
|
||||||
- 改 `data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles` 供 `overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
|
- 改 `data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles` 供 `overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
|
||||||
|
- 发版关系预览时同步 bump `export_relations_site.py` 的 `SITE_VERSION`、`web/relations/config.js` 的同名变量,以及 `index.html` 里 `config.js`/`router.js`/`app.js` 的 `?v=` 缓存戳;并写 `CHANGELOG.md`。
|
||||||
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
||||||
|
|
||||||
## 文档分工
|
## 文档分工
|
||||||
|
|||||||
@@ -4,6 +4,24 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-07-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 线上关系预览技能演示视频改由阿里云 OSS 托管(桶 `climperor`,
|
||||||
|
`https://climperor.oss-cn-shanghai.aliyuncs.com/ability-video/...`),不再打进
|
||||||
|
Cloudflare Pages;本地 `serve_relations.py` 仍读 `assets/ability_videos/`。
|
||||||
|
导出写入 `config.js` 的 `ABILITY_VIDEO_BASE`(`export_relations_site.py
|
||||||
|
--ability-video-base` / `deploy_relations.py` 默认 OSS 地址)。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 版本页底部居中显示站点版本号(`config.js` 的 `SITE_VERSION`,如 `v0.4.1`)。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页熊灵(spirit bear)头像:静态导出包含 `portrait/spirit_bear.png`。
|
||||||
|
|
||||||
## [0.4.0] - 2026-07-27
|
## [0.4.0] - 2026-07-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ python deploy_relations.py
|
|||||||
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
|
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
|
||||||
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
|
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
|
||||||
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
|
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
|
||||||
|
# 技能演示视频默认走阿里云 OSS 桶 climperor(Pages 只部署 ~42MB 静态资源)
|
||||||
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
|
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -148,14 +148,21 @@ def ensure_project(email: str, api_key: str, account_id: str, project: str) -> N
|
|||||||
raise SystemExit(f"create project failed: {d.get('errors')}")
|
raise SystemExit(f"create project failed: {d.get('errors')}")
|
||||||
|
|
||||||
|
|
||||||
def run_export() -> None:
|
def run_export(ability_video_base: str | None = None) -> None:
|
||||||
"""Re-run export_relations_site.py to refresh dist/relations."""
|
"""Re-run export_relations_site.py to refresh dist/relations."""
|
||||||
print("exporting static site ...")
|
print("exporting static site ...")
|
||||||
subprocess.run(
|
cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
|
||||||
[sys.executable, str(ROOT / "export_relations_site.py")],
|
# Production Pages should load ability demos from OSS, not bundle GB of video.
|
||||||
cwd=str(ROOT),
|
base = ability_video_base
|
||||||
check=True,
|
if base is None:
|
||||||
|
base = os.environ.get(
|
||||||
|
"ABILITY_VIDEO_BASE",
|
||||||
|
"https://climperor.oss-cn-shanghai.aliyuncs.com",
|
||||||
)
|
)
|
||||||
|
if base:
|
||||||
|
cmd.extend(["--ability-video-base", base])
|
||||||
|
print(f"ABILITY_VIDEO_BASE={base}")
|
||||||
|
subprocess.run(cmd, cwd=str(ROOT), check=True)
|
||||||
|
|
||||||
|
|
||||||
def check_integrity(dist: Path) -> None:
|
def check_integrity(dist: Path) -> None:
|
||||||
@@ -309,6 +316,12 @@ def main() -> None:
|
|||||||
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
|
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
|
||||||
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
|
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
|
||||||
ap.add_argument("--dist", default=str(DEFAULT_DIST))
|
ap.add_argument("--dist", default=str(DEFAULT_DIST))
|
||||||
|
ap.add_argument(
|
||||||
|
"--ability-video-base",
|
||||||
|
default=None,
|
||||||
|
help="passed to export as --ability-video-base (default: OSS climperor endpoint "
|
||||||
|
"or ABILITY_VIDEO_BASE env)",
|
||||||
|
)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
dist = Path(args.dist).resolve()
|
dist = Path(args.dist).resolve()
|
||||||
@@ -320,7 +333,7 @@ def main() -> None:
|
|||||||
ensure_project(email, api_key, account_id, args.project_name)
|
ensure_project(email, api_key, account_id, args.project_name)
|
||||||
|
|
||||||
if not args.no_export:
|
if not args.no_export:
|
||||||
run_export()
|
run_export(args.ability_video_base)
|
||||||
else:
|
else:
|
||||||
print("--no-export: using existing dist")
|
print("--no-export: using existing dist")
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python export_relations_site.py [--out dist/relations] [--with-videos]
|
python export_relations_site.py [--out dist/relations] [--with-videos]
|
||||||
|
[--ability-video-base URL]
|
||||||
|
|
||||||
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
|
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
|
||||||
the referenced image assets into one directory, ready for any static host
|
the referenced image assets into one directory, ready for any static host
|
||||||
@@ -13,13 +14,15 @@ Notes:
|
|||||||
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
|
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
|
||||||
first (downloads referenced item + ability icons into assets/).
|
first (downloads referenced item + ability icons into assets/).
|
||||||
- Ability videos (several GB locally) are skipped unless --with-videos is
|
- Ability videos (several GB locally) are skipped unless --with-videos is
|
||||||
given; the UI degrades quietly when a video is missing.
|
given; production deploys point the UI at OSS via --ability-video-base
|
||||||
|
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@ from common import (
|
|||||||
)
|
)
|
||||||
from serve_relations import WEB_DIR, build_payload
|
from serve_relations import WEB_DIR, build_payload
|
||||||
|
|
||||||
|
SITE_VERSION = "0.4.1"
|
||||||
|
|
||||||
|
|
||||||
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
|
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
|
||||||
if not src.is_dir():
|
if not src.is_dir():
|
||||||
@@ -49,6 +54,21 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_js(out: Path, ability_video_base: str, site_version: str) -> None:
|
||||||
|
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE)."""
|
||||||
|
base = (ability_video_base or "").strip().rstrip("/")
|
||||||
|
ver = (site_version or "").strip()
|
||||||
|
# JSON-encode so values are safe JS string literals.
|
||||||
|
ver_lit = json.dumps(ver, ensure_ascii=False)
|
||||||
|
base_lit = json.dumps(base, ensure_ascii=False)
|
||||||
|
(out / "config.js").write_text(
|
||||||
|
f"/* generated by export_relations_site.py — do not edit */\n"
|
||||||
|
f"var SITE_VERSION = {ver_lit};\n"
|
||||||
|
f"var ABILITY_VIDEO_BASE = {base_lit};\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="Export relations preview as a static site"
|
description="Export relations preview as a static site"
|
||||||
@@ -59,8 +79,20 @@ def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="also copy assets/ability_videos (several GB)",
|
help="also copy assets/ability_videos (several GB)",
|
||||||
)
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--ability-video-base",
|
||||||
|
default=None,
|
||||||
|
help="public base URL for ability demos (writes config.js); "
|
||||||
|
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
|
||||||
|
)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
video_base = (
|
||||||
|
args.ability_video_base
|
||||||
|
if args.ability_video_base is not None
|
||||||
|
else os.environ.get("ABILITY_VIDEO_BASE", "")
|
||||||
|
)
|
||||||
|
|
||||||
out = Path(args.out).resolve()
|
out = Path(args.out).resolve()
|
||||||
if out == ROOT.resolve() or out.parent == out:
|
if out == ROOT.resolve() or out.parent == out:
|
||||||
raise SystemExit(f"refusing unsafe --out: {out}")
|
raise SystemExit(f"refusing unsafe --out: {out}")
|
||||||
@@ -69,8 +101,12 @@ def main() -> None:
|
|||||||
out.mkdir(parents=True)
|
out.mkdir(parents=True)
|
||||||
|
|
||||||
# Frontend (uses relative paths — works from any sub-path).
|
# Frontend (uses relative paths — works from any sub-path).
|
||||||
for name in ("index.html", "router.js", "app.js", "style.css"):
|
# `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS).
|
||||||
shutil.copy2(WEB_DIR / name, out / name)
|
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
|
||||||
|
src = WEB_DIR / name
|
||||||
|
if src.is_file():
|
||||||
|
shutil.copy2(src, out / name)
|
||||||
|
write_config_js(out, video_base, SITE_VERSION)
|
||||||
|
|
||||||
# Data snapshot (same payload as serve_relations /api/data).
|
# Data snapshot (same payload as serve_relations /api/data).
|
||||||
payload = build_payload()
|
payload = build_payload()
|
||||||
@@ -124,6 +160,7 @@ def main() -> None:
|
|||||||
print(f" {name}/: {n} files")
|
print(f" {name}/: {n} files")
|
||||||
if args.with_videos:
|
if args.with_videos:
|
||||||
print(f" ability-video/: {n_videos} files")
|
print(f" ability-video/: {n_videos} files")
|
||||||
|
print(f" config.js SITE_VERSION={SITE_VERSION!r} ABILITY_VIDEO_BASE={video_base!r}")
|
||||||
print(f" total: {total / 1e6:.1f} MB")
|
print(f" total: {total / 1e6:.1f} MB")
|
||||||
print(f"local check: python -m http.server -d {out} 8080")
|
print(f"local check: python -m http.server -d {out} 8080")
|
||||||
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
|
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
|
||||||
|
|||||||
+93
-2
@@ -33,8 +33,8 @@ import urllib.error
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from common import ABILITY_ICONS, DATA, ITEM_ICONS
|
from common import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
|
||||||
from http_utils import http_json
|
from http_utils import http_bytes, http_json
|
||||||
|
|
||||||
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
|
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
|
||||||
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
|
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
|
||||||
@@ -44,6 +44,13 @@ ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/
|
|||||||
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
|
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
|
||||||
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
|
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
|
||||||
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
|
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
|
||||||
|
# Hero-card CDN (same as fetch_hero_portraits). Non-hero units may only exist at half res.
|
||||||
|
HERO_CARD_URL = (
|
||||||
|
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
|
||||||
|
)
|
||||||
|
# Prefer ability art when Valve's hero-card for a unit is tiny (spirit_bear is 128x72).
|
||||||
|
UNIT_PORTRAIT_FALLBACK_ABILITY = {"spirit_bear": "lone_druid_spirit_bear"}
|
||||||
|
PORTRAIT_TARGET_SIZE = (256, 144)
|
||||||
|
|
||||||
OUT = DATA / "patches.json"
|
OUT = DATA / "patches.json"
|
||||||
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
|
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
|
||||||
@@ -242,6 +249,86 @@ def build_lookup(
|
|||||||
return {"items": items, "abilities": abilities, "heroes": heroes}
|
return {"items": items, "abilities": abilities, "heroes": heroes}
|
||||||
|
|
||||||
|
|
||||||
|
def _png_size(data: bytes) -> tuple[int, int] | None:
|
||||||
|
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||||
|
return None
|
||||||
|
import struct
|
||||||
|
|
||||||
|
return struct.unpack(">II", data[16:24])
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_resize_png(data: bytes, size: tuple[int, int] = PORTRAIT_TARGET_SIZE) -> bytes:
|
||||||
|
"""Center-crop / scale image bytes to a 16:9 hero-card PNG."""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
arr = np.frombuffer(data, dtype=np.uint8)
|
||||||
|
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||||
|
if img is None:
|
||||||
|
raise ValueError("cv2 could not decode image")
|
||||||
|
if img.ndim == 2:
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||||
|
elif img.shape[2] == 4:
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
tw, th = size
|
||||||
|
scale = max(tw / w, th / h)
|
||||||
|
nw, nh = max(tw, int(round(w * scale))), max(th, int(round(h * scale)))
|
||||||
|
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC)
|
||||||
|
x0 = max(0, (nw - tw) // 2)
|
||||||
|
y0 = max(0, (nh - th) // 2)
|
||||||
|
crop = resized[y0 : y0 + th, x0 : x0 + tw]
|
||||||
|
ok, buf = cv2.imencode(".png", crop)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError("cv2 could not encode png")
|
||||||
|
return buf.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def download_unit_portraits(*, delay: float) -> None:
|
||||||
|
"""Fetch portraits for UNIT_NAMES into hero_portraits (not in heroes.json).
|
||||||
|
|
||||||
|
Valve ships some unit cards at half resolution (spirit_bear 128x72). When a
|
||||||
|
fallback ability icon is configured, use that art cover-cropped to 256x144
|
||||||
|
so the patch page matches normal hero cards.
|
||||||
|
"""
|
||||||
|
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
|
||||||
|
ok = skip = fail = 0
|
||||||
|
for unit in UNIT_NAMES.values():
|
||||||
|
key = unit["key"]
|
||||||
|
out = HERO_PORTRAITS / f"{key}.png"
|
||||||
|
if out.is_file():
|
||||||
|
dims = _png_size(out.read_bytes())
|
||||||
|
if dims == PORTRAIT_TARGET_SIZE:
|
||||||
|
skip += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fb = UNIT_PORTRAIT_FALLBACK_ABILITY.get(key)
|
||||||
|
if fb:
|
||||||
|
raw = http_bytes(ABILITY_ICON_URL.format(key=fb), timeout=30)
|
||||||
|
data = _cover_resize_png(raw)
|
||||||
|
src = f"ability:{fb}"
|
||||||
|
else:
|
||||||
|
raw = http_bytes(HERO_CARD_URL.format(key=key), timeout=30)
|
||||||
|
dims = _png_size(raw)
|
||||||
|
data = (
|
||||||
|
_cover_resize_png(raw)
|
||||||
|
if dims and dims != PORTRAIT_TARGET_SIZE
|
||||||
|
else raw
|
||||||
|
)
|
||||||
|
src = "hero-card"
|
||||||
|
out.write_bytes(data)
|
||||||
|
ok += 1
|
||||||
|
print(f" unit portrait {key} <- {src} ({len(data)} bytes)", flush=True)
|
||||||
|
time.sleep(max(delay, 0.05))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
fail += 1
|
||||||
|
print(f" FAIL unit portrait {key}: {e}", flush=True)
|
||||||
|
print(
|
||||||
|
f" unit portraits saved={ok} skipped={skip} fail={fail} -> {HERO_PORTRAITS}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
||||||
"""Pull referenced item + ability icons into assets/ (skip existing)."""
|
"""Pull referenced item + ability icons into assets/ (skip existing)."""
|
||||||
from http_utils import download_icons
|
from http_utils import download_icons
|
||||||
@@ -266,6 +353,10 @@ def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
|||||||
)
|
)
|
||||||
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
|
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
|
||||||
|
|
||||||
|
# Pseudo-heroes in patchnotes (e.g. 熊灵) are absent from herolist / heroes.json.
|
||||||
|
print("downloading unit portraits for patch lookup ...", flush=True)
|
||||||
|
download_unit_portraits(delay=delay)
|
||||||
|
|
||||||
|
|
||||||
def load_existing_details() -> dict[str, dict]:
|
def load_existing_details() -> dict[str, dict]:
|
||||||
if not OUT.is_file():
|
if not OUT.is_file():
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
|
||||||
|
/index.html
|
||||||
|
Cache-Control: public, max-age=0, must-revalidate
|
||||||
|
|
||||||
|
/data.json
|
||||||
|
Cache-Control: public, max-age=0, must-revalidate
|
||||||
|
|
||||||
|
/app.js
|
||||||
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
|
/config.js
|
||||||
|
Cache-Control: public, max-age=0, must-revalidate
|
||||||
|
|
||||||
|
/router.js
|
||||||
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
|
/style.css
|
||||||
|
Cache-Control: public, max-age=300, must-revalidate
|
||||||
+34
-3
@@ -1,4 +1,30 @@
|
|||||||
/* global fetch, document */
|
/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */
|
||||||
|
|
||||||
|
/** OSS / CDN base for ability demos; empty => same-origin `/ability-video/...`. */
|
||||||
|
function abilityVideoBase() {
|
||||||
|
const raw =
|
||||||
|
typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : "";
|
||||||
|
return raw.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityVideoUrl(heroKey, abilityKey) {
|
||||||
|
const rel = `/ability-video/${heroKey}/${abilityKey}.webm`;
|
||||||
|
const base = abilityVideoBase();
|
||||||
|
return base ? `${base}${rel}` : rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteVersionLabel() {
|
||||||
|
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
|
||||||
|
return v ? `v${v}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPatchesSiteVersion() {
|
||||||
|
const el = $("#patches-site-version");
|
||||||
|
if (!el) return;
|
||||||
|
const label = siteVersionLabel();
|
||||||
|
el.textContent = label;
|
||||||
|
el.setAttribute("aria-hidden", label ? "false" : "true");
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
data: null,
|
data: null,
|
||||||
@@ -724,15 +750,17 @@ function mountSkillInspect(root, skillEnt, heroKey) {
|
|||||||
// Official-page layout: demo clip on the left, name/desc column on the right.
|
// Official-page layout: demo clip on the left, name/desc column on the right.
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "skill-info-body";
|
body.className = "skill-info-body";
|
||||||
// Official demo clip (assets/ability_videos); hidden when not cached locally.
|
// Official demo clip: local `/ability-video/...` (serve_relations) or OSS base
|
||||||
|
// from config.js (static export / Pages). Hidden when the file is missing.
|
||||||
if (heroKey && skillEnt.ability_key) {
|
if (heroKey && skillEnt.ability_key) {
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.className = "skill-info-video";
|
video.className = "skill-info-video";
|
||||||
video.src = `/ability-video/${heroKey}/${skillEnt.ability_key}.webm`;
|
video.src = abilityVideoUrl(heroKey, skillEnt.ability_key);
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.loop = true;
|
video.loop = true;
|
||||||
video.autoplay = true;
|
video.autoplay = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
|
video.crossOrigin = "anonymous";
|
||||||
video.addEventListener("error", () => video.remove());
|
video.addEventListener("error", () => video.remove());
|
||||||
body.appendChild(video);
|
body.appendChild(video);
|
||||||
}
|
}
|
||||||
@@ -1723,6 +1751,7 @@ function renderPatches() {
|
|||||||
if (select) select.innerHTML = "";
|
if (select) select.innerHTML = "";
|
||||||
if (verEl) verEl.textContent = "";
|
if (verEl) verEl.textContent = "";
|
||||||
root.innerHTML = '<div class="patches-empty">暂无版本数据,请运行 python fetch_patches.py</div>';
|
root.innerHTML = '<div class="patches-empty">暂无版本数据,请运行 python fetch_patches.py</div>';
|
||||||
|
renderPatchesSiteVersion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1753,6 +1782,7 @@ function renderPatches() {
|
|||||||
const det = details[state.selectedPatch];
|
const det = details[state.selectedPatch];
|
||||||
if (!det) {
|
if (!det) {
|
||||||
root.innerHTML = '<div class="patches-empty">该版本详情暂未抓取,请重跑 python fetch_patches.py</div>';
|
root.innerHTML = '<div class="patches-empty">该版本详情暂未抓取,请重跑 python fetch_patches.py</div>';
|
||||||
|
renderPatchesSiteVersion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1785,6 +1815,7 @@ function renderPatches() {
|
|||||||
.map((h) => renderHeroEntry(h, lookup))
|
.map((h) => renderHeroEntry(h, lookup))
|
||||||
.join("");
|
.join("");
|
||||||
renderPatchSection(root, "英雄改动", heroesHtml);
|
renderPatchSection(root, "英雄改动", heroesHtml);
|
||||||
|
renderPatchesSiteVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||||
|
var SITE_VERSION = "0.4.1";
|
||||||
|
var ABILITY_VIDEO_BASE = "";
|
||||||
@@ -12,6 +12,16 @@
|
|||||||
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
|
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
|
||||||
<span class="brand-title">上分帝</span>
|
<span class="brand-title">上分帝</span>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
class="contact-mail"
|
||||||
|
href="mailto:c9mhs8vfmuyv@outlook.com"
|
||||||
|
aria-label="邮件联系"
|
||||||
|
title="c9mhs8vfmuyv@outlook.com"
|
||||||
|
>
|
||||||
|
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
<nav class="main-tabs" aria-label="主分页">
|
<nav class="main-tabs" aria-label="主分页">
|
||||||
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
||||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||||
@@ -55,11 +65,13 @@
|
|||||||
<select id="patch-select" aria-label="选择版本"></select>
|
<select id="patch-select" aria-label="选择版本"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="patches-detail" id="patches-detail"></div>
|
<div class="patches-detail" id="patches-detail"></div>
|
||||||
|
<footer class="patches-site-version" id="patches-site-version" aria-hidden="true"></footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<section class="detail" id="detail" aria-live="polite"></section>
|
<section class="detail" id="detail" aria-live="polite"></section>
|
||||||
|
|
||||||
<script src="/router.js"></script>
|
<script src="/config.js?v=0.4.1"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/router.js?v=0.4.1"></script>
|
||||||
|
<script src="/app.js?v=0.4.1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -75,6 +75,34 @@ body {
|
|||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.brand { display: none; }
|
.brand { display: none; }
|
||||||
}
|
}
|
||||||
|
.contact-mail {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.contact-mail:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.contact-mail:focus-visible {
|
||||||
|
outline: 2px solid var(--sel);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.contact-mail-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.main-tabs {
|
.main-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1609,6 +1637,17 @@ body:has(#items-view:not(.hidden)) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.patches-site-version {
|
||||||
|
width: min(820px, 92%);
|
||||||
|
padding: 4px 0 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.55;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.patch-section {
|
.patch-section {
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user