模板化改造斗鱼日报与群聊总结图片渲染,支持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:
@@ -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"""<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">{_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
|
||||
# 模板化渲染:
|
||||
# 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)),
|
||||
},
|
||||
)
|
||||
|
||||
597
plugins/douyu/templates/daily_report.html
Normal file
597
plugins/douyu/templates/daily_report.html
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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("<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:
|
||||
"""把 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:
|
||||
|
||||
118
plugins/message_summary/templates/summary_card.html
Normal file
118
plugins/message_summary/templates/summary_card.html
Normal 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>
|
||||
Reference in New Issue
Block a user