Files
abot/plugins/douyu/report_template.py

989 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
import html
from typing import Any, Dict, List
def _escape(value: Any) -> str:
return html.escape(str(value or ""))
def _render_metric_card(label: str, value: Any, hint: str = "") -> str:
return (
'<div class="metric-card">'
f'<div class="metric-label">{_escape(label)}</div>'
f'<div class="metric-value">{_escape(value)}</div>'
f'<div class="metric-hint">{_escape(hint)}</div>'
"</div>"
)
def _render_list(items: List[str], item_class: str = "bullet-list") -> str:
if not items:
return ""
lis = "".join(f'<li>{_escape(item)}</li>' for item in items if str(item or "").strip())
return f'<ul class="{item_class}">{lis}</ul>' if lis else ""
def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str]]:
lead_parts = []
insight_items = []
for line in str(danmu_summary or "").splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("- "):
insight_items.append(stripped[2:].strip())
else:
lead_parts.append(stripped)
lead = " ".join(lead_parts).strip()
return lead, insight_items
def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
if len(normalized) >= target_count:
return normalized[:target_count]
top_terms = [str(item.get("term") or "").strip() for item in (payload.get("top_terms", []) or []) if str(item.get("term") or "").strip()]
merged_templates = [str(item.get("text") or "").strip() for item in (payload.get("merged_templates", []) or []) if str(item.get("text") or "").strip()]
peak_buckets = payload.get("peak_buckets", []) or []
representative_messages = payload.get("representative_messages", []) or []
supplements: List[str] = []
if top_terms:
supplements.append(f"讨论焦点比较集中,弹幕反复围绕 {''.join(top_terms[:5])} 展开。")
if merged_templates:
sample_templates = "".join(text[:24] for text in merged_templates[:3])
supplements.append(f"复读和共识梗比较强,重复内容主要集中在 {sample_templates}")
if peak_buckets:
top_bucket = peak_buckets[0]
bucket_terms = [str(term.get('term') or '').strip() for term in (top_bucket.get("top_terms", []) or []) if str(term.get('term') or '').strip()]
if bucket_terms:
supplements.append(
f"高峰时段出现在 {str(top_bucket.get('start_time') or '')[-8:-3]} 前后,话题明显偏向 {''.join(bucket_terms[:4])}"
)
if representative_messages:
supplements.append("代表性发言里既有操作反馈,也有玩梗调侃和情绪宣泄,互动意愿比较强。")
existing = set(normalized)
for item in supplements:
if item not in existing:
normalized.append(item)
existing.add(item)
if len(normalized) >= target_count:
break
return normalized[:target_count]
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
items: List[str] = []
seen = set()
def push(text: str, suffix: str = "") -> None:
value = str(text or "").strip()
if not value:
return
normalized_key = value
if normalized_key in seen:
return
seen.add(normalized_key)
items.append(f"{value}{suffix}".strip())
for item in (payload.get("merged_templates", []) or [])[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
push(text[:72], f"{count}次)")
for item in (payload.get("repeated_messages", []) or [])[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
push(text[:72], f"{count}次)")
for item in (payload.get("burst_terms", []) or [])[:6]:
text = str(item.get("text") or "").strip()
count = int(item.get("count", 0) or 0)
if text:
push(text[:36], f"(爆发 {count} 次)")
for item in (payload.get("top_terms", []) or [])[:6]:
term = str(item.get("term") or "").strip()
count = int(item.get("count", 0) or 0)
if term:
push(term, f"{count}次提及)")
return items[:limit]
def _render_insight_cards(items: List[str]) -> str:
labels = ["主线", "情绪", "梗点", "节奏", "反馈", "补充"]
blocks = []
for idx, item in enumerate(items[:6]):
extra_class = " full-span" if len(items[:6]) % 2 == 1 and idx == len(items[:6]) - 1 else ""
blocks.append(
f'<div class="insight-card{extra_class}">'
f'<div class="insight-kicker">{_escape(labels[idx] if idx < len(labels) else "观察")}</div>'
f'<div class="insight-text">{_escape(item)}</div>'
"</div>"
)
return "".join(blocks)
def _render_badges(top_badges: List[Dict[str, Any]]) -> str:
blocks = []
for item in top_badges[:6]:
badge_name = str(item.get("badge_name") or "").strip()
if not badge_name:
continue
blocks.append(
'<div class="badge-chip">'
f'<span class="badge-name">{_escape(badge_name)}</span>'
f'<span class="badge-meta">{_escape(item.get("user_count", 0))}人 / {_escape(item.get("message_count", 0))}条</span>'
"</div>"
)
return "".join(blocks)
def _render_hot_times(peak_buckets: List[Dict[str, Any]]) -> str:
blocks = []
for item in peak_buckets[:3]:
start_time = str(item.get("start_time") or "")[-8:-3]
terms = [str(term.get("term") or "").strip() for term in (item.get("top_terms", []) or [])[:4]]
terms = [term for term in terms if term]
blocks.append(
'<div class="hot-card">'
f'<div class="hot-time">{_escape(start_time)}</div>'
f'<div class="hot-count">{_escape(item.get("message_count", 0))} 条弹幕</div>'
f'<div class="hot-terms">{_escape(" / ".join(terms))}</div>'
"</div>"
)
return "".join(blocks)
def _render_active_users(top_active_users: List[Dict[str, Any]]) -> str:
blocks = []
for item in top_active_users[:10]:
nickname = str(item.get("nickname") or item.get("uid") or "").strip()
fans_name = str(item.get("fans_name") or "").strip()
fans_level = int(item.get("fans_level", 0) or 0)
room_level = int(item.get("room_level", 0) or 0)
message_count = int(item.get("message_count", 0) or 0)
chips = []
if fans_name:
fans_label = f"{fans_name} Lv{fans_level}" if fans_level > 0 else fans_name
chips.append(f'<span class="user-chip fans">{_escape(fans_label)}</span>')
if room_level > 0:
chips.append(f'<span class="user-chip room">{_escape(f"平台 Lv{room_level}")}</span>')
meta_html = "".join(chips) if chips else '<span class="user-chip plain">普通观众</span>'
blocks.append(
'<div class="active-user-card">'
'<div class="active-user-top">'
f'<div class="active-user-name">{_escape(nickname)}</div>'
f'<div class="active-user-count">{_escape(message_count)}<span>条</span></div>'
'</div>'
f'<div class="active-user-meta">{meta_html}</div>'
'</div>'
)
return "".join(blocks)
def _render_audience_trend_chart(audience_trend: Dict[str, Any]) -> str:
points = audience_trend.get("points", []) or []
if len(points) < 2:
return (
'<div class="chart-empty">'
'<div class="chart-empty-title">人数趋势</div>'
'<div class="chart-empty-desc">当前直播场次还没有足够的 WS 采样点,暂时无法绘制趋势图。</div>'
'</div>'
)
width = 820
height = 276
padding_left = 62
padding_right = 64
padding_top = 24
padding_bottom = 38
plot_width = width - padding_left - padding_right
plot_height = height - padding_top - padding_bottom
summary = audience_trend.get("summary", {}) or {}
def _compress_chart_points(raw_points: List[Dict[str, Any]], max_points: int = 36) -> List[Dict[str, Any]]:
if len(raw_points) <= max_points:
return list(raw_points)
bucket_size = max(len(raw_points) / max_points, 1)
compressed: List[Dict[str, Any]] = []
for idx in range(max_points):
start = int(round(idx * bucket_size))
end = int(round((idx + 1) * bucket_size))
bucket = raw_points[start:end] or raw_points[start:start + 1]
if not bucket:
continue
compressed.append(dict(bucket[-1]))
first = raw_points[0]
last = raw_points[-1]
if compressed:
compressed[0] = dict(first)
compressed[-1] = dict(last)
return compressed
chart_points = _compress_chart_points(points, max_points=36)
vip_values = [int(item.get("vip_count", 0) or 0) for item in chart_points]
diamond_values = [int(item.get("diamond_count", 0) or 0) for item in chart_points]
labels = [str(item.get("timestamp") or "")[-8:-3] for item in chart_points]
vip_min = min(vip_values)
vip_max = max(vip_values)
diamond_min = min(diamond_values)
diamond_max = max(diamond_values)
vip_padding = max(int((vip_max - vip_min) * 0.12), 60)
diamond_padding = 1 if diamond_max - diamond_min <= 2 else max(int((diamond_max - diamond_min) * 0.2), 1)
vip_display_min = max(vip_min - vip_padding, 0)
vip_display_max = vip_max + vip_padding
diamond_display_min = max(diamond_min - diamond_padding, 0)
diamond_display_max = diamond_max + diamond_padding
vip_span = max(vip_display_max - vip_display_min, 1)
diamond_span = max(diamond_display_max - diamond_display_min, 1)
step_x = plot_width / max(len(chart_points) - 1, 1)
bar_width = max(min(step_x * 0.58, 18), 6)
def x_at(index: int) -> float:
return padding_left + step_x * index
def y_vip(value: int) -> float:
ratio = (value - vip_display_min) / vip_span if vip_span else 0
return padding_top + plot_height - ratio * plot_height
def y_diamond(value: int) -> float:
ratio = (value - diamond_display_min) / diamond_span if diamond_span else 0
return padding_top + plot_height - ratio * plot_height
grid_lines = []
left_ticks = []
right_ticks = []
for idx in range(5):
ratio = idx / 4
y = padding_top + plot_height - ratio * plot_height
vip_tick = round(vip_display_min + vip_span * ratio)
diamond_tick = round(diamond_display_min + diamond_span * ratio)
grid_lines.append(
f'<line x1="{padding_left}" y1="{y:.1f}" x2="{width - padding_right}" y2="{y:.1f}" class="chart-grid" />'
)
left_ticks.append(
f'<text x="{padding_left - 10}" y="{y + 4:.1f}" text-anchor="end" class="axis-label axis-left">{vip_tick}</text>'
)
right_ticks.append(
f'<text x="{width - padding_right + 10}" y="{y + 4:.1f}" text-anchor="start" class="axis-label axis-right">{diamond_tick}</text>'
)
bars = []
line_points = []
dot_points = []
label_marks = []
annotations = []
if len(chart_points) <= 12:
label_indexes = list(range(len(chart_points)))
else:
label_indexes = sorted(set([0, len(chart_points) - 1] + [int(round((len(chart_points) - 1) * i / 6)) for i in range(1, 6)]))
vip_peak_index = vip_values.index(vip_max)
vip_valley_index = vip_values.index(vip_min)
diamond_latest_index = len(chart_points) - 1
for idx, item in enumerate(chart_points):
x = x_at(idx)
vip_y = y_vip(int(item.get("vip_count", 0) or 0))
diamond_y = y_diamond(int(item.get("diamond_count", 0) or 0))
bar_height = padding_top + plot_height - vip_y
bars.append(
f'<rect x="{x - bar_width / 2:.1f}" y="{vip_y:.1f}" width="{bar_width:.1f}" height="{bar_height:.1f}" rx="7" class="vip-bar" />'
)
line_points.append(f"{x:.1f},{diamond_y:.1f}")
dot_points.append(f'<circle cx="{x:.1f}" cy="{diamond_y:.1f}" r="3.5" class="diamond-dot" />')
if idx in label_indexes:
label_marks.append(
f'<text x="{x:.1f}" y="{height - 16}" text-anchor="middle" class="time-label">{_escape(labels[idx])}</text>'
)
if idx == vip_peak_index:
annotations.append(
f'<g class="chart-annotation">'
f'<text x="{x:.1f}" y="{max(vip_y - 16, 18):.1f}" text-anchor="middle" class="value-label vip">峰值 {vip_max}</text>'
f'<text x="{x:.1f}" y="{max(vip_y - 2, 30):.1f}" text-anchor="middle" class="value-sub-label vip">{_escape(labels[idx])}</text>'
f'</g>'
)
elif idx == vip_valley_index and vip_min != vip_max:
annotations.append(
f'<g class="chart-annotation">'
f'<text x="{x:.1f}" y="{min(vip_y + 18, padding_top + plot_height - 18):.1f}" text-anchor="middle" class="value-label vip soft">低点 {vip_min}</text>'
f'<text x="{x:.1f}" y="{min(vip_y + 32, padding_top + plot_height - 6):.1f}" text-anchor="middle" class="value-sub-label vip soft">{_escape(labels[idx])}</text>'
f'</g>'
)
if idx == diamond_latest_index:
annotations.append(
f'<g class="chart-annotation">'
f'<text x="{min(x + 10, width - padding_right - 6):.1f}" y="{max(diamond_y - 14, 18):.1f}" text-anchor="start" class="value-label diamond">最新 {int(item.get("diamond_count", 0) or 0)}</text>'
f'<text x="{min(x + 10, width - padding_right - 6):.1f}" y="{max(diamond_y, 30):.1f}" text-anchor="start" class="value-sub-label diamond">{_escape(labels[idx])}</text>'
f'</g>'
)
polyline = f'<polyline points="{" ".join(line_points)}" class="diamond-line" />'
vip_latest = int(summary.get("vip_latest", vip_values[-1]) or 0)
diamond_latest = int(summary.get("diamond_latest", diamond_values[-1]) or 0)
session_start = str(summary.get("session_start") or "")
first_point_time = str(summary.get("first_point_time") or "")
leading_gap_minutes = int(summary.get("leading_gap_minutes", 0) or 0)
note_text = ""
if session_start and first_point_time and leading_gap_minutes >= 20:
note_text = f"采样起点 {first_point_time[-8:-3]},早于该时段的人数数据未记录"
return f"""
<div class="chart-wrap">
<div class="chart-head">
<div class="chart-title">贵宾 / 钻粉趋势</div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-swatch vip"></span>贵宾柱状</span>
<span class="legend-item"><span class="legend-swatch diamond"></span>钻粉折线</span>
<span class="legend-meta">最新:贵宾 {_escape(vip_latest)} / 钻粉 {_escape(diamond_latest)}</span>
</div>
</div>
{'<div class="chart-note">' + _escape(note_text) + '</div>' if note_text else ''}
<svg viewBox="0 0 {width} {height}" class="audience-chart" role="img" aria-label="贵宾与钻粉趋势图">
<defs>
<linearGradient id="vipBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b59ff" stop-opacity="0.95" />
<stop offset="100%" stop-color="#7aa2ff" stop-opacity="0.72" />
</linearGradient>
<linearGradient id="diamondLineGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#f59e0b" />
<stop offset="100%" stop-color="#ffd166" />
</linearGradient>
</defs>
<rect x="0" y="0" width="{width}" height="{height}" rx="24" class="chart-bg" />
{''.join(grid_lines)}
<line x1="{padding_left}" y1="{padding_top + plot_height:.1f}" x2="{width - padding_right}" y2="{padding_top + plot_height:.1f}" class="chart-axis" />
<line x1="{padding_left}" y1="{padding_top}" x2="{padding_left}" y2="{padding_top + plot_height:.1f}" class="chart-axis soft" />
<line x1="{width - padding_right}" y1="{padding_top}" x2="{width - padding_right}" y2="{padding_top + plot_height:.1f}" class="chart-axis soft" />
{''.join(left_ticks)}
{''.join(right_ticks)}
{''.join(bars)}
{polyline}
{''.join(dot_points)}
{''.join(annotations)}
{''.join(label_marks)}
</svg>
</div>
"""
def render_daily_report_html(
payload: Dict[str, Any],
danmu_summary: str,
operator_summary_lines: List[str],
) -> str:
meta = payload.get("report_meta", {}) or {}
operator = payload.get("operator_metrics", {}) or {}
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
subtitle = (
f"{meta.get('anchor_day', '')} | 场次 {meta.get('session_count', 0)}"
f" | 弹幕 {meta.get('message_count', 0)} | 活跃用户 {meta.get('unique_user_count', 0)}"
)
metrics_html = "".join([
_render_metric_card("活跃用户", meta.get("unique_user_count", 0), "当天参与弹幕的去重人数"),
_render_metric_card("带牌活跃", operator.get("fans_badge_user_count", 0), "带粉丝牌发言用户"),
_render_metric_card("10+粉丝牌", operator.get("high_fans_level_user_count", 0), "高粘性活跃用户"),
_render_metric_card("30+等级用户", operator.get("high_room_level_user_count", 0), "高等级老观众"),
])
template_items = _build_template_items(payload)
top_active_users = payload.get("operator_metrics", {}).get("top_active_users", []) or []
audience_trend = payload.get("audience_trend", {}) or {}
lead_summary, danmu_bullets = _split_summary_blocks(danmu_summary)
danmu_bullets = _normalize_summary_bullets(payload, danmu_bullets, target_count=5)
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;
}}
.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>
<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