#!/usr/bin/env python3 """ 脚本结果可视化工具 V2 功能:为 output_demo_script_v2.json 中的每个视频生成独立的HTML可视化页面,专门展示"整体结构理解"的结果 """ import json import argparse import sys from pathlib import Path from datetime import datetime from typing import List, Dict, Any, Optional import html as html_module # 保证可以从项目根目录导入 PROJECT_ROOT = Path(__file__).parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) class ScriptResultVisualizerV2: """脚本结果可视化器 V2 - 专门展示整体结构理解""" def __init__(self, json_file: str = None): """ 初始化可视化器 Args: json_file: JSON文件路径 """ if json_file is None: self.json_file = None else: self.json_file = Path(json_file) if not self.json_file.is_absolute(): self.json_file = Path.cwd() / json_file def load_json_data(self, file_path: Path) -> Optional[Dict[str, Any]]: """ 加载JSON文件 Args: file_path: JSON文件路径 Returns: JSON数据字典,加载失败返回None """ try: with open(file_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"加载文件失败 {file_path}: {e}") return None def generate_overall_structure_section(self, overall_data: Dict[str, Any], section_idx: int = 0) -> str: """生成整体解构部分HTML""" html = '
\n' html += '

整体解构

\n' html += f' \n' html += '
\n' return html def generate_paragraph_section(self, paragraphs: List[Dict[str, Any]], section_idx: int = 1) -> str: """生成段落解构部分HTML""" html = '
\n' html += '

段落解构

\n' html += f' \n' html += '
\n' return html for para_idx, para in enumerate(paragraphs): # 段落基本信息 para_num = para.get("段落序号", "") time_range = para.get("时间范围", "") units = para.get("包含单元", []) full_text = para.get("段落完整文案", "") html += f'
\n' html += f'
\n' if para_num: html += f' 段落 {para_num}\n' if time_range: html += f' {html_module.escape(time_range)}\n' if units: units_str = ", ".join(str(u) for u in units) if isinstance(units, list) else str(units) html += f' 包含单元: {html_module.escape(units_str)}\n' html += f' \n' html += '
\n' html += f' \n' html += '
\n' html += ' \n' html += '\n' return html def generate_html(self, understanding_data: Dict[str, Any], video_title: str, channel_content_id: str) -> str: """生成完整的HTML页面""" html = '\n' html += '\n' html += '\n' html += ' \n' html += ' \n' html += f' 整体结构理解 - {html_module.escape(video_title)}\n' html += ' \n' html += '\n' html += '\n' html += '
\n' # 页眉 html += '
\n' html += '

整体结构理解

\n' html += f'
{html_module.escape(video_title)}
\n' if channel_content_id: html += f'
ID: {html_module.escape(channel_content_id)}
\n' html += f'
生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
\n' html += '
\n' # 主内容 html += '
\n' # 整体解构 if "整体解构" in understanding_data: html += self.generate_overall_structure_section(understanding_data["整体解构"], section_idx=0) # 段落解构 if "段落解构" in understanding_data: html += self.generate_paragraph_section(understanding_data["段落解构"], section_idx=1) html += '
\n' # 页脚 html += '\n' html += '
\n' html += '\n' html += '\n' html += '\n' return html def generate_javascript(self) -> str: """生成JavaScript代码""" return """ function toggleCollapse(element) { // 阻止事件冒泡(如果是从子元素触发的) if (event) { event.stopPropagation(); } // 查找内容区域 - 优先查找下一个兄弟元素 let content = element.nextElementSibling; // 如果下一个兄弟元素不是内容区域,尝试在元素内部查找 if (!content || (!content.classList.contains('collapsed') && !content.classList.contains('expanded') && !content.classList.contains('section-content') && !content.classList.contains('subsection-content') && !content.classList.contains('paragraph-content') && !content.classList.contains('logic-stage-content') && !content.classList.contains('element-forms') && !content.classList.contains('element-list'))) { // 在元素内部查找内容区域 content = element.querySelector('.section-content, .subsection-content, .paragraph-content, .logic-stage-content, .element-forms, .element-list'); } // 如果还是找不到,尝试查找父元素的下一个兄弟 if (!content && element.parentElement) { const siblings = Array.from(element.parentElement.children); const currentIndex = siblings.indexOf(element); if (currentIndex < siblings.length - 1) { content = siblings[currentIndex + 1]; } } if (content) { // 切换展开/收起状态 const isCollapsed = content.classList.contains('collapsed'); if (isCollapsed) { content.classList.remove('collapsed'); content.classList.add('expanded'); } else { content.classList.remove('expanded'); content.classList.add('collapsed'); } // 更新图标 const icon = element.querySelector('.toggle-icon'); if (icon) { icon.textContent = isCollapsed ? '▲' : '▼'; } } } """ def generate_css(self) -> str: """生成CSS样式""" return """ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; } .header h1 { font-size: 2em; margin-bottom: 10px; } .header .subtitle { font-size: 1.1em; opacity: 0.9; margin-top: 5px; } .content { padding: 30px; } .section { margin-bottom: 40px; } .section h2 { font-size: 1.8em; color: #667eea; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #667eea; } .section-title, .subsection-title { cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; transition: background-color 0.2s; } .section-title:hover, .subsection-title:hover { background-color: rgba(102, 126, 234, 0.1); border-radius: 4px; padding: 5px; margin: -5px; } .toggle-icon { font-size: 0.8em; transition: transform 0.3s; margin-left: 10px; } .section-content, .subsection-content, .paragraph-content, .logic-stage-content, .element-forms, .element-list { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; } .section-content.expanded, .subsection-content.expanded, .paragraph-content.expanded, .logic-stage-content.expanded, .element-forms.expanded, .element-list.expanded { max-height: 10000px; transition: max-height 0.5s ease-in; } .section-content.collapsed, .subsection-content.collapsed, .paragraph-content.collapsed, .logic-stage-content.collapsed, .element-forms.collapsed, .element-list.collapsed { max-height: 0; } .subsection { margin-bottom: 25px; } .subsection h3 { font-size: 1.4em; color: #555; margin-bottom: 15px; } .content-box { background: #f9f9f9; padding: 20px; border-radius: 6px; border-left: 4px solid #667eea; line-height: 1.8; white-space: pre-wrap; } .logic-flow { display: flex; flex-direction: column; gap: 15px; } .logic-stage { background: #f0f4ff; padding: 20px; border-radius: 6px; border-left: 4px solid #764ba2; } .stage-number { font-weight: bold; color: #764ba2; font-size: 1.1em; margin-bottom: 8px; } .stage-name { font-weight: bold; color: #333; font-size: 1.1em; margin-bottom: 10px; } .stage-desc { color: #666; line-height: 1.7; } .paragraph { background: #fafafa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin-bottom: 25px; cursor: pointer; transition: background-color 0.2s; } .paragraph:hover { background-color: #f0f0f0; } .paragraph-header { display: flex; flex-wrap: wrap; align-items: center; gap: 15px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #e0e0e0; } .paragraph-header .toggle-icon { margin-left: auto; } .para-number { font-weight: bold; color: #667eea; font-size: 1.1em; } .time-range { color: #666; background: #e8e8e8; padding: 4px 10px; border-radius: 4px; } .units { color: #666; font-size: 0.9em; } .paragraph-text { background: white; padding: 15px; border-radius: 4px; margin-bottom: 20px; line-height: 1.8; border-left: 3px solid #667eea; } .element-group { margin-bottom: 25px; cursor: pointer; transition: background-color 0.2s; padding: 10px; border-radius: 4px; } .element-group:hover { background-color: rgba(0,0,0,0.02); } .element-group-title { font-size: 1.2em; color: #555; margin-bottom: 15px; padding-bottom: 8px; border-bottom: 1px solid #ddd; display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none; } .element-group-title:hover { color: #667eea; } .element-list { display: flex; flex-direction: column; gap: 15px; } .element-item { background: white; border: 1px solid #e0e0e0; border-radius: 6px; padding: 15px; transition: box-shadow 0.2s; cursor: pointer; } .element-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .element-name-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; } .element-name { font-weight: bold; color: #667eea; font-size: 1.1em; } .logic-stage { cursor: pointer; transition: background-color 0.2s; } .logic-stage:hover { background-color: #e8f0ff; } .logic-stage-header { display: flex; align-items: center; justify-content: space-between; } .logic-stage-header .toggle-icon { margin-left: auto; } .element-forms { display: flex; flex-direction: column; gap: 10px; } .form-item { display: flex; gap: 10px; } .form-label { font-weight: 600; color: #555; min-width: 60px; } .form-content { color: #666; flex: 1; line-height: 1.6; } .footer { background: #f9f9f9; padding: 20px; text-align: center; color: #666; border-top: 1px solid #e0e0e0; } @media (max-width: 768px) { .container { margin: 10px; border-radius: 4px; } .content { padding: 20px; } .header { padding: 20px; } .header h1 { font-size: 1.5em; } .paragraph-header { flex-direction: column; gap: 8px; } .form-item { flex-direction: column; gap: 5px; } .form-label { min-width: auto; } } """ def save_all_html(self, output_dir: str | Path | None = None) -> List[str]: """ 基于 output_demo_script_v2.json,为其中每个视频生成一个独立的 HTML 页面。 仅支持这种结构: { "results": [ { "video_data": {...}, "script_result": { "整体结构理解": {...} } }, ... ] } """ if self.json_file is None: print("❌ 错误: 未指定JSON文件") return [] # 加载JSON数据 data = self.load_json_data(self.json_file) if data is None: return [] results = data.get("results") or [] if not isinstance(results, list) or not results: print("⚠️ JSON 中未找到有效的 results 数组,期望为 output_demo_script_v2.json 结构") return [] # 确定输出目录 if output_dir is None: # 默认输出到examples/html_v2目录 output_dir = Path(__file__).parent / "html_v2" else: output_dir = Path(output_dir) if not output_dir.is_absolute(): output_dir = Path.cwd() / output_dir # 创建输出目录 output_dir.mkdir(parents=True, exist_ok=True) generated_paths: List[str] = [] print(f"📁 检测到 output_demo_script_v2 格式,包含 {len(results)} 条结果") for idx, item in enumerate(results, start=1): script_result = item.get("script_result") if not isinstance(script_result, dict): print(f"⚠️ 跳过第 {idx} 条结果:缺少 script_result 字段或结构不正确") continue understanding_data = script_result.get("整体结构理解") if not isinstance(understanding_data, dict): print(f"⚠️ 跳过第 {idx} 条结果:缺少 整体结构理解 字段或结构不正确") continue video_data = item.get("video_data") or {} channel_content_id = video_data.get("channel_content_id", "") video_title = video_data.get("title", f"视频 {idx}") # 生成输出文件名(优先使用 channel_content_id,回退到序号) if channel_content_id: output_filename = f"understanding_{channel_content_id}.html" else: output_filename = f"{self.json_file.stem}_understanding_{idx}.html" output_path = output_dir / output_filename html_content = self.generate_html(understanding_data, video_title, channel_content_id) with open(output_path, "w", encoding="utf-8") as f: f.write(html_content) generated_paths.append(str(output_path)) print(f"✅ HTML文件已生成: {output_path}") if not generated_paths: print("⚠️ 未能从 JSON 中生成任何 HTML 文件") return generated_paths def main(): """主函数""" # 解析命令行参数 parser = argparse.ArgumentParser( description='脚本结果可视化工具 V2 - 基于 output_demo_script_v2.json 为每个视频生成独立的HTML页面(展示整体结构理解)', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 使用示例: # 在当前 examples 目录下使用默认的 output_demo_script_v2.json 并输出到 examples/html_v2 python visualize_script_results_v2.py # 指定 JSON 文件 python visualize_script_results_v2.py examples/output_demo_script_v2.json # 指定 JSON 文件和输出目录 python visualize_script_results_v2.py examples/output_demo_script_v2.json --output-dir examples/html_v2_custom """ ) parser.add_argument( 'json_file', type=str, nargs='?', help='JSON文件路径(默认为 examples/output_demo_script_v2.json)' ) parser.add_argument( '-o', '--output-dir', type=str, default=None, help='输出目录路径(默认: examples/html_v2)' ) args = parser.parse_args() # 确定 JSON 文件路径 if args.json_file: json_path = Path(args.json_file) if not json_path.is_absolute(): json_path = Path.cwd() / json_path else: # 默认使用 examples/output_demo_script_v2.json json_path = Path(__file__).parent / "output_demo_script_v2.json" print("🚀 开始生成整体结构理解可视化...") print(f"📁 JSON文件: {json_path}") print(f"📄 输出目录: {args.output_dir or (Path(__file__).parent / 'html_v2')}") print() visualizer = ScriptResultVisualizerV2(json_file=str(json_path)) generated_files = visualizer.save_all_html(output_dir=args.output_dir) if generated_files: print() print(f"🎉 完成! 共生成 {len(generated_files)} 个HTML文件") # 提示其中一个示例文件 print(f"📄 示例: 请在浏览器中打开: {generated_files[0]}") if __name__ == "__main__": main()