@cloud411716/fancy-webnovel 0.3.12 → 0.3.14

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.12",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -3,19 +3,16 @@
3
3
  * 番茄小说排行榜采集脚本
4
4
  *
5
5
  * 使用 playwright-core 自己管理浏览器生命周期。
6
- * 采集策略:从榜单页 __INITIAL_STATE__ 取结构化列表,再逐本请求详情页解码真实
7
- * 书名/作者/简介/题材/标签(番茄列表页有字体反爬,详情页 HTML 里是明文)。
8
- * 输出 Markdown 格式匹配 scan-output-format.md 规范。
6
+ * 采集策略:
7
+ * 1. 从榜单页 __INITIAL_STATE__ 取书 ID 列表
8
+ * 2. 逐本打开详情页,用 innerText() 提取渲染后的真实文字(绕过字体加密)
9
+ * 3. 输出 Markdown 格式
9
10
  *
10
11
  * 重试机制:每个品类/每个榜单 最多重试3次,每次间隔5秒。
11
12
  *
12
13
  * 用法:
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 ./ # 指定输出目录
14
+ * node fanqie-rank-scraper.cjs --channel 1 --type 2
15
+ * node fanqie-rank-scraper.cjs --channel all
19
16
  */
20
17
 
21
18
  const fs = require("fs");
@@ -23,9 +20,7 @@ const path = require("path");
23
20
  const { chromium } = require("playwright-core");
24
21
  const { getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
25
22
 
26
- // 一次详情请求的并发批大小
27
- const DETAIL_CHUNK = 5;
28
- // 重试参数
23
+ const DETAIL_CHUNK = 5; // 每次并行打开的详情页数量
29
24
  const MAX_RETRIES = 3;
30
25
  const RETRY_DELAY_MS = 5000;
31
26
 
@@ -35,11 +30,7 @@ function sleep(ms) {
35
30
 
36
31
  /**
37
32
  * 重试包装器
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}|*>} 失败时返回特殊标记对象
33
+ * @returns {{_failed:true, err, label}|*} 失败时返回 _failed=true 的标记对象
43
34
  */
44
35
  async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, label = '') {
45
36
  let lastErr;
@@ -54,54 +45,47 @@ async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, la
54
45
  }
55
46
  }
56
47
  }
57
- // 全部重试失败
58
48
  return { _failed: true, err: lastErr, label };
59
49
  }
60
50
 
61
51
  // ---------------------------------------------------------------------------
62
- // 页面提取
52
+ // 页面提取工具
63
53
  // ---------------------------------------------------------------------------
64
54
 
65
- /** 连通性 + 页面就绪自检 */
55
+ /** 连通性自检 */
66
56
  async function probePage(page) {
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
- });
57
+ return page.evaluate(() => ({
58
+ host: window.location.hostname,
59
+ hasState: !!(window.__INITIAL_STATE__?.rank || window.__INITIAL_STATE__?.rankData),
60
+ }));
73
61
  }
74
62
 
75
- /** DOM 提取品类菜单 */
63
+ /** 从榜单页 __INITIAL_STATE__ 提取品类列表 */
76
64
  async function extractCategories(page, channel, type) {
77
65
  return page.evaluate(({ channel, type }) => {
78
- // 尝试从 __INITIAL_STATE__ 取品类列表
79
66
  const s = window.__INITIAL_STATE__ || {};
80
67
  let cats = null;
81
68
  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,
69
+ s.rank?.categories, s.rank?.categoryList,
70
+ s.rankData?.categories, s.page?.categories,
86
71
  ];
87
72
  for (const c of cands) {
88
73
  if (Array.isArray(c) && c.length) { cats = c; break; }
89
74
  }
90
75
  if (!cats) {
91
- // 降级:从 DOM 提取
92
- const links = Array.from(document.querySelectorAll('a[href*="/rank/' + channel + '_' + type + '_"]'));
76
+ const links = Array.from(document.querySelectorAll(`a[href*="/rank/${channel}_${type}_"]`));
93
77
  const seen = new Set();
94
78
  cats = links
95
79
  .map(a => ({ name: a.innerText.trim() || a.textContent.trim(), href: a.getAttribute('href') }))
96
80
  .filter(c => c.name && c.href && !seen.has(c.href) && seen.add(c.href));
97
81
  }
98
- return cats.slice(0, 20);
82
+ return (cats || []).slice(0, 20);
99
83
  }, { channel, type });
100
84
  }
101
85
 
102
- /** __INITIAL_STATE__ 提取当前品类页的作品列表 */
103
- async function extractBookList(page) {
104
- // 等 __INITIAL_STATE__ 有书单数据后再提取(防止网络慢时页面 JS 还没注数据)
86
+ /** 从榜单页 __INITIAL_STATE__ 提取书籍 ID 列表 */
87
+ async function extractBookIds(page) {
88
+ // 等 __INITIAL_STATE__ 有书单数据
105
89
  try {
106
90
  await page.waitForFunction(
107
91
  () => {
@@ -114,101 +98,88 @@ async function extractBookList(page) {
114
98
  },
115
99
  { timeout: 15000 }
116
100
  );
117
- } catch (waitErr) {
118
- // 超时返回空数组(由调用方判断是否重试)
119
- return [];
120
- }
101
+ } catch (_) { return []; }
102
+
121
103
  return page.evaluate(() => {
122
104
  const s = window.__INITIAL_STATE__ || {};
123
- const cands = [
124
- s.rank && s.rank.book_list,
125
- s.rank && s.rank.bookList,
126
- s.rank && s.rank.rankList,
127
- s.rankData && s.rankData.book_list,
128
- s.page && s.page.book_list,
129
- ];
130
- var list = null;
131
- for (var i = 0; i < cands.length; i++) {
132
- if (Array.isArray(cands[i]) && cands[i].length) {
133
- list = cands[i];
134
- break;
135
- }
105
+ let list = null;
106
+ for (const c of [s.rank, s.rankData, s.page]) {
107
+ if (c?.book_list) { list = c.book_list; break; }
108
+ if (c?.bookList) { list = c.bookList; break; }
109
+ if (c?.rankList) { list = c.rankList; break; }
136
110
  }
137
111
  if (!list) {
138
- var found = null;
112
+ let found = null;
139
113
  (function walk(o, d) {
140
114
  if (found || !o || d > 6) return;
141
- if (Array.isArray(o)) {
142
- if (o.length && o[0] && typeof o[0] === "object" && (o[0].bookId || o[0].book_id)) {
143
- found = o;
144
- return;
145
- }
146
- for (var j = 0; j < o.length && !found; j++) walk(o[j], d + 1);
147
- return;
115
+ if (Array.isArray(o) && o.length && o[0] && (o[0].bookId || o[0].book_id)) {
116
+ found = o; return;
148
117
  }
149
- if (typeof o === "object") {
150
- for (var k in o) {
151
- if (found) break;
152
- try { walk(o[k], d + 1); } catch (e) {}
153
- }
118
+ if (typeof o === 'object') {
119
+ for (const k in o) { try { walk(o[k], d + 1); } catch (_) {} }
154
120
  }
155
121
  })(s, 0);
156
122
  list = found || [];
157
123
  }
158
- return list.map((b) => ({
159
- bookId: String(b.bookId || b.book_id || ""),
160
- read_count: b.read_count || b.readCount || b.read || "",
161
- wordNumber: b.wordNumber || b.word_number || b.wordCount || "",
162
- creationStatus:
163
- b.creationStatus != null
164
- ? b.creationStatus
165
- : b.creation_status != null
166
- ? b.creation_status
167
- : b.status,
168
- lastChapterTitle: b.lastChapterTitle || b.last_chapter_title || b.lastChapter || "",
169
- category: b.category || b.categoryName || b.category_name || "",
170
- }));
124
+ return (list || []).map(b => String(b.bookId || b.book_id || '')).filter(Boolean);
171
125
  });
172
126
  }
173
127
 
174
- /** 分批解码,避免单次 eval 超时 */
175
- async function fetchDetailsChunk(page, ids) {
176
- return page.evaluate((ids) => {
177
- const base = "https://fanqienovel.com";
178
- const map = {};
179
- ids.forEach((id) => {
180
- try {
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("、");
196
- map[id] = { title, author, desc, category, tags };
197
- } catch (e) {
198
- map[id] = { title: "", author: "", desc: "", category: "", tags: "", err: String(e && e.message || e) };
199
- }
200
- });
201
- return map;
202
- }, ids);
203
- }
204
-
205
- /** 分批并行请求详情 */
206
- async function fetchDetails(page, bookIds) {
128
+ /**
129
+ * 打开书籍详情页,提取渲染后的干净文字
130
+ * @param {object} browser
131
+ * @param {string[]} bookIds
132
+ * @returns {Promise<object>} { bookId: { title, author, category, desc } }
133
+ */
134
+ async function fetchBookDetails(browser, bookIds) {
207
135
  const map = {};
208
- for (let i = 0; i < bookIds.length; i += DETAIL_CHUNK) {
209
- const chunk = bookIds.slice(i, i + DETAIL_CHUNK);
210
- const part = await fetchDetailsChunk(page, chunk);
211
- Object.assign(map, part);
136
+ // 复用同一个 context 批量开详情页(比每次新建 context 快)
137
+ const context = await browser.newContext();
138
+ try {
139
+ for (let i = 0; i < bookIds.length; i += DETAIL_CHUNK) {
140
+ const chunk = bookIds.slice(i, i + DETAIL_CHUNK);
141
+ // 并行打开多个详情页
142
+ const pages = await Promise.all(
143
+ chunk.map(id => context.newPage())
144
+ );
145
+ await Promise.all(
146
+ pages.map((p, idx) =>
147
+ p.goto(`https://fanqienovel.com/page/${chunk[idx]}`, { waitUntil: 'domcontentloaded' })
148
+ .catch(() => {})
149
+ )
150
+ );
151
+ // 等待所有页面的主要元素加载
152
+ await Promise.all(
153
+ pages.map(p => p.waitForTimeout(1500))
154
+ );
155
+ // 批量提取
156
+ const results = await Promise.all(
157
+ pages.map((p, idx) =>
158
+ p.evaluate(({ id }) => {
159
+ const sel = '.info-wrapper .title, .book-title, h1, .book-name';
160
+ const titleEl = document.querySelector(sel);
161
+ const title = titleEl ? (titleEl.innerText || titleEl.textContent || '').trim() : '';
162
+ // 作者可能在多个位置
163
+ const authorEl = document.querySelector('.author-name, .author, .writer') ||
164
+ document.querySelector(`a[href*="/author/"]`);
165
+ const author = authorEl ? (authorEl.innerText || authorEl.textContent || '').replace(/^作者:/, '').trim() : '';
166
+ // 题材/分类
167
+ const catEl = document.querySelector('.category, .tag, .book-tag');
168
+ const category = catEl ? (catEl.innerText || catEl.textContent || '').trim() : '';
169
+ // 简介
170
+ const descEl = document.querySelector('.description, .book-desc, .intro');
171
+ const desc = descEl ? (descEl.innerText || descEl.textContent || '').trim().slice(0, 200) : '';
172
+ return { id, title, author, category, desc };
173
+ }, { id: chunk[idx] }).catch(() => ({ id: chunk[idx], title: '', author: '', category: '', desc: '' }))
174
+ )
175
+ );
176
+ for (const r of results) {
177
+ map[r.id] = r;
178
+ }
179
+ await Promise.all(pages.map(p => p.close()));
180
+ }
181
+ } finally {
182
+ await context.close();
212
183
  }
213
184
  return map;
214
185
  }
@@ -220,37 +191,9 @@ async function scrollLoad(page, times, interval = 1000) {
220
191
  }
221
192
  }
222
193
 
223
- function fmtReads(count) {
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) + "亿";
228
- if (n >= 10000) return (n / 10000).toFixed(1) + "万";
229
- return String(n);
230
- }
231
-
232
- function fmtWords(count) {
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
- }
240
-
241
- function fmtStatus(s) {
242
- if (s === 1 || s === "1" || s === "连载") return "连载";
243
- if (s === 2 || s === "2" || s === "完结") return "完结";
244
- return "未知";
245
- }
246
-
247
- function cleanDesc(raw) {
248
- if (!raw) return "";
249
- let d = raw.replace(/\s+/g, " ").trim();
250
- if (d.length <= 100) return d;
251
- const cut = d.slice(0, 100);
252
- const m = cut.match(/[\s\S]*[。!?]/);
253
- return (m ? m[0] : cut) + "...";
194
+ function localDate() {
195
+ const d = new Date();
196
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
254
197
  }
255
198
 
256
199
  // ---------------------------------------------------------------------------
@@ -258,32 +201,49 @@ function cleanDesc(raw) {
258
201
  // ---------------------------------------------------------------------------
259
202
 
260
203
  const args = process.argv.slice(2);
261
- const PROBE = getArg(args, "--probe") !== null;
262
- const LOGIN_WAIT = parseInt(getArg(args, "--login-wait") || "0", 10);
263
- const OUTDIR = getArg(args, "--outdir") || ".";
264
- const CHANNEL = getArg(args, "--channel") || "1";
265
- const TYPE = getArg(args, "--type") || "2";
266
- const TOP = parseInt(getArg(args, "--top") || "20", 10);
204
+ const PROBE = getArg(args, '--probe') !== null;
205
+ const LOGIN_WAIT = parseInt(getArg(args, '--login-wait') || '0', 10);
206
+ const OUTDIR = getArg(args, '--outdir') || '.';
207
+ const CHANNEL = getArg(args, '--channel') || '1';
208
+ const TYPE = getArg(args, '--type') || '2';
209
+ const TOP = parseInt(getArg(args, '--top') || '20', 10);
267
210
 
268
211
  function channelLabel(ch) {
269
- return ch === "1" ? "男频" : ch === "0" ? "女频" : ch;
212
+ return ch === '1' ? '男频' : ch === '0' ? '女频' : ch;
270
213
  }
271
-
272
214
  function typeLabel(t) {
273
- return t === "2" ? "阅读榜" : t === "1" ? "新书榜" : t;
215
+ return t === '2' ? '阅读榜' : t === '1' ? '新书榜' : t;
274
216
  }
275
217
 
276
- async function scrapeCategoryWithRetry(page, cat) {
218
+ async function scrapeCategory(browser, page, cat, chLabel) {
277
219
  return withRetry(async () => {
278
- await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "networkidle" });
279
- await page.waitForTimeout(2500);
220
+ await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: 'domcontentloaded' });
221
+ await page.waitForTimeout(2000);
280
222
  await scrollLoad(page, 2);
281
223
 
282
- const books = await extractBookList(page);
283
- if (!books || !Array.isArray(books)) {
284
- throw new Error("extractBookList 返回异常");
224
+ const bookIds = await extractBookIds(page);
225
+ if (!bookIds || !bookIds.length) {
226
+ throw new Error('extractBookIds 返回空');
285
227
  }
286
- return books;
228
+
229
+ // 打开详情页提取渲染后文字
230
+ const details = await fetchBookDetails(browser, bookIds.slice(0, TOP));
231
+
232
+ const lines = [];
233
+ for (const id of bookIds.slice(0, TOP)) {
234
+ const info = details[id] || {};
235
+ const title = info.title || `(标题待解析 ${id})`;
236
+ const author = info.author || '未知';
237
+ const category = info.category || cat.name;
238
+ const desc = info.desc ? `简介:${info.desc}` : '';
239
+ lines.push(`书名:${title}`);
240
+ lines.push(`题材:${category}`);
241
+ lines.push(`作者:${author}`);
242
+ if (desc) lines.push(desc);
243
+ lines.push(`作品页:https://fanqienovel.com/page/${id}`);
244
+ lines.push('');
245
+ }
246
+ return lines;
287
247
  }, MAX_RETRIES, RETRY_DELAY_MS, `品类[${cat.name}]`);
288
248
  }
289
249
 
@@ -292,106 +252,82 @@ async function scrapeChannel(ch, type) {
292
252
  const tyLabel = typeLabel(type);
293
253
  console.log(`\n→ 采集 ${chLabel}${tyLabel}...`);
294
254
 
295
- const initCatId = ch === "1" ? "1141" : "1139"; // 男频:西方奇幻 / 女频:古风世情
296
- const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
255
+ const initCatId = ch === '1' ? '1141' : '1139';
256
+ const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
297
257
 
298
- const browser = await chromium.launch({ headless: false, args: ["--no-sandbox", "--disable-dev-shm-usage", "--start-maximized"] });
258
+ const browser = await chromium.launch({
259
+ headless: false,
260
+ args: ['--no-sandbox', '--disable-dev-shm-usage', '--start-maximized'],
261
+ });
299
262
  const context = await browser.newContext();
300
- const page = await context.newPage();
263
+ const page = await context.newPage();
301
264
 
302
- // CDP 强制窗口最大化
265
+ // CDP 窗口最大化
303
266
  try {
304
267
  const cdp = await context.newCDPSession(page);
305
268
  const { windowId } = await cdp.send('Browser.getWindowForTarget', { target: page.target() });
306
269
  await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
307
- } catch (_) { }
270
+ } catch (_) {}
308
271
 
309
272
  let scraped = null;
310
273
 
311
274
  try {
312
- // 打开入口页(带重试)
275
+ // 入口页(带重试)
313
276
  const homeResult = await withRetry(async () => {
314
- await page.goto(initUrl, { waitUntil: "networkidle" });
277
+ await page.goto(initUrl, { waitUntil: 'domcontentloaded' });
315
278
  const probe = await probePage(page);
316
- if (!probe.host || probe.host.indexOf("fanqie") === -1) {
317
- throw new Error(`非番茄页面(host=${probe.host}),可能被重定向到登录/验证页`);
279
+ if (!probe.host || probe.host.indexOf('fanqie') === -1) {
280
+ throw new Error(`非番茄页面(host=${probe.host})`);
318
281
  }
319
282
  return probe;
320
- }, MAX_RETRIES, RETRY_DELAY_MS, `打开入口页`);
283
+ }, MAX_RETRIES, RETRY_DELAY_MS, '打开入口页');
321
284
 
322
285
  if (homeResult._failed) {
323
- console.log(` ✗ 入口页采集连续失败:${homeResult.err.message}`);
286
+ console.log(` ✗ 入口页连续失败:${homeResult.err.message}`);
324
287
  return null;
325
288
  }
326
289
 
327
- // 等待登录(用户可在此时手动登录)
328
290
  if (LOGIN_WAIT > 0) {
329
291
  console.log(` ⏳ 等待 ${LOGIN_WAIT}s 供用户登录...`);
330
292
  await page.waitForTimeout(LOGIN_WAIT * 1000);
331
293
  }
332
294
 
333
- // ─── 调试模式:拦截所有请求,找出真实数据接口 ───
295
+ // PROBE 调试模式(保留原有逻辑)
334
296
  if (PROBE) {
335
297
  const intercepted = [];
336
298
  const seen = new Set();
337
-
338
- context.on("request", req => {
299
+ context.on('request', req => {
339
300
  const url = req.url();
340
- if (seen.has(url)) return;
341
- if (/\.(css|woff|jpg|png|ico)/.test(url)) return;
342
- if (!url.includes("fanqie")) return;
301
+ if (seen.has(url) || /\.(css|woff|jpg|png|ico)/.test(url) || !url.includes('fanqie')) return;
343
302
  seen.add(url);
344
303
  console.log(`[REQ] ${url}`);
345
304
  });
346
-
347
- page.on("response", async (resp) => {
305
+ page.on('response', async resp => {
348
306
  const url = resp.url();
349
- const ct = (resp.headers()["content-type"] || "").toLowerCase();
350
307
  if (seen.has(url)) return;
351
- if (!ct.includes("json") && !ct.includes("text") && !url.includes("api")) return;
352
- if (/\.(css|woff|jpg|png|ico)/.test(url)) return;
308
+ const ct = (resp.headers()['content-type'] || '').toLowerCase();
309
+ if (!ct.includes('json') && !ct.includes('text') && !url.includes('api')) return;
353
310
  try {
354
311
  const body = await resp.text();
355
312
  if (!body || body.length < 50) return;
356
- let parsed;
357
- try { parsed = JSON.parse(body); } catch { return; }
358
- const str = JSON.stringify(parsed);
359
- if (str.includes("bookId") || str.includes("book_name") || str.includes("bookName") || str.includes("authorName")) {
313
+ const str = JSON.stringify(JSON.parse(body));
314
+ if (str.includes('bookId') || str.includes('book_name')) {
360
315
  console.log(`[JSON] ${url}`);
361
- console.log(` 预览: ${str.slice(0, 300)}`);
362
- intercepted.push({ url, preview: str.slice(0, 400) });
316
+ intercepted.push({ url, preview: str.slice(0, 300) });
363
317
  }
364
- } catch {}
318
+ } catch (_) {}
365
319
  });
366
-
367
320
  const cats = await extractCategories(page, ch, type);
368
321
  if (!cats.length) {
369
322
  await page.goto(`https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`);
370
323
  await page.waitForTimeout(3000);
371
- const moreCats = await extractCategories(page, ch, type);
372
- if (moreCats.length) {
373
- console.log(` 发现 ${moreCats.length} 个品类`);
374
- for (const c of moreCats.slice(0, 3)) {
375
- console.log(` 探测品类: ${c.name} → ${c.href}`);
376
- await page.goto(`https://fanqienovel.com${c.href}`);
377
- await page.waitForTimeout(2000);
378
- }
379
- }
380
- } else {
381
- console.log(` 发现 ${cats.length} 个品类`);
382
- for (const c of cats.slice(0, 3)) {
383
- console.log(` 探测品类: ${c.name} → ${c.href}`);
384
- await page.goto(`https://fanqienovel.com${c.href}`);
385
- await page.waitForTimeout(2000);
386
- }
387
324
  }
388
-
389
- console.log(`\n=== 共拦截到 ${intercepted.length} 个含书籍数据的接口 ===`);
325
+ console.log(`\n=== 共拦截 ${intercepted.length} 个含书籍数据的接口 ===`);
390
326
  await browser.close();
391
327
  return null;
392
328
  }
393
329
 
394
- // 提取品类菜单
330
+ // 提取品类
395
331
  let categories = await extractCategories(page, ch, type);
396
332
  if (!categories.length) {
397
333
  await scrollLoad(page, 2);
@@ -399,72 +335,50 @@ async function scrapeChannel(ch, type) {
399
335
  categories = await extractCategories(page, ch, type);
400
336
  }
401
337
  if (!categories.length) {
402
- console.log(` 未提取到品类菜单,降级为单题材采集(入口页)`);
403
- categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
338
+ categories = [{ name: '全部(入口页)', href: `/rank/${ch}_${type}_${initCatId}` }];
404
339
  } else {
405
340
  console.log(` 发现 ${categories.length} 个品类`);
406
341
  }
407
342
 
408
- const now = new Date().toISOString();
343
+ const now = new Date().toISOString();
409
344
  const lines = [
410
345
  `# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
411
- "",
346
+ '',
412
347
  `- 频道参数:channel=${ch},type=${type}`,
413
348
  `- 抓取时间:${now}`,
414
- "",
415
- "---",
416
- "",
349
+ '',
350
+ '---',
351
+ '',
417
352
  ];
418
353
 
419
- let totalBooks = 0;
420
- const failedCategories = []; // { name, err }
354
+ const failedCategories = [];
421
355
 
422
356
  for (let ci = 0; ci < categories.length; ci++) {
423
357
  const cat = categories[ci];
424
358
  console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
425
359
 
426
- const booksResult = await scrapeCategoryWithRetry(page, cat);
360
+ const result = await scrapeCategory(browser, page, cat, chLabel);
427
361
 
428
- if (booksResult._failed) {
429
- console.log(` ✗ 品类[${cat.name}]连续${MAX_RETRIES}次失败:${booksResult.err.message}`);
430
- failedCategories.push({ name: cat.name, err: booksResult.err.message });
431
- lines.push(`## ${cat.name} — 采集失败(已重试${MAX_RETRIES}次)`, "", "---", "");
362
+ if (result._failed) {
363
+ console.log(` ✗ 品类[${cat.name}]连续${MAX_RETRIES}次失败:${result.err.message}`);
364
+ failedCategories.push({ name: cat.name, err: result.err.message });
365
+ lines.push(`## ${cat.name} — 采集失败(已重试${MAX_RETRIES}次)`, '', '---', '');
432
366
  continue;
433
367
  }
434
368
 
435
- let books = booksResult;
436
- if (!books.length) {
437
- lines.push(`## ${cat.name} — 0 本`, "", "---", "");
369
+ const bookLines = result;
370
+ if (!bookLines.length) {
371
+ lines.push(`## ${cat.name} — 0 本`, '', '---', '');
438
372
  continue;
439
373
  }
440
- if (books.length > TOP) books = books.slice(0, TOP);
441
-
442
- const bookIds = books.map((b) => String(b.bookId));
443
- const details = await fetchDetails(page, bookIds);
444
-
445
- lines.push(`## ${cat.name} — ${books.length} 本`, "");
446
-
447
- for (let i = 0; i < books.length; i++) {
448
- const b = books[i];
449
- const info = details[String(b.bookId)] || {};
450
- totalBooks++;
451
- const title = info.title || "(标题待解析)";
452
- const author = info.author || "未知";
453
- const category = info.category || b.category || "";
454
-
455
- lines.push(`书名:${title}`);
456
- lines.push(`题材:${category}`);
457
- lines.push(`作者:${author}`);
458
- lines.push(`作品页:https://fanqienovel.com/page/${b.bookId}`);
459
- lines.push("");
460
- }
461
374
 
462
- lines.push("---", "");
375
+ lines.push(`## ${cat.name} — ${bookLines.filter(l => l.startsWith('书名:')).length} 本`, '');
376
+ lines.push(...bookLines);
377
+ lines.push('---', '');
463
378
  }
464
379
 
465
- scraped = lines.join("\n");
380
+ scraped = lines.join('\n');
466
381
 
467
- // 输出失败品类汇总
468
382
  if (failedCategories.length > 0) {
469
383
  console.log(`\n⚠️ 以下品类采集失败(共 ${failedCategories.length} 项):`);
470
384
  for (const f of failedCategories) {
@@ -480,31 +394,27 @@ async function scrapeChannel(ch, type) {
480
394
  }
481
395
 
482
396
  async function main() {
483
- if (!["0", "1", "all"].includes(CHANNEL)) {
484
- throw new Error(`未知 --channel: ${CHANNEL}`);
485
- }
486
- if (!["1", "2", "all"].includes(TYPE)) {
487
- throw new Error(`未知 --type: ${TYPE}`);
488
- }
489
- const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
490
- const types = TYPE === "all" ? ["2", "1"] : [TYPE];
491
- let written = 0;
492
- const failedChannels = []; // { ch, ty, err }
397
+ if (!['0', '1', 'all'].includes(CHANNEL)) throw new Error(`未知 --channel: ${CHANNEL}`);
398
+ if (!['1', '2', 'all'].includes(TYPE)) throw new Error(`未知 --type: ${TYPE}`);
399
+
400
+ const channels = CHANNEL === 'all' ? ['1', '0'] : [CHANNEL];
401
+ const types = TYPE === 'all' ? ['2', '1'] : [TYPE];
402
+ let written = 0;
403
+ const failedChannels = [];
493
404
 
494
405
  for (const ch of channels) {
495
406
  for (const ty of types) {
496
407
  try {
497
408
  const content = await scrapeChannel(ch, ty);
498
409
  if (!content) {
499
- failedChannels.push({ ch, ty, err: "采集返回空" });
410
+ failedChannels.push({ ch, ty, err: '采集返回空' });
500
411
  continue;
501
412
  }
502
-
503
- const date = localDateStamp();
413
+ const date = localDateStamp();
504
414
  const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
505
415
  fs.mkdirSync(OUTDIR, { recursive: true });
506
416
  const filepath = path.join(OUTDIR, filename);
507
- fs.writeFileSync(filepath, content, "utf-8");
417
+ fs.writeFileSync(filepath, content, 'utf-8');
508
418
  written++;
509
419
  console.log(` ✓ 已保存: ${filepath}`);
510
420
  } catch (chErr) {
@@ -514,7 +424,6 @@ async function main() {
514
424
  }
515
425
  }
516
426
 
517
- // 输出失败榜单汇总
518
427
  if (failedChannels.length > 0) {
519
428
  console.log(`\n⚠️ 以下榜单采集失败(共 ${failedChannels.length} 项):`);
520
429
  for (const f of failedChannels) {
@@ -526,13 +435,7 @@ async function main() {
526
435
  }
527
436
 
528
437
  if (require.main === module) {
529
- runCli(main, "番茄采集");
438
+ runCli(main, '番茄采集');
530
439
  }
531
440
 
532
- // 导出纯函数/JS 构建器
533
- module.exports = {
534
- fmtReads,
535
- fmtWords,
536
- fmtStatus,
537
- cleanDesc,
538
- };
441
+ module.exports = {};