#!/usr/bin/env python3 """ WebDAV备份工具 - 全量备份指定目录到WebDAV服务器 """ import os import sys import yaml import time import logging import argparse from pathlib import Path from datetime import datetime, timedelta import re import requests from requests.auth import HTTPBasicAuth from urllib.parse import urljoin class WebDAVBackup: """WebDAV备份类""" def __init__(self, config_path: str): """初始化备份工具""" self.config = self.load_config(config_path) self.setup_logging() self.session = requests.Session() self.session.auth = HTTPBasicAuth( self.config['webdav']['username'], self.config['webdav']['password'] ) self.uploaded_count = 0 self.failed_count = 0 self.total_size = 0 self.deleted_local_count = 0 self.deleted_local_size = 0 def load_config(self, config_path: str) -> dict: """加载配置文件""" if not os.path.exists(config_path): print(f"错误: 配置文件 {config_path} 不存在") sys.exit(1) with open(config_path, 'r', encoding='utf-8') as f: config = yaml.safe_load(f) # 验证必需配置 required_keys = ['webdav', 'backup'] for key in required_keys: if key not in config: print(f"错误: 配置文件中缺少必需的 '{key}' 部分") sys.exit(1) webdav_keys = ['url', 'username', 'password'] for key in webdav_keys: if key not in config['webdav']: print(f"错误: 配置文件中webdav部分缺少 '{key}'") sys.exit(1) if 'directories' not in config['backup']: print("错误: 配置文件中backup部分缺少 'directories'") sys.exit(1) return config def setup_logging(self): """设置日志""" log_level = self.config.get('logging', {}).get('level', 'INFO') log_file = self.config.get('logging', {}).get('file', 'backup.log') logging.basicConfig( level=getattr(logging, log_level), format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_file, encoding='utf-8'), logging.StreamHandler() ] ) self.logger = logging.getLogger(__name__) def test_connection(self) -> bool: """测试WebDAV连接""" self.logger.info("测试WebDAV连接...") try: response = self.session.request( 'PROPFIND', self.config['webdav']['url'], headers={'Depth': '0'} ) if response.status_code in [200, 207]: self.logger.info("WebDAV连接成功") return True else: self.logger.error(f"WebDAV连接失败: HTTP {response.status_code}") return False except Exception as e: self.logger.error(f"WebDAV连接异常: {e}") return False def get_latest_date_dir(self, base_dir: str) -> str: """获取最新的日期文件夹""" if not os.path.exists(base_dir): self.logger.error(f"数据目录不存在: {base_dir}") return None date_pattern = re.compile(r'^\d{8}$') date_dirs = [] for item in os.listdir(base_dir): item_path = os.path.join(base_dir, item) if os.path.isdir(item_path) and date_pattern.match(item): date_dirs.append(item) if not date_dirs: self.logger.error(f"未找到日期文件夹: {base_dir}") return None # 按日期排序,获取最新的 latest = sorted(date_dirs, reverse=True)[0] self.logger.info(f"找到最新日期文件夹: {latest}") return latest def get_exclude_patterns(self) -> list: """获取排除模式""" return self.config['backup'].get('exclude', []) def should_exclude(self, path: str) -> bool: """检查路径是否应该排除""" exclude_patterns = self.get_exclude_patterns() path_name = os.path.basename(path) for pattern in exclude_patterns: if pattern.startswith('*.'): # 扩展名匹配 ext = pattern[1:] if path_name.endswith(ext): return True elif pattern in path_name: # 名称包含匹配 return True return False def create_remote_dir(self, remote_path: str): """创建远程目录""" try: response = self.session.request( 'MKCOL', remote_path ) if response.status_code in [200, 201, 405]: # 405表示已存在,也是成功的 return True return False except Exception as e: self.logger.warning(f"创建目录失败 {remote_path}: {e}") return False def upload_file(self, local_path: str, remote_path: str) -> bool: """上传单个文件""" try: with open(local_path, 'rb') as f: content = f.read() self.logger.debug(f"上传到: {remote_path}") response = self.session.put( remote_path, data=content, headers={'Content-Type': 'application/octet-stream'} ) if response.status_code in [200, 201, 204]: file_size = len(content) self.uploaded_count += 1 self.total_size += file_size return True else: self.logger.warning(f"上传失败 {local_path}: HTTP {response.status_code}") self.logger.warning(f"响应内容: {response.text[:500] if response.text else '无'}") return False except PermissionError: self.logger.warning(f"权限不足 {local_path}") self.failed_count += 1 return False except Exception as e: self.logger.warning(f"上传失败 {local_path}: {e}") self.failed_count += 1 return False def delete_remote_path(self, remote_path: str) -> bool: """删除远程文件或目录""" try: # 先尝试删除文件 response = self.session.delete(remote_path) if response.status_code in [200, 204, 404]: return True # 如果是目录,尝试用 PROPFIND 检查 propfind_response = self.session.request( 'PROPFIND', remote_path, headers={'Depth': '0'} ) if propfind_response.status_code in [200, 207]: # 是目录,需要递归删除 return self._delete_remote_dir(remote_path) return False except Exception as e: self.logger.warning(f"删除失败 {remote_path}: {e}") return False def _delete_remote_dir(self, remote_path: str) -> bool: """递归删除远程目录""" try: # 先获取目录内容 response = self.session.request( 'PROPFIND', remote_path, headers={'Depth': '1'} ) if response.status_code not in [200, 207]: return False # 解析XML获取子项 from xml.etree import ElementTree ns = {'d': 'DAV:'} try: root = ElementTree.fromstring(response.text) # 删除所有子文件 for response_elem in root.findall('.//d:response', ns): href = response_elem.find('d:href', ns) if href is not None: href_text = href.text.rstrip('/') if href_text != remote_path.rstrip('/'): # 从 href.text 提取子项名称,构造完整路径 child_name = os.path.basename(href_text) child_path = remote_path.rstrip('/') + '/' + child_name self.delete_remote_path(child_path) except Exception: pass # 最后删除目录本身 delete_response = self.session.delete(remote_path + '/') return delete_response.status_code in [200, 204, 404] except Exception as e: self.logger.warning(f"删除目录失败 {remote_path}: {e}") return False def cleanup_old_backups(self, remote_base: str, days: int = 3): """清理超过指定天数的备份""" self.logger.info(f"开始清理 {days} 天前的备份...") # 确保 remote_base 以 / 结尾 if not remote_base.endswith('/'): remote_base += '/' try: # 获取远程目录列表 response = self.session.request( 'PROPFIND', remote_base, headers={'Depth': '1'} ) if response.status_code not in [200, 207]: self.logger.warning(f"无法获取远程目录列表: HTTP {response.status_code}") return # 解析XML from xml.etree import ElementTree ns = {'d': 'DAV:'} root = ElementTree.fromstring(response.text) # 计算过期日期 expire_date = datetime.now() - timedelta(days=days) deleted_count = 0 for response_elem in root.findall('.//d:response', ns): href = response_elem.find('d:href', ns) if href is None: continue # 先从 href.text 提取日期目录名称 dir_name = os.path.basename(href.text.rstrip('/')) # 使用 remote_base + dir_name 构建完整路径 # 这样无论 href.text 是什么格式(完整URL、绝对路径、相对路径) # 都能正确构造出远程路径 remote_path = remote_base.rstrip('/') + '/' + dir_name # 检查是否是日期格式的目录 if not re.match(r'^\d{8}$', dir_name): continue # 解析日期 try: dir_date = datetime.strptime(dir_name, '%Y%m%d') except ValueError: continue # 检查是否过期 if dir_date < expire_date: self.logger.info(f"删除过期备份: {dir_name}") if self.delete_remote_path(remote_path): deleted_count += 1 self.logger.info(f"已删除: {dir_name}") self.logger.info(f"清理完成,共删除 {deleted_count} 个过期备份") except Exception as e: self.logger.error(f"清理备份时发生错误: {e}") def backup_directory(self, local_dir: str, remote_base: str): """备份目录""" local_dir = os.path.abspath(local_dir) dir_name = os.path.basename(local_dir) remote_dir = urljoin(remote_base, dir_name + '/') self.logger.info(f"开始备份目录: {local_dir}") # 创建远程根目录 self.create_remote_dir(remote_dir) # 遍历所有文件 for root, dirs, files in os.walk(local_dir): # 计算相对路径 rel_dir = os.path.relpath(root, local_dir) # 处理排除的目录 if rel_dir != '.' and self.should_exclude(rel_dir): dirs.clear() continue # 构建远程目录路径 if rel_dir == '.': current_remote_dir = remote_dir else: current_remote_dir = urljoin(remote_dir, rel_dir + '/') # 创建子目录 for d in dirs: if self.should_exclude(d): continue self.create_remote_dir(urljoin(current_remote_dir, d + '/')) # 上传文件 for filename in files: if self.should_exclude(filename): continue local_path = os.path.join(root, filename) remote_path = urljoin(current_remote_dir, filename) if self.upload_file(local_path, remote_path): self.logger.debug(f"已上传: {local_path}") else: self.logger.warning(f"跳过: {local_path}") def run(self): """执行备份""" self.logger.info("=" * 50) self.logger.info("WebDAV备份开始") self.logger.info("=" * 50) # 测试连接 if not self.test_connection(): self.logger.error("WebDAV连接失败,退出") sys.exit(1) # 获取远程路径基础URL remote_base = self.config['webdav']['url'] if not remote_base.endswith('/'): remote_base += '/' # 获取保留天数配置 retain_days = self.config['backup'].get('retain_days', 3) # 获取上传后是否删除本地文件的配置 delete_after_upload = self.config['backup'].get('delete_after_upload', False) # 用于记录已上传的日期文件夹路径 uploaded_dirs = [] # 备份每个目录(只上传最新日期文件夹) directories = self.config['backup']['directories'] for local_dir in directories: if not os.path.exists(local_dir): self.logger.warning(f"目录不存在,跳过: {local_dir}") continue if not os.path.isdir(local_dir): self.logger.warning(f"不是有效目录,跳过: {local_dir}") continue # 获取最新日期文件夹 latest_date = self.get_latest_date_dir(local_dir) if latest_date: date_dir = os.path.join(local_dir, latest_date) self.backup_directory(date_dir, remote_base) # 记录已上传的目录,用于后续删除 uploaded_dirs.append((date_dir, latest_date)) # 上传完成后删除本地文件 if delete_after_upload and uploaded_dirs: self.logger.info("=" * 50) self.logger.info("开始删除已上传的本地文件...") for date_dir, latest_date in uploaded_dirs: try: # 计算目录大小 dir_size = self.get_dir_size(date_dir) import shutil shutil.rmtree(date_dir) self.deleted_local_count += 1 self.deleted_local_size += dir_size self.logger.info(f"已删除本地目录: {date_dir}") except Exception as e: self.logger.warning(f"删除本地目录失败 {date_dir}: {e}") # 输出统计 self.logger.info("=" * 50) self.logger.info("备份完成") self.logger.info(f"成功上传: {self.uploaded_count} 个文件") self.logger.info(f"总大小: {self.format_size(self.total_size)}") self.logger.info(f"失败数量: {self.failed_count}") # 输出删除本地文件的统计 if delete_after_upload and self.deleted_local_count > 0: self.logger.info(f"已删除本地目录: {self.deleted_local_count} 个") self.logger.info(f"释放空间: {self.format_size(self.deleted_local_size)}") # 清理过期备份 self.cleanup_old_backups(remote_base, retain_days) self.logger.info("=" * 50) def get_dir_size(self, dir_path: str) -> int: """计算目录大小""" total_size = 0 try: for root, dirs, files in os.walk(dir_path): for file in files: file_path = os.path.join(root, file) try: total_size += os.path.getsize(file_path) except (OSError, IOError): pass except Exception: pass return total_size @staticmethod def format_size(size: int) -> str: """格式化文件大小""" for unit in ['B', 'KB', 'MB', 'GB', 'TB']: if size < 1024: return f"{size:.2f} {unit}" size /= 1024 return f"{size:.2f} PB" def create_default_config(config_path: str): """创建默认配置文件""" default_config = { 'webdav': { 'url': 'https://dav.jianguoyun.com/dav/', 'username': 'your_username', 'password': 'your_password' }, 'backup': { 'directories': [ '/path/to/backup/dir1', '/path/to/backup/dir2' ], 'exclude': [ '*.tmp', '*.log', '__pycache__', '.git', 'node_modules' ] }, 'logging': { 'level': 'INFO', 'file': 'backup.log' } } with open(config_path, 'w', encoding='utf-8') as f: yaml.dump(default_config, f, default_flow_style=False, allow_unicode=True) print(f"默认配置文件已创建: {config_path}") print("请编辑配置文件后再运行程序") def main(): """主函数""" parser = argparse.ArgumentParser(description='WebDAV备份工具') parser.add_argument('-c', '--config', default='config.yaml', help='配置文件路径 (默认: config.yaml)') parser.add_argument('--init', action='store_true', help='创建默认配置文件') args = parser.parse_args() # 创建默认配置 if args.init: create_default_config(args.config) sys.exit(0) # 运行备份 backup = WebDAVBackup(args.config) backup.run() if __name__ == '__main__': main()