xiaowei-system/skills/devops/obsidian-vault-sync/scripts/vault-webdav-sync.py

142 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Obsidian vault → WebDAV 全量同步(以本机为权威版本)
用法:
export WEBDAV_URL=http://192.168.188.13:6086/mc # 含目标目录
export WEBDAV_USER=admin
export WEBDAV_PASS=<从 ~/mc/牧尘/claw/key.md 取>
python3 vault-webdav-sync.py --vault ~/mc --clear
--clear 先清空服务器目标目录(以本机为最终版时必加,否则旧的已删数据会复活)
--dry-run 只统计不上传
--threads 并发数(默认 12
不需要 rclone/cadaver —— 纯 stdliburllib。中文文件名自动 URL 编码。
"""
import argparse
import base64
import concurrent.futures
import hashlib
import os
import re
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import quote
EXCL = {".git", ".trash", "__pycache__", ".smart-env", ".tmp"}
def make_client(base, user, pw):
auth = base64.b64encode(f"{user}:{pw}".encode()).decode()
def req(method, path, data=None, depth=None, timeout=90):
url = base + quote(path, safe="/%")
r = urllib.request.Request(url, data=data, method=method)
r.add_header("Authorization", f"Basic {auth}")
if depth:
r.add_header("Depth", depth)
try:
with urllib.request.urlopen(r, timeout=timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, b""
except Exception as e:
return str(e)[:40], b""
return req
def clear_remote(req, root):
"""DELETE 每个顶层条目(目录尾带 / 触发递归删)"""
st, body = req("PROPFIND", root, depth="1")
items = [h.decode() if isinstance(h, bytes) else h
for h in re.findall(rb"<D:href>([^<]+)</D:href>", body)]
items = [i for i in items if i.rstrip("/") != root.rstrip("/")]
for it in items:
req("DELETE", it)
return len(items)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--vault", default=os.path.expanduser("~/mc"))
ap.add_argument("--clear", action="store_true", help="先清空服务器目标目录")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--threads", type=int, default=12)
a = ap.parse_args()
base = os.environ.get("WEBDAV_URL")
user = os.environ.get("WEBDAV_USER", "")
pw = os.environ.get("WEBDAV_PASS")
if not base or pw is None:
sys.exit("需要 WEBDAV_URL / WEBDAV_USER / WEBDAV_PASS凭证见 ~/mc/牧尘/claw/key.md")
# root = URL 的路径部分,供 PROPFIND/DELETE 用
parts = base.rstrip("/").split("/", 3)
root = "/" + (parts[3] if len(parts) > 3 else "") + "/"
req = make_client(base.rstrip("/"), user, pw)
if a.clear and not a.dry_run:
n = clear_remote(req, root)
print(f"清空服务器: {n} 条旧条目")
dirs, files = set(), []
for r, ds, fs in os.walk(a.vault):
ds[:] = [d for d in ds if d not in EXCL]
rel = r[len(a.vault):]
for d in ds:
dirs.add(f"{rel}/{d}")
for f in fs:
files.append((f"{rel}/{f}", os.path.join(r, f)))
print(f"本机: {len(dirs)} 目录 + {len(files)} 文件")
if a.dry_run:
return
for d in sorted(dirs, key=lambda x: x.count("/")):
req("MKCOL", d + "/")
def up(item):
rp, lp = item
try:
data = open(lp, "rb").read()
except Exception:
return ("READ", rp)
return (req("PUT", rp, data), rp)
t0 = time.time()
ok = fail = 0
errs = []
with concurrent.futures.ThreadPoolExecutor(a.threads) as ex:
for st, rp in ex.map(up, files):
if st in (200, 201, 204):
ok += 1
else:
fail += 1
if len(errs) < 10:
errs.append(f"{st} {rp}")
print(f"上传 {ok} 成功 / {fail} 失败,{time.time() - t0:.0f}s")
for e in errs:
print("", e)
print("验证抽查:")
bad = 0
for rp, lp in files[:5]:
st, remote = req("GET", rp)
local = open(lp, "rb").read()
same = hashlib.md5(remote).hexdigest() == hashlib.md5(local).hexdigest()
bad += 0 if same else 1
print(f" {'' if same else ''} {rp}")
print("全部一致 ✅" if bad == 0 else f"{bad} 个不一致 ❌")
st, body = req("PROPFIND", root, depth="infinity")
if st == 207:
print(f"远端对象总数: {len(re.findall(rb'<D:href>', body))}")
if __name__ == "__main__":
main()