From 7de1dc9ee3dfa8b2061f4965cdadffcd004a9d11 Mon Sep 17 00:00:00 2001 From: liuwei Date: Wed, 29 Apr 2026 15:19:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=96=97=E9=B1=BC=E7=B2=89?= =?UTF-8?q?=E4=B8=9D=E6=97=A5=E6=8A=A5=E6=9C=AC=E5=9C=B0=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=B9=B6=E5=8E=8B=E7=BC=A9=E7=89=88=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为本地测试脚本增加粉丝日报 HTML 预览输出,统一映射新版模板需要的数据结构\n2. 内置稳定的预览文案拼装逻辑,方便不依赖LLM也能本地验收页面效果\n3. 压缩粉丝日报模板的卡片间距、字号、行高和高度,让同样的信息更紧凑简约地展示 --- plugins/douyu/local_test_runner.py | 224 +++++++++++++++++- .../douyu/templates/daily_fans_report.html | 166 ++++++------- 2 files changed, 305 insertions(+), 85 deletions(-) diff --git a/plugins/douyu/local_test_runner.py b/plugins/douyu/local_test_runner.py index d63a71d..821311c 100644 --- a/plugins/douyu/local_test_runner.py +++ b/plugins/douyu/local_test_runner.py @@ -12,6 +12,7 @@ import importlib.util import json import os +import sys from datetime import datetime from pathlib import Path from typing import Any, Dict, List @@ -27,6 +28,25 @@ def _load_helper(): 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]: ordered = sorted(messages, key=lambda item: item.get("timestamp") or datetime.min) 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: helper = _load_helper() resolved_path = str(Path(file_path).resolve()) @@ -85,11 +280,36 @@ def run_local_test(file_path: str) -> str: 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__": sample_files = [ r"plugins\douyu\danmu_test\52876_20260428.txt", r"plugins\douyu\danmu_test\52876_20260429.txt", ] for sample in sample_files: - path = run_local_test(sample) - print(path) + result_path = run_local_test(sample) + preview_path = render_fans_preview_from_file(sample) + print(result_path) + print(preview_path) diff --git a/plugins/douyu/templates/daily_fans_report.html b/plugins/douyu/templates/daily_fans_report.html index 10ccb74..9b25ae5 100644 --- a/plugins/douyu/templates/daily_fans_report.html +++ b/plugins/douyu/templates/daily_fans_report.html @@ -105,12 +105,12 @@ .fans-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; - margin-top: 16px; + gap: 10px; + margin-top: 12px; } .fans-metric-card { - padding: 18px 18px 16px; - border-radius: 20px; + padding: 14px 15px 12px; + border-radius: 16px; background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,245,239,.92)); border: 1px solid rgba(219,95,65,.12); } @@ -120,48 +120,48 @@ margin-bottom: 8px; } .fans-metric-value { - font-size: 28px; + font-size: 24px; line-height: 1; font-weight: 900; color: #cf593e; word-break: break-all; } .fans-metric-hint { - margin-top: 8px; - font-size: 12px; + margin-top: 6px; + font-size: 11px; color: #8f7368; - line-height: 1.5; + line-height: 1.4; } .section { - margin-top: 18px; - padding: 22px; - border-radius: 26px; + margin-top: 14px; + padding: 16px 18px; + border-radius: 20px; background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,249,244,.94)); border: 1px solid var(--line); } .section-summary-list { - margin: 0 0 14px; - padding-left: 22px; + margin: 0 0 10px; + padding-left: 18px; } .section-summary-list li { - margin: 8px 0; + margin: 5px 0; color: #5a3e37; - font-size: 15px; - line-height: 1.76; + font-size: 14px; + line-height: 1.58; font-weight: 600; } .section-title { display: flex; align-items: center; - gap: 10px; - margin-bottom: 16px; - font-size: 24px; + gap: 8px; + margin-bottom: 12px; + font-size: 21px; font-weight: 900; color: #5b2d23; } .section-title .icon { - width: 14px; - height: 28px; + width: 12px; + height: 22px; border-radius: 999px; background: linear-gradient(180deg, var(--accent), var(--accent-2)); box-shadow: 0 6px 14px rgba(219,95,65,.2); @@ -169,108 +169,108 @@ .info-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 10px; } .fans-info-card { - padding: 16px 16px 15px; - border-radius: 18px; + padding: 12px 13px; + border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,240,.94)); border: 1px solid rgba(219,95,65,.12); - min-height: 112px; + min-height: 84px; } .fans-info-text { - font-size: 15px; - line-height: 1.76; + font-size: 14px; + line-height: 1.58; color: #523935; font-weight: 600; } .topic-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; + gap: 10px; } .topic-cluster-card { - padding: 18px 18px 14px; - border-radius: 20px; + padding: 13px 14px 11px; + border-radius: 16px; background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,244,237,.94)); border: 1px solid rgba(219,95,65,.12); } .topic-cluster-title { - font-size: 18px; + font-size: 16px; font-weight: 900; color: #6d2f22; - margin-bottom: 8px; + margin-bottom: 5px; } .topic-cluster-meta { - font-size: 12px; + font-size: 11px; color: #8d6c61; - margin-bottom: 10px; + margin-bottom: 7px; } .topic-cluster-list { margin: 0; - padding-left: 18px; + padding-left: 16px; } .topic-cluster-list li { - margin: 8px 0; - font-size: 14px; - line-height: 1.66; + margin: 5px 0; + font-size: 13px; + line-height: 1.52; color: #4d3832; } .hero-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + gap: 10px; } .hero-mention-card { - padding: 16px; - border-radius: 18px; + padding: 12px 13px; + border-radius: 14px; background: linear-gradient(135deg, rgba(255,255,255,.98), rgba(255,241,232,.94)); border: 1px solid rgba(243,164,71,.22); } .hero-mention-name { - font-size: 17px; + font-size: 15px; line-height: 1.4; font-weight: 900; color: #6b311f; - margin-bottom: 6px; + margin-bottom: 4px; } .hero-mention-meta { - font-size: 12px; + font-size: 11px; color: #8c6d61; - margin-bottom: 8px; + margin-bottom: 6px; } .hero-mention-sample { - font-size: 14px; - line-height: 1.65; + font-size: 13px; + line-height: 1.5; color: #533833; } .timeline-layout { display: grid; grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr); - gap: 14px; + gap: 10px; } .hot-window-stack, .repeat-chip-wrap, .meme-rank-stack { display: grid; - gap: 10px; + gap: 8px; } .fans-hot-window-card, .repeat-digest-panel, .rank-panel { - padding: 14px 16px; - border-radius: 18px; + padding: 11px 12px; + border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,239,.94)); border: 1px solid rgba(219,95,65,.12); } .fans-hot-window-time { - font-size: 18px; + font-size: 16px; font-weight: 900; color: #c84f3f; - margin-bottom: 6px; + margin-bottom: 4px; } .fans-hot-window-meta { - font-size: 14px; + font-size: 12px; color: #5c433b; } .repeat-chip-wrap { @@ -280,9 +280,9 @@ display: flex; align-items: center; justify-content: space-between; - gap: 10px; - padding: 12px 14px; - border-radius: 14px; + gap: 8px; + padding: 9px 11px; + border-radius: 12px; background: rgba(255,255,255,.9); border: 1px solid rgba(219,95,65,.1); } @@ -290,35 +290,35 @@ background: rgba(255,247,236,.94); } .repeat-chip-text { - font-size: 14px; - line-height: 1.5; + font-size: 12px; + line-height: 1.4; color: #4d3932; font-weight: 700; } .repeat-chip-count { flex-shrink: 0; - font-size: 12px; + font-size: 11px; color: #a05441; font-weight: 800; } .two-col { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(280px, .92fr); - gap: 16px; + gap: 12px; } .funny-list { margin: 0; - padding-left: 22px; + padding-left: 18px; } .funny-list li { - margin: 10px 0; + margin: 6px 0; color: #533835; - font-size: 15px; - line-height: 1.76; + font-size: 14px; + line-height: 1.58; } .meme-rank-card { - padding: 14px 16px; - border-radius: 16px; + padding: 11px 12px; + border-radius: 13px; background: linear-gradient(135deg, rgba(255,255,255,.98), rgba(255,240,232,.95)); border: 1px solid rgba(242,95,92,.18); } @@ -327,35 +327,35 @@ font-size: 12px; letter-spacing: .08em; font-weight: 900; - margin-bottom: 8px; + margin-bottom: 5px; } .meme-rank-text { color: #4b2e2b; - font-size: 15px; - line-height: 1.66; + font-size: 13px; + line-height: 1.5; font-weight: 700; } .scene-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; + gap: 10px; } .fans-scene-card { - padding: 16px 16px 15px; - border-radius: 18px; + padding: 12px 13px; + border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,245,240,.95)); border: 1px solid rgba(255,138,61,.16); - min-height: 102px; + min-height: 78px; } .fans-scene-quote { color: #5b3c37; - font-size: 15px; - line-height: 1.75; + font-size: 13px; + line-height: 1.54; } .closing-box { - margin-top: 16px; - padding: 20px 22px; - border-radius: 24px; + margin-top: 14px; + padding: 15px 17px; + border-radius: 18px; background: linear-gradient(135deg, rgba(255,126,103,.1), rgba(243,164,71,.14)); border: 1px solid rgba(255,126,103,.16); } @@ -364,16 +364,16 @@ font-size: 13px; letter-spacing: .08em; font-weight: 900; - margin-bottom: 10px; + margin-bottom: 6px; } .closing-text { color: #613b34; - font-size: 18px; - line-height: 1.8; + font-size: 15px; + line-height: 1.6; font-weight: 700; } .footer-note { - margin-top: 18px; + margin-top: 14px; text-align: right; color: #8f736c; font-size: 12px;