@cloud411716/fancy-webnovel 0.3.16 → 1.0.0

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.
@@ -1,461 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 七猫小说排行榜采集脚本
4
- *
5
- * 使用 playwright-core 自己管理浏览器。
6
- * 采集策略:tab 切换男生榜/女生榜和榜单类型,滚动加载后从页面文本解析结构化数据。
7
- *
8
- * 用法:
9
- * node qimao-rank-scraper.js # 默认:扫全部(4个榜单)
10
- * node qimao-rank-scraper.js --channel male --type hot # 男生大热榜(默认日榜)
11
- * node qimao-rank-scraper.js --channel female --type new # 女生新书榜(默认日榜)
12
- * node qimao-rank-scraper.js --channel all --type all # 扫全部(含月榜)
13
- * node qimao-rank-scraper.js --login-wait 60 # 等待手动登录
14
- */
15
-
16
- const fs = require("fs");
17
- const path = require("path");
18
- const { chromium } = require("playwright-core");
19
- const { getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
20
-
21
- const RANK_URL = "https://www.qimao.com/paihang";
22
-
23
- const CHANNELS = [
24
- { id: "male", label: "男频", tab: "男生榜", path: "boy" },
25
- { id: "female", label: "女频", tab: "女生榜", path: "girl" },
26
- ];
27
-
28
- const RANK_TYPES = [
29
- { id: "hot", label: "大热榜", path: "hot" },
30
- { id: "new", label: "新书榜", path: "new" },
31
- { id: "finish", label: "完结榜", path: "over" },
32
- { id: "collect",label: "收藏榜", path: "collect" },
33
- { id: "update", label: "更新榜", path: "update" },
34
- ];
35
-
36
- const PERIODS = [
37
- { id: "day", label: "日榜", path: "date" },
38
- { id: "month", label: "月榜", path: "month" },
39
- ];
40
-
41
- // ---------------------------------------------------------------------------
42
- // 工具函数
43
- // ---------------------------------------------------------------------------
44
-
45
- const MAX_RETRIES = 3;
46
- const RETRY_DELAY_MS = 5000;
47
-
48
- function sleep(ms) {
49
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
50
- }
51
-
52
- /**
53
- * 重试包装器
54
- * @returns {{_failed:true, err, label}|*} 失败时返回 _failed=true 的标记对象
55
- */
56
- async function withRetry(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS, label = '') {
57
- let lastErr;
58
- for (let attempt = 1; attempt <= retries; attempt++) {
59
- try {
60
- return await fn();
61
- } catch (err) {
62
- lastErr = err;
63
- if (attempt < retries) {
64
- console.log(` ⏳ ${label} 第${attempt}次失败,${delayMs / 1000}s后重试...`);
65
- sleep(delayMs);
66
- }
67
- }
68
- }
69
- return { _failed: true, err: lastErr, label };
70
- }
71
-
72
- function rankUrl(channelId, rankTypeId, periodId) {
73
- const channel = CHANNELS.find(c => c.id === channelId);
74
- const rt = RANK_TYPES.find(r => r.id === rankTypeId);
75
- const period = PERIODS.find(p => p.id === (periodId || "day"));
76
- if (!channel || !rt || !period) return "";
77
- return `${RANK_URL}/${channel.path}/${rt.path}/${period.path}/`;
78
- }
79
-
80
- function isUsableBook(book) {
81
- return !!(
82
- book &&
83
- Number.isInteger(book.rank) &&
84
- book.rank > 0 &&
85
- book.title &&
86
- book.author &&
87
- !/^(上一页|下一页|跳转)$/.test(book.title) &&
88
- !/^(上一页|下一页|跳转|友情链接[::]?)$/.test(book.author)
89
- );
90
- }
91
-
92
- function cleanDesc(value) {
93
- const text = String(value || "")
94
- .replace(/\s*(?:飙升|上升|下降)\s*\d+\s*名\s*$/g, "")
95
- .replace(/\s*(?:上一页|下一页)\s*$/g, "")
96
- .replace(/\s+/g, " ").trim();
97
- if (text.length <= 100) return text;
98
- const cut = text.slice(0, 100);
99
- const sentence = cut.match(/[\s\S]*[。!?]/);
100
- return (sentence ? sentence[0] : cut) + "...";
101
- }
102
-
103
- function summarizeQuality(books, rawCount) {
104
- const linked = books.filter(b => b.url).length;
105
- const heated = books.filter(b => b.heat).length;
106
- const fieldCounts = [
107
- ["题材", "genre"], ["子分类", "subGenre"],
108
- ["状态", "status"], ["字数", "words"], ["热度", "heat"],
109
- ].map(([label, field]) => ({
110
- label, missing: books.filter(b => !b[field]).length,
111
- }));
112
- const problems = [];
113
- if (rawCount > books.length) problems.push(`移除无效/UI条目 ${rawCount - books.length} 条`);
114
- if (linked < books.length) problems.push(`作品页链接缺失 ${books.length - linked} 条`);
115
- for (const f of fieldCounts) { if (f.missing) problems.push(`${f.label}缺失 ${f.missing} 条`); }
116
- if (books.length < 15) problems.push(`[数据稀疏] 实际采集 ${books.length} 条`);
117
- return { linked, heated, problems, quality: problems.length ? "[存在问题]" : "[OK]" };
118
- }
119
-
120
- // 遍历 JSON 对象/数组,查找含 bookId + title/author 的数组
121
- function findBookArrays(obj, depth = 0) {
122
- if (!obj || depth > 8) return [];
123
- if (Array.isArray(obj)) {
124
- for (const item of obj) {
125
- if (item && typeof item === "object" && (item.bookId || item.novel_id || item.id)) {
126
- if (item.title || item.bookName || item.author || item.authorName) {
127
- return obj;
128
- }
129
- }
130
- }
131
- for (const item of obj) {
132
- const found = findBookArrays(item, depth + 1);
133
- if (found.length) return found;
134
- }
135
- return [];
136
- }
137
- if (typeof obj === "object") {
138
- for (const key of Object.keys(obj)) {
139
- const found = findBookArrays(obj[key], depth + 1);
140
- if (found.length) return found;
141
- }
142
- }
143
- return [];
144
- }
145
-
146
- // 从 xhrBookMap 中为一本 innerText 书匹配明文数据
147
- function findXhrMatch(book, xhrBookMap) {
148
- // 先用归一化书名直接匹配
149
- const norm = s => (s || "").replace(/\s+/g, "");
150
- for (const [id, data] of xhrBookMap) {
151
- if (norm(data.title) === norm(book.title)) {
152
- return { ...data, id };
153
- }
154
- }
155
- return null;
156
- }
157
-
158
- function findIdByTitle(title, xhrBookMap) {
159
- const norm = s => (s || "").replace(/\s+/g, "");
160
- for (const [id, data] of xhrBookMap) {
161
- if (norm(data.title) === norm(title)) return id;
162
- }
163
- return null;
164
- }
165
-
166
- // ---------------------------------------------------------------------------
167
- // 采集
168
- // ---------------------------------------------------------------------------
169
-
170
- async function scrapeRank(channelId, rankTypeId, periodId) {
171
- const ch = CHANNELS.find(c => c.id === channelId);
172
- const rt = RANK_TYPES.find(r => r.id === rankTypeId);
173
- const period = periodId ? PERIODS.find(p => p.id === periodId) : null;
174
- if (!ch || !rt) { console.log(" ⚠ 未知频道或榜单类型"); return null; }
175
-
176
- const url = rankUrl(channelId, rankTypeId, periodId);
177
- console.log(`\n→ 采集 七猫${ch.label}${rt.label}${period ? period.label : ""}...`);
178
-
179
- const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--start-maximized"] });
180
- const context = await browser.newContext();
181
- const page = await context.newPage();
182
-
183
- // CDP 强制窗口最大化
184
- try {
185
- const cdp = await context.newCDPSession(page);
186
- const { windowId } = await cdp.send('Browser.getWindowForTarget', { target: page.target() });
187
- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
188
- } catch (_) {}
189
-
190
- // 拦截 XHR 响应(只注册一次,放到 withRetry 外面)
191
- const xhrBookMap = new Map();
192
- page.on("response", async (resp) => {
193
- try {
194
- const respUrl = resp.url();
195
- const ct = (resp.headers()["content-type"] || "").toLowerCase();
196
- if (!ct.includes("json") && !respUrl.includes("/api/")) return;
197
- if (/\.(css|woff|jpg|png|ico)/.test(respUrl)) return;
198
- const body = await resp.text();
199
- if (!body || body.length < 100) return;
200
- let parsed;
201
- try { parsed = JSON.parse(body); } catch { return; }
202
- const found = findBookArrays(parsed);
203
- for (const b of found) {
204
- if (!b.bookId && !b.novel_id && !b.id) continue;
205
- const id = String(b.bookId || b.novel_id || b.id);
206
- xhrBookMap.set(id, {
207
- id,
208
- title: b.title || b.bookName || b.name || "",
209
- author: b.author || b.authorName || "",
210
- });
211
- }
212
- } catch (_) {}
213
- });
214
-
215
- // 单次采集+提取逻辑(包进 withRetry)
216
- const pageLabel = `${ch.label}${rt.label}${period ? period.label : ""}`;
217
- const doScrape = async () => {
218
- await page.goto(url, { waitUntil: "networkidle" });
219
- await page.waitForTimeout(3000);
220
-
221
- if (page.url().indexOf("qimao") === -1) {
222
- throw new Error(`非七猫页面(host=${page.url()}),可能被重定向`);
223
- }
224
-
225
- // 验证页面实际选中状态
226
- const observed = await page.evaluate(() => {
227
- function text(sel) {
228
- const e = document.querySelector(sel);
229
- return e ? (e.textContent || "").replace(/\s+/g, "").trim() : "";
230
- }
231
- return {
232
- path: location.pathname,
233
- channel: text(".qm-switch-tab .item.active"),
234
- rankType: text(".child-tabs-item.menu-tab.active"),
235
- period: text(".date-type-tabs .tab.active"),
236
- };
237
- });
238
-
239
- const expectedUrl = rankUrl(channelId, rankTypeId, periodId);
240
- const expectedPath = new URL(expectedUrl).pathname;
241
- const actualPath = String(observed.path || "").replace(/\/+$/, "/");
242
- const periodLabel = period ? period.label : "";
243
- if (
244
- actualPath !== expectedPath ||
245
- !(observed.channel || "").includes(ch.tab) ||
246
- observed.rankType !== rt.label ||
247
- (period && observed.period !== period.label)
248
- ) {
249
- throw new Error(
250
- `页面榜单不一致(请求 ${ch.tab}/${rt.label}/${periodLabel},` +
251
- `实际 ${observed.channel || "?"}/${observed.rankType || "?"}/${observed.period || "?"})`
252
- );
253
- }
254
-
255
- // 滚动加载
256
- for (let i = 0; i < 5; i++) {
257
- await page.evaluate(() => window.scrollBy(0, window.innerHeight));
258
- sleep(1000);
259
- }
260
- await page.waitForTimeout(500);
261
-
262
- // 提取书籍数据(文本解析)
263
- const rawBooks = await page.evaluate(() => {
264
- var text = document.body.innerText || "";
265
- var start = -1;
266
- ["日榜", "月榜"].forEach(m => { if (start < 0) start = text.indexOf(m); });
267
- if (start < 0) return [];
268
- var lines = text.substring(start).split(/\n/);
269
- var books = [];
270
- var cur = null;
271
- var fieldIdx = 0;
272
- for (var i = 0; i < lines.length; i++) {
273
- var line = lines[i].trim();
274
- if (!line) continue;
275
- if (/^(上一页|下一页|跳转|友情链接[::]?)$/.test(line)) { if (cur && cur.title) books.push(cur); cur = null; break; }
276
- if (/^\d{1,2}$/.test(line) && parseInt(line) < 100) {
277
- if (cur && cur.title) books.push(cur);
278
- cur = { rank: parseInt(line), title: "", author: "", genre: "", subGenre: "", status: "", words: "", heat: "", update: "", desc: "" };
279
- fieldIdx = 0; continue;
280
- }
281
- if (!cur) continue;
282
- if (/^(加入书架|立即阅读|蝉联|榜首)/.test(line)) continue;
283
- var hm = line.match(/([\d.]+)\s*万\s*热度/);
284
- if (hm) { cur.heat = hm[1] + "万"; continue; }
285
- if (line.indexOf("最近更新") === 0) { cur.update = line.replace(/^最近更新\s*/, ""); continue; }
286
- if (/^(连载中|已完结)$/.test(line)) { cur.status = line; continue; }
287
- if (/^[\d.]+万字$/.test(line)) { cur.words = line; continue; }
288
- if (fieldIdx === 0) { cur.title = line; fieldIdx = 1; continue; }
289
- if (fieldIdx === 1) { cur.author = line; fieldIdx = 2; continue; }
290
- if (fieldIdx === 2) { cur.genre = line; fieldIdx = 3; continue; }
291
- if (fieldIdx === 3) { cur.subGenre = line; fieldIdx = 4; continue; }
292
- cur.desc += (cur.desc ? " " : "") + line;
293
- }
294
- if (cur && cur.title) books.push(cur);
295
- return books;
296
- });
297
-
298
- if (!rawBooks.length) throw new Error("innerText 解析结果为空");
299
- return rawBooks;
300
- };
301
-
302
- const rawResult = await withRetry(doScrape, MAX_RETRIES, RETRY_DELAY_MS, `七猫${pageLabel}`);
303
-
304
- let books = null;
305
- if (rawResult._failed) {
306
- console.log(` ✗ 七猫${pageLabel}连续${MAX_RETRIES}次失败:${rawResult.err.message}`);
307
- await browser.close();
308
- return null;
309
- } else {
310
- const rawBooks = rawResult.filter(isUsableBook);
311
-
312
- // DOM 链接补全
313
- await page.evaluate(() => {
314
- if (window.__qimaoUrlMap) return;
315
- var urlMap = {};
316
- document.querySelectorAll('a[href*="/shuku/"]').forEach(a => {
317
- var m = a.href.match(/\/shuku\/(\d+)\//);
318
- if (!m) return;
319
- var id = m[1];
320
- var title = (a.innerText || '').trim();
321
- if (title && title.length < 50 && title.length > 0) {
322
- urlMap[title] = 'https://www.qimao.com/shuku/' + id + '/';
323
- }
324
- });
325
- window.__qimaoUrlMap = JSON.stringify(urlMap);
326
- });
327
- const rawUrlMap = await page.evaluate(() => window.__qimaoUrlMap || '{}');
328
- const urlMap = JSON.parse(rawUrlMap);
329
- const norm = s => (s || '').replace(/\s+/g, '');
330
- for (const b of rawBooks) {
331
- if (!b.url) {
332
- var found = Object.entries(urlMap).find(([t]) => norm(t) === norm(b.title));
333
- if (found) b.url = found[1];
334
- }
335
- }
336
-
337
- // XHR 明文覆盖
338
- if (xhrBookMap.size > 0) {
339
- let updated = 0;
340
- for (const b of rawBooks) {
341
- const xhrEntry = findXhrMatch(b, xhrBookMap);
342
- if (xhrEntry) {
343
- if (xhrEntry.title && xhrEntry.title !== b.title) {
344
- b.title = xhrEntry.title;
345
- updated++;
346
- }
347
- if (xhrEntry.author) b.author = xhrEntry.author;
348
- const id = xhrEntry.id || findIdByTitle(b.title, xhrBookMap);
349
- if (id) b.url = `https://www.qimao.com/shuku/${id}/`;
350
- }
351
- }
352
- console.log(` ✓ XHR 明文更新 ${updated}/${rawBooks.length} 本`);
353
- }
354
-
355
- books = rawBooks;
356
- }
357
-
358
- await browser.close();
359
-
360
- if (!books || !books.length) {
361
- console.error(`[qimao] 采集失败:页面结构可能已变。`);
362
- return null;
363
- }
364
-
365
- const summary = summarizeQuality(books, books.length);
366
- console.log(` ✓ 提取 ${books.length} 本(链接 ${summary.linked}/${books.length},热度 ${summary.heated}/${books.length})`);
367
-
368
- const periodLabel = period ? period.label : "";
369
- const now = new Date().toISOString();
370
- const lines = [
371
- `# 七猫 · ${ch.label} · ${rt.label}${periodLabel}`,
372
- "",
373
- `- 来源:${url}`,
374
- `- 抓取时间:${now}`,
375
- "",
376
- "---",
377
- "",
378
- ];
379
-
380
- for (const b of books) {
381
- lines.push(`书名:${b.title || ""}`);
382
- lines.push(`题材:${b.genre || ""}${b.subGenre ? " - " + b.subGenre : ""}`);
383
- lines.push(`作者:${b.author || ""}`);
384
- if (b.url) lines.push(`作品页:${b.url}`);
385
- lines.push("");
386
- }
387
-
388
- return lines.join("\n");
389
- }
390
-
391
-
392
- function buildTargets(channel, rankType, period) {
393
- const channels = channel === "all" ? CHANNELS.map(c => c.id) : [channel];
394
- const rankTypes = rankType === "all" ? RANK_TYPES.map(r => r.id) : [rankType];
395
- const targets = [];
396
- for (const channelId of channels) {
397
- for (const rankTypeId of rankTypes) {
398
- // hot 和 new 都用 day period(用户只扫大热榜日榜和新书榜日榜)
399
- if (rankTypeId === "hot" || rankTypeId === "new") {
400
- const periods = period === "all" ? PERIODS.map(p => p.id) : [period];
401
- for (const periodId of periods) {
402
- targets.push({ channel: channelId, rankType: rankTypeId, period: periodId });
403
- }
404
- } else {
405
- targets.push({ channel: channelId, rankType: rankTypeId, period: null });
406
- }
407
- }
408
- }
409
- return targets;
410
- }
411
-
412
- function outputFilename(channelId, rankTypeId, periodId, date) {
413
- const ch = CHANNELS.find(c => c.id === channelId);
414
- const rt = RANK_TYPES.find(r => r.id === rankTypeId);
415
- const period = periodId ? PERIODS.find(p => p.id === periodId) : null;
416
- return `七猫${ch.label}${rt.label}${period ? period.label : ""}_${date}.md`;
417
- }
418
-
419
- // ---------------------------------------------------------------------------
420
- // 入口
421
- // ---------------------------------------------------------------------------
422
-
423
- const args = process.argv.slice(2);
424
- const OUTDIR = getArg(args, "--outdir") || ".";
425
- const CHANNEL = getArg(args, "--channel") || "all";
426
- const RANKTYPE = getArg(args, "--type") || "all";
427
- const PERIOD = getArg(args, "--period") || "day";
428
- const LOGIN_WAIT = parseInt(getArg(args, "--login-wait") || "0", 10);
429
-
430
- async function main() {
431
- if (CHANNEL !== "all" && !CHANNELS.some(c => c.id === CHANNEL)) {
432
- throw new Error(`未知 --channel: ${CHANNEL}`);
433
- }
434
- if (RANKTYPE !== "all" && !RANK_TYPES.some(r => r.id === RANKTYPE)) {
435
- throw new Error(`未知 --type: ${RANKTYPE}`);
436
- }
437
- if (PERIOD !== "all" && !PERIODS.some(p => p.id === PERIOD)) {
438
- throw new Error(`未知 --period: ${PERIOD}`);
439
- }
440
- const targets = buildTargets(CHANNEL, RANKTYPE, PERIOD);
441
- let written = 0, failed = 0;
442
-
443
- for (const target of targets) {
444
- const content = await scrapeRank(target.channel, target.rankType, target.period);
445
- if (!content) { failed++; continue; }
446
- const date = localDateStamp();
447
- const filename = outputFilename(target.channel, target.rankType, target.period, date);
448
- fs.mkdirSync(OUTDIR, { recursive: true });
449
- fs.writeFileSync(path.join(OUTDIR, filename), content, "utf-8");
450
- written++;
451
- console.log(` ✓ 已保存: ${filename}`);
452
- }
453
-
454
- return { planned: targets.length, written, failed, partial: failed > 0, partialReasons: [] };
455
- }
456
-
457
- if (require.main === module) {
458
- runCli(main, "七猫采集");
459
- }
460
-
461
- module.exports = { rankUrl, isUsableBook, cleanDesc, buildTargets, outputFilename };
@@ -1,158 +0,0 @@
1
- /**
2
- * scan 模板 — 仅 fancy-scan 使用
3
- */
4
-
5
- // ---------------------------------------------------------------------------
6
- // 共享常量
7
- // ---------------------------------------------------------------------------
8
-
9
- export const PLATFORM_TABLE = [
10
- ['1', 'qidian', '起点'],
11
- ['2', 'fanqie', '番茄'],
12
- ['3', 'jinjiang', '晋江'],
13
- ['4', 'qimao', '七猫'],
14
- ];
15
-
16
- export function platformLabel(platform) {
17
- const row = PLATFORM_TABLE.find(r => r[1] === platform);
18
- return row ? row[2] : platform;
19
- }
20
-
21
- // ---------------------------------------------------------------------------
22
- // 各平台榜单选项({ id, label }[]),供弹窗多选用
23
- // ---------------------------------------------------------------------------
24
-
25
- export function rankOptions(platform) {
26
- switch (platform) {
27
- case 'qidian':
28
- return [
29
- { id: 'hotsales', label: '畅销榜' },
30
- { id: 'yuepiao', label: '月票榜' },
31
- { id: 'signnewbook', label: '签约作者新书榜' },
32
- { id: 'pubnewbook', label: '公众作者新书榜' },
33
- { id: 'newauthor', label: '新人作者新书榜' },
34
- { id: '__all__', label: '全选' },
35
- ];
36
-
37
- case 'fanqie':
38
- return [
39
- { id: '1_2', label: '男频阅读榜' },
40
- { id: '1_1', label: '男频新书榜' },
41
- { id: '0_2', label: '女频阅读榜' },
42
- { id: '0_1', label: '女频新书榜' },
43
- { id: '__all__', label: '全选' },
44
- ];
45
-
46
- case 'jinjiang':
47
- return [
48
- { id: '5', label: '月榜' },
49
- { id: '7', label: '总分榜' },
50
- { id: '4', label: '季度榜' },
51
- { id: '12', label: '收入金榜' },
52
- { id: '16', label: '完结金榜' },
53
- { id: '17', label: '新手金榜' },
54
- { id: '__all__', label: '全选' },
55
- ];
56
-
57
- case 'qimao':
58
- return [
59
- { id: 'male_hot', label: '男生大热榜' },
60
- { id: 'male_new', label: '男生新书榜' },
61
- { id: 'female_hot', label: '女生大热榜' },
62
- { id: 'female_new', label: '女生新书榜' },
63
- { id: '__all__', label: '全选' },
64
- ];
65
-
66
- default:
67
- return [];
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // 模板
73
- // ---------------------------------------------------------------------------
74
-
75
- export const scan = {
76
- platformList() {
77
- return (
78
- '📋 支持的平台:\n' +
79
- PLATFORM_TABLE.map(r => ` ${r[0]}. ${r[2]}`).join('\n')
80
- );
81
- },
82
-
83
- askPlatform() {
84
- return { question: '📊 请选择要扫的平台(输入编号 1-4):' };
85
- },
86
-
87
- askRankList(platform) {
88
- const opts = rankOptions(platform);
89
- return {
90
- question: `${platformLabel(platform)} 有多个榜单,支持多选。请选择要采集的榜单:`,
91
- hideCustomInput: true,
92
- multiSelect: true,
93
- options: opts.map(o => ({ id: o.id, label: o.label })),
94
- };
95
- },
96
-
97
- notify({ type, platform, files, topicFile, projectRoot, err }) {
98
- const pLabel = platformLabel(platform);
99
-
100
- switch (type) {
101
- case 'not_initialized':
102
- return '⚠️ 当前目录尚未初始化。请先运行 /fancy-bootstrap 进行初始化。';
103
-
104
- case 'invalid_choice':
105
- return '❌ 无效选择,请输入 1-4 的编号。';
106
-
107
- case 'cancelled':
108
- return '🚫 已取消。';
109
-
110
- case 'failed':
111
- return '❌ 采集失败:' + (err || '');
112
-
113
- case 'parse_error':
114
- return '❌ 采集结果解析失败。';
115
-
116
- case 'handover':
117
- return (
118
- `📊 采集完成,正在将数据交给 LLM 进行分析...\n` +
119
- `📁 生成文件:\n${(files || []).map(f => '- ' + f.replace(projectRoot + '/', '')).join('\n')}\n` +
120
- `💡 分析报告将写入:${(topicFile || '').replace(projectRoot + '/', '')}`
121
- );
122
-
123
- default:
124
- return String(type);
125
- }
126
- },
127
-
128
- llmAnalysisPrompt({ platform, date, files, topicFile, projectRoot }) {
129
- const pLabel = platformLabel(platform);
130
- const fileList = (files || [])
131
- .map(f => '- ' + f.replace(projectRoot + '/', ''))
132
- .join('\n');
133
-
134
- return [
135
- `## 扫榜数据分析任务`,
136
- ``,
137
- `平台:${pLabel}`,
138
- `时间:${date || ''}`,
139
- ``,
140
- `已生成的文件:`,
141
- fileList,
142
- ``,
143
- `请执行以下步骤:`,
144
- `0. **禁止**使用覆盖的方式写文件,**必须**使用追加的方式`,
145
- `1. 读取上述生成的原始数据文件`,
146
- `2. 分析爆款书籍的题材、卖点、节奏、人设等特征`,
147
- `3. 生成扫榜报告(包含:市场趋势、热门题材分析、用户画像、竞争度评估)`,
148
- `4. 给出 3-5 个可行的选题方向建议`,
149
- `5. 将完整分析报告追加写入:${topicFile}(如文件不存在则先新建再追加)`,
150
- ``,
151
- `报告要求:`,
152
- `- 客观分析数据,不臆测`,
153
- `- 选题建议要有差异化竞争力`,
154
- `- 报告语言:中文`,
155
- `- 以追加方式写入目标文件,不要覆盖现有内容`,
156
- ].join('\n');
157
- },
158
- };