status_reporter.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  1. #!/usr/bin/env python3
  2. """
  3. 可视化状态报告系统 (Status Reporter)
  4. 核心理念:面对 1000 个章节,作者会迷失。需要"宏观俯瞰"能力。
  5. 功能:
  6. 1. 角色活跃度分析:哪些角色太久没出场(掉线统计)
  7. 2. 伏笔深度分析:哪些坑挖得太久了(超过 20 万字未收)+ 紧急度排序
  8. 3. 爽点节奏分布:全书高潮点的分布频率(热力图)
  9. 4. 字数分布统计:各卷、各篇的字数分布
  10. 5. 人际关系图谱:好感度/仇恨度趋势
  11. 6. Strand Weave 节奏分析:Quest/Fire/Constellation 三线占比统计
  12. 7. 伏笔紧急度排序:基于三层级系统(核心/支线/装饰)的优先级计算
  13. 输出格式:
  14. - Markdown 报告(.webnovel/health_report.md)
  15. - 包含 Mermaid 图表(角色关系图、爽点热力图)
  16. 使用方式:
  17. # 生成完整健康报告
  18. python status_reporter.py --output .webnovel/health_report.md
  19. # 仅分析角色活跃度
  20. python status_reporter.py --focus characters
  21. # 仅分析伏笔
  22. python status_reporter.py --focus foreshadowing
  23. # 仅分析爽点节奏
  24. python status_reporter.py --focus pacing
  25. # 分析 Strand Weave 节奏
  26. python status_reporter.py --focus strand
  27. 报告示例:
  28. # 全书健康报告
  29. ## 📊 基本数据
  30. - **总章节数**: 450 章
  31. - **总字数**: 1,985,432 字
  32. - **平均章节字数**: 4,412 字
  33. - **创作进度**: 99.3%(目标 200万字)
  34. ## ⚠️ 角色掉线(3人)
  35. | 角色 | 最后出场 | 缺席章节 | 状态 |
  36. |------|---------|---------|------|
  37. | 李雪 | 第 350 章 | 100 章 | 🔴 严重掉线 |
  38. | 血煞门主 | 第 300 章 | 150 章 | 🔴 严重掉线 |
  39. | 天云宗宗主 | 第 400 章 | 50 章 | 🟡 轻度掉线 |
  40. ## ⚠️ 伏笔超时(2条)
  41. | 伏笔内容 | 埋设章节 | 已过章节 | 状态 |
  42. |---------|---------|---------|------|
  43. | "林家宝库铭文的秘密" | 第 200 章 | 250 章 | 🔴 严重超时 |
  44. | "神秘玉佩的来历" | 第 270 章 | 180 章 | 🟡 轻度超时 |
  45. ## 📈 爽点节奏分布
  46. ```
  47. 第 1-100 章 ████████████ 优秀(1200字/爽点)
  48. 第 101-200章 ██████████ 良好(1500字/爽点)
  49. 第 201-300章 ████████ 良好(1600字/爽点)
  50. 第 301-400章 ████ 偏低(2200字/爽点)⚠️
  51. 第 401-450章 ██████ 良好(1550字/爽点)
  52. ```
  53. ## 💑 人际关系趋势
  54. ```mermaid
  55. graph LR
  56. 主角 -->|好感度95| 李雪
  57. 主角 -->|好感度60| 慕容雪
  58. 主角 -->|仇恨度100| 血煞门
  59. ```
  60. """
  61. import json
  62. import os
  63. import re
  64. import sys
  65. from pathlib import Path
  66. from typing import Dict, List, Any, Tuple
  67. from datetime import datetime
  68. from collections import defaultdict
  69. # Windows 编码兼容性修复
  70. if sys.platform == 'win32':
  71. import io
  72. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
  73. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
  74. class StatusReporter:
  75. """状态报告生成器"""
  76. def __init__(self, project_root: str):
  77. self.project_root = Path(project_root)
  78. self.state_file = self.project_root / ".webnovel/state.json"
  79. self.chapters_dir = self.project_root / "正文"
  80. self.state = None
  81. self.chapters_data = []
  82. def load_state(self) -> bool:
  83. """加载 state.json"""
  84. if not self.state_file.exists():
  85. print(f"❌ 状态文件不存在: {self.state_file}")
  86. return False
  87. with open(self.state_file, 'r', encoding='utf-8') as f:
  88. self.state = json.load(f)
  89. return True
  90. def scan_chapters(self):
  91. """扫描所有章节文件"""
  92. if not self.chapters_dir.exists():
  93. print(f"⚠️ 正文目录不存在: {self.chapters_dir}")
  94. return
  95. for chapter_file in sorted(self.chapters_dir.glob("第*.md")):
  96. # 提取章节号
  97. match = re.search(r'第(\d+)章', chapter_file.name)
  98. if not match:
  99. continue
  100. chapter_num = int(match.group(1))
  101. # 读取章节内容
  102. with open(chapter_file, 'r', encoding='utf-8') as f:
  103. content = f.read()
  104. # 统计字数(去除 Markdown 标记)
  105. text = re.sub(r'```[\s\S]*?```', '', content) # 去除代码块
  106. text = re.sub(r'#+ .+', '', text) # 去除标题
  107. text = re.sub(r'---', '', text) # 去除分隔线
  108. word_count = len(text.strip())
  109. # 提取出场角色(粗略:查找 [角色: XXX])
  110. characters = re.findall(r'\[角色:\s*([^\]]+)\]', content)
  111. self.chapters_data.append({
  112. "chapter": chapter_num,
  113. "file": chapter_file,
  114. "word_count": word_count,
  115. "characters": characters
  116. })
  117. def analyze_characters(self) -> Dict:
  118. """分析角色活跃度"""
  119. if not self.state:
  120. return {}
  121. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  122. entities = self.state.get("entities", {})
  123. characters = entities.get("characters", [])
  124. # 统计每个角色的最后出场章节
  125. character_activity = {}
  126. for char in characters:
  127. char_name = char.get("name")
  128. if not char_name:
  129. continue
  130. # 查找最后出场章节
  131. last_appearance = 0
  132. for ch_data in self.chapters_data:
  133. if char_name in ch_data.get("characters", []):
  134. last_appearance = max(last_appearance, ch_data["chapter"])
  135. absence = current_chapter - last_appearance
  136. character_activity[char_name] = {
  137. "last_appearance": last_appearance,
  138. "absence": absence,
  139. "status": self._get_absence_status(absence)
  140. }
  141. return character_activity
  142. def _get_absence_status(self, absence: int) -> str:
  143. """判断掉线状态"""
  144. if absence == 0:
  145. return "✅ 活跃"
  146. elif absence < 30:
  147. return "🟢 正常"
  148. elif absence < 100:
  149. return "🟡 轻度掉线"
  150. else:
  151. return "🔴 严重掉线"
  152. def analyze_foreshadowing(self) -> List[Dict]:
  153. """分析伏笔深度"""
  154. if not self.state:
  155. return []
  156. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  157. plot_threads = self.state.get("plot_threads", {})
  158. foreshadowing = plot_threads.get("foreshadowing", [])
  159. overdue = []
  160. for item in foreshadowing:
  161. if item.get("status") != "未回收":
  162. continue
  163. # 假设每个伏笔记录了"added_chapter"(埋设章节)
  164. # 如果没有,使用 added_at 日期估算(粗略)
  165. # 这里简化:假设第 1 章开始,平均每天写 1 章
  166. # 简化:假设伏笔按添加顺序,第 N 个伏笔大约在第 N*10 章埋下
  167. # 实际项目应该在伏笔记录中加入 "埋设章节号" 字段
  168. # 这里使用 content 中的关键词匹配(极度简化)
  169. content = item.get("content", "")
  170. # 假设伏笔平均埋设时间 = 当前章节的一半(极度粗糙估算)
  171. estimated_chapter = current_chapter // 2
  172. elapsed = current_chapter - estimated_chapter
  173. overdue.append({
  174. "content": content,
  175. "estimated_chapter": estimated_chapter,
  176. "elapsed": elapsed,
  177. "status": self._get_foreshadowing_status(elapsed)
  178. })
  179. return overdue
  180. def _get_foreshadowing_status(self, elapsed: int) -> str:
  181. """判断伏笔超时状态"""
  182. if elapsed < 50:
  183. return "🟢 正常"
  184. elif elapsed < 150:
  185. return "🟡 轻度超时"
  186. else:
  187. return "🔴 严重超时"
  188. def analyze_foreshadowing_urgency(self) -> List[Dict]:
  189. """
  190. 分析伏笔紧急度(基于三层级系统)
  191. 三层级权重:
  192. - 核心(Core): 权重 3.0 - 必须回收,否则剧情崩塌
  193. - 支线(Sub): 权重 2.0 - 应该回收,否则显得作者健忘
  194. - 装饰(Decor): 权重 1.0 - 可回收可不回收,仅增加真实感
  195. 紧急度计算公式:
  196. urgency = (已过章节 / 目标回收章节) × 层级权重
  197. """
  198. if not self.state:
  199. return []
  200. current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
  201. plot_threads = self.state.get("plot_threads", {})
  202. foreshadowing = plot_threads.get("foreshadowing", [])
  203. # 层级权重映射
  204. tier_weights = {
  205. "核心": 3.0,
  206. "core": 3.0,
  207. "支线": 2.0,
  208. "sub": 2.0,
  209. "装饰": 1.0,
  210. "decor": 1.0
  211. }
  212. urgency_list = []
  213. for item in foreshadowing:
  214. if item.get("status") == "已回收":
  215. continue
  216. content = item.get("content", "")
  217. tier = item.get("tier", "支线") # 默认支线
  218. planted_chapter = item.get("planted_chapter", 1)
  219. target_chapter = item.get("target_chapter", planted_chapter + 100)
  220. weight = tier_weights.get(tier.lower(), 2.0)
  221. elapsed = current_chapter - planted_chapter
  222. remaining = target_chapter - current_chapter
  223. # 紧急度计算
  224. if target_chapter > planted_chapter:
  225. urgency = (elapsed / (target_chapter - planted_chapter)) * weight
  226. else:
  227. urgency = weight * 2 # 已超期
  228. urgency_list.append({
  229. "content": content,
  230. "tier": tier,
  231. "weight": weight,
  232. "planted_chapter": planted_chapter,
  233. "target_chapter": target_chapter,
  234. "elapsed": elapsed,
  235. "remaining": remaining,
  236. "urgency": round(urgency, 2),
  237. "status": self._get_urgency_status(urgency, remaining)
  238. })
  239. # 按紧急度排序(降序)
  240. return sorted(urgency_list, key=lambda x: x["urgency"], reverse=True)
  241. def _get_urgency_status(self, urgency: float, remaining: int) -> str:
  242. """判断紧急度状态"""
  243. if remaining < 0:
  244. return "🔴 已超期"
  245. elif urgency >= 2.0:
  246. return "🔴 紧急"
  247. elif urgency >= 1.0:
  248. return "🟡 警告"
  249. else:
  250. return "🟢 正常"
  251. def analyze_strand_weave(self) -> Dict:
  252. """
  253. 分析 Strand Weave 节奏分布
  254. 三线定义:
  255. - Quest(主线): 战斗、任务、升级 - 目标 55-65%
  256. - Fire(感情): 感情线、人际互动 - 目标 20-30%
  257. - Constellation(世界观): 世界观展开、势力背景 - 目标 10-20%
  258. 检查规则:
  259. - Quest 线连续不超过 5 章
  260. - Fire 线缺失不超过 10 章
  261. - Constellation 线缺失不超过 15 章
  262. """
  263. if not self.state:
  264. return {}
  265. strand_tracker = self.state.get("strand_tracker", {})
  266. history = strand_tracker.get("history", [])
  267. if not history:
  268. return {
  269. "has_data": False,
  270. "message": "暂无 Strand Weave 数据"
  271. }
  272. # 统计各线占比
  273. quest_count = 0
  274. fire_count = 0
  275. constellation_count = 0
  276. total = len(history)
  277. for entry in history:
  278. strand = entry.get("strand", "").lower()
  279. if strand in ["quest", "主线", "战斗", "任务"]:
  280. quest_count += 1
  281. elif strand in ["fire", "感情", "感情线", "互动"]:
  282. fire_count += 1
  283. elif strand in ["constellation", "世界观", "背景", "势力"]:
  284. constellation_count += 1
  285. # 计算占比
  286. quest_ratio = (quest_count / total * 100) if total > 0 else 0
  287. fire_ratio = (fire_count / total * 100) if total > 0 else 0
  288. constellation_ratio = (constellation_count / total * 100) if total > 0 else 0
  289. # 检查违规
  290. violations = []
  291. # 检查 Quest 连续超过 5 章
  292. quest_streak = 0
  293. max_quest_streak = 0
  294. for entry in history:
  295. strand = entry.get("strand", "").lower()
  296. if strand in ["quest", "主线", "战斗", "任务"]:
  297. quest_streak += 1
  298. max_quest_streak = max(max_quest_streak, quest_streak)
  299. else:
  300. quest_streak = 0
  301. if max_quest_streak > 5:
  302. violations.append(f"Quest 线连续 {max_quest_streak} 章(超过 5 章限制)")
  303. # 检查 Fire 缺失超过 10 章
  304. fire_gap = 0
  305. max_fire_gap = 0
  306. for entry in history:
  307. strand = entry.get("strand", "").lower()
  308. if strand in ["fire", "感情", "感情线", "互动"]:
  309. max_fire_gap = max(max_fire_gap, fire_gap)
  310. fire_gap = 0
  311. else:
  312. fire_gap += 1
  313. max_fire_gap = max(max_fire_gap, fire_gap)
  314. if max_fire_gap > 10:
  315. violations.append(f"Fire 线缺失 {max_fire_gap} 章(超过 10 章限制)")
  316. # 检查 Constellation 缺失超过 15 章
  317. const_gap = 0
  318. max_const_gap = 0
  319. for entry in history:
  320. strand = entry.get("strand", "").lower()
  321. if strand in ["constellation", "世界观", "背景", "势力"]:
  322. max_const_gap = max(max_const_gap, const_gap)
  323. const_gap = 0
  324. else:
  325. const_gap += 1
  326. max_const_gap = max(max_const_gap, const_gap)
  327. if max_const_gap > 15:
  328. violations.append(f"Constellation 线缺失 {max_const_gap} 章(超过 15 章限制)")
  329. # 检查占比是否在合理范围
  330. if quest_ratio < 55:
  331. violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏低(目标 55-65%)")
  332. elif quest_ratio > 65:
  333. violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏高(目标 55-65%)")
  334. if fire_ratio < 20:
  335. violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏低(目标 20-30%)")
  336. elif fire_ratio > 30:
  337. violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏高(目标 20-30%)")
  338. if constellation_ratio < 10:
  339. violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏低(目标 10-20%)")
  340. elif constellation_ratio > 20:
  341. violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏高(目标 10-20%)")
  342. return {
  343. "has_data": True,
  344. "total_chapters": total,
  345. "quest": {"count": quest_count, "ratio": quest_ratio},
  346. "fire": {"count": fire_count, "ratio": fire_ratio},
  347. "constellation": {"count": constellation_count, "ratio": constellation_ratio},
  348. "violations": violations,
  349. "max_quest_streak": max_quest_streak,
  350. "max_fire_gap": max_fire_gap,
  351. "max_const_gap": max_const_gap,
  352. "health": "✅ 健康" if not violations else f"⚠️ {len(violations)} 个问题"
  353. }
  354. def analyze_pacing(self) -> List[Dict]:
  355. """分析爽点节奏分布(每 100 章为一段)"""
  356. segment_size = 100
  357. segments = []
  358. for i in range(0, len(self.chapters_data), segment_size):
  359. segment_chapters = self.chapters_data[i:i+segment_size]
  360. if not segment_chapters:
  361. continue
  362. start_ch = segment_chapters[0]["chapter"]
  363. end_ch = segment_chapters[-1]["chapter"]
  364. total_words = sum(ch["word_count"] for ch in segment_chapters)
  365. # 假设爽点数量 = 章节数(简化:每章至少 1 个爽点)
  366. # 实际项目应该在审查报告中记录爽点数量
  367. assumed_cool_points = len(segment_chapters)
  368. words_per_point = total_words / assumed_cool_points if assumed_cool_points > 0 else 0
  369. segments.append({
  370. "start": start_ch,
  371. "end": end_ch,
  372. "total_words": total_words,
  373. "cool_points": assumed_cool_points,
  374. "words_per_point": words_per_point,
  375. "rating": self._get_pacing_rating(words_per_point)
  376. })
  377. return segments
  378. def _get_pacing_rating(self, words_per_point: float) -> str:
  379. """判断节奏评级"""
  380. if words_per_point < 1000:
  381. return "优秀"
  382. elif words_per_point < 1500:
  383. return "良好"
  384. elif words_per_point < 2000:
  385. return "及格"
  386. else:
  387. return "偏低⚠️"
  388. def generate_relationship_graph(self) -> str:
  389. """生成人际关系 Mermaid 图"""
  390. if not self.state:
  391. return ""
  392. relationships = self.state.get("relationships", {})
  393. protagonist_name = self.state.get("protagonist_state", {}).get("name", "主角")
  394. lines = ["```mermaid", "graph LR"]
  395. for char_name, rel_data in relationships.items():
  396. affection = rel_data.get("affection", 0)
  397. hatred = rel_data.get("hatred", 0)
  398. if affection > 0:
  399. lines.append(f" {protagonist_name} -->|好感度{affection}| {char_name}")
  400. if hatred > 0:
  401. lines.append(f" {protagonist_name} -->|仇恨度{hatred}| {char_name}")
  402. lines.append("```")
  403. return "\n".join(lines)
  404. def generate_report(self, focus: str = "all") -> str:
  405. """生成健康报告(Markdown 格式)"""
  406. report_lines = [
  407. "# 全书健康报告",
  408. "",
  409. f"> **生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  410. "",
  411. "---",
  412. ""
  413. ]
  414. # 基本数据
  415. if focus in ["all", "basic"]:
  416. report_lines.extend(self._generate_basic_stats())
  417. # 角色活跃度
  418. if focus in ["all", "characters"]:
  419. report_lines.extend(self._generate_character_section())
  420. # 伏笔深度
  421. if focus in ["all", "foreshadowing"]:
  422. report_lines.extend(self._generate_foreshadowing_section())
  423. # 伏笔紧急度(新增)
  424. if focus in ["all", "foreshadowing", "urgency"]:
  425. report_lines.extend(self._generate_urgency_section())
  426. # 爽点节奏
  427. if focus in ["all", "pacing"]:
  428. report_lines.extend(self._generate_pacing_section())
  429. # Strand Weave 节奏(新增)
  430. if focus in ["all", "strand", "pacing"]:
  431. report_lines.extend(self._generate_strand_section())
  432. # 人际关系
  433. if focus in ["all", "relationships"]:
  434. report_lines.extend(self._generate_relationship_section())
  435. return "\n".join(report_lines)
  436. def _generate_basic_stats(self) -> List[str]:
  437. """生成基本统计"""
  438. if not self.state:
  439. return []
  440. progress = self.state.get("progress", {})
  441. current_chapter = progress.get("current_chapter", 0)
  442. total_words = progress.get("total_words", 0)
  443. target_words = self.state.get("project_info", {}).get("target_words", 2000000)
  444. avg_words = total_words / current_chapter if current_chapter > 0 else 0
  445. completion = (total_words / target_words * 100) if target_words > 0 else 0
  446. return [
  447. "## 📊 基本数据",
  448. "",
  449. f"- **总章节数**: {current_chapter} 章",
  450. f"- **总字数**: {total_words:,} 字",
  451. f"- **平均章节字数**: {avg_words:,.0f} 字",
  452. f"- **创作进度**: {completion:.1f}%(目标 {target_words:,} 字)",
  453. "",
  454. "---",
  455. ""
  456. ]
  457. def _generate_character_section(self) -> List[str]:
  458. """生成角色分析章节"""
  459. activity = self.analyze_characters()
  460. if not activity:
  461. return []
  462. # 筛选掉线角色
  463. dropped = {name: data for name, data in activity.items()
  464. if "掉线" in data["status"]}
  465. lines = [
  466. f"## ⚠️ 角色掉线({len(dropped)}人)",
  467. ""
  468. ]
  469. if dropped:
  470. lines.extend([
  471. "| 角色 | 最后出场 | 缺席章节 | 状态 |",
  472. "|------|---------|---------|------|"
  473. ])
  474. for char_name, data in sorted(dropped.items(),
  475. key=lambda x: x[1]["absence"],
  476. reverse=True):
  477. lines.append(
  478. f"| {char_name} | 第 {data['last_appearance']} 章 | "
  479. f"{data['absence']} 章 | {data['status']} |"
  480. )
  481. else:
  482. lines.append("✅ 所有角色活跃度正常")
  483. lines.extend(["", "---", ""])
  484. return lines
  485. def _generate_foreshadowing_section(self) -> List[str]:
  486. """生成伏笔分析章节"""
  487. overdue = self.analyze_foreshadowing()
  488. # 筛选超时伏笔
  489. overdue_items = [item for item in overdue if "超时" in item["status"]]
  490. lines = [
  491. f"## ⚠️ 伏笔超时({len(overdue_items)}条)",
  492. ""
  493. ]
  494. if overdue_items:
  495. lines.extend([
  496. "| 伏笔内容 | 估计埋设 | 已过章节 | 状态 |",
  497. "|---------|---------|---------|------|"
  498. ])
  499. for item in sorted(overdue_items, key=lambda x: x["elapsed"], reverse=True):
  500. lines.append(
  501. f"| {item['content'][:30]}... | 第 {item['estimated_chapter']} 章 | "
  502. f"{item['elapsed']} 章 | {item['status']} |"
  503. )
  504. else:
  505. lines.append("✅ 所有伏笔进度正常")
  506. lines.extend(["", "---", ""])
  507. return lines
  508. def _generate_urgency_section(self) -> List[str]:
  509. """生成伏笔紧急度章节(基于三层级系统)"""
  510. urgency_list = self.analyze_foreshadowing_urgency()
  511. # 筛选紧急伏笔
  512. urgent_items = [item for item in urgency_list if item["urgency"] >= 1.0]
  513. lines = [
  514. f"## 🚨 伏笔紧急度排序({len(urgent_items)}条需关注)",
  515. "",
  516. "> 基于三层级系统:核心(×3) / 支线(×2) / 装饰(×1)",
  517. "> 紧急度 = (已过章节 / 目标回收章节) × 层级权重",
  518. ""
  519. ]
  520. if urgency_list:
  521. lines.extend([
  522. "| 伏笔内容 | 层级 | 埋设 | 目标 | 紧急度 | 状态 |",
  523. "|---------|------|------|------|--------|------|"
  524. ])
  525. for item in urgency_list[:10]: # 只显示前10条
  526. lines.append(
  527. f"| {item['content'][:20]}... | {item['tier']} | "
  528. f"第{item['planted_chapter']}章 | 第{item['target_chapter']}章 | "
  529. f"{item['urgency']:.2f} | {item['status']} |"
  530. )
  531. else:
  532. lines.append("✅ 暂无伏笔数据")
  533. lines.extend(["", "---", ""])
  534. return lines
  535. def _generate_strand_section(self) -> List[str]:
  536. """生成 Strand Weave 节奏章节"""
  537. strand_data = self.analyze_strand_weave()
  538. lines = [
  539. "## 🎭 Strand Weave 节奏分析",
  540. ""
  541. ]
  542. if not strand_data.get("has_data"):
  543. lines.append(f"⚠️ {strand_data.get('message', '暂无数据')}")
  544. lines.extend(["", "---", ""])
  545. return lines
  546. # 占比统计
  547. lines.extend([
  548. "### 三线占比",
  549. "",
  550. "| Strand | 章节数 | 占比 | 目标范围 | 状态 |",
  551. "|--------|--------|------|----------|------|"
  552. ])
  553. q = strand_data["quest"]
  554. q_status = "✅" if 55 <= q["ratio"] <= 65 else "⚠️"
  555. lines.append(f"| Quest(主线) | {q['count']} | {q['ratio']:.1f}% | 55-65% | {q_status} |")
  556. f = strand_data["fire"]
  557. f_status = "✅" if 20 <= f["ratio"] <= 30 else "⚠️"
  558. lines.append(f"| Fire(感情) | {f['count']} | {f['ratio']:.1f}% | 20-30% | {f_status} |")
  559. c = strand_data["constellation"]
  560. c_status = "✅" if 10 <= c["ratio"] <= 20 else "⚠️"
  561. lines.append(f"| Constellation(世界观) | {c['count']} | {c['ratio']:.1f}% | 10-20% | {c_status} |")
  562. lines.append("")
  563. # 连续性检查
  564. lines.extend([
  565. "### 连续性检查",
  566. "",
  567. f"- Quest 最大连续: {strand_data['max_quest_streak']} 章(限制 ≤5)",
  568. f"- Fire 最大缺失: {strand_data['max_fire_gap']} 章(限制 ≤10)",
  569. f"- Constellation 最大缺失: {strand_data['max_const_gap']} 章(限制 ≤15)",
  570. ""
  571. ])
  572. # 违规清单
  573. if strand_data["violations"]:
  574. lines.extend([
  575. "### ⚠️ 违规清单",
  576. ""
  577. ])
  578. for v in strand_data["violations"]:
  579. lines.append(f"- {v}")
  580. else:
  581. lines.append("### ✅ 无违规")
  582. lines.extend(["", f"**综合健康度**: {strand_data['health']}", "", "---", ""])
  583. return lines
  584. def _generate_pacing_section(self) -> List[str]:
  585. """生成节奏分析章节"""
  586. segments = self.analyze_pacing()
  587. lines = [
  588. "## 📈 爽点节奏分布",
  589. "",
  590. "```"
  591. ]
  592. for seg in segments:
  593. bar_length = int(12 - (seg["words_per_point"] / 2000 * 12))
  594. bar_length = max(1, min(12, bar_length))
  595. bar = "█" * bar_length
  596. lines.append(
  597. f"第 {seg['start']}-{seg['end']}章 {bar} "
  598. f"{seg['rating']}({seg['words_per_point']:.0f}字/爽点)"
  599. )
  600. lines.extend(["```", "", "---", ""])
  601. return lines
  602. def _generate_relationship_section(self) -> List[str]:
  603. """生成人际关系章节"""
  604. graph = self.generate_relationship_graph()
  605. lines = [
  606. "## 💑 人际关系趋势",
  607. "",
  608. graph,
  609. "",
  610. "---",
  611. ""
  612. ]
  613. return lines
  614. def main():
  615. import argparse
  616. parser = argparse.ArgumentParser(
  617. description="可视化状态报告生成器",
  618. formatter_class=argparse.RawDescriptionHelpFormatter,
  619. epilog="""
  620. 示例:
  621. # 生成完整健康报告
  622. python status_reporter.py --output .webnovel/health_report.md
  623. # 仅分析角色活跃度
  624. python status_reporter.py --focus characters
  625. # 仅分析伏笔
  626. python status_reporter.py --focus foreshadowing
  627. # 仅分析爽点节奏
  628. python status_reporter.py --focus pacing
  629. """
  630. )
  631. parser.add_argument('--output', default='.webnovel/health_report.md',
  632. help='输出文件路径')
  633. parser.add_argument('--focus', choices=['all', 'basic', 'characters',
  634. 'foreshadowing', 'urgency', 'pacing',
  635. 'strand', 'relationships'],
  636. default='all', help='分析焦点(新增 urgency, strand)')
  637. parser.add_argument('--project-root', default='.', help='项目根目录')
  638. args = parser.parse_args()
  639. # 创建报告生成器
  640. reporter = StatusReporter(args.project_root)
  641. # 加载状态
  642. if not reporter.load_state():
  643. sys.exit(1)
  644. print("📖 正在扫描章节文件...")
  645. reporter.scan_chapters()
  646. print(f"✅ 已扫描 {len(reporter.chapters_data)} 个章节")
  647. print("\n📊 正在分析...")
  648. # 生成报告
  649. report = reporter.generate_report(args.focus)
  650. # 保存报告
  651. output_file = Path(args.output)
  652. output_file.parent.mkdir(parents=True, exist_ok=True)
  653. with open(output_file, 'w', encoding='utf-8') as f:
  654. f.write(report)
  655. print(f"\n✅ 健康报告已生成: {output_file}")
  656. # 预览报告(前 30 行)
  657. print("\n" + "="*60)
  658. print("📄 报告预览:\n")
  659. print("\n".join(report.split("\n")[:30]))
  660. print("\n...")
  661. print("="*60)
  662. if __name__ == "__main__":
  663. main()