feat(qqpush): 浏览器长图渲染公告
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import Optional
|
|||||||
from nonebot import logger
|
from nonebot import logger
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .article_render import render_article_to_base64
|
||||||
from .text_parser import TextParser
|
from .text_parser import TextParser
|
||||||
from .image_render import ImageRenderer
|
from .image_render import ImageRenderer
|
||||||
from .sender import sender
|
from .sender import sender
|
||||||
@@ -42,6 +43,15 @@ class PushRequest(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
"""通知文本(# 表示换行)"""
|
"""通知文本(# 表示换行)"""
|
||||||
|
|
||||||
|
html: Optional[str] = None
|
||||||
|
"""可选的完整公告 HTML;传入时使用虚拟浏览器截图"""
|
||||||
|
|
||||||
|
title: Optional[str] = None
|
||||||
|
"""公告标题"""
|
||||||
|
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
"""公告发布时间"""
|
||||||
|
|
||||||
|
|
||||||
# 响应模型
|
# 响应模型
|
||||||
class PushResponse(BaseModel):
|
class PushResponse(BaseModel):
|
||||||
@@ -109,17 +119,27 @@ def create_routes(token: str, config: Config):
|
|||||||
parsed_text = text_parser.parse(data.text)
|
parsed_text = text_parser.parse(data.text)
|
||||||
logger.info(f"解析文本: {parsed_text[:50]}..." if len(parsed_text) > 50 else parsed_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)
|
# 4. 原始公告使用 Chromium 截图,普通通知保持既有 Pillow 渲染。
|
||||||
image_base64 = await asyncio.to_thread(_get_renderer(config).render_to_base64, parsed_text)
|
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("图片生成成功")
|
logger.info("图片生成成功")
|
||||||
|
|
||||||
# 5. 发送消息
|
|
||||||
send_result = await sender.send_to_group(
|
|
||||||
group_id=data.group_id,
|
|
||||||
image_base64=image_base64,
|
|
||||||
qq=data.qq,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not send_result["success"]:
|
if not send_result["success"]:
|
||||||
logger.error(f"消息发送失败: {send_result['error']}")
|
logger.error(f"消息发送失败: {send_result['error']}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
89
danding_bot/plugins/danding_qqpush/article_render.py
Normal file
89
danding_bot/plugins/danding_qqpush/article_render.py
Normal file
@@ -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)<br\s*/?>", "\n", text)
|
||||||
|
text = re.sub(r"(?i)</(p|div|h[1-6]|li|blockquote)>", "\n", text)
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
return "<p>" + html.escape(text).replace("\n", "<br>") + "</p>"
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><style>
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
html, body {{ margin: 0; background: #f5f6f8; color: #1f2329; }}
|
||||||
|
body {{ width: {ARTICLE_WIDTH}px; font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; }}
|
||||||
|
article {{ margin: 24px; padding: {ARTICLE_PADDING}px; background: #fff; border-radius: 16px; }}
|
||||||
|
.label {{ color: #1677ff; font-size: 22px; font-weight: 700; }}
|
||||||
|
h1 {{ margin: 16px 0 10px; font-size: 34px; line-height: 1.35; }}
|
||||||
|
.time {{ color: #86909c; font-size: 18px; padding-bottom: 24px; border-bottom: 1px solid #e5e6eb; }}
|
||||||
|
.content {{ padding-top: 26px; font-size: 24px; line-height: 1.8; overflow-wrap: anywhere; }}
|
||||||
|
.content p {{ margin: 0 0 20px; }}
|
||||||
|
.content h1, .content h2, .content h3 {{ font-size: 28px; margin: 28px 0 14px; }}
|
||||||
|
.content ul, .content ol {{ padding-left: 1.4em; margin: 0 0 20px; }}
|
||||||
|
.content blockquote {{ margin: 0 0 20px; padding-left: 18px; border-left: 4px solid #91caff; color: #4e5969; }}
|
||||||
|
</style></head>
|
||||||
|
<body><article>
|
||||||
|
<div class="label">阴阳师维护公告</div>
|
||||||
|
<h1>{safe_title}</h1>
|
||||||
|
<div class="time">发布时间:{safe_time}</div>
|
||||||
|
<section class="content">{safe_content}</section>
|
||||||
|
</article></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
44
tests/test_danding_qqpush_article_render.py
Normal file
44
tests/test_danding_qqpush_article_render.py
Normal file
@@ -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(
|
||||||
|
"<p>第一段</p><script>alert('xss')</script><p><strong>第二段</strong></p>",
|
||||||
|
"公告 <标题>",
|
||||||
|
"2026-07-30 20:27",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "<p>第一段</p>" in document
|
||||||
|
assert "<strong>第二段</strong>" in document
|
||||||
|
assert "<script>" not in document
|
||||||
|
assert "公告 <标题>" in document
|
||||||
|
assert "发布时间:2026-07-30 20:27" in document
|
||||||
|
|
||||||
|
|
||||||
|
def test_article_document_uses_safe_text_fallback_without_bleach(monkeypatch):
|
||||||
|
module = load_article_renderer(monkeypatch)
|
||||||
|
monkeypatch.setattr(module, "bleach", None)
|
||||||
|
|
||||||
|
document = module.build_article_document("<p>第一段</p><script>alert('xss')</script><p>第二段</p>", "标题", None)
|
||||||
|
|
||||||
|
assert "<script>" not in document
|
||||||
|
assert "alert" not in document
|
||||||
|
assert "第一段<br>第二段" in document
|
||||||
Reference in New Issue
Block a user