diff --git a/plugins/douyu/config.toml b/plugins/douyu/config.toml index cd39b1b..925bfae 100644 --- a/plugins/douyu/config.toml +++ b/plugins/douyu/config.toml @@ -10,7 +10,9 @@ command = [ "取消订阅鱼吧", "鱼吧订阅列表", "#斗鱼弹幕日报", - "斗鱼弹幕日报" + "斗鱼弹幕日报", + "#斗鱼粉丝日报", + "斗鱼粉丝日报" ] check_interval_minutes = 5 api_url_template = "https://www.douyu.com/betard/{room_id}" diff --git a/plugins/douyu/main.py b/plugins/douyu/main.py index cc14fde..68a2c02 100644 --- a/plugins/douyu/main.py +++ b/plugins/douyu/main.py @@ -23,7 +23,7 @@ from base.plugin_common.plugin_interface import PluginStatus from db.connection import DBConnectionManager from utils.ai.unified_llm import UnifiedLLMClient from plugins.douyu.danmu_summary import DouyuDanmuSummaryHelper -from plugins.douyu.report_template import render_daily_report_html +from plugins.douyu.report_template import render_daily_report_html, render_fans_daily_report_html from utils.decorator.async_job import async_job from utils.decorator.plugin_decorators import plugin_stats_decorator from utils.decorator.points_decorator import plugin_points_cost @@ -464,9 +464,9 @@ class DouyuRedisManager: class DouyuPlugin(MessagePluginInterface): # 报告缓存版本号: # 1. 版本升级后会自动让历史缓存失效,避免继续复用旧文本/旧图片; - # 2. 本次将版本提升到 5,用于修复“手动触发日报未走 Dify(命中缓存)”的问题, - # 确保升级后首次执行会重新走 LLM 生成链路。 - _DAILY_REPORT_CACHE_VERSION = 5 + # 2. 本次将版本提升到 6,新增“粉丝向恶搞日报”的独立结果类型,并同步刷新旧缓存, + # 确保上线后不会误复用旧版图片结构或旧版摘要文案。 + _DAILY_REPORT_CACHE_VERSION = 6 FEATURE_KEY = "DOUYU_MONITOR" FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]" @@ -509,7 +509,8 @@ class DouyuPlugin(MessagePluginInterface): self.redis_manager: Optional[DouyuRedisManager] = None self._commands = ["斗鱼订阅", "取消斗鱼订阅", "斗鱼订阅列表", "斗鱼订阅提醒", "取消斗鱼订阅提醒", "订阅鱼吧", "取消订阅鱼吧", "鱼吧订阅列表", - "#斗鱼弹幕日报", "斗鱼弹幕日报", "#强制斗鱼弹幕日报", "强制斗鱼弹幕日报"] + "#斗鱼弹幕日报", "斗鱼弹幕日报", "#强制斗鱼弹幕日报", "强制斗鱼弹幕日报", + "#斗鱼粉丝日报", "斗鱼粉丝日报", "#强制斗鱼粉丝日报", "强制斗鱼粉丝日报"] self._api_template = "https://www.douyu.com/betard/{room_id}" self._yuba_api = "https://yuba.douyu.com/wgapi/yubanc/api/feed/getUserFeedList" self._user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" @@ -542,6 +543,23 @@ class DouyuPlugin(MessagePluginInterface): return f"{type(exc).__name__}: {message}" return type(exc).__name__ + @staticmethod + def _parse_anchor_day_from_command(parts: List[str]) -> Tuple[bool, str]: + """ + 统一解析日报命令里的日期参数。 + 返回值说明: + 1. 第一个布尔值表示日期是否合法; + 2. 第二个字符串在合法时是最终日期,不合法时保留原始输入,方便上层提示用户。 + """ + anchor_day = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + if len(parts) < 2: + return True, anchor_day + day_text = parts[1].strip() + try: + return True, datetime.strptime(day_text, "%Y-%m-%d").strftime("%Y-%m-%d") + except Exception: + return False, day_text + async def _fetch_json_with_retries(self, session: aiohttp.ClientSession, url: str, headers: Dict[str, str], context: str, params: Optional[Dict[str, Any]] = None) -> Any: @@ -819,14 +837,10 @@ class DouyuPlugin(MessagePluginInterface): await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender) return True, "仅支持群聊" parts = content.split() - anchor_day = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") - if len(parts) >= 2: - day_text = parts[1].strip() - try: - anchor_day = datetime.strptime(day_text, "%Y-%m-%d").strftime("%Y-%m-%d") - except Exception: - await self.bot.send_text_message(roomid, "日期格式错误,请使用:#斗鱼弹幕日报 2026-04-07", sender) - return True, "日期格式错误" + ok, anchor_day = self._parse_anchor_day_from_command(parts) + if not ok: + await self.bot.send_text_message(roomid, "日期格式错误,请使用:#斗鱼弹幕日报 2026-04-07", sender) + return True, "日期格式错误" await self.bot.send_text_message(roomid, f"⏳ 正在生成斗鱼弹幕日报:{anchor_day}", sender) # 普通手动命令也默认重生成,避免命中缓存后看起来“没有走 Dify”。 # 定时任务仍保留缓存策略,这里只影响人工触发路径。 @@ -846,14 +860,10 @@ class DouyuPlugin(MessagePluginInterface): await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender) return True, "仅支持群聊" parts = content.split() - anchor_day = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") - if len(parts) >= 2: - day_text = parts[1].strip() - try: - anchor_day = datetime.strptime(day_text, "%Y-%m-%d").strftime("%Y-%m-%d") - except Exception: - await self.bot.send_text_message(roomid, "日期格式错误,请使用:#强制斗鱼弹幕日报 2026-04-07", sender) - return True, "日期格式错误" + ok, anchor_day = self._parse_anchor_day_from_command(parts) + if not ok: + await self.bot.send_text_message(roomid, "日期格式错误,请使用:#强制斗鱼弹幕日报 2026-04-07", sender) + return True, "日期格式错误" # 这里明确提示“强制重生成”,便于群内区分普通日报和回归测试操作。 await self.bot.send_text_message(roomid, f"⏳ 正在强制重生成斗鱼弹幕日报:{anchor_day}", sender) delivered = await self._send_daily_reports( @@ -867,6 +877,48 @@ class DouyuPlugin(MessagePluginInterface): await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼弹幕日报:{anchor_day}", sender) return True, "暂无日报" + if first_token in {"#斗鱼粉丝日报", "斗鱼粉丝日报"}: + if not roomid: + await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender) + return True, "仅支持群聊" + parts = content.split() + ok, anchor_day = self._parse_anchor_day_from_command(parts) + if not ok: + await self.bot.send_text_message(roomid, "日期格式错误,请使用:#斗鱼粉丝日报 2026-04-07", sender) + return True, "日期格式错误" + await self.bot.send_text_message(roomid, f"🎉 正在生成斗鱼粉丝日报:{anchor_day}", sender) + # 粉丝版定位为“开心图文”,手动触发时默认直接重生成, + # 这样群里测试文案、模板时可以马上看到最新乐子版本。 + delivered = await self._send_fans_daily_reports( + anchor_day, + target_group_id=roomid, + force_regenerate=True, + ) + if delivered: + return True, f"斗鱼粉丝日报已发送:{anchor_day}" + await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼粉丝日报:{anchor_day}", sender) + return True, "暂无日报" + + if first_token in {"#强制斗鱼粉丝日报", "强制斗鱼粉丝日报"}: + if not roomid: + await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender) + return True, "仅支持群聊" + parts = content.split() + ok, anchor_day = self._parse_anchor_day_from_command(parts) + if not ok: + await self.bot.send_text_message(roomid, "日期格式错误,请使用:#强制斗鱼粉丝日报 2026-04-07", sender) + return True, "日期格式错误" + await self.bot.send_text_message(roomid, f"🎉 正在强制重生成斗鱼粉丝日报:{anchor_day}", sender) + delivered = await self._send_fans_daily_reports( + anchor_day, + target_group_id=roomid, + force_regenerate=True, + ) + if delivered: + return True, f"斗鱼粉丝日报已强制重生成并发送:{anchor_day}" + await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼粉丝日报:{anchor_day}", sender) + return True, "暂无日报" + return False, None async def _scheduled_check_job(self): @@ -1291,14 +1343,19 @@ class DouyuPlugin(MessagePluginInterface): os.makedirs(path, exist_ok=True) return path - def _daily_report_cache_path(self, room_id: str, anchor_day: str) -> str: + def _daily_report_cache_path(self, room_id: str, anchor_day: str, report_kind: str = "operator") -> str: + # 把不同风格的日报结果拆到独立缓存文件中: + # 1. 运营版继续使用 operator; + # 2. 粉丝向恶搞版使用 fans; + # 3. 这样两套模板和文本互不覆盖,便于分别调试和回归。 + safe_kind = str(report_kind or "operator").strip().lower() or "operator" return os.path.join( self._daily_report_cache_dir(), - f"{room_id}_{anchor_day.replace('-', '')}_daily_report_result.json", + f"{room_id}_{anchor_day.replace('-', '')}_{safe_kind}_daily_report_result.json", ) - def _load_daily_report_cache(self, room_id: str, anchor_day: str) -> Optional[Dict[str, Any]]: - cache_path = self._daily_report_cache_path(room_id, anchor_day) + def _load_daily_report_cache(self, room_id: str, anchor_day: str, report_kind: str = "operator") -> Optional[Dict[str, Any]]: + cache_path = self._daily_report_cache_path(room_id, anchor_day, report_kind=report_kind) if not os.path.exists(cache_path): return None try: @@ -1307,16 +1364,26 @@ class DouyuPlugin(MessagePluginInterface): if isinstance(data, dict): return data except Exception as e: - logger.warning(f"读取斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}): {e}") + logger.warning( + f"读取斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}, kind={report_kind}): {e}" + ) return None - def _save_daily_report_cache(self, room_id: str, anchor_day: str, data: Dict[str, Any]) -> None: - cache_path = self._daily_report_cache_path(room_id, anchor_day) + def _save_daily_report_cache( + self, + room_id: str, + anchor_day: str, + data: Dict[str, Any], + report_kind: str = "operator", + ) -> None: + cache_path = self._daily_report_cache_path(room_id, anchor_day, report_kind=report_kind) try: with open(cache_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: - logger.warning(f"保存斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}): {e}") + logger.warning( + f"保存斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}, kind={report_kind}): {e}" + ) @staticmethod def _resolve_existing_report_image(image_path: Optional[str]) -> Optional[str]: @@ -1675,6 +1742,70 @@ class DouyuPlugin(MessagePluginInterface): ) return system_prompt, user_prompt + def _build_fans_daily_report_prompt(self, payload: Dict[str, Any]) -> Tuple[str, str]: + """ + 粉丝版日报提示词设计目标: + 1. 和运营版彻底区分开,不再强调“策略、复盘、活跃质量”; + 2. 保留真实弹幕语境,让输出像“群友拿着回放在整活”; + 3. 允许轻微恶搞和夸张,但不能编造未出现的事件,也不能攻击主播或观众。 + """ + meta = payload.get("report_meta", {}) or {} + system_prompt = ( + "你是斗鱼直播间的粉丝向整活日报编辑。" + "请只根据提供的真实弹幕材料,输出一份开心、欢乐、带一点恶搞气质的中文总结。" + "语气要像群友在复盘名场面,不要写成运营分析,不要编造剧情,不要使用代码块。" + ) + user_prompt = ( + "请输出一份适合给粉丝看的《斗鱼弹幕乐子日报》,严格按下面结构输出:\n" + "1. 开头先写 1 段总述,概括今天直播间的整体节目效果和气氛。\n" + "2. 另起一行写标题:`【今日笑点】`,下面写 4 条 bullet,每条一句,突出最有节目效果的地方。\n" + "3. 另起一行写标题:`【弹幕名场面】`,下面写 4-6 条 bullet,尽量保留弹幕原话风格,像现场回放。\n" + "4. 另起一行写标题:`【梗王榜】`,下面写 3 条 bullet,把今天最刷屏、最有共识的梗排出来。\n" + "5. 另起一行写标题:`【收尾播报】`,下面只写 1 句收尾,轻松一点,像群里发图后的总结句。\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_funny_scene_lines(self, payload: Dict[str, Any], limit: int = 5) -> List[str]: + """ + 组装“弹幕名场面”兜底素材。 + 优先级: + 1. 代表性原始弹幕,保证现场感; + 2. 重复刷屏梗,保证“今天大家到底在笑什么”能被看出来。 + """ + lines: List[str] = [] + seen = set() + + def push(text: str) -> None: + value = str(text or "").strip() + if not value: + return + normalized = value.lower() + if normalized in seen: + return + seen.add(normalized) + lines.append(value) + + for item in (payload.get("representative_messages", []) or [])[:12]: + nickname = str(item.get("nickname") or "").strip() or "观众" + content = str(item.get("content") or "").strip() + if content: + push(f"{nickname}:{content[:48]}") + if len(lines) >= limit: + return lines[:limit] + + 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(f"复读现场:{text[:40]}(今天被刷了 {count} 次)") + if len(lines) >= limit: + return lines[:limit] + return lines[:limit] + def _build_fans_extract_lines(self, payload: Dict[str, Any], limit: int = 6) -> List[str]: # 粉丝向萃取强调“可读、像现场弹幕”,优先取代表发言,再补充重复梗与情绪短词。 representative_messages = payload.get("representative_messages", []) or [] @@ -1729,6 +1860,89 @@ class DouyuPlugin(MessagePluginInterface): return lines[:limit] + def _build_fallback_fans_daily_report(self, payload: Dict[str, Any]) -> str: + """ + 当 LLM 不可用或返回空内容时,仍然生成一份可直接发群的粉丝版日报。 + 兜底文本保持“有梗但不胡编”的原则,所有句子都只从真实弹幕统计结果里取材。 + """ + meta = payload.get("report_meta", {}) or {} + top_terms = [ + str(item.get("term") or "").strip() + for item in (payload.get("top_terms", []) or [])[:5] + if str(item.get("term") or "").strip() + ] + merged_templates = payload.get("merged_templates", []) or [] + burst_terms = payload.get("burst_terms", []) or [] + peak_buckets = payload.get("peak_buckets", []) or [] + repeated_messages = payload.get("repeated_messages", []) or [] + anchor_day = str(meta.get("anchor_day", "") or "") + + lead_parts = [ + f"{anchor_day} 这场直播,弹幕区整体处于高能围观状态,大家一边盯着直播内容,一边围着" + f"{'、'.join(top_terms[:4]) or '节目效果'}疯狂接梗。" + ] + if merged_templates: + lead_parts.append( + f"尤其是「{str(merged_templates[0].get('text') or '').strip()[:26]}」这类共识弹幕,一看就是全场默认会背。" + ) + + lines = [" ".join(lead_parts).strip(), "【今日笑点】"] + + if peak_buckets: + top_bucket = peak_buckets[0] + lines.append( + f"- {str(top_bucket.get('start_time') or '')[-8:-3]} 前后弹幕密度冲高,直播间像突然集体抢到麦,乐子值直接拉满。" + ) + if repeated_messages: + first_repeat = repeated_messages[0] + lines.append( + f"- 复读冠军是「{str(first_repeat.get('text') or '').strip()[:32]}」,光这句就被来回刷了 {int(first_repeat.get('count', 0) or 0)} 次。" + ) + if burst_terms: + first_burst = burst_terms[0] + lines.append( + f"- 情绪词「{str(first_burst.get('text') or '').strip()}」反复出现 {int(first_burst.get('count', 0) or 0)} 次,说明那一段大家已经彻底上头。" + ) + if top_terms: + lines.append(f"- 今天的集体关注点基本围着 {'、'.join(top_terms[:4])} 打转,谁路过都会被梗吸进去。") + + lines.append("【弹幕名场面】") + for item in self._build_funny_scene_lines(payload, limit=5): + lines.append(f"- {item}") + + lines.append("【梗王榜】") + rank_items: List[str] = [] + for item in merged_templates[:2]: + text = str(item.get("text") or "").strip() + count = int(item.get("count", 0) or 0) + if text: + rank_items.append(f"{text[:30]}|全场 {count} 次") + for item in burst_terms[:2]: + text = str(item.get("text") or "").strip() + count = int(item.get("count", 0) or 0) + if text: + rank_items.append(f"{text}|情绪爆发 {count} 次") + for item in repeated_messages[:3]: + if len(rank_items) >= 3: + break + text = str(item.get("text") or "").strip() + count = int(item.get("count", 0) or 0) + if text: + candidate = f"{text[:30]}|复读 {count} 次" + if candidate not in rank_items: + rank_items.append(candidate) + for item in rank_items[:3]: + lines.append(f"- {item}") + + lines.append("【收尾播报】") + if peak_buckets: + lines.append( + f"- 今天的直播内容未必全部记住了,但 {str(peak_buckets[0].get('start_time') or '')[-8:-3]} 那波弹幕起哄,已经足够做成群内经典片段。" + ) + else: + lines.append("- 今天的直播总结成一句话就是:画面会结束,梗不会下播。") + return "\n".join(lines).strip() + 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 "主播") @@ -2028,6 +2242,72 @@ class DouyuPlugin(MessagePluginInterface): ) return self._build_fallback_danmu_summary(payload) + async def _generate_fans_daily_report_text(self, payload: Dict[str, Any]) -> str: + """ + 生成独立的粉丝向恶搞日报正文。 + 这里继续复用统一的 LLM 客户端,但通过不同 task_type 和 prompt 把风格切开。 + """ + if self._daily_report_use_llm and self._daily_report_llm_client: + system_prompt, user_prompt = self._build_fans_daily_report_prompt(payload) + result = await asyncio.to_thread( + self._call_daily_report_llm, + task_type="fans_daily_report", + system_prompt=system_prompt, + user_prompt=user_prompt, + payload=payload, + tag=f"douyu_fans_daily_report_{(payload.get('report_meta', {}) or {}).get('room_id', '')}", + ) + if result: + text = result.strip() + if len(text) > self._daily_report_max_length: + return text[: self._daily_report_max_length - 20].rstrip() + "\n...(已截断)" + return text + logger.warning( + f"斗鱼粉丝日报 LLM 生成失败: model={self._daily_report_llm_client.model}, " + f"last_error={self._daily_report_llm_client.last_error}" + ) + return self._build_fallback_fans_daily_report(payload) + + async def _build_fans_daily_report_markdown(self, payload: Dict[str, Any]) -> str: + """ + Markdown 版本主要用于图片模板渲染失败时兜底。 + 即使最终还是走通用 markdown 截图,也要尽量保留粉丝版的结构感。 + """ + meta = payload.get("report_meta", {}) or {} + title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播") + fans_report_text = await self._generate_fans_daily_report_text(payload) + lines = [ + f"# {title_name} 的弹幕乐子日报", + f"{meta.get('anchor_day', '')}|弹幕 {meta.get('message_count', 0)}|围观群众 {meta.get('unique_user_count', 0)}", + "", + fans_report_text, + ] + return "\n".join(lines).strip() + + async def _render_fans_daily_report_image(self, payload: Dict[str, Any]) -> Optional[str]: + markdown = await self._build_fans_daily_report_markdown(payload) + room_id = str((payload.get("report_meta", {}) or {}).get("room_id", "") or "room") + anchor_day = str((payload.get("report_meta", {}) or {}).get("anchor_day", "") or "").replace("-", "") + filename = f"douyu_fans_daily_report_{room_id}_{anchor_day}.png" + try: + fans_report_text = await self._generate_fans_daily_report_text(payload) + html_content = render_fans_daily_report_html( + payload=payload, + fans_report_text=fans_report_text, + ) + output_dir = os.path.join(os.getcwd(), "temp", "md2image") + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, filename) + await html_to_image(html_content, output_path) + return str(Path(output_path).resolve()) + except Exception as e: + logger.error(f"斗鱼粉丝日报专用模板图片生成失败(room={room_id}, day={anchor_day}): {e}") + try: + return await convert_md_str_to_image(markdown, filename) + except Exception as e: + logger.error(f"斗鱼粉丝日报图片生成失败(room={room_id}, day={anchor_day}): {e}") + return None + async def _build_daily_report_markdown(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 "主播") @@ -2130,7 +2410,9 @@ class DouyuPlugin(MessagePluginInterface): ) -> Dict[str, Any]: # force_regenerate=True 时,跳过本地缓存读取,直接重新生成文本/图片并覆盖缓存。 # 这样可以在模型提示词或模板变更后,通过命令立即验证最新效果。 - cached = {} if force_regenerate else (self._load_daily_report_cache(room_id, anchor_day) or {}) + cached = {} if force_regenerate else ( + self._load_daily_report_cache(room_id, anchor_day, report_kind="operator") or {} + ) cached_image = self._resolve_existing_report_image(cached.get("report_image")) cached_text = str(cached.get("report_text") or "").strip() cached_version = int(cached.get("cache_version", 0) or 0) @@ -2154,7 +2436,51 @@ class DouyuPlugin(MessagePluginInterface): "report_image": report_image, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } - self._save_daily_report_cache(room_id, anchor_day, result) + self._save_daily_report_cache(room_id, anchor_day, result, report_kind="operator") + result["cached"] = False + return result + + async def _get_or_create_fans_daily_report_result( + self, + room_id: str, + anchor_day: str, + payload: Dict[str, Any], + *, + force_regenerate: bool = False, + ) -> Dict[str, Any]: + """ + 粉丝向日报使用独立缓存: + 1. 避免和运营版相互覆盖; + 2. 便于后续单独升级风格、模板、提示词; + 3. 手动调试时也能明确区分当前命中的到底是哪一类结果。 + """ + cached = {} if force_regenerate else ( + self._load_daily_report_cache(room_id, anchor_day, report_kind="fans") or {} + ) + cached_image = self._resolve_existing_report_image(cached.get("report_image")) + cached_text = str(cached.get("report_text") or "").strip() + cached_version = int(cached.get("cache_version", 0) or 0) + if cached_version >= self._DAILY_REPORT_CACHE_VERSION and (cached_image or cached_text): + return { + "report_text": cached_text, + "report_image": cached_image, + "cached": True, + } + + report_text = await self._generate_fans_daily_report_text(payload) + report_image = None + if self._daily_report_send_image: + report_image = await self._render_fans_daily_report_image(payload) + + result = { + "room_id": room_id, + "anchor_day": anchor_day, + "cache_version": self._DAILY_REPORT_CACHE_VERSION, + "report_text": report_text, + "report_image": report_image, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + self._save_daily_report_cache(room_id, anchor_day, result, report_kind="fans") result["cached"] = False return result @@ -2225,6 +2551,72 @@ class DouyuPlugin(MessagePluginInterface): ) return delivered_any + async def _send_fans_daily_reports( + self, + anchor_day: str, + target_group_id: Optional[str] = None, + *, + force_regenerate: bool = False, + ) -> bool: + """ + 发送粉丝向恶搞日报。 + 当前刻意不复用“已发送标记”: + 1. 它不是原有定时任务的一部分,默认按手动召回理解; + 2. 群里想反复看不同版本文案时,不会被“今天已经发过”拦住。 + """ + rooms = ( + set(self.redis_manager.list_group_rooms(target_group_id)) + if target_group_id + else self.redis_manager.all_subscribed_rooms() + ) + if not rooms: + logger.info( + f"斗鱼粉丝日报无可处理房间: day={anchor_day}, target_group={target_group_id or 'ALL'}" + ) + return False + + delivered_any = False + for room_id in rooms: + sessions = self._load_sessions_for_anchor_day(room_id, anchor_day) + if not sessions: + logger.info(f"斗鱼粉丝日报无 session: room={room_id}, day={anchor_day}") + continue + if any(bool(session.get("is_live")) for session in sessions): + logger.info(f"斗鱼粉丝日报存在直播中场次,跳过: room={room_id}, day={anchor_day}") + continue + + payload = self._build_daily_report_payload(room_id, anchor_day, sessions) + if not payload: + logger.info( + f"斗鱼粉丝日报 payload 为空: room={room_id}, day={anchor_day}, " + f"sessions={len(sessions)}, min_messages={self._daily_report_min_messages}" + ) + continue + + report_result = await self._get_or_create_fans_daily_report_result( + room_id, + anchor_day, + payload, + force_regenerate=force_regenerate, + ) + report_text = str(report_result.get("report_text") or "").strip() + report_image = self._resolve_existing_report_image(report_result.get("report_image")) + groups = [target_group_id] if target_group_id else self.redis_manager.groups_for_room(room_id) + for gid in groups: + if not gid: + continue + if GroupBotManager.get_group_permission(gid, self.feature) != PermissionStatus.ENABLED: + continue + try: + if report_image: + await self.bot.send_image_message(gid, Path(report_image)) + else: + await self.bot.send_text_message(gid, report_text) + delivered_any = True + except Exception as e: + logger.error(f"发送斗鱼粉丝日报失败(room={room_id}, group={gid}): {e}") + return delivered_any + def _start_danmu_record(self, room_id: str): recorder = self._get_danmu_recorder(room_id) recorder.start() diff --git a/plugins/douyu/report_template.py b/plugins/douyu/report_template.py index 22eb735..283eceb 100644 --- a/plugins/douyu/report_template.py +++ b/plugins/douyu/report_template.py @@ -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 ( '