Files
climperor/web/export_relations_site.py
vosonandCursor 820c3fb1f1 Ship item nicknames, Tiny fear override, and ensure-match API.
Add 冰眼/蛇矛 aliases, correct Tiny fears to Hydra's Breath, and fix production match detail POST 405.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:05:48 +08:00

325 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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] [--site-origin URL]
Copies web/frontend/ + 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, ...).
Also runs seo_prerender: crawlable HTML for heroes/mechanics/top pages,
plus sitemap.xml / llms.txt / robots.txt / _redirects (History SPA).
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 sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import shutil
from shared.http_utils import write_json_atomic
from shared.paths import (
ABILITY_ICONS,
ABILITY_VIDEOS,
ATTR_ICONS,
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
RANK_ICONS,
ROLE_ICONS,
ROOT,
STREAMER_AVATARS,
STREAMER_VIDEOS,
UI_ICONS,
WEB_DIST,
)
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.6.57"
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"),
"role-icon": copy_glob(ROLE_ICONS, out / "role-icon"),
"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.iterdir()):
if not f.is_file() or f.name.startswith("_"):
continue
name = f.name.lower()
if not name.endswith(
(".mp4", ".webm", ".jpg", ".jpeg", ".webp", ".png")
):
continue
shutil.copy2(f, dst / f.name)
n_vid += 1
counts["streamer-video"] = n_vid
# Wide Heroes-page cards only — never fall back to pc/templates/cdn
# (96×96 match crops). That fallback previously overwrote OSS portraits.
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
missing: list[str] = []
for hero in payload.get("heroes") or []:
key = hero.get("key")
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
else:
missing.append(key)
# Patch-only units (e.g. spirit_bear) are optional; copy when present.
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 or (portrait_dst / f"{key}.png").is_file():
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
if missing:
sample = ", ".join(missing[:8])
more = f" (+{len(missing) - 8} more)" if len(missing) > 8 else ""
raise SystemExit(
f"missing {len(missing)} wide hero portrait(s) under "
f"{HERO_PORTRAITS}: {sample}{more}. "
"Run: python web/fetch_hero_portraits.py"
)
return counts
def write_config_js(
out: Path,
*,
ability_video_base: str,
static_asset_base: str,
site_version: str,
site_origin: str,
players_asset_base: str = "",
) -> None:
"""Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/")
players = (players_asset_base or "").strip().rstrip("/") or static
ver = (site_version or "").strip()
origin = (site_origin or "").strip().rstrip("/")
(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 SITE_ORIGIN = {json.dumps(origin, 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"
f"var PLAYERS_ASSET_BASE = {json.dumps(players, 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(WEB_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",
)
ap.add_argument(
"--site-origin",
default=None,
help="canonical site origin for SEO (sitemap / og / config SITE_ORIGIN); "
"falls back to SITE_ORIGIN env, else https://dota2.refining.dev",
)
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", "")
)
site_origin = (
args.site_origin
if args.site_origin is not None
else os.environ.get("SITE_ORIGIN", DEFAULT_SITE_ORIGIN)
)
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",
"mobile-gate.js",
"_headers",
"_redirects",
"robots.txt",
):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
fonts_src = WEB_DIR / "fonts"
if fonts_src.is_dir():
shutil.copytree(fonts_src, out / "fonts")
# Cloudflare Pages Functions (functions/api/*.js -> /api/*).
functions_src = WEB_DIR / "functions"
n_functions = 0
if functions_src.is_dir():
shutil.copytree(functions_src, out / "functions")
n_functions = sum(1 for f in (out / "functions").rglob("*") if f.is_file())
write_config_js(
out,
ability_video_base=video_base,
static_asset_base=static_base,
site_version=SITE_VERSION,
site_origin=site_origin,
)
payload = build_payload()
refresh_run_id = os.environ.get("REFRESH_RUN_ID") or ""
payload.setdefault("meta", {})["refresh_run_id"] = refresh_run_id
write_json_atomic(
out / "data.json",
payload,
indent=None,
separators=(",", ":"),
)
template_html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
seo_counts = write_seo_bundle(
out,
template_html,
payload,
site_origin=site_origin,
)
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")
print(
f" seo prerender: top={seo_counts['top']} "
f"heroes={seo_counts['heroes']} mechanics={seo_counts['mechanics']} "
f"(+ sitemap.xml / llms.txt / robots.txt)"
)
if n_functions:
print(f" functions/: {n_functions} files (Pages Functions)")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(
f" config.js SITE_VERSION={SITE_VERSION!r} SITE_ORIGIN={site_origin!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r} "
f"PLAYERS_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
print(
f"local Pages Function check: npx wrangler pages dev . "
f"--port 8789 (run from {out})"
)
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
if __name__ == "__main__":
main()