Reorganize repository into pc web shared monorepo

Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 14:29:08 +08:00
co-authored by Cursor
parent 96a9312194
commit 9c5aa5b610
280 changed files with 1451 additions and 450 deletions
+189
View File
@@ -0,0 +1,189 @@
"""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://<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
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",
"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()