#!/usr/bin/env python3 """技能自更新:版本检查、异步提示、应用更新。 用法: python scripts/self_update.py --check 检查更新(24 小时缓存,重复检查不拉网络) python scripts/self_update.py --notify 只读缓存打印提示,绝不碰网络(供 cast_chart 调用) python scripts/self_update.py --apply 下载 zipball → 按 manifest 逐文件校验 → 同步本地 退出码(--apply):0 成功;2 无需更新或配置缺失;3 失败(本地原样不动)。 远端双线路 failover:GitHub 优先,Gitea 兜底。HTTPS_PROXY/HTTP_PROXY 等 代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量 ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址, 支持 file:// 形式。 """ import argparse import hashlib import json import os import shutil import sys import tempfile import threading import time import urllib.request import zipfile from pathlib import Path # ---- 远端配置(GitHub 优先,Gitea 兜底)---- GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION" GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main" # Gitea 占位地址:由授权方填写实际自建 Gitea 地址后启用 GITEA_VERSION_URL = "https://gitea.example.com/owner/ZiPingEightChar/raw/branch/main/VERSION" GITEA_ZIPBALL_URL = "https://gitea.example.com/owner/ZiPingEightChar/archive/main.zip" CACHE_REL = "data/.update_check.json" CACHE_TTL_SECONDS = 24 * 3600 NETWORK_TIMEOUT = 3 # 更新管辖区之外:同步时绝不覆盖、绝不删除(与 tools/build_manifest.py 排除表一致) PRESERVE_FILES = {"data/.license.json", "data/.update_check.json"} PRESERVE_DIRS = {".git", "memory/archives", "share/pending", "share/pushed"} PRESERVE_DIR_NAMES = {".git", "__pycache__", ".pytest_cache"} PRESERVE_FILE_NAMES = {".DS_Store"} PRESERVE_SUFFIXES = {".pyc"} def repo_root() -> Path: return Path(__file__).resolve().parents[1] def _version_urls() -> list: env = os.environ.get("ZIPEC_VERSION_URLS") if env: return [u.strip() for u in env.split(",") if u.strip()] return [GITHUB_VERSION_URL, GITEA_VERSION_URL] def _zipball_urls() -> list: env = os.environ.get("ZIPEC_ZIPBALL_URLS") if env: return [u.strip() for u in env.split(",") if u.strip()] return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL] def _fetch_text(url: str, timeout: int = NETWORK_TIMEOUT) -> str: with urllib.request.urlopen(url, timeout=timeout) as r: return r.read().decode("utf-8") def _download(url: str, dest: Path, timeout: int = NETWORK_TIMEOUT) -> None: with urllib.request.urlopen(url, timeout=timeout) as r: with open(dest, "wb") as fh: shutil.copyfileobj(r, fh) def parse_version(text: str) -> tuple: parts = text.strip().split(".") if not parts or any(not p.isdigit() for p in parts): raise ValueError(f"无法解析版本号: {text!r}") return tuple(int(p) for p in parts) def read_local_version(root: Path): try: return (root / "VERSION").read_text(encoding="utf-8").strip() except OSError: return None def _sha256(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(1 << 20), b""): h.update(chunk) return h.hexdigest() # ---- 缓存(data/.update_check.json)---- def load_cache(root: Path): try: return json.loads((root / CACHE_REL).read_text(encoding="utf-8")) except Exception: return None def _write_cache(root: Path, remote_version, local_version) -> dict: update_available = False if remote_version and local_version: try: update_available = parse_version(remote_version) > parse_version(local_version) except ValueError: update_available = False cache = { "checked_at": time.time(), "checked_at_iso": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "local_version": local_version, "remote_version": remote_version, "update_available": update_available, } p = root / CACHE_REL p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return cache def _cache_fresh(cache) -> bool: try: return time.time() - float(cache.get("checked_at", 0)) < CACHE_TTL_SECONDS except Exception: return False def cache_stale(root: Path) -> bool: cache = load_cache(root) return not _cache_fresh(cache) def _fetch_remote_version(): """双线 failover:按序尝试各源,返回第一个成功的 VERSION 文本;全失败返回 None。""" for url in _version_urls(): try: text = _fetch_text(url).strip() if text: return text except Exception: continue return None # ---- --check / --notify ---- def _print_check_result(cache, note: str = "") -> None: if cache.get("update_available") and cache.get("remote_version"): print(f"有新版本 {cache['remote_version']}(当前 {cache.get('local_version') or '?'}){note}") else: print(f"已是最新({cache.get('local_version') or cache.get('remote_version') or '?'}){note}") def cmd_check(root: Path, force: bool = False) -> int: cache = load_cache(root) if not force and _cache_fresh(cache): _print_check_result(cache, note="(24 小时内已检查过)") return 0 remote = _fetch_remote_version() if remote is None: if cache: _print_check_result(cache, note="(网络不可达,沿用上次结果)") return 0 print("检查失败:远端 VERSION 不可达", file=sys.stderr) return 3 _print_check_result(_write_cache(root, remote, read_local_version(root))) return 0 def notify_message(root: Path): """只读缓存、绝不碰网络:有可更新版本时返回一行提示,否则返回 None。""" cache = load_cache(root) if not cache or not cache.get("update_available"): return None ver = cache.get("remote_version") or "?" return f"技能有新版本 {ver},运行 `python scripts/self_update.py --apply` 可更新" def cmd_notify(root: Path) -> int: msg = notify_message(root) if msg: print(msg, file=sys.stderr) return 0 def refresh_cache(root: Path) -> None: """同步刷新缓存;任何失败都静默(供后台线程调用)。""" try: remote = _fetch_remote_version() if remote is not None: _write_cache(root, remote, read_local_version(root)) except Exception: pass def refresh_cache_async(root: Path) -> None: """缓存过期(>24h)才起后台线程拉一次远端 VERSION;主流程不等它。""" try: if not cache_stale(root): return threading.Thread(target=refresh_cache, args=(root,), daemon=True).start() except Exception: pass # ---- --apply ---- def _rel(root: Path, p: Path) -> str: return p.relative_to(root).as_posix() def _preserved_dir(root: Path, d: Path) -> bool: if d.name in PRESERVE_DIR_NAMES: return True rel = _rel(root, d) return any(rel == ex or rel.startswith(ex + "/") for ex in PRESERVE_DIRS) def _preserved_file(root: Path, f: Path) -> bool: rel = _rel(root, f) if rel in PRESERVE_FILES or f.name in PRESERVE_FILE_NAMES: return True if f.suffix in PRESERVE_SUFFIXES: return True # 位于保留目录(或其子目录)内的文件一律保留 parts = rel.split("/")[:-1] for i, name in enumerate(parts): if name in PRESERVE_DIR_NAMES or "/".join(parts[: i + 1]) in PRESERVE_DIRS: return True return False def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: dest_real = os.path.realpath(dest) for info in zf.infolist(): target = os.path.realpath(dest / info.filename) if target != dest_real and not target.startswith(dest_real + os.sep): raise ValueError(f"zip 内含越界路径: {info.filename!r}") zf.extractall(dest) def _find_source_root(extract_dir: Path): """zipball 通常带一层顶层目录(GitHub: -main/;Gitea: /)。""" if (extract_dir / "manifest.json").is_file(): return extract_dir hits = [d for d in sorted(extract_dir.iterdir()) if d.is_dir() and (d / "manifest.json").is_file()] return hits[0] if len(hits) == 1 else None def _verify_manifest(src: Path, files: dict): """逐文件校验 sha256;返回错误信息列表(空 = 全部通过)。只校验,不动本地。""" errors = [] for rel, want in files.items(): f = src / rel if not f.is_file(): errors.append(f"缺失 {rel}") elif _sha256(f) != want: errors.append(f"校验不符 {rel}") return errors def _sync(root: Path, src: Path, files: dict) -> None: managed = set(files) | {"manifest.json", "VERSION"} copied = deleted = 0 for rel in sorted(managed - {"VERSION"}): # VERSION 最后写 s = src / rel if not s.is_file(): continue d = root / rel d.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(s, d) copied += 1 # 删除本地多余但清单不含的文件(仅清单管辖区;保留区一律不动) for dirpath, dirnames, filenames in os.walk(root, topdown=False): d = Path(dirpath) for name in filenames: f = d / name if _preserved_file(root, f) or _rel(root, f) in managed: continue f.unlink() deleted += 1 if d != root and not _preserved_dir(root, d) and not any(d.iterdir()): d.rmdir() # 最后写 VERSION if (src / "VERSION").is_file(): shutil.copy2(src / "VERSION", root / "VERSION") print(f"同步完成:写入 {copied} 个文件,清理 {deleted} 个多余文件") def cmd_apply(root: Path) -> int: local = read_local_version(root) if local is None: print("配置缺失:本地没有 VERSION 文件", file=sys.stderr) return 2 print(f"本地版本 {local},正在获取远端版本…") remote = _fetch_remote_version() if remote is None: print("失败:远端 VERSION 不可达(本地未改动)", file=sys.stderr) return 3 print(f"远端版本 {remote}") try: if parse_version(remote) <= parse_version(local): print("已是最新,无需更新") return 2 except ValueError as e: print(f"失败:{e}(本地未改动)", file=sys.stderr) return 3 with tempfile.TemporaryDirectory(prefix="zipec-update-") as td: zip_path = Path(td) / "remote.zip" ok = False for url in _zipball_urls(): try: print(f"下载 {url} …") _download(url, zip_path) ok = True break except Exception as e: print(f" 下载失败({e}),尝试下一源") if not ok: print("失败:所有更新源均不可达(本地未改动)", file=sys.stderr) return 3 try: extract_dir = Path(td) / "src" extract_dir.mkdir() with zipfile.ZipFile(zip_path) as zf: _safe_extract(zf, extract_dir) except Exception as e: print(f"失败:zip 解压异常({e})(本地未改动)", file=sys.stderr) return 3 src = _find_source_root(extract_dir) if src is None: print("失败:远端包内找不到 manifest.json(本地未改动)", file=sys.stderr) return 3 try: manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8")) files = manifest["files"] except Exception as e: print(f"失败:manifest.json 无法解析({e})(本地未改动)", file=sys.stderr) return 3 errors = _verify_manifest(src, files) if errors: print(f"失败:{len(errors)} 个文件与清单不符,已中止(本地未改动)", file=sys.stderr) for e in errors[:5]: print(f" {e}", file=sys.stderr) return 3 print(f"清单校验通过({len(files)} 个文件),开始同步…") _sync(root, src, files) try: _write_cache(root, remote, remote) except Exception: pass print(f"更新完成:{local} → {remote}") return 0 def main() -> int: ap = argparse.ArgumentParser(prog="self_update.py", description="技能自更新") g = ap.add_mutually_exclusive_group(required=True) g.add_argument("--check", action="store_true", help="检查更新(24 小时缓存)") g.add_argument("--notify", action="store_true", help="只读缓存打印提示,不碰网络") g.add_argument("--apply", action="store_true", help="校验并应用更新") a = ap.parse_args() root = repo_root() if a.check: return cmd_check(root) if a.notify: return cmd_notify(root) return cmd_apply(root) if __name__ == "__main__": raise SystemExit(main())