#!/usr/bin/env node /** * 飞书文档 → Markdown 转换工具(含图片本地化) * * 流程: * 1. wiki +node-get → 解析 obj_token * 2. drive +export markdown → 拿到含 feishu.cn/file/ 的 Markdown * 3. docs +media-preview → 逐张下载图片到本地 * 4. 替换图片链接为相对路径,保存最终 Markdown * * 用法:node feishu-to-md.mjs [输出目录] */ import { execSync } from 'child_process'; import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'fs'; import { resolve, join, basename } from 'path'; const argv = process.argv.slice(2); if (!argv[0] || argv[0] === '--help' || argv[0] === '-h') { console.log(` 用法:node feishu-to-md.mjs [输出目录] 示例: node feishu-to-md.mjs https://your-tenant.feishu.cn/wiki/WikiNodeTokenExample FEISHU_TENANT=your-tenant node feishu-to-md.mjs WikiNodeTokenExample `); process.exit(0); } const input = argv[0].trim(); const outDir = argv[1] ? resolve(argv[1]) : resolve('./output'); let wikiUrl; if (input.startsWith('http')) { wikiUrl = input; } else { const tenant = process.env.FEISHU_TENANT; if (!tenant) { console.error('❌ 传入纯 token 时需设置 FEISHU_TENANT 环境变量,例如:'); console.error(' FEISHU_TENANT=your-tenant node feishu-to-md.mjs '); process.exit(1); } wikiUrl = `https://${tenant}.feishu.cn/wiki/${input}`; } // lark-cli 命令必须在 outDir 下执行(+media-preview 只接受相对路径) process.chdir(outDir); console.log('╔════════════════════════════════════════════╗'); console.log('║ 飞书文档 → Markdown 转换工具 ║'); console.log('╚════════════════════════════════════════════╝'); console.log(`📎 URL: ${wikiUrl}\n`); // ── 1. 解析 wiki node → obj_token ──────────────────────────────────────────── console.log('🔍 正在获取 wiki 节点信息...'); const nodeInfo = larkJson(`lark-cli wiki +node-get --node-token "${wikiUrl}"`); if (!nodeInfo.ok) die('获取 wiki 节点失败', nodeInfo); const { obj_token, title } = nodeInfo.data; console.log(`✅ 标题:${title}`); console.log(`✅ obj_token:${obj_token}\n`); const safeTitle = title.replace(/[/\\:*?"<>|]/g, '_').trim(); const imgDir = join(outDir, `${safeTitle}_files`); if (!existsSync(imgDir)) mkdirSync(imgDir, { recursive: true }); // ── 2. 导出 Markdown ────────────────────────────────────────────────────────── console.log('📄 正在导出 Markdown...'); const exportResult = larkJson( `lark-cli drive +export --token ${obj_token} --doc-type docx --file-extension markdown --output-dir . --overwrite` ); if (!exportResult.ok) die('导出失败', exportResult); const mdPath = exportResult.data.saved_path; let mdContent = readFileSync(mdPath, 'utf-8'); console.log(`✅ 导出成功(${mdContent.length} 字符)\n`); // ── 3. 下载图片 ──────────────────────────────────────────────────────────────── const TOKEN_RE = /!\[[^\]]*\]\(https:\/\/feishu\.cn\/file\/([A-Za-z0-9]+)\)/g; const tokens = [...new Set([...mdContent.matchAll(TOKEN_RE)].map(m => m[1]))]; console.log(`🖼️ 发现 ${tokens.length} 张图片,开始下载...`); const tokenToLocal = new Map(); let n = 0; for (const token of tokens) { n++; const relPath = `${safeTitle}_files/image_${n}.png`; try { execSync(`lark-cli docs +media-preview --token ${token} --output "${relPath}" --overwrite`, { stdio: 'ignore', }); tokenToLocal.set(token, relPath); console.log(` ✓ image_${n}.png`); } catch { tokenToLocal.set(token, `https://feishu.cn/file/${token}`); console.log(` ✗ image_${n}.png(下载失败,保留原链接)`); } } console.log(''); // ── 4. 替换图片链接并清理 ───────────────────────────────────────────────────── mdContent = mdContent.replace( /!\[([^\]]*)\]\(https:\/\/feishu\.cn\/file\/([A-Za-z0-9]+)\)/g, (_, alt, token) => `![${alt}](${tokenToLocal.get(token) ?? `https://feishu.cn/file/${token}`})` ); // 清理 标签和 <grid>/<column> 残留 mdContent = mdContent .replace(/<title>[\s\S]*?<\/title>\n?/g, '') .replace(/<\/?grid[^>]*>\n?/g, '') .replace(/<\/?column[^>]*>\n?/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); // 加首行标题和来源 const finalContent = `# ${title}\n\n> Source: ${wikiUrl}\n\n${mdContent}`; writeFileSync(mdPath, finalContent, 'utf-8'); console.log('╔════════════════════════════════════════════╗'); console.log(`║ ✅ 完成!共 ${finalContent.length} 字符`); console.log(`║ 📄 ${mdPath}`); console.log(`║ 🖼️ ${imgDir} (${tokens.length} 张)`); console.log('╚════════════════════════════════════════════╝'); // ── helpers ────────────────────────────────────────────────────────────────── function larkJson(cmd) { try { return JSON.parse(execSync(cmd, { encoding: 'utf-8' })); } catch (e) { // lark-cli 失败时把 JSON 输出到 stderr try { return JSON.parse(e.stderr || e.stdout || '{}'); } catch { return {}; } } } function die(msg, info) { console.error(`❌ ${msg}:`, info?.error?.message || JSON.stringify(info)); process.exit(1); }