diff --git a/skills/devops/obsidian-vault-sync/SKILL.md b/skills/devops/obsidian-vault-sync/SKILL.md new file mode 100644 index 00000000..bd54128a --- /dev/null +++ b/skills/devops/obsidian-vault-sync/SKILL.md @@ -0,0 +1,259 @@ +--- +name: obsidian-vault-sync +description: "Use when 管理/排查 Obsidian vault(同步·配置·插件·重启·排除文件)。remotely-save+WebDAV 全流程:data.json 解码、远端取证、命令行重启、负载取舍。" +version: "1.4.0" +date: 2026-09-11(§1 **纠正**:data.json 是 reverse+base64 混淆**可解**,解码读值即最强验证;补 2026-09-11 解码基线、ignorePaths 字段、运维地图) +triggers: + - 同步笔记 + - obsidian 同步 + - webdav 同步 + - remotely-save + - 同步失败 + - vault 备份 + - obsidian 管理 + - obsidian 配置 + - 插件同步 + - 排除文件 + - 重启 obsidian +--- + +# Obsidian vault 同步(remotely-save + WebDAV) + +> 把 `~/mc` 这类 Obsidian vault 同步到自建服务器。核心难点不是上传,而是**remotely-save 的配置是混淆存储的**——不会解码就改不了服务器地址。 + +## 触发条件 + +- 「Obsidian 怎么管 / 配置/插件/同步/重启」「排除文件怎么配」 +- 「同步笔记到服务器」「obsidian 同步」「webdav 同步」 +- remotely-save / Remotely Save 插件相关 +- vault 跨设备/备份需求,或同步报错、冲突 + +## 1. remotely-save 配置是混淆存储的 ⭐ + +`.obsidian/plugins/remotely-save/data.json` **只有一个 `d` 字段**,值是混淆串: + +```json +{"readme": "...DO NOT share...", "d": "91nIiojIslWYtVmIs01W6IyclJXd0FW..."} +``` + +**规则:reverse + base64**(编码 = 先 base64 再反转;解码反之): + +```python +import json, base64 + +d = json.load(open(path)) + +# 解码 +cfg = json.loads(base64.b64decode(d['d'][::-1]).decode('utf-8')) + +# ... 改配置,例如 cfg['webdav']['address'] = 'http://new-host:6086/' + +# 编码写回 +d['d'] = base64.b64encode(json.dumps(cfg, ensure_ascii=False).encode('utf-8')).decode()[::-1] +json.dump(d, open(path, 'w'), ensure_ascii=False) +``` + +⚠️ `base64decode(enc)` 直接解会得到乱码 — **必须先 `[::-1]` 反转**。 +⚠️ 改前备份 `data.json.bak-`;写完再解码验证一次。 +⚠️ **插件运行中改文件 = 会被内存态盖回去** → 必须 `pgrep -f obsidian` 确认已关闭再改。 + 实证:改完的 `data.json` 写着 mtime 22:22,但文件实际 mtime 是 **22:34**(Obsidian 从内存重写了一次)→ + **mtime 晚于你的编辑时间,就是这个文件被覆写过的铁证**。 + +### ✅ 验证:**先解码读值**,再用远端取证(2026-09-11 纠正:此前本节写「验证不了」,是错的)⭐ + +`d` 字段是**混淆(reverse+base64)而非加密** —— 所以**能直接解出插件真正在读的配置**。 +这是最强证据:读到的是插件的实际取值,不是「我以为写进去了」。 + +```python +import json, base64 +d = json.load(open(path)) +cfg = json.loads(base64.b64decode(d['d'][::-1]).decode('utf-8')) # ← 注意先 [::-1] 再 b64decode +print(cfg['syncConfigDir'], cfg['webdav']['address'], cfg['ignorePaths']) +``` + +实测(2026-09-11 `~/mc`):解出的 `syncConfigDir = true`(且是那次「疑似被覆写」之后的值) +→ 直接结案;PROPFIND 那条路只是**交叉验证**,不是唯一手段。 + +| 手段 | 强度 | 说明 | +|------|------|------| +| **解码 `d` 读值** | **最高** | 读到插件的真实取值;能证明「写进去了且没被盖回去」(每次读都是当前盘上内容)| +| 服务端取证 | 高 | `PROPFIND` 应出现 `.obsidian/plugins/<插件>/main.js` 且字节数与本机一致 ⇒ 证明**效果**已抵达其他设备 | +| 反向证据 | 高 | 本机删一个大目录 → 等一个周期 → 远端该目录消失 ⇒ 双向删除在工作 | +| GUI 看 | 中 | 设置页开关(人看,不入日志,无法留证)| +| mtime 线索 | 低 | **文件 mtime 晚于你的编辑时间 = 该文件被重写过**;能说明「可能被内存态覆盖」,**但不能说明覆盖成了什么** → 必须解码看内容才能定性(2026-09-11 实测:mtime 22:34 晚于编辑 22:22,但解码后目标值仍在,属重序列化而非回退)| + +⚠️ `.obsidian/` 不在 vault 的 git 里(被 vault `.gitignore` 排除)→ **WebDAV 是插件与同步配置唯一的异地副本**, +它没同步成功 = 换设备重新配置一遍。 + +## 2. 关键配置字段 + +| 字段 | 含义 | 典型值 | +|------|------|--------| +| `serviceType` | 同步后端 | `webdav` / `s3` / `onedrive` | +| `webdav.address` | 地址(**末尾带 /**)| `http://:6086/` | +| `webdav.username` / `password` | 凭证 | 取 `牧尘/claw/key.md`(不硬编码)| +| `webdav.authType` | 认证 | `basic` | +| `syncDirection` | 方向 | `bidirectional` / `incremental-push-only` / `incremental-pull-only` | +| `conflictAction` | 冲突策略 | `keep_newer` | +| `autoRunEveryMilliseconds` | 自动同步间隔 | `600000`(10 分钟)| +| `skipSizeLargerThan` | 单文件上限 | `-1` = 不限 | +| `protectModifyPercentage` | 大规模变更保护 | `100` | +| `ignorePaths` | 排除同步的路径(设备相关/含凭证的)| 实测 4 条:`.obsidian/workspace.json`、`.obsidian/workspace-mobile.json`、`.obsidian/plugins/remotely-save/data.json`(防循环+防凭证外泄)、`.obsidian/cache` | +| `obfuscateSettingFile` | 配置是否混淆 | 默认开(所以看到 `d`)| + +**2026-09-11 实测基线**(`~/mc` 解码所得,改配置后可对照): +``` +syncConfigDir = true syncDirection = bidirectional conflictAction = keep_newer +autoRunEveryMilliseconds = 600000(10min) +webdav.address = http://192.168.188.13:6086/ username = admin authType = basic +``` + +## 3. WebDAV 原生操作(不需要 rclone / cadaver) + +Python `urllib` 足够,零依赖: + +| 操作 | 方法 | 备注 | +|------|------|------| +| 列目录 | `PROPFIND` + `Depth: 1` | 返回 XML,正则提 `` | +| 递归统计 | `PROPFIND` + `Depth: infinity` | 部分服务器不支持 | +| 建目录 | `MKCOL` | 405 = 已存在(可忽略)| +| 上传 | `PUT` | 201 = 新建成功 | +| 下载 | `GET` | | +| 删目录 | `DELETE`(**路径尾带 `/`**)| 递归删整个 collection | + +⚠️ **中文/空格文件名必须 URL 编码**:`urllib.parse.quote(path, safe='/%')` +⚠️ 认证头:`Authorization: Basic base64(user:pass)` +⚠️ `urllib.request` 遇 4xx 抛 `HTTPError` —— 要单独 catch 拿状态码,别当崩溃 + +## 4. 全量同步(以本机为权威版本) + +``` +1. PROPFIND // Depth:1 → 列服务器旧条目 +2. 逐条 DELETE(目录尾带 /) → 清空 +3. os.walk 收集本机 dirs+files → 排除 .git/.trash/__pycache__/.smart-env +4. MKCOL 目录 → 按 path 深度排序(父目录先建) +5. ThreadPoolExecutor(12) 并发 PUT +6. 验证 → 抽查 md5 + PROPFIND Depth:infinity 计数 +``` + +实测(2026-09-10):**2590 文件 + 148 目录 / 12 线程 / 118 秒 / 0 失败**;本机 2738 对象 ↔ 远端 2739 ✓ + +▶ 可复用脚本:`scripts/vault-webdav-sync.py`(清空 + 并发上传 + md5 抽查,凭证走环境变量) + +## 5. 陷阱 + +- **服务器旧目录会「复活」已删数据**:NAS 上的旧同步常含本机早已删除的历史目录(迁出的项目、废弃文档)→ 全量同步前**必须清空**,否则旧的会回来 +- `bidirectional` 只有双方一致后才安全;单向需求改 `syncDirection` +- **凭证库会随 vault 上传**(如 `牧尘/claw/key.md`)→ 先确认服务器在内网/VPN 且有认证 +- **⚠️ 别把「体积大」直接推成「每周期重传」(2026-09-11 自我纠正,此前本行写错了)**: + remotely-save 是**增量同步**,只传变化的文件。证据:服务器端文件 mtime 停在**文件真正改动的那一刻** + (实测某文件服务器 mtime 22:40 = 本机改动时间,而检查时刻已是 01:30;若每周期重传,mtime 应为「刚刚」)。 + 所以 109M 的 assets 只是**首次上传过一次**,之后每轮仅比对 → 同步负担远小于「全量重传」的说法。 +- **大目录要不要移出/排除,先 grep 笔记有没有引用它**: + `grep -rnE '\]\([^)]*assets/|!\[\[|src="[^"]*assets/' <笔记目录>`。 + 实测三个 assets 目录被笔记**相对链接**引用 **110 + 83 处** → 搬出 vault 会让**本地**链接也失效 + (Obsidian 只解析 vault 内相对路径)→ 结论是**保持原地、也不加 `ignorePaths`** + (加了 = 其他设备连链接都点不开)。审计口径:`du -sh */ | sort -h` + `find . -type f -size +5M` +- `.obsidian/` 通常被 vault 的 `.gitignore` 排除 → 插件与同步配置**不进 git**,WebDAV 是它们唯一的异地副本 +- 手动改完同步配置后,记得把新文件也 PUT 一份到服务器(否则下次自动同步可能把服务器旧版拉回来) + +## 6. 设置类改动:`app.json`(排除文件)与「生效」的两个前提 + +remotely-save 的配置在 `data.json`(混淆、易被内存覆写);Obsidian 自身的设置在 `app.json`(**明文、可直接改**)。 +**两者行为不同,别套用同一套结论**: + +| 文件 | 明文? | 运行中改文件 | 关键前提 | +|------|--------|--------------|----------| +| `.obsidian/app.json` | ✅ 明文 | **实测存活**(改完 150s 后 md5 不变)| **要重启 Obsidian 才被读进内存** | +| `.obsidian/plugins/*/data.json` | ❌ 混淆(`d`)| **会被内存覆写** | 必须先关 Obsidian(见 §1)| + +**排除文件(Excluded files)**——作用范围:搜索 / 图谱 / 快速切换 / 链接建议 / 反向链接: + +```json +// .obsidian/app.json +"userIgnoreFilters": ["小唯/04-Archive/", "小雪/07-Wiki/archived/", "小雪/07-Wiki/knowledge/team-docs/"] +``` + +流程:`cp app.json /tmp/app.json.bak-` → 写入 → `md5sum` 记下 → **等 2-3 分钟再 `md5sum` 一次** +→ 变了就是被覆写(改法无效,得走 GUI)。 +**改完必须让用户重启 Obsidian**(不重启则:①规则不生效 ②用户重启前动任何设置都会用内存里的旧副本把文件顶掉)。 + +### 用户不在电脑前时:命令行重启 Obsidian(2026-09-11 实测通过) + +用户说「我不在电脑前,无法操作」时**不用等他**——关键是**先从旧进程抓启动环境**,否则 Electron 起不来: + +```bash +PID=$(pgrep -f '/opt/Obsidian/obsidian$' | head -1) +# ① 抓环境(x11 会话才有) +tr '\0' '\n' < /proc/$PID/environ | grep -E '^(DISPLAY|XAUTHORITY|XDG_RUNTIME_DIR|DBUS_SESSION_BUS_ADDRESS)=' +# ② 温和退出 + 清 renderer/gpu 子进程 +kill -TERM $PID; sleep 3; pkill -f '/opt/Obsidian/obsidian --type=' +# ③ 原环境重开(setsid 让它脱离 agent 的 shell 存活) +setsid env DISPLAY=:0 XAUTHORITY=$HOME/.Xauthority XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus /opt/Obsidian/obsidian \ + > /tmp/obsidian-restart.log 2>&1 < /dev/null & +# ④ 验证**窗口**真的回来了(进程数 ≠ 窗口打开了) +wmctrl -l | grep -i obsidian # 期望 "… - - Obsidian " +``` + +- 写成长脚本再 `bash` 跑,别拼成一条巨长内联命令(会撞 hardline 阻断,见 `git-repo-hygiene` §8) +- **重启后 `app.json` 的 md5 会变 —— 这是正常重序列化,不是被覆写**:判定要**比 JSON 语义** + (`diff <(python3 -m json.tool A) <(python3 -m json.tool B)`),只看 md5 会误判成"规则被顶掉了" +- 重启后 `userIgnoreFilters` 才真正载入内存 → 此时才算锁死;在此之前用户动任何设置都可能把文件顶掉 + +## 7. 验证「同步真的在跑」:用远端取证,别读本地配置 + +本地配置说明不了任何事(混淆 + 可能被覆写)。远端才是证据: + +```bash +PW= # 优先从 牧尘/claw/key.md 取;key.md 里常缺 WebDAV 条目(只有 NAS root)→ 直接问用户,别猜 +curl -s -u "admin:$PW" -X PROPFIND -H "Depth: 1" "http://:6086//" # 目录层 +curl -s -u "admin:$PW" -X PROPFIND -H "Depth: 1" "http://:6086//.obsidian/plugins/" # 插件层 +curl -s -u "admin:$PW" "http://:6086//.obsidian/app.json" # 单文件直读 +``` + +- **正向**:远端出现 `.obsidian/plugins/<插件>/main.js` 且**字节数与本机一致** ⇒ `syncConfigDir` 真生效, + 且「修好的插件」已抵达其他设备(实测:17 个插件 + `zhiyi-memory/main.js` 36956 B 两侧一致)。 + 这比“看 GUI 开关”更硬——开关是意图,远端文件是效果。 +- **反向(最硬)**:本机删掉一个大目录 → 等**一个同步周期**(`autoRunEveryMilliseconds`)→ 远端该目录应消失。 + 实测删除一个 1688 文件的目录后,周期末远端对应子目录确实消失 ⇒ 双向删除也在工作。 +- 顺便看**残留**:远端根列表能发现「本机早已删、远端还在」的旧条目(本次发现远端还留着一个 7.7M 的 `.zip`)。 +- 查同步配置时别忘了 vault 外的**独立仓库**:`.obsidian/` 通常被 vault `.gitignore` 排除 ⇒ 它**不在 git 里**, + WebDAV 是插件与同步配置唯一的异地副本(不同步成功 = 换设备要重配一遍)。 + +## 8. 同步负载:什么不该进来 + +`du -sh --exclude=.git .` + `du -sh */ && find . -type f -size +5M`。 + +⚠️ **先搞清开销模型,再决定动不动它(2026-09-11 纠正)**:remotely-save **增量同步**——大目录的代价是 +**首次上传 + 每周期比对**,不是「每 10 分钟重传一遍」。别把「体积大」当成「必须移走」的理由。 + +- **人读镜像/冻结历史产物**:没有脚本再写、文件名已不可读 ⇒ 移出 vault(见 `notes-vault-curation` §六.11) +- **大二进制**(pptx/pdf/设计稿):**先 grep 笔记引用**再决定 + | 情况 | 处置 | + |------|------| + | 无笔记引用 | 移出 vault(最干净)或 `ignorePaths` | + | **被相对链接引用** | **保持原地** —— 移走断本地链接,`ignorePaths` 断其他设备链接 | +- 移出去之后要**验收两边**:本地 `du -sh` + 远端 PROPFIND 应同步变小 + +## 📎 相关技能与本 vault 的运维地图 + +| 场景 | 去哪 | +|------|------| +| 改 remotely-save 配置 / 验证同步生效 / 重启 Obsidian | **本技能** | +| vault 结构整理、编码乱码、凭证散落、排除文件配置 | `notes-vault-curation`(§五 每日密钥扫描)| +| 发现凭证进了 git / 要清历史 / 写入侧脱敏 | `credential-leak-response` + `git-repo-hygiene` §5-7 | +| 本 vault 的 git 推送、凭据外置、每日自动快照 cron | `git-repo-hygiene` §7.1/§7.2 | +| 开发/修 Obsidian 插件 | `obsidian-plugin` | + +**本 vault 的常驻机制**(`~/mc`): +- 结构检查 cron `a4cbdf0cb31a`(每周一 09:00)· 密钥扫描 cron `6288e67a9a00`(每天 08:45,白名单 `牧尘/`) +- 自动快照 cron `a4da57d2c026`(每天 09:05,脚本 `~/.hermes/scripts/mc-autopush.sh`,双闸门) +- 人读说明书:`小唯/07-Wiki/06-工具与资源/Obsidian配置与插件说明.md`(配置/插件/同步/版本控制政策) + +## 实战记录 + +| 日期 | 内容 | +|------|------| +| 2026-09-11 | 实证 `syncConfigDir` **生效**:`PROPFIND /mc/.obsidian/plugins/` → 17 个插件全在服务器上,`zhiyi-memory/main.js` 36956 B 与本机一致(服务器 mtime 22:40 / 本机 22:29)⇒ 之前的「无法验证」结案。反向验证:本机删除 1688 文件目录 → 一个周期后远端该子目录消失。写入 `app.json` 的 `userIgnoreFilters` 三条排除规则并同步成功(远端 GET 可读到)。移出冻结镜像后 vault 200M→194M / md 2147→459 | +| 2026-09-10 | `~/mc` → `http://192.168.188.13:6086/mc/`(NAS iStoreOS WebDAV,bidirectional + keep_newer + 10min)。从旧地址 `192.168.123.125:6086` 改来(同一台 NAS 的 VPN 网段地址)。清空服务器 31 个旧条目后上传 2590 文件,抽查 5 个 md5 全一致 |