Files
YTScraper/feishu-to-md.mjs
beyondx123 50682eeb30 Sanitize for open-source release
- 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
2026-07-05 14:22:31 +08:00

137 lines
6.0 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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) => `![${alt}](${tokenToLocal.get(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);
}