Files
climperor/web/refresh_web.py
T
vosonandCursor 30218790ce Add 7.41e patch notes with a hand-authored reading sidebar.
Ship Climperor Web 0.6.13: latest-patch summary panel, icon/innate fallbacks, and layout/scroll fixes; keep shared ability badges in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:06:09 +08:00

652 lines
22 KiB
Python

"""Orchestrate Climperor web data refresh tiers, then optional OSS + deploy.
Tiers (see AGENTS.md / Gitea Actions workflows):
daily — OpenDota stats, leaderboards, matches, pro matches, streamers,
streamer live probe; patch check
(live badge is served by /api/live-status on visit; daily probe is
only a data.json fallback until the edge API returns)
weekly — STRATZ meta, hero items, items_meta, item_counter_stats, item_fears
patch — patch list check; on has_new fetch details + version-linked scripts
all — weekly then daily (patch check included in daily/patch)
Usage:
python refresh_web.py --tier patch --skip-deploy --skip-oss
python refresh_web.py --tier daily --skip-deploy
python refresh_web.py --tier weekly
python refresh_web.py --tier all --dry-run
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
REPO_ROOT = ROOT.parent
sys.path.insert(0, str(REPO_ROOT))
from shared.http_utils import http_json, write_json_atomic
from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND
OPENDOTA_PROBE_URL = "https://api.opendota.com/api/heroStats"
# Must be non-empty before any Pages deploy — empty shells overwrite production.
CRITICAL_BY_HERO_FILES = (
ROOT / "data" / "hero_stats.json",
ROOT / "data" / "stratz_hero_meta.json",
ROOT / "data" / "stratz_matchup_tops.json",
)
# Paths whose content change should trigger deploy / OSS.
DATA_WATCH = [
ROOT / "data" / "hero_stats.json",
ROOT / "data" / "leaderboards.json",
ROOT / "data" / "hero_matches.json",
ROOT / "data" / "pro_matches.json",
ROOT / "data" / "streamers.json",
ROOT / "data" / "stratz_hero_meta.json",
ROOT / "data" / "stratz_matchup_tops.json",
ROOT / "data" / "hero_items.json",
ROOT / "data" / "items_meta.json",
ROOT / "data" / "hero_item_fears.json",
ROOT / "data" / "patches.json",
ROOT / "data" / "hero_abilities.json",
ROOT / "data" / "item_shop.json",
ROOT / "data" / "item_counter_stats.json",
]
# Static site inputs that are not rewritten by fetch tiers but still ship in
# data.json / dist (frontend + qualitative relations + hero table / grid).
INPUT_WATCH = [
RELATIONS_JSON,
HEROES_JSON,
ROOT / "data" / "hero_grid_order.json",
ROOT / "data" / "patch_summaries.json",
WEB_FRONTEND / "index.html",
WEB_FRONTEND / "app.js",
WEB_FRONTEND / "style.css",
WEB_FRONTEND / "router.js",
WEB_FRONTEND / "mobile-gate.js",
WEB_FRONTEND / "config.js",
WEB_FRONTEND / "_headers",
WEB_FRONTEND / "_redirects",
WEB_FRONTEND / "robots.txt",
]
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
ASSET_DIRS = [
ROOT / "assets" / "item_icons",
ROOT / "assets" / "ability_icons",
ROOT / "assets" / "item_cat_icons",
ROOT / "assets" / "hero_portraits",
ROOT / "assets" / "streamer_avatars",
ROOT / "assets" / "streamer_videos",
]
SUMMARY_PATH = ROOT / ".refresh" / "summary.json"
VOLATILE_JSON_KEYS = frozenset(
{
"fetched_at",
"generated_at",
"last_attempt_at",
"live_probed_at",
"profile_fetched_at",
"updated_at",
# Live badge is owned by /api/live-status; flipping is_live alone must
# not trigger a full static redeploy.
"is_live",
}
)
RUN_RESULTS: list[dict] = []
PATCH_RESULT: dict = {}
def _watch_key(path: Path) -> str:
"""Stable snapshot key relative to the repo root when possible."""
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT.resolve()).as_posix()
except ValueError:
return resolved.as_posix()
def _file_digest(path: Path) -> str | None:
if not path.is_file():
return None
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _without_volatile_fields(value):
if isinstance(value, dict):
return {
key: _without_volatile_fields(child)
for key, child in value.items()
if key not in VOLATILE_JSON_KEYS
}
if isinstance(value, list):
return [_without_volatile_fields(child) for child in value]
return value
def _semantic_file_digest(path: Path) -> str | None:
"""Hash JSON business content while ignoring refresh-only timestamps."""
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return _file_digest(path)
canonical = json.dumps(
_without_volatile_fields(payload),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def _dir_digest(path: Path) -> str | None:
if not path.is_dir():
return None
h = hashlib.sha256()
for p in sorted(path.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(path).as_posix().encode()
h.update(rel)
h.update(b"\0")
digest = _file_digest(p)
if digest:
h.update(digest.encode())
return h.hexdigest()
def snapshot() -> dict[str, str | None]:
out: dict[str, str | None] = {}
for p in DATA_WATCH:
out[_watch_key(p)] = _semantic_file_digest(p)
for p in INPUT_WATCH:
# Frontend/source files use byte digests; JSON inputs use semantic hash.
if p.suffix.lower() == ".json":
out[_watch_key(p)] = _semantic_file_digest(p)
else:
out[_watch_key(p)] = _file_digest(p)
out[_watch_key(FRONTEND_FUNCTIONS_DIR) + "/"] = _dir_digest(FRONTEND_FUNCTIONS_DIR)
for d in ASSET_DIRS:
out[_watch_key(d) + "/"] = _dir_digest(d)
return out
def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]:
"""Return (data_changed, assets_changed).
``data_changed`` covers generated JSON, frontend/source inputs, and Pages
Functions. Only ``web/assets/...`` directory digests count as assets
(OSS upload).
"""
data_changed = False
assets_changed = False
asset_keys = {_watch_key(d) + "/" for d in ASSET_DIRS}
keys = set(before) | set(after)
for k in keys:
if before.get(k) == after.get(k):
continue
if k in asset_keys:
assets_changed = True
else:
data_changed = True
return data_changed, assets_changed
def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool:
cmd = [sys.executable, str(ROOT / script), *args]
print(f"+ {' '.join(cmd)}", flush=True)
started = time.monotonic()
if dry_run:
RUN_RESULTS.append({"step": script, "ok": True, "dry_run": True, "duration_s": 0.0})
return True
proc = subprocess.run(cmd, cwd=str(ROOT), check=False)
result = {
"step": script,
"args": list(args),
"ok": proc.returncode == 0,
"soft_fail": soft_fail,
"returncode": proc.returncode,
"duration_s": round(time.monotonic() - started, 3),
}
RUN_RESULTS.append(result)
if proc.returncode == 0:
return True
if soft_fail:
print(
f"soft-fail: {script} exited {proc.returncode}; continuing",
flush=True,
)
return False
raise subprocess.CalledProcessError(proc.returncode, cmd)
def patch_check() -> dict:
"""Always hits the network (read-only list); safe under --dry-run."""
cmd = [sys.executable, str(ROOT / "fetch_patches.py"), "--check"]
print(f"+ {' '.join(cmd)}", flush=True)
started = time.monotonic()
proc = subprocess.run(
cmd,
cwd=str(ROOT),
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
line = (proc.stdout or "").strip().splitlines()[-1] if (proc.stdout or "").strip() else ""
if not line:
raise RuntimeError("fetch_patches.py --check produced empty stdout")
result = json.loads(line)
PATCH_RESULT.clear()
PATCH_RESULT.update(result)
RUN_RESULTS.append(
{
"step": "fetch_patches.py --check",
"ok": True,
"duration_s": round(time.monotonic() - started, 3),
"has_new": bool(result.get("has_new")),
"new_versions": list(result.get("new_versions") or []),
}
)
print(json.dumps(result, ensure_ascii=False), flush=True)
return result
def run_patch_linked(*, dry_run: bool = False) -> None:
run_script("fetch_patches.py", dry_run=dry_run)
run_script("fetch_hero_abilities.py", "--icons", dry_run=dry_run)
run_script("fetch_item_shop.py", dry_run=dry_run)
run_script("fetch_items_meta.py", dry_run=dry_run)
run_script("item_fears.py", dry_run=dry_run)
# Soft: patch workflow may lack STRATZ token; weekly still does the hard refresh.
run_script(
"fetch_stratz_meta.py",
"--matchups-only",
dry_run=dry_run,
soft_fail=True,
)
def probe_opendota_available() -> bool:
"""Single fail-fast GET; False means skip OpenDota daily fetches this run."""
try:
http_json(OPENDOTA_PROBE_URL, timeout=15, retries=0)
except Exception as exc:
print(
f"OpenDota probe failed ({type(exc).__name__}: {exc}); "
"skipping hero_stats / hero_matches / pro_matches this run",
flush=True,
)
RUN_RESULTS.append(
{
"step": "opendota_probe",
"ok": False,
"skipped_opendota": True,
"error": f"{type(exc).__name__}: {exc}",
}
)
return False
print("OpenDota probe ok", flush=True)
RUN_RESULTS.append({"step": "opendota_probe", "ok": True})
return True
def assert_critical_data_ready() -> None:
"""Refuse deploy when restored/generated caches would publish empty shells."""
problems: list[str] = []
for path in CRITICAL_BY_HERO_FILES:
rel = path.name
if not path.is_file():
problems.append(f"{rel}: missing")
continue
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
problems.append(f"{rel}: unreadable ({exc})")
continue
by_hero = raw.get("by_hero") if isinstance(raw, dict) else None
if not isinstance(by_hero, dict) or not by_hero:
problems.append(f"{rel}: empty by_hero")
if problems:
raise RuntimeError(
"refusing deploy: critical web data incomplete ("
+ "; ".join(problems)
+ ")"
)
def run_daily(*, dry_run: bool = False) -> bool:
"""Return True if patch-linked fetch ran."""
# Probe once: if OpenDota is 429/down, keep restored cache and do not burn quota.
opendota_ok = True if dry_run else probe_opendota_available()
if opendota_ok:
# Soft-fail OpenDota quota burns: keep restored cache and continue the tier.
run_script("fetch_hero_stats.py", dry_run=dry_run, soft_fail=True)
run_script(
"fetch_hero_matches.py",
"--source",
"league",
dry_run=dry_run,
soft_fail=True,
)
run_script(
"fetch_pro_matches.py",
"--include-pubs",
"--refresh-limit",
"15",
dry_run=dry_run,
soft_fail=True,
)
else:
print(
"skipped OpenDota daily fetches (probe failed); keeping prior cache",
flush=True,
)
run_script("fetch_leaderboards.py", dry_run=dry_run)
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
run_script("fetch_streamers.py", dry_run=dry_run, soft_fail=True)
# Soft-fail live probe (exits 0; probe failures clear is_live / live_probed_at).
run_script("fetch_streamer_live.py", dry_run=dry_run, soft_fail=True)
check = patch_check()
if check.get("has_new"):
print(
f"new patches detected: {check.get('new_versions')}; running version-linked fetch",
flush=True,
)
run_patch_linked(dry_run=dry_run)
return True
print("no new patches", flush=True)
return False
def run_weekly(*, dry_run: bool = False) -> None:
# Default full matchup refresh (not --resume-matchups); stale cells kept on failure.
run_script("fetch_stratz_meta.py", dry_run=dry_run)
run_script("fetch_hero_items.py", "--force", dry_run=dry_run)
run_script("fetch_items_meta.py", dry_run=dry_run)
run_script("fetch_item_counter_stats.py", "--soft-fail", dry_run=dry_run)
run_script("item_fears.py", dry_run=dry_run)
def run_patch_tier(*, dry_run: bool = False) -> bool:
"""Return True if version-linked fetch ran (or would run under dry-run)."""
check = patch_check()
if not check.get("has_new"):
print("no new patches; skip detail fetch", flush=True)
return False
print(
f"new patches detected: {check.get('new_versions')}; running version-linked fetch",
flush=True,
)
run_patch_linked(dry_run=dry_run)
return True
def _stale_metrics() -> dict:
metrics = {
"stratz_meta_stale": False,
"stratz_matchup_stale": 0,
"streamer_profile_missing": 0,
"streamer_live_probe_missing": 0,
}
meta_path = ROOT / "data" / "stratz_hero_meta.json"
matchup_path = ROOT / "data" / "stratz_matchup_tops.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
metrics["stratz_meta_stale"] = bool(meta.get("stale"))
metrics["stratz_meta_stale_brackets"] = list(meta.get("stale_brackets") or [])
except (OSError, json.JSONDecodeError):
metrics["stratz_meta_missing"] = True
try:
matchup = json.loads(matchup_path.read_text(encoding="utf-8"))
metrics["stratz_matchup_stale"] = sum(
1
for cell in (matchup.get("by_hero") or {}).values()
if isinstance(cell, dict) and cell.get("stale")
)
except (OSError, json.JSONDecodeError):
metrics["stratz_matchup_missing"] = True
try:
streamers = json.loads(
(ROOT / "data" / "streamers.json").read_text(encoding="utf-8")
)
rows = streamers.get("streamers") or []
metrics["streamer_profile_missing"] = sum(
1
for row in rows
if isinstance(row, dict)
and row.get("profile_url")
and not row.get("profile_fetched_at")
)
metrics["streamer_live_probe_missing"] = sum(
1
for row in rows
if isinstance(row, dict)
and row.get("live_url")
and not row.get("live_probed_at")
)
except (OSError, json.JSONDecodeError):
metrics["streamers_missing"] = True
metrics["item_counter_missing"] = not (
ROOT / "data" / "item_counter_stats.json"
).is_file()
return metrics
def _finalize_summary(summary: dict) -> dict:
summary["steps"] = list(RUN_RESULTS)
summary["patch_check"] = dict(PATCH_RESULT)
summary["health"] = _stale_metrics()
summary["completed_at"] = datetime.now(timezone.utc).isoformat()
return summary
def _write_summary(summary: dict) -> None:
_finalize_summary(summary)
write_json_atomic(SUMMARY_PATH, summary)
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
def validate_publish_options(
*,
assets_changed: bool,
skip_oss: bool,
skip_deploy: bool,
) -> None:
if assets_changed and skip_oss and not skip_deploy:
raise RuntimeError(
"assets changed but --skip-oss would publish missing OSS assets; "
"also set --skip-deploy for a local-only refresh"
)
def acquire_refresh_lock(tier: str):
"""Serialize refresh/deploy work on a self-hosted runner.
Patch checks are deferred instead of waiting behind a full refresh; the
six-hour schedule will retry them without exporting a stale restored cache.
"""
lock_path = Path(
os.environ.get("CLIMPEROR_REFRESH_LOCK")
or (Path.home() / ".climperor-web-refresh.lock")
)
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+b")
handle.seek(0)
if handle.tell() == 0 and lock_path.stat().st_size == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
try:
if os.name == "nt":
import msvcrt
mode = msvcrt.LK_NBLCK if tier == "patch" else msvcrt.LK_LOCK
msvcrt.locking(handle.fileno(), mode, 1)
else:
import fcntl
flags = fcntl.LOCK_EX | (fcntl.LOCK_NB if tier == "patch" else 0)
fcntl.flock(handle.fileno(), flags)
except (BlockingIOError, OSError):
handle.close()
if tier == "patch":
return None
raise
return handle
def release_refresh_lock(handle) -> None:
try:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
finally:
handle.close()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--tier",
choices=("daily", "weekly", "patch", "all"),
required=True,
help="which refresh tier to run",
)
ap.add_argument("--dry-run", action="store_true", help="print commands only")
ap.add_argument("--skip-deploy", action="store_true", help="do not call deploy_relations.py")
ap.add_argument("--skip-oss", action="store_true", help="do not upload static assets to OSS")
ap.add_argument(
"--force-deploy",
action="store_true",
help="deploy even when snapshot digests are unchanged",
)
args = ap.parse_args()
RUN_RESULTS.clear()
PATCH_RESULT.clear()
started = time.monotonic()
summary = {
"ok": False,
"tier": args.tier,
"run_id": os.environ.get("REFRESH_RUN_ID") or "",
"started_at": datetime.now(timezone.utc).isoformat(),
"data_changed": False,
"assets_changed": False,
"changed_paths": [],
"deployed": False,
"force_deploy": args.force_deploy,
}
lock_handle = acquire_refresh_lock(args.tier)
if lock_handle is None:
summary.update(
{
"ok": True,
"skipped": True,
"skip_reason": "full refresh already running; next patch schedule will retry",
"duration_s": round(time.monotonic() - started, 3),
}
)
_write_summary(summary)
return
try:
cache_enabled = (
os.environ.get("REFRESH_CACHE_ENABLED") == "1" and not args.dry_run
)
if cache_enabled:
run_script("refresh_cache.py", "restore")
before = snapshot() if not args.dry_run else {}
if args.tier in ("weekly", "all"):
run_weekly(dry_run=args.dry_run)
if args.tier in ("daily", "all"):
run_daily(dry_run=args.dry_run)
if args.tier == "patch":
run_patch_tier(dry_run=args.dry_run)
if args.dry_run:
summary["ok"] = True
summary["dry_run"] = True
print(
"dry-run complete (no cache restore/save, deploy, or oss)",
flush=True,
)
return
after = snapshot()
data_changed, assets_changed = diff_snapshots(before, after)
changed_paths = sorted(
key for key in set(before) | set(after) if before.get(key) != after.get(key)
)
summary.update(
{
"data_changed": data_changed,
"assets_changed": assets_changed,
"changed_paths": changed_paths,
}
)
print(
f"changes: data={data_changed} assets={assets_changed} "
f"force_deploy={args.force_deploy}",
flush=True,
)
validate_publish_options(
assets_changed=assets_changed,
skip_oss=args.skip_oss,
skip_deploy=args.skip_deploy,
)
if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload")
elif assets_changed:
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed
if should_deploy and not args.skip_deploy:
assert_critical_data_ready()
run_script("deploy_relations.py")
summary["deployed"] = True
elif should_deploy:
print("deploy needed but --skip-deploy set; skipping", flush=True)
else:
print("no business data/asset changes; skip deploy", flush=True)
if cache_enabled:
run_script("refresh_cache.py", "save")
summary["ok"] = True
except BaseException as exc:
summary["error"] = f"{type(exc).__name__}: {exc}"
raise
finally:
summary["duration_s"] = round(time.monotonic() - started, 3)
if args.dry_run:
# Print for humans; do not persist dry-run artifacts.
_finalize_summary(summary)
print(
"REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False),
flush=True,
)
else:
_write_summary(summary)
release_refresh_lock(lock_handle)
if __name__ == "__main__":
main()