@kadaliao/geektime-downloader 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/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # 极客时间专栏下载器
2
+
3
+ 一键批量下载极客时间专栏文章为 PDF 格式。支持通过 `npx` 直接运行,无需安装。
4
+
5
+ ## ✨ 特性
6
+
7
+ - 🚀 **零安装**:支持 `npx` 直接使用
8
+ - 📦 **批量下载**:自动获取整个专栏的所有文章
9
+ - 📄 **完整内容**:自动处理滚动容器,确保 PDF 包含完整内容
10
+ - 🔗 **智能合并**:自动将所有章节合并为一个 PDF,以专栏名称命名(可选)
11
+ - ⚙️ **灵活配置**:命令行参数或配置文件,任选其一
12
+ - 🎨 **友好界面**:彩色进度提示,实时显示下载状态
13
+
14
+ ## 🚀 快速开始
15
+
16
+ ## 🚀 使用方式
17
+
18
+ ### 方式一:命令行参数方式(推荐)
19
+
20
+ 直接通过命令行参数指定配置,适合一次性使用:
21
+
22
+ ```bash
23
+ npx @kadaliao/geektime-downloader \
24
+ --url "https://time.geekbang.org/column/article/200822" \
25
+ --cookie "你的cookie字符串"
26
+ ```
27
+
28
+ ### 方式二:配置文件方式
29
+
30
+ 创建配置文件后,直接运行命令即可,适合需要多次使用的情况:
31
+
32
+ 1. 在项目目录创建 `config.json`:
33
+
34
+ ```json
35
+ {
36
+ "cookie": "你的完整 cookie 字符串",
37
+ "columnUrl": "https://time.geekbang.org/column/article/200822"
38
+ }
39
+ ```
40
+
41
+ 2. 运行命令:
42
+
43
+ ```bash
44
+ npx @kadaliao/geektime-downloader
45
+ ```
46
+
47
+ ## 📖 使用说明
48
+
49
+ ### 获取 Cookie
50
+
51
+ 1. 浏览器登录极客时间
52
+ 2. 打开任意专栏文章
53
+ 3. 按 **F12** 打开开发者工具
54
+ 4. 切换到 **Network(网络)** 标签
55
+ 5. 刷新页面,找到任意请求
56
+ 6. 在 **Request Headers** 中复制完整 `Cookie` 值
57
+
58
+ ### 命令行选项
59
+
60
+ 你可以通过以下两种方式运行本工具:
61
+
62
+ 1. 使用 `npx`(推荐,无需安装):
63
+ ```bash
64
+ npx @kadaliao/geektime-downloader [选项]
65
+ ```
66
+
67
+ 2. 或者全局安装后使用:
68
+ ```bash
69
+ # 安装
70
+ npm install -g @kadaliao/geektime-downloader
71
+
72
+ # 使用
73
+ geektime-dl [选项]
74
+ ```
75
+
76
+ 可用选项:
77
+ ```
78
+ -V, --version 显示版本号
79
+ -u, --url <url> 专栏文章URL(任意一篇)
80
+ -c, --cookie <cookie> Cookie字符串(用于认证)
81
+ -o, --output <dir> 输出目录 (默认: "./downloads")
82
+ --headless <boolean> 无头模式 (默认: true)
83
+ --concurrency <number> 并发下载数量 (默认: 5)
84
+ --delay <ms> 每篇文章间延迟(ms) (默认: 2000)
85
+ --dry-run 预览模式,只显示文章列表
86
+ --limit <number> 限制下载数量(测试用)
87
+ --no-merge 禁用PDF合并(默认会合并所有文章为一个PDF)
88
+ -h, --help 显示帮助
89
+ ```
90
+
91
+ ### 使用示例
92
+
93
+ **预览文章列表**
94
+
95
+ ```bash
96
+ npx @kadaliao/geektime-downloader \
97
+ -u "https://time.geekbang.org/column/article/200822" \
98
+ -c "your_cookie" \
99
+ --dry-run
100
+ ```
101
+
102
+ **测试下载**
103
+
104
+ ```bash
105
+ npx @kadaliao/geektime-downloader \
106
+ -u "https://time.geekbang.org/column/article/200822" \
107
+ -c "your_cookie" \
108
+ --limit 2
109
+ ```
110
+
111
+ **下载整个专栏**
112
+
113
+ ```bash
114
+ npx @kadaliao/geektime-downloader \
115
+ -u "https://time.geekbang.org/column/article/200822" \
116
+ -c "your_cookie"
117
+ ```
118
+
119
+ **自定义输出目录**
120
+
121
+ ```bash
122
+ npx @kadaliao/geektime-downloader \
123
+ -u "https://..." \
124
+ -c "..." \
125
+ --output ~/Documents/极客时间
126
+ ```
127
+
128
+ **禁用 PDF 合并(仅保留单独章节)**
129
+
130
+ ```bash
131
+ npx @kadaliao/geektime-downloader \
132
+ -u "https://..." \
133
+ -c "..." \
134
+ --no-merge
135
+ ```
136
+
137
+ ## 📁 输出文件
138
+
139
+ 下载完成后,会在输出目录生成以下文件:
140
+
141
+ ### 单独章节 PDF(始终生成)
142
+
143
+ ```
144
+ 001_开篇词___想吃透架构?你得看看真实、接地气的架构案例.pdf
145
+ 002_01___架构的本质:如何打造一个有序的系统?.pdf
146
+ 003_02___业务架构:作为开发,你真的了解业务吗?.pdf
147
+ ```
148
+
149
+ - 三位数字编号保持文章顺序
150
+ - 自动清理非法字符
151
+ - 限制文件名长度
152
+
153
+ ### 合并后的 PDF(默认生成)
154
+
155
+ ```
156
+ 专栏名称.pdf
157
+ ```
158
+
159
+ - 默认会将所有章节合并为一个完整的 PDF 文件
160
+ - 文件名为专栏的标题(自动从 API 获取)
161
+ - 包含所有成功下载的章节,按顺序排列
162
+ - 如不需要合并版本,使用 `--no-merge` 选项
163
+
164
+ ## ⚙️ 配置方式
165
+
166
+ ### 优先级
167
+
168
+ 命令行参数 > 配置文件
169
+
170
+ ### 配置文件示例
171
+
172
+ 创建 `config.json`(可选):
173
+
174
+ ```json
175
+ {
176
+ "cookie": "你的完整 cookie 字符串",
177
+ "columnUrl": "https://time.geekbang.org/column/article/xxxxx"
178
+ }
179
+ ```
180
+
181
+ **注意**:
182
+ - Cookie 必须完整,包含所有认证信息
183
+ - columnUrl 可以是专栏任意一篇文章的 URL
184
+ - Cookie 有过期时间,失败时请重新获取
185
+ - 配置文件完全可选,也可纯命令行使用
186
+
187
+ ## 🐛 常见问题
188
+
189
+ ### Cookie 和 URL 必须通过命令行传吗?
190
+
191
+ 不是。三种方式任选:
192
+ 1. **纯命令行**:`npx @kadaliao/geektime-downloader -u "..." -c "..."`
193
+ 2. **配置文件**:创建 `config.json` 后直接运行
194
+ 3. **混合使用**:命令行参数会覆盖配置文件
195
+
196
+ ### Cookie 过期了怎么办?
197
+
198
+ 重新获取 Cookie 并更新:
199
+ - 命令行方式:`-c "新cookie"`
200
+ - 配置文件方式:更新 `config.json`
201
+
202
+ ### PDF 内容不完整?
203
+
204
+ 增加页面加载延迟:
205
+
206
+ ```bash
207
+ npx @kadaliao/geektime-downloader -u "..." -c "..." --delay 5000
208
+ ```
209
+
210
+ ### 如何下载多个专栏?
211
+
212
+ 每次运行下载一个,只需更改 URL:
213
+
214
+ ```bash
215
+ npx @kadaliao/geektime-downloader -u "专栏A的URL" -c "..."
216
+ npx @kadaliao/geektime-downloader -u "专栏B的URL" -c "..."
217
+ ```
218
+
219
+ ## 🛠 本地开发
220
+
221
+ ```bash
222
+ # 克隆项目
223
+ git clone https://github.com/yourusername/geektime-downloader.git
224
+ cd geektime-downloader
225
+
226
+ # 安装依赖
227
+ npm install
228
+
229
+ # 安装浏览器
230
+ npx playwright install chromium
231
+
232
+ # 本地测试
233
+ npm link
234
+ geektime-dl --help
235
+ ```
236
+
237
+ ## 📝 项目结构
238
+
239
+ ```
240
+ geektime-downloader/
241
+ ├── download.js # 主程序
242
+ ├── package.json # npm 配置
243
+ ├── config.example.json # 配置模板
244
+ ├── README.md # 使用文档
245
+ ├── PUBLISH.md # 发布指南(维护者)
246
+ └── .gitignore # Git 忽略规则
247
+ ```
248
+
249
+ ## 🎯 技术栈
250
+
251
+ - **Playwright**: 浏览器自动化
252
+ - **Commander**: 命令行解析
253
+ - **Chalk**: 彩色输出
254
+ - **Ora**: 进度提示
255
+ - **pdf-lib**: PDF 文档操作和合并
256
+
257
+ ## 📄 License
258
+
259
+ MIT
260
+
261
+ ## ⚠️ 免责声明
262
+
263
+ 本工具仅供个人学习使用,请勿用于商业用途。下载内容版权归极客时间所有,请遵守相关法律法规。
@@ -0,0 +1,4 @@
1
+ {
2
+ "cookie": "你的完整 cookie 字符串",
3
+ "columnUrl": "https://time.geekbang.org/column/article/200822"
4
+ }
package/download.js ADDED
@@ -0,0 +1,1040 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { chromium } from 'playwright';
4
+ import { program } from 'commander';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import * as pdfLib from 'pdf-lib';
11
+ import { outlinePdfFactory } from '@lillallol/outline-pdf';
12
+
13
+ const { PDFDocument } = pdfLib;
14
+ const outlinePdf = outlinePdfFactory(pdfLib);
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ // 全局变量:跟踪当前浏览器实例和是否正在关闭
19
+ let globalBrowser = null;
20
+ let isShuttingDown = false;
21
+
22
+ // 优雅退出处理
23
+ async function gracefulShutdown(signal) {
24
+ if (isShuttingDown) {
25
+ return; // 防止重复调用
26
+ }
27
+ isShuttingDown = true;
28
+
29
+ console.log(chalk.yellow(`\n\n⚠️ 收到 ${signal} 信号,正在优雅退出...\n`));
30
+
31
+ if (globalBrowser) {
32
+ try {
33
+ console.log(chalk.gray('正在关闭浏览器...'));
34
+ await globalBrowser.close();
35
+ console.log(chalk.gray('浏览器已关闭'));
36
+ } catch (error) {
37
+ console.log(chalk.gray('浏览器关闭失败:', error.message));
38
+ }
39
+ }
40
+
41
+ console.log(chalk.yellow('✓ 已清理资源,程序退出\n'));
42
+ process.exit(0);
43
+ }
44
+
45
+ // 注册信号处理器
46
+ process.on('SIGINT', () => gracefulShutdown('SIGINT (Ctrl+C)'));
47
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
48
+
49
+ // 打印样式修复 CSS
50
+ const PRINT_FIX_CSS = `
51
+ @media print {
52
+ /* 让正文容器高度自适应 */
53
+ .simplebar-content-wrapper,
54
+ .simplebar-content,
55
+ .simplebar-offset,
56
+ .simplebar-mask,
57
+ .simplebar-wrapper,
58
+ .Index_contentWrap_qmM23,
59
+ .Index_contentWrapScroller_UOaGU,
60
+ .Index_main_3MKag,
61
+ .Index_wrap_2Piiq,
62
+ .Index_mainAreaWrapper_Z4kqi,
63
+ .Index_contentWidth_3_1Sf,
64
+ #article-content-container,
65
+ .Index_articleContent_QBG5G {
66
+ height: auto !important;
67
+ max-height: none !important;
68
+ overflow: visible !important;
69
+ overflow-y: visible !important;
70
+ }
71
+
72
+ /* 隐藏所有侧边栏、导航栏、工具栏等 */
73
+ .simplebar-track,
74
+ .simplebar-scrollbar,
75
+ nav,
76
+ header:not(.article-header),
77
+ footer,
78
+ aside,
79
+ /* 左侧边栏和目录 */
80
+ .Index_side_2umED,
81
+ .Index_leftSideScrollArea_2llPX,
82
+ .Index_leftSide,
83
+ .Index_catalog,
84
+ .Index_directory,
85
+ .catalog,
86
+ .directory,
87
+ .toc,
88
+ .table-of-contents,
89
+ [class*="catalog"],
90
+ [class*="directory"],
91
+ [class*="toc"],
92
+ [class*="sidebar"],
93
+ [class*="Sidebar"],
94
+ [class*="leftSide"],
95
+ [class*="LeftSide"],
96
+ /* 右侧边栏 */
97
+ .Index_rightSide_3pR3c,
98
+ .Index_rightSide,
99
+ .Index_outline_1uoMm,
100
+ /* 顶部导航 */
101
+ .Index_navWrap_2P51R,
102
+ .Index_nav,
103
+ .navbar,
104
+ /* 底部栏 */
105
+ .Index_bottomBar_1-vh2,
106
+ .Index_bottomBar,
107
+ /* 键盘快捷键提示 */
108
+ .keyboard-wrapper,
109
+ /* 评论区 */
110
+ .comment,
111
+ .comments,
112
+ .Index_comment,
113
+ /* 推荐和广告 */
114
+ .recommend,
115
+ .advertisement,
116
+ .ad,
117
+ .banner,
118
+ /* 分享按钮 */
119
+ .share,
120
+ .social,
121
+ /* 返回顶部等按钮 */
122
+ .back-to-top,
123
+ .scroll-top,
124
+ /* 浮动元素 */
125
+ .float-bar,
126
+ .fixed-bar,
127
+ /* 订阅提示 */
128
+ .subscribe,
129
+ .subscription,
130
+ /* 作者信息卡片(如果不想要的话) */
131
+ .author-card,
132
+ /* 相关推荐 */
133
+ .related,
134
+ .recommendation {
135
+ display: none !important;
136
+ }
137
+
138
+ /* 确保html和body高度自适应和全宽 */
139
+ html, body {
140
+ height: auto !important;
141
+ overflow: visible !important;
142
+ width: 100% !important;
143
+ }
144
+
145
+ /* 破坏所有可能的布局容器约束,强制全宽 */
146
+ body > *,
147
+ body > * > *,
148
+ .Index_wrap_2Piiq,
149
+ .Index_mainAreaWrapper_Z4kqi,
150
+ .Index_mainArea,
151
+ .Index_contentWrap_qmM23,
152
+ .Index_contentWrapScroller_UOaGU,
153
+ .Index_main_3MKag,
154
+ .Index_contentWidth_3_1Sf,
155
+ main,
156
+ [class*="wrap"],
157
+ [class*="Wrap"],
158
+ [class*="container"],
159
+ [class*="Container"],
160
+ [class*="mainArea"],
161
+ [class*="MainArea"] {
162
+ width: 100% !important;
163
+ max-width: none !important;
164
+ min-width: 100% !important;
165
+ margin: 0 !important;
166
+ padding: 20px !important;
167
+ flex: none !important;
168
+ grid-column: 1 / -1 !important;
169
+ /* 破坏 flexbox 和 grid 布局 */
170
+ display: block !important;
171
+ }
172
+
173
+ /* 优化正文排版 */
174
+ .Index_articleContent_QBG5G,
175
+ .article-content,
176
+ article,
177
+ [class*="articleContent"],
178
+ [class*="ArticleContent"] {
179
+ width: 100% !important;
180
+ max-width: 100% !important;
181
+ min-width: 100% !important;
182
+ padding: 0 !important;
183
+ margin: 0 auto !important;
184
+ box-sizing: border-box !important;
185
+ }
186
+
187
+ /* 确保所有内容元素不溢出 */
188
+ * {
189
+ box-sizing: border-box !important;
190
+ max-width: 100% !important;
191
+ }
192
+
193
+ /* 确保代码块完整显示且不溢出 */
194
+ pre, code {
195
+ white-space: pre-wrap !important;
196
+ word-wrap: break-word !important;
197
+ overflow: visible !important;
198
+ max-width: 100% !important;
199
+ box-sizing: border-box !important;
200
+ }
201
+
202
+ /* 确保图片适应页面且不溢出 */
203
+ img {
204
+ max-width: 100% !important;
205
+ height: auto !important;
206
+ page-break-inside: avoid;
207
+ box-sizing: border-box !important;
208
+ }
209
+
210
+ /* 确保表格不溢出 */
211
+ table {
212
+ max-width: 100% !important;
213
+ table-layout: auto !important;
214
+ word-wrap: break-word !important;
215
+ box-sizing: border-box !important;
216
+ }
217
+
218
+ /* 确保长文本自动换行 */
219
+ p, div, span, li {
220
+ word-wrap: break-word !important;
221
+ overflow-wrap: break-word !important;
222
+ box-sizing: border-box !important;
223
+ }
224
+ }
225
+ `;
226
+
227
+ // 解析 cookie 字符串
228
+ function parseCookies(cookieString) {
229
+ return cookieString.split(';').map(cookie => {
230
+ const [name, ...valueParts] = cookie.trim().split('=');
231
+ return {
232
+ name: name.trim(),
233
+ value: valueParts.join('=').trim(),
234
+ domain: '.geekbang.org',
235
+ path: '/'
236
+ };
237
+ });
238
+ }
239
+
240
+ // 获取专栏所有文章列表(通过API)
241
+ async function getArticleList(page, columnUrl) {
242
+ const spinner = ora('正在获取文章列表...').start();
243
+
244
+ // 监听API响应并获取文章列表
245
+ let articlesData = null;
246
+ let handler = null;
247
+
248
+ const responsePromise = new Promise((resolve, reject) => {
249
+ handler = async (response) => {
250
+ const url = response.url();
251
+ if (url.includes('/serv/v1/column/articles')) {
252
+ try {
253
+ const data = await response.json();
254
+ resolve(data);
255
+ } catch (e) {
256
+ console.error('解析API响应失败:', e);
257
+ reject(e);
258
+ }
259
+ }
260
+ };
261
+ page.on('response', handler);
262
+ });
263
+
264
+ try {
265
+ // 访问页面以触发API调用
266
+ await page.goto(columnUrl, { waitUntil: 'networkidle' });
267
+
268
+ // 等待API调用(最多10秒)
269
+ articlesData = await Promise.race([
270
+ responsePromise,
271
+ new Promise((_, reject) => setTimeout(() => reject(new Error('API调用超时')), 10000))
272
+ ]);
273
+ } catch (error) {
274
+ // 如果是因为浏览器关闭导致的错误,静默处理
275
+ if (isShuttingDown || error.message.includes('Target page, context or browser has been closed')) {
276
+ spinner.stop();
277
+ return { articles: [], columnTitle: 'unknown' };
278
+ }
279
+ spinner.fail('获取文章列表失败');
280
+ throw error;
281
+ } finally {
282
+ // 确保移除监听器,防止内存泄漏
283
+ if (handler) {
284
+ try {
285
+ page.off('response', handler);
286
+ } catch (e) {
287
+ // 忽略page已关闭的错误
288
+ }
289
+ }
290
+ }
291
+
292
+ if (!articlesData || !articlesData.data || !articlesData.data.list) {
293
+ spinner.fail('API响应数据格式错误');
294
+ return { articles: [], columnTitle: 'unknown' };
295
+ }
296
+
297
+ // 获取专栏标题 - 尝试多个可能的字段
298
+ let columnTitle = articlesData.data.column_title
299
+ || articlesData.data.column_subtitle
300
+ || articlesData.data.title
301
+ || articlesData.data.name
302
+ || articlesData.data.columnTitle;
303
+
304
+ // 如果还是没有,尝试从第一篇文章的信息中提取
305
+ if (!columnTitle && articlesData.data.list && articlesData.data.list.length > 0) {
306
+ const firstArticle = articlesData.data.list[0];
307
+ columnTitle = firstArticle.column_title || firstArticle.product_title;
308
+ }
309
+
310
+ // 如果API中没有,从页面标题提取
311
+ if (!columnTitle || columnTitle === '专栏') {
312
+ try {
313
+ const pageTitle = await page.title();
314
+ // 页面标题格式通常是:"文章标题 - 专栏名称 - 极客时间"
315
+ const parts = pageTitle.split('-').map(p => p.trim());
316
+ if (parts.length >= 2) {
317
+ columnTitle = parts[1]; // 取第二部分作为专栏名称
318
+ }
319
+ } catch (e) {
320
+ console.error('从页面标题提取失败:', e);
321
+ }
322
+ }
323
+
324
+ // 最后的默认值
325
+ columnTitle = columnTitle || '专栏';
326
+
327
+ // 清理标题
328
+ columnTitle = columnTitle
329
+ .replace(/[<>:"/\\|?*]/g, '_')
330
+ .replace(/\s+/g, '_')
331
+ .substring(0, 100);
332
+
333
+ // 调试信息:记录API响应的结构(仅在环境变量DEBUG存在时)
334
+ if (process.env.DEBUG) {
335
+ console.log(chalk.gray('\nAPI响应数据字段:'));
336
+ console.log(chalk.gray(` column_title: ${articlesData.data.column_title}`));
337
+ console.log(chalk.gray(` column_subtitle: ${articlesData.data.column_subtitle}`));
338
+ console.log(chalk.gray(` title: ${articlesData.data.title}`));
339
+ console.log(chalk.gray(` 提取的专栏名: ${columnTitle}\n`));
340
+ }
341
+
342
+ // 解析文章列表
343
+ const rawArticles = articlesData.data.list;
344
+ const articles = rawArticles.map((article) => {
345
+ const title = article.article_title || article.article_sharetitle || 'Untitled';
346
+ const id = article.id;
347
+
348
+ // 清理标题中的非法字符
349
+ const cleanTitle = title
350
+ .replace(/[<>:"/\\|?*]/g, '_')
351
+ .replace(/\s+/g, '_')
352
+ .replace(/\|/g, '-')
353
+ .substring(0, 100);
354
+
355
+ return {
356
+ title: cleanTitle,
357
+ url: `https://time.geekbang.org/column/article/${id}`,
358
+ originalTitle: title,
359
+ id: id
360
+ };
361
+ });
362
+
363
+ spinner.succeed(`找到 ${chalk.green(articles.length)} 篇文章 - ${columnTitle}`);
364
+ return { articles, columnTitle };
365
+ }
366
+
367
+ // 并发下载控制器
368
+ async function downloadWithConcurrency(context, articles, outputDir, concurrency = 5, delay = 2000) {
369
+ const results = [];
370
+ const total = articles.length;
371
+ let completed = 0;
372
+
373
+ // 使用一个全局进度条
374
+ const progressSpinner = ora(`下载进度: 0/${total}`).start();
375
+
376
+ // 创建并发池
377
+ const pool = [];
378
+ for (let i = 0; i < Math.min(concurrency, articles.length); i++) {
379
+ pool.push(context.newPage());
380
+ }
381
+ const pages = await Promise.all(pool);
382
+
383
+ // 处理队列
384
+ let currentIndex = 0;
385
+
386
+ const processNext = async (page, pageIndex) => {
387
+ while (currentIndex < articles.length) {
388
+ const index = currentIndex++;
389
+ const article = articles[index];
390
+
391
+ try {
392
+ const result = await downloadArticleSilent(page, article, outputDir, index + 1, total);
393
+ results[index] = result;
394
+ completed++;
395
+
396
+ // 更新进度条
397
+ progressSpinner.text = `下载进度: ${completed}/${total}`;
398
+
399
+ // 立即打印完成的文章(在进度条下方)
400
+ if (result.success) {
401
+ progressSpinner.stopAndPersist({
402
+ symbol: chalk.green('✓'),
403
+ text: `[${index + 1}/${total}] ${article.originalTitle || article.title}`
404
+ });
405
+ } else {
406
+ progressSpinner.stopAndPersist({
407
+ symbol: chalk.red('✗'),
408
+ text: `[${index + 1}/${total}] ${article.originalTitle || article.title} - ${result.error}`
409
+ });
410
+ }
411
+
412
+ // 重新启动进度条
413
+ progressSpinner.start();
414
+ progressSpinner.text = `下载进度: ${completed}/${total}`;
415
+
416
+ // 添加延迟,避免请求过快
417
+ if (currentIndex < articles.length) {
418
+ await page.waitForTimeout(delay);
419
+ }
420
+ } catch (error) {
421
+ results[index] = { success: false, title: article.title, error: error.message };
422
+ completed++;
423
+
424
+ progressSpinner.stopAndPersist({
425
+ symbol: chalk.red('✗'),
426
+ text: `[${index + 1}/${total}] ${article.title} - ${error.message}`
427
+ });
428
+
429
+ progressSpinner.start();
430
+ progressSpinner.text = `下载进度: ${completed}/${total}`;
431
+ }
432
+ }
433
+ };
434
+
435
+ // 启动所有worker
436
+ await Promise.all(pages.map((page, idx) => processNext(page, idx)));
437
+
438
+ progressSpinner.succeed(`下载完成: ${completed}/${total}`);
439
+
440
+ // 关闭所有page
441
+ await Promise.all(pages.map(page => page.close()));
442
+
443
+ return results;
444
+ }
445
+
446
+ // 下载单篇文章为 PDF(静默模式,不显示单独的spinner)
447
+ async function downloadArticleSilent(page, article, outputDir, index, total) {
448
+ try {
449
+ // 访问文章页面
450
+ await page.goto(article.url, { waitUntil: 'networkidle' });
451
+ await page.waitForTimeout(2000);
452
+
453
+ // 注入打印修复样式
454
+ await page.addStyleTag({ content: PRINT_FIX_CSS });
455
+
456
+ // 激进的布局重构:提取正文并重建页面结构
457
+ await page.evaluate((titleText) => {
458
+ // 1. 找到文章正文内容
459
+ const articleContent = document.querySelector('.Index_articleContent_QBG5G, .article-content, article, [class*="articleContent"]');
460
+
461
+ if (articleContent) {
462
+ // 2. 克隆正文内容
463
+ const contentClone = articleContent.cloneNode(true);
464
+
465
+ // 3. 清空body的所有内容
466
+ document.body.innerHTML = '';
467
+
468
+ // 4. 重置body样式为全宽
469
+ document.body.style.margin = '0';
470
+ document.body.style.padding = '0';
471
+ document.body.style.width = '100%';
472
+ document.body.style.maxWidth = 'none';
473
+ document.body.style.boxSizing = 'border-box';
474
+
475
+ // 5. 创建一个简单的容器
476
+ const wrapper = document.createElement('div');
477
+ wrapper.style.width = '100%';
478
+ wrapper.style.maxWidth = '100%';
479
+ wrapper.style.margin = '0';
480
+ wrapper.style.padding = '0';
481
+ wrapper.style.boxSizing = 'border-box';
482
+
483
+ // 6. 创建标题元素(使用传入的标题文本)
484
+ if (titleText) {
485
+ const titleElement = document.createElement('h1');
486
+ titleElement.textContent = titleText;
487
+ // 设置标题样式
488
+ titleElement.style.fontSize = '32px';
489
+ titleElement.style.fontWeight = 'bold';
490
+ titleElement.style.marginBottom = '30px';
491
+ titleElement.style.marginTop = '0';
492
+ titleElement.style.lineHeight = '1.4';
493
+ titleElement.style.color = '#000';
494
+ wrapper.appendChild(titleElement);
495
+ }
496
+
497
+ // 7. 将正文插入容器
498
+ wrapper.appendChild(contentClone);
499
+
500
+ // 8. 将容器插入body
501
+ document.body.appendChild(wrapper);
502
+
503
+ // 9. 确保正文内容使用全宽且不溢出
504
+ contentClone.style.width = '100%';
505
+ contentClone.style.maxWidth = '100%';
506
+ contentClone.style.margin = '0';
507
+ contentClone.style.padding = '0';
508
+ contentClone.style.boxSizing = 'border-box';
509
+ contentClone.style.overflowWrap = 'break-word';
510
+ contentClone.style.wordBreak = 'break-word';
511
+ } else {
512
+ // 如果找不到正文,使用原有的删除方法
513
+ const selectors = [
514
+ 'aside',
515
+ '[class*="leftSide"]',
516
+ '[class*="LeftSide"]',
517
+ '[class*="sidebar"]',
518
+ '[class*="Sidebar"]',
519
+ '[class*="side_"]',
520
+ '[class*="catalog"]',
521
+ '[class*="directory"]',
522
+ '[class*="toc"]',
523
+ '[class*="outline"]',
524
+ '[class*="Outline"]',
525
+ 'nav',
526
+ '[class*="nav"]',
527
+ '[class*="Nav"]',
528
+ '[class*="rightSide"]',
529
+ '[class*="RightSide"]',
530
+ '[class*="comment"]',
531
+ '[class*="recommend"]',
532
+ '[class*="footer"]',
533
+ '[class*="bottom"]'
534
+ ];
535
+
536
+ selectors.forEach(selector => {
537
+ try {
538
+ const elements = document.querySelectorAll(selector);
539
+ elements.forEach(el => el.remove());
540
+ } catch (e) {
541
+ // 忽略无效选择器
542
+ }
543
+ });
544
+ }
545
+
546
+ // 额外:删除所有包含"大纲"的元素
547
+ const allElements = document.querySelectorAll('*');
548
+ allElements.forEach(el => {
549
+ const text = el.textContent || el.innerText || '';
550
+ if (text.trim() === '大纲' ||
551
+ (text.length < 200 && text.includes('大纲') && el.children.length <= 10)) {
552
+ el.remove();
553
+ }
554
+ });
555
+ }, article.originalTitle || article.title);
556
+
557
+ // 等待文章内容加载
558
+ await page.waitForSelector('.Index_articleContent_QBG5G, .content', { timeout: 10000 });
559
+
560
+ // 生成 PDF
561
+ const filename = `${String(index).padStart(3, '0')}_${article.title}.pdf`;
562
+ const filepath = path.join(outputDir, filename);
563
+
564
+ await page.pdf({
565
+ path: filepath,
566
+ format: 'A4',
567
+ margin: {
568
+ top: '20mm',
569
+ right: '15mm',
570
+ bottom: '20mm',
571
+ left: '15mm'
572
+ },
573
+ printBackground: true
574
+ });
575
+
576
+ return { success: true, title: article.title };
577
+
578
+ } catch (error) {
579
+ return { success: false, title: article.title, error: error.message };
580
+ }
581
+ }
582
+
583
+ // 下载单篇文章为 PDF
584
+ async function downloadArticle(page, article, outputDir, index, total) {
585
+ const spinner = ora(`[${index}/${total}] 正在下载: ${article.title}`).start();
586
+
587
+ try {
588
+ // 访问文章页面
589
+ await page.goto(article.url, { waitUntil: 'networkidle' });
590
+ await page.waitForTimeout(2000);
591
+
592
+ // 注入打印修复样式
593
+ await page.addStyleTag({ content: PRINT_FIX_CSS });
594
+
595
+ // 激进的布局重构:提取正文并重建页面结构
596
+ await page.evaluate((titleText) => {
597
+ // 1. 找到文章正文内容
598
+ const articleContent = document.querySelector('.Index_articleContent_QBG5G, .article-content, article, [class*="articleContent"]');
599
+
600
+ if (articleContent) {
601
+ // 2. 克隆正文内容
602
+ const contentClone = articleContent.cloneNode(true);
603
+
604
+ // 3. 清空body的所有内容
605
+ document.body.innerHTML = '';
606
+
607
+ // 4. 重置body样式为全宽
608
+ document.body.style.margin = '0';
609
+ document.body.style.padding = '0';
610
+ document.body.style.width = '100%';
611
+ document.body.style.maxWidth = 'none';
612
+ document.body.style.boxSizing = 'border-box';
613
+
614
+ // 5. 创建一个简单的容器
615
+ const wrapper = document.createElement('div');
616
+ wrapper.style.width = '100%';
617
+ wrapper.style.maxWidth = '100%';
618
+ wrapper.style.margin = '0';
619
+ wrapper.style.padding = '0';
620
+ wrapper.style.boxSizing = 'border-box';
621
+
622
+ // 6. 创建标题元素(使用传入的标题文本)
623
+ if (titleText) {
624
+ const titleElement = document.createElement('h1');
625
+ titleElement.textContent = titleText;
626
+ // 设置标题样式
627
+ titleElement.style.fontSize = '32px';
628
+ titleElement.style.fontWeight = 'bold';
629
+ titleElement.style.marginBottom = '30px';
630
+ titleElement.style.marginTop = '0';
631
+ titleElement.style.lineHeight = '1.4';
632
+ titleElement.style.color = '#000';
633
+ wrapper.appendChild(titleElement);
634
+ }
635
+
636
+ // 7. 将正文插入容器
637
+ wrapper.appendChild(contentClone);
638
+
639
+ // 8. 将容器插入body
640
+ document.body.appendChild(wrapper);
641
+
642
+ // 9. 确保正文内容使用全宽且不溢出
643
+ contentClone.style.width = '100%';
644
+ contentClone.style.maxWidth = '100%';
645
+ contentClone.style.margin = '0';
646
+ contentClone.style.padding = '0';
647
+ contentClone.style.boxSizing = 'border-box';
648
+ contentClone.style.overflowWrap = 'break-word';
649
+ contentClone.style.wordBreak = 'break-word';
650
+ } else {
651
+ // 如果找不到正文,使用原有的删除方法
652
+ const selectors = [
653
+ 'aside',
654
+ '[class*="leftSide"]',
655
+ '[class*="LeftSide"]',
656
+ '[class*="sidebar"]',
657
+ '[class*="Sidebar"]',
658
+ '[class*="side_"]',
659
+ '[class*="catalog"]',
660
+ '[class*="directory"]',
661
+ '[class*="toc"]',
662
+ '[class*="outline"]',
663
+ '[class*="Outline"]',
664
+ 'nav',
665
+ '[class*="nav"]',
666
+ '[class*="Nav"]',
667
+ '[class*="rightSide"]',
668
+ '[class*="RightSide"]',
669
+ '[class*="comment"]',
670
+ '[class*="recommend"]',
671
+ '[class*="footer"]',
672
+ '[class*="bottom"]'
673
+ ];
674
+
675
+ selectors.forEach(selector => {
676
+ try {
677
+ const elements = document.querySelectorAll(selector);
678
+ elements.forEach(el => el.remove());
679
+ } catch (e) {
680
+ // 忽略无效选择器
681
+ }
682
+ });
683
+ }
684
+
685
+ // 额外:删除所有包含"大纲"的元素
686
+ const allElements = document.querySelectorAll('*');
687
+ allElements.forEach(el => {
688
+ const text = el.textContent || el.innerText || '';
689
+ if (text.trim() === '大纲' ||
690
+ (text.length < 200 && text.includes('大纲') && el.children.length <= 10)) {
691
+ el.remove();
692
+ }
693
+ });
694
+ }, article.originalTitle || article.title);
695
+
696
+ // 等待文章内容加载
697
+ await page.waitForSelector('.Index_articleContent_QBG5G, .content', { timeout: 10000 });
698
+
699
+ // 生成 PDF
700
+ const filename = `${String(index).padStart(3, '0')}_${article.title}.pdf`;
701
+ const filepath = path.join(outputDir, filename);
702
+
703
+ await page.pdf({
704
+ path: filepath,
705
+ format: 'A4',
706
+ margin: {
707
+ top: '20mm',
708
+ right: '15mm',
709
+ bottom: '20mm',
710
+ left: '15mm'
711
+ },
712
+ printBackground: true
713
+ });
714
+
715
+ spinner.succeed(`[${index}/${total}] ${chalk.green('✓')} ${article.title}`);
716
+ return { success: true, title: article.title };
717
+
718
+ } catch (error) {
719
+ spinner.fail(`[${index}/${total}] ${chalk.red('✗')} ${article.title}: ${error.message}`);
720
+ return { success: false, title: article.title, error: error.message };
721
+ }
722
+ }
723
+
724
+ // 合并所有 PDF 文件
725
+ async function mergePDFs(outputDir, columnTitle, articles, deleteAfterMerge = false) {
726
+ const spinner = ora('正在合并所有 PDF 文件...').start();
727
+
728
+ try {
729
+ // 读取目录中的所有 PDF 文件
730
+ const files = await fs.readdir(outputDir);
731
+ const pdfFiles = files
732
+ .filter(file => file.endsWith('.pdf') && file.match(/^\d{3}_/))
733
+ .sort();
734
+
735
+ if (pdfFiles.length === 0) {
736
+ spinner.warn('没有找到可以合并的 PDF 文件');
737
+ return null;
738
+ }
739
+
740
+ // 创建新的 PDF 文档
741
+ const mergedPdf = await PDFDocument.create();
742
+
743
+ // 用于存储书签信息
744
+ const bookmarks = [];
745
+ let currentPage = 0;
746
+
747
+ // 逐个读取并合并 PDF
748
+ for (let i = 0; i < pdfFiles.length; i++) {
749
+ const file = pdfFiles[i];
750
+ const filePath = path.join(outputDir, file);
751
+ const pdfBytes = await fs.readFile(filePath);
752
+ const pdf = await PDFDocument.load(pdfBytes);
753
+ const pageCount = pdf.getPageCount();
754
+
755
+ // 记录书签信息(章节标题和页码)
756
+ if (articles && articles[i]) {
757
+ bookmarks.push({
758
+ title: articles[i].originalTitle || articles[i].title,
759
+ pageIndex: currentPage
760
+ });
761
+ }
762
+
763
+ const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
764
+ copiedPages.forEach(page => mergedPdf.addPage(page));
765
+
766
+ currentPage += pageCount;
767
+ }
768
+
769
+ // 设置PDF元数据
770
+ mergedPdf.setTitle(columnTitle);
771
+ mergedPdf.setSubject(`包含 ${pdfFiles.length} 个章节`);
772
+
773
+ spinner.text = '正在添加PDF书签...';
774
+
775
+ // 添加PDF书签/大纲
776
+ let finalPdf = mergedPdf;
777
+ if (bookmarks.length > 0) {
778
+ try {
779
+ // 构建outline文本结构
780
+ // 格式:页码(从1开始)||标题
781
+ const outlineText = bookmarks
782
+ .map(b => `${b.pageIndex + 1}||${b.title}`)
783
+ .join('\n');
784
+
785
+ // 调试信息
786
+ if (process.env.DEBUG) {
787
+ console.log(chalk.gray('\n生成的书签格式:'));
788
+ console.log(chalk.gray(outlineText.split('\n').slice(0, 5).join('\n')));
789
+ console.log(chalk.gray(`...(共${bookmarks.length}个书签)\n`));
790
+ }
791
+
792
+ // 使用 outline-pdf 库添加书签
793
+ finalPdf = await outlinePdf({
794
+ outline: outlineText,
795
+ pdf: mergedPdf
796
+ });
797
+
798
+ spinner.text = `已添加 ${bookmarks.length} 个书签`;
799
+ } catch (outlineError) {
800
+ console.log(chalk.yellow(`\n ⚠️ 书签添加失败: ${outlineError.message}`));
801
+ console.log(chalk.gray(` 错误详情: ${outlineError.stack}`));
802
+ console.log(chalk.gray(' 将继续保存不带书签的PDF\n'));
803
+ }
804
+ }
805
+
806
+ // 保存最终的PDF
807
+ const mergedFileName = `${columnTitle}.pdf`;
808
+ const mergedFilePath = path.join(outputDir, mergedFileName);
809
+ const mergedPdfBytes = await finalPdf.save();
810
+ await fs.writeFile(mergedFilePath, mergedPdfBytes);
811
+
812
+ spinner.succeed(`已合并 ${pdfFiles.length} 个 PDF 文件 → ${chalk.green(mergedFileName)}${bookmarks.length > 0 ? chalk.gray(` (${bookmarks.length}个书签)`) : ''}`);
813
+
814
+ // 如果需要删除单独的章节文件
815
+ if (deleteAfterMerge) {
816
+ spinner.text = '正在删除单独的章节PDF...';
817
+ spinner.start();
818
+ for (const file of pdfFiles) {
819
+ await fs.unlink(path.join(outputDir, file));
820
+ }
821
+ spinner.succeed(`已删除 ${pdfFiles.length} 个单独的章节PDF文件`);
822
+ }
823
+
824
+ return mergedFilePath;
825
+
826
+ } catch (error) {
827
+ spinner.fail(`合并 PDF 失败: ${error.message}`);
828
+ console.error(chalk.gray(error.stack));
829
+ return null;
830
+ }
831
+ }
832
+
833
+ // 主函数
834
+ async function main(options) {
835
+ console.log(chalk.bold.cyan('\n🚀 极客时间专栏下载器\n'));
836
+
837
+ // 获取配置:优先级 命令行 > 配置文件
838
+ let cookie = options.cookie;
839
+ let columnUrl = options.url;
840
+
841
+ // 如果命令行没有提供,尝试从配置文件读取
842
+ if (!cookie || !columnUrl) {
843
+ // 使用当前工作目录的config.json,而不是脚本所在目录
844
+ const configPath = path.join(process.cwd(), 'config.json');
845
+ try {
846
+ const configContent = await fs.readFile(configPath, 'utf-8');
847
+ const config = JSON.parse(configContent);
848
+
849
+ // 使用配置文件中的值作为默认值
850
+ if (!cookie) cookie = config.cookie;
851
+ if (!columnUrl) columnUrl = config.columnUrl;
852
+ } catch (error) {
853
+ // 配置文件不存在或读取失败,不是致命错误
854
+ // 只有在命令行也没提供时才报错
855
+ }
856
+ }
857
+
858
+ // 验证必要参数
859
+ if (!cookie) {
860
+ console.error(chalk.red('❌ 缺少 Cookie!'));
861
+ console.log(chalk.yellow('\n请通过以下方式之一提供 Cookie:'));
862
+ console.log(chalk.gray('1. 命令行参数:--cookie "你的cookie字符串"'));
863
+ console.log(chalk.gray('2. 配置文件 config.json:'));
864
+ console.log(chalk.gray(' {'));
865
+ console.log(chalk.gray(' "cookie": "你的cookie字符串",'));
866
+ console.log(chalk.gray(' "columnUrl": "https://time.geekbang.org/column/article/xxxxx"'));
867
+ console.log(chalk.gray(' }\n'));
868
+ process.exit(1);
869
+ }
870
+
871
+ if (!columnUrl) {
872
+ console.error(chalk.red('❌ 缺少专栏 URL!'));
873
+ console.log(chalk.yellow('\n请通过以下方式之一提供专栏 URL:'));
874
+ console.log(chalk.gray('1. 命令行参数:--url "https://time.geekbang.org/column/article/xxxxx"'));
875
+ console.log(chalk.gray('2. 配置文件 config.json\n'));
876
+ process.exit(1);
877
+ }
878
+
879
+ console.log(chalk.gray(`📄 专栏地址: ${columnUrl}`));
880
+
881
+ // 创建输出目录(相对于当前工作目录)
882
+ const outputDir = options.output || path.join(process.cwd(), 'downloads');
883
+ await fs.mkdir(outputDir, { recursive: true });
884
+
885
+ console.log(chalk.gray(`📁 输出目录: ${outputDir}\n`));
886
+
887
+ // 启动浏览器
888
+ const browser = await chromium.launch({
889
+ headless: options.headless !== false
890
+ });
891
+
892
+ // 保存到全局变量,用于信号处理
893
+ globalBrowser = browser;
894
+
895
+ const context = await browser.newContext({
896
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
897
+ });
898
+
899
+ // 设置 cookies
900
+ const cookies = parseCookies(cookie);
901
+ await context.addCookies(cookies);
902
+
903
+ const page = await context.newPage();
904
+
905
+ try {
906
+ // 获取文章列表
907
+ const { articles, columnTitle } = await getArticleList(page, columnUrl);
908
+
909
+ if (articles.length === 0) {
910
+ console.log(chalk.yellow('⚠️ 未找到任何文章'));
911
+ return;
912
+ }
913
+
914
+ // 如果是 dry-run 模式,只显示列表
915
+ if (options.dryRun) {
916
+ console.log(chalk.cyan('\n📋 文章列表(预览模式):\n'));
917
+ articles.forEach((article, index) => {
918
+ console.log(` ${index + 1}. ${article.originalTitle || article.title}`);
919
+ });
920
+ console.log(chalk.gray(`\n总共 ${articles.length} 篇文章`));
921
+ console.log(chalk.gray(`\n提示:运行 'npm start' 开始下载`));
922
+ return;
923
+ }
924
+
925
+ console.log(chalk.cyan('\n开始下载...\n'));
926
+
927
+ // 下载所有文章(或限制数量)
928
+ const limit = options.limit ? Math.min(parseInt(options.limit), articles.length) : articles.length;
929
+ const articlesToDownload = articles.slice(0, limit);
930
+
931
+ if (limit < articles.length) {
932
+ console.log(chalk.yellow(`⚠️ 限制模式:只下载前 ${limit} 篇文章\n`));
933
+ }
934
+
935
+ // 并发下载
936
+ const concurrency = parseInt(options.concurrency) || 3;
937
+ if (concurrency > 1) {
938
+ console.log(chalk.gray(`📊 并发数: ${concurrency}\n`));
939
+ }
940
+
941
+ const results = await downloadWithConcurrency(
942
+ context,
943
+ articlesToDownload,
944
+ outputDir,
945
+ concurrency,
946
+ parseInt(options.delay) || 2000
947
+ );
948
+
949
+ // 统计结果
950
+ const successCount = results.filter(r => r.success).length;
951
+ const failCount = results.filter(r => !r.success).length;
952
+
953
+ console.log(chalk.bold.cyan('\n📊 下载统计\n'));
954
+ console.log(` ${chalk.green('✓')} 成功: ${successCount}`);
955
+ console.log(` ${chalk.red('✗')} 失败: ${failCount}`);
956
+ console.log(` ${chalk.blue('📁')} 保存位置: ${outputDir}\n`);
957
+
958
+ // 合并 PDF
959
+ if (options.merge !== false && successCount > 0) {
960
+ const mergedPath = await mergePDFs(
961
+ outputDir,
962
+ columnTitle,
963
+ articlesToDownload,
964
+ options.deleteAfterMerge
965
+ );
966
+ if (mergedPath) {
967
+ console.log(chalk.green(`\n✅ 合并完成: ${mergedPath}\n`));
968
+ }
969
+ }
970
+
971
+ } catch (error) {
972
+ // 如果是因为用户中断或浏览器关闭,不显示错误
973
+ if (isShuttingDown || error.message.includes('Target page, context or browser has been closed')) {
974
+ // 静默退出
975
+ return;
976
+ }
977
+ console.error(chalk.red(`\n❌ 错误: ${error.message}`));
978
+ if (process.env.DEBUG) {
979
+ console.error(chalk.gray(error.stack));
980
+ }
981
+ process.exit(1);
982
+ } finally {
983
+ // 确保浏览器完全关闭
984
+ try {
985
+ if (browser && !isShuttingDown) {
986
+ await browser.close();
987
+ globalBrowser = null;
988
+ }
989
+ } catch (closeError) {
990
+ console.error(chalk.yellow('浏览器关闭时出现警告:', closeError.message));
991
+ }
992
+ }
993
+ }
994
+
995
+ // 命令行参数
996
+ program
997
+ .name('geektime-dl')
998
+ .description('批量下载极客时间专栏文章为PDF')
999
+ .version('1.0.0')
1000
+ .option('-u, --url <url>', '专栏文章URL(任意一篇)')
1001
+ .option('-c, --cookie <cookie>', 'Cookie字符串(用于认证)')
1002
+ .option('-o, --output <dir>', '输出目录', './downloads')
1003
+ .option('--headless <boolean>', '无头模式', true)
1004
+ .option('--delay <ms>', '每篇文章之间的延迟(ms)', '2000')
1005
+ .option('--concurrency <number>', '并发下载数量', '3')
1006
+ .option('--dry-run', '预览模式,只显示文章列表')
1007
+ .option('--limit <number>', '限制下载数量(用于测试)')
1008
+ .option('--no-merge', '禁用PDF合并(默认会合并所有文章为一个PDF)')
1009
+ .option('--delete-after-merge', '合并后删除单独的章节PDF文件')
1010
+ .addHelpText('after', `
1011
+ 示例:
1012
+ $ geektime-dl --url "https://time.geekbang.org/column/article/200822" --cookie "your_cookie"
1013
+ $ geektime-dl -u "https://time.geekbang.org/column/article/200822" -c "your_cookie" --dry-run
1014
+ $ npx @kadaliao/geektime-downloader --url "https://..." --cookie "..." --limit 5
1015
+ $ geektime-dl --url "..." --cookie "..." --no-merge # 不合并PDF
1016
+ `)
1017
+ .parse();
1018
+
1019
+ const options = program.opts();
1020
+
1021
+ // 运行
1022
+ main(options)
1023
+ .then(() => {
1024
+ // 显式退出进程,确保所有资源都已清理
1025
+ if (!isShuttingDown) {
1026
+ process.exit(0);
1027
+ }
1028
+ })
1029
+ .catch(error => {
1030
+ // 如果是优雅退出过程中的错误,不显示
1031
+ if (isShuttingDown || (error && error.message && error.message.includes('Target page, context or browser has been closed'))) {
1032
+ process.exit(0);
1033
+ } else {
1034
+ console.error(chalk.red('\n程序异常退出:'), error.message);
1035
+ if (process.env.DEBUG) {
1036
+ console.error(chalk.gray(error.stack));
1037
+ }
1038
+ process.exit(1);
1039
+ }
1040
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@kadaliao/geektime-downloader",
3
+ "version": "1.0.0",
4
+ "description": "极客时间专栏文章批量下载工具 - 支持一键下载整个专栏为PDF",
5
+ "type": "module",
6
+ "main": "download.js",
7
+ "bin": {
8
+ "geektime-dl": "./download.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node download.js",
12
+ "test": "node download.js --dry-run"
13
+ },
14
+ "keywords": [
15
+ "geektime",
16
+ "downloader",
17
+ "pdf",
18
+ "crawler",
19
+ "极客时间"
20
+ ],
21
+ "author": "liaoxingyi",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/yourusername/geektime-downloader.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=16.0.0"
29
+ },
30
+ "dependencies": {
31
+ "@lillallol/outline-pdf": "^4.0.0",
32
+ "chalk": "^5.3.0",
33
+ "commander": "^11.1.0",
34
+ "ora": "^8.0.1",
35
+ "pdf-lib": "^1.17.1",
36
+ "playwright": "^1.40.0"
37
+ }
38
+ }