
Feishu Work Archive
- 1 installs
- Updated April 17, 2026
- hamsterider-m/personal-skills
Exports work Feishu documents into Obsidian, classifies them by topic with AI, and generates an index file.
About
Reads documents from a work Feishu account, analyzes their topic and importance, and saves them into Obsidian organized by theme. A developer uses it to back up or archive Feishu cloud docs before leaving a job or on a schedule.
- Lists, reads, and exports Feishu docs via archive.js commands
- AI-classifies by topic and generates an Obsidian index
Feishu Work Archive by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hamsterider-m/personal-skills --skill feishu-work-archiveAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 17, 2026 |
| Repository | hamsterider-m/personal-skills ↗ |
What it does
Exports work Feishu documents into Obsidian, classifies them by topic with AI, and generates an index file.
Files
飞书工作归档 Skill
从工作飞书账号读取文档,AI 分析主题,整理到 Obsidian。
使用场景
- 离职前整理工作文档
- 定期备份飞书云文档
- 按主题归档项目资料
工作流程
1. 列出工作飞书的文档/知识库 2. 读取文档内容 3. AI 分析主题和重要性 4. 按主题分类保存到 Obsidian 5. 生成索引文件
配置
需要在 openclaw.json 配置工作飞书账号(account: "work")
命令
# 列出可导出的文档
node archive.js list
# 导出所有文档
node archive.js export
# 导出指定文档
node archive.js export --doc-token ABC123
# 生成索引
node archive.js index#!/bin/bash
# 使用 agent-browser 批量导出飞书文档
PROFILE_DIR="$HOME/.openclaw/workspace/skills/feishu-work-archive/agent-browser-profile"
BATCHES_FILE="$HOME/.openclaw/workspace/skills/feishu-work-archive/batches.json"
PROGRESS_FILE="$HOME/.openclaw/workspace/skills/feishu-work-archive/progress-agent.json"
OUTPUT_DIR="$HOME/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档"
# 初始化进度
if [ ! -f "$PROGRESS_FILE" ]; then
echo '{"completed":0,"failed":[],"results":[]}' > "$PROGRESS_FILE"
fi
# 读取进度
COMPLETED=$(jq -r '.completed' "$PROGRESS_FILE")
TOTAL=$(jq '[.batches[][]] | length' "$BATCHES_FILE")
echo "进度: $COMPLETED/$TOTAL"
echo "首次运行需要手动登录飞书..."
echo ""
# 打开飞书首页让用户登录
agent-browser --profile "$PROFILE_DIR" open https://ottno-cd.feishu.cn/drive/home/
echo ""
echo "请在浏览器中登录飞书,登录完成后按回车继续..."
read
echo "开始批量导出..."
#!/usr/bin/env node
/**
* 飞书工作文档归档工具
* 用途:导出工作飞书文档到 Obsidian,AI 分析主题并整理
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
// 配置
const CONFIG = {
appId: 'cli_a75960290439500d',
appSecret: 'tBd5CYrc3jFTa4yQ9aD3whBMyMvDfl2w',
obsidianPath: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档',
domain: 'feishu.cn'
};
// 确保输出目录存在
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
// 获取 tenant_access_token
async function getTenantToken() {
return new Promise((resolve, reject) => {
const data = JSON.stringify({
app_id: CONFIG.appId,
app_secret: CONFIG.appSecret
});
const options = {
hostname: `open.${CONFIG.domain}`,
path: '/open-apis/auth/v3/tenant_access_token/internal',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
const result = JSON.parse(body);
if (result.code === 0) {
resolve(result.tenant_access_token);
} else {
reject(new Error(`获取 token 失败: ${JSON.stringify(result)}`));
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
// 通用 API 调用
async function callFeishuAPI(token, method, path, data = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: `open.${CONFIG.domain}`,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
if (data) {
const jsonData = JSON.stringify(data);
options.headers['Content-Length'] = jsonData.length;
}
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const result = JSON.parse(body);
resolve(result);
} catch (e) {
reject(new Error(`解析响应失败: ${body}`));
}
});
});
req.on('error', reject);
if (data) {
req.write(JSON.stringify(data));
}
req.end();
});
}
module.exports = {
CONFIG,
ensureDir,
getTenantToken,
callFeishuAPI
};
#!/usr/bin/env node
/**
* 自动批量导出飞书文档
* Headless 模式,后台运行,每 5 分钟报告进度
*/
const fs = require('fs');
const path = require('path');
const CONFIG = {
batchesFile: path.join(__dirname, 'batches.json'),
progressFile: path.join(__dirname, 'progress.json'),
outputDir: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档',
targetId: 'CC074F4EEB55CC58F90083D3AF5A7676',
reportInterval: 5 * 60 * 1000 // 5 分钟
};
// 读取批次
const { batches } = JSON.parse(fs.readFileSync(CONFIG.batchesFile, 'utf8'));
// 读取或初始化进度
let progress = { completed: 0, failed: [], lastReport: Date.now() };
if (fs.existsSync(CONFIG.progressFile)) {
progress = JSON.parse(fs.readFileSync(CONFIG.progressFile, 'utf8'));
}
// 保存进度
function saveProgress() {
fs.writeFileSync(CONFIG.progressFile, JSON.stringify(progress, null, 2));
}
// 确保目录存在
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
// 生成安全文件名
function sanitizeFilename(name) {
return name.replace(/[\/\\:*?"<>|]/g, '_').substring(0, 100);
}
console.log('开始批量导出...');
console.log(`总文档数: ${batches.flat().length}`);
console.log(`已完成: ${progress.completed}`);
console.log(`剩余: ${batches.flat().length - progress.completed}\n`);
module.exports = { CONFIG, batches, progress, saveProgress, ensureDir, sanitizeFilename };
#!/usr/bin/env node
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG = {
batchesFile: path.join(__dirname, 'batches.json'),
progressFile: path.join(__dirname, 'progress.json'),
outputDir: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档',
userDataDir: path.join(__dirname, 'browser-data'),
reportInterval: 5 * 60 * 1000
};
const { batches } = JSON.parse(fs.readFileSync(CONFIG.batchesFile, 'utf8'));
const allDocs = batches.flat();
let progress = { completed: 0, failed: [], lastReport: Date.now(), results: [] };
if (fs.existsSync(CONFIG.progressFile)) {
progress = JSON.parse(fs.readFileSync(CONFIG.progressFile, 'utf8'));
}
function saveProgress() {
fs.writeFileSync(CONFIG.progressFile, JSON.stringify(progress, null, 2));
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function sanitize(name) {
return name.replace(/[\/\\:*?"<>|]/g, '_').substring(0, 100);
}
async function extractDocContent(page) {
await page.waitForTimeout(3000);
const content = await page.evaluate(() => {
const main = document.querySelector('[role="main"]') || document.body;
return main.innerText || '';
});
return content;
}
async function processDoc(page, doc) {
try {
await page.goto(doc.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
const content = await extractDocContent(page);
if (content.includes('扫码登录') || content.includes('账号登录')) {
throw new Error('需要登录');
}
const categoryDir = path.join(CONFIG.outputDir, doc.category);
ensureDir(categoryDir);
const filename = `${sanitize(doc.name)}.md`;
const frontmatter = `---
title: ${doc.name}
category: ${doc.category}
source: 工作飞书
url: ${doc.url}
exported_at: ${new Date().toISOString()}
---
`;
fs.writeFileSync(path.join(categoryDir, filename), frontmatter + content, 'utf8');
progress.completed++;
progress.results.push({ id: doc.id, name: doc.name, status: 'success' });
console.log(`✓ [${progress.completed}/${allDocs.length}] ${doc.name}`);
} catch (err) {
progress.failed.push({ id: doc.id, name: doc.name, error: err.message });
console.error(`✗ ${doc.name}: ${err.message}`);
} finally {
saveProgress();
}
}
async function main() {
console.log('启动浏览器,等待 10 秒让你登录...');
console.log(`总文档数: ${allDocs.length}\n`);
const browser = await chromium.launchPersistentContext(CONFIG.userDataDir, {
headless: false,
args: ['--disable-blink-features=AutomationControlled']
});
const page = await browser.newPage();
await page.goto('https://ottno-cd.feishu.cn/drive/home/');
console.log('等待 10 秒...');
await page.waitForTimeout(10000);
console.log('\n开始导出...\n');
const startFrom = progress.completed;
for (let i = startFrom; i < allDocs.length; i++) {
await processDoc(page, allDocs[i]);
if (Date.now() - progress.lastReport > CONFIG.reportInterval) {
console.log(`\n进度: ${progress.completed}/${allDocs.length} (${Math.round(progress.completed/allDocs.length*100)}%)`);
console.log(`失败: ${progress.failed.length}\n`);
progress.lastReport = Date.now();
}
}
await browser.close();
console.log(`\n✓ 完成!成功: ${progress.completed - progress.failed.length} 失败: ${progress.failed.length}`);
}
main().catch(console.error);
#!/usr/bin/env node
/**
* 批量导出飞书工作文档到 Obsidian
* 用法: node batch-export.js <doc-list.md>
*/
const fs = require('fs');
const path = require('path');
const OBSIDIAN_BASE = '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档';
const DOC_LIST_PATH = process.argv[2] || '/Users/maygo/.openclaw/media/inbound/梅杭俊_云文档列表---eb5002fd-4e2c-47d6-880a-e941538a424f';
// 解析文档列表
function parseDocList(content) {
const lines = content.split('\n');
const docs = [];
for (const line of lines) {
const match = line.match(/\|\s*(\d+)\s*\|\s*(.+?)\s*\|\s*(https:\/\/[^\s]+)/);
if (match) {
const [, id, name, url] = match;
const tokenMatch = url.match(/\/([a-zA-Z0-9]+)$/);
if (tokenMatch) {
docs.push({
id: parseInt(id),
name: name.trim(),
url: url.trim(),
token: tokenMatch[1]
});
}
}
}
return docs;
}
// 主题分类规则
function categorizeDoc(name) {
if (name.includes('降本') || name.includes('预算') || name.includes('EBITDA') || name.includes('现金流')) {
return '财务预算管理';
}
if (name.includes('智能纪要') || name.includes('文字记录') || name.includes('会议速递')) {
return '会议纪要';
}
if (name.includes('周工作报告') || name.includes('工作汇报')) {
return '工作汇报';
}
if (name.includes('Excel') || name.includes('PowerQuery') || name.includes('多维表格') || name.includes('技术文档')) {
return '技术工具';
}
if (name.includes('门店') || name.includes('闭店') || name.includes('经营分析')) {
return '运营管理';
}
return '其他文档';
}
// 生成安全文件名
function sanitizeFilename(name) {
return name
.replace(/[\/\\:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.substring(0, 100);
}
// 主函数
async function main() {
console.log('读取文档列表...');
const content = fs.readFileSync(DOC_LIST_PATH, 'utf8');
const docs = parseDocList(content);
console.log(`找到 ${docs.length} 个文档\n`);
// 按主题分组
const grouped = {};
for (const doc of docs) {
const category = categorizeDoc(doc.name);
if (!grouped[category]) grouped[category] = [];
grouped[category].push(doc);
}
// 输出分类统计
console.log('文档分类统计:');
for (const [category, items] of Object.entries(grouped)) {
console.log(` ${category}: ${items.length} 个`);
}
// 生成导出清单
const manifest = {
total: docs.length,
categories: grouped,
exportTime: new Date().toISOString(),
obsidianPath: OBSIDIAN_BASE
};
fs.writeFileSync(
path.join(__dirname, 'export-manifest.json'),
JSON.stringify(manifest, null, 2)
);
console.log('\n✓ 导出清单已生成: export-manifest.json');
console.log('\n下一步: 使用 OpenClaw feishu_doc 工具批量读取文档');
}
main().catch(console.error);
#!/usr/bin/env node
/**
* 使用 OpenClaw browser 工具批量导出
* 在同一个标签页中逐个访问
*/
const fs = require('fs');
const path = require('path');
const CONFIG = {
batchesFile: path.join(__dirname, 'batches.json'),
progressFile: path.join(__dirname, 'progress-browser.json'),
outputDir: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档'
};
const { batches } = JSON.parse(fs.readFileSync(CONFIG.batchesFile, 'utf8'));
const allDocs = batches.flat();
let progress = { completed: 0, failed: [], results: [] };
if (fs.existsSync(CONFIG.progressFile)) {
progress = JSON.parse(fs.readFileSync(CONFIG.progressFile, 'utf8'));
}
// 输出下一个要处理的文档
const nextDoc = allDocs[progress.completed];
if (!nextDoc) {
console.log('全部完成!');
process.exit(0);
}
console.log(JSON.stringify({
action: 'next',
doc: nextDoc,
progress: `${progress.completed}/${allDocs.length}`
}));
#!/usr/bin/env node
/**
* 通过浏览器批量导出飞书文档
*/
const fs = require('fs');
const path = require('path');
const MANIFEST_PATH = path.join(__dirname, 'export-manifest.json');
const OUTPUT_DIR = '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档';
const PROGRESS_FILE = path.join(__dirname, 'export-progress.json');
// 读取清单
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const allDocs = [];
for (const [category, docs] of Object.entries(manifest.categories)) {
for (const doc of docs) {
allDocs.push({ ...doc, category });
}
}
console.log(`总共 ${allDocs.length} 个文档`);
console.log(`输出目录: ${OUTPUT_DIR}`);
console.log('\n需要通过 OpenClaw browser 工具逐个访问并提取内容');
console.log('建议分批执行,每批 20-30 个文档\n');
// 生成批次
const BATCH_SIZE = 20;
const batches = [];
for (let i = 0; i < allDocs.length; i += BATCH_SIZE) {
batches.push(allDocs.slice(i, i + BATCH_SIZE));
}
console.log(`已分为 ${batches.length} 批,每批 ${BATCH_SIZE} 个文档`);
console.log('\n第一批文档:');
batches[0].slice(0, 5).forEach((doc, i) => {
console.log(` ${i+1}. ${doc.name} (${doc.category})`);
});
// 保存批次信息
fs.writeFileSync(
path.join(__dirname, 'batches.json'),
JSON.stringify({ batches, batchSize: BATCH_SIZE }, null, 2)
);
console.log('\n✓ 批次信息已保存到 batches.json');
#!/bin/bash
set -e
PROFILE="$HOME/.openclaw/workspace/skills/feishu-work-archive/profile"
OUTPUT_DIR="$HOME/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档"
BATCHES="$HOME/.openclaw/workspace/skills/feishu-work-archive/batches.json"
PROGRESS="$HOME/.openclaw/workspace/skills/feishu-work-archive/progress-final.json"
# 初始化进度
if [ ! -f "$PROGRESS" ]; then
echo '{"completed":0,"failed":[]}' > "$PROGRESS"
fi
completed=$(jq -r '.completed' "$PROGRESS")
total=$(jq '[.batches[][]] | length' "$BATCHES")
echo "继续从第 $((completed + 1)) 个开始,共 $total 个"
echo ""
# 读取所有文档,跳过已完成的
docs=$(jq -r "[.batches[][]][$completed:] | .[] | @json" "$BATCHES")
count=$completed
while IFS= read -r doc; do
count=$((count + 1))
name=$(echo "$doc" | jq -r '.name')
url=$(echo "$doc" | jq -r '.url')
category=$(echo "$doc" | jq -r '.category')
echo "[$count/$total] $name"
# 访问文档
if ! agent-browser --profile "$PROFILE" open "$url" > /dev/null 2>&1; then
echo " ✗ 访问失败"
jq ".failed += [{\"name\": \"$name\", \"error\": \"访问失败\"}]" "$PROGRESS" > "$PROGRESS.tmp" && mv "$PROGRESS.tmp" "$PROGRESS"
continue
fi
sleep 2
# 提取内容
content=$(agent-browser eval "document.querySelector('[role=\"main\"]')?.innerText || document.body.innerText" 2>/dev/null || echo "")
if [ -z "$content" ]; then
echo " ✗ 内容为空"
jq ".failed += [{\"name\": \"$name\", \"error\": \"内容为空\"}]" "$PROGRESS" > "$PROGRESS.tmp" && mv "$PROGRESS.tmp" "$PROGRESS"
jq ".completed = $count" "$PROGRESS" > "$PROGRESS.tmp" && mv "$PROGRESS.tmp" "$PROGRESS"
continue
fi
# 保存
category_dir="$OUTPUT_DIR/$category"
mkdir -p "$category_dir"
filename=$(echo "$name" | sed 's/[\/\\:*?"<>|]/_/g' | cut -c1-100).md
filepath="$category_dir/$filename"
cat > "$filepath" <<EOF
---
title: $name
category: $category
source: 工作飞书
url: $url
exported_at: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
---
$content
EOF
echo " ✓ 已保存"
# 更新进度
jq ".completed = $count" "$PROGRESS" > "$PROGRESS.tmp" && mv "$PROGRESS.tmp" "$PROGRESS"
# 每 20 个报告一次
if [ $((count % 20)) -eq 0 ]; then
failed=$(jq '.failed | length' "$PROGRESS")
echo ""
echo "进度: $count/$total (失败: $failed)"
echo ""
fi
done <<< "$docs"
failed=$(jq '.failed | length' "$PROGRESS")
echo ""
echo "全部完成!成功: $((count - failed)) 失败: $failed"
#!/usr/bin/env node
const { getTenantToken, callFeishuAPI, CONFIG, ensureDir } = require('./archive.js');
const fs = require('fs');
const path = require('path');
// 列出云空间文件
async function listDriveFiles(token, folderToken = null) {
const params = new URLSearchParams({
page_size: 50,
...(folderToken && { folder_token: folderToken })
});
const result = await callFeishuAPI(token, 'GET', `/open-apis/drive/v1/files?${params}`);
return result;
}
// 读取文档内容
async function readDocument(token, docToken) {
const result = await callFeishuAPI(token, 'GET', `/open-apis/docx/v1/documents/${docToken}/raw_content`);
return result;
}
// 保存文档到本地
function saveToObsidian(filename, content, metadata = {}) {
ensureDir(CONFIG.obsidianPath);
const frontmatter = `---
title: ${metadata.title || filename}
source: feishu
doc_token: ${metadata.doc_token || ''}
exported_at: ${new Date().toISOString()}
tags: [工作归档, 飞书]
---
`;
const fullContent = frontmatter + content;
const filepath = path.join(CONFIG.obsidianPath, `${filename}.md`);
fs.writeFileSync(filepath, fullContent, 'utf8');
console.log(`✓ 已保存: ${filepath}`);
return filepath;
}
// 主函数
async function main() {
const cmd = process.argv[2] || 'list';
try {
console.log('获取访问令牌...');
const token = await getTenantToken();
if (cmd === 'list') {
console.log('\n列出云空间文件...');
const files = await listDriveFiles(token);
if (files.code === 0 && files.data?.files) {
console.log(`\n找到 ${files.data.files.length} 个文件:\n`);
files.data.files.forEach((file, i) => {
console.log(`${i+1}. [${file.type}] ${file.name}`);
console.log(` Token: ${file.token}`);
});
} else {
console.log('返回:', JSON.stringify(files, null, 2));
}
} else if (cmd === 'export') {
const docToken = process.argv[3];
if (!docToken) {
console.error('用法: node list-export.js export <doc_token>');
process.exit(1);
}
console.log(`\n读取文档 ${docToken}...`);
const doc = await readDocument(token, docToken);
if (doc.code === 0 && doc.data?.content) {
const filename = `doc_${docToken}_${Date.now()}`;
saveToObsidian(filename, doc.data.content, {
doc_token: docToken,
title: filename
});
} else {
console.log('返回:', JSON.stringify(doc, null, 2));
}
}
} catch (err) {
console.error('错误:', err.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
#!/usr/bin/env node
/**
* 本地文档处理工具
* 读取手动导出的文档,AI 分析主题,整理到 Obsidian
*
* 用法:
* 1. 从飞书手动导出文档到 ~/Downloads/工作文档/
* 2. 运行: node local-process.js
*/
const fs = require('fs');
const path = require('path');
const CONFIG = {
inputDir: path.join(process.env.HOME, 'Downloads/工作文档'),
outputDir: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档',
indexFile: 'INDEX.md'
};
// 确保目录存在
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
// 扫描输入目录
function scanInputFiles() {
ensureDir(CONFIG.inputDir);
const files = fs.readdirSync(CONFIG.inputDir);
return files.filter(f =>
f.endsWith('.md') ||
f.endsWith('.txt') ||
f.endsWith('.docx')
);
}
// 读取文件内容
function readFile(filename) {
const filepath = path.join(CONFIG.inputDir, filename);
return fs.readFileSync(filepath, 'utf8');
}
// 保存到 Obsidian
function saveToObsidian(category, filename, content, metadata = {}) {
const categoryDir = path.join(CONFIG.outputDir, category);
ensureDir(categoryDir);
const frontmatter = `---
title: ${metadata.title || filename}
category: ${category}
source: 工作飞书
exported_at: ${new Date().toISOString()}
tags: [工作归档, ${category}]
---
`;
const outputPath = path.join(categoryDir, filename);
fs.writeFileSync(outputPath, frontmatter + content, 'utf8');
return outputPath;
}
// 生成索引
function generateIndex(processed) {
const grouped = {};
for (const item of processed) {
if (!grouped[item.category]) grouped[item.category] = [];
grouped[item.category].push(item);
}
let index = `# 工作文档归档索引\n\n`;
index += `**生成时间:** ${new Date().toLocaleString('zh-CN')}\n`;
index += `**文档总数:** ${processed.length}\n\n`;
for (const [category, items] of Object.entries(grouped)) {
index += `## ${category} (${items.length})\n\n`;
for (const item of items) {
index += `- [[${item.filename}|${item.title}]]\n`;
}
index += `\n`;
}
fs.writeFileSync(
path.join(CONFIG.outputDir, CONFIG.indexFile),
index,
'utf8'
);
}
// 主函数
async function main() {
console.log('扫描输入目录:', CONFIG.inputDir);
const files = scanInputFiles();
if (files.length === 0) {
console.log('\n未找到文件。请先手动导出文档到:', CONFIG.inputDir);
console.log('\n操作步骤:');
console.log('1. 在飞书打开文档');
console.log('2. 点击右上角 "..." → 导出 → Markdown');
console.log('3. 保存到', CONFIG.inputDir);
return;
}
console.log(`找到 ${files.length} 个文件\n`);
const processed = [];
for (const file of files) {
console.log(`处理: ${file}`);
const content = readFile(file);
// 简单分类(后续可接入 AI)
let category = '其他文档';
if (file.includes('预算') || file.includes('降本')) category = '财务预算管理';
else if (file.includes('纪要') || file.includes('会议')) category = '会议纪要';
else if (file.includes('周工作报告')) category = '工作汇报';
const outputPath = saveToObsidian(category, file, content, {
title: file.replace(/\.(md|txt|docx)$/, '')
});
processed.push({
filename: file,
title: file.replace(/\.(md|txt|docx)$/, ''),
category,
path: outputPath
});
console.log(` ✓ 已保存到: ${category}/${file}`);
}
generateIndex(processed);
console.log(`\n✓ 完成!已处理 ${processed.length} 个文档`);
console.log(`✓ 索引文件: ${path.join(CONFIG.outputDir, CONFIG.indexFile)}`);
}
main().catch(console.error);
{
"name": "feishu-doc-exporter",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "feishu-doc-exporter",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.40.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
{
"name": "feishu-doc-exporter",
"version": "1.0.0",
"type": "module",
"dependencies": {
"playwright": "^1.40.0"
}
}
#!/usr/bin/env node
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG = {
batchesFile: path.join(__dirname, 'batches.json'),
progressFile: path.join(__dirname, 'progress.json'),
outputDir: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档',
userDataDir: path.join(__dirname, 'browser-data'),
reportInterval: 5 * 60 * 1000
};
const { batches } = JSON.parse(fs.readFileSync(CONFIG.batchesFile, 'utf8'));
const allDocs = batches.flat();
let progress = { completed: 0, failed: [], lastReport: Date.now(), results: [] };
if (fs.existsSync(CONFIG.progressFile)) {
progress = JSON.parse(fs.readFileSync(CONFIG.progressFile, 'utf8'));
}
function saveProgress() {
fs.writeFileSync(CONFIG.progressFile, JSON.stringify(progress, null, 2));
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function sanitize(name) {
return name.replace(/[\/\\:*?"<>|]/g, '_').substring(0, 100);
}
async function extractDocContent(page) {
await page.waitForTimeout(3000);
const content = await page.evaluate(() => {
const main = document.querySelector('[role="main"]') || document.body;
return main.innerText || '';
});
return content;
}
async function processDoc(page, doc) {
try {
await page.goto(doc.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
const content = await extractDocContent(page);
if (content.includes('扫码登录') || content.includes('账号登录')) {
throw new Error('需要登录');
}
const categoryDir = path.join(CONFIG.outputDir, doc.category);
ensureDir(categoryDir);
const filename = `${sanitize(doc.name)}.md`;
const frontmatter = `---
title: ${doc.name}
category: ${doc.category}
source: 工作飞书
url: ${doc.url}
exported_at: ${new Date().toISOString()}
---
`;
fs.writeFileSync(path.join(categoryDir, filename), frontmatter + content, 'utf8');
progress.completed++;
progress.results.push({ id: doc.id, name: doc.name, status: 'success' });
console.log(`✓ [${progress.completed}/${allDocs.length}] ${doc.name}`);
} catch (err) {
progress.failed.push({ id: doc.id, name: doc.name, error: err.message });
console.error(`✗ ${doc.name}: ${err.message}`);
} finally {
saveProgress();
}
}
async function main() {
console.log('首次运行需要手动登录飞书...');
console.log(`总文档数: ${allDocs.length}\n`);
const browser = await chromium.launchPersistentContext(CONFIG.userDataDir, {
headless: false,
args: ['--disable-blink-features=AutomationControlled']
});
const page = await browser.newPage();
console.log('请在浏览器中登录飞书,登录完成后按回车继续...');
await page.goto('https://ottno-cd.feishu.cn/drive/home/');
await new Promise(resolve => {
process.stdin.once('data', resolve);
});
console.log('\n开始导出...\n');
const startFrom = progress.completed;
for (let i = startFrom; i < allDocs.length; i++) {
await processDoc(page, allDocs[i]);
if (Date.now() - progress.lastReport > CONFIG.reportInterval) {
console.log(`\n进度: ${progress.completed}/${allDocs.length} (${Math.round(progress.completed/allDocs.length*100)}%)`);
console.log(`失败: ${progress.failed.length}\n`);
progress.lastReport = Date.now();
}
}
await browser.close();
console.log(`\n✓ 完成!成功: ${progress.completed - progress.failed.length} 失败: ${progress.failed.length}`);
}
main().catch(console.error);
飞书文档批量导出 - 最终方案
成功验证
✅ agent-browser + 持久化 profile 可以保持登录状态 ✅ 登录一次后,后续访问文档无需重新登录 ✅ 可以正常提取文档内容
使用方法
1. 首次登录(已完成)
agent-browser --profile ~/.openclaw/workspace/skills/feishu-work-archive/profile --headed open https://ottno-cd.feishu.cn/drive/home/
# 手动登录后,登录状态会保存到 profile 目录2. 批量导出(待执行)
创建 todo list,交给 kimi/qwen agent 处理:
任务清单: 229 个文档(见 batches.json)
每个文档的处理步骤: 1. agent-browser --profile <path> open <doc_url> 2. agent-browser --profile <path> snapshot -i -c 或 get text 'body' 3. 保存到 Obsidian:~/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档/<category>/<filename>.md 4. 添加 frontmatter(title, category, url, exported_at)
3. 关键命令
# 访问文档
agent-browser --profile ~/.openclaw/workspace/skills/feishu-work-archive/profile open <url>
# 提取内容(交互元素)
agent-browser snapshot -i -c
# 提取全文
agent-browser eval "document.body.innerText"
# 关闭
agent-browser close文档分类
- 财务预算管理: 36 个
- 会议纪要: 37 个
- 工作汇报: 48 个
- 运营管理: 18 个
- 技术工具: 3 个
- 其他文档: 87 个
下一步
将 batches.json 转换为 agent 可执行的 todo list,交给 kimi/qwen 处理。
#!/usr/bin/env node
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG = {
batchesFile: path.join(__dirname, 'batches.json'),
cookiesFile: path.join(__dirname, 'cookies.txt'),
progressFile: path.join(__dirname, 'progress.json'),
outputDir: '/Users/maygo/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档',
reportInterval: 5 * 60 * 1000
};
const { batches } = JSON.parse(fs.readFileSync(CONFIG.batchesFile, 'utf8'));
const allDocs = batches.flat();
const cookieStr = fs.readFileSync(CONFIG.cookiesFile, 'utf8').trim();
const cookies = cookieStr.split('; ').map(c => {
const [name, ...valueParts] = c.split('=');
return { name: name.trim(), value: valueParts.join('=').trim(), domain: '.feishu.cn', path: '/' };
});
let progress = { completed: 0, failed: [], lastReport: Date.now(), results: [] };
if (fs.existsSync(CONFIG.progressFile)) {
progress = JSON.parse(fs.readFileSync(CONFIG.progressFile, 'utf8'));
}
function saveProgress() {
fs.writeFileSync(CONFIG.progressFile, JSON.stringify(progress, null, 2));
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function sanitize(name) {
return name.replace(/[\/\\:*?"<>|]/g, '_').substring(0, 100);
}
async function extractDocContent(page) {
await page.waitForTimeout(3000);
const content = await page.evaluate(() => {
const main = document.querySelector('[role="main"]') || document.body;
return main.innerText || '';
});
return content;
}
async function processDoc(browser, doc) {
const page = await browser.newPage();
try {
await page.goto(doc.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
const content = await extractDocContent(page);
const categoryDir = path.join(CONFIG.outputDir, doc.category);
ensureDir(categoryDir);
const filename = `${sanitize(doc.name)}.md`;
const frontmatter = `---
title: ${doc.name}
category: ${doc.category}
source: 工作飞书
url: ${doc.url}
exported_at: ${new Date().toISOString()}
---
`;
fs.writeFileSync(path.join(categoryDir, filename), frontmatter + content, 'utf8');
progress.completed++;
progress.results.push({ id: doc.id, name: doc.name, status: 'success' });
console.log(`✓ [${progress.completed}/${allDocs.length}] ${doc.name}`);
} catch (err) {
progress.failed.push({ id: doc.id, name: doc.name, error: err.message });
console.error(`✗ ${doc.name}: ${err.message}`);
} finally {
await page.close();
saveProgress();
}
}
async function main() {
console.log(`开始导出 ${allDocs.length} 个文档...`);
const browser = await chromium.launch({
headless: false,
args: ['--disable-blink-features=AutomationControlled']
});
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
});
await context.addCookies(cookies);
const startFrom = progress.completed;
for (let i = startFrom; i < allDocs.length; i++) {
await processDoc(browser, allDocs[i]);
if (Date.now() - progress.lastReport > CONFIG.reportInterval) {
console.log(`\n进度: ${progress.completed}/${allDocs.length} (${Math.round(progress.completed/allDocs.length*100)}%)`);
console.log(`失败: ${progress.failed.length}\n`);
progress.lastReport = Date.now();
}
}
await browser.close();
console.log(`\n✓ 完成!成功: ${progress.completed - progress.failed.length} 失败: ${progress.failed.length}`);
}
main().catch(console.error);
#!/bin/bash
set -e
PROFILE="$HOME/.openclaw/workspace/skills/feishu-work-archive/profile"
OUTPUT_DIR="$HOME/Library/CloudStorage/OneDrive-个人/obsidian-vault/Projects/工作归档"
BATCHES="$HOME/.openclaw/workspace/skills/feishu-work-archive/batches.json"
# 读取前 10 个文档
docs=$(jq -r '[.batches[][]][0:10] | .[] | @json' "$BATCHES")
count=0
while IFS= read -r doc; do
count=$((count + 1))
name=$(echo "$doc" | jq -r '.name')
url=$(echo "$doc" | jq -r '.url')
category=$(echo "$doc" | jq -r '.category')
echo "[$count/10] $name"
# 访问文档
agent-browser --profile "$PROFILE" open "$url" > /dev/null 2>&1
sleep 2
# 提取内容
content=$(agent-browser eval "document.querySelector('[role=\"main\"]')?.innerText || document.body.innerText" 2>/dev/null || echo "")
if [ -z "$content" ]; then
echo " ✗ 内容为空"
continue
fi
# 保存
category_dir="$OUTPUT_DIR/$category"
mkdir -p "$category_dir"
filename=$(echo "$name" | sed 's/[\/\\:*?"<>|]/_/g' | cut -c1-100).md
filepath="$category_dir/$filename"
cat > "$filepath" <<EOF
---
title: $name
category: $category
source: 工作飞书
url: $url
exported_at: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
---
$content
EOF
echo " ✓ 已保存"
done <<< "$docs"
echo ""
echo "完成!处理了 $count 个文档"