@cloud411716/fancy-webnovel 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/fancy-scan/scripts/run-scan.js +89 -104
- package/plugins/fancy-scan/scripts/scrapers/cdp-utils.js +242 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +435 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +403 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +436 -0
- package/plugins/fancy-scan/scripts/scrapers/zhihu-rank-scraper.js +178 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 番茄小说排行榜采集脚本
|
|
4
|
+
*
|
|
5
|
+
* 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
|
|
6
|
+
* 采集策略:从榜单页 __INITIAL_STATE__ 取结构化列表,再逐本请求详情页解码真实
|
|
7
|
+
* 书名/作者/简介/题材/标签(番茄列表页有字体反爬,详情页 HTML 里是明文)。
|
|
8
|
+
* 输出 Markdown 格式匹配 scan-output-format.md 规范。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* node fanqie-rank-scraper.js --channel 1 --type 2 # 男频阅读榜
|
|
12
|
+
* node fanqie-rank-scraper.js --channel 0 --type 1 # 女频新书榜
|
|
13
|
+
* node fanqie-rank-scraper.js --channel 1 --type 2 --outdir ./ # 指定输出目录
|
|
14
|
+
* node fanqie-rank-scraper.js --channel all # 全部采集
|
|
15
|
+
* node fanqie-rank-scraper.js --channel 1 --top 15 # 每题材只取前 15 本
|
|
16
|
+
*
|
|
17
|
+
* 前置:
|
|
18
|
+
* node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const { ab, sleep, evalJSONBase64, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");
|
|
24
|
+
|
|
25
|
+
// 一次详情请求的并发批大小。番茄详情页用同步 XHR 拉取,批太大会撞上
|
|
26
|
+
// cdp-utils 里 ab() 的 20s 超时;超时会显式失败,这里分批是为了避免整个题材被中断。
|
|
27
|
+
const DETAIL_CHUNK = 5;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// 页面提取
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** 连通性 + 页面就绪自检 */
|
|
34
|
+
function probePage(port) {
|
|
35
|
+
return evalJSONBase64(
|
|
36
|
+
port,
|
|
37
|
+
"JSON.stringify({host:location.host,hasState:!!window.__INITIAL_STATE__})"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 构建:提取侧边菜单品类链接的浏览器 JS */
|
|
42
|
+
function buildCategoriesJS(prefix) {
|
|
43
|
+
return `JSON.stringify((function(){
|
|
44
|
+
var prefix=${JSON.stringify(prefix)};
|
|
45
|
+
var out=[];var seen={};
|
|
46
|
+
Array.from(document.querySelectorAll('a')).forEach(function(a){
|
|
47
|
+
var href=a.getAttribute('href')||'';
|
|
48
|
+
if(href.indexOf(prefix)===-1)return;
|
|
49
|
+
var name=(a.innerText||a.textContent||'').trim();
|
|
50
|
+
if(!name)return;
|
|
51
|
+
if(seen[href])return;seen[href]=1;
|
|
52
|
+
out.push({name:name,href:href});
|
|
53
|
+
});
|
|
54
|
+
return out;
|
|
55
|
+
})())`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 提取侧边菜单品类链接 */
|
|
59
|
+
function extractCategories(port, channel, type) {
|
|
60
|
+
const prefix = `/rank/${channel}_${type}_`;
|
|
61
|
+
return evalJSONBase64(port, buildCategoriesJS(prefix)) || [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 从 __INITIAL_STATE__ 提取当前品类页的作品列表。
|
|
66
|
+
* 多路径尝试 + 深度兜底扫描,并把字段名归一,避免站点改 state 结构就全盘失败。
|
|
67
|
+
*/
|
|
68
|
+
function buildBookListJS() {
|
|
69
|
+
return `JSON.stringify((function(){
|
|
70
|
+
var s=window.__INITIAL_STATE__||{};
|
|
71
|
+
var cands=[
|
|
72
|
+
s.rank&&s.rank.book_list, s.rank&&s.rank.bookList, s.rank&&s.rank.rankList,
|
|
73
|
+
s.rankData&&s.rankData.book_list, s.page&&s.page.book_list
|
|
74
|
+
];
|
|
75
|
+
var list=null;
|
|
76
|
+
for(var i=0;i<cands.length;i++){ if(Array.isArray(cands[i])&&cands[i].length){list=cands[i];break;} }
|
|
77
|
+
if(!list){
|
|
78
|
+
var found=null;
|
|
79
|
+
(function walk(o,d){
|
|
80
|
+
if(found||!o||d>6)return;
|
|
81
|
+
if(Array.isArray(o)){
|
|
82
|
+
if(o.length&&o[0]&&typeof o[0]==='object'&&(o[0].bookId||o[0].book_id)){found=o;return;}
|
|
83
|
+
for(var j=0;j<o.length&&!found;j++)walk(o[j],d+1);return;
|
|
84
|
+
}
|
|
85
|
+
if(typeof o==='object'){ for(var k in o){ if(found)break; try{walk(o[k],d+1)}catch(e){} } }
|
|
86
|
+
})(s,0);
|
|
87
|
+
list=found||[];
|
|
88
|
+
}
|
|
89
|
+
return list.map(function(b){return {
|
|
90
|
+
bookId:String(b.bookId||b.book_id||''),
|
|
91
|
+
read_count:b.read_count||b.readCount||b.read||'',
|
|
92
|
+
wordNumber:b.wordNumber||b.word_number||b.wordCount||'',
|
|
93
|
+
creationStatus:(b.creationStatus!=null?b.creationStatus:(b.creation_status!=null?b.creation_status:b.status)),
|
|
94
|
+
lastChapterTitle:b.lastChapterTitle||b.last_chapter_title||b.lastChapter||'',
|
|
95
|
+
category:b.category||b.categoryName||b.category_name||''
|
|
96
|
+
};}).filter(function(b){return b.bookId;});
|
|
97
|
+
})())`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractBookList(port) {
|
|
101
|
+
const list = evalJSONBase64(port, buildBookListJS());
|
|
102
|
+
return Array.isArray(list) ? list : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 批量解码详情:逐本同步 XHR 请求 /page/{id},多策略解析明文字段。
|
|
107
|
+
* 番茄列表页书名/作者被字体反爬,详情页 HTML 内嵌 JSON 与 <title> 是明文。
|
|
108
|
+
* 字段名以真实 SSR(__INITIAL_STATE__) 为准:bookName/author/abstract 明文,
|
|
109
|
+
* 题材在 categoryV2(转义 JSON 数组的首个 Name),番茄 SSR 不含数字评分。
|
|
110
|
+
* 返回 { id: {title, author, desc, category, tags} }。
|
|
111
|
+
*/
|
|
112
|
+
function buildDetailJS(ids) {
|
|
113
|
+
return `JSON.stringify((function(){
|
|
114
|
+
var ids=${JSON.stringify(ids)};
|
|
115
|
+
var map={};
|
|
116
|
+
function pick(h,res){for(var i=0;i<res.length;i++){var m=h.match(res[i]);if(m&&m[1])return m[1].trim();}return '';}
|
|
117
|
+
for(var k=0;k<ids.length;k++){
|
|
118
|
+
var id=ids[k];
|
|
119
|
+
try{
|
|
120
|
+
var x=new XMLHttpRequest();
|
|
121
|
+
x.open('GET','/page/'+id,false);
|
|
122
|
+
x.send();
|
|
123
|
+
var h=x.responseText||'';
|
|
124
|
+
var title=pick(h,[
|
|
125
|
+
/"bookName"\\s*:\\s*"([^"]+)"/,
|
|
126
|
+
/<title>([^<]*?)(?:完整版|最新章节|在线阅读|_番茄小说|-番茄小说|_番茄|-番茄)/,
|
|
127
|
+
/<meta[^>]+property="og:title"[^>]+content="([^"]+)"/,
|
|
128
|
+
/<title>([^<|_]{1,40})/
|
|
129
|
+
]);
|
|
130
|
+
var author=pick(h,[
|
|
131
|
+
/"author"\\s*:\\s*"([^"]+)"/,
|
|
132
|
+
/"authorName"\\s*:\\s*"([^"]+)"/,
|
|
133
|
+
/<meta[^>]+property="og:novel:author"[^>]+content="([^"]+)"/
|
|
134
|
+
]);
|
|
135
|
+
// abstract(真实简介)优先;meta description 是平台模板("番茄小说提供..."),
|
|
136
|
+
// 且常带 data-rh 属性,故用宽松属性匹配兜底。
|
|
137
|
+
var abs=pick(h,[/"abstract"\\s*:\\s*"([^"]{6,}?)"/]);
|
|
138
|
+
var desc=abs||pick(h,[
|
|
139
|
+
/<meta[^>]+name="description"[^>]+content="([^"]+)"/,
|
|
140
|
+
/<meta[^>]+property="og:description"[^>]+content="([^"]+)"/
|
|
141
|
+
]);
|
|
142
|
+
// 题材:category 常为空字符串,真实题材在 categoryV2(转义 JSON)首个 Name。
|
|
143
|
+
var category=pick(h,[
|
|
144
|
+
/"categoryV2":"\\[\\{[\\s\\S]*?\\\\"Name\\\\":\\\\"([^"\\\\]+)/,
|
|
145
|
+
/"category"\\s*:\\s*"([^"]{1,20})"/,
|
|
146
|
+
/<meta[^>]+property="og:novel:category"[^>]+content="([^"]+)"/
|
|
147
|
+
]);
|
|
148
|
+
// 标签:番茄简介开头常带【tag+tag+...】,是题材细分的真实信号。
|
|
149
|
+
var tags='';
|
|
150
|
+
var bm=(abs||desc||'').match(/[【\\[]([^】\\]]{2,40})[】\\]]/);
|
|
151
|
+
if(bm){tags=bm[1].split(/[+、,\\/\\s]+/).filter(Boolean).slice(0,6).join('、');}
|
|
152
|
+
map[id]={title:title,author:author,desc:desc,category:category,tags:tags};
|
|
153
|
+
}catch(e){
|
|
154
|
+
map[id]={title:'',author:'',desc:'',category:'',tags:'',err:String(e&&e.message||e)};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return map;
|
|
158
|
+
})())`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fetchDetailsChunk(port, ids) {
|
|
162
|
+
return evalJSONBase64(port, buildDetailJS(ids)) || {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 分批解码,避免单次 eval 超时;返回合并后的 map */
|
|
166
|
+
function fetchDetails(port, bookIds) {
|
|
167
|
+
const map = {};
|
|
168
|
+
for (let i = 0; i < bookIds.length; i += DETAIL_CHUNK) {
|
|
169
|
+
const chunk = bookIds.slice(i, i + DETAIL_CHUNK);
|
|
170
|
+
const part = fetchDetailsChunk(port, chunk);
|
|
171
|
+
Object.assign(map, part);
|
|
172
|
+
sleep(300);
|
|
173
|
+
}
|
|
174
|
+
return map;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// 格式化
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function fmtReads(count) {
|
|
182
|
+
if (!count || count === "0") return "未知";
|
|
183
|
+
const n = parseInt(count, 10);
|
|
184
|
+
if (isNaN(n)) return "未知";
|
|
185
|
+
if (n >= 10000) return (n / 10000).toFixed(1) + "万";
|
|
186
|
+
return String(n);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fmtWords(count) {
|
|
190
|
+
if (!count) return "未知";
|
|
191
|
+
const n = parseInt(count, 10);
|
|
192
|
+
if (isNaN(n)) return "未知";
|
|
193
|
+
if (n >= 10000) return (n / 10000).toFixed(1) + "万";
|
|
194
|
+
return String(n);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fmtStatus(s) {
|
|
198
|
+
const v = String(s);
|
|
199
|
+
if (v === "1") return "连载中";
|
|
200
|
+
if (v === "0" || v === "2") return "已完结";
|
|
201
|
+
return s ? String(s) : "未知";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 清洗简介:去平台模板文本 → 折叠空白 → 句末截断 100 字 */
|
|
205
|
+
function cleanDesc(raw) {
|
|
206
|
+
if (!raw) return "";
|
|
207
|
+
let d = String(raw)
|
|
208
|
+
// 简介取自 JSON 字符串原文,先还原常见转义(\n \uXXXX \" 等)
|
|
209
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
|
210
|
+
.replace(/\\[nrt]/g, " ")
|
|
211
|
+
.replace(/\\"/g, '"')
|
|
212
|
+
.replace(/番茄小说[^。!?]*?(?:免费阅读|完整版|在线阅读)[^。!?]*[。!?]/g, "")
|
|
213
|
+
.replace(/番茄小说[^。!?]*?(?:免费阅读|完整版|在线阅读)[^。!?]*$/g, "")
|
|
214
|
+
.replace(/\s+/g, " ")
|
|
215
|
+
.trim();
|
|
216
|
+
if (d.length <= 100) return d;
|
|
217
|
+
const cut = d.slice(0, 100);
|
|
218
|
+
const m = cut.match(/^[\s\S]*[。!?]/);
|
|
219
|
+
return (m ? m[0] : cut) + "...";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// 主流程
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
const args = process.argv.slice(2);
|
|
227
|
+
const PORT = parseInt(getArg(args, "--port") || "9222", 10);
|
|
228
|
+
const OUTDIR = getArg(args, "--outdir") || ".";
|
|
229
|
+
const CHANNEL = getArg(args, "--channel") || "1";
|
|
230
|
+
const TYPE = getArg(args, "--type") || "2";
|
|
231
|
+
const TOP = parseInt(getArg(args, "--top") || "20", 10);
|
|
232
|
+
|
|
233
|
+
function channelLabel(ch) {
|
|
234
|
+
return ch === "1" ? "男频" : "女频";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function typeLabel(t) {
|
|
238
|
+
return t === "2" ? "阅读榜" : "新书榜";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function scrapeChannel(ch, type) {
|
|
242
|
+
const chLabel = channelLabel(ch);
|
|
243
|
+
const tyLabel = typeLabel(type);
|
|
244
|
+
console.log(`\n→ 采集 ${chLabel}${tyLabel}...`);
|
|
245
|
+
|
|
246
|
+
// 用已知品类 ID 作为入口,确保菜单只显示当前频道/类型的品类
|
|
247
|
+
const initCatId = ch === "1" ? "1141" : "1139"; // 男频:西方奇幻 / 女频:古风世情
|
|
248
|
+
const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
|
|
249
|
+
ab(PORT, "open", initUrl);
|
|
250
|
+
sleep(3000);
|
|
251
|
+
|
|
252
|
+
// 连通性自检:把"静默写出一堆 bookId"变成可操作的报错
|
|
253
|
+
const probe = probePage(PORT);
|
|
254
|
+
if (!probe) {
|
|
255
|
+
console.error(
|
|
256
|
+
` ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${PORT}),且 agent-browser 可用。`
|
|
257
|
+
);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (probe.host && probe.host.indexOf("fanqie") === -1) {
|
|
261
|
+
console.error(
|
|
262
|
+
` ✗ 当前页面非番茄(host=${probe.host}),可能被重定向到登录/验证页,已跳过。`
|
|
263
|
+
);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (!probe.hasState) {
|
|
267
|
+
console.error(` ⚠ 页面未挂载 __INITIAL_STATE__,将尝试兜底扫描,结果可能不完整。`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let categories = extractCategories(PORT, ch, type);
|
|
271
|
+
if (!categories.length) {
|
|
272
|
+
// 菜单可能懒加载,滚动后重试一次
|
|
273
|
+
scrollLoad(PORT, 2);
|
|
274
|
+
sleep(1000);
|
|
275
|
+
categories = extractCategories(PORT, ch, type);
|
|
276
|
+
}
|
|
277
|
+
if (!categories.length) {
|
|
278
|
+
// 仍失败:降级为只采当前入口页,至少产出数据而不是空跑
|
|
279
|
+
console.log(` ⚠ 未提取到品类菜单,降级为单题材采集(入口页)`);
|
|
280
|
+
categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
|
|
281
|
+
} else {
|
|
282
|
+
console.log(` 发现 ${categories.length} 个品类`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const now = new Date().toISOString();
|
|
286
|
+
const lines = [
|
|
287
|
+
`# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
|
|
288
|
+
"",
|
|
289
|
+
`- 频道参数:channel=${ch},type=${type}`,
|
|
290
|
+
`- 抓取时间:${now}`,
|
|
291
|
+
`- 每题材上限 ≈ ${TOP}`,
|
|
292
|
+
"",
|
|
293
|
+
"---",
|
|
294
|
+
"",
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
let totalBooks = 0;
|
|
298
|
+
let resolvedTitles = 0;
|
|
299
|
+
const bodyLines = [];
|
|
300
|
+
|
|
301
|
+
for (let ci = 0; ci < categories.length; ci++) {
|
|
302
|
+
const cat = categories[ci];
|
|
303
|
+
console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
ab(PORT, "open", `https://fanqienovel.com${cat.href}`);
|
|
307
|
+
sleep(2500);
|
|
308
|
+
scrollLoad(PORT, 2);
|
|
309
|
+
|
|
310
|
+
let books = extractBookList(PORT);
|
|
311
|
+
if (!Array.isArray(books) || !books.length) {
|
|
312
|
+
bodyLines.push(`## ${cat.name} — 0 本`, "", "---", "");
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (books.length > TOP) books = books.slice(0, TOP);
|
|
316
|
+
|
|
317
|
+
// 分批解码真实书名/作者/简介/题材/评分/标签
|
|
318
|
+
const bookIds = books.map((b) => String(b.bookId));
|
|
319
|
+
const details = fetchDetails(PORT, bookIds);
|
|
320
|
+
|
|
321
|
+
bodyLines.push(`## ${cat.name} — ${books.length} 本`, "");
|
|
322
|
+
|
|
323
|
+
for (let i = 0; i < books.length; i++) {
|
|
324
|
+
const b = books[i];
|
|
325
|
+
const info = details[String(b.bookId)] || {};
|
|
326
|
+
totalBooks++;
|
|
327
|
+
const resolved = !!info.title;
|
|
328
|
+
if (resolved) resolvedTitles++;
|
|
329
|
+
|
|
330
|
+
const title = info.title || "(标题待解析)";
|
|
331
|
+
const author = info.author || "未知";
|
|
332
|
+
const category = info.category || b.category || "";
|
|
333
|
+
const catSeg = category ? ` · ${category}` : "";
|
|
334
|
+
|
|
335
|
+
bodyLines.push(`### #${i + 1} ${title}`);
|
|
336
|
+
bodyLines.push(
|
|
337
|
+
`*${author}${catSeg} · ${fmtStatus(b.creationStatus)} · ${fmtReads(b.read_count)} 在读 · ${fmtWords(b.wordNumber)}字*`
|
|
338
|
+
);
|
|
339
|
+
if (info.tags) bodyLines.push(`**标签:** ${info.tags}`);
|
|
340
|
+
bodyLines.push(`**最新更新:** ${b.lastChapterTitle || "未知"}`);
|
|
341
|
+
bodyLines.push(`**bookId:** ${b.bookId}`);
|
|
342
|
+
bodyLines.push(`[作品页](https://fanqienovel.com/page/${b.bookId})`);
|
|
343
|
+
const desc = cleanDesc(info.desc);
|
|
344
|
+
if (desc) {
|
|
345
|
+
bodyLines.push("");
|
|
346
|
+
bodyLines.push("**简介**");
|
|
347
|
+
bodyLines.push("");
|
|
348
|
+
bodyLines.push(desc);
|
|
349
|
+
}
|
|
350
|
+
bodyLines.push("");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
bodyLines.push("---", "");
|
|
354
|
+
} catch (catErr) {
|
|
355
|
+
console.error(
|
|
356
|
+
` [fanqie] 品类 ${cat.name} 处理出错,跳过: ${catErr && catErr.message ? catErr.message : catErr}`
|
|
357
|
+
);
|
|
358
|
+
bodyLines.push(`## ${cat.name} — 采集失败`, "", "---", "");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 质量状态:标题解析比例是番茄采集成败的核心信号
|
|
363
|
+
const ratio = totalBooks ? resolvedTitles / totalBooks : 0;
|
|
364
|
+
const quality = totalBooks === 0
|
|
365
|
+
? "[无数据]"
|
|
366
|
+
: ratio < 0.5
|
|
367
|
+
? "[标题解析异常]"
|
|
368
|
+
: "[OK]";
|
|
369
|
+
lines.splice(5, 0,
|
|
370
|
+
`- 标题解析:成功 ${resolvedTitles} / 共 ${totalBooks}`,
|
|
371
|
+
`- 数据质量:${quality}`
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
if (totalBooks > 0 && resolvedTitles === 0) {
|
|
375
|
+
console.error(
|
|
376
|
+
` ✗ ${chLabel}${tyLabel}:${totalBooks} 本全部标题解析失败。多为详情页结构变动或登录/验证拦截,` +
|
|
377
|
+
`请在 Chrome 内手动打开任一 https://fanqienovel.com/page/{bookId} 确认页面正常。`
|
|
378
|
+
);
|
|
379
|
+
} else if (ratio < 0.5) {
|
|
380
|
+
console.error(
|
|
381
|
+
` ⚠ ${chLabel}${tyLabel}:标题解析率偏低(${resolvedTitles}/${totalBooks}),结果质量已标注。`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return lines.concat(bodyLines).join("\n");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function main() {
|
|
389
|
+
if (!["0", "1", "all"].includes(CHANNEL)) {
|
|
390
|
+
throw new Error(`未知 --channel: ${CHANNEL}`);
|
|
391
|
+
}
|
|
392
|
+
if (!["1", "2", "all"].includes(TYPE)) {
|
|
393
|
+
throw new Error(`未知 --type: ${TYPE}`);
|
|
394
|
+
}
|
|
395
|
+
const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
|
|
396
|
+
const types = TYPE === "all" ? ["2", "1"] : [TYPE];
|
|
397
|
+
let written = 0;
|
|
398
|
+
|
|
399
|
+
for (const ch of channels) {
|
|
400
|
+
for (const ty of types) {
|
|
401
|
+
try {
|
|
402
|
+
const content = scrapeChannel(ch, ty);
|
|
403
|
+
if (!content) continue;
|
|
404
|
+
|
|
405
|
+
const date = localDateStamp();
|
|
406
|
+
const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
|
|
407
|
+
fs.mkdirSync(OUTDIR, { recursive: true });
|
|
408
|
+
const filepath = path.join(OUTDIR, filename);
|
|
409
|
+
fs.writeFileSync(filepath, content, "utf-8");
|
|
410
|
+
written++;
|
|
411
|
+
console.log(` ✓ 已保存: ${filepath}`);
|
|
412
|
+
} catch (chErr) {
|
|
413
|
+
console.error(
|
|
414
|
+
`[fanqie] ${channelLabel(ch)}${typeLabel(ty)} 采集失败,跳过: ${chErr && chErr.message ? chErr.message : chErr}`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return written;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (require.main === module) {
|
|
423
|
+
runCli(main, "番茄采集");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 导出纯函数/JS 构建器,供测试在 sandbox 内验证解析逻辑
|
|
427
|
+
module.exports = {
|
|
428
|
+
buildCategoriesJS,
|
|
429
|
+
buildBookListJS,
|
|
430
|
+
buildDetailJS,
|
|
431
|
+
fmtReads,
|
|
432
|
+
fmtWords,
|
|
433
|
+
fmtStatus,
|
|
434
|
+
cleanDesc,
|
|
435
|
+
};
|