@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.
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 七猫小说排行榜采集脚本
4
+ *
5
+ * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
6
+ * 采集策略:tab 切换男生榜/女生榜和榜单类型,滚动加载后从页面文本解析结构化数据。
7
+ * 输出 Markdown 格式匹配 scan-output-format.md 规范。
8
+ *
9
+ * 用法:
10
+ * node qimao-rank-scraper.js --channel male --type hot --period day # 男生大热榜日榜
11
+ * node qimao-rank-scraper.js --channel male --type hot --period month # 男生大热榜月榜
12
+ * node qimao-rank-scraper.js --channel male --type hot --period all # 日榜+月榜
13
+ * node qimao-rank-scraper.js --channel female --type new # 女生新书榜
14
+ * node qimao-rank-scraper.js --channel all --type all # 全部采集
15
+ *
16
+ * 前置:
17
+ * node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
18
+ */
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const { ab, sleep, evalJSONBase64, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");
23
+
24
+ const RANK_URL = "https://www.qimao.com/paihang";
25
+
26
+ /** 连通性 + 页面就绪自检 */
27
+ function probePage(port) {
28
+ return evalJSONBase64(
29
+ port,
30
+ "JSON.stringify({host:location.host,path:location.pathname,len:(document.body&&document.body.innerText||'').length})"
31
+ );
32
+ }
33
+
34
+ const CHANNELS = [
35
+ { id: "male", label: "男频", tab: "男生榜", path: "boy" },
36
+ { id: "female", label: "女频", tab: "女生榜", path: "girl" },
37
+ ];
38
+
39
+ const RANK_TYPES = [
40
+ { id: "hot", label: "大热榜", path: "hot" },
41
+ { id: "new", label: "新书榜", path: "new" },
42
+ { id: "finish", label: "完结榜", path: "over" },
43
+ { id: "collect", label: "收藏榜", path: "collect" },
44
+ { id: "update", label: "更新榜", path: "update" },
45
+ ];
46
+
47
+ const PERIODS = [
48
+ { id: "day", label: "日榜", path: "date" },
49
+ { id: "month", label: "月榜", path: "month" },
50
+ ];
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // 页面操作
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function rankUrl(channelId, rankTypeId, periodId) {
57
+ const channel = CHANNELS.find((item) => item.id === channelId);
58
+ const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
59
+ const period = PERIODS.find((item) => item.id === (periodId || "day"));
60
+ if (!channel || !rankType || !period) return "";
61
+ return `${RANK_URL}/${channel.path}/${rankType.path}/${period.path}/`;
62
+ }
63
+
64
+ /** 读取页面实际 active 状态;输出文件标签必须由该状态校验后才能使用。 */
65
+ function extractObservedSelection(port) {
66
+ const js = `JSON.stringify((function(){
67
+ function text(selector){var e=document.querySelector(selector);return e?(e.textContent||'').replace(/\\s+/g,'').trim():'';}
68
+ return {path:location.pathname,channel:text('.qm-switch-tab .item.active'),rankType:text('.child-tabs-item.menu-tab.active'),period:text('.date-type-tabs .tab.active')};
69
+ })())`;
70
+ return evalJSONBase64(port, js) || {};
71
+ }
72
+
73
+ function selectionMatches(observed, channelId, rankTypeId, periodId) {
74
+ const channel = CHANNELS.find((item) => item.id === channelId);
75
+ const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
76
+ const period = periodId ? PERIODS.find((item) => item.id === periodId) : null;
77
+ if (!channel || !rankType) return false;
78
+ const expectedUrl = rankUrl(channelId, rankTypeId, periodId);
79
+ if (!expectedUrl) return false;
80
+ const expectedPath = new URL(expectedUrl).pathname;
81
+ const actualPath = String(observed && observed.path || "").replace(/\/+$/, "/");
82
+ return !!(
83
+ actualPath === expectedPath &&
84
+ String(observed.channel || "").includes(channel.tab) &&
85
+ observed.rankType === rankType.label &&
86
+ (!period || observed.period === period.label)
87
+ );
88
+ }
89
+
90
+ /**
91
+ * 从 DOM 获取书籍链接。每本书有多个 anchor(排名数字/书名/最近更新),
92
+ * 按 bookId 聚合后取最像书名的文本(非纯数字、非"最近更新"前缀、最长),
93
+ * 否则书名会被排名数字 anchor 覆盖,导致后续按书名回填链接全失败。
94
+ */
95
+ function extractBookUrls(port) {
96
+ const js = `JSON.stringify((function(){
97
+ var byId={};var order=[];
98
+ Array.from(document.querySelectorAll('a')).forEach(function(a){
99
+ var h=a.getAttribute('href')||a.href||'';
100
+ var m=h.match(/\\/(?:shuku|book)\\/([0-9]+)/);
101
+ if(!m)return; var id=m[1];
102
+ var t=(a.innerText||a.textContent||'').replace(/\\s+/g,' ').trim();
103
+ if(!byId[id]){byId[id]='';order.push(id);}
104
+ if(t&&!/^[0-9]+$/.test(t)&&!/^(最近更新|最新章节|最新)/.test(t)){
105
+ if(t.length>byId[id].length)byId[id]=t;
106
+ }
107
+ });
108
+ return order.map(function(id){return {bookId:id,title:byId[id],url:'https://www.qimao.com/shuku/'+id+'/'};});
109
+ })())`;
110
+ return evalJSONBase64(port, js) || [];
111
+ }
112
+
113
+ /**
114
+ * 从页面 innerText 解析结构化书籍数据。
115
+ * 七猫页面文本结构固定:排名→书名→作者→题材→子分类→状态→字数→简介→更新→热度
116
+ */
117
+ function extractBooksFromText(port) {
118
+ const js =
119
+ "JSON.stringify((()=>{" +
120
+ "var text=document.body.innerText||'';" +
121
+ // 找到榜单数据起始位置
122
+ "var start=-1;" +
123
+ "['日榜','月榜'].forEach(function(m){if(start<0)start=text.indexOf(m)});" +
124
+ "if(start<0)return[];" +
125
+ "var lines=text.substring(start).split(/\\n/);" +
126
+ "var books=[];var cur=null;var fieldIdx=0;" +
127
+ "for(var i=0;i<lines.length;i++){" +
128
+ " var line=lines[i].trim();" +
129
+ " if(!line)continue;" +
130
+ // 排行数据结束后的分页器/页脚必须立刻截断;否则“5 / 下一页 / 跳转 / 友情链接”
131
+ // 会被串成一条字段齐全的假书目。
132
+ " if(/^(上一页|下一页|跳转|友情链接[::]?)$/.test(line)){if(cur&&cur.title)books.push(cur);cur=null;break}" +
133
+ // 排名标记:独立数字 1-99
134
+ " if(/^\\d{1,2}$/.test(line)&&parseInt(line)<100){" +
135
+ " if(cur&&cur.title)books.push(cur);" +
136
+ " cur={rank:parseInt(line),title:'',author:'',genre:'',subGenre:'',status:'',words:'',heat:'',update:'',desc:''};" +
137
+ " fieldIdx=0;continue" +
138
+ " }" +
139
+ " if(!cur)continue;" +
140
+ // 跳过 UI 文字
141
+ " if(/^(加入书架|立即阅读|蝉联|榜首)/.test(line))continue;" +
142
+ // 热度
143
+ " var hm=line.match(/([\\d.]+)\\s*万\\s*热度/);" +
144
+ " if(hm){cur.heat=hm[1]+'万';continue}" +
145
+ // 最新更新
146
+ " if(line.indexOf('最近更新')===0){cur.update=line.replace(/^最近更新\\s*/,'');continue}" +
147
+ // 状态
148
+ " if(/^(连载中|已完结)$/.test(line)){cur.status=line;continue}" +
149
+ // 字数
150
+ " if(/^[\\d.]+万字$/.test(line)){cur.words=line;continue}" +
151
+ // 按序填充:书名→作者→题材→子分类
152
+ " if(fieldIdx===0){cur.title=line;fieldIdx=1;continue}" +
153
+ " if(fieldIdx===1){cur.author=line;fieldIdx=2;continue}" +
154
+ " if(fieldIdx===2){cur.genre=line;fieldIdx=3;continue}" +
155
+ " if(fieldIdx===3){cur.subGenre=line;fieldIdx=4;continue}" +
156
+ // 其余为简介
157
+ " cur.desc+=(cur.desc?' ':'')+line" +
158
+ "}" +
159
+ "if(cur&&cur.title)books.push(cur);" +
160
+ "return books" +
161
+ "})())";
162
+ return evalJSONBase64(port, js) || [];
163
+ }
164
+
165
+ /**
166
+ * 排除分页器等被正文文本解析器误认成的伪书目。
167
+ * 七猫榜单尾部会出现“5 / 下一页”这类纯 UI 文本;有效条目必须同时有正排名、书名和作者。
168
+ */
169
+ function isUsableBook(book) {
170
+ return !!(
171
+ book &&
172
+ Number.isInteger(book.rank) &&
173
+ book.rank > 0 &&
174
+ book.title &&
175
+ book.author &&
176
+ !/^(上一页|下一页|跳转)$/.test(book.title) &&
177
+ !/^(上一页|下一页|跳转|友情链接[::]?)$/.test(book.author)
178
+ );
179
+ }
180
+
181
+ function cleanDesc(value) {
182
+ const text = String(value || "")
183
+ .replace(/\s*(?:飙升|上升|下降)\s*\d+\s*名\s*$/g, "")
184
+ .replace(/\s*(?:上一页|下一页)\s*$/g, "")
185
+ .replace(/\s+/g, " ")
186
+ .trim();
187
+ if (text.length <= 100) return text;
188
+ const cut = text.slice(0, 100);
189
+ const sentence = cut.match(/^[\s\S]*[。!?]/);
190
+ return (sentence ? sentence[0] : cut) + "...";
191
+ }
192
+
193
+ function summarizeQuality(books, rawCount) {
194
+ const linked = books.filter((book) => book.url).length;
195
+ const heated = books.filter((book) => book.heat).length;
196
+ const fieldCounts = [
197
+ ["题材", "genre"],
198
+ ["子分类", "subGenre"],
199
+ ["状态", "status"],
200
+ ["字数", "words"],
201
+ ["热度", "heat"],
202
+ ].map(([label, field]) => ({
203
+ label,
204
+ missing: books.filter((book) => !book[field]).length,
205
+ }));
206
+ const problems = [];
207
+ if (rawCount > books.length) problems.push(`移除无效/UI条目 ${rawCount - books.length} 条`);
208
+ if (linked < books.length) problems.push(`作品页链接缺失 ${books.length - linked} 条`);
209
+ for (const field of fieldCounts) {
210
+ if (field.missing) problems.push(`${field.label}缺失 ${field.missing} 条`);
211
+ }
212
+ if (books.length < 15) problems.push(`[数据稀疏] 实际采集 ${books.length} 条`);
213
+ return {
214
+ linked,
215
+ heated,
216
+ problems,
217
+ quality: problems.length ? "[存在问题]" : "[OK]",
218
+ };
219
+ }
220
+
221
+ function renderMarkdown(ch, rt, period, url, books, rawCount, now = new Date().toISOString()) {
222
+ const periodLabel = period ? period.label : "";
223
+ const summary = summarizeQuality(books, rawCount);
224
+ const lines = [
225
+ `# 七猫 · ${ch.label} · ${rt.label}${periodLabel}`,
226
+ "",
227
+ `- 数据质量:${summary.quality}`,
228
+ `- 有效条目:${books.length} / ${rawCount}`,
229
+ `- 问题摘要:${summary.problems.length ? summary.problems.join(";") : "无"}`,
230
+ `- 作品页链接:${summary.linked} / ${books.length}`,
231
+ `- 热度命中:${summary.heated} / ${books.length}`,
232
+ `- 来源:${url}`,
233
+ `- 抓取时间:${now}`,
234
+ `- 条目数:${books.length}`,
235
+ "",
236
+ "---",
237
+ "",
238
+ ];
239
+
240
+ for (const b of books) {
241
+ try {
242
+ lines.push(`### #${b.rank} ${b.title}`);
243
+ const meta = [
244
+ b.author || "[待补]",
245
+ b.genre || "[待补]",
246
+ b.subGenre || "[待补]",
247
+ b.status || "[待补]",
248
+ b.words || "[待补]",
249
+ b.heat ? b.heat + "热度" : "[待补]",
250
+ ].join(" · ");
251
+ lines.push(`*${meta}*`);
252
+ if (b.update) lines.push(`**最新更新:** ${b.update}`);
253
+ if (b.url) lines.push(`[作品页](${b.url})`);
254
+ const desc = cleanDesc(b.desc);
255
+ if (desc) {
256
+ lines.push("");
257
+ lines.push("**简介**");
258
+ lines.push("");
259
+ lines.push(desc);
260
+ }
261
+ lines.push("", "---", "");
262
+ } catch (bookErr) {
263
+ console.error(`[qimao] ${ch.label}${rt.label} 第${b.rank}条处理出错: ${bookErr.message}`);
264
+ lines.push("", "---", "");
265
+ }
266
+ }
267
+
268
+ return lines.join("\n");
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // 主流程
273
+ // ---------------------------------------------------------------------------
274
+
275
+ const args = process.argv.slice(2);
276
+ const PORT = parseInt(getArg(args, "--port") || "9222", 10);
277
+ const OUTDIR = getArg(args, "--outdir") || ".";
278
+ const CHANNEL = getArg(args, "--channel") || "male";
279
+ const RANKTYPE = getArg(args, "--type") || "hot";
280
+ const PERIOD = getArg(args, "--period") || "day";
281
+
282
+ function scrapeRank(port, channelId, rankTypeId, periodId) {
283
+ const ch = CHANNELS.find((c) => c.id === channelId);
284
+ const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
285
+ const period = periodId ? PERIODS.find((p) => p.id === periodId) : null;
286
+ if (!ch || !rt) {
287
+ console.log(" ⚠ 未知频道或榜单类型");
288
+ return null;
289
+ }
290
+
291
+ const periodLabel = period ? period.label : "";
292
+ const url = rankUrl(channelId, rankTypeId, periodId);
293
+ console.log(`\n→ 采集 七猫${ch.label}${rt.label}${periodLabel}...`);
294
+
295
+ let books, urls, rawCount;
296
+ try {
297
+ ab(port, "open", url);
298
+ sleep(3000);
299
+
300
+ // 连通性自检:CDP 未起/被重定向时给可操作报错,而非静默产空
301
+ const probe = probePage(port);
302
+ if (!probe) {
303
+ console.error(
304
+ ` ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${port}),且 agent-browser 可用。`
305
+ );
306
+ return null;
307
+ }
308
+ if (probe.host && probe.host.indexOf("qimao") === -1) {
309
+ console.error(` ✗ 当前页面非七猫(host=${probe.host}),可能被重定向,已跳过。`);
310
+ return null;
311
+ }
312
+ const observed = extractObservedSelection(port);
313
+ if (!selectionMatches(observed, channelId, rankTypeId, periodId)) {
314
+ console.error(
315
+ ` ✗ 页面实际榜单与请求不一致(请求 ${ch.tab}/${rt.label}/${periodLabel || "日榜"},` +
316
+ `实际 ${observed.channel || "?"}/${observed.rankType || "?"}/${observed.period || "?"},path=${observed.path || probe.path || "?"}),已跳过。`
317
+ );
318
+ return null;
319
+ }
320
+ console.log(` ✓ 已验证页面实际榜单:${observed.channel}/${observed.rankType}${observed.period ? "/" + observed.period : ""}`);
321
+
322
+ // 滚动加载更多
323
+ scrollLoad(port, 5);
324
+ sleep(1000);
325
+
326
+ // 文本解析获取书籍数据 + DOM 获取链接
327
+ const rawBooks = extractBooksFromText(port);
328
+ rawCount = rawBooks.length;
329
+ books = rawBooks.filter(isUsableBook);
330
+ urls = extractBookUrls(port);
331
+ } catch (err) {
332
+ console.error(`[qimao] ${ch.label}${rt.label}${periodLabel} 页面加载或提取出错: ${err.message}`);
333
+ return null;
334
+ }
335
+
336
+ if (!books.length) {
337
+ console.error(`[qimao] 采集失败:页面结构可能已变(选择器没匹配到数据),请检查榜单URL或更新选择器 (${RANK_URL} ${ch.label}${rt.label}${periodLabel})`);
338
+ return null;
339
+ }
340
+
341
+ // 按标题匹配 URL(书名归一后比对,吸收空白差异)
342
+ const norm = (s) => (s || "").replace(/\s+/g, "");
343
+ for (const b of books) {
344
+ try {
345
+ const matched = urls.find((u) => norm(u.title) === norm(b.title));
346
+ if (matched) b.url = matched.url;
347
+ } catch (matchErr) {
348
+ console.error(`[qimao] URL匹配出错(#${b.rank} ${b.title}): ${matchErr.message}`);
349
+ }
350
+ }
351
+
352
+ const summary = summarizeQuality(books, rawCount);
353
+ console.log(
354
+ ` ✓ 提取 ${books.length} 本(链接 ${summary.linked}/${books.length},热度 ${summary.heated}/${books.length})`
355
+ );
356
+ return renderMarkdown(ch, rt, period, url, books, rawCount);
357
+ }
358
+
359
+ function buildTargets(channel, rankType, period) {
360
+ const channels = channel === "all" ? CHANNELS.map((item) => item.id) : [channel];
361
+ const rankTypes = rankType === "all" ? RANK_TYPES.map((item) => item.id) : [rankType];
362
+ const targets = [];
363
+ for (const channelId of channels) {
364
+ for (const rankTypeId of rankTypes) {
365
+ if (rankTypeId === "hot") {
366
+ const periods = period === "all" ? PERIODS.map((item) => item.id) : [period];
367
+ for (const periodId of periods) {
368
+ targets.push({ channel: channelId, rankType: rankTypeId, period: periodId });
369
+ }
370
+ } else {
371
+ targets.push({ channel: channelId, rankType: rankTypeId, period: null });
372
+ }
373
+ }
374
+ }
375
+ return targets;
376
+ }
377
+
378
+ function outputFilename(channelId, rankTypeId, periodId, date) {
379
+ const channel = CHANNELS.find((item) => item.id === channelId);
380
+ const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
381
+ const period = periodId ? PERIODS.find((item) => item.id === periodId) : null;
382
+ return `七猫${channel.label}${rankType.label}${period ? period.label : ""}_${date}.md`;
383
+ }
384
+
385
+ function main() {
386
+ if (CHANNEL !== "all" && !CHANNELS.some((channel) => channel.id === CHANNEL)) {
387
+ throw new Error(`未知 --channel: ${CHANNEL}`);
388
+ }
389
+ if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
390
+ throw new Error(`未知 --type: ${RANKTYPE}`);
391
+ }
392
+ if (PERIOD !== "all" && !PERIODS.some((period) => period.id === PERIOD)) {
393
+ throw new Error(`未知 --period: ${PERIOD}`);
394
+ }
395
+ const targets = buildTargets(CHANNEL, RANKTYPE, PERIOD);
396
+ let written = 0;
397
+ let failed = 0;
398
+
399
+ for (const target of targets) {
400
+ const content = scrapeRank(PORT, target.channel, target.rankType, target.period);
401
+ if (!content) {
402
+ failed++;
403
+ continue;
404
+ }
405
+
406
+ const date = localDateStamp();
407
+ const filename = outputFilename(target.channel, target.rankType, target.period, date);
408
+ fs.mkdirSync(OUTDIR, { recursive: true });
409
+ const filepath = path.join(OUTDIR, filename);
410
+ fs.writeFileSync(filepath, content, "utf-8");
411
+ written++;
412
+ console.log(` ✓ 已保存: ${filepath}`);
413
+ }
414
+ return {
415
+ planned: targets.length,
416
+ written,
417
+ failed,
418
+ partial: failed > 0,
419
+ partialReasons: [],
420
+ };
421
+ }
422
+
423
+ if (require.main === module) {
424
+ runCli(main, "七猫采集");
425
+ }
426
+
427
+ module.exports = {
428
+ extractBooksFromText,
429
+ isUsableBook,
430
+ cleanDesc,
431
+ renderMarkdown,
432
+ rankUrl,
433
+ selectionMatches,
434
+ buildTargets,
435
+ outputFilename,
436
+ };
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 知乎短篇/长篇采集脚本
4
+ *
5
+ * 入口:https://www.zhihu.com/fiore/h5/vip-web
6
+ *
7
+ * 短篇(4 个榜单):推荐榜 / 热搜榜 / 热度榜 / 口碑榜
8
+ * 长篇(1 个榜单):长篇榜
9
+ *
10
+ * 用法:
11
+ * node zhihu-rank-scraper.js --length short # 短篇 4 个榜单
12
+ * node zhihu-rank-scraper.js --length long # 长篇 1 个榜单
13
+ * node zhihu-rank-scraper.js --length short --boards rec,hot # 指定短篇子榜
14
+ *
15
+ * 前置:
16
+ * node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
17
+ * 知乎需要登录态才能看完整榜单,请先用 Chrome 手动登录 zhihu.com
18
+ *
19
+ * 输出:扫榜结果/知乎{长篇|短篇}{榜单名}_{YYYYMMDD}.md
20
+ */
21
+
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+ const { ab, evalJSONBase64, getArg, localDateStamp, runCli } = require("./cdp-utils");
25
+
26
+ const PORT = 9222;
27
+ const OUTDIR = getArg(process.argv, "--outdir") || "扫榜结果";
28
+ const ENTRY_URL = "https://www.zhihu.com/fiore/h5/vip-web";
29
+
30
+ const LENGTH = getArg(process.argv, "--length") || "short";
31
+
32
+ // 短篇 4 个榜单
33
+ const SHORT_BOARDS = [
34
+ { id: "rec", label: "推荐榜", tabText: "推荐榜" },
35
+ { id: "hot", label: "热搜榜", tabText: "热搜榜" },
36
+ { id: "trending", label: "热度榜", tabText: "热度榜" },
37
+ { id: "reputation", label: "口碑榜", tabText: "口碑榜" },
38
+ ];
39
+
40
+ // 长篇 1 个榜单
41
+ const LONG_BOARDS = [
42
+ { id: "long", label: "长篇榜", tabText: "长篇榜" },
43
+ ];
44
+
45
+ const BOARDS = LENGTH === "long" ? LONG_BOARDS : SHORT_BOARDS;
46
+
47
+ // 自定义子榜(可选)
48
+ const customBoards = getArg(process.argv, "--boards");
49
+ const targetBoards = customBoards
50
+ ? BOARDS.filter((b) => customBoards.split(",").map((s) => s.trim()).includes(b.id))
51
+ : BOARDS;
52
+
53
+ /**
54
+ * 在页面里点指定 tab,等待内容刷新,再提取故事列表
55
+ */
56
+ async function scrapeBoard(port, board) {
57
+ console.log(` [${board.label}] 正在抓取...`);
58
+
59
+ // 导航到入口页(每次都重新进入首页,避免 SPA 状态污染)
60
+ const navResult = ab(port, "navigate", ENTRY_URL);
61
+ if (!navResult.ok) {
62
+ console.error(` [${board.label}] 导航失败: ${navResult.error || "unknown"}`);
63
+ return null;
64
+ }
65
+
66
+ // 等待页面加载
67
+ await new Promise((r) => setTimeout(r, 3000));
68
+
69
+ // 点击对应 tab
70
+ // 用包含 tabText 的元素点击(兼容多种 class 命名)
71
+ const clickJs = `
72
+ (function(){
73
+ const target = ${JSON.stringify(board.tabText)};
74
+ const candidates = Array.from(document.querySelectorAll('a, button, div, span, li'))
75
+ .filter((el) => {
76
+ const t = (el.innerText || '').trim();
77
+ return t === target || t.startsWith(target);
78
+ });
79
+ if (candidates.length === 0) return JSON.stringify({ok:false, reason:'tab_not_found', target:target});
80
+ candidates[0].click();
81
+ return JSON.stringify({ok:true, clicked:candidates[0].tagName});
82
+ })()
83
+ `;
84
+ const clickResult = evalJSONBase64(port, clickJs);
85
+ if (!clickResult || !clickResult.ok) {
86
+ console.error(` [${board.label}] 点击 tab 失败: ${JSON.stringify(clickResult)}`);
87
+ return null;
88
+ }
89
+
90
+ // 等待内容加载
91
+ await new Promise((r) => setTimeout(r, 2500));
92
+
93
+ // 提取故事列表
94
+ // 兼容多种可能的选择器(知乎没有公开 class 文档,按常见模式)
95
+ const storiesJs = `
96
+ (function(){
97
+ const items = [];
98
+ const seen = new Set();
99
+
100
+ // 策略 1:找所有含故事标题的 anchor
101
+ const anchors = document.querySelectorAll('a[href*="/p/"], a[href*="/question/"], a[href*="/xen/"]');
102
+ anchors.forEach((a, i) => {
103
+ const href = a.href || '';
104
+ if (!href || seen.has(href)) return;
105
+ const text = (a.innerText || '').trim();
106
+ if (!text || text.length < 4 || text.length > 200) return;
107
+ // 跳过导航/按钮文字
108
+ if (/^(首页|发现|等你来答|登录|注册|更多|查看全部)$/.test(text)) return;
109
+ seen.add(href);
110
+ items.push({rank: i + 1, title: text.split('\\n')[0].trim(), url: href});
111
+ });
112
+
113
+ return JSON.stringify(items.slice(0, 100));
114
+ })()
115
+ `;
116
+
117
+ const stories = evalJSONBase64(port, storiesJs);
118
+ if (!stories || stories.length === 0) {
119
+ console.warn(` [${board.label}] 未提取到故事`);
120
+ return [];
121
+ }
122
+
123
+ return stories;
124
+ }
125
+
126
+ function buildBoardMarkdown(board, stories, date) {
127
+ const lines = [
128
+ `# 知乎${board.label}(${date})`,
129
+ "",
130
+ `> 数据来源:${ENTRY_URL} → ${board.label}`,
131
+ `> 篇幅:${LENGTH === "long" ? "长篇" : "短篇"}`,
132
+ `> 知乎需要登录态才能看完整榜单,请确认 Chrome 已登录`,
133
+ "",
134
+ `## ${board.label}内容`,
135
+ "",
136
+ ];
137
+
138
+ if (stories.length === 0) {
139
+ lines.push("(未提取到数据,可能页面结构变化或需要登录)", "");
140
+ } else {
141
+ stories.forEach((s) => {
142
+ lines.push(`### ${s.rank}. ${s.title || "(无标题)"}`);
143
+ if (s.url) lines.push(`- 链接:${s.url}`);
144
+ lines.push("", "---", "");
145
+ });
146
+ }
147
+
148
+ return lines.join("\n");
149
+ }
150
+
151
+ function main() {
152
+ const date = localDateStamp();
153
+ fs.mkdirSync(OUTDIR, { recursive: true });
154
+
155
+ let written = 0;
156
+ for (const board of targetBoards) {
157
+ const stories = scrapeBoard(PORT, board);
158
+ if (stories === null) {
159
+ // 采集失败(导航失败 / tab 找不到),跳过这个榜单继续下一个
160
+ continue;
161
+ }
162
+
163
+ const md = buildBoardMarkdown(board, stories, date);
164
+ const filename = `知乎${LENGTH === "long" ? "长篇" : "短篇"}${board.label}_${date}.md`;
165
+ const filepath = path.join(OUTDIR, filename);
166
+ fs.writeFileSync(filepath, md, "utf-8");
167
+ console.log(` ✓ ${filename} (${stories.length} 条)`);
168
+ written++;
169
+ }
170
+
171
+ return written;
172
+ }
173
+
174
+ if (require.main === module) {
175
+ runCli(main, "知乎采集");
176
+ }
177
+
178
+ module.exports = { scrapeBoard, buildBoardMarkdown };