growork 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import { getConfigPath, configExists, getDefaultConfig } from '../utils/config.js';
5
+
6
+ const DIRS_TO_CREATE = [
7
+ 'docs/product',
8
+ 'docs/design',
9
+ 'docs/api',
10
+ 'docs/tech',
11
+ 'docs/test',
12
+ ];
13
+
14
+ export async function initCommand(): Promise<void> {
15
+ console.log(chalk.blue('📁 初始化 Growork 项目...\n'));
16
+
17
+ const cwd = process.cwd();
18
+
19
+ // 创建目录结构
20
+ for (const dir of DIRS_TO_CREATE) {
21
+ const fullPath = path.join(cwd, dir);
22
+ if (!fs.existsSync(fullPath)) {
23
+ fs.mkdirSync(fullPath, { recursive: true });
24
+ console.log(chalk.green(` ✓ 创建目录: ${dir}`));
25
+ } else {
26
+ console.log(chalk.gray(` - 目录已存在: ${dir}`));
27
+ }
28
+ }
29
+
30
+ // 创建配置文件
31
+ const configPath = getConfigPath();
32
+ if (!configExists()) {
33
+ fs.writeFileSync(configPath, getDefaultConfig(), 'utf-8');
34
+ console.log(chalk.green(` ✓ 创建配置文件: growork.config.yaml`));
35
+ } else {
36
+ console.log(chalk.gray(` - 配置文件已存在: growork.config.yaml`));
37
+ }
38
+
39
+ // 更新 .gitignore
40
+ const gitignorePath = path.join(cwd, '.gitignore');
41
+ const ignoreEntry = 'growork.config.yaml';
42
+
43
+ if (fs.existsSync(gitignorePath)) {
44
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
45
+ if (!content.includes(ignoreEntry)) {
46
+ fs.appendFileSync(gitignorePath, `\n# Growork 配置文件(包含敏感凭证)\n${ignoreEntry}\n`);
47
+ console.log(chalk.green(` ✓ 已将配置文件添加到 .gitignore`));
48
+ }
49
+ } else {
50
+ fs.writeFileSync(gitignorePath, `# Growork 配置文件(包含敏感凭证)\n${ignoreEntry}\n`);
51
+ console.log(chalk.green(` ✓ 创建 .gitignore 并添加配置文件`));
52
+ }
53
+
54
+ console.log(chalk.blue('\n✅ 初始化完成!\n'));
55
+ console.log(chalk.yellow('下一步:'));
56
+ console.log(' 1. 编辑 growork.config.yaml,填入飞书凭证和文档链接');
57
+ console.log(' 2. 运行 growork sync 同步文档\n');
58
+ }
@@ -0,0 +1,37 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import { loadConfig, configExists } from '../utils/config.js';
5
+
6
+ export async function listCommand(): Promise<void> {
7
+ if (!configExists()) {
8
+ console.log(chalk.red('❌ 配置文件不存在,请先运行 growork init'));
9
+ process.exit(1);
10
+ }
11
+
12
+ const config = loadConfig();
13
+ const cwd = process.cwd();
14
+
15
+ console.log(chalk.blue('📋 文档列表\n'));
16
+
17
+ console.log(chalk.gray(' 名称'.padEnd(18) + '类型'.padEnd(10) + '输出路径'.padEnd(30) + '状态'));
18
+ console.log(chalk.gray(' ' + '-'.repeat(70)));
19
+
20
+ for (const doc of config.docs) {
21
+ const outputPath = path.join(cwd, doc.output);
22
+ const exists = fs.existsSync(outputPath);
23
+
24
+ const status = exists
25
+ ? chalk.green('已同步')
26
+ : chalk.yellow('未同步');
27
+
28
+ const name = ` ${doc.name}`.padEnd(18);
29
+ const type = doc.type.padEnd(10);
30
+ const output = doc.output.padEnd(30);
31
+
32
+ console.log(`${name}${chalk.gray(type)}${output}${status}`);
33
+ }
34
+
35
+ console.log('');
36
+ console.log(chalk.gray(`共 ${config.docs.length} 个文档配置`));
37
+ }
@@ -0,0 +1,77 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import { loadConfig } from '../utils/config.js';
5
+ import { FeishuService } from '../services/feishu.js';
6
+ import { NotionService } from '../services/notion.js';
7
+
8
+ function clearLine(): void {
9
+ if (process.stdout.isTTY) {
10
+ process.stdout.clearLine(0);
11
+ process.stdout.cursorTo(0);
12
+ } else {
13
+ console.log('');
14
+ }
15
+ }
16
+
17
+ export async function syncCommand(docName?: string): Promise<void> {
18
+ const config = loadConfig();
19
+ const feishuService = config.feishu ? new FeishuService(config.feishu) : null;
20
+ const notionService = config.notion ? new NotionService(config.notion) : null;
21
+
22
+ let docsToSync = config.docs;
23
+
24
+ if (docName) {
25
+ const doc = config.docs.find(d => d.name === docName);
26
+ if (!doc) {
27
+ console.log(chalk.red(`❌ 未找到名为 "${docName}" 的文档配置`));
28
+ console.log(chalk.gray(`可用的文档: ${config.docs.map(d => d.name).join(', ')}`));
29
+ process.exit(1);
30
+ }
31
+ docsToSync = [doc];
32
+ }
33
+
34
+ console.log(chalk.blue('📄 开始同步文档...\n'));
35
+
36
+ let successCount = 0;
37
+
38
+ for (const doc of docsToSync) {
39
+ process.stdout.write(chalk.gray(` ⏳ ${doc.name.padEnd(15)} → ${doc.output}`));
40
+
41
+ try {
42
+ let content: string;
43
+ if (doc.type === 'feishu') {
44
+ if (!feishuService) throw new Error('飞书服务未配置');
45
+ content = await feishuService.getDocumentAsMarkdown(doc.url);
46
+ } else if (doc.type === 'notion') {
47
+ if (!notionService) throw new Error('Notion 服务未配置');
48
+ content = await notionService.getPageAsMarkdown(doc.url);
49
+ } else {
50
+ throw new Error(`不支持的文档类型: ${doc.type}`);
51
+ }
52
+
53
+ const outputPath = path.join(process.cwd(), doc.output);
54
+ const outputDir = path.dirname(outputPath);
55
+ if (!fs.existsSync(outputDir)) {
56
+ fs.mkdirSync(outputDir, { recursive: true });
57
+ }
58
+
59
+ fs.writeFileSync(outputPath, content, 'utf-8');
60
+
61
+ clearLine();
62
+ console.log(chalk.green(` ✓ ${doc.name.padEnd(15)} → ${doc.output}`));
63
+ successCount++;
64
+ } catch (error) {
65
+ clearLine();
66
+ const errorMessage = error instanceof Error ? error.message : String(error);
67
+ console.log(chalk.red(` ✗ ${doc.name.padEnd(15)} → ${errorMessage}`));
68
+ }
69
+ }
70
+
71
+ console.log('');
72
+ if (successCount === docsToSync.length) {
73
+ console.log(chalk.green(`✅ 同步完成,共 ${docsToSync.length} 个文档`));
74
+ } else {
75
+ console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${docsToSync.length} 个文档成功`));
76
+ }
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { initCommand } from './commands/init.js';
5
+ import { syncCommand } from './commands/sync.js';
6
+ import { listCommand } from './commands/list.js';
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name('growork')
12
+ .description('将飞书文档同步到本地,为 AI Agent 提供完整上下文')
13
+ .version('1.0.0');
14
+
15
+ program
16
+ .command('init')
17
+ .description('初始化 Growork 配置和目录结构')
18
+ .action(initCommand);
19
+
20
+ program
21
+ .command('sync [name]')
22
+ .description('同步文档,可选指定文档名称')
23
+ .action(syncCommand);
24
+
25
+ program
26
+ .command('list')
27
+ .description('列出所有配置的文档')
28
+ .action(listCommand);
29
+
30
+ program.parse();
@@ -0,0 +1,360 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk';
2
+ import { FeishuConfig } from '../utils/config.js';
3
+
4
+ export class FeishuService {
5
+ private client: lark.Client;
6
+
7
+ constructor(config: FeishuConfig) {
8
+ const domain = config.domain === 'lark' ? lark.Domain.Lark : lark.Domain.Feishu;
9
+
10
+ this.client = new lark.Client({
11
+ appId: config.appId,
12
+ appSecret: config.appSecret,
13
+ disableTokenCache: false,
14
+ domain: domain,
15
+ });
16
+ }
17
+
18
+ parseDocumentId(url: string): { type: 'docx' | 'wiki'; documentId: string } {
19
+ const docxMatch = url.match(/\/docx\/([a-zA-Z0-9]+)/);
20
+ if (docxMatch) {
21
+ return { type: 'docx', documentId: docxMatch[1] };
22
+ }
23
+
24
+ const wikiMatch = url.match(/\/wiki\/([a-zA-Z0-9]+)/);
25
+ if (wikiMatch) {
26
+ return { type: 'wiki', documentId: wikiMatch[1] };
27
+ }
28
+
29
+ throw new Error(`无法解析飞书文档 URL: ${url}`);
30
+ }
31
+
32
+ async getWikiNodeInfo(token: string): Promise<{ objToken: string; objType: string }> {
33
+ const response = await this.client.wiki.v2.space.getNode({
34
+ params: { token },
35
+ });
36
+
37
+ const objToken = response.data?.node?.obj_token;
38
+ const objType = response.data?.node?.obj_type;
39
+
40
+ if (!objToken) {
41
+ throw new Error(`无法获取 Wiki 节点信息,请检查权限和链接是否正确`);
42
+ }
43
+
44
+ return { objToken, objType: objType || 'docx' };
45
+ }
46
+
47
+ private async resolveDocumentId(url: string): Promise<string> {
48
+ const { type, documentId } = this.parseDocumentId(url);
49
+ if (type === 'wiki') {
50
+ const nodeInfo = await this.getWikiNodeInfo(documentId);
51
+ return nodeInfo.objToken;
52
+ }
53
+ return documentId;
54
+ }
55
+
56
+ async getDocumentAsMarkdown(url: string): Promise<string> {
57
+ const actualDocumentId = await this.resolveDocumentId(url);
58
+
59
+ const docInfo = await this.client.docx.document.get({
60
+ path: { document_id: actualDocumentId },
61
+ });
62
+
63
+ const title = docInfo.data?.document?.title || '未命名文档';
64
+
65
+ const blocks = await this.getAllBlocks(actualDocumentId);
66
+
67
+ // 构建块索引
68
+ const blockMap = new Map<string, any>();
69
+ for (const block of blocks) {
70
+ blockMap.set(block.block_id, block);
71
+ }
72
+
73
+ // 找到根块(page)
74
+ const rootBlock = blocks.find(b => b.block_type === 1);
75
+ if (!rootBlock) {
76
+ throw new Error('无法找到文档根节点');
77
+ }
78
+
79
+ // 递归转换
80
+ const lines: string[] = [];
81
+ lines.push(`# ${title}`);
82
+ lines.push('');
83
+
84
+ this.convertChildrenToMarkdown(rootBlock.children || [], blockMap, lines, 0);
85
+
86
+ return lines.join('\n');
87
+ }
88
+
89
+ private async getAllBlocks(documentId: string): Promise<any[]> {
90
+ const allBlocks: any[] = [];
91
+ let pageToken: string | undefined;
92
+
93
+ do {
94
+ const response = await this.client.docx.documentBlock.list({
95
+ path: { document_id: documentId },
96
+ params: {
97
+ page_size: 500,
98
+ page_token: pageToken,
99
+ },
100
+ });
101
+
102
+ const blocks = response.data?.items || [];
103
+ allBlocks.push(...blocks);
104
+ pageToken = response.data?.page_token;
105
+ } while (pageToken);
106
+
107
+ return allBlocks;
108
+ }
109
+
110
+ private convertChildrenToMarkdown(
111
+ childIds: string[],
112
+ blockMap: Map<string, any>,
113
+ lines: string[],
114
+ indent: number
115
+ ): void {
116
+ for (const childId of childIds) {
117
+ const block = blockMap.get(childId);
118
+ if (!block) continue;
119
+
120
+ const md = this.blockToMarkdown(block, blockMap, indent);
121
+ if (md !== null) {
122
+ lines.push(md);
123
+ }
124
+ }
125
+ }
126
+
127
+ private blockToMarkdown(block: any, blockMap: Map<string, any>, indent: number): string | null {
128
+ const blockType = block.block_type;
129
+ const indentStr = ' '.repeat(indent);
130
+
131
+ switch (blockType) {
132
+ case 1: // Page
133
+ return null;
134
+
135
+ case 2: // Text
136
+ const text = this.textElementsToMarkdown(block.text?.elements);
137
+ return text ? text + '\n' : '';
138
+
139
+ case 3: // Heading1
140
+ return `# ${this.textElementsToMarkdown(block.heading1?.elements)}\n`;
141
+
142
+ case 4: // Heading2
143
+ return `## ${this.textElementsToMarkdown(block.heading2?.elements)}\n`;
144
+
145
+ case 5: // Heading3
146
+ return `### ${this.textElementsToMarkdown(block.heading3?.elements)}\n`;
147
+
148
+ case 6: // Heading4
149
+ return `#### ${this.textElementsToMarkdown(block.heading4?.elements)}\n`;
150
+
151
+ case 7: // Heading5
152
+ return `##### ${this.textElementsToMarkdown(block.heading5?.elements)}\n`;
153
+
154
+ case 8: // Heading6
155
+ return `###### ${this.textElementsToMarkdown(block.heading6?.elements)}\n`;
156
+
157
+ case 9: // Heading7
158
+ case 10: // Heading8
159
+ case 11: // Heading9
160
+ return `###### ${this.textElementsToMarkdown(block[`heading${blockType - 6}`]?.elements)}\n`;
161
+
162
+ case 12: // Bullet
163
+ case 13: // Ordered
164
+ const isBullet = blockType === 12;
165
+ const listText = this.textElementsToMarkdown(block[isBullet ? 'bullet' : 'ordered']?.elements);
166
+ const listChildren = block.children || [];
167
+ let listResult = `${indentStr}${isBullet ? '-' : '1.'} ${listText}`;
168
+ if (listChildren.length > 0) {
169
+ const childLines: string[] = [];
170
+ this.convertChildrenToMarkdown(listChildren, blockMap, childLines, indent + 1);
171
+ if (childLines.length > 0) {
172
+ listResult += '\n' + childLines.join('\n');
173
+ }
174
+ }
175
+ return listResult;
176
+
177
+ case 14: // Code
178
+ const lang = this.getLanguageName(block.code?.style?.language || 0);
179
+ const codeText = this.textElementsToMarkdown(block.code?.elements);
180
+ return `\`\`\`${lang}\n${codeText}\n\`\`\`\n`;
181
+
182
+ case 15: // Quote
183
+ return `> ${this.textElementsToMarkdown(block.quote?.elements)}\n`;
184
+
185
+ case 17: // TodoList
186
+ const checked = block.todo?.style?.done ? 'x' : ' ';
187
+ return `${indentStr}- [${checked}] ${this.textElementsToMarkdown(block.todo?.elements)}`;
188
+
189
+ case 18: // Divider
190
+ return '---\n';
191
+
192
+ case 19: // Image
193
+ const imageToken = block.image?.token;
194
+ return imageToken ? `![image](${imageToken})\n` : '';
195
+
196
+ case 27: // Quote Container
197
+ const quoteChildren = block.children || [];
198
+ if (quoteChildren.length > 0) {
199
+ const childLines: string[] = [];
200
+ this.convertChildrenToMarkdown(quoteChildren, blockMap, childLines, 0);
201
+ return childLines.map(line => `> ${line}`).join('\n') + '\n';
202
+ }
203
+ return '';
204
+
205
+ case 24: // Grid - 分栏布局
206
+ const gridChildren = block.children || [];
207
+ if (gridChildren.length > 0) {
208
+ const childLines: string[] = [];
209
+ this.convertChildrenToMarkdown(gridChildren, blockMap, childLines, indent);
210
+ return childLines.join('\n');
211
+ }
212
+ return '';
213
+
214
+ case 25: // Callout - 高亮块
215
+ const calloutChildren = block.children || [];
216
+ if (calloutChildren.length > 0) {
217
+ const childLines: string[] = [];
218
+ this.convertChildrenToMarkdown(calloutChildren, blockMap, childLines, indent);
219
+ return childLines.join('\n');
220
+ }
221
+ return '';
222
+
223
+ case 30: // Sheet - 嵌入表格
224
+ const sheetToken = block.sheet?.token;
225
+ return sheetToken ? `[嵌入表格: ${sheetToken}]\n` : '';
226
+
227
+ case 31: // Table
228
+ return this.tableToMarkdown(block, blockMap);
229
+
230
+ case 32: // Table Cell - 跳过,由表格统一处理
231
+ return null;
232
+
233
+ default:
234
+ return null;
235
+ }
236
+ }
237
+
238
+ private tableToMarkdown(tableBlock: any, blockMap: Map<string, any>): string {
239
+ const table = tableBlock.table;
240
+ if (!table) return '';
241
+
242
+ const colSize = table.property?.column_size || 0;
243
+ const rowSize = table.property?.row_size || 0;
244
+ const cellIds = table.cells || [];
245
+
246
+ if (colSize === 0 || rowSize === 0) return '';
247
+
248
+ const rows: string[][] = [];
249
+
250
+ for (let row = 0; row < rowSize; row++) {
251
+ const rowCells: string[] = [];
252
+ for (let col = 0; col < colSize; col++) {
253
+ const cellIndex = row * colSize + col;
254
+ const cellId = cellIds[cellIndex];
255
+ const cellBlock = blockMap.get(cellId);
256
+ const cellContent = this.getCellContent(cellBlock, blockMap);
257
+ rowCells.push(cellContent);
258
+ }
259
+ rows.push(rowCells);
260
+ }
261
+
262
+ // 生成 Markdown 表格
263
+ const lines: string[] = [];
264
+
265
+ // 表头
266
+ if (rows.length > 0) {
267
+ lines.push('| ' + rows[0].join(' | ') + ' |');
268
+ lines.push('| ' + rows[0].map(() => '---').join(' | ') + ' |');
269
+ }
270
+
271
+ // 表体
272
+ for (let i = 1; i < rows.length; i++) {
273
+ lines.push('| ' + rows[i].join(' | ') + ' |');
274
+ }
275
+
276
+ return lines.join('\n') + '\n';
277
+ }
278
+
279
+ private getCellContent(cellBlock: any, blockMap: Map<string, any>): string {
280
+ if (!cellBlock) return '';
281
+
282
+ const children = cellBlock.children || [];
283
+ const contents: string[] = [];
284
+
285
+ for (const childId of children) {
286
+ const child = blockMap.get(childId);
287
+ if (!child) continue;
288
+
289
+ if (child.block_type === 2) {
290
+ contents.push(this.textElementsToMarkdown(child.text?.elements));
291
+ }
292
+ }
293
+
294
+ return contents.join(' ').replace(/\|/g, '\\|').replace(/\n/g, ' ');
295
+ }
296
+
297
+ private textElementsToMarkdown(elements: any[] | undefined): string {
298
+ if (!elements) return '';
299
+
300
+ return elements.map(el => {
301
+ if (el.text_run) {
302
+ let text = el.text_run.content || '';
303
+ const style = el.text_run.text_element_style;
304
+
305
+ if (style?.bold) {
306
+ text = `**${text}**`;
307
+ }
308
+ if (style?.italic) {
309
+ text = `*${text}*`;
310
+ }
311
+ if (style?.strikethrough) {
312
+ text = `~~${text}~~`;
313
+ }
314
+ if (style?.inline_code) {
315
+ text = `\`${text}\``;
316
+ }
317
+ if (style?.link?.url) {
318
+ text = `[${text}](${style.link.url})`;
319
+ }
320
+
321
+ return text;
322
+ }
323
+
324
+ if (el.mention_user) {
325
+ return `@${el.mention_user.user_id || 'user'}`;
326
+ }
327
+
328
+ if (el.mention_doc) {
329
+ const title = el.mention_doc.title || '文档';
330
+ const url = el.mention_doc.url || '';
331
+ return `[${title}](${url})`;
332
+ }
333
+
334
+ return '';
335
+ }).join('');
336
+ }
337
+
338
+ private getLanguageName(langCode: number | string): string {
339
+ const langMap: Record<number, string> = {
340
+ 1: 'plaintext', 2: 'abap', 3: 'ada', 4: 'apache', 5: 'apex',
341
+ 6: 'assembly', 7: 'bash', 8: 'csharp', 9: 'cpp', 10: 'c',
342
+ 11: 'cobol', 12: 'css', 13: 'coffeescript', 14: 'd', 15: 'dart',
343
+ 16: 'delphi', 17: 'django', 18: 'dockerfile', 19: 'erlang', 20: 'fortran',
344
+ 21: 'foxpro', 22: 'go', 23: 'groovy', 24: 'html', 25: 'htmlbars',
345
+ 26: 'http', 27: 'haskell', 28: 'json', 29: 'java', 30: 'javascript',
346
+ 31: 'julia', 32: 'kotlin', 33: 'latex', 34: 'lisp', 35: 'lua',
347
+ 36: 'matlab', 37: 'makefile', 38: 'markdown', 39: 'nginx', 40: 'objectivec',
348
+ 41: 'openedgeabl', 42: 'php', 43: 'perl', 44: 'powershell', 45: 'prolog',
349
+ 46: 'protobuf', 47: 'python', 48: 'r', 49: 'rpm', 50: 'ruby',
350
+ 51: 'rust', 52: 'sas', 53: 'scss', 54: 'sql', 55: 'scala',
351
+ 56: 'scheme', 57: 'scratch', 58: 'shell', 59: 'swift', 60: 'thrift',
352
+ 61: 'typescript', 62: 'vbscript', 63: 'visual-basic', 64: 'xml', 65: 'yaml',
353
+ };
354
+
355
+ if (typeof langCode === 'number') {
356
+ return langMap[langCode] || '';
357
+ }
358
+ return String(langCode);
359
+ }
360
+ }
@@ -0,0 +1,129 @@
1
+ import { Client } from '@notionhq/client';
2
+ import { NotionToMarkdown } from 'notion-to-md';
3
+ import { NotionConfig } from '../utils/config.js';
4
+
5
+ export class NotionService {
6
+ private client: Client;
7
+ private n2m: NotionToMarkdown;
8
+
9
+ constructor(config: NotionConfig) {
10
+ this.client = new Client({ auth: config.token });
11
+ this.n2m = new NotionToMarkdown({ notionClient: this.client });
12
+ this.setupCustomTransformers();
13
+ }
14
+
15
+ private setupCustomTransformers(): void {
16
+ this.n2m.setCustomTransformer('child_database', async (block: any) => {
17
+ const dbId = block.id;
18
+
19
+ try {
20
+ const db = await this.client.databases.retrieve({ database_id: dbId }) as any;
21
+ const title = db.title?.[0]?.plain_text || '未命名数据库';
22
+
23
+ if (!db.data_sources || db.data_sources.length === 0) {
24
+ return `### ${title}\n\n(无法获取数据源)\n`;
25
+ }
26
+
27
+ const dataSourceId = db.data_sources[0].id;
28
+ const result = await (this.client as any).dataSources.query({ data_source_id: dataSourceId });
29
+
30
+ if (!result.results || result.results.length === 0) {
31
+ return `### ${title}\n\n(空数据库)\n`;
32
+ }
33
+
34
+ const columns = Object.keys(result.results[0].properties);
35
+ const rows = result.results.map((page: any) => {
36
+ const row: Record<string, string> = {};
37
+ for (const [key, prop] of Object.entries(page.properties)) {
38
+ row[key] = this.extractPropertyValue(prop);
39
+ }
40
+ return row;
41
+ });
42
+
43
+ let md = `### ${title}\n\n`;
44
+ md += '| ' + columns.join(' | ') + ' |\n';
45
+ md += '| ' + columns.map(() => '---').join(' | ') + ' |\n';
46
+ for (const row of rows) {
47
+ const cells = columns.map(col => {
48
+ const val = row[col] || '';
49
+ return val.replace(/\|/g, '\\|').replace(/\n/g, '<br>').slice(0, 200);
50
+ });
51
+ md += '| ' + cells.join(' | ') + ' |\n';
52
+ }
53
+
54
+ return md;
55
+ } catch (e: any) {
56
+ return `### ${block.child_database?.title || '数据库'}\n\n(导出失败: ${e.message})\n`;
57
+ }
58
+ });
59
+ }
60
+
61
+ private extractPropertyValue(prop: any): string {
62
+ if (!prop) return '';
63
+ switch (prop.type) {
64
+ case 'title':
65
+ return prop.title?.map((t: any) => t.plain_text).join('') || '';
66
+ case 'rich_text':
67
+ return prop.rich_text?.map((t: any) => t.plain_text).join('') || '';
68
+ case 'select':
69
+ return prop.select?.name || '';
70
+ case 'multi_select':
71
+ return prop.multi_select?.map((s: any) => s.name).join(', ') || '';
72
+ case 'number':
73
+ return prop.number?.toString() ?? '';
74
+ case 'checkbox':
75
+ return prop.checkbox ? '✓' : '';
76
+ case 'date':
77
+ return prop.date?.start || '';
78
+ case 'url':
79
+ return prop.url || '';
80
+ case 'files':
81
+ return prop.files?.map((f: any) => f.name || 'file').join(', ') || '';
82
+ default:
83
+ return '';
84
+ }
85
+ }
86
+
87
+ parsePageId(url: string): string {
88
+ // 支持多种 Notion URL 格式
89
+ // https://www.notion.so/workspace/Page-Title-abc123def456
90
+ // https://www.notion.so/abc123def456
91
+ // https://notion.so/abc123def456?v=xxx
92
+ const match = url.match(/([a-f0-9]{32})|([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i);
93
+ if (!match) {
94
+ throw new Error(`无法解析 Notion 页面 URL: ${url}`);
95
+ }
96
+ return match[0].replace(/-/g, '');
97
+ }
98
+
99
+ async getPageAsMarkdown(url: string): Promise<string> {
100
+ const pageId = this.parsePageId(url);
101
+
102
+ const page = await this.client.pages.retrieve({ page_id: pageId }) as any;
103
+ const title = this.getPageTitle(page);
104
+
105
+ const mdBlocks = await this.n2m.pageToMarkdown(pageId);
106
+ const mdString = this.n2m.toMarkdownString(mdBlocks);
107
+
108
+ const lines: string[] = [];
109
+ lines.push(`# ${title}`);
110
+ lines.push('');
111
+ lines.push(mdString.parent);
112
+
113
+ return lines.join('\n');
114
+ }
115
+
116
+ private getPageTitle(page: any): string {
117
+ const properties = page.properties;
118
+ if (!properties) return '未命名';
119
+
120
+ for (const key of Object.keys(properties)) {
121
+ const prop = properties[key];
122
+ if (prop.type === 'title' && prop.title?.length > 0) {
123
+ return prop.title.map((t: any) => t.plain_text).join('');
124
+ }
125
+ }
126
+
127
+ return '未命名';
128
+ }
129
+ }