补充斗鱼粉丝日报本地预览脚本并压缩版面

1. 为本地测试脚本增加粉丝日报 HTML 预览输出,统一映射新版模板需要的数据结构\n2. 内置稳定的预览文案拼装逻辑,方便不依赖LLM也能本地验收页面效果\n3. 压缩粉丝日报模板的卡片间距、字号、行高和高度,让同样的信息更紧凑简约地展示
This commit is contained in:
liuwei
2026-04-29 15:19:27 +08:00
parent 4386d0df75
commit 7de1dc9ee3
2 changed files with 305 additions and 85 deletions

View File

@@ -12,6 +12,7 @@
import importlib.util import importlib.util
import json import json
import os import os
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
@@ -27,6 +28,25 @@ def _load_helper():
return module.DouyuDanmuSummaryHelper return module.DouyuDanmuSummaryHelper
def _load_report_template_module():
"""
单独按文件路径加载模板模块。
这样本地预览不需要完整初始化插件,也不依赖 Redis 或其他运行时对象。
"""
current_dir = Path(__file__).resolve().parent
project_root = current_dir.parent.parent
project_root_str = str(project_root)
# 把项目根目录补进 sys.path保证 report_template.py 内部引用 utils 等项目模块时可正常导入。
if project_root_str not in sys.path:
sys.path.insert(0, project_root_str)
module_path = current_dir / "report_template.py"
spec = importlib.util.spec_from_file_location("douyu_report_template_local", module_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _build_session(room_id: str, anchor_day: str, messages: List[Dict[str, Any]]) -> Dict[str, Any]: def _build_session(room_id: str, anchor_day: str, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
ordered = sorted(messages, key=lambda item: item.get("timestamp") or datetime.min) ordered = sorted(messages, key=lambda item: item.get("timestamp") or datetime.min)
if not ordered: if not ordered:
@@ -51,6 +71,181 @@ def _build_session(room_id: str, anchor_day: str, messages: List[Dict[str, Any]]
} }
def _build_preview_template_payload(local_result: Dict[str, Any]) -> Dict[str, Any]:
"""
将本地测试结果转成粉丝日报模板真正需要的结构。
这样预览链路和正式模板共用同一套字段命名,后续查问题更直观。
"""
session_meta = local_result.get("session_meta", {}) or {}
local_stats_preview = local_result.get("local_stats_preview", {}) or {}
topic_clusters = local_result.get("topic_evidence_clusters", []) or []
hero_mentions = local_result.get("hero_mentions", []) or []
content_cues = local_result.get("content_cues", []) or []
timeline_digest = local_result.get("timeline_digest", []) or []
representative_messages = local_result.get("representative_messages", []) or []
return {
"report_meta": {
"room_id": str(session_meta.get("room_id") or "").strip(),
"anchor_day": str(session_meta.get("anchor_day") or "").strip(),
"nickname": str(session_meta.get("nickname") or "").strip(),
"room_name": str(session_meta.get("room_name") or "").strip(),
"session_count": 1,
"message_count": int(session_meta.get("message_count", 0) or 0),
"unique_user_count": int(session_meta.get("unique_user_count", 0) or 0),
},
"local_stats": {
"message_count": int(session_meta.get("message_count", 0) or 0),
"unique_user_count": int(session_meta.get("unique_user_count", 0) or 0),
"top_emotion_bursts": [
{
"text": str(item.get("text") or "").strip(),
"count": int(item.get("count", 0) or 0),
}
for item in content_cues
if str(item.get("kind") or "").strip() == "emotion"
][:8],
"top_repeated_messages": [
{
"text": str(item.get("text") or "").strip(),
"count": int(item.get("count", 0) or 0),
"user_count": int(item.get("user_count", 0) or 0),
}
for item in local_stats_preview.get("top_repeated_messages", [])[:8]
],
"peak_windows": [
{
"start_time": str(item.get("start_time") or "").strip(),
"message_count": int(item.get("message_count", 0) or 0),
"user_count": int(item.get("user_count", 0) or 0),
}
for item in local_stats_preview.get("peak_buckets", [])[:6]
],
},
"topic_evidence_clusters": [
{
"label": str(item.get("label") or "").strip(),
"count": int(item.get("match_count", item.get("count", 0)) or 0),
"user_count": int(item.get("user_count", 0) or 0),
"time_range": (
f"{str(item.get('first_hm') or '').strip()}-{str(item.get('last_hm') or '').strip()}"
).strip("-"),
"keywords": item.get("keywords", []) or [],
"samples": item.get("samples", []) or [],
}
for item in topic_clusters[:6]
],
"compact_scene_material": {
"semantic_fact_hints": {
"hero_mentions": hero_mentions[:6],
},
"content_cues": content_cues[:18],
"timeline_digest": timeline_digest[:20],
},
"representative_messages": representative_messages[:12],
"repeated_messages": [
{
"text": str(item.get("text") or "").strip(),
"count": int(item.get("count", 0) or 0),
"user_count": int(item.get("user_count", 0) or 0),
}
for item in local_stats_preview.get("top_repeated_messages", [])[:12]
],
"burst_terms": [
{
"text": str(item.get("text") or "").strip(),
"count": int(item.get("count", 0) or 0),
}
for item in local_stats_preview.get("top_burst_terms", [])[:12]
],
"peak_buckets": local_stats_preview.get("peak_buckets", [])[:6],
"top_terms": [
{"term": str(keyword).strip(), "count": 0}
for item in topic_clusters[:4]
for keyword in (item.get("keywords", []) or [])[:2]
if str(keyword).strip()
],
}
def _build_preview_report_text(payload: Dict[str, Any]) -> str:
"""
为本地模板预览提供一份稳定的示例文本。
这里不依赖真实 LLM只用已经提纯好的结果拼装固定结构
方便我们快速检查模板是否把关键信息展示完整。
"""
meta = payload.get("report_meta", {}) or {}
topic_clusters = payload.get("topic_evidence_clusters", []) or []
hero_mentions = (
payload.get("compact_scene_material", {})
.get("semantic_fact_hints", {})
.get("hero_mentions", [])
or []
)
repeated_messages = payload.get("repeated_messages", []) or []
burst_terms = payload.get("burst_terms", []) or []
peak_buckets = payload.get("peak_buckets", []) or []
representative_messages = payload.get("representative_messages", []) or []
anchor_day = str(meta.get("anchor_day") or "").strip()
lines = [
f"{anchor_day} 这场直播的弹幕不只是热闹,核心信息也很密:赛事、位置、英雄、团播人物和摄像头梗都有人追着聊。",
"【今日重点信息】",
]
for item in topic_clusters[:5]:
label = str(item.get("label") or "").strip()
time_range = str(item.get("time_range") or "").strip()
count = int(item.get("count", 0) or 0)
samples = item.get("samples", []) or []
sample_text = str(samples[0].get("content") or "").strip()[:42] if samples else ""
if label and sample_text:
lines.append(f"- {label}{time_range or '全场'} 一直有人聊,相关弹幕约 {count} 条,代表说法是「{sample_text}」。")
lines.append("【核心讨论话题】")
for item in topic_clusters[:4]:
label = str(item.get("label") or "").strip()
keywords = [str(keyword).strip() for keyword in (item.get("keywords", []) or [])[:5] if str(keyword).strip()]
if label and keywords:
lines.append(f"- 大家围着 {label} 打转,关键词主要是 {''.join(keywords)}")
lines.append("【英雄与对局焦点】")
for item in hero_mentions[:4]:
hero_name = str(item.get("hero") or "").strip()
mention_count = int(item.get("mention_count", 0) or 0)
samples = item.get("samples", []) or []
sample_text = str(samples[0].get("content") or "").strip()[:36] if samples else ""
if hero_name and sample_text:
lines.append(f"- {hero_name}被点名 {mention_count} 次,弹幕现场直接聊到「{sample_text}」。")
lines.append("【今日笑点】")
if peak_buckets:
top_bucket = peak_buckets[0]
lines.append(
f"- {str(top_bucket.get('start_time') or '')[-8:-3]} 前后是最热窗口,弹幕量直接冲到 {int(top_bucket.get('message_count', 0) or 0)} 条。"
)
if repeated_messages:
item = repeated_messages[0]
lines.append(f"- 复读冠军是「{str(item.get('text') or '').strip()[:24]}」,一天被刷了 {int(item.get('count', 0) or 0)} 次。")
if burst_terms:
item = burst_terms[0]
lines.append(f"- 情绪词「{str(item.get('text') or '').strip()}」集中爆了 {int(item.get('count', 0) or 0)} 次。")
lines.append("【弹幕名场面】")
for item in representative_messages[:5]:
nickname = str(item.get("nickname") or "").strip() or "观众"
content = str(item.get("content") or "").strip()
if content:
lines.append(f"- {nickname}{content[:44]}")
lines.append("【梗王榜】")
for item in repeated_messages[:3]:
lines.append(f"- {str(item.get('text') or '').strip()[:28]}|复读 {int(item.get('count', 0) or 0)}")
lines.append("【收尾播报】")
lines.append("- 本地预览版已经把有效信息和乐子一起塞进同一张图里了。")
return "\n".join(lines)
def run_local_test(file_path: str) -> str: def run_local_test(file_path: str) -> str:
helper = _load_helper() helper = _load_helper()
resolved_path = str(Path(file_path).resolve()) resolved_path = str(Path(file_path).resolve())
@@ -85,11 +280,36 @@ def run_local_test(file_path: str) -> str:
return str(output_path) return str(output_path)
def render_fans_preview_from_file(file_path: str) -> str:
"""
读取本地弹幕文件并直接产出新版粉丝日报 HTML 预览。
这样我们每次调整提纯逻辑或模板后,都能用同一条命令快速验收最终展示效果。
"""
local_result_path = Path(run_local_test(file_path))
local_result = json.loads(local_result_path.read_text(encoding="utf-8"))
payload = _build_preview_template_payload(local_result)
report_text = _build_preview_report_text(payload)
report_template = _load_report_template_module()
html_content = report_template.render_fans_daily_report_html(
payload=payload,
fans_report_text=report_text,
)
output_dir = Path(os.getcwd()) / "temp" / "douyu_materials"
output_dir.mkdir(parents=True, exist_ok=True)
file_name = Path(file_path).stem
output_path = output_dir / f"{file_name}_fans_template_preview.html"
output_path.write_text(html_content, encoding="utf-8")
return str(output_path)
if __name__ == "__main__": if __name__ == "__main__":
sample_files = [ sample_files = [
r"plugins\douyu\danmu_test\52876_20260428.txt", r"plugins\douyu\danmu_test\52876_20260428.txt",
r"plugins\douyu\danmu_test\52876_20260429.txt", r"plugins\douyu\danmu_test\52876_20260429.txt",
] ]
for sample in sample_files: for sample in sample_files:
path = run_local_test(sample) result_path = run_local_test(sample)
print(path) preview_path = render_fans_preview_from_file(sample)
print(result_path)
print(preview_path)

View File

@@ -105,12 +105,12 @@
.fans-metric-grid { .fans-metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px; gap: 10px;
margin-top: 16px; margin-top: 12px;
} }
.fans-metric-card { .fans-metric-card {
padding: 18px 18px 16px; padding: 14px 15px 12px;
border-radius: 20px; border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,245,239,.92)); background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,245,239,.92));
border: 1px solid rgba(219,95,65,.12); border: 1px solid rgba(219,95,65,.12);
} }
@@ -120,48 +120,48 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.fans-metric-value { .fans-metric-value {
font-size: 28px; font-size: 24px;
line-height: 1; line-height: 1;
font-weight: 900; font-weight: 900;
color: #cf593e; color: #cf593e;
word-break: break-all; word-break: break-all;
} }
.fans-metric-hint { .fans-metric-hint {
margin-top: 8px; margin-top: 6px;
font-size: 12px; font-size: 11px;
color: #8f7368; color: #8f7368;
line-height: 1.5; line-height: 1.4;
} }
.section { .section {
margin-top: 18px; margin-top: 14px;
padding: 22px; padding: 16px 18px;
border-radius: 26px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,249,244,.94)); background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,249,244,.94));
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.section-summary-list { .section-summary-list {
margin: 0 0 14px; margin: 0 0 10px;
padding-left: 22px; padding-left: 18px;
} }
.section-summary-list li { .section-summary-list li {
margin: 8px 0; margin: 5px 0;
color: #5a3e37; color: #5a3e37;
font-size: 15px; font-size: 14px;
line-height: 1.76; line-height: 1.58;
font-weight: 600; font-weight: 600;
} }
.section-title { .section-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
margin-bottom: 16px; margin-bottom: 12px;
font-size: 24px; font-size: 21px;
font-weight: 900; font-weight: 900;
color: #5b2d23; color: #5b2d23;
} }
.section-title .icon { .section-title .icon {
width: 14px; width: 12px;
height: 28px; height: 22px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(180deg, var(--accent), var(--accent-2)); background: linear-gradient(180deg, var(--accent), var(--accent-2));
box-shadow: 0 6px 14px rgba(219,95,65,.2); box-shadow: 0 6px 14px rgba(219,95,65,.2);
@@ -169,108 +169,108 @@
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 10px;
} }
.fans-info-card { .fans-info-card {
padding: 16px 16px 15px; padding: 12px 13px;
border-radius: 18px; border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,240,.94)); background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,240,.94));
border: 1px solid rgba(219,95,65,.12); border: 1px solid rgba(219,95,65,.12);
min-height: 112px; min-height: 84px;
} }
.fans-info-text { .fans-info-text {
font-size: 15px; font-size: 14px;
line-height: 1.76; line-height: 1.58;
color: #523935; color: #523935;
font-weight: 600; font-weight: 600;
} }
.topic-grid { .topic-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 10px;
} }
.topic-cluster-card { .topic-cluster-card {
padding: 18px 18px 14px; padding: 13px 14px 11px;
border-radius: 20px; border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,244,237,.94)); background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,244,237,.94));
border: 1px solid rgba(219,95,65,.12); border: 1px solid rgba(219,95,65,.12);
} }
.topic-cluster-title { .topic-cluster-title {
font-size: 18px; font-size: 16px;
font-weight: 900; font-weight: 900;
color: #6d2f22; color: #6d2f22;
margin-bottom: 8px; margin-bottom: 5px;
} }
.topic-cluster-meta { .topic-cluster-meta {
font-size: 12px; font-size: 11px;
color: #8d6c61; color: #8d6c61;
margin-bottom: 10px; margin-bottom: 7px;
} }
.topic-cluster-list { .topic-cluster-list {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 16px;
} }
.topic-cluster-list li { .topic-cluster-list li {
margin: 8px 0; margin: 5px 0;
font-size: 14px; font-size: 13px;
line-height: 1.66; line-height: 1.52;
color: #4d3832; color: #4d3832;
} }
.hero-grid { .hero-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px; gap: 10px;
} }
.hero-mention-card { .hero-mention-card {
padding: 16px; padding: 12px 13px;
border-radius: 18px; border-radius: 14px;
background: linear-gradient(135deg, rgba(255,255,255,.98), rgba(255,241,232,.94)); background: linear-gradient(135deg, rgba(255,255,255,.98), rgba(255,241,232,.94));
border: 1px solid rgba(243,164,71,.22); border: 1px solid rgba(243,164,71,.22);
} }
.hero-mention-name { .hero-mention-name {
font-size: 17px; font-size: 15px;
line-height: 1.4; line-height: 1.4;
font-weight: 900; font-weight: 900;
color: #6b311f; color: #6b311f;
margin-bottom: 6px; margin-bottom: 4px;
} }
.hero-mention-meta { .hero-mention-meta {
font-size: 12px; font-size: 11px;
color: #8c6d61; color: #8c6d61;
margin-bottom: 8px; margin-bottom: 6px;
} }
.hero-mention-sample { .hero-mention-sample {
font-size: 14px; font-size: 13px;
line-height: 1.65; line-height: 1.5;
color: #533833; color: #533833;
} }
.timeline-layout { .timeline-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr); grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr);
gap: 14px; gap: 10px;
} }
.hot-window-stack, .hot-window-stack,
.repeat-chip-wrap, .repeat-chip-wrap,
.meme-rank-stack { .meme-rank-stack {
display: grid; display: grid;
gap: 10px; gap: 8px;
} }
.fans-hot-window-card, .fans-hot-window-card,
.repeat-digest-panel, .repeat-digest-panel,
.rank-panel { .rank-panel {
padding: 14px 16px; padding: 11px 12px;
border-radius: 18px; border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,239,.94)); background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,239,.94));
border: 1px solid rgba(219,95,65,.12); border: 1px solid rgba(219,95,65,.12);
} }
.fans-hot-window-time { .fans-hot-window-time {
font-size: 18px; font-size: 16px;
font-weight: 900; font-weight: 900;
color: #c84f3f; color: #c84f3f;
margin-bottom: 6px; margin-bottom: 4px;
} }
.fans-hot-window-meta { .fans-hot-window-meta {
font-size: 14px; font-size: 12px;
color: #5c433b; color: #5c433b;
} }
.repeat-chip-wrap { .repeat-chip-wrap {
@@ -280,9 +280,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 8px;
padding: 12px 14px; padding: 9px 11px;
border-radius: 14px; border-radius: 12px;
background: rgba(255,255,255,.9); background: rgba(255,255,255,.9);
border: 1px solid rgba(219,95,65,.1); border: 1px solid rgba(219,95,65,.1);
} }
@@ -290,35 +290,35 @@
background: rgba(255,247,236,.94); background: rgba(255,247,236,.94);
} }
.repeat-chip-text { .repeat-chip-text {
font-size: 14px; font-size: 12px;
line-height: 1.5; line-height: 1.4;
color: #4d3932; color: #4d3932;
font-weight: 700; font-weight: 700;
} }
.repeat-chip-count { .repeat-chip-count {
flex-shrink: 0; flex-shrink: 0;
font-size: 12px; font-size: 11px;
color: #a05441; color: #a05441;
font-weight: 800; font-weight: 800;
} }
.two-col { .two-col {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(280px, .92fr); grid-template-columns: minmax(0, 1.08fr) minmax(280px, .92fr);
gap: 16px; gap: 12px;
} }
.funny-list { .funny-list {
margin: 0; margin: 0;
padding-left: 22px; padding-left: 18px;
} }
.funny-list li { .funny-list li {
margin: 10px 0; margin: 6px 0;
color: #533835; color: #533835;
font-size: 15px; font-size: 14px;
line-height: 1.76; line-height: 1.58;
} }
.meme-rank-card { .meme-rank-card {
padding: 14px 16px; padding: 11px 12px;
border-radius: 16px; border-radius: 13px;
background: linear-gradient(135deg, rgba(255,255,255,.98), rgba(255,240,232,.95)); background: linear-gradient(135deg, rgba(255,255,255,.98), rgba(255,240,232,.95));
border: 1px solid rgba(242,95,92,.18); border: 1px solid rgba(242,95,92,.18);
} }
@@ -327,35 +327,35 @@
font-size: 12px; font-size: 12px;
letter-spacing: .08em; letter-spacing: .08em;
font-weight: 900; font-weight: 900;
margin-bottom: 8px; margin-bottom: 5px;
} }
.meme-rank-text { .meme-rank-text {
color: #4b2e2b; color: #4b2e2b;
font-size: 15px; font-size: 13px;
line-height: 1.66; line-height: 1.5;
font-weight: 700; font-weight: 700;
} }
.scene-grid { .scene-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 10px;
} }
.fans-scene-card { .fans-scene-card {
padding: 16px 16px 15px; padding: 12px 13px;
border-radius: 18px; border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,240,.95)); background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,240,.95));
border: 1px solid rgba(255,138,61,.16); border: 1px solid rgba(255,138,61,.16);
min-height: 102px; min-height: 78px;
} }
.fans-scene-quote { .fans-scene-quote {
color: #5b3c37; color: #5b3c37;
font-size: 15px; font-size: 13px;
line-height: 1.75; line-height: 1.54;
} }
.closing-box { .closing-box {
margin-top: 16px; margin-top: 14px;
padding: 20px 22px; padding: 15px 17px;
border-radius: 24px; border-radius: 18px;
background: linear-gradient(135deg, rgba(255,126,103,.1), rgba(243,164,71,.14)); background: linear-gradient(135deg, rgba(255,126,103,.1), rgba(243,164,71,.14));
border: 1px solid rgba(255,126,103,.16); border: 1px solid rgba(255,126,103,.16);
} }
@@ -364,16 +364,16 @@
font-size: 13px; font-size: 13px;
letter-spacing: .08em; letter-spacing: .08em;
font-weight: 900; font-weight: 900;
margin-bottom: 10px; margin-bottom: 6px;
} }
.closing-text { .closing-text {
color: #613b34; color: #613b34;
font-size: 18px; font-size: 15px;
line-height: 1.8; line-height: 1.6;
font-weight: 700; font-weight: 700;
} }
.footer-note { .footer-note {
margin-top: 18px; margin-top: 14px;
text-align: right; text-align: right;
color: #8f736c; color: #8f736c;
font-size: 12px; font-size: 12px;