novel-maker 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.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/bin/novel-maker.js +229 -0
  4. package/package.json +33 -0
  5. package/skill/CHANGELOG.md +82 -0
  6. package/skill/QUICK-REF.md +168 -0
  7. package/skill/README.md +75 -0
  8. package/skill/SKILL.md +715 -0
  9. package/skill/agents/README.md +59 -0
  10. package/skill/agents/auditor.md +234 -0
  11. package/skill/agents/coordinator.md +150 -0
  12. package/skill/agents/planner.md +220 -0
  13. package/skill/agents/reviewer.md +249 -0
  14. package/skill/agents/reviser.md +144 -0
  15. package/skill/agents/writer.md +213 -0
  16. package/skill/arc-templates/README.md +43 -0
  17. package/skill/arc-templates/apocalypse/ability.md +81 -0
  18. package/skill/arc-templates/apocalypse/faction.md +81 -0
  19. package/skill/arc-templates/apocalypse/humanity.md +81 -0
  20. package/skill/arc-templates/apocalypse/survival.md +81 -0
  21. package/skill/arc-templates/game/competition.md +81 -0
  22. package/skill/arc-templates/game/dungeon.md +81 -0
  23. package/skill/arc-templates/game/guild-war.md +81 -0
  24. package/skill/arc-templates/game/leveling.md +80 -0
  25. package/skill/arc-templates/general/challenge.md +44 -0
  26. package/skill/arc-templates/general/conflict.md +71 -0
  27. package/skill/arc-templates/general/explore.md +71 -0
  28. package/skill/arc-templates/general/growth.md +71 -0
  29. package/skill/arc-templates/general/mystery.md +71 -0
  30. package/skill/arc-templates/general/relation.md +71 -0
  31. package/skill/arc-templates/history/battle.md +71 -0
  32. package/skill/arc-templates/history/politics.md +71 -0
  33. package/skill/arc-templates/history/reform.md +71 -0
  34. package/skill/arc-templates/infinite-flow/boss.md +71 -0
  35. package/skill/arc-templates/infinite-flow/dungeon.md +71 -0
  36. package/skill/arc-templates/infinite-flow/enhance.md +71 -0
  37. package/skill/arc-templates/infinite-flow/team.md +71 -0
  38. package/skill/arc-templates/mystery/case.md +71 -0
  39. package/skill/arc-templates/mystery/deduction.md +71 -0
  40. package/skill/arc-templates/mystery/twist.md +71 -0
  41. package/skill/arc-templates/romance/angst.md +80 -0
  42. package/skill/arc-templates/romance/chase.md +71 -0
  43. package/skill/arc-templates/romance/slow-burn.md +71 -0
  44. package/skill/arc-templates/romance/sweet.md +80 -0
  45. package/skill/arc-templates/sci-fi/awakening.md +81 -0
  46. package/skill/arc-templates/sci-fi/breakthrough.md +81 -0
  47. package/skill/arc-templates/sci-fi/contact.md +81 -0
  48. package/skill/arc-templates/sci-fi/exploration.md +81 -0
  49. package/skill/arc-templates/urban/business.md +71 -0
  50. package/skill/arc-templates/urban/revenge.md +71 -0
  51. package/skill/arc-templates/urban/rise.md +71 -0
  52. package/skill/arc-templates/urban/romance.md +71 -0
  53. package/skill/arc-templates/western-fantasy/adventure.md +80 -0
  54. package/skill/arc-templates/western-fantasy/kingdom.md +71 -0
  55. package/skill/arc-templates/western-fantasy/magic-awakening.md +71 -0
  56. package/skill/arc-templates/western-fantasy/racial-conflict.md +71 -0
  57. package/skill/arc-templates/wuxia/breakthrough.md +71 -0
  58. package/skill/arc-templates/wuxia/grudge.md +71 -0
  59. package/skill/arc-templates/wuxia/hero-path.md +71 -0
  60. package/skill/arc-templates/wuxia/sect-war.md +71 -0
  61. package/skill/arc-templates/xianxia/breakthrough.md +71 -0
  62. package/skill/arc-templates/xianxia/dungeon.md +71 -0
  63. package/skill/arc-templates/xianxia/tournament.md +71 -0
  64. package/skill/arc-templates/xianxia/tribulation.md +71 -0
  65. package/skill/docs/examples.md +61 -0
  66. package/skill/docs/faq.md +81 -0
  67. package/skill/docs/installation.md +87 -0
  68. package/skill/docs/quickstart.md +83 -0
  69. package/skill/genre-packs/README.md +47 -0
  70. package/skill/genre-packs/_default/arc-types.md +153 -0
  71. package/skill/genre-packs/_default/rules.md +56 -0
  72. package/skill/genre-packs/_default/templates.md +135 -0
  73. package/skill/genre-packs/apocalypse/arc-types.md +109 -0
  74. package/skill/genre-packs/apocalypse/rules.md +113 -0
  75. package/skill/genre-packs/apocalypse/settings.md +106 -0
  76. package/skill/genre-packs/apocalypse/templates.md +192 -0
  77. package/skill/genre-packs/game/arc-types.md +109 -0
  78. package/skill/genre-packs/game/rules.md +113 -0
  79. package/skill/genre-packs/game/settings.md +103 -0
  80. package/skill/genre-packs/game/templates.md +173 -0
  81. package/skill/genre-packs/history/arc-types.md +109 -0
  82. package/skill/genre-packs/history/rules.md +107 -0
  83. package/skill/genre-packs/history/settings.md +126 -0
  84. package/skill/genre-packs/history/templates.md +179 -0
  85. package/skill/genre-packs/infinite-flow/arc-types.md +101 -0
  86. package/skill/genre-packs/infinite-flow/rules.md +75 -0
  87. package/skill/genre-packs/infinite-flow/settings.md +102 -0
  88. package/skill/genre-packs/infinite-flow/templates.md +226 -0
  89. package/skill/genre-packs/mystery/arc-types.md +109 -0
  90. package/skill/genre-packs/mystery/rules.md +107 -0
  91. package/skill/genre-packs/mystery/settings.md +103 -0
  92. package/skill/genre-packs/mystery/templates.md +178 -0
  93. package/skill/genre-packs/romance/arc-types.md +130 -0
  94. package/skill/genre-packs/romance/rules.md +88 -0
  95. package/skill/genre-packs/romance/settings.md +146 -0
  96. package/skill/genre-packs/romance/templates.md +245 -0
  97. package/skill/genre-packs/sci-fi/arc-types.md +109 -0
  98. package/skill/genre-packs/sci-fi/rules.md +113 -0
  99. package/skill/genre-packs/sci-fi/settings.md +99 -0
  100. package/skill/genre-packs/sci-fi/templates.md +170 -0
  101. package/skill/genre-packs/urban/arc-types.md +101 -0
  102. package/skill/genre-packs/urban/rules.md +75 -0
  103. package/skill/genre-packs/urban/settings.md +82 -0
  104. package/skill/genre-packs/urban/templates.md +212 -0
  105. package/skill/genre-packs/western-fantasy/arc-types.md +128 -0
  106. package/skill/genre-packs/western-fantasy/rules.md +88 -0
  107. package/skill/genre-packs/western-fantasy/settings.md +160 -0
  108. package/skill/genre-packs/western-fantasy/templates.md +225 -0
  109. package/skill/genre-packs/wuxia/arc-types.md +126 -0
  110. package/skill/genre-packs/wuxia/rules.md +86 -0
  111. package/skill/genre-packs/wuxia/settings.md +150 -0
  112. package/skill/genre-packs/wuxia/templates.md +195 -0
  113. package/skill/genre-packs/xianxia/arc-types.md +101 -0
  114. package/skill/genre-packs/xianxia/rules.md +74 -0
  115. package/skill/genre-packs/xianxia/settings.md +107 -0
  116. package/skill/genre-packs/xianxia/templates.md +202 -0
  117. package/skill/hooks/README.md +102 -0
  118. package/skill/hooks/chapter-complete.md +176 -0
  119. package/skill/hooks/context-injection.md +152 -0
  120. package/skill/hooks/intent-detection.md +183 -0
  121. package/skill/hooks/review-trigger.md +219 -0
  122. package/skill/hooks/summary-trigger.md +185 -0
  123. package/skill/references/act-guidance.md +228 -0
  124. package/skill/references/audit-core.md +130 -0
  125. package/skill/references/audit-dimensions.md +202 -0
  126. package/skill/references/character-voice-card.md +196 -0
  127. package/skill/references/consistency-checker.md +209 -0
  128. package/skill/references/content-expansion.md +68 -0
  129. package/skill/references/creative-constraints.md +200 -0
  130. package/skill/references/data-agent.md +286 -0
  131. package/skill/references/dialogue-writing.md +104 -0
  132. package/skill/references/editorial-perspective.md +166 -0
  133. package/skill/references/emotion-curve.md +127 -0
  134. package/skill/references/genre-rules.md +389 -0
  135. package/skill/references/golden-opening.md +81 -0
  136. package/skill/references/memory-system.md +288 -0
  137. package/skill/references/pacing-analysis.md +201 -0
  138. package/skill/references/platform-rules.md +244 -0
  139. package/skill/references/plot-structures.md +108 -0
  140. package/skill/references/reader-feedback.md +119 -0
  141. package/skill/references/rhythm-system.md +204 -0
  142. package/skill/references/style-imitation.md +193 -0
  143. package/skill/references/sweet-spot-tracking.md +182 -0
  144. package/skill/references/usage-guide.md +174 -0
  145. package/skill/references/writing-methods.md +169 -0
  146. package/skill/rules/anti-ai-expressions.md +206 -0
  147. package/skill/rules/character-voice.md +184 -0
  148. package/skill/rules/consistency-check.md +232 -0
  149. package/skill/rules/smart-query.md +263 -0
  150. package/skill/scripts/README.md +380 -0
  151. package/skill/scripts/auditor/chapter_transition.py +217 -0
  152. package/skill/scripts/auditor/consistency_scan.py +194 -0
  153. package/skill/scripts/auditor/dialogue_checker.py +194 -0
  154. package/skill/scripts/auditor/hook_report.py +115 -0
  155. package/skill/scripts/auditor/pacing_optimizer.py +303 -0
  156. package/skill/scripts/auditor/pacing_report.py +275 -0
  157. package/skill/scripts/auditor/pre_audit.py +203 -0
  158. package/skill/scripts/auditor/style_check.py +158 -0
  159. package/skill/scripts/auditor/worldbuilding_checker.py +637 -0
  160. package/skill/scripts/common/analyze.py +129 -0
  161. package/skill/scripts/common/init_guide.py +796 -0
  162. package/skill/scripts/common/install.py +169 -0
  163. package/skill/scripts/common/nm_utils.py +296 -0
  164. package/skill/scripts/common/validate.py +215 -0
  165. package/skill/scripts/coordinator/stats_report.py +165 -0
  166. package/skill/scripts/coordinator/volume_batch.py +121 -0
  167. package/skill/scripts/planner/outline_extractor.py +89 -0
  168. package/skill/scripts/planner/planner_context.py +220 -0
  169. package/skill/scripts/planner/query_engine.py +289 -0
  170. package/skill/scripts/reviewer/chapter_diff.py +143 -0
  171. package/skill/scripts/reviewer/character_arc_tracker.py +191 -0
  172. package/skill/scripts/reviewer/emotion_curve.py +340 -0
  173. package/skill/scripts/reviewer/foreshadowing_tracker.py +286 -0
  174. package/skill/scripts/reviewer/subplot_tracker.py +207 -0
  175. package/skill/scripts/reviewer/summary_generator.py +130 -0
  176. package/skill/scripts/reviewer/truth_diff.py +227 -0
  177. package/skill/scripts/reviewer/truth_manager.py +120 -0
  178. package/skill/scripts/test_scripts.py +366 -0
  179. package/skill/scripts/writer/build_write_context.py +255 -0
  180. package/skill/scripts/writer/chapter_info.py +67 -0
  181. package/skill/scripts/writer/check_wordcount.py +115 -0
  182. package/skill/scripts/writer/scene_builder.py +227 -0
  183. package/skill/scripts/writer/style_anchor.py +135 -0
  184. package/skill/styles/author-styles.md +53 -0
  185. package/skill/styles/authors//344/270/245/350/260/250/350/256/276/345/256/232/346/265/201//345/277/230/350/257/255.md +110 -0
  186. package/skill/styles/authors//344/270/245/350/260/250/350/256/276/345/256/232/346/265/201//347/210/261/346/275/234/346/260/264/347/232/204/344/271/214/350/264/274.md +110 -0
  187. package/skill/styles/authors//344/270/245/350/260/250/350/256/276/345/256/232/346/265/201//350/250/200/345/275/222/346/255/243/344/274/240.md +110 -0
  188. package/skill/styles/authors//345/244/232/347/245/236/350/257/235/347/203/255/350/241/200/346/265/201//344/270/211/344/271/235/351/237/263/345/237/237.md +108 -0
  189. package/skill/styles/authors//346/202/254/347/226/221/346/216/250/347/220/206/346/265/201//346/235/200/350/231/253/351/230/237/351/230/237/345/221/230.md +108 -0
  190. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//344/270/211/345/244/251/344/270/244/350/247/211.md +110 -0
  191. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//344/274/232/350/257/264/350/257/235/347/232/204/350/202/230/345/255/220.md +110 -0
  192. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//345/215/226/346/212/245/345/260/217/351/203/216/345/220/233.md +108 -0
  193. package/skill/styles/authors//346/220/236/347/254/221/345/271/275/351/273/230/346/265/201//345/274/210/351/235/222/345/263/260.md +123 -0
  194. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//345/224/220/345/256/266/344/270/211/345/260/221.md +109 -0
  195. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//345/244/251/350/232/225/345/234/237/350/261/206.md +110 -0
  196. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//346/210/221/345/220/203/350/245/277/347/272/242/346/237/277.md +108 -0
  197. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//346/273/232/345/274/200.md +109 -0
  198. package/skill/styles/authors//347/203/255/350/241/200/345/215/207/347/272/247/346/265/201//350/276/260/344/270/234.md +109 -0
  199. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//345/244/251/344/270/213/351/234/270/345/224/261.md +110 -0
  200. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//346/234/210/345/205/263.md +108 -0
  201. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//350/220/247/351/274/216.md +109 -0
  202. package/skill/styles/authors//347/211/271/350/211/262/351/242/206/345/237/237/346/265/201//350/235/264/350/235/266/350/223/235.md +112 -0
  203. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//346/204/244/346/200/222/347/232/204/351/246/231/350/225/211.md +109 -0
  204. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//347/203/275/347/201/253/346/210/217/350/257/270/344/276/257.md +109 -0
  205. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//347/214/253/350/205/273.md +110 -0
  206. package/skill/styles/authors//347/273/206/350/205/273/346/226/207/351/235/222/346/265/201//350/200/263/346/240/271.md +109 -0
  207. package/skill/templates/INDEX.md +48 -0
  208. package/skill/templates/act-plan.md +72 -0
  209. package/skill/templates/chapter.md +53 -0
  210. package/skill/templates/character-profile.md +107 -0
  211. package/skill/templates/character-voice.md +106 -0
  212. package/skill/templates/constitution.md +90 -0
  213. package/skill/templates/emotional-arcs.md +37 -0
  214. package/skill/templates/hook-template.md +68 -0
  215. package/skill/templates/outline.md +155 -0
  216. package/skill/templates/plot-card.md +432 -0
  217. package/skill/templates/power-system.md +124 -0
  218. package/skill/templates/presets.json +69 -0
  219. package/skill/templates/review-report.md +135 -0
  220. package/skill/templates/scene-plan.md +221 -0
  221. package/skill/templates/scene-template.md +78 -0
  222. package/skill/templates/subplot-board.md +48 -0
  223. package/skill/templates/summary-10chapters.md +79 -0
  224. package/skill/templates/summary-50chapters.md +131 -0
  225. package/skill/templates/summary-volume.md +148 -0
  226. package/skill/templates/timeline.md +37 -0
  227. package/skill/templates/volume-plan.md +44 -0
  228. package/skill/templates/world-setting.md +151 -0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 钩子密度报告
5
+ 分析卷内所有章节的结尾钩子,输出类型分布、连续相同钩子警告。
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import json
11
+ from collections import Counter
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
14
+ from nm_utils import (
15
+ list_chapters, read_chapter, detect_hook, detect_hook_type_from_patterns
16
+ )
17
+
18
+
19
+ def extract_last_paragraphs(text, n=3):
20
+ """Extract last N paragraphs (likely contains chapter ending hook)."""
21
+ paragraphs = [p.strip() for p in text.split('\n') if p.strip()]
22
+ if not paragraphs:
23
+ return text[-300:]
24
+ return '\n'.join(paragraphs[-n:])
25
+
26
+
27
+ def analyze_volume(chapters_dir, recent_n=None):
28
+ chapter_files = list_chapters(chapters_dir, recent_n)
29
+ if not chapter_files:
30
+ return {'error': f'No chapters found in {chapters_dir}'}
31
+
32
+ results = []
33
+ for idx, filepath in enumerate(chapter_files):
34
+ raw, title, clean, wc = read_chapter(filepath)
35
+ ending = extract_last_paragraphs(raw)
36
+ hook_type = detect_hook_type_from_patterns(ending)
37
+
38
+ hook_sentences = []
39
+ import re
40
+ for m in re.finditer(r'[^。!?\n]{5,50}[。!?]', raw):
41
+ sentence = m.group(0)
42
+ from nm_utils import _HOOK_PATTERN_MAP
43
+ if any(pattern.search(sentence) for pattern in _HOOK_PATTERN_MAP.values()):
44
+ hook_sentences.append(sentence.strip())
45
+
46
+ results.append({
47
+ "chapter": idx + 1,
48
+ "title": title,
49
+ "word_count": wc,
50
+ "hook_type": hook_type,
51
+ "hook_sentences": hook_sentences[:3],
52
+ "ending_summary": ending[:200],
53
+ })
54
+
55
+ hook_types = [r["hook_type"] for r in results]
56
+ hook_dist = dict(Counter(hook_types))
57
+
58
+ consecutive_same = []
59
+ if len(hook_types) >= 3:
60
+ for i in range(len(hook_types) - 2):
61
+ if hook_types[i] == hook_types[i+1] == hook_types[i+2]:
62
+ consecutive_same.append(f"第{results[i]['chapter']}-{results[i+2]['chapter']}章连续使用「{hook_types[i]}」钩子")
63
+
64
+ return {
65
+ "total_chapters": len(results),
66
+ "hook_distribution": hook_dist,
67
+ "chapters": results,
68
+ "warnings": {
69
+ "consecutive_same_hook": consecutive_same,
70
+ "unknown_hooks": sum(1 for h in hook_types if h == "未知"),
71
+ }
72
+ }
73
+
74
+
75
+ def main():
76
+ import argparse
77
+ parser = argparse.ArgumentParser(description="Hook density report")
78
+ parser.add_argument("chapters_dir", help="Chapters directory")
79
+ parser.add_argument("--recent", type=int, help="Only analyze last N chapters")
80
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
81
+ args = parser.parse_args()
82
+
83
+ chapters_dir = args.chapters_dir
84
+ if not os.path.isdir(chapters_dir):
85
+ print(f"Error: {chapters_dir} is not a directory", file=sys.stderr)
86
+ sys.exit(1)
87
+
88
+ result = analyze_volume(chapters_dir, recent_n=args.recent)
89
+
90
+ if args.json:
91
+ print(json.dumps(result, ensure_ascii=False, indent=2))
92
+ else:
93
+ print(f"=== 钩子密度报告 (最近{result.get('total_chapters', 0)}章) ===\n")
94
+ if 'error' in result:
95
+ print(result['error'])
96
+ return
97
+ print("钩子类型分布:")
98
+ for hook_type, count in sorted(result["hook_distribution"].items(),
99
+ key=lambda x: x[1], reverse=True):
100
+ bar = "█" * count
101
+ print(f" {hook_type:4s}: {bar} ({count})")
102
+ print()
103
+ if result["warnings"]["consecutive_same_hook"]:
104
+ print("⚠ 警告 - 连续相同钩子:")
105
+ for w in result["warnings"]["consecutive_same_hook"]:
106
+ print(f" {w}")
107
+ if result["warnings"]["unknown_hooks"] > 0:
108
+ print(f"\n⚠ {result['warnings']['unknown_hooks']} 章钩子类型未知,建议手动标记")
109
+ print()
110
+ for ch in result["chapters"]:
111
+ print(f" 第{ch['chapter']}章 [{ch['hook_type']}]: {ch['ending_summary'][:80]}...")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 节奏优化建议脚本
5
+ 基于节奏分析结果,提供具体的优化建议,帮助改善故事节奏。
6
+ 供审计师在审查时使用,或写手在修改时参考。
7
+
8
+ 用法:
9
+ python scripts/auditor/pacing_optimizer.py 章节目录 --json
10
+ python scripts/auditor/pacing_optimizer.py 章节目录 --recent 10
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from collections import defaultdict
19
+
20
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
21
+ from nm_utils import (
22
+ list_chapters, read_chapter, estimate_pacing, count_chinese,
23
+ detect_hook_type_from_patterns
24
+ )
25
+
26
+ # ─── 节奏规则 ───────────────────────────────
27
+ PACING_RULES = {
28
+ 'max_consecutive_high': 3, # 不能连续3章S4+
29
+ 'max_consecutive_low': 3, # 不能连续3章S2-
30
+ 'max_no_peak_interval': 8, # 每8章必须有S4/S5
31
+ 'max_no_mid_interval': 3, # 每3章必须有S3+
32
+ 'ideal_peak_ratio': 0.15, # 理想高潮比例 15%
33
+ 'ideal_mid_ratio': 0.35, # 理想上升比例 35%
34
+ 'ideal_low_ratio': 0.25, # 理想平缓比例 25%
35
+ 'ideal_valley_ratio': 0.10 # 理想低谷比例 10%
36
+ }
37
+
38
+ # ─── 优化建议模板 ──────────────────────────────
39
+ OPTIMIZATION_SUGGESTIONS = {
40
+ 'consecutive_high': {
41
+ 'problem': '连续高潮',
42
+ 'suggestion': '在高潮后插入1-2章平缓或低谷章节,让读者有喘息空间',
43
+ 'example': '可以在第{chapter}章后加入角色日常、内心反思或环境描写'
44
+ },
45
+ 'consecutive_low': {
46
+ 'problem': '连续平淡',
47
+ 'suggestion': '在平淡章节中插入小冲突或悬念,提升节奏',
48
+ 'example': '可以在第{chapter}章加入意外事件、新角色登场或旧伏笔回收'
49
+ },
50
+ 'no_peak': {
51
+ 'problem': '缺乏高潮',
52
+ 'suggestion': '在适当位置安排高潮章节,提升故事张力',
53
+ 'example': '建议在第{chapter}章安排一次重要对决或关键转折'
54
+ },
55
+ 'no_mid': {
56
+ 'problem': '缺乏上升',
57
+ 'suggestion': '增加上升章节,为高潮做铺垫',
58
+ 'example': '可以在第{chapter}章加入角色成长、实力提升或关系进展'
59
+ },
60
+ 'peak_ratio_low': {
61
+ 'problem': '高潮比例过低',
62
+ 'suggestion': '增加高潮章节数量,提升故事吸引力',
63
+ 'example': '当前高潮比例{current}%,建议提升到{ideal}%'
64
+ },
65
+ 'peak_ratio_high': {
66
+ 'problem': '高潮比例过高',
67
+ 'suggestion': '适当降低高潮频率,避免读者疲劳',
68
+ 'example': '当前高潮比例{current}%,建议降低到{ideal}%'
69
+ },
70
+ 'hook_missing': {
71
+ 'problem': '章节结尾缺乏钩子',
72
+ 'suggestion': '在章节结尾设置悬念或转折,吸引读者继续阅读',
73
+ 'example': '可以在第{chapter}章结尾加入未解之谜或突发事件'
74
+ },
75
+ 'pacing_jump': {
76
+ 'problem': '节奏跳跃过大',
77
+ 'suggestion': '在节奏突变处增加过渡章节,使变化更自然',
78
+ 'example': '第{chapter}章从{from_pacing}直接跳到{to_pacing},建议加入过渡'
79
+ }
80
+ }
81
+
82
+
83
+ def analyze_pacing_with_suggestions(chapters_dir, recent_n=None):
84
+ """Analyze pacing and generate optimization suggestions."""
85
+ chapter_files = list_chapters(chapters_dir, recent_n)
86
+ if not chapter_files:
87
+ return {'error': '未找到章节文件'}
88
+
89
+ # Analyze each chapter
90
+ chapters = []
91
+ pacing_sequence = []
92
+
93
+ for idx, cf in enumerate(chapter_files):
94
+ raw, title, clean, wc = read_chapter(cf)
95
+ pacing_label, pacing_score = estimate_pacing(clean)
96
+ hook = detect_hook_type_from_patterns(raw)
97
+
98
+ chapters.append({
99
+ 'chapter_num': idx + 1,
100
+ 'title': title,
101
+ 'word_count': wc,
102
+ 'pacing': pacing_label,
103
+ 'pacing_score': pacing_score,
104
+ 'hook': hook if isinstance(hook, str) else hook.get('type', '未知')
105
+ })
106
+ pacing_sequence.append(pacing_label)
107
+
108
+ # Calculate distribution
109
+ pacing_dist = defaultdict(int)
110
+ for p in pacing_sequence:
111
+ pacing_dist[p] += 1
112
+
113
+ total = len(pacing_sequence)
114
+ ratios = {k: round(v / max(total, 1) * 100, 1) for k, v in pacing_dist.items()}
115
+
116
+ # Detect issues and generate suggestions
117
+ issues = []
118
+ suggestions = []
119
+
120
+ # 1. Check consecutive high/low
121
+ current_pacing = pacing_sequence[0] if pacing_sequence else None
122
+ streak_start = 0
123
+
124
+ for i, pacing in enumerate(pacing_sequence):
125
+ if pacing != current_pacing:
126
+ streak_length = i - streak_start
127
+
128
+ if current_pacing in ['S4', 'S5'] and streak_length >= PACING_RULES['max_consecutive_high']:
129
+ issues.append({
130
+ 'type': 'consecutive_high',
131
+ 'chapters': f"第{streak_start+1}-{i}章",
132
+ 'length': streak_length
133
+ })
134
+ suggestions.append({
135
+ 'type': 'consecutive_high',
136
+ 'chapter': streak_start + 1,
137
+ 'detail': OPTIMIZATION_SUGGESTIONS['consecutive_high']['suggestion'],
138
+ 'example': OPTIMIZATION_SUGGESTIONS['consecutive_high']['example'].format(chapter=streak_start + 1)
139
+ })
140
+
141
+ elif current_pacing in ['S1', 'S2'] and streak_length >= PACING_RULES['max_consecutive_low']:
142
+ issues.append({
143
+ 'type': 'consecutive_low',
144
+ 'chapters': f"第{streak_start+1}-{i}章",
145
+ 'length': streak_length
146
+ })
147
+ suggestions.append({
148
+ 'type': 'consecutive_low',
149
+ 'chapter': streak_start + 1,
150
+ 'detail': OPTIMIZATION_SUGGESTIONS['consecutive_low']['suggestion'],
151
+ 'example': OPTIMIZATION_SUGGESTIONS['consecutive_low']['example'].format(chapter=streak_start + 1)
152
+ })
153
+
154
+ current_pacing = pacing
155
+ streak_start = i
156
+
157
+ # 2. Check peak interval
158
+ last_peak = None
159
+ for i, pacing in enumerate(pacing_sequence):
160
+ if pacing in ['S4', 'S5']:
161
+ if last_peak is not None and (i - last_peak) > PACING_RULES['max_no_peak_interval']:
162
+ issues.append({
163
+ 'type': 'no_peak',
164
+ 'gap': i - last_peak,
165
+ 'chapters': f"第{last_peak+1}-{i}章"
166
+ })
167
+ suggestions.append({
168
+ 'type': 'no_peak',
169
+ 'chapter': last_peak + PACING_RULES['max_no_peak_interval'] // 2,
170
+ 'detail': OPTIMIZATION_SUGGESTIONS['no_peak']['suggestion'],
171
+ 'example': OPTIMIZATION_SUGGESTIONS['no_peak']['example'].format(
172
+ chapter=last_peak + PACING_RULES['max_no_peak_interval'] // 2
173
+ )
174
+ })
175
+ last_peak = i
176
+
177
+ # 3. Check mid interval
178
+ last_mid = None
179
+ for i, pacing in enumerate(pacing_sequence):
180
+ if pacing in ['S3', 'S4', 'S5']:
181
+ if last_mid is not None and (i - last_mid) > PACING_RULES['max_no_mid_interval']:
182
+ issues.append({
183
+ 'type': 'no_mid',
184
+ 'gap': i - last_mid,
185
+ 'chapters': f"第{last_mid+1}-{i}章"
186
+ })
187
+ suggestions.append({
188
+ 'type': 'no_mid',
189
+ 'chapter': last_mid + PACING_RULES['max_no_mid_interval'] // 2,
190
+ 'detail': OPTIMIZATION_SUGGESTIONS['no_mid']['suggestion'],
191
+ 'example': OPTIMIZATION_SUGGESTIONS['no_mid']['example'].format(
192
+ chapter=last_mid + PACING_RULES['max_no_mid_interval'] // 2
193
+ )
194
+ })
195
+ last_mid = i
196
+
197
+ # 4. Check ratios
198
+ peak_ratio = ratios.get('S4', 0) + ratios.get('S5', 0)
199
+ if peak_ratio < PACING_RULES['ideal_peak_ratio'] * 100 * 0.7: # 30% below ideal
200
+ suggestions.append({
201
+ 'type': 'peak_ratio_low',
202
+ 'current': round(peak_ratio, 1),
203
+ 'ideal': round(PACING_RULES['ideal_peak_ratio'] * 100, 1),
204
+ 'detail': OPTIMIZATION_SUGGESTIONS['peak_ratio_low']['suggestion'],
205
+ 'example': OPTIMIZATION_SUGGESTIONS['peak_ratio_low']['example'].format(
206
+ current=round(peak_ratio, 1),
207
+ ideal=round(PACING_RULES['ideal_peak_ratio'] * 100, 1)
208
+ )
209
+ })
210
+
211
+ # 5. Check hooks
212
+ hook_missing_chapters = [ch for ch in chapters if ch['hook'] == '未检测']
213
+ if hook_missing_chapters:
214
+ suggestions.append({
215
+ 'type': 'hook_missing',
216
+ 'count': len(hook_missing_chapters),
217
+ 'chapters': [ch['chapter_num'] for ch in hook_missing_chapters[:5]],
218
+ 'detail': OPTIMIZATION_SUGGESTIONS['hook_missing']['suggestion'],
219
+ 'example': f"以下章节缺少钩子:{', '.join(f'第{ch}章' for ch in hook_missing_chapters[:5])}"
220
+ })
221
+
222
+ # 6. Check pacing jumps
223
+ for i in range(len(pacing_sequence) - 1):
224
+ from_score = chapters[i]['pacing_score']
225
+ to_score = chapters[i+1]['pacing_score']
226
+ if abs(to_score - from_score) > 2: # Jump of more than 2 levels
227
+ issues.append({
228
+ 'type': 'pacing_jump',
229
+ 'from_chapter': i + 1,
230
+ 'to_chapter': i + 2,
231
+ 'from_pacing': chapters[i]['pacing'],
232
+ 'to_pacing': chapters[i+1]['pacing']
233
+ })
234
+ suggestions.append({
235
+ 'type': 'pacing_jump',
236
+ 'chapter': i + 1,
237
+ 'from_pacing': chapters[i]['pacing'],
238
+ 'to_pacing': chapters[i+1]['pacing'],
239
+ 'detail': OPTIMIZATION_SUGGESTIONS['pacing_jump']['suggestion'],
240
+ 'example': OPTIMIZATION_SUGGESTIONS['pacing_jump']['example'].format(
241
+ chapter=i + 1,
242
+ from_pacing=chapters[i]['pacing'],
243
+ to_pacing=chapters[i+1]['pacing']
244
+ )
245
+ })
246
+
247
+ return {
248
+ 'total_chapters': total,
249
+ 'pacing_distribution': dict(pacing_dist),
250
+ 'ratios': ratios,
251
+ 'chapters': chapters,
252
+ 'issues': issues,
253
+ 'suggestions': suggestions,
254
+ 'issue_count': len(issues),
255
+ 'suggestion_count': len(suggestions)
256
+ }
257
+
258
+
259
+ def main():
260
+ parser = argparse.ArgumentParser(description='节奏优化建议脚本')
261
+ parser.add_argument('chapters_dir', help='章节目录路径')
262
+ parser.add_argument('--recent', type=int, help='仅分析最近N章')
263
+ parser.add_argument('--json', action='store_true', help='JSON输出')
264
+ args = parser.parse_args()
265
+
266
+ if not os.path.isdir(args.chapters_dir):
267
+ print(f"错误: 目录不存在: {args.chapters_dir}", file=sys.stderr)
268
+ sys.exit(1)
269
+
270
+ result = analyze_pacing_with_suggestions(args.chapters_dir, args.recent)
271
+
272
+ if args.json:
273
+ print(json.dumps(result, ensure_ascii=False, indent=2))
274
+ else:
275
+ if 'error' in result:
276
+ print(result['error'])
277
+ return
278
+
279
+ print(f"\n=== 节奏优化建议 (最近{result['total_chapters']}章) ===\n")
280
+
281
+ print("节奏分布:")
282
+ dist = result['pacing_distribution']
283
+ for label in ['S5', 'S4', 'S3', 'S2', 'S1']:
284
+ count = dist.get(label, 0)
285
+ ratio = result['ratios'].get(label, 0)
286
+ bar = "█" * count
287
+ print(f" {label}: {bar} ({count}章, {ratio}%)")
288
+
289
+ print(f"\n问题数: {result['issue_count']}")
290
+ print(f"建议数: {result['suggestion_count']}")
291
+
292
+ if result['suggestions']:
293
+ print(f"\n优化建议:")
294
+ for i, suggestion in enumerate(result['suggestions'], 1):
295
+ print(f"\n {i}. [{suggestion['type']}]")
296
+ print(f" {suggestion['detail']}")
297
+ print(f" 示例: {suggestion['example']}")
298
+ else:
299
+ print(f"\n✓ 节奏良好,无需优化")
300
+
301
+
302
+ if __name__ == '__main__':
303
+ main()
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 卷级节奏报告(含可视化)
5
+ 分析卷内所有章节的节奏分布、问题区域、追读力趋势,支持 emoji 可视化。
6
+ 供 `/novel-maker review pacing volume` 使用。
7
+
8
+ 用法:
9
+ python scripts/auditor/pacing_report.py 章节目录 --json
10
+ python scripts/auditor/pacing_report.py 章节目录 --visualize
11
+ python scripts/auditor/pacing_report.py 章节目录 --recent 10
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import json
17
+ import re
18
+ from collections import Counter
19
+
20
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
21
+ from nm_utils import (
22
+ list_chapters, read_chapter, estimate_pacing, detect_hook_type_from_patterns
23
+ )
24
+
25
+ PACING_NAMES = {
26
+ 'S5': '极高潮', 'S4': '高潮', 'S3': '上升', 'S2': '平缓', 'S1': '低谷'
27
+ }
28
+
29
+ PACING_MARKERS = {
30
+ 'S5': '🔴', 'S4': '', 'S3': '', 'S2': '🟢', 'S1': '⚪'
31
+ }
32
+
33
+ EMOTION_MARKERS = {
34
+ '爽': '💥', '感动': '❤️', '虐': '😢', '惊': '😱', '笑': '😂'
35
+ }
36
+
37
+ # 节奏规则
38
+ MAX_CONSECUTIVE_HIGH = 3 # 不能连续3章S4+
39
+ MAX_CONSECUTIVE_LOW = 3 # 不能连续3章S2-
40
+ MAX_NO_PEAK_INTERVAL = 8 # 每8章必须有S4/S5
41
+ MAX_NO_MID_INTERVAL = 3 # 每3章必须有S3+
42
+
43
+
44
+ def analyze_pacing_volume(chapters_dir, recent_n=None):
45
+ chapter_files = list_chapters(chapters_dir, recent_n)
46
+ if not chapter_files:
47
+ return {'error': 'No chapters found'}
48
+
49
+ chapters = []
50
+ pacing_sequence = []
51
+
52
+ for idx, filepath in enumerate(chapter_files):
53
+ raw, title, clean, wc = read_chapter(filepath)
54
+ pacing_label, pacing_num = estimate_pacing(raw)
55
+ hook_type = detect_hook_type_from_patterns(raw[-500:] if len(raw) > 500 else raw)
56
+
57
+ chapters.append({
58
+ 'num': idx + 1,
59
+ 'title': title,
60
+ 'words': wc,
61
+ 'pacing': pacing_label,
62
+ 'pacing_num': pacing_num,
63
+ 'hook_type': hook_type,
64
+ })
65
+ pacing_sequence.append(pacing_num)
66
+
67
+ # 节奏分布统计
68
+ pacing_dist = Counter(c['pacing'] for c in chapters)
69
+ total = len(chapters)
70
+
71
+ # 问题区域检测
72
+ problems = []
73
+
74
+ # 连续高潮检测
75
+ run_start = 0
76
+ for i in range(1, len(pacing_sequence)):
77
+ if pacing_sequence[i] >= 4 and pacing_sequence[i-1] >= 4:
78
+ if i - run_start >= MAX_CONSECUTIVE_HIGH - 1:
79
+ problems.append({
80
+ 'type': 'consecutive_high',
81
+ 'chapters': f"第{run_start+1}-{i+1}章",
82
+ 'severity': 'warning',
83
+ 'message': f"连续{i-run_start+1}章S4/S5高潮,读者可能疲劳"
84
+ })
85
+ else:
86
+ run_start = i
87
+
88
+ # 连续平淡检测
89
+ run_start = 0
90
+ for i in range(1, len(pacing_sequence)):
91
+ if pacing_sequence[i] <= 2 and pacing_sequence[i-1] <= 2:
92
+ if i - run_start >= MAX_CONSECUTIVE_LOW - 1:
93
+ problems.append({
94
+ 'type': 'consecutive_low',
95
+ 'chapters': f"第{run_start+1}-{i+1}章",
96
+ 'severity': 'warning',
97
+ 'message': f"连续{i-run_start+1}章S1/S2平淡,读者可能弃读"
98
+ })
99
+ else:
100
+ run_start = i
101
+
102
+ # 峰值间隔检测
103
+ last_peak = -1
104
+ for i, p in enumerate(pacing_sequence):
105
+ if p >= 4:
106
+ if last_peak >= 0 and (i - last_peak) > MAX_NO_PEAK_INTERVAL:
107
+ problems.append({
108
+ 'type': 'peak_gap',
109
+ 'chapters': f"第{last_peak+2}-{i}章",
110
+ 'severity': 'warning',
111
+ 'message': f"超过{MAX_NO_PEAK_INTERVAL}章无S4/S5高潮"
112
+ })
113
+ last_peak = i
114
+
115
+ # 追读力趋势评估(每5章一组)
116
+ read_trends = []
117
+ for start in range(0, total, 5):
118
+ end = min(start + 5, total)
119
+ chunk = chapters[start:end]
120
+ avg_pacing = sum(c['pacing_num'] for c in chunk) / len(chunk)
121
+ high_count = sum(1 for c in chunk if c['pacing_num'] >= 4)
122
+ if avg_pacing >= 3.5:
123
+ rating = '★★★★★'
124
+ elif avg_pacing >= 3.0:
125
+ rating = '★★★★☆'
126
+ elif avg_pacing >= 2.5:
127
+ rating = '★★★☆☆'
128
+ elif avg_pacing >= 2.0:
129
+ rating = '★★☆☆☆'
130
+ else:
131
+ rating = '★☆☆☆☆'
132
+ read_trends.append({
133
+ 'range': f"第{start+1}-{end}章",
134
+ 'rating': rating,
135
+ 'avg_pacing': round(avg_pacing, 1),
136
+ 'peak_count': high_count,
137
+ })
138
+
139
+ # 钩子类型分布
140
+ hook_dist = Counter(c['hook_type'] for c in chapters)
141
+
142
+ # 后续建议
143
+ suggestions = []
144
+ if pacing_sequence[-3:] == [4, 4, 4] or pacing_sequence[-3:] == [5, 4, 5]:
145
+ suggestions.append("下一章安排日常缓冲(S2)")
146
+ if pacing_sequence[-3:] == [1, 1, 2] or pacing_sequence[-3:] == [2, 1, 1]:
147
+ suggestions.append("下一章安排冲突/高潮(S3+)")
148
+ if hook_dist.get('悬念', 0) + hook_dist.get('未知', 0) > total * 0.4:
149
+ suggestions.append("钩子类型过于单一,建议丰富悬念类型")
150
+
151
+ return {
152
+ 'total_chapters': total,
153
+ 'pacing_distribution': dict(pacing_dist),
154
+ 'chapters': chapters,
155
+ 'problems': problems,
156
+ 'read_trends': read_trends,
157
+ 'hook_distribution': dict(hook_dist),
158
+ 'suggestions': suggestions,
159
+ }
160
+
161
+
162
+ def generate_heatmap(chapters_dir, recent_n=None):
163
+ """生成节奏热力图(从 pacing_visualize.py 合并)"""
164
+ chapter_files = list_chapters(chapters_dir, recent_n)
165
+ if not chapter_files:
166
+ return "未找到章节文件"
167
+
168
+ lines = [f"章: " + " ".join(f"{i:02d}" for i in range(1, len(chapter_files) + 1))]
169
+ markers = []
170
+ for cf in chapter_files:
171
+ raw, _, _, _ = read_chapter(cf)
172
+ pacing_label, _ = estimate_pacing(raw)
173
+ markers.append(PACING_MARKERS.get(pacing_label, ''))
174
+ lines.append(" " + " ".join(markers))
175
+ return "\n".join(lines)
176
+
177
+
178
+ def generate_emotion_stats(chapters_dir, recent_n=None):
179
+ """生成多维度情绪统计(从 pacing_visualize.py 合并)"""
180
+ chapter_files = list_chapters(chapters_dir, recent_n)
181
+ if not chapter_files:
182
+ return "未找到章节文件"
183
+
184
+ total_emotions = {}
185
+ for cf in chapter_files:
186
+ raw, _, _, _ = read_chapter(cf)
187
+ for emotion in EMOTION_MARKERS:
188
+ count = raw.count(emotion)
189
+ if count > 0:
190
+ total_emotions[emotion] = total_emotions.get(emotion, 0) + count
191
+
192
+ lines = []
193
+ chapter_count = len(chapter_files)
194
+ for emotion, count in sorted(total_emotions.items(), key=lambda x: -x[1]):
195
+ avg = chapter_count / count if count > 0 else 0
196
+ lines.append(f"- {EMOTION_MARKERS[emotion]} {emotion}:{count}次(平均{avg:.1f}章/次)")
197
+ return "\n".join(lines) if lines else "未检测到情绪关键词"
198
+
199
+
200
+ def main():
201
+ import argparse
202
+ parser = argparse.ArgumentParser(description="Volume pacing report with visualization")
203
+ parser.add_argument("chapters_dir", help="Chapters directory")
204
+ parser.add_argument("--recent", type=int, help="Only analyze last N chapters")
205
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
206
+ parser.add_argument("--visualize", action="store_true", help="Show heatmap and emotion stats")
207
+ args = parser.parse_args()
208
+
209
+ if not os.path.isdir(args.chapters_dir):
210
+ print(f"Error: {args.chapters_dir} is not a directory", file=sys.stderr)
211
+ sys.exit(1)
212
+
213
+ result = analyze_pacing_volume(args.chapters_dir, recent_n=args.recent)
214
+
215
+ if args.json:
216
+ # Add visualization data to JSON output
217
+ if args.visualize:
218
+ result['heatmap'] = generate_heatmap(args.chapters_dir, args.recent)
219
+ result['emotion_stats'] = generate_emotion_stats(args.chapters_dir, args.recent)
220
+ print(json.dumps(result, ensure_ascii=False, indent=2))
221
+ return
222
+
223
+ if 'error' in result:
224
+ print(result['error'])
225
+ return
226
+
227
+ print(f"\n{'=' * 60}")
228
+ print(f"📊 节奏报告 (最近{result['total_chapters']}章)")
229
+ print(f"{'=' * 60}\n")
230
+
231
+ print("节奏分布:")
232
+ dist = result['pacing_distribution']
233
+ total = result['total_chapters']
234
+ for label in ['S5', 'S4', 'S3', 'S2', 'S1']:
235
+ count = dist.get(label, 0)
236
+ pct = round(count / max(total, 1) * 100, 0)
237
+ bar = "█" * count
238
+ name = PACING_NAMES.get(label, label)
239
+ print(f" {label}({name:4s}): {bar} ({count}章, {pct}%)")
240
+
241
+ print(f"\n问题区域:")
242
+ if result['problems']:
243
+ for p in result['problems']:
244
+ print(f" ⚠ {p['chapters']}: {p['message']}")
245
+ else:
246
+ print(" ✅ 无明显问题")
247
+
248
+ print(f"\n追读力趋势:")
249
+ for t in result['read_trends']:
250
+ print(f" {t['range']}: {t['rating']} (平均节奏 {t['avg_pacing']}, 高潮{t['peak_count']}次)")
251
+
252
+ print(f"\n钩子分布:")
253
+ for h, c in sorted(result['hook_distribution'].items(), key=lambda x: x[1], reverse=True):
254
+ print(f" {h}: {c}次")
255
+
256
+ if result['suggestions']:
257
+ print(f"\n改进建议:")
258
+ for s in result['suggestions']:
259
+ print(f" → {s}")
260
+
261
+ print(f"\n章节节奏明细:")
262
+ for ch in result['chapters']:
263
+ print(f" 第{ch['num']}章: {ch['title']} ({ch['pacing']}, {ch['words']}字)")
264
+
265
+ if args.visualize:
266
+ print(f"\n{'=' * 60}")
267
+ print(f"🎨 节奏可视化")
268
+ print(f"{'=' * 60}\n")
269
+ print(generate_heatmap(args.chapters_dir, args.recent))
270
+ print(f"\n情绪统计:")
271
+ print(generate_emotion_stats(args.chapters_dir, args.recent))
272
+
273
+
274
+ if __name__ == "__main__":
275
+ main()