diff --git a/plugins/douyu/report_template.py b/plugins/douyu/report_template.py index 4e2f206..22eb735 100644 --- a/plugins/douyu/report_template.py +++ b/plugins/douyu/report_template.py @@ -2,6 +2,10 @@ 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 "")) @@ -463,614 +467,25 @@ def render_daily_report_html( 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""" - - - - - - -
-
-
DOUYU DAILY REPORT
-
{_escape(title_name)}
-
{_escape(subtitle)}
-
-
-
- {metrics_html} -
- -
-
弹幕总结
-
-
-
-
整体观察
-
{_escape(lead_summary)}
-
-
- {_render_insight_cards(danmu_bullets)} -
-
-
给粉丝看的弹幕萃取
- {_render_list(fans_extract_bullets, item_class="fans-list")} -
-
-
-
高频梗
- {_render_list(template_items)} -
-
- -
- {_render_hot_times(payload.get("peak_buckets", []) or [])} -
-
- -
-
运营数据总结
-
- {_render_audience_trend_chart(audience_trend)} -
-
-
- {_render_list(operator_summary_lines)} -
-
-
活跃牌子
-
{_render_badges(operator.get("top_badges", []) or [])}
-
-
-
-
核心发言用户
-
- {_render_active_users(top_active_users)} -
-
-
- - -
-
- -""" - return html_doc + # 模板化渲染: + # 1. 报告样式和结构迁移到独立 HTML 模板文件; + # 2. Python 仅负责准备数据与片段,后续改 UI 只改模板即可。 + renderer = HtmlTemplateRenderer() + return renderer.render( + "plugins/douyu/templates/daily_report.html", + { + "title_name": title_name, + "subtitle": subtitle, + # 下列片段由当前函数内辅助方法生成,文本内容已做转义,可安全注入模板。 + "metrics_html": Markup(metrics_html), + "lead_summary": lead_summary, + "danmu_insights_html": Markup(_render_insight_cards(danmu_bullets)), + "fans_extract_html": Markup(_render_list(fans_extract_bullets, item_class="fans-list")), + "template_items_html": Markup(_render_list(template_items)), + "hot_times_html": Markup(_render_hot_times(payload.get("peak_buckets", []) or [])), + "audience_trend_html": Markup(_render_audience_trend_chart(audience_trend)), + "operator_summary_html": Markup(_render_list(operator_summary_lines)), + "badges_html": Markup(_render_badges(operator.get("top_badges", []) or [])), + "active_users_html": Markup(_render_active_users(top_active_users)), + }, + ) diff --git a/plugins/douyu/templates/daily_report.html b/plugins/douyu/templates/daily_report.html new file mode 100644 index 0000000..6a2c3d2 --- /dev/null +++ b/plugins/douyu/templates/daily_report.html @@ -0,0 +1,597 @@ + + + + + + + +
+
+
DOUYU DAILY REPORT
+
{{ title_name }}
+
{{ subtitle }}
+
+
+
{{ metrics_html }}
+ +
+
弹幕总结
+
+
+
+
整体观察
+
{{ lead_summary }}
+
+
{{ danmu_insights_html }}
+
+
给粉丝看的弹幕萃取
+ {{ fans_extract_html }} +
+
+
+
高频梗
+ {{ template_items_html }} +
+
+
{{ hot_times_html }}
+
+ +
+
运营数据总结
+
{{ audience_trend_html }}
+
+
{{ operator_summary_html }}
+
+
活跃牌子
+
{{ badges_html }}
+
+
+
+
核心发言用户
+
{{ active_users_html }}
+
+
+ + +
+
+ + \ No newline at end of file diff --git a/plugins/message_summary/config.toml b/plugins/message_summary/config.toml index e04cda3..a10e82e 100644 --- a/plugins/message_summary/config.toml +++ b/plugins/message_summary/config.toml @@ -11,3 +11,9 @@ retry_delays_seconds = [10, 20] [output] output_dir = "output" image_format = "png" +# 图片渲染模式: +# - template: 使用 HTML 模板渲染(推荐,便于后续只改模板) +# - markdown: 使用历史 md2image 样式 +summary_image_mode = "template" +# 总结卡片模板路径(相对项目根目录) +summary_image_template_path = "plugins/message_summary/templates/summary_card.html" diff --git a/plugins/message_summary/main.py b/plugins/message_summary/main.py index a8d09c4..b248751 100644 --- a/plugins/message_summary/main.py +++ b/plugins/message_summary/main.py @@ -1,4 +1,5 @@ import asyncio +import html import json import re import time @@ -7,6 +8,7 @@ from pathlib import Path from typing import Dict, Any, Tuple, Optional, List from loguru import logger +from markupsafe import Markup from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.plugin_interface import PluginStatus @@ -15,7 +17,8 @@ from utils.compress_chat_data import compress_chat_data from utils.decorator.plugin_decorators import plugin_stats_decorator from utils.decorator.points_decorator import plugin_points_cost from utils.decorator.rate_limit_decorator import group_feature_rate_limit -from utils.markdown_to_image import convert_md_str_to_image +from utils.html_template_renderer import HtmlTemplateRenderer +from utils.markdown_to_image import convert_md_str_to_image, html_to_image from utils.revoke.message_auto_revoke import MessageAutoRevoke from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus from utils.string_utils import remove_reasoning_content, remove_trailing_content @@ -94,6 +97,13 @@ class MessageSummaryPlugin(MessagePluginInterface): self._image_render_timeout_seconds = int(output_config.get("image_render_timeout_seconds", 90)) # 默认只尝试 1 次,优先保证任务快速返回;需要更高成功率可在配置里提高。 self._image_render_retries = int(output_config.get("image_render_retries", 1)) + # 输出模板配置: + # 1. summary_image_mode=template 时优先按 HTML 模板生图; + # 2. template 失败会自动回退到 markdown 模式,保证可用性。 + self._summary_image_mode = str(output_config.get("summary_image_mode", "template")).strip().lower() + self._summary_image_template_path = str( + output_config.get("summary_image_template_path", "plugins/message_summary/templates/summary_card.html") + ).strip() self.llm_client = UnifiedLLMClient(api_config) self._api_mode = self.llm_client.mode or self._api_mode self._response_mode = self.llm_client.response_mode or self._response_mode @@ -398,6 +408,91 @@ class MessageSummaryPlugin(MessagePluginInterface): cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() return cleaned + @staticmethod + def _summary_markdown_to_html(summary_text: str) -> str: + """把总结 Markdown 转为基础 HTML 片段(模板内部展示用)。""" + # 这里不依赖第三方 markdown 库,保证在最小运行环境也能稳定渲染。 + # 规则按“标题/列表/段落”三类做轻量转换,足够覆盖总结文本场景。 + lines = str(summary_text or "").splitlines() + html_parts: List[str] = [] + list_items: List[str] = [] + + def flush_list() -> None: + if not list_items: + return + html_parts.append("") + list_items.clear() + + for raw_line in lines: + line = raw_line.strip() + if not line: + flush_list() + continue + if line.startswith("## "): + flush_list() + html_parts.append(f"

{html.escape(line[3:].strip())}

") + continue + if line.startswith("### "): + flush_list() + html_parts.append(f"

{html.escape(line[4:].strip())}

") + continue + if line.startswith("- "): + list_items.append(f"
  • {html.escape(line[2:].strip())}
  • ") + continue + flush_list() + html_parts.append(f"

    {html.escape(line)}

    ") + flush_list() + return "".join(html_parts) + + def _render_summary_template_html(self, group_name: str, summary_text: str) -> str: + """根据模板路径渲染总结图片 HTML。""" + # 约束:模板只负责展示,正文仍然由模型生成并在此做安全转义后注入。 + renderer = HtmlTemplateRenderer() + summary_html = self._summary_markdown_to_html(summary_text) + return renderer.render( + self._summary_image_template_path, + { + "title": f"{group_name} 群聊总结", + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "summary_html": Markup(summary_html), + }, + ) + + async def _render_summary_image( + self, + answer: str, + group_name: str, + output_filename: str, + total_timeout: int, + ) -> str: + """生成总结图片:模板优先,失败自动回退 Markdown。""" + image_root = Path("temp") / "md2image" + image_root.mkdir(parents=True, exist_ok=True) + output_path = image_root / output_filename + + # 优先模板渲染,便于后续仅改 HTML 文件完成样式迭代。 + if self._summary_image_mode != "markdown": + try: + html_content = self._render_summary_template_html(group_name=group_name, summary_text=answer) + await asyncio.wait_for(html_to_image(html_content, str(output_path)), timeout=total_timeout) + if not output_path.exists() or output_path.stat().st_size < 1024: + raise RuntimeError("模板截图输出文件异常") + return str(output_path.resolve()) + except Exception as template_error: + self.LOG.warning(f"模板渲染失败,回退 Markdown 转图: {template_error}") + + # 回退逻辑:保持旧链路,确保模板异常时仍能发送图片。 + return await asyncio.wait_for( + convert_md_str_to_image( + answer, + output_filename, + max_retries=self._image_render_retries, + render_timeout_seconds=self._image_render_timeout_seconds, + html_timeout_seconds=min(30, self._image_render_timeout_seconds), + ), + timeout=total_timeout, + ) + def _stringify_output(self, value: Any) -> str: """把 workflow 输出统一转成文本""" if value is None: @@ -546,15 +641,12 @@ class MessageSummaryPlugin(MessagePluginInterface): self.LOG.info(f"开始生成图片: {output_path}") # 额外包一层总超时,确保就算底层依赖异常也不会把整个任务拖住。 total_timeout = max(30, self._image_render_timeout_seconds * self._image_render_retries + 10) - spath = await asyncio.wait_for( - convert_md_str_to_image( - answer, - output_path, - max_retries=self._image_render_retries, - render_timeout_seconds=self._image_render_timeout_seconds, - html_timeout_seconds=min(30, self._image_render_timeout_seconds), - ), - timeout=total_timeout, + # 统一走图片渲染入口:优先模板,失败自动降级到旧的 Markdown 转图链路。 + spath = await self._render_summary_image( + answer=answer, + group_name=group_name, + output_filename=output_path, + total_timeout=total_timeout, ) self.LOG.info(f"成功生成图片: {spath}") except Exception as e: diff --git a/plugins/message_summary/templates/summary_card.html b/plugins/message_summary/templates/summary_card.html new file mode 100644 index 0000000..4ee10bc --- /dev/null +++ b/plugins/message_summary/templates/summary_card.html @@ -0,0 +1,118 @@ + + + + + + + +
    +
    +
    群聊总结
    +
    {{ title }}
    +
    生成时间:{{ generated_at }}
    +
    +
    + {{ summary_html }} + +
    +
    + +