"""
灵感点分析结果可视化脚本
读取 how/灵感点 目录下的分析结果,结合作者历史帖子详情,生成可视化HTML页面
"""
import json
from pathlib import Path
from typing import Dict, Any, List, Optional
from datetime import datetime
import html as html_module
def load_inspiration_points_data(inspiration_dir: str) -> List[Dict[str, Any]]:
"""
加载所有灵感点的分析结果
Args:
inspiration_dir: 灵感点目录路径
Returns:
灵感点分析结果列表
"""
inspiration_path = Path(inspiration_dir)
results = []
# 遍历所有子目录
for subdir in inspiration_path.iterdir():
if subdir.is_dir():
# 查找 all_summary 文件
summary_files = list(subdir.glob("all_summary_*.json"))
if summary_files:
summary_file = summary_files[0]
try:
with open(summary_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 加载完整的 step1 和 step2 数据
step1_data = None
step2_data = None
# 直接从当前子目录查找 step1 和 step2 文件
step1_files = list(subdir.glob("all_step1_*.json"))
step2_files = list(subdir.glob("all_step2_*.json"))
if step1_files:
try:
with open(step1_files[0], 'r', encoding='utf-8') as f:
step1_data = json.load(f)
except Exception as e:
print(f"警告: 读取 {step1_files[0]} 失败: {e}")
if step2_files:
try:
with open(step2_files[0], 'r', encoding='utf-8') as f:
step2_data = json.load(f)
except Exception as e:
print(f"警告: 读取 {step2_files[0]} 失败: {e}")
# 加载搜索结果和匹配分数
search_results = {}
search_dir = subdir / "search"
if search_dir.exists() and search_dir.is_dir():
search_files = list(search_dir.glob("all_search_*.json"))
for search_file in search_files:
try:
with open(search_file, 'r', encoding='utf-8') as f:
search_data = json.load(f)
# 从JSON内容中读取真实的keyword,而不是从文件名提取
keyword = search_data.get("search_params", {}).get("keyword", "")
if keyword:
# 尝试加载对应的匹配结果文件
match_file = search_dir / "all_step4_搜索结果匹配_gemini-2.5-pro.json"
match_data = None
if match_file.exists():
try:
with open(match_file, 'r', encoding='utf-8') as mf:
match_data = json.load(mf)
except Exception as e:
print(f"警告: 读取匹配文件 {match_file} 失败: {e}")
search_results[keyword] = {
"search_data": search_data,
"match_data": match_data
}
else:
# 如果JSON中没有keyword,则从文件名提取
keyword = search_file.stem.replace("all_search_", "")
search_results[keyword] = {
"search_data": search_data,
"match_data": None
}
except Exception as e:
print(f"警告: 读取 {search_file} 失败: {e}")
results.append({
"summary": data,
"step1": step1_data,
"step2": step2_data,
"search_results": search_results,
"inspiration_name": subdir.name
})
except Exception as e:
print(f"警告: 读取 {summary_file} 失败: {e}")
return results
def load_posts_data(posts_dir: str) -> Dict[str, Dict[str, Any]]:
"""
加载所有帖子详情数据
Args:
posts_dir: 帖子目录路径
Returns:
帖子ID到帖子详情的映射
"""
posts_path = Path(posts_dir)
posts_map = {}
for post_file in posts_path.glob("*.json"):
try:
with open(post_file, 'r', encoding='utf-8') as f:
post_data = json.load(f)
post_id = post_data.get("channel_content_id")
if post_id:
posts_map[post_id] = post_data
except Exception as e:
print(f"警告: 读取 {post_file} 失败: {e}")
return posts_map
def generate_post_card_html(post: Dict[str, Any], note_id_prefix: str = "info-post", post_to_mapping_data: Dict[str, Any] = None) -> str:
"""
生成单个帖子卡片HTML(与搜索结果样式一致)
Args:
post: 帖子数据
note_id_prefix: 帖子ID前缀,用于区分不同区域的帖子
post_to_mapping_data: 帖子到分类和点映射数据
Returns:
HTML字符串
"""
import html as html_module
import random
title = post.get("title", "")
desc = post.get("body_text", "")
images = post.get("images", [])
like_count = post.get("like_count", 0)
comment_count = post.get("comment_count", 0)
link = post.get("link", "")
author = post.get("channel_account_name", "")
post_id = post.get("channel_content_id", "")
publish_time = post.get("publish_time", "")
# 生成唯一的note_id
note_id = f"{note_id_prefix}-{random.randint(10000, 99999)}"
# 生成图片轮播HTML
images_html = ""
if images and len(images) > 0:
images_track = "".join([f'
' for i, img in enumerate(images)])
# 图片导航按钮
nav_buttons = ""
if len(images) > 1:
nav_buttons = f'''
'''
images_html = f'''
{images_track}
{nav_buttons}
'''
else:
# 无图片时显示占位符
images_html = f'''
'''
# 准备详情数据
note_data = {
"title": title,
"desc": desc,
"images": images,
"link": link,
"author": author,
"like_count": like_count,
"comment_count": comment_count
}
# 添加灵感点、关键点、目的点到note_data
if post_to_mapping_data and post_id and post_id in post_to_mapping_data:
mapping = post_to_mapping_data[post_id]
note_data["inspiration_points"] = mapping.get("灵感点列表", [])
note_data["key_points"] = mapping.get("关键点列表", [])
note_data["purpose_points"] = mapping.get("目的点列表", [])
import json
note_data_json = json.dumps(note_data, ensure_ascii=False)
note_data_json_escaped = html_module.escape(note_data_json)
# 获取帖子的灵感点、关键点、目的点
points_html = ""
if post_to_mapping_data and post_id and post_id in post_to_mapping_data:
mapping = post_to_mapping_data[post_id]
inspiration_points = mapping.get("灵感点列表", [])
key_points = mapping.get("关键点列表", [])
purpose_points = mapping.get("目的点列表", [])
points_sections = []
# 灵感点
if inspiration_points:
insp_items = "".join([
f'{html_module.escape(p.get("灵感点", ""))}
'
for p in inspiration_points[:3] # 最多显示3个
])
points_sections.append(f'')
# 关键点
if key_points:
key_items = "".join([
f'{html_module.escape(k.get("关键点", ""))}
'
for k in key_points[:3] # 最多显示3个
])
points_sections.append(f'')
# 目的点
if purpose_points:
purpose_items = "".join([
f'{html_module.escape(p.get("目的点", ""))}
'
for p in purpose_points[:3] # 最多显示3个
])
points_sections.append(f'')
if points_sections:
points_html = f'{"".join(points_sections)}
'
# 生成发布日期HTML
publish_date_html = ""
if publish_time:
publish_date_html = f'📅 {html_module.escape(publish_time)}
'
card_html = f'''
{images_html}
{html_module.escape(title) if title else "无标题"}
{html_module.escape(desc) if desc else "暂无描述"}
{publish_date_html}
{f'
{points_html}
' if points_html else ''}
'''
return card_html
def generate_inspiration_card_html(
inspiration_data: Dict[str, Any],
inspiration_to_post_data: Dict[str, Any] = None,
category_index_data: Dict[str, Any] = None,
post_to_mapping_data: Dict[str, Any] = None
) -> str:
"""
生成单个灵感点的卡片HTML
Args:
inspiration_data: 灵感点数据
inspiration_to_post_data: 点到帖子映射数据
category_index_data: 分类索引数据
Returns:
HTML字符串
"""
summary = inspiration_data.get("summary", {})
step1 = inspiration_data.get("step1", {})
inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
search_results = inspiration_data.get("search_results", {})
# 提取关键指标
metrics = summary.get("关键指标", {})
step1_score = metrics.get("step1_top1_score", 0)
step1_match_element = metrics.get("step1_top1_匹配要素", "")
# 确定卡片颜色(基于Step1分数)
if step1_score >= 0.7:
border_color = "#10b981"
step1_color = "#10b981"
elif step1_score >= 0.5:
border_color = "#f59e0b"
step1_color = "#f59e0b"
elif step1_score >= 0.3:
border_color = "#3b82f6"
step1_color = "#3b82f6"
else:
border_color = "#ef4444"
step1_color = "#ef4444"
# 转义HTML
inspiration_name_escaped = html_module.escape(inspiration_name)
step1_match_element_escaped = html_module.escape(step1_match_element)
# 获取Step1匹配结果(简要展示)
step1_matches = step1.get("匹配结果列表", []) if step1 else []
step1_match_preview = ""
if step1_matches:
top_match = step1_matches[0]
# 从新的数据结构中提取信息
input_info = top_match.get("输入信息", {})
match_result = top_match.get("匹配结果", {})
element_name = input_info.get("A", "")
match_score = match_result.get("score", 0)
same_parts = match_result.get("相同部分", {}) or {}
increment_parts = match_result.get("增量部分", {}) or {}
# 生成相同部分和增量部分的HTML
parts_html = ""
if same_parts:
same_items = [f"{html_module.escape(k)}" for k in same_parts.keys()]
parts_html += f'相同: {", ".join(same_items)}
'
if increment_parts:
inc_items = [f"{html_module.escape(k)}" for k in increment_parts.keys()]
parts_html += f'增量: {", ".join(inc_items)}
'
step1_match_preview = f'''
{html_module.escape(element_name)}
{match_score:.2f}
{parts_html}
'''
# 提取 top3 匹配信息
top3_matches = []
if step1:
matches = step1.get("匹配结果列表", [])
for i, match in enumerate(matches[:3]):
input_info = match.get("输入信息", {})
match_result = match.get("匹配结果", {})
element_name = input_info.get("A", "")
match_score = match_result.get("score", 0)
context = input_info.get("A_Context", "")
# 解析层级关系
hierarchy = []
if context:
lines = context.split("\n")
for line in lines:
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key in ["所属视角", "一级分类", "二级分类"]:
hierarchy.append(value)
top3_matches.append({
"rank": i + 1,
"name": element_name,
"score": match_score,
"hierarchy": hierarchy
})
# 准备详细数据用于弹窗
detail_data_json = json.dumps(inspiration_data, ensure_ascii=False)
detail_data_json_escaped = html_module.escape(detail_data_json)
# top3 匹配数据
top3_json = json.dumps(top3_matches, ensure_ascii=False)
top3_json_escaped = html_module.escape(top3_json)
# 生成详细HTML并进行HTML转义
detail_html = generate_detail_html(inspiration_data)
detail_html_escaped = html_module.escape(detail_html)
# 生成匹配列表HTML
matches_html = ""
if step1:
step1_matches = step1.get("匹配结果列表", [])
for idx, match in enumerate(step1_matches):
input_info = match.get("输入信息", {})
match_result = match.get("匹配结果", {})
element_name = input_info.get("A", "")
context = input_info.get("A_Context", "")
score = match_result.get("score", 0)
score_explain = match_result.get("score说明", "") or ""
same_parts = match_result.get("相同部分", {}) or {}
increment_parts = match_result.get("增量部分", {}) or {}
# 为搜索结果容器生成唯一ID
safe_insp_name = ''.join(c if c.isalnum() else '_' for c in inspiration_name)
unique_match_id = f"{safe_insp_name}-match-{idx}"
# 解析层级
hierarchy = []
if context:
lines = context.split("\n")
for line in lines:
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key in ["所属视角", "一级分类", "二级分类"]:
hierarchy.append(value)
hierarchy_html = " › ".join([html_module.escape(h) for h in hierarchy]) if hierarchy else ""
rank_class = f"rank-{idx + 1}" if idx < 3 else ""
# 相同部分HTML
same_parts_html = ""
if same_parts:
same_items = "".join([f'{html_module.escape(k)}:{html_module.escape(v)}
' for k, v in same_parts.items()])
same_parts_html = f'''
'''
# 增量部分HTML
increment_parts_html = ""
if increment_parts:
inc_items = "".join([f'{html_module.escape(k)}:{html_module.escape(v)}
' for k, v in increment_parts.items()])
increment_parts_html = f'''
'''
# 生成搜索结果HTML(网格展示,图片轮播)
search_html = ""
if element_name in search_results:
result_obj = search_results[element_name]
search_data = result_obj.get("search_data", {})
match_data = result_obj.get("match_data", None)
search_params = search_data.get("search_params", {})
notes = search_data.get("notes", [])
notes_count = len(notes)
# 构建匹配分数字典 {channel_content_id: match_info}
match_scores = {}
if match_data and "匹配结果列表" in match_data:
for match_item in match_data["匹配结果列表"]:
business_info = match_item.get("业务信息", {})
input_info = match_item.get("输入信息", {})
content_id = business_info.get("channel_content_id", "")
if content_id:
match_scores[content_id] = {
"score": match_item.get("匹配结果", {}).get("score", 0),
"score说明": match_item.get("匹配结果", {}).get("score说明", "") or "",
"相同部分": match_item.get("匹配结果", {}).get("相同部分", {}) or {},
"增量部分": match_item.get("匹配结果", {}).get("增量部分", {}) or {},
"输入B": input_info.get("B", "") or "",
"输入A": input_info.get("A", "") or "",
"B_Context": input_info.get("B_Context", "") or "",
"A_Context": input_info.get("A_Context", "") or ""
}
# 为notes添加匹配分数、原始索引,并准备排序
notes_with_scores = []
for original_idx, note in enumerate(notes):
note_id = note.get("channel_content_id", "")
score_info = match_scores.get(note_id, None)
notes_with_scores.append({
"note": note,
"score_info": score_info,
"original_index": original_idx, # 原始搜索结果位置(0-based)
"page": (original_idx // 20) + 1, # 第几页(假设每页20条)
"position_in_page": (original_idx % 20) + 1 # 页内位置
})
# 默认按分数降序排序(没有分数的放到最后)
notes_with_scores.sort(key=lambda x: x["score_info"]["score"] if x["score_info"] else -1, reverse=True)
# 生成搜索参数HTML
search_params_html = ""
if search_params:
keyword = search_params.get("keyword", "")
content_type = search_params.get("content_type", "不限")
sort_type = search_params.get("sort_type", "综合")
publish_time = search_params.get("publish_time", "不限")
search_params_html = f'''
🔍 搜索参数
关键词:
{html_module.escape(keyword)}
内容类型:
{html_module.escape(content_type)}
排序方式:
{html_module.escape(sort_type)}
发布时间:
{html_module.escape(publish_time)}
'''
# 生成搜索结果统计HTML(带排序按钮)
search_summary_html = f'''
'''
if notes_count > 0:
notes_items = ""
for note_idx, item in enumerate(notes_with_scores): # 使用包含元数据的列表
note = item["note"]
score_info = item["score_info"]
original_index = item["original_index"]
page = item["page"]
position_in_page = item["position_in_page"]
title = note.get("title", "")
desc = note.get("desc", "")
link = note.get("link", "")
author = note.get("channel_account_name", "")
like_count = note.get("like_count", 0)
comment_count = note.get("comment_count", 0)
images = note.get("images", [])
content_id = note.get("channel_content_id", "")
publish_time = note.get("publish_time", "")
note_id = f"note-{idx}-{note_idx}"
# 生成图片轮播HTML
images_html = ""
if images and len(images) > 0:
images_track = "".join([f'
' for i, img in enumerate(images)])
# 图片导航按钮和指示点
nav_buttons = ""
dots_html = ""
if len(images) > 1:
nav_buttons = f'''
'''
dots = "".join([f'' for i in range(len(images))])
dots_html = f'{dots}
'
images_html = f'''
{images_track}
{nav_buttons}
{dots_html}
'''
else:
# 无图片时显示占位符
images_html = f'''
'''
# 准备详情数据
note_data = {
"title": title,
"desc": desc,
"link": link,
"author": author,
"like_count": like_count,
"comment_count": comment_count,
"images": images
}
note_data_json = json.dumps(note_data, ensure_ascii=False)
note_data_escaped = html_module.escape(note_data_json)
# 生成匹配分数HTML
score_badge_html = ""
score_detail_html = ""
if score_info:
note_score = score_info["score"]
note_score_explain = score_info.get("score说明", "") or ""
note_same_parts = score_info.get("相同部分", {}) or {}
note_increment_parts = score_info.get("增量部分", {}) or {}
input_b = score_info.get("输入B", "") or ""
input_a = score_info.get("输入A", "") or ""
b_context = score_info.get("B_Context", "") or ""
a_context = score_info.get("A_Context", "") or ""
# 分数详情JSON
score_detail_data = {
"score": note_score,
"score说明": note_score_explain,
"相同部分": note_same_parts,
"增量部分": note_increment_parts,
"输入B": input_b,
"输入A": input_a,
"B_Context": b_context,
"A_Context": a_context
}
score_detail_json = json.dumps(score_detail_data, ensure_ascii=False)
score_detail_escaped = html_module.escape(score_detail_json)
score_badge_html = f'''
匹配分数
{note_score:.2f}
'''
# 生成发布日期HTML
publish_date_html = ""
if publish_time:
publish_date_html = f'📅 {html_module.escape(publish_time)}
'
# 计算匹配分数(用于排序)
sort_score = score_info["score"] if score_info else -1
notes_items += f'''
{score_badge_html}
{images_html}
{html_module.escape(title) if title else "无标题"}
{html_module.escape(desc) if desc else "暂无描述"}
{publish_date_html}
'''
search_html = f'''
{search_params_html}
{search_summary_html}
{notes_items}
'''
else:
# 没有搜索结果时也显示参数
search_html = f'''
{search_params_html}
{search_summary_html}
'''
# 只有第一个匹配项默认展开
expanded_class = " expanded" if idx == 0 else ""
# 生成当前匹配项的灵感点和灵感分类详情
match_info_section = ""
if inspiration_to_post_data and category_index_data:
# 获取灵感点数据
inspiration_points = inspiration_to_post_data.get("点到帖子映射", {}).get("灵感点", {})
inspiration_info = inspiration_points.get(inspiration_name, {})
# 获取当前匹配的灵感分类数据
categories = category_index_data.get("灵感分类", {})
category_info = categories.get(element_name, {})
# 生成灵感点详情HTML(左栏)
insp_detail_html = ""
if inspiration_info:
insp_dimension = inspiration_info.get("维度", "")
insp_desc = inspiration_info.get("描述", "")
insp_posts = inspiration_info.get("帖子详情列表", [])
# 生成帖子卡片
insp_posts_html = ""
for post in insp_posts[:6]: # 最多显示6个
insp_posts_html += generate_post_card_html(post, f"{unique_match_id}-insp-post", post_to_mapping_data)
insp_detail_html = f'''
{f'
维度:{html_module.escape(insp_dimension)}
' if insp_dimension else ''}
{f'
描述:{html_module.escape(insp_desc)}
' if insp_desc else ''}
{f'
' if insp_posts_html else ''}
'''
# 生成灵感分类详情HTML(右栏)
cat_detail_html = ""
if category_info:
cat_level = category_info.get("分类层级", "")
cat_definition = category_info.get("分类定义", "")
cat_posts = category_info.get("帖子详情列表", [])
# 生成帖子卡片
cat_posts_html = ""
for post in cat_posts[:6]: # 最多显示6个
cat_posts_html += generate_post_card_html(post, f"{unique_match_id}-cat-post", post_to_mapping_data)
cat_detail_html = f'''
{f'
层级:{html_module.escape(cat_level)}
' if cat_level else ''}
{f'
定义:{html_module.escape(cat_definition)}
' if cat_definition else ''}
{f'
' if cat_posts_html else ''}
'''
if insp_detail_html or cat_detail_html:
match_info_section = f'''
{insp_detail_html}
{cat_detail_html}
'''
# 步骤1:灵感点匹配灵感分类
step1_html = f'''
{same_parts_html}
{increment_parts_html}
{f'
💡 分数说明
{html_module.escape(score_explain)}
' if score_explain else ''}
'''
# 步骤2:搜索结果(如果有)
step2_html = ""
if search_html:
step2_html = f'''
'''
# 创建安全的ID(移除特殊字符)
safe_element_id = ''.join(c if c.isalnum() or c in '_-' else '_' for c in element_name)
matches_html += f'''
{match_info_section}
{step1_html}
{step2_html}
'''
# 获取top1匹配的灵感分类名称(用于顶部标题)
top1_category_name = ""
if step1:
step1_matches = step1.get("匹配结果列表", [])
if step1_matches:
top_match = step1_matches[0]
input_info = top_match.get("输入信息", {})
top1_category_name = input_info.get("A", "")
html = f'''
[灵感点] {inspiration_name_escaped}
'''
return html
def generate_detail_html(inspiration_data: Dict[str, Any]) -> str:
"""
生成灵感点的详细信息HTML
Args:
inspiration_data: 灵感点数据
Returns:
详细信息的HTML字符串
"""
import html as html_module
summary = inspiration_data.get("summary", {})
step1 = inspiration_data.get("step1", {})
step2 = inspiration_data.get("step2", {})
inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
content = f'''
'''
# 获取元数据,用于后面的日志链接
metadata = summary.get("元数据", {})
# Step1 详细信息
if step1 and step1.get("灵感"):
inspiration = step1.get("灵感", "")
matches = step1.get("匹配结果列表", [])
content += f'''
🎯 Step1: 灵感人设匹配
灵感内容:
{html_module.escape(inspiration)}
'''
# 显示匹配结果(只显示Top1)
if matches:
content += f'''
Top1匹配结果:
'''
for index, match in enumerate(matches[:1]):
input_info = match.get("输入信息", {})
match_result = match.get("匹配结果", {})
element_a = input_info.get("A", "")
context_a = input_info.get("A_Context", "")
score = match_result.get("score", 0)
score_explain = match_result.get("score说明", "") or ""
same_parts = match_result.get("相同部分", {}) or {}
increment_parts = match_result.get("增量部分", {}) or {}
content += f'''
'''
if context_a:
content += f'
📍 所属分类: {html_module.escape(context_a).replace(chr(10), "
")}
'
if score_explain:
content += f'
💡 分数说明: {html_module.escape(score_explain)}
'
# 相同部分
if same_parts:
content += '''
'''
for key, value in same_parts.items():
content += f'''
{html_module.escape(key)}:
{html_module.escape(value)}
'''
content += '''
'''
# 增量部分
if increment_parts:
content += '''
'''
for key, value in increment_parts.items():
content += f'''
{html_module.escape(key)}:
{html_module.escape(value)}
'''
content += '''
'''
content += '''
'''
content += '''
'''
content += '''
'''
# 日志链接
if metadata.get("log_url"):
content += f'''
'''
return content
def generate_detail_modal_content_js() -> str:
"""
生成详情弹窗内容的JavaScript函数
Returns:
JavaScript代码字符串
"""
return '''
// 笔记图片当前索引管理
const noteImageStates = {};
// 移动笔记图片
function moveNoteImage(noteId, direction) {
if (!noteImageStates[noteId]) {
noteImageStates[noteId] = 0;
}
const carousel = document.querySelector(`[data-note-id="${noteId}"]`);
if (!carousel) return;
const totalImages = parseInt(carousel.dataset.totalImages);
const track = document.getElementById(noteId + '-track');
if (!track) return;
let newIndex = noteImageStates[noteId] + direction;
if (newIndex < 0) newIndex = 0;
if (newIndex >= totalImages) newIndex = totalImages - 1;
noteImageStates[noteId] = newIndex;
// 移动轨道
track.style.transform = `translateX(-${newIndex * 100}%)`;
// 更新指示点
const dots = document.querySelectorAll(`#${noteId}-dots .note-image-dot`);
dots.forEach((dot, i) => {
dot.classList.toggle('active', i === newIndex);
});
// 更新按钮状态
const prevBtn = carousel.querySelector('.note-carousel-button.prev');
const nextBtn = carousel.querySelector('.note-carousel-button.next');
if (prevBtn) {
prevBtn.classList.toggle('disabled', newIndex === 0);
}
if (nextBtn) {
nextBtn.classList.toggle('disabled', newIndex >= totalImages - 1);
}
}
// 显示笔记详情
function showNoteDetail(element) {
const noteDataStr = element.dataset.noteData;
if (!noteDataStr) return;
try {
const noteData = JSON.parse(noteDataStr);
// 生成图片HTML
let imagesHtml = '';
if (noteData.images && noteData.images.length > 0) {
imagesHtml = noteData.images.map(img =>
`
`
).join('');
} else {
imagesHtml = '暂无图片
';
}
// 生成灵感点、关键点、目的点HTML
let pointsDetailHtml = '';
const hasPoints = (noteData.inspiration_points && noteData.inspiration_points.length > 0) ||
(noteData.key_points && noteData.key_points.length > 0) ||
(noteData.purpose_points && noteData.purpose_points.length > 0);
if (hasPoints) {
let sections = [];
// 灵感点
if (noteData.inspiration_points && noteData.inspiration_points.length > 0) {
const items = noteData.inspiration_points.map(p =>
`
${p.灵感点 || ''}
${p.描述 ? `
${p.描述}
` : ''}
`
).join('');
sections.push(``);
}
// 关键点
if (noteData.key_points && noteData.key_points.length > 0) {
const items = noteData.key_points.map(k =>
`
${k.关键点 || ''}
${k.描述 ? `
${k.描述}
` : ''}
`
).join('');
sections.push(``);
}
// 目的点
if (noteData.purpose_points && noteData.purpose_points.length > 0) {
const items = noteData.purpose_points.map(p =>
`
${p.目的点 || ''}
${p.描述 ? `
${p.描述}
` : ''}
`
).join('');
sections.push(``);
}
pointsDetailHtml = `${sections.join('')}
`;
}
const modalHtml = `
${noteData.desc ? `
${noteData.desc}
` : ''}
${pointsDetailHtml}
${imagesHtml}
`;
let modal = document.getElementById('noteDetailModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'noteDetailModal';
modal.className = 'note-detail-modal';
modal.onclick = (e) => {
if (e.target === modal) closeNoteDetail();
};
document.body.appendChild(modal);
}
modal.innerHTML = modalHtml;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
} catch (e) {
console.error('Error parsing note data:', e);
}
}
// 关闭笔记详情
function closeNoteDetail() {
const modal = document.getElementById('noteDetailModal');
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
// 显示分数详情
function showScoreDetail(element) {
const scoreDetailStr = element.dataset.scoreDetail;
if (!scoreDetailStr) return;
try {
const scoreData = JSON.parse(scoreDetailStr);
const modal = document.getElementById('scoreDetailModal');
const modalBody = document.getElementById('scoreModalBody');
// 生成相同部分HTML
let samePartsHTML = '';
if (scoreData.相同部分 && Object.keys(scoreData.相同部分).length > 0) {
const sameItems = Object.entries(scoreData.相同部分).map(([key, value]) =>
`${key}:${value}
`
).join('');
samePartsHTML = `
`;
}
// 生成增量部分HTML
let incrementPartsHTML = '';
if (scoreData.增量部分 && Object.keys(scoreData.增量部分).length > 0) {
const incItems = Object.entries(scoreData.增量部分).map(([key, value]) =>
`${key}:${value}
`
).join('');
incrementPartsHTML = `
`;
}
// 生成分数说明HTML
let explainHTML = '';
if (scoreData.score说明) {
explainHTML = `
💡 分数说明
${scoreData.score说明}
`;
}
// 生成输入信息HTML
let inputInfoHTML = '';
if (scoreData.输入B || scoreData.输入A) {
inputInfoHTML = `
`;
}
modalBody.innerHTML = `
匹配分数详情
匹配分数:
${scoreData.score.toFixed(2)}
${inputInfoHTML}
${explainHTML}
${samePartsHTML}
${incrementPartsHTML}
`;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
} catch (e) {
console.error('Failed to parse score detail:', e);
}
}
// 关闭分数详情
function closeScoreDetail() {
const modal = document.getElementById('scoreDetailModal');
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
// 点击Modal背景关闭分数详情
function closeScoreDetailModal(event) {
if (event.target.id === 'scoreDetailModal') {
closeScoreDetail();
}
}
// 搜索结果排序
function sortSearchResults(button, containerId) {
const sortType = button.dataset.sort;
const container = document.getElementById(containerId);
if (!container) return;
// 更新按钮状态
const allButtons = button.parentElement.querySelectorAll('.search-sort-btn');
allButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// 获取所有搜索结果卡片
const items = Array.from(container.querySelectorAll('.search-note-item'));
// 根据排序类型排序
items.sort((a, b) => {
if (sortType === 'score') {
const scoreA = parseFloat(a.dataset.score) || -1;
const scoreB = parseFloat(b.dataset.score) || -1;
return scoreB - scoreA; // 降序
} else if (sortType === 'original') {
const indexA = parseInt(a.dataset.originalIndex) || 0;
const indexB = parseInt(b.dataset.originalIndex) || 0;
return indexA - indexB; // 升序
} else if (sortType === 'likes') {
const likesA = parseInt(a.dataset.likes) || 0;
const likesB = parseInt(b.dataset.likes) || 0;
return likesB - likesA; // 降序
}
return 0;
});
// 重新排列DOM
items.forEach(item => container.appendChild(item));
}
// ESC键关闭详情
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeNoteDetail();
closeScoreDetail();
}
});
// 切换主匹配项的展开/折叠
function toggleMainMatch(element) {
const matchItem = element.closest('.match-item');
matchItem.classList.toggle('expanded');
}
// 切换步骤wrapper的展开/折叠
function toggleStepWrapper(element) {
const stepWrapper = element.closest('.step-section-wrapper');
stepWrapper.classList.toggle('expanded');
}
// 切换步骤的展开/折叠
function toggleStep(element) {
const stepSection = element.closest('.step-section');
stepSection.classList.toggle('expanded');
}
// 切换匹配详情的展开/折叠
function toggleMatchSection(element) {
const matchSection = element.closest('.match-section');
matchSection.classList.toggle('expanded');
}
// 显示指定的灵感详情
function showDetail(index) {
const details = document.querySelectorAll('.inspiration-detail');
details.forEach((detail, i) => {
if (i === index) {
detail.classList.add('active');
// 滚动到顶部
const section = document.querySelector('.inspirations-section');
if (section) {
section.scrollTop = 0;
}
} else {
detail.classList.remove('active');
}
});
}
// 生成导航目录
function generateNavigation() {
const details = document.querySelectorAll('.inspiration-detail');
const navList = document.getElementById('navList');
navList.innerHTML = '';
details.forEach((detail, index) => {
const name = detail.dataset.inspirationName;
const score = parseFloat(detail.dataset.step1Score) || 0;
const top3MatchesStr = detail.dataset.top3Matches;
let top3Matches = [];
try {
top3Matches = JSON.parse(top3MatchesStr);
} catch(e) {
console.error('Error parsing top3 matches:', e);
}
const navItem = document.createElement('div');
navItem.className = 'nav-item';
if (index === 0) navItem.classList.add('active');
navItem.dataset.cardIndex = index;
// 生成匹配列表HTML
let matchesHtml = '';
if (top3Matches && top3Matches.length > 0) {
matchesHtml = '';
top3Matches.forEach((match, i) => {
// 生成层级路径
let hierarchyHtml = '';
if (match.hierarchy && match.hierarchy.length > 0) {
hierarchyHtml = '
';
match.hierarchy.forEach((level, idx) => {
if (idx > 0) {
hierarchyHtml += '›';
}
hierarchyHtml += `${level}`;
});
hierarchyHtml += '
';
}
matchesHtml += `
Top${match.rank}
${match.name}
${hierarchyHtml}
${match.score.toFixed(2)}
`;
});
matchesHtml += '
';
}
navItem.innerHTML = `
${matchesHtml}
`;
navItem.addEventListener('click', () => {
// 移除所有active状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
// 添加当前active状态
navItem.classList.add('active');
// 显示对应详情
showDetail(index);
});
navList.appendChild(navItem);
});
// 默认显示第一个详情
if (details.length > 0) {
showDetail(0);
}
}
// 更新面包屑
function updateBreadcrumb(matchName, stepName) {
const breadcrumb = document.querySelector('.inspiration-detail.active #dynamicBreadcrumb');
if (!breadcrumb) return;
const inspirationName = document.querySelector('.inspiration-detail.active').dataset.inspirationName;
let breadcrumbHtml = `
[灵感点] ${inspirationName}
`;
if (matchName) {
breadcrumbHtml += `
›
[灵感分类] ${matchName}
`;
}
if (stepName) {
breadcrumbHtml += `
›
${stepName}
`;
}
breadcrumb.innerHTML = breadcrumbHtml;
}
// 监听滚动,更新面包屑
function setupBreadcrumbObserver() {
const activeDetail = document.querySelector('.inspiration-detail.active');
if (!activeDetail) return;
const contentWrapper = activeDetail.querySelector('.inspiration-content-wrapper');
if (!contentWrapper) return;
// 获取所有需要监听的section
const sections = activeDetail.querySelectorAll('.step-section');
if (sections.length === 0) return;
// 创建Intersection Observer
const observerOptions = {
root: null,
rootMargin: '-100px 0px -50% 0px',
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const section = entry.target;
const matchItem = section.closest('.match-item');
const matchName = matchItem ? matchItem.dataset.matchName : '';
const stepName = section.dataset.stepName || '';
updateBreadcrumb(matchName, stepName);
}
});
}, observerOptions);
// 观察所有section
sections.forEach(section => observer.observe(section));
// 存储observer以便清理
if (!window.breadcrumbObservers) {
window.breadcrumbObservers = [];
}
window.breadcrumbObservers.push(observer);
}
// 页面加载时生成导航
document.addEventListener('DOMContentLoaded', () => {
generateNavigation();
setupBreadcrumbObserver();
});
// 当切换灵感点时,重新设置observer
const originalShowDetail = showDetail;
showDetail = function(index) {
// 清理旧的observers
if (window.breadcrumbObservers) {
window.breadcrumbObservers.forEach(obs => obs.disconnect());
window.breadcrumbObservers = [];
}
// 调用原始函数
originalShowDetail(index);
// 设置新的observer
setTimeout(() => {
setupBreadcrumbObserver();
}, 100);
};
'''
def generate_persona_structure_html(persona_data: Dict[str, Any]) -> str:
"""
生成人设结构的树状HTML
Args:
persona_data: 人设数据
Returns:
人设结构的HTML字符串
"""
if not persona_data:
return '暂无人设数据
'
inspiration_list = persona_data.get("灵感点列表", [])
if not inspiration_list:
return '暂无灵感点列表数据
'
html_parts = ['']
for perspective_idx, perspective in enumerate(inspiration_list):
perspective_name = perspective.get("视角名称", "未知视角")
perspective_desc = perspective.get("视角描述", "")
pattern_list = perspective.get("模式列表", [])
# 一级节点:视角
html_parts.append(f'''
')
html_parts.append('
')
return ''.join(html_parts)
def generate_html(
inspirations_data: List[Dict[str, Any]],
posts_map: Dict[str, Dict[str, Any]],
persona_data: Dict[str, Any],
output_path: str,
inspiration_to_post_data: Dict[str, Any] = None,
category_index_data: Dict[str, Any] = None,
post_to_mapping_data: Dict[str, Any] = None
) -> str:
"""
生成完整的可视化HTML
Args:
inspirations_data: 灵感点数据列表
posts_map: 帖子数据映射
persona_data: 人设数据
output_path: 输出文件路径
Returns:
输出文件路径
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 统计信息
total_count = len(inspirations_data)
# Step1 统计
step1_excellent_count = sum(1 for d in inspirations_data
if d["summary"].get("关键指标", {}).get("step1_top1_score", 0) >= 0.7)
step1_good_count = sum(1 for d in inspirations_data
if 0.5 <= d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.7)
step1_normal_count = sum(1 for d in inspirations_data
if 0.3 <= d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.5)
step1_need_opt_count = sum(1 for d in inspirations_data
if d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.3)
# 平均分数
total_step1_score = sum(d["summary"].get("关键指标", {}).get("step1_top1_score", 0)
for d in inspirations_data)
avg_step1_score = total_step1_score / total_count if total_count > 0 else 0
# 按Step1分数排序
inspirations_data_sorted = sorted(
inspirations_data,
key=lambda x: x["summary"].get("关键指标", {}).get("step1_top1_score", 0),
reverse=True
)
# 生成卡片HTML
cards_html = [
generate_inspiration_card_html(data, inspiration_to_post_data, category_index_data, post_to_mapping_data)
for data in inspirations_data_sorted
]
cards_html_str = '\n'.join(cards_html)
# 生成人设结构HTML
persona_structure_html = generate_persona_structure_html(persona_data)
# 生成JavaScript
detail_modal_js = generate_detail_modal_content_js()
# 完整HTML
html_content = f'''
灵感点分析可视化
'''
# 写入文件
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)
return str(output_file.absolute())
def load_persona_data(persona_path: str) -> Dict[str, Any]:
"""
加载人设数据
Args:
persona_path: 人设JSON文件路径
Returns:
人设数据字典
"""
try:
with open(persona_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"警告: 读取人设文件失败: {e}")
return {}
def main():
"""主函数"""
import sys
import os
# 配置路径(使用当前脚本的相对路径)
script_dir = os.path.dirname(os.path.abspath(__file__))
base_dir = os.path.join(script_dir, "data/阿里多多酱")
inspiration_dir = os.path.join(base_dir, "out/人设_1110/how/灵感点")
posts_dir = os.path.join(base_dir, "作者历史帖子")
persona_path = os.path.join(base_dir, "out/人设_1110/人设.json")
inspiration_to_post_path = os.path.join(base_dir, "out/人设_1110/点到帖子映射.json")
category_index_path = os.path.join(base_dir, "out/人设_1110/分类索引_完整.json")
post_to_mapping_path = os.path.join(base_dir, "out/人设_1110/帖子到分类和点映射.json")
output_path = os.path.join(base_dir, "out/人设_1110/how/灵感点可视化.html")
print("=" * 60)
print("灵感点分析可视化脚本")
print("=" * 60)
# 加载数据
print("\n📂 正在加载灵感点数据...")
inspirations_data = load_inspiration_points_data(inspiration_dir)
print(f"✅ 成功加载 {len(inspirations_data)} 个灵感点")
print("\n📂 正在加载帖子数据...")
posts_map = load_posts_data(posts_dir)
print(f"✅ 成功加载 {len(posts_map)} 个帖子")
print("\n📂 正在加载人设数据...")
persona_data = load_persona_data(persona_path)
print(f"✅ 成功加载人设数据")
print("\n📂 正在加载点到帖子映射数据...")
with open(inspiration_to_post_path, 'r', encoding='utf-8') as f:
inspiration_to_post_data = json.load(f)
print(f"✅ 成功加载点到帖子映射数据")
print("\n📂 正在加载分类索引数据...")
with open(category_index_path, 'r', encoding='utf-8') as f:
category_index_data = json.load(f)
print(f"✅ 成功加载分类索引数据")
print("\n📂 正在加载帖子到分类和点映射数据...")
with open(post_to_mapping_path, 'r', encoding='utf-8') as f:
post_to_mapping_data = json.load(f)
print(f"✅ 成功加载帖子到分类和点映射数据")
# 生成HTML
print("\n🎨 正在生成可视化HTML...")
result_path = generate_html(
inspirations_data,
posts_map,
persona_data,
output_path,
inspiration_to_post_data,
category_index_data,
post_to_mapping_data
)
print(f"\n✅ 可视化文件已生成!")
print(f"📄 文件路径: {result_path}")
print(f"\n💡 在浏览器中打开该文件即可查看可视化结果")
print("=" * 60)
if __name__ == "__main__":
main()