nconv-cli 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 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/config.ts","../src/core/exporter.ts","../src/utils/file.ts","../src/utils/logger.ts","../src/commands/md.ts","../src/commands/debug.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command } from 'commander';\nimport { mdCommand } from './commands/md.js';\nimport { debugCommand } from './commands/debug.js';\n\nconst program = new Command();\n\nprogram\n .name('nconv')\n .description('CLI tool for converting Notion pages to blog-ready markdown')\n .version('1.0.0');\n\nprogram\n .command('md <url>')\n .description('Convert a Notion page to markdown')\n .option('-o, --output <dir>', 'Output directory', './output')\n .option('-i, --image-dir <dir>', 'Image folder name (relative to output)', 'images')\n .option('-f, --filename <name>', '출력 파일명 (확장자 제외 또는 포함)')\n .option('-v, --verbose', 'Enable verbose logging', false)\n .action(async (url: string, options) => {\n await mdCommand(url, {\n output: options.output,\n imageDir: options.imageDir,\n filename: options.filename,\n verbose: options.verbose,\n });\n });\n\n// 개발 환경에서만 debug 명령어를 활성화\nif (process.env.NODE_ENV !== 'production') {\n program\n .command('debug <url>')\n .description('Debug: Output raw markdown and image URLs')\n .option('-o, --output <dir>', '출력 디렉토리', './output')\n .option('-i, --image-dir <dir>', 'Image folder name', 'images')\n .option('-v, --verbose', 'Enable verbose logging', false)\n .action(async (url: string, options) => {\n await debugCommand(url, {\n output: options.output,\n imageDir: options.imageDir,\n filename: '',\n verbose: options.verbose,\n });\n });\n}\n\nprogram.parse();\n","import { config as dotenvConfig } from 'dotenv';\nimport { existsSync } from 'fs';\nimport { resolve } from 'path';\n\n// 환경 변수 로드\ndotenvConfig();\n\nexport interface NotionConfig {\n tokenV2: string;\n fileToken: string;\n}\n\nexport interface ConverterOptions {\n output: string;\n imageDir: string;\n filename?: string;\n verbose: boolean;\n}\n\nexport interface FullConfig extends NotionConfig, ConverterOptions {}\n\n/**\n * Notion 인증 설정 가져오기\n */\nexport function getNotionConfig(): NotionConfig {\n const tokenV2 = process.env.TOKEN_V2 || '';\n const fileToken = process.env.FILE_TOKEN || '';\n\n if (!tokenV2 || !fileToken) {\n throw new Error(\n 'Notion 토큰이 설정되지 않았습니다.\\n' +\n '.env 파일에 TOKEN_V2와 FILE_TOKEN을 설정해주세요.\\n' +\n '자세한 내용은 .env.example을 참고하세요.'\n );\n }\n\n return { tokenV2, fileToken };\n}\n\n/**\n * 전체 설정 생성\n */\nexport function createConfig(options: ConverterOptions): FullConfig {\n const notionConfig = getNotionConfig();\n\n // 출력 디렉토리 절대 경로로 변환\n const outputDir = resolve(process.cwd(), options.output);\n\n return {\n ...notionConfig,\n ...options,\n output: outputDir,\n };\n}\n\n/**\n * 디렉토리 존재 여부 확인\n */\nexport function ensureDirectoryExists(dir: string): boolean {\n return existsSync(dir);\n}\n","import { NotionExporter } from 'notion-exporter';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport type { NotionConfig } from '../config.js';\n\nexport interface ExportResult {\n markdown: string;\n imageFiles: Array<{ filename: string; sourcePath: string }>;\n}\n\n/**\n * Notion 페이지를 마크다운으로 내보내기\n */\nexport class NotionMarkdownExporter {\n private exporter: NotionExporter;\n\n constructor(config: NotionConfig) {\n this.exporter = new NotionExporter(config.tokenV2, config.fileToken);\n }\n\n /**\n * Notion URL에서 마크다운과 이미지 파일 가져오기\n */\n async exportWithImages(notionUrl: string, tempDir: string): Promise<ExportResult> {\n try {\n // 임시 디렉토리 생성\n await fs.mkdir(tempDir, { recursive: true });\n\n // getMdFiles로 마크다운과 이미지를 함께 다운로드\n await this.exporter.getMdFiles(notionUrl, tempDir);\n\n // 다운로드된 파일 목록 가져오기\n const files = await fs.readdir(tempDir, { withFileTypes: true });\n\n // 마크다운 파일 찾기\n const mdFile = files.find(f => f.isFile() && f.name.endsWith('.md'));\n if (!mdFile) {\n throw new Error('Markdown file not found.');\n }\n\n // 마크다운 내용 읽기\n const mdPath = path.join(tempDir, mdFile.name);\n const markdown = await fs.readFile(mdPath, 'utf-8');\n\n // 이미지 파일 목록 (마크다운이 아닌 파일들)\n const imageFiles = files\n .filter(f => f.isFile() && !f.name.endsWith('.md'))\n .map(f => ({\n filename: f.name,\n sourcePath: path.join(tempDir, f.name),\n }));\n\n // 디렉토리 내 폴더도 확인 (Notion이 폴더 구조로 저장할 수 있음)\n const dirs = files.filter(f => f.isDirectory());\n for (const dir of dirs) {\n const subFiles = await fs.readdir(path.join(tempDir, dir.name), { withFileTypes: true });\n for (const subFile of subFiles) {\n if (subFile.isFile() && !subFile.name.endsWith('.md')) {\n imageFiles.push({\n filename: path.join(dir.name, subFile.name),\n sourcePath: path.join(tempDir, dir.name, subFile.name),\n });\n }\n }\n }\n\n return { markdown, imageFiles };\n } catch (error) {\n if (error instanceof Error) {\n throw new Error(`Failed to fetch Notion page: ${error.message}`);\n }\n throw new Error('Failed to fetch Notion page.');\n }\n }\n}\n","import { promises as fs } from 'fs';\nimport path from 'path';\nimport slugify from 'slugify';\nimport { v4 as uuidv4 } from 'uuid';\n\n/**\n * Notion URL에서 페이지 제목 추출 (간단한 버전)\n */\nexport function extractTitleFromUrl(notionUrl: string): string | null {\n try {\n const url = new URL(notionUrl);\n const pathname = url.pathname;\n\n // /Page-Title-abc123 형식에서 제목 부분 추출\n const match = pathname.match(/\\/([^/]+)-[a-f0-9]{32}$/i);\n if (match) {\n const title = match[1].replace(/-/g, ' ');\n return title;\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * 안전한 파일명 생성\n */\nexport function generateSafeFilename(title: string | null, extension: string = 'md'): string {\n if (title) {\n const slug = slugify(title, {\n lower: true,\n strict: true,\n locale: 'ko',\n });\n\n if (slug.length > 0) {\n return extension ? `${slug}.${extension}` : slug;\n }\n }\n\n // 제목이 없거나 slugify 실패 시 UUID 사용\n const hash = uuidv4().slice(0, 8);\n return extension ? `notion-export-${hash}.${extension}` : `notion-export-${hash}`;\n}\n\n/**\n * 마크다운 파일 저장\n */\nexport async function saveMarkdownFile(\n outputDir: string,\n filename: string,\n content: string\n): Promise<string> {\n await fs.mkdir(outputDir, { recursive: true });\n\n const filePath = path.join(outputDir, filename);\n await fs.writeFile(filePath, content, 'utf-8');\n\n return filePath;\n}\n\n/**\n * 파일 경로를 상대 경로로 변환\n */\nexport function toRelativePath(from: string, to: string): string {\n return path.relative(from, to);\n}\n","import chalk from 'chalk';\nimport ora, { Ora } from 'ora';\n\n/**\n * 성공 메시지 출력\n */\nexport function success(message: string): void {\n console.log(chalk.green('✓'), message);\n}\n\n/**\n * 에러 메시지 출력\n */\nexport function error(message: string): void {\n console.error(chalk.red('✗'), message);\n}\n\n/**\n * 정보 메시지 출력\n */\nexport function info(message: string): void {\n console.log(chalk.blue('ℹ'), message);\n}\n\n/**\n * 경고 메시지 출력\n */\nexport function warn(message: string): void {\n console.log(chalk.yellow('⚠'), message);\n}\n\n/**\n * 스피너 생성\n */\nexport function spinner(text: string): Ora {\n return ora(text).start();\n}\n","import { ConverterOptions, createConfig } from '../config.js';\nimport { NotionMarkdownExporter } from '../core/exporter.js';\nimport { generateSafeFilename, extractTitleFromUrl, saveMarkdownFile } from '../utils/file.js';\nimport * as logger from '../utils/logger.js';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport os from 'os';\n\n/**\n * md 명령어 핸들러\n */\nexport async function mdCommand(notionUrl: string, options: ConverterOptions) {\n const tempDir = path.join(os.tmpdir(), `nconv-cli-${Date.now()}`);\n\n try {\n // 설정 생성\n const config = createConfig(options);\n\n if (config.verbose) {\n logger.info('Configuration loaded successfully');\n console.log(` Output directory: ${config.output}\\n`);\n }\n\n // 1. Notion에서 마크다운과 이미지 가져오기\n const spinner = logger.spinner('Fetching Notion page...');\n\n const exporter = new NotionMarkdownExporter({\n tokenV2: config.tokenV2,\n fileToken: config.fileToken,\n });\n\n let result;\n try {\n result = await exporter.exportWithImages(notionUrl, tempDir);\n spinner.succeed(`Notion page fetched (${result.imageFiles.length} images)`);\n } catch (error) {\n spinner.fail('Failed to fetch Notion page');\n throw error;\n }\n\n // 2. 파일명/폴더명 생성\n let baseFilename: string;\n if (config.filename) {\n baseFilename = config.filename.replace(/\\.md$/, '');\n } else {\n const title = extractTitleFromUrl(notionUrl);\n baseFilename = generateSafeFilename(title, ''); // 확장자 없이 생성\n }\n\n // 3. 제목별 폴더 생성 (output/제목/)\n const pageDir = path.join(config.output, baseFilename);\n await fs.mkdir(pageDir, { recursive: true });\n\n if (config.verbose) {\n console.log(`📁 출력 폴더: ${path.relative(process.cwd(), pageDir)}\\n`);\n }\n\n // 4. 이미지 폴더 생성 및 이미지 파일 이동\n const imageOutputDir = path.join(pageDir, config.imageDir);\n await fs.mkdir(imageOutputDir, { recursive: true });\n\n if (config.verbose && result.imageFiles.length > 0) {\n console.log(`Processing image files...\\n`);\n }\n\n let processedMarkdown = result.markdown;\n for (const imageFile of result.imageFiles) {\n try {\n // 파일명에서 공백을 하이픈으로 변경 (마크다운 호환성)\n const originalFileName = path.basename(imageFile.filename);\n const safeFileName = originalFileName.replace(/\\s+/g, '-');\n\n // 이미지 파일 복사\n const targetPath = path.join(imageOutputDir, safeFileName);\n await fs.copyFile(imageFile.sourcePath, targetPath);\n\n if (config.verbose) {\n console.log(`✓ ${safeFileName}`);\n }\n\n // 마크다운 내 경로를 상대경로로 변경\n const originalPath = imageFile.filename;\n const relativePath = `./${config.imageDir}/${safeFileName}`;\n\n // Notion이 URL 인코딩하는 방식: 각 경로 부분을 개별적으로 인코딩\n const pathParts = originalPath.split('/');\n const encodedPath = pathParts.map(part => encodeURIComponent(part)).join('/');\n\n // 정규식 특수문자 이스케이프 함수\n const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\n // 모든 가능한 형태의 경로를 교체\n processedMarkdown = processedMarkdown\n .replace(new RegExp(`\\\\(${escapeRegex(originalPath)}\\\\)`, 'g'), `(${relativePath})`)\n .replace(new RegExp(`\\\\(${escapeRegex(encodedPath)}\\\\)`, 'g'), `(${relativePath})`);\n\n } catch (error) {\n if (config.verbose) {\n const errorMsg = error instanceof Error ? error.message : '알 수 없는 오류';\n console.error(`✗ ${imageFile.filename}: ${errorMsg}`);\n }\n }\n }\n\n // 5. 마크다운 파일 저장 (제목 폴더 안에)\n const filename = `${baseFilename}.md`;\n const filePath = await saveMarkdownFile(pageDir, filename, processedMarkdown);\n\n // 6. 결과 출력\n console.log('');\n logger.success('Conversion complete!');\n console.log('');\n console.log(`📁 Folder: ${path.relative(process.cwd(), pageDir)}`);\n console.log(`📄 Markdown: ${filename}`);\n\n if (result.imageFiles.length > 0) {\n console.log(`🖼️ Images: ${config.imageDir}/ (${result.imageFiles.length} files)`);\n }\n\n console.log('');\n\n } catch (error) {\n if (error instanceof Error) {\n logger.error(error.message);\n } else {\n logger.error('An unknown error occurred.');\n }\n process.exit(1);\n } finally {\n // 임시 디렉토리 정리\n try {\n await fs.rm(tempDir, { recursive: true, force: true });\n } catch {\n // 무시\n }\n }\n}\n","import { ConverterOptions, createConfig } from '../config.js';\nimport { NotionMarkdownExporter } from '../core/exporter.js';\nimport * as fs from 'fs/promises';\nimport * as os from 'os';\nimport * as path from 'path';\n\n/**\n * Debugging command - output raw markdown\n */\nexport async function debugCommand(notionUrl: string, options: ConverterOptions) {\n let tempDir: string | undefined;\n try {\n const config = createConfig(options);\n\n const exporter = new NotionMarkdownExporter({\n tokenV2: config.tokenV2,\n fileToken: config.fileToken,\n });\n\n tempDir = path.join(os.tmpdir(), `notion-debug-${Date.now()}`); // Create temporary directory\n\n console.log('Fetching Notion page...\\n');\n const { markdown, imageFiles } = await exporter.exportWithImages(notionUrl, tempDir);\n\n console.log('=== Raw Markdown ===\\n');\n console.log(markdown.slice(0, 3000));\n console.log('\\n... (truncated) ...\\n');\n\n console.log(`\\n=== Found Images (${imageFiles.length}) ===\\n`);\n imageFiles.forEach((file, i) => {\n console.log(`${i + 1}. ${file.filename} (원본 경로: ${file.sourcePath})`);\n });\n\n } catch (error) {\n console.error('Error:', error);\n } finally {\n if (tempDir) {\n // Clean up temporary directory\n await fs.rm(tempDir, { recursive: true, force: true }).catch(err => {\n console.warn(`Error cleaning up temporary directory: ${err.message}`);\n });\n }\n }\n}"],"mappings":"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","names":["error","fs","path","path","fs","spinner","error","fs","os","path","error"]}
Binary file
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "nconv-cli",
3
+ "version": "1.0.0",
4
+ "description": "A CLI tool that converts Notion pages into blog-ready Markdown (with automatic image extraction and path normalization)",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "nconv": "./bin/nconv.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsup",
12
+ "dev": "tsup --watch",
13
+ "start": "node dist/index.js"
14
+ },
15
+ "keywords": ["notion", "markdown", "blog", "converter", "cli"],
16
+ "author": "",
17
+ "license": "ISC",
18
+ "dependencies": {
19
+ "notion-exporter": "^0.8.1",
20
+ "commander": "^12.0.0",
21
+ "axios": "^1.13.2",
22
+ "dotenv": "^17.2.3",
23
+ "chalk": "^5.4.0",
24
+ "ora": "^8.0.0",
25
+ "slugify": "^1.6.6",
26
+ "uuid": "^13.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.0.10",
30
+ "@types/uuid": "^10.0.0",
31
+ "typescript": "^5.9.3",
32
+ "tsup": "^8.0.0"
33
+ }
34
+ }
@@ -0,0 +1,44 @@
1
+ import { ConverterOptions, createConfig } from '../config.js';
2
+ import { NotionMarkdownExporter } from '../core/exporter.js';
3
+ import * as fs from 'fs/promises';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ /**
8
+ * Debugging command - output raw markdown
9
+ */
10
+ export async function debugCommand(notionUrl: string, options: ConverterOptions) {
11
+ let tempDir: string | undefined;
12
+ try {
13
+ const config = createConfig(options);
14
+
15
+ const exporter = new NotionMarkdownExporter({
16
+ tokenV2: config.tokenV2,
17
+ fileToken: config.fileToken,
18
+ });
19
+
20
+ tempDir = path.join(os.tmpdir(), `notion-debug-${Date.now()}`); // Create temporary directory
21
+
22
+ console.log('Fetching Notion page...\n');
23
+ const { markdown, imageFiles } = await exporter.exportWithImages(notionUrl, tempDir);
24
+
25
+ console.log('=== Raw Markdown ===\n');
26
+ console.log(markdown.slice(0, 3000));
27
+ console.log('\n... (truncated) ...\n');
28
+
29
+ console.log(`\n=== Found Images (${imageFiles.length}) ===\n`);
30
+ imageFiles.forEach((file, i) => {
31
+ console.log(`${i + 1}. ${file.filename} (원본 경로: ${file.sourcePath})`);
32
+ });
33
+
34
+ } catch (error) {
35
+ console.error('Error:', error);
36
+ } finally {
37
+ if (tempDir) {
38
+ // Clean up temporary directory
39
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(err => {
40
+ console.warn(`Error cleaning up temporary directory: ${err.message}`);
41
+ });
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,137 @@
1
+ import { ConverterOptions, createConfig } from '../config.js';
2
+ import { NotionMarkdownExporter } from '../core/exporter.js';
3
+ import { generateSafeFilename, extractTitleFromUrl, saveMarkdownFile } from '../utils/file.js';
4
+ import * as logger from '../utils/logger.js';
5
+ import path from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import os from 'os';
8
+
9
+ /**
10
+ * md 명령어 핸들러
11
+ */
12
+ export async function mdCommand(notionUrl: string, options: ConverterOptions) {
13
+ const tempDir = path.join(os.tmpdir(), `nconv-cli-${Date.now()}`);
14
+
15
+ try {
16
+ // 설정 생성
17
+ const config = createConfig(options);
18
+
19
+ if (config.verbose) {
20
+ logger.info('Configuration loaded successfully');
21
+ console.log(` Output directory: ${config.output}\n`);
22
+ }
23
+
24
+ // 1. Notion에서 마크다운과 이미지 가져오기
25
+ const spinner = logger.spinner('Fetching Notion page...');
26
+
27
+ const exporter = new NotionMarkdownExporter({
28
+ tokenV2: config.tokenV2,
29
+ fileToken: config.fileToken,
30
+ });
31
+
32
+ let result;
33
+ try {
34
+ result = await exporter.exportWithImages(notionUrl, tempDir);
35
+ spinner.succeed(`Notion page fetched (${result.imageFiles.length} images)`);
36
+ } catch (error) {
37
+ spinner.fail('Failed to fetch Notion page');
38
+ throw error;
39
+ }
40
+
41
+ // 2. 파일명/폴더명 생성
42
+ let baseFilename: string;
43
+ if (config.filename) {
44
+ baseFilename = config.filename.replace(/\.md$/, '');
45
+ } else {
46
+ const title = extractTitleFromUrl(notionUrl);
47
+ baseFilename = generateSafeFilename(title, ''); // 확장자 없이 생성
48
+ }
49
+
50
+ // 3. 제목별 폴더 생성 (output/제목/)
51
+ const pageDir = path.join(config.output, baseFilename);
52
+ await fs.mkdir(pageDir, { recursive: true });
53
+
54
+ if (config.verbose) {
55
+ console.log(`📁 출력 폴더: ${path.relative(process.cwd(), pageDir)}\n`);
56
+ }
57
+
58
+ // 4. 이미지 폴더 생성 및 이미지 파일 이동
59
+ const imageOutputDir = path.join(pageDir, config.imageDir);
60
+ await fs.mkdir(imageOutputDir, { recursive: true });
61
+
62
+ if (config.verbose && result.imageFiles.length > 0) {
63
+ console.log(`Processing image files...\n`);
64
+ }
65
+
66
+ let processedMarkdown = result.markdown;
67
+ for (const imageFile of result.imageFiles) {
68
+ try {
69
+ // 파일명에서 공백을 하이픈으로 변경 (마크다운 호환성)
70
+ const originalFileName = path.basename(imageFile.filename);
71
+ const safeFileName = originalFileName.replace(/\s+/g, '-');
72
+
73
+ // 이미지 파일 복사
74
+ const targetPath = path.join(imageOutputDir, safeFileName);
75
+ await fs.copyFile(imageFile.sourcePath, targetPath);
76
+
77
+ if (config.verbose) {
78
+ console.log(`✓ ${safeFileName}`);
79
+ }
80
+
81
+ // 마크다운 내 경로를 상대경로로 변경
82
+ const originalPath = imageFile.filename;
83
+ const relativePath = `./${config.imageDir}/${safeFileName}`;
84
+
85
+ // Notion이 URL 인코딩하는 방식: 각 경로 부분을 개별적으로 인코딩
86
+ const pathParts = originalPath.split('/');
87
+ const encodedPath = pathParts.map(part => encodeURIComponent(part)).join('/');
88
+
89
+ // 정규식 특수문자 이스케이프 함수
90
+ const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+
92
+ // 모든 가능한 형태의 경로를 교체
93
+ processedMarkdown = processedMarkdown
94
+ .replace(new RegExp(`\\(${escapeRegex(originalPath)}\\)`, 'g'), `(${relativePath})`)
95
+ .replace(new RegExp(`\\(${escapeRegex(encodedPath)}\\)`, 'g'), `(${relativePath})`);
96
+
97
+ } catch (error) {
98
+ if (config.verbose) {
99
+ const errorMsg = error instanceof Error ? error.message : '알 수 없는 오류';
100
+ console.error(`✗ ${imageFile.filename}: ${errorMsg}`);
101
+ }
102
+ }
103
+ }
104
+
105
+ // 5. 마크다운 파일 저장 (제목 폴더 안에)
106
+ const filename = `${baseFilename}.md`;
107
+ const filePath = await saveMarkdownFile(pageDir, filename, processedMarkdown);
108
+
109
+ // 6. 결과 출력
110
+ console.log('');
111
+ logger.success('Conversion complete!');
112
+ console.log('');
113
+ console.log(`📁 Folder: ${path.relative(process.cwd(), pageDir)}`);
114
+ console.log(`📄 Markdown: ${filename}`);
115
+
116
+ if (result.imageFiles.length > 0) {
117
+ console.log(`🖼️ Images: ${config.imageDir}/ (${result.imageFiles.length} files)`);
118
+ }
119
+
120
+ console.log('');
121
+
122
+ } catch (error) {
123
+ if (error instanceof Error) {
124
+ logger.error(error.message);
125
+ } else {
126
+ logger.error('An unknown error occurred.');
127
+ }
128
+ process.exit(1);
129
+ } finally {
130
+ // 임시 디렉토리 정리
131
+ try {
132
+ await fs.rm(tempDir, { recursive: true, force: true });
133
+ } catch {
134
+ // 무시
135
+ }
136
+ }
137
+ }
package/src/config.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { config as dotenvConfig } from 'dotenv';
2
+ import { existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ // 환경 변수 로드
6
+ dotenvConfig();
7
+
8
+ export interface NotionConfig {
9
+ tokenV2: string;
10
+ fileToken: string;
11
+ }
12
+
13
+ export interface ConverterOptions {
14
+ output: string;
15
+ imageDir: string;
16
+ filename?: string;
17
+ verbose: boolean;
18
+ }
19
+
20
+ export interface FullConfig extends NotionConfig, ConverterOptions {}
21
+
22
+ /**
23
+ * Notion 인증 설정 가져오기
24
+ */
25
+ export function getNotionConfig(): NotionConfig {
26
+ const tokenV2 = process.env.TOKEN_V2 || '';
27
+ const fileToken = process.env.FILE_TOKEN || '';
28
+
29
+ if (!tokenV2 || !fileToken) {
30
+ throw new Error(
31
+ 'Notion 토큰이 설정되지 않았습니다.\n' +
32
+ '.env 파일에 TOKEN_V2와 FILE_TOKEN을 설정해주세요.\n' +
33
+ '자세한 내용은 .env.example을 참고하세요.'
34
+ );
35
+ }
36
+
37
+ return { tokenV2, fileToken };
38
+ }
39
+
40
+ /**
41
+ * 전체 설정 생성
42
+ */
43
+ export function createConfig(options: ConverterOptions): FullConfig {
44
+ const notionConfig = getNotionConfig();
45
+
46
+ // 출력 디렉토리 절대 경로로 변환
47
+ const outputDir = resolve(process.cwd(), options.output);
48
+
49
+ return {
50
+ ...notionConfig,
51
+ ...options,
52
+ output: outputDir,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 디렉토리 존재 여부 확인
58
+ */
59
+ export function ensureDirectoryExists(dir: string): boolean {
60
+ return existsSync(dir);
61
+ }
@@ -0,0 +1,75 @@
1
+ import { NotionExporter } from 'notion-exporter';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import type { NotionConfig } from '../config.js';
5
+
6
+ export interface ExportResult {
7
+ markdown: string;
8
+ imageFiles: Array<{ filename: string; sourcePath: string }>;
9
+ }
10
+
11
+ /**
12
+ * Notion 페이지를 마크다운으로 내보내기
13
+ */
14
+ export class NotionMarkdownExporter {
15
+ private exporter: NotionExporter;
16
+
17
+ constructor(config: NotionConfig) {
18
+ this.exporter = new NotionExporter(config.tokenV2, config.fileToken);
19
+ }
20
+
21
+ /**
22
+ * Notion URL에서 마크다운과 이미지 파일 가져오기
23
+ */
24
+ async exportWithImages(notionUrl: string, tempDir: string): Promise<ExportResult> {
25
+ try {
26
+ // 임시 디렉토리 생성
27
+ await fs.mkdir(tempDir, { recursive: true });
28
+
29
+ // getMdFiles로 마크다운과 이미지를 함께 다운로드
30
+ await this.exporter.getMdFiles(notionUrl, tempDir);
31
+
32
+ // 다운로드된 파일 목록 가져오기
33
+ const files = await fs.readdir(tempDir, { withFileTypes: true });
34
+
35
+ // 마크다운 파일 찾기
36
+ const mdFile = files.find(f => f.isFile() && f.name.endsWith('.md'));
37
+ if (!mdFile) {
38
+ throw new Error('Markdown file not found.');
39
+ }
40
+
41
+ // 마크다운 내용 읽기
42
+ const mdPath = path.join(tempDir, mdFile.name);
43
+ const markdown = await fs.readFile(mdPath, 'utf-8');
44
+
45
+ // 이미지 파일 목록 (마크다운이 아닌 파일들)
46
+ const imageFiles = files
47
+ .filter(f => f.isFile() && !f.name.endsWith('.md'))
48
+ .map(f => ({
49
+ filename: f.name,
50
+ sourcePath: path.join(tempDir, f.name),
51
+ }));
52
+
53
+ // 디렉토리 내 폴더도 확인 (Notion이 폴더 구조로 저장할 수 있음)
54
+ const dirs = files.filter(f => f.isDirectory());
55
+ for (const dir of dirs) {
56
+ const subFiles = await fs.readdir(path.join(tempDir, dir.name), { withFileTypes: true });
57
+ for (const subFile of subFiles) {
58
+ if (subFile.isFile() && !subFile.name.endsWith('.md')) {
59
+ imageFiles.push({
60
+ filename: path.join(dir.name, subFile.name),
61
+ sourcePath: path.join(tempDir, dir.name, subFile.name),
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ return { markdown, imageFiles };
68
+ } catch (error) {
69
+ if (error instanceof Error) {
70
+ throw new Error(`Failed to fetch Notion page: ${error.message}`);
71
+ }
72
+ throw new Error('Failed to fetch Notion page.');
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,118 @@
1
+ import axios from 'axios';
2
+ import { createWriteStream, promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ export interface ImageProcessResult {
7
+ markdown: string;
8
+ downloadedImages: string[];
9
+ failedImages: string[];
10
+ }
11
+
12
+ export interface ProcessorOptions {
13
+ outputDir: string;
14
+ imageDir: string;
15
+ verbose: boolean;
16
+ }
17
+
18
+ /**
19
+ * 이미지 다운로드 및 마크다운 경로 변환 처리
20
+ */
21
+ export class ImageProcessor {
22
+ private options: ProcessorOptions;
23
+ private imageOutputDir: string;
24
+
25
+ constructor(options: ProcessorOptions) {
26
+ this.options = options;
27
+ this.imageOutputDir = path.join(options.outputDir, options.imageDir);
28
+ }
29
+
30
+ /**
31
+ * 이미지 디렉토리 생성
32
+ */
33
+ async ensureImageDirectory(): Promise<void> {
34
+ await fs.mkdir(this.imageOutputDir, { recursive: true });
35
+ }
36
+
37
+ /**
38
+ * 단일 이미지 다운로드
39
+ */
40
+ private async downloadImage(url: string): Promise<string> {
41
+ const response = await axios({
42
+ url,
43
+ method: 'GET',
44
+ responseType: 'stream',
45
+ timeout: 30000, // 30초 타임아웃
46
+ });
47
+
48
+ const extension = response.headers['content-type']?.split('/')[1] || 'png';
49
+ const fileName = `${uuidv4().slice(0, 8)}.${extension}`;
50
+ const filePath = path.join(this.imageOutputDir, fileName);
51
+
52
+ const writer = response.data.pipe(createWriteStream(filePath));
53
+
54
+ return new Promise((resolve, reject) => {
55
+ writer.on('finish', () => resolve(fileName));
56
+ writer.on('error', reject);
57
+ });
58
+ }
59
+
60
+ /**
61
+ * 마크다운에서 이미지 URL 추출
62
+ */
63
+ private extractImageUrls(markdown: string): string[] {
64
+ const imageUrlRegex = /!\[.*?\]\((https:\/\/[^)]+)\)/g;
65
+ const matches = [...markdown.matchAll(imageUrlRegex)];
66
+ return matches.map(match => match[1]);
67
+ }
68
+
69
+ /**
70
+ * 마크다운 내 이미지 처리
71
+ */
72
+ async processMarkdown(markdown: string): Promise<ImageProcessResult> {
73
+ await this.ensureImageDirectory();
74
+
75
+ const imageUrls = this.extractImageUrls(markdown);
76
+
77
+ if (this.options.verbose) {
78
+ console.log(`\n총 ${imageUrls.length}개의 이미지 발견`);
79
+ console.log(`이미지 다운로드 시작...\n`);
80
+ }
81
+
82
+ let processedMarkdown = markdown;
83
+ const downloadedImages: string[] = [];
84
+ const failedImages: string[] = [];
85
+
86
+ // 이미지 다운로드 (순차 처리)
87
+ for (const url of imageUrls) {
88
+ try {
89
+ if (this.options.verbose) {
90
+ console.log(`다운로드 중: ${url}`);
91
+ }
92
+
93
+ const fileName = await this.downloadImage(url);
94
+ const relativePath = `./${this.options.imageDir}/${fileName}`;
95
+
96
+ // 마크다운 내 URL 교체
97
+ processedMarkdown = processedMarkdown.replaceAll(url, relativePath);
98
+ downloadedImages.push(fileName);
99
+
100
+ if (this.options.verbose) {
101
+ console.log(`✓ 저장됨: ${relativePath}\n`);
102
+ }
103
+ } catch (error) {
104
+ failedImages.push(url);
105
+ if (this.options.verbose) {
106
+ const errorMsg = error instanceof Error ? error.message : '알 수 없는 오류';
107
+ console.error(`✗ 실패: ${url}\n ${errorMsg}\n`);
108
+ }
109
+ }
110
+ }
111
+
112
+ return {
113
+ markdown: processedMarkdown,
114
+ downloadedImages,
115
+ failedImages,
116
+ };
117
+ }
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { mdCommand } from './commands/md.js';
5
+ import { debugCommand } from './commands/debug.js';
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name('nconv')
11
+ .description('CLI tool for converting Notion pages to blog-ready markdown')
12
+ .version('1.0.0');
13
+
14
+ program
15
+ .command('md <url>')
16
+ .description('Convert a Notion page to markdown')
17
+ .option('-o, --output <dir>', 'Output directory', './output')
18
+ .option('-i, --image-dir <dir>', 'Image folder name (relative to output)', 'images')
19
+ .option('-f, --filename <name>', '출력 파일명 (확장자 제외 또는 포함)')
20
+ .option('-v, --verbose', 'Enable verbose logging', false)
21
+ .action(async (url: string, options) => {
22
+ await mdCommand(url, {
23
+ output: options.output,
24
+ imageDir: options.imageDir,
25
+ filename: options.filename,
26
+ verbose: options.verbose,
27
+ });
28
+ });
29
+
30
+ // 개발 환경에서만 debug 명령어를 활성화
31
+ if (process.env.NODE_ENV !== 'production') {
32
+ program
33
+ .command('debug <url>')
34
+ .description('Debug: Output raw markdown and image URLs')
35
+ .option('-o, --output <dir>', '출력 디렉토리', './output')
36
+ .option('-i, --image-dir <dir>', 'Image folder name', 'images')
37
+ .option('-v, --verbose', 'Enable verbose logging', false)
38
+ .action(async (url: string, options) => {
39
+ await debugCommand(url, {
40
+ output: options.output,
41
+ imageDir: options.imageDir,
42
+ filename: '',
43
+ verbose: options.verbose,
44
+ });
45
+ });
46
+ }
47
+
48
+ program.parse();
@@ -0,0 +1,69 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import slugify from 'slugify';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ /**
7
+ * Notion URL에서 페이지 제목 추출 (간단한 버전)
8
+ */
9
+ export function extractTitleFromUrl(notionUrl: string): string | null {
10
+ try {
11
+ const url = new URL(notionUrl);
12
+ const pathname = url.pathname;
13
+
14
+ // /Page-Title-abc123 형식에서 제목 부분 추출
15
+ const match = pathname.match(/\/([^/]+)-[a-f0-9]{32}$/i);
16
+ if (match) {
17
+ const title = match[1].replace(/-/g, ' ');
18
+ return title;
19
+ }
20
+
21
+ return null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 안전한 파일명 생성
29
+ */
30
+ export function generateSafeFilename(title: string | null, extension: string = 'md'): string {
31
+ if (title) {
32
+ const slug = slugify(title, {
33
+ lower: true,
34
+ strict: true,
35
+ locale: 'ko',
36
+ });
37
+
38
+ if (slug.length > 0) {
39
+ return extension ? `${slug}.${extension}` : slug;
40
+ }
41
+ }
42
+
43
+ // 제목이 없거나 slugify 실패 시 UUID 사용
44
+ const hash = uuidv4().slice(0, 8);
45
+ return extension ? `notion-export-${hash}.${extension}` : `notion-export-${hash}`;
46
+ }
47
+
48
+ /**
49
+ * 마크다운 파일 저장
50
+ */
51
+ export async function saveMarkdownFile(
52
+ outputDir: string,
53
+ filename: string,
54
+ content: string
55
+ ): Promise<string> {
56
+ await fs.mkdir(outputDir, { recursive: true });
57
+
58
+ const filePath = path.join(outputDir, filename);
59
+ await fs.writeFile(filePath, content, 'utf-8');
60
+
61
+ return filePath;
62
+ }
63
+
64
+ /**
65
+ * 파일 경로를 상대 경로로 변환
66
+ */
67
+ export function toRelativePath(from: string, to: string): string {
68
+ return path.relative(from, to);
69
+ }
@@ -0,0 +1,37 @@
1
+ import chalk from 'chalk';
2
+ import ora, { Ora } from 'ora';
3
+
4
+ /**
5
+ * 성공 메시지 출력
6
+ */
7
+ export function success(message: string): void {
8
+ console.log(chalk.green('✓'), message);
9
+ }
10
+
11
+ /**
12
+ * 에러 메시지 출력
13
+ */
14
+ export function error(message: string): void {
15
+ console.error(chalk.red('✗'), message);
16
+ }
17
+
18
+ /**
19
+ * 정보 메시지 출력
20
+ */
21
+ export function info(message: string): void {
22
+ console.log(chalk.blue('ℹ'), message);
23
+ }
24
+
25
+ /**
26
+ * 경고 메시지 출력
27
+ */
28
+ export function warn(message: string): void {
29
+ console.log(chalk.yellow('⚠'), message);
30
+ }
31
+
32
+ /**
33
+ * 스피너 생성
34
+ */
35
+ export function spinner(text: string): Ora {
36
+ return ora(text).start();
37
+ }