@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.
@@ -180,31 +180,24 @@ ${cross_list_block}- 用户画像从题材、卖点、情绪价值推断;**区
180
180
  ${section_titles_block}
181
181
 
182
182
  - **第二节标题固定为"二、市场结构(单日快照)"** —— 数据只覆盖一天,讲的是当日结构,不是趋势
183
- - 每个选题方向写全 11 项:一句话定位、目标读者与情绪价值、金手指(能力/代价/限制)、核心设定(世界/规则/主线)、爽点模式(前 10 章 / 每 3 章 / 长线各是什么)、情绪价值曲线(压抑—释放—升级)、开篇切入点(第 1 章的场景与钩子)、故事节奏规划(开篇/前期/中期/后期)、人物设定框架(主角/对手/关键配角/关系网)、差异化竞争策略(与榜上同类的具体区别)、主要风险与验证指标
183
+ - 每个选题方向只写 4 项:题材、一句话定位、市场评分、推荐理由
184
184
  - **方向标题用固定格式**:`### 方向 1:<名称>`、`### 方向 2:<名称>`……(序号从 1 连续编号)
185
185
 
186
- 每个方向**照抄下面这 11 行**(内容替换成实际内容,标签一个字都不许改):
186
+ 每个方向**照抄下面这个格式**(内容替换成实际内容,标签一个字都不许改):
187
187
 
188
188
  ```markdown
189
189
  ### 方向 N:<名称>
190
- ${direction_template_block}
190
+ - **题材**:<一个题材名>
191
+ - **一句话定位**:<题材、主角、核心卖点,一句话说清>
192
+ - **市场评分**:<1-10 分,可写小数>
193
+ - **推荐理由**:<为什么这个方向值得写;写清数据依据、竞争度和差异化>
191
194
  ```
192
195
 
193
- 11 个标签**全部加粗**,冒号用全角 `:`;不要改写、合并或省略。
194
- **每个标签后面至少 ${min_tag_chars} 个汉字** —— 写个标签名充数不算数。
196
+ 4 个标签**全部加粗**,冒号用全角 `:`;不要改写、合并或省略。
197
+ **题材**至少 2 个汉字;**一句话定位**和**推荐理由**至少 10 个汉字 —— 写个标签名充数不算数。
195
198
  **固定格式是为了让写入后的校验能确定性执行**(见 5.2 的 `append-block.js`)。
196
199
  - **方向之间不能是"同一题材换皮"**:${min_directions}-${max_directions} 个方向中,任意两个至少在"题材 / 金手指 / 情绪价值"三项里的**两项**不同
197
- - **优先级排序按这四项打分**(每项写清依据):
198
- - **数据支撑度**(有多少${heat_metric_label}数据支持)—— 权重 40%
199
- - **竞争度**(该题材越挤越靠后)—— 权重 25%
200
- - **差异化空间**(越空白越靠前)—— 权重 25%
201
- - **作者匹配度** —— 权重 10%;**数据里没有作者画像**,没有用户提供的偏好时这一项**标"待确认"**,**不要编一个"擅长什么"**,也不要默认给 3 分
202
- - 每项 1-5 分。**作者匹配度可用时**,总分按
203
- `(数据支撑度×40 + 竞争度×25 + 差异化空间×25 + 作者匹配度×10)÷ 100` 计算;**作者匹配度标"待确认"时不计入总分**,总分按
204
- `(数据支撑度×40 + 竞争度×25 + 差异化空间×25)÷ 90` 计算。四舍五入保留两位小数。
205
- **必须写出各项原始分和算出的总分**,不能只写结论。
206
- - **优先级计算示例**:数据支撑度 4 分、竞争度 2 分、差异化空间 4 分、作者匹配度待确认 → 总分 = `(4×40 + 2×25 + 4×25) ÷ 90 = 3.44` 分。竞争度是反向指标:竞争越激烈,这项分数越低。
207
- - 按总分**从高到低**排;同分时依次比数据支撑度、竞争度,仍同分再比该题材主指标总量,并写明依据。
200
+ - **市场评分**按 1-10 分给出,综合数据支撑度、竞争度、差异化空间;在「推荐理由」里写清依据,不要只给分数。
208
201
 
209
202
  **硬性要求**(写入时会被脚本逐条检查,不合格**写不进去**):
210
203
 
@@ -231,9 +224,9 @@ ${report_headers_block}
231
224
  ${genre_self_check}
232
225
  - **指标用的是${heat_metric_label}数据**,不是恒等于常数的书目数占比
233
226
  - **没有写没有数据支撑的趋势判断**
234
- - ${min_directions}-${max_directions} 个方向,每个 11 项齐全、每项 ≥${min_tag_chars} 汉字,标题格式正确
227
+ - ${min_directions}-${max_directions} 个方向,每个 4 项齐全,题材 ≥2 汉字、定位/理由 ≥10 汉字,标题格式正确
235
228
  - **任意两个方向至少两项不同**
236
- - **有按四项标准的优先级排序**
229
+ - **每个方向都有 1-10 的市场评分和推荐理由**
237
230
  - **字数、表格数与表头都符合上面的硬性要求**
238
231
  - 题材表没有“全部”汇总行,至少有 3 个题材
239
232
  - 结论有"书名 + 排名 + ${heat_metric_label}"佐证,且每个题材/结构结论至少 2 本书佐证
@@ -27,6 +27,7 @@ import {
27
27
  TABLE_SEP_RE,
28
28
  DIRECTION_RE,
29
29
  DIRECTION_LABELS,
30
+ CANDIDATE_LABELS,
30
31
  } from '../templates/report-schema.js';
31
32
 
32
33
  /**
@@ -352,29 +353,56 @@ export function validateReportBody(body, opts = {}) {
352
353
  + ' —— 必须写成 `### 方向 N:名称`(N 是连续数字)');
353
354
  }
354
355
 
355
- // ── 3. 每个方向的 11 项标签 ───────────────────────────────────
356
+ // ── 3. 每个方向的标签 ─────────────────────────────────────────
357
+ // 新格式只查 4 项;旧报告仍兼容 11 项。
356
358
  const tagStats = [];
357
359
  dirPos.forEach((start, k) => {
358
360
  const end = k + 1 < dirPos.length ? dirPos[k + 1] : lines.length;
359
361
  const section = lines.slice(start, end);
360
- let okTags = 0;
361
- for (const lab of DIRECTION_LABELS) {
362
+ const findLabel = (label) => {
362
363
  const re = new RegExp(
363
- '^\\s*[-*+]?\\s*\\*\\*' + lab.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\*\\*\\s*[::]\\s*(.+)$'
364
+ '^\\s*[-*+]?\\s*\\*\\*' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\*\\*\\s*[::]\\s*(.+)$'
364
365
  );
365
- const hit = section.map(l => re.exec(l)).find(Boolean);
366
+ return section.map(l => re.exec(l)).find(Boolean);
367
+ };
368
+ const newHits = CANDIDATE_LABELS.map(findLabel);
369
+ const oldHits = DIRECTION_LABELS.map(findLabel);
370
+ const isNew = newHits.every(Boolean);
371
+ const isOld = oldHits.every(Boolean);
372
+ if (!isNew && !isOld) {
373
+ const missing = CANDIDATE_LABELS.filter((_, i) => !newHits[i]);
374
+ errors.push(`方向 ${k + 1} 缺标签:${missing.map(l => `「${l}」`).join('、')}`
375
+ + '(新格式要求 题材 / 一句话定位 / 市场评分 / 推荐理由 四项齐全)');
376
+ }
377
+ const labels = isNew ? CANDIDATE_LABELS : DIRECTION_LABELS;
378
+ let okTags = 0;
379
+ for (let i = 0; i < labels.length; i += 1) {
380
+ const lab = labels[i];
381
+ const hit = (isNew ? newHits : oldHits)[i];
366
382
  if (!hit) {
367
- errors.push(`方向 ${k + 1} 缺标签「${lab}」(11 项必须齐全,格式 \`- **${lab}**:内容\`)`);
383
+ errors.push(`方向 ${k + 1} 缺标签「${lab}」(格式 \`- **${lab}**:内容\`)`);
384
+ continue;
385
+ }
386
+ const value = hit[1].trim();
387
+ if (lab === '市场评分') {
388
+ if (!/^\d+(?:\.\d+)?(?:\s*\/\s*10)?$/.test(value)) {
389
+ errors.push(`方向 ${k + 1} 的「市场评分」必须是 1-10 的数字(如 8.5 或 8.5/10)`);
390
+ } else {
391
+ okTags++;
392
+ }
368
393
  continue;
369
394
  }
370
- const chars = cjkCount(hit[1]);
371
- if (chars < R.minTagChars) {
372
- errors.push(`方向 ${k + 1} 的「${lab}」只有 ${chars} 个汉字(要求 ≥${R.minTagChars},不能只写个标签名充数)`);
395
+ const chars = cjkCount(value);
396
+ const minChars = isNew
397
+ ? (lab === '题材' ? 2 : (lab === '一句话定位' || lab === '推荐理由' ? 10 : R.minTagChars))
398
+ : 20;
399
+ if (chars < minChars) {
400
+ errors.push(`方向 ${k + 1} 的「${lab}」只有 ${chars} 个汉字(要求 ≥${minChars},不能只写个标签名充数)`);
373
401
  } else {
374
402
  okTags++;
375
403
  }
376
404
  }
377
- tagStats.push({ direction: k + 1, okTags, total: DIRECTION_LABELS.length });
405
+ tagStats.push({ direction: k + 1, okTags, total: labels.length });
378
406
  });
379
407
 
380
408
  // ── 4. 字数 ────────────────────────────────────────────────────
@@ -603,72 +631,7 @@ export function validateReportBody(body, opts = {}) {
603
631
  });
604
632
  }
605
633
 
606
- // 选题对比表的“优先级”列必须是名次 1..N(从上到下递增),
607
- // 不是加权总分。之前模型在列里填 4.44 / 3.83,读者无法一眼看出排名。
608
- if (choiceTableRows.length) {
609
- const nums = choiceTableRows.map(row => {
610
- const cells = row.split('|').map(s => s.trim());
611
- return Number(cells[cells.length - 1]);
612
- });
613
- const valid = nums.every((p, i) => Number.isInteger(p) && p === i + 1);
614
- if (!valid) {
615
- errors.push(`选题对比表的“优先级”列必须是 1–${choiceTableRows.length} 的名次`
616
- + `(从上到下递增),当前是 ${nums.map(x => Number.isFinite(x) ? x : '空').join('/')}`);
617
- }
618
- }
619
-
620
- // 优先级打分是自由文本,但常见写法是
621
- // "方向 N ... a/b/c/待确认,加权后为 X"。
622
- // 能解析就核对算术与排序;解析不到就跳过(不因为写法不同而误报)。
623
- const scoreRows = [];
624
- for (const chunk of text.split(/[;;\n]/)) {
625
- const m = chunk.match(
626
- /方向\s*(\d+)[^。;\n]{0,60}?(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)[^。;\n]{0,60}?加权后为\s*(\d+(?:\.\d+)?)/
627
- );
628
- if (!m) continue;
629
- const scores = [Number(m[2]), Number(m[3]), Number(m[4])];
630
- const expected = Math.round(
631
- ((scores[0] * 40 + scores[1] * 25 + scores[2] * 25) / 90) * 100
632
- ) / 100;
633
- scoreRows.push({
634
- direction: Number(m[1]),
635
- scores,
636
- stated: Number(m[5]),
637
- expected,
638
- });
639
- }
640
- for (const r of scoreRows) {
641
- if (Math.abs(r.expected - r.stated) > 0.011) {
642
- warnings.push(`优先级打分算术不一致:方向 ${r.direction} 的 ${r.scores.join('/')}`
643
- + ` 按 90% 归一化应为 ${r.expected.toFixed(2)},正文写 ${r.stated.toFixed(2)}`);
644
- }
645
- }
646
- if (scoreRows.length > 1 && choiceTableRows.length) {
647
- const priorityByDir = new Map();
648
- for (const row of choiceTableRows) {
649
- const cells = row.split('|').map(s => s.trim());
650
- if (cells.length < 2) continue;
651
- const dm = /^方向\s*(\d+)/.exec(cells[0]);
652
- const p = Number(cells[cells.length - 1]);
653
- if (dm && Number.isFinite(p)) priorityByDir.set(Number(dm[1]), p);
654
- }
655
- for (let i = 0; i < scoreRows.length; i++) {
656
- for (let j = i + 1; j < scoreRows.length; j++) {
657
- const a = scoreRows[i];
658
- const b = scoreRows[j];
659
- const pa = priorityByDir.get(a.direction);
660
- const pb = priorityByDir.get(b.direction);
661
- if (!pa || !pb || Math.abs(a.expected - b.expected) < 0.005) continue;
662
- const wrong = (a.expected > b.expected && pa > pb)
663
- || (b.expected > a.expected && pb > pa);
664
- if (wrong) {
665
- warnings.push(`选题对比表的优先级与加权总分不一致:方向 ${a.direction}`
666
- + `(${a.expected.toFixed(2)})与方向 ${b.direction}(${b.expected.toFixed(2)})`
667
- + ` 的顺序对不上(表里优先级 ${pa} vs ${pb})`);
668
- }
669
- }
670
- }
671
- }
634
+ // 新格式不再要求“优先级”列;方向的市场评分在第四节逐项校验。
672
635
 
673
636
  // ── 6. 引用(书名 + 指标) ──────────────────────────────────────
674
637
  // 数的是**出现次数**,不是行数 —— 一行里写了两本书的两条佐证也算两处。
@@ -41,19 +41,22 @@ export const SECTION_TITLES = [
41
41
  * - 表格数用**分隔行**数(`| --- | --- |` 那种),不是"看起来像表格的行";
42
42
  * - 标签要求"加粗 + 全角冒号 + 正文 ≥20 汉字",避免"写个标题充数"。
43
43
  */
44
+ /** 新格式每个方向只要求四项 */
45
+ export const CANDIDATE_LABELS = ['题材', '一句话定位', '市场评分', '推荐理由'];
46
+
44
47
  export const REPORT_RULES = {
45
48
  /** 方向个数 */
46
49
  minDirections: 3,
47
- maxDirections: 4,
48
- /** 每个方向的 11 项设计标签 */
49
- minTagsPerDirection: DIRECTION_LABELS.length,
50
+ maxDirections: 5,
51
+ /** 新格式每个方向的 4 个标签 */
52
+ minTagsPerDirection: CANDIDATE_LABELS.length,
50
53
  /** 每个标签后面的正文汉字数下限(防止"写个标签名充数") */
51
- minTagChars: 20,
52
- /** 全文汉字数区间 */
53
- minCjkTotal: 4000,
54
+ minTagChars: 4,
55
+ /** 全文汉字数区间(只保留市场分析,不再展开 11 项设计) */
56
+ minCjkTotal: 3000,
54
57
  maxCjkTotal: 15000,
55
58
  /** 纯正文汉字数下限(排除表格行、标题行、标签行之后的正文量) */
56
- minPureCjk: 3000,
59
+ minPureCjk: 2200,
57
60
  /** 表格张数 */
58
61
  minTables: 3,
59
62
  /** 至少几处"《书名》…月票 N 张"式的引用 */
@@ -74,7 +77,7 @@ export function tableHeaders(metricLabel, grouped) {
74
77
  return {
75
78
  topic: `题材 | 书目数 | ${metricLabel}总量 | 均值 | 中位数 | ${share}`,
76
79
  competition: `题材 | ${share} | 头部集中度 | 同质化程度 | 差异化空间 | 判断`,
77
- choice: '方向 | 题材 | 金手指 | 情绪价值 | 差异化 | 风险 | 优先级',
80
+ choice: '方向 | 题材 | 一句话定位 | 市场评分 | 推荐理由',
78
81
  };
79
82
  }
80
83