@cloud411716/fancy-webnovel 2.2.0 → 3.0.3
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/README.md +65 -0
- package/client.js +201 -15
- package/cordis.patch.yml +5 -1
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +12 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +31 -7
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +145 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -1,111 +1,144 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scan
|
|
2
|
+
* scan 模板渲染层 —— 组装提示词 + 提供运行时 API
|
|
3
|
+
*
|
|
4
|
+
* ## 分层(与 fancy-story-builder 同构)
|
|
5
|
+
*
|
|
6
|
+
* prompts/01-analysis-prompt.md 提示词层:静态任务契约、报告结构、校验口径。
|
|
7
|
+
* 只描述"要什么"和"脚本退出码代表什么",
|
|
8
|
+
* 不包含任何直接写目标文件的命令。
|
|
9
|
+
* templates/platforms.js 模板层:平台/榜单/指标/数据形态的单一事实来源。
|
|
10
|
+
* templates/blocks.js 模板层:按平台生成的提示词动态段落。
|
|
11
|
+
* templates/report-schema.js 模板层:报告章节/阈值/表头契约(与校验脚本同源)。
|
|
12
|
+
* scripts/append-block.js 脚本层:预检 + 校验 + 写入 + 回滚(部署到工作区内)。
|
|
13
|
+
* scripts/report-validate.js 脚本层:正文机械校验(提示词与插件共用)。
|
|
14
|
+
*
|
|
15
|
+
* **LLM 只能通过部署到 `.fancy-tools/fancy-scan/` 的脚本写目标文件**;
|
|
16
|
+
* 提示词里的 3.1 预检与 5.2 写入都只调用脚本,由退出码与 JSON 决定下一步。
|
|
3
17
|
*/
|
|
4
18
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
import { shellSingleQuote } from '#path-guard';
|
|
20
|
+
import {
|
|
21
|
+
DIRECTION_LABELS,
|
|
22
|
+
DIRECTION_LABEL_RE,
|
|
23
|
+
directionTemplateBlock,
|
|
24
|
+
} from '#direction-labels';
|
|
25
|
+
// 报告的结构契约 —— **提示词与校验脚本同源**(report-schema.js)。
|
|
26
|
+
// 阈值、章节标题、表头只定义一次,改一处两边同时变。
|
|
27
|
+
import {
|
|
28
|
+
SECTION_TITLES,
|
|
29
|
+
REPORT_RULES,
|
|
30
|
+
tableHeaders,
|
|
31
|
+
} from './templates/report-schema.js';
|
|
32
|
+
import {
|
|
33
|
+
PLATFORM_TABLE,
|
|
34
|
+
HEAT_METRIC,
|
|
35
|
+
heatMetric,
|
|
36
|
+
DATA_SHAPE,
|
|
37
|
+
dataShape,
|
|
38
|
+
RANK_LISTS,
|
|
39
|
+
platformLabel,
|
|
40
|
+
parseScanMode,
|
|
41
|
+
SCAN_MODE_OPTIONS,
|
|
42
|
+
scanModeLabel,
|
|
43
|
+
} from './templates/platforms.js';
|
|
44
|
+
import {
|
|
45
|
+
dataShapeBlock,
|
|
46
|
+
genreRuleBlock,
|
|
47
|
+
tagRuleBlock,
|
|
48
|
+
competitionBlock,
|
|
49
|
+
} from './templates/blocks.js';
|
|
50
|
+
import { readFileSync } from 'fs';
|
|
51
|
+
import { join, dirname } from 'path';
|
|
52
|
+
import { createHash } from 'crypto';
|
|
53
|
+
import { fileURLToPath } from 'url';
|
|
10
54
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{ id: 'readindex', label: '阅读榜' },
|
|
24
|
-
{ id: 'newfans', label: '书友榜' },
|
|
25
|
-
{ id: 'rec', label: '推荐榜' },
|
|
26
|
-
{ id: 'update', label: '更新榜' },
|
|
27
|
-
{ id: 'sign', label: '签约榜' },
|
|
28
|
-
{ id: 'newbook', label: '新书榜' },
|
|
29
|
-
{ id: 'newauthor', label: '新人作者榜' },
|
|
30
|
-
],
|
|
31
|
-
fanqie: [
|
|
32
|
-
{ id: '1_2', label: '男频阅读榜' },
|
|
33
|
-
{ id: '1_1', label: '男频新书榜' },
|
|
34
|
-
{ id: '0_2', label: '女频阅读榜' },
|
|
35
|
-
{ id: '0_1', label: '女频新书榜' },
|
|
36
|
-
],
|
|
37
|
-
jinjiang: [
|
|
38
|
-
{ id: '5', label: '月榜' },
|
|
39
|
-
{ id: '7', label: '总分榜' },
|
|
40
|
-
{ id: '4', label: '季度榜' },
|
|
41
|
-
{ id: '3', label: '新晋作者榜' },
|
|
42
|
-
],
|
|
43
|
-
qimao: [
|
|
44
|
-
{ id: 'boy/hot/month', label: '男生大热月榜' },
|
|
45
|
-
{ id: 'boy/new/month', label: '男生新书月榜' },
|
|
46
|
-
{ id: 'girl/hot/month', label: '女生大热月榜' },
|
|
47
|
-
{ id: 'girl/new/month', label: '女生新书月榜' },
|
|
48
|
-
],
|
|
55
|
+
// 兼容旧入口:外部(index.js / 测试)一直从 templates.js 取这些表。
|
|
56
|
+
export {
|
|
57
|
+
PLATFORM_TABLE,
|
|
58
|
+
HEAT_METRIC,
|
|
59
|
+
heatMetric,
|
|
60
|
+
DATA_SHAPE,
|
|
61
|
+
dataShape,
|
|
62
|
+
RANK_LISTS,
|
|
63
|
+
platformLabel,
|
|
64
|
+
parseScanMode,
|
|
65
|
+
SCAN_MODE_OPTIONS,
|
|
66
|
+
scanModeLabel,
|
|
49
67
|
};
|
|
50
68
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
/** 校验/追加脚本的绝对路径(写目标文件的唯一入口) */
|
|
71
|
+
export const APPEND_SCRIPT = join(__dirname, 'scripts', 'append-block.js');
|
|
72
|
+
|
|
73
|
+
/** 静态提示词契约(**提示词层**;动态段落由 templates/blocks.js 注入) */
|
|
74
|
+
export const ANALYSIS_PROMPT_FILE = join(__dirname, 'prompts', '01-analysis-prompt.md');
|
|
75
|
+
|
|
76
|
+
/** 块 ID:平台-榜单列表-日期(提示词、预检、写入、复核共用) */
|
|
77
|
+
export function makeBlockId(platform, rankListsStr, date) {
|
|
78
|
+
return `${platformLabel(platform)}-${rankListsStr}-${date || ''}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 把块(数组或空)转成带换行的文本,便于注入 Markdown 模板 */
|
|
82
|
+
export function blockToText(block) {
|
|
83
|
+
if (!block) return '';
|
|
84
|
+
const lines = Array.isArray(block) ? block : [String(block)];
|
|
85
|
+
if (!lines.length) return '';
|
|
86
|
+
return lines.join('\n') + '\n';
|
|
54
87
|
}
|
|
55
88
|
|
|
56
89
|
/**
|
|
57
|
-
*
|
|
90
|
+
* 渲染提示词:静态契约来自 prompts/01-analysis-prompt.md,
|
|
91
|
+
* 动态段落(按平台的数据形态/指标/竞争度)由调用方传入。
|
|
58
92
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* ocr —— 渲染后截图 + tesseract 识别。
|
|
93
|
+
* **提示词层不执行任何校验**;它只描述契约与脚本的退出码。
|
|
94
|
+
* 校验与写盘在 scripts/append-block.js(部署到项目 `.fancy-tools/`)。
|
|
62
95
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*/
|
|
66
|
-
export const SCAN_MODE_OPTIONS = {
|
|
67
|
-
fanqie: [
|
|
68
|
-
{
|
|
69
|
-
id: SCAN_MODE_API,
|
|
70
|
-
label: 'API扫描(默认)',
|
|
71
|
-
description:
|
|
72
|
-
'优势:书名/作者/简介取自番茄明文接口,零识别误差、精度 100%,整榜耗时约为 OCR 扫描的 1/5;' +
|
|
73
|
-
'劣势:依赖番茄 /api/book/info 接口,该接口若变更或临时不可用时,简介会缺失',
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
id: SCAN_MODE_OCR,
|
|
77
|
-
label: 'OCR扫描',
|
|
78
|
-
description:
|
|
79
|
-
'优势:渲染后截图识别,不依赖番茄内部接口,接口失效也能采到;' +
|
|
80
|
-
'劣势:每本约 0.5 秒且存在认字误差(实测简介字符错误率 8%~17%),整榜耗时约为 API 扫描的 5 倍',
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 扫描方式 id → 展示用名称(去掉"(默认)"后缀,只在结果回执里展示)。
|
|
96
|
+
* @param {Record<string, string|number>} vars
|
|
97
|
+
* @returns {string}
|
|
87
98
|
*/
|
|
88
|
-
export function
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
export function renderAnalysisPrompt(vars) {
|
|
100
|
+
let text = readFileSync(ANALYSIS_PROMPT_FILE, 'utf-8');
|
|
101
|
+
// 维护者元数据(@meta-only 之前)不发给 LLM
|
|
102
|
+
const meta = text.indexOf('\n@meta-only\n');
|
|
103
|
+
if (meta !== -1) {
|
|
104
|
+
text = text.slice(meta + '\n@meta-only\n'.length);
|
|
105
|
+
text = text.replace(/^\s*---\s*\n/, '');
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
108
|
+
text = text.split('${' + key + '}').join(value === undefined || value === null ? '' : String(value));
|
|
109
|
+
}
|
|
110
|
+
// 没有对应变量的占位符替空,避免把 ${...} 原样发给模型
|
|
111
|
+
text = text.replace(/\$\{[a-z_]+\}/g, '');
|
|
112
|
+
return text;
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
/**
|
|
94
|
-
*
|
|
116
|
+
* 正文临时文件的路径 —— 按块 ID 派生,同一块永远是同一个文件。
|
|
95
117
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* 解析不出合法 id 时返回 null,由调用方按"无效选择"处理。
|
|
118
|
+
* 放在 <工作区目录>/.tmp 下:这是 DSH 沙箱允许写、且不会进 `扫榜结果/` 的
|
|
119
|
+
* 位置;不要用系统 /tmp。
|
|
99
120
|
*/
|
|
100
|
-
export function
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
export function bodyFilePath(projectRoot, blockId, scope = '') {
|
|
122
|
+
const safe = String(blockId)
|
|
123
|
+
.replace(/[^\w一-鿿-]+/g, '_')
|
|
124
|
+
.slice(0, 60);
|
|
125
|
+
// 不同项目同平台同日期会得到同一个 blockId;把 topicFile/projectRoot
|
|
126
|
+
// 的哈希拼进去,避免两个会话共用同一个临时正文文件、互相覆盖。
|
|
127
|
+
const h = scope
|
|
128
|
+
? createHash('sha256').update(String(scope)).digest('hex').slice(0, 10)
|
|
129
|
+
: '';
|
|
130
|
+
const tempDir = projectRoot ? join(projectRoot, '.tmp') : '.tmp';
|
|
131
|
+
return join(tempDir, `fancy_scan_body_${safe}${h ? '_' + h : ''}.md`);
|
|
107
132
|
}
|
|
108
133
|
|
|
134
|
+
// 11 项标签的**单一事实来源**在 lib/direction-labels.js ——
|
|
135
|
+
// 这里转出去一份,外部引用方不用知道它住在哪个 lib 文件里。
|
|
136
|
+
export { DIRECTION_LABELS, DIRECTION_LABEL_RE };
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// 共享常量
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
109
142
|
// ---------------------------------------------------------------------------
|
|
110
143
|
// 模板
|
|
111
144
|
// ---------------------------------------------------------------------------
|
|
@@ -165,55 +198,163 @@ export const scan = {
|
|
|
165
198
|
};
|
|
166
199
|
},
|
|
167
200
|
|
|
168
|
-
notify({ type, files, topicFile, projectRoot, err, failedRanks, scanMode, detail }) {
|
|
201
|
+
notify({ type, files, topicFile, projectRoot, err, failedRanks, scanMode, detail, repaired, dirt, platform, rankLabels, date, blockId, closed, dates }) {
|
|
169
202
|
switch (type) {
|
|
203
|
+
case 'help':
|
|
204
|
+
return `📊 扫榜 用法
|
|
205
|
+
|
|
206
|
+
/fancy-scan 交互选择平台与榜单,抓取榜单并生成 <工作区>/扫榜结果/
|
|
207
|
+
/fancy-scan help 显示本帮助
|
|
208
|
+
|
|
209
|
+
作用:为 fancy-topic 提供扫榜数据。`;
|
|
210
|
+
|
|
170
211
|
case 'not_initialized':
|
|
171
|
-
return '⚠️
|
|
212
|
+
return '⚠️ 当前目录不属于已初始化的 fancy-webnovel 工作区。请先运行 /fancy-bootstrap 初始化工作区。';
|
|
172
213
|
|
|
173
214
|
case 'invalid_choice':
|
|
174
|
-
return '❌
|
|
215
|
+
return '❌ 选择无效,请输入 1-4 的编号。';
|
|
175
216
|
|
|
176
217
|
case 'invalid_scan_mode':
|
|
177
|
-
return '❌
|
|
218
|
+
return '❌ 扫描方式选择无效,请重新运行 /fancy-scan。';
|
|
178
219
|
|
|
179
220
|
case 'cancelled':
|
|
180
|
-
return '🚫
|
|
221
|
+
return '🚫 操作已取消。';
|
|
181
222
|
|
|
182
223
|
case 'no_provider':
|
|
183
|
-
return '⚠️
|
|
224
|
+
return '⚠️ 当前运行环境不支持交互式提问。请在 dsh-tui 中运行该命令,或确认 Web UI 已正确加载交互组件。';
|
|
184
225
|
|
|
185
226
|
case 'no_project_root':
|
|
186
|
-
return '❌
|
|
227
|
+
return '❌ 无法确定工作区:' + (err || '会话未提供当前工作目录') + '。请在 fancy-webnovel 工作区目录下运行该命令。';
|
|
187
228
|
|
|
188
229
|
case 'ask_failed':
|
|
189
|
-
return '❌
|
|
230
|
+
return '❌ 交互提问失败:' + (err || '未知错误');
|
|
190
231
|
|
|
191
232
|
case 'cancelled_with_hint':
|
|
192
|
-
return '⚠️
|
|
233
|
+
return '⚠️ 操作已取消。请重新运行 /fancy-scan。';
|
|
193
234
|
|
|
194
235
|
case 'failed':
|
|
195
236
|
return '❌ 采集失败:' + err;
|
|
196
237
|
|
|
197
238
|
case 'scraper_failed':
|
|
198
|
-
return '❌ ' + err;
|
|
239
|
+
return '❌ 榜单采集失败:' + err;
|
|
199
240
|
|
|
200
241
|
case 'no_rank_selected':
|
|
201
|
-
return '❌
|
|
242
|
+
return '❌ 未选择任何榜单,无法开始采集。';
|
|
243
|
+
|
|
244
|
+
case 'scan_date_mixed': {
|
|
245
|
+
const ds = Array.isArray(dates) ? dates : [];
|
|
246
|
+
const fs = Array.isArray(files) ? files : [];
|
|
247
|
+
const rel = fs.map(f => '- ' + String(f).replace((projectRoot || '') + '/', '')).join('\n');
|
|
248
|
+
return '❌ 本次采集跨过了本地午夜,各榜单落在了不同日期:' + ds.join(' / ') + '。\n'
|
|
249
|
+
+ '为避免把两天的数据写进同一份日报,已停止分析,**没有生成报告**。\n'
|
|
250
|
+
+ (rel ? `\n本批数据文件:\n${rel}\n` : '')
|
|
251
|
+
+ '\n请删除这批数据文件后重新采集,确保本批数据文件使用同一个日期。';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
case 'already_scanned': {
|
|
255
|
+
const rel = topicFile
|
|
256
|
+
? topicFile.replace((projectRoot || '') + '/', '')
|
|
257
|
+
: '扫榜结果/topic_decision_<日期>.md';
|
|
258
|
+
const unclosed = closed === false ? '\n(注意:这个块目前只有 start、缺 end 标记)' : '';
|
|
259
|
+
return `⏭️ 本次未执行扫榜:${platform || ''} · ${rankLabels || ''} · ${date || ''}。\n`
|
|
260
|
+
+ `目标文件:${rel}\n`
|
|
261
|
+
+ `目标文件中已存在本次标记 \`# ${blockId || ''}-start\`${unclosed}\n`
|
|
262
|
+
+ '为避免重复采集,本次未启动采集脚本,也未改动任何文件。\n'
|
|
263
|
+
+ '如需重新采集,请先删除该内容块,或更换榜单/日期。';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case 'existing_block_unreadable': {
|
|
267
|
+
const rel = topicFile
|
|
268
|
+
? topicFile.replace((projectRoot || '') + '/', '')
|
|
269
|
+
: '扫榜结果/topic_decision_<日期>.md';
|
|
270
|
+
return `❌ 无法检查 ${rel} 中是否已存在本次内容块:${err || '目标文件无法读取'}。\n`
|
|
271
|
+
+ '为避免重复采集或重复写入,本次未启动采集脚本。\n'
|
|
272
|
+
+ '请确认该文件可读且不是符号链接,然后重试。';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'dirty_file': {
|
|
276
|
+
// 模型自己把文件写歪了:同一个平台写了两遍,或者把校验命令写进了正文。
|
|
277
|
+
// 它自己看不见(写完就交差了),只能插件来说。
|
|
278
|
+
const d = dirt || {};
|
|
279
|
+
let out = '⚠️ 目标文件中检测到上次写入异常的内容:\n';
|
|
280
|
+
if (d.deduped && d.deduped.removed && d.deduped.removed.length) {
|
|
281
|
+
out += `- 已自动合并 **${d.deduped.removed.length} 个重复块**(同一平台被写入多次,保留最后一份)\n`;
|
|
282
|
+
for (const r of d.deduped.removed) {
|
|
283
|
+
out += ` - 「${r.key}」第 ${r.lines} 行的重复块已删除\n`;
|
|
284
|
+
}
|
|
285
|
+
out += d.deduped.backupPath
|
|
286
|
+
? `- 删除前已备份:\`${d.deduped.backupPath}\`\n`
|
|
287
|
+
: '- ⚠️ 本次备份失败,已删除内容无法恢复。\n';
|
|
288
|
+
}
|
|
289
|
+
if (d.lines && d.lines.length) {
|
|
290
|
+
out += (d.deduped && d.deduped.removed && d.deduped.removed.length ? '\n以下内容尚未处理:\n' : '') +
|
|
291
|
+
d.lines.map((l) => '- ' + l).join('\n') + '\n';
|
|
292
|
+
}
|
|
293
|
+
out += '\n这些异常通常由模型写入文件时 heredoc 缺少结束符 `FANCY_SCAN_BODY_EOF` 导致。' +
|
|
294
|
+
'正文中混入的命令片段和未闭合块不会被自动修改,以免误删正文内容。' +
|
|
295
|
+
'请人工确认后处理。';
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'manifest_failed':
|
|
300
|
+
return '❌ 生成本次分析运行清单失败,已停止:\n'
|
|
301
|
+
+ (err || '未知错误') + '\n\n'
|
|
302
|
+
+ '未改动任何采集结果。请检查工作区目录与 `.fancy-tools/fancy-scan/` 的写权限后重试。';
|
|
303
|
+
|
|
304
|
+
case 'tool_deploy_failed':
|
|
305
|
+
return '❌ 准备工作区内的校验/写入脚本失败,已停止本次扫榜:\n'
|
|
306
|
+
+ (err || '未知错误') + '\n\n'
|
|
307
|
+
+ '未开始采集,扫榜结果/ 未发生改动。\n'
|
|
308
|
+
+ '请检查工作区目录的写权限,并确认 `.fancy-tools` 是真实目录(不能是符号链接)后重试。';
|
|
202
309
|
|
|
203
310
|
case 'llm_delivery_failed':
|
|
204
|
-
return '❌
|
|
205
|
-
'\n
|
|
311
|
+
return '❌ 采集已完成,但提示词发送给 LLM 失败:' + (err || '未知错误') +
|
|
312
|
+
'\n已采集数据保留在 扫榜结果/ 下,请重新运行 /fancy-scan 触发分析。';
|
|
313
|
+
|
|
314
|
+
case 'write_verify_failed':
|
|
315
|
+
// 模型回合结束后插件自己查了一遍,发现没写进去 / 写歪了。
|
|
316
|
+
// 之前这种情况会被当成"写成功了"——用户看到的是一份残缺报告。
|
|
317
|
+
return '❌ 采集与分析已完成,但分析结果未能正确写入目标文件:\n'
|
|
318
|
+
+ (err || '未知原因') + '\n\n'
|
|
319
|
+
+ '📁 已采集数据保留在 扫榜结果/ 下,目标文件中未损坏的内容仍然保留。\n'
|
|
320
|
+
+ '💡 重新运行 /fancy-scan 可重新执行分析;同一平台同一天的内容块不会重复写入。';
|
|
206
321
|
|
|
207
322
|
case 'phase_advance_failed':
|
|
208
323
|
// 采集成功但 phase 推不动 = 整个流程卡住:
|
|
209
324
|
// 下游 /fancy-topic 要求 phase === 'scanned',推不动就永远过不了。
|
|
210
325
|
// 之前这个失败只写进 run-scan.js 的 stderr,用户只看到"采集完成",
|
|
211
326
|
// 接着跑 /fancy-topic 报"状态文件读不了",完全不知道问题出在这儿。
|
|
212
|
-
return '❌ 采集已完成,但
|
|
327
|
+
return '❌ 采集已完成,但 `.fancy-deployed` 的 phase 未能推进到 `scanned`,后续流程无法继续。\n' +
|
|
213
328
|
`原因:${err || '未知原因'}\n\n` +
|
|
214
|
-
'📁
|
|
329
|
+
'📁 已采集数据保留在 扫榜结果/ 下。修复状态文件后重新运行 /fancy-scan。\n\n' +
|
|
215
330
|
(detail || '');
|
|
216
331
|
|
|
332
|
+
case 'analysis_done': {
|
|
333
|
+
const failed = (failedRanks || []).length
|
|
334
|
+
? '\n⚠️ 以下榜单存在采集问题:'
|
|
335
|
+
+ failedRanks.map(f => f.label + '(' + f.error + ')').join(';')
|
|
336
|
+
: '';
|
|
337
|
+
const modeLine = scanMode
|
|
338
|
+
? `\n🔍 扫描方式:${scanModeLabel(scanMode)}`
|
|
339
|
+
: '';
|
|
340
|
+
const repairedLine = (repaired || []).length
|
|
341
|
+
? `\n♻️ 检测到上一次的 topic_decision 被覆盖,已从备份补回 ${repaired.length} 个内容块:${repaired.join('、')}`
|
|
342
|
+
: '';
|
|
343
|
+
const fileLines = (files || []).length
|
|
344
|
+
? '\n📁 本次采集文件:\n' + files.map(f => '- ' + f.replace((projectRoot || '') + '/', '')).join('\n')
|
|
345
|
+
: '';
|
|
346
|
+
const rel = topicFile
|
|
347
|
+
? topicFile.replace((projectRoot || '') + '/', '')
|
|
348
|
+
: '扫榜结果/topic_decision_<日期>.md';
|
|
349
|
+
return `✅ 采集与分析${failed ? '完成(部分榜单存在异常)' : '完成'}(${platform || ''} · ${rankLabels || ''} · ${date || ''})`
|
|
350
|
+
+ modeLine
|
|
351
|
+
+ repairedLine
|
|
352
|
+
+ fileLines
|
|
353
|
+
+ `\n📄 分析报告已追加写入:${rel}`
|
|
354
|
+
+ failed
|
|
355
|
+
+ '\n\n💡 下一步:运行 /fancy-topic 进行选题。';
|
|
356
|
+
}
|
|
357
|
+
|
|
217
358
|
case 'handover': {
|
|
218
359
|
// 部分榜单失败时必须显式列出:此前直接中断整个命令,
|
|
219
360
|
// 已采集成功的榜单产物被丢弃,用户既不知道采到了什么,也拿不到文件。
|
|
@@ -224,12 +365,18 @@ export const scan = {
|
|
|
224
365
|
const modeLine = scanMode
|
|
225
366
|
? `\n🔍 扫描方式:${scanModeLabel(scanMode)}`
|
|
226
367
|
: '';
|
|
368
|
+
// 上一次分析把 topic_decision 覆盖了(LLM 的 write 是覆盖语义),
|
|
369
|
+
// 这次已从备份补回 —— 必须告诉用户,不然他会以为之前那轮白跑了。
|
|
370
|
+
const repairedLine = (repaired || []).length
|
|
371
|
+
? `\n♻️ 检测到上一次的 topic_decision 被覆盖,已从备份补回 ${repaired.length} 个内容块:${repaired.join('、')}\n`
|
|
372
|
+
: '';
|
|
227
373
|
return (
|
|
228
|
-
`📊
|
|
374
|
+
`📊 采集完成,正在将数据发送给 LLM 进行分析…` +
|
|
229
375
|
modeLine +
|
|
376
|
+
repairedLine +
|
|
230
377
|
`\n📁 生成文件:\n${(files || []).map(f => '- ' + f.replace(projectRoot + '/', '')).join('\n')}\n` +
|
|
231
|
-
`💡
|
|
232
|
-
`⏳ LLM
|
|
378
|
+
`💡 分析报告将**追加**写入:${(topicFile || '').replace(projectRoot + '/', '')}\n` +
|
|
379
|
+
`⏳ LLM 完成后会将分析写入上述 topic_decision 文件,届时可执行 /fancy-topic` +
|
|
233
380
|
failed
|
|
234
381
|
);
|
|
235
382
|
}
|
|
@@ -239,57 +386,309 @@ export const scan = {
|
|
|
239
386
|
}
|
|
240
387
|
},
|
|
241
388
|
|
|
242
|
-
|
|
389
|
+
/**
|
|
390
|
+
* @param {object} args
|
|
391
|
+
* @param {string} args.platform - 平台 value(fanqie/qidian/…)
|
|
392
|
+
* @param {string[]} args.rankLists - 榜单中文名列表
|
|
393
|
+
* @param {string} args.date - 日期戳(YYYYMMDD)
|
|
394
|
+
* @param {string[]} args.files - 采集产物绝对路径
|
|
395
|
+
* @param {string} args.topicFile - topic_decision_<date>.md 绝对路径
|
|
396
|
+
* @param {string} args.projectRoot - 工作区目录
|
|
397
|
+
* @param {Array<{key: string, startTitle: string, endTitle: string|null, closed: boolean}>} [args.existingBlocks]
|
|
398
|
+
* 文件里**已有**的标记块(插件读文件得到的)。
|
|
399
|
+
* 告诉模型"里面已经有什么",它才知道自己只该追加、不该重写。
|
|
400
|
+
*/
|
|
401
|
+
llmAnalysisPrompt({ platform, rankLists, date, files, topicFile, projectRoot, existingBlocks, fileWarnings, failedRanks, appendScript: appendScriptArg, manifestPath }) {
|
|
243
402
|
const pLabel = platformLabel(platform);
|
|
244
403
|
const listsStr = rankLists ? rankLists.join('、') : '全选';
|
|
404
|
+
// 本次是**一个**榜单还是**多个**(含"全选")—— 措辞必须跟着走。
|
|
405
|
+
// 之前一律写"本报告只有一个榜单",而用户选"全选"或多个榜单时,
|
|
406
|
+
// 那句话和实际数据文件直接矛盾(起点"全选"本身就含新书榜)。
|
|
407
|
+
//
|
|
408
|
+
// "全选"要**单独判**:它只有一个元素,但代表的是该平台的**全部**榜单 ——
|
|
409
|
+
// 只看数组长度会把它当成单榜(实测踩过:选了全选,提示词却说"只有一个榜单")。
|
|
410
|
+
const multiList = !rankLists || rankLists.length > 1 || rankLists.includes('全选');
|
|
411
|
+
const listRule = multiList
|
|
412
|
+
? [
|
|
413
|
+
`- **本报告覆盖多个榜单**(${pLabel}·${listsStr})—— 它们**都在本次数据文件里**,`,
|
|
414
|
+
` 可以做它们之间的对比;但**不得引入数据文件之外的榜单**,`,
|
|
415
|
+
` 不得编造本次没有提供的对比维度。`,
|
|
416
|
+
`- **"二、市场结构(单日快照)"里必须加一个"榜单间结构"小节**(标题逐字写 \`### 榜单间结构\`),至少包含下面 3 部分:`,
|
|
417
|
+
` 1. **跨榜重合**:哪些书出现在 ≥2 个榜单里(重合度),各榜全部条目重合多少本;`,
|
|
418
|
+
` 2. **名次差**:同一本书在不同榜的名次差;名次差大的书要说明原因,原因只能标"推断";`,
|
|
419
|
+
` 3. **题材分布差异**:各榜的题材偏好有什么不同,至少举 2 本书佐证。`,
|
|
420
|
+
` **不要把多个榜单简单合并成一个总表** —— 合并会造成榜单选择偏差`,
|
|
421
|
+
` (某本书在几个榜里出现,它的样本量就机械性变大)。`,
|
|
422
|
+
]
|
|
423
|
+
: [
|
|
424
|
+
`- **本报告只有一个榜单**(${pLabel}·${listsStr}),**没有第二个榜单可比**:`,
|
|
425
|
+
` 不得引入"新书榜"等其它榜单,不得编造"阅读榜 vs 新书榜"式的对比。`,
|
|
426
|
+
];
|
|
427
|
+
// 各平台的热度指标不一样(起点月票/番茄在读/晋江积分/七猫热度)——
|
|
428
|
+
// 提示词里所有"热度"相关的措辞都要跟着平台走。
|
|
429
|
+
const h = heatMetric(platform);
|
|
430
|
+
// 单榜报告没有第二个榜单,不能出现"跨榜汇总/某题材在越多榜单里"这类说明;
|
|
431
|
+
// 只有多榜时才注入这段。
|
|
432
|
+
const crossListRule = multiList
|
|
433
|
+
? [
|
|
434
|
+
`- **同一本书在多个榜单里的${h.label}可能有微小差异**(抓取时间差)——`,
|
|
435
|
+
` **跨榜汇总/表格取抓取时间最新的值**;**正文按具体榜单引用时取该榜文件里的值并注明榜单**;`,
|
|
436
|
+
` 在"一、数据与方法"里记一句差异量级(两者差通常很小,写清口径即可)`,
|
|
437
|
+
]
|
|
438
|
+
: [];
|
|
439
|
+
// 数据形态按平台走 —— 起点/七猫是平铺的,番茄/晋江才分组(评审 P0-1)
|
|
440
|
+
const ds = dataShape(platform);
|
|
441
|
+
// 单榜报告不写"同一本书在多个榜单里"——它只有一个榜单。
|
|
442
|
+
const dedupeRule = multiList
|
|
443
|
+
? `- **去重键:书名 + 作者**;同一本书在多个${ds.grouped ? '组' : '榜单'}里出现时只算一次,并注明它出现在哪几个`
|
|
444
|
+
: `- **去重键:书名 + 作者**;同一本书重复出现时只算一次,并注明重复出现的位置`;
|
|
445
|
+
const genreSelfCheck = ds.grouped && ds.genreFrom === 'group'
|
|
446
|
+
? `- 题材用的是数据文件里的官方分组,且跳过了"全部"`
|
|
447
|
+
: ds.grouped
|
|
448
|
+
? `- 题材用的是每本书自己的题材字段(不是标题、不是频道名)`
|
|
449
|
+
: `- 题材用的是每本书自己的题材字段(不是标题)`;
|
|
450
|
+
// 契约常量:章节标题、阈值、表头 —— 与校验脚本同源
|
|
451
|
+
const R = REPORT_RULES;
|
|
452
|
+
const headers = tableHeaders(h.label, ds.grouped);
|
|
453
|
+
// 校验/追加脚本(写目标文件的唯一入口)。
|
|
454
|
+
// 运行时由 index.js 传入**工作区内**部署后的路径;测试/直接调用时
|
|
455
|
+
// 回退到插件内路径(只用于渲染提示词,不用于真的执行)。
|
|
456
|
+
const appendScript = appendScriptArg || APPEND_SCRIPT;
|
|
245
457
|
const fileList = (files || [])
|
|
246
458
|
.map(f => '- `' + f.replace(projectRoot + '/', '') + '`')
|
|
247
459
|
.join('\n');
|
|
248
460
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
461
|
+
// 块 ID **只算一次**(评审 11.3)—— 计数命令、写入命令、标记行、校验、
|
|
462
|
+
// 回复里凡是"平台-榜单-日期"都用这三个常量。之前散在 6 处各拼一次,
|
|
463
|
+
// 任何一次改格式都会和其它几处对不上。
|
|
464
|
+
const blockId = makeBlockId(platform, listsStr, date);
|
|
465
|
+
// 正文临时文件:按块 ID 派生,同一块永远是同一个文件
|
|
466
|
+
const bodyFile = bodyFilePath(projectRoot, blockId, topicFile || projectRoot || '');
|
|
467
|
+
// 预检/写入命令:优先用插件生成的 manifest。
|
|
468
|
+
// manifest 里已经写死目标、工作区目录、块 ID、数据文件、正文路径、指标与开关,
|
|
469
|
+
// 模型只需要照抄两条短命令 —— 少一个参数就少一个跑偏的机会。
|
|
470
|
+
const preflightCommand = manifestPath
|
|
471
|
+
? `node ${shellSingleQuote(appendScript)} --preflight --manifest ${shellSingleQuote(manifestPath)}`
|
|
472
|
+
: [
|
|
473
|
+
`node ${shellSingleQuote(appendScript)} --preflight`,
|
|
474
|
+
`--target ${shellSingleQuote(topicFile)}`,
|
|
475
|
+
projectRoot ? `--project-root ${shellSingleQuote(projectRoot)} ` : '',
|
|
476
|
+
`--block-id ${shellSingleQuote(blockId)}`,
|
|
477
|
+
...((files || []).map(f => `--data-file ${shellSingleQuote(f)}`)),
|
|
478
|
+
].filter(Boolean).join(' ');
|
|
479
|
+
const appendCommand = manifestPath
|
|
480
|
+
? `node ${shellSingleQuote(appendScript)} --manifest ${shellSingleQuote(manifestPath)}`
|
|
481
|
+
: [
|
|
482
|
+
`node ${shellSingleQuote(appendScript)}`,
|
|
483
|
+
`--target ${shellSingleQuote(topicFile)}`,
|
|
484
|
+
projectRoot ? `--project-root ${shellSingleQuote(projectRoot)} ` : '',
|
|
485
|
+
`--block-id ${shellSingleQuote(blockId)}`,
|
|
486
|
+
`--body ${shellSingleQuote(bodyFile)}`,
|
|
487
|
+
`--metric-label ${shellSingleQuote(h.label)}`,
|
|
488
|
+
ds.grouped ? '--grouped' : '--ungrouped',
|
|
489
|
+
multiList ? '--multi-list' : '--single-list',
|
|
490
|
+
].filter(Boolean).join(' ');
|
|
491
|
+
|
|
492
|
+
const existing = existingBlocks || [];
|
|
493
|
+
// 措辞不能依赖"有没有标记块"来推断"文件存不存在" ——
|
|
494
|
+
// 文件可能有内容但没有标记块(模型没写标记、或内容是别的格式)。
|
|
495
|
+
// 说成"该文件目前不存在或是空的"会让模型以为可以重建/覆盖,
|
|
496
|
+
// 与"只能追加"自相矛盾。所以统一讲清追加语义,存在性交给预检去查。
|
|
497
|
+
const existingText = existing.length
|
|
498
|
+
? [
|
|
499
|
+
`该文件里**已经有**下面这些内容块(**一个字都不许动**):`,
|
|
500
|
+
...existing.map(b => '- `' + b.startTitle + '` … `' + (b.endTitle || '(缺 end 标记)') + '`'),
|
|
501
|
+
``,
|
|
502
|
+
`本次**只追加**,绝不覆盖、截断或重建这个文件。`,
|
|
503
|
+
].join('\n')
|
|
504
|
+
: [
|
|
505
|
+
`目标文件**可能已存在**(含其它平台的内容)。`,
|
|
506
|
+
`本次**只追加**,绝不覆盖、截断或重建它 —— 它是否存在、有多大,以 3.1 预检的结果为准。`,
|
|
507
|
+
].join('\n');
|
|
508
|
+
|
|
509
|
+
// 读数据文件时拼出来的绝对路径示例 —— 拼错路径是常见失败模式,
|
|
510
|
+
// 给一个具体例子比讲道理管用。
|
|
511
|
+
const absExample = projectRoot + '/' +
|
|
512
|
+
((files && files[0] ? files[0].replace(projectRoot + '/', '') : '扫榜结果/xxx.md'));
|
|
513
|
+
|
|
514
|
+
// 采集阶段的部分失败必须让分析模型知道:它拿到的不是“全选完整数据”,
|
|
515
|
+
// 不能把缺失的榜单当成“没有书”或“热度为 0”。
|
|
516
|
+
const failed = Array.isArray(failedRanks) ? failedRanks : [];
|
|
517
|
+
const failedBlock = failed.length
|
|
518
|
+
? [
|
|
519
|
+
`**采集异常(必须写进报告)**`,
|
|
520
|
+
``,
|
|
521
|
+
`以下榜单/条目在采集阶段失败或部分失败。**缺失的部分不在本次文件里**:`,
|
|
522
|
+
...failed.map(f => `- ${String(f.label || '').replace(/\s+/g, ' ')}:${String(f.error || '').replace(/\s+/g, ' ')}`),
|
|
523
|
+
``,
|
|
524
|
+
`- 不得假装这些榜单完整;不得把缺失解释成“没有书/热度为 0”。`,
|
|
525
|
+
`- 在“一、数据与方法”里列出这些异常,并在“七、风险与验证”里说明局限。`,
|
|
526
|
+
].join('\n')
|
|
527
|
+
: '';
|
|
528
|
+
|
|
529
|
+
// 文件里有上次写歪的痕迹时,先告诉模型现状(细节见 index.js 的 inspectTopicDecision)。
|
|
530
|
+
const warningsBlock = (fileWarnings && fileWarnings.length)
|
|
531
|
+
? [
|
|
532
|
+
``,
|
|
533
|
+
`⚠️ **这个文件里有上次写歪的痕迹,追加前先看清楚**:`,
|
|
534
|
+
...fileWarnings.map(w => `- ${w}`),
|
|
535
|
+
``,
|
|
536
|
+
`- **本次只追加一个块**(一个 start 标记 + 正文 + 一个 end 标记)。`,
|
|
537
|
+
`- **上面提到的旧块一个字都不许动,也不要试图"修"它们** —— 脏的地方由用户自己决定怎么处理,你只管把本次的内容追加对。`,
|
|
538
|
+
`- **如果上面提到某个块没闭合**:那说明上次没写完。你**不要替它补 end 标记**,`,
|
|
539
|
+
` 也不要往它后面再追加 —— 直接把情况报告给用户,让他决定怎么处理。`,
|
|
540
|
+
].join('\n') + '\n'
|
|
541
|
+
: '';
|
|
542
|
+
|
|
543
|
+
// 2.3 热度指标说明 —— 主指标 + 辅助指标,各自"代表什么"与"口径注意"。
|
|
544
|
+
// 只说"有哪些数字"模型会用错,所以每个数字都要说清它的坑。
|
|
545
|
+
const aux = h.aux || [];
|
|
546
|
+
const metricRows = [
|
|
547
|
+
`| **${h.label}**(主指标) | ${h.unit} | \`${h.sampleLine}\` | ${h.role} | ${h.caveat} |`,
|
|
548
|
+
...aux.map(a =>
|
|
549
|
+
`| ${a.label} | ${a.unit} | \`${a.sampleLine}\` | ${a.meaning} | ${a.caveat} |`),
|
|
550
|
+
];
|
|
551
|
+
const scaleNote = aux.length
|
|
552
|
+
? `**这 ${metricRows.length} 个数字的量级差极大**(${
|
|
553
|
+
[h.label, ...aux.map(a => a.label)].join('、')
|
|
554
|
+
}),**不要把它们相加成一个"热度分",也不要互相代入占比**。`
|
|
555
|
+
: `本平台只有这一个热度指标 —— 需求规模、需求份额、头部集中度三项都用它。`;
|
|
556
|
+
// 参数表里只有"主热度指标"这一行,但 2.3 会列出主指标 + 辅助指标。
|
|
557
|
+
// 不写清"谁是主、谁只作辅助",模型会把 4 个数字都当成热度指标。
|
|
558
|
+
const metricIntro = aux.length
|
|
559
|
+
? `本次(${pLabel})数据文件里的热度指标:**主指标 1 个(${h.label})+ 辅助指标 ${aux.length} 个(${aux.map(a => a.label).join(' / ')})**。`
|
|
560
|
+
+ `\n需求规模 / 需求份额 / 头部集中度**只用主指标 ${h.label}**;辅助指标只作交叉验证,不进公式。`
|
|
561
|
+
: `本次(${pLabel})数据文件里只有 1 个热度指标(${h.label}),需求规模 / 需求份额 / 头部集中度都用它。`;
|
|
562
|
+
const heatMetricBlock = [
|
|
563
|
+
`### 2.3 热度指标说明`,
|
|
255
564
|
``,
|
|
256
|
-
|
|
257
|
-
fileList,
|
|
565
|
+
metricIntro,
|
|
258
566
|
``,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
`3. 生成扫榜报告(包含:市场趋势、热门题材分析、用户画像、竞争度评估)`,
|
|
263
|
-
`4. 给出 3-5 个可行的选题方向建议。为每个方向设计金手指、核心设定、爽点模式、情绪价值曲线、开篇切入点、故事节奏规划、人物设定框架、差异化竞争策略等一系列要素`,
|
|
264
|
-
`5. 首先检查 \`${topicFile}\` 是否存在。如果该文件不存在则**必须**新建此文件(只新建空文件,不要写内容,也不要写标题);文件存在后就直接进入第 6 条的四步,把本次分析报告**追加**写入`,
|
|
567
|
+
`| 指标 | 单位 | 数据文件里的字段 | 代表什么 | 口径注意 |`,
|
|
568
|
+
`| --- | --- | --- | --- | --- |`,
|
|
569
|
+
...metricRows,
|
|
265
570
|
``,
|
|
266
|
-
|
|
571
|
+
scaleNote,
|
|
572
|
+
].join('\n');
|
|
267
573
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
574
|
+
// 2.4 其它可用字段 —— 字数 / 标签 / 排名变动。
|
|
575
|
+
// 这三个字段不是每个平台都有(采集端采到了才会有)。
|
|
576
|
+
// **按平台列**:起点/七猫压根没有 `**标签:**` 和 `**排名变动:**`,
|
|
577
|
+
// 把番茄/晋江的坑写给它们看是纯噪声(评审 P1-10)。
|
|
578
|
+
const otherFieldsBlock = [
|
|
579
|
+
`### 2.4 其它可用字段`,
|
|
272
580
|
``,
|
|
273
|
-
|
|
581
|
+
`数据文件里**可能**出现下面这些行 —— 本平台的采集端采到了才会有。`,
|
|
582
|
+
`**出现就记下来,没出现就跳过**(不要因为没有某一行就编一个值):`,
|
|
274
583
|
``,
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
`- 两个标记之间**只放本次平台**的分析内容,不要混入其它平台的内容`,
|
|
285
|
-
`- 标记里的平台名、榜单名、日期必须与上面写的完全一致(本次是:${pLabel}-${listsStr}-${date || ''})`,
|
|
286
|
-
`- 本次追加写入的分析报告内容要**重新开始编号**(从"一、"重新数)`,
|
|
287
|
-
`- 标记行**不要**加粗、**不要**缩进、**不要**带任何 markdown 装饰符号`,
|
|
288
|
-
``,
|
|
289
|
-
`报告要求:`,
|
|
290
|
-
`- 客观分析数据,不臆测`,
|
|
291
|
-
`- 选题建议要有差异化竞争力`,
|
|
292
|
-
`- 报告语言:中文`,
|
|
584
|
+
`| 行 | 代表什么 | 怎么用 | 坑 |`,
|
|
585
|
+
`| --- | --- | --- | --- |`,
|
|
586
|
+
`| \`**字数:**\` | 作品累计字数 | 反映**体量** —— 老书天然字数大 | **字数不是热度** —— 不可与热度指标相加或代入占比;各平台粒度不同(精确字数 / "647.39万字"),**原样记录** |`,
|
|
587
|
+
ds.hasTags
|
|
588
|
+
? `| \`**标签:**\` | 平台**官方标签** | 统计标签出现频次,可看出题材细分与元素/套路分布 | 番茄:**第一个标签是主分类**(= 文件里的题材组),统计时**跳过它**,否则与题材频次重复计数;晋江:**全是内容标签、没有主分类**。两者的标签都**题材细分与元素套路混在一起**(如"都市/破镜重圆/暗恋"、"无限流/单女主/无脑爽")—— **原样分组统计,不要自己判断它属于哪类** |`
|
|
589
|
+
: `| (本平台没有 \`**标签:**\` 行) | — | **不做标签频次分析** —— 没有数据源 | 详见 3.2 的"标签"一条 |`,
|
|
590
|
+
ds.hasRankChange
|
|
591
|
+
? `| \`**排名变动:**\` | 榜页给出的**名次变化**(上升/下降 N 名/持平) | 说明"名次较上次有变动" | **只是一个变化值,不是历史序列** —— 可以说"名次较上次上升 N 位",**不能推断连续走势** |`
|
|
592
|
+
: `| (本平台没有 \`**排名变动:**\` 行) | — | **不要写名次变动** —— 没有这个数据源 | — |`,
|
|
293
593
|
].join('\n');
|
|
594
|
+
|
|
595
|
+
// 3.3 里"辅助指标怎么用" —— 只在有辅助指标的平台出现(目前只有起点)。
|
|
596
|
+
const auxUsageBlock = aux.length
|
|
597
|
+
? [
|
|
598
|
+
`- **辅助指标(${aux.map(a => a.label).join(' / ')})只作交叉验证,不进上面三项公式** ——`,
|
|
599
|
+
` 它们的口径和量级都与${h.label}不同(见 2.3),代入占比或相加都得出没有意义的数。用法:`,
|
|
600
|
+
...aux.map(a => ` - ${a.usage}`),
|
|
601
|
+
` 以上都是**同一天、同一批书的横向比较** —— 不能写成"正在上升""持续走高"。`,
|
|
602
|
+
]
|
|
603
|
+
: [];
|
|
604
|
+
|
|
605
|
+
// 写前自检里也要有一项:辅助指标没被混用(累计量进份额公式 / 数字相加)。
|
|
606
|
+
const selfCheckExtra = aux.length
|
|
607
|
+
? ` / **${h.label}与辅助指标(${aux.map(a => a.label).join('/')})没有混用**(累计量没进份额公式、数字没相加)`
|
|
608
|
+
: '';
|
|
609
|
+
|
|
610
|
+
// 3.3 里"排名变动怎么用" —— 只有采集端真的采到 `**排名变动:**` 的平台才写。
|
|
611
|
+
// 起点/七猫没有这个字段,写给它看只会增加噪声,还会让模型去找不存在的行。
|
|
612
|
+
const rankChangeRule = ds.hasRankChange
|
|
613
|
+
? [
|
|
614
|
+
`- **数据文件里有 \`**排名变动:**\` 行时**,它说明"名次较上次有变动"(上升/下降 N 名/持平)——`,
|
|
615
|
+
` 这可以写进结论,但**只能写这一次的变动**:"名次较上次上升 N 位";`,
|
|
616
|
+
` **不能**由它推断连续走势("连续上升""一路走高")—— 它是单个变化值,不是历史序列。`,
|
|
617
|
+
]
|
|
618
|
+
: [];
|
|
619
|
+
|
|
620
|
+
return renderAnalysisPrompt({
|
|
621
|
+
platform_label: pLabel,
|
|
622
|
+
rank_lists: listsStr,
|
|
623
|
+
date: date || '',
|
|
624
|
+
heat_metric_label: h.label,
|
|
625
|
+
heat_metric_note: h.note,
|
|
626
|
+
heat_precision_note: h.precisionNote,
|
|
627
|
+
heat_sample_value: h.sampleValue,
|
|
628
|
+
topic_file: topicFile,
|
|
629
|
+
file_list_block: fileList,
|
|
630
|
+
project_root: projectRoot,
|
|
631
|
+
abs_example: absExample,
|
|
632
|
+
failed_block: failedBlock ? failedBlock + '\n\n' : '',
|
|
633
|
+
existing_block: existingText,
|
|
634
|
+
warnings_block: warningsBlock,
|
|
635
|
+
heat_metric_block: heatMetricBlock,
|
|
636
|
+
other_fields_block: otherFieldsBlock,
|
|
637
|
+
preflight_command: preflightCommand,
|
|
638
|
+
append_command: appendCommand,
|
|
639
|
+
data_shape_block: blockToText(dataShapeBlock(ds)),
|
|
640
|
+
dedupe_rule: dedupeRule,
|
|
641
|
+
tag_rule_block: blockToText(tagRuleBlock(ds)),
|
|
642
|
+
group_scope: ds.grouped ? '组内' : '榜内',
|
|
643
|
+
all_group_block: ds.grouped && ds.genreFrom === 'group'
|
|
644
|
+
? blockToText([
|
|
645
|
+
`- **"全部"组是汇总,不是题材** —— 实测它取的是**第一个题材组的前 N 本**,`,
|
|
646
|
+
` 不是"跨题材的整体前 10"。**不计入题材频次与占比**;`,
|
|
647
|
+
` **也不要拿"有多少本进入全部组"当指标** —— 它恒为"某题材 N、其余全 0",没有区分度`,
|
|
648
|
+
])
|
|
649
|
+
: '',
|
|
650
|
+
snapshot_scope: multiList ? '、这几个榜单' : '、这一个榜单',
|
|
651
|
+
list_rule_block: blockToText(listRule),
|
|
652
|
+
cross_list_block: blockToText(crossListRule),
|
|
653
|
+
genre_rule_block: blockToText(genreRuleBlock(ds, multiList)),
|
|
654
|
+
genre_self_check: genreSelfCheck,
|
|
655
|
+
competition_block: blockToText(competitionBlock(ds, h, multiList)),
|
|
656
|
+
aux_usage_block: blockToText(auxUsageBlock),
|
|
657
|
+
rank_change_block: blockToText(rankChangeRule),
|
|
658
|
+
section_titles_block: SECTION_TITLES.map(t => `- \`## ${t}\``).join('\n'),
|
|
659
|
+
direction_template_block: directionTemplateBlock(),
|
|
660
|
+
min_tag_chars: R.minTagChars,
|
|
661
|
+
min_directions: R.minDirections,
|
|
662
|
+
max_directions: R.maxDirections,
|
|
663
|
+
min_cjk_total: R.minCjkTotal.toLocaleString('en-US'),
|
|
664
|
+
max_cjk_total: R.maxCjkTotal.toLocaleString('en-US'),
|
|
665
|
+
min_pure_cjk: R.minPureCjk.toLocaleString('en-US'),
|
|
666
|
+
min_tables: R.minTables,
|
|
667
|
+
report_headers_block: Object.entries(headers)
|
|
668
|
+
.map(([k, v]) => ` - **${k === 'topic' ? '题材表' : k === 'competition' ? '竞争度表' : '选题对比表'}**:${v}`)
|
|
669
|
+
.join('\n'),
|
|
670
|
+
min_citations: R.minCitations,
|
|
671
|
+
min_infer_marks: R.minInferMarks,
|
|
672
|
+
self_check_extra: selfCheckExtra,
|
|
673
|
+
body_file_quoted: shellSingleQuote(bodyFile),
|
|
674
|
+
append_script_quoted: shellSingleQuote(appendScript),
|
|
675
|
+
topic_file_quoted: shellSingleQuote(topicFile),
|
|
676
|
+
project_root_arg: projectRoot ? `--project-root ${shellSingleQuote(projectRoot)} ` : '',
|
|
677
|
+
block_id_quoted: shellSingleQuote(blockId),
|
|
678
|
+
heat_metric_label_quoted: shellSingleQuote(h.label),
|
|
679
|
+
grouped_flag: ds.grouped ? '--grouped' : '--ungrouped',
|
|
680
|
+
multi_flag: multiList ? '--multi-list' : '--single-list',
|
|
681
|
+
});
|
|
294
682
|
},
|
|
683
|
+
|
|
684
|
+
// 这些函数在模块顶层定义,index.js 通过 `scan.xxx(...)` 调用。
|
|
685
|
+
// 之前多次出现“只 export 顶层函数、没挂到 scan 对象上”的问题,
|
|
686
|
+
// 运行时直接 `TypeError: scan.xxx is not a function`。
|
|
687
|
+
// 这里统一挂到 scan 对象;测试会扫描 index.js 里所有 `scan.xxx(` 调用,
|
|
688
|
+
// 逐个断言 typeof === 'function',防止再漏。
|
|
689
|
+
dataShape,
|
|
690
|
+
heatMetric,
|
|
691
|
+
platformLabel,
|
|
692
|
+
makeBlockId,
|
|
693
|
+
bodyFilePath,
|
|
295
694
|
};
|