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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/novel-maker.js +229 -0
- package/package.json +33 -0
- package/skill/CHANGELOG.md +82 -0
- package/skill/QUICK-REF.md +168 -0
- package/skill/README.md +75 -0
- package/skill/SKILL.md +715 -0
- package/skill/agents/README.md +59 -0
- package/skill/agents/auditor.md +234 -0
- package/skill/agents/coordinator.md +150 -0
- package/skill/agents/planner.md +220 -0
- package/skill/agents/reviewer.md +249 -0
- package/skill/agents/reviser.md +144 -0
- package/skill/agents/writer.md +213 -0
- package/skill/arc-templates/README.md +43 -0
- package/skill/arc-templates/apocalypse/ability.md +81 -0
- package/skill/arc-templates/apocalypse/faction.md +81 -0
- package/skill/arc-templates/apocalypse/humanity.md +81 -0
- package/skill/arc-templates/apocalypse/survival.md +81 -0
- package/skill/arc-templates/game/competition.md +81 -0
- package/skill/arc-templates/game/dungeon.md +81 -0
- package/skill/arc-templates/game/guild-war.md +81 -0
- package/skill/arc-templates/game/leveling.md +80 -0
- package/skill/arc-templates/general/challenge.md +44 -0
- package/skill/arc-templates/general/conflict.md +71 -0
- package/skill/arc-templates/general/explore.md +71 -0
- package/skill/arc-templates/general/growth.md +71 -0
- package/skill/arc-templates/general/mystery.md +71 -0
- package/skill/arc-templates/general/relation.md +71 -0
- package/skill/arc-templates/history/battle.md +71 -0
- package/skill/arc-templates/history/politics.md +71 -0
- package/skill/arc-templates/history/reform.md +71 -0
- package/skill/arc-templates/infinite-flow/boss.md +71 -0
- package/skill/arc-templates/infinite-flow/dungeon.md +71 -0
- package/skill/arc-templates/infinite-flow/enhance.md +71 -0
- package/skill/arc-templates/infinite-flow/team.md +71 -0
- package/skill/arc-templates/mystery/case.md +71 -0
- package/skill/arc-templates/mystery/deduction.md +71 -0
- package/skill/arc-templates/mystery/twist.md +71 -0
- package/skill/arc-templates/romance/angst.md +80 -0
- package/skill/arc-templates/romance/chase.md +71 -0
- package/skill/arc-templates/romance/slow-burn.md +71 -0
- package/skill/arc-templates/romance/sweet.md +80 -0
- package/skill/arc-templates/sci-fi/awakening.md +81 -0
- package/skill/arc-templates/sci-fi/breakthrough.md +81 -0
- package/skill/arc-templates/sci-fi/contact.md +81 -0
- package/skill/arc-templates/sci-fi/exploration.md +81 -0
- package/skill/arc-templates/urban/business.md +71 -0
- package/skill/arc-templates/urban/revenge.md +71 -0
- package/skill/arc-templates/urban/rise.md +71 -0
- package/skill/arc-templates/urban/romance.md +71 -0
- package/skill/arc-templates/western-fantasy/adventure.md +80 -0
- package/skill/arc-templates/western-fantasy/kingdom.md +71 -0
- package/skill/arc-templates/western-fantasy/magic-awakening.md +71 -0
- package/skill/arc-templates/western-fantasy/racial-conflict.md +71 -0
- package/skill/arc-templates/wuxia/breakthrough.md +71 -0
- package/skill/arc-templates/wuxia/grudge.md +71 -0
- package/skill/arc-templates/wuxia/hero-path.md +71 -0
- package/skill/arc-templates/wuxia/sect-war.md +71 -0
- package/skill/arc-templates/xianxia/breakthrough.md +71 -0
- package/skill/arc-templates/xianxia/dungeon.md +71 -0
- package/skill/arc-templates/xianxia/tournament.md +71 -0
- package/skill/arc-templates/xianxia/tribulation.md +71 -0
- package/skill/docs/examples.md +61 -0
- package/skill/docs/faq.md +81 -0
- package/skill/docs/installation.md +87 -0
- package/skill/docs/quickstart.md +83 -0
- package/skill/genre-packs/README.md +47 -0
- package/skill/genre-packs/_default/arc-types.md +153 -0
- package/skill/genre-packs/_default/rules.md +56 -0
- package/skill/genre-packs/_default/templates.md +135 -0
- package/skill/genre-packs/apocalypse/arc-types.md +109 -0
- package/skill/genre-packs/apocalypse/rules.md +113 -0
- package/skill/genre-packs/apocalypse/settings.md +106 -0
- package/skill/genre-packs/apocalypse/templates.md +192 -0
- package/skill/genre-packs/game/arc-types.md +109 -0
- package/skill/genre-packs/game/rules.md +113 -0
- package/skill/genre-packs/game/settings.md +103 -0
- package/skill/genre-packs/game/templates.md +173 -0
- package/skill/genre-packs/history/arc-types.md +109 -0
- package/skill/genre-packs/history/rules.md +107 -0
- package/skill/genre-packs/history/settings.md +126 -0
- package/skill/genre-packs/history/templates.md +179 -0
- package/skill/genre-packs/infinite-flow/arc-types.md +101 -0
- package/skill/genre-packs/infinite-flow/rules.md +75 -0
- package/skill/genre-packs/infinite-flow/settings.md +102 -0
- package/skill/genre-packs/infinite-flow/templates.md +226 -0
- package/skill/genre-packs/mystery/arc-types.md +109 -0
- package/skill/genre-packs/mystery/rules.md +107 -0
- package/skill/genre-packs/mystery/settings.md +103 -0
- package/skill/genre-packs/mystery/templates.md +178 -0
- package/skill/genre-packs/romance/arc-types.md +130 -0
- package/skill/genre-packs/romance/rules.md +88 -0
- package/skill/genre-packs/romance/settings.md +146 -0
- package/skill/genre-packs/romance/templates.md +245 -0
- package/skill/genre-packs/sci-fi/arc-types.md +109 -0
- package/skill/genre-packs/sci-fi/rules.md +113 -0
- package/skill/genre-packs/sci-fi/settings.md +99 -0
- package/skill/genre-packs/sci-fi/templates.md +170 -0
- package/skill/genre-packs/urban/arc-types.md +101 -0
- package/skill/genre-packs/urban/rules.md +75 -0
- package/skill/genre-packs/urban/settings.md +82 -0
- package/skill/genre-packs/urban/templates.md +212 -0
- package/skill/genre-packs/western-fantasy/arc-types.md +128 -0
- package/skill/genre-packs/western-fantasy/rules.md +88 -0
- package/skill/genre-packs/western-fantasy/settings.md +160 -0
- package/skill/genre-packs/western-fantasy/templates.md +225 -0
- package/skill/genre-packs/wuxia/arc-types.md +126 -0
- package/skill/genre-packs/wuxia/rules.md +86 -0
- package/skill/genre-packs/wuxia/settings.md +150 -0
- package/skill/genre-packs/wuxia/templates.md +195 -0
- package/skill/genre-packs/xianxia/arc-types.md +101 -0
- package/skill/genre-packs/xianxia/rules.md +74 -0
- package/skill/genre-packs/xianxia/settings.md +107 -0
- package/skill/genre-packs/xianxia/templates.md +202 -0
- package/skill/hooks/README.md +102 -0
- package/skill/hooks/chapter-complete.md +176 -0
- package/skill/hooks/context-injection.md +152 -0
- package/skill/hooks/intent-detection.md +183 -0
- package/skill/hooks/review-trigger.md +219 -0
- package/skill/hooks/summary-trigger.md +185 -0
- package/skill/references/act-guidance.md +228 -0
- package/skill/references/audit-core.md +130 -0
- package/skill/references/audit-dimensions.md +202 -0
- package/skill/references/character-voice-card.md +196 -0
- package/skill/references/consistency-checker.md +209 -0
- package/skill/references/content-expansion.md +68 -0
- package/skill/references/creative-constraints.md +200 -0
- package/skill/references/data-agent.md +286 -0
- package/skill/references/dialogue-writing.md +104 -0
- package/skill/references/editorial-perspective.md +166 -0
- package/skill/references/emotion-curve.md +127 -0
- package/skill/references/genre-rules.md +389 -0
- package/skill/references/golden-opening.md +81 -0
- package/skill/references/memory-system.md +288 -0
- package/skill/references/pacing-analysis.md +201 -0
- package/skill/references/platform-rules.md +244 -0
- package/skill/references/plot-structures.md +108 -0
- package/skill/references/reader-feedback.md +119 -0
- package/skill/references/rhythm-system.md +204 -0
- package/skill/references/style-imitation.md +193 -0
- package/skill/references/sweet-spot-tracking.md +182 -0
- package/skill/references/usage-guide.md +174 -0
- package/skill/references/writing-methods.md +169 -0
- package/skill/rules/anti-ai-expressions.md +206 -0
- package/skill/rules/character-voice.md +184 -0
- package/skill/rules/consistency-check.md +232 -0
- package/skill/rules/smart-query.md +263 -0
- package/skill/scripts/README.md +380 -0
- package/skill/scripts/auditor/chapter_transition.py +217 -0
- package/skill/scripts/auditor/consistency_scan.py +194 -0
- package/skill/scripts/auditor/dialogue_checker.py +194 -0
- package/skill/scripts/auditor/hook_report.py +115 -0
- package/skill/scripts/auditor/pacing_optimizer.py +303 -0
- package/skill/scripts/auditor/pacing_report.py +275 -0
- package/skill/scripts/auditor/pre_audit.py +203 -0
- package/skill/scripts/auditor/style_check.py +158 -0
- package/skill/scripts/auditor/worldbuilding_checker.py +637 -0
- package/skill/scripts/common/analyze.py +129 -0
- package/skill/scripts/common/init_guide.py +796 -0
- package/skill/scripts/common/install.py +169 -0
- package/skill/scripts/common/nm_utils.py +296 -0
- package/skill/scripts/common/validate.py +215 -0
- package/skill/scripts/coordinator/stats_report.py +165 -0
- package/skill/scripts/coordinator/volume_batch.py +121 -0
- package/skill/scripts/planner/outline_extractor.py +89 -0
- package/skill/scripts/planner/planner_context.py +220 -0
- package/skill/scripts/planner/query_engine.py +289 -0
- package/skill/scripts/reviewer/chapter_diff.py +143 -0
- package/skill/scripts/reviewer/character_arc_tracker.py +191 -0
- package/skill/scripts/reviewer/emotion_curve.py +340 -0
- package/skill/scripts/reviewer/foreshadowing_tracker.py +286 -0
- package/skill/scripts/reviewer/subplot_tracker.py +207 -0
- package/skill/scripts/reviewer/summary_generator.py +130 -0
- package/skill/scripts/reviewer/truth_diff.py +227 -0
- package/skill/scripts/reviewer/truth_manager.py +120 -0
- package/skill/scripts/test_scripts.py +366 -0
- package/skill/scripts/writer/build_write_context.py +255 -0
- package/skill/scripts/writer/chapter_info.py +67 -0
- package/skill/scripts/writer/check_wordcount.py +115 -0
- package/skill/scripts/writer/scene_builder.py +227 -0
- package/skill/scripts/writer/style_anchor.py +135 -0
- package/skill/styles/author-styles.md +53 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/skill/templates/INDEX.md +48 -0
- package/skill/templates/act-plan.md +72 -0
- package/skill/templates/chapter.md +53 -0
- package/skill/templates/character-profile.md +107 -0
- package/skill/templates/character-voice.md +106 -0
- package/skill/templates/constitution.md +90 -0
- package/skill/templates/emotional-arcs.md +37 -0
- package/skill/templates/hook-template.md +68 -0
- package/skill/templates/outline.md +155 -0
- package/skill/templates/plot-card.md +432 -0
- package/skill/templates/power-system.md +124 -0
- package/skill/templates/presets.json +69 -0
- package/skill/templates/review-report.md +135 -0
- package/skill/templates/scene-plan.md +221 -0
- package/skill/templates/scene-template.md +78 -0
- package/skill/templates/subplot-board.md +48 -0
- package/skill/templates/summary-10chapters.md +79 -0
- package/skill/templates/summary-50chapters.md +131 -0
- package/skill/templates/summary-volume.md +148 -0
- package/skill/templates/timeline.md +37 -0
- package/skill/templates/volume-plan.md +44 -0
- 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()
|