import datetime import os import re import zipfile from pathlib import Path EXCLUDED_DIRS = { ".claude", ".codex", ".coverage", ".cache", ".git", ".idea", ".mypy_cache", ".next", ".pytest_cache", ".ruff_cache", ".venv", ".vscode", "__pycache__", "backups", "bin", "bin 2", "node_modules", "runtime", "volumes", "build", "dist", "htmlcov", "logs", "playwright-report", "screenshots", "temp", "tmp", "uploads", } EXCLUDED_PATH_PREFIXES = { ("docs", "design"), ("export_assets",), ("frontend", ".next"), ("MOMO Pro",), } EXCLUDED_FILES = { ".DS_Store", ".env", "google_credentials.json", "google_token.json", "google_token.pickle", } EXCLUDED_SUFFIXES = (".pyc", ".pyo", ".sqlite", ".sqlite3", ".tsbuildinfo") def _resolve_project_root() -> Path: configured = os.getenv("MOMO_BACKUP_ROOT") if configured: return Path(configured).expanduser().resolve() return Path(__file__).resolve().parents[2] def _resolve_backup_folder(project_root: Path) -> Path: configured = os.getenv("MOMO_BACKUP_DIR") if configured: return Path(configured).expanduser().resolve() return project_root / "backups" def _read_system_version(project_root: Path) -> str: version = "Unknown" config_path = project_root / "config.py" try: if config_path.exists(): content = config_path.read_text(encoding="utf-8") match = re.search(r'SYSTEM_VERSION\s*=\s*["\']([^"\']+)["\']', content) if match: version = match.group(1) except Exception as e: print(f"⚠️ 無法讀取版本號: {e}") return version def _should_skip_file(file_path: Path, project_root: Path, backup_folder: Path) -> bool: if file_path.is_symlink(): return True if file_path.name in EXCLUDED_FILES: return True if file_path.name.startswith(".env."): return True if file_path.suffix in EXCLUDED_SUFFIXES: return True try: file_path.resolve().relative_to(backup_folder) return True except ValueError: pass rel_parts = file_path.relative_to(project_root).parts if _is_excluded_path(rel_parts): return True return any(_is_excluded_dir_name(part) for part in rel_parts[:-1]) def _is_excluded_dir_name(dirname: str) -> bool: return dirname in EXCLUDED_DIRS or dirname.startswith("production_v") def _is_excluded_path(rel_parts: tuple[str, ...]) -> bool: for prefix in EXCLUDED_PATH_PREFIXES: if rel_parts[:len(prefix)] == prefix: return True return False def _prune_dirs(dirs: list[str], root: Path, project_root: Path, backup_folder: Path) -> None: kept = [] for dirname in dirs: candidate = root / dirname try: rel_parts = candidate.relative_to(project_root).parts except ValueError: rel_parts = () if rel_parts and _is_excluded_path(rel_parts): continue if _is_excluded_dir_name(dirname): continue if candidate.is_symlink(): continue try: candidate.resolve().relative_to(backup_folder) continue except ValueError: pass kept.append(dirname) dirs[:] = kept def create_backup(): """ 建立系統完整備份 (Zip 壓縮檔) 檔名格式: momo_pro_system_backup_YYYYMMDD_HHMMSS_V{version}.zip """ project_root = _resolve_project_root() backup_folder = _resolve_backup_folder(project_root) if not backup_folder.exists(): backup_folder.mkdir(parents=True) print(f"📂 已建立備份目錄: {backup_folder}") version = _read_system_version(project_root) timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') base_name = f"momo_pro_system_backup_{timestamp}_{version}.zip" output_path = backup_folder / base_name print(f"📦 正在打包專案目錄: {project_root}") print(f"🎯 目標檔案: {output_path}") try: with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for root, dirs, files in os.walk(project_root): root_path = Path(root) _prune_dirs(dirs, root_path, project_root, backup_folder) for file in files: file_path = root_path / file if _should_skip_file(file_path, project_root, backup_folder): continue arcname = file_path.relative_to(project_root) zipf.write(file_path, arcname) print(f"✅ 備份完成!") return str(output_path) except Exception as e: print(f"❌ 備份失敗: {e}") return None if __name__ == "__main__": create_backup()