@cloud411716/fancy-webnovel 0.3.14 → 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 +1 -1
- package/plugins/fancy-scan/scripts/run-scan.js +35 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.cjs +1 -1
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.cjs +37 -15
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.cjs +57 -26
package/package.json
CHANGED
|
@@ -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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
|
@@ -256,7 +256,7 @@ async function scrapeChannel(ch, type) {
|
|
|
256
256
|
const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
|
|
257
257
|
|
|
258
258
|
const browser = await chromium.launch({
|
|
259
|
-
headless:
|
|
259
|
+
headless: true,
|
|
260
260
|
args: ['--no-sandbox', '--disable-dev-shm-usage', '--start-maximized'],
|
|
261
261
|
});
|
|
262
262
|
const context = await browser.newContext();
|
|
@@ -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);
|
|
@@ -114,7 +136,7 @@ async function scrapeRank(rankTypeId, channelId) {
|
|
|
114
136
|
console.log(`\n→ 采集 晋江${rt.label}(${chLabel})...`);
|
|
115
137
|
console.log(` URL: ${url}`);
|
|
116
138
|
|
|
117
|
-
const browser = await chromium.launch({ headless:
|
|
139
|
+
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--start-maximized"] });
|
|
118
140
|
const context = await browser.newContext();
|
|
119
141
|
const page = await context.newPage();
|
|
120
142
|
|
|
@@ -126,19 +148,15 @@ async function scrapeRank(rankTypeId, channelId) {
|
|
|
126
148
|
} catch (_) {}
|
|
127
149
|
|
|
128
150
|
let data = null;
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
+
const dataResult = await withRetry(doScrape, MAX_RETRIES, RETRY_DELAY_MS, `晋江${pageLabel}`);
|
|
233
252
|
|
|
234
|
-
|
|
235
|
-
|
|
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);
|
|
@@ -153,7 +176,7 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
|
|
|
153
176
|
const url = rankUrl(channelId, rankTypeId, periodId);
|
|
154
177
|
console.log(`\n→ 采集 七猫${ch.label}${rt.label}${period ? period.label : ""}...`);
|
|
155
178
|
|
|
156
|
-
const browser = await chromium.launch({ headless:
|
|
179
|
+
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--start-maximized"] });
|
|
157
180
|
const context = await browser.newContext();
|
|
158
181
|
const page = await context.newPage();
|
|
159
182
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
337
|
+
// XHR 明文覆盖
|
|
307
338
|
if (xhrBookMap.size > 0) {
|
|
308
339
|
let updated = 0;
|
|
309
|
-
for (const b of
|
|
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}/${
|
|
352
|
+
console.log(` ✓ XHR 明文更新 ${updated}/${rawBooks.length} 本`);
|
|
324
353
|
}
|
|
325
354
|
|
|
326
|
-
|
|
327
|
-
await browser.close();
|
|
355
|
+
books = rawBooks;
|
|
328
356
|
}
|
|
329
357
|
|
|
330
|
-
|
|
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,
|
|
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];
|