weixin-article-exporter-cli 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/index.mjs +1143 -0
  4. package/package.json +37 -0
package/index.mjs ADDED
@@ -0,0 +1,1143 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import readline from 'node:readline';
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ import jpeg from 'jpeg-js';
10
+ import jsQR from 'jsqr';
11
+ import QRCode from 'qrcode';
12
+ import { NodeHtmlMarkdown } from 'node-html-markdown';
13
+
14
+ const USER_AGENT =
15
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 WAE-CLI/0.1.0';
16
+ const ARTICLE_LIST_PAGE_SIZE = 20;
17
+ const ACCOUNT_LIST_PAGE_SIZE = 5;
18
+ const FULL_SYNC_ARTICLE_LIMIT = 1000;
19
+ const DOWNLOAD_ALL_DEFAULT_LIMIT = 50;
20
+ const MP_BASE = 'https://mp.weixin.qq.com';
21
+ const DATA_DIR = process.env.WAE_CLI_HOME
22
+ ? path.resolve(process.env.WAE_CLI_HOME)
23
+ : path.join(homedir(), '.weixin-article-exporter-cli');
24
+
25
+ const files = {
26
+ auth: path.join(DATA_DIR, 'auth.json'),
27
+ accounts: path.join(DATA_DIR, 'accounts.json'),
28
+ articles: path.join(DATA_DIR, 'articles.json'),
29
+ qrcode: path.join(DATA_DIR, 'qrcode.jpg'),
30
+ htmlDir: path.join(DATA_DIR, 'html'),
31
+ mdDir: path.join(DATA_DIR, 'md'),
32
+ };
33
+
34
+ function parseArgs(argv) {
35
+ const args = { _: [] };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i];
38
+ if (!arg.startsWith('--')) {
39
+ args._.push(arg);
40
+ continue;
41
+ }
42
+ const key = arg.slice(2);
43
+ const next = argv[i + 1];
44
+ if (!next || next.startsWith('--')) {
45
+ args[key] = true;
46
+ } else {
47
+ args[key] = next;
48
+ i++;
49
+ }
50
+ }
51
+ return args;
52
+ }
53
+
54
+ function usage() {
55
+ return `
56
+ weixin-article-exporter CLI
57
+
58
+ Commands:
59
+ --status 获取当前登录状态
60
+ --login 获取登录二维码并等待扫码确认
61
+ --qr <both|terminal|file> 二维码展示方式,默认 both
62
+ --logout 清空登录信息
63
+ --search <keyword> 搜索公众号,通过方向键选择并回车添加
64
+ --add <index|fakeid> 跳过交互,直接添加指定搜索结果
65
+ --list-only 只显示搜索结果,不添加
66
+ --list 展示已保存公众号,方向键选择,d 移除
67
+ --list-only 仅打印列表,不进入交互模式
68
+ --verbose 显示 fakeid 等详细信息
69
+ --remove <id> 根据 --list 返回的数字 ID 移除公众号
70
+ --clear 清除全部公众号的本地同步文章索引
71
+ --id <id> 只清除 --list 返回的数字 ID
72
+ --fakeid <fakeid> 只清除指定公众号
73
+ --sync 同步已添加公众号历史文章
74
+ --id <id> 只同步 --list 返回的数字 ID
75
+ --fakeid <fakeid> 只同步指定公众号
76
+ --full 全量同步,忽略本地增量边界,最多 1000 篇
77
+ --since <YYYY-MM-DD> 同步到指定日期后停止
78
+ --delay <seconds> 分页请求间隔,默认 2
79
+ --export 导出 JSON 文章列表,按公众号分组
80
+ --from <YYYY-MM-DD> 开始日期
81
+ --to <YYYY-MM-DD> 结束日期
82
+ --out <file> 输出路径,默认 stdout
83
+ --download <id|url> 根据 fakeid:aid、aid 或文章 URL 下载 HTML
84
+ --out <file> 输出路径,默认数据目录/html/<fakeid>/<aid>.html
85
+ --raw 保留微信原始页面源码,不进行静态化
86
+ --download-all 批量下载未下载过的文章为纯文字 Markdown(不含图片)
87
+ --id <id> 只下载 --list 返回的数字 ID 对应公众号
88
+ --fakeid <fakeid> 只下载指定公众号
89
+ --limit <n> 最多下载多少篇未下载文章,默认 50
90
+ --delay <seconds> 每篇下载间隔,默认 2
91
+ --out <dir> 输出目录,默认数据目录/md/<fakeid>/<aid>.md
92
+ 下载成功会标记 downloadedAt,重复运行自动跳过已下载文章
93
+
94
+ Examples:
95
+ weixin-article-exporter --login
96
+ weixin-article-exporter --search "人民日报"
97
+ weixin-article-exporter --list
98
+ weixin-article-exporter --list --verbose
99
+ weixin-article-exporter --remove 0
100
+ weixin-article-exporter --clear --id 0
101
+ weixin-article-exporter --sync --id 0
102
+ weixin-article-exporter --sync --id 0 --full
103
+ weixin-article-exporter --sync --since 2024-01-01
104
+ weixin-article-exporter --export --from 2024-01-01 --out articles.json
105
+ weixin-article-exporter --download Mzxxx:2247483660
106
+ weixin-article-exporter --download-all --id 0 --limit 20
107
+ `.trim();
108
+ }
109
+
110
+ async function ensureDataDir() {
111
+ await mkdir(DATA_DIR, { recursive: true });
112
+ await mkdir(files.htmlDir, { recursive: true });
113
+ await mkdir(files.mdDir, { recursive: true });
114
+ }
115
+
116
+ async function readJson(file, fallback) {
117
+ if (!existsSync(file)) return fallback;
118
+ return JSON.parse(await readFile(file, 'utf8'));
119
+ }
120
+
121
+ async function writeJson(file, value) {
122
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
123
+ }
124
+
125
+ function toQuery(params) {
126
+ const query = new URLSearchParams();
127
+ for (const [key, value] of Object.entries(params)) {
128
+ if (value !== undefined && value !== null) query.set(key, String(value));
129
+ }
130
+ return query.toString();
131
+ }
132
+
133
+ function getSetCookies(headers) {
134
+ if (typeof headers.getSetCookie === 'function') {
135
+ return headers.getSetCookie();
136
+ }
137
+ const raw = headers.get('set-cookie');
138
+ if (!raw) return [];
139
+ return raw.split(/,(?=\s*[^;,=\s]+=[^;,]+)/g).map(item => item.trim());
140
+ }
141
+
142
+ function parseSetCookie(setCookie) {
143
+ const [nameValue] = setCookie.split(';');
144
+ const eq = nameValue.indexOf('=');
145
+ if (eq < 0) return null;
146
+ const name = nameValue.slice(0, eq).trim();
147
+ const value = nameValue.slice(eq + 1).trim();
148
+ if (!name || !value || value === 'EXPIRED') return null;
149
+ return [name, value];
150
+ }
151
+
152
+ function mergeCookies(cookieMap, setCookies) {
153
+ const next = { ...cookieMap };
154
+ for (const item of setCookies) {
155
+ const parsed = parseSetCookie(item);
156
+ if (!parsed) continue;
157
+ next[parsed[0]] = parsed[1];
158
+ }
159
+ return next;
160
+ }
161
+
162
+ function cookieHeader(cookieMap = {}) {
163
+ return Object.entries(cookieMap)
164
+ .filter(([, value]) => value)
165
+ .map(([name, value]) => `${name}=${value}`)
166
+ .join('; ');
167
+ }
168
+
169
+ async function mpRequest(endpoint, { method = 'GET', query, body, cookies, expect = 'json' } = {}) {
170
+ const url = `${endpoint}${query ? `?${toQuery(query)}` : ''}`;
171
+ const headers = {
172
+ Referer: 'https://mp.weixin.qq.com/',
173
+ Origin: 'https://mp.weixin.qq.com',
174
+ 'User-Agent': USER_AGENT,
175
+ 'Accept-Encoding': 'identity',
176
+ };
177
+ const cookie = cookieHeader(cookies);
178
+ if (cookie) headers.Cookie = cookie;
179
+ let requestBody;
180
+ if (method === 'POST' && body) {
181
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
182
+ requestBody = toQuery(body);
183
+ }
184
+
185
+ const response = await fetch(url, {
186
+ method,
187
+ headers,
188
+ body: requestBody,
189
+ redirect: 'follow',
190
+ });
191
+ const setCookies = getSetCookies(response.headers);
192
+ if (!response.ok) {
193
+ throw new Error(`HTTP ${response.status} ${response.statusText}: ${url}`);
194
+ }
195
+ if (expect === 'buffer') return { data: Buffer.from(await response.arrayBuffer()), setCookies };
196
+ if (expect === 'text') return { data: await response.text(), setCookies };
197
+ return { data: await response.json(), setCookies };
198
+ }
199
+
200
+ async function loadAuth(required = true) {
201
+ const auth = await readJson(files.auth, null);
202
+ if (required && (!auth || !auth.token || !auth.cookies)) {
203
+ throw new Error('未登录。请先执行:weixin-article-exporter --login');
204
+ }
205
+ return auth;
206
+ }
207
+
208
+ async function startLoginSession() {
209
+ const sid = `${Date.now()}${Math.random().toString(16).slice(2)}`;
210
+ const body = {
211
+ userlang: 'zh_CN',
212
+ redirect_url: '',
213
+ login_type: 3,
214
+ sessionid: sid,
215
+ token: '',
216
+ lang: 'zh_CN',
217
+ f: 'json',
218
+ ajax: 1,
219
+ };
220
+ const { data, setCookies } = await mpRequest(`${MP_BASE}/cgi-bin/bizlogin`, {
221
+ method: 'POST',
222
+ query: { action: 'startlogin' },
223
+ body,
224
+ });
225
+ if (!data?.base_resp || data.base_resp.ret !== 0) {
226
+ throw new Error(`${data?.base_resp?.err_msg || '获取登录会话失败'}`);
227
+ }
228
+ const cookies = mergeCookies({}, setCookies);
229
+ return { sid, data, cookies };
230
+ }
231
+
232
+ async function getLoginQrcode(cookies) {
233
+ const { data, setCookies } = await mpRequest(`${MP_BASE}/cgi-bin/scanloginqrcode`, {
234
+ query: { action: 'getqrcode', random: Date.now() },
235
+ cookies,
236
+ expect: 'buffer',
237
+ });
238
+ await writeFile(files.qrcode, data);
239
+ return mergeCookies(cookies, setCookies);
240
+ }
241
+
242
+ async function showQrCode(mode) {
243
+ const qrMode = mode === true || mode === undefined ? 'both' : String(mode);
244
+ if (!['both', 'terminal', 'file'].includes(qrMode)) {
245
+ throw new Error('--qr 只支持 both、terminal、file');
246
+ }
247
+
248
+ if (qrMode === 'file') {
249
+ console.log(`二维码已保存:${files.qrcode}`);
250
+ return;
251
+ }
252
+
253
+ const rendered = await renderQrCodeAsUnicode(files.qrcode);
254
+ if (rendered) {
255
+ process.stdout.write(`\n${rendered}\n`);
256
+ } else {
257
+ console.log('未能从二维码图片中识别出内容,已保存二维码文件。');
258
+ }
259
+
260
+ if (qrMode === 'both') {
261
+ console.log(`二维码文件:${files.qrcode}`);
262
+ }
263
+ }
264
+
265
+ // Decodes the downloaded JPEG QR image back into its encoded text, then re-renders that
266
+ // text as a Unicode half-block QR code for the terminal. This works in any terminal (no
267
+ // inline-image protocol needed) and on any OS, since jpeg-js/jsqr/qrcode are pure JS.
268
+ async function renderQrCodeAsUnicode(imagePath) {
269
+ const jpegBuffer = await readFile(imagePath);
270
+ let decoded;
271
+ try {
272
+ decoded = jpeg.decode(jpegBuffer, { useTArray: true });
273
+ } catch {
274
+ return null;
275
+ }
276
+ const pixels = new Uint8ClampedArray(decoded.data.buffer, decoded.data.byteOffset, decoded.data.length);
277
+ const result = jsQR(pixels, decoded.width, decoded.height);
278
+ if (!result?.data) return null;
279
+ return QRCode.toString(result.data, { type: 'terminal', small: true });
280
+ }
281
+
282
+
283
+ async function askScanStatus(cookies) {
284
+ const { data, setCookies } = await mpRequest(`${MP_BASE}/cgi-bin/scanloginqrcode`, {
285
+ query: { action: 'ask', token: '', lang: 'zh_CN', f: 'json', ajax: 1 },
286
+ cookies,
287
+ });
288
+ return { data, cookies: mergeCookies(cookies, setCookies) };
289
+ }
290
+
291
+ async function finishLogin(cookies) {
292
+ const payload = {
293
+ userlang: 'zh_CN',
294
+ redirect_url: '',
295
+ cookie_forbidden: 0,
296
+ cookie_cleaned: 0,
297
+ plugin_used: 0,
298
+ login_type: 3,
299
+ token: '',
300
+ lang: 'zh_CN',
301
+ f: 'json',
302
+ ajax: 1,
303
+ };
304
+ const { data, setCookies } = await mpRequest(`${MP_BASE}/cgi-bin/bizlogin`, {
305
+ method: 'POST',
306
+ query: { action: 'login' },
307
+ body: payload,
308
+ cookies,
309
+ });
310
+ const redirectUrl = data?.redirect_url;
311
+ if (!redirectUrl) {
312
+ throw new Error(`登录失败,未返回 redirect_url: ${JSON.stringify(data)}`);
313
+ }
314
+ const token = new URL(`http://localhost${redirectUrl}`).searchParams.get('token');
315
+ if (!token) {
316
+ throw new Error(`登录失败,redirect_url 中没有 token: ${redirectUrl}`);
317
+ }
318
+ const auth = {
319
+ token,
320
+ cookies: mergeCookies(cookies, setCookies),
321
+ createdAt: new Date().toISOString(),
322
+ expiresAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
323
+ };
324
+ const info = await fetchLoginInfo(auth).catch(() => null);
325
+ if (info) auth.account = info;
326
+ await writeJson(files.auth, auth);
327
+ return auth;
328
+ }
329
+
330
+ async function fetchLoginInfo(auth) {
331
+ const { data } = await mpRequest(`${MP_BASE}/cgi-bin/home`, {
332
+ query: { t: 'home/index', token: auth.token, lang: 'zh_CN' },
333
+ cookies: auth.cookies,
334
+ expect: 'text',
335
+ });
336
+ const nickName = data.match(/wx\.cgiData\.nick_name\s*?=\s*?"(?<value>[^"]+)"/)?.groups?.value || '';
337
+ const headImg = data.match(/wx\.cgiData\.head_img\s*?=\s*?"(?<value>[^"]+)"/)?.groups?.value || '';
338
+ return { nickName, headImg };
339
+ }
340
+
341
+ async function commandLogin(args) {
342
+ console.log('正在创建扫码登录会话...');
343
+ let { cookies } = await startLoginSession();
344
+ cookies = await getLoginQrcode(cookies);
345
+ await showQrCode(args.qr);
346
+ console.log('请用微信扫码并在手机上确认。CLI 将等待最多 120 秒。');
347
+
348
+ for (let i = 0; i < 60; i++) {
349
+ await sleep(2000);
350
+ const result = await askScanStatus(cookies);
351
+ cookies = result.cookies;
352
+ const status = Number(result.data?.status);
353
+ if (status === 0) {
354
+ continue;
355
+ }
356
+ if (status === 1) {
357
+ const auth = await finishLogin(cookies);
358
+ console.log(`登录成功:${auth.account?.nickName || '未知账号'}`);
359
+ console.log(`本地登录态预计保留到:${auth.expiresAt}`);
360
+ return;
361
+ }
362
+ if (status === 4 || status === 6) {
363
+ console.log('扫码成功,等待手机端确认...');
364
+ continue;
365
+ }
366
+ if (status === 2 || status === 3) {
367
+ throw new Error('二维码已过期,请重新执行 --login');
368
+ }
369
+ if (status === 5) {
370
+ throw new Error('该账号尚未绑定邮箱,不能扫码登录');
371
+ }
372
+ console.log(`扫码状态:${JSON.stringify(result.data)}`);
373
+ }
374
+ throw new Error('登录超时,请重新执行 --login');
375
+ }
376
+
377
+ async function commandStatus() {
378
+ const auth = await loadAuth(false);
379
+ if (!auth) {
380
+ console.log('未登录');
381
+ return;
382
+ }
383
+ try {
384
+ const info = await fetchLoginInfo(auth);
385
+ auth.account = info;
386
+ await writeJson(files.auth, auth);
387
+ console.log('已登录');
388
+ console.log(`账号:${info.nickName || '未知'}`);
389
+ console.log(`本地登录态创建:${auth.createdAt}`);
390
+ console.log(`本地登录态过期:${auth.expiresAt}`);
391
+ } catch (error) {
392
+ console.log('登录态可能已失效');
393
+ console.log(error.message);
394
+ }
395
+ }
396
+
397
+ async function commandLogout() {
398
+ await rm(files.auth, { force: true });
399
+ console.log('已清空登录信息');
400
+ }
401
+
402
+ async function searchAccounts(keyword) {
403
+ const auth = await loadAuth();
404
+ const { data } = await mpRequest(`${MP_BASE}/cgi-bin/searchbiz`, {
405
+ query: {
406
+ action: 'search_biz',
407
+ begin: 0,
408
+ count: ACCOUNT_LIST_PAGE_SIZE,
409
+ query: keyword,
410
+ token: auth.token,
411
+ lang: 'zh_CN',
412
+ f: 'json',
413
+ ajax: '1',
414
+ },
415
+ cookies: auth.cookies,
416
+ });
417
+ if (data.base_resp?.ret !== 0) {
418
+ throw new Error(`${data.base_resp?.ret}:${data.base_resp?.err_msg || '搜索公众号失败'}`);
419
+ }
420
+ return data.list || [];
421
+ }
422
+
423
+ function formatAccount(account, index, verbose = false) {
424
+ const alias = account.alias ? ` 微信号:${account.alias}` : '';
425
+ const fakeid = verbose ? ` fakeid:${account.fakeid}` : '';
426
+ return `[${index}] ${account.nickname}${alias}${fakeid}`;
427
+ }
428
+
429
+ function parseNumericArg(value, message) {
430
+ if (value === true || !/^\d+$/.test(String(value))) {
431
+ throw new Error(message);
432
+ }
433
+ return Number(value);
434
+ }
435
+
436
+ function getAccountById(accounts, id) {
437
+ const account = accounts[id];
438
+ if (!account) throw new Error(`找不到公众号 ID:${id}`);
439
+ return account;
440
+ }
441
+
442
+ async function removeAccountById(id) {
443
+ const accounts = await readJson(files.accounts, []);
444
+ getAccountById(accounts, id);
445
+ const [removed] = accounts.splice(id, 1);
446
+ await writeJson(files.accounts, accounts);
447
+ return removed;
448
+ }
449
+
450
+ function requireInteractiveTty(message) {
451
+ if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') {
452
+ throw new Error(message);
453
+ }
454
+ }
455
+
456
+ // Shared raw-mode keyboard menu used by `selectAccount` (pick one search result) and
457
+ // `commandList` (browse + delete saved accounts). `getItems` is read fresh on every
458
+ // keypress so callers may mutate the underlying array (e.g. delete) between renders.
459
+ async function runInteractiveMenu({ getItems, header, renderItem, onEnter, onEscape, onNavigate, onKey, isBusy }) {
460
+ readline.emitKeypressEvents(process.stdin);
461
+ const wasRaw = process.stdin.isRaw;
462
+ let selected = 0;
463
+ let renderedLines = 0;
464
+
465
+ const render = () => {
466
+ const items = getItems();
467
+ if (renderedLines > 0) process.stdout.write(`\u001b[${renderedLines}A\u001b[J`);
468
+ process.stdout.write('\u001b[?25l');
469
+ process.stdout.write(`${header()}\u001b[K\n`);
470
+ items.forEach((item, index) => {
471
+ process.stdout.write(`${renderItem(item, index, index === selected)}\u001b[K\n`);
472
+ });
473
+ renderedLines = items.length + 1;
474
+ };
475
+
476
+ render();
477
+ process.stdin.setRawMode(true);
478
+ process.stdin.resume();
479
+
480
+ return new Promise((resolve, reject) => {
481
+ const cleanup = () => {
482
+ process.stdin.off('keypress', onKeypress);
483
+ process.stdin.setRawMode(Boolean(wasRaw));
484
+ process.stdin.pause();
485
+ process.stdout.write('\u001b[?25h');
486
+ };
487
+
488
+ const onKeypress = async (_text, key = {}) => {
489
+ if (isBusy && isBusy()) return;
490
+ const items = getItems();
491
+ if (key.ctrl && key.name === 'c') {
492
+ cleanup();
493
+ process.stdout.write('\n');
494
+ reject(new Error('已取消'));
495
+ return;
496
+ }
497
+ if (key.name === 'up' || _text === 'k') {
498
+ selected = (selected - 1 + items.length) % items.length;
499
+ if (onNavigate) onNavigate();
500
+ render();
501
+ return;
502
+ }
503
+ if (key.name === 'down' || _text === 'j') {
504
+ selected = (selected + 1) % items.length;
505
+ if (onNavigate) onNavigate();
506
+ render();
507
+ return;
508
+ }
509
+ if (key.name === 'return') {
510
+ cleanup();
511
+ resolve(onEnter(items[selected], selected));
512
+ return;
513
+ }
514
+ if (key.name === 'escape' || _text === 'q') {
515
+ cleanup();
516
+ resolve(onEscape ? onEscape() : null);
517
+ return;
518
+ }
519
+ if (onKey) {
520
+ await onKey({
521
+ text: _text,
522
+ key,
523
+ selected,
524
+ items,
525
+ setSelected: value => {
526
+ selected = value;
527
+ },
528
+ render,
529
+ cleanup,
530
+ resolve,
531
+ reject,
532
+ });
533
+ }
534
+ };
535
+
536
+ process.stdin.on('keypress', onKeypress);
537
+ });
538
+ }
539
+
540
+ async function selectAccount(results) {
541
+ requireInteractiveTty('当前不是交互式终端,请使用 --add <index|fakeid> 指定公众号,或使用 --list-only 只查看结果');
542
+
543
+ return runInteractiveMenu({
544
+ getItems: () => results,
545
+ header: () => '请选择要添加的公众号(↑/↓ 移动,回车确认,Esc/q 取消)',
546
+ renderItem: (account, index, isSelected) => {
547
+ const marker = isSelected ? '\u001b[36m❯\u001b[0m' : ' ';
548
+ const label = isSelected ? `\u001b[36m${formatAccount(account, index)}\u001b[0m` : formatAccount(account, index);
549
+ return `${marker} ${label}`;
550
+ },
551
+ onEnter: account => account,
552
+ onEscape: () => {
553
+ process.stdout.write('\n已取消添加\n');
554
+ return null;
555
+ },
556
+ });
557
+ }
558
+
559
+ async function commandSearch(args) {
560
+ const keyword = args.search;
561
+ if (typeof keyword !== 'string') throw new Error('--search 需要关键词');
562
+ const results = await searchAccounts(keyword);
563
+ if (results.length === 0) {
564
+ console.log('没有搜索到公众号');
565
+ return;
566
+ }
567
+ if (args['list-only']) {
568
+ results.forEach((account, index) => console.log(formatAccount(account, index)));
569
+ return;
570
+ }
571
+
572
+ let account;
573
+ if (args.add !== undefined) {
574
+ if (args.add === true) throw new Error('--add 需要搜索结果序号或 fakeid');
575
+ const add = String(args.add);
576
+ account = /^\d+$/.test(add) ? results[Number(add)] : results.find(item => item.fakeid === add);
577
+ if (!account) throw new Error(`找不到要添加的搜索结果:${add}`);
578
+ } else {
579
+ account = await selectAccount(results);
580
+ if (!account) return;
581
+ }
582
+ const accounts = await readJson(files.accounts, []);
583
+ const next = accounts.filter(item => item.fakeid !== account.fakeid);
584
+ next.push({
585
+ fakeid: account.fakeid,
586
+ nickname: account.nickname,
587
+ alias: account.alias,
588
+ round_head_img: account.round_head_img,
589
+ service_type: account.service_type,
590
+ signature: account.signature,
591
+ addedAt: new Date().toISOString(),
592
+ });
593
+ await writeJson(files.accounts, next);
594
+ console.log(`已添加公众号:${account.nickname} (${account.fakeid})`);
595
+ }
596
+
597
+ async function commandList(args) {
598
+ const accounts = await readJson(files.accounts, []);
599
+ if (accounts.length === 0) {
600
+ console.log('公众号列表为空');
601
+ return;
602
+ }
603
+ if (args['list-only']) {
604
+ accounts.forEach((account, index) => console.log(formatAccount(account, index, Boolean(args.verbose))));
605
+ return;
606
+ }
607
+ requireInteractiveTty('当前不是交互式终端,请使用 --list --list-only 仅打印列表,或使用 --remove <ID> 移除');
608
+
609
+ let status = '';
610
+ let busy = false;
611
+
612
+ await runInteractiveMenu({
613
+ getItems: () => accounts,
614
+ header: () => status || '已保存公众号(↑/↓ 移动,d 移除,Esc/q/回车 退出)',
615
+ renderItem: (account, index, isSelected) => {
616
+ const marker = isSelected ? '\u001b[36m❯\u001b[0m' : ' ';
617
+ const formatted = formatAccount(account, index, Boolean(args.verbose));
618
+ const label = isSelected ? `\u001b[36m${formatted}\u001b[0m` : formatted;
619
+ return `${marker} ${label}`;
620
+ },
621
+ isBusy: () => busy,
622
+ onNavigate: () => {
623
+ status = '';
624
+ },
625
+ onEnter: () => {
626
+ process.stdout.write('\n');
627
+ },
628
+ onEscape: () => {
629
+ process.stdout.write('\n');
630
+ },
631
+ onKey: async ({ text, selected, items, setSelected, render, cleanup, resolve, reject }) => {
632
+ if (text !== 'd') return;
633
+ busy = true;
634
+ try {
635
+ const [removed] = items.splice(selected, 1);
636
+ await writeJson(files.accounts, items);
637
+ if (items.length === 0) {
638
+ cleanup();
639
+ process.stdout.write(`\n已移除公众号:${removed.nickname}\n公众号列表为空\n`);
640
+ resolve();
641
+ return;
642
+ }
643
+ setSelected(Math.min(selected, items.length - 1));
644
+ status = `已移除公众号:${removed.nickname}`;
645
+ render();
646
+ } catch (error) {
647
+ cleanup();
648
+ reject(error);
649
+ } finally {
650
+ busy = false;
651
+ }
652
+ },
653
+ });
654
+ }
655
+
656
+ async function commandRemove(args) {
657
+ const id = parseNumericArg(args.remove, '--remove 需要 --list 返回的数字 ID');
658
+ const removed = await removeAccountById(id);
659
+ console.log(`已移除公众号:${removed.nickname} (${removed.fakeid})`);
660
+ }
661
+
662
+ async function fetchArticlePage(auth, account, begin) {
663
+ const { data } = await mpRequest(`${MP_BASE}/cgi-bin/appmsgpublish`, {
664
+ query: {
665
+ sub: 'list',
666
+ search_field: 'null',
667
+ begin,
668
+ count: ARTICLE_LIST_PAGE_SIZE,
669
+ query: '',
670
+ fakeid: account.fakeid,
671
+ type: '101_1',
672
+ free_publish_type: 1,
673
+ sub_action: 'list_ex',
674
+ token: auth.token,
675
+ lang: 'zh_CN',
676
+ f: 'json',
677
+ ajax: 1,
678
+ },
679
+ cookies: auth.cookies,
680
+ });
681
+ if (data.base_resp?.ret !== 0) {
682
+ throw new Error(`${data.base_resp?.ret}:${data.base_resp?.err_msg || '文章列表接口失败'}`);
683
+ }
684
+ return JSON.parse(data.publish_page);
685
+ }
686
+
687
+ // `article` is the freshly fetched WeChat record; `existing` (if any) is the previously
688
+ // stored local copy. Re-syncing must not wipe locally-added state like `downloadedAt`
689
+ // (set by `--download-all`) just because the article still shows up in a synced page.
690
+ function normalizeArticle(account, article, existing) {
691
+ return {
692
+ ...article,
693
+ fakeid: account.fakeid,
694
+ accountNickname: account.nickname,
695
+ syncedAt: new Date().toISOString(),
696
+ ...(existing?.downloadedAt ? { downloadedAt: existing.downloadedAt } : {}),
697
+ };
698
+ }
699
+
700
+ async function syncOneAccount(store, auth, account, options) {
701
+ const current = store[account.fakeid] || [];
702
+ const startCount = current.length;
703
+ const byKey = new Map(current.map(article => [article.aid, article]));
704
+ const incremental = !options.full && current.length > 0;
705
+ const articleLimit = incremental ? Number.POSITIVE_INFINITY : FULL_SYNC_ARTICLE_LIMIT;
706
+ const sinceTs = dateToTs(options.since) || 0;
707
+ let begin = 0;
708
+ let processed = 0;
709
+
710
+ const persist = async () => {
711
+ store[account.fakeid] = Array.from(byKey.values()).sort((a, b) => b.create_time - a.create_time);
712
+ await writeJson(files.articles, store);
713
+ };
714
+
715
+ // Persisted after every page (not just once at the end) so a network error or WeChat
716
+ // rate-limit mid-sync doesn't throw away pages already fetched in this run.
717
+ while (true) {
718
+ const page = await fetchArticlePage(auth, account, begin);
719
+ const publishList = (page.publish_list || []).filter(item => item.publish_info);
720
+ if (publishList.length === 0) break;
721
+
722
+ const pageArticles = publishList.flatMap(item => JSON.parse(item.publish_info).appmsgex || []);
723
+ const articles = pageArticles.slice(0, articleLimit - processed);
724
+ const newArticleCount = articles.filter(article => !byKey.has(article.aid)).length;
725
+ for (const article of articles) {
726
+ byKey.set(article.aid, normalizeArticle(account, article, byKey.get(article.aid)));
727
+ }
728
+ processed += articles.length;
729
+ await persist();
730
+
731
+ const messageCount = pageArticles.filter(article => article.itemidx === 1).length;
732
+ begin += messageCount;
733
+ const lastArticle = articles.at(-1);
734
+ console.log(`${account.nickname}: 已同步 ${byKey.size} 篇,begin=${begin}`);
735
+
736
+ if (sinceTs && lastArticle && lastArticle.create_time < sinceTs) break;
737
+ if (processed >= articleLimit) {
738
+ console.log(`${account.nickname}: 已达到全量同步上限 ${FULL_SYNC_ARTICLE_LIMIT} 篇`);
739
+ break;
740
+ }
741
+ if (incremental && newArticleCount === 0) {
742
+ console.log(`${account.nickname}: 已到达本地同步边界`);
743
+ break;
744
+ }
745
+ if (messageCount === 0) break;
746
+ await sleep(options.delay * 1000);
747
+ }
748
+
749
+ await persist();
750
+ return { total: store[account.fakeid].length, touched: byKey.size - startCount, processed };
751
+ }
752
+
753
+ async function commandSync(args) {
754
+ const auth = await loadAuth();
755
+ const accounts = await readJson(files.accounts, []);
756
+ if (args.id !== undefined && args.fakeid !== undefined) {
757
+ throw new Error('--id 和 --fakeid 不能同时使用');
758
+ }
759
+
760
+ let targets = accounts;
761
+ if (args.id !== undefined) {
762
+ const id = parseNumericArg(args.id, '--id 需要 --list 返回的数字 ID');
763
+ targets = [getAccountById(accounts, id)];
764
+ } else if (args.fakeid !== undefined) {
765
+ if (args.fakeid === true) throw new Error('--fakeid 需要公众号 fakeid');
766
+ targets = accounts.filter(item => item.fakeid === args.fakeid);
767
+ if (targets.length === 0) throw new Error(`未添加公众号:${args.fakeid}`);
768
+ }
769
+ if (targets.length === 0) throw new Error('公众号列表为空,请先 --search 添加');
770
+ const delay = Number(args.delay || 2);
771
+ const articles = await readJson(files.articles, {});
772
+ const failures = [];
773
+ for (const account of targets) {
774
+ const mode = args.full || !articles[account.fakeid]?.length ? '全量' : '增量';
775
+ console.log(`开始${mode}同步:${account.nickname} (${account.fakeid})`);
776
+ try {
777
+ const result = await syncOneAccount(articles, auth, account, {
778
+ since: args.since,
779
+ delay,
780
+ full: Boolean(args.full),
781
+ });
782
+ const summary = [
783
+ `完成:${account.nickname}`,
784
+ `本次处理 ${result.processed} 篇`,
785
+ `新增 ${result.touched} 篇`,
786
+ `当前缓存 ${result.total} 篇`,
787
+ ];
788
+ console.log(summary.join(','));
789
+ } catch (error) {
790
+ console.log(`同步失败:${account.nickname} (${account.fakeid}):${error.message}`);
791
+ failures.push(account.nickname);
792
+ }
793
+ }
794
+ if (failures.length > 0) {
795
+ throw new Error(`以下公众号同步失败,已跳过(此前页面的进度已保存):${failures.join('、')}`);
796
+ }
797
+ }
798
+
799
+ async function commandClear(args) {
800
+ if (args.id !== undefined && args.fakeid !== undefined) {
801
+ throw new Error('--id 和 --fakeid 不能同时使用');
802
+ }
803
+
804
+ const articles = await readJson(files.articles, {});
805
+ let fakeid;
806
+ let nickname;
807
+
808
+ if (args.id !== undefined) {
809
+ const accounts = await readJson(files.accounts, []);
810
+ const id = parseNumericArg(args.id, '--id 需要 --list 返回的数字 ID');
811
+ const account = getAccountById(accounts, id);
812
+ fakeid = account.fakeid;
813
+ nickname = account.nickname;
814
+ } else if (args.fakeid !== undefined) {
815
+ if (args.fakeid === true) throw new Error('--fakeid 需要公众号 fakeid');
816
+ const accounts = await readJson(files.accounts, []);
817
+ fakeid = String(args.fakeid);
818
+ nickname = accounts.find(account => account.fakeid === fakeid)?.nickname || fakeid;
819
+ }
820
+
821
+ if (fakeid) {
822
+ const count = Array.isArray(articles[fakeid]) ? articles[fakeid].length : 0;
823
+ delete articles[fakeid];
824
+ await writeJson(files.articles, articles);
825
+ console.log(`已清除同步数据:${nickname},共 ${count} 篇文章索引`);
826
+ return;
827
+ }
828
+
829
+ const count = Object.values(articles).reduce((total, list) => total + (Array.isArray(list) ? list.length : 0), 0);
830
+ await writeJson(files.articles, {});
831
+ console.log(`已清除全部同步数据,共 ${count} 篇文章索引`);
832
+ }
833
+
834
+ function dateToTs(value, endOfDay = false) {
835
+ if (!value) return null;
836
+ const suffix = endOfDay ? 'T23:59:59+08:00' : 'T00:00:00+08:00';
837
+ return Math.floor(new Date(`${value}${suffix}`).getTime() / 1000);
838
+ }
839
+
840
+ async function commandExport(args) {
841
+ const accounts = await readJson(files.accounts, []);
842
+ const articles = await readJson(files.articles, {});
843
+ const from = dateToTs(args.from);
844
+ const to = dateToTs(args.to, true);
845
+ const grouped = accounts.map(account => {
846
+ const list = (articles[account.fakeid] || []).filter(article => {
847
+ if (from && article.create_time < from) return false;
848
+ if (to && article.create_time > to) return false;
849
+ return true;
850
+ });
851
+ return {
852
+ fakeid: account.fakeid,
853
+ nickname: account.nickname,
854
+ alias: account.alias,
855
+ articleCount: list.length,
856
+ articles: list,
857
+ };
858
+ });
859
+ const output = {
860
+ exportedAt: new Date().toISOString(),
861
+ range: { from: args.from || null, to: args.to || null },
862
+ accountCount: grouped.length,
863
+ accounts: grouped,
864
+ };
865
+ const text = `${JSON.stringify(output, null, 2)}\n`;
866
+ if (args.out) {
867
+ await writeFile(path.resolve(String(args.out)), text, 'utf8');
868
+ console.log(`已导出:${path.resolve(String(args.out))}`);
869
+ } else {
870
+ process.stdout.write(text);
871
+ }
872
+ }
873
+
874
+ async function findArticle(identifier) {
875
+ const articles = await readJson(files.articles, {});
876
+ if (identifier.startsWith('http')) {
877
+ const all = Object.values(articles).flat();
878
+ return all.find(article => article.link === identifier) || { link: identifier, fakeid: 'unknown', aid: String(Date.now()) };
879
+ }
880
+ if (identifier.includes(':')) {
881
+ const [fakeid, aid] = identifier.split(':');
882
+ return (articles[fakeid] || []).find(article => article.aid === aid);
883
+ }
884
+ return Object.values(articles).flat().find(article => article.aid === identifier);
885
+ }
886
+
887
+ function validateHtml(html) {
888
+ if (/\bid=["']js_article["']/.test(html)) return ['Success', null];
889
+ const msg =
890
+ html
891
+ .match(/<div[^>]+class=["'][^"']*(?:weui-msg__title|mesg-block)[^"']*["'][^>]*>(?<value>[\s\S]*?)<\/div>/)
892
+ ?.groups?.value?.replace(/<[^>]+>/g, '')
893
+ .trim()
894
+ .replace(/\s+/g, ' ') || '';
895
+ if (msg) return ['Exception', msg];
896
+ return ['Error', null];
897
+ }
898
+
899
+ function makeStaticHtml(html) {
900
+ let output = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
901
+
902
+ output = output.replace(/<img\b[^>]*>/gi, tag => {
903
+ const dataSrc = tag.match(/\sdata-src=(["'])([\s\S]*?)\1/i);
904
+ if (!dataSrc) return tag;
905
+ if (/\ssrc=(["'])[\s\S]*?\1/i.test(tag)) {
906
+ return tag.replace(/\ssrc=(["'])[\s\S]*?\1/i, ` src=${dataSrc[1]}${dataSrc[2]}${dataSrc[1]}`);
907
+ }
908
+ return tag.replace(/<img\b/i, `<img src=${dataSrc[1]}${dataSrc[2]}${dataSrc[1]}`);
909
+ });
910
+
911
+ output = output.replace(/(\s(?:src|href)=["'])\/\//gi, '$1https://');
912
+
913
+ const staticStyle = `
914
+ <style id="wae-static-html">
915
+ #js_content { visibility: visible !important; opacity: 1 !important; }
916
+ #js_content img { max-width: 100%; height: auto; }
917
+ </style>`;
918
+ return /<\/head>/i.test(output) ? output.replace(/<\/head>/i, `${staticStyle}\n</head>`) : `${staticStyle}\n${output}`;
919
+ }
920
+
921
+ async function commandDownload(args) {
922
+ const identifier = args.download;
923
+ if (typeof identifier !== 'string') throw new Error('--download 需要 fakeid:aid、aid 或文章 URL');
924
+ const article = await findArticle(identifier);
925
+ if (!article?.link) throw new Error(`找不到文章:${identifier}`);
926
+
927
+ const { data } = await mpRequest(article.link, { expect: 'text' });
928
+ const [status, reason] = validateHtml(data);
929
+ if (status !== 'Success') {
930
+ throw new Error(`文章 HTML 下载异常:${status}${reason ? ` ${reason}` : ''}`);
931
+ }
932
+
933
+ let out = args.out ? path.resolve(String(args.out)) : null;
934
+ if (out) {
935
+ await mkdir(path.dirname(out), { recursive: true });
936
+ } else {
937
+ const dir = path.join(files.htmlDir, article.fakeid || 'unknown');
938
+ await mkdir(dir, { recursive: true });
939
+ out = path.join(dir, `${article.aid || Date.now()}.html`);
940
+ }
941
+ const outputHtml = args.raw ? data : makeStaticHtml(data);
942
+ await writeFile(out, outputHtml, 'utf8');
943
+ console.log(`已下载${args.raw ? '原始' : '静态'} HTML:${out}`);
944
+ }
945
+
946
+ // Pulls out the inner HTML of the `id="js_content"` element (WeChat's article body wrapper)
947
+ // by counting matching open/close tags of that element's own tag name, so nested elements
948
+ // with the same tag name don't prematurely close the match. Returns null if not found
949
+ // (e.g. non-standard article types like image shares, per AGENTS.md).
950
+ function extractContentHtml(html) {
951
+ const openMatch = html.match(/<([a-z][\w:-]*)\b[^>]*\bid=["']js_content["'][^>]*>/i);
952
+ if (!openMatch) return null;
953
+ const tagName = openMatch[1];
954
+ const startIdx = openMatch.index + openMatch[0].length;
955
+ const openRe = new RegExp(`<${tagName}\\b[^>]*>`, 'gi');
956
+ const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
957
+ let depth = 1;
958
+ let cursor = startIdx;
959
+ while (depth > 0) {
960
+ openRe.lastIndex = cursor;
961
+ closeRe.lastIndex = cursor;
962
+ const nextOpen = openRe.exec(html);
963
+ const nextClose = closeRe.exec(html);
964
+ if (!nextClose) return html.slice(startIdx);
965
+ if (nextOpen && nextOpen.index < nextClose.index) {
966
+ depth++;
967
+ cursor = nextOpen.index + nextOpen[0].length;
968
+ } else {
969
+ depth--;
970
+ cursor = nextClose.index + nextClose[0].length;
971
+ if (depth === 0) return html.slice(startIdx, nextClose.index);
972
+ }
973
+ }
974
+ return null;
975
+ }
976
+
977
+ const markdownConverter = new NodeHtmlMarkdown({ ignore: ['img', 'script', 'style'] });
978
+
979
+ function convertToMarkdown(contentHtml) {
980
+ return markdownConverter.translate(contentHtml).trim();
981
+ }
982
+
983
+ const HTML_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' };
984
+
985
+ function decodeHtmlEntities(text) {
986
+ return text
987
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
988
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
989
+ .replace(/&(lt|gt|amp|quot|apos|nbsp);/g, (_, name) => HTML_ENTITIES[name]);
990
+ }
991
+
992
+ // Image-share articles (item_show_type=8) have no #js_content — the caption text instead
993
+ // ships as a JS string literal (`window.desc = "..."`), hex-escaped (`\xHH`) and with any
994
+ // embedded HTML further entity-escaped (`&lt;a ...&gt;` etc). Converting `\xHH` to `\u00HH`
995
+ // lets JSON.parse do the JS-string unescaping, then a second entity-decode pass recovers
996
+ // any real embedded markup before handing it to the same HTML->Markdown converter.
997
+ function extractDescMarkdown(html) {
998
+ const match = html.match(/window\.desc\s*=\s*"((?:[^"\\]|\\.)*)"/);
999
+ if (!match) return null;
1000
+ let raw;
1001
+ try {
1002
+ raw = JSON.parse(`"${match[1].replace(/\\x([0-9a-fA-F]{2})/g, '\\u00$1')}"`);
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ const decoded = decodeHtmlEntities(raw);
1007
+ const paragraphHtml = decoded
1008
+ .split(/\n{2,}/)
1009
+ .map(p => p.trim())
1010
+ .filter(Boolean)
1011
+ .map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
1012
+ .join('\n');
1013
+ if (!paragraphHtml) return null;
1014
+ return convertToMarkdown(paragraphHtml);
1015
+ }
1016
+
1017
+ function extractArticleMarkdown(html) {
1018
+ const contentHtml = extractContentHtml(html);
1019
+ if (contentHtml) return convertToMarkdown(contentHtml);
1020
+ return extractDescMarkdown(html);
1021
+ }
1022
+
1023
+ async function commandDownloadAll(args) {
1024
+ if (args.id !== undefined && args.fakeid !== undefined) {
1025
+ throw new Error('--id 和 --fakeid 不能同时使用');
1026
+ }
1027
+
1028
+ const accounts = await readJson(files.accounts, []);
1029
+ let targetAccounts = accounts;
1030
+ if (args.id !== undefined) {
1031
+ const id = parseNumericArg(args.id, '--id 需要 --list 返回的数字 ID');
1032
+ targetAccounts = [getAccountById(accounts, id)];
1033
+ } else if (args.fakeid !== undefined) {
1034
+ if (args.fakeid === true) throw new Error('--fakeid 需要公众号 fakeid');
1035
+ targetAccounts = accounts.filter(item => item.fakeid === args.fakeid);
1036
+ if (targetAccounts.length === 0) throw new Error(`未添加公众号:${args.fakeid}`);
1037
+ }
1038
+ if (targetAccounts.length === 0) throw new Error('公众号列表为空,请先 --search 添加');
1039
+
1040
+ const limit = args.limit === undefined ? DOWNLOAD_ALL_DEFAULT_LIMIT : Number(args.limit);
1041
+ if (!Number.isInteger(limit) || limit <= 0) {
1042
+ throw new Error('--limit 需要正整数');
1043
+ }
1044
+ const delay = Number(args.delay || 2);
1045
+ const outBase = args.out ? path.resolve(String(args.out)) : files.mdDir;
1046
+
1047
+ const articles = await readJson(files.articles, {});
1048
+ const accountByFakeid = new Map(accounts.map(account => [account.fakeid, account]));
1049
+
1050
+ // Not specifying --id/--fakeid pools undownloaded articles across ALL accounts and still
1051
+ // caps at `limit` total (not per account), so a large account list can't balloon a single
1052
+ // invocation into hundreds of requests against WeChat's anti-scraping limits.
1053
+ const candidates = targetAccounts
1054
+ .flatMap(account => (articles[account.fakeid] || []).filter(article => !article.downloadedAt))
1055
+ .sort((a, b) => b.create_time - a.create_time)
1056
+ .slice(0, limit);
1057
+
1058
+ if (candidates.length === 0) {
1059
+ console.log('没有待下载的文章(可能都已下载,或尚未 --sync)。');
1060
+ return;
1061
+ }
1062
+
1063
+ console.log(`本次将下载 ${candidates.length} 篇未下载文章,每篇间隔 ${delay} 秒`);
1064
+
1065
+ let downloaded = 0;
1066
+ let failed = 0;
1067
+ for (const article of candidates) {
1068
+ const account = accountByFakeid.get(article.fakeid);
1069
+ const label = `${account?.nickname || article.fakeid}《${article.title || article.aid}》`;
1070
+ try {
1071
+ const { data } = await mpRequest(article.link, { expect: 'text' });
1072
+ const [status, reason] = validateHtml(data);
1073
+ if (status !== 'Success') {
1074
+ throw new Error(`HTML 异常:${status}${reason ? ` ${reason}` : ''}`);
1075
+ }
1076
+ const markdown = extractArticleMarkdown(data);
1077
+ if (!markdown) {
1078
+ throw new Error('未找到正文内容(既不是标准图文也不是图片分享)');
1079
+ }
1080
+ const dir = path.join(outBase, article.fakeid);
1081
+ await mkdir(dir, { recursive: true });
1082
+ const outPath = path.join(dir, `${article.aid}.md`);
1083
+ const heading = article.title ? `# ${article.title}\n\n` : '';
1084
+ await writeFile(outPath, `${heading}${markdown}\n`, 'utf8');
1085
+
1086
+ article.downloadedAt = new Date().toISOString();
1087
+ article.mdPath = outPath;
1088
+ await writeJson(files.articles, articles);
1089
+ downloaded++;
1090
+ console.log(`已下载:${label} -> ${outPath}`);
1091
+ } catch (error) {
1092
+ failed++;
1093
+ console.log(`下载失败:${label}:${error.message}`);
1094
+ }
1095
+ if (article !== candidates.at(-1)) {
1096
+ await sleep(delay * 1000);
1097
+ }
1098
+ }
1099
+
1100
+ console.log(`完成:成功 ${downloaded} 篇,失败 ${failed} 篇`);
1101
+ if (failed > 0) {
1102
+ throw new Error(`${failed} 篇文章下载失败,可重新执行 --download-all 重试(已成功的文章会自动跳过)`);
1103
+ }
1104
+ }
1105
+
1106
+ async function main() {
1107
+ const args = parseArgs(process.argv.slice(2));
1108
+ await ensureDataDir();
1109
+
1110
+ if (args.help || Object.keys(args).length === 1) {
1111
+ console.log(usage());
1112
+ } else if (args.status) {
1113
+ await commandStatus();
1114
+ } else if (args.login) {
1115
+ await commandLogin(args);
1116
+ } else if (args.logout) {
1117
+ await commandLogout();
1118
+ } else if (args.search) {
1119
+ await commandSearch(args);
1120
+ } else if (args.list) {
1121
+ await commandList(args);
1122
+ } else if (args.remove) {
1123
+ await commandRemove(args);
1124
+ } else if (args.clear) {
1125
+ await commandClear(args);
1126
+ } else if (args.sync) {
1127
+ await commandSync(args);
1128
+ } else if (args.export) {
1129
+ await commandExport(args);
1130
+ } else if (args.download) {
1131
+ await commandDownload(args);
1132
+ } else if (args['download-all']) {
1133
+ await commandDownloadAll(args);
1134
+ } else {
1135
+ console.log(usage());
1136
+ process.exitCode = 1;
1137
+ }
1138
+ }
1139
+
1140
+ main().catch(error => {
1141
+ console.error(error.message);
1142
+ process.exitCode = 1;
1143
+ });