diff --git a/AGENTS.md b/AGENTS.md index 1bff11f..b39f78e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 | `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) | +| `_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/` | | `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `data/item_shop.json` + 图标 | @@ -120,6 +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 _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态 python gsi_setup.py --check python gsi_watch.py --once python recognize.py samples/raw/<帧>.png --sheet diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3665f..516707a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ 本文件记录 **上分帝(Climperor)** 的用户可见变更。格式参考 [Keep a Changelog](https://keepachangelog.com/)。 +## [Unreleased] + +## [0.4.0] - 2026-07-27 + +### Added + +- 新增 `deploy_relations.py`:一键把关系预览静态站点部署到 Cloudflare Pages + —— 串联导出(`export_relations_site.py`)→ 资产完整性预检(缺图则中止) + → `wrangler pages deploy` 直传 `dist/relations/` → 绑定自定义域名。凭据 + 经 keyzoo 注入(`refining/cloudflare` 的 Global API Key)或手动 + `CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` 环境变量;默认项目 + `climperor-relations`、域名 `dota2.refining.dev`,均可参数覆盖。 +- 新增 `_cf_status.py`:只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名 + 状态(凭据经 keyzoo 注入),部署后核对用;另有一次性辅助脚本 + `_cf_domain.py`(探测域名/ DNS 记录)与 `_cf_add_cname.py`(补建缺失的 + CNAME,幂等)。 +- 物品页搜索框进 URL:`#/items?q=...`,刷新保状态、可分享;英雄页与物品页 + 搜索各用独立 debounce 计时器,互不干扰。 +- 版本页支持非英雄单位:Valve patchnotes `heroes[]` 中的熊灵(id 1961)等 + 伪英雄 id 现解析为中文名 + 本地头像(`fetch_patches.py` 的 `UNIT_NAMES`, + 静态导出一并拷贝头像)。 + +### Fixed + +- 修复 `serializeHash` 生成 `#heroes`(缺 `/`)导致刷新后路由解析异常的 + 问题,现统一为 `#/heroes` 形态。 +- 版本日志中未知单位 id 不再显示 `# 1961` 原始数字,回退为「其它单位」。 +- 本地预览 `serve_relations.py` 增加 CSP 响应头(`default-src 'self'` 等), + 限制资源只从本站加载。 +- `data.json` 加载失败时显式抛出 HTTP 状态错误,不再静默进入异常渲染。 + +### Performance + +- 关系预览缓存英雄技能条目(`skillEntriesFor`)与血蓝条量程 + (`resourceBarScale`)计算结果,数据重载时失效,减少重复遍历。 + ## [0.3.0] - 2026-07-27 ### Added diff --git a/README.md b/README.md index fd78d08..6ad666e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,18 @@ python fetch_patches.py # 近一年版本日志 + 详情 → data/patches python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新) ``` -预览页带 **Hash 路由**:`#/heroes/axe/core`、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages)。 +预览页带 **Hash 路由**:`#/heroes/axe/core`、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。 + +### 部署到 Cloudflare Pages + +```powershell +python deploy_relations.py +# 串联:导出 → 资产完整性预检 → wrangler 直传 → 绑定自定义域名 +# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key); +# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行 +# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖) +python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态) +``` 仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。 英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。 diff --git a/_cf_add_cname.py b/_cf_add_cname.py new file mode 100644 index 0000000..a4c769f --- /dev/null +++ b/_cf_add_cname.py @@ -0,0 +1,62 @@ +"""Add the missing CNAME for dota2.refining.dev -> climperor-relations.pages.dev. + +Cloudflare Pages bound the custom domain but did not auto-create the zone +CNAME, so SSL validation is stuck at 'CNAME record not set'. This adds it +(proxied so CF issues SSL + serves from the edge). Idempotent: skips if present. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + +ZONE = "refining.dev" +DOMAIN = "dota2.refining.dev" +TARGET = "climperor-relations.pages.dev" + + +def cf(method: str, path: str, email: str, key: str, body: dict | None = None) -> dict: + url = "https://api.cloudflare.com/client/v4" + path + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json", + }) + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return json.loads(e.read().decode()) + + +def main() -> None: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get("KEYZOO_ASSET_META_USERNAME") + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY") + zd = cf("GET", f"/zones?name={ZONE}", email, key) + zr = zd.get("result") or [] + if not zr: + raise SystemExit(f"zone {ZONE} not found") + zid = zr[0]["id"] + print(f"zone {ZONE}: {zid}") + + recs = cf("GET", f"/zones/{zid}/dns_records?name={DOMAIN}", email, key) + existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"] + if existing: + for r in existing: + print(f"CNAME already exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})") + return + + print(f"adding CNAME {DOMAIN} -> {TARGET} (proxied=true)") + d = cf("POST", f"/zones/{zid}/dns_records", email, key, body={ + "type": "CNAME", "name": DOMAIN, "content": TARGET, "proxied": True, "comment": "climperor-relations Pages", + }) + if d.get("success"): + r = d.get("result", {}) + print(f"created: {r.get('type')} {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})") + else: + print(f"FAILED: {d.get('errors')}") + + +if __name__ == "__main__": + main() diff --git a/_cf_domain.py b/_cf_domain.py new file mode 100644 index 0000000..f16c524 --- /dev/null +++ b/_cf_domain.py @@ -0,0 +1,53 @@ +"""Probe a single Cloudflare Pages custom domain + its zone DNS record.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + +DOMAIN = "dota2.refining.dev" +PROJECT = "climperor-relations" + + +def cf(method: str, path: str, email: str, key: str) -> dict: + url = "https://api.cloudflare.com/client/v4" + path + req = urllib.request.Request(url, method=method, headers={ + "X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json", + }) + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return json.loads(e.read().decode()) + + +def main() -> None: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get("KEYZOO_ASSET_META_USERNAME") + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY") + d = cf("GET", "/accounts", email, key) + aid = (d.get("result") or [{}])[0].get("id") + + d = cf("GET", f"/accounts/{aid}/pages/projects/{PROJECT}/domains/{DOMAIN}", email, key) + print("domain detail:") + for k, v in (d.get("result") or {}).items(): + if k != "zone_id": + print(f" {k}: {v}") + + zd = cf("GET", "/zones?name=refining.dev", email, key) + zr = zd.get("result") or [] + if not zr: + print("zone refining.dev: NOT FOUND on this account") + return + zid = zr[0]["id"] + recs = cf("GET", f"/zones/{zid}/dns_records?name={DOMAIN}", email, key) + print(f"dns records for {DOMAIN}:") + for rec in (recs.get("result") or []): + print(f" {rec.get('type')} {rec.get('name')} -> {rec.get('content')} (proxied={rec.get('proxied')})") + if not (recs.get("result") or []): + print(" (none -- CNAME not added yet)") + + +if __name__ == "__main__": + main() diff --git a/_cf_status.py b/_cf_status.py new file mode 100644 index 0000000..eb0bf45 --- /dev/null +++ b/_cf_status.py @@ -0,0 +1,85 @@ +"""Read-only Cloudflare Pages status probe (diagnostic helper). + +Prints account id, whether the target project exists, its latest deployment, +and bound custom domains. Credentials come from env (keyzoo inject). + +Usage via keyzoo: python _cf_status.py [--project-name NAME] +""" + +from __future__ import annotations + +import argparse +import json +import os +import urllib.error +import urllib.request + +DEFAULT_PROJECT = "climperor-relations" + + +def cf(method: str, path: str, email: str, key: str, body: dict | None = None) -> dict: + url = "https://api.cloudflare.com/client/v4" + path + headers = {"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json"} + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + try: + return json.loads(e.read().decode()) + except Exception: + return {"success": False, "errors": [{"code": e.code, "message": str(e)}]} + except urllib.error.URLError as e: + return {"success": False, "errors": [{"code": -1, "message": str(e)}]} + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--project-name", default=DEFAULT_PROJECT) + args = ap.parse_args() + + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get("KEYZOO_ASSET_META_USERNAME") + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY") + if not email or not key: + raise SystemExit("missing credentials in env") + + d = cf("GET", "/accounts", email, key) + if not d.get("success"): + print("GET /accounts FAILED:", d.get("errors")) + return + accts = d.get("result") or [] + print(f"accounts: {[ (a['id'], a.get('name')) for a in accts ]}") + if not accts: + return + aid = accts[0]["id"] + + d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}", email, key) + if not d.get("success"): + print(f"project {args.project_name}: NOT created -> {d.get('errors')}") + return + print(f"project: {args.project_name} (created)") + + d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}/deployments", email, key) + deps = d.get("result") or [] + print(f"deployments: {len(deps)}") + for dep in deps[:3]: + print(f" - {dep.get('latest_stage', {}).get('name')} / {dep.get('latest_stage', {}).get('status')} | env={dep.get('environment')} | created={dep.get('created_on')} | url={dep.get('url')}") + + d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}/domains", email, key) + doms = d.get("result") or [] + print("custom domains:") + for x in doms: + print(f" - {x.get('name')} status={x.get('status')}") + # Confirm the CNAME was auto-added on the refining.dev zone. + zd = cf("GET", "/zones?name=refining.dev", email, key) + zr = zd.get("result") or [] + if zr: + zid = zr[0]["id"] + recs = cf("GET", f"/zones/{zid}/dns_records?name=dota2.refining.dev", email, key) + for rec in (recs.get("result") or []): + print(f" dns: {rec.get('type')} {rec.get('name')} -> {rec.get('content')} (proxied={rec.get('proxied')})") + + +if __name__ == "__main__": + main() diff --git a/data/patches.json b/data/patches.json index c62ce74..b31a559 100644 --- a/data/patches.json +++ b/data/patches.json @@ -1,7 +1,7 @@ { "meta": { "source": "https://www.dota2.com/datafeed/patchnoteslist?language=schinese", - "fetched_at": "2026-07-27T02:53:43.356291+00:00", + "fetched_at": "2026-07-27T07:41:08.378536+00:00", "window_days": 365, "since": "2025-07-27", "count": 10, @@ -9,7 +9,7 @@ "lookup_counts": { "items": 171, "abilities": 583, - "heroes": 127 + "heroes": 128 } }, "patches": [ @@ -3603,6 +3603,10 @@ "155": { "key": "largo", "name_loc": "朗戈" + }, + "1961": { + "key": "spirit_bear", + "name_loc": "熊灵" } } }, diff --git a/deploy_relations.py b/deploy_relations.py new file mode 100644 index 0000000..5057820 --- /dev/null +++ b/deploy_relations.py @@ -0,0 +1,341 @@ +"""Deploy the relations preview static site to Cloudflare Pages. + +Bundles export + integrity check + wrangler direct upload + custom domain +binding into one local command. Designed to run with credentials injected +by keyzoo asset_exec, but also works with manually exported env vars. + +Usage: + python deploy_relations.py [--no-export] [--project-name NAME] \\ + [--domain DOMAIN] [--dist PATH] + +Env vars (credentials, never printed): + CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY -- preferred + KEYZOO_ASSET_META_USERNAME -- fallback email (keyzoo inject) + KEYZOO_ASSET_SECRET_GLOBAL_API_KEY -- fallback key (keyzoo inject) + +Flow: + 1. resolve credentials + account_id (GET /accounts) + 2. ensure Pages project exists (create if missing, production_branch=main) + 3. run export_relations_site.py unless --no-export + 4. asset-integrity check: abort if key dirs are empty + 5. wrangler pages deploy dist/relations (CI=true, non-interactive) + 6. bind custom domain if not already + 7. print deployment + custom domain URLs + +Security: the API key is only ever placed into HTTP headers and the +wrangler subprocess env. It is never printed, logged, or written to disk. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +DEFAULT_PROJECT = "climperor-relations" +DEFAULT_DOMAIN = "dota2.refining.dev" +DEFAULT_DIST = ROOT / "dist" / "relations" + +# 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 +# (True) or just a warning (False). Portrait/ui-icon must be non-empty or the +# site is visibly broken; item/ability degrade with onerror but should exist. +INTEGRITY_DIRS: dict[str, bool] = { + "portrait": True, + "ui-icon": True, + "item": True, + "ability": False, + "attr": True, + "item-cat": True, +} + + +def resolve_credentials() -> tuple[str, str]: + """Return (email, api_key) from env, preferring CLOUDFLARE_* vars.""" + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( + "KEYZOO_ASSET_META_USERNAME" + ) + api_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( + "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" + ) + if not email or not api_key: + raise SystemExit( + "missing credentials: set CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY " + "(or run via keyzoo asset_exec on the refining/cloudflare asset)" + ) + return email, api_key + + +def cf_api( + method: str, + path: str, + *, + email: str, + api_key: str, + body: dict | None = None, +) -> dict: + """Call the Cloudflare REST API with Global API Key auth. + + Uses X-Auth-Email + X-Auth-Key headers. Returns the parsed JSON envelope. + Never raises on HTTP error; returns the error envelope so callers can + inspect `success` / `errors` without a traceback. + """ + url = "https://api.cloudflare.com/client/v4" + path + headers = { + "X-Auth-Email": email, + "X-Auth-Key": api_key, + "Content-Type": "application/json", + } + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + # HTTPError still carries the CF JSON envelope; surface it verbatim + # (it contains no secrets, only error codes/messages). + try: + return json.loads(exc.read().decode("utf-8")) + except Exception: + return {"success": False, "errors": [{"code": exc.code, "message": str(exc)}]} + except urllib.error.URLError as exc: + return {"success": False, "errors": [{"code": -1, "message": str(exc)}]} + + +def get_account_id(email: str, api_key: str) -> str: + """Resolve the Cloudflare account id via GET /accounts.""" + d = cf_api("GET", "/accounts", email=email, api_key=api_key) + if not d.get("success"): + raise SystemExit(f"GET /accounts failed: {d.get('errors')}") + accounts = d.get("result") or [] + if not accounts: + raise SystemExit("no Cloudflare accounts found for these credentials") + if len(accounts) > 1: + names = [a.get("name", a.get("id")) for a in accounts] + print(f"multiple accounts found, using the first: {names}", file=sys.stderr) + return str(accounts[0]["id"]) + + +def ensure_project(email: str, api_key: str, account_id: str, project: str) -> None: + """Create the Pages project if it does not already exist.""" + d = cf_api( + "GET", + f"/accounts/{account_id}/pages/projects/{project}", + email=email, + api_key=api_key, + ) + if d.get("success"): + print(f"project exists: {project}") + return + # 8000xxx = project not found; anything else is a real error. + print(f"project not found, creating: {project}") + d = cf_api( + "POST", + f"/accounts/{account_id}/pages/projects", + email=email, + api_key=api_key, + body={"name": project, "production_branch": "main"}, + ) + if not d.get("success"): + raise SystemExit(f"create project failed: {d.get('errors')}") + + +def run_export() -> 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, + ) + + +def check_integrity(dist: Path) -> None: + """Abort if a hard-required asset directory is empty; warn otherwise.""" + problems: list[str] = [] + for sub, required in INTEGRITY_DIRS.items(): + d = dist / sub + n = sum(1 for _ in d.glob("*") if _.is_file()) if d.is_dir() else 0 + if n == 0: + tag = "MISSING (abort)" if required else "empty (warn)" + problems.append(f" {sub}/: {tag}") + hint = { + "portrait": "run: python fetch_cdn_templates.py && python fetch_hero_portraits.py", + "ability": "run: python fetch_hero_abilities.py --icons-only", + "item": "run: python fetch_hero_items.py", + "attr": "assets/attr_icons is committed; check git checkout", + "item-cat": "assets/item_cat_icons is committed; check git checkout", + "ui-icon": "assets/ui_icons is committed; check git checkout", + }.get(sub, "see AGENTS.md fetch commands") + problems.append(f" -> {hint}") + if not problems: + return + hard = [s for s, r in INTEGRITY_DIRS.items() if r and not any((dist / s).glob("*"))] + print("asset integrity check:") + print("\n".join(problems)) + if hard: + raise SystemExit( + f"aborting deploy: required asset dirs empty: {hard}. " + "Run the fetch scripts first so the exported site is complete." + ) + + +def resolve_npx() -> str: + """Locate the npx executable (Windows ships npx.cmd).""" + for name in ("npx", "npx.cmd"): + path = shutil.which(name) + if path: + return path + raise SystemExit("npx not found on PATH; install Node.js first") + + +def wrangler_deploy( + dist: Path, + project: str, + *, + email: str, + api_key: str, + account_id: str, +) -> None: + """Upload dist via `wrangler pages deploy` (direct upload, non-interactive).""" + env = os.environ.copy() + # wrangler reads Global API Key auth from these env vars. They are passed + # to the subprocess only; wrangler does not echo them. + env["CLOUDFLARE_EMAIL"] = email + env["CLOUDFLARE_API_KEY"] = api_key + env["CLOUDFLARE_ACCOUNT_ID"] = account_id + env["CI"] = "true" + cmd = [ + resolve_npx(), + "--yes", + "wrangler@3", + "pages", + "deploy", + str(dist), + "--project-name", + project, + "--commit-dirty", + "--branch", + "main", + ] + print(f"uploading {dist} via wrangler ...") + subprocess.run(cmd, cwd=str(ROOT), env=env, check=True) + + +def ensure_cname(email: str, api_key: str, domain: str, project: str) -> None: + """Ensure the zone has a proxied CNAME: -> .pages.dev. + + Pages binds the custom domain but does not always auto-create the zone + CNAME; without it SSL validation stalls at 'CNAME record not set'. + Idempotent: skips if a CNAME already exists for the domain. + """ + zone = domain.split(".", 1)[1] if "." in domain else domain + target = f"{project}.pages.dev" + zd = cf_api("GET", f"/zones?name={zone}", email=email, api_key=api_key) + zr = zd.get("result") or [] + if not zr: + print(f"warn: zone {zone} not found on this account; add CNAME manually") + return + zid = zr[0]["id"] + recs = cf_api( + "GET", f"/zones/{zid}/dns_records?name={domain}", email=email, api_key=api_key + ) + existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"] + if existing: + r = existing[0] + print(f"CNAME exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})") + return + print(f"adding CNAME: {domain} -> {target} (proxied=true)") + d = cf_api( + "POST", + f"/zones/{zid}/dns_records", + email=email, + api_key=api_key, + body={"type": "CNAME", "name": domain, "content": target, "proxied": True}, + ) + if d.get("success"): + print("CNAME added; SSL will activate within a few minutes") + else: + print(f"warn: add CNAME failed: {d.get('errors')} (add manually in dashboard)") + + +def bind_domain( + email: str, + api_key: str, + account_id: str, + project: str, + domain: str, +) -> None: + """Attach the custom domain to the Pages project and ensure its CNAME.""" + d = cf_api( + "GET", + f"/accounts/{account_id}/pages/projects/{project}/domains", + email=email, + api_key=api_key, + ) + if not d.get("success"): + raise SystemExit(f"list domains failed: {d.get('errors')}") + existing = [x.get("name") for x in (d.get("result") or [])] + if domain in existing: + print(f"custom domain already bound: {domain}") + else: + print(f"binding custom domain: {domain}") + d = cf_api( + "POST", + f"/accounts/{account_id}/pages/projects/{project}/domains", + email=email, + api_key=api_key, + body={"name": domain}, + ) + if not d.get("success"): + raise SystemExit( + f"bind domain failed: {d.get('errors')}. " + f"Ensure {domain} zone is hosted on this Cloudflare account." + ) + ensure_cname(email, api_key, domain, project) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Deploy relations site to Cloudflare Pages") + ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist") + ap.add_argument("--project-name", default=DEFAULT_PROJECT) + ap.add_argument("--domain", default=DEFAULT_DOMAIN) + ap.add_argument("--dist", default=str(DEFAULT_DIST)) + args = ap.parse_args() + + dist = Path(args.dist).resolve() + email, api_key = resolve_credentials() + + account_id = get_account_id(email, api_key) + print(f"account_id: {account_id}") + + ensure_project(email, api_key, account_id, args.project_name) + + if not args.no_export: + run_export() + else: + print("--no-export: using existing dist") + + if not dist.is_dir(): + raise SystemExit(f"dist not found: {dist}. Run without --no-export first.") + + check_integrity(dist) + wrangler_deploy(dist, args.project_name, email=email, api_key=api_key, account_id=account_id) + bind_domain(email, api_key, account_id, args.project_name, args.domain) + + print() + print(f"deployment url : https://{args.project_name}.pages.dev") + print(f"custom domain : https://{args.domain}") + print("note: a freshly bound custom domain takes ~1-2 min to issue SSL.") + + +if __name__ == "__main__": + main() diff --git a/export_relations_site.py b/export_relations_site.py index 921922d..2db6b00 100644 --- a/export_relations_site.py +++ b/export_relations_site.py @@ -102,6 +102,15 @@ def main() -> None: 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 diff --git a/fetch_patches.py b/fetch_patches.py index 4ab8630..7e97487 100644 --- a/fetch_patches.py +++ b/fetch_patches.py @@ -50,6 +50,10 @@ HERO_ABILITIES_PATH = DATA / "hero_abilities.json" DEFAULT_WINDOW_DAYS = 365 # Bundled shared badges — never fetched from CDN (many innate keys 404). SKIP_ABILITY_ICON_KEYS = frozenset({"innate", "talent_tree"}) +# Non-hero units that Valve places in patchnotes heroes[] under a pseudo hero_id +# absent from herolist. Resolve to a Chinese name + portrait key so the preview +# never shows #1961. Portrait files live in assets/hero_portraits/.png. +UNIT_NAMES = {1961: {"key": "spirit_bear", "name_loc": "熊灵"}} def _date(ts: int) -> str: @@ -231,6 +235,10 @@ def build_lookup( meta = hero_index.get(hid) if meta: heroes[str(hid)] = {"key": meta["key"], "name_loc": meta["name_loc"]} + elif hid in UNIT_NAMES: + # Non-hero unit (e.g. 1961 = 熊灵): bundled portrait in hero_portraits. + u = UNIT_NAMES[hid] + heroes[str(hid)] = {"key": u["key"], "name_loc": u["name_loc"]} return {"items": items, "abilities": abilities, "heroes": heroes} diff --git a/serve_relations.py b/serve_relations.py index cb9e454..42b01d2 100644 --- a/serve_relations.py +++ b/serve_relations.py @@ -15,6 +15,7 @@ import json import mimetypes import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from urllib.parse import urlparse import urllib.error @@ -416,6 +417,11 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " + "script-src 'self'; media-src 'self'", + ) self.end_headers() self.wfile.write(body) diff --git a/web/relations/app.js b/web/relations/app.js index 7fc8254..6e9474c 100644 --- a/web/relations/app.js +++ b/web/relations/app.js @@ -397,8 +397,11 @@ function withAbilityDetail(ent, ab) { return ent; } -/** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style). */ +/** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style). + * Cached per hero key; invalidated when data reloads. */ +const _skillEntriesCache = new Map(); function skillEntriesFor(heroKey) { + if (_skillEntriesCache.has(heroKey)) return _skillEntriesCache.get(heroKey); const cell = heroAbilityCell(heroKey); if (cell == null) return null; const entries = []; @@ -473,7 +476,9 @@ function skillEntriesFor(heroKey) { ); } } - return { entries, talents: cell.talents }; + const result = { entries, talents: cell.talents }; + _skillEntriesCache.set(heroKey, result); + return result; } const DISPEL_LABEL = { @@ -1029,14 +1034,17 @@ function fmtRegen(v) { return `+${fmtNum(v, 2)}`; } -/** Shared HP/Mana bar scale: max base resource across all heroes. */ +/** Shared HP/Mana bar scale: max base resource across all heroes (cached). */ +let _resourceBarScaleCache = null; function resourceBarScale() { + if (_resourceBarScaleCache != null) return _resourceBarScaleCache; let max = 0; for (const h of state.data?.heroes || []) { if (h.health != null) max = Math.max(max, Number(h.health) || 0); if (h.mana != null) max = Math.max(max, Number(h.mana) || 0); } - return max > 0 ? max : 1000; + _resourceBarScaleCache = max > 0 ? max : 1000; + return _resourceBarScaleCache; } /** Fill width % on shared scale; clamp so tiny values stay visible. */ @@ -1637,7 +1645,7 @@ function renderItemEntry(entry, lookup) { function renderHeroEntry(hero, lookup) { const meta = (lookup.heroes || {})[String(hero.hero_id)]; const key = meta ? meta.key : ""; - const name = meta ? meta.name_loc : "# " + hero.hero_id; + const name = meta ? meta.name_loc : "其它单位"; const portrait = key ? `` : ""; @@ -1695,6 +1703,14 @@ function buildPatchChangesPanel(heroKey) { return wrap; } +function ensureDefaultPatch() { + const patches = state.data?.patches || []; + if (!patches.length) return; + if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) { + state.selectedPatch = patches[0].version; + } +} + function renderPatches() { if (!state.data) return; const select = $("#patch-select"); @@ -1710,9 +1726,7 @@ function renderPatches() { return; } - if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) { - state.selectedPatch = patches[0].version; // default: latest - } + ensureDefaultPatch(); if (select) { select.innerHTML = patches @@ -1823,8 +1837,9 @@ function render() { } } -// Search-box URL sync timer (debounced replace so typing does not spam history). -let searchSyncTimer = 0; +// Search-box URL sync timers (debounced replace so typing does not spam history). +let heroSearchSyncTimer = 0; +let itemSearchSyncTimer = 0; const SEARCH_SYNC_DEBOUNCE_MS = 300; /** @@ -1885,6 +1900,11 @@ function applyPatch(patch) { const inp = $("#q"); if (inp && inp.value !== state.query) inp.value = state.query; } + if (typeof patch.itemQuery === "string") { + state.itemQuery = patch.itemQuery; + const inp = $("#q-item"); + if (inp && inp.value !== state.itemQuery) inp.value = state.itemQuery; + } render(); } @@ -1895,10 +1915,9 @@ function bindSearch() { input.addEventListener("input", () => { state.query = input.value; if (state.page === "heroes") renderBoard(); - // Debounced replace so each keystroke does not push a history entry. - if (searchSyncTimer) clearTimeout(searchSyncTimer); - searchSyncTimer = setTimeout(() => { - searchSyncTimer = 0; + if (heroSearchSyncTimer) clearTimeout(heroSearchSyncTimer); + heroSearchSyncTimer = setTimeout(() => { + heroSearchSyncTimer = 0; syncStateToUrl({ replace: true }); }, SEARCH_SYNC_DEBOUNCE_MS); }); @@ -1909,9 +1928,9 @@ function bindSearch() { itemInput.addEventListener("input", () => { state.itemQuery = itemInput.value; if (state.page === "items") renderItemShop(); - if (searchSyncTimer) clearTimeout(searchSyncTimer); - searchSyncTimer = setTimeout(() => { - searchSyncTimer = 0; + if (itemSearchSyncTimer) clearTimeout(itemSearchSyncTimer); + itemSearchSyncTimer = setTimeout(() => { + itemSearchSyncTimer = 0; syncStateToUrl({ replace: true }); }, SEARCH_SYNC_DEBOUNCE_MS); }); @@ -1983,6 +2002,7 @@ async function main() { bindSearch(); bindPageChrome(); const res = await fetch("data.json"); + if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); state.data = await res.json(); state.data.relations = state.data.relations || { counters: [], synergies: [] }; state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} }; @@ -1997,8 +2017,9 @@ async function main() { state.data.patches = state.data.patches || []; state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; state.data.patch_details = state.data.patch_details || {}; - // Install hash router now that state.data is loaded (validation needs it), - // then apply the initial URL (deep-link support) which also renders. + _skillEntriesCache.clear(); + _resourceBarScaleCache = null; + _innateAbilityKeys = null; installRouter({ getState: () => state, applyPatch }); applyUrlToState(); } diff --git a/web/relations/router.js b/web/relations/router.js index 091f8b9..91b5ca1 100644 --- a/web/relations/router.js +++ b/web/relations/router.js @@ -8,7 +8,7 @@ * - hero: selected hero key + detail sub-tab (skills|core|fears|patches) * - item: selected shop item key (items page) * - patch: selected version string (patches page; latest when absent) - * - query params (heroes page only): tags=csv, q=search text + * - query params: tags=csv (heroes), q=search text (heroes + items) * * Two-way binding without feedback loops: * - App calls syncStateToUrl() after each state mutation. We use @@ -48,6 +48,7 @@ function parseHash(hash) { patchVersion: null, tags: null, query: null, + itemQuery: null, }; let raw = hash || ""; if (raw.startsWith("#")) raw = raw.slice(1); @@ -76,7 +77,10 @@ function parseHash(hash) { ); } const qParam = params.get("q"); - if (qParam != null) out.query = qParam; + if (qParam != null) { + if (page === "items") out.itemQuery = qParam; + else out.query = qParam; + } return out; } @@ -91,7 +95,7 @@ function safeDecode(s) { /** Serialize current app state into a hash string (with leading '#'). */ function serializeHash(state) { if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT; - let hash = "#" + state.page; + let hash = "#/" + state.page; if (state.page === "heroes") { if (state.selectedKey) { hash += "/" + encodeURIComponent(state.selectedKey); @@ -117,13 +121,16 @@ function serializeHash(state) { if (state.page === "heroes") { const qs = new URLSearchParams(); if (state.tagFilters && state.tagFilters.size) { - // URLSearchParams.set already percent-encodes the value, so join raw - // tag names with "," — do NOT pre-encodeURIComponent (double encoding). qs.set("tags", Array.from(state.tagFilters).join(",")); } if (state.query) qs.set("q", state.query); const s = qs.toString(); if (s) hash += "?" + s; + } else if (state.page === "items") { + const qs = new URLSearchParams(); + if (state.itemQuery) qs.set("q", state.itemQuery); + const s = qs.toString(); + if (s) hash += "?" + s; } return hash; }