新增斗鱼粉丝向恶搞弹幕日报并拆分运营版
1. 新增斗鱼粉丝日报和强制粉丝日报命令,手动触发时走独立发送链路。\n2. 为粉丝向日报补充独立提示词、兜底文案、缓存分类和图片渲染逻辑。\n3. 新增粉丝向日报 HTML 模板与模板解析函数,整体风格调整为开心欢乐的整活总结。\n4. 保留原有斗鱼运营日报定时与发送逻辑,避免两种日报互相污染。
This commit is contained in:
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user