visualize_inspiration_points.py 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068
  1. """
  2. 灵感点分析结果可视化脚本
  3. 读取 how/灵感点 目录下的分析结果,结合作者历史帖子详情,生成可视化HTML页面
  4. """
  5. import json
  6. from pathlib import Path
  7. from typing import Dict, Any, List, Optional
  8. from datetime import datetime
  9. import html as html_module
  10. def load_inspiration_points_data(inspiration_dir: str) -> List[Dict[str, Any]]:
  11. """
  12. 加载所有灵感点的分析结果
  13. Args:
  14. inspiration_dir: 灵感点目录路径
  15. Returns:
  16. 灵感点分析结果列表
  17. """
  18. inspiration_path = Path(inspiration_dir)
  19. results = []
  20. # 遍历所有子目录
  21. for subdir in inspiration_path.iterdir():
  22. if subdir.is_dir():
  23. # 查找 all_summary 文件
  24. summary_files = list(subdir.glob("all_summary_*.json"))
  25. if summary_files:
  26. summary_file = summary_files[0]
  27. try:
  28. with open(summary_file, 'r', encoding='utf-8') as f:
  29. data = json.load(f)
  30. # 加载完整的 step1 和 step2 数据
  31. step1_data = None
  32. step2_data = None
  33. # 直接从当前子目录查找 step1 和 step2 文件
  34. step1_files = list(subdir.glob("all_step1_*.json"))
  35. step2_files = list(subdir.glob("all_step2_*.json"))
  36. if step1_files:
  37. try:
  38. with open(step1_files[0], 'r', encoding='utf-8') as f:
  39. step1_data = json.load(f)
  40. except Exception as e:
  41. print(f"警告: 读取 {step1_files[0]} 失败: {e}")
  42. if step2_files:
  43. try:
  44. with open(step2_files[0], 'r', encoding='utf-8') as f:
  45. step2_data = json.load(f)
  46. except Exception as e:
  47. print(f"警告: 读取 {step2_files[0]} 失败: {e}")
  48. results.append({
  49. "summary": data,
  50. "step1": step1_data,
  51. "step2": step2_data,
  52. "inspiration_name": subdir.name
  53. })
  54. except Exception as e:
  55. print(f"警告: 读取 {summary_file} 失败: {e}")
  56. return results
  57. def load_posts_data(posts_dir: str) -> Dict[str, Dict[str, Any]]:
  58. """
  59. 加载所有帖子详情数据
  60. Args:
  61. posts_dir: 帖子目录路径
  62. Returns:
  63. 帖子ID到帖子详情的映射
  64. """
  65. posts_path = Path(posts_dir)
  66. posts_map = {}
  67. for post_file in posts_path.glob("*.json"):
  68. try:
  69. with open(post_file, 'r', encoding='utf-8') as f:
  70. post_data = json.load(f)
  71. post_id = post_data.get("channel_content_id")
  72. if post_id:
  73. posts_map[post_id] = post_data
  74. except Exception as e:
  75. print(f"警告: 读取 {post_file} 失败: {e}")
  76. return posts_map
  77. def generate_inspiration_card_html(inspiration_data: Dict[str, Any]) -> str:
  78. """
  79. 生成单个灵感点的卡片HTML
  80. Args:
  81. inspiration_data: 灵感点数据
  82. Returns:
  83. HTML字符串
  84. """
  85. summary = inspiration_data.get("summary", {})
  86. step1 = inspiration_data.get("step1", {})
  87. step2 = inspiration_data.get("step2", {})
  88. inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
  89. # 提取关键指标
  90. metrics = summary.get("关键指标", {})
  91. step1_score = metrics.get("step1_top1_score", 0)
  92. step2_score = metrics.get("step2_score", 0)
  93. step1_match_element = metrics.get("step1_top1_匹配要素", "")
  94. step2_increment_count = metrics.get("step2_增量词数量", 0)
  95. # 确定卡片颜色(基于Step1分数)
  96. if step1_score >= 0.7:
  97. border_color = "#10b981"
  98. step1_color = "#10b981"
  99. elif step1_score >= 0.5:
  100. border_color = "#f59e0b"
  101. step1_color = "#f59e0b"
  102. elif step1_score >= 0.3:
  103. border_color = "#3b82f6"
  104. step1_color = "#3b82f6"
  105. else:
  106. border_color = "#ef4444"
  107. step1_color = "#ef4444"
  108. # Step2颜色
  109. if step2_score >= 0.7:
  110. step2_color = "#10b981"
  111. elif step2_score >= 0.5:
  112. step2_color = "#f59e0b"
  113. elif step2_score >= 0.3:
  114. step2_color = "#3b82f6"
  115. else:
  116. step2_color = "#ef4444"
  117. # 转义HTML
  118. inspiration_name_escaped = html_module.escape(inspiration_name)
  119. step1_match_element_escaped = html_module.escape(step1_match_element)
  120. # 获取Step1匹配结果(简要展示)
  121. step1_matches = step1.get("匹配结果列表", []) if step1 else []
  122. step1_match_preview = ""
  123. if step1_matches:
  124. top_match = step1_matches[0]
  125. # 从新的数据结构中提取信息
  126. input_info = top_match.get("输入信息", {})
  127. match_result = top_match.get("匹配结果", {})
  128. element_name = input_info.get("A", "")
  129. match_score = match_result.get("score", 0)
  130. same_parts = match_result.get("相同部分", {})
  131. increment_parts = match_result.get("增量部分", {})
  132. # 生成相同部分和增量部分的HTML
  133. parts_html = ""
  134. if same_parts:
  135. same_items = [f"{html_module.escape(k)}" for k in same_parts.keys()]
  136. parts_html += f'<div class="preview-parts same"><strong>相同:</strong> {", ".join(same_items)}</div>'
  137. if increment_parts:
  138. inc_items = [f"{html_module.escape(k)}" for k in increment_parts.keys()]
  139. parts_html += f'<div class="preview-parts increment"><strong>增量:</strong> {", ".join(inc_items)}</div>'
  140. step1_match_preview = f'''
  141. <div class="match-preview">
  142. <div class="match-preview-header">🎯 Step1 Top1匹配</div>
  143. <div class="match-preview-content">
  144. <span class="match-preview-name">{html_module.escape(element_name)}</span>
  145. <span class="match-preview-score" style="color: {step1_color};">{match_score:.2f}</span>
  146. </div>
  147. {parts_html}
  148. </div>
  149. '''
  150. # 获取Step2匹配结果(简要展示)
  151. step2_match_preview = ""
  152. if step2:
  153. input_info = step2.get("输入信息", {})
  154. match_result = step2.get("匹配结果", {})
  155. increment_word = input_info.get("B", "")
  156. match_score = match_result.get("score", 0)
  157. same_parts = match_result.get("相同部分", {})
  158. increment_parts = match_result.get("增量部分", {})
  159. # 只有当增量词不为空时才显示
  160. if increment_word.strip():
  161. # 生成相同部分和增量部分的HTML
  162. parts_html = ""
  163. if same_parts:
  164. same_items = [f"{html_module.escape(k)}" for k in same_parts.keys()]
  165. parts_html += f'<div class="preview-parts same"><strong>相同:</strong> {", ".join(same_items)}</div>'
  166. if increment_parts:
  167. inc_items = [f"{html_module.escape(k)}" for k in increment_parts.keys()]
  168. parts_html += f'<div class="preview-parts increment"><strong>增量:</strong> {", ".join(inc_items)}</div>'
  169. step2_match_preview = f'''
  170. <div class="match-preview">
  171. <div class="match-preview-header">➕ Step2 Top1增量词</div>
  172. <div class="match-preview-content">
  173. <span class="match-preview-name">{html_module.escape(increment_word)}</span>
  174. <span class="match-preview-score" style="color: {step2_color};">{match_score:.2f}</span>
  175. </div>
  176. {parts_html}
  177. </div>
  178. '''
  179. # 准备详细数据用于弹窗
  180. detail_data_json = json.dumps(inspiration_data, ensure_ascii=False)
  181. detail_data_json_escaped = html_module.escape(detail_data_json)
  182. # 生成详细HTML并进行HTML转义
  183. detail_html = generate_detail_html(inspiration_data)
  184. detail_html_escaped = html_module.escape(detail_html)
  185. html = f'''
  186. <div class="inspiration-card" style="border-left-color: {border_color};"
  187. data-inspiration-name="{inspiration_name_escaped}"
  188. data-detail="{detail_data_json_escaped}"
  189. data-detail-html="{detail_html_escaped}"
  190. data-step1-score="{step1_score}"
  191. data-step2-score="{step2_score}"
  192. onclick="showInspirationDetail(this)">
  193. <div class="card-header">
  194. <h3 class="inspiration-name">{inspiration_name_escaped}</h3>
  195. </div>
  196. <div class="score-section">
  197. <div class="score-item">
  198. <div class="score-label">Step1分数</div>
  199. <div class="score-value" style="color: {step1_color};">{step1_score:.3f}</div>
  200. </div>
  201. <div class="score-divider"></div>
  202. <div class="score-item">
  203. <div class="score-label">Step2分数</div>
  204. <div class="score-value" style="color: {step2_color};">{step2_score:.3f}</div>
  205. </div>
  206. </div>
  207. {step1_match_preview}
  208. {step2_match_preview}
  209. <div class="metrics-section">
  210. <div class="metric-item">
  211. <span class="metric-icon">📊</span>
  212. <span class="metric-label">增量词数:</span>
  213. <span class="metric-value">{step2_increment_count}</span>
  214. </div>
  215. </div>
  216. <div class="click-hint">点击查看详情 →</div>
  217. </div>
  218. '''
  219. return html
  220. def generate_detail_html(inspiration_data: Dict[str, Any]) -> str:
  221. """
  222. 生成灵感点的详细信息HTML
  223. Args:
  224. inspiration_data: 灵感点数据
  225. Returns:
  226. 详细信息的HTML字符串
  227. """
  228. import html as html_module
  229. summary = inspiration_data.get("summary", {})
  230. step1 = inspiration_data.get("step1", {})
  231. step2 = inspiration_data.get("step2", {})
  232. inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
  233. content = f'''
  234. <div class="modal-header">
  235. <h2 class="modal-title">{html_module.escape(inspiration_name)}</h2>
  236. </div>
  237. '''
  238. # 获取元数据,用于后面的日志链接
  239. metadata = summary.get("元数据", {})
  240. # Step1 详细信息
  241. if step1 and step1.get("灵感"):
  242. inspiration = step1.get("灵感", "")
  243. matches = step1.get("匹配结果列表", [])
  244. content += f'''
  245. <div class="modal-section">
  246. <h3>🎯 Step1: 灵感人设匹配</h3>
  247. <div class="step-content">
  248. <div class="step-field">
  249. <span class="step-field-label">灵感内容:</span>
  250. <span class="step-field-value">{html_module.escape(inspiration)}</span>
  251. </div>
  252. '''
  253. # 显示匹配结果(只显示Top1)
  254. if matches:
  255. content += f'''
  256. <div class="step-field">
  257. <span class="step-field-label">Top1匹配结果:</span>
  258. <div class="matches-list">
  259. '''
  260. for index, match in enumerate(matches[:1]):
  261. input_info = match.get("输入信息", {})
  262. match_result = match.get("匹配结果", {})
  263. element_a = input_info.get("A", "")
  264. context_a = input_info.get("A_Context", "")
  265. score = match_result.get("score", 0)
  266. score_explain = match_result.get("score说明", "")
  267. same_parts = match_result.get("相同部分", {})
  268. increment_parts = match_result.get("增量部分", {})
  269. content += f'''
  270. <div class="match-item">
  271. <div class="match-header">
  272. <span class="match-element-name">{html_module.escape(element_a)}</span>
  273. <span class="match-score">{score:.2f}</span>
  274. </div>
  275. '''
  276. if context_a:
  277. content += f'<div class="match-context"><strong>📍 所属分类:</strong> {html_module.escape(context_a).replace(chr(10), "<br>")}</div>'
  278. if score_explain:
  279. content += f'<div class="match-explain"><strong>💡 分数说明:</strong> {html_module.escape(score_explain)}</div>'
  280. # 相同部分
  281. if same_parts:
  282. content += '''
  283. <div class="match-parts same-parts">
  284. <div class="parts-header">✅ 相同部分</div>
  285. <div class="parts-content">
  286. '''
  287. for key, value in same_parts.items():
  288. content += f'''
  289. <div class="part-item">
  290. <span class="part-key">{html_module.escape(key)}:</span>
  291. <span class="part-value">{html_module.escape(value)}</span>
  292. </div>
  293. '''
  294. content += '''
  295. </div>
  296. </div>
  297. '''
  298. # 增量部分
  299. if increment_parts:
  300. content += '''
  301. <div class="match-parts increment-parts">
  302. <div class="parts-header">➕ 增量部分</div>
  303. <div class="parts-content">
  304. '''
  305. for key, value in increment_parts.items():
  306. content += f'''
  307. <div class="part-item">
  308. <span class="part-key">{html_module.escape(key)}:</span>
  309. <span class="part-value">{html_module.escape(value)}</span>
  310. </div>
  311. '''
  312. content += '''
  313. </div>
  314. </div>
  315. '''
  316. content += '''
  317. </div>
  318. '''
  319. content += '''
  320. </div>
  321. </div>
  322. '''
  323. content += '''
  324. </div>
  325. </div>
  326. '''
  327. # Step2 详细信息
  328. if step2 and step2.get("灵感"):
  329. input_info = step2.get("输入信息", {})
  330. match_result = step2.get("匹配结果", {})
  331. increment_word = input_info.get("B", "")
  332. b_context = input_info.get("B_Context", "")
  333. score = match_result.get("score", 0)
  334. score_explain = match_result.get("score说明", "")
  335. same_parts = match_result.get("相同部分", {})
  336. increment_parts = match_result.get("增量部分", {})
  337. content += '''
  338. <div class="modal-section">
  339. <h3>➕ Step2: 增量词匹配</h3>
  340. <div class="step-content">
  341. '''
  342. if increment_word.strip():
  343. content += f'''
  344. <div class="step-field">
  345. <span class="step-field-label">增量词:</span>
  346. <span class="step-field-value">{html_module.escape(increment_word)}</span>
  347. </div>
  348. '''
  349. if b_context:
  350. content += f'''
  351. <div class="increment-context">
  352. <strong>📌 增量词来源:</strong> {html_module.escape(b_context)}
  353. </div>
  354. '''
  355. content += f'''
  356. <div class="increment-item">
  357. <div class="increment-header">
  358. <span class="increment-words">分数</span>
  359. <span class="increment-score">{score:.2f}</span>
  360. </div>
  361. '''
  362. if score_explain:
  363. content += f'''
  364. <div class="match-explain">
  365. <strong>💡 分数说明:</strong> {html_module.escape(score_explain)}
  366. </div>
  367. '''
  368. # 相同部分
  369. if same_parts:
  370. content += '''
  371. <div class="match-parts same-parts">
  372. <div class="parts-header">✅ 相同部分</div>
  373. <div class="parts-content">
  374. '''
  375. for key, value in same_parts.items():
  376. content += f'''
  377. <div class="part-item">
  378. <span class="part-key">{html_module.escape(key)}:</span>
  379. <span class="part-value">{html_module.escape(value)}</span>
  380. </div>
  381. '''
  382. content += '''
  383. </div>
  384. </div>
  385. '''
  386. # 增量部分
  387. if increment_parts:
  388. content += '''
  389. <div class="match-parts increment-parts">
  390. <div class="parts-header">➕ 增量部分</div>
  391. <div class="parts-content">
  392. '''
  393. for key, value in increment_parts.items():
  394. content += f'''
  395. <div class="part-item">
  396. <span class="part-key">{html_module.escape(key)}:</span>
  397. <span class="part-value">{html_module.escape(value)}</span>
  398. </div>
  399. '''
  400. content += '''
  401. </div>
  402. </div>
  403. '''
  404. content += '''
  405. </div>
  406. '''
  407. else:
  408. content += '''
  409. <div class="empty-state">暂无增量词匹配结果</div>
  410. '''
  411. content += '''
  412. </div>
  413. </div>
  414. '''
  415. # 日志链接
  416. if metadata.get("log_url"):
  417. content += f'''
  418. <div class="modal-link">
  419. <a href="{metadata["log_url"]}" target="_blank" class="modal-link-btn">
  420. 🔗 查看详细日志
  421. </a>
  422. </div>
  423. '''
  424. return content
  425. def generate_detail_modal_content_js() -> str:
  426. """
  427. 生成详情弹窗内容的JavaScript函数
  428. Returns:
  429. JavaScript代码字符串
  430. """
  431. return '''
  432. // Tab切换功能
  433. function switchTab(event, tabId) {
  434. // 移除所有tab的active状态
  435. const tabButtons = document.querySelectorAll('.tab-button');
  436. tabButtons.forEach(button => {
  437. button.classList.remove('active');
  438. });
  439. // 隐藏所有tab内容
  440. const tabContents = document.querySelectorAll('.tab-content');
  441. tabContents.forEach(content => {
  442. content.classList.remove('active');
  443. });
  444. // 激活当前tab
  445. event.currentTarget.classList.add('active');
  446. document.getElementById(tabId).classList.add('active');
  447. }
  448. function showInspirationDetail(element) {
  449. const detailHtml = element.dataset.detailHtml;
  450. const modal = document.getElementById('detailModal');
  451. const modalBody = document.getElementById('modalBody');
  452. modalBody.innerHTML = detailHtml;
  453. modal.classList.add('active');
  454. document.body.style.overflow = 'hidden';
  455. }
  456. function closeModal() {
  457. const modal = document.getElementById('detailModal');
  458. modal.classList.remove('active');
  459. document.body.style.overflow = '';
  460. }
  461. function closeModalOnOverlay(event) {
  462. if (event.target.id === 'detailModal') {
  463. closeModal();
  464. }
  465. }
  466. // ESC键关闭Modal
  467. document.addEventListener('keydown', function(event) {
  468. if (event.key === 'Escape') {
  469. closeModal();
  470. }
  471. });
  472. // 搜索和过滤功能
  473. function filterInspirations() {
  474. const searchInput = document.getElementById('searchInput').value.toLowerCase();
  475. const sortSelect = document.getElementById('sortSelect').value;
  476. const cards = document.querySelectorAll('.inspiration-card');
  477. let visibleCards = Array.from(cards);
  478. // 搜索过滤
  479. visibleCards.forEach(card => {
  480. const name = card.dataset.inspirationName.toLowerCase();
  481. if (name.includes(searchInput)) {
  482. card.style.display = '';
  483. } else {
  484. card.style.display = 'none';
  485. }
  486. });
  487. // 获取可见的卡片
  488. visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
  489. // 排序
  490. if (sortSelect === 'step1-desc' || sortSelect === 'step1-asc') {
  491. visibleCards.sort((a, b) => {
  492. const step1A = parseFloat(a.dataset.step1Score) || 0;
  493. const step1B = parseFloat(b.dataset.step1Score) || 0;
  494. const step2A = parseFloat(a.dataset.step2Score) || 0;
  495. const step2B = parseFloat(b.dataset.step2Score) || 0;
  496. if (sortSelect === 'step1-desc') {
  497. return step1B !== step1A ? step1B - step1A : step2B - step2A;
  498. } else {
  499. return step1A !== step1B ? step1A - step1B : step2A - step2B;
  500. }
  501. });
  502. } else if (sortSelect === 'step2-desc' || sortSelect === 'step2-asc') {
  503. visibleCards.sort((a, b) => {
  504. const step2A = parseFloat(a.dataset.step2Score) || 0;
  505. const step2B = parseFloat(b.dataset.step2Score) || 0;
  506. const step1A = parseFloat(a.dataset.step1Score) || 0;
  507. const step1B = parseFloat(b.dataset.step1Score) || 0;
  508. if (sortSelect === 'step2-desc') {
  509. return step2B !== step2A ? step2B - step2A : step1B - step1A;
  510. } else {
  511. return step2A !== step2B ? step2A - step2B : step1A - step1B;
  512. }
  513. });
  514. } else if (sortSelect === 'name-asc' || sortSelect === 'name-desc') {
  515. visibleCards.sort((a, b) => {
  516. const nameA = a.dataset.inspirationName;
  517. const nameB = b.dataset.inspirationName;
  518. return sortSelect === 'name-asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
  519. });
  520. }
  521. // 重新排列卡片
  522. const container = document.querySelector('.inspirations-grid');
  523. visibleCards.forEach(card => {
  524. container.appendChild(card);
  525. });
  526. // 更新统计
  527. updateStats();
  528. }
  529. function updateStats() {
  530. const cards = document.querySelectorAll('.inspiration-card');
  531. const visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
  532. document.getElementById('totalCount').textContent = visibleCards.length;
  533. let step1ExcellentCount = 0;
  534. let step1GoodCount = 0;
  535. let step1NormalCount = 0;
  536. let step1NeedOptCount = 0;
  537. let step2ExcellentCount = 0;
  538. let step2GoodCount = 0;
  539. let step2NormalCount = 0;
  540. let step2NeedOptCount = 0;
  541. let totalStep1Score = 0;
  542. let totalStep2Score = 0;
  543. visibleCards.forEach(card => {
  544. const step1Score = parseFloat(card.dataset.step1Score) || 0;
  545. const step2Score = parseFloat(card.dataset.step2Score) || 0;
  546. totalStep1Score += step1Score;
  547. totalStep2Score += step2Score;
  548. // Step1 统计
  549. if (step1Score >= 0.7) step1ExcellentCount++;
  550. else if (step1Score >= 0.5) step1GoodCount++;
  551. else if (step1Score >= 0.3) step1NormalCount++;
  552. else step1NeedOptCount++;
  553. // Step2 统计
  554. if (step2Score >= 0.7) step2ExcellentCount++;
  555. else if (step2Score >= 0.5) step2GoodCount++;
  556. else if (step2Score >= 0.3) step2NormalCount++;
  557. else step2NeedOptCount++;
  558. });
  559. document.getElementById('step1ExcellentCount').textContent = step1ExcellentCount;
  560. document.getElementById('step1GoodCount').textContent = step1GoodCount;
  561. document.getElementById('step1NormalCount').textContent = step1NormalCount;
  562. document.getElementById('step1NeedOptCount').textContent = step1NeedOptCount;
  563. document.getElementById('step2ExcellentCount').textContent = step2ExcellentCount;
  564. document.getElementById('step2GoodCount').textContent = step2GoodCount;
  565. document.getElementById('step2NormalCount').textContent = step2NormalCount;
  566. document.getElementById('step2NeedOptCount').textContent = step2NeedOptCount;
  567. const avgStep1Score = visibleCards.length > 0 ? (totalStep1Score / visibleCards.length).toFixed(3) : '0.000';
  568. const avgStep2Score = visibleCards.length > 0 ? (totalStep2Score / visibleCards.length).toFixed(3) : '0.000';
  569. document.getElementById('avgStep1Score').textContent = avgStep1Score;
  570. document.getElementById('avgStep2Score').textContent = avgStep2Score;
  571. }
  572. '''
  573. def generate_persona_structure_html(persona_data: Dict[str, Any]) -> str:
  574. """
  575. 生成人设结构的树状HTML
  576. Args:
  577. persona_data: 人设数据
  578. Returns:
  579. 人设结构的HTML字符串
  580. """
  581. if not persona_data:
  582. return '<div class="empty-state">暂无人设数据</div>'
  583. inspiration_list = persona_data.get("灵感点列表", [])
  584. if not inspiration_list:
  585. return '<div class="empty-state">暂无灵感点列表数据</div>'
  586. html_parts = ['<div class="tree">']
  587. for perspective_idx, perspective in enumerate(inspiration_list):
  588. perspective_name = perspective.get("视角名称", "未知视角")
  589. perspective_desc = perspective.get("视角描述", "")
  590. pattern_list = perspective.get("模式列表", [])
  591. # 一级节点:视角
  592. html_parts.append(f'''
  593. <ul>
  594. <li>
  595. <div class="tree-node level-1">
  596. <span class="node-icon">📁</span>
  597. <span class="node-name">{html_module.escape(perspective_name)}</span>
  598. <span class="node-count">{len(pattern_list)}个分类</span>
  599. </div>
  600. ''')
  601. if perspective_desc:
  602. html_parts.append(f'''
  603. <div class="node-desc">{html_module.escape(perspective_desc)}</div>
  604. ''')
  605. # 二级节点:分类
  606. if pattern_list:
  607. html_parts.append('<ul>')
  608. for pattern in pattern_list:
  609. category_name = pattern.get("分类名称", "未知分类")
  610. core_definition = pattern.get("核心定义", "")
  611. subcategories = pattern.get("二级细分", [])
  612. total_posts = sum(len(sub.get("帖子ID列表", [])) for sub in subcategories)
  613. html_parts.append(f'''
  614. <li>
  615. <div class="tree-node level-2">
  616. <span class="node-icon">📂</span>
  617. <span class="node-name">{html_module.escape(category_name)}</span>
  618. <span class="node-count">{total_posts}个帖子</span>
  619. </div>
  620. ''')
  621. if core_definition:
  622. html_parts.append(f'''
  623. <div class="node-desc">{html_module.escape(core_definition)}</div>
  624. ''')
  625. # 三级节点:细分
  626. if subcategories:
  627. html_parts.append('<ul>')
  628. for subcategory in subcategories:
  629. sub_name = subcategory.get("分类名称", "未知细分")
  630. sub_definition = subcategory.get("分类定义", "")
  631. post_ids = subcategory.get("帖子ID列表", [])
  632. html_parts.append(f'''
  633. <li>
  634. <div class="tree-node level-3">
  635. <span class="node-icon">📄</span>
  636. <span class="node-name">{html_module.escape(sub_name)}</span>
  637. <span class="node-count">{len(post_ids)}个帖子</span>
  638. </div>
  639. ''')
  640. if sub_definition:
  641. html_parts.append(f'''
  642. <div class="node-desc">{html_module.escape(sub_definition)}</div>
  643. ''')
  644. if post_ids:
  645. html_parts.append(f'''
  646. <div class="node-posts">
  647. <span class="posts-label">📋 帖子ID:</span>
  648. <span class="posts-ids">{", ".join([html_module.escape(str(pid)) for pid in post_ids[:5]])}</span>
  649. {f'<span class="posts-more">... 等{len(post_ids)}个</span>' if len(post_ids) > 5 else ''}
  650. </div>
  651. ''')
  652. html_parts.append('</li>')
  653. html_parts.append('</ul>')
  654. html_parts.append('</li>')
  655. html_parts.append('</ul>')
  656. html_parts.append('</li>')
  657. html_parts.append('</ul>')
  658. html_parts.append('</div>')
  659. return ''.join(html_parts)
  660. def generate_html(
  661. inspirations_data: List[Dict[str, Any]],
  662. posts_map: Dict[str, Dict[str, Any]],
  663. persona_data: Dict[str, Any],
  664. output_path: str
  665. ) -> str:
  666. """
  667. 生成完整的可视化HTML
  668. Args:
  669. inspirations_data: 灵感点数据列表
  670. posts_map: 帖子数据映射
  671. persona_data: 人设数据
  672. output_path: 输出文件路径
  673. Returns:
  674. 输出文件路径
  675. """
  676. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  677. # 统计信息
  678. total_count = len(inspirations_data)
  679. # Step1 统计
  680. step1_excellent_count = sum(1 for d in inspirations_data
  681. if d["summary"].get("关键指标", {}).get("step1_top1_score", 0) >= 0.7)
  682. step1_good_count = sum(1 for d in inspirations_data
  683. if 0.5 <= d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.7)
  684. step1_normal_count = sum(1 for d in inspirations_data
  685. if 0.3 <= d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.5)
  686. step1_need_opt_count = sum(1 for d in inspirations_data
  687. if d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.3)
  688. # Step2 统计
  689. step2_excellent_count = sum(1 for d in inspirations_data
  690. if d["summary"].get("关键指标", {}).get("step2_score", 0) >= 0.7)
  691. step2_good_count = sum(1 for d in inspirations_data
  692. if 0.5 <= d["summary"].get("关键指标", {}).get("step2_score", 0) < 0.7)
  693. step2_normal_count = sum(1 for d in inspirations_data
  694. if 0.3 <= d["summary"].get("关键指标", {}).get("step2_score", 0) < 0.5)
  695. step2_need_opt_count = sum(1 for d in inspirations_data
  696. if d["summary"].get("关键指标", {}).get("step2_score", 0) < 0.3)
  697. # 平均分数
  698. total_step1_score = sum(d["summary"].get("关键指标", {}).get("step1_top1_score", 0)
  699. for d in inspirations_data)
  700. total_step2_score = sum(d["summary"].get("关键指标", {}).get("step2_score", 0)
  701. for d in inspirations_data)
  702. avg_step1_score = total_step1_score / total_count if total_count > 0 else 0
  703. avg_step2_score = total_step2_score / total_count if total_count > 0 else 0
  704. # 按Step1分数排序(Step2作为次要排序)
  705. inspirations_data_sorted = sorted(
  706. inspirations_data,
  707. key=lambda x: (x["summary"].get("关键指标", {}).get("step1_top1_score", 0),
  708. x["summary"].get("关键指标", {}).get("step2_score", 0)),
  709. reverse=True
  710. )
  711. # 生成卡片HTML
  712. cards_html = [generate_inspiration_card_html(data) for data in inspirations_data_sorted]
  713. cards_html_str = '\n'.join(cards_html)
  714. # 生成人设结构HTML
  715. persona_structure_html = generate_persona_structure_html(persona_data)
  716. # 生成JavaScript
  717. detail_modal_js = generate_detail_modal_content_js()
  718. # 完整HTML
  719. html_content = f'''<!DOCTYPE html>
  720. <html lang="zh-CN">
  721. <head>
  722. <meta charset="UTF-8">
  723. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  724. <title>灵感点分析可视化</title>
  725. <style>
  726. * {{
  727. margin: 0;
  728. padding: 0;
  729. box-sizing: border-box;
  730. }}
  731. body {{
  732. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  733. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  734. color: #333;
  735. line-height: 1.6;
  736. min-height: 100vh;
  737. padding: 20px;
  738. }}
  739. .container {{
  740. max-width: 1600px;
  741. margin: 0 auto;
  742. }}
  743. .header {{
  744. background: white;
  745. padding: 40px;
  746. border-radius: 16px;
  747. margin-bottom: 30px;
  748. box-shadow: 0 10px 40px rgba(0,0,0,0.2);
  749. }}
  750. .header h1 {{
  751. font-size: 42px;
  752. margin-bottom: 10px;
  753. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  754. -webkit-background-clip: text;
  755. -webkit-text-fill-color: transparent;
  756. font-weight: 800;
  757. }}
  758. .header-subtitle {{
  759. font-size: 16px;
  760. color: #6b7280;
  761. margin-bottom: 30px;
  762. }}
  763. .stats-overview {{
  764. display: grid;
  765. grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  766. gap: 20px;
  767. margin-top: 25px;
  768. }}
  769. .stat-box {{
  770. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  771. padding: 20px;
  772. border-radius: 12px;
  773. text-align: center;
  774. transition: transform 0.3s ease;
  775. }}
  776. .stat-box:hover {{
  777. transform: translateY(-5px);
  778. }}
  779. .stat-label {{
  780. font-size: 13px;
  781. color: #6b7280;
  782. margin-bottom: 8px;
  783. font-weight: 600;
  784. }}
  785. .stat-value {{
  786. font-size: 32px;
  787. font-weight: 700;
  788. color: #1a1a1a;
  789. }}
  790. .stat-box.excellent .stat-value {{
  791. color: #10b981;
  792. }}
  793. .stat-box.good .stat-value {{
  794. color: #f59e0b;
  795. }}
  796. .stat-box.normal .stat-value {{
  797. color: #3b82f6;
  798. }}
  799. .stat-box.need-opt .stat-value {{
  800. color: #ef4444;
  801. }}
  802. .controls-section {{
  803. background: #f9fafb;
  804. padding: 25px;
  805. border-radius: 12px;
  806. margin-bottom: 30px;
  807. display: flex;
  808. gap: 20px;
  809. flex-wrap: wrap;
  810. align-items: center;
  811. }}
  812. .search-box {{
  813. flex: 1;
  814. min-width: 250px;
  815. }}
  816. .search-input {{
  817. width: 100%;
  818. padding: 12px 20px;
  819. border: 2px solid #e5e7eb;
  820. border-radius: 10px;
  821. font-size: 15px;
  822. transition: all 0.3s;
  823. }}
  824. .search-input:focus {{
  825. outline: none;
  826. border-color: #667eea;
  827. box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
  828. }}
  829. .sort-box {{
  830. display: flex;
  831. align-items: center;
  832. gap: 12px;
  833. }}
  834. .sort-label {{
  835. font-size: 14px;
  836. font-weight: 600;
  837. color: #374151;
  838. }}
  839. .sort-select {{
  840. padding: 10px 16px;
  841. border: 2px solid #e5e7eb;
  842. border-radius: 10px;
  843. font-size: 14px;
  844. background: white;
  845. cursor: pointer;
  846. transition: all 0.3s;
  847. }}
  848. .sort-select:focus {{
  849. outline: none;
  850. border-color: #667eea;
  851. }}
  852. .inspirations-section {{
  853. padding: 0;
  854. }}
  855. .inspirations-grid {{
  856. display: grid;
  857. grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
  858. gap: 25px;
  859. }}
  860. .inspiration-card {{
  861. background: white;
  862. border-radius: 14px;
  863. padding: 25px;
  864. border-left: 6px solid #10b981;
  865. cursor: pointer;
  866. transition: all 0.3s ease;
  867. box-shadow: 0 4px 12px rgba(0,0,0,0.08);
  868. position: relative;
  869. }}
  870. .inspiration-card:hover {{
  871. transform: translateY(-8px);
  872. box-shadow: 0 12px 30px rgba(102, 126, 234, 0.2);
  873. }}
  874. .card-header {{
  875. display: flex;
  876. justify-content: space-between;
  877. align-items: flex-start;
  878. margin-bottom: 20px;
  879. gap: 12px;
  880. }}
  881. .inspiration-name {{
  882. font-size: 19px;
  883. font-weight: 700;
  884. color: #1a1a1a;
  885. line-height: 1.4;
  886. flex: 1;
  887. }}
  888. .grade-badge {{
  889. background: #10b981;
  890. color: white;
  891. padding: 6px 14px;
  892. border-radius: 20px;
  893. font-size: 12px;
  894. font-weight: 700;
  895. white-space: nowrap;
  896. }}
  897. .score-section {{
  898. display: flex;
  899. align-items: center;
  900. gap: 25px;
  901. margin-bottom: 20px;
  902. padding: 20px;
  903. background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
  904. border-radius: 12px;
  905. }}
  906. .score-item {{
  907. display: flex;
  908. flex-direction: column;
  909. align-items: center;
  910. gap: 8px;
  911. flex: 1;
  912. }}
  913. .main-score {{
  914. display: flex;
  915. flex-direction: column;
  916. align-items: center;
  917. gap: 8px;
  918. }}
  919. .score-circle {{
  920. width: 90px;
  921. height: 90px;
  922. border-radius: 50%;
  923. border: 6px solid #10b981;
  924. display: flex;
  925. align-items: center;
  926. justify-content: center;
  927. background: white;
  928. }}
  929. .score-value {{
  930. font-size: 26px;
  931. font-weight: 800;
  932. color: #10b981;
  933. }}
  934. .score-label {{
  935. font-size: 12px;
  936. color: #6b7280;
  937. font-weight: 600;
  938. }}
  939. .sub-scores {{
  940. flex: 1;
  941. display: flex;
  942. flex-direction: column;
  943. gap: 12px;
  944. }}
  945. .sub-score-item {{
  946. display: flex;
  947. justify-content: space-between;
  948. align-items: center;
  949. padding: 10px 15px;
  950. background: white;
  951. border-radius: 8px;
  952. }}
  953. .sub-score-label {{
  954. font-size: 13px;
  955. color: #6b7280;
  956. font-weight: 600;
  957. }}
  958. .sub-score-value {{
  959. font-size: 18px;
  960. font-weight: 700;
  961. color: #2563eb;
  962. }}
  963. .metrics-section {{
  964. display: flex;
  965. flex-direction: column;
  966. gap: 10px;
  967. margin-bottom: 15px;
  968. }}
  969. .metric-item {{
  970. display: flex;
  971. align-items: center;
  972. gap: 8px;
  973. font-size: 13px;
  974. color: #4b5563;
  975. }}
  976. .metric-icon {{
  977. font-size: 16px;
  978. }}
  979. .metric-label {{
  980. font-weight: 600;
  981. }}
  982. .metric-value {{
  983. color: #1f2937;
  984. }}
  985. .match-preview {{
  986. background: #f9fafb;
  987. padding: 12px;
  988. border-radius: 8px;
  989. margin-bottom: 10px;
  990. border-left: 3px solid #8b5cf6;
  991. }}
  992. .match-preview-header {{
  993. font-size: 12px;
  994. font-weight: 600;
  995. color: #6b7280;
  996. margin-bottom: 6px;
  997. }}
  998. .match-preview-content {{
  999. display: flex;
  1000. justify-content: space-between;
  1001. align-items: center;
  1002. }}
  1003. .match-preview-name {{
  1004. font-size: 13px;
  1005. color: #1f2937;
  1006. flex: 1;
  1007. }}
  1008. .match-preview-score {{
  1009. font-size: 16px;
  1010. font-weight: 700;
  1011. }}
  1012. .preview-parts {{
  1013. margin-top: 8px;
  1014. padding: 8px 10px;
  1015. border-radius: 6px;
  1016. font-size: 12px;
  1017. line-height: 1.6;
  1018. }}
  1019. .preview-parts.same {{
  1020. background: #f0fdf4;
  1021. color: #15803d;
  1022. border-left: 3px solid #10b981;
  1023. }}
  1024. .preview-parts.increment {{
  1025. background: #fff7ed;
  1026. color: #92400e;
  1027. border-left: 3px solid #f59e0b;
  1028. margin-top: 6px;
  1029. }}
  1030. .preview-parts strong {{
  1031. font-weight: 700;
  1032. margin-right: 6px;
  1033. }}
  1034. .score-divider {{
  1035. width: 1px;
  1036. height: 40px;
  1037. background: #e5e7eb;
  1038. }}
  1039. .click-hint {{
  1040. position: absolute;
  1041. bottom: 15px;
  1042. right: 15px;
  1043. font-size: 12px;
  1044. color: #8b5cf6;
  1045. font-weight: 700;
  1046. opacity: 0;
  1047. transition: opacity 0.3s ease;
  1048. background: rgba(139, 92, 246, 0.1);
  1049. padding: 6px 12px;
  1050. border-radius: 8px;
  1051. }}
  1052. .inspiration-card:hover .click-hint {{
  1053. opacity: 1;
  1054. }}
  1055. /* Modal样式 */
  1056. .modal-overlay {{
  1057. display: none;
  1058. position: fixed;
  1059. top: 0;
  1060. left: 0;
  1061. right: 0;
  1062. bottom: 0;
  1063. background: rgba(0, 0, 0, 0.8);
  1064. z-index: 1000;
  1065. align-items: center;
  1066. justify-content: center;
  1067. padding: 20px;
  1068. overflow-y: auto;
  1069. }}
  1070. .modal-overlay.active {{
  1071. display: flex;
  1072. }}
  1073. .modal-content {{
  1074. background: white;
  1075. border-radius: 16px;
  1076. max-width: 1200px;
  1077. width: 100%;
  1078. max-height: 90vh;
  1079. overflow-y: auto;
  1080. position: relative;
  1081. }}
  1082. .modal-close {{
  1083. position: sticky;
  1084. top: 0;
  1085. right: 0;
  1086. background: white;
  1087. border: none;
  1088. font-size: 32px;
  1089. color: #6b7280;
  1090. cursor: pointer;
  1091. padding: 15px 20px;
  1092. z-index: 10;
  1093. text-align: right;
  1094. border-bottom: 1px solid #e5e7eb;
  1095. }}
  1096. .modal-close:hover {{
  1097. color: #1f2937;
  1098. }}
  1099. .modal-body {{
  1100. padding: 30px;
  1101. }}
  1102. .modal-header {{
  1103. margin-bottom: 25px;
  1104. padding-bottom: 20px;
  1105. border-bottom: 2px solid #e5e7eb;
  1106. }}
  1107. .modal-title {{
  1108. font-size: 28px;
  1109. font-weight: 800;
  1110. color: #1a1a1a;
  1111. }}
  1112. .modal-section {{
  1113. margin-bottom: 30px;
  1114. }}
  1115. .modal-section h3 {{
  1116. font-size: 20px;
  1117. font-weight: 700;
  1118. color: #374151;
  1119. margin-bottom: 15px;
  1120. padding-bottom: 10px;
  1121. border-bottom: 2px solid #f3f4f6;
  1122. }}
  1123. .info-grid {{
  1124. display: grid;
  1125. grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  1126. gap: 15px;
  1127. }}
  1128. .info-item {{
  1129. background: #f9fafb;
  1130. padding: 12px 16px;
  1131. border-radius: 8px;
  1132. border-left: 3px solid #8b5cf6;
  1133. }}
  1134. .info-label {{
  1135. font-weight: 600;
  1136. color: #6b7280;
  1137. font-size: 13px;
  1138. margin-right: 8px;
  1139. }}
  1140. .info-value {{
  1141. color: #1f2937;
  1142. font-size: 14px;
  1143. }}
  1144. .metrics-grid {{
  1145. display: grid;
  1146. grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  1147. gap: 15px;
  1148. }}
  1149. .metric-box {{
  1150. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1151. padding: 20px;
  1152. border-radius: 12px;
  1153. text-align: center;
  1154. color: white;
  1155. }}
  1156. .metric-box.wide {{
  1157. grid-column: span 2;
  1158. }}
  1159. .metric-box-label {{
  1160. font-size: 13px;
  1161. opacity: 0.9;
  1162. margin-bottom: 8px;
  1163. font-weight: 600;
  1164. }}
  1165. .metric-box-value {{
  1166. font-size: 28px;
  1167. font-weight: 700;
  1168. }}
  1169. .metric-box-value.small {{
  1170. font-size: 16px;
  1171. }}
  1172. .step-content {{
  1173. background: #f9fafb;
  1174. padding: 20px;
  1175. border-radius: 12px;
  1176. }}
  1177. .step-field {{
  1178. margin-bottom: 20px;
  1179. }}
  1180. .step-field-label {{
  1181. font-weight: 700;
  1182. color: #374151;
  1183. font-size: 14px;
  1184. margin-bottom: 8px;
  1185. display: block;
  1186. }}
  1187. .step-field-value {{
  1188. color: #1f2937;
  1189. font-size: 15px;
  1190. line-height: 1.7;
  1191. }}
  1192. .matches-list {{
  1193. display: flex;
  1194. flex-direction: column;
  1195. gap: 15px;
  1196. margin-top: 10px;
  1197. }}
  1198. .match-item {{
  1199. background: white;
  1200. padding: 18px;
  1201. border-radius: 10px;
  1202. border-left: 5px solid #3b82f6;
  1203. }}
  1204. .match-item.top1 {{
  1205. border-left-color: #fbbf24;
  1206. background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, white 100%);
  1207. }}
  1208. .match-item.top2 {{
  1209. border-left-color: #c0c0c0;
  1210. background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 50%, white 100%);
  1211. }}
  1212. .match-item.top3 {{
  1213. border-left-color: #cd7f32;
  1214. background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 50%, white 100%);
  1215. }}
  1216. .match-header {{
  1217. display: flex;
  1218. justify-content: space-between;
  1219. align-items: center;
  1220. margin-bottom: 12px;
  1221. gap: 10px;
  1222. }}
  1223. .match-rank {{
  1224. font-size: 18px;
  1225. font-weight: 800;
  1226. color: #6b7280;
  1227. }}
  1228. .match-element-name {{
  1229. flex: 1;
  1230. font-size: 16px;
  1231. font-weight: 700;
  1232. color: #1f2937;
  1233. }}
  1234. .match-score {{
  1235. font-size: 22px;
  1236. font-weight: 800;
  1237. color: #2563eb;
  1238. background: white;
  1239. padding: 6px 14px;
  1240. border-radius: 8px;
  1241. }}
  1242. .match-detail {{
  1243. background: rgba(255, 255, 255, 0.7);
  1244. padding: 10px;
  1245. border-radius: 6px;
  1246. margin-bottom: 10px;
  1247. font-size: 13px;
  1248. color: #4b5563;
  1249. }}
  1250. .match-reason {{
  1251. color: #1f2937;
  1252. font-size: 14px;
  1253. line-height: 1.7;
  1254. }}
  1255. .increment-matches {{
  1256. display: flex;
  1257. flex-direction: column;
  1258. gap: 12px;
  1259. margin-top: 10px;
  1260. }}
  1261. .increment-item {{
  1262. background: white;
  1263. padding: 15px;
  1264. border-radius: 8px;
  1265. border-left: 4px solid #10b981;
  1266. }}
  1267. .increment-header {{
  1268. display: flex;
  1269. justify-content: space-between;
  1270. align-items: center;
  1271. margin-bottom: 10px;
  1272. }}
  1273. .increment-words {{
  1274. font-weight: 700;
  1275. color: #1f2937;
  1276. font-size: 15px;
  1277. }}
  1278. .increment-score {{
  1279. font-size: 20px;
  1280. font-weight: 800;
  1281. color: #10b981;
  1282. }}
  1283. .increment-reason {{
  1284. color: #4b5563;
  1285. font-size: 13px;
  1286. line-height: 1.6;
  1287. }}
  1288. .empty-state {{
  1289. text-align: center;
  1290. padding: 40px;
  1291. color: #9ca3af;
  1292. font-size: 14px;
  1293. }}
  1294. .modal-link {{
  1295. margin-top: 25px;
  1296. padding-top: 20px;
  1297. border-top: 2px solid #e5e7eb;
  1298. text-align: center;
  1299. }}
  1300. .modal-link-btn {{
  1301. display: inline-flex;
  1302. align-items: center;
  1303. gap: 10px;
  1304. padding: 12px 24px;
  1305. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1306. color: white;
  1307. text-decoration: none;
  1308. border-radius: 10px;
  1309. font-size: 15px;
  1310. font-weight: 600;
  1311. transition: all 0.3s;
  1312. }}
  1313. .modal-link-btn:hover {{
  1314. transform: translateY(-2px);
  1315. box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
  1316. }}
  1317. .timestamp {{
  1318. text-align: center;
  1319. color: white;
  1320. font-size: 13px;
  1321. margin-top: 30px;
  1322. opacity: 0.8;
  1323. }}
  1324. .match-context {{
  1325. background: #f3f4f6;
  1326. padding: 8px 12px;
  1327. border-radius: 6px;
  1328. margin: 8px 0;
  1329. font-size: 12px;
  1330. color: #6b7280;
  1331. line-height: 1.6;
  1332. }}
  1333. .match-explain {{
  1334. background: #fef3c7;
  1335. padding: 10px 12px;
  1336. border-radius: 6px;
  1337. margin: 10px 0;
  1338. font-size: 13px;
  1339. color: #92400e;
  1340. line-height: 1.7;
  1341. border-left: 3px solid #f59e0b;
  1342. }}
  1343. .match-parts {{
  1344. margin: 12px 0;
  1345. border-radius: 8px;
  1346. overflow: hidden;
  1347. }}
  1348. .match-parts.same-parts {{
  1349. background: #f0fdf4;
  1350. border: 2px solid #10b981;
  1351. }}
  1352. .match-parts.increment-parts {{
  1353. background: #fff7ed;
  1354. border: 2px solid #f59e0b;
  1355. }}
  1356. .parts-header {{
  1357. font-weight: 700;
  1358. padding: 10px 12px;
  1359. font-size: 13px;
  1360. }}
  1361. .same-parts .parts-header {{
  1362. background: #dcfce7;
  1363. color: #15803d;
  1364. }}
  1365. .increment-parts .parts-header {{
  1366. background: #fed7aa;
  1367. color: #92400e;
  1368. }}
  1369. .parts-content {{
  1370. padding: 8px 12px;
  1371. }}
  1372. .part-item {{
  1373. padding: 6px 0;
  1374. border-bottom: 1px solid rgba(0,0,0,0.05);
  1375. font-size: 13px;
  1376. line-height: 1.6;
  1377. }}
  1378. .part-item:last-child {{
  1379. border-bottom: none;
  1380. }}
  1381. .part-key {{
  1382. font-weight: 600;
  1383. color: #374151;
  1384. margin-right: 6px;
  1385. }}
  1386. .part-value {{
  1387. color: #1f2937;
  1388. }}
  1389. .increment-context {{
  1390. background: #fef3c7;
  1391. padding: 10px 12px;
  1392. border-radius: 6px;
  1393. margin: 10px 0;
  1394. font-size: 12px;
  1395. color: #92400e;
  1396. line-height: 1.6;
  1397. border-left: 3px solid #f59e0b;
  1398. }}
  1399. /* Tab样式 */
  1400. .tabs-nav {{
  1401. background: white;
  1402. padding: 0 30px;
  1403. border-radius: 16px 16px 0 0;
  1404. margin-bottom: 0;
  1405. box-shadow: 0 4px 20px rgba(0,0,0,0.1);
  1406. display: flex;
  1407. gap: 10px;
  1408. }}
  1409. .tab-button {{
  1410. padding: 15px 30px;
  1411. border: none;
  1412. background: transparent;
  1413. color: #6b7280;
  1414. font-size: 15px;
  1415. font-weight: 600;
  1416. cursor: pointer;
  1417. border-bottom: 3px solid transparent;
  1418. transition: all 0.3s;
  1419. }}
  1420. .tab-button:hover {{
  1421. color: #667eea;
  1422. background: rgba(102, 126, 234, 0.05);
  1423. }}
  1424. .tab-button.active {{
  1425. color: #667eea;
  1426. border-bottom-color: #667eea;
  1427. background: rgba(102, 126, 234, 0.05);
  1428. }}
  1429. .tab-content {{
  1430. display: none;
  1431. background: white;
  1432. padding: 30px;
  1433. border-radius: 0 0 16px 16px;
  1434. box-shadow: 0 10px 40px rgba(0,0,0,0.15);
  1435. }}
  1436. .tab-content.active {{
  1437. display: block;
  1438. }}
  1439. /* 人设结构样式 */
  1440. .persona-structure-section h2 {{
  1441. font-size: 28px;
  1442. font-weight: 700;
  1443. margin-bottom: 25px;
  1444. color: #1a1a1a;
  1445. }}
  1446. /* 树状图样式 */
  1447. .tree {{
  1448. font-size: 14px;
  1449. }}
  1450. .tree ul {{
  1451. padding-left: 30px;
  1452. list-style: none;
  1453. position: relative;
  1454. }}
  1455. .tree ul ul {{
  1456. padding-left: 40px;
  1457. }}
  1458. .tree li {{
  1459. position: relative;
  1460. padding: 8px 0;
  1461. }}
  1462. .tree li::before {{
  1463. content: "";
  1464. position: absolute;
  1465. top: 0;
  1466. left: -20px;
  1467. border-left: 2px solid #d1d5db;
  1468. border-bottom: 2px solid #d1d5db;
  1469. width: 20px;
  1470. height: 20px;
  1471. }}
  1472. .tree li::after {{
  1473. content: "";
  1474. position: absolute;
  1475. top: 20px;
  1476. left: -20px;
  1477. border-left: 2px solid #d1d5db;
  1478. height: 100%;
  1479. }}
  1480. .tree li:last-child::after {{
  1481. display: none;
  1482. }}
  1483. .tree > ul > li::before,
  1484. .tree > ul > li::after {{
  1485. display: none;
  1486. }}
  1487. .tree-node {{
  1488. display: inline-flex;
  1489. align-items: center;
  1490. gap: 10px;
  1491. padding: 12px 16px;
  1492. border-radius: 8px;
  1493. transition: all 0.3s;
  1494. margin-bottom: 8px;
  1495. }}
  1496. .tree-node:hover {{
  1497. transform: translateX(4px);
  1498. box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  1499. }}
  1500. .tree-node.level-1 {{
  1501. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1502. color: white;
  1503. font-size: 18px;
  1504. font-weight: 700;
  1505. padding: 16px 20px;
  1506. }}
  1507. .tree-node.level-2 {{
  1508. background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
  1509. color: #1e40af;
  1510. font-size: 16px;
  1511. font-weight: 600;
  1512. border: 2px solid #3b82f6;
  1513. }}
  1514. .tree-node.level-3 {{
  1515. background: white;
  1516. color: #374151;
  1517. font-size: 14px;
  1518. font-weight: 500;
  1519. border: 1px solid #e5e7eb;
  1520. }}
  1521. .node-icon {{
  1522. font-size: 20px;
  1523. }}
  1524. .tree-node.level-1 .node-icon {{
  1525. font-size: 24px;
  1526. }}
  1527. .node-name {{
  1528. flex: 1;
  1529. }}
  1530. .node-count {{
  1531. background: rgba(255, 255, 255, 0.3);
  1532. padding: 4px 12px;
  1533. border-radius: 12px;
  1534. font-size: 12px;
  1535. font-weight: 600;
  1536. }}
  1537. .tree-node.level-1 .node-count {{
  1538. background: rgba(255, 255, 255, 0.4);
  1539. }}
  1540. .tree-node.level-2 .node-count {{
  1541. background: #bfdbfe;
  1542. color: #1e3a8a;
  1543. }}
  1544. .tree-node.level-3 .node-count {{
  1545. background: #dcfce7;
  1546. color: #166534;
  1547. }}
  1548. .node-desc {{
  1549. margin: 8px 0 8px 50px;
  1550. padding: 12px 16px;
  1551. background: #fffbeb;
  1552. border-left: 3px solid #f59e0b;
  1553. border-radius: 6px;
  1554. color: #92400e;
  1555. font-size: 13px;
  1556. line-height: 1.7;
  1557. }}
  1558. .node-posts {{
  1559. margin: 8px 0 8px 50px;
  1560. padding: 12px 16px;
  1561. background: #f0fdf4;
  1562. border-left: 3px solid #10b981;
  1563. border-radius: 6px;
  1564. font-size: 12px;
  1565. line-height: 1.8;
  1566. }}
  1567. .posts-label {{
  1568. font-weight: 600;
  1569. color: #15803d;
  1570. margin-right: 8px;
  1571. }}
  1572. .posts-ids {{
  1573. color: #166534;
  1574. word-break: break-all;
  1575. }}
  1576. .posts-more {{
  1577. color: #059669;
  1578. font-weight: 600;
  1579. margin-left: 8px;
  1580. }}
  1581. @media (max-width: 768px) {{
  1582. .inspirations-grid {{
  1583. grid-template-columns: 1fr;
  1584. }}
  1585. .header h1 {{
  1586. font-size: 32px;
  1587. }}
  1588. .stats-overview {{
  1589. grid-template-columns: repeat(2, 1fr);
  1590. }}
  1591. }}
  1592. </style>
  1593. </head>
  1594. <body>
  1595. <div class="container">
  1596. <div class="header">
  1597. <h1>💡 灵感点分析可视化</h1>
  1598. <div class="header-subtitle">基于HOW人设的灵感点匹配分析结果</div>
  1599. <div class="stats-overview">
  1600. <div class="stat-box">
  1601. <div class="stat-label">分析总数</div>
  1602. <div class="stat-value" id="totalCount">{total_count}</div>
  1603. </div>
  1604. <div class="stat-box excellent">
  1605. <div class="stat-label">Step1优秀 (≥0.7)</div>
  1606. <div class="stat-value" id="step1ExcellentCount">{step1_excellent_count}</div>
  1607. </div>
  1608. <div class="stat-box good">
  1609. <div class="stat-label">Step1良好 (0.5-0.7)</div>
  1610. <div class="stat-value" id="step1GoodCount">{step1_good_count}</div>
  1611. </div>
  1612. <div class="stat-box normal">
  1613. <div class="stat-label">Step1一般 (0.3-0.5)</div>
  1614. <div class="stat-value" id="step1NormalCount">{step1_normal_count}</div>
  1615. </div>
  1616. <div class="stat-box need-opt">
  1617. <div class="stat-label">Step1待优化 (<0.3)</div>
  1618. <div class="stat-value" id="step1NeedOptCount">{step1_need_opt_count}</div>
  1619. </div>
  1620. <div class="stat-box excellent">
  1621. <div class="stat-label">Step2优秀 (≥0.7)</div>
  1622. <div class="stat-value" id="step2ExcellentCount">{step2_excellent_count}</div>
  1623. </div>
  1624. <div class="stat-box good">
  1625. <div class="stat-label">Step2良好 (0.5-0.7)</div>
  1626. <div class="stat-value" id="step2GoodCount">{step2_good_count}</div>
  1627. </div>
  1628. <div class="stat-box normal">
  1629. <div class="stat-label">Step2一般 (0.3-0.5)</div>
  1630. <div class="stat-value" id="step2NormalCount">{step2_normal_count}</div>
  1631. </div>
  1632. <div class="stat-box need-opt">
  1633. <div class="stat-label">Step2待优化 (<0.3)</div>
  1634. <div class="stat-value" id="step2NeedOptCount">{step2_need_opt_count}</div>
  1635. </div>
  1636. <div class="stat-box">
  1637. <div class="stat-label">Step1平均分</div>
  1638. <div class="stat-value" id="avgStep1Score">{avg_step1_score:.3f}</div>
  1639. </div>
  1640. <div class="stat-box">
  1641. <div class="stat-label">Step2平均分</div>
  1642. <div class="stat-value" id="avgStep2Score">{avg_step2_score:.3f}</div>
  1643. </div>
  1644. </div>
  1645. </div>
  1646. <div class="tabs-nav">
  1647. <button class="tab-button active" onclick="switchTab(event, 'tab-inspirations')">
  1648. 灵感点分析
  1649. </button>
  1650. <button class="tab-button" onclick="switchTab(event, 'tab-persona')">
  1651. 人设结构
  1652. </button>
  1653. </div>
  1654. <div id="tab-inspirations" class="tab-content active">
  1655. <div class="controls-section">
  1656. <div class="search-box">
  1657. <input type="text"
  1658. id="searchInput"
  1659. class="search-input"
  1660. placeholder="🔍 搜索灵感点名称..."
  1661. oninput="filterInspirations()">
  1662. </div>
  1663. <div class="sort-box">
  1664. <span class="sort-label">排序方式:</span>
  1665. <select id="sortSelect" class="sort-select" onchange="filterInspirations()">
  1666. <option value="step1-desc">Step1分数从高到低</option>
  1667. <option value="step1-asc">Step1分数从低到高</option>
  1668. <option value="step2-desc">Step2分数从高到低</option>
  1669. <option value="step2-asc">Step2分数从低到高</option>
  1670. <option value="name-asc">名称A-Z</option>
  1671. <option value="name-desc">名称Z-A</option>
  1672. </select>
  1673. </div>
  1674. </div>
  1675. <div class="inspirations-section">
  1676. <div class="inspirations-grid">
  1677. {cards_html_str}
  1678. </div>
  1679. </div>
  1680. </div>
  1681. <div id="tab-persona" class="tab-content">
  1682. <div class="persona-structure-section">
  1683. <h2>📚 人设结构</h2>
  1684. {persona_structure_html}
  1685. </div>
  1686. </div>
  1687. <div class="timestamp">生成时间: {timestamp}</div>
  1688. <!-- Modal -->
  1689. <div id="detailModal" class="modal-overlay" onclick="closeModalOnOverlay(event)">
  1690. <div class="modal-content">
  1691. <button class="modal-close" onclick="closeModal()">&times;</button>
  1692. <div class="modal-body" id="modalBody">
  1693. <!-- Content will be inserted here -->
  1694. </div>
  1695. </div>
  1696. </div>
  1697. </div>
  1698. <script>
  1699. {detail_modal_js}
  1700. </script>
  1701. </body>
  1702. </html>'''
  1703. # 写入文件
  1704. output_file = Path(output_path)
  1705. output_file.parent.mkdir(parents=True, exist_ok=True)
  1706. with open(output_file, 'w', encoding='utf-8') as f:
  1707. f.write(html_content)
  1708. return str(output_file.absolute())
  1709. def load_persona_data(persona_path: str) -> Dict[str, Any]:
  1710. """
  1711. 加载人设数据
  1712. Args:
  1713. persona_path: 人设JSON文件路径
  1714. Returns:
  1715. 人设数据字典
  1716. """
  1717. try:
  1718. with open(persona_path, 'r', encoding='utf-8') as f:
  1719. return json.load(f)
  1720. except Exception as e:
  1721. print(f"警告: 读取人设文件失败: {e}")
  1722. return {}
  1723. def main():
  1724. """主函数"""
  1725. import sys
  1726. # 配置路径
  1727. inspiration_dir = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/how/灵感点"
  1728. posts_dir = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/作者历史帖子"
  1729. persona_path = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/人设.json"
  1730. output_path = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/how/灵感点可视化.html"
  1731. print("=" * 60)
  1732. print("灵感点分析可视化脚本")
  1733. print("=" * 60)
  1734. # 加载数据
  1735. print("\n📂 正在加载灵感点数据...")
  1736. inspirations_data = load_inspiration_points_data(inspiration_dir)
  1737. print(f"✅ 成功加载 {len(inspirations_data)} 个灵感点")
  1738. print("\n📂 正在加载帖子数据...")
  1739. posts_map = load_posts_data(posts_dir)
  1740. print(f"✅ 成功加载 {len(posts_map)} 个帖子")
  1741. print("\n📂 正在加载人设数据...")
  1742. persona_data = load_persona_data(persona_path)
  1743. print(f"✅ 成功加载人设数据")
  1744. # 生成HTML
  1745. print("\n🎨 正在生成可视化HTML...")
  1746. result_path = generate_html(inspirations_data, posts_map, persona_data, output_path)
  1747. print(f"\n✅ 可视化文件已生成!")
  1748. print(f"📄 文件路径: {result_path}")
  1749. print(f"\n💡 在浏览器中打开该文件即可查看可视化结果")
  1750. print("=" * 60)
  1751. if __name__ == "__main__":
  1752. main()