diff --git a/danding_bot/plugins/danding_qqpush/api.py b/danding_bot/plugins/danding_qqpush/api.py
index 0ad0f74..845aec1 100644
--- a/danding_bot/plugins/danding_qqpush/api.py
+++ b/danding_bot/plugins/danding_qqpush/api.py
@@ -6,6 +6,7 @@ from typing import Optional
from nonebot import logger
from .config import Config
+from .article_render import render_article_to_base64
from .text_parser import TextParser
from .image_render import ImageRenderer
from .sender import sender
@@ -39,8 +40,17 @@ class PushRequest(BaseModel):
qq: Optional[int] = None
"""可选的被 @ QQ 号;未传时仅发送群消息"""
- text: str
- """通知文本(# 表示换行)"""
+ text: str
+ """通知文本(# 表示换行)"""
+
+ html: Optional[str] = None
+ """可选的完整公告 HTML;传入时使用虚拟浏览器截图"""
+
+ title: Optional[str] = None
+ """公告标题"""
+
+ published_at: Optional[str] = None
+ """公告发布时间"""
# 响应模型
@@ -109,16 +119,26 @@ def create_routes(token: str, config: Config):
parsed_text = text_parser.parse(data.text)
logger.info(f"解析文本: {parsed_text[:50]}..." if len(parsed_text) > 50 else parsed_text)
- # 4. 生成图片 (reuse shared renderer to avoid per-request font loading)
- image_base64 = await asyncio.to_thread(_get_renderer(config).render_to_base64, parsed_text)
- logger.info("图片生成成功")
-
- # 5. 发送消息
- send_result = await sender.send_to_group(
- group_id=data.group_id,
- image_base64=image_base64,
- qq=data.qq,
- )
+ # 4. 原始公告使用 Chromium 截图,普通通知保持既有 Pillow 渲染。
+ if data.html:
+ image_base64 = await render_article_to_base64(
+ data.html,
+ data.title or "蛋定助手通知",
+ data.published_at,
+ )
+ send_result = await sender.send_to_group(
+ group_id=data.group_id,
+ image_base64=image_base64,
+ qq=data.qq,
+ )
+ else:
+ image_base64 = await asyncio.to_thread(_get_renderer(config).render_to_base64, parsed_text)
+ send_result = await sender.send_to_group(
+ group_id=data.group_id,
+ image_base64=image_base64,
+ qq=data.qq,
+ )
+ logger.info("图片生成成功")
if not send_result["success"]:
logger.error(f"消息发送失败: {send_result['error']}")
diff --git a/danding_bot/plugins/danding_qqpush/article_render.py b/danding_bot/plugins/danding_qqpush/article_render.py
new file mode 100644
index 0000000..ad697cd
--- /dev/null
+++ b/danding_bot/plugins/danding_qqpush/article_render.py
@@ -0,0 +1,89 @@
+"""阴阳师等长公告的安全 HTML 截图渲染。"""
+import base64
+import html
+import re
+from typing import Optional
+
+from pyppeteer import launch
+
+try:
+ import bleach
+except ImportError:
+ bleach = None
+
+
+ARTICLE_WIDTH = 900
+ARTICLE_PADDING = 48
+
+_ALLOWED_TAGS = [
+ "p", "br", "strong", "b", "em", "i", "u", "span", "div",
+ "h1", "h2", "h3", "ul", "ol", "li", "blockquote", "hr",
+]
+
+
+def _sanitize_article_html(content_html: str) -> str:
+ """净化外部公告 HTML;缺少 bleach 时退化为安全的纯文本段落。"""
+ if bleach is not None:
+ return bleach.clean(content_html, tags=_ALLOWED_TAGS, attributes={}, strip=True)
+
+ # 生产环境缺少可选依赖时,宁可丢失样式也不把不可信标签交给浏览器执行。
+ text = re.sub(r"(?is)<(script|style).*?>.*?\1>", "", content_html)
+ text = re.sub(r"(?i)
", "\n", text)
+ text = re.sub(r"(?i)(p|div|h[1-6]|li|blockquote)>", "\n", text)
+ text = re.sub(r"<[^>]+>", "", text)
+ return "
" + html.escape(text).replace("\n", "
") + "
第一段
第二段
", + "公告 <标题>", + "2026-07-30 20:27", + ) + + assert "第一段
" in document + assert "第二段" in document + assert "第二段
", "标题", None) + + assert "