563 lines
18 KiB
Python
563 lines
18 KiB
Python
# -*- 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_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 []
|
||
active_user_items = []
|
||
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()
|
||
message_count = int(item.get("message_count", 0) or 0)
|
||
if fans_name:
|
||
active_user_items.append(f"{nickname} | {fans_name} | {message_count}条")
|
||
else:
|
||
active_user_items.append(f"{nickname} | {message_count}条")
|
||
|
||
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: 28px;
|
||
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: hidden;
|
||
box-shadow: var(--shadow);
|
||
border: 1px solid rgba(255,255,255,0.6);
|
||
}}
|
||
.hero {{
|
||
position: relative;
|
||
padding: 34px 40px 30px;
|
||
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;
|
||
}}
|
||
.compact-user-list {{
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
column-count: 2;
|
||
column-gap: 20px;
|
||
}}
|
||
.compact-user-list li {{
|
||
break-inside: avoid;
|
||
color: #475569;
|
||
margin: 8px 0;
|
||
line-height: 1.62;
|
||
font-size: 14px;
|
||
}}
|
||
.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;
|
||
}}
|
||
.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;
|
||
}}
|
||
</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 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>
|
||
{_render_list(active_user_items, "compact-user-list")}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer-note">ABOT · Douyu Report Template</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
return html_doc
|