Compare commits
2
Commits
124bdbb7c7
...
9898dd39fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9898dd39fb | ||
|
|
74deb17888 |
@@ -8,6 +8,10 @@ venv/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# OSS ability-video upload runtime (regenerable)
|
||||||
|
.oss_ability_videos_bucket
|
||||||
|
oss_upload.pid
|
||||||
|
|
||||||
# Runtime / debug artifacts (regenerable)
|
# Runtime / debug artifacts (regenerable)
|
||||||
preview/
|
preview/
|
||||||
results/
|
results/
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
|||||||
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
|
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
|
||||||
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
|
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
|
||||||
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json`) |
|
| `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` 可选本地拷贝视频,生产部署勿用) |
|
| `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` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS;凭据经 keyzoo 注入或 env) |
|
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
||||||
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
|
| `_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_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
|
||||||
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
|
| `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` + 图标 |
|
| `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_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
|
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
|
||||||
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
|
| `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 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
|
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
|
||||||
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
||||||
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
||||||
@@ -122,8 +123,9 @@ python item_fears.py # 英雄怕的装备
|
|||||||
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
|
||||||
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
|
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
|
||||||
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages)
|
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages)
|
||||||
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com
|
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 deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS)
|
||||||
|
python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSS(keyzoo voson-RAM)
|
||||||
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
||||||
python gsi_setup.py --check
|
python gsi_setup.py --check
|
||||||
python gsi_watch.py --once
|
python gsi_watch.py --once
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.4.1] - 2026-07-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ python deploy_relations.py
|
|||||||
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
|
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
|
||||||
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
|
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
|
||||||
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
|
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
|
||||||
# 技能演示视频默认走阿里云 OSS 桶 climperor(Pages 只部署 ~42MB 静态资源)
|
# 技能演示视频与静态图标默认走阿里云 OSS 桶 climperor(Pages 只部署 ~2MB HTML/JS/data)
|
||||||
|
python _oss_static_assets.py upload # patch/新英雄后同步图标到 OSS
|
||||||
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
|
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -43,6 +43,7 @@ ROOT = Path(__file__).resolve().parent
|
|||||||
DEFAULT_PROJECT = "climperor-relations"
|
DEFAULT_PROJECT = "climperor-relations"
|
||||||
DEFAULT_DOMAIN = "dota2.refining.dev"
|
DEFAULT_DOMAIN = "dota2.refining.dev"
|
||||||
DEFAULT_DIST = ROOT / "dist" / "relations"
|
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
|
# 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
|
# 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')}")
|
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."""
|
"""Re-run export_relations_site.py to refresh dist/relations."""
|
||||||
print("exporting static site ...")
|
print("exporting static site ...")
|
||||||
cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
|
cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
|
||||||
# Production Pages should load ability demos from OSS, not bundle GB of video.
|
oss = ability_video_base
|
||||||
base = ability_video_base
|
if oss is None:
|
||||||
if base is None:
|
oss = os.environ.get("ABILITY_VIDEO_BASE", DEFAULT_OSS_BASE)
|
||||||
base = os.environ.get(
|
static = static_asset_base
|
||||||
"ABILITY_VIDEO_BASE",
|
if static is None:
|
||||||
"https://climperor.oss-cn-shanghai.aliyuncs.com",
|
static = os.environ.get("STATIC_ASSET_BASE", oss or DEFAULT_OSS_BASE)
|
||||||
)
|
if oss:
|
||||||
if base:
|
cmd.extend(["--ability-video-base", oss])
|
||||||
cmd.extend(["--ability-video-base", base])
|
print(f"ABILITY_VIDEO_BASE={oss}")
|
||||||
print(f"ABILITY_VIDEO_BASE={base}")
|
if static:
|
||||||
|
cmd.extend(["--static-asset-base", static])
|
||||||
|
print(f"STATIC_ASSET_BASE={static}")
|
||||||
subprocess.run(cmd, cwd=str(ROOT), check=True)
|
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:
|
def check_integrity(dist: Path) -> None:
|
||||||
"""Abort if a hard-required asset directory is empty; warn otherwise."""
|
"""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] = []
|
problems: list[str] = []
|
||||||
for sub, required in INTEGRITY_DIRS.items():
|
for sub, required in INTEGRITY_DIRS.items():
|
||||||
d = dist / sub
|
d = dist / sub
|
||||||
@@ -191,7 +216,7 @@ def check_integrity(dist: Path) -> None:
|
|||||||
if hard:
|
if hard:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"aborting deploy: required asset dirs empty: {hard}. "
|
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 "
|
help="passed to export as --ability-video-base (default: OSS climperor endpoint "
|
||||||
"or ABILITY_VIDEO_BASE env)",
|
"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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
dist = Path(args.dist).resolve()
|
dist = Path(args.dist).resolve()
|
||||||
@@ -333,7 +364,7 @@ def main() -> None:
|
|||||||
ensure_project(email, api_key, account_id, args.project_name)
|
ensure_project(email, api_key, account_id, args.project_name)
|
||||||
|
|
||||||
if not args.no_export:
|
if not args.no_export:
|
||||||
run_export(args.ability_video_base)
|
run_export(args.ability_video_base, args.static_asset_base)
|
||||||
else:
|
else:
|
||||||
print("--no-export: using existing dist")
|
print("--no-export: using existing dist")
|
||||||
|
|
||||||
|
|||||||
+81
-49
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python export_relations_site.py [--out dist/relations] [--with-videos]
|
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) +
|
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
|
||||||
the referenced image assets into one directory, ready for any static host
|
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
|
- Ability videos (several GB locally) are skipped unless --with-videos is
|
||||||
given; production deploys point the UI at OSS via --ability-video-base
|
given; production deploys point the UI at OSS via --ability-video-base
|
||||||
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
|
(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
|
from __future__ import annotations
|
||||||
@@ -39,7 +41,8 @@ from common import (
|
|||||||
)
|
)
|
||||||
from serve_relations import WEB_DIR, build_payload
|
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:
|
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
|
return n
|
||||||
|
|
||||||
|
|
||||||
def write_config_js(out: Path, ability_video_base: str, site_version: str) -> None:
|
def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
|
||||||
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE)."""
|
"""Copy preview image dirs into ``out`` (portrait/item/ability/...)."""
|
||||||
base = (ability_video_base or "").strip().rstrip("/")
|
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()
|
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(
|
(out / "config.js").write_text(
|
||||||
f"/* generated by export_relations_site.py — do not edit */\n"
|
"/* generated by export_relations_site.py — do not edit */\n"
|
||||||
f"var SITE_VERSION = {ver_lit};\n"
|
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
|
||||||
f"var ABILITY_VIDEO_BASE = {base_lit};\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",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +128,13 @@ def main() -> None:
|
|||||||
help="public base URL for ability demos (writes config.js); "
|
help="public base URL for ability demos (writes config.js); "
|
||||||
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
|
"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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
video_base = (
|
video_base = (
|
||||||
@@ -92,6 +142,11 @@ def main() -> None:
|
|||||||
if args.ability_video_base is not None
|
if args.ability_video_base is not None
|
||||||
else os.environ.get("ABILITY_VIDEO_BASE", "")
|
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()
|
out = Path(args.out).resolve()
|
||||||
if out == ROOT.resolve() or out.parent == out:
|
if out == ROOT.resolve() or out.parent == out:
|
||||||
@@ -100,54 +155,28 @@ def main() -> None:
|
|||||||
shutil.rmtree(out)
|
shutil.rmtree(out)
|
||||||
out.mkdir(parents=True)
|
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"):
|
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
|
||||||
src = WEB_DIR / name
|
src = WEB_DIR / name
|
||||||
if src.is_file():
|
if src.is_file():
|
||||||
shutil.copy2(src, out / name)
|
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()
|
payload = build_payload()
|
||||||
(out / "data.json").write_text(
|
(out / "data.json").write_text(
|
||||||
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assets.
|
if static_base:
|
||||||
counts = {
|
counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")}
|
||||||
"attr": copy_glob(ATTR_ICONS, out / "attr"),
|
print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)")
|
||||||
"item": copy_glob(ITEM_ICONS, out / "item"),
|
else:
|
||||||
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
|
counts = populate_static_assets(out, payload)
|
||||||
"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
|
|
||||||
|
|
||||||
n_videos = 0
|
n_videos = 0
|
||||||
if args.with_videos and ABILITY_VIDEOS.is_dir():
|
if args.with_videos and ABILITY_VIDEOS.is_dir():
|
||||||
@@ -160,7 +189,10 @@ def main() -> None:
|
|||||||
print(f" {name}/: {n} files")
|
print(f" {name}/: {n} files")
|
||||||
if args.with_videos:
|
if args.with_videos:
|
||||||
print(f" ability-video/: {n_videos} files")
|
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" total: {total / 1e6:.1f} MB")
|
||||||
print(f"local check: python -m http.server -d {out} 8080")
|
print(f"local check: python -m http.server -d {out} 8080")
|
||||||
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
|
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
|
||||||
|
|||||||
+56
-20
@@ -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() {
|
function abilityVideoBase() {
|
||||||
const raw =
|
return trimBase(ABILITY_VIDEO_BASE);
|
||||||
typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : "";
|
|
||||||
return raw.replace(/\/+$/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function abilityVideoUrl(heroKey, abilityKey) {
|
function abilityVideoUrl(heroKey, abilityKey) {
|
||||||
@@ -13,6 +15,41 @@ function abilityVideoUrl(heroKey, abilityKey) {
|
|||||||
return base ? `${base}${rel}` : rel;
|
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() {
|
function siteVersionLabel() {
|
||||||
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
|
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
|
||||||
return v ? `v${v}` : "";
|
return v ? `v${v}` : "";
|
||||||
@@ -104,7 +141,7 @@ function renderBoard() {
|
|||||||
for (const attr of attr_order) {
|
for (const attr of attr_order) {
|
||||||
const col = document.createElement("section");
|
const col = document.createElement("section");
|
||||||
col.className = "col";
|
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");
|
const grid = document.createElement("div");
|
||||||
grid.className = "grid";
|
grid.className = "grid";
|
||||||
|
|
||||||
@@ -157,7 +194,7 @@ function renderBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = `portrait/${h.key}.png?v=wide`;
|
img.src = portraitSrc(h.key);
|
||||||
img.alt = h.name_loc;
|
img.alt = h.name_loc;
|
||||||
img.loading = "lazy";
|
img.loading = "lazy";
|
||||||
btn.appendChild(img);
|
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). */
|
/** Floating talent popover (body-level; avoids #detail overflow clipping). */
|
||||||
let talentPopoverEl = null;
|
let talentPopoverEl = null;
|
||||||
let talentPopoverHideTimer = 0;
|
let talentPopoverHideTimer = 0;
|
||||||
@@ -391,11 +425,11 @@ function wireTalentTrigger(btn, talents) {
|
|||||||
*/
|
*/
|
||||||
function setAbilityIcon(img, abilityKey, isInnate) {
|
function setAbilityIcon(img, abilityKey, isInnate) {
|
||||||
if (isInnate) {
|
if (isInnate) {
|
||||||
img.src = INNATE_ICON_SRC;
|
img.src = innateIconSrc();
|
||||||
img.onerror = null;
|
img.onerror = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
img.src = `ability/${encodeURIComponent(abilityKey)}.png`;
|
img.src = abilityIconSrc(abilityKey);
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
img.onerror = null;
|
img.onerror = null;
|
||||||
img.removeAttribute("src");
|
img.removeAttribute("src");
|
||||||
@@ -618,7 +652,7 @@ function buildSkillsPanel(heroKey) {
|
|||||||
talentBtn.setAttribute("aria-haspopup", "dialog");
|
talentBtn.setAttribute("aria-haspopup", "dialog");
|
||||||
talentBtn.title = "天赋树";
|
talentBtn.title = "天赋树";
|
||||||
const talentImg = document.createElement("img");
|
const talentImg = document.createElement("img");
|
||||||
talentImg.src = TALENT_TREE_ICON_SRC;
|
talentImg.src = talentTreeIconSrc();
|
||||||
talentImg.alt = "天赋树";
|
talentImg.alt = "天赋树";
|
||||||
talentImg.loading = "lazy";
|
talentImg.loading = "lazy";
|
||||||
talentBtn.appendChild(talentImg);
|
talentBtn.appendChild(talentImg);
|
||||||
@@ -836,7 +870,7 @@ function mountSkillInspect(root, skillEnt, heroKey) {
|
|||||||
cd.className = "skill-stat cooldown";
|
cd.className = "skill-stat cooldown";
|
||||||
const ico = document.createElement("img");
|
const ico = document.createElement("img");
|
||||||
ico.className = "skill-stat-icon";
|
ico.className = "skill-stat-icon";
|
||||||
ico.src = "ui-icon/cooldown.png";
|
ico.src = assetUrl("ui-icon/cooldown.png");
|
||||||
ico.alt = "";
|
ico.alt = "";
|
||||||
const txt = document.createElement("span");
|
const txt = document.createElement("span");
|
||||||
txt.className = "skill-stat-text";
|
txt.className = "skill-stat-text";
|
||||||
@@ -1150,7 +1184,7 @@ function buildHeroVitalsAttrs(hero) {
|
|||||||
(primary === a.key || primary === "all" ? " primary" : "");
|
(primary === a.key || primary === "all" ? " primary" : "");
|
||||||
cell.setAttribute("title", a.label);
|
cell.setAttribute("title", a.label);
|
||||||
cell.innerHTML =
|
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-base">${a.base}</span>` +
|
||||||
`<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`;
|
`<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`;
|
||||||
attrs.appendChild(cell);
|
attrs.appendChild(cell);
|
||||||
@@ -1447,7 +1481,7 @@ function appendShopColumn(parent, sec) {
|
|||||||
if (sec.icon) {
|
if (sec.icon) {
|
||||||
const cat = document.createElement("img");
|
const cat = document.createElement("img");
|
||||||
cat.className = "shop-column-cat";
|
cat.className = "shop-column-cat";
|
||||||
cat.src = `item-cat/${encodeURIComponent(sec.icon)}`;
|
cat.src = itemCatIconSrc(sec.icon);
|
||||||
cat.alt = sec.label || "";
|
cat.alt = sec.label || "";
|
||||||
cat.title = sec.label || "";
|
cat.title = sec.label || "";
|
||||||
col.appendChild(cat);
|
col.appendChild(cat);
|
||||||
@@ -1493,7 +1527,7 @@ function renderItemShop() {
|
|||||||
|
|
||||||
function itemIconSrc(key) {
|
function itemIconSrc(key) {
|
||||||
const stem = key.startsWith("recipe_") ? "recipe" : 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 } = {}) {
|
function makeCraftChip(key, { clickable = true, onPick = null } = {}) {
|
||||||
@@ -1594,7 +1628,7 @@ const STAT_ATTR_ICON = {
|
|||||||
function statChip(icon) {
|
function statChip(icon) {
|
||||||
if (!icon) return "";
|
if (!icon) return "";
|
||||||
const a = STAT_ATTR_ICON[icon];
|
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;
|
const label = STAT_LABELS[icon] || icon;
|
||||||
return `<span class="stat-chip">${escapeHtml(label)}</span>`;
|
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 aname = ameta ? ameta.name_loc : "# " + ab.ability_id;
|
||||||
const innate = akey && innateAbilityKeys().has(akey);
|
const innate = akey && innateAbilityKeys().has(akey);
|
||||||
const aicon = 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>`;
|
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 key = meta ? meta.key : "";
|
||||||
const name = meta ? meta.name_loc : "其它单位";
|
const name = meta ? meta.name_loc : "其它单位";
|
||||||
const portrait = key
|
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>`;
|
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);
|
html += renderHeroNotes(hero.hero_notes);
|
||||||
@@ -2032,6 +2066,8 @@ async function main() {
|
|||||||
);
|
);
|
||||||
bindSearch();
|
bindSearch();
|
||||||
bindPageChrome();
|
bindPageChrome();
|
||||||
|
const brandLogo = document.querySelector(".brand-logo");
|
||||||
|
if (brandLogo) brandLogo.src = assetUrl("ui-icon/dota2_logo.png");
|
||||||
const res = await fetch("data.json");
|
const res = await fetch("data.json");
|
||||||
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
|
||||||
state.data = await res.json();
|
state.data = await res.json();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* 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 ABILITY_VIDEO_BASE = "";
|
||||||
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
|
|
||||||
<section class="detail" id="detail" aria-live="polite"></section>
|
<section class="detail" id="detail" aria-live="polite"></section>
|
||||||
|
|
||||||
<script src="/config.js?v=0.4.1"></script>
|
<script src="/config.js?v=0.4.2"></script>
|
||||||
<script src="/router.js?v=0.4.1"></script>
|
<script src="/router.js?v=0.4.2"></script>
|
||||||
<script src="/app.js?v=0.4.1"></script>
|
<script src="/app.js?v=0.4.2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user