Export and OSS upload require wide Heroes-page cards only; bump cache stamp to wide2 so clients drop polluted squares. Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""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.9"
|
||
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,
|
||
) -> 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()
|
||
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",
|
||
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}"
|
||
)
|
||
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()
|
||
|