{"slug": "bulk-backup-of-all-plaud-ai-recordings-audio-transcripts-ai-summaries-across-s", "title": "Bulk backup of all Plaud.ai recordings (audio, transcripts, AI summaries) across multiple accounts, using Plaud's official CLI (@plaud-ai/cli). Resumable, per-account logins.", "summary": "A developer published an open-source Node.js script, plaud-backup.mjs, that uses Plaud's official CLI (@plaud-ai/cli) to bulk-export all recordings from one or more Plaud.ai accounts, including audio, transcripts, AI summaries and highlight images. The tool isolates each account in its own login profile under a per-account home directory so multiple accounts can be backed up without logging out, and re-runs resume by skipping completed recordings while fetching any missing photos. It retries on network and timeout errors but stops immediately on authentication failures.", "body_md": "|  | #!/usr/bin/env node | \n|  | // plaud-backup.mjs — back up every Plaud recording (audio, transcripts, summaries) | \n|  | // from one or more Plaud accounts, using Plaud's official CLI (@plaud-ai/cli). | \n|  | // | \n|  | // Usage (PowerShell): | \n|  | //   node plaud-backup.mjs                      -> prompts for an account label, logs in, backs up | \n|  | //   node plaud-backup.mjs --out \"D:\\PlaudBackup\" | \n|  | //   node plaud-backup.mjs --account work       -> skip the prompt | \n|  | //   node plaud-backup.mjs --account work --yes -> also skip the \"is this the right account?\" check | \n|  | //   node plaud-backup.mjs --no-audio           -> skip audio files | \n|  | //   node plaud-backup.mjs --no-images          -> skip photos from highlights/summaries | \n|  | // | \n|  | // Each account gets its own login profile under <out>\\_profiles\\<label>, so you can | \n|  | // back up several accounts without logging out/in. Re-running resumes: recordings | \n|  | // already finished are skipped (but photos missing from older runs are fetched). | \n|  |  | \n|  | import { spawnSync, execSync } from \"node:child_process\"; | \n|  | import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, createWriteStream, unlinkSync, readdirSync } from \"node:fs\"; | \n|  | import { join, extname } from \"node:path\"; | \n|  | import { homedir } from \"node:os\"; | \n|  | import { createInterface } from \"node:readline/promises\"; | \n|  | import { Readable } from \"node:stream\"; | \n|  | import { pipeline } from \"node:stream/promises\"; | \n|  |  | \n|  | // ---------- args ---------- | \n|  | const args = process.argv.slice(2); | \n|  | const argVal = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; | \n|  | const OUT_ROOT = argVal(\"--out\") ?? join(homedir(), \"Documents\", \"PlaudBackup\"); | \n|  | let ACCOUNT = argVal(\"--account\"); | \n|  | const SKIP_AUDIO = args.includes(\"--no-audio\"); | \n|  | const AUTO_YES = args.includes(\"--yes\"); | \n|  | const SKIP_IMAGES = args.includes(\"--no-images\"); | \n|  |  | \n|  | // ---------- locate the official CLI ---------- | \n|  | function findCli() { | \n|  | if (process.env.PLAUD_CLI_PATH) return process.env.PLAUD_CLI_PATH; | \n|  | let root; | \n|  | try { root = execSync(\"npm root -g\", { encoding: \"utf8\" }).trim(); } catch { root = \"\"; } | \n|  | const p = join(root, \"@plaud-ai\", \"cli\", \"dist\", \"index.js\"); | \n|  | if (!existsSync(p)) { | \n|  | console.error(\"Plaud CLI not found. Install it first:  npm install -g @plaud-ai/cli\"); | \n|  | process.exit(1); | \n|  | } | \n|  | return p; | \n|  | } | \n|  | const CLI = findCli(); | \n|  |  | \n|  | const ANSI = /\\x1b\\[[0-9;]*[A-Za-z]/g; | \n|  | const strip = (s) => (s ?? \"\").replace(ANSI, \"\"); | \n|  | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | \n|  | const safe = (s, max = 60) => | \n|  | (s \\|\\| \"untitled\").replace(/[<>:\"/\\\\\\|?*\\x00-\\x1f]/g, \"_\").replace(/\\s+/g, \" \").trim().replace(/[. ]+$/, \"\").slice(0, max) \\|\\| \"untitled\"; | \n|  |  | \n|  | // ---------- run CLI under a per-account home ---------- | \n|  | let PROFILE_HOME; | \n|  | function cliEnv() { | \n|  | return { | \n|  | ...process.env, | \n|  | USERPROFILE: PROFILE_HOME, // Windows: os.homedir() -> tokens in <PROFILE_HOME>\\.plaud | \n|  | HOME: PROFILE_HOME, // macOS/Linux | \n|  | FORCE_COLOR: \"0\", | \n|  | NO_COLOR: \"1\", | \n|  | PLAUD_TELEMETRY_DISABLED: \"1\", | \n|  | PLAUD_NO_UPDATE_NOTIFIER: \"1\", | \n|  | }; | \n|  | } | \n|  | function runCli(cliArgs, { inherit = false } = {}) { | \n|  | const r = spawnSync(process.execPath, [CLI, ...cliArgs], { | \n|  | env: cliEnv(), | \n|  | encoding: \"utf8\", | \n|  | stdio: inherit ? \"inherit\" : \"pipe\", | \n|  | maxBuffer: 256 * 1024 * 1024, | \n|  | }); | \n|  | return { code: r.status ?? 1, out: strip(r.stdout), err: strip(r.stderr) }; | \n|  | } | \n|  | // Retry on network (3) / timeout (4) / generic (1); stop hard on auth (2). | \n|  | async function cli(cliArgs, tries = 4) { | \n|  | let last; | \n|  | for (let i = 0; i < tries; i++) { | \n|  | last = runCli(cliArgs); | \n|  | if (last.code === 0) return last; | \n|  | if (last.code === 2) throw new Error(\"AUTH: login expired or invalid — re-run the script to log in again.\"); | \n|  | await sleep(2000 * (i + 1)); | \n|  | } | \n|  | return last; | \n|  | } | \n|  |  | \n|  | // ---------- parsing ---------- | \n|  | function parseFilesPage(out) { | \n|  | const ids = []; | \n|  | let inTable = false; | \n|  | for (const line of out.split(/\\r?\\n/)) { | \n|  | if (line.includes(\"\\u2500\\u2500\\u2500\")) { inTable = true; continue; } | \n|  | if (!inTable) continue; | \n|  | if (/^\\s*Page \\d+/.test(line)) break; | \n|  | const m = line.match(/^\\s{2}(\\S+)\\s{2,}/); | \n|  | if (m) ids.push(m[1]); | \n|  | } | \n|  | const countMatch = out.match(/Files on this page:\\s*(\\d+)/); | \n|  | return { ids, count: countMatch ? Number(countMatch[1]) : ids.length }; | \n|  | } | \n|  | function parseDetails(out) { | \n|  | const get = (k) => { const m = out.match(new RegExp(`^\\\\s*${k}:\\\\s*(.*)$`, \"m\")); return m ? m[1].trim() : \"\"; }; | \n|  | return { name: get(\"name\"), created_at: get(\"created_at\"), start_at: get(\"start_at\"), duration: get(\"duration\") }; | \n|  | } | \n|  | function stamp(iso) { | \n|  | const d = new Date(iso); | \n|  | if (isNaN(d)) return \"unknown-date\"; | \n|  | const p = (n) => String(n).padStart(2, \"0\"); | \n|  | return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`; | \n|  | } | \n|  | function audioExt(url, contentType) { | \n|  | const fromUrl = extname(new URL(url).pathname).toLowerCase(); | \n|  | if (fromUrl && fromUrl.length <= 6) return fromUrl; | \n|  | const ct = (contentType \\|\\| \"\").toLowerCase(); | \n|  | if (ct.includes(\"mpeg\")) return \".mp3\"; | \n|  | if (ct.includes(\"ogg\") \\|\\| ct.includes(\"opus\")) return \".opus\"; | \n|  | if (ct.includes(\"wav\")) return \".wav\"; | \n|  | if (ct.includes(\"mp4\") \\|\\| ct.includes(\"m4a\") \\|\\| ct.includes(\"aac\")) return \".m4a\"; | \n|  | return \".audio\"; | \n|  | } | \n|  |  | \n|  | // ---------- images (highlight photos + images embedded in summaries) ---------- | \n|  | function imageExt(url, contentType) { | \n|  | let fromUrl = \"\"; | \n|  | try { fromUrl = extname(new URL(url).pathname).toLowerCase(); } catch {} | \n|  | if (/^\\.(png\\|jpe?g\\|webp\\|gif\\|heic\\|bmp)$/.test(fromUrl)) return fromUrl; | \n|  | const ct = (contentType \\|\\| \"\").toLowerCase(); | \n|  | if (ct.includes(\"png\")) return \".png\"; | \n|  | if (ct.includes(\"jpeg\") \\|\\| ct.includes(\"jpg\")) return \".jpg\"; | \n|  | if (ct.includes(\"webp\")) return \".webp\"; | \n|  | if (ct.includes(\"gif\")) return \".gif\"; | \n|  | if (ct.includes(\"heic\")) return \".heic\"; | \n|  | return \".img\"; | \n|  | } | \n|  | function mmss(ms) { | \n|  | const s = Math.floor((Number(ms) \\|\\| 0) / 1000); | \n|  | return `${String(Math.floor(s / 60)).padStart(2, \"0\")}m${String(s % 60).padStart(2, \"0\")}s`; | \n|  | } | \n|  | function findImageLinks(dir) { | \n|  | const links = []; | \n|  | const hl = join(dir, \"highlights.txt\"); | \n|  | if (existsSync(hl)) { | \n|  | try { | \n|  | const items = JSON.parse(readFileSync(hl, \"utf8\")); | \n|  | if (Array.isArray(items)) items.forEach((it, i) => { | \n|  | const url = it?.picture_full_url \\|\\| it?.picture_link; | \n|  | if (typeof url === \"string\" && /^https?:\\/\\//.test(url)) | \n|  | links.push({ url, base: `highlight_${mmss(it.timestamp)}_${String(i + 1).padStart(2, \"0\")}` }); | \n|  | }); | \n|  | } catch {} | \n|  | } | \n|  | const sm = join(dir, \"summary_and_notes.md\"); | \n|  | if (existsSync(sm)) { | \n|  | const re = /!\\[[^\\]]*\\]\\((https?:\\/\\/[^)\\s]+)/g; | \n|  | let m, k = 0; | \n|  | const text = readFileSync(sm, \"utf8\"); | \n|  | while ((m = re.exec(text))) links.push({ url: m[1], base: `summary_${String(++k).padStart(2, \"0\")}` }); | \n|  | } | \n|  | return links; | \n|  | } | \n|  | async function downloadImages(dir) { | \n|  | const problems = []; | \n|  | const imgDir = join(dir, \"images\"); | \n|  | if (existsSync(join(imgDir, \".done\"))) return problems; | \n|  | const links = findImageLinks(dir); | \n|  | if (!links.length) return problems; | \n|  | mkdirSync(imgDir, { recursive: true }); | \n|  | for (const { url, base } of links) { | \n|  | if (readdirSync(imgDir).some((f) => f.startsWith(base + \".\"))) continue; // already have it | \n|  | try { | \n|  | const res = await fetch(url); | \n|  | if (!res.ok) throw new Error(`HTTP ${res.status}`); | \n|  | const target = join(imgDir, base + imageExt(url, res.headers.get(\"content-type\"))); | \n|  | await pipeline(Readable.fromWeb(res.body), createWriteStream(target + \".part\")); | \n|  | renameSync(target + \".part\", target); | \n|  | } catch (e) { | \n|  | problems.push(`image ${base}: ${e.message}`); | \n|  | } | \n|  | } | \n|  | if (!problems.length) writeFileSync(join(imgDir, \".done\"), new Date().toISOString()); | \n|  | return problems; | \n|  | } | \n|  |  | \n|  | // ---------- main ---------- | \n|  | let rl; | \n|  | const ask = async (q) => { rl ??= createInterface({ input: process.stdin, output: process.stdout }); return rl.question(q); }; | \n|  | if (!ACCOUNT) ACCOUNT = (await ask(\"Label for this Plaud account (e.g. personal, work): \")).trim(); | \n|  | ACCOUNT = safe(ACCOUNT, 40); | \n|  | PROFILE_HOME = join(OUT_ROOT, \"_profiles\", ACCOUNT); | \n|  | mkdirSync(PROFILE_HOME, { recursive: true }); | \n|  | const ACCT_DIR = join(OUT_ROOT, ACCOUNT); | \n|  | mkdirSync(ACCT_DIR, { recursive: true }); | \n|  | const ERRLOG = join(ACCT_DIR, \"errors.log\"); | \n|  |  | \n|  | console.log(`\\nBacking up account \"${ACCOUNT}\" to ${ACCT_DIR}\\n`); | \n|  |  | \n|  | // Login if needed (each account has its own token store) | \n|  | let me = runCli([\"me\"]); | \n|  | if (me.code !== 0) { | \n|  | console.log(\"Signing in. A browser window will open.\"); | \n|  | console.log(\"IMPORTANT: if your browser is already signed in to a DIFFERENT Plaud account, sign out of\"); | \n|  | console.log(\"web.plaud.ai first, or copy the printed URL into an InPrivate/Incognito window.\\n\"); | \n|  | runCli([\"login\"], { inherit: true }); | \n|  | me = runCli([\"me\"]); | \n|  | if (me.code !== 0) { console.error(\"Login failed:\\n\" + me.err); process.exit(2); } | \n|  | } | \n|  | console.log(me.out.trim()); | \n|  | const ok = AUTO_YES ? \"y\" : (await ask(\"\\nIs this the account you want to back up? (y/n): \")).trim().toLowerCase(); | \n|  | if (ok !== \"y\") { | \n|  | console.log(`Logging this profile out. Re-run and sign in with the right account.`); | \n|  | runCli([\"logout\"], { inherit: true }); | \n|  | process.exit(0); | \n|  | } | \n|  | rl?.close(); | \n|  |  | \n|  | // 1) List every recording | \n|  | const allIds = []; | \n|  | for (let page = 1; page <= 1000; page++) { | \n|  | const r = await cli([\"files\", \"-p\", String(page), \"-s\", \"100\"]); | \n|  | if (r.code !== 0) { console.error(`Failed listing page ${page}:\\n${r.err}`); process.exit(1); } | \n|  | const { ids, count } = parseFilesPage(r.out); | \n|  | allIds.push(...ids); | \n|  | process.stdout.write(`\\rListed ${allIds.length} recordings...`); | \n|  | if (count < 100) break; | \n|  | } | \n|  | const ids = [...new Set(allIds)]; | \n|  | console.log(`\\nFound ${ids.length} recordings.\\n`); | \n|  |  | \n|  | // Map already-finished folders (resume support) | \n|  | const doneById = new Map(); | \n|  | for (const d of readdirSync(ACCT_DIR, { withFileTypes: true })) { | \n|  | if (d.isDirectory() && existsSync(join(ACCT_DIR, d.name, \".done\"))) { | \n|  | const m = d.name.match(/__(.{1,8})$/); | \n|  | if (m) doneById.set(m[1], d.name); | \n|  | } | \n|  | } | \n|  |  | \n|  | const indexPath = join(ACCT_DIR, \"index.csv\"); | \n|  | if (!existsSync(indexPath)) writeFileSync(indexPath, \"id,date,name,duration,folder,audio,transcript,summary,status\\n\"); | \n|  | const csv = (v) => `\"${String(v ?? \"\").replace(/\"/g, '\"\"')}\"`; | \n|  |  | \n|  | // Catch-up pass: recordings finished by an older version of this script may have | \n|  | // photos that were never downloaded. Refresh their links and fetch the images. | \n|  | if (!SKIP_IMAGES) { | \n|  | const needImages = [...doneById.entries()].filter(([, folder]) => { | \n|  | const dir = join(ACCT_DIR, folder); | \n|  | return !existsSync(join(dir, \"images\", \".done\")) && findImageLinks(dir).length > 0; | \n|  | }); | \n|  | if (needImages.length) console.log(`Fetching images for ${needImages.length} previously saved recording(s)...`); | \n|  | for (const [tag, folder] of needImages) { | \n|  | const dir = join(ACCT_DIR, folder); | \n|  | const fullId = ids.find((x) => x.endsWith(tag)); | \n|  | if (fullId) await cli([\"transcript\", fullId, \"--block\", \"mark_memo\", \"-o\", join(dir, \"highlights.txt\")]); // fresh links | \n|  | const probs = await downloadImages(dir); | \n|  | if (probs.length) appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${folder} (images)\\n  ` + probs.join(\"\\n  \") + \"\\n\"); | \n|  | } | \n|  | } | \n|  |  | \n|  | let n = 0, failed = 0, skipped = 0; | \n|  | for (const id of ids) { | \n|  | n++; | \n|  | const tag = id.slice(-8); | \n|  | if (doneById.has(tag)) { skipped++; continue; } | \n|  |  | \n|  | const problems = []; | \n|  | try { | \n|  | const det = await cli([\"file\", id]); | \n|  | if (det.code !== 0) throw new Error(`file details: ${det.err.trim()}`); | \n|  | const info = parseDetails(det.out); | \n|  | const folderName = `${stamp(info.start_at && info.start_at !== \"-\" ? info.start_at : info.created_at)}_${safe(info.name)}__${tag}`; | \n|  | const dir = join(ACCT_DIR, folderName); | \n|  | mkdirSync(dir, { recursive: true }); | \n|  | console.log(`[${n}/${ids.length}] ${info.name \\|\\| id}`); | \n|  | writeFileSync(join(dir, \"details.txt\"), `id: ${id}\\n` + det.out.trim() + \"\\n\"); | \n|  |  | \n|  | // Transcripts (missing blocks are simply skipped by the CLI) | \n|  | const blocks = [ | \n|  | [\"transaction\", \"transcript.txt\"], | \n|  | [\"transaction_polish\", \"transcript_polished.txt\"], | \n|  | [\"outline\", \"outline.txt\"], | \n|  | [\"mark_memo\", \"highlights.txt\"], | \n|  | ]; | \n|  | for (const [block, fname] of blocks) { | \n|  | const r = await cli([\"transcript\", id, \"--block\", block, \"-o\", join(dir, fname)]); | \n|  | if (r.code !== 0) problems.push(`transcript(${block}): ${r.err.trim()}`); | \n|  | } | \n|  |  | \n|  | // Summaries / notes: readable + raw JSON | \n|  | let r = await cli([\"summary\", id, \"--all\", \"-o\", join(dir, \"summary_and_notes.md\")]); | \n|  | if (r.code !== 0) problems.push(`summary: ${r.err.trim()}`); | \n|  | r = await cli([\"summary\", id, \"--json\", \"-o\", join(dir, \"notes.json\")]); | \n|  | if (r.code !== 0) problems.push(`notes json: ${r.err.trim()}`); | \n|  |  | \n|  | // Images (photos from highlights + images embedded in summaries) | \n|  | if (!SKIP_IMAGES) problems.push(...await downloadImages(dir)); | \n|  |  | \n|  | // Audio | \n|  | let gotAudio = false, noAudio = false; | \n|  | if (!SKIP_AUDIO) { | \n|  | for (let attempt = 0; attempt < 3 && !gotAudio; attempt++) { | \n|  | const a = await cli([\"audio\", id]); | \n|  | const url = (a.out.match(/https?:\\/\\/\\S+/) \\|\\| [])[0]; | \n|  | if (!url) { | \n|  | if (/not available for this recording/i.test(a.out)) { noAudio = true; break; } // genuinely no audio | \n|  | await sleep(5000); | \n|  | continue; | \n|  | } | \n|  | try { | \n|  | const res = await fetch(url); | \n|  | if (!res.ok) throw new Error(`HTTP ${res.status}`); | \n|  | const target = join(dir, \"audio\" + audioExt(url, res.headers.get(\"content-type\"))); | \n|  | const part = target + \".part\"; | \n|  | await pipeline(Readable.fromWeb(res.body), createWriteStream(part)); | \n|  | renameSync(part, target); | \n|  | gotAudio = true; | \n|  | } catch (e) { | \n|  | problems.push(`audio download attempt ${attempt + 1}: ${e.message}`); | \n|  | for (const f of readdirSync(dir)) if (f.endsWith(\".part\")) try { unlinkSync(join(dir, f)); } catch {} | \n|  | await sleep(5000); | \n|  | } | \n|  | } | \n|  | if (!gotAudio && !noAudio) problems.push(\"audio: not downloaded\"); | \n|  | } | \n|  |  | \n|  | const has = (f) => existsSync(join(dir, f)) ? \"yes\" : \"no\"; | \n|  | const audioFile = readdirSync(dir).find((f) => f.startsWith(\"audio.\")); | \n|  | const status = problems.length ? \"partial\" : \"ok\"; | \n|  | appendFileSync(indexPath, [id, (info.start_at && info.start_at !== \"-\" ? info.start_at : info.created_at), info.name, info.duration, folderName, | \n|  | audioFile ? \"yes\" : \"no\", has(\"transcript.txt\"), has(\"summary_and_notes.md\"), status].map(csv).join(\",\") + \"\\n\"); | \n|  |  | \n|  | if (problems.length) { | \n|  | failed++; | \n|  | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id} ${info.name}\\n  ` + problems.join(\"\\n  \") + \"\\n\"); | \n|  | } else { | \n|  | writeFileSync(join(dir, \".done\"), new Date().toISOString()); | \n|  | } | \n|  | } catch (e) { | \n|  | failed++; | \n|  | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id}\\n  ${e.message}\\n`); | \n|  | console.error(`  ! ${e.message}`); | \n|  | if (e.message.startsWith(\"AUTH\")) process.exit(2); | \n|  | } | \n|  | } | \n|  |  | \n|  | console.log(`\\nDone. ${ids.length} recordings: ${ids.length - failed - skipped} saved, ${skipped} already done, ${failed} with problems.`); | \n|  | if (failed) console.log(`See ${ERRLOG}. Re-run the same command to retry anything incomplete.`); | \n|  | console.log(`Backup folder: ${ACCT_DIR}`); |", "url": "https://wpnews.pro/news/bulk-backup-of-all-plaud-ai-recordings-audio-transcripts-ai-summaries-across-s", "canonical_source": "https://gist.github.com/gareebr/55a3ecae918f609ba7b16d6974379279", "published_at": "2026-11-03 22:08:43+00:00", "updated_at": "2026-11-04 15:51:52.752790+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products"], "entities": ["Plaud.ai", "@plaud-ai/cli", "Node.js", "npm"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/bulk-backup-of-all-plaud-ai-recordings-audio-transcripts-ai-summaries-across-s", "markdown": "https://wpnews.pro/news/bulk-backup-of-all-plaud-ai-recordings-audio-transcripts-ai-summaries-across-s.md", "text": "https://wpnews.pro/news/bulk-backup-of-all-plaud-ai-recordings-audio-transcripts-ai-summaries-across-s.txt", "jsonld": "https://wpnews.pro/news/bulk-backup-of-all-plaud-ai-recordings-audio-transcripts-ai-summaries-across-s.jsonld"}}