v0.4.0: Cloudflare Pages deploy tooling, items search in URL, spirit bear patch lookup

This commit is contained in:
voson
2026-07-27 16:28:31 +08:00
parent 1d0428b4a3
commit adb8f303c2
13 changed files with 674 additions and 27 deletions
+4
View File
@@ -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
+36
View File
@@ -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
+12 -1
View File
@@ -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) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
+62
View File
@@ -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()
+53
View File
@@ -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()
+85
View File
@@ -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()
+6 -2
View File
@@ -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": "熊灵"
}
}
},
+341
View File
@@ -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: <domain> -> <project>.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()
+9
View File
@@ -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
+8
View File
@@ -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/<key>.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}
+6
View File
@@ -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)
+40 -19
View File
@@ -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
? `<img class="patch-hero-icon" src="portrait/${encodeURIComponent(key)}.png?v=wide" alt="" onerror="this.style.visibility='hidden'">`
: "";
@@ -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();
}
+12 -5
View File
@@ -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;
}