新增斗鱼粉丝向恶搞弹幕日报并拆分运营版

1. 新增斗鱼粉丝日报和强制粉丝日报命令,手动触发时走独立发送链路。\n2. 为粉丝向日报补充独立提示词、兜底文案、缓存分类和图片渲染逻辑。\n3. 新增粉丝向日报 HTML 模板与模板解析函数,整体风格调整为开心欢乐的整活总结。\n4. 保留原有斗鱼运营日报定时与发送逻辑,避免两种日报互相污染。
This commit is contained in:
liuwei
2026-04-27 12:22:30 +08:00
parent d09ccb788c
commit 515361c34f
4 changed files with 1022 additions and 32 deletions

View File

@@ -11,6 +11,25 @@ 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 (
'<div class="metric-card">'
@@ -61,6 +80,60 @@ def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str], List[str]
return lead, insight_items, fans_extract_items
def _split_fans_report_blocks(report_text: str) -> Dict[str, Any]:
"""
将“粉丝向恶搞日报”文本拆成模板需要的结构化区块。
约定模型尽量输出如下标题:
- 【今日笑点】
- 【弹幕名场面】
- 【梗王榜】
- 【收尾播报】
即便模型没有完全按约定输出,这里也会尽量兜底,保证页面不空。
"""
header_alias_map = {
"今日笑点": "laugh_points",
"笑点": "laugh_points",
"欢乐总结": "laugh_points",
"弹幕名场面": "famous_scenes",
"名场面": "famous_scenes",
"现场整活": "famous_scenes",
"梗王榜": "meme_rank",
"梗榜": "meme_rank",
"复读冠军": "meme_rank",
"收尾播报": "closing",
"结尾播报": "closing",
"结尾": "closing",
}
sections = {
"lead": "",
"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:
@@ -138,6 +211,130 @@ def _normalize_fans_extract_bullets(payload: Dict[str, Any], items: List[str], t
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()
@@ -193,6 +390,78 @@ def _render_insight_cards(items: List[str]) -> str:
return "".join(blocks)
def _render_fans_scene_cards(items: List[str]) -> str:
blocks = []
for item in items[:6]:
blocks.append(
'<div class="fans-scene-card">'
f'<div class="fans-scene-quote">{_escape(item)}</div>'
"</div>"
)
return "".join(blocks)
def _render_rank_cards(items: List[str]) -> str:
blocks = []
for idx, item in enumerate(items[:3], start=1):
blocks.append(
'<div class="meme-rank-card">'
f'<div class="meme-rank-no">TOP {idx}</div>'
f'<div class="meme-rank-text">{_escape(item)}</div>'
"</div>"
)
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(
'<div class="fans-metric-card">'
f'<div class="fans-metric-label">{_escape(item.get("label", ""))}</div>'
f'<div class="fans-metric-value">{_escape(item.get("value", ""))}</div>'
f'<div class="fans-metric-hint">{_escape(item.get("hint", ""))}</div>'
"</div>"
)
return "".join(blocks)
def _render_badges(top_badges: List[Dict[str, Any]]) -> str:
blocks = []
for item in top_badges[:6]:
@@ -489,3 +758,44 @@ def render_daily_report_html(
"active_users_html": Markup(_render_active_users(top_active_users)),
},
)
def render_fans_daily_report_html(
payload: Dict[str, Any],
fans_report_text: str,
) -> str:
"""
渲染“粉丝向恶搞日报”。
这份报告的设计重点不是运营洞察,而是把当天最有节目效果的弹幕氛围单独做成一页,
方便群里直接转发、看图找乐子。
"""
meta = payload.get("report_meta", {}) or {}
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
subtitle = (
f"{meta.get('anchor_day', '')} | 弹幕 {meta.get('message_count', 0)}"
f" | 围观群众 {meta.get('unique_user_count', 0)}"
)
sections = _split_fans_report_blocks(fans_report_text)
laugh_points = _normalize_funny_bullets(payload, sections.get("laugh_points", []), target_count=4)
famous_scenes = _normalize_scene_bullets(payload, sections.get("famous_scenes", []), target_count=5)
meme_rank = _normalize_rank_bullets(payload, sections.get("meme_rank", []), target_count=3)
closing_text = _normalize_closing_text(payload, sections.get("closing", []))
lead_text = str(sections.get("lead") or "").strip()
if not lead_text:
lead_text = "今天这场直播的弹幕主打一个集体上头,观众一边盯着画面,一边忙着把梗越刷越离谱。"
renderer = HtmlTemplateRenderer()
return renderer.render(
"plugins/douyu/templates/daily_fans_report.html",
{
"title_name": title_name,
"subtitle": subtitle,
"lead_text": lead_text,
# 粉丝版刻意弱化“分析感”,下面几块都只展示娱乐化后的结果。
"fans_metrics_html": Markup(_render_fans_metric_cards(_build_fans_fun_metrics(payload))),
"laugh_points_html": Markup(_render_list(laugh_points, item_class="funny-list")),
"famous_scenes_html": Markup(_render_fans_scene_cards(famous_scenes)),
"meme_rank_html": Markup(_render_rank_cards(meme_rank)),
"closing_text": closing_text,
},
)