소스 검색

feat: 添加How解构结果可视化脚本(v2)

新增功能:
- 创建交互式HTML可视化,支持多帖子标签页切换
- 左侧目录导航,自动追踪当前浏览位置并高亮
- 紧凑帖子卡片设计,点击展开模态框查看详情
- 特征分类层级显示:[大类/中类/小类] 格式
- 历史帖子来源卡片,展示特征的历史应用案例
- 按How步骤分组展示匹配结果,支持多级折叠

交互优化:
- 所有帖子卡片(主卡片和历史卡片)均可点击查看详情
- 目录项随滚动自动激活高亮
- 帖子详情模态框支持图片轮播
- 三级折叠:步骤 → 特征 → 匹配项

数据集成:
- 加载特征分类映射(287个特征)
- 加载特征来源映射(275个特征)
- 支持灵感点、关键点、目的点三类特征

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 2 주 전
부모
커밋
2f2e916d99
3개의 변경된 파일2663개의 추가작업 그리고 0개의 파일을 삭제
  1. 314 0
      script/data_processing/match_inspiration_features.py
  2. 573 0
      script/data_processing/visualize_how_results.py
  3. 1776 0
      script/data_processing/visualize_how_results_v2.py

+ 314 - 0
script/data_processing/match_inspiration_features.py

@@ -0,0 +1,314 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+灵感点特征匹配脚本
+
+从解构任务列表中提取灵感点的特征,与人设灵感特征进行匹配,
+使用 relation_analyzer 模块分析特征之间的语义关系。
+"""
+
+import json
+import asyncio
+from pathlib import Path
+from typing import Dict, List
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from lib.relation_analyzer import analyze_relation
+
+# 全局并发限制
+MAX_CONCURRENT_REQUESTS = 20
+semaphore = None
+
+
+def get_semaphore():
+    """获取全局信号量"""
+    global semaphore
+    if semaphore is None:
+        semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
+    return semaphore
+
+
+async def match_single_pair(
+    feature_name: str,
+    persona_name: str,
+    model_name: str = None
+) -> Dict:
+    """
+    匹配单个特征对(带并发限制)
+
+    Args:
+        feature_name: 要匹配的特征名称
+        persona_name: 人设特征名称
+        model_name: 使用的模型名称
+
+    Returns:
+        单个匹配结果
+    """
+    sem = get_semaphore()
+    async with sem:
+        print(f"      匹配: {feature_name} <-> {persona_name}")
+        relation_result = await analyze_relation(
+            phrase_a=feature_name,
+            phrase_b=persona_name,
+            model_name=model_name
+        )
+
+        return {
+            "人设特征名称": persona_name,
+            "匹配结果": relation_result
+        }
+
+
+async def match_feature_with_persona(
+    feature_name: str,
+    persona_features: List[Dict],
+    model_name: str = None
+) -> List[Dict]:
+    """
+    将一个特征与人设特征列表进行匹配(并发执行)
+
+    Args:
+        feature_name: 要匹配的特征名称
+        persona_features: 人设特征列表
+        model_name: 使用的模型名称
+
+    Returns:
+        匹配结果列表
+    """
+    # 创建所有匹配任务
+    tasks = [
+        match_single_pair(feature_name, persona_feature["特征名称"], model_name)
+        for persona_feature in persona_features
+    ]
+
+    # 并发执行所有匹配
+    match_results = await asyncio.gather(*tasks)
+
+    return list(match_results)
+
+
+async def match_single_feature(
+    feature_name: str,
+    persona_features: List[Dict],
+    model_name: str = None
+) -> Dict:
+    """
+    匹配单个特征与所有人设特征
+
+    Args:
+        feature_name: 特征名称
+        persona_features: 人设特征列表
+        model_name: 使用的模型名称
+
+    Returns:
+        特征匹配结果
+    """
+    print(f"    特征: {feature_name}")
+    match_results = await match_feature_with_persona(
+        feature_name=feature_name,
+        persona_features=persona_features,
+        model_name=model_name
+    )
+
+    return {
+        "特征名称": feature_name,
+        "匹配结果": match_results
+    }
+
+
+async def process_single_inspiration_point(
+    inspiration_point: Dict,
+    persona_features: List[Dict],
+    model_name: str = None
+) -> Dict:
+    """
+    处理单个灵感点的特征匹配(并发执行)
+
+    Args:
+        inspiration_point: 灵感点数据
+        persona_features: 人设灵感特征列表
+        model_name: 使用的模型名称
+
+    Returns:
+        包含 how 步骤列表的灵感点数据
+    """
+    point_name = inspiration_point.get("名称", "")
+    feature_list = inspiration_point.get("特征列表", [])
+
+    print(f"  处理灵感点: {point_name}")
+    print(f"    特征数量: {len(feature_list)}")
+
+    # 并发匹配所有特征
+    tasks = [
+        match_single_feature(feature_name, persona_features, model_name)
+        for feature_name in feature_list
+    ]
+    feature_match_results = await asyncio.gather(*tasks)
+
+    # 构建 how 步骤
+    how_step = {
+        "步骤名称": "灵感特征分别匹配人设特征",
+        "特征列表": list(feature_match_results)
+    }
+
+    # 返回更新后的灵感点
+    result = inspiration_point.copy()
+    result["how步骤列表"] = [how_step]
+
+    return result
+
+
+async def process_single_task(
+    task: Dict,
+    task_index: int,
+    total_tasks: int,
+    persona_inspiration_features: List[Dict],
+    model_name: str = None
+) -> Dict:
+    """
+    处理单个任务
+
+    Args:
+        task: 任务数据
+        task_index: 任务索引(从1开始)
+        total_tasks: 总任务数
+        persona_inspiration_features: 人设灵感特征列表
+        model_name: 使用的模型名称
+
+    Returns:
+        包含 how 解构结果的任务
+    """
+    post_id = task.get("帖子id", "")
+    print(f"\n处理任务 [{task_index}/{total_tasks}]: {post_id}")
+
+    # 获取灵感点列表
+    what_result = task.get("what解构结果", {})
+    inspiration_list = what_result.get("灵感点列表", [])
+
+    print(f"  灵感点数量: {len(inspiration_list)}")
+
+    # 并发处理所有灵感点
+    tasks = [
+        process_single_inspiration_point(
+            inspiration_point=inspiration_point,
+            persona_features=persona_inspiration_features,
+            model_name=model_name
+        )
+        for inspiration_point in inspiration_list
+    ]
+    updated_inspiration_list = await asyncio.gather(*tasks)
+
+    # 构建 how 解构结果
+    how_result = {
+        "灵感点列表": list(updated_inspiration_list)
+    }
+
+    # 更新任务
+    updated_task = task.copy()
+    updated_task["how解构结果"] = how_result
+
+    return updated_task
+
+
+async def process_task_list(
+    task_list: List[Dict],
+    persona_features_dict: Dict,
+    model_name: str = None
+) -> List[Dict]:
+    """
+    处理整个解构任务列表(并发执行)
+
+    Args:
+        task_list: 解构任务列表
+        persona_features_dict: 人设特征字典(包含灵感点、目的点、关键点)
+        model_name: 使用的模型名称
+
+    Returns:
+        包含 how 解构结果的任务列表
+    """
+    persona_inspiration_features = persona_features_dict.get("灵感点", [])
+    print(f"人设灵感特征数量: {len(persona_inspiration_features)}")
+
+    # 并发处理所有任务
+    tasks = [
+        process_single_task(
+            task=task,
+            task_index=i,
+            total_tasks=len(task_list),
+            persona_inspiration_features=persona_inspiration_features,
+            model_name=model_name
+        )
+        for i, task in enumerate(task_list, 1)
+    ]
+    updated_task_list = await asyncio.gather(*tasks)
+
+    return list(updated_task_list)
+
+
+async def main():
+    """主函数"""
+    # 输入输出路径
+    script_dir = Path(__file__).parent
+    project_root = script_dir.parent.parent
+    data_dir = project_root / "data" / "data_1117"
+
+    task_list_file = data_dir / "当前帖子_解构任务列表.json"
+    persona_features_file = data_dir / "特征名称_帖子来源.json"
+    output_dir = data_dir / "当前帖子_how解构结果"
+
+    # 创建输出目录
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    print(f"读取解构任务列表: {task_list_file}")
+    with open(task_list_file, "r", encoding="utf-8") as f:
+        task_list_data = json.load(f)
+
+    print(f"读取人设特征: {persona_features_file}")
+    with open(persona_features_file, "r", encoding="utf-8") as f:
+        persona_features_data = json.load(f)
+
+    # 获取任务列表
+    task_list = task_list_data.get("解构任务列表", [])
+    print(f"\n总任务数: {len(task_list)}")
+
+    # 处理任务列表
+    updated_task_list = await process_task_list(
+        task_list=task_list,
+        persona_features_dict=persona_features_data,
+        model_name=None  # 使用默认模型
+    )
+
+    # 分文件保存结果
+    print(f"\n保存结果到: {output_dir}")
+    for task in updated_task_list:
+        post_id = task.get("帖子id", "unknown")
+        output_file = output_dir / f"{post_id}_how.json"
+
+        print(f"  保存: {output_file.name}")
+        with open(output_file, "w", encoding="utf-8") as f:
+            json.dump(task, f, ensure_ascii=False, indent=4)
+
+    print("\n完成!")
+
+    # 打印统计信息
+    total_inspiration_points = sum(
+        len(task["how解构结果"]["灵感点列表"])
+        for task in updated_task_list
+    )
+    total_features = sum(
+        len(point["特征列表"])
+        for task in updated_task_list
+        for point in task["how解构结果"]["灵感点列表"]
+    )
+    print(f"\n统计:")
+    print(f"  处理的帖子数: {len(updated_task_list)}")
+    print(f"  处理的灵感点数: {total_inspiration_points}")
+    print(f"  处理的特征数: {total_features}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 573 - 0
script/data_processing/visualize_how_results.py

@@ -0,0 +1,573 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+How解构结果可视化脚本
+
+将 how 解构结果转化为 HTML 格式,使用标签页展示多个帖子
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+
+def get_relation_color(relation: str) -> str:
+    """根据关系类型返回对应的颜色"""
+    color_map = {
+        "same": "#10b981",           # 绿色 - 同义
+        "contains": "#3b82f6",       # 蓝色 - 包含
+        "contained_by": "#8b5cf6",   # 紫色 - 被包含
+        "coordinate": "#f59e0b",     # 橙色 - 同级
+        "overlap": "#ec4899",        # 粉色 - 部分重叠
+        "related": "#6366f1",        # 靛蓝 - 相关
+        "unrelated": "#9ca3af"       # 灰色 - 无关
+    }
+    return color_map.get(relation, "#9ca3af")
+
+
+def get_relation_label(relation: str) -> str:
+    """返回关系类型的中文标签"""
+    label_map = {
+        "same": "同义",
+        "contains": "包含",
+        "contained_by": "被包含",
+        "coordinate": "同级",
+        "overlap": "部分重叠",
+        "related": "相关",
+        "unrelated": "无关"
+    }
+    return label_map.get(relation, relation)
+
+
+def generate_match_item_html(match: Dict) -> str:
+    """生成单个匹配项的HTML"""
+    persona_name = match.get("人设特征名称", "")
+    match_result = match.get("匹配结果", {})
+    relation = match_result.get("relation", "unrelated")
+    score = match_result.get("score", 0.0)
+    explanation = match_result.get("explanation", "")
+
+    color = get_relation_color(relation)
+    label = get_relation_label(relation)
+
+    # 根据分数设置背景透明度
+    opacity = min(score, 1.0)
+    bg_color = f"rgba({int(color[1:3], 16)}, {int(color[3:5], 16)}, {int(color[5:7], 16)}, {opacity * 0.15})"
+
+    html = f"""
+        <div class="match-item" style="border-left: 3px solid {color}; background: {bg_color};">
+            <div class="match-header">
+                <span class="persona-name">{persona_name}</span>
+                <span class="relation-badge" style="background: {color};">{label}</span>
+                <span class="score-badge">分数: {score:.2f}</span>
+            </div>
+            <div class="match-explanation">{explanation}</div>
+        </div>
+    """
+    return html
+
+
+def generate_feature_html(feature_data: Dict) -> str:
+    """生成单个特征的HTML"""
+    feature_name = feature_data.get("特征名称", "")
+    match_results = feature_data.get("匹配结果", [])
+
+    # 按分数排序(从高到低)
+    sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("score", 0), reverse=True)
+
+    # 统计匹配类型
+    relation_counts = {}
+    for match in match_results:
+        relation = match.get("匹配结果", {}).get("relation", "unrelated")
+        relation_counts[relation] = relation_counts.get(relation, 0) + 1
+
+    # 生成统计信息
+    stats_html = "<div class='relation-stats'>"
+    for relation, count in sorted(relation_counts.items(), key=lambda x: x[1], reverse=True):
+        label = get_relation_label(relation)
+        color = get_relation_color(relation)
+        stats_html += f"<span class='stat-item' style='color: {color};'>{label}: {count}</span>"
+    stats_html += "</div>"
+
+    matches_html = "".join(generate_match_item_html(match) for match in sorted_matches)
+
+    html = f"""
+        <div class="feature-section">
+            <div class="feature-header">
+                <h3>特征: {feature_name}</h3>
+                {stats_html}
+            </div>
+            <div class="matches-container">
+                {matches_html}
+            </div>
+        </div>
+    """
+    return html
+
+
+def generate_inspiration_point_html(point_data: Dict) -> str:
+    """生成单个灵感点的HTML"""
+    name = point_data.get("名称", "")
+    desc = point_data.get("描述", "")
+    how_steps = point_data.get("how步骤列表", [])
+
+    steps_html = ""
+    for step in how_steps:
+        step_name = step.get("步骤名称", "")
+        features = step.get("特征列表", [])
+
+        features_html = "".join(generate_feature_html(f) for f in features)
+
+        steps_html += f"""
+            <div class="step-section">
+                <h4 class="step-name">{step_name}</h4>
+                {features_html}
+            </div>
+        """
+
+    html = f"""
+        <div class="inspiration-point">
+            <div class="point-header">
+                <h2>{name}</h2>
+            </div>
+            <div class="point-description">{desc}</div>
+            {steps_html}
+        </div>
+    """
+    return html
+
+
+def generate_post_content_html(post_data: Dict) -> str:
+    """生成单个帖子的内容HTML(不包含完整页面结构)"""
+    post_id = post_data.get("帖子id", "")
+    post_detail = post_data.get("帖子详情", {})
+    publish_time = post_detail.get("publish_time", "")
+    like_count = post_detail.get("like_count", 0)
+    link = post_detail.get("link", "")
+
+    how_result = post_data.get("how解构结果", {})
+    inspiration_list = how_result.get("灵感点列表", [])
+
+    inspiration_html = "".join(generate_inspiration_point_html(p) for p in inspiration_list)
+
+    html = f"""
+        <div class="post-meta-bar">
+            <div class="meta-item">
+                <span class="meta-label">帖子ID:</span>
+                <span class="meta-value">{post_id}</span>
+            </div>
+            <div class="meta-item">
+                <span class="meta-label">发布时间:</span>
+                <span class="meta-value">{publish_time}</span>
+            </div>
+            <div class="meta-item">
+                <span class="meta-label">点赞数:</span>
+                <span class="meta-value">{like_count}</span>
+            </div>
+            <div class="meta-item">
+                <a href="{link}" target="_blank" class="view-link">查看原帖 →</a>
+            </div>
+        </div>
+        {inspiration_html}
+    """
+    return html
+
+
+def generate_combined_html(posts_data: List[Dict]) -> str:
+    """生成包含所有帖子的单一HTML(带标签页)"""
+
+    # 生成标签页按钮
+    tabs_html = ""
+    for i, post in enumerate(posts_data):
+        post_detail = post.get("帖子详情", {})
+        title = post_detail.get("title", "无标题")
+        active_class = "active" if i == 0 else ""
+        tabs_html += f'<button class="tab-button {active_class}" onclick="openTab(event, \'post-{i}\')">{title}</button>\n'
+
+    # 生成标签页内容
+    contents_html = ""
+    for i, post in enumerate(posts_data):
+        active_class = "active" if i == 0 else ""
+        content = generate_post_content_html(post)
+        contents_html += f"""
+        <div id="post-{i}" class="tab-content {active_class}">
+            {content}
+        </div>
+        """
+
+    html = f"""
+    <!DOCTYPE html>
+    <html lang="zh-CN">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>How解构结果可视化</title>
+        <style>
+            * {{
+                margin: 0;
+                padding: 0;
+                box-sizing: border-box;
+            }}
+
+            body {{
+                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+                background: #f5f5f5;
+                color: #333;
+                line-height: 1.6;
+            }}
+
+            .container {{
+                max-width: 1400px;
+                margin: 0 auto;
+                background: white;
+                min-height: 100vh;
+                box-shadow: 0 0 40px rgba(0,0,0,0.1);
+            }}
+
+            .header {{
+                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+                color: white;
+                padding: 30px;
+                text-align: center;
+            }}
+
+            .header h1 {{
+                font-size: 32px;
+                font-weight: bold;
+                margin-bottom: 10px;
+            }}
+
+            .header p {{
+                font-size: 16px;
+                opacity: 0.9;
+            }}
+
+            .tabs-container {{
+                display: flex;
+                background: #f9fafb;
+                border-bottom: 2px solid #e5e7eb;
+                overflow-x: auto;
+                position: sticky;
+                top: 0;
+                z-index: 100;
+            }}
+
+            .tab-button {{
+                flex: 1;
+                min-width: 200px;
+                padding: 18px 30px;
+                background: transparent;
+                border: none;
+                border-bottom: 3px solid transparent;
+                cursor: pointer;
+                font-size: 15px;
+                font-weight: 500;
+                color: #6b7280;
+                transition: all 0.3s;
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+            }}
+
+            .tab-button:hover {{
+                background: #f3f4f6;
+                color: #374151;
+            }}
+
+            .tab-button.active {{
+                color: #667eea;
+                border-bottom-color: #667eea;
+                background: white;
+            }}
+
+            .tab-content {{
+                display: none;
+                padding: 30px;
+                animation: fadeIn 0.3s;
+            }}
+
+            .tab-content.active {{
+                display: block;
+            }}
+
+            @keyframes fadeIn {{
+                from {{ opacity: 0; transform: translateY(10px); }}
+                to {{ opacity: 1; transform: translateY(0); }}
+            }}
+
+            .post-meta-bar {{
+                display: flex;
+                flex-wrap: wrap;
+                gap: 25px;
+                padding: 20px;
+                background: #f9fafb;
+                border-radius: 8px;
+                margin-bottom: 30px;
+                border: 1px solid #e5e7eb;
+            }}
+
+            .meta-item {{
+                display: flex;
+                align-items: center;
+                gap: 8px;
+            }}
+
+            .meta-label {{
+                font-weight: 600;
+                color: #6b7280;
+                font-size: 14px;
+            }}
+
+            .meta-value {{
+                color: #111827;
+                font-size: 14px;
+            }}
+
+            .view-link {{
+                color: #667eea;
+                text-decoration: none;
+                font-weight: 600;
+                font-size: 14px;
+                transition: color 0.2s;
+            }}
+
+            .view-link:hover {{
+                color: #764ba2;
+            }}
+
+            .inspiration-point {{
+                margin-bottom: 40px;
+                border: 1px solid #e5e7eb;
+                border-radius: 8px;
+                overflow: hidden;
+            }}
+
+            .point-header {{
+                background: #f9fafb;
+                padding: 20px;
+                border-bottom: 2px solid #e5e7eb;
+            }}
+
+            .point-header h2 {{
+                font-size: 22px;
+                color: #1f2937;
+            }}
+
+            .point-description {{
+                padding: 20px;
+                background: #fefefe;
+                font-size: 15px;
+                color: #4b5563;
+                line-height: 1.8;
+                border-bottom: 1px solid #e5e7eb;
+            }}
+
+            .step-section {{
+                padding: 20px;
+            }}
+
+            .step-name {{
+                font-size: 18px;
+                color: #374151;
+                margin-bottom: 20px;
+                padding-bottom: 10px;
+                border-bottom: 2px solid #e5e7eb;
+            }}
+
+            .feature-section {{
+                margin-bottom: 30px;
+            }}
+
+            .feature-header {{
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                margin-bottom: 15px;
+                padding: 15px;
+                background: #f3f4f6;
+                border-radius: 6px;
+            }}
+
+            .feature-header h3 {{
+                font-size: 18px;
+                color: #111827;
+            }}
+
+            .relation-stats {{
+                display: flex;
+                gap: 15px;
+                font-size: 13px;
+            }}
+
+            .stat-item {{
+                font-weight: 600;
+            }}
+
+            .matches-container {{
+                display: grid;
+                gap: 10px;
+            }}
+
+            .match-item {{
+                padding: 15px;
+                border-radius: 6px;
+                transition: transform 0.2s;
+            }}
+
+            .match-item:hover {{
+                transform: translateX(5px);
+            }}
+
+            .match-header {{
+                display: flex;
+                align-items: center;
+                gap: 10px;
+                margin-bottom: 8px;
+            }}
+
+            .persona-name {{
+                font-weight: 600;
+                font-size: 15px;
+                color: #111827;
+            }}
+
+            .relation-badge {{
+                padding: 3px 10px;
+                border-radius: 12px;
+                color: white;
+                font-size: 12px;
+                font-weight: 600;
+            }}
+
+            .score-badge {{
+                padding: 3px 10px;
+                border-radius: 12px;
+                background: #e5e7eb;
+                color: #374151;
+                font-size: 12px;
+                font-weight: 600;
+            }}
+
+            .match-explanation {{
+                font-size: 14px;
+                color: #6b7280;
+                line-height: 1.6;
+            }}
+
+            @media (max-width: 768px) {{
+                .header {{
+                    padding: 20px;
+                }}
+
+                .header h1 {{
+                    font-size: 24px;
+                }}
+
+                .tab-button {{
+                    min-width: 150px;
+                    padding: 15px 20px;
+                    font-size: 14px;
+                }}
+
+                .tab-content {{
+                    padding: 15px;
+                }}
+
+                .post-meta-bar {{
+                    flex-direction: column;
+                    gap: 10px;
+                }}
+
+                .feature-header {{
+                    flex-direction: column;
+                    align-items: flex-start;
+                    gap: 10px;
+                }}
+
+                .relation-stats {{
+                    flex-wrap: wrap;
+                }}
+            }}
+        </style>
+    </head>
+    <body>
+        <div class="container">
+            <div class="header">
+                <h1>How 解构结果可视化</h1>
+                <p>灵感点特征匹配分析</p>
+            </div>
+            <div class="tabs-container">
+                {tabs_html}
+            </div>
+            {contents_html}
+        </div>
+
+        <script>
+            function openTab(evt, tabId) {{
+                // 隐藏所有标签页内容
+                var tabContents = document.getElementsByClassName("tab-content");
+                for (var i = 0; i < tabContents.length; i++) {{
+                    tabContents[i].classList.remove("active");
+                }}
+
+                // 移除所有按钮的 active 类
+                var tabButtons = document.getElementsByClassName("tab-button");
+                for (var i = 0; i < tabButtons.length; i++) {{
+                    tabButtons[i].classList.remove("active");
+                }}
+
+                // 显示当前标签页并添加 active 类
+                document.getElementById(tabId).classList.add("active");
+                evt.currentTarget.classList.add("active");
+            }}
+        </script>
+    </body>
+    </html>
+    """
+    return html
+
+
+def main():
+    """主函数"""
+    # 输入输出路径
+    script_dir = Path(__file__).parent
+    project_root = script_dir.parent.parent
+    data_dir = project_root / "data" / "data_1117"
+
+    input_dir = data_dir / "当前帖子_how解构结果"
+    output_file = data_dir / "当前帖子_how解构结果_可视化.html"
+
+    print(f"读取 how 解构结果: {input_dir}")
+
+    # 获取所有 JSON 文件
+    json_files = list(input_dir.glob("*_how.json"))
+    print(f"找到 {len(json_files)} 个文件\n")
+
+    # 读取所有帖子数据
+    posts_data = []
+    for i, file_path in enumerate(json_files, 1):
+        print(f"读取文件 [{i}/{len(json_files)}]: {file_path.name}")
+        with open(file_path, "r", encoding="utf-8") as f:
+            post_data = json.load(f)
+            posts_data.append(post_data)
+
+    # 生成合并的 HTML
+    print(f"\n生成合并的 HTML...")
+    html_content = generate_combined_html(posts_data)
+
+    # 保存 HTML 文件
+    print(f"保存到: {output_file}")
+    with open(output_file, "w", encoding="utf-8") as f:
+        f.write(html_content)
+
+    print(f"\n完成! 可视化文件已保存")
+    print(f"请在浏览器中打开: {output_file}")
+
+
+if __name__ == "__main__":
+    main()

+ 1776 - 0
script/data_processing/visualize_how_results_v2.py

@@ -0,0 +1,1776 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+How解构结果可视化脚本 V2
+
+改进版:
+- 使用标签页展示多个帖子
+- 参考 visualize_inspiration_points.py 的帖子详情展示
+- 分层可折叠的匹配结果
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List
+import sys
+import html as html_module
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+
+def get_relation_color(relation: str) -> str:
+    """根据关系类型返回对应的颜色"""
+    color_map = {
+        "same": "#10b981",           # 绿色 - 同义
+        "contains": "#3b82f6",       # 蓝色 - 包含
+        "contained_by": "#8b5cf6",   # 紫色 - 被包含
+        "coordinate": "#f59e0b",     # 橙色 - 同级
+        "overlap": "#ec4899",        # 粉色 - 部分重叠
+        "related": "#6366f1",        # 靛蓝 - 相关
+        "unrelated": "#9ca3af"       # 灰色 - 无关
+    }
+    return color_map.get(relation, "#9ca3af")
+
+
+def get_relation_label(relation: str) -> str:
+    """返回关系类型的中文标签"""
+    label_map = {
+        "same": "同义",
+        "contains": "包含",
+        "contained_by": "被包含",
+        "coordinate": "同级",
+        "overlap": "部分重叠",
+        "related": "相关",
+        "unrelated": "无关"
+    }
+    return label_map.get(relation, relation)
+
+
+def generate_historical_post_card_html(post_detail: Dict, inspiration_point: Dict) -> str:
+    """生成历史帖子的紧凑卡片HTML"""
+    title = post_detail.get("title", "无标题")
+    body_text = post_detail.get("body_text", "")
+    images = post_detail.get("images", [])
+    like_count = post_detail.get("like_count", 0)
+    collect_count = post_detail.get("collect_count", 0)
+    comment_count = post_detail.get("comment_count", 0)
+    author = post_detail.get("channel_account_name", "")
+    link = post_detail.get("link", "#")
+    publish_time = post_detail.get("publish_time", "")
+
+    # 获取灵感点信息
+    point_name = inspiration_point.get("点的名称", "")
+    point_desc = inspiration_point.get("点的描述", "")
+
+    # 准备详情数据(用于模态框)
+    import json
+    post_detail_data = {
+        "title": title,
+        "body_text": body_text,
+        "images": images,
+        "like_count": like_count,
+        "comment_count": comment_count,
+        "collect_count": collect_count,
+        "author": author,
+        "publish_time": publish_time,
+        "link": link
+    }
+    post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
+    post_data_json_escaped = html_module.escape(post_data_json)
+
+    # 截取正文预览(前80个字符)
+    body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
+
+    # 生成缩略图
+    thumbnail_html = ""
+    if images:
+        thumbnail_html = f'<img src="{images[0]}" alt="Post thumbnail" class="historical-post-thumbnail" loading="lazy">'
+
+    html = f'''
+    <div class="historical-post-card" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
+        <div class="historical-post-image">
+            {thumbnail_html}
+            {f'<div class="post-card-image-count">{len(images)}</div>' if len(images) > 1 else ''}
+        </div>
+        <div class="historical-post-content">
+            <div class="historical-post-title">{html_module.escape(title)}</div>
+            <div class="historical-inspiration-info">
+                <span class="inspiration-type-badge-small">灵感点</span>
+                <span class="inspiration-name-small">{html_module.escape(point_name)}</span>
+            </div>
+            <div class="historical-inspiration-desc">{html_module.escape(point_desc[:100])}{"..." if len(point_desc) > 100 else ""}</div>
+            <div class="historical-post-meta">
+                <span class="historical-post-time">📅 {publish_time}</span>
+            </div>
+            <div class="historical-post-stats">
+                <span>❤ {like_count}</span>
+                <span>⭐ {collect_count}</span>
+                <a href="{link}" target="_blank" class="view-link" onclick="event.stopPropagation()">查看原帖 →</a>
+            </div>
+        </div>
+    </div>
+    '''
+    return html
+
+
+def generate_post_detail_html(post_data: Dict, post_idx: int) -> str:
+    """生成帖子详情HTML(紧凑的卡片样式,点击可展开)"""
+    post_detail = post_data.get("帖子详情", {})
+
+    title = post_detail.get("title", "无标题")
+    body_text = post_detail.get("body_text", "")
+    images = post_detail.get("images", [])
+    like_count = post_detail.get("like_count", 0)
+    comment_count = post_detail.get("comment_count", 0)
+    collect_count = post_detail.get("collect_count", 0)
+    author = post_detail.get("channel_account_name", "")
+    publish_time = post_detail.get("publish_time", "")
+    link = post_detail.get("link", "")
+    post_id = post_data.get("帖子id", f"post-{post_idx}")
+
+    # 准备详情数据(用于模态框)
+    import json
+    post_detail_data = {
+        "title": title,
+        "body_text": body_text,
+        "images": images,
+        "like_count": like_count,
+        "comment_count": comment_count,
+        "collect_count": collect_count,
+        "author": author,
+        "publish_time": publish_time,
+        "link": link
+    }
+    post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
+    post_data_json_escaped = html_module.escape(post_data_json)
+
+    # 生成缩略图HTML
+    thumbnail_html = ""
+    if images and len(images) > 0:
+        # 使用第一张图片作为缩略图
+        thumbnail_html = f'<img src="{images[0]}" class="post-card-thumbnail" alt="缩略图">'
+    else:
+        thumbnail_html = '<div class="post-card-thumbnail-placeholder">📄</div>'
+
+    # 截断正文用于预览
+    body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
+
+    html = f'''
+    <div class="post-card-compact" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
+        <div class="post-card-image">
+            {thumbnail_html}
+            {f'<div class="post-card-image-count">📷 {len(images)}</div>' if len(images) > 1 else ''}
+        </div>
+        <div class="post-card-content">
+            <div class="post-card-title">{html_module.escape(title)}</div>
+            <div class="post-card-preview">{html_module.escape(body_preview) if body_preview else "暂无正文"}</div>
+            <div class="post-card-meta">
+                <span class="post-card-author">👤 {html_module.escape(author)}</span>
+                <span class="post-card-time">📅 {publish_time}</span>
+            </div>
+            <div class="post-card-stats">
+                <span>👍 {like_count}</span>
+                <span>💬 {comment_count if comment_count else 0}</span>
+                <span>⭐ {collect_count if collect_count else 0}</span>
+            </div>
+        </div>
+    </div>
+    '''
+    return html
+
+
+def generate_inspiration_detail_html(inspiration_point: Dict) -> str:
+    """生成灵感点详情HTML"""
+    name = inspiration_point.get("名称", "")
+    desc = inspiration_point.get("描述", "")
+    features = inspiration_point.get("特征列表", [])
+
+    features_html = "".join([
+        f'<span class="feature-tag">{html_module.escape(f)}</span>'
+        for f in features
+    ])
+
+    html = f'''
+    <div class="inspiration-detail-card">
+        <div class="inspiration-header">
+            <span class="inspiration-type-badge">灵感点</span>
+            <h3 class="inspiration-name">{html_module.escape(name)}</h3>
+        </div>
+        <div class="inspiration-description">
+            <div class="desc-label">描述:</div>
+            <div class="desc-text">{html_module.escape(desc)}</div>
+        </div>
+        <div class="inspiration-features">
+            <div class="features-label">特征列表:</div>
+            <div class="features-tags">{features_html}</div>
+        </div>
+    </div>
+    '''
+    return html
+
+
+def load_feature_category_mapping() -> Dict:
+    """加载特征名称到分类的映射"""
+    script_dir = Path(__file__).parent
+    project_root = script_dir.parent.parent
+    mapping_file = project_root / "data" / "data_1117" / "特征名称_分类映射.json"
+
+    try:
+        with open(mapping_file, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except Exception as e:
+        print(f"警告: 无法加载特征分类映射文件: {e}")
+        return {}
+
+
+def load_feature_source_mapping() -> Dict:
+    """加载特征名称到帖子来源的映射"""
+    script_dir = Path(__file__).parent
+    project_root = script_dir.parent.parent
+    mapping_file = project_root / "data" / "data_1117" / "特征名称_帖子来源.json"
+
+    try:
+        with open(mapping_file, "r", encoding="utf-8") as f:
+            data = json.load(f)
+            # 转换为便于查询的格式: {特征名称: [来源列表]}
+            result = {}
+            for feature_type in ["灵感点", "关键点", "目的点"]:
+                if feature_type in data:
+                    for item in data[feature_type]:
+                        feature_name = item.get("特征名称")
+                        if feature_name:
+                            result[feature_name] = item.get("特征来源", [])
+            return result
+    except Exception as e:
+        print(f"警告: 无法加载特征来源映射文件: {e}")
+        return {}
+
+
+def generate_match_results_html(how_steps: List[Dict], feature_idx: int, insp_idx: int, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
+    """生成可折叠的匹配结果HTML"""
+    if not how_steps or len(how_steps) == 0:
+        return ""
+
+    step = how_steps[0]
+    features = step.get("特征列表", [])
+
+    if feature_idx >= len(features):
+        return ""
+
+    feature_data = features[feature_idx]
+    feature_name = feature_data.get("特征名称", "")
+    match_results = feature_data.get("匹配结果", [])
+
+    if category_mapping is None:
+        category_mapping = {}
+
+    # 按分数排序
+    sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("score", 0), reverse=True)
+
+    # 统计匹配类型
+    relation_counts = {}
+    for match in match_results:
+        relation = match.get("匹配结果", {}).get("relation", "unrelated")
+        relation_counts[relation] = relation_counts.get(relation, 0) + 1
+
+    # 生成统计信息
+    stats_items = []
+    for relation, count in sorted(relation_counts.items(), key=lambda x: x[1], reverse=True):
+        label = get_relation_label(relation)
+        color = get_relation_color(relation)
+        stats_items.append(f'<span class="stat-badge" style="background: {color};">{label}: {count}</span>')
+    stats_html = "".join(stats_items)
+
+    # 生成匹配项
+    matches_html = ""
+    for i, match in enumerate(sorted_matches):
+        persona_name = match.get("人设特征名称", "")
+        match_result = match.get("匹配结果", {})
+        relation = match_result.get("relation", "unrelated")
+        score = match_result.get("score", 0.0)
+        explanation = match_result.get("explanation", "")
+
+        color = get_relation_color(relation)
+        label = get_relation_label(relation)
+
+        match_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-match-{i}"
+
+        # 获取该人设特征的分类信息
+        # 需要在三个类型中查找该特征
+        categories_html = ""
+        if category_mapping and persona_name:
+            found_categories = None
+            # 依次在灵感点、关键点、目的点中查找
+            for persona_type in ["灵感点", "关键点", "目的点"]:
+                if persona_type in category_mapping:
+                    type_mapping = category_mapping[persona_type]
+                    if persona_name in type_mapping:
+                        found_categories = type_mapping[persona_name].get("所属分类", [])
+                        break
+
+            if found_categories:
+                # 简洁样式:[大类/中类/小类]
+                categories_reversed = list(reversed(found_categories))
+                categories_text = "/".join(categories_reversed)
+                categories_html = f'<span class="category-simple">[{html_module.escape(categories_text)}]</span>'
+
+        # 获取该人设特征的历史帖子来源
+        historical_posts_html = ""
+        if source_mapping and persona_name and persona_name in source_mapping:
+            source_list = source_mapping[persona_name]
+            if source_list:
+                historical_cards = []
+                for source_item in source_list:
+                    post_detail = source_item.get("帖子详情", {})
+                    if post_detail:
+                        card_html = generate_historical_post_card_html(post_detail, source_item)
+                        historical_cards.append(card_html)
+
+                if historical_cards:
+                    historical_posts_html = f'''
+                    <div class="historical-posts-section">
+                        <h4 class="historical-posts-title">历史帖子来源</h4>
+                        <div class="historical-posts-grid">
+                            {"".join(historical_cards)}
+                        </div>
+                    </div>
+                    '''
+
+        matches_html += f'''
+        <div class="match-item-collapsible">
+            <div class="match-header" onclick="toggleMatch('{match_id}')">
+                <div class="match-header-left">
+                    <span class="expand-icon" id="{match_id}-icon">▶</span>
+                    <span class="persona-name">{categories_html} {html_module.escape(persona_name)}</span>
+                    <span class="relation-badge" style="background: {color};">{label}</span>
+                    <span class="score-badge">分数: {score:.2f}</span>
+                </div>
+            </div>
+            <div class="match-content" id="{match_id}-content" style="display: none;">
+                <div class="match-explanation">{html_module.escape(explanation)}</div>
+                {historical_posts_html}
+            </div>
+        </div>
+        '''
+
+    section_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-section"
+
+    html = f'''
+    <div class="match-results-section">
+        <div class="match-section-header collapsible-header" onclick="toggleFeatureSection('{section_id}')">
+            <div class="header-left">
+                <span class="expand-icon" id="{section_id}-icon">▼</span>
+                <h4>匹配结果: {html_module.escape(feature_name)}</h4>
+            </div>
+            <div class="match-stats">{stats_html}</div>
+        </div>
+        <div class="matches-list" id="{section_id}-content">
+            {matches_html}
+        </div>
+    </div>
+    '''
+    return html
+
+
+def generate_toc_html(post_data: Dict, post_idx: int) -> str:
+    """生成目录导航HTML"""
+    how_result = post_data.get("how解构结果", {})
+    inspiration_list = how_result.get("灵感点列表", [])
+
+    toc_items = []
+
+    # 帖子详情
+    toc_items.append(f'<div class="toc-item toc-level-1" onclick="scrollToSection(\'post-{post_idx}-detail\')"><span class="toc-badge toc-badge-post">帖子详情</span> 帖子信息</div>')
+
+    # 灵感点
+    for insp_idx, inspiration_point in enumerate(inspiration_list):
+        name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
+        name_short = name[:18] + "..." if len(name) > 18 else name
+
+        toc_items.append(f'<div class="toc-item toc-level-1" onclick="scrollToSection(\'post-{post_idx}-insp-{insp_idx}\')"><span class="toc-badge toc-badge-inspiration">灵感点</span> {html_module.escape(name_short)}</div>')
+
+        # 特征列表
+        how_steps = inspiration_point.get("how步骤列表", [])
+        if how_steps:
+            features = how_steps[0].get("特征列表", [])
+            for feat_idx, feature_data in enumerate(features):
+                feature_name = feature_data.get("特征名称", f"特征 {feat_idx + 1}")
+                toc_items.append(f'<div class="toc-item toc-level-2" onclick="scrollToSection(\'post-{post_idx}-feat-{insp_idx}-{feat_idx}\')"><span class="toc-badge toc-badge-feature">特征</span> {html_module.escape(feature_name)}</div>')
+
+    return f'''
+    <div class="toc-container">
+        <div class="toc-header">目录导航</div>
+        <div class="toc-content">
+            {"".join(toc_items)}
+        </div>
+    </div>
+    '''
+
+
+def generate_post_content_html(post_data: Dict, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
+    """生成单个帖子的完整内容HTML"""
+    # 生成目录
+    toc_html = generate_toc_html(post_data, post_idx)
+
+    # 1. 帖子详情
+    post_detail_html = generate_post_detail_html(post_data, post_idx)
+
+    # 2. 灵感点详情和匹配结果
+    how_result = post_data.get("how解构结果", {})
+    inspiration_list = how_result.get("灵感点列表", [])
+
+    # 生成所有灵感点的详情HTML(只包含灵感点详情,不包含匹配结果)
+    inspirations_detail_html = ""
+    for insp_idx, inspiration_point in enumerate(inspiration_list):
+        inspiration_detail = generate_inspiration_detail_html(inspiration_point)
+        inspirations_detail_html += f'''
+        <div id="post-{post_idx}-insp-{insp_idx}" class="inspiration-detail-item content-section">
+            {inspiration_detail}
+        </div>
+        '''
+
+    # 生成所有匹配结果HTML,按照how步骤分组
+    all_matches_html = ""
+    for insp_idx, inspiration_point in enumerate(inspiration_list):
+        inspiration_name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
+        how_steps = inspiration_point.get("how步骤列表", [])
+
+        if how_steps:
+            # 为每个灵感点创建一个区域
+            for step_idx, step in enumerate(how_steps):
+                step_name = step.get("步骤名称", f"步骤 {step_idx + 1}")
+                features = step.get("特征列表", [])
+
+                # 生成该步骤下所有特征的匹配结果
+                features_html = ""
+                for feat_idx, feature_data in enumerate(features):
+                    match_html = generate_match_results_html([step], feat_idx, insp_idx, post_idx, category_mapping, source_mapping)
+                    features_html += f'<div id="post-{post_idx}-feat-{insp_idx}-{feat_idx}" class="feature-match-wrapper">{match_html}</div>'
+
+                # 生成步骤区域(可折叠)
+                step_section_id = f"post-{post_idx}-step-{insp_idx}-{step_idx}"
+                all_matches_html += f'''
+                <div class="step-section">
+                    <div class="step-header collapsible-header" onclick="toggleStepSection('{step_section_id}')">
+                        <div class="header-left">
+                            <span class="expand-icon" id="{step_section_id}-icon">▼</span>
+                            <h3 class="step-name">{html_module.escape(step_name)}</h3>
+                        </div>
+                        <span class="step-inspiration-name">来自: {html_module.escape(inspiration_name)}</span>
+                    </div>
+                    <div class="step-features-list" id="{step_section_id}-content">
+                        {features_html}
+                    </div>
+                </div>
+                '''
+
+    html = f'''
+    <div class="content-with-toc">
+        {toc_html}
+        <div class="main-content">
+            <!-- 第一个框:左右分栏(帖子详情 + 灵感点详情) -->
+            <div class="top-section-box">
+                <div class="two-column-layout">
+                    <div class="left-column">
+                        <div id="post-{post_idx}-detail" class="post-detail-wrapper">
+                            {post_detail_html}
+                        </div>
+                    </div>
+                    <div class="right-column">
+                        <div class="inspirations-detail-wrapper">
+                            {inspirations_detail_html}
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- 下面:所有匹配结果 -->
+            <div class="matches-section">
+                {all_matches_html}
+            </div>
+        </div>
+    </div>
+    '''
+    return html
+
+
+def generate_combined_html(posts_data: List[Dict], category_mapping: Dict = None, source_mapping: Dict = None) -> str:
+    """生成包含所有帖子的单一HTML(带标签页)"""
+
+    # 生成标签页按钮
+    tabs_html = ""
+    for i, post in enumerate(posts_data):
+        post_detail = post.get("帖子详情", {})
+        title = post_detail.get("title", "无标题")
+        active_class = "active" if i == 0 else ""
+        tabs_html += f'<button class="tab-button {active_class}" onclick="openTab(event, \'post-{i}\')">{html_module.escape(title)}</button>\n'
+
+    # 生成标签页内容
+    contents_html = ""
+    for i, post in enumerate(posts_data):
+        active_class = "active" if i == 0 else ""
+        content = generate_post_content_html(post, i, category_mapping, source_mapping)
+        contents_html += f'''
+        <div id="post-{i}" class="tab-content {active_class}">
+            {content}
+        </div>
+        '''
+
+    html = f'''
+    <!DOCTYPE html>
+    <html lang="zh-CN">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>How解构结果可视化</title>
+        <style>
+            * {{
+                margin: 0;
+                padding: 0;
+                box-sizing: border-box;
+            }}
+
+            body {{
+                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+                background: #f5f5f5;
+                color: #333;
+                line-height: 1.6;
+            }}
+
+            .container {{
+                max-width: 1600px;
+                margin: 0 auto;
+                background: white;
+                min-height: 100vh;
+            }}
+
+            .header {{
+                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+                color: white;
+                padding: 30px;
+                text-align: center;
+            }}
+
+            .header h1 {{
+                font-size: 32px;
+                font-weight: bold;
+                margin-bottom: 10px;
+            }}
+
+            .header p {{
+                font-size: 16px;
+                opacity: 0.9;
+            }}
+
+            /* 标签页样式 */
+            .tabs-container {{
+                display: flex;
+                background: #f9fafb;
+                border-bottom: 2px solid #e5e7eb;
+                overflow-x: auto;
+                position: sticky;
+                top: 0;
+                z-index: 100;
+                box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+            }}
+
+            .tab-button {{
+                flex: 1;
+                min-width: 200px;
+                padding: 18px 30px;
+                background: transparent;
+                border: none;
+                border-bottom: 3px solid transparent;
+                cursor: pointer;
+                font-size: 15px;
+                font-weight: 500;
+                color: #6b7280;
+                transition: all 0.3s;
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+            }}
+
+            .tab-button:hover {{
+                background: #f3f4f6;
+                color: #374151;
+            }}
+
+            .tab-button.active {{
+                color: #667eea;
+                border-bottom-color: #667eea;
+                background: white;
+            }}
+
+            .tab-content {{
+                display: none;
+                padding: 30px;
+            }}
+
+            .tab-content.active {{
+                display: block;
+                animation: fadeIn 0.3s;
+            }}
+
+            @keyframes fadeIn {{
+                from {{ opacity: 0; transform: translateY(10px); }}
+                to {{ opacity: 1; transform: translateY(0); }}
+            }}
+
+            /* 目录和主内容布局 */
+            .content-with-toc {{
+                display: grid;
+                grid-template-columns: 280px 1fr;
+                gap: 30px;
+                align-items: start;
+            }}
+
+            .toc-container {{
+                position: sticky;
+                top: 80px;
+                background: white;
+                border: 1px solid #e5e7eb;
+                border-radius: 12px;
+                overflow: hidden;
+                max-height: calc(100vh - 100px);
+                display: flex;
+                flex-direction: column;
+            }}
+
+            .toc-header {{
+                padding: 15px 20px;
+                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+                color: white;
+                font-weight: 600;
+                font-size: 16px;
+            }}
+
+            .toc-content {{
+                padding: 10px;
+                overflow-y: auto;
+                flex: 1;
+            }}
+
+            .toc-item {{
+                padding: 10px 15px;
+                margin: 4px 0;
+                cursor: pointer;
+                border-radius: 6px;
+                font-size: 14px;
+                transition: all 0.2s;
+                user-select: none;
+            }}
+
+            .toc-item:hover {{
+                background: #f3f4f6;
+            }}
+
+            .toc-item.active {{
+                background: #e0e7ff;
+                color: #667eea;
+                font-weight: 600;
+            }}
+
+            .toc-level-1 {{
+                color: #111827;
+                font-weight: 500;
+            }}
+
+            .toc-level-2 {{
+                color: #6b7280;
+                font-size: 13px;
+                padding-left: 30px;
+            }}
+
+            .toc-badge {{
+                display: inline-block;
+                padding: 2px 8px;
+                border-radius: 10px;
+                font-size: 11px;
+                font-weight: 600;
+                margin-right: 6px;
+            }}
+
+            .toc-badge-post {{
+                background: #dbeafe;
+                color: #1e40af;
+            }}
+
+            .toc-badge-inspiration {{
+                background: #fef3c7;
+                color: #92400e;
+            }}
+
+            .toc-badge-feature {{
+                background: #e0e7ff;
+                color: #3730a3;
+            }}
+
+            .main-content {{
+                min-width: 0;
+                display: flex;
+                flex-direction: column;
+                gap: 30px;
+            }}
+
+            /* 顶部框:包含帖子详情和灵感点详情 */
+            .top-section-box {{
+                background: white;
+                border: 1px solid #e5e7eb;
+                border-radius: 12px;
+                padding: 25px;
+                box-shadow: 0 2px 8px rgba(0,0,0,0.05);
+            }}
+
+            /* 顶部框内的两栏布局 */
+            .two-column-layout {{
+                display: grid;
+                grid-template-columns: 380px 1fr;
+                gap: 25px;
+                align-items: start;
+            }}
+
+            .left-column {{
+                position: relative;
+            }}
+
+            .post-detail-wrapper {{
+                /* 帖子详情区域 */
+            }}
+
+            .right-column {{
+                min-width: 0;
+            }}
+
+            .inspirations-detail-wrapper {{
+                display: flex;
+                flex-direction: column;
+                gap: 20px;
+                max-height: 600px;
+                overflow-y: auto;
+                padding-right: 10px;
+            }}
+
+            .inspiration-detail-item {{
+                /* 单个灵感点详情 */
+            }}
+
+            /* 匹配结果区域 */
+            .matches-section {{
+                display: flex;
+                flex-direction: column;
+                gap: 20px;
+            }}
+
+            /* 步骤区域 */
+            .step-section {{
+                background: white;
+                border: 1px solid #e5e7eb;
+                border-radius: 12px;
+                overflow: hidden;
+                box-shadow: 0 2px 8px rgba(0,0,0,0.05);
+            }}
+
+            .step-header {{
+                padding: 20px 25px;
+                background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
+                border-bottom: 2px solid #e5e7eb;
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+            }}
+
+            .step-name {{
+                font-size: 18px;
+                color: #111827;
+                font-weight: 600;
+                margin: 0;
+            }}
+
+            .step-inspiration-name {{
+                font-size: 14px;
+                color: #6b7280;
+                font-style: italic;
+            }}
+
+            .step-features-list {{
+                padding: 20px;
+                display: flex;
+                flex-direction: column;
+                gap: 15px;
+            }}
+
+            .feature-match-wrapper {{
+                /* 特征匹配容器 */
+            }}
+
+            .content-section {{
+                scroll-margin-top: 80px;
+            }}
+
+            /* 帖子详情卡片(紧凑样式) */
+            .post-card-compact {{
+                display: grid;
+                grid-template-columns: 200px 1fr;
+                gap: 20px;
+                background: white;
+                border: 1px solid #e5e7eb;
+                border-radius: 12px;
+                overflow: hidden;
+                padding: 20px;
+                cursor: pointer;
+                transition: all 0.3s;
+                margin-bottom: 30px;
+            }}
+
+            .post-card-compact:hover {{
+                box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+                transform: translateY(-2px);
+                border-color: #667eea;
+            }}
+
+            .post-card-image {{
+                position: relative;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+            }}
+
+            .post-card-thumbnail {{
+                width: 100%;
+                height: 150px;
+                object-fit: contain;
+                border-radius: 8px;
+                background: #f9fafb;
+            }}
+
+            .post-card-thumbnail-placeholder {{
+                width: 100%;
+                height: 150px;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                background: #f3f4f6;
+                color: #9ca3af;
+                font-size: 48px;
+                border-radius: 8px;
+            }}
+
+            .post-card-image-count {{
+                position: absolute;
+                bottom: 8px;
+                right: 8px;
+                background: rgba(0, 0, 0, 0.7);
+                color: white;
+                padding: 4px 8px;
+                border-radius: 12px;
+                font-size: 11px;
+                font-weight: 600;
+            }}
+
+            .post-card-content {{
+                display: flex;
+                flex-direction: column;
+                gap: 12px;
+            }}
+
+            .post-card-title {{
+                font-size: 18px;
+                font-weight: 600;
+                color: #111827;
+                line-height: 1.4;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                display: -webkit-box;
+                -webkit-line-clamp: 2;
+                -webkit-box-orient: vertical;
+            }}
+
+            .post-card-preview {{
+                font-size: 14px;
+                color: #6b7280;
+                line-height: 1.6;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                display: -webkit-box;
+                -webkit-line-clamp: 2;
+                -webkit-box-orient: vertical;
+            }}
+
+            .post-card-meta {{
+                display: flex;
+                gap: 15px;
+                font-size: 13px;
+                color: #9ca3af;
+            }}
+
+            .post-card-stats {{
+                display: flex;
+                gap: 15px;
+                font-size: 13px;
+                color: #6b7280;
+                font-weight: 500;
+            }}
+
+            /* 灵感点详情卡片样式保持不变 */
+
+            .inspiration-detail-card {{
+                background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
+                padding: 25px;
+                border-bottom: 2px solid #e5e7eb;
+            }}
+
+            .inspiration-header {{
+                margin-bottom: 15px;
+                display: flex;
+                align-items: center;
+                gap: 10px;
+            }}
+
+            .inspiration-type-badge {{
+                display: inline-block;
+                padding: 4px 12px;
+                background: #fef3c7;
+                color: #92400e;
+                border-radius: 12px;
+                font-size: 12px;
+                font-weight: 600;
+            }}
+
+            .inspiration-name {{
+                font-size: 20px;
+                color: #111827;
+                font-weight: 600;
+                margin: 0;
+            }}
+
+            .inspiration-description {{
+                margin-bottom: 15px;
+            }}
+
+            .desc-label {{
+                font-weight: 600;
+                color: #6b7280;
+                font-size: 13px;
+                margin-bottom: 8px;
+            }}
+
+            .desc-text {{
+                color: #4b5563;
+                font-size: 14px;
+                line-height: 1.7;
+            }}
+
+            .inspiration-features {{
+                margin-top: 15px;
+            }}
+
+            .features-label {{
+                font-weight: 600;
+                color: #6b7280;
+                font-size: 13px;
+                margin-bottom: 8px;
+            }}
+
+            .features-tags {{
+                display: flex;
+                flex-wrap: wrap;
+                gap: 8px;
+            }}
+
+            .feature-tag {{
+                padding: 5px 12px;
+                background: #667eea;
+                color: white;
+                border-radius: 16px;
+                font-size: 13px;
+                font-weight: 500;
+            }}
+
+            /* 匹配结果部分 */
+            .match-results-section {{
+                padding: 25px;
+                border-bottom: 1px solid #e5e7eb;
+            }}
+
+            .match-results-section:last-child {{
+                border-bottom: none;
+            }}
+
+            .match-section-header {{
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                margin-bottom: 20px;
+                padding-bottom: 15px;
+                border-bottom: 2px solid #e5e7eb;
+            }}
+
+            .collapsible-header {{
+                cursor: pointer;
+                user-select: none;
+                transition: background 0.2s;
+                padding: 10px;
+                margin: -10px;
+                border-radius: 6px;
+            }}
+
+            .collapsible-header:hover {{
+                background: #f9fafb;
+            }}
+
+            .header-left {{
+                display: flex;
+                align-items: center;
+                gap: 10px;
+            }}
+
+            .match-section-header h4 {{
+                font-size: 18px;
+                color: #111827;
+                margin: 0;
+            }}
+
+            .match-stats {{
+                display: flex;
+                gap: 10px;
+                flex-wrap: wrap;
+            }}
+
+            .stat-badge {{
+                padding: 4px 10px;
+                border-radius: 12px;
+                color: white;
+                font-size: 12px;
+                font-weight: 600;
+            }}
+
+            .matches-list {{
+                display: flex;
+                flex-direction: column;
+                gap: 8px;
+            }}
+
+            .match-item-collapsible {{
+                border: 1px solid #e5e7eb;
+                border-radius: 8px;
+                overflow: hidden;
+                background: white;
+                transition: box-shadow 0.2s;
+            }}
+
+            .match-item-collapsible:hover {{
+                box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+            }}
+
+            .match-header {{
+                padding: 12px 16px;
+                cursor: pointer;
+                user-select: none;
+                transition: background 0.2s;
+            }}
+
+            .match-header:hover {{
+                background: #f9fafb;
+            }}
+
+            .match-header-left {{
+                display: flex;
+                align-items: center;
+                gap: 10px;
+            }}
+
+            .expand-icon {{
+                color: #9ca3af;
+                font-size: 12px;
+                transition: transform 0.2s;
+            }}
+
+            .expand-icon.expanded {{
+                transform: rotate(90deg);
+            }}
+
+            .persona-name {{
+                font-weight: 600;
+                font-size: 14px;
+                color: #111827;
+            }}
+
+            .relation-badge {{
+                padding: 3px 10px;
+                border-radius: 12px;
+                color: white;
+                font-size: 11px;
+                font-weight: 600;
+            }}
+
+            .score-badge {{
+                padding: 3px 10px;
+                border-radius: 12px;
+                background: #e5e7eb;
+                color: #374151;
+                font-size: 11px;
+                font-weight: 600;
+            }}
+
+            .match-content {{
+                padding: 16px;
+                background: #f9fafb;
+                border-top: 1px solid #e5e7eb;
+            }}
+
+            .match-explanation {{
+                font-size: 13px;
+                color: #4b5563;
+                line-height: 1.7;
+                margin-bottom: 16px;
+            }}
+
+            /* 历史帖子来源区域 */
+            .historical-posts-section {{
+                margin-top: 20px;
+                padding-top: 20px;
+                border-top: 2px solid #e5e7eb;
+            }}
+
+            .historical-posts-title {{
+                font-size: 14px;
+                font-weight: 600;
+                color: #374151;
+                margin-bottom: 12px;
+            }}
+
+            .historical-posts-grid {{
+                display: grid;
+                grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+                gap: 16px;
+            }}
+
+            .historical-post-card {{
+                display: grid;
+                grid-template-columns: 120px 1fr;
+                gap: 12px;
+                background: white;
+                border: 1px solid #e5e7eb;
+                border-radius: 8px;
+                padding: 12px;
+                transition: all 0.2s;
+                cursor: pointer;
+            }}
+
+            .historical-post-card:hover {{
+                border-color: #667eea;
+                box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
+                transform: translateY(-2px);
+            }}
+
+            .historical-post-image {{
+                position: relative;
+            }}
+
+            .historical-post-thumbnail {{
+                width: 100%;
+                height: 100px;
+                object-fit: contain;
+                border-radius: 6px;
+                background: #f9fafb;
+            }}
+
+            .historical-post-content {{
+                display: flex;
+                flex-direction: column;
+                gap: 8px;
+            }}
+
+            .historical-post-title {{
+                font-size: 13px;
+                font-weight: 600;
+                color: #1f2937;
+                line-height: 1.4;
+                overflow: hidden;
+                display: -webkit-box;
+                -webkit-line-clamp: 2;
+                -webkit-box-orient: vertical;
+            }}
+
+            .historical-inspiration-info {{
+                display: flex;
+                align-items: center;
+                gap: 6px;
+            }}
+
+            .inspiration-type-badge-small {{
+                padding: 2px 6px;
+                border-radius: 4px;
+                background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
+                color: white;
+                font-size: 10px;
+                font-weight: 600;
+            }}
+
+            .inspiration-name-small {{
+                font-size: 11px;
+                color: #667eea;
+                font-weight: 500;
+            }}
+
+            .historical-inspiration-desc {{
+                font-size: 11px;
+                color: #6b7280;
+                line-height: 1.5;
+                overflow: hidden;
+                display: -webkit-box;
+                -webkit-line-clamp: 2;
+                -webkit-box-orient: vertical;
+            }}
+
+            .historical-post-meta {{
+                margin: 4px 0;
+            }}
+
+            .historical-post-time {{
+                font-size: 11px;
+                color: #9ca3af;
+            }}
+
+            .historical-post-stats {{
+                display: flex;
+                align-items: center;
+                gap: 10px;
+                font-size: 11px;
+                color: #9ca3af;
+            }}
+
+            .view-link {{
+                margin-left: auto;
+                color: #667eea;
+                text-decoration: none;
+                font-weight: 500;
+            }}
+
+            .view-link:hover {{
+                color: #764ba2;
+            }}
+
+            .category-simple {{
+                color: #9ca3af;
+                font-size: 12px;
+                font-weight: 400;
+                margin-right: 6px;
+            }}
+
+            /* 帖子详情模态框 */
+            .post-detail-modal {{
+                display: none;
+                position: fixed;
+                z-index: 1000;
+                left: 0;
+                top: 0;
+                width: 100%;
+                height: 100%;
+                background: rgba(0, 0, 0, 0.7);
+                overflow: auto;
+                animation: fadeIn 0.3s;
+            }}
+
+            .post-detail-modal.active {{
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                padding: 40px 20px;
+            }}
+
+            .post-detail-content {{
+                position: relative;
+                background: white;
+                border-radius: 16px;
+                max-width: 900px;
+                width: 100%;
+                max-height: 90vh;
+                overflow-y: auto;
+                box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+            }}
+
+            .post-detail-close {{
+                position: sticky;
+                top: 20px;
+                right: 20px;
+                float: right;
+                font-size: 36px;
+                font-weight: 300;
+                color: #9ca3af;
+                background: white;
+                border: none;
+                cursor: pointer;
+                width: 40px;
+                height: 40px;
+                border-radius: 50%;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                transition: all 0.2s;
+                z-index: 10;
+                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+            }}
+
+            .post-detail-close:hover {{
+                color: #ef4444;
+                background: #fee2e2;
+                transform: rotate(90deg);
+            }}
+
+            .post-detail-header {{
+                padding: 40px 40px 20px 40px;
+                border-bottom: 2px solid #e5e7eb;
+            }}
+
+            .post-detail-title {{
+                font-size: 28px;
+                font-weight: bold;
+                color: #111827;
+                line-height: 1.4;
+                margin-bottom: 15px;
+            }}
+
+            .post-detail-meta {{
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                color: #6b7280;
+                font-size: 14px;
+                gap: 20px;
+            }}
+
+            .post-detail-author {{
+                font-weight: 500;
+            }}
+
+            .post-detail-time {{
+                color: #9ca3af;
+            }}
+
+            .post-detail-stats {{
+                display: flex;
+                gap: 15px;
+                font-weight: 500;
+            }}
+
+            .post-detail-body {{
+                padding: 30px 40px;
+            }}
+
+            .post-detail-desc {{
+                font-size: 15px;
+                color: #4b5563;
+                line-height: 1.8;
+                margin-bottom: 25px;
+                white-space: pre-wrap;
+            }}
+
+            .post-detail-images {{
+                display: grid;
+                grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+                gap: 15px;
+                margin-top: 20px;
+            }}
+
+            .post-detail-image {{
+                width: 100%;
+                border-radius: 8px;
+                object-fit: cover;
+                cursor: pointer;
+                transition: transform 0.2s;
+            }}
+
+            .post-detail-image:hover {{
+                transform: scale(1.02);
+            }}
+
+            .post-detail-footer {{
+                padding: 20px 40px 30px 40px;
+                border-top: 1px solid #e5e7eb;
+                text-align: center;
+            }}
+
+            .post-detail-link {{
+                display: inline-block;
+                padding: 12px 30px;
+                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+                color: white;
+                text-decoration: none;
+                border-radius: 8px;
+                font-weight: 500;
+                transition: all 0.3s;
+            }}
+
+            .post-detail-link:hover {{
+                transform: translateY(-2px);
+                box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+            }}
+
+            /* 响应式 */
+            @media (max-width: 1400px) {{
+                .content-with-toc {{
+                    grid-template-columns: 240px 1fr;
+                }}
+
+                .two-column-layout {{
+                    grid-template-columns: 320px 1fr;
+                }}
+            }}
+
+            @media (max-width: 1200px) {{
+                .content-with-toc {{
+                    grid-template-columns: 200px 1fr;
+                }}
+
+                .two-column-layout {{
+                    grid-template-columns: 280px 1fr;
+                }}
+            }}
+
+            @media (max-width: 1024px) {{
+                .content-with-toc {{
+                    grid-template-columns: 1fr;
+                }}
+
+                .toc-container {{
+                    position: relative;
+                    top: 0;
+                    max-height: 300px;
+                }}
+
+                .two-column-layout {{
+                    grid-template-columns: 1fr;
+                }}
+
+                .inspirations-detail-wrapper {{
+                    max-height: none;
+                }}
+
+                .post-card-compact {{
+                    grid-template-columns: 150px 1fr;
+                }}
+            }}
+
+            @media (max-width: 768px) {{
+                .header {{
+                    padding: 20px;
+                }}
+
+                .header h1 {{
+                    font-size: 24px;
+                }}
+
+                .tab-button {{
+                    min-width: 150px;
+                    padding: 15px 20px;
+                }}
+
+                .tab-content {{
+                    padding: 15px;
+                }}
+
+                .post-info-section {{
+                    padding: 20px;
+                }}
+            }}
+        </style>
+    </head>
+    <body>
+        <div class="container">
+            <div class="header">
+                <h1>How 解构结果可视化</h1>
+                <p>灵感点特征匹配分析</p>
+            </div>
+            <div class="tabs-container">
+                {tabs_html}
+            </div>
+            {contents_html}
+        </div>
+
+        <!-- 帖子详情模态框 -->
+        <div id="postDetailModal" class="post-detail-modal" onclick="closePostDetail(event)"></div>
+
+        <script>
+            function openTab(evt, tabId) {{
+                var tabContents = document.getElementsByClassName("tab-content");
+                for (var i = 0; i < tabContents.length; i++) {{
+                    tabContents[i].classList.remove("active");
+                }}
+
+                var tabButtons = document.getElementsByClassName("tab-button");
+                for (var i = 0; i < tabButtons.length; i++) {{
+                    tabButtons[i].classList.remove("active");
+                }}
+
+                document.getElementById(tabId).classList.add("active");
+                evt.currentTarget.classList.add("active");
+            }}
+
+            function scrollToSection(sectionId) {{
+                var element = document.getElementById(sectionId);
+                if (element) {{
+                    element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
+
+                    // 更新目录项的active状态
+                    var tocItems = document.querySelectorAll('.toc-item');
+                    tocItems.forEach(function(item) {{
+                        item.classList.remove('active');
+                    }});
+                    event.currentTarget.classList.add('active');
+                }}
+            }}
+
+            function toggleMatch(matchId) {{
+                var content = document.getElementById(matchId + '-content');
+                var icon = document.getElementById(matchId + '-icon');
+
+                if (content.style.display === 'none') {{
+                    content.style.display = 'block';
+                    icon.classList.add('expanded');
+                }} else {{
+                    content.style.display = 'none';
+                    icon.classList.remove('expanded');
+                }}
+            }}
+
+            function toggleFeatureSection(sectionId) {{
+                var content = document.getElementById(sectionId + '-content');
+                var icon = document.getElementById(sectionId + '-icon');
+
+                if (content.style.display === 'none') {{
+                    content.style.display = 'flex';
+                    icon.textContent = '▼';
+                }} else {{
+                    content.style.display = 'none';
+                    icon.textContent = '▶';
+                }}
+            }}
+
+            function toggleStepSection(sectionId) {{
+                var content = document.getElementById(sectionId + '-content');
+                var icon = document.getElementById(sectionId + '-icon');
+
+                if (content.style.display === 'none') {{
+                    content.style.display = 'flex';
+                    icon.textContent = '▼';
+                }} else {{
+                    content.style.display = 'none';
+                    icon.textContent = '▶';
+                }}
+            }}
+
+            function showPostDetail(element) {{
+                const postDataStr = element.dataset.postData;
+                if (!postDataStr) return;
+
+                try {{
+                    const postData = JSON.parse(postDataStr);
+
+                    // 生成图片HTML
+                    let imagesHtml = '';
+                    if (postData.images && postData.images.length > 0) {{
+                        imagesHtml = postData.images.map(img =>
+                            `<img src="${{img}}" class="post-detail-image" alt="图片">`
+                        ).join('');
+                    }} else {{
+                        imagesHtml = '<div style="text-align: center; color: #9ca3af; padding: 40px;">暂无图片</div>';
+                    }}
+
+                    const modalHtml = `
+                        <div class="post-detail-content" onclick="event.stopPropagation()">
+                            <button class="post-detail-close" onclick="closePostDetail()">×</button>
+                            <div class="post-detail-header">
+                                <div class="post-detail-title">${{postData.title || '无标题'}}</div>
+                                <div class="post-detail-meta">
+                                    <div>
+                                        <span class="post-detail-author">👤 ${{postData.author}}</span>
+                                        <span class="post-detail-time"> · 📅 ${{postData.publish_time}}</span>
+                                    </div>
+                                    <div class="post-detail-stats">
+                                        <span>👍 ${{postData.like_count}}</span>
+                                        <span>💬 ${{postData.comment_count}}</span>
+                                        <span>⭐ ${{postData.collect_count}}</span>
+                                    </div>
+                                </div>
+                            </div>
+                            <div class="post-detail-body">
+                                ${{postData.body_text ? `<div class="post-detail-desc">${{postData.body_text}}</div>` : '<div style="color: #9ca3af;">暂无正文</div>'}}
+                                <div class="post-detail-images">
+                                    ${{imagesHtml}}
+                                </div>
+                            </div>
+                            <div class="post-detail-footer">
+                                <a href="${{postData.link}}" target="_blank" class="post-detail-link">
+                                    在小红书查看完整内容 →
+                                </a>
+                            </div>
+                        </div>
+                    `;
+
+                    let modal = document.getElementById('postDetailModal');
+                    if (!modal) {{
+                        modal = document.createElement('div');
+                        modal.id = 'postDetailModal';
+                        modal.className = 'post-detail-modal';
+                        modal.onclick = closePostDetail;
+                        document.body.appendChild(modal);
+                    }}
+
+                    modal.innerHTML = modalHtml;
+                    modal.classList.add('active');
+                    document.body.style.overflow = 'hidden';
+                }} catch (e) {{
+                    console.error('解析帖子数据失败:', e);
+                }}
+            }}
+
+            function closePostDetail(event) {{
+                if (event && event.target !== event.currentTarget) return;
+
+                const modal = document.getElementById('postDetailModal');
+                if (modal) {{
+                    modal.classList.remove('active');
+                    document.body.style.overflow = '';
+                }}
+            }}
+
+            function moveImage(postId, direction) {{
+                var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
+                var track = document.getElementById(postId + '-track');
+                var totalImages = parseInt(carousel.getAttribute('data-total-images'));
+
+                if (!carousel.currentIndex) {{
+                    carousel.currentIndex = 0;
+                }}
+
+                carousel.currentIndex = (carousel.currentIndex + direction + totalImages) % totalImages;
+                track.style.transform = `translateX(-${{carousel.currentIndex * 100}}%)`;
+
+                updateIndicators(postId, carousel.currentIndex);
+            }}
+
+            function jumpToImage(postId, index) {{
+                var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
+                var track = document.getElementById(postId + '-track');
+
+                carousel.currentIndex = index;
+                track.style.transform = `translateX(-${{index * 100}}%)`;
+
+                updateIndicators(postId, index);
+            }}
+
+            function updateIndicators(postId, activeIndex) {{
+                var indicators = document.querySelectorAll(`#${{postId}}-indicators .indicator`);
+                indicators.forEach((indicator, i) => {{
+                    if (i === activeIndex) {{
+                        indicator.classList.add('active');
+                    }} else {{
+                        indicator.classList.remove('active');
+                    }}
+                }});
+            }}
+
+            // 目录激活状态追踪
+            function updateTocActiveState() {{
+                const mainContent = document.querySelector('.main-content');
+                if (!mainContent) return;
+
+                const sections = document.querySelectorAll('.post-detail-wrapper, .inspiration-detail-card, .step-section');
+                const tocItems = document.querySelectorAll('.toc-item');
+
+                let currentActive = null;
+                const scrollTop = mainContent.scrollTop;
+                const windowHeight = window.innerHeight;
+
+                // 找到当前在视口中的section
+                sections.forEach(section => {{
+                    const rect = section.getBoundingClientRect();
+                    const sectionTop = rect.top;
+                    const sectionBottom = rect.bottom;
+
+                    // 如果section的顶部在视口上半部分,认为它是当前激活的
+                    if (sectionTop < windowHeight / 2 && sectionBottom > 0) {{
+                        currentActive = section.id;
+                    }}
+                }});
+
+                // 更新目录项的激活状态
+                tocItems.forEach(item => {{
+                    const targetId = item.getAttribute('onclick')?.match(/'([^']+)'/)?.[1];
+                    if (targetId === currentActive) {{
+                        item.classList.add('active');
+                    }} else {{
+                        item.classList.remove('active');
+                    }}
+                }});
+            }}
+
+            // 监听滚动事件
+            document.addEventListener('DOMContentLoaded', function() {{
+                const mainContent = document.querySelector('.main-content');
+                if (mainContent) {{
+                    mainContent.addEventListener('scroll', function() {{
+                        // 使用节流避免频繁触发
+                        if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
+                        this.scrollTimeout = setTimeout(updateTocActiveState, 50);
+                    }});
+
+                    // 初始化时更新一次
+                    updateTocActiveState();
+                }}
+            }});
+        </script>
+    </body>
+    </html>
+    '''
+    return html
+
+
+def main():
+    """主函数"""
+    script_dir = Path(__file__).parent
+    project_root = script_dir.parent.parent
+    data_dir = project_root / "data" / "data_1117"
+
+    input_dir = data_dir / "当前帖子_how解构结果"
+    output_file = data_dir / "当前帖子_how解构结果_可视化.html"
+
+    print(f"读取 how 解构结果: {input_dir}")
+
+    # 加载特征分类映射
+    print(f"加载特征分类映射...")
+    category_mapping = load_feature_category_mapping()
+    print(f"已加载 {sum(len(v) for v in category_mapping.values())} 个特征分类")
+
+    # 加载特征来源映射
+    print(f"加载特征来源映射...")
+    source_mapping = load_feature_source_mapping()
+    print(f"已加载 {len(source_mapping)} 个特征的来源信息")
+
+    json_files = list(input_dir.glob("*_how.json"))
+    print(f"找到 {len(json_files)} 个文件\n")
+
+    posts_data = []
+    for i, file_path in enumerate(json_files, 1):
+        print(f"读取文件 [{i}/{len(json_files)}]: {file_path.name}")
+        with open(file_path, "r", encoding="utf-8") as f:
+            post_data = json.load(f)
+            posts_data.append(post_data)
+
+    print(f"\n生成合并的 HTML...")
+    html_content = generate_combined_html(posts_data, category_mapping, source_mapping)
+
+    print(f"保存到: {output_file}")
+    with open(output_file, "w", encoding="utf-8") as f:
+        f.write(html_content)
+
+    print(f"\n完成! 可视化文件已保存")
+    print(f"请在浏览器中打开: {output_file}")
+
+
+if __name__ == "__main__":
+    main()