@cloud411716/fancy-webnovel 0.3.13 → 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.13",
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,33 +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
- // domcontentloaded 避免被第三方资源打断导航;extractBookList 内部已 waitForFunction 确保数据就绪
279
- await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "domcontentloaded" });
220
+ await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: 'domcontentloaded' });
280
221
  await page.waitForTimeout(2000);
281
222
  await scrollLoad(page, 2);
282
223
 
283
- const books = await extractBookList(page);
284
- if (!books || !Array.isArray(books)) {
285
- throw new Error("extractBookList 返回异常");
224
+ const bookIds = await extractBookIds(page);
225
+ if (!bookIds || !bookIds.length) {
226
+ throw new Error('extractBookIds 返回空');
286
227
  }
287
- 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;
288
247
  }, MAX_RETRIES, RETRY_DELAY_MS, `品类[${cat.name}]`);
289
248
  }
290
249
 
@@ -293,106 +252,82 @@ async function scrapeChannel(ch, type) {
293
252
  const tyLabel = typeLabel(type);
294
253
  console.log(`\n→ 采集 ${chLabel}${tyLabel}...`);
295
254
 
296
- const initCatId = ch === "1" ? "1141" : "1139"; // 男频:西方奇幻 / 女频:古风世情
297
- 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}`;
298
257
 
299
- 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
+ });
300
262
  const context = await browser.newContext();
301
- const page = await context.newPage();
263
+ const page = await context.newPage();
302
264
 
303
- // CDP 强制窗口最大化
265
+ // CDP 窗口最大化
304
266
  try {
305
267
  const cdp = await context.newCDPSession(page);
306
268
  const { windowId } = await cdp.send('Browser.getWindowForTarget', { target: page.target() });
307
269
  await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
308
- } catch (_) { }
270
+ } catch (_) {}
309
271
 
310
272
  let scraped = null;
311
273
 
312
274
  try {
313
- // 打开入口页(带重试)
275
+ // 入口页(带重试)
314
276
  const homeResult = await withRetry(async () => {
315
- await page.goto(initUrl, { waitUntil: "domcontentloaded" });
277
+ await page.goto(initUrl, { waitUntil: 'domcontentloaded' });
316
278
  const probe = await probePage(page);
317
- if (!probe.host || probe.host.indexOf("fanqie") === -1) {
318
- throw new Error(`非番茄页面(host=${probe.host}),可能被重定向到登录/验证页`);
279
+ if (!probe.host || probe.host.indexOf('fanqie') === -1) {
280
+ throw new Error(`非番茄页面(host=${probe.host})`);
319
281
  }
320
282
  return probe;
321
- }, MAX_RETRIES, RETRY_DELAY_MS, `打开入口页`);
283
+ }, MAX_RETRIES, RETRY_DELAY_MS, '打开入口页');
322
284
 
323
285
  if (homeResult._failed) {
324
- console.log(` ✗ 入口页采集连续失败:${homeResult.err.message}`);
286
+ console.log(` ✗ 入口页连续失败:${homeResult.err.message}`);
325
287
  return null;
326
288
  }
327
289
 
328
- // 等待登录(用户可在此时手动登录)
329
290
  if (LOGIN_WAIT > 0) {
330
291
  console.log(` ⏳ 等待 ${LOGIN_WAIT}s 供用户登录...`);
331
292
  await page.waitForTimeout(LOGIN_WAIT * 1000);
332
293
  }
333
294
 
334
- // ─── 调试模式:拦截所有请求,找出真实数据接口 ───
295
+ // PROBE 调试模式(保留原有逻辑)
335
296
  if (PROBE) {
336
297
  const intercepted = [];
337
298
  const seen = new Set();
338
-
339
- context.on("request", req => {
299
+ context.on('request', req => {
340
300
  const url = req.url();
341
- if (seen.has(url)) return;
342
- if (/\.(css|woff|jpg|png|ico)/.test(url)) return;
343
- if (!url.includes("fanqie")) return;
301
+ if (seen.has(url) || /\.(css|woff|jpg|png|ico)/.test(url) || !url.includes('fanqie')) return;
344
302
  seen.add(url);
345
303
  console.log(`[REQ] ${url}`);
346
304
  });
347
-
348
- page.on("response", async (resp) => {
305
+ page.on('response', async resp => {
349
306
  const url = resp.url();
350
- const ct = (resp.headers()["content-type"] || "").toLowerCase();
351
307
  if (seen.has(url)) return;
352
- if (!ct.includes("json") && !ct.includes("text") && !url.includes("api")) return;
353
- 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;
354
310
  try {
355
311
  const body = await resp.text();
356
312
  if (!body || body.length < 50) return;
357
- let parsed;
358
- try { parsed = JSON.parse(body); } catch { return; }
359
- const str = JSON.stringify(parsed);
360
- 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')) {
361
315
  console.log(`[JSON] ${url}`);
362
- console.log(` 预览: ${str.slice(0, 300)}`);
363
- intercepted.push({ url, preview: str.slice(0, 400) });
316
+ intercepted.push({ url, preview: str.slice(0, 300) });
364
317
  }
365
- } catch {}
318
+ } catch (_) {}
366
319
  });
367
-
368
320
  const cats = await extractCategories(page, ch, type);
369
321
  if (!cats.length) {
370
322
  await page.goto(`https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`);
371
323
  await page.waitForTimeout(3000);
372
- const moreCats = await extractCategories(page, ch, type);
373
- if (moreCats.length) {
374
- console.log(` 发现 ${moreCats.length} 个品类`);
375
- for (const c of moreCats.slice(0, 3)) {
376
- console.log(` 探测品类: ${c.name} → ${c.href}`);
377
- await page.goto(`https://fanqienovel.com${c.href}`);
378
- await page.waitForTimeout(2000);
379
- }
380
- }
381
- } else {
382
- console.log(` 发现 ${cats.length} 个品类`);
383
- for (const c of cats.slice(0, 3)) {
384
- console.log(` 探测品类: ${c.name} → ${c.href}`);
385
- await page.goto(`https://fanqienovel.com${c.href}`);
386
- await page.waitForTimeout(2000);
387
- }
388
324
  }
389
-
390
- console.log(`\n=== 共拦截到 ${intercepted.length} 个含书籍数据的接口 ===`);
325
+ console.log(`\n=== 共拦截 ${intercepted.length} 个含书籍数据的接口 ===`);
391
326
  await browser.close();
392
327
  return null;
393
328
  }
394
329
 
395
- // 提取品类菜单
330
+ // 提取品类
396
331
  let categories = await extractCategories(page, ch, type);
397
332
  if (!categories.length) {
398
333
  await scrollLoad(page, 2);
@@ -400,72 +335,50 @@ async function scrapeChannel(ch, type) {
400
335
  categories = await extractCategories(page, ch, type);
401
336
  }
402
337
  if (!categories.length) {
403
- console.log(` 未提取到品类菜单,降级为单题材采集(入口页)`);
404
- categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
338
+ categories = [{ name: '全部(入口页)', href: `/rank/${ch}_${type}_${initCatId}` }];
405
339
  } else {
406
340
  console.log(` 发现 ${categories.length} 个品类`);
407
341
  }
408
342
 
409
- const now = new Date().toISOString();
343
+ const now = new Date().toISOString();
410
344
  const lines = [
411
345
  `# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
412
- "",
346
+ '',
413
347
  `- 频道参数:channel=${ch},type=${type}`,
414
348
  `- 抓取时间:${now}`,
415
- "",
416
- "---",
417
- "",
349
+ '',
350
+ '---',
351
+ '',
418
352
  ];
419
353
 
420
- let totalBooks = 0;
421
- const failedCategories = []; // { name, err }
354
+ const failedCategories = [];
422
355
 
423
356
  for (let ci = 0; ci < categories.length; ci++) {
424
357
  const cat = categories[ci];
425
358
  console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
426
359
 
427
- const booksResult = await scrapeCategoryWithRetry(page, cat);
360
+ const result = await scrapeCategory(browser, page, cat, chLabel);
428
361
 
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}次)`, "", "---", "");
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}次)`, '', '---', '');
433
366
  continue;
434
367
  }
435
368
 
436
- let books = booksResult;
437
- if (!books.length) {
438
- lines.push(`## ${cat.name} — 0 本`, "", "---", "");
369
+ const bookLines = result;
370
+ if (!bookLines.length) {
371
+ lines.push(`## ${cat.name} — 0 本`, '', '---', '');
439
372
  continue;
440
373
  }
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
- }
462
374
 
463
- lines.push("---", "");
375
+ lines.push(`## ${cat.name} — ${bookLines.filter(l => l.startsWith('书名:')).length} 本`, '');
376
+ lines.push(...bookLines);
377
+ lines.push('---', '');
464
378
  }
465
379
 
466
- scraped = lines.join("\n");
380
+ scraped = lines.join('\n');
467
381
 
468
- // 输出失败品类汇总
469
382
  if (failedCategories.length > 0) {
470
383
  console.log(`\n⚠️ 以下品类采集失败(共 ${failedCategories.length} 项):`);
471
384
  for (const f of failedCategories) {
@@ -481,31 +394,27 @@ async function scrapeChannel(ch, type) {
481
394
  }
482
395
 
483
396
  async function main() {
484
- if (!["0", "1", "all"].includes(CHANNEL)) {
485
- throw new Error(`未知 --channel: ${CHANNEL}`);
486
- }
487
- if (!["1", "2", "all"].includes(TYPE)) {
488
- throw new Error(`未知 --type: ${TYPE}`);
489
- }
490
- const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
491
- const types = TYPE === "all" ? ["2", "1"] : [TYPE];
492
- let written = 0;
493
- 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 = [];
494
404
 
495
405
  for (const ch of channels) {
496
406
  for (const ty of types) {
497
407
  try {
498
408
  const content = await scrapeChannel(ch, ty);
499
409
  if (!content) {
500
- failedChannels.push({ ch, ty, err: "采集返回空" });
410
+ failedChannels.push({ ch, ty, err: '采集返回空' });
501
411
  continue;
502
412
  }
503
-
504
- const date = localDateStamp();
413
+ const date = localDateStamp();
505
414
  const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
506
415
  fs.mkdirSync(OUTDIR, { recursive: true });
507
416
  const filepath = path.join(OUTDIR, filename);
508
- fs.writeFileSync(filepath, content, "utf-8");
417
+ fs.writeFileSync(filepath, content, 'utf-8');
509
418
  written++;
510
419
  console.log(` ✓ 已保存: ${filepath}`);
511
420
  } catch (chErr) {
@@ -515,7 +424,6 @@ async function main() {
515
424
  }
516
425
  }
517
426
 
518
- // 输出失败榜单汇总
519
427
  if (failedChannels.length > 0) {
520
428
  console.log(`\n⚠️ 以下榜单采集失败(共 ${failedChannels.length} 项):`);
521
429
  for (const f of failedChannels) {
@@ -527,13 +435,7 @@ async function main() {
527
435
  }
528
436
 
529
437
  if (require.main === module) {
530
- runCli(main, "番茄采集");
438
+ runCli(main, '番茄采集');
531
439
  }
532
440
 
533
- // 导出纯函数/JS 构建器
534
- module.exports = {
535
- fmtReads,
536
- fmtWords,
537
- fmtStatus,
538
- cleanDesc,
539
- };
441
+ module.exports = {};