diff --git a/AGENTS.md b/AGENTS.md index b39f78e..1efd091 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,8 +29,8 @@ 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 快照 + 前端 + 图片;`--with-videos` 含视频) | -| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;凭据经 keyzoo 注入或 env) | +| `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) | | `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) | | `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) | | `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` | @@ -57,7 +57,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` / `app.js` / `style.css` / `router.js`) | +| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION` 与 `ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` | | `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) | | `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) | | `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 | @@ -68,7 +68,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 | `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) | | `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 | | `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) | -| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取) | +| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) | 运行时产物(**勿提交**,见 `.gitignore`): @@ -122,7 +122,8 @@ python item_fears.py # 英雄怕的装备 python fetch_patches.py # 近一年版本日志 + 详情(版本页) python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店 python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages) -python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev) +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 _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态 python gsi_setup.py --check python gsi_watch.py --once @@ -138,6 +139,7 @@ python evaluate.py - 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。 - 改预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`。 - 改 `data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles` 供 `overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。 +- 发版关系预览时同步 bump `export_relations_site.py` 的 `SITE_VERSION`、`web/relations/config.js` 的同名变量,以及 `index.html` 里 `config.js`/`router.js`/`app.js` 的 `?v=` 缓存戳;并写 `CHANGELOG.md`。 - 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。 ## 文档分工 diff --git a/CHANGELOG.md b/CHANGELOG.md index 516707a..601eb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [0.4.1] - 2026-07-27 + +### Changed + +- 线上关系预览技能演示视频改由阿里云 OSS 托管(桶 `climperor`, + `https://climperor.oss-cn-shanghai.aliyuncs.com/ability-video/...`),不再打进 + Cloudflare Pages;本地 `serve_relations.py` 仍读 `assets/ability_videos/`。 + 导出写入 `config.js` 的 `ABILITY_VIDEO_BASE`(`export_relations_site.py + --ability-video-base` / `deploy_relations.py` 默认 OSS 地址)。 + +### Added + +- 版本页底部居中显示站点版本号(`config.js` 的 `SITE_VERSION`,如 `v0.4.1`)。 + +### Fixed + +- 版本页熊灵(spirit bear)头像:静态导出包含 `portrait/spirit_bear.png`。 + ## [0.4.0] - 2026-07-27 ### Added diff --git a/README.md b/README.md index 6ad666e..dd26731 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ 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 桶 climperor(Pages 只部署 ~42MB 静态资源) python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态) ``` diff --git a/deploy_relations.py b/deploy_relations.py index 5057820..6082c53 100644 --- a/deploy_relations.py +++ b/deploy_relations.py @@ -148,14 +148,21 @@ 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() -> None: +def run_export(ability_video_base: str | None = None) -> None: """Re-run export_relations_site.py to refresh dist/relations.""" print("exporting static site ...") - subprocess.run( - [sys.executable, str(ROOT / "export_relations_site.py")], - cwd=str(ROOT), - check=True, - ) + 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}") + subprocess.run(cmd, cwd=str(ROOT), check=True) def check_integrity(dist: Path) -> None: @@ -309,6 +316,12 @@ def main() -> None: ap.add_argument("--project-name", default=DEFAULT_PROJECT) ap.add_argument("--domain", default=DEFAULT_DOMAIN) ap.add_argument("--dist", default=str(DEFAULT_DIST)) + ap.add_argument( + "--ability-video-base", + default=None, + help="passed to export as --ability-video-base (default: OSS climperor endpoint " + "or ABILITY_VIDEO_BASE env)", + ) args = ap.parse_args() dist = Path(args.dist).resolve() @@ -320,7 +333,7 @@ def main() -> None: ensure_project(email, api_key, account_id, args.project_name) if not args.no_export: - run_export() + run_export(args.ability_video_base) else: print("--no-export: using existing dist") diff --git a/export_relations_site.py b/export_relations_site.py index 2db6b00..ebe04e3 100644 --- a/export_relations_site.py +++ b/export_relations_site.py @@ -2,6 +2,7 @@ Usage: python export_relations_site.py [--out dist/relations] [--with-videos] + [--ability-video-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 @@ -13,13 +14,15 @@ Notes: - For patch-notes names/icons on the 版本 page, run `python fetch_patches.py` first (downloads referenced item + ability icons into assets/). - Ability videos (several GB locally) are skipped unless --with-videos is - given; the UI degrades quietly when a video is missing. + given; production deploys point the UI at OSS via --ability-video-base + (or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages. """ from __future__ import annotations import argparse import json +import os import shutil from pathlib import Path @@ -36,6 +39,8 @@ from common import ( ) from serve_relations import WEB_DIR, build_payload +SITE_VERSION = "0.4.1" + def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int: if not src.is_dir(): @@ -49,6 +54,21 @@ 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("/") + 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", + encoding="utf-8", + ) + + def main() -> None: ap = argparse.ArgumentParser( description="Export relations preview as a static site" @@ -59,8 +79,20 @@ def main() -> None: action="store_true", help="also copy assets/ability_videos (several GB)", ) + ap.add_argument( + "--ability-video-base", + default=None, + help="public base URL for ability demos (writes config.js); " + "falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)", + ) args = ap.parse_args() + video_base = ( + args.ability_video_base + if args.ability_video_base is not None + else os.environ.get("ABILITY_VIDEO_BASE", "") + ) + out = Path(args.out).resolve() if out == ROOT.resolve() or out.parent == out: raise SystemExit(f"refusing unsafe --out: {out}") @@ -69,8 +101,12 @@ def main() -> None: out.mkdir(parents=True) # Frontend (uses relative paths — works from any sub-path). - for name in ("index.html", "router.js", "app.js", "style.css"): - shutil.copy2(WEB_DIR / name, out / name) + # `_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) # Data snapshot (same payload as serve_relations /api/data). payload = build_payload() @@ -124,6 +160,7 @@ 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" 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 / ...)") diff --git a/fetch_patches.py b/fetch_patches.py index 7e97487..faf5be3 100644 --- a/fetch_patches.py +++ b/fetch_patches.py @@ -33,8 +33,8 @@ import urllib.error from datetime import datetime, timedelta, timezone from pathlib import Path -from common import ABILITY_ICONS, DATA, ITEM_ICONS -from http_utils import http_json +from common import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS +from http_utils import http_bytes, http_json PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese" PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese" @@ -44,6 +44,13 @@ ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/ ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json" ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png" ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png" +# Hero-card CDN (same as fetch_hero_portraits). Non-hero units may only exist at half res. +HERO_CARD_URL = ( + "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png" +) +# Prefer ability art when Valve's hero-card for a unit is tiny (spirit_bear is 128x72). +UNIT_PORTRAIT_FALLBACK_ABILITY = {"spirit_bear": "lone_druid_spirit_bear"} +PORTRAIT_TARGET_SIZE = (256, 144) OUT = DATA / "patches.json" HERO_ABILITIES_PATH = DATA / "hero_abilities.json" @@ -242,6 +249,86 @@ def build_lookup( return {"items": items, "abilities": abilities, "heroes": heroes} +def _png_size(data: bytes) -> tuple[int, int] | None: + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": + return None + import struct + + return struct.unpack(">II", data[16:24]) + + +def _cover_resize_png(data: bytes, size: tuple[int, int] = PORTRAIT_TARGET_SIZE) -> bytes: + """Center-crop / scale image bytes to a 16:9 hero-card PNG.""" + import cv2 + import numpy as np + + arr = np.frombuffer(data, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + if img is None: + raise ValueError("cv2 could not decode image") + if img.ndim == 2: + img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + elif img.shape[2] == 4: + img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) + h, w = img.shape[:2] + tw, th = size + scale = max(tw / w, th / h) + nw, nh = max(tw, int(round(w * scale))), max(th, int(round(h * scale))) + resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC) + x0 = max(0, (nw - tw) // 2) + y0 = max(0, (nh - th) // 2) + crop = resized[y0 : y0 + th, x0 : x0 + tw] + ok, buf = cv2.imencode(".png", crop) + if not ok: + raise ValueError("cv2 could not encode png") + return buf.tobytes() + + +def download_unit_portraits(*, delay: float) -> None: + """Fetch portraits for UNIT_NAMES into hero_portraits (not in heroes.json). + + Valve ships some unit cards at half resolution (spirit_bear 128x72). When a + fallback ability icon is configured, use that art cover-cropped to 256x144 + so the patch page matches normal hero cards. + """ + HERO_PORTRAITS.mkdir(parents=True, exist_ok=True) + ok = skip = fail = 0 + for unit in UNIT_NAMES.values(): + key = unit["key"] + out = HERO_PORTRAITS / f"{key}.png" + if out.is_file(): + dims = _png_size(out.read_bytes()) + if dims == PORTRAIT_TARGET_SIZE: + skip += 1 + continue + try: + fb = UNIT_PORTRAIT_FALLBACK_ABILITY.get(key) + if fb: + raw = http_bytes(ABILITY_ICON_URL.format(key=fb), timeout=30) + data = _cover_resize_png(raw) + src = f"ability:{fb}" + else: + raw = http_bytes(HERO_CARD_URL.format(key=key), timeout=30) + dims = _png_size(raw) + data = ( + _cover_resize_png(raw) + if dims and dims != PORTRAIT_TARGET_SIZE + else raw + ) + src = "hero-card" + out.write_bytes(data) + ok += 1 + print(f" unit portrait {key} <- {src} ({len(data)} bytes)", flush=True) + time.sleep(max(delay, 0.05)) + except Exception as e: # noqa: BLE001 + fail += 1 + print(f" FAIL unit portrait {key}: {e}", flush=True) + print( + f" unit portraits saved={ok} skipped={skip} fail={fail} -> {HERO_PORTRAITS}", + flush=True, + ) + + def download_referenced_icons(lookup: dict, *, delay: float) -> None: """Pull referenced item + ability icons into assets/ (skip existing).""" from http_utils import download_icons @@ -266,6 +353,10 @@ def download_referenced_icons(lookup: dict, *, delay: float) -> None: ) print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True) + # Pseudo-heroes in patchnotes (e.g. 熊灵) are absent from herolist / heroes.json. + print("downloading unit portraits for patch lookup ...", flush=True) + download_unit_portraits(delay=delay) + def load_existing_details() -> dict[str, dict]: if not OUT.is_file(): diff --git a/web/relations/_headers b/web/relations/_headers new file mode 100644 index 0000000..d3498f1 --- /dev/null +++ b/web/relations/_headers @@ -0,0 +1,20 @@ +/* + X-Content-Type-Options: nosniff + +/index.html + Cache-Control: public, max-age=0, must-revalidate + +/data.json + Cache-Control: public, max-age=0, must-revalidate + +/app.js + Cache-Control: public, max-age=60, must-revalidate + +/config.js + Cache-Control: public, max-age=0, must-revalidate + +/router.js + Cache-Control: public, max-age=60, must-revalidate + +/style.css + Cache-Control: public, max-age=300, must-revalidate diff --git a/web/relations/app.js b/web/relations/app.js index 6e9474c..6a0b28f 100644 --- a/web/relations/app.js +++ b/web/relations/app.js @@ -1,4 +1,30 @@ -/* global fetch, document */ +/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */ + +/** OSS / CDN 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(/\/+$/, ""); +} + +function abilityVideoUrl(heroKey, abilityKey) { + const rel = `/ability-video/${heroKey}/${abilityKey}.webm`; + const base = abilityVideoBase(); + return base ? `${base}${rel}` : rel; +} + +function siteVersionLabel() { + const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; + return v ? `v${v}` : ""; +} + +function renderPatchesSiteVersion() { + const el = $("#patches-site-version"); + if (!el) return; + const label = siteVersionLabel(); + el.textContent = label; + el.setAttribute("aria-hidden", label ? "false" : "true"); +} const state = { data: null, @@ -724,15 +750,17 @@ function mountSkillInspect(root, skillEnt, heroKey) { // Official-page layout: demo clip on the left, name/desc column on the right. const body = document.createElement("div"); body.className = "skill-info-body"; - // Official demo clip (assets/ability_videos); hidden when not cached locally. + // Official demo clip: local `/ability-video/...` (serve_relations) or OSS base + // from config.js (static export / Pages). Hidden when the file is missing. if (heroKey && skillEnt.ability_key) { const video = document.createElement("video"); video.className = "skill-info-video"; - video.src = `/ability-video/${heroKey}/${skillEnt.ability_key}.webm`; + video.src = abilityVideoUrl(heroKey, skillEnt.ability_key); video.muted = true; video.loop = true; video.autoplay = true; video.playsInline = true; + video.crossOrigin = "anonymous"; video.addEventListener("error", () => video.remove()); body.appendChild(video); } @@ -1723,6 +1751,7 @@ function renderPatches() { if (select) select.innerHTML = ""; if (verEl) verEl.textContent = ""; root.innerHTML = '
暂无版本数据,请运行 python fetch_patches.py
'; + renderPatchesSiteVersion(); return; } @@ -1753,6 +1782,7 @@ function renderPatches() { const det = details[state.selectedPatch]; if (!det) { root.innerHTML = '
该版本详情暂未抓取,请重跑 python fetch_patches.py
'; + renderPatchesSiteVersion(); return; } @@ -1785,6 +1815,7 @@ function renderPatches() { .map((h) => renderHeroEntry(h, lookup)) .join(""); renderPatchSection(root, "英雄改动", heroesHtml); + renderPatchesSiteVersion(); } function escapeHtml(s) { diff --git a/web/relations/config.js b/web/relations/config.js new file mode 100644 index 0000000..048c60c --- /dev/null +++ b/web/relations/config.js @@ -0,0 +1,3 @@ +/* Local defaults; production export overwrites via export_relations_site.py. */ +var SITE_VERSION = "0.4.1"; +var ABILITY_VIDEO_BASE = ""; diff --git a/web/relations/index.html b/web/relations/index.html index e24d815..5229d9f 100644 --- a/web/relations/index.html +++ b/web/relations/index.html @@ -12,6 +12,16 @@ 上分帝 + + +