Second commit
This commit is contained in:
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 飞书文档 → Markdown 转换工具(含图片本地化)
|
||||
*
|
||||
* 流程:
|
||||
* 1. wiki +node-get → 解析 obj_token
|
||||
* 2. drive +export markdown → 拿到含 feishu.cn/file/<token> 的 Markdown
|
||||
* 3. docs +media-preview → 逐张下载图片到本地
|
||||
* 4. 替换图片链接为相对路径,保存最终 Markdown
|
||||
*
|
||||
* 用法:node feishu-to-md.mjs <wiki_url_or_token> [输出目录]
|
||||
*/
|
||||
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 <wiki_url_or_token> [输出目录]
|
||||
|
||||
示例:
|
||||
node feishu-to-md.mjs https://yitanger.feishu.cn/wiki/R9NywV2i9izjuqkPd63cWuafn4g
|
||||
node feishu-to-md.mjs R9NywV2i9izjuqkPd63cWuafn4g
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const input = argv[0].trim();
|
||||
const outDir = argv[1] ? resolve(argv[1]) : resolve('./output');
|
||||
const wikiUrl = input.startsWith('http') ? input : `https://yitanger.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) => ` ?? `https://feishu.cn/file/${token}`})`
|
||||
);
|
||||
|
||||
// 清理 <title> 标签和 <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);
|
||||
}
|
||||
Reference in New Issue
Block a user