@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
|
@@ -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.
|
|
12
|
-
* node fanqie-rank-scraper.
|
|
13
|
-
* node fanqie-rank-scraper.
|
|
14
|
-
* node fanqie-rank-scraper.
|
|
15
|
-
* node fanqie-rank-scraper.
|
|
16
|
-
* node fanqie-rank-scraper.
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
}))
|
|
170
|
+
}));
|
|
139
171
|
});
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
/**
|
|
174
|
+
/** 分批解码,避免单次 eval 超时 */
|
|
143
175
|
async function fetchDetailsChunk(page, ids) {
|
|
144
176
|
return page.evaluate((ids) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
/**
|
|
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.
|
|
217
|
-
|
|
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
|
|
227
|
-
const n =
|
|
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 =
|
|
236
|
-
if (isNaN(n)) return
|
|
237
|
-
if (n >=
|
|
238
|
-
return
|
|
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
|
-
|
|
243
|
-
if (
|
|
244
|
-
|
|
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 =
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
const info = details[String(b.bookId)] || {};
|
|
447
|
-
totalBooks++;
|
|
448
|
-
const resolved = !!info.title;
|
|
463
|
+
lines.push("---", "");
|
|
464
|
+
}
|
|
449
465
|
|
|
450
|
-
|
|
451
|
-
const author = info.author || "未知";
|
|
452
|
-
const category = info.category || b.category || "";
|
|
466
|
+
scraped = lines.join("\n");
|
|
453
467
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|