# -*- coding: utf-8 -*- """bypy 上传封装(参考 pythonimg_baidu_baijiahao/baijiahao_baidu_pan.py)。""" from __future__ import annotations import importlib.util import json import locale import os import subprocess import sys import time from pathlib import Path import requests import _bootstrap # noqa: F401 import backup_settings as S def _subprocess_text_encoding() -> str: for enc in ( getattr(sys.stdout, "encoding", None), locale.getpreferredencoding(False), "utf-8", ): if enc: return enc return "utf-8" def _subprocess_no_window_kwargs() -> dict: if sys.platform != "win32": return {} si = subprocess.STARTUPINFO() si.dwFlags |= subprocess.STARTF_USESHOWWINDOW si.wShowWindow = 0 kw: dict = {"startupinfo": si} flag = getattr(subprocess, "CREATE_NO_WINDOW", 0) if flag: kw["creationflags"] = flag return kw def check_bypy_auth() -> bool: try: if importlib.util.find_spec("bypy") is None: print("[FAIL] 未安装 bypy,请执行: pip install bypy") return False enc = _subprocess_text_encoding() r = subprocess.run( [sys.executable, "-m", "bypy", "info"], capture_output=True, text=True, encoding=enc, errors="replace", timeout=60, **_subprocess_no_window_kwargs(), ) out = ((r.stdout or "") + (r.stderr or "")).lower() ok = r.returncode == 0 and ( "quota" in out or "used:" in out or ("total" in out and "free" in out) ) if ok: print("[OK] bypy 已授权") return True print("[FAIL] bypy 未授权或检查失败,请运行: python -m bypy info") return False except Exception as e: print(f"[FAIL] bypy 检查异常: {e}") return False def ensure_remote_dir(remote_rel: str) -> None: remote_rel = remote_rel.replace("\\", "/").strip("/") if not remote_rel: return enc = _subprocess_text_encoding() parts = remote_rel.split("/") acc: list[str] = [] for part in parts: acc.append(part) subprocess.run( [sys.executable, "-m", "bypy", "mkdir", "/".join(acc)], capture_output=True, text=True, encoding=enc, errors="replace", timeout=60, **_subprocess_no_window_kwargs(), ) def upload_local_file(local_path: str | Path, remote_rel_path: str) -> bool: local_path = str(local_path) enc = _subprocess_text_encoding() remote_rel_path = remote_rel_path.replace("\\", "/").lstrip("/") parent = os.path.dirname(remote_rel_path) if parent: ensure_remote_dir(parent) attempts = S.BYPY_UPLOAD_RETRIES + 1 last_err = "" for attempt in range(1, attempts + 1): try: r = subprocess.run( [sys.executable, "-m", "bypy", "upload", local_path, remote_rel_path], capture_output=True, text=True, encoding=enc, errors="replace", timeout=S.BYPY_UPLOAD_TIMEOUT_S, **_subprocess_no_window_kwargs(), ) except subprocess.TimeoutExpired: last_err = f"超时({S.BYPY_UPLOAD_TIMEOUT_S}s)" print(f" [bypy] {last_err},第 {attempt}/{attempts} 次") if attempt < attempts: time.sleep(min(60, 15 * attempt)) continue except Exception as e: last_err = str(e) print(f" [bypy] 上传异常: {e},第 {attempt}/{attempts} 次") if attempt < attempts: time.sleep(min(60, 15 * attempt)) continue if r.returncode == 0: return True err = (r.stderr or r.stdout or "").strip() last_err = err[:200] if err else f"returncode={r.returncode}" if err: print(f" [bypy] {last_err}") print(f" [bypy] 上传失败,第 {attempt}/{attempts} 次") if attempt < attempts: time.sleep(min(60, 15 * attempt)) print(f" [bypy] 已放弃: {Path(local_path).name}({last_err})") return False def upload_directory(local_dir: str | Path, remote_dir: str) -> bool: """递归上传目录(逐文件 bypy upload,适合中小体量备份树)。""" local_dir = Path(local_dir) remote_dir = remote_dir.replace("\\", "/").strip("/") ensure_remote_dir(remote_dir) ok_all = True files = [p for p in local_dir.rglob("*") if p.is_file()] total = len(files) for idx, fp in enumerate(files, 1): rel = fp.relative_to(local_dir).as_posix() remote = f"{remote_dir}/{rel}" if remote_dir else rel print(f" [{idx}/{total}] {rel}") if not upload_local_file(fp, remote): ok_all = False return ok_all # ---------- 终盘移动:我的应用数据/bypy → /AIinfra_Cursor ---------- def _normalize_pan_path(path: str) -> str: path = path.replace("\\", "/").strip() if not path.startswith("/"): path = "/" + path return path.rstrip("/") or "/" def staging_bypy_path(remote_rel: str) -> str: rel = remote_rel.replace("\\", "/").strip("/") return _normalize_pan_path(f"{S.BYPY_APP_ROOT}/{rel}") def _load_bypy_token_data() -> dict | None: if not S.BYPY_TOKEN_PATH.is_file(): return None try: return json.loads(S.BYPY_TOKEN_PATH.read_text(encoding="utf-8")) except Exception: return None def _get_bypy_access_token() -> str | None: data = _load_bypy_token_data() if not data: return None token = (data.get("access_token") or "").strip() return token or None def _refresh_bypy_access_token() -> str | None: data = _load_bypy_token_data() if not data: return None refresh_token = (data.get("refresh_token") or "").strip() if not refresh_token: return None try: r = requests.get( "https://openapi.baidu.com/oauth/2.0/token", params={ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": "pS70KOzRGjiFACFulvMYLlpU", "client_secret": "jHCzah5B5aAaSbZqNOG4WfWi", }, timeout=30, ) if r.status_code != 200: return None j = r.json() new_token = (j.get("access_token") or "").strip() if not new_token: return None data.update(j) S.BYPY_TOKEN_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") return new_token except Exception: return None def _xpan_request(method: str, params: dict, data: dict | None = None, retry_auth: bool = True) -> dict | None: token = _get_bypy_access_token() if not token: return None req_params = dict(params) req_params["access_token"] = token try: if method.upper() == "GET": r = requests.get(S.XPAN_FILE_API, params=req_params, timeout=60) else: r = requests.post(S.XPAN_FILE_API, params=req_params, data=data or {}, timeout=120) except Exception: return None if r.status_code != 200: return None try: j = r.json() except Exception: return None errno = j.get("errno") if errno in (-6, 111, 31045, 31034) and retry_auth: if _refresh_bypy_access_token(): return _xpan_request(method, params, data, retry_auth=False) return j def ensure_final_root_dir() -> bool: """确保网盘根下 /AIinfra_Cursor 存在。""" path = _normalize_pan_path(S.BAIDUPAN_FINAL_ROOT) j = _xpan_request("GET", {"method": "list", "dir": path, "limit": 1, "start": 0}) if j and j.get("errno") == 0: return True # 创建目录 j = _xpan_request( "POST", {"method": "create"}, {"path": path, "size": "0", "isdir": "1", "rtype": "0"}, ) if j and j.get("errno") == 0: print(f"[OK] 已创建终盘目录: {path}") return True # 可能已存在或权限问题 errno = (j or {}).get("errno") if errno in (-8, 31061): # already exists variants return True print(f"[WARN] 创建终盘目录失败 errno={errno} path={path}") return False def move_bundle_to_final(bundle_id: str) -> tuple[bool, str]: """ 将 staging「我的应用数据/bypy/AIinfra_Cursor/{bundle_id}」 整夹移动到「/AIinfra_Cursor/{bundle_id}」。 """ if not _get_bypy_access_token(): print("[FAIL] 未找到 bypy token,无法移动到终盘,请运行: python -m bypy info") return False, "" if not ensure_final_root_dir(): return False, "" src_rel = f"{S.BAIDUPAN_FOLDER}/{bundle_id}" src_path = staging_bypy_path(src_rel) dest_parent = _normalize_pan_path(S.BAIDUPAN_FINAL_ROOT) final_path = f"{dest_parent}/{bundle_id}" # 终盘已有同名则视为完成(不覆盖) j_list = _xpan_request("GET", {"method": "list", "dir": final_path, "limit": 1, "start": 0}) if j_list and j_list.get("errno") == 0: print(f"[INFO] 终盘已存在同名目录,跳过移动: {final_path}") return True, final_path filelist = json.dumps([{"path": src_path, "dest": dest_parent}], ensure_ascii=False) attempts = S.BYPY_UPLOAD_RETRIES + 1 last_err = "" for attempt in range(1, attempts + 1): j = _xpan_request( "POST", {"method": "filemanager", "opera": "move"}, {"async": "0", "filelist": filelist, "ondup": "fail"}, ) if j and j.get("errno") == 0: info = (j.get("info") or [{}])[0] if info.get("errno") == 0: to_path = info.get("to") or info.get("to_path") or final_path print(f"[OK] 已移动到终盘: {to_path}") return True, to_path last_err = str(info.get("errno")) else: last_err = str((j or {}).get("errno", "unknown")) print(f" [网盘移动] 失败 errno={last_err},第 {attempt}/{attempts} 次") if attempt < attempts: time.sleep(min(60, 15 * attempt)) print(f"[FAIL] 移动已放弃: {src_path} → {dest_parent}") return False, ""