图床自动化同步 Prompt

功能描述

搭建自动化脚本,定时从 Cloudflare R2 同步桶拉取 Obsidian vault,扫描笔记中的本地视频/附件引用,将文件复制到图床桶,替换笔记中的链接为远程 URL,删除本地文件,Git 提交版本备份,再推回同步桶。

环境信息

前置条件

1. Cloudflare R2 配置

需要准备两个 R2 存储桶:

同步桶(存储整个 Obsidian vault)

图床桶(存储视频/附件)

获取方式: Cloudflare Dashboard → R2 → 创建桶 → 管理 R2 API 令牌

2. GitHub 仓库配置

创建私有仓库用于版本备份:

3. rclone 配置

# 安装 rclone
curl https://rclone.org/install.sh | sudo bash

# 配置
rclone config

在交互式配置中创建两个 remote:

remote 1: r2-sync(同步桶)

remote 2: r2-imagebed(图床桶)

4. SSH 密钥配置(用于 GitHub)

# 生成 SSH key
ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_rsa_github

# 查看 public key
cat ~/.ssh/id_rsa_github.pub
# 复制到 GitHub → Settings → SSH and GPG keys → New SSH key

# 配置 SSH config
cat > ~/.ssh/config << 'EOF'
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_rsa_github
    IdentitiesOnly yes
EOF

chmod 600 ~/.ssh/config

脚本实现

1. Python 处理脚本

文件: /path/to/scripts/media-to-imagebed.py

#!/usr/bin/env python3
"""
Obsidian 媒体文件自动迁移到图床
扫描 vault 中的视频/图片,上传到 R2 图床,替换链接
"""

import os
import re
import boto3
import logging
from pathlib import Path
from datetime import datetime

# ============ 配置区域 ============
VAULT_PATH = "/path/to/your-vault"
LOG_FILE = "/path/to/scripts/logs/media-migrate.log"
ATTACHMENTS_DIR = "Attachments"

# R2 图床配置
R2_ENDPOINT = "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com"
R2_ACCESS_KEY = "YOUR_IMAGEBED_ACCESS_KEY_ID"
R2_SECRET_KEY = "YOUR_IMAGEBED_SECRET_ACCESS_KEY"
R2_BUCKET = "your-image-bed-bucket"
R2_PUBLIC_URL = "https://your-domain.r2.dev"

# 支持的媒体文件后缀
MEDIA_EXTENSIONS = {'.mp4', '.mov', '.webm', '.avi', '.mkv', '.flv', 
                    '.wmv', '.m4v', '.mp3', '.wav', '.ogg', '.m4a',
                    '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf'}
# ==================================

# 设置日志
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)

def get_s3_client():
    """创建 S3 客户端"""
    return boto3.client(
        's3',
        endpoint_url=R2_ENDPOINT,
        aws_access_key_id=R2_ACCESS_KEY,
        aws_secret_access_key=R2_SECRET_KEY
    )

def file_exists_in_bucket(s3, bucket, key):
    """检查文件是否已存在于桶中"""
    try:
        s3.head_object(Bucket=bucket, Key=key)
        return True
    except:
        return False

def get_file_size(path):
    """获取文件大小"""
    return os.path.getsize(path)

def process_markdown_file(md_path, s3):
    """处理单个 Markdown 文件"""
    logger.info(f"处理文件: {md_path}")
    
    with open(md_path, 'r', encoding='utf-8') as f:
        content = f.read()
    
    original_content = content
    modified = False
    
    # 匹配 Obsidian 格式的本地媒体引用: ![[文件名.后缀]]
    pattern = r'!\[\[([^\]]+\.(?:' + '|'.join(ext[1:] for ext in MEDIA_EXTENSIONS) + r'))\]\]'
    
    for match in re.finditer(pattern, content, re.IGNORECASE):
        filename = match.group(1)
        media_path = Path(VAULT_PATH) / ATTACHMENTS_DIR / filename
        
        # 确定存储目录(视频/音频/图片分开)
        ext = Path(filename).suffix.lower()
        if ext in {'.mp4', '.mov', '.webm', '.avi', '.mkv', '.flv', '.wmv', '.m4v'}:
            folder = "video"
        elif ext in {'.mp3', '.wav', '.ogg', '.m4a'}:
            folder = "audio"
        else:
            folder = "images"
        
        s3_key = f"{folder}/{filename}"
        
        try:
            if not media_path.exists():
                logger.warning(f"文件不存在: {media_path}")
                continue
            
            # 检查是否已在图床
            if file_exists_in_bucket(s3, R2_BUCKET, s3_key):
                logger.info(f"文件已存在图床: {filename}")
            else:
                # 上传文件
                file_size = get_file_size(media_path)
                logger.info(f"上传文件: {filename} ({file_size} bytes)")
                
                s3.upload_file(
                    str(media_path), 
                    R2_BUCKET, 
                    s3_key,
                    ExtraArgs={'ContentType': get_content_type(filename)}
                )
                logger.info(f"上传成功: {filename}")
            
            # 替换链接
            old_ref = f"![[{filename}]]"
            new_ref = f"![]({R2_PUBLIC_URL}/{s3_key})"
            content = content.replace(old_ref, new_ref)
            modified = True
            
            # 删除本地文件
            os.remove(media_path)
            logger.info(f"删除本地文件: {media_path}")
            
        except Exception as e:
            logger.error(f"处理 {filename} 失败: {str(e)}")
            continue
    
    # 保存修改
    if modified:
        with open(md_path, 'w', encoding='utf-8') as f:
            f.write(content)
        logger.info(f"文件已更新: {md_path}")
    
    return modified

def get_content_type(filename):
    """根据文件名获取 Content-Type"""
    ext = Path(filename).suffix.lower()
    content_types = {
        '.mp4': 'video/mp4',
        '.mov': 'video/quicktime',
        '.webm': 'video/webm',
        '.mp3': 'audio/mpeg',
        '.wav': 'audio/wav',
        '.png': 'image/png',
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg',
        '.gif': 'image/gif',
        '.pdf': 'application/pdf',
    }
    return content_types.get(ext, 'application/octet-stream')

def main():
    logger.info("===== 开始媒体文件迁移 =====")
    
    s3 = get_s3_client()
    vault_path = Path(VAULT_PATH)
    
    processed = 0
    modified = 0
    failed = 0
    
    # 遍历所有 Markdown 文件
    for md_file in vault_path.rglob("*.md"):
        try:
            processed += 1
            if process_markdown_file(md_file, s3):
                modified += 1
        except Exception as e:
            logger.error(f"处理文件 {md_file} 失败: {str(e)}")
            failed += 1
    
    logger.info(f"===== 迁移完成 =====")
    logger.info(f"处理文件数: {processed}")
    logger.info(f"修改文件数: {modified}")
    logger.info(f"失败数: {failed}")

if __name__ == "__main__":
    main()

2. 主调度脚本(含 Git 备份)

文件: /path/to/scripts/sync-and-migrate.sh

#!/bin/bash
# Obsidian Vault 同步 + 附件迁移到图床 + Git 版本备份
# 流程: R2 pull → 附件迁移 → Git commit → R2 push

set -euo pipefail

# ============ 配置区域 ============
LOCK_FILE="/tmp/sync-and-migrate.lock"
LOG_DIR="/path/to/scripts/logs"
RCLONE_LOG="$LOG_DIR/rclone.log"
VAULT_PATH="/path/to/your-vault"

# Git 配置
GIT_USER_NAME="Your Name"
GIT_USER_EMAIL="your-email@example.com"
GITHUB_REPO="git@github.com:your-username/your-vault-backup.git"
# ==================================

mkdir -p "$LOG_DIR"

# flock 防并发
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') [SKIP] 已有实例在运行,跳过本次执行" >> "$RCLONE_LOG"
    exit 0
fi

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') [SYNC] $1" >> "$RCLONE_LOG"
    echo "$1"
}

log "===== 开始同步任务 ====="

# 1. 从 R2 同步桶拉取最新 vault
log "Step 1: 从 R2 拉取 vault..."
if ! rclone sync r2-sync:your-vault-bucket "$VAULT_PATH" --log-file="$RCLONE_LOG" --log-level=INFO; then
    log "ERROR: rclone pull 失败,终止任务"
    exit 1
fi
log "Step 1 完成: vault 拉取成功"

# 2. 运行附件迁移脚本
log "Step 2: 运行附件迁移脚本..."
if ! python3 /path/to/scripts/media-to-imagebed.py; then
    log "ERROR: 附件迁移脚本失败,终止任务"
    exit 1
fi
log "Step 2 完成: 附件迁移完成"

# 3. Git 版本备份(大保底)
log "Step 3: Git 备份到 GitHub..."
cd "$VAULT_PATH"

# 检查是否是 git 仓库,如果不是则初始化
if [ ! -d ".git" ]; then
    log "初始化 Git 仓库..."
    git init
    git config user.name "$GIT_USER_NAME"
    git config user.email "$GIT_USER_EMAIL"
    git remote add origin "$GITHUB_REPO"
    git branch -m main
fi

# 检查是否有修改
if git status --porcelain | grep -q .; then
    log "检测到文件变化,执行 Git 备份..."
    
    # 添加所有更改
    if ! git add .; then
        log "WARNING: git add 失败"
    fi
    
    # 提交(带时间戳)
    COMMIT_MSG="Auto backup: $(date '+%Y-%m-%d %H:%M:%S')"
    if git commit -m "$COMMIT_MSG"; then
        log "Git commit 成功: $COMMIT_MSG"
        
        # 推送到 GitHub
        if git push origin main; then
            log "Git push 成功: 已推送到 GitHub"
        else
            log "WARNING: git push 失败,可能是网络或认证问题"
        fi
    else
        log "INFO: 没有需要提交的更改"
    fi
else
    log "INFO: 没有文件变化,跳过 Git 备份"
fi

# 4. 将修改推回 R2 同步桶
log "Step 4: 推送修改回 R2..."
if ! rclone sync "$VAULT_PATH" r2-sync:your-vault-bucket --log-file="$RCLONE_LOG" --log-level=INFO; then
    log "ERROR: rclone push 失败,终止任务"
    exit 1
fi
log "Step 4 完成: vault 推送成功"

log "===== 同步任务完成 ====="

3. 安装脚本

文件: /path/to/scripts/setup.sh

#!/bin/bash
# 一键安装脚本

set -e

echo "=== 图床自动化同步安装脚本 ==="

# 创建目录
echo "创建目录..."
mkdir -p /path/to/scripts/logs
mkdir -p /path/to/your-vault
mkdir -p ~/.ssh

# 安装依赖
echo "安装 Python 依赖..."
pip3 install boto3

echo "安装 rclone..."
if ! command -v rclone &> /dev/null; then
    curl https://rclone.org/install.sh | sudo bash
else
    echo "rclone 已安装"
fi

echo "检查 Git..."
if ! command -v git &> /dev/null; then
    echo "安装 Git..."
    sudo apt-get update && sudo apt-get install -y git  # Ubuntu/Debian
    # sudo yum install -y git  # CentOS/RHEL
else
    echo "Git 已安装"
fi

# 添加执行权限
echo "添加执行权限..."
chmod +x /path/to/scripts/sync-and-migrate.sh
chmod +x /path/to/scripts/media-to-imagebed.py
chmod +x /path/to/scripts/setup.sh

echo ""
echo "=== 安装完成 ==="
echo ""
echo "请完成以下配置:"
echo "1. 修改 scripts/media-to-imagebed.py 中的配置区域"
echo "2. 修改 scripts/sync-and-migrate.sh 中的配置区域"
echo "3. 运行 'rclone config' 配置 R2 连接"
echo "4. 生成 SSH key 并添加到 GitHub"
echo "5. 添加 crontab: */10 * * * * /path/to/scripts/sync-and-migrate.sh"
echo ""

4. Cron 定时任务

# 编辑 crontab
crontab -e

# 添加以下行(每 10 分钟执行一次)
*/10 * * * * /path/to/scripts/sync-and-migrate.sh >> /path/to/scripts/logs/cron.log 2>&1

工作原理

┌─────────────────┐         ┌──────────────┐         ┌─────────────────┐
│   iOS Obsidian  │         │              │         │  PC Obsidian    │
│   Remotely Save │◄────────┤  Cloudflare  ├────────►│   Remotely Save │
└────────┬────────┘         │      R2      │         └────────┬────────┘
         │                  │  (同步桶)    │                  │
         │                  └──────┬───────┘                  │
         │                         │                          │
         ▼                         ▼                          ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                              NAS 服务器                                  │
│  ┌─────────────┐    ┌──────────────┐    ┌──────────┐    ┌─────────────┐ │
│  │ rclone pull │───►│ media-to-    │───►│  Git     │───►│ rclone push │ │
│  │             │    │ imagebed.py  │    │  commit  │    │             │ │
│  └─────────────┘    └──────────────┘    │  & push  │    └─────────────┘ │
│                            │            └────┬─────┘                   │
│                            ▼                 │                          │
│                   ┌─────────────────┐        │                          │
│                   │  R2 图床桶      │        │    ┌──────────────────┐  │
│                   │  (video/audio/  │        └───►│  GitHub          │  │
│                   │   images)       │             │  (版本备份)      │  │
│                   └─────────────────┘             └──────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

流程说明:

  1. R2 Pull: 从 Cloudflare R2 同步桶拉取最新 vault 到本地
  2. 附件迁移: Python 脚本扫描 Markdown 文件,将本地附件上传到 R2 图床,替换为远程链接,删除本地文件
  3. Git 备份: 提交所有变更到本地 Git 仓库,推送到 GitHub(版本控制 + 异地备份)
  4. R2 Push: 将处理后的纯文本 vault 推回 R2 同步桶,供各设备同步

双保险架构

层级 功能 优势
R2 同步 实时多端同步 毫秒级同步,设备间无缝切换
Git 备份 版本历史记录 可回滚到任意版本,防止误删

为什么需要 Git?

注意事项

  1. 首次运行: 建议先手动运行一次 sync-and-migrate.sh 确保流程正常
  2. 备份: 重要数据建议先在本地备份一份
  3. 网络: NAS 需要有稳定的出站网络连接(HTTPS)
  4. 费用: R2 有免费额度,超出后会产生费用,请留意使用量
  5. 安全: Access Key 和 SSH key 请妥善保管,不要上传到公开仓库

故障排查

问题 可能原因 解决方案
rclone 连接失败 密钥错误或网络问题 检查 rclone config 和防火墙
Git push 失败 SSH 认证失败 检查 SSH key 是否正确添加到 GitHub
文件未上传 路径配置错误 检查 VAULT_PATH 和 ATTACHMENTS_DIR
链接未替换 文件格式不匹配 确认 MEDIA_EXTENSIONS 包含你的文件类型
Cron 不执行 权限或路径问题 使用绝对路径,检查日志文件

扩展建议

文件结构

/path/to/
├── your-vault/                  # Obsidian Vault 本地目录
│   ├── .git/                    # Git 仓库
│   ├── .obsidian/               # Obsidian 配置
│   ├── Attachments/             # 附件目录(会被清理)
│   ├── Daily/                   # 日记
│   ├── Templates/               # 模板
│   └── *.md                     # 笔记文件
│
└── scripts/                     # 脚本目录
    ├── sync-and-migrate.sh      # 主调度脚本
    ├── media-to-imagebed.py     # 附件迁移脚本
    ├── setup.sh                 # 安装脚本
    └── logs/                    # 日志目录
        ├── rclone.log
        ├── media-migrate.log
        └── cron.log