| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992 |
- #!/usr/bin/env python3
- """
- 可视化状态报告系统 (Status Reporter)
- 核心理念:面对 1000 个章节,作者会迷失。需要"宏观俯瞰"能力。
- 功能:
- 1. 角色活跃度分析:哪些角色太久没出场(掉线统计)
- 2. 伏笔深度分析:哪些坑挖得太久了(超过 20 万字未收)+ 紧急度排序
- 3. 爽点节奏分布:全书高潮点的分布频率(热力图)
- 4. 字数分布统计:各卷、各篇的字数分布
- 5. 人际关系图谱:好感度/仇恨度趋势
- 6. Strand Weave 节奏分析:Quest/Fire/Constellation 三线占比统计
- 7. 伏笔紧急度排序:基于三层级系统(核心/支线/装饰)的优先级计算
- 输出格式:
- - Markdown 报告(.webnovel/health_report.md)
- - 包含 Mermaid 图表(角色关系图、爽点热力图)
- 使用方式:
- # 生成完整健康报告
- python status_reporter.py --output .webnovel/health_report.md
- # 仅分析角色活跃度
- python status_reporter.py --focus characters
- # 仅分析伏笔
- python status_reporter.py --focus foreshadowing
- # 仅分析爽点节奏
- python status_reporter.py --focus pacing
- # 分析 Strand Weave 节奏
- python status_reporter.py --focus strand
- 报告示例:
- # 全书健康报告
- ## 📊 基本数据
- - **总章节数**: 450 章
- - **总字数**: 1,985,432 字
- - **平均章节字数**: 4,412 字
- - **创作进度**: 99.3%(目标 200万字)
- ## ⚠️ 角色掉线(3人)
- | 角色 | 最后出场 | 缺席章节 | 状态 |
- |------|---------|---------|------|
- | 李雪 | 第 350 章 | 100 章 | 🔴 严重掉线 |
- | 血煞门主 | 第 300 章 | 150 章 | 🔴 严重掉线 |
- | 天云宗宗主 | 第 400 章 | 50 章 | 🟡 轻度掉线 |
- ## ⚠️ 伏笔超时(2条)
- | 伏笔内容 | 埋设章节 | 已过章节 | 状态 |
- |---------|---------|---------|------|
- | "林家宝库铭文的秘密" | 第 200 章 | 250 章 | 🔴 严重超时 |
- | "神秘玉佩的来历" | 第 270 章 | 180 章 | 🟡 轻度超时 |
- ## 📈 爽点节奏分布
- ```
- 第 1-100 章 ████████████ 优秀(1200字/爽点)
- 第 101-200章 ██████████ 良好(1500字/爽点)
- 第 201-300章 ████████ 良好(1600字/爽点)
- 第 301-400章 ████ 偏低(2200字/爽点)⚠️
- 第 401-450章 ██████ 良好(1550字/爽点)
- ```
- ## 💑 人际关系趋势
- ```mermaid
- graph LR
- 主角 -->|好感度95| 李雪
- 主角 -->|好感度60| 慕容雪
- 主角 -->|仇恨度100| 血煞门
- ```
- """
- import json
- import os
- import re
- import sys
- from pathlib import Path
- from typing import Dict, List, Any, Tuple
- from datetime import datetime
- from collections import defaultdict
- from project_locator import resolve_project_root
- from chapter_paths import extract_chapter_num_from_filename
- # 导入配置
- try:
- from data_modules.config import get_config, DataModulesConfig
- except ImportError:
- from scripts.data_modules.config import get_config, DataModulesConfig
- def _is_resolved_foreshadowing_status(raw_status: Any) -> bool:
- """判断伏笔是否已回收(兼容历史字段与同义词)。"""
- if raw_status is None:
- return False
- status = str(raw_status).strip()
- if not status:
- return False
- status_lower = status.lower()
- if status in {"已回收", "已完成", "已解决", "完成"}:
- return True
- if status_lower in {"resolved", "done", "complete"}:
- return True
- if "已回收" in status:
- return True
- return False
- # Windows 编码兼容性修复
- if sys.platform == 'win32':
- import io
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
- class StatusReporter:
- """状态报告生成器"""
- def __init__(self, project_root: str):
- self.project_root = Path(project_root)
- self.config = get_config(self.project_root)
- self.state_file = self.project_root / ".webnovel/state.json"
- self.chapters_dir = self.project_root / "正文"
- self.state = None
- self.chapters_data = []
- # 可选:集成结构化索引(如果可用,角色统计更准)
- self.index = None
- try:
- from structured_index import StructuredIndex
- self.index = StructuredIndex(self.project_root)
- except Exception:
- self.index = None
- def _extract_stats_field(self, content: str, field_name: str) -> str:
- """
- 从“本章统计”区块提取字段值,例如:
- - **主导Strand**: quest
- """
- pattern = rf"^\s*-\s*\*\*{re.escape(field_name)}\*\*\s*:\s*(.+?)\s*$"
- for line in content.splitlines():
- m = re.match(pattern, line)
- if m:
- return m.group(1).strip()
- return ""
- def load_state(self) -> bool:
- """加载 state.json"""
- if not self.state_file.exists():
- print(f"❌ 状态文件不存在: {self.state_file}")
- return False
- with open(self.state_file, 'r', encoding='utf-8') as f:
- self.state = json.load(f)
- return True
- def scan_chapters(self):
- """扫描所有章节文件"""
- if not self.chapters_dir.exists():
- print(f"⚠️ 正文目录不存在: {self.chapters_dir}")
- return
- # 支持两种目录结构:
- # 1) 正文/第0001章.md
- # 2) 正文/第1卷/第001章-标题.md
- chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
- # 角色候选(fallback 用):从 state.json 获取已知角色名 (v5.0 entities_v3 格式)
- known_character_names: List[str] = []
- protagonist_name = ""
- if self.state:
- protagonist_name = self.state.get("protagonist_state", {}).get("name", "") or ""
- # v5.0: 从 entities_v3.角色 获取角色名
- entities_v3 = self.state.get("entities_v3", {})
- characters_dict = entities_v3.get("角色", {})
- known_character_names = [
- c.get("canonical_name", char_id)
- for char_id, c in characters_dict.items()
- if c.get("canonical_name")
- ]
- for chapter_file in chapter_files:
- chapter_num = extract_chapter_num_from_filename(chapter_file.name)
- if not chapter_num:
- continue
- # 读取章节内容
- with open(chapter_file, 'r', encoding='utf-8') as f:
- content = f.read()
- # 统计字数(去除 Markdown 标记)
- text = re.sub(r'```[\s\S]*?```', '', content) # 去除代码块
- text = re.sub(r'#+ .+', '', text) # 去除标题
- text = re.sub(r'---', '', text) # 去除分隔线
- word_count = len(text.strip())
- # 主导 Strand / 爽点类型(优先从“本章统计”解析)
- dominant_strand = (self._extract_stats_field(content, "主导Strand") or "").lower()
- cool_point_type = self._extract_stats_field(content, "爽点")
- # 角色提取:优先从结构化索引读取(若有),否则 fallback 用“出现即算出场”
- characters: List[str] = []
- if self.index is not None:
- try:
- cursor = self.index.conn.execute(
- "SELECT characters FROM chapters WHERE chapter_num = ?",
- (chapter_num,),
- )
- row = cursor.fetchone()
- if row and row[0]:
- try:
- stored = json.loads(row[0])
- if isinstance(stored, list):
- # v4.0: chapters.characters 存 entity_id 列表,输出时尽量还原为 canonical_name
- for x in stored:
- entity_id = str(x).strip()
- if not entity_id:
- continue
- name = entity_id
- try:
- ent = self.index.query_entity_by_id(entity_id)
- if ent and ent.get("canonical_name"):
- name = str(ent["canonical_name"]).strip() or entity_id
- except Exception:
- name = entity_id
- characters.append(name)
- except json.JSONDecodeError:
- characters = []
- except Exception:
- characters = []
- if not characters and (protagonist_name or known_character_names):
- # 限制候选规模,避免在超大角色库下过慢
- candidates = []
- if protagonist_name:
- candidates.append(protagonist_name)
- candidates.extend(known_character_names[:self.config.character_candidates_limit])
- seen = set()
- for name in candidates:
- if not name or name in seen:
- continue
- if name in content:
- characters.append(name)
- seen.add(name)
- self.chapters_data.append({
- "chapter": chapter_num,
- "file": chapter_file,
- "word_count": word_count,
- "characters": characters,
- "dominant": dominant_strand,
- "cool_point": cool_point_type,
- })
- def analyze_characters(self) -> Dict:
- """分析角色活跃度 (v5.0 entities_v3 格式)"""
- if not self.state:
- return {}
- current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
- # v5.0: 从 entities_v3.角色 获取角色
- entities_v3 = self.state.get("entities_v3", {})
- characters_dict = entities_v3.get("角色", {})
- # 统计每个角色的最后出场章节
- character_activity = {}
- for char_id, char in characters_dict.items():
- char_name = char.get("canonical_name", char_id)
- if not char_name:
- continue
- # 查找最后出场章节
- last_appearance = 0
- for ch_data in self.chapters_data:
- if char_name in ch_data.get("characters", []):
- last_appearance = max(last_appearance, ch_data["chapter"])
- absence = current_chapter - last_appearance
- character_activity[char_name] = {
- "last_appearance": last_appearance,
- "absence": absence,
- "status": self._get_absence_status(absence)
- }
- return character_activity
- def _get_absence_status(self, absence: int) -> str:
- """判断掉线状态"""
- if absence == 0:
- return "✅ 活跃"
- elif absence < self.config.character_absence_warning:
- return "🟢 正常"
- elif absence < self.config.character_absence_critical:
- return "🟡 轻度掉线"
- else:
- return "🔴 严重掉线"
- def analyze_foreshadowing(self) -> List[Dict]:
- """分析伏笔深度"""
- if not self.state:
- return []
- current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
- plot_threads = self.state.get("plot_threads", {})
- foreshadowing = plot_threads.get("foreshadowing", [])
- overdue = []
- for item in foreshadowing:
- status = item.get("status")
- if _is_resolved_foreshadowing_status(status):
- continue
- # 假设每个伏笔记录了"added_chapter"(埋设章节)
- # 如果没有,使用 added_at 日期估算(粗略)
- # 这里简化:假设第 1 章开始,平均每天写 1 章
- # 简化:假设伏笔按添加顺序,第 N 个伏笔大约在第 N*10 章埋下
- # 实际项目应该在伏笔记录中加入 "埋设章节号" 字段
- # 这里使用 content 中的关键词匹配(极度简化)
- content = item.get("content", "")
- # 假设伏笔平均埋设时间 = 当前章节的一半(极度粗糙估算)
- estimated_chapter = current_chapter // 2
- elapsed = current_chapter - estimated_chapter
- overdue.append({
- "content": content,
- "estimated_chapter": estimated_chapter,
- "elapsed": elapsed,
- "status": self._get_foreshadowing_status(elapsed)
- })
- return overdue
- def _get_foreshadowing_status(self, elapsed: int) -> str:
- """判断伏笔超时状态"""
- if elapsed < self.config.foreshadowing_urgency_pending_medium:
- return "🟢 正常"
- elif elapsed < self.config.foreshadowing_urgency_pending_high + 50:
- return "🟡 轻度超时"
- else:
- return "🔴 严重超时"
- def analyze_foreshadowing_urgency(self) -> List[Dict]:
- """
- 分析伏笔紧急度(基于三层级系统)
- 三层级权重:
- - 核心(Core): 权重 3.0 - 必须回收,否则剧情崩塌
- - 支线(Sub): 权重 2.0 - 应该回收,否则显得作者健忘
- - 装饰(Decor): 权重 1.0 - 可回收可不回收,仅增加真实感
- 紧急度计算公式:
- urgency = (已过章节 / 目标回收章节) × 层级权重
- """
- if not self.state:
- return []
- current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
- plot_threads = self.state.get("plot_threads", {})
- foreshadowing = plot_threads.get("foreshadowing", [])
- # 层级权重映射
- tier_weights = {
- "核心": self.config.foreshadowing_tier_weight_core,
- "core": self.config.foreshadowing_tier_weight_core,
- "支线": self.config.foreshadowing_tier_weight_sub,
- "sub": self.config.foreshadowing_tier_weight_sub,
- "装饰": self.config.foreshadowing_tier_weight_decor,
- "decor": self.config.foreshadowing_tier_weight_decor
- }
- urgency_list = []
- for item in foreshadowing:
- if _is_resolved_foreshadowing_status(item.get("status")):
- continue
- content = item.get("content", "")
- tier = item.get("tier", "支线") # 默认支线
- planted_chapter = item.get("planted_chapter", 1)
- target_chapter = item.get("target_chapter", planted_chapter + 100)
- weight = tier_weights.get(tier.lower(), self.config.foreshadowing_tier_weight_sub)
- elapsed = current_chapter - planted_chapter
- remaining = target_chapter - current_chapter
- # 紧急度计算
- if target_chapter > planted_chapter:
- urgency = (elapsed / (target_chapter - planted_chapter)) * weight
- else:
- urgency = weight * 2 # 已超期
- urgency_list.append({
- "content": content,
- "tier": tier,
- "weight": weight,
- "planted_chapter": planted_chapter,
- "target_chapter": target_chapter,
- "elapsed": elapsed,
- "remaining": remaining,
- "urgency": round(urgency, 2),
- "status": self._get_urgency_status(urgency, remaining)
- })
- # 按紧急度排序(降序)
- return sorted(urgency_list, key=lambda x: x["urgency"], reverse=True)
- def _get_urgency_status(self, urgency: float, remaining: int) -> str:
- """判断紧急度状态"""
- if remaining < 0:
- return "🔴 已超期"
- elif urgency >= self.config.foreshadowing_tier_weight_sub:
- return "🔴 紧急"
- elif urgency >= 1.0:
- return "🟡 警告"
- else:
- return "🟢 正常"
- def analyze_strand_weave(self) -> Dict:
- """
- 分析 Strand Weave 节奏分布
- 三线定义:
- - Quest(主线): 战斗、任务、升级 - 目标 55-65%
- - Fire(感情): 感情线、人际互动 - 目标 20-30%
- - Constellation(世界观): 世界观展开、势力背景 - 目标 10-20%
- 检查规则:
- - Quest 线连续不超过 5 章
- - Fire 线缺失不超过 10 章
- - Constellation 线缺失不超过 15 章
- """
- if not self.state:
- return {}
- strand_tracker = self.state.get("strand_tracker", {})
- history = strand_tracker.get("history", [])
- if not history:
- return {
- "has_data": False,
- "message": "暂无 Strand Weave 数据"
- }
- # 统计各线占比
- quest_count = 0
- fire_count = 0
- constellation_count = 0
- total = len(history)
- for entry in history:
- strand = (entry.get("strand") or entry.get("dominant") or "").lower()
- if strand in ["quest", "主线", "战斗", "任务"]:
- quest_count += 1
- elif strand in ["fire", "感情", "感情线", "互动"]:
- fire_count += 1
- elif strand in ["constellation", "世界观", "背景", "势力"]:
- constellation_count += 1
- # 计算占比
- quest_ratio = (quest_count / total * 100) if total > 0 else 0
- fire_ratio = (fire_count / total * 100) if total > 0 else 0
- constellation_ratio = (constellation_count / total * 100) if total > 0 else 0
- # 检查违规
- violations = []
- # 检查 Quest 连续超过 5 章
- quest_streak = 0
- max_quest_streak = 0
- for entry in history:
- strand = (entry.get("strand") or entry.get("dominant") or "").lower()
- if strand in ["quest", "主线", "战斗", "任务"]:
- quest_streak += 1
- max_quest_streak = max(max_quest_streak, quest_streak)
- else:
- quest_streak = 0
- if max_quest_streak > self.config.strand_quest_max_consecutive:
- violations.append(f"Quest 线连续 {max_quest_streak} 章(超过 {self.config.strand_quest_max_consecutive} 章限制)")
- # 检查 Fire 缺失超过 10 章
- fire_gap = 0
- max_fire_gap = 0
- for entry in history:
- strand = (entry.get("strand") or entry.get("dominant") or "").lower()
- if strand in ["fire", "感情", "感情线", "互动"]:
- max_fire_gap = max(max_fire_gap, fire_gap)
- fire_gap = 0
- else:
- fire_gap += 1
- max_fire_gap = max(max_fire_gap, fire_gap)
- if max_fire_gap > self.config.strand_fire_max_gap:
- violations.append(f"Fire 线缺失 {max_fire_gap} 章(超过 {self.config.strand_fire_max_gap} 章限制)")
- # 检查 Constellation 缺失超过 15 章
- const_gap = 0
- max_const_gap = 0
- for entry in history:
- strand = (entry.get("strand") or entry.get("dominant") or "").lower()
- if strand in ["constellation", "世界观", "背景", "势力"]:
- max_const_gap = max(max_const_gap, const_gap)
- const_gap = 0
- else:
- const_gap += 1
- max_const_gap = max(max_const_gap, const_gap)
- if max_const_gap > self.config.strand_constellation_max_gap:
- violations.append(f"Constellation 线缺失 {max_const_gap} 章(超过 {self.config.strand_constellation_max_gap} 章限制)")
- # 检查占比是否在合理范围
- cfg = self.config
- if quest_ratio < cfg.strand_quest_ratio_min:
- violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏低(目标 {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}%)")
- elif quest_ratio > cfg.strand_quest_ratio_max:
- violations.append(f"Quest 占比 {quest_ratio:.1f}% 偏高(目标 {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}%)")
- if fire_ratio < cfg.strand_fire_ratio_min:
- violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏低(目标 {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}%)")
- elif fire_ratio > cfg.strand_fire_ratio_max:
- violations.append(f"Fire 占比 {fire_ratio:.1f}% 偏高(目标 {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}%)")
- if constellation_ratio < cfg.strand_constellation_ratio_min:
- violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏低(目标 {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}%)")
- elif constellation_ratio > cfg.strand_constellation_ratio_max:
- violations.append(f"Constellation 占比 {constellation_ratio:.1f}% 偏高(目标 {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}%)")
- return {
- "has_data": True,
- "total_chapters": total,
- "quest": {"count": quest_count, "ratio": quest_ratio},
- "fire": {"count": fire_count, "ratio": fire_ratio},
- "constellation": {"count": constellation_count, "ratio": constellation_ratio},
- "violations": violations,
- "max_quest_streak": max_quest_streak,
- "max_fire_gap": max_fire_gap,
- "max_const_gap": max_const_gap,
- "health": "✅ 健康" if not violations else f"⚠️ {len(violations)} 个问题"
- }
- def analyze_pacing(self) -> List[Dict]:
- """分析爽点节奏分布(每 N 章为一段)"""
- segment_size = self.config.pacing_segment_size
- segments = []
- for i in range(0, len(self.chapters_data), segment_size):
- segment_chapters = self.chapters_data[i:i+segment_size]
- if not segment_chapters:
- continue
- start_ch = segment_chapters[0]["chapter"]
- end_ch = segment_chapters[-1]["chapter"]
- total_words = sum(ch["word_count"] for ch in segment_chapters)
- # 假设爽点数量 = 章节数(简化:每章至少 1 个爽点)
- # 实际项目应该在审查报告中记录爽点数量
- assumed_cool_points = len(segment_chapters)
- words_per_point = total_words / assumed_cool_points if assumed_cool_points > 0 else 0
- segments.append({
- "start": start_ch,
- "end": end_ch,
- "total_words": total_words,
- "cool_points": assumed_cool_points,
- "words_per_point": words_per_point,
- "rating": self._get_pacing_rating(words_per_point)
- })
- return segments
- def _get_pacing_rating(self, words_per_point: float) -> str:
- """判断节奏评级"""
- if words_per_point < self.config.pacing_words_per_point_excellent:
- return "优秀"
- elif words_per_point < self.config.pacing_words_per_point_good:
- return "良好"
- elif words_per_point < self.config.pacing_words_per_point_acceptable:
- return "及格"
- else:
- return "偏低⚠️"
- def generate_relationship_graph(self) -> str:
- """生成人际关系 Mermaid 图"""
- if not self.state:
- return ""
- relationships = self.state.get("relationships", {})
- protagonist_name = self.state.get("protagonist_state", {}).get("name", "主角")
- lines = ["```mermaid", "graph LR"]
- # 支持两种格式:
- # 格式1(新): {"allies": [...], "enemies": [...]}
- # 格式2(旧): {"角色名": {"affection": X, "hatred": Y}}
- allies = relationships.get("allies", [])
- enemies = relationships.get("enemies", [])
- if allies or enemies:
- # 新格式
- for ally in allies:
- if isinstance(ally, dict):
- name = ally.get("name", "未知")
- relation = ally.get("relation", "友好")
- lines.append(f" {protagonist_name} -->|{relation}| {name}")
- for enemy in enemies:
- if isinstance(enemy, dict):
- name = enemy.get("name", "未知")
- relation = enemy.get("relation", "敌对")
- lines.append(f" {protagonist_name} -.->|{relation}| {name}")
- else:
- # 旧格式兼容
- for char_name, rel_data in relationships.items():
- if isinstance(rel_data, dict):
- affection = rel_data.get("affection", 0)
- hatred = rel_data.get("hatred", 0)
- if affection > 0:
- lines.append(f" {protagonist_name} -->|好感度{affection}| {char_name}")
- if hatred > 0:
- lines.append(f" {protagonist_name} -.->|仇恨度{hatred}| {char_name}")
- lines.append("```")
- return "\n".join(lines)
- def generate_report(self, focus: str = "all") -> str:
- """生成健康报告(Markdown 格式)"""
- report_lines = [
- "# 全书健康报告",
- "",
- f"> **生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
- "",
- "---",
- ""
- ]
- # 基本数据
- if focus in ["all", "basic"]:
- report_lines.extend(self._generate_basic_stats())
- # 角色活跃度
- if focus in ["all", "characters"]:
- report_lines.extend(self._generate_character_section())
- # 伏笔深度
- if focus in ["all", "foreshadowing"]:
- report_lines.extend(self._generate_foreshadowing_section())
- # 伏笔紧急度(新增)
- if focus in ["all", "foreshadowing", "urgency"]:
- report_lines.extend(self._generate_urgency_section())
- # 爽点节奏
- if focus in ["all", "pacing"]:
- report_lines.extend(self._generate_pacing_section())
- # Strand Weave 节奏(新增)
- if focus in ["all", "strand", "pacing"]:
- report_lines.extend(self._generate_strand_section())
- # 人际关系
- if focus in ["all", "relationships"]:
- report_lines.extend(self._generate_relationship_section())
- return "\n".join(report_lines)
- def _generate_basic_stats(self) -> List[str]:
- """生成基本统计"""
- if not self.state:
- return []
- progress = self.state.get("progress", {})
- current_chapter = progress.get("current_chapter", 0)
- total_words = progress.get("total_words", 0)
- target_words = self.state.get("project_info", {}).get("target_words", 2000000)
- avg_words = total_words / current_chapter if current_chapter > 0 else 0
- completion = (total_words / target_words * 100) if target_words > 0 else 0
- return [
- "## 📊 基本数据",
- "",
- f"- **总章节数**: {current_chapter} 章",
- f"- **总字数**: {total_words:,} 字",
- f"- **平均章节字数**: {avg_words:,.0f} 字",
- f"- **创作进度**: {completion:.1f}%(目标 {target_words:,} 字)",
- "",
- "---",
- ""
- ]
- def _generate_character_section(self) -> List[str]:
- """生成角色分析章节"""
- activity = self.analyze_characters()
- if not activity:
- return []
- # 筛选掉线角色
- dropped = {name: data for name, data in activity.items()
- if "掉线" in data["status"]}
- lines = [
- f"## ⚠️ 角色掉线({len(dropped)}人)",
- ""
- ]
- if dropped:
- lines.extend([
- "| 角色 | 最后出场 | 缺席章节 | 状态 |",
- "|------|---------|---------|------|"
- ])
- for char_name, data in sorted(dropped.items(),
- key=lambda x: x[1]["absence"],
- reverse=True):
- lines.append(
- f"| {char_name} | 第 {data['last_appearance']} 章 | "
- f"{data['absence']} 章 | {data['status']} |"
- )
- else:
- lines.append("✅ 所有角色活跃度正常")
- lines.extend(["", "---", ""])
- return lines
- def _generate_foreshadowing_section(self) -> List[str]:
- """生成伏笔分析章节"""
- overdue = self.analyze_foreshadowing()
- # 筛选超时伏笔
- overdue_items = [item for item in overdue if "超时" in item["status"]]
- lines = [
- f"## ⚠️ 伏笔超时({len(overdue_items)}条)",
- ""
- ]
- if overdue_items:
- lines.extend([
- "| 伏笔内容 | 估计埋设 | 已过章节 | 状态 |",
- "|---------|---------|---------|------|"
- ])
- for item in sorted(overdue_items, key=lambda x: x["elapsed"], reverse=True):
- lines.append(
- f"| {item['content'][:30]}... | 第 {item['estimated_chapter']} 章 | "
- f"{item['elapsed']} 章 | {item['status']} |"
- )
- else:
- lines.append("✅ 所有伏笔进度正常")
- lines.extend(["", "---", ""])
- return lines
- def _generate_urgency_section(self) -> List[str]:
- """生成伏笔紧急度章节(基于三层级系统)"""
- urgency_list = self.analyze_foreshadowing_urgency()
- # 筛选紧急伏笔
- urgent_items = [item for item in urgency_list if item["urgency"] >= 1.0]
- lines = [
- f"## 🚨 伏笔紧急度排序({len(urgent_items)}条需关注)",
- "",
- "> 基于三层级系统:核心(×3) / 支线(×2) / 装饰(×1)",
- "> 紧急度 = (已过章节 / 目标回收章节) × 层级权重",
- ""
- ]
- if urgency_list:
- lines.extend([
- "| 伏笔内容 | 层级 | 埋设 | 目标 | 紧急度 | 状态 |",
- "|---------|------|------|------|--------|------|"
- ])
- for item in urgency_list[:10]: # 只显示前10条
- lines.append(
- f"| {item['content'][:20]}... | {item['tier']} | "
- f"第{item['planted_chapter']}章 | 第{item['target_chapter']}章 | "
- f"{item['urgency']:.2f} | {item['status']} |"
- )
- else:
- lines.append("✅ 暂无伏笔数据")
- lines.extend(["", "---", ""])
- return lines
- def _generate_strand_section(self) -> List[str]:
- """生成 Strand Weave 节奏章节"""
- strand_data = self.analyze_strand_weave()
- lines = [
- "## 🎭 Strand Weave 节奏分析",
- ""
- ]
- if not strand_data.get("has_data"):
- lines.append(f"⚠️ {strand_data.get('message', '暂无数据')}")
- lines.extend(["", "---", ""])
- return lines
- # 占比统计
- cfg = self.config
- lines.extend([
- "### 三线占比",
- "",
- "| Strand | 章节数 | 占比 | 目标范围 | 状态 |",
- "|--------|--------|------|----------|------|"
- ])
- q = strand_data["quest"]
- q_status = "✅" if cfg.strand_quest_ratio_min <= q["ratio"] <= cfg.strand_quest_ratio_max else "⚠️"
- lines.append(f"| Quest(主线) | {q['count']} | {q['ratio']:.1f}% | {cfg.strand_quest_ratio_min}-{cfg.strand_quest_ratio_max}% | {q_status} |")
- f = strand_data["fire"]
- f_status = "✅" if cfg.strand_fire_ratio_min <= f["ratio"] <= cfg.strand_fire_ratio_max else "⚠️"
- lines.append(f"| Fire(感情) | {f['count']} | {f['ratio']:.1f}% | {cfg.strand_fire_ratio_min}-{cfg.strand_fire_ratio_max}% | {f_status} |")
- c = strand_data["constellation"]
- c_status = "✅" if cfg.strand_constellation_ratio_min <= c["ratio"] <= cfg.strand_constellation_ratio_max else "⚠️"
- lines.append(f"| Constellation(世界观) | {c['count']} | {c['ratio']:.1f}% | {cfg.strand_constellation_ratio_min}-{cfg.strand_constellation_ratio_max}% | {c_status} |")
- lines.append("")
- # 连续性检查
- lines.extend([
- "### 连续性检查",
- "",
- f"- Quest 最大连续: {strand_data['max_quest_streak']} 章(限制 ≤5)",
- f"- Fire 最大缺失: {strand_data['max_fire_gap']} 章(限制 ≤10)",
- f"- Constellation 最大缺失: {strand_data['max_const_gap']} 章(限制 ≤15)",
- ""
- ])
- # 违规清单
- if strand_data["violations"]:
- lines.extend([
- "### ⚠️ 违规清单",
- ""
- ])
- for v in strand_data["violations"]:
- lines.append(f"- {v}")
- else:
- lines.append("### ✅ 无违规")
- lines.extend(["", f"**综合健康度**: {strand_data['health']}", "", "---", ""])
- return lines
- def _generate_pacing_section(self) -> List[str]:
- """生成节奏分析章节"""
- segments = self.analyze_pacing()
- lines = [
- "## 📈 爽点节奏分布",
- "",
- "```"
- ]
- for seg in segments:
- bar_length = int(12 - (seg["words_per_point"] / 2000 * 12))
- bar_length = max(1, min(12, bar_length))
- bar = "█" * bar_length
- lines.append(
- f"第 {seg['start']}-{seg['end']}章 {bar} "
- f"{seg['rating']}({seg['words_per_point']:.0f}字/爽点)"
- )
- lines.extend(["```", "", "---", ""])
- return lines
- def _generate_relationship_section(self) -> List[str]:
- """生成人际关系章节"""
- graph = self.generate_relationship_graph()
- lines = [
- "## 💑 人际关系趋势",
- "",
- graph,
- "",
- "---",
- ""
- ]
- return lines
- def main():
- import argparse
- parser = argparse.ArgumentParser(
- description="可视化状态报告生成器",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="""
- 示例:
- # 生成完整健康报告
- python status_reporter.py --output .webnovel/health_report.md
- # 仅分析角色活跃度
- python status_reporter.py --focus characters
- # 仅分析伏笔
- python status_reporter.py --focus foreshadowing
- # 仅分析爽点节奏
- python status_reporter.py --focus pacing
- """
- )
- parser.add_argument('--output', default='.webnovel/health_report.md',
- help='输出文件路径')
- parser.add_argument('--focus', choices=['all', 'basic', 'characters',
- 'foreshadowing', 'urgency', 'pacing',
- 'strand', 'relationships'],
- default='all', help='分析焦点(新增 urgency, strand)')
- parser.add_argument('--project-root', default='.', help='项目根目录')
- args = parser.parse_args()
- # 解析项目根目录(支持从仓库根目录运行)
- project_root = args.project_root
- if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
- try:
- project_root = str(resolve_project_root())
- except FileNotFoundError:
- project_root = args.project_root
- # 创建报告生成器
- reporter = StatusReporter(project_root)
- # 加载状态
- if not reporter.load_state():
- sys.exit(1)
- print("📖 正在扫描章节文件...")
- reporter.scan_chapters()
- print(f"✅ 已扫描 {len(reporter.chapters_data)} 个章节")
- print("\n📊 正在分析...")
- # 生成报告
- report = reporter.generate_report(args.focus)
- # 保存报告
- output_file = Path(args.output)
- if args.output == '.webnovel/health_report.md' and project_root != '.':
- output_file = Path(project_root) / '.webnovel' / 'health_report.md'
- output_file.parent.mkdir(parents=True, exist_ok=True)
- with open(output_file, 'w', encoding='utf-8') as f:
- f.write(report)
- print(f"\n✅ 健康报告已生成: {output_file}")
- # 预览报告(前 30 行)
- print("\n" + "="*60)
- print("📄 报告预览:\n")
- print("\n".join(report.split("\n")[:30]))
- print("\n...")
- print("="*60)
- if __name__ == "__main__":
- main()
|