@cloud411716/fancy-webnovel 1.0.45 → 2.2.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.
- package/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -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 +204 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
- 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 +66 -0
- 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 +76 -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 +72 -0
- 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 +37 -0
- 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 +53 -0
- 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 +40 -0
- 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 +79 -0
- 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 +132 -0
- 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 +133 -0
- 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 +160 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
- 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 +134 -0
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -160
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -275
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# 01 导入执行须知
|
|
2
|
+
|
|
3
|
+
> 本文件是导入阶段的**产物契约**:从旧稿反推出什么文件、写成什么格式、什么会被拦下。
|
|
4
|
+
> 导入的因果方向和正向四阶段相反:
|
|
5
|
+
> **旧稿已经在那里了 → 你把它读成结构化产物**,而不是照配额从零造。
|
|
6
|
+
>
|
|
7
|
+
> 导入阶段由插件驱动:它扫目录、切章、算配额、跑校验器、把错误发回来。
|
|
8
|
+
> **你不需要自己决定写哪些文件,也不需要自己跑校验脚本。**
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 一、核心铁律
|
|
13
|
+
|
|
14
|
+
> 1. **只反推,不创作**。旧稿里没有的人物、势力、地点、伏笔**不许新增**。
|
|
15
|
+
> 你的任务是"把旧稿里已经有的东西整理成结构化数据",
|
|
16
|
+
> 不是"补全一部没写完的小说"。
|
|
17
|
+
> 旧稿确实没写到的部分**留空**,由插件在报告里列成待补清单。
|
|
18
|
+
>
|
|
19
|
+
> 2. **不改旧稿正文**。正文由插件直接落盘(保留原标题),
|
|
20
|
+
> 你只负责反推结构化产物。写了正文也算违反契约。
|
|
21
|
+
>
|
|
22
|
+
> 3. **不修改 `模板/` 和 `references/` 下的任何文件**。这两个目录是只读输入。
|
|
23
|
+
>
|
|
24
|
+
> 4. **只写本轮指定的文件**。本轮要写哪几个文件,驱动会在派活时列清楚。
|
|
25
|
+
> 不要顺手多写,也不要改别的阶段的文件。
|
|
26
|
+
>
|
|
27
|
+
> 5. **引用的实体只能用 id 引用**(如 `C001`),不要在关系里写名字。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 二、产物清单
|
|
32
|
+
|
|
33
|
+
| 阶段 | 文件 | 落盘路径 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| 骨架 | 实体.json | `设定/raw/sources/骨架/实体.json` |
|
|
36
|
+
| 骨架 | 关系.json | `设定/raw/sources/骨架/关系.json` |
|
|
37
|
+
| 骨架 | 层级.json | `设定/raw/sources/骨架/层级.json` |
|
|
38
|
+
| 血肉 | 角色.json 等 8 个 | `设定/raw/sources/血肉/<名字>.json` |
|
|
39
|
+
| 节奏 | 章节规划.json 等 4 个 | `设定/raw/sources/节奏/<名字>.json` |
|
|
40
|
+
| 写作 | 4 个台账 | `设定/raw/sources/写作/台账/<名字>.json` |
|
|
41
|
+
|
|
42
|
+
**`骨架/框架.json` 由插件生成,不要写。** 里面的 `quota` 是插件从旧稿实际规模
|
|
43
|
+
反推出来的(推不出来的按公式补齐),你写的实体条数必须和它对得上。
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 三、怎么反推
|
|
48
|
+
|
|
49
|
+
### 3.1 通用原则
|
|
50
|
+
|
|
51
|
+
1. **先通读**:驱动会把旧稿的正文(或设定资料)摘要给你,先建立整体印象。
|
|
52
|
+
2. **再归类**:把旧稿里出现过的东西按类型归到实体清单里。
|
|
53
|
+
3. **只登记出现过的东西**:每个实体都要能在旧稿里找到出处
|
|
54
|
+
(角色有出场、势力有提及、地点有场景)。
|
|
55
|
+
4. **拿不准就留空**:宁可少写一个,也别编一个。插件会把没写的列进待补清单。
|
|
56
|
+
|
|
57
|
+
### 3.2 各阶段要点
|
|
58
|
+
|
|
59
|
+
| 阶段 | 反推什么 | 注意 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| 骨架 | 实体 / 关系 / 层级 | 只登记"出现过"的;层级里的卷要对应旧稿实际的卷 |
|
|
62
|
+
| 血肉 | 每个实体"具体什么样" | 旧稿里写明的外貌/动机/口吻照抄;没写的留空字符串 |
|
|
63
|
+
| 节奏 | 什么时间发生什么 | 章节号对应旧稿的实际章号;旧稿没写的章不要编 |
|
|
64
|
+
| 台账 | 已发生的状态 | 章台账按 `ch` 一章一条;人物/伏笔/时间线照实记 |
|
|
65
|
+
|
|
66
|
+
### 3.3 字段与枚举
|
|
67
|
+
|
|
68
|
+
- 字段结构、id 规则、枚举取值:**以 `模板/` 下对应模板为准**
|
|
69
|
+
(`模板/骨架/实体模板.json` 等)。
|
|
70
|
+
- 枚举词表(角色类型、冲突类型、伏笔类型…):**以 `references/glossary.md` 为准**。
|
|
71
|
+
写错枚举值会被校验器拦下。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 四、会被拦下的东西
|
|
76
|
+
|
|
77
|
+
| 类别 | 错误码 | 说明 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 必填 | `REQUIRED` | 必填字段为空 |
|
|
80
|
+
| 类型 | `TYPE` | 字段类型不对(该是数组写成了对象) |
|
|
81
|
+
| id 规则 | `ID_FORMAT` / `ID_DUP` | id 不符合 `C001` / `F001` 这类格式,或 id 重复 |
|
|
82
|
+
| 枚举 | `ENUM_INVALID` | 取值不在 glossary 的词表里 |
|
|
83
|
+
| 引用 | `REF_UNKNOWN` | 关系里引用了不存在的 id |
|
|
84
|
+
| 结构 | `DERIVATION_MISSING` | 框架.json 缺推导记录(**这个由插件生成,你不用管**) |
|
|
85
|
+
| 内容 | `NAME_PLACEHOLDER` / `TEXT_PLACEHOLDER` / `TEXT_TOO_SHORT` / `TEXT_DUPLICATE` | 实体名是"主角1"这类标签、字段是"待补充"这类占位、字段太短、同质化 |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 五、收到校验错误后怎么改
|
|
90
|
+
|
|
91
|
+
驱动会把校验错误原样发回来(`code` / `path` / `message` / `hint`)。按这个顺序处理:
|
|
92
|
+
|
|
93
|
+
1. **只改错误指向的字段**。错误没提到的部分不要动。
|
|
94
|
+
2. **按 `hint` 的方向改**,不要绕过。
|
|
95
|
+
3. **不要为了通过校验而编造内容**。校验器拦下"实体名是占位名"时,
|
|
96
|
+
正确做法是**删掉这个编出来的实体**,不是给它起个像样的名字。
|
|
97
|
+
4. 改完**重新写整个文件**(产物是整份覆盖,不是打补丁)。
|
|
98
|
+
|
|
99
|
+
**汇报格式**(每轮完成后给一段话):
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
本轮完成:骨架(实体.json / 关系.json / 层级.json)
|
|
103
|
+
- 实体 12 条:角色 5、冲突 3、势力 2、地点 2
|
|
104
|
+
- 关系 18 条、层级 1 卷 3 主线 5 支线
|
|
105
|
+
- 旧稿没写到的:地点的详细描述(已留空)
|
|
106
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-import 插件的文案模板
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const importTemplates = {
|
|
6
|
+
/**
|
|
7
|
+
* 通知文案。
|
|
8
|
+
* @param {Object} o
|
|
9
|
+
* @param {string} o.type
|
|
10
|
+
*/
|
|
11
|
+
notify({ type, err, extra, chapters, files, rounds, failed }) {
|
|
12
|
+
switch (type) {
|
|
13
|
+
case 'no_project_root':
|
|
14
|
+
return '❌ 无法确定项目目录:' + (err || '会话未提供 cwd') + '。请在项目目录下运行该命令。';
|
|
15
|
+
|
|
16
|
+
case 'not_initialized':
|
|
17
|
+
return '⚠️ 当前目录尚未初始化,请先运行 /fancy-bootstrap 进行初始化。';
|
|
18
|
+
|
|
19
|
+
case 'no_source':
|
|
20
|
+
return '❌ 导入失败:' + (err || '未知错误');
|
|
21
|
+
|
|
22
|
+
case 'no_source_arg':
|
|
23
|
+
return '⚠️ 请指定要导入的旧稿路径:\n'
|
|
24
|
+
+ '/fancy-import <旧稿目录或文件>\n'
|
|
25
|
+
+ '(目录里的 .txt / .md 会被扫描;正文与设定资料自动归类)';
|
|
26
|
+
|
|
27
|
+
case 'deploy_failed':
|
|
28
|
+
return '❌ 导入阶段文件部署失败:' + (err || '未知错误') + '。请检查磁盘空间与目录权限后重试。';
|
|
29
|
+
|
|
30
|
+
case 'setup_id_unavailable':
|
|
31
|
+
return '❌ 未能建立 llm-wiki 设定项目:' + (err || '未知原因') +
|
|
32
|
+
'\n导入的产物要落在「设定」目录下才会被纳管,请先按提示处理 llm-wiki 配置。';
|
|
33
|
+
|
|
34
|
+
case 'import_done':
|
|
35
|
+
return '✅ 导入完成:' + chapters + ' 章旧稿已转成 story-builder 格式,' +
|
|
36
|
+
(rounds || 0) + ' 轮反推的**单文件校验**都通过(跨文件一致性由骨架阶段的 `--all` 校验把关)。' +
|
|
37
|
+
'报告见 `导入报告/导入报告.md`。' +
|
|
38
|
+
(extra ? '\n\n' + extra : '');
|
|
39
|
+
|
|
40
|
+
case 'import_incomplete':
|
|
41
|
+
return '⚠️ 导入完成,但有 ' + (failed || 0) + ' 轮反推未通过校验。' +
|
|
42
|
+
'**导入的产物目前不完整**,待补清单见 `导入报告/导入报告.md`。' +
|
|
43
|
+
(extra ? '\n\n' + extra : '');
|
|
44
|
+
|
|
45
|
+
default:
|
|
46
|
+
return extra || '';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-scan plugin entry
|
|
3
|
+
*/
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
|
|
7
|
+
import { readDeployedState, deployedStateProblemText } from '#deployed-state';
|
|
8
|
+
import { localDateStamp, DEFAULT_SCAN_MODE } from '#scraper-utils';
|
|
9
|
+
import { scan, PLATFORM_TABLE, RANK_LISTS, parseScanMode } from './templates.js';
|
|
10
|
+
|
|
11
|
+
export const inject = ['commands', 'userQuestions'];
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const SCAN_SCRIPTS_DIR = join(__dirname, 'scripts');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 解析用户选择的榜单
|
|
18
|
+
* @param {string[]} selected - 用户选择的 label 数组(如 '畅销榜' 或 '全选')
|
|
19
|
+
* @param {string} platform - 平台
|
|
20
|
+
* @returns {string[]} 榜单 ID 数组,如果选择"全选"则返回所有榜单
|
|
21
|
+
*/
|
|
22
|
+
function parseRankLists(selected, platform) {
|
|
23
|
+
if (!Array.isArray(selected) || selected.length === 0) return [];
|
|
24
|
+
|
|
25
|
+
const allLists = (RANK_LISTS[platform] || []);
|
|
26
|
+
|
|
27
|
+
// 如果选择了"全选",返回所有榜单
|
|
28
|
+
if (selected.includes('全选')) {
|
|
29
|
+
return allLists.map(r => r.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 否则通过 label 查找 rank id
|
|
33
|
+
const ids = [];
|
|
34
|
+
for (const label of selected) {
|
|
35
|
+
const rank = allLists.find(r => r.label === label);
|
|
36
|
+
if (rank) ids.push(rank.id);
|
|
37
|
+
}
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 获取榜单标签列表(用于通知显示)
|
|
43
|
+
*/
|
|
44
|
+
function getRankLabels(rankIds, platform) {
|
|
45
|
+
if (!Array.isArray(rankIds) || rankIds.length === 0) return '';
|
|
46
|
+
|
|
47
|
+
const allLists = (RANK_LISTS[platform] || []);
|
|
48
|
+
if (rankIds.length === allLists.length) {
|
|
49
|
+
return '全选';
|
|
50
|
+
}
|
|
51
|
+
const labels = [];
|
|
52
|
+
for (const id of rankIds) {
|
|
53
|
+
const rank = allLists.find(r => r.id === id);
|
|
54
|
+
labels.push(rank ? rank.label : id);
|
|
55
|
+
}
|
|
56
|
+
return labels.join('、');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function apply(ctx) {
|
|
60
|
+
ctx.commands.register({
|
|
61
|
+
name: 'fancy-scan',
|
|
62
|
+
description: '📊 扫榜分析 ( usage: /fancy-scan )',
|
|
63
|
+
input: {
|
|
64
|
+
hint: '.',
|
|
65
|
+
},
|
|
66
|
+
handler: async (invocation) => {
|
|
67
|
+
const session = invocation.agent.session;
|
|
68
|
+
const { signal } = invocation;
|
|
69
|
+
|
|
70
|
+
// 获取项目根(忽略所有命令行参数,统一通过交互选择)
|
|
71
|
+
// 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
|
|
72
|
+
// 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
|
|
73
|
+
const projectRoot = session.header?.cwd ?? null;
|
|
74
|
+
if (!projectRoot) {
|
|
75
|
+
return notify(session, 'error', scan.notify({
|
|
76
|
+
type: 'no_project_root',
|
|
77
|
+
err: '会话未提供 cwd'
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 体检 .fancy-deployed —— 必须在**扫榜之前**查。
|
|
82
|
+
//
|
|
83
|
+
// 之前只检查文件存不存在,于是文件损坏时:
|
|
84
|
+
// 扫榜照跑、产物照出、命令照报成功,
|
|
85
|
+
// 但 phase 推不动(advancePhase 读不了那个文件,失败只写进 stderr),
|
|
86
|
+
// 下游 /fancy-topic 于是永远过不了 phase 检查。
|
|
87
|
+
// 用户只能反复重扫,死循环。
|
|
88
|
+
const deployedState = readDeployedState(projectRoot);
|
|
89
|
+
if (deployedState.status !== 'ok') {
|
|
90
|
+
const problem = deployedStateProblemText(deployedState, { projectRoot });
|
|
91
|
+
return notify(session, 'error', problem || scan.notify({ type: 'not_initialized' }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 忽略命令行参数,始终进入交互选择流程
|
|
95
|
+
let platform = '';
|
|
96
|
+
|
|
97
|
+
// 平台选择
|
|
98
|
+
if (!platform) {
|
|
99
|
+
if (signal?.aborted) {
|
|
100
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
101
|
+
}
|
|
102
|
+
const askedChoice = await askUserQuestion(ctx, invocation, [scan.askPlatform()]);
|
|
103
|
+
if (!askedChoice.ok) {
|
|
104
|
+
return notify(session, 'error', scan.notify({
|
|
105
|
+
type: askedChoice.failure.type,
|
|
106
|
+
err: askedChoice.failure.message
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
const choice = askedChoice.answer;
|
|
110
|
+
if (!choice?.answers?.length || !choice.answers[0]) {
|
|
111
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
112
|
+
}
|
|
113
|
+
const selectedValue = choice.answers[0]?.selected?.[0];
|
|
114
|
+
if (!selectedValue) {
|
|
115
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
116
|
+
}
|
|
117
|
+
if (signal?.aborted) {
|
|
118
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
119
|
+
}
|
|
120
|
+
const row = PLATFORM_TABLE.find(r => r[2] === selectedValue);
|
|
121
|
+
if (!row) {
|
|
122
|
+
return notify(session, 'error', scan.notify({ type: 'invalid_choice' }));
|
|
123
|
+
}
|
|
124
|
+
platform = row[1];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 榜单选择
|
|
128
|
+
let rankIds = [];
|
|
129
|
+
let rankListsQuestion = scan.askRankLists(platform);
|
|
130
|
+
|
|
131
|
+
if (signal?.aborted) {
|
|
132
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
133
|
+
}
|
|
134
|
+
const askedrankResult = await askUserQuestion(ctx, invocation, [rankListsQuestion]);
|
|
135
|
+
if (!askedrankResult.ok) {
|
|
136
|
+
return notify(session, 'error', scan.notify({
|
|
137
|
+
type: askedrankResult.failure.type,
|
|
138
|
+
err: askedrankResult.failure.message
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
const rankResult = askedrankResult.answer;
|
|
142
|
+
if (!rankResult?.answers?.length || !rankResult.answers[0]) {
|
|
143
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
144
|
+
}
|
|
145
|
+
const selectedLabels = Array.isArray(rankResult.answers[0]?.selected) ? rankResult.answers[0].selected : [];
|
|
146
|
+
if (!selectedLabels.length) {
|
|
147
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
148
|
+
}
|
|
149
|
+
if (signal?.aborted) {
|
|
150
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 解析选择的榜单
|
|
154
|
+
rankIds = parseRankLists(selectedLabels, platform);
|
|
155
|
+
// 空榜单必须硬失败:此前会一路走到"采集完成"文案,但一个文件都没采到、
|
|
156
|
+
// 也不会投喂 LLM,用户看到的却是一切正常。
|
|
157
|
+
if (!rankIds.length) {
|
|
158
|
+
return notify(session, 'error', scan.notify({ type: 'no_rank_selected' }));
|
|
159
|
+
}
|
|
160
|
+
const rankLabels = getRankLabels(rankIds, platform);
|
|
161
|
+
|
|
162
|
+
// 扫描方式选择(榜单选定之后)。
|
|
163
|
+
// 只有番茄有两条取数路径(明文接口 / 截图 OCR),其他平台 askScanMode
|
|
164
|
+
// 返回 null —— 此时不打扰用户,scanMode 留空表示"该平台无此选项"。
|
|
165
|
+
let scanMode = '';
|
|
166
|
+
const scanModeQuestion = scan.askScanMode(platform);
|
|
167
|
+
if (scanModeQuestion) {
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
170
|
+
}
|
|
171
|
+
const askedMode = await askUserQuestion(ctx, invocation, [scanModeQuestion]);
|
|
172
|
+
if (!askedMode.ok) {
|
|
173
|
+
return notify(session, 'error', scan.notify({
|
|
174
|
+
type: askedMode.failure.type,
|
|
175
|
+
err: askedMode.failure.message
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
const modeAnswer = askedMode.answer;
|
|
179
|
+
if (!modeAnswer?.answers?.length || !modeAnswer.answers[0]) {
|
|
180
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
181
|
+
}
|
|
182
|
+
// DSH 的 selected 回传的是 label 字符串(options.value 会被静默忽略)
|
|
183
|
+
const selectedMode = modeAnswer.answers[0]?.selected?.[0];
|
|
184
|
+
if (!selectedMode) {
|
|
185
|
+
return notify(session, 'error', scan.notify({ type: 'cancelled' }));
|
|
186
|
+
}
|
|
187
|
+
const parsedMode = parseScanMode(selectedMode);
|
|
188
|
+
if (!parsedMode) {
|
|
189
|
+
return notify(session, 'error', scan.notify({ type: 'invalid_scan_mode' }));
|
|
190
|
+
}
|
|
191
|
+
scanMode = parsedMode;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 依次采集每个榜单(单个榜单失败不中断,失败清单在末尾汇总)
|
|
195
|
+
const allFiles = [];
|
|
196
|
+
let phaseBlocked = null;
|
|
197
|
+
const failedRanks = [];
|
|
198
|
+
let lastDate = '';
|
|
199
|
+
for (const rankId of rankIds) {
|
|
200
|
+
const rankRow = RANK_LISTS[platform]?.find(r => r.id === rankId);
|
|
201
|
+
const rankLabel = rankRow ? rankRow.label : rankId;
|
|
202
|
+
|
|
203
|
+
const scriptResult = await spawnScript(
|
|
204
|
+
'node',
|
|
205
|
+
[join(SCAN_SCRIPTS_DIR, 'run-scan.js'),
|
|
206
|
+
'--project-root', projectRoot, '--platform', platform, '--rank', rankId,
|
|
207
|
+
'--scan-mode', scanMode || DEFAULT_SCAN_MODE],
|
|
208
|
+
// 这一层是兜底预算,必须大于 run-scan.js 内部的 scraper 预算
|
|
209
|
+
// (25 分钟,见 run-scan.js 的 SCRAPER_TIMEOUT_MS)。
|
|
210
|
+
// 若两层等长,外层会先 δ(node 启动耗时)触发:外层杀的是
|
|
211
|
+
// run-scan.js 的进程组,而 scraper 是 detached 独立成组的,
|
|
212
|
+
// 外层够不到它 —— run-scan.js 一死,它内部的定时器也没了,
|
|
213
|
+
// scraper 就成了带 chromium 的孤儿,一路跑到自然结束。
|
|
214
|
+
{ timeout: 1800000 }
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (!scriptResult.ok) {
|
|
218
|
+
const innerErr = (typeof scriptResult.error === 'object' && scriptResult.error !== null)
|
|
219
|
+
? (scriptResult.error.message || JSON.stringify(scriptResult.error))
|
|
220
|
+
: String(scriptResult.error || '未知错误');
|
|
221
|
+
// 此前任一榜单失败就整体中断,已采集成功的榜单产物被丢弃,
|
|
222
|
+
// 用户既不知道采到了什么,也拿不到那些文件。
|
|
223
|
+
failedRanks.push({ label: rankLabel, error: innerErr });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 解析 receipt
|
|
228
|
+
let receipt;
|
|
229
|
+
try { receipt = JSON.parse(scriptResult.output); } catch (_) {
|
|
230
|
+
failedRanks.push({ label: rankLabel, error: '采集结果解析失败(输出非 JSON)' });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lastDate = receipt.date || lastDate;
|
|
235
|
+
const files = receipt.scan_files || [];
|
|
236
|
+
allFiles.push(...files);
|
|
237
|
+
// scraper 内部的部分失败(番茄某个题材采失败)必须透出:
|
|
238
|
+
// run-scan.js 对这种情况仍报 ok:true(只要有文件),
|
|
239
|
+
// 若这里不读 partialReasons,失败信息会在插件层被整个吞掉,
|
|
240
|
+
// 用户只看到"采集完成",不知道哪个题材没采到。
|
|
241
|
+
if (Array.isArray(receipt.partialReasons) && receipt.partialReasons.length) {
|
|
242
|
+
failedRanks.push({
|
|
243
|
+
label: rankLabel,
|
|
244
|
+
error: '部分题材失败:' + receipt.partialReasons.join(';'),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// phase 推不动 = 整个流程卡住(/fancy-topic 要求 phase === 'scanned')。
|
|
248
|
+
// run-scan.js 以前只把失败写进 stderr,这里不读就等于没发生。
|
|
249
|
+
if (receipt.phase_blocked === true) {
|
|
250
|
+
const pa = receipt.phase_advance || {};
|
|
251
|
+
phaseBlocked = {
|
|
252
|
+
reason: pa.reason || '未知原因',
|
|
253
|
+
message: pa.message || '',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 一个榜单都没采成功 → 硬失败
|
|
259
|
+
if (allFiles.length === 0) {
|
|
260
|
+
const detail = failedRanks.length
|
|
261
|
+
? failedRanks.map(f => f.label + ':' + f.error).join(';')
|
|
262
|
+
: '未产生任何采集文件';
|
|
263
|
+
return notify(session, 'error', scan.notify({ type: 'scraper_failed', err: detail }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 日期口径必须与 run-scan.js 一致(本地时区)。此前 fallback 用
|
|
267
|
+
// toISOString()(UTC),凌晨 0-8 点会算出比采集时点早一天的文件名。
|
|
268
|
+
const topicFile = allFiles.length > 0
|
|
269
|
+
? join(projectRoot, '扫榜结果', `topic_decision_${lastDate || localDateStamp()}.md`)
|
|
270
|
+
: '';
|
|
271
|
+
|
|
272
|
+
// 通知用户采集完成(返回结果会显示在消息栏左侧)
|
|
273
|
+
const result = notify(session, 'success', scan.notify({
|
|
274
|
+
type: 'handover',
|
|
275
|
+
files: allFiles,
|
|
276
|
+
topicFile,
|
|
277
|
+
projectRoot,
|
|
278
|
+
failedRanks,
|
|
279
|
+
scanMode,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
// phase 推不动就别再触发 LLM 分析了 —— 分析产物写进 topic_decision 后,
|
|
283
|
+
// /fancy-topic 依然会因为 phase 不对而跑不了,纯属浪费。
|
|
284
|
+
if (phaseBlocked) {
|
|
285
|
+
const detail = deployedStateProblemText(
|
|
286
|
+
readDeployedState(projectRoot),
|
|
287
|
+
{ projectRoot }
|
|
288
|
+
) || '';
|
|
289
|
+
return notify(session, 'error', scan.notify({
|
|
290
|
+
type: 'phase_advance_failed',
|
|
291
|
+
err: phaseBlocked.reason + (phaseBlocked.message ? `(${phaseBlocked.message})` : ''),
|
|
292
|
+
detail,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 触发 LLM 分析
|
|
297
|
+
if (allFiles.length > 0 && rankLabels) {
|
|
298
|
+
const follow = await llmFollowup(invocation, scan.llmAnalysisPrompt({
|
|
299
|
+
platform,
|
|
300
|
+
rankLists: rankLabels ? rankLabels.split('、') : ['全选'],
|
|
301
|
+
date: lastDate,
|
|
302
|
+
files: allFiles,
|
|
303
|
+
topicFile,
|
|
304
|
+
projectRoot,
|
|
305
|
+
}));
|
|
306
|
+
// llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
|
|
307
|
+
// 否则投递失败也会报"采集完成,正在将数据交给 LLM 进行分析"
|
|
308
|
+
if (!follow || follow.ok !== true) {
|
|
309
|
+
return notify(session, 'error', scan.notify({
|
|
310
|
+
type: 'llm_delivery_failed',
|
|
311
|
+
err: (follow && follow.error) || '提示词投递失败'
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|