@cloud411716/fancy-webnovel 3.0.4 → 4.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.
- package/docs/material-library/README.md +6 -5
- package/docs/material-library/prompt.md +3 -2
- package/docs/workspace-mcp.md +2 -2
- package/lib/material-topic.js +248 -0
- package/lib/topic-candidates.js +157 -0
- package/package.json +4 -2
- package/plugins/fancy-material/genres.js +4 -36
- package/plugins/fancy-material/index.js +18 -32
- package/plugins/fancy-material/templates.js +11 -3
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +11 -18
- package/plugins/fancy-scan/scripts/report-validate.js +39 -76
- package/plugins/fancy-scan/templates/report-schema.js +11 -8
- package/plugins/fancy-topic/index.js +216 -546
- package/plugins/fancy-topic/prompts/02-setup-prompt.md +112 -0
- package/plugins/fancy-topic/scripts/save-minimal-topic.js +97 -0
- package/plugins/fancy-topic/templates.js +104 -6
|
@@ -297,12 +297,13 @@ DEFINE ANALYZER material_zh
|
|
|
297
297
|
/fancy-material export 导出 <工作区>/素材库.md
|
|
298
298
|
```
|
|
299
299
|
|
|
300
|
-
|
|
301
|
-
让你选择本次使用哪一份;插件只读取选中的那份文件里的「选题方向」,不会合并
|
|
302
|
-
多个报告。只有一份时自动使用;没有时回退到番茄扫榜题材。
|
|
300
|
+
素材题材只认用户选题链路:
|
|
303
301
|
|
|
304
|
-
|
|
305
|
-
`
|
|
302
|
+
1. `_novel_topic.json`(最终选题)
|
|
303
|
+
2. `_novel_topic_suggestion.json`(扫榜候选方向选择结果)
|
|
304
|
+
3. 两者都没有时,弹框确认后使用内置全题材(番茄男频 + 女频)
|
|
305
|
+
|
|
306
|
+
不再从 `topic_decision` 里猜题材。
|
|
306
307
|
|
|
307
308
|
不带参数时,插件会覆盖全部 54 个 kind:
|
|
308
309
|
|
|
@@ -238,8 +238,9 @@ brainstorm → 给活人打印死亡证明的打印店
|
|
|
238
238
|
## 七、题材来源与优先级
|
|
239
239
|
|
|
240
240
|
- 当前模式:`{{MATERIAL_MODE}}`
|
|
241
|
-
- `
|
|
242
|
-
- `
|
|
241
|
+
- `topic`:题材来自 `_novel_topic.json`。
|
|
242
|
+
- `suggestion`:题材来自 `_novel_topic_suggestion.json`。
|
|
243
|
+
- `builtin`:没有选题记录,使用内置全题材。
|
|
243
244
|
- 本次题材列表:{{GENRES}}
|
|
244
245
|
|
|
245
246
|
不同题材优先补充的 kind:
|
package/docs/workspace-mcp.md
CHANGED
|
@@ -147,8 +147,8 @@ namespace: fancy
|
|
|
147
147
|
### 创作
|
|
148
148
|
|
|
149
149
|
```text
|
|
150
|
-
/fancy-topic #
|
|
151
|
-
/fancy-topic setup #
|
|
150
|
+
/fancy-topic # 选 decision 文件、平台和候选方向 → _novel_topic_suggestion.json
|
|
151
|
+
/fancy-topic setup # 素材驱动生成 5 个候选方案 → _novel_topic.json + 项目库 + 用户
|
|
152
152
|
/fancy-build scale|macro|cast|rhythm|outline|write
|
|
153
153
|
/fancy-export
|
|
154
154
|
```
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* material-topic.js —— 素材驱动选题的最小契约
|
|
3
|
+
*
|
|
4
|
+
* 2026-11 新链路:
|
|
5
|
+
* - `_novel_topic_suggestion.json` 只保存扫榜候选方向的选择结果;
|
|
6
|
+
* - `_novel_topic.json` 只保存题材 +「能力」组 +「剧情与脑洞」组的素材。
|
|
7
|
+
*
|
|
8
|
+
* 不再保存 11 项方向设计字段。fancy-build 目前仍读旧格式,
|
|
9
|
+
* 这轮先不动,等后续单独适配。
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
12
|
+
import { join, resolve } from 'path';
|
|
13
|
+
|
|
14
|
+
/** 「能力」组:与 plugins/fancy-material/kinds.js 的 KIND_GROUPS 保持一致 */
|
|
15
|
+
export const ABILITY_KINDS = [
|
|
16
|
+
'ability_mechanism',
|
|
17
|
+
'ability_activation',
|
|
18
|
+
'ability_cost',
|
|
19
|
+
'ability_limit',
|
|
20
|
+
'ability_counter',
|
|
21
|
+
'power_source',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** 「剧情与脑洞」组 */
|
|
25
|
+
export const PLOT_KINDS = [
|
|
26
|
+
'brainstorm',
|
|
27
|
+
'conflict_seed',
|
|
28
|
+
'conflict_escalation',
|
|
29
|
+
'plot_twist',
|
|
30
|
+
'mystery_seed',
|
|
31
|
+
'payoff_scene',
|
|
32
|
+
'trope',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** 内置题材:番茄男频 + 女频主流分类(fancy-material 兜底) */
|
|
36
|
+
export const BUILTIN_GENRES = [
|
|
37
|
+
'西方奇幻',
|
|
38
|
+
'东方仙侠',
|
|
39
|
+
'科幻末世',
|
|
40
|
+
'都市日常',
|
|
41
|
+
'都市修真',
|
|
42
|
+
'都市高武',
|
|
43
|
+
'历史古代',
|
|
44
|
+
'战神赘婿',
|
|
45
|
+
'都市种田',
|
|
46
|
+
'传统玄幻',
|
|
47
|
+
'历史脑洞',
|
|
48
|
+
'悬疑脑洞',
|
|
49
|
+
'都市脑洞',
|
|
50
|
+
'玄幻脑洞',
|
|
51
|
+
'悬疑灵异',
|
|
52
|
+
'抗战谍战',
|
|
53
|
+
'游戏体育',
|
|
54
|
+
'动漫衍生',
|
|
55
|
+
'男频衍生',
|
|
56
|
+
'古风世情',
|
|
57
|
+
'女频衍生',
|
|
58
|
+
'玄幻言情',
|
|
59
|
+
'种田',
|
|
60
|
+
'年代',
|
|
61
|
+
'现言脑洞',
|
|
62
|
+
'宫斗宅斗',
|
|
63
|
+
'古言脑洞',
|
|
64
|
+
'快穿',
|
|
65
|
+
'青春甜宠',
|
|
66
|
+
'星光璀璨',
|
|
67
|
+
'女频悬疑',
|
|
68
|
+
'职场婚恋',
|
|
69
|
+
'豪门总裁',
|
|
70
|
+
'民国言情',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const TOPIC_PLATFORMS = ['番茄', '起点', '晋江', '七猫'];
|
|
74
|
+
|
|
75
|
+
export const NOVEL_TOPIC_FILE = '_novel_topic.json';
|
|
76
|
+
export const NOVEL_SUGGESTION_FILE = '_novel_topic_suggestion.json';
|
|
77
|
+
|
|
78
|
+
function isPlainObject(value) {
|
|
79
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nonEmptyString(value) {
|
|
83
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isControlFree(text) {
|
|
87
|
+
return !/[\x00-\x1f\x7f]/.test(text);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateKindGroup(group, kinds, label) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
if (!isPlainObject(group)) {
|
|
93
|
+
return [`${label} 必须是对象`];
|
|
94
|
+
}
|
|
95
|
+
const keys = Object.keys(group);
|
|
96
|
+
for (const kind of kinds) {
|
|
97
|
+
if (!(kind in group)) {
|
|
98
|
+
errors.push(`${label} 缺 kind: ${kind}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const value = group[kind];
|
|
102
|
+
if (!nonEmptyString(value)) {
|
|
103
|
+
errors.push(`${label}.${kind} 必须是非空字符串`);
|
|
104
|
+
} else if (!isControlFree(value)) {
|
|
105
|
+
errors.push(`${label}.${kind} 不能含换行/控制字符`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
if (!kinds.includes(key)) {
|
|
110
|
+
errors.push(`${label} 出现未知 kind: ${key}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return errors;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 校验最小选题对象:{ genre, ability, plot }。
|
|
118
|
+
*
|
|
119
|
+
* @param {object} data
|
|
120
|
+
* @returns {{ok: boolean, errors: string[], normalized: object|null}}
|
|
121
|
+
*/
|
|
122
|
+
export function validateMinimalTopic(data) {
|
|
123
|
+
const errors = [];
|
|
124
|
+
if (!isPlainObject(data)) {
|
|
125
|
+
return { ok: false, errors: ['顶层必须是 JSON 对象'], normalized: null };
|
|
126
|
+
}
|
|
127
|
+
if (!nonEmptyString(data.genre)) {
|
|
128
|
+
errors.push('genre 必须是非空字符串');
|
|
129
|
+
} else if (!isControlFree(data.genre)) {
|
|
130
|
+
errors.push('genre 不能含换行/控制字符');
|
|
131
|
+
}
|
|
132
|
+
errors.push(...validateKindGroup(data.ability, ABILITY_KINDS, 'ability'));
|
|
133
|
+
errors.push(...validateKindGroup(data.plot, PLOT_KINDS, 'plot'));
|
|
134
|
+
if (errors.length) return { ok: false, errors, normalized: null };
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
errors: [],
|
|
138
|
+
normalized: {
|
|
139
|
+
genre: data.genre.trim(),
|
|
140
|
+
ability: Object.fromEntries(ABILITY_KINDS.map(k => [k, data.ability[k].trim()])),
|
|
141
|
+
plot: Object.fromEntries(PLOT_KINDS.map(k => [k, data.plot[k].trim()])),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 校验 `_novel_topic_suggestion.json`。
|
|
148
|
+
*
|
|
149
|
+
* @param {object} data
|
|
150
|
+
* @returns {{ok: boolean, errors: string[], normalized: object|null}}
|
|
151
|
+
*/
|
|
152
|
+
export function validateSuggestion(data) {
|
|
153
|
+
const errors = [];
|
|
154
|
+
if (!isPlainObject(data)) {
|
|
155
|
+
return { ok: false, errors: ['顶层必须是 JSON 对象'], normalized: null };
|
|
156
|
+
}
|
|
157
|
+
for (const field of ['direction_title', 'genre', 'positioning']) {
|
|
158
|
+
if (!nonEmptyString(data[field])) errors.push(`${field} 必须是非空字符串`);
|
|
159
|
+
else if (!isControlFree(data[field])) errors.push(`${field} 不能含换行/控制字符`);
|
|
160
|
+
}
|
|
161
|
+
if (!nonEmptyString(data.platform)) {
|
|
162
|
+
errors.push('platform 必须是非空字符串');
|
|
163
|
+
} else if (!TOPIC_PLATFORMS.includes(data.platform)) {
|
|
164
|
+
errors.push(`platform 必须是 ${TOPIC_PLATFORMS.join(' / ')} 之一`);
|
|
165
|
+
}
|
|
166
|
+
if (!nonEmptyString(data.source_decision_file)) {
|
|
167
|
+
errors.push('source_decision_file 必须是非空字符串');
|
|
168
|
+
} else {
|
|
169
|
+
const source = resolve(data.source_decision_file);
|
|
170
|
+
if (!existsSync(source) || !statSync(source).isFile()) {
|
|
171
|
+
errors.push('source_decision_file 不存在或不是文件');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (data.market_score !== undefined && data.market_score !== null
|
|
175
|
+
&& typeof data.market_score !== 'number' && typeof data.market_score !== 'string') {
|
|
176
|
+
errors.push('market_score 必须是数字或字符串');
|
|
177
|
+
}
|
|
178
|
+
if (data.recommendation !== undefined && data.recommendation !== null
|
|
179
|
+
&& typeof data.recommendation !== 'string') {
|
|
180
|
+
errors.push('recommendation 必须是字符串');
|
|
181
|
+
}
|
|
182
|
+
if (errors.length) return { ok: false, errors, normalized: null };
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
errors: [],
|
|
186
|
+
normalized: {
|
|
187
|
+
platform: data.platform,
|
|
188
|
+
source_decision_file: resolve(data.source_decision_file),
|
|
189
|
+
direction_title: data.direction_title.trim(),
|
|
190
|
+
genre: data.genre.trim(),
|
|
191
|
+
positioning: data.positioning.trim(),
|
|
192
|
+
market_score: data.market_score ?? null,
|
|
193
|
+
recommendation: typeof data.recommendation === 'string' ? data.recommendation.trim() : '',
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readJson(file) {
|
|
199
|
+
try {
|
|
200
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
201
|
+
return isPlainObject(data) ? data : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 读取最小 `_novel_topic.json`;旧格式没有 ability/plot 时返回 null */
|
|
208
|
+
export function readMinimalTopic(root) {
|
|
209
|
+
const data = readJson(join(root, NOVEL_TOPIC_FILE));
|
|
210
|
+
if (!data) return null;
|
|
211
|
+
const v = validateMinimalTopic(data);
|
|
212
|
+
return v.ok ? v.normalized : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 读取 suggestion;旧格式也兼容(有 genre 即可) */
|
|
216
|
+
export function readSuggestion(root) {
|
|
217
|
+
const data = readJson(join(root, NOVEL_SUGGESTION_FILE));
|
|
218
|
+
if (!data) return null;
|
|
219
|
+
const v = validateSuggestion(data);
|
|
220
|
+
return v.ok ? v.normalized : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* fancy-material 的题材来源。
|
|
225
|
+
*
|
|
226
|
+
* 优先级:_novel_topic.json > _novel_topic_suggestion.json > null。
|
|
227
|
+
* null 表示两个文件都不存在/不可读,由调用方弹框确认后使用 BUILTIN_GENRES。
|
|
228
|
+
*
|
|
229
|
+
* @param {string} root
|
|
230
|
+
* @returns {{mode: 'topic'|'suggestion'|null, genres: string[], source: string|null}}
|
|
231
|
+
*/
|
|
232
|
+
export function resolveMaterialTopic(root) {
|
|
233
|
+
// 新格式优先;旧格式(有 genre、没有 ability/plot)也继续可用。
|
|
234
|
+
const topic = readJson(join(root, NOVEL_TOPIC_FILE));
|
|
235
|
+
if (topic && nonEmptyString(topic.genre)) {
|
|
236
|
+
const normalized = validateMinimalTopic(topic);
|
|
237
|
+
if (normalized.ok || (Array.isArray(topic.genres) && topic.genres.length)) {
|
|
238
|
+
const genres = normalized.ok ? [normalized.normalized.genre] : [String(topic.genre).trim()];
|
|
239
|
+
return { mode: 'topic', genres, source: NOVEL_TOPIC_FILE };
|
|
240
|
+
}
|
|
241
|
+
return { mode: 'topic', genres: [String(topic.genre).trim()], source: NOVEL_TOPIC_FILE };
|
|
242
|
+
}
|
|
243
|
+
const suggestion = readJson(join(root, NOVEL_SUGGESTION_FILE));
|
|
244
|
+
if (suggestion && nonEmptyString(suggestion.genre)) {
|
|
245
|
+
return { mode: 'suggestion', genres: [String(suggestion.genre).trim()], source: NOVEL_SUGGESTION_FILE };
|
|
246
|
+
}
|
|
247
|
+
return { mode: null, genres: [], source: null };
|
|
248
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* topic-candidates.js —— 从 topic_decision 文件里解析候选方向。
|
|
3
|
+
*
|
|
4
|
+
* 新格式(2026-11)每个方向只要求四项:
|
|
5
|
+
* - **题材**:
|
|
6
|
+
* - **一句话定位**:
|
|
7
|
+
* - **市场评分**:
|
|
8
|
+
* - **推荐理由**:
|
|
9
|
+
*
|
|
10
|
+
* 旧格式里的 11 项仍然兼容:只取「定位」,题材从方向标题前缀推导。
|
|
11
|
+
*/
|
|
12
|
+
import { TOPIC_PLATFORMS } from '#material-topic';
|
|
13
|
+
|
|
14
|
+
export const PLATFORM_OPTIONS = [
|
|
15
|
+
{ label: '番茄', value: 'fanqie' },
|
|
16
|
+
{ label: '起点', value: 'qidian' },
|
|
17
|
+
{ label: '晋江', value: 'jinjiang' },
|
|
18
|
+
{ label: '七猫', value: 'qimao' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const PLATFORM_DIR = PLATFORM_OPTIONS.map((p) => ({
|
|
22
|
+
value: p.value,
|
|
23
|
+
label: p.label,
|
|
24
|
+
lowerValue: String(p.value).toLowerCase(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
function platformOfHeader(text) {
|
|
28
|
+
const core = String(text || '')
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/[-—–\s]*(start|end)\s*$/i, '')
|
|
31
|
+
.trim();
|
|
32
|
+
if (!core) return null;
|
|
33
|
+
for (const p of PLATFORM_DIR) {
|
|
34
|
+
if (core.startsWith(p.label)) return p.value;
|
|
35
|
+
if (core.toLowerCase().startsWith(p.lowerValue)) return p.value;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function platformLabel(value) {
|
|
41
|
+
return PLATFORM_OPTIONS.find((p) => p.value === value)?.label || value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cleanValue(value) {
|
|
45
|
+
return String(value || '')
|
|
46
|
+
.replace(/\*\*/g, '')
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseScore(value) {
|
|
51
|
+
const text = cleanValue(value);
|
|
52
|
+
const m = text.match(/-?\d+(?:\.\d+)?/);
|
|
53
|
+
return m ? Number(m[0]) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function splitTitleGenre(title) {
|
|
57
|
+
const text = cleanValue(title);
|
|
58
|
+
const m = text.match(/^([^·::|—–-]+?)\s*(?:[·::|—–-]|$)/);
|
|
59
|
+
return m ? m[1].trim() : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} CandidateDirection
|
|
64
|
+
* @property {string} title
|
|
65
|
+
* @property {string} genre
|
|
66
|
+
* @property {string} positioning
|
|
67
|
+
* @property {number|string|null} market_score
|
|
68
|
+
* @property {string} recommendation
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} content
|
|
73
|
+
* @returns {{platforms: Array<{value:string,label:string,directions:CandidateDirection[]}>}}
|
|
74
|
+
*/
|
|
75
|
+
export function parseDecisionCandidates(content) {
|
|
76
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
77
|
+
const sections = [];
|
|
78
|
+
let cur = null;
|
|
79
|
+
let inFence = false;
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
83
|
+
inFence = !inFence;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inFence) continue;
|
|
87
|
+
|
|
88
|
+
const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
89
|
+
if (heading && /^#\s/.test(line)) {
|
|
90
|
+
const platform = platformOfHeader(heading[1]);
|
|
91
|
+
if (platform) {
|
|
92
|
+
if (cur) sections.push(cur);
|
|
93
|
+
cur = { platform, lines: [] };
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (cur) cur.lines.push(line);
|
|
98
|
+
}
|
|
99
|
+
if (cur) sections.push(cur);
|
|
100
|
+
|
|
101
|
+
const byPlatform = new Map();
|
|
102
|
+
for (const section of sections) {
|
|
103
|
+
const directions = parseDirections(section.lines);
|
|
104
|
+
if (!directions.length) continue;
|
|
105
|
+
const existing = byPlatform.get(section.platform);
|
|
106
|
+
if (existing) existing.directions.push(...directions);
|
|
107
|
+
else byPlatform.set(section.platform, {
|
|
108
|
+
value: section.platform,
|
|
109
|
+
label: platformLabel(section.platform),
|
|
110
|
+
directions,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const order = new Map(PLATFORM_OPTIONS.map((p, i) => [p.value, i]));
|
|
115
|
+
const platforms = [...byPlatform.values()].sort(
|
|
116
|
+
(a, b) => (order.get(a.value) ?? 99) - (order.get(b.value) ?? 99),
|
|
117
|
+
);
|
|
118
|
+
return { platforms };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseDirections(lines) {
|
|
122
|
+
const starts = [];
|
|
123
|
+
lines.forEach((line, index) => {
|
|
124
|
+
if (/^#{2,4}\s*方向\s*\d+\s*[::]/.test(line.trim())) starts.push(index);
|
|
125
|
+
});
|
|
126
|
+
const out = [];
|
|
127
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
128
|
+
const start = starts[i];
|
|
129
|
+
const end = i + 1 < starts.length ? starts[i + 1] : lines.length;
|
|
130
|
+
const block = lines.slice(start, end);
|
|
131
|
+
const title = cleanValue(block[0].replace(/^#{2,4}\s*方向\s*\d+\s*[::]\s*/, ''));
|
|
132
|
+
const fields = {};
|
|
133
|
+
for (const line of block.slice(1)) {
|
|
134
|
+
const m = line.match(/^\s*[-*+]?\s*\*\*([^*]+?)\*\*\s*[::]\s*(.+)$/);
|
|
135
|
+
if (!m) continue;
|
|
136
|
+
const key = cleanValue(m[1]);
|
|
137
|
+
const value = cleanValue(m[2]);
|
|
138
|
+
if (!value) continue;
|
|
139
|
+
fields[key] = value;
|
|
140
|
+
}
|
|
141
|
+
const positioning = fields['一句话定位'] || fields['定位'] || '';
|
|
142
|
+
const genre = fields['题材'] || splitTitleGenre(title);
|
|
143
|
+
const recommendation = fields['推荐理由'] || fields['优先级'] || '';
|
|
144
|
+
const market_score = parseScore(fields['市场评分'] ?? fields['评分'] ?? '');
|
|
145
|
+
if (!title || !genre || !positioning) continue;
|
|
146
|
+
out.push({
|
|
147
|
+
title,
|
|
148
|
+
genre,
|
|
149
|
+
positioning,
|
|
150
|
+
market_score,
|
|
151
|
+
recommendation,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { TOPIC_PLATFORMS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud411716/fancy-webnovel",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"#projects": "./lib/projects.js",
|
|
27
27
|
"#db-init": "./lib/db-init.js",
|
|
28
28
|
"#surreal-server": "./lib/surreal-server.js",
|
|
29
|
-
"#material-db": "./lib/material-db.js"
|
|
29
|
+
"#material-db": "./lib/material-db.js",
|
|
30
|
+
"#material-topic": "./lib/material-topic.js",
|
|
31
|
+
"#topic-candidates": "./lib/topic-candidates.js"
|
|
30
32
|
},
|
|
31
33
|
"engines": {
|
|
32
34
|
"node": ">=18.0.0",
|
|
@@ -15,42 +15,10 @@ import fs from 'fs';
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import { workspacePaths } from '#workspace';
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'都市日常',
|
|
23
|
-
'都市修真',
|
|
24
|
-
'都市高武',
|
|
25
|
-
'历史古代',
|
|
26
|
-
'战神赘婿',
|
|
27
|
-
'都市种田',
|
|
28
|
-
'传统玄幻',
|
|
29
|
-
'历史脑洞',
|
|
30
|
-
'悬疑脑洞',
|
|
31
|
-
'都市脑洞',
|
|
32
|
-
'玄幻脑洞',
|
|
33
|
-
'悬疑灵异',
|
|
34
|
-
'抗战谍战',
|
|
35
|
-
'游戏体育',
|
|
36
|
-
'动漫衍生',
|
|
37
|
-
'男频衍生',
|
|
38
|
-
'古风世情',
|
|
39
|
-
'女频衍生',
|
|
40
|
-
'玄幻言情',
|
|
41
|
-
'种田',
|
|
42
|
-
'年代',
|
|
43
|
-
'现言脑洞',
|
|
44
|
-
'宫斗宅斗',
|
|
45
|
-
'古言脑洞',
|
|
46
|
-
'快穿',
|
|
47
|
-
'青春甜宠',
|
|
48
|
-
'星光璀璨',
|
|
49
|
-
'女频悬疑',
|
|
50
|
-
'职场婚恋',
|
|
51
|
-
'豪门总裁',
|
|
52
|
-
'民国言情',
|
|
53
|
-
];
|
|
18
|
+
import { BUILTIN_GENRES } from '#material-topic';
|
|
19
|
+
|
|
20
|
+
/** 兼容旧名字:内置全题材列表统一放在 lib/material-topic.js */
|
|
21
|
+
export const FALLBACK_GENRES = BUILTIN_GENRES;
|
|
54
22
|
|
|
55
23
|
function unique(values) {
|
|
56
24
|
const seen = new Set();
|
|
@@ -22,9 +22,10 @@ import {
|
|
|
22
22
|
workspacePaths,
|
|
23
23
|
} from '#workspace';
|
|
24
24
|
import { connectMaterial, ensureMaterialDatabase, materialDatabaseName, removeMaterialDatabase } from '#material-db';
|
|
25
|
+
import { BUILTIN_GENRES, resolveMaterialTopic } from '#material-topic';
|
|
25
26
|
import { makeWaitForTurn } from '../fancy-story-builder/writing-driver.js';
|
|
26
27
|
import { exportMaterials } from './exporter.js';
|
|
27
|
-
import { allKnownGenres
|
|
28
|
+
import { allKnownGenres } from './genres.js';
|
|
28
29
|
import { buildExistingIndex, countByKind, countKind } from './inventory.js';
|
|
29
30
|
import { KIND_LABELS, MATERIAL_KINDS, focusBlock, renderKindTable, resolveFocus } from './kinds.js';
|
|
30
31
|
import { nextBatchRequest, planMaterialQueue } from './queue.js';
|
|
@@ -388,38 +389,27 @@ async function runGenerate(ctx, invocation, session, focusRaw = '') {
|
|
|
388
389
|
return notify(session, 'error', '❌ 未识别素材类型:' + focusRaw
|
|
389
390
|
+ '\n可用示例:地名、人名、姓氏、门派、技能、超能力、武器、颜色、特效、脑洞、人物性格、关系、场景。');
|
|
390
391
|
}
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (topicFiles.length > 1) {
|
|
397
|
-
const options = topicFiles.map((file) => {
|
|
398
|
-
let genres = [];
|
|
399
|
-
try { genres = parseTopicDecisionGenres(fs.readFileSync(file, 'utf8')); } catch { /* preview only */ }
|
|
400
|
-
return {
|
|
401
|
-
label: path.basename(file),
|
|
402
|
-
description: genres.length ? genres.join('、') : '未解析到选题方向',
|
|
403
|
-
};
|
|
404
|
-
});
|
|
392
|
+
// 素材题材只认用户选题链路:
|
|
393
|
+
// _novel_topic.json > _novel_topic_suggestion.json > 内置全题材。
|
|
394
|
+
// 不再从 topic_decision 里猜题材,避免素材和用户最终选题脱节。
|
|
395
|
+
let plan = resolveMaterialTopic(root);
|
|
396
|
+
if (!plan.mode) {
|
|
405
397
|
const asked = await askUserQuestion(ctx, invocation, [{
|
|
406
|
-
id: 'material-
|
|
407
|
-
header: '
|
|
408
|
-
question: '
|
|
409
|
-
detail: '
|
|
410
|
-
options
|
|
398
|
+
id: 'material-builtin-confirm',
|
|
399
|
+
header: '没有选题记录',
|
|
400
|
+
question: '当前没有执行过扫榜和 /fancy-topic,将使用内置全题材生成素材(男频 + 女频)。是否继续?',
|
|
401
|
+
detail: '内置题材共 ' + BUILTIN_GENRES.length + ' 类;确认后所有 kind 都会按全题材生成。',
|
|
402
|
+
options: [
|
|
403
|
+
{ label: '确认', description: '使用内置全题材生成素材' },
|
|
404
|
+
{ label: '取消', description: '不生成任何素材' },
|
|
405
|
+
],
|
|
411
406
|
}]);
|
|
412
407
|
if (!asked.ok) {
|
|
413
408
|
return notify(session, 'error', asked.failure?.message || '❌ 提问失败');
|
|
414
409
|
}
|
|
415
410
|
const selected = asked.answer?.answers?.[0]?.selected?.[0];
|
|
416
|
-
if (
|
|
417
|
-
|
|
418
|
-
if (!selectedTopicFile) {
|
|
419
|
-
return notify(session, 'error', '❌ 未找到选中的选题文件:' + selected);
|
|
420
|
-
}
|
|
421
|
-
} else if (topicFiles.length === 1) {
|
|
422
|
-
selectedTopicFile = topicFiles[0];
|
|
411
|
+
if (selected !== '确认') return notify(session, 'success', '操作已取消。');
|
|
412
|
+
plan = { mode: 'builtin', genres: [...BUILTIN_GENRES], topicFile: null };
|
|
423
413
|
}
|
|
424
414
|
|
|
425
415
|
const config = generationConfig();
|
|
@@ -429,10 +419,6 @@ async function runGenerate(ctx, invocation, session, focusRaw = '') {
|
|
|
429
419
|
return notify(session, 'error', '❌ 部署素材查询脚本失败:\n' + (tools.problems.join('\n') || '未知错误'));
|
|
430
420
|
}
|
|
431
421
|
const database = materialDatabaseName(readWorkspace(root));
|
|
432
|
-
const plan = resolveMaterialGenres(root, { topicFile: selectedTopicFile || undefined });
|
|
433
|
-
if (selectedTopicFile && plan.mode !== 'topic_decision') {
|
|
434
|
-
return notify(session, 'error', '❌ 选中的文件没有解析到「选题方向」:' + path.basename(selectedTopicFile));
|
|
435
|
-
}
|
|
436
422
|
const counts = await countByKind(root, plan.genres);
|
|
437
423
|
const kinds = [...new Set(focus.kinds.length ? focus.kinds : MATERIAL_KINDS)];
|
|
438
424
|
const planned = planMaterialQueue({ kinds, target: config.targetPerKind });
|
|
@@ -791,7 +777,7 @@ export async function apply(ctx) {
|
|
|
791
777
|
try { root = workspaceOf(session); } catch { /* prompt also works outside a workspace */ }
|
|
792
778
|
const genres = allKnownGenres(root);
|
|
793
779
|
const rendered = renderBasePrompt({
|
|
794
|
-
mode: '
|
|
780
|
+
mode: 'builtin',
|
|
795
781
|
genres,
|
|
796
782
|
topicFile: '(prompt 子命令固定输出全题材)',
|
|
797
783
|
existingIndex: '(运行 /fancy-material 时由插件提供)',
|
|
@@ -17,8 +17,9 @@ export function helpText() {
|
|
|
17
17
|
说明:
|
|
18
18
|
- 素材是原子积木,每条只有 id / kind / name / tags / genres / note / source。
|
|
19
19
|
- 每次运行:每个 kind 新增 50 条;每批最多 10 条,插件自动分批直到完成。
|
|
20
|
-
- 未指定类型:54 个 kind
|
|
21
|
-
-
|
|
20
|
+
- 未指定类型:54 个 kind 全部排队。
|
|
21
|
+
- 题材来源:优先 _novel_topic.json,其次 _novel_topic_suggestion.json;都没有时弹框确认后使用内置全题材。
|
|
22
|
+
- 指定类型:只生成指定 kind,题材来源不变。
|
|
22
23
|
- 不设库内上限:运行两次就是每个 kind 累计 100 条;已有素材只用于去重。
|
|
23
24
|
- 中断后重跑会开始新一轮 50 条配额,已经导入的素材不会撤销。
|
|
24
25
|
- 只追加,不覆盖;同 kind 同 name 的素材会被跳过。
|
|
@@ -103,7 +104,14 @@ export function generationPrompt({
|
|
|
103
104
|
lines.push('- 工作区目录:' + workspace);
|
|
104
105
|
lines.push('- 目标文件(**绝对路径**):`' + target + '`');
|
|
105
106
|
lines.push('- 批次 ID:`' + batchId + '`');
|
|
106
|
-
|
|
107
|
+
const modeLabel = mode === 'topic'
|
|
108
|
+
? 'topic(_novel_topic.json)'
|
|
109
|
+
: mode === 'suggestion'
|
|
110
|
+
? 'suggestion(_novel_topic_suggestion.json)'
|
|
111
|
+
: mode === 'builtin'
|
|
112
|
+
? 'builtin(内置全题材)'
|
|
113
|
+
: String(mode || '未指定');
|
|
114
|
+
lines.push('- 题材模式:' + modeLabel);
|
|
107
115
|
if (Array.isArray(genres) && genres.length) lines.push('- 本次题材:' + genres.join('、'));
|
|
108
116
|
if (focus && focus.query) {
|
|
109
117
|
const kinds = (focus.kinds || []).map((item) => `\`${item}\``).join('、');
|