@cloud411716/fancy-webnovel 0.3.11 → 0.3.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -7,13 +7,15 @@
7
7
  * 书名/作者/简介/题材/标签(番茄列表页有字体反爬,详情页 HTML 里是明文)。
8
8
  * 输出 Markdown 格式匹配 scan-output-format.md 规范。
9
9
  *
10
+ * 重试机制:每个品类/每个榜单 最多重试3次,每次间隔5秒。
11
+ *
10
12
  * 用法:
11
- * node fanqie-rank-scraper.js --channel 1 --type 2 # 男频阅读榜
12
- * node fanqie-rank-scraper.js --channel 0 --type 1 # 女频新书榜
13
- * node fanqie-rank-scraper.js --channel all # 全部采集
14
- * node fanqie-rank-scraper.js --channel 1 --top 15 # 每题材只取前 15 本
15
- * node fanqie-rank-scraper.js --login-wait 60 # 打开浏览器后等待用户登录(秒)
16
- * node fanqie-rank-scraper.js --outdir ./ # 指定输出目录
13
+ * node fanqie-rank-scraper.cjs --channel 1 --type 2 # 男频阅读榜
14
+ * node fanqie-rank-scraper.cjs --channel 0 --type 1 # 女频新书榜
15
+ * node fanqie-rank-scraper.cjs --channel all # 全部采集
16
+ * node fanqie-rank-scraper.cjs --channel 1 --top 15 # 每题材只取前 15 本
17
+ * node fanqie-rank-scraper.cjs --login-wait 60 # 打开浏览器后等待用户登录(秒)
18
+ * node fanqie-rank-scraper.cjs --outdir ./ # 指定输出目录
17
19
  */
18
20
 
19
21
  const fs = require("fs");
@@ -23,46 +25,78 @@ const { getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
23
25
 
24
26
  // 一次详情请求的并发批大小
25
27
  const DETAIL_CHUNK = 5;
26
-
27
- // ---------------------------------------------------------------------------
28
- // 工具函数
29
- // ---------------------------------------------------------------------------
28
+ // 重试参数
29
+ const MAX_RETRIES = 3;
30
+ const RETRY_DELAY_MS = 5000;
30
31
 
31
32
  function sleep(ms) {
32
- // 同步等待:Atomics.wait 在主线程阻塞,不占用事件循环
33
33
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
34
34
  }
35
35
 
36
+ /**
37
+ * 重试包装器
38
+ * @param {Function} fn 异步任务函数,返回结果或抛异常
39
+ * @param {number} retries 最大重试次数(含首次)
40
+ * @param {number} delayMs 重试间隔(毫秒)
41
+ * @param {string} label 日志标签
42
+ * @returns {Promise<{_failed:boolean, err:Error, label:string}|*>} 失败时返回特殊标记对象
43
+ */
44
+ async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, label = '') {
45
+ let lastErr;
46
+ for (let attempt = 1; attempt <= retries; attempt++) {
47
+ try {
48
+ return await fn();
49
+ } catch (err) {
50
+ lastErr = err;
51
+ if (attempt < retries) {
52
+ console.log(` ⏳ ${label} 第${attempt}次失败,${delayMs / 1000}s后重试...`);
53
+ sleep(delayMs);
54
+ }
55
+ }
56
+ }
57
+ // 全部重试失败
58
+ return { _failed: true, err: lastErr, label };
59
+ }
60
+
36
61
  // ---------------------------------------------------------------------------
37
62
  // 页面提取
38
63
  // ---------------------------------------------------------------------------
39
64
 
40
65
  /** 连通性 + 页面就绪自检 */
41
66
  async function probePage(page) {
42
- return page.evaluate(() => ({
43
- host: location.host,
44
- hasState: !!window.__INITIAL_STATE__,
45
- }));
67
+ return page.evaluate(() => {
68
+ const s = window.__INITIAL_STATE__ || {};
69
+ const host = window.location.hostname;
70
+ const hasState = !!(s.rank || s.rankData || s.page);
71
+ return { host, hasState };
72
+ });
46
73
  }
47
74
 
48
- /** 提取侧边菜单品类链接 */
75
+ /** DOM 提取品类菜单 */
49
76
  async function extractCategories(page, channel, type) {
50
- const prefix = `/rank/${channel}_${type}_`;
51
- const cats = await page.evaluate((p) => {
52
- const out = [];
53
- const seen = {};
54
- document.querySelectorAll("a").forEach((a) => {
55
- const href = a.getAttribute("href") || "";
56
- if (href.indexOf(p) === -1) return;
57
- const name = (a.innerText || a.textContent || "").trim();
58
- if (!name) return;
59
- if (seen[href]) return;
60
- seen[href] = 1;
61
- out.push({ name, href });
62
- });
63
- return out;
64
- }, prefix);
65
- return cats;
77
+ return page.evaluate(({ channel, type }) => {
78
+ // 尝试从 __INITIAL_STATE__ 取品类列表
79
+ const s = window.__INITIAL_STATE__ || {};
80
+ let cats = null;
81
+ const cands = [
82
+ s.rank && s.rank.categories,
83
+ s.rank && s.rank.categoryList,
84
+ s.rankData && s.rankData.categories,
85
+ s.page && s.page.categories,
86
+ ];
87
+ for (const c of cands) {
88
+ if (Array.isArray(c) && c.length) { cats = c; break; }
89
+ }
90
+ if (!cats) {
91
+ // 降级:从 DOM 提取
92
+ const links = Array.from(document.querySelectorAll('a[href*="/rank/' + channel + '_' + type + '_"]'));
93
+ const seen = new Set();
94
+ cats = links
95
+ .map(a => ({ name: a.innerText.trim() || a.textContent.trim(), href: a.getAttribute('href') }))
96
+ .filter(c => c.name && c.href && !seen.has(c.href) && seen.add(c.href));
97
+ }
98
+ return cats.slice(0, 20);
99
+ }, { channel, type });
66
100
  }
67
101
 
68
102
  /** 从 __INITIAL_STATE__ 提取当前品类页的作品列表 */
@@ -81,8 +115,8 @@ async function extractBookList(page) {
81
115
  { timeout: 15000 }
82
116
  );
83
117
  } catch (waitErr) {
84
- console.log(` ✗ 等待页面数据超时(15s),可能是网络慢或页面结构变化:${waitErr.message}`);
85
- return []; // 返回空数组而非抛异常,避免中断整个采集
118
+ // 超时返回空数组(由调用方判断是否重试)
119
+ return [];
86
120
  }
87
121
  return page.evaluate(() => {
88
122
  const s = window.__INITIAL_STATE__ || {};
@@ -115,9 +149,7 @@ async function extractBookList(page) {
115
149
  if (typeof o === "object") {
116
150
  for (var k in o) {
117
151
  if (found) break;
118
- try {
119
- walk(o[k], d + 1);
120
- } catch (e) {}
152
+ try { walk(o[k], d + 1); } catch (e) {}
121
153
  }
122
154
  }
123
155
  })(s, 0);
@@ -135,126 +167,86 @@ async function extractBookList(page) {
135
167
  : b.status,
136
168
  lastChapterTitle: b.lastChapterTitle || b.last_chapter_title || b.lastChapter || "",
137
169
  category: b.category || b.categoryName || b.category_name || "",
138
- })).filter((b) => b.bookId);
170
+ }));
139
171
  });
140
172
  }
141
173
 
142
- /** 批量解码详情:逐本同步 XHR 请求 /page/{id} */
174
+ /** 分批解码,避免单次 eval 超时 */
143
175
  async function fetchDetailsChunk(page, ids) {
144
176
  return page.evaluate((ids) => {
145
- var map = {};
146
- function pick(h, res) {
147
- for (var i = 0; i < res.length; i++) {
148
- var m = h.match(res[i]);
149
- if (m && m[1]) return m[1].trim();
150
- }
151
- return "";
152
- }
153
- for (var k = 0; k < ids.length; k++) {
154
- var id = ids[k];
177
+ const base = "https://fanqienovel.com";
178
+ const map = {};
179
+ ids.forEach((id) => {
155
180
  try {
156
- var x = new XMLHttpRequest();
157
- x.open("GET", "/page/" + id, false);
158
- x.send();
159
- var h = x.responseText || "";
160
- var title = pick(h, [
161
- /"bookName"\s*:\s*"([^"]+)"/,
162
- /<title>([^<]*?)(?:完整版|最新章节|在线阅读|_番茄小说|-番茄小说|_番茄|-番茄)/,
163
- /<meta[^>]+property="og:title"[^>]+content="([^"]+)"/,
164
- /<title>([^<|_]{1,40})/,
165
- ]);
166
- var author = pick(h, [
167
- /"author"\s*:\s*"([^"]+)"/,
168
- /"authorName"\s*:\s*"([^"]+)"/,
169
- /<meta[^>]+property="og:novel:author"[^>]+content="([^"]+)"/,
170
- ]);
171
- var abs = pick(h, [/"abstract"\s*:\s*"([^"]{6,}?)"/]);
172
- var desc =
173
- abs ||
174
- pick(h, [
175
- /<meta[^>]+name="description"[^>]+content="([^"]+)"/,
176
- /<meta[^>]+property="og:description"[^>]+content="([^"]+)"/,
177
- ]);
178
- var category = pick(h, [
179
- /"categoryV2":"\\[\{[\\s\\S]*?\\"Name\\":\\"([^"\\]+)/,
180
- /"category"\s*:\s*"([^"]{1,20})"/,
181
- /<meta[^>]+property="og:novel:category"[^>]+content="([^"]+)"/,
182
- ]);
183
- var tags = "";
184
- var bm = (abs || desc || "").match(/【\[]([^】\]]{2,40})[】\]]/);
185
- if (bm) {
186
- tags = bm[1]
187
- .split(/[+、,\/\s]+/)
188
- .filter(Boolean)
189
- .slice(0, 6)
190
- .join("、");
191
- }
181
+ const el = document.querySelector(`[data-bookid="${id}"]`) ||
182
+ document.querySelector(`[href*="/page/${id}"]`) ||
183
+ document.querySelector(`[href*="/book/${id}"]`);
184
+ if (!el) { map[id] = {}; return; }
185
+ const card = el.closest(".rank-book-item") || el.closest(".book-card") || el;
186
+ const titleEl = card.querySelector(".book-title") || card.querySelector(".book-name") || card.querySelector("a");
187
+ const authorEl = card.querySelector(".author-name") || card.querySelector(".author") || card.querySelector(".writer");
188
+ const descEl = card.querySelector(".book-desc") || card.querySelector(".description");
189
+ const categoryEl = card.querySelector(".category") || card.querySelector(".tag");
190
+ const title = titleEl ? (titleEl.innerText || titleEl.textContent || "").trim() : "";
191
+ const author = authorEl ? (authorEl.innerText || authorEl.textContent || "").replace(/^作者:/, "").trim() : "";
192
+ const desc = descEl ? (descEl.innerText || descEl.textContent || "").trim() : "";
193
+ const category = categoryEl ? (categoryEl.innerText || categoryEl.textContent || "").trim() : "";
194
+ // 尝试从 href 提取标签
195
+ const tags = (desc.match(/#[^\s,,。]+/g) || []).slice(0, 6).join("、");
192
196
  map[id] = { title, author, desc, category, tags };
193
197
  } catch (e) {
194
198
  map[id] = { title: "", author: "", desc: "", category: "", tags: "", err: String(e && e.message || e) };
195
199
  }
196
- }
200
+ });
197
201
  return map;
198
202
  }, ids);
199
203
  }
200
204
 
201
- /** 分批解码,避免单次 eval 超时 */
205
+ /** 分批并行请求详情 */
202
206
  async function fetchDetails(page, bookIds) {
203
207
  const map = {};
204
208
  for (let i = 0; i < bookIds.length; i += DETAIL_CHUNK) {
205
209
  const chunk = bookIds.slice(i, i + DETAIL_CHUNK);
206
210
  const part = await fetchDetailsChunk(page, chunk);
207
211
  Object.assign(map, part);
208
- sleep(300);
209
212
  }
210
213
  return map;
211
214
  }
212
215
 
213
- /** 滚动页面加载更多内容 */
214
216
  async function scrollLoad(page, times, interval = 1000) {
215
217
  for (let i = 0; i < times; i++) {
216
- await page.evaluate(() => window.scrollBy(0, window.innerHeight));
217
- sleep(interval);
218
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
219
+ await page.waitForTimeout(interval);
218
220
  }
219
221
  }
220
222
 
221
- // ---------------------------------------------------------------------------
222
- // 格式化
223
- // ---------------------------------------------------------------------------
224
-
225
223
  function fmtReads(count) {
226
- if (!count || count === "0") return "未知";
227
- const n = parseInt(count, 10);
228
- if (isNaN(n)) return "未知";
224
+ if (!count && count !== 0) return "";
225
+ const n = Number(count);
226
+ if (isNaN(n)) return String(count);
227
+ if (n >= 100000000) return (n / 100000000).toFixed(1) + "亿";
229
228
  if (n >= 10000) return (n / 10000).toFixed(1) + "万";
230
229
  return String(n);
231
230
  }
232
231
 
233
232
  function fmtWords(count) {
234
- if (!count) return "未知";
235
- const n = parseInt(count, 10);
236
- if (isNaN(n)) return "未知";
237
- if (n >= 10000) return (n / 10000).toFixed(1) + "";
238
- return String(n);
233
+ if (!count && count !== 0) return "";
234
+ const n = Number(count);
235
+ if (isNaN(n)) return String(count);
236
+ if (n >= 100000000) return (n / 100000000).toFixed(1) + "亿字";
237
+ if (n >= 10000) return (n / 10000).toFixed(0) + "万字";
238
+ return String(n) + "字";
239
239
  }
240
240
 
241
241
  function fmtStatus(s) {
242
- const v = String(s);
243
- if (v === "1") return "连载中";
244
- if (v === "0" || v === "2") return "已完结";
245
- return s ? String(s) : "未知";
242
+ if (s === 1 || s === "1" || s === "连载") return "连载";
243
+ if (s === 2 || s === "2" || s === "完结") return "完结";
244
+ return "未知";
246
245
  }
247
246
 
248
247
  function cleanDesc(raw) {
249
248
  if (!raw) return "";
250
- let d = String(raw)
251
- .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
252
- .replace(/\\[nrt]/g, " ")
253
- .replace(/\\"/g, '"')
254
- .replace(/番茄小说[^。!?]*?(?:免费阅读|完整版|在线阅读)[^。!?]*[。!?]/g, "")
255
- .replace(/番茄小说[^。!?]*?(?:免费阅读|完整版|在线阅读)[^。!?]*$/g, "")
256
- .replace(/\s+/g, " ")
257
- .trim();
249
+ let d = raw.replace(/\s+/g, " ").trim();
258
250
  if (d.length <= 100) return d;
259
251
  const cut = d.slice(0, 100);
260
252
  const m = cut.match(/[\s\S]*[。!?]/);
@@ -274,11 +266,26 @@ const TYPE = getArg(args, "--type") || "2";
274
266
  const TOP = parseInt(getArg(args, "--top") || "20", 10);
275
267
 
276
268
  function channelLabel(ch) {
277
- return ch === "1" ? "男频" : "女频";
269
+ return ch === "1" ? "男频" : ch === "0" ? "女频" : ch;
278
270
  }
279
271
 
280
272
  function typeLabel(t) {
281
- return t === "2" ? "阅读榜" : "新书榜";
273
+ return t === "2" ? "阅读榜" : t === "1" ? "新书榜" : t;
274
+ }
275
+
276
+ async function scrapeCategoryWithRetry(page, cat) {
277
+ return withRetry(async () => {
278
+ // 用 domcontentloaded 避免被第三方资源打断导航;extractBookList 内部已 waitForFunction 确保数据就绪
279
+ await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "domcontentloaded" });
280
+ await page.waitForTimeout(2000);
281
+ await scrollLoad(page, 2);
282
+
283
+ const books = await extractBookList(page);
284
+ if (!books || !Array.isArray(books)) {
285
+ throw new Error("extractBookList 返回异常");
286
+ }
287
+ return books;
288
+ }, MAX_RETRIES, RETRY_DELAY_MS, `品类[${cat.name}]`);
282
289
  }
283
290
 
284
291
  async function scrapeChannel(ch, type) {
@@ -298,15 +305,25 @@ async function scrapeChannel(ch, type) {
298
305
  const cdp = await context.newCDPSession(page);
299
306
  const { windowId } = await cdp.send('Browser.getWindowForTarget', { target: page.target() });
300
307
  await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
301
- } catch (_) {}
308
+ } catch (_) { }
302
309
 
303
310
  let scraped = null;
304
311
 
305
312
  try {
306
- // 打开入口页
307
- await page.goto(initUrl, { waitUntil: "networkidle" }).catch(gotoErr => {
308
- console.log(` ✗ 打开入口页失败:${gotoErr.message}`);
309
- });
313
+ // 打开入口页(带重试)
314
+ const homeResult = await withRetry(async () => {
315
+ await page.goto(initUrl, { waitUntil: "domcontentloaded" });
316
+ const probe = await probePage(page);
317
+ if (!probe.host || probe.host.indexOf("fanqie") === -1) {
318
+ throw new Error(`非番茄页面(host=${probe.host}),可能被重定向到登录/验证页`);
319
+ }
320
+ return probe;
321
+ }, MAX_RETRIES, RETRY_DELAY_MS, `打开入口页`);
322
+
323
+ if (homeResult._failed) {
324
+ console.log(` ✗ 入口页采集连续失败:${homeResult.err.message}`);
325
+ return null;
326
+ }
310
327
 
311
328
  // 等待登录(用户可在此时手动登录)
312
329
  if (LOGIN_WAIT > 0) {
@@ -314,24 +331,11 @@ async function scrapeChannel(ch, type) {
314
331
  await page.waitForTimeout(LOGIN_WAIT * 1000);
315
332
  }
316
333
 
317
- // 连通性自检
318
- const probe = await probePage(page);
319
- if (!probe.host || probe.host.indexOf("fanqie") === -1) {
320
- console.log(
321
- ` ✗ 当前页面非番茄(host=${probe.host}),可能被重定向到登录/验证页,已跳过。`
322
- );
323
- return null;
324
- }
325
- if (!probe.hasState) {
326
- console.log(` ⚠ 页面未挂载 __INITIAL_STATE__,将尝试兜底扫描,结果可能不完整。`);
327
- }
328
-
329
334
  // ─── 调试模式:拦截所有请求,找出真实数据接口 ───
330
335
  if (PROBE) {
331
336
  const intercepted = [];
332
337
  const seen = new Set();
333
338
 
334
- // 先注册拦截器,再导航到目标页(避免漏掉 initial 请求)
335
339
  context.on("request", req => {
336
340
  const url = req.url();
337
341
  if (seen.has(url)) return;
@@ -361,7 +365,6 @@ async function scrapeChannel(ch, type) {
361
365
  } catch {}
362
366
  });
363
367
 
364
- // 直接导航到品类页(不用 waitUntil,避免卡住)
365
368
  const cats = await extractCategories(page, ch, type);
366
369
  if (!cats.length) {
367
370
  await page.goto(`https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`);
@@ -415,59 +418,61 @@ async function scrapeChannel(ch, type) {
415
418
  ];
416
419
 
417
420
  let totalBooks = 0;
418
- let resolvedTitles = 0;
419
- const bodyLines = [];
421
+ const failedCategories = []; // { name, err }
420
422
 
421
423
  for (let ci = 0; ci < categories.length; ci++) {
422
424
  const cat = categories[ci];
423
425
  console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
424
426
 
425
- try {
426
- await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "networkidle" }).catch(gotoErr => {
427
- console.log(` ✗ 打开品类页失败:${gotoErr.message}`);
428
- });
429
- await page.waitForTimeout(2500);
430
- await scrollLoad(page, 2);
431
-
432
- let books = await extractBookList(page);
433
- if (!Array.isArray(books) || !books.length) {
434
- bodyLines.push(`## ${cat.name} — 0 本`, "", "---", "");
435
- continue;
436
- }
437
- if (books.length > TOP) books = books.slice(0, TOP);
427
+ const booksResult = await scrapeCategoryWithRetry(page, cat);
438
428
 
439
- const bookIds = books.map((b) => String(b.bookId));
440
- const details = await fetchDetails(page, bookIds);
429
+ if (booksResult._failed) {
430
+ console.log(` ✗ 品类[${cat.name}]连续${MAX_RETRIES}次失败:${booksResult.err.message}`);
431
+ failedCategories.push({ name: cat.name, err: booksResult.err.message });
432
+ lines.push(`## ${cat.name} — 采集失败(已重试${MAX_RETRIES}次)`, "", "---", "");
433
+ continue;
434
+ }
441
435
 
442
- bodyLines.push(`## ${cat.name} — ${books.length} 本`, "");
436
+ let books = booksResult;
437
+ if (!books.length) {
438
+ lines.push(`## ${cat.name} — 0 本`, "", "---", "");
439
+ continue;
440
+ }
441
+ if (books.length > TOP) books = books.slice(0, TOP);
442
+
443
+ const bookIds = books.map((b) => String(b.bookId));
444
+ const details = await fetchDetails(page, bookIds);
445
+
446
+ lines.push(`## ${cat.name} — ${books.length} 本`, "");
447
+
448
+ for (let i = 0; i < books.length; i++) {
449
+ const b = books[i];
450
+ const info = details[String(b.bookId)] || {};
451
+ totalBooks++;
452
+ const title = info.title || "(标题待解析)";
453
+ const author = info.author || "未知";
454
+ const category = info.category || b.category || "";
455
+
456
+ lines.push(`书名:${title}`);
457
+ lines.push(`题材:${category}`);
458
+ lines.push(`作者:${author}`);
459
+ lines.push(`作品页:https://fanqienovel.com/page/${b.bookId}`);
460
+ lines.push("");
461
+ }
443
462
 
444
- for (let i = 0; i < books.length; i++) {
445
- const b = books[i];
446
- const info = details[String(b.bookId)] || {};
447
- totalBooks++;
448
- const resolved = !!info.title;
463
+ lines.push("---", "");
464
+ }
449
465
 
450
- const title = info.title || "(标题待解析)";
451
- const author = info.author || "未知";
452
- const category = info.category || b.category || "";
466
+ scraped = lines.join("\n");
453
467
 
454
- bodyLines.push(`书名:${title}`);
455
- bodyLines.push(`题材:${category}`);
456
- bodyLines.push(`作者:${author}`);
457
- bodyLines.push(`作品页:https://fanqienovel.com/page/${b.bookId}`);
458
- bodyLines.push("");
459
- }
460
-
461
- bodyLines.push("---", "");
462
- } catch (catErr) {
463
- console.log(
464
- ` [fanqie] 品类 ${cat.name} 处理出错,跳过: ${catErr && catErr.message ? catErr.message : catErr}`
465
- );
466
- bodyLines.push(`## ${cat.name} — 采集失败`, "", "---", "");
468
+ // 输出失败品类汇总
469
+ if (failedCategories.length > 0) {
470
+ console.log(`\n⚠️ 以下品类采集失败(共 ${failedCategories.length} 项):`);
471
+ for (const f of failedCategories) {
472
+ console.log(` • ${f.name}:${f.err}`);
467
473
  }
468
474
  }
469
475
 
470
- scraped = lines.concat(bodyLines).join("\n");
471
476
  } finally {
472
477
  await browser.close();
473
478
  }
@@ -485,12 +490,16 @@ async function main() {
485
490
  const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
486
491
  const types = TYPE === "all" ? ["2", "1"] : [TYPE];
487
492
  let written = 0;
493
+ const failedChannels = []; // { ch, ty, err }
488
494
 
489
495
  for (const ch of channels) {
490
496
  for (const ty of types) {
491
497
  try {
492
498
  const content = await scrapeChannel(ch, ty);
493
- if (!content) continue;
499
+ if (!content) {
500
+ failedChannels.push({ ch, ty, err: "采集返回空" });
501
+ continue;
502
+ }
494
503
 
495
504
  const date = localDateStamp();
496
505
  const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
@@ -500,12 +509,20 @@ async function main() {
500
509
  written++;
501
510
  console.log(` ✓ 已保存: ${filepath}`);
502
511
  } catch (chErr) {
503
- console.log(
504
- `[fanqie] ${channelLabel(ch)}${typeLabel(ty)} 采集失败,跳过: ${chErr && chErr.message ? chErr.message : chErr}`
505
- );
512
+ console.log(` ✗ 频道${channelLabel(ch)}${typeLabel(ty)}采集失败:${chErr.message}`);
513
+ failedChannels.push({ ch, ty, err: chErr.message });
506
514
  }
507
515
  }
508
516
  }
517
+
518
+ // 输出失败榜单汇总
519
+ if (failedChannels.length > 0) {
520
+ console.log(`\n⚠️ 以下榜单采集失败(共 ${failedChannels.length} 项):`);
521
+ for (const f of failedChannels) {
522
+ console.log(` • ${channelLabel(f.ch)}${typeLabel(f.ty)}:${f.err}`);
523
+ }
524
+ }
525
+
509
526
  return written;
510
527
  }
511
528