@cloud411716/fancy-webnovel 0.1.18 → 0.1.20

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,453 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fancy-scan run-scan.js — 采集编排层
4
+ *
5
+ * 职责:
6
+ * 1. 检查 .fancy-deployed
7
+ * 2. 调用平台采集脚本
8
+ * 3. 生成扫榜报告
9
+ * 4. 生成/追加 topic_decision
10
+ *
11
+ * 调用方式:
12
+ * node run-scan.js --project-root <abs> --platform qidian --length long
13
+ */
14
+
15
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
16
+ import { join } from 'path';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // 平台配置
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const VALID_PLATFORMS = ['qidian', 'fanqie', 'jinjiang', 'zhihu', 'dianzhong', 'qimao'];
23
+ const VALID_LENGTHS = ['long', 'short'];
24
+
25
+ const PLATFORM_SUPPORTED_LENGTHS = {
26
+ qidian: ['long'],
27
+ fanqie: ['long'],
28
+ jinjiang: ['long'],
29
+ zhihu: ['long', 'short'],
30
+ dianzhong:['short'],
31
+ qimao: ['long', 'short'],
32
+ };
33
+
34
+ const PLATFORM_CN = {
35
+ qidian: '起点',
36
+ fanqie: '番茄',
37
+ jinjiang: '晋江',
38
+ zhihu: '知乎',
39
+ dianzhong:'点众',
40
+ qimao: '七猫',
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // 工具函数
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function nowIso() {
48
+ return new Date().toISOString();
49
+ }
50
+
51
+ function todayStr() {
52
+ const d = new Date();
53
+ return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
54
+ }
55
+
56
+ function ensureDir(dir) {
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // HTTP 采集 — 起点 mobile SSR
62
+ // ---------------------------------------------------------------------------
63
+
64
+ async function scrapeQidian(outDir) {
65
+ const MOBILE_BASE = 'https://m.qidian.com';
66
+ const RANK_TYPES = [
67
+ { id: 'hotsales', label: '畅销榜', path: '/rank/hotsales/' },
68
+ { id: 'yuepiao', label: '月票榜', path: '/rank/yuepiao/' },
69
+ { id: 'signnewbook',label: '签约作者新书榜', path: '/rank/sign/' },
70
+ { id: 'pubnewbook', label: '公众作者新书榜', path: '/rank/newbook/' },
71
+ { id: 'newauthor', label: '新人作者新书榜', path: '/rank/newauthor/' },
72
+ ];
73
+
74
+ const MOBILE_HEADERS = {
75
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
76
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
77
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
78
+ 'Accept-Encoding': 'identity',
79
+ };
80
+
81
+ const results = [];
82
+
83
+ for (const rt of RANK_TYPES) {
84
+ const url = `${MOBILE_BASE}${rt.path}`;
85
+ let html = '';
86
+ try {
87
+ html = await fetch(url, { headers: MOBILE_HEADERS, signal: AbortSignal.timeout(15000) }).then(r => r.text());
88
+ } catch (e) {
89
+ console.error(` ⚠ ${rt.label} 请求失败: ${e.message}`);
90
+ continue;
91
+ }
92
+
93
+ // 提取 pageContext JSON
94
+ const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
95
+ if (!m) {
96
+ console.error(` ⚠ ${rt.label} 未找到 pageContext`);
97
+ continue;
98
+ }
99
+
100
+ let pageContext;
101
+ try {
102
+ pageContext = JSON.parse(m[1]);
103
+ } catch (e) {
104
+ console.error(` ⚠ ${rt.label} JSON 解析失败`);
105
+ continue;
106
+ }
107
+
108
+ const records = pageContext?.pageContext?.pageProps?.pageData?.records || [];
109
+ if (!records.length) {
110
+ console.error(` ⚠ ${rt.label} 无书籍数据`);
111
+ continue;
112
+ }
113
+
114
+ const books = records.map((r, i) => ({
115
+ rank: r.rankNum || i + 1,
116
+ title: r.bName || r.bookName || '',
117
+ url: r.bid ? `${MOBILE_BASE}/book/${r.bid}/` : '',
118
+ author: r.bAuth || r.author || '',
119
+ genre: [r.cat, r.subCat].filter(Boolean).join('·'),
120
+ status: r.status || '',
121
+ words: r.cnt || r.wordCount || '',
122
+ rankValue: r.rankCnt || '',
123
+ totalRecommend: r.totalRecommend || '',
124
+ signStatus: r.signStatus || '',
125
+ vipStatus: r.vipStatus || '',
126
+ }));
127
+
128
+ const today = todayStr();
129
+ const outFile = join(outDir, `${PLATFORM_CN.qidian}${rt.label}_${today}.md`);
130
+ const md = renderQidianMarkdown(rt.label, url, books);
131
+ writeFileSync(outFile, md, 'utf-8');
132
+ console.log(` ✅ ${rt.label}: ${books.length} 本 → ${outFile}`);
133
+ results.push({ label: rt.label, count: books.length, file: outFile });
134
+ }
135
+
136
+ return results;
137
+ }
138
+
139
+ function renderQidianMarkdown(rankLabel, url, books) {
140
+ const now = nowIso();
141
+ const lines = [
142
+ `# 起点 · ${rankLabel}`,
143
+ '',
144
+ `- 来源:${url}`,
145
+ `- 抓取方式:mobile-ssr`,
146
+ `- 抓取时间:${now}`,
147
+ `- 条目数:${books.length}`,
148
+ '',
149
+ '---',
150
+ '',
151
+ ];
152
+
153
+ for (const b of books) {
154
+ lines.push(`## #${b.rank} ${b.title}`);
155
+ const meta = [b.author, b.genre, b.status].filter(Boolean).join(' · ');
156
+ if (meta) lines.push(`*${meta}*`);
157
+ const req = (v) => (v === undefined || v === null || v === '') ? '[待补]' : String(v);
158
+ lines.push(`**字数:** ${req(b.words)}`);
159
+ if (b.rankValue) lines.push(`**榜单值:** ${b.rankValue}`);
160
+ lines.push(`**总推荐:** ${req(b.totalRecommend)}`);
161
+ lines.push(`**签约:** ${req(b.signStatus)}`);
162
+ lines.push(`**收费:** ${req(b.vipStatus)}`);
163
+ if (b.url) lines.push(`[作品页](${b.url})`);
164
+ lines.push('', '---', '');
165
+ }
166
+
167
+ return lines.join('\n');
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // 占位符采集 — 浏览器平台
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function scrapeBrowserStub(platform, length, outDir) {
175
+ // 这些平台需要 browser_* 工具,由 LLM 调用
176
+ // 这里生成指令文件,供 LLM 读取执行
177
+ const today = todayStr();
178
+ const pcn = PLATFORM_CN[platform] || platform;
179
+ const lenStr = length === 'long' ? '长篇' : '短篇';
180
+ const instrFile = join(outDir, `_browser_instructions_${platform}_${length}_${today}.json`);
181
+ const instructions = getBrowserInstructions(platform, length);
182
+
183
+ writeFileSync(instrFile, JSON.stringify(instructions, null, 2), 'utf-8');
184
+ console.log(` ℹ ${pcn}(${lenStr})需要 browser_* 工具,已生成指令文件:${instrFile}`);
185
+ console.log(` ℹ 请让 LLM 读取并执行该指令文件中的 browser 操作`);
186
+ return { file: instrFile, instructions };
187
+ }
188
+
189
+ function getBrowserInstructions(platform, length) {
190
+ // 返回给 LLM 的操作指令
191
+ const today = todayStr();
192
+ const pcn = PLATFORM_CN[platform] || platform;
193
+ const lenStr = length === 'long' ? '长篇' : '短篇';
194
+
195
+ if (platform === 'fanqie') {
196
+ return {
197
+ platform, length, pcn, lenStr, date: today,
198
+ action: 'scrape_fanqie',
199
+ steps: [
200
+ { tool: 'browser_navigate', url: 'https://fanqienovel.com/rankings?channel=1&type=2', label: '番茄男频阅读榜' },
201
+ { tool: 'sleep', seconds: 3 },
202
+ { tool: 'scroll', times: 3 },
203
+ { tool: 'extract', description: '提取页面书籍列表:排名、书名、作者、在读数、题材' },
204
+ ],
205
+ outputFile: `扫榜结果/番茄男频阅读榜_${today}.md`,
206
+ format: '见 scan-output-format.md',
207
+ };
208
+ }
209
+
210
+ if (platform === 'jinjiang') {
211
+ return {
212
+ platform, length, pcn, lenStr, date: today,
213
+ action: 'scrape_jinjiang',
214
+ steps: [
215
+ { tool: 'browser_navigate', url: 'https://www.jjwxc.net/topten.php?orderstr=12&t=0', label: '晋江金榜' },
216
+ { tool: 'sleep', seconds: 3 },
217
+ { tool: 'extract', description: '提取页面书籍列表:排名、书名、作者、收藏数' },
218
+ ],
219
+ outputFile: `扫榜结果/晋江金榜_${today}.md`,
220
+ format: '见 scan-output-format.md',
221
+ };
222
+ }
223
+
224
+ if (platform === 'zhihu') {
225
+ const subType = length === 'short' ? '短篇4榜' : '长篇榜';
226
+ return {
227
+ platform, length, pcn, lenStr, date: today,
228
+ action: 'scrape_zhihu',
229
+ steps: [
230
+ { tool: 'browser_navigate', url: 'https://www.zhihu.com/fiore/h5/vip-web', label: `知乎${subType}` },
231
+ { tool: 'sleep', seconds: 3 },
232
+ { tool: 'extract', description: '提取页面书籍列表' },
233
+ ],
234
+ outputFile: `扫榜结果/知乎${subType}_${today}.md`,
235
+ format: '见 scan-output-format.md',
236
+ };
237
+ }
238
+
239
+ if (platform === 'dianzhong') {
240
+ return {
241
+ platform, length, pcn, lenStr, date: today,
242
+ action: 'scrape_dianzhong',
243
+ steps: [
244
+ { tool: 'browser_navigate', url: 'https://www.tiyi.cn/', label: '点众短篇榜' },
245
+ { tool: 'sleep', seconds: 3 },
246
+ { tool: 'extract', description: '提取页面书籍列表' },
247
+ ],
248
+ outputFile: `扫榜结果/点众短篇榜_${today}.md`,
249
+ format: '见 scan-output-format.md',
250
+ };
251
+ }
252
+
253
+ if (platform === 'qimao') {
254
+ const gender = length === 'long' ? '男频' : '女频';
255
+ return {
256
+ platform, length, pcn, lenStr, date: today,
257
+ action: 'scrape_qimao',
258
+ steps: [
259
+ { tool: 'browser_navigate', url: 'https://www.qimao.com/paihang', label: `七猫${gender}大热榜` },
260
+ { tool: 'sleep', seconds: 3 },
261
+ { tool: 'extract', description: '提取页面书籍列表:排名、书名、作者、热度' },
262
+ ],
263
+ outputFile: `扫榜结果/七猫${gender}大热榜_${today}.md`,
264
+ format: '见 scan-output-format.md',
265
+ };
266
+ }
267
+
268
+ return { error: `未知平台: ${platform}` };
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // 扫榜报告生成
273
+ // ---------------------------------------------------------------------------
274
+
275
+ function generateReport(platform, length, scanFiles, outDir) {
276
+ const today = todayStr();
277
+ const pcn = PLATFORM_CN[platform] || platform;
278
+ const lenStr = length === 'long' ? '长篇' : '短篇';
279
+
280
+ // 读取原始数据文件
281
+ const books = [];
282
+ for (const f of scanFiles) {
283
+ if (!existsSync(f)) continue;
284
+ const content = readFileSync(f, 'utf-8');
285
+ // 简单解析 Markdown 中的 ## #N 书名 格式
286
+ const matches = [...content.matchAll(/^## #(\d+) (.+)$/gm)];
287
+ for (const m of matches) {
288
+ books.push({ rank: parseInt(m[1]), title: m[2].trim() });
289
+ }
290
+ }
291
+
292
+ const report = [
293
+ `# ${pcn}${lenStr}扫榜报告:${today}`,
294
+ '',
295
+ '## 市场概况',
296
+ `- 扫榜时间:${today}`,
297
+ `- 核心发现:${books.length > 0 ? `共采集 ${books.length} 本上榜作品` : '(数据采集中)'}`,
298
+ '',
299
+ '## 题材热度排行',
300
+ '- (从原始数据分析提取)',
301
+ '',
302
+ '## 新题材信号',
303
+ '- (从原始数据分析提取)',
304
+ '',
305
+ '## 关键数据洞察',
306
+ `- 字数区间:(待分析)`,
307
+ `- 书名特征:(待分析)`,
308
+ '',
309
+ '## 值得关注的方向',
310
+ '1. (待从榜单提取后填入)',
311
+ '',
312
+ '## 一句话',
313
+ '(待分析后填入)',
314
+ ].join('\n');
315
+
316
+ const reportFile = join(outDir, `${pcn}${lenStr}扫榜报告_${today}.md`);
317
+ writeFileSync(reportFile, report, 'utf-8');
318
+ console.log(` ✅ 扫榜报告 → ${reportFile}`);
319
+ return reportFile;
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // topic_decision 追加
324
+ // ---------------------------------------------------------------------------
325
+
326
+ function appendTopicDecision(platform, length, reportFile, outDir) {
327
+ const today = todayStr();
328
+ const pcn = PLATFORM_CN[platform] || platform;
329
+ const lenStr = length === 'long' ? '长篇' : '短篇';
330
+
331
+ const section = [
332
+ `## ${pcn}(${lenStr})推荐选题`,
333
+ `- 扫榜日期:${today}`,
334
+ `- 数据来源:${reportFile}`,
335
+ '',
336
+ '### 选题 1:(待从报告提取)',
337
+ '- 题材组合:(待填入)',
338
+ '- 目标读者:(待填入)',
339
+ '- 核心卖点:(待填入)',
340
+ '- 能爆的原因:(待填入)',
341
+ '- 差异化定位:(待填入)',
342
+ '- 可行性:高/中/低 — (待评估)',
343
+ '- 失败风险:(待评估)',
344
+ '- 验证动作:(待填入)',
345
+ '- 篇幅/平台:(待填入)',
346
+ ].join('\n');
347
+
348
+ const decisionFile = join(outDir, `topic_decision_${today}.md`);
349
+ const sep = existsSync(decisionFile) ? '\n\n---\n\n' : '';
350
+
351
+ if (existsSync(decisionFile)) {
352
+ writeFileSync(decisionFile, readFileSync(decisionFile, 'utf-8') + sep + section + '\n', 'utf-8');
353
+ } else {
354
+ const header = `# 选题决策:${today}\n\n---\n\n`;
355
+ writeFileSync(decisionFile, header + section + '\n', 'utf-8');
356
+ }
357
+ console.log(` ✅ topic_decision 追加 → ${decisionFile}`);
358
+ return decisionFile;
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // 主流程
363
+ // ---------------------------------------------------------------------------
364
+
365
+ async function main() {
366
+ const args = process.argv.slice(2);
367
+ let projectRoot = '', platform = '', length = '';
368
+
369
+ for (let i = 0; i < args.length; i++) {
370
+ if (args[i] === '--project-root') projectRoot = args[i + 1] || '';
371
+ if (args[i] === '--platform') platform = args[i + 1] || '';
372
+ if (args[i] === '--length') length = args[i + 1] || '';
373
+ }
374
+
375
+ if (!projectRoot || !platform || !length) {
376
+ console.error('缺少必要参数: --project-root --platform --length');
377
+ process.exit(1);
378
+ }
379
+
380
+ // 检查 .fancy-deployed
381
+ if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
382
+ console.error('项目未初始化:.fancy-deployed 不存在');
383
+ process.exit(1);
384
+ }
385
+
386
+ // 验证平台×篇幅
387
+ if (!VALID_PLATFORMS.includes(platform)) {
388
+ console.error(`不支持的平台: ${platform}`);
389
+ process.exit(1);
390
+ }
391
+ if (!VALID_LENGTHS.includes(length)) {
392
+ console.error(`不支持的篇幅: ${length}`);
393
+ process.exit(1);
394
+ }
395
+ if (!PLATFORM_SUPPORTED_LENGTHS[platform].includes(length)) {
396
+ console.error(`${platform} 不支持 ${length}`);
397
+ process.exit(1);
398
+ }
399
+
400
+ const today = todayStr();
401
+ const scanDir = join(projectRoot, '扫榜结果');
402
+ ensureDir(scanDir);
403
+
404
+ let scanFiles = [];
405
+ let scrapeFiles = [];
406
+
407
+ if (platform === 'qidian') {
408
+ // 起点:HTTP mobile SSR
409
+ console.log('→ 采集 起点(mobile SSR)...');
410
+ scrapeFiles = await scrapeQidian(scanDir);
411
+ if (!scrapeFiles.length) {
412
+ console.error(' ❌ 起点采集失败');
413
+ process.exit(1);
414
+ }
415
+ scanFiles = scrapeFiles.map(f => f.file);
416
+ } else {
417
+ // 其他平台:browser_* 工具(LLM 读取指令文件后执行)
418
+ console.log(`→ ${PLATFORM_CN[platform]}(browser_* 工具)...`);
419
+ const { file, instructions } = scrapeBrowserStub(platform, length, scanDir);
420
+ scrapeFiles.push({ file });
421
+ scanFiles.push(file);
422
+
423
+ // 输出指令供 LLM 读取
424
+ console.log('\n=== 浏览器采集指令 ===');
425
+ console.log(JSON.stringify(instructions, null, 2));
426
+ console.log('=== 浏览器采集指令 END ===\n');
427
+ }
428
+
429
+ // 生成扫榜报告
430
+ const mdFiles = scanFiles.filter(f => f.endsWith('.md') && !f.includes('扫榜报告') && !f.includes('topic_decision'));
431
+ const reportFile = generateReport(platform, length, mdFiles, scanDir);
432
+ appendTopicDecision(platform, length, reportFile, scanDir);
433
+
434
+ const receipt = {
435
+ ok: true,
436
+ operation: 'scan',
437
+ platform,
438
+ length,
439
+ date: today,
440
+ scan_files: scanFiles,
441
+ report_file: reportFile,
442
+ topic_decision: join(scanDir, `topic_decision_${today}.md`),
443
+ summary: `${PLATFORM_CN[platform]}(${length})采集完成,共 ${scanFiles.length} 个文件`,
444
+ };
445
+
446
+ console.log('\n=== RECEIPT ===');
447
+ console.log(JSON.stringify(receipt, null, 2));
448
+ }
449
+
450
+ main().catch(e => {
451
+ console.error('Fatal:', e);
452
+ process.exit(1);
453
+ });
@@ -0,0 +1,207 @@
1
+ # 扫榜数据采集格式规范
2
+ 定义起点/番茄/七猫/晋江的采集字段、输出模板和清洗规则。
3
+
4
+ ---
5
+
6
+ ## 起点
7
+
8
+ ### 起点采集说明
9
+
10
+ 榜单清单与 URL 见 SKILL.md「起点采集目标」表。
11
+
12
+ 优先使用 `scripts/qidian-rank-scraper.js` 的默认 `--mode auto`。脚本先读取 `https://m.qidian.com` 移动端 SSR pageContext JSON,规避 PC 站风控页;移动端不可用时才回退到 CDP/PC 页面。输出头部会标注 `抓取方式:mobile-ssr` 或 `cdp-pc`。
13
+
14
+
15
+ ### 字段
16
+
17
+ 排名 | 书名 | 作者 | 题材 | 状态 | 签约 | 收费模式 | 字数(万字) | 总推荐 | 标签(详情页) | 最新更新(详情页) | 作品页链接 | 简介(详情页,截断100字)
18
+
19
+ ### 输出模板
20
+
21
+ ```markdown
22
+ # qidian · {榜单名称}
23
+ - 来源:{榜单URL}
24
+ - 抓取时间:{ISO 8601}
25
+ - 条目数:{N}
26
+
27
+ ---
28
+
29
+ ## #{排名} {书名}
30
+ *{作者} · {题材} · {状态} · {签约} · {免费/VIP} · {字数}万字 · {推荐数}总推荐*
31
+ **标签:** {标签}
32
+ **最新更新:** {YYYY-MM-DD HH:MM:SS} · {章节标题}
33
+
34
+ [作品页]({URL})
35
+
36
+ **简介**
37
+ {简介原文}
38
+ ```
39
+
40
+ ### 采集要点
41
+
42
+ 榜单页含:排名/书名/作者/题材/字数/推荐/签约/免费VIP。详情页需:标签/最新更新/简介。三江按周分组。
43
+
44
+ ---
45
+
46
+ ## 番茄小说
47
+
48
+ 榜单 URL 格式与参数说明见 SKILL.md「番茄采集目标」表。
49
+
50
+ ### 题材cat_id
51
+
52
+ 男频19个:西方奇幻(1141) / 东方仙侠(1140) / 科幻末世(8) / 都市日常(261) / 都市修真(124) / 都市高武(1014) / 历史古代(273) / 战神赘婿(27) / 都市种田(263) / 传统玄幻(258) / 历史脑洞(272) / 悬疑脑洞(539) / 都市脑洞(262) / 玄幻脑洞(257) / 悬疑灵异(751) / 抗战谍战(504) / 游戏体育(746) / 动漫衍生(718) / 男频衍生(1016)
53
+
54
+ 女频18个:古风世情(1139) / 科幻末世(8) / 游戏体育(746) / 女频衍生(1015) / 玄幻言情(248) / 种田(23) / 年代(79) / 现言脑洞(267) / 宫斗宅斗(246) / 悬疑脑洞(539) / 古言脑洞(253) / 快穿(24) / 青春甜宠(749) / 星光璀璨(745) / 女频悬疑(747) / 职场婚恋(750) / 豪门总裁(748) / 民国言情(1017)
55
+
56
+ ### 字段
57
+
58
+ 排名 | 书名(需详情页解码) | 作者(需详情页解码) | 题材(详情页 categoryV2) | 状态 | 在读(核心指标) | 字数 | 标签(简介内【】) | 最新更新 | bookId | 作品页链接 | 简介(截断100字)
59
+
60
+ > 番茄 SSR 详情页**没有数字评分**,故不输出评分。题材取详情页 `categoryV2`(转义 JSON 的首个 `Name`,如「西方奇幻」);标签取简介开头的 `【tag+tag+...】`(如「种田、慢热、西幻」),是题材细分的真实信号。
61
+
62
+ ### 输出模板
63
+
64
+ ```markdown
65
+ # 番茄 · {频道}{榜单名} · 全 {N} 题材
66
+ - 频道参数:channel={0女频/1男频},type={1新书榜/2阅读榜}
67
+ - 抓取时间:{ISO 8601}
68
+ - 标题解析:成功 {X} / 共 {Y}
69
+ - 数据质量:[OK / 标题解析异常 / 无数据]
70
+ - 每题材上限 ≈ {N}(cap≈20)
71
+
72
+ ---
73
+
74
+ ## {题材名称} — {N} 本
75
+
76
+ ### #{排名} {书名}
77
+ *{作者} · {题材} · {状态} · {在读数} 在读 · {字数}字*
78
+ **标签:** {标签1、标签2}
79
+ **最新更新:** {章节}
80
+ **bookId:** {bookId}
81
+
82
+ [作品页]({URL})
83
+
84
+ **简介**
85
+ {简介原文}
86
+ ```
87
+
88
+ > 标题/作者/题材/标签/简介均为可选字段:详情页拿到才输出。书名解码失败时书名显示 `(标题待解析)`,但 bookId 与作品页链接始终保留,便于人工回查。
89
+
90
+ ### 采集要点
91
+
92
+ 字体反爬:列表页 innerText 被自定义字体混淆,`scripts/fanqie-rank-scraper.js` 改从详情页 HTML(内嵌 JSON `bookName`/`author`/`abstract`/`categoryV2` + `<title>` + og:meta)多策略解码明文,规避字体反爬。流程:访问品类页 → 提取品类链接 → 逐品类取 `__INITIAL_STATE__` 列表 → 分批(每 5 本)请求详情页解码。单页上限约 20 本需滚动加载;`--top N` 可调每题材上限。
93
+
94
+ **故障排查(书名全是 `bookId:xxx` / `(标题待解析)`)**:
95
+ - 看文件头 `数据质量`:标 `[标题解析异常]` 说明详情页解码失败率高。
96
+ - 多为详情页结构变动或被登录/验证页拦截。在已登录的 Chrome 里手动打开任一 `https://fanqienovel.com/page/{bookId}` 确认页面正常、非验证页。
97
+ - 控制台若报 `CDP 无响应`,说明 Chrome/CDP 没起来或端口不对,按 browser-cdp skill 重新启动。确认正常后重采。
98
+
99
+ ---
100
+
101
+ ## 七猫
102
+
103
+ ### 榜单
104
+
105
+ 入口:qimao.com/paihang,男生榜/女生榜tab切换。类型:大热榜(日/月) / 新书榜 / 完结榜 / 收藏榜 / 更新榜
106
+
107
+ ### 字段
108
+
109
+ 排名 | 书名 | 作者 | 题材 | 分类标签 | 状态 | 字数(万字) | 热度(核心指标) | 最新更新 | 作品页链接 | 简介(截断100字)
110
+
111
+ ### 输出模板
112
+
113
+ ```markdown
114
+ # 七猫 · {男/女}频 · {榜单名称}
115
+ - 来源:qimao.com/paihang
116
+ - 抓取时间:{ISO 8601}
117
+ - 条目数:{N}
118
+
119
+ ---
120
+
121
+ ### #{排名} {书名}
122
+ *{作者} · {题材} · {分类标签} · {状态} · {字数}万字 · {热度}万热度*
123
+ **最新更新:** {时间} · {章节}
124
+
125
+ [作品页]({URL})
126
+
127
+ **简介**
128
+ {简介原文}
129
+ ```
130
+
131
+ ### 采集要点
132
+
133
+ 无明显反爬需滚动加载。男生榜/女生榜tab切换,大热榜有日/月切换。
134
+
135
+ ---
136
+
137
+ ## 晋江
138
+
139
+ ### 榜单URL
140
+
141
+ `jjwxc.net/topten.php?orderstr={榜单ID}&t={频道ID}`(t=0全站,各频道ID从页面获取)
142
+
143
+ | 榜单 | orderstr |
144
+ |------|----------|
145
+ | 收入金榜 | 12 |
146
+ | 月榜 | 7 |
147
+ | 季度榜 | 8 |
148
+ | 完结金榜 | 14 |
149
+ | 新手金榜 | 15 |
150
+ | 千字金榜 | 17 |
151
+
152
+ ### 字段
153
+
154
+ 频道 | 排名 | 书名 | 作者 | novelid | 收藏数(核心) | 营养液 | 积分 | 字数 | 状态 | 作品页链接
155
+
156
+ ### 输出模板
157
+
158
+ ```markdown
159
+ # 晋江 · {榜单名}
160
+ - 来源:{topten URL}
161
+ - 抓取时间:{ISO 8601}
162
+ - 频道数:{N} / 总条目数:{M}
163
+ - 详情采集:{命中收藏数} / {计划数}(每频道前 {top},上限 {limit})
164
+ - 数据质量:[OK / 详情解析异常·登录态缺失 / 仅列表-无核心指标]
165
+
166
+ ---
167
+
168
+ ## {频道名} — {N} 本
169
+
170
+ ### #{排名} {书名}
171
+ *{作者} · 收藏 {X} · 营养液 {Y} · 积分 {Z} · 字数 {W}字 · {状态}*
172
+ [作品页](https://www.jjwxc.net/onebook.php?novelid={id})
173
+ ```
174
+
175
+ ### 采集要点
176
+
177
+ 两步:① 列表页 `topten.php` 取频道分组 + 书名/作者,从书名 anchor 取 `novelid`(排除"X向《书名》投了Y"霸王票记录);② 进 `onebook.php?novelid=` 详情页补采核心指标。
178
+ - **编码**:晋江是 gb18030,详情页必须 `fetch+arrayBuffer+TextDecoder('gb18030')` 解码(同步 XHR 的 responseText 按 UTF-8 解码会乱码)。
179
+ - **字段来源**:详情页 `itemprop` 微数据——`collectedCount`(收藏)/`nutritionCount`(营养液)/`scoreCount`(积分)/`wordCount`(字数)/`updataStatus`(状态)。这些是公开指标,**无需登录**。
180
+ - **控量**:列表全量保留,仅每频道前 `--top` 本(受 `--detail-limit` 总量约束)补详情,避免对全站数百本逐一请求。
181
+
182
+ ---
183
+
184
+ ## 数据清洗
185
+
186
+ 通用:移除平台模板文本→简介超100字在句号处截断加`...`→空值标`[待补]`
187
+
188
+ | 平台 | 额外必填 |
189
+ |------|----------|
190
+ | 起点 | 题材、字数、总推荐 |
191
+ | 番茄 | 在读数 |
192
+ | 七猫 | 热度 |
193
+ | 晋江 | 收藏数、营养液(或积分)、字数 |
194
+
195
+ 最低采集量:主流平台15条,小平台10条。低于底线标`[数据稀疏]`。
196
+
197
+ ---
198
+
199
+ ## 批量采集
200
+
201
+ | 平台 | 默认组合 |
202
+ |------|----------|
203
+ | 起点 | 新人签约新书榜+签约作者新书榜前20+月票榜前20+畅销榜前20 |
204
+ | 番茄 | 男频阅读榜全题材+女频阅读榜全题材 |
205
+ | 七猫 | 男频大热榜日榜+女频大热榜日榜 |
206
+ | 晋江 | 收入金榜+月榜 |
207
+ | 全平台 | 起点+番茄+七猫默认组合 |