图床自动化同步 Prompt
功能描述
搭建自动化脚本,定时从 Cloudflare R2 同步桶拉取 Obsidian vault,扫描笔记中的本地视频/附件引用,将文件复制到图床桶,替换笔记中的链接为远程 URL,删除本地文件,Git 提交版本备份,再推回同步桶。
环境信息
- OS: Rocky Linux 9 / Ubuntu / CentOS(24 小时运行的服务器)
- Vault 本地路径:
/path/to/your-vault - Python 3: 已安装
- Git: 已安装
前置条件
1. Cloudflare R2 配置
需要准备两个 R2 存储桶:
同步桶(存储整个 Obsidian vault)
- 桶名:
your-vault-bucket - Access Key ID:
YOUR_SYNC_ACCESS_KEY_ID - Secret Access Key:
YOUR_SYNC_SECRET_ACCESS_KEY
图床桶(存储视频/附件)
- 桶名:
your-image-bed-bucket - Access Key ID:
YOUR_IMAGEBED_ACCESS_KEY_ID - Secret Access Key:
YOUR_IMAGEBED_SECRET_ACCESS_KEY - 公开域名:
https://your-domain.r2.dev
获取方式: Cloudflare Dashboard → R2 → 创建桶 → 管理 R2 API 令牌
2. GitHub 仓库配置
创建私有仓库用于版本备份:
- 仓库名:
your-vault-backup - 类型: Private(保护隐私)
- 初始化: 不勾选 README(本地已有文件)
3. rclone 配置
# 安装 rclone
curl https://rclone.org/install.sh | sudo bash
# 配置
rclone config
在交互式配置中创建两个 remote:
remote 1: r2-sync(同步桶)
- type: s3
- provider: Cloudflare
- access_key_id: YOUR_SYNC_ACCESS_KEY_ID
- secret_access_key: YOUR_SYNC_SECRET_ACCESS_KEY
- endpoint: https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
- acl: private
remote 2: r2-imagebed(图床桶)
- type: s3
- provider: Cloudflare
- access_key_id: YOUR_IMAGEBED_ACCESS_KEY_ID
- secret_access_key: YOUR_IMAGEBED_SECRET_ACCESS_KEY
- endpoint: https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
- acl: private
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""
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) │ │ (版本备份) │ │
│ └─────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
流程说明:
- R2 Pull: 从 Cloudflare R2 同步桶拉取最新 vault 到本地
- 附件迁移: Python 脚本扫描 Markdown 文件,将本地附件上传到 R2 图床,替换为远程链接,删除本地文件
- Git 备份: 提交所有变更到本地 Git 仓库,推送到 GitHub(版本控制 + 异地备份)
- R2 Push: 将处理后的纯文本 vault 推回 R2 同步桶,供各设备同步
双保险架构
| 层级 | 功能 | 优势 |
|---|---|---|
| R2 同步 | 实时多端同步 | 毫秒级同步,设备间无缝切换 |
| Git 备份 | 版本历史记录 | 可回滚到任意版本,防止误删 |
为什么需要 Git?
- R2 同步是覆盖式的,没有历史记录
- Git 提供完整的时间机器,可查看/恢复任意历史版本
- GitHub 作为异地备份,即使 R2 出问题也能恢复
- 纯文本仓库体积小,Git 处理速度快
注意事项
- 首次运行: 建议先手动运行一次
sync-and-migrate.sh确保流程正常 - 备份: 重要数据建议先在本地备份一份
- 网络: NAS 需要有稳定的出站网络连接(HTTPS)
- 费用: R2 有免费额度,超出后会产生费用,请留意使用量
- 安全: Access Key 和 SSH key 请妥善保管,不要上传到公开仓库
故障排查
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| rclone 连接失败 | 密钥错误或网络问题 | 检查 rclone config 和防火墙 |
| Git push 失败 | SSH 认证失败 | 检查 SSH key 是否正确添加到 GitHub |
| 文件未上传 | 路径配置错误 | 检查 VAULT_PATH 和 ATTACHMENTS_DIR |
| 链接未替换 | 文件格式不匹配 | 确认 MEDIA_EXTENSIONS 包含你的文件类型 |
| Cron 不执行 | 权限或路径问题 | 使用绝对路径,检查日志文件 |
扩展建议
- 添加 Telegram/钉钉通知功能,同步失败时告警
- 支持更多附件类型(如 PDF、压缩包)
- 添加上传后图片压缩/转码功能
- 添加 Web 界面查看同步状态和日志
- 配置 GitHub Actions 自动备份到多个仓库
文件结构
/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