#!/usr/bin/env python3 # -*- coding: utf-8 -*- """从 207 拉取 /opt/workbuddy,打包并上传到百度网盘 AIinfra_Cursor。 流程: 1) SSH tar 拉取 207:/opt/workbuddy 到本地 data/baidu_backup/_pull_207/ 2) 按 backup_manifest.json 打包(可选再拉平台配置快照) 3) bypy 上传;bundle_id 为 workbuddy_backup_207_<时间戳> 4) 移动到终盘 /AIinfra_Cursor/workbuddy_backup_207_<时间戳>/ 用法(Windows,项目根目录): python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py --dry-run python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py --pack-only python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py --no-move python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py --incremental python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py --mode tree python scripts_cursor/百度网盘备份/backup_02_207端打包并上传.py --no-remote 说明: - 207 无需安装 bypy;上传在 Windows(已 OAuth 的 bypy)完成 - SSH 主机/账号默认读 patches_cursor/百度网盘备份/backup_manifest.json → remote_snapshot - Windows 本机完整备份请用 backup_01_打包并上传.py 前置:pip install bypy paramiko && python -m bypy info(完成 OAuth 授权) """ from __future__ import annotations import argparse import importlib import io import json import shutil import sys import tarfile from pathlib import Path import paramiko import _bootstrap # noqa: F401 import backup_settings as S from backup_bypy import check_bypy_auth, ensure_remote_dir, move_bundle_to_final from backup_collect import build_backup_bundle, load_manifest from backup_progress import write_progress _backup01 = importlib.import_module("backup_01_打包并上传") upload_bundle = _backup01.upload_bundle REMOTE_WORKBUDDY = "/opt/workbuddy" PULL_DIR_NAME = "_pull_207" LAST_MANIFEST_207_FILE = S.STATE_DIR / "last_manifest_207.json" BUNDLE_PREFIX = "workbuddy_backup_207_" def _load_last_fingerprint() -> str | None: if not LAST_MANIFEST_207_FILE.is_file(): return None try: return json.loads(LAST_MANIFEST_207_FILE.read_text(encoding="utf-8")).get("zip_sha256") except Exception: return None def _save_last_manifest(meta: dict) -> None: S.STATE_DIR.mkdir(parents=True, exist_ok=True) LAST_MANIFEST_207_FILE.write_text( json.dumps( { "bundle_id": meta["bundle_id"], "created_at": meta["created_at"], "zip_sha256": meta["zip_sha256"], "staging_prefix": f"{S.BAIDUPAN_FOLDER}/{meta['bundle_id']}", "final_path": meta.get("final_path") or f"/{S.BAIDUPAN_FINAL_ROOT}/{meta['bundle_id']}", "source": "207", }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) def _ssh_creds() -> tuple[str, str, str]: cfg = (load_manifest().get("remote_snapshot") or {}) host = str(cfg.get("host") or "192.168.110.207") user = str(cfg.get("user") or "root") password = str(cfg.get("pass") or "") return host, user, password def pull_workbuddy_tree(dest_parent: Path) -> Path: """SSH tar 拉取 /opt/workbuddy → dest_parent/workbuddy,返回该目录。""" if dest_parent.exists(): shutil.rmtree(dest_parent) dest_parent.mkdir(parents=True) host, user, password = _ssh_creds() print(f"[INFO] SSH 拉取 {user}@{host}:{REMOTE_WORKBUDDY} …") write_progress(status="拉取207", detail=f"{host}:{REMOTE_WORKBUDDY}") client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(host, username=user, password=password, timeout=30) try: # -C /opt workbuddy → 归档内顶层为 workbuddy/ _, stdout, stderr = client.exec_command( f"tar czf - -C /opt {Path(REMOTE_WORKBUDDY).name}", timeout=600, ) raw = stdout.read() err = (stderr.read() or b"").decode("utf-8", "replace") finally: client.close() if err.strip(): print(f"[WARN] tar stderr: {err[:500]}") if not raw: raise RuntimeError("207 tar 输出为空") with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf: tf.extractall(dest_parent) wb_root = dest_parent / Path(REMOTE_WORKBUDDY).name if not wb_root.is_dir(): raise RuntimeError(f"解压后无目录: {wb_root}") return wb_root def rename_bundle_to_207(meta: dict) -> dict: """将 workbuddy_backup_ 重命名为 workbuddy_backup_207_。""" old_id = meta["bundle_id"] if old_id.startswith(BUNDLE_PREFIX): return meta if not old_id.startswith("workbuddy_backup_"): raise RuntimeError(f"意外的 bundle_id: {old_id}") new_id = BUNDLE_PREFIX + old_id[len("workbuddy_backup_") :] old_work = Path(meta["zip_path"]).parent new_work = S.STATE_DIR / new_id if old_work != new_work: if new_work.exists(): shutil.rmtree(new_work) old_work.rename(new_work) old_zip = new_work / f"{old_id}.zip" new_zip = new_work / f"{new_id}.zip" if old_zip.exists(): old_zip.rename(new_zip) meta["zip_path"] = str(new_zip) meta["bundle_id"] = new_id for name in ("manifest.json", "README_backup.txt", "备份说明.html"): fp = new_work / name if fp.is_file(): fp.write_text(fp.read_text(encoding="utf-8").replace(old_id, new_id), encoding="utf-8") tree_guide = new_work / "tree" / "备份说明.html" if tree_guide.is_file(): tree_guide.write_text( tree_guide.read_text(encoding="utf-8").replace(old_id, new_id), encoding="utf-8", ) meta_path = new_work / "manifest.json" meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") meta["guide_html_path"] = str(new_work / "备份说明.html") return meta def main() -> int: if sys.platform == "win32": try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass ap = argparse.ArgumentParser(description="207 /opt/workbuddy → 百度网盘 AIinfra_Cursor 备份") ap.add_argument("--dry-run", action="store_true", help="仅打包并打印 manifest,不上传") ap.add_argument("--pack-only", action="store_true", help="仅本地打包,不上传") ap.add_argument("--incremental", action="store_true", help="zip 指纹与上次 207 备份相同则跳过上传") ap.add_argument( "--mode", choices=("zip", "tree"), default="zip", help="zip=上传压缩包+manifest+备份说明.html(默认);tree=逐文件上传目录树", ) ap.add_argument("--no-remote", action="store_true", help="不额外拉取平台配置快照(仍会拉 /opt/workbuddy)") ap.add_argument( "--no-move", action="store_true", help="上传后不移动到终盘 /AIinfra_Cursor(留在我的应用数据/bypy)", ) ap.add_argument( "--keep-pull", action="store_true", help="结束后保留 data/baidu_backup/_pull_207(默认删除)", ) args = ap.parse_args() do_move = S.BAIDUPAN_MOVE_AFTER_UPLOAD and not args.no_move pull_parent = S.STATE_DIR / PULL_DIR_NAME print(f"[INFO] 本地项目根: {S.ROOT}") print(f"[INFO] 远程源: {REMOTE_WORKBUDDY}") print(f"[INFO] staging: 我的应用数据/bypy/{S.BAIDUPAN_FOLDER}/<207时间戳>/") print(f"[INFO] 终盘: /{S.BAIDUPAN_FINAL_ROOT}/workbuddy_backup_207_<时间戳>/ (移动={'开' if do_move else '关'})") try: wb_root = pull_workbuddy_tree(pull_parent) except Exception as e: print(f"[FAIL] 拉取 207 失败: {e}") write_progress(status="拉取失败", detail=str(e)) return 1 print(f"[INFO] 207 树已落盘: {wb_root}") write_progress(status="打包中", detail="207 tree + zip + html") meta = build_backup_bundle(root=wb_root, pull_remote=not args.no_remote) meta = rename_bundle_to_207(meta) meta["root"] = str(wb_root) meta["source"] = "207" Path(meta["zip_path"]).parent.joinpath("manifest.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"[INFO] bundle: {meta['bundle_id']}") print(f"[INFO] 文件数: {meta['file_count']},原始 {meta['total_bytes']} bytes") print(f"[INFO] zip: {meta['zip_path']} ({meta['zip_bytes']} bytes)") print(f"[INFO] sha256: {meta['zip_sha256']}") if meta.get("categories"): print("[INFO] 分类:", ", ".join(f"{k}={v}" for k, v in sorted(meta["categories"].items()))) if args.dry_run or args.pack_only: print("[INFO] 跳过上传(dry-run / pack-only)") write_progress(status="仅打包", detail=str(meta["zip_path"])) if not args.keep_pull and pull_parent.exists(): shutil.rmtree(pull_parent, ignore_errors=True) return 0 if args.incremental and _load_last_fingerprint() == meta["zip_sha256"]: print("[INFO] 增量模式:207 内容与上次相同,跳过上传") write_progress(status="跳过(无变化)", detail=meta["bundle_id"]) if not args.keep_pull and pull_parent.exists(): shutil.rmtree(pull_parent, ignore_errors=True) return 0 if not check_bypy_auth(): return 1 ensure_remote_dir(S.BAIDUPAN_FOLDER) ok = upload_bundle(meta, mode=args.mode) if not ok: print("[FAIL] 上传未全部成功,可重跑本脚本(会生成新 bundle_id)") return 2 final_path = f"/{S.BAIDUPAN_FINAL_ROOT}/{meta['bundle_id']}" if do_move: print( f"[INFO] 开始移动:我的应用数据/bypy/{S.BAIDUPAN_FOLDER}/{meta['bundle_id']}/ " f"→ /{S.BAIDUPAN_FINAL_ROOT}/{meta['bundle_id']}/" ) write_progress(status="移动到终盘", detail=final_path) move_ok, moved_to = move_bundle_to_final(meta["bundle_id"]) if not move_ok: print("[FAIL] staging 已上传成功,但移动到终盘失败;文件仍在「我的应用数据/bypy」下") write_progress(status="移动失败(staging 已有)", detail=meta["bundle_id"]) return 3 final_path = moved_to or final_path meta["final_path"] = final_path write_progress(status="已完成", detail=final_path, uploaded=1, total=1) _save_last_manifest(meta) print(f"[PASS] 207 备份已落到终盘: {final_path}") else: meta["final_path"] = f"/apps/bypy/{S.BAIDUPAN_FOLDER}/{meta['bundle_id']}" _save_last_manifest(meta) print( f"[PASS] 已上传到 staging(未移动): " f"我的应用数据/bypy/{S.BAIDUPAN_FOLDER}/{meta['bundle_id']}/" ) if not args.keep_pull and pull_parent.exists(): shutil.rmtree(pull_parent, ignore_errors=True) return 0 if __name__ == "__main__": sys.exit(main())