@cloud411716/fancy-webnovel 0.1.44 → 0.1.45

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.
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 晋江文学城排行榜采集脚本
4
+ *
5
+ * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
6
+ * 采集策略:
7
+ * 1) topten.php 列表页(纯文本,频道名直接出现,书名/作者交替行)解出频道分组。
8
+ * 2) 从书名 anchor 取 novelid,逐本进 onebook.php 详情页补采核心指标
9
+ * (收藏数/营养液/积分/字数/状态),满足规范对晋江的硬性要求。
10
+ * 晋江页面为 gb18030 编码:详情页用 fetch+arrayBuffer+TextDecoder('gb18030') 解码
11
+ * (同步 XHR 的 responseText 会按 UTF-8 解码导致中文乱码)。
12
+ * 详情采集默认开启但有上限(每频道前 N + 总量上限),用 --list-only 可只采列表。
13
+ *
14
+ * 用法:
15
+ * node jjwxc-rank-scraper.js --type 12 # 收入金榜(默认含详情)
16
+ * node jjwxc-rank-scraper.js --type 12 --top 15 # 每频道补采前 15 本
17
+ * node jjwxc-rank-scraper.js --type 12 --detail-limit 60 # 详情总量上限 60
18
+ * node jjwxc-rank-scraper.js --type 12 --list-only # 只采列表(快,无核心指标)
19
+ * node jjwxc-rank-scraper.js --type all # 全部榜单
20
+ *
21
+ * 前置:
22
+ * node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
23
+ */
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const { ab, sleep, evalJSONBase64, getArg, localDateStamp, runCli } = require("./cdp-utils");
28
+
29
+ const BASE_URL = "https://www.jjwxc.net/topten.php";
30
+
31
+ const RANK_TYPES = [
32
+ { id: "12", label: "收入金榜" },
33
+ { id: "7", label: "月榜" },
34
+ { id: "8", label: "季度榜" },
35
+ { id: "14", label: "完结金榜" },
36
+ { id: "15", label: "新手金榜" },
37
+ { id: "17", label: "千字金榜" },
38
+ ];
39
+
40
+ // 详情请求批大小(async fetch 并发,整批控制在 ab() 20s 超时内)
41
+ const DETAIL_CHUNK = 6;
42
+
43
+ /** 连通性 + 页面就绪自检 */
44
+ function probePage(port) {
45
+ return evalJSONBase64(
46
+ port,
47
+ "JSON.stringify({host:location.host,len:(document.body&&document.body.innerText||'').length})"
48
+ );
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // 列表页提取
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * 提取晋江榜单数据(频道分组 + 书名/作者交替),并从书名 anchor 附上 novelid。
57
+ */
58
+ function extractRankData(port) {
59
+ const js =
60
+ "JSON.stringify((function(){" +
61
+ "var result={channels:[]};" +
62
+ "var text=document.body.innerText||'';" +
63
+ "var lines=text.split(/\\n/).map(function(l){return l.trim()}).filter(Boolean);" +
64
+ // 书名 anchor → novelid(排除霸王票"X向《书名》投了Y"这类记录)
65
+ "var idMap={};" +
66
+ "Array.from(document.querySelectorAll('a')).forEach(function(a){" +
67
+ " var hm=(a.getAttribute('href')||'').match(/novelid=([0-9]+)/);if(!hm)return;" +
68
+ " var t=(a.innerText||a.textContent||'').trim();" +
69
+ " if(!t||t.indexOf('向《')>-1||t.indexOf('投')>-1||t.length>30)return;" +
70
+ " if(!idMap[t])idMap[t]=hm[1];" +
71
+ "});" +
72
+ "var channels=['古代言情','现代言情','古代穿越','现代都市纯爱','现代幻想纯爱','古代纯爱','衍生纯爱','幻想现言','奇幻言情','未来游戏悬疑','百合','无CP','二次元言情','衍生言情','衍生无cp','未来幻想纯爱','原创轻小说','多元'];" +
73
+ "var channelSet={};channels.forEach(function(c){channelSet[c]=true});" +
74
+ "var curChannel='';" +
75
+ "var channelBooks={};" +
76
+ "var expectTitle=true;" +
77
+ "var pendingTitle='';" +
78
+ "for(var i=0;i<lines.length;i++){" +
79
+ " var line=lines[i];" +
80
+ " if(/上榜天数记录|榜单说明/.test(line)){break}" +
81
+ " if(/^(免费强推|vip强推|新晋作者|月榜|季榜|半年榜|长生殿|总分榜|字数榜|收入金榜|霸王票|霸王总榜|勤奋指数|完结金榜|新手金榜|栽培月榜|驻站|完结高分|千字金榜|完结全订榜)$/.test(line)){continue}" +
82
+ " if(line.length>30&&line.indexOf('·')>0)continue;" +
83
+ " if(channelSet[line]){" +
84
+ " if(curChannel&&channelBooks[curChannel])channelBooks[curChannel]._finished=true;" +
85
+ " curChannel=line;" +
86
+ " if(!channelBooks[curChannel])channelBooks[curChannel]={books:[]};" +
87
+ " expectTitle=true;pendingTitle='';continue" +
88
+ " }" +
89
+ " if(!curChannel)continue;" +
90
+ " if(expectTitle){" +
91
+ " pendingTitle=line;expectTitle=false" +
92
+ " }else{" +
93
+ " if(pendingTitle){" +
94
+ " channelBooks[curChannel].books.push({title:pendingTitle,author:line,novelid:idMap[pendingTitle]||''})" +
95
+ " }" +
96
+ " expectTitle=true;pendingTitle=''" +
97
+ " }" +
98
+ "}" +
99
+ "for(var name in channelBooks){" +
100
+ " if(channelBooks[name].books.length>0){" +
101
+ " result.channels.push({name:name,books:channelBooks[name].books})" +
102
+ " }" +
103
+ "}" +
104
+ "return result" +
105
+ "})())";
106
+ return evalJSONBase64(port, js);
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // 详情页提取(gb18030 + itemprop 微数据)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** 构建:一批 novelid 的详情解码 JS(async fetch + TextDecoder,返回 JSON 字符串) */
114
+ function buildDetailJS(ids) {
115
+ return `Promise.all(${JSON.stringify(ids)}.map(function(id){
116
+ return fetch('/onebook.php?novelid='+id)
117
+ .then(function(r){return r.arrayBuffer()})
118
+ .then(function(b){
119
+ var h=new TextDecoder('gb18030').decode(new Uint8Array(b));
120
+ function prop(n){var m=h.match(new RegExp('itemprop="'+n+'"[^>]*>([^<]*)<'));return m?m[1].trim():'';}
121
+ var status=(h.match(/itemprop="updataStatus"[^>]*>\\s*([^<\\s]{1,6})/)||[,''])[1]
122
+ ||(h.match(/(连载中|已完结|完结)/)||[,''])[1]||'';
123
+ return {id:id,collect:prop('collectedCount'),nutrition:prop('nutritionCount'),
124
+ score:prop('scoreCount'),review:prop('reviewCount'),words:prop('wordCount'),status:status};
125
+ })
126
+ .catch(function(e){return {id:id,err:String(e&&e.message||e)}});
127
+ })).then(function(arr){var map={};arr.forEach(function(o){map[o.id]=o});return JSON.stringify(map);})`;
128
+ }
129
+
130
+ /**
131
+ * 分批解码详情,合并结果。
132
+ * 每批单独 try/catch:整批的并发 fetch 贴着 ab() 的 20s 超时线,一次瞬时超时(或
133
+ * 返回非 JSON)只该丢这 6 本,不能连坐后面几十本,更不能把已解析好的列表带走。
134
+ */
135
+ function fetchDetails(port, ids) {
136
+ const map = {};
137
+ let failedChunks = 0;
138
+ for (let i = 0; i < ids.length; i += DETAIL_CHUNK) {
139
+ const chunk = ids.slice(i, i + DETAIL_CHUNK);
140
+ try {
141
+ const part = evalJSONBase64(port, buildDetailJS(chunk)) || {};
142
+ Object.assign(map, part);
143
+ } catch (chunkErr) {
144
+ failedChunks++;
145
+ console.error(
146
+ ` ⚠ 详情批次 ${Math.floor(i / DETAIL_CHUNK) + 1}(${chunk.length} 本)获取失败,跳过: ${chunkErr.message}`
147
+ );
148
+ }
149
+ sleep(400);
150
+ }
151
+ if (failedChunks > 0) {
152
+ console.error(` ⚠ 共 ${failedChunks} 个详情批次失败,这部分书只有列表数据。`);
153
+ }
154
+ return { map, failedChunks };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // 格式化
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function fmtWan(s, unit) {
162
+ if (s == null || s === "") return "";
163
+ const n = parseInt(String(s).replace(/[^0-9]/g, ""), 10);
164
+ if (isNaN(n)) return "";
165
+ if (n >= 10000) return (n / 10000).toFixed(1) + "万" + (unit || "");
166
+ return n + (unit || "");
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // 主流程
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const args = process.argv.slice(2);
174
+ const PORT = parseInt(getArg(args, "--port") || "9222", 10);
175
+ const OUTDIR = getArg(args, "--outdir") || ".";
176
+ const RANKTYPE = getArg(args, "--type") || "12";
177
+ const CHANNEL = getArg(args, "--channel") || "0";
178
+ const TOP = parseInt(getArg(args, "--top") || "10", 10);
179
+ const DETAIL_LIMIT = parseInt(getArg(args, "--detail-limit") || "100", 10);
180
+ const LIST_ONLY = args.includes("--list-only");
181
+
182
+ function scrapeRank(port, rankTypeId, channelId) {
183
+ const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
184
+ if (!rt) {
185
+ console.log(` ⚠ 未知榜单类型: ${rankTypeId}`);
186
+ return null;
187
+ }
188
+
189
+ const url = `${BASE_URL}?orderstr=${rankTypeId}&t=${channelId}`;
190
+ const chLabel = channelId === "0" ? "全站" : `频道${channelId}`;
191
+ console.log(`\n→ 采集 晋江${rt.label}(${chLabel})...`);
192
+ console.log(` URL: ${url}`);
193
+
194
+ let data;
195
+ try {
196
+ ab(port, "open", url);
197
+ sleep(4000);
198
+
199
+ // 连通性自检:CDP 未起/被重定向时给可操作报错,而非误报"结构已变"
200
+ const probe = probePage(port);
201
+ if (!probe) {
202
+ console.error(
203
+ ` ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${port}),且 agent-browser 可用。`
204
+ );
205
+ return null;
206
+ }
207
+ if (probe.host && probe.host.indexOf("jjwxc") === -1) {
208
+ console.error(` ✗ 当前页面非晋江(host=${probe.host}),可能被重定向,已跳过。`);
209
+ return null;
210
+ }
211
+
212
+ data = extractRankData(port);
213
+ if (!data?.channels?.length) {
214
+ console.error(`[jjwxc] 采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开 ${url} 确认。`);
215
+ return null;
216
+ }
217
+ } catch (err) {
218
+ console.error(`[jjwxc] ${rt.label} 页面加载或提取出错: ${err.message}`);
219
+ return null;
220
+ }
221
+
222
+ let totalBooks = 0;
223
+ data.channels.forEach((ch) => {
224
+ totalBooks += ch.books.length;
225
+ const authors = new Set(ch.books.map((b) => b.author));
226
+ if (ch.books.length >= 5 && authors.size / ch.books.length < 0.2) {
227
+ console.log(` ⚠ ${ch.name}:${ch.books.length} 本只有 ${authors.size} 个唯一作者,可能提取有误`);
228
+ }
229
+ });
230
+ console.log(` ✓ 列表:${data.channels.length} 个频道,共 ${totalBooks} 本`);
231
+
232
+ // 选取每频道前 TOP 本(有 novelid 的)补采详情,受 DETAIL_LIMIT 总量约束
233
+ let detailMap = {};
234
+ let detailPlanned = 0;
235
+ let detailOk = 0;
236
+ let detailFailedChunks = 0;
237
+ if (!LIST_ONLY) {
238
+ const picked = [];
239
+ for (const ch of data.channels) {
240
+ let n = 0;
241
+ for (const b of ch.books) {
242
+ if (picked.length >= DETAIL_LIMIT) break;
243
+ if (n >= TOP) break;
244
+ if (b.novelid) { picked.push(b.novelid); n++; }
245
+ }
246
+ if (picked.length >= DETAIL_LIMIT) break;
247
+ }
248
+ detailPlanned = picked.length;
249
+ if (picked.length) {
250
+ console.log(` → 补采详情 ${picked.length} 本(每频道前 ${TOP},上限 ${DETAIL_LIMIT})...`);
251
+ // 详情是列表的增补,不是前提:整段失败也要保住已解析好的列表落盘
252
+ // (下面的质量门会把 detailOk===0 标成 [详情解析异常/登录态缺失])
253
+ try {
254
+ const detailResult = fetchDetails(port, picked);
255
+ detailMap = detailResult.map;
256
+ detailFailedChunks = detailResult.failedChunks;
257
+ } catch (detailErr) {
258
+ detailMap = {};
259
+ detailFailedChunks = Math.max(1, Math.ceil(picked.length / DETAIL_CHUNK));
260
+ console.error(` ⚠ 详情补采整体失败,仅保留列表数据: ${detailErr.message}`);
261
+ }
262
+ detailOk = Object.values(detailMap).filter((d) => d && d.collect).length;
263
+ console.log(` ✓ 详情命中收藏数 ${detailOk}/${picked.length}`);
264
+ }
265
+ }
266
+
267
+ // 质量状态:详情开启时,收藏数命中率是核心信号
268
+ let quality = "[OK]";
269
+ const detailPartial =
270
+ !LIST_ONLY &&
271
+ detailPlanned > 0 &&
272
+ (detailFailedChunks > 0 || detailOk < detailPlanned);
273
+ if (!LIST_ONLY && detailPlanned > 0 && detailOk === 0) {
274
+ quality = "[详情解析异常/登录态缺失]";
275
+ console.error(` ⚠ 详情全部无收藏数:可能页面结构变动或需登录,已在文件头标注。`);
276
+ } else if (detailPartial) {
277
+ quality = "[部分详情缺失]";
278
+ console.error(` ⚠ 详情仅命中 ${detailOk}/${detailPlanned},已按部分结果标注。`);
279
+ } else if (LIST_ONLY) {
280
+ quality = "[仅列表-无核心指标]";
281
+ }
282
+
283
+ const now = new Date().toISOString();
284
+ const lines = [
285
+ `# 晋江 · ${rt.label}`,
286
+ "",
287
+ `- 来源:${url}`,
288
+ `- 抓取时间:${now}`,
289
+ `- 频道数:${data.channels.length}`,
290
+ `- 总条目数:${totalBooks}`,
291
+ `- 详情采集:${detailOk} / ${detailPlanned}(每频道前 ${TOP},上限 ${DETAIL_LIMIT})`,
292
+ `- 数据质量:${quality}`,
293
+ "",
294
+ "---",
295
+ "",
296
+ ];
297
+
298
+ for (const ch of data.channels) {
299
+ try {
300
+ lines.push(`## ${ch.name} — ${ch.books.length} 本`, "");
301
+ for (let i = 0; i < ch.books.length; i++) {
302
+ try {
303
+ const b = ch.books[i];
304
+ lines.push(`### #${i + 1} ${b.title}`);
305
+ const d = b.novelid ? detailMap[b.novelid] : null;
306
+ const seg = [b.author || ""];
307
+ if (d) {
308
+ if (d.collect) seg.push("收藏 " + fmtWan(d.collect));
309
+ if (d.nutrition) seg.push("营养液 " + fmtWan(d.nutrition));
310
+ if (d.score) seg.push("积分 " + d.score);
311
+ if (d.words) seg.push("字数 " + fmtWan(d.words, "字"));
312
+ if (d.status) seg.push(d.status);
313
+ }
314
+ const meta = seg.filter(Boolean).join(" · ");
315
+ if (meta) lines.push(`*${meta}*`);
316
+ if (b.novelid) lines.push(`[作品页](https://www.jjwxc.net/onebook.php?novelid=${b.novelid})`);
317
+ lines.push("");
318
+ } catch (bookErr) {
319
+ console.error(`[jjwxc] ${rt.label} ${ch.name} 第${i + 1}条处理出错: ${bookErr.message}`);
320
+ lines.push("");
321
+ }
322
+ }
323
+ lines.push("---", "");
324
+ } catch (chErr) {
325
+ console.error(`[jjwxc] ${rt.label} 频道「${ch.name}」处理出错,跳过: ${chErr.message}`);
326
+ }
327
+ }
328
+
329
+ return {
330
+ content: lines.join("\n"),
331
+ partial: detailPartial,
332
+ partialReason: detailPartial
333
+ ? `${rt.label}: detail ${detailOk}/${detailPlanned}, failed chunks ${detailFailedChunks}`
334
+ : "",
335
+ };
336
+ }
337
+
338
+ function main() {
339
+ if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
340
+ throw new Error(`未知 --type: ${RANKTYPE}`);
341
+ }
342
+ // 当前脚本只实现全站榜(t=0);不能把任意数字静默标成“频道 N”。
343
+ // 若后续支持分频道,先从页面提取并维护明确 ID 白名单再开放。
344
+ if (CHANNEL !== "0") {
345
+ throw new Error(`未知 --channel: ${CHANNEL}(当前仅支持 0=全站)`);
346
+ }
347
+ const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
348
+ const channels = [CHANNEL]; // 晋江频道 ID 需从页面获取,默认全站
349
+ let written = 0;
350
+ let failed = 0;
351
+ let partial = false;
352
+ const partialReasons = [];
353
+
354
+ for (const rt of rankTypes) {
355
+ for (const ch of channels) {
356
+ // per-榜单隔离:一个榜单出错不该掐掉 --type all 后面的榜单(与番茄/刺猬猫一致)
357
+ try {
358
+ const result = scrapeRank(PORT, rt, ch);
359
+ if (!result) {
360
+ failed++;
361
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
362
+ partialReasons.push(`${rtInfo ? rtInfo.label : rt}: no usable data`);
363
+ continue;
364
+ }
365
+ if (result.partial) {
366
+ partial = true;
367
+ if (result.partialReason) partialReasons.push(result.partialReason);
368
+ }
369
+
370
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
371
+ const date = localDateStamp();
372
+ const chLabel = ch === "0" ? "全站" : `频道${ch}`;
373
+ const filename = `晋江${rtInfo.label}_${chLabel}_${date}.md`;
374
+ fs.mkdirSync(OUTDIR, { recursive: true });
375
+ const filepath = path.join(OUTDIR, filename);
376
+ fs.writeFileSync(filepath, result.content, "utf-8");
377
+ written++;
378
+ console.log(` ✓ 已保存: ${filepath}`);
379
+ } catch (rankErr) {
380
+ failed++;
381
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
382
+ const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
383
+ partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${message}`);
384
+ console.error(
385
+ `[jjwxc] ${rtInfo ? rtInfo.label : rt} 采集失败,跳过: ${message}`
386
+ );
387
+ }
388
+ }
389
+ }
390
+ return {
391
+ planned: rankTypes.length * channels.length,
392
+ written,
393
+ failed,
394
+ partial: partial || failed > 0,
395
+ partialReasons,
396
+ };
397
+ }
398
+
399
+ if (require.main === module) {
400
+ runCli(main, "晋江采集");
401
+ }
402
+
403
+ module.exports = { buildDetailJS, fmtWan };