@cloud411716/fancy-webnovel 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "main": "plugins/fancy-bootstrap/index.js",
6
6
  "exports": {
@@ -1,56 +1,30 @@
1
1
  /**
2
2
  * registry.js — 合并注册所有命令
3
3
  * fancy-bootstrap + fancy-scan
4
+ *
5
+ * 所有消息模板 → scripts/templates.js
6
+ * 所有基础设施 → scripts/infra.js
4
7
  */
5
8
  import { fileURLToPath } from 'url';
6
9
  import { dirname, join } from 'path';
7
10
  import { existsSync } from 'fs';
11
+ import { notify, llmFollowup, spawnScript } from './scripts/infra.js';
12
+ import { scan, bootstrap, PLATFORM_TABLE, platformLabel } from './scripts/templates.js';
8
13
 
9
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const SCRIPTS_DIR = join(__dirname, 'scripts');
10
16
 
11
17
  // ---------------------------------------------------------------------------
12
- // fancy-bootstrap
18
+ // 路径工具
13
19
  // ---------------------------------------------------------------------------
14
- const BOOTSTRAP_DIR = __dirname; // registry.js is inside plugins/fancy-bootstrap/
15
- const BOOTSTRAP_SCRIPTS_DIR = join(BOOTSTRAP_DIR, 'scripts');
16
-
17
- function notifyBootstrap(session, text) {
18
- queueMicrotask(() => {
19
- session.append(
20
- 'user/message',
21
- { content: [{ type: 'text', text }], source: { kind: 'user' } },
22
- { surfaceOp: 'append' }
23
- );
24
- });
25
- }
26
20
 
27
21
  function isValidPath(p) {
28
- return !p.includes('..') && !/[<>:"|?*[\x00-\x1f]/.test(p) && !/\s/.test(p);
22
+ return !p.includes('..') && !/[<>:"'|?*[\x00-\x1f]/.test(p) && !/\s/.test(p);
29
23
  }
30
24
 
31
- function runBootstrapScript(projectRoot) {
32
- return new Promise(async (resolve) => {
33
- const initScript = join(BOOTSTRAP_SCRIPTS_DIR, 'init.js');
34
- const { spawn } = await import('child_process');
35
- const proc = spawn(
36
- 'node', [initScript, projectRoot],
37
- { timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] }
38
- );
39
- const outParts = [], errParts = [];
40
- proc.stdout.on('data', (d) => { outParts.push(d.toString()); });
41
- proc.stderr.on('data', (d) => { errParts.push(d.toString()); });
42
- proc.on('close', (code) => {
43
- const stdout = outParts.join(''), stderr = errParts.join('');
44
- try {
45
- const parsed = JSON.parse(stdout);
46
- resolve({ ok: parsed.ok === true, output: stdout, error: parsed.error || stderr || undefined });
47
- } catch (_) {
48
- resolve({ ok: false, output: stdout, error: stderr || ('exit ' + code) });
49
- }
50
- });
51
- proc.on('error', (err) => resolve({ ok: false, output: '', error: err.message }));
52
- });
53
- }
25
+ // ---------------------------------------------------------------------------
26
+ // fancy-bootstrap
27
+ // ---------------------------------------------------------------------------
54
28
 
55
29
  async function applyBootstrap(ctx) {
56
30
  ctx.commands.register({
@@ -61,65 +35,61 @@ async function applyBootstrap(ctx) {
61
35
  const rawInput = invocation.rawInput ? invocation.rawInput.trim() : '';
62
36
 
63
37
  let projectRoot = rawInput;
38
+
39
+ // 无参数 → 弹窗
64
40
  if (!projectRoot) {
65
41
  const result = await ctx.userQuestions.ask({
66
- questions: [{
67
- id: 'path',
68
- question: '📂 请输入项目根路径 ( 或者输入 . 代表当前路径 )',
69
- }],
42
+ questions: [bootstrap.askPath()],
70
43
  });
71
- projectRoot = result.answers[0]?.custom ? result.answers[0].custom.trim() : '';
44
+ projectRoot = result.answers[0]?.custom?.trim() || '';
72
45
  }
73
46
 
74
47
  if (!projectRoot) {
75
- notifyBootstrap(session, '⚠️ 未提供路径,已取消');
48
+ notify(session, bootstrap.notify({ type: 'no_path' }));
76
49
  return { kind: 'success', text: '' };
77
50
  }
78
51
 
79
52
  if (projectRoot === '.') {
80
53
  projectRoot = process.cwd();
81
- notifyBootstrap(session, `📂 使用当前目录:${projectRoot}`);
54
+ notify(session, bootstrap.notify({ type: 'using_cwd', projectRoot }));
82
55
  }
83
56
 
84
- if (projectRoot.includes('..') || /[<>:"'|?*[\x00-\x1f]/.test(projectRoot) || /\s/.test(projectRoot)) {
85
- notifyBootstrap(session, '❌ 路径不合法(不能含空格、.. 或特殊字符)');
57
+ if (!isValidPath(projectRoot)) {
58
+ notify(session, bootstrap.notify({ type: 'invalid_path' }));
86
59
  return { kind: 'success', text: '' };
87
60
  }
88
61
 
89
62
  if (existsSync(join(projectRoot, '.fancy-deployed'))) {
90
- notifyBootstrap(session, '⚠️ 该目录已初始化(.fancy-deployed 已存在)');
63
+ notify(session, bootstrap.notify({ type: 'already_initialized' }));
91
64
  return { kind: 'success', text: '' };
92
65
  }
93
66
 
94
67
  const confirm = await ctx.userQuestions.ask({
95
68
  questions: [{
96
69
  id: 'confirm',
97
- question: '❓ 确认将以下路径作为项目根?',
70
+ question: bootstrap.notify({ type: 'confirm', projectRoot }),
98
71
  detail: projectRoot,
99
72
  hideCustomInput: true,
100
- options: [
101
- { label: '确认创建', description: '开始在此目录初始化项目' },
102
- { label: '取消', description: '取消本次操作' },
103
- ],
73
+ options: bootstrap.confirmOptions(),
104
74
  }],
105
75
  });
106
76
  const ans = confirm.answers[0];
107
77
  const chosen = ans?.selected?.[0] ?? ans?.custom;
108
78
  if (chosen !== '确认创建') {
109
- notifyBootstrap(session, '🚫 已取消');
79
+ notify(session, bootstrap.notify({ type: 'cancelled' }));
110
80
  return { kind: 'success', text: '' };
111
81
  }
112
82
 
113
- notifyBootstrap(session, `⏳ 正在初始化 ${projectRoot}...`);
114
- const result = await runBootstrapScript(projectRoot);
83
+ notify(session, bootstrap.notify({ type: 'initializing', projectRoot }));
84
+ const result = await spawnScript('node', [join(SCRIPTS_DIR, 'init.js'), projectRoot], { timeout: 30000 });
115
85
 
116
86
  if (result.ok) {
117
- notifyBootstrap(session, '✅ 项目初始化完成!\n下一步:运行 /fancy-scan 扫榜');
87
+ notify(session, bootstrap.notify({ type: 'success' }));
118
88
  } else {
119
- const errMsg = (typeof result.error === 'object' && result.error !== null)
89
+ const err = (typeof result.error === 'object' && result.error !== null)
120
90
  ? (result.error.message || JSON.stringify(result.error))
121
91
  : String(result.error || '未知错误');
122
- notifyBootstrap(session, '❌ 初始化失败:' + errMsg);
92
+ notify(session, bootstrap.notify({ type: 'error', err }));
123
93
  }
124
94
 
125
95
  return { kind: 'success', text: '' };
@@ -130,56 +100,6 @@ async function applyBootstrap(ctx) {
130
100
  // ---------------------------------------------------------------------------
131
101
  // fancy-scan
132
102
  // ---------------------------------------------------------------------------
133
- const SCAN_DIR = join(dirname(__dirname), 'fancy-scan'); // from plugins/fancy-bootstrap/ up to plugins/, then fancy-scan
134
- const SCAN_SCRIPTS_DIR = join(SCAN_DIR, 'scripts');
135
-
136
- const PLATFORM_TABLE = [
137
- ['1', 'qidian', '长篇', '起点'],
138
- ['2', 'fanqie', '长篇', '番茄'],
139
- ['3', 'jinjiang', '长篇', '晋江'],
140
- ['4', 'zhihu', '长/短','知乎'],
141
- ['5', 'dianzhong','短篇','点众'],
142
- ['6', 'qimao', '长/短','七猫'],
143
- ];
144
-
145
- function platformLabel(platform) {
146
- const row = PLATFORM_TABLE.find(r => r[1] === platform);
147
- return row ? row[3] : platform;
148
- }
149
-
150
- function notifyScan(session, text) {
151
- queueMicrotask(() => {
152
- session.append(
153
- 'user/message',
154
- { content: [{ type: 'text', text }], source: { kind: 'user' } },
155
- { surfaceOp: 'append' }
156
- );
157
- });
158
- }
159
-
160
- function runScanScript(projectRoot, platform, length) {
161
- return new Promise(async (resolve) => {
162
- const scriptPath = join(SCAN_SCRIPTS_DIR, 'run-scan.js');
163
- const { spawn } = await import('child_process');
164
- const proc = spawn(
165
- 'node', [scriptPath, '--project-root', projectRoot, '--platform', platform, '--length', length],
166
- { timeout: 120000, stdio: ['ignore', 'pipe', 'pipe'] }
167
- );
168
- const outParts = [], errParts = [];
169
- proc.stdout.on('data', (d) => { outParts.push(d.toString()); });
170
- proc.stderr.on('data', (d) => { errParts.push(d.toString()); });
171
- proc.on('close', (code) => {
172
- const stdout = outParts.join(''), stderr = errParts.join('');
173
- try {
174
- const parsed = JSON.parse(stdout);
175
- resolve({ ok: parsed.ok === true, output: stdout, error: parsed.error ? (parsed.error.message || JSON.stringify(parsed.error)) : stderr || undefined });
176
- } catch (_) {
177
- resolve({ ok: false, output: stdout, error: stderr || ('exit ' + code) });
178
- }
179
- });
180
- proc.on('error', (err) => resolve({ ok: false, output: '', error: err.message }));
181
- });
182
- }
183
103
 
184
104
  async function applyScan(ctx) {
185
105
  ctx.commands.register({
@@ -194,7 +114,7 @@ async function applyScan(ctx) {
194
114
  if (!projectRoot) projectRoot = process.cwd();
195
115
 
196
116
  if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
197
- notifyScan(session, '⚠️ 当前目录尚未初始化。请先运行 /fancy-bootstrap 进行初始化。');
117
+ notify(session, scan.notify({ type: 'not_initialized' }));
198
118
  return { kind: 'success', text: '' };
199
119
  }
200
120
 
@@ -218,46 +138,39 @@ async function applyScan(ctx) {
218
138
  }
219
139
  }
220
140
 
141
+ // 参数不全 → 弹窗选择
221
142
  if (!platform || !length || !PLATFORM_TABLE.find(r => r[1] === platform)) {
222
- notifyScan(session, '📋 支持的平台:\n' +
223
- PLATFORM_TABLE.map(r => ` ${r[0]}. ${r[3]}(${r[2]})`).join('\n') +
224
- '\n\n- 长篇:关注 追读、月票、订阅 等指标\n- 短篇:关注 传播、完读率 等指标\n- 知乎和七猫同时支持长篇和短篇');
143
+ notify(session, scan.platformList());
225
144
 
226
145
  const choice = await ctx.userQuestions.ask({
227
- questions: [{ id: 'platform', question: '📊 请选择要扫的平台(输入编号 1-6):' }],
146
+ questions: [scan.askPlatform()],
228
147
  });
229
148
  const answer = choice.answers[0]?.custom?.trim();
230
149
  if (!answer) {
231
- notifyScan(session, '⚠️ 已取消。请再次调用 /fancy-scan 继续。');
150
+ notify(session, scan.notify({ type: 'cancelled_with_hint' }));
232
151
  return { kind: 'success', text: '' };
233
152
  }
234
153
  const numMatch = answer.match(/^(\d+)$/);
235
154
  const row = numMatch ? PLATFORM_TABLE.find(r => r[0] === numMatch[1]) : null;
236
155
  if (!row) {
237
- notifyScan(session, '❌ 无效选择,请输入 1-6 的编号。');
156
+ notify(session, scan.notify({ type: 'invalid_choice' }));
238
157
  return { kind: 'success', text: '' };
239
158
  }
240
159
  platform = row[1];
241
- length = (platform === 'zhihu' || platform === 'qimao') ? null : (row[2] === '长篇' ? 'long' : 'short');
160
+ length = (platform === 'zhihu' || platform === 'qimao')
161
+ ? null
162
+ : (row[2] === '长篇' ? 'long' : 'short');
242
163
  }
243
164
 
244
165
  // 知乎/七猫二次选择篇幅
245
166
  if (platform === 'zhihu' || platform === 'qimao') {
246
167
  if (!length || !['long', 'short'].includes(length)) {
247
168
  const lenResult = await ctx.userQuestions.ask({
248
- questions: [{
249
- id: 'length',
250
- question: `${platformLabel(platform)} 同时支持长篇和短篇,请选择:`,
251
- hideCustomInput: true,
252
- options: [
253
- { label: '长篇', description: '' },
254
- { label: '短篇', description: '' },
255
- ],
256
- }],
169
+ questions: [scan.askLength(platform)],
257
170
  });
258
171
  const lenAnswer = lenResult.answers[0]?.selected?.[0] ?? lenResult.answers[0]?.custom;
259
172
  if (!lenAnswer || lenAnswer === '取消') {
260
- notifyScan(session, '🚫 已取消');
173
+ notify(session, scan.notify({ type: 'cancelled' }));
261
174
  return { kind: 'success', text: '' };
262
175
  }
263
176
  length = (lenAnswer === '长篇') ? 'long' : 'short';
@@ -266,56 +179,44 @@ async function applyScan(ctx) {
266
179
  length = 'long';
267
180
  }
268
181
 
269
- notifyScan(session, `⏳ 正在采集 ${platformLabel(platform)}(${length === 'long' ? '长篇' : '短篇'})...`);
182
+ notify(session, scan.notify({ type: 'collecting', platform, length }));
270
183
 
271
- const result = await runScanScript(projectRoot, platform, length);
184
+ const scriptResult = await spawnScript(
185
+ 'node',
186
+ [join(SCRIPTS_DIR, '..', 'fancy-scan', 'scripts', 'run-scan.js'),
187
+ '--project-root', projectRoot, '--platform', platform, '--length', length],
188
+ { timeout: 120000 }
189
+ );
272
190
 
273
- if (!result.ok) {
274
- const errMsg = (typeof result.error === 'object' && result.error !== null)
275
- ? (result.error.message || JSON.stringify(result.error))
276
- : String(result.error || '未知错误');
277
- notifyScan(session, '❌ 采集失败:' + errMsg);
191
+ if (!scriptResult.ok) {
192
+ const err = (typeof scriptResult.error === 'object' && scriptResult.error !== null)
193
+ ? (scriptResult.error.message || JSON.stringify(scriptResult.error))
194
+ : String(scriptResult.error || '未知错误');
195
+ notify(session, scan.notify({ type: 'failed', err }));
278
196
  return { kind: 'success', text: '' };
279
197
  }
280
198
 
281
- // Step 7: 解析 receipt,发送分析任务给 LLM
199
+ // 解析 receipt
282
200
  let receipt;
283
- try { receipt = JSON.parse(result.output); } catch (_) {
284
- notifyScan(session, ' 采集结果解析失败');
201
+ try { receipt = JSON.parse(scriptResult.output); } catch (_) {
202
+ notify(session, scan.notify({ type: 'parse_error' }));
285
203
  return { kind: 'success', text: '' };
286
204
  }
287
205
 
288
206
  const files = receipt.scan_files || [];
289
- const reportFile = receipt.report_file || '';
290
207
  const topicFile = receipt.topic_decision || '';
291
- const fileList = files.map(f => '- ' + f.replace(projectRoot + '/', '')).join('\n');
292
-
293
- // Step 8-9: 通知 LLM 执行分析
294
- notifyScan(session,
295
- `📊 采集完成,正在将数据交给 LLM 进行分析...\n` +
296
- `📁 生成文件:\n${fileList}\n` +
297
- `📄 报告:${reportFile.replace(projectRoot + '/', '')}\n` +
298
- `💡 分析报告将写入:${topicFile.replace(projectRoot + '/', '')}`
299
- );
300
208
 
301
- // 发消息给 LLM,让它读取原始文件并生成分析
302
- const analysisPrompt =
303
- `## 扫榜数据分析任务\n\n` +
304
- `平台:${platformLabel(platform)}(${length === 'long' ? '长篇' : '短篇'})\n` +
305
- `时间:${receipt.date || ''}\n\n` +
306
- `已生成的文件:\n${fileList}\n\n` +
307
- `请执行以下步骤:\n` +
308
- `1. 读取上述生成的原始数据文件(扫榜结果目录下的 .md 文件)\n` +
309
- `2. 分析爆款书籍的题材、卖点、节奏、人设等特征\n` +
310
- `3. 生成扫榜报告(包含:市场趋势、热门题材分析、用户画像、竞争度评估)\n` +
311
- `4. 给出 3-5 个可行的选题方向建议\n` +
312
- `5. 将完整分析报告追加写入:${topicFile}\n\n` +
313
- `报告要求:\n- 客观分析数据,不臆测\n- 选题建议要有差异化竞争力\n- 报告语言:中文`;
314
-
315
- invocation.agent.followup({
316
- content: [{ type: 'text', text: analysisPrompt }],
317
- source: { kind: 'user' }
209
+ // 通知用户 + 触发 LLM 分析
210
+ notify(session, scan.notify({ type: 'handover', platform, length, files, topicFile, projectRoot }));
211
+
212
+ const prompt = scan.llmAnalysisPrompt({
213
+ platform, length,
214
+ date: receipt.date || '',
215
+ files,
216
+ topicFile,
217
+ projectRoot,
318
218
  });
219
+ llmFollowup(invocation, prompt);
319
220
 
320
221
  return { kind: 'success', text: '' };
321
222
  }
@@ -323,8 +224,9 @@ async function applyScan(ctx) {
323
224
  }
324
225
 
325
226
  // ---------------------------------------------------------------------------
326
- // 导出合并的 apply
227
+ // 导出
327
228
  // ---------------------------------------------------------------------------
229
+
328
230
  export const inject = ['commands', 'userQuestions'];
329
231
 
330
232
  export async function apply(ctx) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * infra.js — DSH 插件核心基础设施
3
+ *
4
+ * 提供:
5
+ * notify(session, text) — 打印消息给用户(不触发 LLM)
6
+ * llmFollowup(invocation, prompt) — 发送消息给 LLM,让 LLM 继续处理
7
+ * spawnScript(cmd, args, opts) — spawn 子进程,解析 stdout JSON
8
+ *
9
+ * 所有 fancy-* 插件均直接 import 此文件。
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // notify — 打印消息给用户(queueMicrotask 避免打断 LLM 输出顺序)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export function notify(session, text) {
17
+ queueMicrotask(() => {
18
+ session.append(
19
+ 'user/message',
20
+ { content: [{ type: 'text', text }], source: { kind: 'user' } },
21
+ { surfaceOp: 'append' }
22
+ );
23
+ });
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // llmFollowup — 发送消息给 LLM,让 LLM 在同一 session 继续处理
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export function llmFollowup(invocation, content) {
31
+ const message = typeof content === 'string'
32
+ ? { content: [{ type: 'text', text: content }], source: { kind: 'user' } }
33
+ : content; // 允许传入完整消息对象
34
+ invocation.agent.followup(message);
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // spawnScript — spawn 子进程,stdout 只收集 JSON,stderr 单独收集
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export function spawnScript(cmd, args, { timeout = 30000, cwd } = {}) {
42
+ return new Promise(async (resolve) => {
43
+ const { spawn } = await import('child_process');
44
+ const proc = spawn(cmd, args, { timeout, cwd, stdio: ['ignore', 'pipe', 'pipe'] });
45
+ const outParts = [], errParts = [];
46
+ proc.stdout.on('data', (d) => { outParts.push(d.toString()); });
47
+ proc.stderr.on('data', (d) => { errParts.push(d.toString()); });
48
+ proc.on('close', (code) => {
49
+ const stdout = outParts.join(''), stderr = errParts.join('');
50
+ try {
51
+ const parsed = JSON.parse(stdout);
52
+ resolve({
53
+ ok: parsed.ok === true,
54
+ output: stdout,
55
+ error: parsed.error
56
+ ? (parsed.error.message || JSON.stringify(parsed.error))
57
+ : stderr || undefined,
58
+ });
59
+ } catch (_) {
60
+ resolve({ ok: false, output: stdout, error: stderr || ('exit ' + code) });
61
+ }
62
+ });
63
+ proc.on('error', (err) => resolve({ ok: false, output: '', error: err.message }));
64
+ });
65
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * templates.js — 所有插件模板字符串
3
+ *
4
+ * 包括:
5
+ * bootstrap 提示消息
6
+ * scan 提示消息
7
+ * LLM 分析 prompt 模板
8
+ *
9
+ * 原则:所有展示给用户的文字、发送给 LLM 的 prompt
10
+ * 均从此文件导出,插件主代码只做逻辑和拼装。
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // 共享常量
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export const PLATFORM_TABLE = [
18
+ ['1', 'qidian', '长篇', '起点'],
19
+ ['2', 'fanqie', '长篇', '番茄'],
20
+ ['3', 'jinjiang', '长篇', '晋江'],
21
+ ['4', 'zhihu', '长/短','知乎'],
22
+ ['5', 'dianzhong','短篇', '点众'],
23
+ ['6', 'qimao', '长/短','七猫'],
24
+ ];
25
+
26
+ export function platformLabel(platform) {
27
+ const row = PLATFORM_TABLE.find(r => r[1] === platform);
28
+ return row ? row[3] : platform;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // fancy-bootstrap 模板
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export const bootstrap = {
36
+ askPath(question = '📂 请输入项目根路径 ( 或者输入 . 代表当前路径 )') {
37
+ return { question };
38
+ },
39
+
40
+ notify({ type, projectRoot }) {
41
+ switch (type) {
42
+ case 'no_path':
43
+ return '⚠️ 未提供路径,已取消';
44
+ case 'using_cwd':
45
+ return `📂 使用当前目录:${projectRoot}`;
46
+ case 'invalid_path':
47
+ return '❌ 路径不合法(不能含空格、.. 或特殊字符)';
48
+ case 'already_initialized':
49
+ return '⚠️ 该目录已初始化(.fancy-deployed 已存在)';
50
+ case 'confirm':
51
+ return `❓ 确认将以下路径作为项目根?\n${projectRoot}`;
52
+ case 'cancelled':
53
+ return '🚫 已取消';
54
+ case 'initializing':
55
+ return `⏳ 正在初始化 ${projectRoot}...`;
56
+ case 'success':
57
+ return '✅ 项目初始化完成!\n下一步:运行 /fancy-scan 扫榜';
58
+ case 'error':
59
+ return (err) => '❌ 初始化失败:' + err;
60
+ default:
61
+ return String(type);
62
+ }
63
+ },
64
+
65
+ confirmOptions() {
66
+ return [
67
+ { label: '确认创建', description: '开始在此目录初始化项目' },
68
+ { label: '取消', description: '取消本次操作' },
69
+ ];
70
+ },
71
+ };
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // fancy-scan 模板
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export const scan = {
78
+ platformList() {
79
+ return (
80
+ '📋 支持的平台:\n' +
81
+ PLATFORM_TABLE.map(r => ` ${r[0]}. ${r[3]}(${r[2]})`).join('\n') +
82
+ '\n\n- 长篇:关注 追读、月票、订阅 等指标\n' +
83
+ '- 短篇:关注 传播、完读率 等指标\n' +
84
+ '- 知乎和七猫同时支持长篇和短篇'
85
+ );
86
+ },
87
+
88
+ askPlatform() {
89
+ return { question: '📊 请选择要扫的平台(输入编号 1-6):' };
90
+ },
91
+
92
+ askLength(platform) {
93
+ return {
94
+ question: `${platformLabel(platform)} 同时支持长篇和短篇,请选择:` ,
95
+ hideCustomInput: true,
96
+ options: [
97
+ { label: '长篇', description: '' },
98
+ { label: '短篇', description: '' },
99
+ ],
100
+ };
101
+ },
102
+
103
+ notify({ type, platform, length, files, receipt, topicFile, projectRoot, err }) {
104
+ const lenStr = length === 'long' ? '长篇' : '短篇';
105
+ const pLabel = platformLabel(platform);
106
+
107
+ switch (type) {
108
+ case 'not_initialized':
109
+ return '⚠️ 当前目录尚未初始化。请先运行 /fancy-bootstrap 进行初始化。';
110
+
111
+ case 'invalid_choice':
112
+ return '❌ 无效选择,请输入 1-6 的编号。';
113
+
114
+ case 'cancelled':
115
+ return '🚫 已取消';
116
+
117
+ case 'cancelled_with_hint':
118
+ return '⚠️ 已取消。请再次调用 /fancy-scan 继续。';
119
+
120
+ case 'collecting':
121
+ return `⏳ 正在采集 ${pLabel}(${lenStr})...`;
122
+
123
+ case 'scan_complete':
124
+ if (!files || files.length === 0) return `✅ ${pLabel}(${lenStr})采集完成`;
125
+ const fileList = files.map(f => ' - ' + f.replace(projectRoot + '/', '')).join('\n');
126
+ return `✅ ${pLabel}(${lenStr})采集完成\n\n📁 生成文件:\n${fileList}`;
127
+
128
+ case 'failed':
129
+ return '❌ 采集失败:' + err;
130
+
131
+ case 'parse_error':
132
+ return '❌ 采集结果解析失败';
133
+
134
+ case 'handover':
135
+ return (
136
+ `📊 采集完成,正在将数据交给 LLM 进行分析...\n` +
137
+ `📁 生成文件:\n${(files || []).map(f => '- ' + f.replace(projectRoot + '/', '')).join('\n')}\n` +
138
+ `💡 分析报告将写入:${(topicFile || '').replace(projectRoot + '/', '')}`
139
+ );
140
+
141
+ default:
142
+ return String(type);
143
+ }
144
+ },
145
+
146
+ /**
147
+ * 发送给 LLM 的分析 prompt 模板
148
+ * @param {object} params
149
+ * @param params.platform
150
+ * @param params.length
151
+ * @param params.date
152
+ * @param params.files string[] — 完整绝对路径列表
153
+ * @param params.topicFile string — topic_decision 文件完整路径
154
+ * @param params.projectRoot string
155
+ */
156
+ llmAnalysisPrompt({ platform, length, date, files, topicFile, projectRoot }) {
157
+ const pLabel = platformLabel(platform);
158
+ const lenStr = length === 'long' ? '长篇' : '短篇';
159
+ const fileList = (files || [])
160
+ .map(f => '- ' + f.replace(projectRoot + '/', ''))
161
+ .join('\n');
162
+
163
+ return [
164
+ `## 扫榜数据分析任务`,
165
+ ``,
166
+ `平台:${pLabel}(${lenStr})`,
167
+ `时间:${date || ''}`,
168
+ ``,
169
+ `已生成的文件:`,
170
+ fileList,
171
+ ``,
172
+ `请执行以下步骤:`,
173
+ `1. 读取上述生成的原始数据文件(扫榜结果目录下的 .md 文件)`,
174
+ `2. 分析爆款书籍的题材、卖点、节奏、人设等特征`,
175
+ `3. 生成扫榜报告(包含:市场趋势、热门题材分析、用户画像、竞争度评估)`,
176
+ `4. 给出 3-5 个可行的选题方向建议`,
177
+ `5. 将完整分析报告追加写入:${topicFile}`,
178
+ ``,
179
+ `报告要求:`,
180
+ `- 客观分析数据,不臆测`,
181
+ `- 选题建议要有差异化竞争力`,
182
+ `- 报告语言:中文`,
183
+ ].join('\n');
184
+ },
185
+ };
@@ -426,11 +426,6 @@ async function main() {
426
426
  console.error('=== 浏览器采集指令 END ===\n');
427
427
  }
428
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
429
  // Step 6: 收集原始数据
435
430
  const rawData = {};
436
431
  for (const f of scanFiles) {
@@ -449,10 +444,9 @@ async function main() {
449
444
  length,
450
445
  date: today,
451
446
  scan_files: scanFiles,
452
- report_file: reportFile,
453
447
  topic_decision: join(scanDir, `topic_decision_${today}.md`),
454
448
  raw_data: rawData,
455
- summary: `${PLATFORM_CN[platform]}(${length})采集完成,共 ${scanFiles.length} 个文件`,
449
+ summary: `${PLATFORM_CN[platform]}(${length === 'long' ? '长篇' : '短篇'})采集完成,共 ${scanFiles.length} 个文件`,
456
450
  };
457
451
 
458
452
  console.log(JSON.stringify(receipt));
@@ -1,3 +0,0 @@
1
- - insert:
2
- - id: fancy-bootstrap
3
- name: "@cloud411716/fancy-webnovel-bootstrap"
@@ -1,17 +0,0 @@
1
- /**
2
- * 路径工具
3
- */
4
- export function expandHome(pathStr) {
5
- if (pathStr.startsWith('~/') || pathStr === '~') {
6
- return pathStr === '~' ? (process.env.HOME ?? '/root') : pathStr.replace('~', process.env.HOME ?? '/root');
7
- }
8
- return pathStr;
9
- }
10
-
11
- export function resolvePath(pathStr) {
12
- return expandHome(pathStr).replace(/\\+$/, '');
13
- }
14
-
15
- export function joinPath(...parts) {
16
- return parts.join('/').replace(/\/+/g, '/');
17
- }
@@ -1,197 +0,0 @@
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
- }