@cloud411716/fancy-webnovel 0.3.12 → 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,32 +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
|
-
await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil:
|
|
279
|
-
await page.waitForTimeout(
|
|
220
|
+
await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: 'domcontentloaded' });
|
|
221
|
+
await page.waitForTimeout(2000);
|
|
280
222
|
await scrollLoad(page, 2);
|
|
281
223
|
|
|
282
|
-
const
|
|
283
|
-
if (!
|
|
284
|
-
throw new Error(
|
|
224
|
+
const bookIds = await extractBookIds(page);
|
|
225
|
+
if (!bookIds || !bookIds.length) {
|
|
226
|
+
throw new Error('extractBookIds 返回空');
|
|
285
227
|
}
|
|
286
|
-
|
|
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;
|
|
287
247
|
}, MAX_RETRIES, RETRY_DELAY_MS, `品类[${cat.name}]`);
|
|
288
248
|
}
|
|
289
249
|
|
|
@@ -292,106 +252,82 @@ async function scrapeChannel(ch, type) {
|
|
|
292
252
|
const tyLabel = typeLabel(type);
|
|
293
253
|
console.log(`\n→ 采集 ${chLabel}${tyLabel}...`);
|
|
294
254
|
|
|
295
|
-
const initCatId = ch ===
|
|
296
|
-
const initUrl
|
|
255
|
+
const initCatId = ch === '1' ? '1141' : '1139';
|
|
256
|
+
const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
|
|
297
257
|
|
|
298
|
-
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
|
+
});
|
|
299
262
|
const context = await browser.newContext();
|
|
300
|
-
const page
|
|
263
|
+
const page = await context.newPage();
|
|
301
264
|
|
|
302
|
-
// CDP
|
|
265
|
+
// CDP 窗口最大化
|
|
303
266
|
try {
|
|
304
267
|
const cdp = await context.newCDPSession(page);
|
|
305
268
|
const { windowId } = await cdp.send('Browser.getWindowForTarget', { target: page.target() });
|
|
306
269
|
await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
|
|
307
|
-
} catch (_) {
|
|
270
|
+
} catch (_) {}
|
|
308
271
|
|
|
309
272
|
let scraped = null;
|
|
310
273
|
|
|
311
274
|
try {
|
|
312
|
-
//
|
|
275
|
+
// 入口页(带重试)
|
|
313
276
|
const homeResult = await withRetry(async () => {
|
|
314
|
-
await page.goto(initUrl, { waitUntil:
|
|
277
|
+
await page.goto(initUrl, { waitUntil: 'domcontentloaded' });
|
|
315
278
|
const probe = await probePage(page);
|
|
316
|
-
if (!probe.host || probe.host.indexOf(
|
|
317
|
-
throw new Error(`非番茄页面(host=${probe.host}
|
|
279
|
+
if (!probe.host || probe.host.indexOf('fanqie') === -1) {
|
|
280
|
+
throw new Error(`非番茄页面(host=${probe.host})`);
|
|
318
281
|
}
|
|
319
282
|
return probe;
|
|
320
|
-
}, MAX_RETRIES, RETRY_DELAY_MS,
|
|
283
|
+
}, MAX_RETRIES, RETRY_DELAY_MS, '打开入口页');
|
|
321
284
|
|
|
322
285
|
if (homeResult._failed) {
|
|
323
|
-
console.log(` ✗
|
|
286
|
+
console.log(` ✗ 入口页连续失败:${homeResult.err.message}`);
|
|
324
287
|
return null;
|
|
325
288
|
}
|
|
326
289
|
|
|
327
|
-
// 等待登录(用户可在此时手动登录)
|
|
328
290
|
if (LOGIN_WAIT > 0) {
|
|
329
291
|
console.log(` ⏳ 等待 ${LOGIN_WAIT}s 供用户登录...`);
|
|
330
292
|
await page.waitForTimeout(LOGIN_WAIT * 1000);
|
|
331
293
|
}
|
|
332
294
|
|
|
333
|
-
//
|
|
295
|
+
// PROBE 调试模式(保留原有逻辑)
|
|
334
296
|
if (PROBE) {
|
|
335
297
|
const intercepted = [];
|
|
336
298
|
const seen = new Set();
|
|
337
|
-
|
|
338
|
-
context.on("request", req => {
|
|
299
|
+
context.on('request', req => {
|
|
339
300
|
const url = req.url();
|
|
340
|
-
if (seen.has(url)) return;
|
|
341
|
-
if (/\.(css|woff|jpg|png|ico)/.test(url)) return;
|
|
342
|
-
if (!url.includes("fanqie")) return;
|
|
301
|
+
if (seen.has(url) || /\.(css|woff|jpg|png|ico)/.test(url) || !url.includes('fanqie')) return;
|
|
343
302
|
seen.add(url);
|
|
344
303
|
console.log(`[REQ] ${url}`);
|
|
345
304
|
});
|
|
346
|
-
|
|
347
|
-
page.on("response", async (resp) => {
|
|
305
|
+
page.on('response', async resp => {
|
|
348
306
|
const url = resp.url();
|
|
349
|
-
const ct = (resp.headers()["content-type"] || "").toLowerCase();
|
|
350
307
|
if (seen.has(url)) return;
|
|
351
|
-
|
|
352
|
-
if (
|
|
308
|
+
const ct = (resp.headers()['content-type'] || '').toLowerCase();
|
|
309
|
+
if (!ct.includes('json') && !ct.includes('text') && !url.includes('api')) return;
|
|
353
310
|
try {
|
|
354
311
|
const body = await resp.text();
|
|
355
312
|
if (!body || body.length < 50) return;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const str = JSON.stringify(parsed);
|
|
359
|
-
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')) {
|
|
360
315
|
console.log(`[JSON] ${url}`);
|
|
361
|
-
|
|
362
|
-
intercepted.push({ url, preview: str.slice(0, 400) });
|
|
316
|
+
intercepted.push({ url, preview: str.slice(0, 300) });
|
|
363
317
|
}
|
|
364
|
-
} catch {}
|
|
318
|
+
} catch (_) {}
|
|
365
319
|
});
|
|
366
|
-
|
|
367
320
|
const cats = await extractCategories(page, ch, type);
|
|
368
321
|
if (!cats.length) {
|
|
369
322
|
await page.goto(`https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`);
|
|
370
323
|
await page.waitForTimeout(3000);
|
|
371
|
-
const moreCats = await extractCategories(page, ch, type);
|
|
372
|
-
if (moreCats.length) {
|
|
373
|
-
console.log(` 发现 ${moreCats.length} 个品类`);
|
|
374
|
-
for (const c of moreCats.slice(0, 3)) {
|
|
375
|
-
console.log(` 探测品类: ${c.name} → ${c.href}`);
|
|
376
|
-
await page.goto(`https://fanqienovel.com${c.href}`);
|
|
377
|
-
await page.waitForTimeout(2000);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
} else {
|
|
381
|
-
console.log(` 发现 ${cats.length} 个品类`);
|
|
382
|
-
for (const c of cats.slice(0, 3)) {
|
|
383
|
-
console.log(` 探测品类: ${c.name} → ${c.href}`);
|
|
384
|
-
await page.goto(`https://fanqienovel.com${c.href}`);
|
|
385
|
-
await page.waitForTimeout(2000);
|
|
386
|
-
}
|
|
387
324
|
}
|
|
388
|
-
|
|
389
|
-
console.log(`\n=== 共拦截到 ${intercepted.length} 个含书籍数据的接口 ===`);
|
|
325
|
+
console.log(`\n=== 共拦截 ${intercepted.length} 个含书籍数据的接口 ===`);
|
|
390
326
|
await browser.close();
|
|
391
327
|
return null;
|
|
392
328
|
}
|
|
393
329
|
|
|
394
|
-
//
|
|
330
|
+
// 提取品类
|
|
395
331
|
let categories = await extractCategories(page, ch, type);
|
|
396
332
|
if (!categories.length) {
|
|
397
333
|
await scrollLoad(page, 2);
|
|
@@ -399,72 +335,50 @@ async function scrapeChannel(ch, type) {
|
|
|
399
335
|
categories = await extractCategories(page, ch, type);
|
|
400
336
|
}
|
|
401
337
|
if (!categories.length) {
|
|
402
|
-
|
|
403
|
-
categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
|
|
338
|
+
categories = [{ name: '全部(入口页)', href: `/rank/${ch}_${type}_${initCatId}` }];
|
|
404
339
|
} else {
|
|
405
340
|
console.log(` 发现 ${categories.length} 个品类`);
|
|
406
341
|
}
|
|
407
342
|
|
|
408
|
-
const now
|
|
343
|
+
const now = new Date().toISOString();
|
|
409
344
|
const lines = [
|
|
410
345
|
`# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
|
|
411
|
-
|
|
346
|
+
'',
|
|
412
347
|
`- 频道参数:channel=${ch},type=${type}`,
|
|
413
348
|
`- 抓取时间:${now}`,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
349
|
+
'',
|
|
350
|
+
'---',
|
|
351
|
+
'',
|
|
417
352
|
];
|
|
418
353
|
|
|
419
|
-
|
|
420
|
-
const failedCategories = []; // { name, err }
|
|
354
|
+
const failedCategories = [];
|
|
421
355
|
|
|
422
356
|
for (let ci = 0; ci < categories.length; ci++) {
|
|
423
357
|
const cat = categories[ci];
|
|
424
358
|
console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
|
|
425
359
|
|
|
426
|
-
const
|
|
360
|
+
const result = await scrapeCategory(browser, page, cat, chLabel);
|
|
427
361
|
|
|
428
|
-
if (
|
|
429
|
-
console.log(` ✗ 品类[${cat.name}]连续${MAX_RETRIES}次失败:${
|
|
430
|
-
failedCategories.push({ name: cat.name, err:
|
|
431
|
-
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}次)`, '', '---', '');
|
|
432
366
|
continue;
|
|
433
367
|
}
|
|
434
368
|
|
|
435
|
-
|
|
436
|
-
if (!
|
|
437
|
-
lines.push(`## ${cat.name} — 0 本`,
|
|
369
|
+
const bookLines = result;
|
|
370
|
+
if (!bookLines.length) {
|
|
371
|
+
lines.push(`## ${cat.name} — 0 本`, '', '---', '');
|
|
438
372
|
continue;
|
|
439
373
|
}
|
|
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
|
-
}
|
|
461
374
|
|
|
462
|
-
lines.push(
|
|
375
|
+
lines.push(`## ${cat.name} — ${bookLines.filter(l => l.startsWith('书名:')).length} 本`, '');
|
|
376
|
+
lines.push(...bookLines);
|
|
377
|
+
lines.push('---', '');
|
|
463
378
|
}
|
|
464
379
|
|
|
465
|
-
scraped = lines.join(
|
|
380
|
+
scraped = lines.join('\n');
|
|
466
381
|
|
|
467
|
-
// 输出失败品类汇总
|
|
468
382
|
if (failedCategories.length > 0) {
|
|
469
383
|
console.log(`\n⚠️ 以下品类采集失败(共 ${failedCategories.length} 项):`);
|
|
470
384
|
for (const f of failedCategories) {
|
|
@@ -480,31 +394,27 @@ async function scrapeChannel(ch, type) {
|
|
|
480
394
|
}
|
|
481
395
|
|
|
482
396
|
async function main() {
|
|
483
|
-
if (![
|
|
484
|
-
throw new Error(`未知 --
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
const types = TYPE === "all" ? ["2", "1"] : [TYPE];
|
|
491
|
-
let written = 0;
|
|
492
|
-
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 = [];
|
|
493
404
|
|
|
494
405
|
for (const ch of channels) {
|
|
495
406
|
for (const ty of types) {
|
|
496
407
|
try {
|
|
497
408
|
const content = await scrapeChannel(ch, ty);
|
|
498
409
|
if (!content) {
|
|
499
|
-
failedChannels.push({ ch, ty, err:
|
|
410
|
+
failedChannels.push({ ch, ty, err: '采集返回空' });
|
|
500
411
|
continue;
|
|
501
412
|
}
|
|
502
|
-
|
|
503
|
-
const date = localDateStamp();
|
|
413
|
+
const date = localDateStamp();
|
|
504
414
|
const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
|
|
505
415
|
fs.mkdirSync(OUTDIR, { recursive: true });
|
|
506
416
|
const filepath = path.join(OUTDIR, filename);
|
|
507
|
-
fs.writeFileSync(filepath, content,
|
|
417
|
+
fs.writeFileSync(filepath, content, 'utf-8');
|
|
508
418
|
written++;
|
|
509
419
|
console.log(` ✓ 已保存: ${filepath}`);
|
|
510
420
|
} catch (chErr) {
|
|
@@ -514,7 +424,6 @@ async function main() {
|
|
|
514
424
|
}
|
|
515
425
|
}
|
|
516
426
|
|
|
517
|
-
// 输出失败榜单汇总
|
|
518
427
|
if (failedChannels.length > 0) {
|
|
519
428
|
console.log(`\n⚠️ 以下榜单采集失败(共 ${failedChannels.length} 项):`);
|
|
520
429
|
for (const f of failedChannels) {
|
|
@@ -526,13 +435,7 @@ async function main() {
|
|
|
526
435
|
}
|
|
527
436
|
|
|
528
437
|
if (require.main === module) {
|
|
529
|
-
runCli(main,
|
|
438
|
+
runCli(main, '番茄采集');
|
|
530
439
|
}
|
|
531
440
|
|
|
532
|
-
|
|
533
|
-
module.exports = {
|
|
534
|
-
fmtReads,
|
|
535
|
-
fmtWords,
|
|
536
|
-
fmtStatus,
|
|
537
|
-
cleanDesc,
|
|
538
|
-
};
|
|
441
|
+
module.exports = {};
|