142 lines
4.5 KiB
Python
142 lines
4.5 KiB
Python
#!/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 —— 纯 stdlib(urllib)。中文文件名自动 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()
|