/** * 从坚果云接入笔记 * * 模式 A — 本地同步盘(推荐,有坚果云客户端时): * .env 设置 NUTSTORE_VAULT=D:/Nutstore/我的坚果云/Obsidian * npm run sync:nutstore * * 模式 B — WebDAV(本机没有客户端时): * .env 设置: * NUTSTORE_WEBDAV_USER=你的坚果云邮箱 * NUTSTORE_WEBDAV_PASS=应用密码(账号安全页生成,不是登录密码) * NUTSTORE_WEBDAV_PATH=/dav/你的库文件夹名 (可选,默认整盘 dav 根下递归 .md) * npm run sync:nutstore -- --webdav * * 其它: * --dry-run 只列文件 * --clean 同步前清空 src/content/posts */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadEnv } from './lib/load-env.mjs'; import { IMAGE_EXT, ensureDir, ensureTitle, parseFrontmatter, shouldPublish, syncLocalVault, } from './lib/vault-sync.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); loadEnv(root); const dryRun = process.argv.includes('--dry-run'); const clean = process.argv.includes('--clean'); const forceWebdav = process.argv.includes('--webdav'); const postsOut = path.join(root, 'src/content/posts'); const assetsOut = path.join(root, 'public/assets'); const cacheDir = path.join(root, '.cache/nutstore-vault'); const DAV_BASE = (process.env.NUTSTORE_WEBDAV_URL || 'https://dav.jianguoyun.com/dav').replace( /\/$/, '', ); function authHeader() { const user = process.env.NUTSTORE_WEBDAV_USER || process.env.NUTSTORE_USER; const pass = process.env.NUTSTORE_WEBDAV_PASS || process.env.NUTSTORE_PASS; if (!user || !pass) { throw new Error( '缺少 WebDAV 凭证。请在 .env 设置 NUTSTORE_WEBDAV_USER / NUTSTORE_WEBDAV_PASS\n' + '应用密码:坚果云网页 → 账户信息 → 安全选项 → 添加应用密码', ); } return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); } function joinDav(base, rel) { const b = base.replace(/\/$/, ''); const r = String(rel || '').replace(/^\/+/, ''); return r ? `${b}/${r}` : b; } /** PROPFIND 列出一层;返回 { href, isDir, displayName }[] */ async function propfind(url, depth = '1') { const res = await fetch(url, { method: 'PROPFIND', headers: { Authorization: authHeader(), Depth: depth, 'Content-Type': 'application/xml; charset=utf-8', }, body: ` `, }); if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`WebDAV PROPFIND 失败 ${res.status} ${url}\n${t.slice(0, 300)}`); } const xml = await res.text(); const responses = []; const chunks = xml.split(/]/i).slice(1); for (const chunk of chunks) { const hrefM = chunk.match(/([^<]+)<\/d:href>/i); if (!hrefM) continue; let href = hrefM[1].trim(); try { href = decodeURIComponent(href); } catch { /* keep raw */ } const isDir = //i.test(chunk) || //i.test(chunk); const nameM = chunk.match(/([^<]*)<\/d:displayname>/i); const displayName = nameM ? nameM[1] : path.posix.basename(href.replace(/\/$/, '')); responses.push({ href, isDir, displayName }); } return responses; } /** 递归收集 .md / 图片的 WebDAV 路径(相对 dav root 的 path) */ async function listRemoteFiles(startUrl) { const found = []; const queue = [startUrl]; const seen = new Set(); while (queue.length) { const url = queue.shift(); if (seen.has(url)) continue; seen.add(url); const items = await propfind(url, '1'); // 第一项通常是自己 for (const item of items) { const abs = item.href.startsWith('http') ? item.href : `https://dav.jianguoyun.com${item.href.startsWith('/') ? '' : '/'}${item.href}`; // 跳过自身 const normSelf = url.replace(/\/$/, ''); const normItem = abs.replace(/\/$/, ''); if (normItem === normSelf) continue; if (item.isDir) { // 跳过隐藏/系统目录 const base = item.displayName || path.posix.basename(normItem); if (base.startsWith('.')) continue; if (['private', 'templates', 'template', '.obsidian', '.trash'].includes(base.toLowerCase())) continue; queue.push(normItem.endsWith('/') ? normItem : normItem + '/'); continue; } const ext = path.extname(normItem).toLowerCase(); if (ext === '.md' || ext === '.mdx' || IMAGE_EXT.has(ext)) { found.push(abs); } } } return found; } function remoteToRel(remoteUrl, rootUrl) { const r = remoteUrl.replace(/\/$/, ''); const root = rootUrl.replace(/\/$/, ''); let rel = r.startsWith(root) ? r.slice(root.length) : r; rel = rel.replace(/^\/+/, ''); try { rel = decodeURIComponent(rel); } catch { /* ignore */ } return rel; } async function downloadToCache(remoteFiles, rootUrl) { ensureDir(cacheDir); // 清空缓存目录 for (const name of fs.readdirSync(cacheDir)) { fs.rmSync(path.join(cacheDir, name), { recursive: true, force: true }); } let n = 0; for (const url of remoteFiles) { const rel = remoteToRel(url, rootUrl); const dest = path.join(cacheDir, rel); if (dryRun) { console.log(` ~ ${rel}`); n++; continue; } ensureDir(path.dirname(dest)); const res = await fetch(url, { headers: { Authorization: authHeader() } }); if (!res.ok) { console.warn(` ! 下载失败 ${res.status}: ${rel}`); continue; } const buf = Buffer.from(await res.arrayBuffer()); fs.writeFileSync(dest, buf); n++; if (n % 20 === 0) console.log(` … 已下载 ${n} 个文件`); } return n; } function resolveLocalVault() { return ( process.env.NUTSTORE_VAULT || process.env.NUTSTORE_LOCAL_PATH || process.env.OBSIDIAN_VAULT || '' ); } async function main() { const local = resolveLocalVault(); const hasWebdavCred = Boolean(process.env.NUTSTORE_WEBDAV_USER || process.env.NUTSTORE_USER) && Boolean(process.env.NUTSTORE_WEBDAV_PASS || process.env.NUTSTORE_PASS); // 有本地路径且存在、未强制 webdav → 本地模式 if (local && fs.existsSync(local) && !forceWebdav) { console.log('模式: 坚果云本地同步盘'); console.log(`Vault: ${local}`); console.log(`Out: ${postsOut}(统一文章流)`); const r = syncLocalVault({ vault: local, postsOut, assetsOut, dryRun, clean }); console.log( dryRun ? `[dry-run] 将同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}` : `完成:同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`, ); return; } // 无本机库 → WebDAV(填了凭证即可,不必强加 --webdav) if (!hasWebdavCred) { console.error('笔记不在本机时,请用坚果云 WebDAV:\n'); console.error('1. 打开 https://www.jianguoyun.com/d/account → 安全选项 → 添加应用密码'); console.error('2. 编辑项目根目录 .env:'); console.error(' NUTSTORE_WEBDAV_USER=你的邮箱'); console.error(' NUTSTORE_WEBDAV_PASS=应用密码(不是登录密码)'); console.error(' NUTSTORE_WEBDAV_PATH=库文件夹名 # 可选,先留空可扫整个盘'); console.error('3. 运行: npm run sync:nutstore:webdav'); console.error(' 或: npm run sync:nutstore -- --dry-run'); process.exit(1); } if (local && !fs.existsSync(local)) { console.warn(`提示: NUTSTORE_VAULT 路径不存在,改走 WebDAV: ${local}`); } // WebDAV 模式 const sub = (process.env.NUTSTORE_WEBDAV_PATH || process.env.NUTSTORE_DAV_PATH || '').replace( /^\/dav\/?/i, '', ); const rootUrl = joinDav(DAV_BASE, sub); console.log('模式: 坚果云 WebDAV'); console.log(`Remote: ${rootUrl}`); console.log(`Cache: ${cacheDir}`); console.log(`Out: ${postsOut}(统一文章流)`); console.log('列出远程文件…'); const files = await listRemoteFiles(rootUrl.endsWith('/') ? rootUrl : rootUrl + '/'); console.log(`远程匹配 ${files.length} 个文件(md/图片)`); const downloaded = await downloadToCache(files, rootUrl); console.log(dryRun ? `[dry-run] 将下载 ${downloaded} 个` : `已下载 ${downloaded} 个到缓存`); if (dryRun) return; const r = syncLocalVault({ vault: cacheDir, postsOut, assetsOut, dryRun: false, clean, }); console.log(`完成:同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`); } main().catch((e) => { console.error(e.message || e); process.exit(1); });