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).*?>.*?", "", content_html) + text = re.sub(r"(?i)", "\n", text) + text = re.sub(r"(?i)", "\n", text) + text = re.sub(r"<[^>]+>", "", text) + return "

" + html.escape(text).replace("\n", "
") + "

" + + +def build_article_document(content_html: str, title: str, published_at: Optional[str]) -> str: + """净化上游公告 HTML,并包装为固定版式的截图页面。""" + safe_content = _sanitize_article_html(content_html) + safe_title = html.escape(title) + safe_time = html.escape(published_at or "") + return f""" + + + +
+
阴阳师维护公告
+

{safe_title}

+
发布时间:{safe_time}
+
{safe_content}
+
+ """ + + +async def render_article_to_base64(content_html: str, title: str, published_at: Optional[str]) -> str: + """使用无头 Chromium 渲染完整公告为一张长图。""" + browser = None + page = None + try: + browser = await launch( + headless=True, + args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], + ) + page = await browser.newPage() + await page.setViewport({"width": ARTICLE_WIDTH, "height": 1200, "deviceScaleFactor": 1}) + await page.setContent(build_article_document(content_html, title, published_at)) + height = await page.evaluate("document.documentElement.scrollHeight") + if height <= 0: + raise ValueError("公告截图高度无效") + image = await page.screenshot({"fullPage": True, "type": "png"}) + return f"base64://{base64.b64encode(image).decode('ascii')}" + finally: + if page is not None: + await page.close() + if browser is not None: + await browser.close() diff --git a/tests/test_danding_qqpush_article_render.py b/tests/test_danding_qqpush_article_render.py new file mode 100644 index 0000000..c1dd10b --- /dev/null +++ b/tests/test_danding_qqpush_article_render.py @@ -0,0 +1,44 @@ +import importlib.util +import sys +import types +from pathlib import Path + + +def load_article_renderer(monkeypatch): + """加载纯 HTML 构建逻辑,避免测试中启动 Chromium。""" + pyppeteer = types.ModuleType("pyppeteer") + pyppeteer.launch = None + monkeypatch.setitem(sys.modules, "pyppeteer", pyppeteer) + + path = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "danding_qqpush" / "article_render.py" + spec = importlib.util.spec_from_file_location("danding_qqpush_article_render", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_article_document_preserves_layout_and_removes_script(monkeypatch): + module = load_article_renderer(monkeypatch) + + document = module.build_article_document( + "

第一段

第二段

", + "公告 <标题>", + "2026-07-30 20:27", + ) + + assert "

第一段

" in document + assert "第二段" in document + assert "

第二段

", "标题", None) + + assert "