"""Export the Climperor web site as a static bundle (no server needed). Usage: python export_relations_site.py [--out dist/relations] [--with-videos] [--ability-video-base URL] [--static-asset-base URL] Copies web/relations/ + a snapshot of the /api/data payload (data.json) + the referenced image assets into one directory, ready for any static host (GitHub Pages, Cloudflare Pages, nginx, ...). Notes: - Only already-cached assets are exported. For full ability-icon coverage run `python fetch_hero_abilities.py --icons-only` first. - For patch-notes names/icons on the 版本 page, run `python fetch_patches.py` first (downloads referenced item + ability icons into assets/). - Ability videos (several GB locally) are skipped unless --with-videos is given; production deploys point the UI at OSS via --ability-video-base (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 import argparse import json import os import shutil from pathlib import Path from common import ( ABILITY_ICONS, ABILITY_VIDEOS, ATTR_ICONS, 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.5.59" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int: if not src.is_dir(): return 0 dst.mkdir(parents=True, exist_ok=True) n = 0 for f in sorted(src.glob(pattern)): if f.is_file(): shutil.copy2(f, dst / f.name) n += 1 return n def populate_static_assets(out: Path, payload: dict) -> dict[str, int]: """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) 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() (out / "config.js").write_text( "/* generated by export_relations_site.py — do not edit */\n" f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\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", ) def main() -> None: ap = argparse.ArgumentParser( description="Export Climperor web site as a static bundle" ) ap.add_argument("--out", default=str(ROOT / "dist" / "relations")) ap.add_argument( "--with-videos", action="store_true", 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)", ) 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() video_base = ( args.ability_video_base if args.ability_video_base is not None 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() if out == ROOT.resolve() or out.parent == out: raise SystemExit(f"refusing unsafe --out: {out}") if out.exists(): shutil.rmtree(out) out.mkdir(parents=True) 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, ability_video_base=video_base, static_asset_base=static_base, site_version=SITE_VERSION, ) payload = build_payload() (out / "data.json").write_text( json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8", ) if static_base: 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) n_videos = 0 if args.with_videos and ABILITY_VIDEOS.is_dir(): shutil.copytree(ABILITY_VIDEOS, out / "ability-video") n_videos = sum(1 for f in (out / "ability-video").rglob("*") if f.is_file()) total = sum(f.stat().st_size for f in out.rglob("*") if f.is_file()) print(f"exported static site -> {out}") for name, n in counts.items(): print(f" {name}/: {n} files") if args.with_videos: print(f" ability-video/: {n_videos} files") 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"local check: python -m http.server -d {out} 8080") print("deploy: upload the directory to any static host (Pages / nginx / ...)") if __name__ == "__main__": main()