Compare commits

...
2 Commits
Author SHA1 Message Date
vosonandCursor 9898dd39fb v0.4.2: host preview icons on OSS for slim Pages deploys.
Add STATIC_ASSET_BASE so Cloudflare Pages ships ~2MB HTML/JS/data; icons served from climperor OSS via _oss_static_assets.py. New machines can deploy without local fetch scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:28:01 +08:00
vosonandCursor 74deb17888 Add OSS ability-video upload helpers and ignore runtime artifacts.
Commit _oss_*.py ops scripts alongside existing _cf_* helpers; gitignore bucket marker and upload PID.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 20:31:00 +08:00
13 changed files with 826 additions and 92 deletions
+4
View File
@@ -8,6 +8,10 @@ venv/
Thumbs.db
*.log
# OSS ability-video upload runtime (regenerable)
.oss_ability_videos_bucket
oss_upload.pid
# Runtime / debug artifacts (regenerable)
preview/
results/
+7 -5
View File
@@ -29,9 +29,10 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` `config.js` 指向 OSS 技能视频`--with-videos` 可选本地拷贝视频,生产部署勿用) |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS;凭据经 keyzoo 注入或 env |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` / `--static-asset-base``config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist`--with-videos` 可选本地拷贝视频,生产部署勿用) |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
| `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/``ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_static_assets.py` 同步图标等静态图;`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 |
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 |
@@ -57,7 +58,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` |
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` |
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
@@ -122,8 +123,9 @@ python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev技能视频走 OSS
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com --static-asset-base https://climperor.oss-cn-shanghai.aliyuncs.com
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS
python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSSkeyzoo voson-RAM
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
python gsi_setup.py --check
python gsi_watch.py --once
+13
View File
@@ -4,6 +4,19 @@
## [Unreleased]
## [0.4.2] - 2026-07-27
### Changed
- 关系预览静态图标(头像 / 装备 / 技能 / 属性等)改由阿里云 OSS 同桶托管;
`config.js` 新增 `STATIC_ASSET_BASE`,生产 Pages 包体约 **2 MB**(仅
HTML/JS/data),换机部署无需本地 fetch 图标。同步脚本
`_oss_static_assets.py upload`
### Added
- 新增 `_oss_static_assets.py`:将 portrait/item/ability 等图标同步到 OSS。
## [0.4.1] - 2026-07-27
### Changed
+2 -1
View File
@@ -51,7 +51,8 @@ python deploy_relations.py
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
# 技能演示视频默认走阿里云 OSS 桶 climperorPages 只部署 ~42MB 静态资源
# 技能演示视频与静态图标默认走阿里云 OSS 桶 climperorPages 只部署 ~2MB HTML/JS/data
python _oss_static_assets.py upload # patch/新英雄后同步图标到 OSS
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
```
+294
View File
@@ -0,0 +1,294 @@
"""One-shot helper: create OSS bucket + sync ability videos (keyzoo inject).
Env (from keyzoo asset_exec on digitevents/voson-RAM):
KEYZOO_ASSET_META_ACCESSKEY_ID
KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET
Usage:
python _oss_ability_videos.py setup
python _oss_ability_videos.py upload [--force]
python _oss_ability_videos.py verify
"""
from __future__ import annotations
import argparse
import mimetypes
import os
import sys
import time
from pathlib import Path
import oss2
from oss2.models import BucketCors, CorsRule
from common import ABILITY_VIDEOS, ROOT
BUCKET_CANDIDATES = ("climperor", "climperor-videos", "climperor-ability-videos")
ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
REGION = "cn-shanghai"
PREFIX = "ability-video/"
DEFAULT_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
CORS_ORIGINS = [
"https://dota2.refining.dev",
"https://climperor-relations.pages.dev",
"http://localhost:8080",
"http://localhost:8765",
"http://127.0.0.1:8080",
"http://127.0.0.1:8765",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
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 _auth() -> oss2.Auth:
ak, sk = _creds()
return oss2.Auth(ak, sk)
def _list_bucket_names(auth: oss2.Auth) -> list[str]:
service = oss2.Service(auth, ENDPOINT)
return [b.name for b in oss2.BucketIterator(service)]
def _bucket(auth: oss2.Auth, name: str) -> oss2.Bucket:
return oss2.Bucket(auth, ENDPOINT, name)
def setup_bucket() -> str:
auth = _auth()
existing = _list_bucket_names(auth)
print(f"existing buckets: {', '.join(existing) or '(none)'}")
chosen = None
for name in BUCKET_CANDIDATES:
if name in existing:
chosen = name
print(f"bucket_exists: {name}")
break
bucket = _bucket(auth, name)
try:
bucket.create_bucket(oss2.BUCKET_ACL_PUBLIC_READ)
chosen = name
print(f"created: {name}")
break
except oss2.exceptions.BucketAlreadyExists:
print(f"name_conflict (other account?): {name}")
continue
except oss2.exceptions.OssError as exc:
print(f"create_failed {name}: {exc.code} {exc.message[:160]}")
continue
if not chosen:
raise SystemExit("could not create or reuse any candidate bucket name")
bucket = _bucket(auth, chosen)
try:
bucket.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ)
print("acl: public-read")
except oss2.exceptions.OssError as exc:
print(f"acl_warn: {exc.code} {exc.message[:120]}")
rule = CorsRule(
allowed_origins=CORS_ORIGINS,
allowed_methods=["GET", "HEAD"],
allowed_headers=["*"],
expose_headers=["ETag", "Content-Type", "Content-Length"],
max_age_seconds=86400,
)
try:
bucket.put_bucket_cors(BucketCors([rule]))
print("cors: ok")
except oss2.exceptions.OssError as exc:
print(f"cors_warn: {exc.code} {exc.message[:200]}")
info = bucket.get_bucket_info()
base = f"https://{chosen}.oss-{REGION}.aliyuncs.com"
print(f"bucket: {chosen}")
print(f"location: {info.location}")
print(f"acl: {info.acl.grant if info.acl else '?'}")
print(f"public_base: {base}")
# Persist chosen name for upload step.
state = ROOT / ".oss_ability_videos_bucket"
state.write_text(chosen, encoding="utf-8")
return chosen
def _resolve_bucket_name(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 BUCKET_CANDIDATES[0]
def _iter_local_videos() -> list[Path]:
if not ABILITY_VIDEOS.is_dir():
raise SystemExit(f"missing local videos: {ABILITY_VIDEOS}")
files = sorted(
p
for p in ABILITY_VIDEOS.rglob("*")
if p.is_file() and p.suffix.lower() in {".webm", ".mp4"}
)
return files
def _object_key(local: Path) -> str:
rel = local.relative_to(ABILITY_VIDEOS).as_posix()
return PREFIX + rel
def upload(
bucket_name: str,
*,
force: bool = False,
max_files: int | None = None,
offset: int = 0,
) -> None:
auth = _auth()
bucket = _bucket(auth, bucket_name)
files = _iter_local_videos()
if offset or max_files is not None:
end = None if max_files is None else offset + max_files
files = files[offset:end]
total = len(files)
total_bytes = sum(f.stat().st_size for f in files)
print(
f"upload -> oss://{bucket_name}/{PREFIX} "
f"({total} files in this batch, {total_bytes / 1e9:.2f} GB; "
f"offset={offset})"
)
# Remote index for skip-if-same-size.
remote_sizes: dict[str, int] = {}
if not force:
print("listing remote objects ...")
for obj in oss2.ObjectIterator(bucket, prefix=PREFIX):
remote_sizes[obj.key] = int(obj.size)
print(f"remote objects under prefix: {len(remote_sizes)}")
uploaded = skipped = failed = 0
t0 = time.time()
for i, path in enumerate(files, 1):
key = _object_key(path)
size = path.stat().st_size
if not force and remote_sizes.get(key) == size:
skipped += 1
if i % 50 == 0 or i == total:
print(f"[{i}/{total}] skip {key}")
continue
headers = {}
ctype, _ = mimetypes.guess_type(str(path))
if path.suffix.lower() == ".webm":
ctype = "video/webm"
elif path.suffix.lower() == ".mp4":
ctype = "video/mp4"
if ctype:
headers["Content-Type"] = ctype
try:
# Resumable multipart for large files.
oss2.resumable_upload(
bucket,
key,
str(path),
headers=headers,
multipart_threshold=8 * 1024 * 1024,
part_size=8 * 1024 * 1024,
num_threads=4,
)
uploaded += 1
if i % 10 == 0 or i == total or uploaded <= 3:
elapsed = time.time() - t0
print(
f"[{i}/{total}] ok {key} "
f"({size / 1e6:.1f} MB) uploaded={uploaded} skipped={skipped} "
f"elapsed={elapsed:.0f}s"
)
except Exception as exc: # noqa: BLE001 — keep uploading rest
failed += 1
print(f"[{i}/{total}] FAIL {key}: {type(exc).__name__}: {exc}")
elapsed = time.time() - t0
print(
f"done: uploaded={uploaded} skipped={skipped} failed={failed} "
f"elapsed={elapsed:.0f}s"
)
if failed:
raise SystemExit(1)
def verify(bucket_name: str, samples: int = 5) -> None:
import urllib.request
auth = _auth()
bucket = _bucket(auth, bucket_name)
base = f"https://{bucket_name}.oss-{REGION}.aliyuncs.com"
files = _iter_local_videos()
# Prefer a few heroes spread across the alphabet.
picks = []
if files:
step = max(1, len(files) // samples)
picks = [files[i] for i in range(0, len(files), step)][:samples]
print(f"spot-check {len(picks)} URLs against {base}")
ok = 0
for path in picks:
key = _object_key(path)
url = f"{base}/{key}"
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=30) as resp:
code = resp.status
ctype = resp.headers.get("Content-Type", "")
clen = resp.headers.get("Content-Length", "")
print(f" {code} {ctype} len={clen} {url}")
if code == 200:
ok += 1
except Exception as exc: # noqa: BLE001
print(f" FAIL {url}: {exc}")
# Count remote.
n = sum(1 for _ in oss2.ObjectIterator(bucket, prefix=PREFIX))
print(f"remote object count under {PREFIX}: {n}")
print(f"spot-check ok: {ok}/{len(picks)}")
if ok < len(picks):
raise SystemExit(1)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=("setup", "upload", "verify", "setup-upload"))
ap.add_argument("--bucket", default=None)
ap.add_argument("--force", action="store_true")
ap.add_argument("--max-files", type=int, default=None, help="upload at most N files")
ap.add_argument("--offset", type=int, default=0, help="skip first N local files")
args = ap.parse_args()
if args.cmd in ("setup", "setup-upload"):
name = setup_bucket()
else:
name = _resolve_bucket_name(args.bucket)
if args.cmd in ("upload", "setup-upload"):
upload(name, force=args.force, max_files=args.max_files, offset=args.offset)
if args.cmd == "verify":
verify(name)
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
"""Fix climperor OSS public-read access (Block Public Access + policy)."""
from __future__ import annotations
import json
import os
import sys
import oss2
BUCKET = "climperor"
ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
POLICY = {
"Version": "1",
"Statement": [
{
"Sid": "PublicReadAbilityVideos",
"Effect": "Allow",
"Principal": "*",
"Action": ["oss:GetObject", "oss:GetObjectAcl"],
"Resource": [f"acs:oss:*:*:{BUCKET}/ability-video/*"],
}
],
}
def main() -> None:
ak = os.environ["KEYZOO_ASSET_META_ACCESSKEY_ID"]
sk = os.environ["KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET"]
auth = oss2.Auth(ak, sk)
b = oss2.Bucket(auth, ENDPOINT, BUCKET)
# 1) Try disable Block Public Access (required on newer Aliyun accounts).
try:
# oss2 >= 2.18: put_bucket_public_access_block(block_public_access=False)
if hasattr(b, "put_bucket_public_access_block"):
b.put_bucket_public_access_block(False)
print("public_access_block: disabled via SDK")
else:
# Raw REST: PUT /?publicAccessBlock with XML
xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<PublicAccessBlockConfiguration>"
"<BlockPublicAccess>false</BlockPublicAccess>"
"</PublicAccessBlockConfiguration>"
)
resp = b._do("PUT", "", params={"publicAccessBlock": ""}, data=xml)
print(f"public_access_block: raw PUT status={resp.status}")
except Exception as exc: # noqa: BLE001
print(f"public_access_block_warn: {type(exc).__name__}: {exc}")
# 2) Bucket ACL public-read (may be denied by account policy).
try:
b.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ)
print("acl: public-read set")
except Exception as exc: # noqa: BLE001
print(f"acl_warn: {type(exc).__name__}: {exc}")
# 3) Bucket policy for anonymous GetObject under ability-video/.
try:
b.put_bucket_policy(json.dumps(POLICY))
print("policy: public GetObject on ability-video/*")
except Exception as exc: # noqa: BLE001
print(f"policy_fail: {type(exc).__name__}: {exc}")
raise SystemExit(1)
info = b.get_bucket_info()
print(f"acl_now: {info.acl.grant if info.acl else '?'}")
# 4) Probe one known object if any exist.
import urllib.request
sample = None
for obj in oss2.ObjectIterator(b, prefix="ability-video/", max_keys=1):
sample = obj.key
break
if not sample:
print("no objects yet to probe")
return
url = f"https://{BUCKET}.oss-cn-shanghai.aliyuncs.com/{sample}"
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=20) as resp:
print(f"probe: {resp.status} {resp.headers.get('Content-Type')} {url}")
except Exception as exc: # noqa: BLE001
print(f"probe_fail: {exc}")
# Try signed URL to confirm object exists
signed = b.sign_url("HEAD", sample, 60)
print(f"signed_head_url_len={len(signed)} (object exists check via SDK)")
try:
meta = b.head_object(sample)
print(f"sdk_head: ok content_type={meta.content_type} size={meta.content_length}")
except Exception as exc2: # noqa: BLE001
print(f"sdk_head_fail: {exc2}")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
"""Launch OSS upload as a detached process inheriting current env (keyzoo inject)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
LOG = ROOT / "oss_upload.log"
ERR = ROOT / "oss_upload_err.log"
PID = ROOT / "oss_upload.pid"
def main() -> None:
if not os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") and not os.environ.get(
"OSS_ACCESS_KEY_SECRET"
):
raise SystemExit("missing AccessKey secret in env")
# Clear previous logs.
for p in (LOG, ERR):
if p.exists():
p.unlink()
creationflags = 0
if sys.platform == "win32":
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
out = open(LOG, "w", encoding="utf-8")
err = open(ERR, "w", encoding="utf-8")
proc = subprocess.Popen(
[sys.executable, str(ROOT / "_oss_ability_videos.py"), "upload"],
cwd=str(ROOT),
stdout=out,
stderr=err,
env=os.environ.copy(),
creationflags=creationflags,
close_fds=True,
)
PID.write_text(str(proc.pid), encoding="utf-8")
print(f"started_pid={proc.pid}")
print(f"log={LOG}")
if __name__ == "__main__":
main()
+176
View File
@@ -0,0 +1,176 @@
"""Sync relations preview 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
import oss2
from common 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", "item", "item-cat", "ability", "ui-icon", "portrait")
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()
+44 -13
View File
@@ -43,6 +43,7 @@ ROOT = Path(__file__).resolve().parent
DEFAULT_PROJECT = "climperor-relations"
DEFAULT_DOMAIN = "dota2.refining.dev"
DEFAULT_DIST = ROOT / "dist" / "relations"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
# Directories export_relations_site.py writes under dist/relations. Keys are
# the sub-directory names; values flag whether an empty dir is a hard failure
@@ -148,25 +149,49 @@ def ensure_project(email: str, api_key: str, account_id: str, project: str) -> N
raise SystemExit(f"create project failed: {d.get('errors')}")
def run_export(ability_video_base: str | None = None) -> None:
def run_export(
ability_video_base: str | None = None,
static_asset_base: str | None = None,
) -> None:
"""Re-run export_relations_site.py to refresh dist/relations."""
print("exporting static site ...")
cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
# Production Pages should load ability demos from OSS, not bundle GB of video.
base = ability_video_base
if base is None:
base = os.environ.get(
"ABILITY_VIDEO_BASE",
"https://climperor.oss-cn-shanghai.aliyuncs.com",
)
if base:
cmd.extend(["--ability-video-base", base])
print(f"ABILITY_VIDEO_BASE={base}")
oss = ability_video_base
if oss is None:
oss = os.environ.get("ABILITY_VIDEO_BASE", DEFAULT_OSS_BASE)
static = static_asset_base
if static is None:
static = os.environ.get("STATIC_ASSET_BASE", oss or DEFAULT_OSS_BASE)
if oss:
cmd.extend(["--ability-video-base", oss])
print(f"ABILITY_VIDEO_BASE={oss}")
if static:
cmd.extend(["--static-asset-base", static])
print(f"STATIC_ASSET_BASE={static}")
subprocess.run(cmd, cwd=str(ROOT), check=True)
def read_config_js_var(dist: Path, name: str) -> str:
"""Parse a string literal from generated config.js."""
cfg = dist / "config.js"
if not cfg.is_file():
return ""
prefix = f"var {name} = "
for line in cfg.read_text(encoding="utf-8").splitlines():
if line.startswith(prefix):
return json.loads(line[len(prefix) :].rstrip(";"))
return ""
def check_integrity(dist: Path) -> None:
"""Abort if a hard-required asset directory is empty; warn otherwise."""
static_base = read_config_js_var(dist, "STATIC_ASSET_BASE")
if static_base:
print(
f"asset integrity check: skipped local dirs "
f"(STATIC_ASSET_BASE={static_base})"
)
return
problems: list[str] = []
for sub, required in INTEGRITY_DIRS.items():
d = dist / sub
@@ -191,7 +216,7 @@ def check_integrity(dist: Path) -> None:
if hard:
raise SystemExit(
f"aborting deploy: required asset dirs empty: {hard}. "
"Run the fetch scripts first so the exported site is complete."
"Run the fetch scripts first, or set STATIC_ASSET_BASE for OSS hosting."
)
@@ -322,6 +347,12 @@ def main() -> None:
help="passed to export as --ability-video-base (default: OSS climperor endpoint "
"or ABILITY_VIDEO_BASE env)",
)
ap.add_argument(
"--static-asset-base",
default=None,
help="passed to export as --static-asset-base (default: same as ability-video-base "
"or STATIC_ASSET_BASE env)",
)
args = ap.parse_args()
dist = Path(args.dist).resolve()
@@ -333,7 +364,7 @@ def main() -> None:
ensure_project(email, api_key, account_id, args.project_name)
if not args.no_export:
run_export(args.ability_video_base)
run_export(args.ability_video_base, args.static_asset_base)
else:
print("--no-export: using existing dist")
+81 -49
View File
@@ -2,7 +2,7 @@
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL]
[--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
@@ -16,6 +16,8 @@ Notes:
- 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
@@ -39,7 +41,8 @@ from common import (
)
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.4.1"
SITE_VERSION = "0.4.2"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
@@ -54,17 +57,57 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
return n
def write_config_js(out: Path, ability_video_base: str, site_version: str) -> None:
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE)."""
base = (ability_video_base or "").strip().rstrip("/")
def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
"""Copy preview image dirs into ``out`` (portrait/item/ability/...)."""
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"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"),
}
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()
# JSON-encode so values are safe JS string literals.
ver_lit = json.dumps(ver, ensure_ascii=False)
base_lit = json.dumps(base, ensure_ascii=False)
(out / "config.js").write_text(
f"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {ver_lit};\n"
f"var ABILITY_VIDEO_BASE = {base_lit};\n",
"/* 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",
)
@@ -85,6 +128,13 @@ def main() -> 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 = (
@@ -92,6 +142,11 @@ def main() -> None:
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:
@@ -100,54 +155,28 @@ def main() -> None:
shutil.rmtree(out)
out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
# `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS).
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, video_base, SITE_VERSION)
write_config_js(
out,
ability_video_base=video_base,
static_asset_base=static_base,
site_version=SITE_VERSION,
)
# Data snapshot (same payload as serve_relations /api/data).
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
# Assets.
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"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"),
}
# Portraits: official wide cards, fall back to CDN match templates.
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
# Non-hero units in patch lookup (e.g. 熊灵) use bundled portraits too.
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
if static_base:
counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")}
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():
@@ -160,7 +189,10 @@ def main() -> None:
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} ABILITY_VIDEO_BASE={video_base!r}")
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 / ...)")
+56 -20
View File
@@ -1,10 +1,12 @@
/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */
/** OSS / CDN base for ability demos; empty => same-origin `/ability-video/...`. */
function trimBase(raw) {
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
}
/** OSS base for ability demos; empty => same-origin `/ability-video/...`. */
function abilityVideoBase() {
const raw =
typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : "";
return raw.replace(/\/+$/, "");
return trimBase(ABILITY_VIDEO_BASE);
}
function abilityVideoUrl(heroKey, abilityKey) {
@@ -13,6 +15,41 @@ function abilityVideoUrl(heroKey, abilityKey) {
return base ? `${base}${rel}` : rel;
}
/** OSS base for icons/portraits; empty => same-origin relative paths. */
function staticAssetBase() {
return trimBase(STATIC_ASSET_BASE);
}
function assetUrl(rel) {
const clean = String(rel || "").replace(/^\//, "");
const base = staticAssetBase();
return base ? `${base}/${clean}` : clean;
}
function attrIconSrc(key) {
return assetUrl(`attr/${key}.png`);
}
function portraitSrc(key) {
return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`);
}
function abilityIconSrc(abilityKey) {
return assetUrl(`ability/${encodeURIComponent(abilityKey)}.png`);
}
function innateIconSrc() {
return assetUrl("ability/innate.png");
}
function talentTreeIconSrc() {
return assetUrl("ability/talent_tree.png");
}
function itemCatIconSrc(icon) {
return assetUrl(`item-cat/${encodeURIComponent(icon)}`);
}
function siteVersionLabel() {
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
return v ? `v${v}` : "";
@@ -104,7 +141,7 @@ function renderBoard() {
for (const attr of attr_order) {
const col = document.createElement("section");
col.className = "col";
col.innerHTML = `<div class="col-head"><img class="attr-icon" src="attr/${attr}.png" alt="${attr_labels[attr]}" /><span>${attr_labels[attr]}</span></div>`;
col.innerHTML = `<div class="col-head"><img class="attr-icon" src="${attrIconSrc(attr)}" alt="${attr_labels[attr]}" /><span>${attr_labels[attr]}</span></div>`;
const grid = document.createElement("div");
grid.className = "grid";
@@ -157,7 +194,7 @@ function renderBoard() {
}
const img = document.createElement("img");
img.src = `portrait/${h.key}.png?v=wide`;
img.src = portraitSrc(h.key);
img.alt = h.name_loc;
img.loading = "lazy";
btn.appendChild(img);
@@ -243,9 +280,6 @@ function heroAbilityCell(heroKey) {
};
}
const INNATE_ICON_SRC = "ability/innate.png";
const TALENT_TREE_ICON_SRC = "ability/talent_tree.png";
/** Floating talent popover (body-level; avoids #detail overflow clipping). */
let talentPopoverEl = null;
let talentPopoverHideTimer = 0;
@@ -391,11 +425,11 @@ function wireTalentTrigger(btn, talents) {
*/
function setAbilityIcon(img, abilityKey, isInnate) {
if (isInnate) {
img.src = INNATE_ICON_SRC;
img.src = innateIconSrc();
img.onerror = null;
return;
}
img.src = `ability/${encodeURIComponent(abilityKey)}.png`;
img.src = abilityIconSrc(abilityKey);
img.onerror = () => {
img.onerror = null;
img.removeAttribute("src");
@@ -618,7 +652,7 @@ function buildSkillsPanel(heroKey) {
talentBtn.setAttribute("aria-haspopup", "dialog");
talentBtn.title = "天赋树";
const talentImg = document.createElement("img");
talentImg.src = TALENT_TREE_ICON_SRC;
talentImg.src = talentTreeIconSrc();
talentImg.alt = "天赋树";
talentImg.loading = "lazy";
talentBtn.appendChild(talentImg);
@@ -836,7 +870,7 @@ function mountSkillInspect(root, skillEnt, heroKey) {
cd.className = "skill-stat cooldown";
const ico = document.createElement("img");
ico.className = "skill-stat-icon";
ico.src = "ui-icon/cooldown.png";
ico.src = assetUrl("ui-icon/cooldown.png");
ico.alt = "";
const txt = document.createElement("span");
txt.className = "skill-stat-text";
@@ -1150,7 +1184,7 @@ function buildHeroVitalsAttrs(hero) {
(primary === a.key || primary === "all" ? " primary" : "");
cell.setAttribute("title", a.label);
cell.innerHTML =
`<img class="attr-icon" src="attr/${a.key}.png" alt="${a.label}" />` +
`<img class="attr-icon" src="${attrIconSrc(a.key)}" alt="${a.label}" />` +
`<span class="hero-stat-attr-base">${a.base}</span>` +
`<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`;
attrs.appendChild(cell);
@@ -1447,7 +1481,7 @@ function appendShopColumn(parent, sec) {
if (sec.icon) {
const cat = document.createElement("img");
cat.className = "shop-column-cat";
cat.src = `item-cat/${encodeURIComponent(sec.icon)}`;
cat.src = itemCatIconSrc(sec.icon);
cat.alt = sec.label || "";
cat.title = sec.label || "";
col.appendChild(cat);
@@ -1493,7 +1527,7 @@ function renderItemShop() {
function itemIconSrc(key) {
const stem = key.startsWith("recipe_") ? "recipe" : key;
return `item/${encodeURIComponent(stem)}.png`;
return assetUrl(`item/${encodeURIComponent(stem)}.png`);
}
function makeCraftChip(key, { clickable = true, onPick = null } = {}) {
@@ -1594,7 +1628,7 @@ const STAT_ATTR_ICON = {
function statChip(icon) {
if (!icon) return "";
const a = STAT_ATTR_ICON[icon];
if (a) return `<img class="stat-icon" src="attr/${a}.png" alt="" onerror="this.style.visibility='hidden'">`;
if (a) return `<img class="stat-icon" src="${attrIconSrc(a)}" alt="" onerror="this.style.visibility='hidden'">`;
const label = STAT_LABELS[icon] || icon;
return `<span class="stat-chip">${escapeHtml(label)}</span>`;
}
@@ -1655,7 +1689,7 @@ function patchAbilityBlockHtml(ab, lookup) {
const aname = ameta ? ameta.name_loc : "# " + ab.ability_id;
const innate = akey && innateAbilityKeys().has(akey);
const aicon = akey
? `<img class="patch-ability-icon" src="${innate ? INNATE_ICON_SRC : "ability/" + encodeURIComponent(akey) + ".png"}" alt="" onerror="this.style.visibility='hidden'">`
? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="this.style.visibility='hidden'">`
: "";
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
}
@@ -1675,7 +1709,7 @@ function renderHeroEntry(hero, lookup) {
const key = meta ? meta.key : "";
const name = meta ? meta.name_loc : "其它单位";
const portrait = key
? `<img class="patch-hero-icon" src="portrait/${encodeURIComponent(key)}.png?v=wide" alt="" onerror="this.style.visibility='hidden'">`
? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
: "";
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
html += renderHeroNotes(hero.hero_notes);
@@ -2032,6 +2066,8 @@ async function main() {
);
bindSearch();
bindPageChrome();
const brandLogo = document.querySelector(".brand-logo");
if (brandLogo) brandLogo.src = assetUrl("ui-icon/dota2_logo.png");
const res = await fetch("data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json();
+2 -1
View File
@@ -1,3 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.4.1";
var SITE_VERSION = "0.4.2";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+3 -3
View File
@@ -70,8 +70,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.4.1"></script>
<script src="/router.js?v=0.4.1"></script>
<script src="/app.js?v=0.4.1"></script>
<script src="/config.js?v=0.4.2"></script>
<script src="/router.js?v=0.4.2"></script>
<script src="/app.js?v=0.4.2"></script>
</body>
</html>