@cloud411716/fancy-webnovel 0.3.10 → 0.3.12
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,25 @@ 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
|
+
await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "networkidle" });
|
|
279
|
+
await page.waitForTimeout(2500);
|
|
280
|
+
await scrollLoad(page, 2);
|
|
281
|
+
|
|
282
|
+
const books = await extractBookList(page);
|
|
283
|
+
if (!books || !Array.isArray(books)) {
|
|
284
|
+
throw new Error("extractBookList 返回异常");
|
|
285
|
+
}
|
|
286
|
+
return books;
|
|
287
|
+
}, MAX_RETRIES, RETRY_DELAY_MS, `品类[${cat.name}]`);
|
|
282
288
|
}
|
|
283
289
|
|
|
284
290
|
async function scrapeChannel(ch, type) {
|
|
@@ -298,15 +304,25 @@ async function scrapeChannel(ch, type) {
|
|
|
298
304
|
const cdp = await context.newCDPSession(page);
|
|
299
305
|
const { windowId } = await cdp.send('Browser.getWindowForTarget', { target: page.target() });
|
|
300
306
|
await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
|
|
301
|
-
} catch (_) {}
|
|
307
|
+
} catch (_) { }
|
|
302
308
|
|
|
303
309
|
let scraped = null;
|
|
304
310
|
|
|
305
311
|
try {
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
312
|
+
// 打开入口页(带重试)
|
|
313
|
+
const homeResult = await withRetry(async () => {
|
|
314
|
+
await page.goto(initUrl, { waitUntil: "networkidle" });
|
|
315
|
+
const probe = await probePage(page);
|
|
316
|
+
if (!probe.host || probe.host.indexOf("fanqie") === -1) {
|
|
317
|
+
throw new Error(`非番茄页面(host=${probe.host}),可能被重定向到登录/验证页`);
|
|
318
|
+
}
|
|
319
|
+
return probe;
|
|
320
|
+
}, MAX_RETRIES, RETRY_DELAY_MS, `打开入口页`);
|
|
321
|
+
|
|
322
|
+
if (homeResult._failed) {
|
|
323
|
+
console.log(` ✗ 入口页采集连续失败:${homeResult.err.message}`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
310
326
|
|
|
311
327
|
// 等待登录(用户可在此时手动登录)
|
|
312
328
|
if (LOGIN_WAIT > 0) {
|
|
@@ -314,24 +330,11 @@ async function scrapeChannel(ch, type) {
|
|
|
314
330
|
await page.waitForTimeout(LOGIN_WAIT * 1000);
|
|
315
331
|
}
|
|
316
332
|
|
|
317
|
-
// 连通性自检
|
|
318
|
-
const probe = await probePage(page);
|
|
319
|
-
if (!probe.host || probe.host.indexOf("fanqie") === -1) {
|
|
320
|
-
console.error(
|
|
321
|
-
` ✗ 当前页面非番茄(host=${probe.host}),可能被重定向到登录/验证页,已跳过。`
|
|
322
|
-
);
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
325
|
-
if (!probe.hasState) {
|
|
326
|
-
console.log(` ⚠ 页面未挂载 __INITIAL_STATE__,将尝试兜底扫描,结果可能不完整。`);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
333
|
// ─── 调试模式:拦截所有请求,找出真实数据接口 ───
|
|
330
334
|
if (PROBE) {
|
|
331
335
|
const intercepted = [];
|
|
332
336
|
const seen = new Set();
|
|
333
337
|
|
|
334
|
-
// 先注册拦截器,再导航到目标页(避免漏掉 initial 请求)
|
|
335
338
|
context.on("request", req => {
|
|
336
339
|
const url = req.url();
|
|
337
340
|
if (seen.has(url)) return;
|
|
@@ -361,7 +364,6 @@ async function scrapeChannel(ch, type) {
|
|
|
361
364
|
} catch {}
|
|
362
365
|
});
|
|
363
366
|
|
|
364
|
-
// 直接导航到品类页(不用 waitUntil,避免卡住)
|
|
365
367
|
const cats = await extractCategories(page, ch, type);
|
|
366
368
|
if (!cats.length) {
|
|
367
369
|
await page.goto(`https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`);
|
|
@@ -415,59 +417,61 @@ async function scrapeChannel(ch, type) {
|
|
|
415
417
|
];
|
|
416
418
|
|
|
417
419
|
let totalBooks = 0;
|
|
418
|
-
|
|
419
|
-
const bodyLines = [];
|
|
420
|
+
const failedCategories = []; // { name, err }
|
|
420
421
|
|
|
421
422
|
for (let ci = 0; ci < categories.length; ci++) {
|
|
422
423
|
const cat = categories[ci];
|
|
423
424
|
console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
|
|
424
425
|
|
|
425
|
-
|
|
426
|
-
await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "networkidle" }).catch(gotoErr => {
|
|
427
|
-
console.error(` ✗ 打开品类页失败:${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);
|
|
426
|
+
const booksResult = await scrapeCategoryWithRetry(page, cat);
|
|
438
427
|
|
|
439
|
-
|
|
440
|
-
|
|
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}次)`, "", "---", "");
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
441
434
|
|
|
442
|
-
|
|
435
|
+
let books = booksResult;
|
|
436
|
+
if (!books.length) {
|
|
437
|
+
lines.push(`## ${cat.name} — 0 本`, "", "---", "");
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
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
|
+
}
|
|
443
461
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const info = details[String(b.bookId)] || {};
|
|
447
|
-
totalBooks++;
|
|
448
|
-
const resolved = !!info.title;
|
|
462
|
+
lines.push("---", "");
|
|
463
|
+
}
|
|
449
464
|
|
|
450
|
-
|
|
451
|
-
const author = info.author || "未知";
|
|
452
|
-
const category = info.category || b.category || "";
|
|
465
|
+
scraped = lines.join("\n");
|
|
453
466
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
bodyLines.push("---", "");
|
|
462
|
-
} catch (catErr) {
|
|
463
|
-
console.error(
|
|
464
|
-
` [fanqie] 品类 ${cat.name} 处理出错,跳过: ${catErr && catErr.message ? catErr.message : catErr}`
|
|
465
|
-
);
|
|
466
|
-
bodyLines.push(`## ${cat.name} — 采集失败`, "", "---", "");
|
|
467
|
+
// 输出失败品类汇总
|
|
468
|
+
if (failedCategories.length > 0) {
|
|
469
|
+
console.log(`\n⚠️ 以下品类采集失败(共 ${failedCategories.length} 项):`);
|
|
470
|
+
for (const f of failedCategories) {
|
|
471
|
+
console.log(` • ${f.name}:${f.err}`);
|
|
467
472
|
}
|
|
468
473
|
}
|
|
469
474
|
|
|
470
|
-
scraped = lines.concat(bodyLines).join("\n");
|
|
471
475
|
} finally {
|
|
472
476
|
await browser.close();
|
|
473
477
|
}
|
|
@@ -485,12 +489,16 @@ async function main() {
|
|
|
485
489
|
const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
|
|
486
490
|
const types = TYPE === "all" ? ["2", "1"] : [TYPE];
|
|
487
491
|
let written = 0;
|
|
492
|
+
const failedChannels = []; // { ch, ty, err }
|
|
488
493
|
|
|
489
494
|
for (const ch of channels) {
|
|
490
495
|
for (const ty of types) {
|
|
491
496
|
try {
|
|
492
497
|
const content = await scrapeChannel(ch, ty);
|
|
493
|
-
if (!content)
|
|
498
|
+
if (!content) {
|
|
499
|
+
failedChannels.push({ ch, ty, err: "采集返回空" });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
494
502
|
|
|
495
503
|
const date = localDateStamp();
|
|
496
504
|
const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
|
|
@@ -500,12 +508,20 @@ async function main() {
|
|
|
500
508
|
written++;
|
|
501
509
|
console.log(` ✓ 已保存: ${filepath}`);
|
|
502
510
|
} catch (chErr) {
|
|
503
|
-
console.
|
|
504
|
-
|
|
505
|
-
);
|
|
511
|
+
console.log(` ✗ 频道${channelLabel(ch)}${typeLabel(ty)}采集失败:${chErr.message}`);
|
|
512
|
+
failedChannels.push({ ch, ty, err: chErr.message });
|
|
506
513
|
}
|
|
507
514
|
}
|
|
508
515
|
}
|
|
516
|
+
|
|
517
|
+
// 输出失败榜单汇总
|
|
518
|
+
if (failedChannels.length > 0) {
|
|
519
|
+
console.log(`\n⚠️ 以下榜单采集失败(共 ${failedChannels.length} 项):`);
|
|
520
|
+
for (const f of failedChannels) {
|
|
521
|
+
console.log(` • ${channelLabel(f.ch)}${typeLabel(f.ty)}:${f.err}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
509
525
|
return written;
|
|
510
526
|
}
|
|
511
527
|
|