# -*- coding: utf-8 -*-
import html
from typing import Any, Dict, List
from markupsafe import Markup
from utils.html_template_renderer import HtmlTemplateRenderer
def _escape(value: Any) -> str:
return html.escape(str(value or ""))
def _clean_bullet_text(line: str) -> str:
"""
统一清洗模型输出里的 bullet 前缀。
这样做的目的有两个:
1. 兼容 `-`、`•`、`1.` 这类常见列表格式;
2. 让模板层只拿到干净文本,避免在 HTML 里再做重复判断。
"""
text = str(line or "").strip()
if not text:
return ""
if text.startswith("- "):
return text[2:].strip()
if text.startswith("•"):
return text[1:].strip()
if len(text) > 2 and text[0].isdigit() and text[1] in {".", "、"}:
return text[2:].strip()
return text
def _render_metric_card(label: str, value: Any, hint: str = "") -> str:
return (
'
'
f'
{_escape(label)}
'
f'
{_escape(value)}
'
f'
{_escape(hint)}
'
"
"
)
def _render_list(items: List[str], item_class: str = "bullet-list") -> str:
if not items:
return ""
lis = "".join(f'
{_escape(item)}
' for item in items if str(item or "").strip())
return f'
{lis}
' if lis else ""
def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str], List[str]]:
# 这里把 LLM 返回的弹幕总结拆成三部分:
# 1) lead: 顶部总述段落
# 2) insight_items: 常规的复盘要点(运营/观察视角)
# 3) fans_extract_items: 专门给粉丝看的“弹幕萃取”要点
# 约定:当检测到“【粉丝向弹幕萃取】”或同义标记后,后续条目归入 fans_extract_items。
lead_parts = []
insight_items = []
fans_extract_items = []
in_fans_extract_block = False
for line in str(danmu_summary or "").splitlines():
stripped = line.strip()
if not stripped:
continue
# 兼容不同模型可能产出的标题样式,尽量把粉丝向内容稳定识别出来。
if stripped.startswith("【粉丝向弹幕萃取】") or stripped.startswith("粉丝向弹幕萃取") or stripped.startswith("给粉丝看的弹幕萃取"):
in_fans_extract_block = True
continue
if stripped.startswith("- "):
if in_fans_extract_block:
fans_extract_items.append(stripped[2:].strip())
else:
insight_items.append(stripped[2:].strip())
else:
# 非 bullet 文本在粉丝区块中也保留,避免模型偶发输出短段落导致信息丢失。
if in_fans_extract_block:
fans_extract_items.append(stripped)
else:
lead_parts.append(stripped)
lead = " ".join(lead_parts).strip()
return lead, insight_items, fans_extract_items
def _split_fans_report_blocks(report_text: str) -> Dict[str, Any]:
"""
将“粉丝向恶搞日报”文本拆成模板需要的结构化区块。
约定模型尽量输出如下标题:
- 【今日笑点】
- 【弹幕名场面】
- 【梗王榜】
- 【收尾播报】
即便模型没有完全按约定输出,这里也会尽量兜底,保证页面不空。
"""
header_alias_map = {
"今日重点信息": "key_info",
"重点信息": "key_info",
"有效信息": "key_info",
"核心讨论话题": "topic_focus",
"讨论话题": "topic_focus",
"核心话题": "topic_focus",
"英雄与对局焦点": "hero_focus",
"对局焦点": "hero_focus",
"英雄焦点": "hero_focus",
"今日笑点": "laugh_points",
"笑点": "laugh_points",
"欢乐总结": "laugh_points",
"弹幕名场面": "famous_scenes",
"名场面": "famous_scenes",
"现场整活": "famous_scenes",
"梗王榜": "meme_rank",
"梗榜": "meme_rank",
"复读冠军": "meme_rank",
"收尾播报": "closing",
"结尾播报": "closing",
"结尾": "closing",
}
sections = {
"lead": "",
"key_info": [],
"topic_focus": [],
"hero_focus": [],
"laugh_points": [],
"famous_scenes": [],
"meme_rank": [],
"closing": [],
}
current_key = "lead"
lead_parts: List[str] = []
for raw_line in str(report_text or "").splitlines():
stripped = raw_line.strip()
if not stripped:
continue
normalized_title = stripped.strip("【】:#: ").replace(":", "").replace(":", "")
if normalized_title in header_alias_map:
current_key = header_alias_map[normalized_title]
continue
clean_text = _clean_bullet_text(stripped)
if not clean_text:
continue
if current_key == "lead":
lead_parts.append(clean_text)
else:
sections[current_key].append(clean_text)
sections["lead"] = " ".join(lead_parts).strip()
return sections
def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
if len(normalized) >= target_count:
return normalized[:target_count]
top_terms = [str(item.get("term") or "").strip() for item in (payload.get("top_terms", []) or []) if str(item.get("term") or "").strip()]
merged_templates = [str(item.get("text") or "").strip() for item in (payload.get("merged_templates", []) or []) if str(item.get("text") or "").strip()]
peak_buckets = payload.get("peak_buckets", []) or []
representative_messages = payload.get("representative_messages", []) or []
supplements: List[str] = []
if top_terms:
supplements.append(f"讨论焦点比较集中,弹幕反复围绕 {'、'.join(top_terms[:5])} 展开。")
if merged_templates:
sample_templates = ";".join(text[:24] for text in merged_templates[:3])
supplements.append(f"复读和共识梗比较强,重复内容主要集中在 {sample_templates}。")
if peak_buckets:
top_bucket = peak_buckets[0]
bucket_terms = [str(term.get('term') or '').strip() for term in (top_bucket.get("top_terms", []) or []) if str(term.get('term') or '').strip()]
if bucket_terms:
supplements.append(
f"高峰时段出现在 {str(top_bucket.get('start_time') or '')[-8:-3]} 前后,话题明显偏向 {'、'.join(bucket_terms[:4])}。"
)
if representative_messages:
supplements.append("代表性发言里既有操作反馈,也有玩梗调侃和情绪宣泄,互动意愿比较强。")
existing = set(normalized)
for item in supplements:
if item not in existing:
normalized.append(item)
existing.add(item)
if len(normalized) >= target_count:
break
return normalized[:target_count]
def _normalize_fans_extract_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 6) -> List[str]:
# 粉丝向萃取强调“现场感”,优先保留模型给出的条目;
# 不足时再从代表弹幕/重复梗中补齐,避免页面出现空区块。
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
if len(normalized) >= target_count:
return normalized[:target_count]
supplements: List[str] = []
representative_messages = payload.get("representative_messages", []) or []
repeated_messages = payload.get("repeated_messages", []) or []
burst_terms = payload.get("burst_terms", []) or []
for item in representative_messages[:8]:
nickname = str(item.get("nickname") or "").strip() or "观众"
content = str(item.get("content") or "").strip()
if not content:
continue
supplements.append(f"{nickname}:{content[:46]}")
for item in repeated_messages[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"复读梗「{text[:34]}」出现 {count} 次。")
for item in burst_terms[:4]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"情绪短词「{text}」集中刷了 {count} 次。")
existing = set(normalized)
for item in supplements:
if item not in existing:
normalized.append(item)
existing.add(item)
if len(normalized) >= target_count:
break
return normalized[:target_count]
def _normalize_funny_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 4) -> List[str]:
"""
“今日笑点”优先保留模型自己写的梗概;
如果模型输出偏保守,就从高频梗、爆发词里补出几条更有现场感的句子。
"""
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
if len(normalized) >= target_count:
return normalized[:target_count]
supplements: List[str] = []
for item in (payload.get("merged_templates", []) or [])[:4]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"同一句梗反复刷了 {count} 次,直播间默认进入复读机模式:{text[:30]}。")
for item in (payload.get("burst_terms", []) or [])[:4]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"情绪词「{text}」高频刷屏 {count} 次,说明这一段大家已经集体上头。")
existing = set(normalized)
for item in supplements:
if item not in existing:
normalized.append(item)
existing.add(item)
if len(normalized) >= target_count:
break
return normalized[:target_count]
def _normalize_scene_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
"""
“弹幕名场面”强调像直播间回放,因此优先从代表弹幕中补句子,
让最终成品看起来更像观众之间的接龙,而不是纯数据总结。
"""
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
if len(normalized) >= target_count:
return normalized[:target_count]
supplements: List[str] = []
for item in (payload.get("representative_messages", []) or [])[:10]:
nickname = str(item.get("nickname") or "").strip() or "观众"
content = str(item.get("content") or "").strip()
if content:
supplements.append(f"{nickname}:{content[:42]}")
existing = set(normalized)
for item in supplements:
if item not in existing:
normalized.append(item)
existing.add(item)
if len(normalized) >= target_count:
break
return normalized[:target_count]
def _normalize_rank_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 3) -> List[str]:
"""
“梗王榜”兜底来源按优先级走:
1. 已聚合的长模板梗;
2. 重复短句;
3. 爆发情绪词。
这样即便模型漏写榜单,页面也能稳定展示“今天到底大家在刷什么”。
"""
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
if len(normalized) >= target_count:
return normalized[:target_count]
supplements: List[str] = []
for item in (payload.get("merged_templates", []) or [])[:3]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"{text[:30]}|全场 {count} 次")
for item in (payload.get("repeated_messages", []) or [])[:3]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"{text[:30]}|复读 {count} 次")
for item in (payload.get("burst_terms", []) or [])[:3]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
supplements.append(f"{text}|情绪爆发 {count} 次")
existing = set(normalized)
for item in supplements:
if item not in existing:
normalized.append(item)
existing.add(item)
if len(normalized) >= target_count:
break
return normalized[:target_count]
def _normalize_closing_text(payload: Dict[str, Any], closing_items: List[str]) -> str:
"""
收尾句只有一句,优先保留模型原话;
如果模型没给,就用当天最高峰时段和热词拼一个轻松结尾。
"""
for item in closing_items:
value = str(item or "").strip()
if value:
return value
peak_buckets = payload.get("peak_buckets", []) or []
if peak_buckets:
top_bucket = peak_buckets[0]
top_terms = [
str(term.get("term") or "").strip()
for term in (top_bucket.get("top_terms", []) or [])[:3]
if str(term.get("term") or "").strip()
]
return (
f"今晚最佳观影时段锁定 {str(top_bucket.get('start_time') or '')[-8:-3]},"
f"大家围着 {'、'.join(top_terms) or '节目效果'} 一起起哄,收工时空气里都还是梗。"
)
return "今天的直播结论很简单:操作未必全记住了,但弹幕梗已经自动住进群友脑回路。"
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
items: List[str] = []
seen = set()
def push(text: str, suffix: str = "") -> None:
value = str(text or "").strip()
if not value:
return
normalized_key = value
if normalized_key in seen:
return
seen.add(normalized_key)
items.append(f"{value}{suffix}".strip())
for item in (payload.get("merged_templates", []) or [])[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
push(text[:72], f"({count}次)")
for item in (payload.get("repeated_messages", []) or [])[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
push(text[:72], f"({count}次)")
for item in (payload.get("burst_terms", []) or [])[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
push(text[:36], f"(爆发 {count} 次)")
for item in (payload.get("top_terms", []) or [])[:6]:
term = str(item.get("term") or "").strip()
count = int(item.get("count", 0) or 0)
if term:
push(term, f"({count}次提及)")
return items[:limit]
def _render_insight_cards(items: List[str]) -> str:
labels = ["主线", "情绪", "梗点", "节奏", "反馈", "补充"]
blocks = []
for idx, item in enumerate(items[:6]):
extra_class = " full-span" if len(items[:6]) % 2 == 1 and idx == len(items[:6]) - 1 else ""
blocks.append(
f'
'
f'
{_escape(labels[idx] if idx < len(labels) else "观察")}
'
f'
{_escape(item)}
'
"
"
)
return "".join(blocks)
def _render_fans_scene_cards(items: List[str]) -> str:
blocks = []
for item in items[:6]:
blocks.append(
'
'
f'
{_escape(item)}
'
"
"
)
return "".join(blocks)
def _render_rank_cards(items: List[str]) -> str:
blocks = []
for idx, item in enumerate(items[:3], start=1):
blocks.append(
'
'
f'
TOP {idx}
'
f'
{_escape(item)}
'
"
"
)
return "".join(blocks)
def _build_fans_fun_metrics(payload: Dict[str, Any]) -> List[Dict[str, str]]:
"""
粉丝版避免直接沿用“运营指标”命名,改成更轻松的展示口径。
底层仍然来自同一份 payload,所以既分风格,又不损失真实性。
"""
meta = payload.get("report_meta", {}) or {}
peak_buckets = payload.get("peak_buckets", []) or []
repeated_messages = payload.get("repeated_messages", []) or []
burst_terms = payload.get("burst_terms", []) or []
top_bucket = peak_buckets[0] if peak_buckets else {}
top_repeat = repeated_messages[0] if repeated_messages else {}
top_burst = burst_terms[0] if burst_terms else {}
return [
{
"label": "今日弹幕量",
"value": str(meta.get("message_count", 0) or 0),
"hint": "今天一共刷了多少句",
},
{
"label": "围观群众",
"value": str(meta.get("unique_user_count", 0) or 0),
"hint": "参与一起起哄的人数",
},
{
"label": "最高能时段",
"value": str(top_bucket.get("start_time") or "")[-8:-3] or "--:--",
"hint": "弹幕最炸裂的时间点",
},
{
"label": "今日爆词",
"value": str(top_burst.get("text") or top_repeat.get("text") or "乐"),
"hint": "刷得最凶的那句",
},
]
def _render_fans_metric_cards(metrics: List[Dict[str, str]]) -> str:
blocks = []
for item in metrics:
blocks.append(
'
'
f'
{_escape(item.get("label", ""))}
'
f'
{_escape(item.get("value", ""))}
'
f'
{_escape(item.get("hint", ""))}
'
"
"
)
return "".join(blocks)
def _get_compact_scene_material(payload: Dict[str, Any]) -> Dict[str, Any]:
"""
兼容两种来源的粉丝日报载荷:
1. 新版 prompt 材料已经预先组装好的 compact_scene_material;
2. 正式渲染链路里仍然保留在 llm_compact 下的压缩材料。
这样模板层可以向后兼容,不会因为字段尚未完全迁移就出现空版块。
"""
compact = payload.get("compact_scene_material", {}) or {}
if compact:
return compact
return payload.get("llm_compact", {}) or {}
def _get_topic_evidence_clusters(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
兼容“已展开主题簇”和“仍放在 llm_compact.semantic_fact_hints.topic_clusters”两种结构。
"""
clusters = payload.get("topic_evidence_clusters", []) or []
if clusters:
return clusters
compact = _get_compact_scene_material(payload)
fact_hints = compact.get("semantic_fact_hints", {}) or {}
normalized = []
for item in (fact_hints.get("topic_clusters", []) or [])[:6]:
normalized.append({
"label": str(item.get("label") or "").strip(),
"count": int(item.get("match_count", item.get("count", 0)) or 0),
"user_count": int(item.get("user_count", 0) or 0),
"time_range": (
f"{str(item.get('first_hm') or '').strip()}-{str(item.get('last_hm') or '').strip()}"
).strip("-"),
"keywords": item.get("keywords", []) or [],
"samples": item.get("samples", []) or [],
})
return normalized
def _get_hero_mentions(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
compact = _get_compact_scene_material(payload)
return (
compact.get("semantic_fact_hints", {})
.get("hero_mentions", [])
or []
)
def _build_local_stats(payload: Dict[str, Any]) -> Dict[str, Any]:
"""
兼容新旧载荷的本地统计字段。
如果没有 top-level local_stats,就从 peak_buckets / repeated_messages / burst_terms / content_cues 临时拼一份,
保证“热点窗口与共识梗”始终有内容可用。
"""
local_stats = payload.get("local_stats", {}) or {}
if local_stats:
return local_stats
compact = _get_compact_scene_material(payload)
content_cues = compact.get("content_cues", []) or []
return {
"message_count": int((payload.get("report_meta", {}) or {}).get("message_count", 0) or 0),
"unique_user_count": int((payload.get("report_meta", {}) or {}).get("unique_user_count", 0) or 0),
"top_emotion_bursts": [
{
"text": str(item.get("text") or "").strip(),
"count": int(item.get("count", 0) or 0),
}
for item in content_cues
if str(item.get("kind") or "").strip() == "emotion" and str(item.get("text") or "").strip()
][:8],
"top_repeated_messages": [
{
"text": str(item.get("text") or "").strip(),
"count": int(item.get("count", 0) or 0),
"user_count": int(item.get("user_count", 0) or 0),
}
for item in (payload.get("repeated_messages", []) or [])[:8]
if str(item.get("text") or "").strip()
],
"peak_windows": [
{
"start_time": str(item.get("start_time") or "").strip(),
"message_count": int(item.get("message_count", 0) or 0),
"user_count": int(item.get("user_count", 0) or 0),
}
for item in (payload.get("peak_buckets", []) or [])[:6]
],
}
def _build_fans_effective_info_lines(payload: Dict[str, Any], limit: int = 6) -> List[str]:
"""
为粉丝日报补一层“有效信息速览”。
这里优先从本地提纯后的主题证据簇中拿事实,不依赖 LLM 自己归纳,
这样模板本身就能稳定承载更多有效信息。
"""
lines: List[str] = []
seen = set()
def push(text: str) -> None:
value = str(text or "").strip()
if not value or value in seen:
return
seen.add(value)
lines.append(value)
for item in _get_topic_evidence_clusters(payload)[:6]:
label = str(item.get("label") or "").strip()
count = int(item.get("count", 0) or 0)
time_range = str(item.get("time_range") or "").strip()
samples = item.get("samples", []) or []
sample_text = ""
if samples:
first_sample = samples[0]
sample_text = str(first_sample.get("content") or "").strip()[:42]
if label and sample_text:
push(f"{label}在 {time_range or '全场'} 持续被讨论,相关弹幕约 {count} 条,代表内容是「{sample_text}」。")
elif label:
push(f"{label}是今天的高关注话题之一,相关弹幕约 {count} 条。")
if len(lines) >= limit:
return lines[:limit]
hero_mentions = _get_hero_mentions(payload)
if hero_mentions:
hero_names = [str(item.get("hero") or "").strip() for item in hero_mentions[:4] if str(item.get("hero") or "").strip()]
if hero_names:
push(f"英雄讨论主要集中在 {'、'.join(hero_names)}。")
hot_windows = _build_local_stats(payload).get("peak_windows", []) or []
if hot_windows:
top_window = hot_windows[0]
push(
f"最热窗口出现在 {str(top_window.get('start_time') or '')[-8:-3]},"
f"该时段累计弹幕 {int(top_window.get('message_count', 0) or 0)} 条。"
)
return lines[:limit]
def _build_local_topic_focus_lines(payload: Dict[str, Any], limit: int = 4) -> List[str]:
"""
为“核心讨论话题”补充本地可直接确定的摘要句。
这里故意不让模型自己重新发明事实,而是把主题簇已经聚好的结果转成人能读懂的话。
"""
lines: List[str] = []
seen = set()
def push(text: str) -> None:
value = str(text or "").strip()
if not value or value in seen:
return
seen.add(value)
lines.append(value)
for item in _get_topic_evidence_clusters(payload)[:4]:
label = str(item.get("label") or "").strip()
keywords = [str(keyword).strip() for keyword in (item.get("keywords", []) or [])[:5] if str(keyword).strip()]
count = int(item.get("count", 0) or 0)
if label and keywords:
push(f"{label}是高频主线,相关讨论约 {count} 条,关键词集中在 {'、'.join(keywords)}。")
elif label:
push(f"{label}是今天反复被拉出来聊的主线之一,相关讨论约 {count} 条。")
if len(lines) >= limit:
return lines[:limit]
return lines[:limit]
def _build_local_hero_focus_lines(payload: Dict[str, Any], limit: int = 4) -> List[str]:
"""
为“英雄与对局焦点”准备本地兜底。
这部分直接复用英雄提及聚类,优先强调出现频次和代表发言,方便粉丝快速看懂今天在聊什么英雄。
"""
hero_mentions = _get_hero_mentions(payload)
lines: List[str] = []
seen = set()
def push(text: str) -> None:
value = str(text or "").strip()
if not value or value in seen:
return
seen.add(value)
lines.append(value)
for item in hero_mentions[:4]:
hero_name = str(item.get("hero") or "").strip()
mention_count = int(item.get("mention_count", 0) or 0)
samples = item.get("samples", []) or []
sample_text = ""
if samples:
sample_text = str(samples[0].get("content") or "").strip()[:36]
if hero_name and sample_text:
push(f"{hero_name}被提到 {mention_count} 次,现场典型弹幕是「{sample_text}」。")
elif hero_name:
push(f"{hero_name}是今天的主要英雄讨论点之一,被提到 {mention_count} 次。")
if len(lines) >= limit:
return lines[:limit]
return lines[:limit]
def _normalize_information_section_items(
llm_items: List[str],
local_items: List[str],
target_count: int,
) -> List[str]:
"""
将模型提炼结果与本地事实兜底合并。
设计目标:
1. 先尊重模型已经总结好的“可读句子”;
2. 如果模型漏了,就用本地证据补足;
3. 始终保证最终区块有信息量,而不是空标题。
"""
normalized: List[str] = []
seen = set()
for source in (llm_items, local_items):
for item in source:
value = str(item or "").strip()
if not value or value in seen:
continue
seen.add(value)
normalized.append(value)
if len(normalized) >= target_count:
return normalized[:target_count]
return normalized[:target_count]
def _render_fans_info_cards(items: List[str]) -> str:
blocks = []
for item in items[:6]:
blocks.append(
'
'
f'
{_escape(item)}
'
'
'
)
return "".join(blocks)
def _render_topic_clusters(topic_clusters: List[Dict[str, Any]]) -> str:
blocks = []
for item in topic_clusters[:5]:
label = str(item.get("label") or "").strip()
if not label:
continue
count = int(item.get("count", 0) or 0)
user_count = int(item.get("user_count", 0) or 0)
time_range = str(item.get("time_range") or "").strip()
samples = item.get("samples", []) or []
sample_lines = []
for sample in samples[:3]:
content = str(sample.get("content") or "").strip()
nickname = str(sample.get("nickname") or "").strip() or "观众"
hm = str(sample.get("hm") or "").strip()
if content:
sample_lines.append(f"{hm} {nickname}:{content[:42]}")
sample_html = "".join(f'
{_escape(line)}
' for line in sample_lines)
blocks.append(
'
'
f'
{_escape(label)}
'
f'
{_escape(time_range or "全场")} · {count} 条讨论 · {user_count} 人参与
'
f'
{sample_html}
'
'
'
)
return "".join(blocks)
def _render_hero_mentions(hero_mentions: List[Dict[str, Any]]) -> str:
blocks = []
for item in hero_mentions[:4]:
hero_name = str(item.get("hero") or "").strip()
if not hero_name:
continue
mention_count = int(item.get("mention_count", 0) or 0)
user_count = int(item.get("user_count", 0) or 0)
sample_text = ""
samples = item.get("samples", []) or []
if samples:
sample = samples[0]
sample_text = str(sample.get("content") or "").strip()[:46]
blocks.append(
'
'
f'
{_escape(hero_name)}
'
f'
{mention_count} 次提及 / {user_count} 人讨论
'
f'
{_escape(sample_text)}
'
'
'
)
return "".join(blocks)
def _render_hot_window_cards(hot_windows: List[Dict[str, Any]]) -> str:
blocks = []
for item in hot_windows[:4]:
start_time = str(item.get("start_time") or "")[-8:-3]
message_count = int(item.get("message_count", 0) or 0)
user_count = int(item.get("user_count", 0) or 0)
blocks.append(
'
'
f'
{_escape(start_time)}
'
f'
{message_count} 条弹幕 / {user_count} 人参与
'
'
'
)
# 页面层兜底:
# 如果热窗没有任何卡片,也返回一个说明块,避免整个区域看起来像渲染坏了。
if not blocks:
blocks.append(
'
'
'
--:--
'
'
当前载荷里没有可展示的热点窗口数据
'
'
'
)
return "".join(blocks)
def _render_repeat_digest(payload: Dict[str, Any]) -> str:
local_stats = _build_local_stats(payload)
repeated_messages = local_stats.get("top_repeated_messages", []) or []
emotion_bursts = local_stats.get("top_emotion_bursts", []) or []
blocks = []
for item in repeated_messages[:4]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
blocks.append(
'
'
f'{_escape(text[:28])}'
f'{count} 次'
'
'
)
for item in emotion_bursts[:4]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
blocks.append(
'
'
f'{_escape(text[:18])}'
f'{count} 次'
'
'
)
if not blocks:
blocks.append(
'
'
'暂无共识梗数据'
'0 次'
'
'
)
return "".join(blocks)
def _render_badges(top_badges: List[Dict[str, Any]]) -> str:
blocks = []
for item in top_badges[:6]:
badge_name = str(item.get("badge_name") or "").strip()
if not badge_name:
continue
blocks.append(
'
"
)
return "".join(blocks)
def _render_hot_times(peak_buckets: List[Dict[str, Any]]) -> str:
blocks = []
for item in peak_buckets[:3]:
start_time = str(item.get("start_time") or "")[-8:-3]
terms = [str(term.get("term") or "").strip() for term in (item.get("top_terms", []) or [])[:4]]
terms = [term for term in terms if term]
blocks.append(
'
'
f'
{_escape(start_time)}
'
f'
{_escape(item.get("message_count", 0))} 条弹幕
'
f'
{_escape(" / ".join(terms))}
'
"
"
)
return "".join(blocks)
def _render_active_users(top_active_users: List[Dict[str, Any]]) -> str:
blocks = []
for item in top_active_users[:10]:
nickname = str(item.get("nickname") or item.get("uid") or "").strip()
fans_name = str(item.get("fans_name") or "").strip()
fans_level = int(item.get("fans_level", 0) or 0)
room_level = int(item.get("room_level", 0) or 0)
message_count = int(item.get("message_count", 0) or 0)
chips = []
if fans_name:
fans_label = f"{fans_name} Lv{fans_level}" if fans_level > 0 else fans_name
chips.append(f'{_escape(fans_label)}')
if room_level > 0:
chips.append(f'{_escape(f"平台 Lv{room_level}")}')
meta_html = "".join(chips) if chips else '普通观众'
blocks.append(
'