@cloud411716/fancy-webnovel 0.1.17 → 0.1.19

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/cordis.patch.yml CHANGED
@@ -2,3 +2,6 @@
2
2
  - id: fancy-bootstrap
3
3
  name: "@cloud411716/fancy-webnovel"
4
4
  from: "./plugins/fancy-bootstrap/index.js"
5
+ - id: fancy-scan
6
+ name: "@cloud411716/fancy-webnovel"
7
+ from: "./plugins/fancy-scan/index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "main": "plugins/fancy-bootstrap/index.js",
6
6
  "exports": {
@@ -21,6 +21,12 @@ function notify(session, text) {
21
21
  });
22
22
  }
23
23
 
24
+ const ILLEGAL_PATH_RE = /[<>:"|?*[\x00-\x1f]/;
25
+
26
+ function isValidPath(p) {
27
+ return !p.includes('..') && !ILLEGAL_PATH_RE.test(p) && !/\s/.test(p);
28
+ }
29
+
24
30
  export async function apply(ctx) {
25
31
  ctx.commands.register({
26
32
  name: 'fancy-bootstrap',
@@ -47,7 +53,7 @@ export async function apply(ctx) {
47
53
  notify(session, '📍 使用当前路径:' + projectRoot);
48
54
  }
49
55
 
50
- if (projectRoot.includes('..') || /[<>:"'|?*[\x00-\x1f]/.test(projectRoot) || /\s/.test(projectRoot)) {
56
+ if (!isValidPath(projectRoot)) {
51
57
  notify(session, '❌ 路径不合法(不能含空格、.. 或特殊字符)');
52
58
  return { kind: 'success', text: '' };
53
59
  }
@@ -64,7 +70,8 @@ export async function apply(ctx) {
64
70
  ],
65
71
  }],
66
72
  });
67
- const chosen = confirm.answers[0]?.selected ? confirm.answers[0].selected[0] : confirm.answers[0]?.custom;
73
+ const ans = confirm.answers[0];
74
+ const chosen = ans?.selected?.[0] ?? ans?.custom;
68
75
  if (chosen === '取消') {
69
76
  notify(session, '🚫 已取消');
70
77
  return { kind: 'success', text: '' };
@@ -90,13 +97,22 @@ export async function apply(ctx) {
90
97
  function runScript(cmd, args) {
91
98
  return new Promise((resolve) => {
92
99
  const proc = spawn(cmd, args, { timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] });
93
- let stdout = '', stderr = '';
94
- proc.stdout.on('data', (d) => { stdout += d.toString(); });
95
- proc.stderr.on('data', (d) => { stderr += d.toString(); });
100
+ const outParts = [], errParts = [];
101
+ proc.stdout.on('data', (d) => { outParts.push(d.toString()); });
102
+ proc.stderr.on('data', (d) => { errParts.push(d.toString()); });
96
103
  proc.on('close', (code) => {
104
+ const stdout = outParts.join(''), stderr = errParts.join('');
97
105
  try {
98
106
  const parsed = JSON.parse(stdout);
99
- resolve({ ok: parsed.ok === true, output: stdout, error: parsed.error ? (parsed.error.message || JSON.stringify(parsed.error)) : stderr || undefined });
107
+ if (parsed.ok === true) {
108
+ resolve({ ok: true, output: stdout });
109
+ } else {
110
+ const e = parsed.error;
111
+ const msg = (typeof e === 'object' && e !== null)
112
+ ? (e.message || JSON.stringify(e))
113
+ : (e || stderr || ('exit ' + code));
114
+ resolve({ ok: false, output: stdout, error: msg });
115
+ }
100
116
  } catch (_) {
101
117
  resolve({ ok: false, output: stdout, error: stderr || ('exit ' + code) });
102
118
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * fancy-scan DSH plugin
3
+ * 扫榜:平台数据采集 + 报告生成
4
+ */
5
+ import { writeFileSync, existsSync, mkdirSync } from 'fs';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ export const name = 'fancy-scan';
13
+ export const inject = ['commands', 'userQuestions'];
14
+
15
+ const PLATFORM_TABLE = [
16
+ ['1', 'qidian', '长篇', '起点'],
17
+ ['2', 'fanqie', '长篇', '番茄'],
18
+ ['3', 'jinjiang','长篇', '晋江'],
19
+ ['4', 'zhihu', '长/短', '知乎'],
20
+ ['5', 'dianzhong','短篇','点众'],
21
+ ['6', 'qimao', '长/短', '七猫'],
22
+ ];
23
+
24
+ function platformLabel(platform) {
25
+ const row = PLATFORM_TABLE.find(r => r[1] === platform);
26
+ return row ? row[3] : platform;
27
+ }
28
+
29
+ function notify(session, text) {
30
+ queueMicrotask(() => {
31
+ session.append(
32
+ 'user/message',
33
+ { content: [{ type: 'text', text }], source: { kind: 'user' } },
34
+ { surfaceOp: 'append' }
35
+ );
36
+ });
37
+ }
38
+
39
+ function getProjectRoot(invocation) {
40
+ // 从 rawInput 提取项目根(fancy-bootstrap 初始化后的 .fancy-deployed 所在目录)
41
+ // 优先取命令后的路径参数,否则用当前目录
42
+ const raw = invocation.rawInput ? invocation.rawInput.trim() : '';
43
+ return raw || process.cwd();
44
+ }
45
+
46
+ function checkInitialized(projectRoot) {
47
+ return existsSync(join(projectRoot, '.fancy-deployed'));
48
+ }
49
+
50
+ function buildPlatformChoices() {
51
+ return PLATFORM_TABLE.map(r => ({
52
+ label: `${r[0]}. ${r[3]}(${r[2]})`,
53
+ description: '',
54
+ }));
55
+ }
56
+
57
+ function buildLengthChoices(platform) {
58
+ if (platform === 'zhihu' || platform === 'qimao') {
59
+ return [
60
+ { label: '长篇', description: '' },
61
+ { label: '短篇', description: '' },
62
+ ];
63
+ }
64
+ return null; // 无需二次选择
65
+ }
66
+
67
+ export async function apply(ctx) {
68
+ ctx.commands.register({
69
+ name: 'fancy-scan',
70
+ description: '📊 扫榜分析 ( usage: /fancy-scan [平台] [篇幅] )',
71
+ handler: async (invocation) => {
72
+ const session = invocation.agent.session;
73
+ const rawInput = invocation.rawInput ? invocation.rawInput.trim() : '';
74
+
75
+ // Step 1: 检查 .fancy-deployed
76
+ const projectRoot = getProjectRoot(invocation);
77
+ if (!checkInitialized(projectRoot)) {
78
+ notify(session, '⚠️ 当前目录尚未初始化。请先运行 /fancy-bootstrap 进行初始化。');
79
+ return { kind: 'success', text: '' };
80
+ }
81
+
82
+ // Step 2: 解析参数
83
+ let platform = '', length = '';
84
+
85
+ if (rawInput) {
86
+ // 从 rawInput 解析平台+篇幅
87
+ const parts = rawInput.split(/\s+/);
88
+ platform = parts[0] || '';
89
+ length = parts[1] || '';
90
+ // 尝试数字映射
91
+ const numMatch = platform.match(/^(\d+)$/);
92
+ if (numMatch) {
93
+ const row = PLATFORM_TABLE.find(r => r[0] === numMatch[1]);
94
+ if (row) {
95
+ platform = row[1];
96
+ if (!length && (platform === 'zhihu' || platform === 'qimao')) {
97
+ // 需要二次选择,默认长篇
98
+ length = 'long';
99
+ } else {
100
+ length = row[2] === '长篇' ? 'long' : (row[2] === '短篇' ? 'short' : 'long');
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // 无参数或参数不全 → 弹窗
107
+ if (!platform || !length || !PLATFORM_TABLE.find(r => r[1] === platform)) {
108
+ // 打印调用方式
109
+ notify(session, '[/fancy-scan] 调用方式:带参数(平台 篇幅)\n' +
110
+ '[/fancy-scan] 调用方式:无参数(弹窗选择)');
111
+ notify(session, '📋 支持的平台:\n' +
112
+ PLATFORM_TABLE.map(r => ` ${r[0]}. ${r[3]}(${r[2]})`).join('\n') +
113
+ '\n\n- 长篇:关注 追读、月票、订阅 等指标\n- 短篇:关注 传播、完读率 等指标\n- 知乎和七猫同时支持长篇和短篇');
114
+
115
+ const choice = await ctx.userQuestions.ask({
116
+ questions: [{
117
+ id: 'platform',
118
+ question: '📊 请选择要扫的平台(输入编号 1-6):',
119
+ }],
120
+ });
121
+ const answer = choice.answers[0]?.custom?.trim();
122
+ if (!answer) {
123
+ notify(session, '⚠️ 已取消。请再次调用 /fancy-scan 继续。');
124
+ return { kind: 'success', text: '' };
125
+ }
126
+ const numMatch = answer.match(/^(\d+)$/);
127
+ const row = numMatch ? PLATFORM_TABLE.find(r => r[0] === numMatch[1]) : null;
128
+ if (!row) {
129
+ notify(session, '❌ 无效选择,请输入 1-6 的编号。');
130
+ return { kind: 'success', text: '' };
131
+ }
132
+ platform = row[1];
133
+ length = row[2] === '长/短' ? null : (row[2] === '长篇' ? 'long' : 'short');
134
+ }
135
+
136
+ // 知乎/七猫需要二次选择篇幅
137
+ if (!length || length === 'long/short') {
138
+ const lengthChoices = buildLengthChoices(platform);
139
+ if (lengthChoices) {
140
+ const lenResult = await ctx.userQuestions.ask({
141
+ questions: [{
142
+ id: 'length',
143
+ question: `${platformLabel(platform)} 同时支持长篇和短篇,请选择:`,
144
+ hideCustomInput: true,
145
+ options: lengthChoices,
146
+ }],
147
+ });
148
+ const lenAnswer = lenResult.answers[0]?.selected?.[0] ?? lenResult.answers[0]?.custom;
149
+ if (!lenAnswer || lenAnswer === '取消') {
150
+ notify(session, '🚫 已取消');
151
+ return { kind: 'success', text: '' };
152
+ }
153
+ length = (lenAnswer === '长篇') ? 'long' : 'short';
154
+ } else {
155
+ length = 'long';
156
+ }
157
+ }
158
+
159
+ notify(session, `⏳ 正在采集 ${platformLabel(platform)}(${length === 'long' ? '长篇' : '短篇'})...`);
160
+
161
+ // Step 3: 调用采集脚本
162
+ const scriptPath = join(__dirname, 'scripts', 'run-scan.js');
163
+ const { ok, output, error } = await runScript('node', [scriptPath, '--project-root', projectRoot, '--platform', platform, '--length', length]);
164
+
165
+ if (!ok) {
166
+ notify(session, '❌ 采集失败:' + (error || '未知错误'));
167
+ } else {
168
+ // 解析输出中的文件列表
169
+ let files = [];
170
+ try { files = JSON.parse(output).files || []; } catch (_) {}
171
+ const fileList = files.length > 0 ? '\n\n📁 生成文件:\n' + files.map(f => ' - ' + f).join('\n') : '';
172
+ notify(session, `✅ ${platformLabel(platform)}(${length === 'long' ? '长篇' : '短篇'})采集完成${fileList}\n\n如需扫其他平台,请再次调用 /fancy-scan`);
173
+ }
174
+
175
+ return { kind: 'success', text: '' };
176
+ }
177
+ });
178
+ }
179
+
180
+ function runScript(cmd, args) {
181
+ return new Promise((resolve) => {
182
+ const proc = require('child_process').spawn(cmd, args, { timeout: 120000, stdio: ['ignore', 'pipe', 'pipe'] });
183
+ const outParts = [], errParts = [];
184
+ proc.stdout.on('data', (d) => { outParts.push(d.toString()); });
185
+ proc.stderr.on('data', (d) => { errParts.push(d.toString()); });
186
+ proc.on('close', (code) => {
187
+ const stdout = outParts.join(''), stderr = errParts.join('');
188
+ try {
189
+ const parsed = JSON.parse(stdout);
190
+ resolve({ ok: parsed.ok === true, output: stdout, error: parsed.error ? (parsed.error.message || JSON.stringify(parsed.error)) : stderr || undefined });
191
+ } catch (_) {
192
+ resolve({ ok: false, output: stdout, error: stderr || ('exit ' + code) });
193
+ }
194
+ });
195
+ proc.on('error', (err) => resolve({ ok: false, output: '', error: err.message }));
196
+ });
197
+ }
@@ -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
+ | 全平台 | 起点+番茄+七猫默认组合 |
@@ -0,0 +1,115 @@
1
+ # 平台 × 篇幅 → Scraper 映射表
2
+
3
+ ## 平台速查
4
+
5
+ | 平台 | 中文 | 默认篇幅 | 支持长篇? | 支持短篇? |
6
+ |------|------|---------|-----------|-----------|
7
+ | `qidian` | 起点 | 长篇 | ✅ | ❌ |
8
+ | `fanqie` | 番茄 | 长篇 | ✅ | ❌ |
9
+ | `jinjiang` | 晋江 | 长篇 | ✅ | ❌ |
10
+ | `zhihu` | 知乎 | 需选择 | ✅ | ✅ |
11
+ | `dianzhong` | 点众 | 短篇 | ❌ | ✅ |
12
+ | `qimao` | 七猫 | 需选择 | ✅ | ✅ |
13
+
14
+ ---
15
+
16
+ ## Chrome CDP 环境要求
17
+
18
+ > ⚠️ **除 qidian 外,其他所有 scraper 都需要先启动 Chrome CDP**:
19
+ > ```bash
20
+ > node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js --yes
21
+ > ```
22
+ > qidian 默认走 mobile-SSR(不需要 Chrome),失败才降级 CDP。
23
+
24
+ ---
25
+
26
+ ## Scraper 映射
27
+
28
+ ### 起点(qidian)
29
+
30
+ | 篇幅 | Scraper | 采集模式 | 示例命令 |
31
+ |------|---------|---------|---------|
32
+ | 长篇 | qidian-rank-scraper.js | **mobile-SSR 优先**(不需要 Chrome)| `node qidian-rank-scraper.js --type hotsales --outdir {out}` |
33
+
34
+ ### 番茄(fanqie)
35
+
36
+ | 篇幅 | Scraper | 采集模式 | 示例命令 |
37
+ |------|---------|---------|---------|
38
+ | 长篇 | fanqie-rank-scraper.js | CDP(需要 Chrome)| `node fanqie-rank-scraper.js --channel all --type all --outdir {out}` |
39
+
40
+ ### 晋江(jinjiang)
41
+
42
+ | 篇幅 | Scraper | 采集模式 | 示例命令 |
43
+ |------|---------|---------|---------|
44
+ | 长篇 | jjwxc-rank-scraper.js | CDP(需要 Chrome)| `node jjwxc-rank-scraper.js --type 12 --outdir {out}` |
45
+
46
+ ### 知乎(zhihu)
47
+
48
+ | 篇幅 | Scraper | 采集模式 | 示例命令 |
49
+ |------|---------|---------|---------|
50
+ | 长篇 | zhihu-rank-scraper.js | CDP(需要 Chrome)| `node zhihu-rank-scraper.js --length long --outdir {out}` |
51
+ | 短篇 | zhihu-rank-scraper.js | CDP(需要 Chrome)| `node zhihu-rank-scraper.js --length short --outdir {out}` |
52
+
53
+ 入口 URL:`https://www.zhihu.com/fiore/h5/vip-web`
54
+
55
+ - 短篇自动点击 4 个 tab:推荐榜 / 热搜榜 / 热度榜 / 口碑榜
56
+ - 长篇自动点击 1 个 tab:长篇榜
57
+
58
+ > ⚠️ 知乎**需要登录**才能看完整榜单。先在 Chrome 中手动登录 zhihu.com 再采集。
59
+
60
+ ### 点众(dianzhong)
61
+
62
+ | 篇幅 | Scraper | 采集模式 | 示例命令 |
63
+ |------|---------|---------|---------|
64
+ | 短篇 | dz-browse-scraper.js | CDP(需要 Chrome)| `node dz-browse-scraper.js --channel all --outdir {out}` |
65
+
66
+ ### 七猫(qimao)
67
+
68
+ | 篇幅 | Scraper | 采集模式 | 示例命令 |
69
+ |------|---------|---------|---------|
70
+ | 长篇 | qimao-rank-scraper.js | CDP(需要 Chrome)| `node qimao-rank-scraper.js --type hot --period day --outdir {out}` |
71
+ | 短篇 | qimao-rank-scraper.js | CDP(需要 Chrome)| `node qimao-rank-scraper.js --type hot --period day --gender f --outdir {out}` |
72
+
73
+ ---
74
+
75
+ ## 长短篇判断规则
76
+
77
+ **如果用户只写了 `qimao` 或 `zhihu` 而没有写 `long`/`short`**:
78
+ - 主会话**必须**用 clarify 弹出:`{平台中文名} 同时支持长篇和短篇,请输入 长篇/短篇`
79
+ - 禁止跳过此步骤
80
+
81
+ ---
82
+
83
+ ## 脚本路径约定
84
+
85
+ Scraper 脚本统一放在 `{SKILL_DIR}/references/` 下:
86
+
87
+ ```
88
+ fancy-scan/
89
+ ├── SKILL.md
90
+ └── references/
91
+ ├── run-scan.py ← Python 编排层
92
+ ├── scraper-registry.md ← 本文件
93
+ ├── scan-output-format.md
94
+ ├── cdp-utils.js
95
+ ├── qidian-rank-scraper.js
96
+ ├── fanqie-rank-scraper.js
97
+ ├── jjwxc-rank-scraper.js
98
+ ├── zhihu-rank-scraper.js
99
+ ├── dz-browse-scraper.js
100
+ └── qimao-rank-scraper.js
101
+ ```
102
+
103
+ > ⚠️ 如果 `{SKILL_DIR}/references/` 下没有某个 scraper,run-scan.py 回退到"内置知识"模式,输出占位符报告并标注 `[内置知识-未采集]`。
104
+
105
+ ---
106
+
107
+ ## 输出文件命名规范
108
+
109
+ `{平台}{榜单名称}_{YYYYMMDD}.md`
110
+
111
+ 例:
112
+ - `起点畅销榜_20260827.md`
113
+ - `番茄男频阅读榜_20260827.md`
114
+ - `七猫大热榜_20260827.md`
115
+ - `点众男频短篇_20260827.md`