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.
- package/.claude/settings.local.json +12 -0
- package/.env.example +4 -0
- package/README.en.md +200 -0
- package/README.md +196 -0
- package/bin/nconv.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +285 -0
- package/dist/index.js.map +1 -0
- package/nconv-cli-1.0.0.tgz +0 -0
- package/package.json +34 -0
- package/src/commands/debug.ts +44 -0
- package/src/commands/md.ts +137 -0
- package/src/config.ts +61 -0
- package/src/core/exporter.ts +75 -0
- package/src/core/image-processor.ts +118 -0
- package/src/index.ts +48 -0
- package/src/utils/file.ts +69 -0
- package/src/utils/logger.ts +37 -0
- package/tsconfig.json +19 -0
- package/tsup.config.ts +11 -0
package/.env.example
ADDED
package/README.en.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
<p align="right">
|
|
2
|
+
<a href="./README.md">한국어</a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# nconv-cli (Notion Convertor CLI)
|
|
6
|
+
|
|
7
|
+
> A CLI tool that converts Notion pages into blog-ready Markdown
|
|
8
|
+
> (with automatic image extraction and path normalization)
|
|
9
|
+
|
|
10
|
+
nconv-cli converts public Notion pages into Markdown
|
|
11
|
+
and extracts images into local files, organizing everything
|
|
12
|
+
in a blog-friendly directory structure.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- 🚀 Convert public Notion pages into blog-ready Markdown
|
|
17
|
+
- 🖼️ Automatically download images and rewrite them as relative paths
|
|
18
|
+
- 📁 Output organized by post-level directory structure
|
|
19
|
+
- 🎨 Simple and intuitive CLI interface
|
|
20
|
+
|
|
21
|
+
## Problems with Existing Notion → Markdown Workflows
|
|
22
|
+
|
|
23
|
+
When moving content written in Notion to a blog,
|
|
24
|
+
simple copy & paste or the built-in Markdown export often causes issues.
|
|
25
|
+
|
|
26
|
+
- **Copy & Paste**
|
|
27
|
+
- Text is converted into Markdown-like syntax
|
|
28
|
+
- Images remain linked to Notion CDN URLs
|
|
29
|
+
- Image access frequently breaks with `Access Denied` errors
|
|
30
|
+
|
|
31
|
+
- **Notion Markdown Export**
|
|
32
|
+
- Requires manual download and extraction of exported files
|
|
33
|
+
- Difficult to organize content by individual posts
|
|
34
|
+
|
|
35
|
+
As a result, publishing to a blog usually involves
|
|
36
|
+
manually downloading images, renaming files, fixing paths,
|
|
37
|
+
and reorganizing directory structures.
|
|
38
|
+
|
|
39
|
+
| Method | Image Handling | Blog Usability |
|
|
40
|
+
|------|---------------|----------------|
|
|
41
|
+
| Copy & Paste | ❌ Dependent on Notion CDN | ❌ Broken images |
|
|
42
|
+
| Notion Export | ⚠️ Manual organization required | ⚠️ Inconvenient |
|
|
43
|
+
| **nconv-cli** | ✅ Local image extraction | ✅ Ready to publish |
|
|
44
|
+
|
|
45
|
+
## Recommended For
|
|
46
|
+
|
|
47
|
+
- Developers who write in Notion and publish Markdown posts to
|
|
48
|
+
Velog, Tistory, GitHub Pages, Hugo, or similar platforms
|
|
49
|
+
- Anyone who has experienced broken images due to Notion CDN issues
|
|
50
|
+
- Users looking to migrate Notion content into tools like Obsidian
|
|
51
|
+
|
|
52
|
+
## Environment
|
|
53
|
+
|
|
54
|
+
- **Node.js**: v20 or higher (npm v10 or higher)
|
|
55
|
+
- **TypeScript**: v5 or higher
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Install dependencies
|
|
61
|
+
npm install
|
|
62
|
+
|
|
63
|
+
# Build
|
|
64
|
+
npm run build
|
|
65
|
+
|
|
66
|
+
# Install CLI locally
|
|
67
|
+
npm link
|
|
68
|
+
````
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
Set your Notion authentication tokens in the `.env` file.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Create .env file
|
|
76
|
+
cp .env.example .env
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Edit `.env` and set the following values:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
TOKEN_V2=your_token_v2_here
|
|
83
|
+
FILE_TOKEN=your_file_token_here
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### How to Find Notion Tokens
|
|
87
|
+
|
|
88
|
+
1. Log in to [notion.so](https://notion.so)
|
|
89
|
+
2. Open browser developer tools (F12)
|
|
90
|
+
3. Application > Cookies > notion.so
|
|
91
|
+
4. Copy `token_v2` → paste into `.env` as `TOKEN_V2`
|
|
92
|
+
5. Copy `file_token` → paste into `.env` as `FILE_TOKEN`
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
### Basic Usage
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
nconv md <notion-url>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Examples
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Default output (saved to ./output)
|
|
106
|
+
nconv md "https://notion.so/My-Page-abc123"
|
|
107
|
+
|
|
108
|
+
# Specify output directory
|
|
109
|
+
nconv md "https://notion.so/My-Page-abc123" -o ./blog-posts
|
|
110
|
+
|
|
111
|
+
# Custom filename
|
|
112
|
+
nconv md "https://notion.so/My-Page-abc123" -f "my-article"
|
|
113
|
+
|
|
114
|
+
# Verbose logging
|
|
115
|
+
nconv md "https://notion.so/My-Page-abc123" -v
|
|
116
|
+
|
|
117
|
+
# All options
|
|
118
|
+
nconv md "https://notion.so/My-Page-abc123" -o ./blog -i assets -f "article-1" -v
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Options
|
|
122
|
+
|
|
123
|
+
| Option | Short | Description | Default |
|
|
124
|
+
| ------------------- | ----- | ------------------------------------------- | ------------------ |
|
|
125
|
+
| `--output <dir>` | `-o` | Output directory | `./output` |
|
|
126
|
+
| `--image-dir <dir>` | `-i` | Image directory (relative to output) | `images` |
|
|
127
|
+
| `--filename <name>` | `-f` | Output filename (with or without extension) | Extracted from URL |
|
|
128
|
+
| `--verbose` | `-v` | Enable verbose logging | `false` |
|
|
129
|
+
|
|
130
|
+
## Output Structure
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
output/
|
|
134
|
+
├── my-article-folder/
|
|
135
|
+
│ ├── my-article.md
|
|
136
|
+
│ └── images/
|
|
137
|
+
│ ├── abc12345.png
|
|
138
|
+
│ ├── def67890.jpg
|
|
139
|
+
│ └── ...
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Image paths inside the Markdown file are converted to relative paths:
|
|
143
|
+
|
|
144
|
+
```md
|
|
145
|
+

|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Libraries
|
|
149
|
+
|
|
150
|
+
### Main Libraries
|
|
151
|
+
|
|
152
|
+
* **notion-exporter**: Export Notion pages to Markdown
|
|
153
|
+
* **commander**: CLI command definition and parsing
|
|
154
|
+
* **axios**: HTTP client (image downloads)
|
|
155
|
+
* **dotenv**: Environment variable management
|
|
156
|
+
* **chalk**: Terminal output styling
|
|
157
|
+
* **ora**: Terminal spinner for progress display
|
|
158
|
+
* **slugify**: Convert strings to URL-friendly slugs
|
|
159
|
+
* **uuid**: Generate unique IDs
|
|
160
|
+
|
|
161
|
+
### Open Source Licenses
|
|
162
|
+
|
|
163
|
+
* **nconv-cli**: [ISC License](LICENSE)
|
|
164
|
+
* This project uses multiple open source libraries,
|
|
165
|
+
each distributed under its respective license.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Development mode (watch files)
|
|
171
|
+
npm run dev
|
|
172
|
+
|
|
173
|
+
# Build
|
|
174
|
+
npm run build
|
|
175
|
+
|
|
176
|
+
# Local testing
|
|
177
|
+
npm link
|
|
178
|
+
nconv md "https://notion.so/test-page"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Project Structure
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
nconv/
|
|
185
|
+
├── src/
|
|
186
|
+
│ ├── index.ts
|
|
187
|
+
│ ├── config.ts
|
|
188
|
+
│ ├── commands/
|
|
189
|
+
│ │ └── md.ts
|
|
190
|
+
│ ├── core/
|
|
191
|
+
│ │ ├── exporter.ts
|
|
192
|
+
│ │ └── image-processor.ts
|
|
193
|
+
│ └── utils/
|
|
194
|
+
│ ├── file.ts
|
|
195
|
+
│ └── logger.ts
|
|
196
|
+
├── bin/
|
|
197
|
+
│ └── nconv.js
|
|
198
|
+
├── package.json
|
|
199
|
+
├── tsconfig.json
|
|
200
|
+
└── tsup.config.ts
|
package/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<p align="right">
|
|
2
|
+
<a href="./README.en.md">English</a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# nconv-cli (Notion Convertor CLI)
|
|
6
|
+
|
|
7
|
+
> Notion 글을 블로그에 바로 올릴 수 있는 마크다운으로 변환하는 CLI
|
|
8
|
+
> (이미지 자동 추출 및 경로 정리 포함)
|
|
9
|
+
|
|
10
|
+
Notion 퍼블릭 페이지를 마크다운으로 변환하고
|
|
11
|
+
이미지 파일을 로컬로 추출해 블로그 친화적인 구조로 정리해주는 CLI 도구입니다.
|
|
12
|
+
|
|
13
|
+
## 특징
|
|
14
|
+
|
|
15
|
+
- 🚀 Notion 퍼블릭 페이지를 블로그용 마크다운으로 바로 변환
|
|
16
|
+
- 🖼️ 이미지 파일을 로컬로 자동 다운로드 및 상대경로로 변환
|
|
17
|
+
- 📁 게시글 단위로 정리된 출력 디렉토리 구조
|
|
18
|
+
- 🎨 간단한 CLI 인터페이스
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## 기존 Notion -> Markdown 변환의 문제점
|
|
23
|
+
|
|
24
|
+
Notion에서 작성한 글을 블로그에 옮길 때,
|
|
25
|
+
단순한 복사/붙여넣기나 기본 Markdown export는 포스팅에 불편함이 있습니다.
|
|
26
|
+
|
|
27
|
+
* 복사 & 붙여넣기 방식의 문제점
|
|
28
|
+
* 마크다운 식으로 텍스트는 변환되지만,
|
|
29
|
+
* 이미지는 Notion CDN URL로 유지되며
|
|
30
|
+
* Access Denied 로 이미지 접근이 깨지는 경우가 많습니다
|
|
31
|
+
|
|
32
|
+
* Notion Markdown Export 방식의 문제점
|
|
33
|
+
* 직접 다운받아서 수동으로 저장 후 압축을 해제해야하며,
|
|
34
|
+
* 게시글 단위로 정리하기 어렵습니다
|
|
35
|
+
|
|
36
|
+
결과적으로 블로그에 게시하려면 이미지 개별 다운로드,
|
|
37
|
+
파일명 및 경로 수정, 폴더 구조 재정리가 필요합니다.
|
|
38
|
+
|
|
39
|
+
| 방식 | 이미지 관리 | 블로그 사용성 |
|
|
40
|
+
|------|------------|---------------|
|
|
41
|
+
| 복사 & 붙여넣기 | ❌ Notion CDN 의존 | ❌ 이미지 깨짐 |
|
|
42
|
+
| Notion Export | ⚠️ 수동 정리 필요 | ⚠️ 번거로움 |
|
|
43
|
+
| **nconv-cli** | ✅ 로컬 자동 추출 | ✅ 즉시 사용 가능 |
|
|
44
|
+
|
|
45
|
+
## 이런 분들에게 추천합니다
|
|
46
|
+
|
|
47
|
+
- Notion으로 글을 쓰고, Velog / Tistory / GitHub Pages / Hugo 등의 플랫폼에 마크다운으로 포스팅을 하시는 분
|
|
48
|
+
- Notion 이미지 CDN 문제로 글이 깨져본 경험이 있는 분
|
|
49
|
+
- 노션 문서 체계를 옵시디언 등으로 마이그레이션하기 원하시는 분
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## 환경
|
|
53
|
+
- **Node.js**: v20 이상 (npm v10 이상)
|
|
54
|
+
- **TypeScript**: v5 이상
|
|
55
|
+
|
|
56
|
+
## 설치
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# 의존성 설치
|
|
60
|
+
npm install
|
|
61
|
+
|
|
62
|
+
# 빌드
|
|
63
|
+
npm run build
|
|
64
|
+
|
|
65
|
+
# 로컬에 CLI 설치
|
|
66
|
+
npm link
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 설정
|
|
70
|
+
|
|
71
|
+
`.env` 파일에 Notion 인증 토큰을 설정해야 합니다.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# .env 파일 생성
|
|
75
|
+
cp .env.example .env
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`.env` 파일을 열고 아래 값들을 설정하세요:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
TOKEN_V2=your_token_v2_here
|
|
82
|
+
FILE_TOKEN=your_file_token_here
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Notion env 값 확인하는 방법
|
|
86
|
+
|
|
87
|
+
1. [notion.so](https://notion.so)에 로그인
|
|
88
|
+
2. 브라우저 개발자 도구 열기 (F12)
|
|
89
|
+
3. Application > Cookies > notion.so
|
|
90
|
+
4. `token_v2` 값 복사 → `.env`의 `TOKEN_V2`에 붙여넣기
|
|
91
|
+
5. `file_token` 값 복사 → `.env`의 `FILE_TOKEN`에 붙여넣기
|
|
92
|
+
|
|
93
|
+
## 사용법
|
|
94
|
+
|
|
95
|
+
### 기본 사용법
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
nconv md <notion-url>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 예시
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# 기본 사용 (./output 폴더에 저장)
|
|
105
|
+
nconv md "https://notion.so/My-Page-abc123"
|
|
106
|
+
|
|
107
|
+
# 출력 디렉토리 지정
|
|
108
|
+
nconv md "https://notion.so/My-Page-abc123" -o ./blog-posts
|
|
109
|
+
|
|
110
|
+
# 커스텀 파일명
|
|
111
|
+
nconv md "https://notion.so/My-Page-abc123" -f "my-article"
|
|
112
|
+
|
|
113
|
+
# 상세 로그 출력
|
|
114
|
+
nconv md "https://notion.so/My-Page-abc123" -v
|
|
115
|
+
|
|
116
|
+
# 모든 옵션 사용
|
|
117
|
+
nconv md "https://notion.so/My-Page-abc123" -o ./blog -i assets -f "article-1" -v
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## 옵션
|
|
121
|
+
|
|
122
|
+
| 옵션 | 단축 | 설명 | 기본값 |
|
|
123
|
+
|------|------|------|--------|
|
|
124
|
+
| `--output <dir>` | `-o` | 출력 디렉토리 | `./output` |
|
|
125
|
+
| `--image-dir <dir>` | `-i` | 이미지 폴더명 (output 기준 상대경로) | `images` |
|
|
126
|
+
| `--filename <name>` | `-f` | 출력 파일명 (확장자 제외 또는 포함) | URL에서 자동 추출 |
|
|
127
|
+
| `--verbose` | `-v` | 상세 로그 출력 | `false` |
|
|
128
|
+
|
|
129
|
+
## 출력 구조
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
output/
|
|
133
|
+
├── my-article-folder/
|
|
134
|
+
├── my-article.md # 마크다운 파일
|
|
135
|
+
└── images/
|
|
136
|
+
├── abc12345.png # 다운로드된 이미지들
|
|
137
|
+
├── def67890.jpg
|
|
138
|
+
└── ...
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
마크다운 파일 내 이미지 경로는 상대경로로 변환됩니다:
|
|
142
|
+
|
|
143
|
+
```markdown
|
|
144
|
+

|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 라이브러리
|
|
148
|
+
### 주요 라이브러리
|
|
149
|
+
- **notion-exporter**: Notion 페이지를 마크다운으로 내보내는 라이브러리
|
|
150
|
+
- **commander**: CLI 명령 정의 및 파싱 도구
|
|
151
|
+
- **axios**: HTTP 클라이언트 (이미지 다운로드 등)
|
|
152
|
+
- **dotenv**: 환경 변수 관리
|
|
153
|
+
- **chalk**: 터미널 출력 색상화
|
|
154
|
+
- **ora**: 터미널 스피너 (진행 상황 표시)
|
|
155
|
+
- **slugify**: 문자열을 URL 슬러그로 변환
|
|
156
|
+
- **uuid**: 고유 ID 생성
|
|
157
|
+
|
|
158
|
+
### 오픈소스 라이선스
|
|
159
|
+
- **nconv-cli**: [ISC License](LICENSE)
|
|
160
|
+
- 본 프로젝트는 위에 명시된 주요 라이브러리 외에도 다수의 오픈소스 라이브러리를 사용하며, 각 라이브러리는 해당 라이선스 정책을 따릅니다.
|
|
161
|
+
|
|
162
|
+
## 개발
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# 개발 모드 (파일 변경 감지)
|
|
166
|
+
npm run dev
|
|
167
|
+
|
|
168
|
+
# 빌드
|
|
169
|
+
npm run build
|
|
170
|
+
|
|
171
|
+
# 로컬 테스트
|
|
172
|
+
npm link
|
|
173
|
+
nconv md "https://notion.so/test-page"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## 프로젝트 구조
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
nconv/
|
|
180
|
+
├── src/
|
|
181
|
+
│ ├── index.ts # CLI 진입점
|
|
182
|
+
│ ├── config.ts # 설정 관리
|
|
183
|
+
│ ├── commands/
|
|
184
|
+
│ │ └── md.ts # md 명령어
|
|
185
|
+
│ ├── core/
|
|
186
|
+
│ │ ├── exporter.ts # Notion 내보내기
|
|
187
|
+
│ │ └── image-processor.ts # 이미지 처리
|
|
188
|
+
│ └── utils/
|
|
189
|
+
│ ├── file.ts # 파일 유틸리티
|
|
190
|
+
│ └── logger.ts # 로깅
|
|
191
|
+
├── bin/
|
|
192
|
+
│ └── nconv.js # 실행 파일
|
|
193
|
+
├── package.json
|
|
194
|
+
├── tsconfig.json
|
|
195
|
+
└── tsup.config.ts
|
|
196
|
+
```
|
package/bin/nconv.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { config as dotenvConfig } from "dotenv";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
dotenvConfig();
|
|
10
|
+
function getNotionConfig() {
|
|
11
|
+
const tokenV2 = process.env.TOKEN_V2 || "";
|
|
12
|
+
const fileToken = process.env.FILE_TOKEN || "";
|
|
13
|
+
if (!tokenV2 || !fileToken) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"Notion \uD1A0\uD070\uC774 \uC124\uC815\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.\n.env \uD30C\uC77C\uC5D0 TOKEN_V2\uC640 FILE_TOKEN\uC744 \uC124\uC815\uD574\uC8FC\uC138\uC694.\n\uC790\uC138\uD55C \uB0B4\uC6A9\uC740 .env.example\uC744 \uCC38\uACE0\uD558\uC138\uC694."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return { tokenV2, fileToken };
|
|
19
|
+
}
|
|
20
|
+
function createConfig(options) {
|
|
21
|
+
const notionConfig = getNotionConfig();
|
|
22
|
+
const outputDir = resolve(process.cwd(), options.output);
|
|
23
|
+
return {
|
|
24
|
+
...notionConfig,
|
|
25
|
+
...options,
|
|
26
|
+
output: outputDir
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/core/exporter.ts
|
|
31
|
+
import { NotionExporter } from "notion-exporter";
|
|
32
|
+
import { promises as fs } from "fs";
|
|
33
|
+
import path from "path";
|
|
34
|
+
var NotionMarkdownExporter = class {
|
|
35
|
+
constructor(config) {
|
|
36
|
+
this.exporter = new NotionExporter(config.tokenV2, config.fileToken);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Notion URL에서 마크다운과 이미지 파일 가져오기
|
|
40
|
+
*/
|
|
41
|
+
async exportWithImages(notionUrl, tempDir) {
|
|
42
|
+
try {
|
|
43
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
44
|
+
await this.exporter.getMdFiles(notionUrl, tempDir);
|
|
45
|
+
const files = await fs.readdir(tempDir, { withFileTypes: true });
|
|
46
|
+
const mdFile = files.find((f) => f.isFile() && f.name.endsWith(".md"));
|
|
47
|
+
if (!mdFile) {
|
|
48
|
+
throw new Error("Markdown file not found.");
|
|
49
|
+
}
|
|
50
|
+
const mdPath = path.join(tempDir, mdFile.name);
|
|
51
|
+
const markdown = await fs.readFile(mdPath, "utf-8");
|
|
52
|
+
const imageFiles = files.filter((f) => f.isFile() && !f.name.endsWith(".md")).map((f) => ({
|
|
53
|
+
filename: f.name,
|
|
54
|
+
sourcePath: path.join(tempDir, f.name)
|
|
55
|
+
}));
|
|
56
|
+
const dirs = files.filter((f) => f.isDirectory());
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
const subFiles = await fs.readdir(path.join(tempDir, dir.name), { withFileTypes: true });
|
|
59
|
+
for (const subFile of subFiles) {
|
|
60
|
+
if (subFile.isFile() && !subFile.name.endsWith(".md")) {
|
|
61
|
+
imageFiles.push({
|
|
62
|
+
filename: path.join(dir.name, subFile.name),
|
|
63
|
+
sourcePath: path.join(tempDir, dir.name, subFile.name)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { markdown, imageFiles };
|
|
69
|
+
} catch (error2) {
|
|
70
|
+
if (error2 instanceof Error) {
|
|
71
|
+
throw new Error(`Failed to fetch Notion page: ${error2.message}`);
|
|
72
|
+
}
|
|
73
|
+
throw new Error("Failed to fetch Notion page.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// src/utils/file.ts
|
|
79
|
+
import { promises as fs2 } from "fs";
|
|
80
|
+
import path2 from "path";
|
|
81
|
+
import slugify from "slugify";
|
|
82
|
+
import { v4 as uuidv4 } from "uuid";
|
|
83
|
+
function extractTitleFromUrl(notionUrl) {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(notionUrl);
|
|
86
|
+
const pathname = url.pathname;
|
|
87
|
+
const match = pathname.match(/\/([^/]+)-[a-f0-9]{32}$/i);
|
|
88
|
+
if (match) {
|
|
89
|
+
const title = match[1].replace(/-/g, " ");
|
|
90
|
+
return title;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function generateSafeFilename(title, extension = "md") {
|
|
98
|
+
if (title) {
|
|
99
|
+
const slug = slugify(title, {
|
|
100
|
+
lower: true,
|
|
101
|
+
strict: true,
|
|
102
|
+
locale: "ko"
|
|
103
|
+
});
|
|
104
|
+
if (slug.length > 0) {
|
|
105
|
+
return extension ? `${slug}.${extension}` : slug;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const hash = uuidv4().slice(0, 8);
|
|
109
|
+
return extension ? `notion-export-${hash}.${extension}` : `notion-export-${hash}`;
|
|
110
|
+
}
|
|
111
|
+
async function saveMarkdownFile(outputDir, filename, content) {
|
|
112
|
+
await fs2.mkdir(outputDir, { recursive: true });
|
|
113
|
+
const filePath = path2.join(outputDir, filename);
|
|
114
|
+
await fs2.writeFile(filePath, content, "utf-8");
|
|
115
|
+
return filePath;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/utils/logger.ts
|
|
119
|
+
import chalk from "chalk";
|
|
120
|
+
import ora from "ora";
|
|
121
|
+
function success(message) {
|
|
122
|
+
console.log(chalk.green("\u2713"), message);
|
|
123
|
+
}
|
|
124
|
+
function error(message) {
|
|
125
|
+
console.error(chalk.red("\u2717"), message);
|
|
126
|
+
}
|
|
127
|
+
function info(message) {
|
|
128
|
+
console.log(chalk.blue("\u2139"), message);
|
|
129
|
+
}
|
|
130
|
+
function spinner(text) {
|
|
131
|
+
return ora(text).start();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/commands/md.ts
|
|
135
|
+
import path3 from "path";
|
|
136
|
+
import { promises as fs3 } from "fs";
|
|
137
|
+
import os from "os";
|
|
138
|
+
async function mdCommand(notionUrl, options) {
|
|
139
|
+
const tempDir = path3.join(os.tmpdir(), `nconv-cli-${Date.now()}`);
|
|
140
|
+
try {
|
|
141
|
+
const config = createConfig(options);
|
|
142
|
+
if (config.verbose) {
|
|
143
|
+
info("Configuration loaded successfully");
|
|
144
|
+
console.log(` Output directory: ${config.output}
|
|
145
|
+
`);
|
|
146
|
+
}
|
|
147
|
+
const spinner2 = spinner("Fetching Notion page...");
|
|
148
|
+
const exporter = new NotionMarkdownExporter({
|
|
149
|
+
tokenV2: config.tokenV2,
|
|
150
|
+
fileToken: config.fileToken
|
|
151
|
+
});
|
|
152
|
+
let result;
|
|
153
|
+
try {
|
|
154
|
+
result = await exporter.exportWithImages(notionUrl, tempDir);
|
|
155
|
+
spinner2.succeed(`Notion page fetched (${result.imageFiles.length} images)`);
|
|
156
|
+
} catch (error2) {
|
|
157
|
+
spinner2.fail("Failed to fetch Notion page");
|
|
158
|
+
throw error2;
|
|
159
|
+
}
|
|
160
|
+
let baseFilename;
|
|
161
|
+
if (config.filename) {
|
|
162
|
+
baseFilename = config.filename.replace(/\.md$/, "");
|
|
163
|
+
} else {
|
|
164
|
+
const title = extractTitleFromUrl(notionUrl);
|
|
165
|
+
baseFilename = generateSafeFilename(title, "");
|
|
166
|
+
}
|
|
167
|
+
const pageDir = path3.join(config.output, baseFilename);
|
|
168
|
+
await fs3.mkdir(pageDir, { recursive: true });
|
|
169
|
+
if (config.verbose) {
|
|
170
|
+
console.log(`\u{1F4C1} \uCD9C\uB825 \uD3F4\uB354: ${path3.relative(process.cwd(), pageDir)}
|
|
171
|
+
`);
|
|
172
|
+
}
|
|
173
|
+
const imageOutputDir = path3.join(pageDir, config.imageDir);
|
|
174
|
+
await fs3.mkdir(imageOutputDir, { recursive: true });
|
|
175
|
+
if (config.verbose && result.imageFiles.length > 0) {
|
|
176
|
+
console.log(`Processing image files...
|
|
177
|
+
`);
|
|
178
|
+
}
|
|
179
|
+
let processedMarkdown = result.markdown;
|
|
180
|
+
for (const imageFile of result.imageFiles) {
|
|
181
|
+
try {
|
|
182
|
+
const originalFileName = path3.basename(imageFile.filename);
|
|
183
|
+
const safeFileName = originalFileName.replace(/\s+/g, "-");
|
|
184
|
+
const targetPath = path3.join(imageOutputDir, safeFileName);
|
|
185
|
+
await fs3.copyFile(imageFile.sourcePath, targetPath);
|
|
186
|
+
if (config.verbose) {
|
|
187
|
+
console.log(`\u2713 ${safeFileName}`);
|
|
188
|
+
}
|
|
189
|
+
const originalPath = imageFile.filename;
|
|
190
|
+
const relativePath = `./${config.imageDir}/${safeFileName}`;
|
|
191
|
+
const pathParts = originalPath.split("/");
|
|
192
|
+
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
|
193
|
+
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
194
|
+
processedMarkdown = processedMarkdown.replace(new RegExp(`\\(${escapeRegex(originalPath)}\\)`, "g"), `(${relativePath})`).replace(new RegExp(`\\(${escapeRegex(encodedPath)}\\)`, "g"), `(${relativePath})`);
|
|
195
|
+
} catch (error2) {
|
|
196
|
+
if (config.verbose) {
|
|
197
|
+
const errorMsg = error2 instanceof Error ? error2.message : "\uC54C \uC218 \uC5C6\uB294 \uC624\uB958";
|
|
198
|
+
console.error(`\u2717 ${imageFile.filename}: ${errorMsg}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const filename = `${baseFilename}.md`;
|
|
203
|
+
const filePath = await saveMarkdownFile(pageDir, filename, processedMarkdown);
|
|
204
|
+
console.log("");
|
|
205
|
+
success("Conversion complete!");
|
|
206
|
+
console.log("");
|
|
207
|
+
console.log(`\u{1F4C1} Folder: ${path3.relative(process.cwd(), pageDir)}`);
|
|
208
|
+
console.log(`\u{1F4C4} Markdown: ${filename}`);
|
|
209
|
+
if (result.imageFiles.length > 0) {
|
|
210
|
+
console.log(`\u{1F5BC}\uFE0F Images: ${config.imageDir}/ (${result.imageFiles.length} files)`);
|
|
211
|
+
}
|
|
212
|
+
console.log("");
|
|
213
|
+
} catch (error2) {
|
|
214
|
+
if (error2 instanceof Error) {
|
|
215
|
+
error(error2.message);
|
|
216
|
+
} else {
|
|
217
|
+
error("An unknown error occurred.");
|
|
218
|
+
}
|
|
219
|
+
process.exit(1);
|
|
220
|
+
} finally {
|
|
221
|
+
try {
|
|
222
|
+
await fs3.rm(tempDir, { recursive: true, force: true });
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/commands/debug.ts
|
|
229
|
+
import * as fs4 from "fs/promises";
|
|
230
|
+
import * as os2 from "os";
|
|
231
|
+
import * as path4 from "path";
|
|
232
|
+
async function debugCommand(notionUrl, options) {
|
|
233
|
+
let tempDir;
|
|
234
|
+
try {
|
|
235
|
+
const config = createConfig(options);
|
|
236
|
+
const exporter = new NotionMarkdownExporter({
|
|
237
|
+
tokenV2: config.tokenV2,
|
|
238
|
+
fileToken: config.fileToken
|
|
239
|
+
});
|
|
240
|
+
tempDir = path4.join(os2.tmpdir(), `notion-debug-${Date.now()}`);
|
|
241
|
+
console.log("Fetching Notion page...\n");
|
|
242
|
+
const { markdown, imageFiles } = await exporter.exportWithImages(notionUrl, tempDir);
|
|
243
|
+
console.log("=== Raw Markdown ===\n");
|
|
244
|
+
console.log(markdown.slice(0, 3e3));
|
|
245
|
+
console.log("\n... (truncated) ...\n");
|
|
246
|
+
console.log(`
|
|
247
|
+
=== Found Images (${imageFiles.length}) ===
|
|
248
|
+
`);
|
|
249
|
+
imageFiles.forEach((file, i) => {
|
|
250
|
+
console.log(`${i + 1}. ${file.filename} (\uC6D0\uBCF8 \uACBD\uB85C: ${file.sourcePath})`);
|
|
251
|
+
});
|
|
252
|
+
} catch (error2) {
|
|
253
|
+
console.error("Error:", error2);
|
|
254
|
+
} finally {
|
|
255
|
+
if (tempDir) {
|
|
256
|
+
await fs4.rm(tempDir, { recursive: true, force: true }).catch((err) => {
|
|
257
|
+
console.warn(`Error cleaning up temporary directory: ${err.message}`);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/index.ts
|
|
264
|
+
var program = new Command();
|
|
265
|
+
program.name("nconv").description("CLI tool for converting Notion pages to blog-ready markdown").version("1.0.0");
|
|
266
|
+
program.command("md <url>").description("Convert a Notion page to markdown").option("-o, --output <dir>", "Output directory", "./output").option("-i, --image-dir <dir>", "Image folder name (relative to output)", "images").option("-f, --filename <name>", "\uCD9C\uB825 \uD30C\uC77C\uBA85 (\uD655\uC7A5\uC790 \uC81C\uC678 \uB610\uB294 \uD3EC\uD568)").option("-v, --verbose", "Enable verbose logging", false).action(async (url, options) => {
|
|
267
|
+
await mdCommand(url, {
|
|
268
|
+
output: options.output,
|
|
269
|
+
imageDir: options.imageDir,
|
|
270
|
+
filename: options.filename,
|
|
271
|
+
verbose: options.verbose
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
if (process.env.NODE_ENV !== "production") {
|
|
275
|
+
program.command("debug <url>").description("Debug: Output raw markdown and image URLs").option("-o, --output <dir>", "\uCD9C\uB825 \uB514\uB809\uD1A0\uB9AC", "./output").option("-i, --image-dir <dir>", "Image folder name", "images").option("-v, --verbose", "Enable verbose logging", false).action(async (url, options) => {
|
|
276
|
+
await debugCommand(url, {
|
|
277
|
+
output: options.output,
|
|
278
|
+
imageDir: options.imageDir,
|
|
279
|
+
filename: "",
|
|
280
|
+
verbose: options.verbose
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
program.parse();
|
|
285
|
+
//# sourceMappingURL=index.js.map
|