Skill: caozeal/webdav-backup (version 1.2.2)

--- name: webdav-backup version: 1.2.2 description: WebDAV 备份工具 - 将 OpenClaw 工作目录备份到 WebDAV 服务器或本地目录（默认 `~/openclaw/output`）。当用户需要备份数据、同步文件到云端、做本地归档、或配置自动备份计划时使用此技能。 metadata: openclaw: requires: env: [WEBDAV_URL, WEBDAV_USERNAME, WEBDAV_PASSWORD] --- # WebDAV / 本地 备份工具 将 OpenClaw 工作目录与基础配置一起备份到 WebDAV 服务器，或直接备份到本地目录。 ## 支持的 WebDAV 服务 - 坚果云 (jianguoyun.com) - Nextcloud / ownCloud - 阿里云盘（需 WebDAV 插件） - 其他标准 WebDAV 服务 ## 配置 如果只做本地备份，其实不需要配置 WebDAV。 - 本地默认输出目录：`~/openclaw/output` - 如需云端同步，再配置 WebDAV 连接信息 ### 方式一：openclaw.json（推荐） 编辑 `~/.openclaw/openclaw.json`，在 `skills.entries` 中添加： ```json { "skills": { "entries": { "webdav-backup": { "enabled": true, "env": { "WEBDAV_URL": "https://dav.jianguoyun.com/dav/", "WEBDAV_USERNAME": "[REDACTED_SECRET]", "WEBDAV_PASSWORD": "your-password" } } } } } ``` ### 方式二：环境变量 ```bash # 仅在当前 shell 会话中临时设置 export WEBDAV_URL="https://dav.jianguoyun.com/dav/" export WEBDAV_USERNAME="[REDACTED_SECRET]" export WEBDAV_PASSWORD="your-password" ``` > **更安全的建议**：优先把配置写进 `~/.openclaw/openclaw.json`，并确保该文件权限受控；不建议把密码长期明文写入 `~/.bashrc` / `~/.zshrc`。 > **优先级**: 环境变量 > openclaw.json 配置 ## 使用方法 ### 手动备份 ```bash # 只做本地备份（默认保存到 ~/openclaw/output） # 默认会备份：workspace + openclaw.json + cron + workspace/config python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --local-only # 备份默认清单到本地 + WebDAV python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py # 只备份你指定的单个目录/文件 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --source /path/to/data --local-only # 指定备份文件名前缀 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --name my-backup-2025 --local-only # 指定本地备份目录 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --local-only --local-dir ~/openclaw/my-backups # 只上传 WebDAV，不保留本地副本 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --remote-only ``` ### 自动备份 使用 cron 设置定时备份： ```bash # 每天凌晨2点做本地备份 cron add --schedule "0 2 * * *" --command "python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --local-only" # 每天凌晨3点做本地 + WebDAV 双备份 cron add --schedule "0 3 * * *" --command "python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py" ``` ### 查看备份列表 ```bash # 查看本地备份 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --list # 查看远端备份 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --list-remote ``` ### 恢复备份 ```bash # 恢复本地最新备份到当前目录 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --restore latest # 恢复到指定目录 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --restore latest --restore-dir ~/restore-test # 恢复指定备份文件 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --restore ~/openclaw/output/openclaw-backup-20260310-010203.tar.gz --restore-dir ~/restore-test # 如需覆盖已存在文件 python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --restore latest --restore-dir ~/restore-test --force ``` #### 恢复判断建议（给 agent） 恢复阶段不要求死板固定流程，按场景自主判断即可： - **只想查看内容 / 做核对**：优先恢复到临时目录或单独目录，先看 `openclaw/backup-manifest.json`，确认备份里实际包含了哪些内容。 - **想恢复部分文件**：先恢复到一个中间目录，再从中挑选 `openclaw/workspace/`、`openclaw/openclaw.json`、`openclaw/cron/` 等需要的部分拷回目标位置。 - **想整体迁移到新环境**：恢复到指定目录后，再按目标机器的实际目录结构落位，不要默认直接覆盖生产路径。 - **目标目录已存在内容**：默认不要覆盖；只有在用户明确要覆盖、或 agent 已完成核对后，才加 `--force`。 - **不确定备份包是否完整时**：先读取 manifest，再决定恢复范围；不要先覆盖再检查。 原则很简单：**先解出来、先核对，再决定怎么落位。** ## 默认备份内容 默认不再只看 workspace 根目录，而是打包一份更完整的 OpenClaw 备份清单： - `~/.openclaw/workspace/` - 主工作区（包含 memory、skills、文档、媒体等） - `~/.openclaw/openclaw.json` - 主配置 - `~/.openclaw/cron/` - 定时任务配置（如存在） - `~/.openclaw/workspace/config/` - workspace 内补充配置（如存在） - `openclaw/backup-manifest.json` - 本次备份实际包含内容清单 如果你传了 `--source`，则按你指定的目录或文件单独备份。 ## 默认排除内容 为避免备份包过大、夹带无意义缓存，默认会排除这些内容： - `.git/` - `.ace-tool/` - `.clawhub/` - `__pycache__/`、`*.pyc` - `node_modules/` - `.cache/` - `tmp/`、`temp/` - `output/`、`outputs/` 及迁移前快照目录 - `.DS_Store` 这些排除规则也会写进 `backup-manifest.json`，方便你事后核对。 ## 备份文件格式 备份文件以 tar.gz 压缩包形式存储，命名格式： ``` openclaw-backup-YYYYMMDD-HHMMSS.tar.gz ``` ## 故障排除 详见 [references/config.md](references/config.md)

path: CHANGELOG.md
content: # Changelog

## 1.2.2 - 2026-03-10

- 统一环境变量命名为 `WEBDAV_PASSWORD`，移除 `WEBDAV_PASS` 兼容分支
- 调整 `SKILL.md` metadata 为更标准的 `openclaw.requires.env` 结构，降低 ClawHub 扫描中的不一致提示

## 1.2.1 - 2026-03-10

- 移除明文密码写入 shell 启动文件的文档建议
- 移除关闭 TLS 校验的建议，改为更安全的证书排查说明
- 补充恢复前先检查 manifest 的安全建议
- 清理发布包中的杂质文件与本地元数据目录（如 `.DS_Store`、`__pycache__`、`.clawhub`、`.ace-tool`）

## 1.2.0 - 2026-03-10

- 默认本地输出目录统一到 `~/openclaw/output`
- 默认备份范围扩展为 `workspace + openclaw.json + cron + workspace/config`
- 新增 `backup-manifest.json`
- 新增默认排除规则，跳过 `.git`、缓存、临时目录、输出目录等内容
- 新增本地恢复能力：支持 `--restore latest`、`--restore-dir`、`--force`
- 在 `SKILL.md` 中补充恢复判断建议，交由 agent 按场景自主决策

## 1.1.0 - 2026-03-10

- 新增本地备份能力
- 支持 `--local-only`、`--remote-only`、`--list`、`--list-remote`
- 默认本地输出目录从原先备份目录逻辑收敛为统一输出目录方案

## 1.0.2

- 初始 WebDAV 备份版本


path: README.md
content: # webdav-backup

一个给 OpenClaw 用的备份 skill：支持把 `~/.openclaw` 相关工作区与基础配置打包到本地目录，或同步到 WebDAV。

当前默认本地输出目录：`~/openclaw/output`

## 现在支持什么

- 本地备份
- WebDAV 备份
- 本地 + WebDAV 双备份
- 默认备份清单（不只 workspace，还包含基础配置）
- 默认排除缓存、Git、临时目录、旧 output 等无意义内容
- 从本地备份包恢复到当前目录或指定目录
- 生成 `backup-manifest.json`，方便恢复前先核对内容

## 默认备份内容

不传 `--source` 时，默认备份：

- `~/.openclaw/workspace/`
- `~/.openclaw/openclaw.json`
- `~/.openclaw/cron/`
- `~/.openclaw/workspace/config/`

并额外写入：

- `openclaw/backup-manifest.json`

## 默认排除内容

为了避免备份包臃肿，默认会排除：

- `.git/`
- `.ace-tool/`
- `.clawhub/`
- `__pycache__/`、`*.pyc`
- `node_modules/`
- `.cache/`
- `tmp/`、`temp/`
- `output/`、`outputs/`
- `.DS_Store`

## 常用命令

### 只做本地备份

```bash
python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --local-only
```

### 本地 + WebDAV 双备份

```bash
python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py
```

### 只备份指定目录或文件

```bash
python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --source /path/to/data --local-only
```

### 恢复本地最新备份

```bash
python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --restore latest
```

### 恢复到指定目录

```bash
python3 ~/.openclaw/workspace/skills/webdav-backup/scripts/backup.py --restore latest --restore-dir ~/restore-test
```

## 配置 WebDAV

可以通过 `~/.openclaw/openclaw.json` 或环境变量配置：

- `WEBDAV_URL`
- `WEBDAV_USERNAME`
- `WEBDAV_PASSWORD`
- `OPENCLAW_LOCAL_BACKUP_DIR`（可选，覆盖默认本地输出目录）

环境变量名统一使用 `WEBDAV_PASSWORD`，不再额外兼容 `WEBDAV_PASS`。

更推荐把 WebDAV 配置写进 `~/.openclaw/openclaw.json`，并控制好文件权限；不建议把密码明文长期写入 shell 启动文件。

## 恢复策略建议

恢复时别一上来就覆盖生产目录。更稳的方式是：

1. 先恢复到独立目录
2. 先看 `backup-manifest.json`
3. 再决定整体恢复还是只挑部分内容回填
4. 只有确认无误时才考虑覆盖

## 文件结构

```text
webdav-backup/
├── SKILL.md
├── README.md
├── CHANGELOG.md
├── references/
│   └── config.md
└── scripts/
    └── backup.py
```


path: _meta.json
content: ownerId: kn7bbr2b6ayxj6qhd30rfjm9ms80k82t
slug: webdav-backup
version: 1.2.2
publishedAt: 1772347078556

path: references/config.md
content: # WebDAV 备份配置指南

## 支持的 WebDAV 服务配置

### 1. 坚果云 (推荐)

```bash
export WEBDAV_URL="https://dav.jianguoyun.com/dav/"
export WEBDAV_USERNAME="你的坚果云邮箱"
export WEBDAV_PASSWORD="你的坚果云应用密码"
```

**注意**: 必须使用"应用密码"，不是登录密码。
在坚果云网页版 → 安全选项 → 添加应用密码。

环境变量名统一使用 `WEBDAV_PASSWORD`。

### 2. Nextcloud

```bash
export WEBDAV_URL="https://your-nextcloud.com/remote.php/dav/files/username/"
export WEBDAV_USERNAME="username"
export WEBDAV_PASSWORD="password"
```

### 3. ownCloud

```bash
export WEBDAV_URL="https://your-owncloud.com/remote.php/webdav/"
export WEBDAV_USERNAME="username"
export WEBDAV_PASSWORD="password"
```

### 4. 阿里云盘（通过 WebDAV 插件）

需要先部署 aliyundrive-webdav：
```bash
export WEBDAV_URL="http://localhost:8080/"
export WEBDAV_USERNAME="admin"
export WEBDAV_PASSWORD="your-token"
```

## 本地备份目录

默认本地备份目录为：`~/openclaw/output`

如需修改，可设置：

```bash
export OPENCLAW_LOCAL_BACKUP_DIR="$HOME/openclaw/output"
```

也可以写入 `~/.openclaw/openclaw.json` 的 `webdav-backup.env`。

## 配置建议

优先使用 `~/.openclaw/openclaw.json` 保存 WebDAV 配置，并确保文件权限受控。

如果只是临时测试，可以只在当前 shell 会话中设置环境变量：

```bash
export WEBDAV_URL="https://dav.jianguoyun.com/dav/"
export WEBDAV_USERNAME="your-email"
export WEBDAV_PASSWORD="your-password"
```

不建议把密码明文长期写入 `~/.bashrc` 或 `~/.zshrc`。

## 测试连接

```bash
# 测试 WebDAV 连接
curl -u $WEBDAV_USERNAME:$WEBDAV_PASSWORD -X PROPFIND $WEBDAV_URL
```

## 默认备份范围

不传 `--source` 时，默认备份这些内容：

- `~/.openclaw/workspace/`
- `~/.openclaw/openclaw.json`
- `~/.openclaw/cron/`
- `~/.openclaw/workspace/config/`

如果只想备份单独目录或文件，请显式传 `--source`。

## 默认排除规则

默认会排除版本库、缓存、临时目录和输出目录，例如：

- `.git/`
- `.ace-tool/`
- `.clawhub/`
- `__pycache__/`、`*.pyc`
- `node_modules/`
- `.cache/`
- `tmp/`、`temp/`
- `output/`、`outputs/`

## 恢复说明

- `--restore latest`：从本地默认备份目录恢复最新备份
- `--restore /path/to/file.tar.gz`：恢复指定备份包
- `--restore-dir DIR`：指定恢复目标目录
- 默认不覆盖已存在文件；如需覆盖，加 `--force`

恢复前建议先解到独立目录，再查看 `openclaw/backup-manifest.json`，确认内容无误后再决定是否局部回填或覆盖。

### 恢复时的推荐判断顺序

1. 先恢复到独立目录，而不是直接覆盖生产路径
2. 先查看 `openclaw/backup-manifest.json`，确认备份实际包含内容
3. 若只需部分恢复，优先从恢复目录中手动挑选目标文件/目录
4. 只有确认目标路径和覆盖意图后，才使用 `--force`

## 备份策略建议

### 每日自动备份
```bash
# 添加到 crontab
crontab -e
# 添加行（根据实际路径调整）：
0 2 * * * /usr/bin/python3 ~/.openclaw/workspace/skills/openclaw-webdav-backup/scripts/backup.py >> /tmp/webdav-backup.log 2>&1
```

### 保留策略
- 本地保留最近7天备份
- WebDAV 保留最近30天备份
- 每月1日保留月备份

## 故障排除

### 1. 上传失败 "401 Unauthorized"
- 检查用户名和密码
- 坚果云用户确认使用的是"应用密码"

### 2. 上传失败 "403 Forbidden"
- 检查 WebDAV 目录是否有写入权限
- 确认 URL 路径正确

### 3. SSL 证书错误

不要通过关闭 TLS 校验来绕过证书问题。

更安全的做法是：
- 检查 WebDAV 服务端证书是否正确
- 使用受信任 CA 签发的证书
- 校正本机 CA / 系统时间 / 代理设置
- 优先使用可信的 HTTPS WebDAV 端点

### 4. 备份文件太大
- 排除大文件：`--exclude-pattern "*.mp4,*.zip"`
- 只备份关键数据而非整个 workspace

## 安全建议

1. **不要将密码硬编码在脚本中**
2. **使用应用密码而非主密码**
3. **定期更换密码**
4. **备份文件建议加密**

## 加密备份（高级）

如需加密备份，在创建 tar.gz 前添加加密步骤：

```bash
# 加密
gpg --symmetric --cipher-algo AES256 backup.tar.gz
# 解密
gpg --decrypt backup.tar.gz.gpg
```


path: scripts/backup.py
content: #!/usr/bin/env python3
"""
WebDAV 备份脚本
将 OpenClaw 工作目录备份到 WebDAV 服务器
"""

import os
import sys
import argparse
import tarfile
import datetime
import json
import re
from pathlib import Path
import urllib.request
import urllib.error
import urllib.parse
import xml.etree.ElementTree as ET
import fnmatch
import io
import shutil

OPENCLAW_ROOT = os.path.expanduser('~/.openclaw')
DEFAULT_EXCLUDE_PATTERNS = [
    '.DS_Store',
    '__pycache__',
    '*.pyc',
    '*.pyo',
    '.git',
    '.git/*',
    '.ace-tool',
    '.ace-tool/*',
    '.clawhub',
    '.clawhub/*',
    'node_modules',
    'node_modules/*',
    '.cache',
    '.cache/*',
    'tmp',
    'tmp/*',
    'temp',
    'temp/*',
    'output',
    'output/*',
    'outputs',
    'outputs/*',
    'outputs.pre-migration-*',
]

DEFAULT_BACKUP_ITEMS = [
    {
        'path': os.path.join(OPENCLAW_ROOT, 'workspace'),
        'arcname': 'openclaw/workspace',
        'required': True,
        'label': 'workspace',
    },
    {
        'path': os.path.join(OPENCLAW_ROOT, 'openclaw.json'),
        'arcname': 'openclaw/openclaw.json',
        'required': False,
        'label': '主配置 openclaw.json',
    },
    {
        'path': os.path.join(OPENCLAW_ROOT, 'cron'),
        'arcname': 'openclaw/cron',
        'required': False,
        'label': 'cron 配置',
    },
    {
        'path': os.path.join(OPENCLAW_ROOT, 'workspace', 'config'),
        'arcname': 'openclaw/workspace/config',
        'required': False,
        'label': 'workspace/config',
    },
]


def load_openclaw_config():
    """从 openclaw.json 加载 webdav-backup 配置"""
    config_paths = [
        os.path.expanduser('~/.openclaw/openclaw.json'),
        os.path.expanduser('~/.config/openclaw/openclaw.json'),
    ]
    
    for config_path in config_paths:
        if os.path.exists(config_path):
            try:
                with open(config_path, 'r') as f:
                    config = json.load(f)
                
                # 查找 webdav-backup 技能配置
                skills = config.get('skills', {}).get('entries', {})
                skill_config = skills.get('webdav-backup', {})
                
                if skill_config and skill_config.get('enabled', False):
                    return skill_config.get('env', {})
            except Exception:
                pass
    
    return {}


# 加载 openclaw.json 配置
openclaw_env = load_openclaw_config()

# 配置 - 优先级: 环境变量 > openclaw.json > 默认值
DEFAULT_WORKSPACE = os.path.expanduser('~/.openclaw/workspace')
DEFAULT_OUTPUT_ROOT = os.path.expanduser('~/openclaw/output')
DEFAULT_LOCAL_BACKUP_DIR = DEFAULT_OUTPUT_ROOT
WORKSPACE = os.environ.get('OPENCLAW_WORKSPACE', DEFAULT_WORKSPACE)
WEBDAV_URL = os.environ.get('WEBDAV_URL', openclaw_env.get('WEBDAV_URL', ''))
WEBDAV_USER = os.environ.get('WEBDAV_USERNAME', openclaw_env.get('WEBDAV_USERNAME', ''))
WEBDAV_PASS = os.environ.get('WEBDAV_PASSWORD', 
              openclaw_env.get('WEBDAV_PASSWORD', ''))
LOCAL_BACKUP_DIR = os.environ.get('OPENCLAW_LOCAL_BACKUP_DIR', openclaw_env.get('OPENCLAW_LOCAL_BACKUP_DIR', DEFAULT_LOCAL_BACKUP_DIR))
RETENTION_DAYS = 60
MIN_KEEP_COUNT = 20
BACKUP_FILENAME_RE = re.compile(r'^(?P<prefix>.+)-(?P<timestamp>\d{8}-\d{6})\.tar\.gz$')


def should_exclude(rel_path, exclude_patterns=None):
    patterns = exclude_patterns or DEFAULT_EXCLUDE_PATTERNS
    normalized = rel_path.replace('\\', '/')
    if normalized.startswith('./'):
        normalized = normalized[2:]
    if not normalized:
        return False
    parts = [p for p in normalized.split('/') if p]
    for pattern in patterns:
        if fnmatch.fnmatch(normalized, pattern):
            return True
        if any(fnmatch.fnmatch(part, pattern) for part in parts):
            return True
        if any(fnmatch.fnmatch('/'.join(parts[:i + 1]), pattern) for i in range(len(parts))):
            return True
    return False


def add_path_to_tar(tar, source_path, arcname, exclude_patterns=None):
    source = Path(source_path).expanduser()
    if not source.exists():
        return False

    if source.is_file():
        tar.add(source, arcname=arcname)
        return True

    added_any = False
    for root, dirs, files in os.walk(source):
        root_path = Path(root)
        rel_root = root_path.relative_to(source)

        kept_dirs = []
        for d in dirs:
            rel_dir = str((rel_root / d).as_posix())
            if should_exclude(rel_dir, exclude_patterns):
                continue
            kept_dirs.append(d)
        dirs[:] = kept_dirs

        tar_dir_arc = arcname if str(rel_root) == '.' else f"{arcname}/{rel_root.as_posix()}"
        dir_info = tarfile.TarInfo(tar_dir_arc)
        dir_info.type = tarfile.DIRTYPE
        dir_info.mtime = root_path.stat().st_mtime
        tar.addfile(dir_info)
        added_any = True

        for file_name in files:
            rel_file = str((rel_root / file_name).as_posix())
            if should_exclude(rel_file, exclude_patterns):
                continue
            file_path = root_path / file_name
            file_arc = f"{arcname}/{rel_file}"
            tar.add(file_path, arcname=file_arc, recursive=False)
            added_any = True

    return added_any


def resolve_latest_backup(local_dir, prefix='openclaw-backup'):
    local_path = Path(local_dir).expanduser()
    files = sorted(local_path.glob(f'{prefix}-*.tar.gz'), reverse=True)
    return files[0] if files else None


def restore_backup(backup_input, restore_dir, force=False, prefix='openclaw-backup'):
    if backup_input == 'latest':
        backup_path = resolve_latest_backup(LOCAL_BACKUP_DIR, prefix=prefix)
        if not backup_path:
            raise FileNotFoundError(f'在 {LOCAL_BACKUP_DIR} 下找不到最新备份')
    else:
        backup_path = Path(backup_input).expanduser()
        if not backup_path.exists():
            raise FileNotFoundError(f'备份文件不存在: {backup_path}')

    restore_path = Path(restore_dir).expanduser()
    restore_path.mkdir(parents=True, exist_ok=True)

    print(f'♻️  正在恢复备份: {backup_path}')
    print(f'📁 恢复目标目录: {restore_path}')

    restored = 0
    skipped = 0
    with tarfile.open(backup_path, 'r:gz') as tar:
        for member in tar.getmembers():
            target = restore_path / member.name
            if target.exists() and not force:
                skipped += 1
                continue
            tar.extract(member, path=restore_path, filter='data')
            restored += 1

    print(f'✅ 恢复完成: restored={restored}, skipped={skipped}')
    return backup_path, restore_path

def check_webdav_config():
    """检查 WebDAV 配置"""
    if not WEBDAV_URL or not WEBDAV_USER or not WEBDAV_PASS:
        print("❌ WebDAV 配置缺失")
        print("")
        print("配置方式一：编辑 ~/.openclaw/openclaw.json")
        print('  {')
        print('    "skills": {')
        print('      "entries": {')
        print('        "webdav-backup": {')
        print('          "enabled": true,')
        print('          "env": {')
        print('            "WEBDAV_URL": "https://dav.jianguoyun.com/dav/",')
        print('            "WEBDAV_USERNAME": "your-email",')
        print('            "WEBDAV_PASSWORD": "your-password",')
        print('            "OPENCLAW_LOCAL_BACKUP_DIR": "~/openclaw/backups"')
        print('          }')
        print('        }')
        print('      }')
        print('    }')
        print('  }')
        print("")
        print("配置方式二：设置环境变量")
        print("  export WEBDAV_URL='https://dav.jianguoyun.com/dav/'")
        print("  export WEBDAV_USERNAME='your-email'")
        print("  export WEBDAV_PASSWORD='your-password'")
        return False

    print(f"📡 WebDAV URL: {WEBDAV_URL}")
    print(f"👤 用户名: {WEBDAV_USER}")
    return True


def ensure_local_backup_dir(local_dir):
    """确保本地备份目录存在"""
    local_path = Path(local_dir).expanduser()
    local_path.mkdir(parents=True, exist_ok=True)
    return local_path

def create_backup(source_dir=None, backup_name=None, output_dir='/tmp', include_defaults=True):
    """创建备份压缩包"""
    timestamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
    if backup_name:
        backup_file = f"{backup_name}-{timestamp}.tar.gz"
    else:
        backup_file = f"openclaw-backup-{timestamp}.tar.gz"

    output_path = Path(output_dir).expanduser()
    output_path.mkdir(parents=True, exist_ok=True)
    backup_path = output_path / backup_file

    print(f"📦 正在创建备份: {backup_file}")

    with tarfile.open(backup_path, 'w:gz') as tar:
        if include_defaults and not source_dir:
            print('🧩 使用默认备份清单：workspace + 基础配置')
            manifest = []
            for item in DEFAULT_BACKUP_ITEMS:
                item_path = Path(item['path']).expanduser()
                if item_path.exists():
                    added = add_path_to_tar(tar, item_path, item['arcname'], DEFAULT_EXCLUDE_PATTERNS)
                    if added:
                        manifest.append({
                            'label': item['label'],
                            'path': str(item_path),
                            'arcname': item['arcname'],
                        })
                        print(f"✅ 已添加: {item['label']} -> {item['arcname']}")
                    else:
                        print(f"ℹ️  跳过空目录或全被排除: {item_path}")
                elif item.get('required'):
                    print(f"⚠️  缺失必备路径: {item_path}")
                else:
                    print(f"ℹ️  跳过不存在项: {item_path}")

            manifest_excludes = json.dumps(DEFAULT_EXCLUDE_PATTERNS, ensure_ascii=False)

            manifest_bytes = json.dumps({
                'created_at': datetime.datetime.now().isoformat(),
                'items': manifest,
                'exclude_patterns': DEFAULT_EXCLUDE_PATTERNS,
            }, ensure_ascii=False, indent=2).encode('utf-8')
            info = tarfile.TarInfo(name='openclaw/backup-manifest.json')
            info.size = len(manifest_bytes)
            info.mtime = datetime.datetime.now().timestamp()
            tar.addfile(info, io.BytesIO(manifest_bytes))
            print('✅ 已添加: openclaw/backup-manifest.json')
        else:
            source = Path(source_dir).expanduser()
            if source.exists():
                added = add_path_to_tar(tar, source, source.name, DEFAULT_EXCLUDE_PATTERNS)
                if added:
                    print(f"✅ 已添加: {source}")
                else:
                    print(f"ℹ️  已跳过：{source}（空目录或全被排除）")
            else:
                print(f"⚠️  目录不存在: {source}")

    size = backup_path.stat().st_size
    print(f"📊 备份大小: {size / 1024 / 1024:.2f} MB")

    return backup_path

def create_webdav_opener():
    """创建带认证的 WebDAV opener"""
    parsed = urllib.parse.urlparse(WEBDAV_URL)
    base_url = f"{parsed.scheme}://{parsed.netloc}"
    password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
    password_mgr.add_password(None, base_url, WEBDAV_USER, WEBDAV_PASS)
    password_mgr.add_password(None, WEBDAV_URL, WEBDAV_USER, WEBDAV_PASS)
    handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
    return urllib.request.build_opener(handler)

def ensure_webdav_directory():
    """确保 WebDAV 目录存在，不存在则创建"""
    # 解析 URL，获取基础 URL 和路径
    parsed = urllib.parse.urlparse(WEBDAV_URL)
    base_url = f"{parsed.scheme}://{parsed.netloc}"
    
    # 获取 WEBDAV_URL 中的路径部分（去掉开头的 /dav/）
    webdav_path = parsed.path.strip('/')
    if webdav_path.startswith('dav/'):
        webdav_path = webdav_path[4:]  # 去掉 'dav/'
    
    # 如果没有子目录，直接返回
    if not webdav_path:
        return
    
    opener = create_webdav_opener()
    
    # 逐级创建目录
    parts = webdav_path.split('/')
    current_path = "/dav"
    for part in parts:
        if not part:
            continue
        current_path += "/" + part
        dir_url = base_url + current_path
        
        try:
            req = urllib.request.Request(dir_url, method='MKCOL')
            opener.open(req)
        except urllib.error.HTTPError as e:
            # 405 = 目录已存在，忽略
            if e.code not in [405, 201]:
                pass

def parse_backup_filename(filename):
    """从备份文件名解析前缀和时间戳"""
    match = BACKUP_FILENAME_RE.match(filename)
    if not match:
        return None
    try:
        timestamp = datetime.datetime.strptime(match.group('timestamp'), '%Y%m%d-%H%M%S')
    except ValueError:
        return None
    return {
        'prefix': match.group('prefix'),
        'timestamp': timestamp,
        'name': filename,
    }

def list_remote_backups(opener, prefix):
    """列出指定前缀的远端备份（按时间倒序）"""
    remote_url = WEBDAV_URL.rstrip('/') + '/'
    data = b'<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
    req = urllib.request.Request(remote_url, data=data, method='PROPFIND')
    req.add_header('Depth', '1')
    req.add_header('Content-Type', 'application/xml; charset=utf-8')

    with opener.open(req) as response:
        body = response.read()

    root = ET.fromstring(body)
    backups = []

    for response_node in root.iter():
        if not response_node.tag.endswith('response'):
            continue

        href = None
        for child in response_node:
            if child.tag.endswith('href'):
                href = child.text or ''
                break
        if not href:
            continue

        path = urllib.parse.unquote(urllib.parse.urlparse(href).path or href)
        filename = Path(path.rstrip('/')).name
        if not filename:
            continue

        parsed = parse_backup_filename(filename)
        if not parsed or parsed['prefix'] != prefix:
            continue
        backups.append(parsed)

    backups.sort(key=lambda item: item['timestamp'], reverse=True)
    return backups

def delete_remote_backup(opener, filename):
    """删除远端备份文件"""
    remote_url = WEBDAV_URL.rstrip('/') + '/' + urllib.parse.quote(filename)
    req = urllib.request.Request(remote_url, method='DELETE')
    with opener.open(req):
        return

def cleanup_old_backups(current_backup_name):
    """清理旧备份：保留3个月内或者最近20个备份"""
    current = parse_backup_filename(current_backup_name)
    if not current:
        print(f"⚠️  跳过清理：文件名不符合备份格式 {current_backup_name}")
        return

    print("🧹 正在清理旧备份...")
    opener = create_webdav_opener()
    try:
        backups = list_remote_backups(opener, current['prefix'])
    except urllib.error.HTTPError as e:
        print(f"⚠️  清理跳过：WebDAV 不支持 PROPFIND 或请求失败 ({e.code})")
        return
    except Exception as e:
        print(f"⚠️  清理跳过：无法列出远端备份 ({e})")
        return

    if len(backups) <= MIN_KEEP_COUNT:
        print(f"ℹ️  无需清理：当前共 {len(backups)} 个备份（<= {MIN_KEEP_COUNT}）")
        return

    cutoff = datetime.datetime.now() - datetime.timedelta(days=RETENTION_DAYS)
    keep_by_count = {item['name'] for item in backups[:MIN_KEEP_COUNT]}
    to_delete = [
        item for item in backups
        if item['timestamp'] < cutoff and item['name'] not in keep_by_count
    ]

    if not to_delete:
        print("ℹ️  无需清理：没有符合删除条件的旧备份")
        return

    deleted = 0
    failed = 0
    for item in to_delete:
        try:
            delete_remote_backup(opener, item['name'])
            deleted += 1
            print(f"🗑️  已删除: {item['name']}")
        except Exception as e:
            failed += 1
            print(f"⚠️  删除失败: {item['name']} ({e})")

    print(f"✅ 清理完成: 删除 {deleted} 个，失败 {failed} 个，保留 {len(backups) - deleted} 个")

def upload_to_webdav(local_file, remote_name):
    """上传到 WebDAV 服务器"""
    print(f"☁️  正在上传到 WebDAV...")
    
    remote_url = WEBDAV_URL.rstrip('/') + '/' + remote_name
    
    # 先确保目录存在
    ensure_webdav_directory()
    
    opener = create_webdav_opener()
    
    try:
        with open(local_file, 'rb') as f:
            data = f.read()
        
        req = urllib.request.Request(remote_url, data=data, method='PUT')
        req.add_header('Content-Type', 'application/octet-stream')
        
        with opener.open(req) as response:
            if response.status in [200, 201, 204]:
                print(f"✅ 上传成功: {remote_name}")
                return True
            else:
                print(f"❌ 上传失败: HTTP {response.status}")
                return False
                
    except urllib.error.HTTPError as e:
        print(f"❌ HTTP 错误: {e.code} - {e.reason}")
        if e.code == 409:
            print("💡 提示: 409 错误通常表示目录不存在或文件已存在")
            print("   坚果云需要在网页端手动创建文件夹")
            print("   请访问 https://www.jianguoyun.com/ 创建文件夹: openclaw-tencent-backup")
        elif e.code == 404:
            print("💡 提示: 404 错误通常表示 WebDAV 路径不存在")
            print("   请检查坚果云网页端是否有对应文件夹")
            print("   路径示例: https://dav.jianguoyun.com/dav/openclaw-backup/")
        elif e.code == 401:
            print("💡 提示: 401 错误表示认证失败")
            print("   请检查用户名和密码是否正确")
            print("   注意: 坚果云需要使用'应用密码'而非登录密码")
        return False
    except Exception as e:
        print(f"❌ 上传失败: {e}")
        return False

def list_backups(local_dir=None, remote=False, prefix='openclaw-backup'):
    """列出本地或 WebDAV 上的备份文件"""
    if local_dir:
        local_path = Path(local_dir).expanduser()
        print(f"📋 本地备份列表: {local_path}")
        if not local_path.exists():
            print("（目录不存在）")
            return
        files = sorted(local_path.glob(f'{prefix}-*.tar.gz'), reverse=True)
        if not files:
            print("（暂无备份）")
            return
        for file in files:
            size_mb = file.stat().st_size / 1024 / 1024
            print(f"- {file.name} ({size_mb:.2f} MB)")

    if remote:
        print("📋 WebDAV 备份列表")
        print("注意: 此功能需要 WebDAV 服务器支持 PROPFIND 方法")
        print(f"WebDAV URL: {WEBDAV_URL}")

def main():
    parser = argparse.ArgumentParser(description='WebDAV / 本地 备份工具')
    parser.add_argument('--source', '-s', help='要备份的源目录；不传时使用默认备份清单（workspace + 基础配置）')
    parser.add_argument('--name', '-n', default='openclaw-backup', help='备份文件名前缀')
    parser.add_argument('--list', '-l', action='store_true', help='列出本地备份')
    parser.add_argument('--list-remote', action='store_true', help='列出 WebDAV 备份')
    parser.add_argument('--restore', '-r', help='恢复指定备份文件，或传 latest 恢复本地最新备份')
    parser.add_argument('--restore-dir', default='.', help='恢复目标目录，默认当前目录')
    parser.add_argument('--force', action='store_true', help='恢复时允许覆盖已存在文件')
    parser.add_argument('--local-dir', default=LOCAL_BACKUP_DIR, help='本地备份目录，默认 ~/openclaw/output')
    parser.add_argument('--local-only', action='store_true', help='只做本地备份，不上传 WebDAV')
    parser.add_argument('--remote-only', action='store_true', help='只保留远端备份；先在临时目录打包再上传')

    args = parser.parse_args()

    if args.local_only and args.remote_only:
        print('❌ --local-only 与 --remote-only 不能同时使用')
        sys.exit(1)

    if args.list:
        list_backups(local_dir=args.local_dir, prefix=args.name)
        return

    if args.list_remote:
        list_backups(remote=True)
        return

    if args.restore:
        try:
            restore_backup(args.restore, args.restore_dir, force=args.force, prefix=args.name)
        except Exception as e:
            print(f'❌ 恢复失败: {e}')
            sys.exit(1)
        return

    local_target_dir = '/tmp' if args.remote_only else args.local_dir
    if not args.remote_only:
        resolved_local_dir = ensure_local_backup_dir(args.local_dir)
        print(f"📁 本地备份目录: {resolved_local_dir}")

    backup_file = create_backup(args.source, args.name, output_dir=local_target_dir, include_defaults=not bool(args.source))

    if args.local_only:
        print(f"✅ 本地备份完成: {backup_file}")
        return

    if not check_webdav_config():
        print(f"⚠️  WebDAV 未配置，本地备份已保留在: {backup_file}")
        sys.exit(1)

    remote_name = backup_file.name
    if upload_to_webdav(backup_file, remote_name):
        cleanup_old_backups(remote_name)
        if args.remote_only:
            backup_file.unlink()
        print(f"✅ 备份完成: {remote_name}")
        if not args.remote_only:
            print(f"📁 本地副本保留在: {backup_file}")
    else:
        print(f"⚠️  上传失败，本地备份保留在: {backup_file}")
        sys.exit(1)

if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)