50682eeb30
- Replace internal tenant name yitanger.feishu.cn with your-tenant.feishu.cn - Replace real wiki token with placeholder WikiNodeTokenExample - Parameterize tenant via FEISHU_TENANT env var in feishu-to-md.mjs - Expand .gitignore to cover cookies, auth artifacts, IDE files
137 lines
6.0 KiB
JavaScript
Executable File
137 lines
6.0 KiB
JavaScript
Executable File
#!/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://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 <token>');
|
||
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) => ` ?? `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);
|
||
}
|