@cloud411716/fancy-webnovel 0.3.15 → 0.3.16

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.15",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -33,6 +33,29 @@ const PLATFORM_CN = {
33
33
  // 工具函数
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
+ const MAX_RETRIES = 3;
37
+ const RETRY_DELAY_MS = 5000;
38
+
39
+ function sleep(ms) {
40
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
41
+ }
42
+
43
+ async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, label = '') {
44
+ let lastErr;
45
+ for (let attempt = 1; attempt <= retries; attempt++) {
46
+ try {
47
+ return await fn();
48
+ } catch (err) {
49
+ lastErr = err;
50
+ if (attempt < retries) {
51
+ console.log(` ⏳ ${label} 第${attempt}次失败,${delayMs / 1000}s后重试...`);
52
+ sleep(delayMs);
53
+ }
54
+ }
55
+ }
56
+ return { _failed: true, err: lastErr, label };
57
+ }
58
+
36
59
  function nowIso() {
37
60
  return new Date().toISOString();
38
61
  }
@@ -64,31 +87,21 @@ async function scrapeQidian(outDir, ranks) {
64
87
 
65
88
  for (const rt of ranks) {
66
89
  const url = `${MOBILE_BASE}${rt.path}`;
67
- let html = '';
68
- try {
69
- html = await fetch(url, { headers: MOBILE_HEADERS, signal: AbortSignal.timeout(15000) }).then(r => r.text());
70
- } catch (e) {
71
- console.error(` ⚠ ${rt.label} 请求失败: ${e.message}`);
72
- continue;
73
- }
74
90
 
75
- const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
76
- if (!m) {
77
- console.error(` ⚠ ${rt.label} 未找到 pageContext`);
78
- continue;
79
- }
91
+ const doScrape = async () => {
92
+ const html = await fetch(url, { headers: MOBILE_HEADERS, signal: AbortSignal.timeout(15000) }).then(r => r.text());
93
+ const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
94
+ if (!m) throw new Error(`未找到 pageContext`);
95
+ const pageContext = JSON.parse(m[1]);
96
+ const records = pageContext?.pageContext?.pageProps?.pageData?.records || [];
97
+ if (!records.length) throw new Error(`无书籍数据`);
98
+ return records;
99
+ };
80
100
 
81
- let pageContext;
82
- try {
83
- pageContext = JSON.parse(m[1]);
84
- } catch (_) {
85
- console.error(` ⚠ ${rt.label} JSON 解析失败`);
86
- continue;
87
- }
101
+ const records = await withRetry(doScrape, MAX_RETRIES, RETRY_DELAY_MS, `起点${rt.label}`);
88
102
 
89
- const records = pageContext?.pageContext?.pageProps?.pageData?.records || [];
90
- if (!records.length) {
91
- console.error(` ⚠ ${rt.label} 无书籍数据`);
103
+ if (records._failed) {
104
+ console.error(` ⚠ 起点${rt.label}连续${MAX_RETRIES}次失败:${records.err.message}`);
92
105
  continue;
93
106
  }
94
107
 
@@ -38,6 +38,8 @@ const RANK_TYPES = [
38
38
  ];
39
39
 
40
40
  const DETAIL_CHUNK = 6;
41
+ const MAX_RETRIES = 3;
42
+ const RETRY_DELAY_MS = 5000;
41
43
 
42
44
  // ---------------------------------------------------------------------------
43
45
  // 工具函数
@@ -47,6 +49,26 @@ function sleep(ms) {
47
49
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
50
  }
49
51
 
52
+ /**
53
+ * 重试包装器
54
+ * @returns {{_failed:true, err, label}|*} 失败时返回 _failed=true 的标记对象
55
+ */
56
+ async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, label = '') {
57
+ let lastErr;
58
+ for (let attempt = 1; attempt <= retries; attempt++) {
59
+ try {
60
+ return await fn();
61
+ } catch (err) {
62
+ lastErr = err;
63
+ if (attempt < retries) {
64
+ console.log(` ⏳ ${label} 第${attempt}次失败,${delayMs / 1000}s后重试...`);
65
+ sleep(delayMs);
66
+ }
67
+ }
68
+ }
69
+ return { _failed: true, err: lastErr, label };
70
+ }
71
+
50
72
  function fmtWan(s, unit) {
51
73
  if (s == null || s === "") return "";
52
74
  const n = parseInt(String(s).replace(/[^0-9]/g, ""), 10);
@@ -126,19 +148,15 @@ async function scrapeRank(rankTypeId, channelId) {
126
148
  } catch (_) {}
127
149
 
128
150
  let data = null;
129
- try {
151
+ const pageLabel = `${rt.label}(${chLabel})`;
152
+ const doScrape = async () => {
130
153
  await page.goto(url, { waitUntil: "networkidle" });
131
154
  await page.waitForTimeout(3000);
132
-
133
- // 连通性自检
134
155
  const host = page.url();
135
156
  if (host.indexOf("jjwxc") === -1) {
136
- console.error(` ✗ 当前页面非晋江(url=${host}),可能被重定向,已跳过。`);
137
- return null;
157
+ throw new Error(`非晋江页面(url=${host}),可能被重定向`);
138
158
  }
139
-
140
- // 布局检测:先找含"序号"/"作者"/"作品"表头的表格,有则为布局A(表格),无为布局B(交替行)
141
- data = await page.evaluate(() => {
159
+ return await page.evaluate(() => {
142
160
  // ── 布局 A:表格型(表头在第一行 <tr> 的 <td> 里,含"序号""作者""作品") ──
143
161
  var tables = document.querySelectorAll('table');
144
162
  for (var ti = 0; ti < tables.length; ti++) {
@@ -228,17 +246,21 @@ async function scrapeRank(rankTypeId, channelId) {
228
246
  }
229
247
  return result;
230
248
  });
249
+ };
231
250
 
232
- console.log(` 布局检测:${data.layout === 'A' ? '表格型' : '交替行型'}`);
251
+ const dataResult = await withRetry(doScrape, MAX_RETRIES, RETRY_DELAY_MS, `晋江${pageLabel}`);
233
252
 
234
- if (!data?.channels?.length) {
235
- console.error(`[jjwxc] 采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开 ${url} 确认。`);
236
- return null;
237
- }
238
- } finally {
253
+ if (dataResult._failed) {
254
+ console.log(` 晋江${pageLabel}连续${MAX_RETRIES}次失败:${dataResult.err.message}`);
239
255
  await browser.close();
256
+ return null;
240
257
  }
241
258
 
259
+ data = dataResult;
260
+ await browser.close();
261
+
262
+ console.log(` ✓ 布局检测:${data.layout === 'A' ? '表格型' : '交替行型'}`);
263
+
242
264
  let totalBooks = 0;
243
265
  data.channels.forEach(ch => {
244
266
  totalBooks += ch.books.length;
@@ -42,10 +42,33 @@ const PERIODS = [
42
42
  // 工具函数
43
43
  // ---------------------------------------------------------------------------
44
44
 
45
+ const MAX_RETRIES = 3;
46
+ const RETRY_DELAY_MS = 5000;
47
+
45
48
  function sleep(ms) {
46
49
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
47
50
  }
48
51
 
52
+ /**
53
+ * 重试包装器
54
+ * @returns {{_failed:true, err, label}|*} 失败时返回 _failed=true 的标记对象
55
+ */
56
+ async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, label = '') {
57
+ let lastErr;
58
+ for (let attempt = 1; attempt <= retries; attempt++) {
59
+ try {
60
+ return await fn();
61
+ } catch (err) {
62
+ lastErr = err;
63
+ if (attempt < retries) {
64
+ console.log(` ⏳ ${label} 第${attempt}次失败,${delayMs / 1000}s后重试...`);
65
+ sleep(delayMs);
66
+ }
67
+ }
68
+ }
69
+ return { _failed: true, err: lastErr, label };
70
+ }
71
+
49
72
  function rankUrl(channelId, rankTypeId, periodId) {
50
73
  const channel = CHANNELS.find(c => c.id === channelId);
51
74
  const rt = RANK_TYPES.find(r => r.id === rankTypeId);
@@ -164,7 +187,7 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
164
187
  await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
165
188
  } catch (_) {}
166
189
 
167
- // 拦截 XHR 响应,从 JSON 直接拿 bookId + 明文书名/作者,绕过字体加密
190
+ // 拦截 XHR 响应(只注册一次,放到 withRetry 外面)
168
191
  const xhrBookMap = new Map();
169
192
  page.on("response", async (resp) => {
170
193
  try {
@@ -176,13 +199,12 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
176
199
  if (!body || body.length < 100) return;
177
200
  let parsed;
178
201
  try { parsed = JSON.parse(body); } catch { return; }
179
- // 遍历 JSON 结构,找含 bookId + title/author 的数组
180
202
  const found = findBookArrays(parsed);
181
203
  for (const b of found) {
182
204
  if (!b.bookId && !b.novel_id && !b.id) continue;
183
205
  const id = String(b.bookId || b.novel_id || b.id);
184
206
  xhrBookMap.set(id, {
185
- id: id,
207
+ id,
186
208
  title: b.title || b.bookName || b.name || "",
187
209
  author: b.author || b.authorName || "",
188
210
  });
@@ -190,14 +212,14 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
190
212
  } catch (_) {}
191
213
  });
192
214
 
193
- let books, rawCount;
194
- try {
215
+ // 单次采集+提取逻辑(包进 withRetry)
216
+ const pageLabel = `${ch.label}${rt.label}${period ? period.label : ""}`;
217
+ const doScrape = async () => {
195
218
  await page.goto(url, { waitUntil: "networkidle" });
196
219
  await page.waitForTimeout(3000);
197
220
 
198
221
  if (page.url().indexOf("qimao") === -1) {
199
- console.error(` ✗ 当前页面非七猫,可能被重定向,已跳过。`);
200
- return null;
222
+ throw new Error(`非七猫页面(host=${page.url()}),可能被重定向`);
201
223
  }
202
224
 
203
225
  // 验证页面实际选中状态
@@ -224,13 +246,11 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
224
246
  observed.rankType !== rt.label ||
225
247
  (period && observed.period !== period.label)
226
248
  ) {
227
- console.error(
228
- ` ✗ 页面实际榜单与请求不一致(请求 ${ch.tab}/${rt.label}/${periodLabel},` +
229
- `实际 ${observed.channel || "?"}/${observed.rankType || "?"}/${observed.period || "?"}),已跳过。`
249
+ throw new Error(
250
+ `页面榜单不一致(请求 ${ch.tab}/${rt.label}/${periodLabel},` +
251
+ `实际 ${observed.channel || "?"}/${observed.rankType || "?"}/${observed.period || "?"})`
230
252
  );
231
- return null;
232
253
  }
233
- console.log(` ✓ 已验证页面实际榜单:${observed.channel}/${observed.rankType}${observed.period ? "/" + observed.period : ""}`);
234
254
 
235
255
  // 滚动加载
236
256
  for (let i = 0; i < 5; i++) {
@@ -275,10 +295,21 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
275
295
  return books;
276
296
  });
277
297
 
278
- rawCount = rawBooks.length;
279
- books = rawBooks.filter(isUsableBook);
298
+ if (!rawBooks.length) throw new Error("innerText 解析结果为空");
299
+ return rawBooks;
300
+ };
301
+
302
+ const rawResult = await withRetry(doScrape, MAX_RETRIES, RETRY_DELAY_MS, `七猫${pageLabel}`);
303
+
304
+ let books = null;
305
+ if (rawResult._failed) {
306
+ console.log(` ✗ 七猫${pageLabel}连续${MAX_RETRIES}次失败:${rawResult.err.message}`);
307
+ await browser.close();
308
+ return null;
309
+ } else {
310
+ const rawBooks = rawResult.filter(isUsableBook);
280
311
 
281
- // 直接从 DOM 提取 /shuku/{id}/ 链接补到每本书上(不依赖 XHR 匹配)
312
+ // DOM 链接补全
282
313
  await page.evaluate(() => {
283
314
  if (window.__qimaoUrlMap) return;
284
315
  var urlMap = {};
@@ -296,18 +327,17 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
296
327
  const rawUrlMap = await page.evaluate(() => window.__qimaoUrlMap || '{}');
297
328
  const urlMap = JSON.parse(rawUrlMap);
298
329
  const norm = s => (s || '').replace(/\s+/g, '');
299
- for (const b of books) {
330
+ for (const b of rawBooks) {
300
331
  if (!b.url) {
301
332
  var found = Object.entries(urlMap).find(([t]) => norm(t) === norm(b.title));
302
333
  if (found) b.url = found[1];
303
334
  }
304
335
  }
305
336
 
306
- // XHR 数据覆盖明文书名/作者,绕过字体加密
337
+ // XHR 明文覆盖
307
338
  if (xhrBookMap.size > 0) {
308
339
  let updated = 0;
309
- for (const b of books) {
310
- // 尝试从 XHR map 中找匹配的明文数据(优先用 bookId,其次用书名归一匹配)
340
+ for (const b of rawBooks) {
311
341
  const xhrEntry = findXhrMatch(b, xhrBookMap);
312
342
  if (xhrEntry) {
313
343
  if (xhrEntry.title && xhrEntry.title !== b.title) {
@@ -315,24 +345,24 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
315
345
  updated++;
316
346
  }
317
347
  if (xhrEntry.author) b.author = xhrEntry.author;
318
- // 用 XHR 数据里的 id 补 URL(如果有的话)
319
348
  const id = xhrEntry.id || findIdByTitle(b.title, xhrBookMap);
320
349
  if (id) b.url = `https://www.qimao.com/shuku/${id}/`;
321
350
  }
322
351
  }
323
- console.log(` ✓ XHR 明文更新 ${updated}/${books.length} 本`);
352
+ console.log(` ✓ XHR 明文更新 ${updated}/${rawBooks.length} 本`);
324
353
  }
325
354
 
326
- } finally {
327
- await browser.close();
355
+ books = rawBooks;
328
356
  }
329
357
 
330
- if (!books.length) {
358
+ await browser.close();
359
+
360
+ if (!books || !books.length) {
331
361
  console.error(`[qimao] 采集失败:页面结构可能已变。`);
332
362
  return null;
333
363
  }
334
364
 
335
- const summary = summarizeQuality(books, rawCount);
365
+ const summary = summarizeQuality(books, books.length);
336
366
  console.log(` ✓ 提取 ${books.length} 本(链接 ${summary.linked}/${books.length},热度 ${summary.heated}/${books.length})`);
337
367
 
338
368
  const periodLabel = period ? period.label : "";
@@ -358,6 +388,7 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
358
388
  return lines.join("\n");
359
389
  }
360
390
 
391
+
361
392
  function buildTargets(channel, rankType, period) {
362
393
  const channels = channel === "all" ? CHANNELS.map(c => c.id) : [channel];
363
394
  const rankTypes = rankType === "all" ? RANK_TYPES.map(r => r.id) : [rankType];