@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
|
@@ -3,19 +3,16 @@
|
|
|
3
3
|
* 番茄小说排行榜采集脚本
|
|
4
4
|
*
|
|
5
5
|
* 使用 playwright-core 自己管理浏览器生命周期。
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
/**
|
|
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
|
|
83
|
-
s.
|
|
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
|
-
|
|
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
|
-
/**
|
|
103
|
-
async function
|
|
104
|
-
// 等 __INITIAL_STATE__
|
|
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 (
|
|
118
|
-
|
|
119
|
-
return [];
|
|
120
|
-
}
|
|
101
|
+
} catch (_) { return []; }
|
|
102
|
+
|
|
121
103
|
return page.evaluate(() => {
|
|
122
104
|
const s = window.__INITIAL_STATE__ || {};
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
112
|
+
let found = null;
|
|
139
113
|
(function walk(o, d) {
|
|
140
114
|
if (found || !o || d > 6) return;
|
|
141
|
-
if (Array.isArray(o)) {
|
|
142
|
-
|
|
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 ===
|
|
150
|
-
for (
|
|
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(
|
|
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
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
262
|
-
const LOGIN_WAIT = parseInt(getArg(args,
|
|
263
|
-
const OUTDIR
|
|
264
|
-
const CHANNEL = getArg(args,
|
|
265
|
-
const TYPE
|
|
266
|
-
const TOP
|
|
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 ===
|
|
212
|
+
return ch === '1' ? '男频' : ch === '0' ? '女频' : ch;
|
|
270
213
|
}
|
|
271
|
-
|
|
272
214
|
function typeLabel(t) {
|
|
273
|
-
return t ===
|
|
215
|
+
return t === '2' ? '阅读榜' : t === '1' ? '新书榜' : t;
|
|
274
216
|
}
|
|
275
217
|
|
|
276
|
-
async function
|
|
218
|
+
async function scrapeCategory(browser, page, cat, chLabel) {
|
|
277
219
|
return withRetry(async () => {
|
|
278
|
-
|
|
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
|
|
284
|
-
if (!
|
|
285
|
-
throw new Error(
|
|
224
|
+
const bookIds = await extractBookIds(page);
|
|
225
|
+
if (!bookIds || !bookIds.length) {
|
|
226
|
+
throw new Error('extractBookIds 返回空');
|
|
286
227
|
}
|
|
287
|
-
|
|
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 ===
|
|
297
|
-
const initUrl
|
|
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({
|
|
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
|
|
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:
|
|
277
|
+
await page.goto(initUrl, { waitUntil: 'domcontentloaded' });
|
|
316
278
|
const probe = await probePage(page);
|
|
317
|
-
if (!probe.host || probe.host.indexOf(
|
|
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(` ✗
|
|
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
|
-
|
|
353
|
-
if (
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
360
|
+
const result = await scrapeCategory(browser, page, cat, chLabel);
|
|
428
361
|
|
|
429
|
-
if (
|
|
430
|
-
console.log(` ✗ 品类[${cat.name}]连续${MAX_RETRIES}次失败:${
|
|
431
|
-
failedCategories.push({ name: cat.name, err:
|
|
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
|
-
|
|
437
|
-
if (!
|
|
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(
|
|
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 (![
|
|
485
|
-
throw new Error(`未知 --
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
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,
|
|
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
|
-
|
|
534
|
-
module.exports = {
|
|
535
|
-
fmtReads,
|
|
536
|
-
fmtWords,
|
|
537
|
-
fmtStatus,
|
|
538
|
-
cleanDesc,
|
|
539
|
-
};
|
|
441
|
+
module.exports = {};
|