v0.4.2: host preview icons on OSS for slim Pages deploys.

Add STATIC_ASSET_BASE so Cloudflare Pages ships ~2MB HTML/JS/data; icons served from climperor OSS via _oss_static_assets.py. New machines can deploy without local fetch scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-27 21:28:01 +08:00
co-authored by Cursor
parent 74deb17888
commit 9898dd39fb
9 changed files with 384 additions and 93 deletions
+7 -6
View File
@@ -29,10 +29,10 @@ 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 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` `config.js` 指向 OSS 技能视频`--with-videos` 可选本地拷贝视频,生产部署勿用) | | `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` / `--static-asset-base``config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist`--with-videos` 可选本地拷贝视频,生产部署勿用) |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS;凭据经 keyzoo 注入或 env | | `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) | | `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
| `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/``ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 | | `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/``ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_static_assets.py` 同步图标等静态图;`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 |
| `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/` |
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 | | `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 |
@@ -58,7 +58,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` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` | | `web/relations/` | 关系只读预览页静态资源(`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 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) | | `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) | | `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 | | `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
@@ -123,8 +123,9 @@ 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 export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com --static-asset-base https://climperor.oss-cn-shanghai.aliyuncs.com
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev技能视频走 OSS python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS
python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSSkeyzoo voson-RAM
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
+13
View File
@@ -4,6 +4,19 @@
## [Unreleased] ## [Unreleased]
## [0.4.2] - 2026-07-27
### Changed
- 关系预览静态图标(头像 / 装备 / 技能 / 属性等)改由阿里云 OSS 同桶托管;
`config.js` 新增 `STATIC_ASSET_BASE`,生产 Pages 包体约 **2 MB**(仅
HTML/JS/data),换机部署无需本地 fetch 图标。同步脚本
`_oss_static_assets.py upload`
### Added
- 新增 `_oss_static_assets.py`:将 portrait/item/ability 等图标同步到 OSS。
## [0.4.1] - 2026-07-27 ## [0.4.1] - 2026-07-27
### Changed ### Changed
+2 -1
View File
@@ -51,7 +51,8 @@ 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 桶 climperorPages 只部署 ~42MB 静态资源 # 技能演示视频与静态图标默认走阿里云 OSS 桶 climperorPages 只部署 ~2MB HTML/JS/data
python _oss_static_assets.py upload # patch/新英雄后同步图标到 OSS
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态) python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
``` ```
+176
View File
@@ -0,0 +1,176 @@
"""Sync relations preview 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,...}/.
Env (from keyzoo asset_exec on digitevents/voson-RAM):
KEYZOO_ASSET_META_ACCESSKEY_ID
KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET
Usage:
python _oss_static_assets.py upload [--force]
python _oss_static_assets.py verify
"""
from __future__ import annotations
import argparse
import mimetypes
import os
import shutil
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
import oss2
from common import ROOT
from export_relations_site import DEFAULT_OSS_BASE, populate_static_assets
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")
def _creds() -> tuple[str, str]:
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
"OSS_ACCESS_KEY_ID"
)
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
"OSS_ACCESS_KEY_SECRET"
)
if not ak or not sk:
raise SystemExit(
"missing AccessKey: inject via keyzoo or set OSS_ACCESS_KEY_ID / "
"OSS_ACCESS_KEY_SECRET"
)
return ak, sk
def _bucket(name: str) -> oss2.Bucket:
return oss2.Bucket(oss2.Auth(*_creds()), ENDPOINT, name)
def _resolve_bucket(explicit: str | None) -> str:
if explicit:
return explicit
state = ROOT / ".oss_ability_videos_bucket"
if state.is_file():
return state.read_text(encoding="utf-8").strip()
return "climperor"
def _build_staging() -> tuple[Path, dict[str, int]]:
payload = build_payload()
staging = Path(tempfile.mkdtemp(prefix="climperor-static-"))
counts = populate_static_assets(staging, payload)
return staging, counts
def _iter_files(root: Path) -> list[Path]:
files: list[Path] = []
for sub in ASSET_DIRS:
d = root / sub
if not d.is_dir():
continue
files.extend(sorted(p for p in d.rglob("*") if p.is_file()))
return files
def upload(bucket_name: str, *, force: bool = False) -> None:
staging, counts = _build_staging()
try:
files = _iter_files(staging)
total_bytes = sum(f.stat().st_size for f in files)
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
for name, n in counts.items():
print(f" {name}/: {n}")
bucket = _bucket(bucket_name)
remote_sizes: dict[str, int] = {}
if not force:
print("listing remote static objects ...")
for sub in ASSET_DIRS:
for obj in oss2.ObjectIterator(bucket, prefix=f"{sub}/"):
remote_sizes[obj.key] = int(obj.size)
print(f"remote keys indexed: {len(remote_sizes)}")
uploaded = skipped = failed = 0
t0 = time.time()
for i, path in enumerate(files, 1):
key = path.relative_to(staging).as_posix()
size = path.stat().st_size
if not force and remote_sizes.get(key) == size:
skipped += 1
continue
headers: dict[str, str] = {}
ctype, _ = mimetypes.guess_type(str(path))
if ctype:
headers["Content-Type"] = ctype
try:
bucket.put_object_from_file(key, str(path), headers=headers)
uploaded += 1
if i % 100 == 0 or i == len(files) or uploaded <= 5:
print(f"[{i}/{len(files)}] ok {key} ({size} bytes)")
except Exception as exc: # noqa: BLE001
failed += 1
print(f"[{i}/{len(files)}] FAIL {key}: {exc}")
elapsed = time.time() - t0
print(
f"done: uploaded={uploaded} skipped={skipped} failed={failed} "
f"elapsed={elapsed:.0f}s"
)
if failed:
raise SystemExit(1)
finally:
shutil.rmtree(staging, ignore_errors=True)
def verify(bucket_name: str, samples: int = 8) -> None:
staging, _ = _build_staging()
try:
files = _iter_files(staging)
if not files:
raise SystemExit("no files in staging — run fetch scripts first")
base = f"https://{bucket_name}.oss-{REGION}.aliyuncs.com"
step = max(1, len(files) // samples)
picks = [files[i] for i in range(0, len(files), step)][:samples]
ok = 0
for path in picks:
key = path.relative_to(staging).as_posix()
url = f"{base}/{key}"
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=30) as resp:
print(f" {resp.status} {resp.headers.get('Content-Type', '')} {url}")
if resp.status == 200:
ok += 1
except Exception as exc: # noqa: BLE001
print(f" FAIL {url}: {exc}")
print(f"spot-check ok: {ok}/{len(picks)}")
if ok < len(picks):
raise SystemExit(1)
finally:
shutil.rmtree(staging, ignore_errors=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=("upload", "verify"))
ap.add_argument("--bucket", default=None)
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
name = _resolve_bucket(args.bucket)
print(f"bucket: {name} public_base: {DEFAULT_OSS_BASE}")
if args.cmd == "upload":
upload(name, force=args.force)
else:
verify(name)
if __name__ == "__main__":
main()
+44 -13
View File
@@ -43,6 +43,7 @@ ROOT = Path(__file__).resolve().parent
DEFAULT_PROJECT = "climperor-relations" DEFAULT_PROJECT = "climperor-relations"
DEFAULT_DOMAIN = "dota2.refining.dev" DEFAULT_DOMAIN = "dota2.refining.dev"
DEFAULT_DIST = ROOT / "dist" / "relations" DEFAULT_DIST = ROOT / "dist" / "relations"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
# Directories export_relations_site.py writes under dist/relations. Keys are # Directories export_relations_site.py writes under dist/relations. Keys are
# the sub-directory names; values flag whether an empty dir is a hard failure # the sub-directory names; values flag whether an empty dir is a hard failure
@@ -148,25 +149,49 @@ 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(ability_video_base: str | None = None) -> None: def run_export(
ability_video_base: str | None = None,
static_asset_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 ...")
cmd = [sys.executable, str(ROOT / "export_relations_site.py")] cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
# Production Pages should load ability demos from OSS, not bundle GB of video. oss = ability_video_base
base = ability_video_base if oss is None:
if base is None: oss = os.environ.get("ABILITY_VIDEO_BASE", DEFAULT_OSS_BASE)
base = os.environ.get( static = static_asset_base
"ABILITY_VIDEO_BASE", if static is None:
"https://climperor.oss-cn-shanghai.aliyuncs.com", static = os.environ.get("STATIC_ASSET_BASE", oss or DEFAULT_OSS_BASE)
) if oss:
if base: cmd.extend(["--ability-video-base", oss])
cmd.extend(["--ability-video-base", base]) print(f"ABILITY_VIDEO_BASE={oss}")
print(f"ABILITY_VIDEO_BASE={base}") if static:
cmd.extend(["--static-asset-base", static])
print(f"STATIC_ASSET_BASE={static}")
subprocess.run(cmd, cwd=str(ROOT), check=True) subprocess.run(cmd, cwd=str(ROOT), check=True)
def read_config_js_var(dist: Path, name: str) -> str:
"""Parse a string literal from generated config.js."""
cfg = dist / "config.js"
if not cfg.is_file():
return ""
prefix = f"var {name} = "
for line in cfg.read_text(encoding="utf-8").splitlines():
if line.startswith(prefix):
return json.loads(line[len(prefix) :].rstrip(";"))
return ""
def check_integrity(dist: Path) -> None: def check_integrity(dist: Path) -> None:
"""Abort if a hard-required asset directory is empty; warn otherwise.""" """Abort if a hard-required asset directory is empty; warn otherwise."""
static_base = read_config_js_var(dist, "STATIC_ASSET_BASE")
if static_base:
print(
f"asset integrity check: skipped local dirs "
f"(STATIC_ASSET_BASE={static_base})"
)
return
problems: list[str] = [] problems: list[str] = []
for sub, required in INTEGRITY_DIRS.items(): for sub, required in INTEGRITY_DIRS.items():
d = dist / sub d = dist / sub
@@ -191,7 +216,7 @@ def check_integrity(dist: Path) -> None:
if hard: if hard:
raise SystemExit( raise SystemExit(
f"aborting deploy: required asset dirs empty: {hard}. " f"aborting deploy: required asset dirs empty: {hard}. "
"Run the fetch scripts first so the exported site is complete." "Run the fetch scripts first, or set STATIC_ASSET_BASE for OSS hosting."
) )
@@ -322,6 +347,12 @@ def main() -> None:
help="passed to export as --ability-video-base (default: OSS climperor endpoint " help="passed to export as --ability-video-base (default: OSS climperor endpoint "
"or ABILITY_VIDEO_BASE env)", "or ABILITY_VIDEO_BASE env)",
) )
ap.add_argument(
"--static-asset-base",
default=None,
help="passed to export as --static-asset-base (default: same as ability-video-base "
"or STATIC_ASSET_BASE env)",
)
args = ap.parse_args() args = ap.parse_args()
dist = Path(args.dist).resolve() dist = Path(args.dist).resolve()
@@ -333,7 +364,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(args.ability_video_base) run_export(args.ability_video_base, args.static_asset_base)
else: else:
print("--no-export: using existing dist") print("--no-export: using existing dist")
+81 -49
View File
@@ -2,7 +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] [--ability-video-base URL] [--static-asset-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
@@ -16,6 +16,8 @@ Notes:
- Ability videos (several GB locally) are skipped unless --with-videos is - Ability videos (several GB locally) are skipped unless --with-videos is
given; production deploys point the UI at OSS via --ability-video-base given; production deploys point the UI at OSS via --ability-video-base
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages. (or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
- When --static-asset-base is set, image dirs are omitted from dist/ and the
UI loads icons/portraits from OSS (see _oss_static_assets.py to sync).
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,7 +41,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" SITE_VERSION = "0.4.2"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int: def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
@@ -54,17 +57,57 @@ 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: def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE).""" """Copy preview image dirs into ``out`` (portrait/item/ability/...)."""
base = (ability_video_base or "").strip().rstrip("/") counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"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"),
}
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
for hero in payload.get("heroes") or []:
key = hero.get("key")
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if not src.is_file():
src = TEMPLATES_CDN / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
key = cell.get("key") if isinstance(cell, dict) else None
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
return counts
def write_config_js(
out: Path,
*,
ability_video_base: str,
static_asset_base: str,
site_version: str,
) -> None:
"""Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/")
ver = (site_version or "").strip() 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( (out / "config.js").write_text(
f"/* generated by export_relations_site.py — do not edit */\n" "/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {ver_lit};\n" f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
f"var ABILITY_VIDEO_BASE = {base_lit};\n", f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -85,6 +128,13 @@ def main() -> None:
help="public base URL for ability demos (writes config.js); " help="public base URL for ability demos (writes config.js); "
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)", "falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
) )
ap.add_argument(
"--static-asset-base",
default=None,
help="public base URL for icons/portraits (writes config.js); "
"when set, image dirs are not copied into dist; "
"falls back to STATIC_ASSET_BASE env, else empty",
)
args = ap.parse_args() args = ap.parse_args()
video_base = ( video_base = (
@@ -92,6 +142,11 @@ def main() -> None:
if args.ability_video_base is not None if args.ability_video_base is not None
else os.environ.get("ABILITY_VIDEO_BASE", "") else os.environ.get("ABILITY_VIDEO_BASE", "")
) )
static_base = (
args.static_asset_base
if args.static_asset_base is not None
else os.environ.get("STATIC_ASSET_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:
@@ -100,54 +155,28 @@ def main() -> None:
shutil.rmtree(out) shutil.rmtree(out)
out.mkdir(parents=True) out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
# `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS).
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"): for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
src = WEB_DIR / name src = WEB_DIR / name
if src.is_file(): if src.is_file():
shutil.copy2(src, out / name) shutil.copy2(src, out / name)
write_config_js(out, video_base, SITE_VERSION) write_config_js(
out,
ability_video_base=video_base,
static_asset_base=static_base,
site_version=SITE_VERSION,
)
# Data snapshot (same payload as serve_relations /api/data).
payload = build_payload() payload = build_payload()
(out / "data.json").write_text( (out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")), json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8", encoding="utf-8",
) )
# Assets. if static_base:
counts = { counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")}
"attr": copy_glob(ATTR_ICONS, out / "attr"), print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)")
"item": copy_glob(ITEM_ICONS, out / "item"), else:
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"), counts = populate_static_assets(out, payload)
"ability": copy_glob(ABILITY_ICONS, out / "ability"),
"ui-icon": copy_glob(UI_ICONS, out / "ui-icon"),
}
# Portraits: official wide cards, fall back to CDN match templates.
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
for hero in payload.get("heroes") or []:
key = hero.get("key")
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if not src.is_file():
src = TEMPLATES_CDN / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
# Non-hero units in patch lookup (e.g. 熊灵) use bundled portraits too.
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
key = cell.get("key") if isinstance(cell, dict) else None
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
n_videos = 0 n_videos = 0
if args.with_videos and ABILITY_VIDEOS.is_dir(): if args.with_videos and ABILITY_VIDEOS.is_dir():
@@ -160,7 +189,10 @@ 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" config.js SITE_VERSION={SITE_VERSION!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_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 / ...)")
+56 -20
View File
@@ -1,10 +1,12 @@
/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */ /* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */
/** OSS / CDN base for ability demos; empty => same-origin `/ability-video/...`. */ function trimBase(raw) {
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
}
/** OSS base for ability demos; empty => same-origin `/ability-video/...`. */
function abilityVideoBase() { function abilityVideoBase() {
const raw = return trimBase(ABILITY_VIDEO_BASE);
typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : "";
return raw.replace(/\/+$/, "");
} }
function abilityVideoUrl(heroKey, abilityKey) { function abilityVideoUrl(heroKey, abilityKey) {
@@ -13,6 +15,41 @@ function abilityVideoUrl(heroKey, abilityKey) {
return base ? `${base}${rel}` : rel; return base ? `${base}${rel}` : rel;
} }
/** OSS base for icons/portraits; empty => same-origin relative paths. */
function staticAssetBase() {
return trimBase(STATIC_ASSET_BASE);
}
function assetUrl(rel) {
const clean = String(rel || "").replace(/^\//, "");
const base = staticAssetBase();
return base ? `${base}/${clean}` : clean;
}
function attrIconSrc(key) {
return assetUrl(`attr/${key}.png`);
}
function portraitSrc(key) {
return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`);
}
function abilityIconSrc(abilityKey) {
return assetUrl(`ability/${encodeURIComponent(abilityKey)}.png`);
}
function innateIconSrc() {
return assetUrl("ability/innate.png");
}
function talentTreeIconSrc() {
return assetUrl("ability/talent_tree.png");
}
function itemCatIconSrc(icon) {
return assetUrl(`item-cat/${encodeURIComponent(icon)}`);
}
function siteVersionLabel() { function siteVersionLabel() {
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
return v ? `v${v}` : ""; return v ? `v${v}` : "";
@@ -104,7 +141,7 @@ function renderBoard() {
for (const attr of attr_order) { for (const attr of attr_order) {
const col = document.createElement("section"); const col = document.createElement("section");
col.className = "col"; col.className = "col";
col.innerHTML = `<div class="col-head"><img class="attr-icon" src="attr/${attr}.png" alt="${attr_labels[attr]}" /><span>${attr_labels[attr]}</span></div>`; col.innerHTML = `<div class="col-head"><img class="attr-icon" src="${attrIconSrc(attr)}" alt="${attr_labels[attr]}" /><span>${attr_labels[attr]}</span></div>`;
const grid = document.createElement("div"); const grid = document.createElement("div");
grid.className = "grid"; grid.className = "grid";
@@ -157,7 +194,7 @@ function renderBoard() {
} }
const img = document.createElement("img"); const img = document.createElement("img");
img.src = `portrait/${h.key}.png?v=wide`; img.src = portraitSrc(h.key);
img.alt = h.name_loc; img.alt = h.name_loc;
img.loading = "lazy"; img.loading = "lazy";
btn.appendChild(img); btn.appendChild(img);
@@ -243,9 +280,6 @@ function heroAbilityCell(heroKey) {
}; };
} }
const INNATE_ICON_SRC = "ability/innate.png";
const TALENT_TREE_ICON_SRC = "ability/talent_tree.png";
/** Floating talent popover (body-level; avoids #detail overflow clipping). */ /** Floating talent popover (body-level; avoids #detail overflow clipping). */
let talentPopoverEl = null; let talentPopoverEl = null;
let talentPopoverHideTimer = 0; let talentPopoverHideTimer = 0;
@@ -391,11 +425,11 @@ function wireTalentTrigger(btn, talents) {
*/ */
function setAbilityIcon(img, abilityKey, isInnate) { function setAbilityIcon(img, abilityKey, isInnate) {
if (isInnate) { if (isInnate) {
img.src = INNATE_ICON_SRC; img.src = innateIconSrc();
img.onerror = null; img.onerror = null;
return; return;
} }
img.src = `ability/${encodeURIComponent(abilityKey)}.png`; img.src = abilityIconSrc(abilityKey);
img.onerror = () => { img.onerror = () => {
img.onerror = null; img.onerror = null;
img.removeAttribute("src"); img.removeAttribute("src");
@@ -618,7 +652,7 @@ function buildSkillsPanel(heroKey) {
talentBtn.setAttribute("aria-haspopup", "dialog"); talentBtn.setAttribute("aria-haspopup", "dialog");
talentBtn.title = "天赋树"; talentBtn.title = "天赋树";
const talentImg = document.createElement("img"); const talentImg = document.createElement("img");
talentImg.src = TALENT_TREE_ICON_SRC; talentImg.src = talentTreeIconSrc();
talentImg.alt = "天赋树"; talentImg.alt = "天赋树";
talentImg.loading = "lazy"; talentImg.loading = "lazy";
talentBtn.appendChild(talentImg); talentBtn.appendChild(talentImg);
@@ -836,7 +870,7 @@ function mountSkillInspect(root, skillEnt, heroKey) {
cd.className = "skill-stat cooldown"; cd.className = "skill-stat cooldown";
const ico = document.createElement("img"); const ico = document.createElement("img");
ico.className = "skill-stat-icon"; ico.className = "skill-stat-icon";
ico.src = "ui-icon/cooldown.png"; ico.src = assetUrl("ui-icon/cooldown.png");
ico.alt = ""; ico.alt = "";
const txt = document.createElement("span"); const txt = document.createElement("span");
txt.className = "skill-stat-text"; txt.className = "skill-stat-text";
@@ -1150,7 +1184,7 @@ function buildHeroVitalsAttrs(hero) {
(primary === a.key || primary === "all" ? " primary" : ""); (primary === a.key || primary === "all" ? " primary" : "");
cell.setAttribute("title", a.label); cell.setAttribute("title", a.label);
cell.innerHTML = cell.innerHTML =
`<img class="attr-icon" src="attr/${a.key}.png" alt="${a.label}" />` + `<img class="attr-icon" src="${attrIconSrc(a.key)}" alt="${a.label}" />` +
`<span class="hero-stat-attr-base">${a.base}</span>` + `<span class="hero-stat-attr-base">${a.base}</span>` +
`<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`; `<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`;
attrs.appendChild(cell); attrs.appendChild(cell);
@@ -1447,7 +1481,7 @@ function appendShopColumn(parent, sec) {
if (sec.icon) { if (sec.icon) {
const cat = document.createElement("img"); const cat = document.createElement("img");
cat.className = "shop-column-cat"; cat.className = "shop-column-cat";
cat.src = `item-cat/${encodeURIComponent(sec.icon)}`; cat.src = itemCatIconSrc(sec.icon);
cat.alt = sec.label || ""; cat.alt = sec.label || "";
cat.title = sec.label || ""; cat.title = sec.label || "";
col.appendChild(cat); col.appendChild(cat);
@@ -1493,7 +1527,7 @@ function renderItemShop() {
function itemIconSrc(key) { function itemIconSrc(key) {
const stem = key.startsWith("recipe_") ? "recipe" : key; const stem = key.startsWith("recipe_") ? "recipe" : key;
return `item/${encodeURIComponent(stem)}.png`; return assetUrl(`item/${encodeURIComponent(stem)}.png`);
} }
function makeCraftChip(key, { clickable = true, onPick = null } = {}) { function makeCraftChip(key, { clickable = true, onPick = null } = {}) {
@@ -1594,7 +1628,7 @@ const STAT_ATTR_ICON = {
function statChip(icon) { function statChip(icon) {
if (!icon) return ""; if (!icon) return "";
const a = STAT_ATTR_ICON[icon]; const a = STAT_ATTR_ICON[icon];
if (a) return `<img class="stat-icon" src="attr/${a}.png" alt="" onerror="this.style.visibility='hidden'">`; if (a) return `<img class="stat-icon" src="${attrIconSrc(a)}" alt="" onerror="this.style.visibility='hidden'">`;
const label = STAT_LABELS[icon] || icon; const label = STAT_LABELS[icon] || icon;
return `<span class="stat-chip">${escapeHtml(label)}</span>`; return `<span class="stat-chip">${escapeHtml(label)}</span>`;
} }
@@ -1655,7 +1689,7 @@ function patchAbilityBlockHtml(ab, lookup) {
const aname = ameta ? ameta.name_loc : "# " + ab.ability_id; const aname = ameta ? ameta.name_loc : "# " + ab.ability_id;
const innate = akey && innateAbilityKeys().has(akey); const innate = akey && innateAbilityKeys().has(akey);
const aicon = akey const aicon = akey
? `<img class="patch-ability-icon" src="${innate ? INNATE_ICON_SRC : "ability/" + encodeURIComponent(akey) + ".png"}" alt="" onerror="this.style.visibility='hidden'">` ? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="this.style.visibility='hidden'">`
: ""; : "";
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`; return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
} }
@@ -1675,7 +1709,7 @@ function renderHeroEntry(hero, lookup) {
const key = meta ? meta.key : ""; const key = meta ? meta.key : "";
const name = meta ? meta.name_loc : "其它单位"; const name = meta ? meta.name_loc : "其它单位";
const portrait = key const portrait = key
? `<img class="patch-hero-icon" src="portrait/${encodeURIComponent(key)}.png?v=wide" alt="" onerror="this.style.visibility='hidden'">` ? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
: ""; : "";
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`; let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
html += renderHeroNotes(hero.hero_notes); html += renderHeroNotes(hero.hero_notes);
@@ -2032,6 +2066,8 @@ async function main() {
); );
bindSearch(); bindSearch();
bindPageChrome(); bindPageChrome();
const brandLogo = document.querySelector(".brand-logo");
if (brandLogo) brandLogo.src = assetUrl("ui-icon/dota2_logo.png");
const res = await fetch("data.json"); const res = await fetch("data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json(); state.data = await res.json();
+2 -1
View File
@@ -1,3 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */ /* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.4.1"; var SITE_VERSION = "0.4.2";
var ABILITY_VIDEO_BASE = ""; var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+3 -3
View File
@@ -70,8 +70,8 @@
<section class="detail" id="detail" aria-live="polite"></section> <section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.4.1"></script> <script src="/config.js?v=0.4.2"></script>
<script src="/router.js?v=0.4.1"></script> <script src="/router.js?v=0.4.2"></script>
<script src="/app.js?v=0.4.1"></script> <script src="/app.js?v=0.4.2"></script>
</body> </body>
</html> </html>