diff --git a/plugins/douyu/main.py b/plugins/douyu/main.py index a745dd7..15233a3 100644 --- a/plugins/douyu/main.py +++ b/plugins/douyu/main.py @@ -462,7 +462,8 @@ class DouyuRedisManager: class DouyuPlugin(MessagePluginInterface): - _DAILY_REPORT_CACHE_VERSION = 3 + # 报告结构有新增(粉丝向弹幕萃取区块),提升缓存版本以触发重新生成。 + _DAILY_REPORT_CACHE_VERSION = 4 FEATURE_KEY = "DOUYU_MONITOR" FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]" @@ -1538,14 +1539,70 @@ class DouyuPlugin(MessagePluginInterface): "请输出一段适合放在日报图片上半部分的弹幕总结,要求:\n" "1. 先用 1 段总述直播氛围与主线。\n" "2. 再用 5 条要点总结观众关注点、情绪变化、反复出现的梗、节奏变化和额外反馈,每条只写一句。\n" - "3. 语言像运营复盘,简洁自然。\n" - "4. 不要写标题,不要写“根据数据”。\n\n" + "3. 另起一行固定写标题:`【粉丝向弹幕萃取】`。\n" + "4. 在该标题下输出 4-6 条短句,尽量保留弹幕原话风格(可以保留口头语、玩梗、情绪词)。\n" + "5. 整体语气要像“直播间现场记录”,不要写成运营复盘。\n" + "6. 不要写“根据数据”“建议”“策略”等词。\n\n" f"主播:{meta.get('nickname') or meta.get('room_name') or meta.get('room_id')}\n" f"日期:{meta.get('anchor_day', '')}\n" f"材料:\n{json.dumps(payload, ensure_ascii=False, indent=2)}" ) return system_prompt, user_prompt + def _build_fans_extract_lines(self, payload: Dict[str, Any], limit: int = 6) -> List[str]: + # 粉丝向萃取强调“可读、像现场弹幕”,优先取代表发言,再补充重复梗与情绪短词。 + representative_messages = payload.get("representative_messages", []) or [] + repeated_messages = payload.get("repeated_messages", []) or [] + merged_templates = payload.get("merged_templates", []) or [] + burst_terms = payload.get("burst_terms", []) or [] + + lines: List[str] = [] + seen = set() + + def push(text: str) -> None: + value = str(text or "").strip() + if not value: + return + key = value.lower() + if key in seen: + return + seen.add(key) + lines.append(value) + + for item in representative_messages[:10]: + nickname = str(item.get("nickname") or "").strip() or "观众" + content = str(item.get("content") or "").strip() + if content: + push(f"{nickname}:{content[:56]}") + if len(lines) >= limit: + return lines[:limit] + + for item in repeated_messages[:6]: + text = str(item.get("text") or "").strip() + count = int(item.get("count", 0) or 0) + if text: + push(f"复读梗「{text[:36]}」刷了 {count} 次。") + if len(lines) >= limit: + return lines[:limit] + + for item in merged_templates[:6]: + text = str(item.get("text") or "").strip() + count = int(item.get("count", 0) or 0) + if text: + push(f"共识弹幕「{text[:36]}」出现 {count} 次。") + if len(lines) >= limit: + return lines[:limit] + + for item in burst_terms[:4]: + text = str(item.get("text") or "").strip() + count = int(item.get("count", 0) or 0) + if text: + push(f"情绪短词「{text}」集中出现 {count} 次。") + if len(lines) >= limit: + return lines[:limit] + + return lines[:limit] + def _build_fallback_daily_report(self, payload: Dict[str, Any]) -> str: meta = payload.get("report_meta", {}) or {} title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播") @@ -1671,6 +1728,12 @@ class DouyuPlugin(MessagePluginInterface): lines.append("- 情绪特点:代表性发言里既有对操作和决策的即时反馈,也有大量玩梗、调侃和情绪宣泄。") if top_terms: lines.append(f"- 关注焦点:高频词主要落在 {'、'.join(top_terms[:6])},说明观众注意力相对集中。") + # 在兜底模式下也强制补出“粉丝向弹幕萃取”,避免图片模板出现空区块。 + fans_extract_lines = self._build_fans_extract_lines(payload, limit=6) + if fans_extract_lines: + lines.append("【粉丝向弹幕萃取】") + for item in fans_extract_lines: + lines.append(f"- {item}") return "\n".join(lines).strip() def _build_operator_summary_text(self, payload: Dict[str, Any]) -> str: diff --git a/plugins/douyu/report_template.py b/plugins/douyu/report_template.py index 16dc80f..4e2f206 100644 --- a/plugins/douyu/report_template.py +++ b/plugins/douyu/report_template.py @@ -24,19 +24,37 @@ def _render_list(items: List[str], item_class: str = "bullet-list") -> str: return f'' if lis else "" -def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str]]: +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("- "): - insight_items.append(stripped[2:].strip()) + if in_fans_extract_block: + fans_extract_items.append(stripped[2:].strip()) + else: + insight_items.append(stripped[2:].strip()) else: - lead_parts.append(stripped) + # 非 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 + return lead, insight_items, fans_extract_items def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]: @@ -75,6 +93,47 @@ def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target 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 _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]: items: List[str] = [] seen = set() @@ -400,8 +459,9 @@ def render_daily_report_html( top_active_users = payload.get("operator_metrics", {}).get("top_active_users", []) or [] audience_trend = payload.get("audience_trend", {}) or {} - lead_summary, danmu_bullets = _split_summary_blocks(danmu_summary) + lead_summary, danmu_bullets, fans_extract_bullets = _split_summary_blocks(danmu_summary) danmu_bullets = _normalize_summary_bullets(payload, danmu_bullets, target_count=5) + fans_extract_bullets = _normalize_fans_extract_bullets(payload, fans_extract_bullets, target_count=6) html_doc = f""" @@ -589,6 +649,30 @@ def render_daily_report_html( grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }} + .fans-panel {{ + margin-top: 14px; + padding: 14px 15px 12px; + border-radius: 18px; + background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(245,250,255,0.94)); + border: 1px solid rgba(73, 136, 224, 0.18); + }} + .fans-title {{ + color: #1d4ed8; + font-size: 13px; + letter-spacing: .06em; + font-weight: 700; + margin-bottom: 8px; + }} + .fans-list {{ + margin: 0; + padding-left: 18px; + }} + .fans-list li {{ + color: #1e3a5f; + margin: 8px 0; + line-height: 1.65; + font-size: 14px; + }} .insight-card {{ padding: 15px 16px; border-radius: 18px; @@ -946,6 +1030,10 @@ def render_daily_report_html(
{_render_insight_cards(danmu_bullets)}
+
+
给粉丝看的弹幕萃取
+ {_render_list(fans_extract_bullets, item_class="fans-list")} +
高频梗