模板化改造斗鱼日报与群聊总结图片渲染,支持HTML模板独立维护

变更项:\n1. 将 plugins/douyu/report_template.py 从内联HTML重构为模板渲染,新增 plugins/douyu/templates/daily_report.html 承载完整样式与结构,Python侧仅保留数据组装与安全注入。\n2. 修复斗鱼日报模板迁移后的样式缺失问题,补齐 metric-card、insight-card、badge-wall、active-user-grid、chart 等所有关键类样式,确保视觉与旧版一致。\n3. 在 plugins/message_summary/main.py 新增模板化图片渲染链路:优先使用 HtmlTemplateRenderer + html_to_image 生成总结图片,模板异常时自动回退 convert_md_str_to_image,保证稳定性。\n4. 新增 plugins/message_summary/templates/summary_card.html 作为群聊总结卡片模板,后续可仅改模板文件完成UI迭代。\n5. 扩展 plugins/message_summary/config.toml 输出配置,增加 summary_image_mode 与 summary_image_template_path,支持模板模式与回退模式按配置切换。\n6. 保持原有业务流程与发送逻辑不变,仅改造渲染层,降低后续维护成本。
This commit is contained in:
liuwei
2026-04-20 13:23:54 +08:00
parent f1a6f6d565
commit f719b10c4a
5 changed files with 849 additions and 621 deletions

View File

@@ -2,6 +2,10 @@
import html import html
from typing import Any, Dict, List from typing import Any, Dict, List
from markupsafe import Markup
from utils.html_template_renderer import HtmlTemplateRenderer
def _escape(value: Any) -> str: def _escape(value: Any) -> str:
return html.escape(str(value or "")) 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) 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) fans_extract_bullets = _normalize_fans_extract_bullets(payload, fans_extract_bullets, target_count=6)
html_doc = f"""<html> # 模板化渲染:
<head> # 1. 报告样式和结构迁移到独立 HTML 模板文件;
<meta charset="UTF-8"> # 2. Python 仅负责准备数据与片段,后续改 UI 只改模板即可。
<meta name="viewport" content="width=device-width, initial-scale=1.0"> renderer = HtmlTemplateRenderer()
<style> return renderer.render(
:root {{ "plugins/douyu/templates/daily_report.html",
--bg-top: #f3efe5; {
--bg-bottom: #e6edf5; "title_name": title_name,
--paper: rgba(255, 252, 247, 0.97); "subtitle": subtitle,
--text: #1f2937; # 下列片段由当前函数内辅助方法生成,文本内容已做转义,可安全注入模板。
--muted: #6b7280; "metrics_html": Markup(metrics_html),
--line: rgba(137, 148, 163, 0.18); "lead_summary": lead_summary,
--navy: #14213d; "danmu_insights_html": Markup(_render_insight_cards(danmu_bullets)),
--blue: #2b59ff; "fans_extract_html": Markup(_render_list(fans_extract_bullets, item_class="fans-list")),
--cyan: #1fa8a0; "template_items_html": Markup(_render_list(template_items)),
--gold: #c89b3c; "hot_times_html": Markup(_render_hot_times(payload.get("peak_buckets", []) or [])),
--gold-soft: rgba(200, 155, 60, 0.14); "audience_trend_html": Markup(_render_audience_trend_chart(audience_trend)),
--red-soft: rgba(210, 84, 61, 0.10); "operator_summary_html": Markup(_render_list(operator_summary_lines)),
--shadow: 0 26px 60px rgba(33, 52, 84, 0.14); "badges_html": Markup(_render_badges(operator.get("top_badges", []) or [])),
}} "active_users_html": Markup(_render_active_users(top_active_users)),
* {{ box-sizing: border-box; }} },
body {{ )
margin: 0;
padding: 34px;
background:
radial-gradient(circle at 0% 0%, rgba(43, 89, 255, 0.08), transparent 24%),
radial-gradient(circle at 100% 0%, rgba(31, 168, 160, 0.10), transparent 20%),
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
font-family: 'Microsoft YaHei', 'PingFang SC', 'Segoe UI', sans-serif;
color: var(--text);
}}
.sheet {{
width: 920px;
margin: 0 auto;
background: var(--paper);
border-radius: 34px;
overflow: visible;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,0.6);
}}
.hero {{
position: relative;
padding: 34px 40px 30px;
border-radius: 34px 34px 0 0;
background:
radial-gradient(circle at 18% 18%, rgba(255,255,255,0.12), transparent 18%),
radial-gradient(circle at 84% 14%, rgba(255,255,255,0.11), transparent 19%),
linear-gradient(135deg, #111827 0%, #1d4ed8 46%, #0f766e 100%);
color: #fff;
}}
.hero::after {{
content: "";
position: absolute;
right: -52px;
top: -40px;
width: 230px;
height: 230px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 0 0 28px rgba(255,255,255,0.04), 0 0 0 60px rgba(255,255,255,0.02);
}}
.eyebrow {{
display: inline-block;
padding: 7px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.18);
font-size: 12px;
letter-spacing: .08em;
}}
.hero-title {{
margin: 18px 0 10px;
font-size: 44px;
font-weight: 800;
line-height: 1.16;
letter-spacing: -0.03em;
}}
.hero-subtitle {{
color: rgba(240, 246, 255, 0.84);
font-size: 16px;
}}
.content {{
padding: 28px 30px 34px;
}}
.metric-grid {{
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: -34px;
position: relative;
z-index: 2;
}}
.metric-card {{
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 22px;
padding: 18px 18px 16px;
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.08);
}}
.metric-label {{
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}}
.metric-value {{
font-size: 32px;
font-weight: 800;
color: var(--navy);
line-height: 1;
}}
.metric-hint {{
color: #8090a7;
font-size: 12px;
margin-top: 8px;
}}
.section {{
margin-top: 20px;
padding: 24px;
border-radius: 26px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(248,250,252,0.92));
}}
.section.danmu {{
background:
linear-gradient(180deg, rgba(255,255,255,0.95), rgba(247, 249, 255, 0.94));
}}
.section.ops {{
background:
linear-gradient(180deg, rgba(255,251,244,0.96), rgba(255,255,255,0.95));
}}
.section-title {{
display: flex;
align-items: center;
gap: 10px;
font-size: 27px;
font-weight: 800;
margin-bottom: 16px;
color: var(--navy);
}}
.section-title .icon {{
width: 14px;
height: 30px;
border-radius: 999px;
background: linear-gradient(180deg, var(--blue), var(--cyan));
box-shadow: 0 6px 16px rgba(43,89,255,0.24);
}}
.section.ops .section-title .icon {{
background: linear-gradient(180deg, #d6a547, #f59e0b);
box-shadow: 0 6px 16px rgba(200,155,60,0.24);
}}
.summary-grid {{
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(255px, 0.78fr);
gap: 18px;
}}
.prose p {{
margin: 0 0 12px;
color: #334155;
font-size: 17px;
line-height: 1.84;
}}
.lead-panel {{
padding: 18px 18px 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(242,246,255,0.92), rgba(255,255,255,0.96));
border: 1px solid rgba(125, 145, 186, 0.14);
margin-bottom: 14px;
}}
.lead-title {{
color: #5e6d87;
font-size: 13px;
letter-spacing: .06em;
margin-bottom: 10px;
}}
.lead-text {{
color: #24364c;
font-size: 18px;
line-height: 1.9;
font-weight: 500;
}}
.insight-grid {{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}}
.fans-panel {{
margin-top: 14px;
padding: 14px 15px 12px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(245,250,255,0.94));
border: 1px solid rgba(73, 136, 224, 0.18);
}}
.fans-title {{
color: #1d4ed8;
font-size: 13px;
letter-spacing: .06em;
font-weight: 700;
margin-bottom: 8px;
}}
.fans-list {{
margin: 0;
padding-left: 18px;
}}
.fans-list li {{
color: #1e3a5f;
margin: 8px 0;
line-height: 1.65;
font-size: 14px;
}}
.insight-card {{
padding: 15px 16px;
border-radius: 18px;
background: rgba(255,255,255,0.9);
border: 1px solid rgba(125, 145, 186, 0.14);
min-height: 110px;
}}
.insight-card.full-span {{
grid-column: 1 / -1;
}}
.insight-kicker {{
color: #2b59ff;
font-size: 12px;
letter-spacing: .08em;
font-weight: 700;
margin-bottom: 8px;
}}
.insight-text {{
color: #334155;
font-size: 15px;
line-height: 1.76;
}}
.bullet-list {{
margin: 0;
padding-left: 22px;
}}
.bullet-list li {{
color: #334155;
margin: 10px 0;
line-height: 1.72;
font-size: 16px;
}}
.aside-card {{
padding: 18px;
border-radius: 20px;
background: rgba(245, 248, 255, 0.86);
border: 1px solid rgba(125, 145, 186, 0.16);
}}
.aside-card.warm {{
background: rgba(255, 248, 234, 0.82);
border: 1px solid rgba(200, 155, 60, 0.18);
}}
.aside-title {{
font-size: 14px;
letter-spacing: .06em;
color: #68758a;
margin-bottom: 12px;
}}
.badge-wall {{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}}
.badge-chip {{
padding: 10px 12px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(242,246,255,0.92));
border: 1px solid rgba(129, 147, 181, 0.16);
min-width: 0;
}}
.badge-name {{
display: block;
font-weight: 700;
color: var(--navy);
margin-bottom: 4px;
}}
.badge-meta {{
display: block;
font-size: 12px;
color: #6b7280;
}}
.active-user-grid {{
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}}
.active-user-card {{
padding: 12px 12px 11px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,255,0.92));
border: 1px solid rgba(129,147,181,0.18);
box-shadow: 0 10px 24px rgba(25, 38, 66, 0.05);
}}
.active-user-top {{
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}}
.active-user-name {{
font-size: 14px;
font-weight: 700;
color: var(--navy);
line-height: 1.3;
word-break: break-all;
}}
.active-user-count {{
flex-shrink: 0;
min-width: 44px;
text-align: right;
font-size: 19px;
font-weight: 800;
color: var(--blue);
line-height: 1;
}}
.active-user-count span {{
margin-left: 2px;
font-size: 11px;
font-weight: 600;
color: #64748b;
}}
.active-user-meta {{
display: flex;
flex-wrap: wrap;
gap: 6px;
}}
.user-chip {{
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1;
border: 1px solid transparent;
}}
.user-chip.fans {{
color: #9a6700;
background: rgba(250, 204, 21, 0.14);
border-color: rgba(234, 179, 8, 0.24);
}}
.user-chip.room {{
color: #1d4ed8;
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.18);
}}
.user-chip.plain {{
color: #64748b;
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.16);
}}
.hot-grid {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}}
.hot-card {{
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255,255,255,0.94), rgba(241,246,255,0.92));
border: 1px solid rgba(129,147,181,0.16);
}}
.hot-time {{
font-size: 22px;
font-weight: 800;
color: var(--blue);
margin-bottom: 6px;
}}
.hot-count {{
font-size: 14px;
color: #334155;
margin-bottom: 8px;
}}
.hot-terms {{
font-size: 13px;
color: #64748b;
line-height: 1.56;
}}
.footer-note {{
margin-top: 20px;
text-align: right;
color: #7b8798;
font-size: 12px;
letter-spacing: .04em;
}}
.chart-wrap {{
padding: 18px 18px 14px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
border: 1px solid rgba(129,147,181,0.16);
}}
.chart-head {{
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}}
.chart-note {{
margin: -2px 0 8px;
color: #7b8798;
font-size: 12px;
line-height: 1.5;
}}
.chart-title {{
font-size: 17px;
font-weight: 800;
color: var(--navy);
}}
.chart-legend {{
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
color: #64748b;
font-size: 12px;
}}
.legend-item {{
display: inline-flex;
align-items: center;
gap: 6px;
}}
.legend-swatch {{
width: 18px;
height: 8px;
border-radius: 999px;
display: inline-block;
}}
.legend-swatch.vip {{
background: linear-gradient(90deg, #2b59ff, #7aa2ff);
}}
.legend-swatch.diamond {{
background: linear-gradient(90deg, #f59e0b, #ffd166);
}}
.legend-meta {{
color: #475569;
font-weight: 600;
}}
.audience-chart {{
width: 100%;
height: auto;
display: block;
}}
.chart-bg {{
fill: rgba(255,255,255,0.72);
}}
.chart-grid {{
stroke: rgba(148, 163, 184, 0.20);
stroke-width: 1;
stroke-dasharray: 4 6;
}}
.chart-axis {{
stroke: rgba(71, 85, 105, 0.38);
stroke-width: 1.2;
}}
.chart-axis.soft {{
stroke: rgba(148, 163, 184, 0.28);
}}
.axis-label {{
font-size: 11px;
font-weight: 600;
}}
.axis-left {{
fill: #3557c8;
}}
.axis-right {{
fill: #b26a00;
}}
.vip-bar {{
fill: url(#vipBarGradient);
}}
.diamond-line {{
fill: none;
stroke: url(#diamondLineGradient);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}}
.diamond-dot {{
fill: #ffcc66;
stroke: #ffffff;
stroke-width: 1.4;
}}
.value-label {{
font-size: 11px;
font-weight: 700;
}}
.value-label.vip {{
fill: #2449ad;
}}
.value-label.vip.soft {{
fill: #5573c7;
}}
.value-label.diamond {{
fill: #b26a00;
}}
.value-sub-label {{
font-size: 10px;
font-weight: 700;
}}
.value-sub-label.vip {{
fill: #5f7bd0;
}}
.value-sub-label.vip.soft {{
fill: #7d94d8;
}}
.value-sub-label.diamond {{
fill: #c27c16;
}}
.time-label {{
fill: #64748b;
font-size: 11px;
font-weight: 600;
}}
.chart-empty {{
padding: 26px 24px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
border: 1px solid rgba(129,147,181,0.16);
}}
.chart-empty-title {{
font-size: 17px;
font-weight: 800;
color: var(--navy);
margin-bottom: 8px;
}}
.chart-empty-desc {{
color: #64748b;
font-size: 14px;
line-height: 1.7;
}}
@media (max-width: 900px) {{
.active-user-grid {{
grid-template-columns: repeat(2, minmax(0, 1fr));
}}
}}
@media (max-width: 640px) {{
.active-user-grid {{
grid-template-columns: 1fr;
}}
}}
</style>
</head>
<body>
<div class="sheet">
<div class="hero">
<div class="eyebrow">DOUYU DAILY REPORT</div>
<div class="hero-title">{_escape(title_name)}</div>
<div class="hero-subtitle">{_escape(subtitle)}</div>
</div>
<div class="content">
<div class="metric-grid">
{metrics_html}
</div>
<div class="section danmu">
<div class="section-title"><span class="icon"></span><span>弹幕总结</span></div>
<div class="summary-grid">
<div class="prose">
<div class="lead-panel">
<div class="lead-title">整体观察</div>
<div class="lead-text">{_escape(lead_summary)}</div>
</div>
<div class="insight-grid">
{_render_insight_cards(danmu_bullets)}
</div>
<div class="fans-panel">
<div class="fans-title">给粉丝看的弹幕萃取</div>
{_render_list(fans_extract_bullets, item_class="fans-list")}
</div>
</div>
<div class="aside-card">
<div class="aside-title">高频梗</div>
{_render_list(template_items)}
</div>
</div>
<div class="hot-grid">
{_render_hot_times(payload.get("peak_buckets", []) or [])}
</div>
</div>
<div class="section ops">
<div class="section-title"><span class="icon"></span><span>运营数据总结</span></div>
<div style="margin-top: 2px; margin-bottom: 16px;">
{_render_audience_trend_chart(audience_trend)}
</div>
<div class="summary-grid">
<div class="prose">
{_render_list(operator_summary_lines)}
</div>
<div class="aside-card warm">
<div class="aside-title">活跃牌子</div>
<div class="badge-wall">{_render_badges(operator.get("top_badges", []) or [])}</div>
</div>
</div>
<div class="aside-card" style="margin-top: 16px;">
<div class="aside-title">核心发言用户</div>
<div class="active-user-grid">
{_render_active_users(top_active_users)}
</div>
</div>
</div>
<div class="footer-note">ABOT · Douyu Report Template</div>
</div>
</div>
</body>
</html>"""
return html_doc

View File

@@ -0,0 +1,597 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--bg-top: #f3efe5;
--bg-bottom: #e6edf5;
--paper: rgba(255, 252, 247, 0.97);
--text: #1f2937;
--muted: #6b7280;
--line: rgba(137, 148, 163, 0.18);
--navy: #14213d;
--blue: #2b59ff;
--cyan: #1fa8a0;
--gold: #c89b3c;
--gold-soft: rgba(200, 155, 60, 0.14);
--red-soft: rgba(210, 84, 61, 0.10);
--shadow: 0 26px 60px rgba(33, 52, 84, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 34px;
background:
radial-gradient(circle at 0% 0%, rgba(43, 89, 255, 0.08), transparent 24%),
radial-gradient(circle at 100% 0%, rgba(31, 168, 160, 0.10), transparent 20%),
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
font-family: 'Microsoft YaHei', 'PingFang SC', 'Segoe UI', sans-serif;
color: var(--text);
}
.sheet {
width: 920px;
margin: 0 auto;
background: var(--paper);
border-radius: 34px;
overflow: visible;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,0.6);
}
.hero {
position: relative;
padding: 34px 40px 30px;
border-radius: 34px 34px 0 0;
background:
radial-gradient(circle at 18% 18%, rgba(255,255,255,0.12), transparent 18%),
radial-gradient(circle at 84% 14%, rgba(255,255,255,0.11), transparent 19%),
linear-gradient(135deg, #111827 0%, #1d4ed8 46%, #0f766e 100%);
color: #fff;
}
.hero::after {
content: "";
position: absolute;
right: -52px;
top: -40px;
width: 230px;
height: 230px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 0 0 28px rgba(255,255,255,0.04), 0 0 0 60px rgba(255,255,255,0.02);
}
.eyebrow {
display: inline-block;
padding: 7px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.18);
font-size: 12px;
letter-spacing: .08em;
}
.hero-title {
margin: 18px 0 10px;
font-size: 44px;
font-weight: 800;
line-height: 1.16;
letter-spacing: -0.03em;
}
.hero-subtitle {
color: rgba(240, 246, 255, 0.84);
font-size: 16px;
}
.content {
padding: 28px 30px 34px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: -34px;
position: relative;
z-index: 2;
}
.metric-card {
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 22px;
padding: 18px 18px 16px;
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.08);
}
.metric-label {
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}
.metric-value {
font-size: 32px;
font-weight: 800;
color: var(--navy);
line-height: 1;
}
.metric-hint {
color: #8090a7;
font-size: 12px;
margin-top: 8px;
}
.section {
margin-top: 20px;
padding: 24px;
border-radius: 26px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(248,250,252,0.92));
}
.section.danmu {
background:
linear-gradient(180deg, rgba(255,255,255,0.95), rgba(247, 249, 255, 0.94));
}
.section.ops {
background:
linear-gradient(180deg, rgba(255,251,244,0.96), rgba(255,255,255,0.95));
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 27px;
font-weight: 800;
margin-bottom: 16px;
color: var(--navy);
}
.section-title .icon {
width: 14px;
height: 30px;
border-radius: 999px;
background: linear-gradient(180deg, var(--blue), var(--cyan));
box-shadow: 0 6px 16px rgba(43,89,255,0.24);
}
.section.ops .section-title .icon {
background: linear-gradient(180deg, #d6a547, #f59e0b);
box-shadow: 0 6px 16px rgba(200,155,60,0.24);
}
.summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(255px, 0.78fr);
gap: 18px;
}
.prose p {
margin: 0 0 12px;
color: #334155;
font-size: 17px;
line-height: 1.84;
}
.lead-panel {
padding: 18px 18px 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(242,246,255,0.92), rgba(255,255,255,0.96));
border: 1px solid rgba(125, 145, 186, 0.14);
margin-bottom: 14px;
}
.lead-title {
color: #5e6d87;
font-size: 13px;
letter-spacing: .06em;
margin-bottom: 10px;
}
.lead-text {
color: #24364c;
font-size: 18px;
line-height: 1.9;
font-weight: 500;
}
.insight-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fans-panel {
margin-top: 14px;
padding: 14px 15px 12px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(245,250,255,0.94));
border: 1px solid rgba(73, 136, 224, 0.18);
}
.fans-title {
color: #1d4ed8;
font-size: 13px;
letter-spacing: .06em;
font-weight: 700;
margin-bottom: 8px;
}
.fans-list {
margin: 0;
padding-left: 18px;
}
.fans-list li {
color: #1e3a5f;
margin: 8px 0;
line-height: 1.65;
font-size: 14px;
}
.insight-card {
padding: 15px 16px;
border-radius: 18px;
background: rgba(255,255,255,0.9);
border: 1px solid rgba(125, 145, 186, 0.14);
min-height: 110px;
}
.insight-card.full-span {
grid-column: 1 / -1;
}
.insight-kicker {
color: #2b59ff;
font-size: 12px;
letter-spacing: .08em;
font-weight: 700;
margin-bottom: 8px;
}
.insight-text {
color: #334155;
font-size: 15px;
line-height: 1.76;
}
.bullet-list {
margin: 0;
padding-left: 22px;
}
.bullet-list li {
color: #334155;
margin: 10px 0;
line-height: 1.72;
font-size: 16px;
}
.aside-card {
padding: 18px;
border-radius: 20px;
background: rgba(245, 248, 255, 0.86);
border: 1px solid rgba(125, 145, 186, 0.16);
}
.aside-card.warm {
background: rgba(255, 248, 234, 0.82);
border: 1px solid rgba(200, 155, 60, 0.18);
}
.aside-title {
font-size: 14px;
letter-spacing: .06em;
color: #68758a;
margin-bottom: 12px;
}
.badge-wall {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.badge-chip {
padding: 10px 12px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(242,246,255,0.92));
border: 1px solid rgba(129, 147, 181, 0.16);
min-width: 0;
}
.badge-name {
display: block;
font-weight: 700;
color: var(--navy);
margin-bottom: 4px;
}
.badge-meta {
display: block;
font-size: 12px;
color: #6b7280;
}
.active-user-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.active-user-card {
padding: 12px 12px 11px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,255,0.92));
border: 1px solid rgba(129,147,181,0.18);
box-shadow: 0 10px 24px rgba(25, 38, 66, 0.05);
}
.active-user-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.active-user-name {
font-size: 14px;
font-weight: 700;
color: var(--navy);
line-height: 1.3;
word-break: break-all;
}
.active-user-count {
flex-shrink: 0;
min-width: 44px;
text-align: right;
font-size: 19px;
font-weight: 800;
color: var(--blue);
line-height: 1;
}
.active-user-count span {
margin-left: 2px;
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.active-user-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.user-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1;
border: 1px solid transparent;
}
.user-chip.fans {
color: #9a6700;
background: rgba(250, 204, 21, 0.14);
border-color: rgba(234, 179, 8, 0.24);
}
.user-chip.room {
color: #1d4ed8;
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.18);
}
.user-chip.plain {
color: #64748b;
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.16);
}
.hot-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.hot-card {
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255,255,255,0.94), rgba(241,246,255,0.92));
border: 1px solid rgba(129,147,181,0.16);
}
.hot-time {
font-size: 22px;
font-weight: 800;
color: var(--blue);
margin-bottom: 6px;
}
.hot-count {
font-size: 14px;
color: #334155;
margin-bottom: 8px;
}
.hot-terms {
font-size: 13px;
color: #64748b;
line-height: 1.56;
}
.footer-note {
margin-top: 20px;
text-align: right;
color: #7b8798;
font-size: 12px;
letter-spacing: .04em;
}
.chart-wrap {
padding: 18px 18px 14px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
border: 1px solid rgba(129,147,181,0.16);
}
.chart-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.chart-note {
margin: -2px 0 8px;
color: #7b8798;
font-size: 12px;
line-height: 1.5;
}
.chart-title {
font-size: 17px;
font-weight: 800;
color: var(--navy);
}
.chart-legend {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
color: #64748b;
font-size: 12px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-swatch {
width: 18px;
height: 8px;
border-radius: 999px;
display: inline-block;
}
.legend-swatch.vip {
background: linear-gradient(90deg, #2b59ff, #7aa2ff);
}
.legend-swatch.diamond {
background: linear-gradient(90deg, #f59e0b, #ffd166);
}
.legend-meta {
color: #475569;
font-weight: 600;
}
.audience-chart {
width: 100%;
height: auto;
display: block;
}
.chart-bg {
fill: rgba(255,255,255,0.72);
}
.chart-grid {
stroke: rgba(148, 163, 184, 0.20);
stroke-width: 1;
stroke-dasharray: 4 6;
}
.chart-axis {
stroke: rgba(71, 85, 105, 0.38);
stroke-width: 1.2;
}
.chart-axis.soft {
stroke: rgba(148, 163, 184, 0.28);
}
.axis-label {
font-size: 11px;
font-weight: 600;
}
.axis-left {
fill: #3557c8;
}
.axis-right {
fill: #b26a00;
}
.vip-bar {
fill: url(#vipBarGradient);
}
.diamond-line {
fill: none;
stroke: url(#diamondLineGradient);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.diamond-dot {
fill: #ffcc66;
stroke: #ffffff;
stroke-width: 1.4;
}
.value-label {
font-size: 11px;
font-weight: 700;
}
.value-label.vip {
fill: #2449ad;
}
.value-label.vip.soft {
fill: #5573c7;
}
.value-label.diamond {
fill: #b26a00;
}
.value-sub-label {
font-size: 10px;
font-weight: 700;
}
.value-sub-label.vip {
fill: #5f7bd0;
}
.value-sub-label.vip.soft {
fill: #7d94d8;
}
.value-sub-label.diamond {
fill: #c27c16;
}
.time-label {
fill: #64748b;
font-size: 11px;
font-weight: 600;
}
.chart-empty {
padding: 26px 24px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
border: 1px solid rgba(129,147,181,0.16);
}
.chart-empty-title {
font-size: 17px;
font-weight: 800;
color: var(--navy);
margin-bottom: 8px;
}
.chart-empty-desc {
color: #64748b;
font-size: 14px;
line-height: 1.7;
}
@media (max-width: 900px) {
.active-user-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.active-user-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="sheet">
<div class="hero">
<div class="eyebrow">DOUYU DAILY REPORT</div>
<div class="hero-title">{{ title_name }}</div>
<div class="hero-subtitle">{{ subtitle }}</div>
</div>
<div class="content">
<div class="metric-grid">{{ metrics_html }}</div>
<div class="section danmu">
<div class="section-title"><span class="icon"></span><span>弹幕总结</span></div>
<div class="summary-grid">
<div class="prose">
<div class="lead-panel">
<div class="lead-title">整体观察</div>
<div class="lead-text">{{ lead_summary }}</div>
</div>
<div class="insight-grid">{{ danmu_insights_html }}</div>
<div class="fans-panel">
<div class="fans-title">给粉丝看的弹幕萃取</div>
{{ fans_extract_html }}
</div>
</div>
<div class="aside-card">
<div class="aside-title">高频梗</div>
{{ template_items_html }}
</div>
</div>
<div class="hot-grid">{{ hot_times_html }}</div>
</div>
<div class="section ops">
<div class="section-title"><span class="icon"></span><span>运营数据总结</span></div>
<div style="margin-top: 2px; margin-bottom: 16px;">{{ audience_trend_html }}</div>
<div class="summary-grid">
<div class="prose">{{ operator_summary_html }}</div>
<div class="aside-card warm">
<div class="aside-title">活跃牌子</div>
<div class="badge-wall">{{ badges_html }}</div>
</div>
</div>
<div class="aside-card" style="margin-top: 16px;">
<div class="aside-title">核心发言用户</div>
<div class="active-user-grid">{{ active_users_html }}</div>
</div>
</div>
<div class="footer-note">ABOT · Douyu Report Template</div>
</div>
</div>
</body>
</html>

View File

@@ -11,3 +11,9 @@ retry_delays_seconds = [10, 20]
[output] [output]
output_dir = "output" output_dir = "output"
image_format = "png" image_format = "png"
# 图片渲染模式:
# - template: 使用 HTML 模板渲染(推荐,便于后续只改模板)
# - markdown: 使用历史 md2image 样式
summary_image_mode = "template"
# 总结卡片模板路径(相对项目根目录)
summary_image_template_path = "plugins/message_summary/templates/summary_card.html"

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import html
import json import json
import re import re
import time import time
@@ -7,6 +8,7 @@ from pathlib import Path
from typing import Dict, Any, Tuple, Optional, List from typing import Dict, Any, Tuple, Optional, List
from loguru import logger from loguru import logger
from markupsafe import Markup
from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus 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.plugin_decorators import plugin_stats_decorator
from utils.decorator.points_decorator import plugin_points_cost from utils.decorator.points_decorator import plugin_points_cost
from utils.decorator.rate_limit_decorator import group_feature_rate_limit 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.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
from utils.string_utils import remove_reasoning_content, remove_trailing_content 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)) self._image_render_timeout_seconds = int(output_config.get("image_render_timeout_seconds", 90))
# 默认只尝试 1 次,优先保证任务快速返回;需要更高成功率可在配置里提高。 # 默认只尝试 1 次,优先保证任务快速返回;需要更高成功率可在配置里提高。
self._image_render_retries = int(output_config.get("image_render_retries", 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.llm_client = UnifiedLLMClient(api_config)
self._api_mode = self.llm_client.mode or self._api_mode self._api_mode = self.llm_client.mode or self._api_mode
self._response_mode = self.llm_client.response_mode or self._response_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() cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
return cleaned 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("<ul>" + "".join(list_items) + "</ul>")
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"<h2>{html.escape(line[3:].strip())}</h2>")
continue
if line.startswith("### "):
flush_list()
html_parts.append(f"<h3>{html.escape(line[4:].strip())}</h3>")
continue
if line.startswith("- "):
list_items.append(f"<li>{html.escape(line[2:].strip())}</li>")
continue
flush_list()
html_parts.append(f"<p>{html.escape(line)}</p>")
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: def _stringify_output(self, value: Any) -> str:
"""把 workflow 输出统一转成文本""" """把 workflow 输出统一转成文本"""
if value is None: if value is None:
@@ -546,15 +641,12 @@ class MessageSummaryPlugin(MessagePluginInterface):
self.LOG.info(f"开始生成图片: {output_path}") self.LOG.info(f"开始生成图片: {output_path}")
# 额外包一层总超时,确保就算底层依赖异常也不会把整个任务拖住。 # 额外包一层总超时,确保就算底层依赖异常也不会把整个任务拖住。
total_timeout = max(30, self._image_render_timeout_seconds * self._image_render_retries + 10) total_timeout = max(30, self._image_render_timeout_seconds * self._image_render_retries + 10)
spath = await asyncio.wait_for( # 统一走图片渲染入口:优先模板,失败自动降级到旧的 Markdown 转图链路。
convert_md_str_to_image( spath = await self._render_summary_image(
answer, answer=answer,
output_path, group_name=group_name,
max_retries=self._image_render_retries, output_filename=output_path,
render_timeout_seconds=self._image_render_timeout_seconds, total_timeout=total_timeout,
html_timeout_seconds=min(30, self._image_render_timeout_seconds),
),
timeout=total_timeout,
) )
self.LOG.info(f"成功生成图片: {spath}") self.LOG.info(f"成功生成图片: {spath}")
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,118 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--bg-top: #eef3fb;
--bg-bottom: #e5edf7;
--card: rgba(255, 255, 255, 0.96);
--title: #0f172a;
--text: #334155;
--muted: #64748b;
--line: rgba(148, 163, 184, 0.22);
--blue: #2563eb;
--cyan: #0f766e;
--shadow: 0 20px 48px rgba(15, 23, 42, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 26px;
background:
radial-gradient(circle at 10% 0%, rgba(37, 99, 235, 0.12), transparent 30%),
radial-gradient(circle at 90% 0%, rgba(15, 118, 110, 0.12), transparent 26%),
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
color: var(--text);
}
.sheet {
width: 820px;
margin: 0 auto;
background: var(--card);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: var(--shadow);
overflow: hidden;
}
.hero {
padding: 24px 28px 20px;
background:
radial-gradient(circle at 18% 18%, rgba(255,255,255,0.12), transparent 18%),
linear-gradient(135deg, #111827 0%, #1d4ed8 52%, #0f766e 100%);
color: #ffffff;
}
.hero-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
letter-spacing: .05em;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.hero-title {
margin: 14px 0 8px;
font-size: 30px;
font-weight: 800;
line-height: 1.25;
}
.hero-time {
font-size: 13px;
color: rgba(240, 246, 255, 0.86);
}
.content {
padding: 24px 26px 26px;
}
h2 {
margin: 20px 0 10px;
font-size: 20px;
font-weight: 800;
color: var(--title);
}
h3 {
margin: 14px 0 8px;
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
p {
margin: 8px 0;
font-size: 15px;
line-height: 1.78;
color: var(--text);
}
ul {
margin: 8px 0 12px;
padding-left: 22px;
}
li {
margin: 6px 0;
line-height: 1.72;
font-size: 14px;
color: var(--text);
}
.footer {
margin-top: 20px;
padding-top: 12px;
border-top: 1px dashed var(--line);
text-align: right;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<div class="sheet">
<div class="hero">
<div class="hero-badge">群聊总结</div>
<div class="hero-title">{{ title }}</div>
<div class="hero-time">生成时间:{{ generated_at }}</div>
</div>
<div class="content">
{{ summary_html }}
<div class="footer">ABOT · Message Summary Template</div>
</div>
</div>
</body>
</html>