"""Sync Climperor web site static images to Aliyun OSS (keyzoo inject). Builds the same portrait/item/ability/... tree as export_relations_site and uploads under oss:///{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 sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import oss2 from shared.paths 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", "role-icon", "rank", "item", "item-cat", "ability", "ui-icon", "portrait", "streamer-avatar", "streamer-video", ) 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()