新增斗鱼粉丝向恶搞弹幕日报并拆分运营版
1. 新增斗鱼粉丝日报和强制粉丝日报命令,手动触发时走独立发送链路。\n2. 为粉丝向日报补充独立提示词、兜底文案、缓存分类和图片渲染逻辑。\n3. 新增粉丝向日报 HTML 模板与模板解析函数,整体风格调整为开心欢乐的整活总结。\n4. 保留原有斗鱼运营日报定时与发送逻辑,避免两种日报互相污染。
This commit is contained in:
@@ -10,7 +10,9 @@ command = [
|
|||||||
"取消订阅鱼吧",
|
"取消订阅鱼吧",
|
||||||
"鱼吧订阅列表",
|
"鱼吧订阅列表",
|
||||||
"#斗鱼弹幕日报",
|
"#斗鱼弹幕日报",
|
||||||
"斗鱼弹幕日报"
|
"斗鱼弹幕日报",
|
||||||
|
"#斗鱼粉丝日报",
|
||||||
|
"斗鱼粉丝日报"
|
||||||
]
|
]
|
||||||
check_interval_minutes = 5
|
check_interval_minutes = 5
|
||||||
api_url_template = "https://www.douyu.com/betard/{room_id}"
|
api_url_template = "https://www.douyu.com/betard/{room_id}"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from base.plugin_common.plugin_interface import PluginStatus
|
|||||||
from db.connection import DBConnectionManager
|
from db.connection import DBConnectionManager
|
||||||
from utils.ai.unified_llm import UnifiedLLMClient
|
from utils.ai.unified_llm import UnifiedLLMClient
|
||||||
from plugins.douyu.danmu_summary import DouyuDanmuSummaryHelper
|
from plugins.douyu.danmu_summary import DouyuDanmuSummaryHelper
|
||||||
from plugins.douyu.report_template import render_daily_report_html
|
from plugins.douyu.report_template import render_daily_report_html, render_fans_daily_report_html
|
||||||
from utils.decorator.async_job import async_job
|
from utils.decorator.async_job import async_job
|
||||||
from utils.decorator.plugin_decorators import plugin_stats_decorator
|
from utils.decorator.plugin_decorators import plugin_stats_decorator
|
||||||
from utils.decorator.points_decorator import plugin_points_cost
|
from utils.decorator.points_decorator import plugin_points_cost
|
||||||
@@ -464,9 +464,9 @@ class DouyuRedisManager:
|
|||||||
class DouyuPlugin(MessagePluginInterface):
|
class DouyuPlugin(MessagePluginInterface):
|
||||||
# 报告缓存版本号:
|
# 报告缓存版本号:
|
||||||
# 1. 版本升级后会自动让历史缓存失效,避免继续复用旧文本/旧图片;
|
# 1. 版本升级后会自动让历史缓存失效,避免继续复用旧文本/旧图片;
|
||||||
# 2. 本次将版本提升到 5,用于修复“手动触发日报未走 Dify(命中缓存)”的问题,
|
# 2. 本次将版本提升到 6,新增“粉丝向恶搞日报”的独立结果类型,并同步刷新旧缓存,
|
||||||
# 确保升级后首次执行会重新走 LLM 生成链路。
|
# 确保上线后不会误复用旧版图片结构或旧版摘要文案。
|
||||||
_DAILY_REPORT_CACHE_VERSION = 5
|
_DAILY_REPORT_CACHE_VERSION = 6
|
||||||
FEATURE_KEY = "DOUYU_MONITOR"
|
FEATURE_KEY = "DOUYU_MONITOR"
|
||||||
FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]"
|
FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]"
|
||||||
|
|
||||||
@@ -509,7 +509,8 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
self.redis_manager: Optional[DouyuRedisManager] = None
|
self.redis_manager: Optional[DouyuRedisManager] = None
|
||||||
self._commands = ["斗鱼订阅", "取消斗鱼订阅", "斗鱼订阅列表", "斗鱼订阅提醒", "取消斗鱼订阅提醒",
|
self._commands = ["斗鱼订阅", "取消斗鱼订阅", "斗鱼订阅列表", "斗鱼订阅提醒", "取消斗鱼订阅提醒",
|
||||||
"订阅鱼吧", "取消订阅鱼吧", "鱼吧订阅列表",
|
"订阅鱼吧", "取消订阅鱼吧", "鱼吧订阅列表",
|
||||||
"#斗鱼弹幕日报", "斗鱼弹幕日报", "#强制斗鱼弹幕日报", "强制斗鱼弹幕日报"]
|
"#斗鱼弹幕日报", "斗鱼弹幕日报", "#强制斗鱼弹幕日报", "强制斗鱼弹幕日报",
|
||||||
|
"#斗鱼粉丝日报", "斗鱼粉丝日报", "#强制斗鱼粉丝日报", "强制斗鱼粉丝日报"]
|
||||||
self._api_template = "https://www.douyu.com/betard/{room_id}"
|
self._api_template = "https://www.douyu.com/betard/{room_id}"
|
||||||
self._yuba_api = "https://yuba.douyu.com/wgapi/yubanc/api/feed/getUserFeedList"
|
self._yuba_api = "https://yuba.douyu.com/wgapi/yubanc/api/feed/getUserFeedList"
|
||||||
self._user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
self._user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
@@ -542,6 +543,23 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
return f"{type(exc).__name__}: {message}"
|
return f"{type(exc).__name__}: {message}"
|
||||||
return type(exc).__name__
|
return type(exc).__name__
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_anchor_day_from_command(parts: List[str]) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
统一解析日报命令里的日期参数。
|
||||||
|
返回值说明:
|
||||||
|
1. 第一个布尔值表示日期是否合法;
|
||||||
|
2. 第二个字符串在合法时是最终日期,不合法时保留原始输入,方便上层提示用户。
|
||||||
|
"""
|
||||||
|
anchor_day = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return True, anchor_day
|
||||||
|
day_text = parts[1].strip()
|
||||||
|
try:
|
||||||
|
return True, datetime.strptime(day_text, "%Y-%m-%d").strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
return False, day_text
|
||||||
|
|
||||||
async def _fetch_json_with_retries(self, session: aiohttp.ClientSession, url: str,
|
async def _fetch_json_with_retries(self, session: aiohttp.ClientSession, url: str,
|
||||||
headers: Dict[str, str], context: str,
|
headers: Dict[str, str], context: str,
|
||||||
params: Optional[Dict[str, Any]] = None) -> Any:
|
params: Optional[Dict[str, Any]] = None) -> Any:
|
||||||
@@ -819,12 +837,8 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender)
|
await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender)
|
||||||
return True, "仅支持群聊"
|
return True, "仅支持群聊"
|
||||||
parts = content.split()
|
parts = content.split()
|
||||||
anchor_day = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
ok, anchor_day = self._parse_anchor_day_from_command(parts)
|
||||||
if len(parts) >= 2:
|
if not ok:
|
||||||
day_text = parts[1].strip()
|
|
||||||
try:
|
|
||||||
anchor_day = datetime.strptime(day_text, "%Y-%m-%d").strftime("%Y-%m-%d")
|
|
||||||
except Exception:
|
|
||||||
await self.bot.send_text_message(roomid, "日期格式错误,请使用:#斗鱼弹幕日报 2026-04-07", sender)
|
await self.bot.send_text_message(roomid, "日期格式错误,请使用:#斗鱼弹幕日报 2026-04-07", sender)
|
||||||
return True, "日期格式错误"
|
return True, "日期格式错误"
|
||||||
await self.bot.send_text_message(roomid, f"⏳ 正在生成斗鱼弹幕日报:{anchor_day}", sender)
|
await self.bot.send_text_message(roomid, f"⏳ 正在生成斗鱼弹幕日报:{anchor_day}", sender)
|
||||||
@@ -846,12 +860,8 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender)
|
await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender)
|
||||||
return True, "仅支持群聊"
|
return True, "仅支持群聊"
|
||||||
parts = content.split()
|
parts = content.split()
|
||||||
anchor_day = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
ok, anchor_day = self._parse_anchor_day_from_command(parts)
|
||||||
if len(parts) >= 2:
|
if not ok:
|
||||||
day_text = parts[1].strip()
|
|
||||||
try:
|
|
||||||
anchor_day = datetime.strptime(day_text, "%Y-%m-%d").strftime("%Y-%m-%d")
|
|
||||||
except Exception:
|
|
||||||
await self.bot.send_text_message(roomid, "日期格式错误,请使用:#强制斗鱼弹幕日报 2026-04-07", sender)
|
await self.bot.send_text_message(roomid, "日期格式错误,请使用:#强制斗鱼弹幕日报 2026-04-07", sender)
|
||||||
return True, "日期格式错误"
|
return True, "日期格式错误"
|
||||||
# 这里明确提示“强制重生成”,便于群内区分普通日报和回归测试操作。
|
# 这里明确提示“强制重生成”,便于群内区分普通日报和回归测试操作。
|
||||||
@@ -867,6 +877,48 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼弹幕日报:{anchor_day}", sender)
|
await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼弹幕日报:{anchor_day}", sender)
|
||||||
return True, "暂无日报"
|
return True, "暂无日报"
|
||||||
|
|
||||||
|
if first_token in {"#斗鱼粉丝日报", "斗鱼粉丝日报"}:
|
||||||
|
if not roomid:
|
||||||
|
await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender)
|
||||||
|
return True, "仅支持群聊"
|
||||||
|
parts = content.split()
|
||||||
|
ok, anchor_day = self._parse_anchor_day_from_command(parts)
|
||||||
|
if not ok:
|
||||||
|
await self.bot.send_text_message(roomid, "日期格式错误,请使用:#斗鱼粉丝日报 2026-04-07", sender)
|
||||||
|
return True, "日期格式错误"
|
||||||
|
await self.bot.send_text_message(roomid, f"🎉 正在生成斗鱼粉丝日报:{anchor_day}", sender)
|
||||||
|
# 粉丝版定位为“开心图文”,手动触发时默认直接重生成,
|
||||||
|
# 这样群里测试文案、模板时可以马上看到最新乐子版本。
|
||||||
|
delivered = await self._send_fans_daily_reports(
|
||||||
|
anchor_day,
|
||||||
|
target_group_id=roomid,
|
||||||
|
force_regenerate=True,
|
||||||
|
)
|
||||||
|
if delivered:
|
||||||
|
return True, f"斗鱼粉丝日报已发送:{anchor_day}"
|
||||||
|
await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼粉丝日报:{anchor_day}", sender)
|
||||||
|
return True, "暂无日报"
|
||||||
|
|
||||||
|
if first_token in {"#强制斗鱼粉丝日报", "强制斗鱼粉丝日报"}:
|
||||||
|
if not roomid:
|
||||||
|
await self.bot.send_text_message(sender, "请在群聊中使用该命令", sender)
|
||||||
|
return True, "仅支持群聊"
|
||||||
|
parts = content.split()
|
||||||
|
ok, anchor_day = self._parse_anchor_day_from_command(parts)
|
||||||
|
if not ok:
|
||||||
|
await self.bot.send_text_message(roomid, "日期格式错误,请使用:#强制斗鱼粉丝日报 2026-04-07", sender)
|
||||||
|
return True, "日期格式错误"
|
||||||
|
await self.bot.send_text_message(roomid, f"🎉 正在强制重生成斗鱼粉丝日报:{anchor_day}", sender)
|
||||||
|
delivered = await self._send_fans_daily_reports(
|
||||||
|
anchor_day,
|
||||||
|
target_group_id=roomid,
|
||||||
|
force_regenerate=True,
|
||||||
|
)
|
||||||
|
if delivered:
|
||||||
|
return True, f"斗鱼粉丝日报已强制重生成并发送:{anchor_day}"
|
||||||
|
await self.bot.send_text_message(roomid, f"暂无可发送的斗鱼粉丝日报:{anchor_day}", sender)
|
||||||
|
return True, "暂无日报"
|
||||||
|
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
async def _scheduled_check_job(self):
|
async def _scheduled_check_job(self):
|
||||||
@@ -1291,14 +1343,19 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def _daily_report_cache_path(self, room_id: str, anchor_day: str) -> str:
|
def _daily_report_cache_path(self, room_id: str, anchor_day: str, report_kind: str = "operator") -> str:
|
||||||
|
# 把不同风格的日报结果拆到独立缓存文件中:
|
||||||
|
# 1. 运营版继续使用 operator;
|
||||||
|
# 2. 粉丝向恶搞版使用 fans;
|
||||||
|
# 3. 这样两套模板和文本互不覆盖,便于分别调试和回归。
|
||||||
|
safe_kind = str(report_kind or "operator").strip().lower() or "operator"
|
||||||
return os.path.join(
|
return os.path.join(
|
||||||
self._daily_report_cache_dir(),
|
self._daily_report_cache_dir(),
|
||||||
f"{room_id}_{anchor_day.replace('-', '')}_daily_report_result.json",
|
f"{room_id}_{anchor_day.replace('-', '')}_{safe_kind}_daily_report_result.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _load_daily_report_cache(self, room_id: str, anchor_day: str) -> Optional[Dict[str, Any]]:
|
def _load_daily_report_cache(self, room_id: str, anchor_day: str, report_kind: str = "operator") -> Optional[Dict[str, Any]]:
|
||||||
cache_path = self._daily_report_cache_path(room_id, anchor_day)
|
cache_path = self._daily_report_cache_path(room_id, anchor_day, report_kind=report_kind)
|
||||||
if not os.path.exists(cache_path):
|
if not os.path.exists(cache_path):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -1307,16 +1364,26 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"读取斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}): {e}")
|
logger.warning(
|
||||||
|
f"读取斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}, kind={report_kind}): {e}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _save_daily_report_cache(self, room_id: str, anchor_day: str, data: Dict[str, Any]) -> None:
|
def _save_daily_report_cache(
|
||||||
cache_path = self._daily_report_cache_path(room_id, anchor_day)
|
self,
|
||||||
|
room_id: str,
|
||||||
|
anchor_day: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
report_kind: str = "operator",
|
||||||
|
) -> None:
|
||||||
|
cache_path = self._daily_report_cache_path(room_id, anchor_day, report_kind=report_kind)
|
||||||
try:
|
try:
|
||||||
with open(cache_path, "w", encoding="utf-8") as f:
|
with open(cache_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"保存斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}): {e}")
|
logger.warning(
|
||||||
|
f"保存斗鱼每日报告缓存失败(room={room_id}, day={anchor_day}, kind={report_kind}): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_existing_report_image(image_path: Optional[str]) -> Optional[str]:
|
def _resolve_existing_report_image(image_path: Optional[str]) -> Optional[str]:
|
||||||
@@ -1675,6 +1742,70 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
)
|
)
|
||||||
return system_prompt, user_prompt
|
return system_prompt, user_prompt
|
||||||
|
|
||||||
|
def _build_fans_daily_report_prompt(self, payload: Dict[str, Any]) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
粉丝版日报提示词设计目标:
|
||||||
|
1. 和运营版彻底区分开,不再强调“策略、复盘、活跃质量”;
|
||||||
|
2. 保留真实弹幕语境,让输出像“群友拿着回放在整活”;
|
||||||
|
3. 允许轻微恶搞和夸张,但不能编造未出现的事件,也不能攻击主播或观众。
|
||||||
|
"""
|
||||||
|
meta = payload.get("report_meta", {}) or {}
|
||||||
|
system_prompt = (
|
||||||
|
"你是斗鱼直播间的粉丝向整活日报编辑。"
|
||||||
|
"请只根据提供的真实弹幕材料,输出一份开心、欢乐、带一点恶搞气质的中文总结。"
|
||||||
|
"语气要像群友在复盘名场面,不要写成运营分析,不要编造剧情,不要使用代码块。"
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
"请输出一份适合给粉丝看的《斗鱼弹幕乐子日报》,严格按下面结构输出:\n"
|
||||||
|
"1. 开头先写 1 段总述,概括今天直播间的整体节目效果和气氛。\n"
|
||||||
|
"2. 另起一行写标题:`【今日笑点】`,下面写 4 条 bullet,每条一句,突出最有节目效果的地方。\n"
|
||||||
|
"3. 另起一行写标题:`【弹幕名场面】`,下面写 4-6 条 bullet,尽量保留弹幕原话风格,像现场回放。\n"
|
||||||
|
"4. 另起一行写标题:`【梗王榜】`,下面写 3 条 bullet,把今天最刷屏、最有共识的梗排出来。\n"
|
||||||
|
"5. 另起一行写标题:`【收尾播报】`,下面只写 1 句收尾,轻松一点,像群里发图后的总结句。\n"
|
||||||
|
"6. 可以夸张一点、调皮一点,但不要低俗,不要攻击主播,不要使用“建议、策略、转化、数据表现”等运营词。\n\n"
|
||||||
|
f"主播:{meta.get('nickname') or meta.get('room_name') or meta.get('room_id')}\n"
|
||||||
|
f"日期:{meta.get('anchor_day', '')}\n"
|
||||||
|
f"材料:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||||
|
)
|
||||||
|
return system_prompt, user_prompt
|
||||||
|
|
||||||
|
def _build_funny_scene_lines(self, payload: Dict[str, Any], limit: int = 5) -> List[str]:
|
||||||
|
"""
|
||||||
|
组装“弹幕名场面”兜底素材。
|
||||||
|
优先级:
|
||||||
|
1. 代表性原始弹幕,保证现场感;
|
||||||
|
2. 重复刷屏梗,保证“今天大家到底在笑什么”能被看出来。
|
||||||
|
"""
|
||||||
|
lines: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def push(text: str) -> None:
|
||||||
|
value = str(text or "").strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
normalized = value.lower()
|
||||||
|
if normalized in seen:
|
||||||
|
return
|
||||||
|
seen.add(normalized)
|
||||||
|
lines.append(value)
|
||||||
|
|
||||||
|
for item in (payload.get("representative_messages", []) or [])[:12]:
|
||||||
|
nickname = str(item.get("nickname") or "").strip() or "观众"
|
||||||
|
content = str(item.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
push(f"{nickname}:{content[:48]}")
|
||||||
|
if len(lines) >= limit:
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
|
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(f"复读现场:{text[:40]}(今天被刷了 {count} 次)")
|
||||||
|
if len(lines) >= limit:
|
||||||
|
return lines[:limit]
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
def _build_fans_extract_lines(self, payload: Dict[str, Any], limit: int = 6) -> List[str]:
|
def _build_fans_extract_lines(self, payload: Dict[str, Any], limit: int = 6) -> List[str]:
|
||||||
# 粉丝向萃取强调“可读、像现场弹幕”,优先取代表发言,再补充重复梗与情绪短词。
|
# 粉丝向萃取强调“可读、像现场弹幕”,优先取代表发言,再补充重复梗与情绪短词。
|
||||||
representative_messages = payload.get("representative_messages", []) or []
|
representative_messages = payload.get("representative_messages", []) or []
|
||||||
@@ -1729,6 +1860,89 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
|
|
||||||
return lines[:limit]
|
return lines[:limit]
|
||||||
|
|
||||||
|
def _build_fallback_fans_daily_report(self, payload: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
当 LLM 不可用或返回空内容时,仍然生成一份可直接发群的粉丝版日报。
|
||||||
|
兜底文本保持“有梗但不胡编”的原则,所有句子都只从真实弹幕统计结果里取材。
|
||||||
|
"""
|
||||||
|
meta = payload.get("report_meta", {}) or {}
|
||||||
|
top_terms = [
|
||||||
|
str(item.get("term") or "").strip()
|
||||||
|
for item in (payload.get("top_terms", []) or [])[:5]
|
||||||
|
if str(item.get("term") or "").strip()
|
||||||
|
]
|
||||||
|
merged_templates = payload.get("merged_templates", []) or []
|
||||||
|
burst_terms = payload.get("burst_terms", []) or []
|
||||||
|
peak_buckets = payload.get("peak_buckets", []) or []
|
||||||
|
repeated_messages = payload.get("repeated_messages", []) or []
|
||||||
|
anchor_day = str(meta.get("anchor_day", "") or "")
|
||||||
|
|
||||||
|
lead_parts = [
|
||||||
|
f"{anchor_day} 这场直播,弹幕区整体处于高能围观状态,大家一边盯着直播内容,一边围着"
|
||||||
|
f"{'、'.join(top_terms[:4]) or '节目效果'}疯狂接梗。"
|
||||||
|
]
|
||||||
|
if merged_templates:
|
||||||
|
lead_parts.append(
|
||||||
|
f"尤其是「{str(merged_templates[0].get('text') or '').strip()[:26]}」这类共识弹幕,一看就是全场默认会背。"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [" ".join(lead_parts).strip(), "【今日笑点】"]
|
||||||
|
|
||||||
|
if peak_buckets:
|
||||||
|
top_bucket = peak_buckets[0]
|
||||||
|
lines.append(
|
||||||
|
f"- {str(top_bucket.get('start_time') or '')[-8:-3]} 前后弹幕密度冲高,直播间像突然集体抢到麦,乐子值直接拉满。"
|
||||||
|
)
|
||||||
|
if repeated_messages:
|
||||||
|
first_repeat = repeated_messages[0]
|
||||||
|
lines.append(
|
||||||
|
f"- 复读冠军是「{str(first_repeat.get('text') or '').strip()[:32]}」,光这句就被来回刷了 {int(first_repeat.get('count', 0) or 0)} 次。"
|
||||||
|
)
|
||||||
|
if burst_terms:
|
||||||
|
first_burst = burst_terms[0]
|
||||||
|
lines.append(
|
||||||
|
f"- 情绪词「{str(first_burst.get('text') or '').strip()}」反复出现 {int(first_burst.get('count', 0) or 0)} 次,说明那一段大家已经彻底上头。"
|
||||||
|
)
|
||||||
|
if top_terms:
|
||||||
|
lines.append(f"- 今天的集体关注点基本围着 {'、'.join(top_terms[:4])} 打转,谁路过都会被梗吸进去。")
|
||||||
|
|
||||||
|
lines.append("【弹幕名场面】")
|
||||||
|
for item in self._build_funny_scene_lines(payload, limit=5):
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
|
||||||
|
lines.append("【梗王榜】")
|
||||||
|
rank_items: List[str] = []
|
||||||
|
for item in merged_templates[:2]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
rank_items.append(f"{text[:30]}|全场 {count} 次")
|
||||||
|
for item in burst_terms[:2]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
rank_items.append(f"{text}|情绪爆发 {count} 次")
|
||||||
|
for item in repeated_messages[:3]:
|
||||||
|
if len(rank_items) >= 3:
|
||||||
|
break
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
candidate = f"{text[:30]}|复读 {count} 次"
|
||||||
|
if candidate not in rank_items:
|
||||||
|
rank_items.append(candidate)
|
||||||
|
for item in rank_items[:3]:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
|
||||||
|
lines.append("【收尾播报】")
|
||||||
|
if peak_buckets:
|
||||||
|
lines.append(
|
||||||
|
f"- 今天的直播内容未必全部记住了,但 {str(peak_buckets[0].get('start_time') or '')[-8:-3]} 那波弹幕起哄,已经足够做成群内经典片段。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- 今天的直播总结成一句话就是:画面会结束,梗不会下播。")
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
def _build_fallback_daily_report(self, payload: Dict[str, Any]) -> str:
|
def _build_fallback_daily_report(self, payload: Dict[str, Any]) -> str:
|
||||||
meta = payload.get("report_meta", {}) or {}
|
meta = payload.get("report_meta", {}) or {}
|
||||||
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
||||||
@@ -2028,6 +2242,72 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
)
|
)
|
||||||
return self._build_fallback_danmu_summary(payload)
|
return self._build_fallback_danmu_summary(payload)
|
||||||
|
|
||||||
|
async def _generate_fans_daily_report_text(self, payload: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
生成独立的粉丝向恶搞日报正文。
|
||||||
|
这里继续复用统一的 LLM 客户端,但通过不同 task_type 和 prompt 把风格切开。
|
||||||
|
"""
|
||||||
|
if self._daily_report_use_llm and self._daily_report_llm_client:
|
||||||
|
system_prompt, user_prompt = self._build_fans_daily_report_prompt(payload)
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
self._call_daily_report_llm,
|
||||||
|
task_type="fans_daily_report",
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
payload=payload,
|
||||||
|
tag=f"douyu_fans_daily_report_{(payload.get('report_meta', {}) or {}).get('room_id', '')}",
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
text = result.strip()
|
||||||
|
if len(text) > self._daily_report_max_length:
|
||||||
|
return text[: self._daily_report_max_length - 20].rstrip() + "\n...(已截断)"
|
||||||
|
return text
|
||||||
|
logger.warning(
|
||||||
|
f"斗鱼粉丝日报 LLM 生成失败: model={self._daily_report_llm_client.model}, "
|
||||||
|
f"last_error={self._daily_report_llm_client.last_error}"
|
||||||
|
)
|
||||||
|
return self._build_fallback_fans_daily_report(payload)
|
||||||
|
|
||||||
|
async def _build_fans_daily_report_markdown(self, payload: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Markdown 版本主要用于图片模板渲染失败时兜底。
|
||||||
|
即使最终还是走通用 markdown 截图,也要尽量保留粉丝版的结构感。
|
||||||
|
"""
|
||||||
|
meta = payload.get("report_meta", {}) or {}
|
||||||
|
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
||||||
|
fans_report_text = await self._generate_fans_daily_report_text(payload)
|
||||||
|
lines = [
|
||||||
|
f"# {title_name} 的弹幕乐子日报",
|
||||||
|
f"{meta.get('anchor_day', '')}|弹幕 {meta.get('message_count', 0)}|围观群众 {meta.get('unique_user_count', 0)}",
|
||||||
|
"",
|
||||||
|
fans_report_text,
|
||||||
|
]
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
async def _render_fans_daily_report_image(self, payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
markdown = await self._build_fans_daily_report_markdown(payload)
|
||||||
|
room_id = str((payload.get("report_meta", {}) or {}).get("room_id", "") or "room")
|
||||||
|
anchor_day = str((payload.get("report_meta", {}) or {}).get("anchor_day", "") or "").replace("-", "")
|
||||||
|
filename = f"douyu_fans_daily_report_{room_id}_{anchor_day}.png"
|
||||||
|
try:
|
||||||
|
fans_report_text = await self._generate_fans_daily_report_text(payload)
|
||||||
|
html_content = render_fans_daily_report_html(
|
||||||
|
payload=payload,
|
||||||
|
fans_report_text=fans_report_text,
|
||||||
|
)
|
||||||
|
output_dir = os.path.join(os.getcwd(), "temp", "md2image")
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
output_path = os.path.join(output_dir, filename)
|
||||||
|
await html_to_image(html_content, output_path)
|
||||||
|
return str(Path(output_path).resolve())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"斗鱼粉丝日报专用模板图片生成失败(room={room_id}, day={anchor_day}): {e}")
|
||||||
|
try:
|
||||||
|
return await convert_md_str_to_image(markdown, filename)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"斗鱼粉丝日报图片生成失败(room={room_id}, day={anchor_day}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def _build_daily_report_markdown(self, payload: Dict[str, Any]) -> str:
|
async def _build_daily_report_markdown(self, payload: Dict[str, Any]) -> str:
|
||||||
meta = payload.get("report_meta", {}) or {}
|
meta = payload.get("report_meta", {}) or {}
|
||||||
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
||||||
@@ -2130,7 +2410,9 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
# force_regenerate=True 时,跳过本地缓存读取,直接重新生成文本/图片并覆盖缓存。
|
# force_regenerate=True 时,跳过本地缓存读取,直接重新生成文本/图片并覆盖缓存。
|
||||||
# 这样可以在模型提示词或模板变更后,通过命令立即验证最新效果。
|
# 这样可以在模型提示词或模板变更后,通过命令立即验证最新效果。
|
||||||
cached = {} if force_regenerate else (self._load_daily_report_cache(room_id, anchor_day) or {})
|
cached = {} if force_regenerate else (
|
||||||
|
self._load_daily_report_cache(room_id, anchor_day, report_kind="operator") or {}
|
||||||
|
)
|
||||||
cached_image = self._resolve_existing_report_image(cached.get("report_image"))
|
cached_image = self._resolve_existing_report_image(cached.get("report_image"))
|
||||||
cached_text = str(cached.get("report_text") or "").strip()
|
cached_text = str(cached.get("report_text") or "").strip()
|
||||||
cached_version = int(cached.get("cache_version", 0) or 0)
|
cached_version = int(cached.get("cache_version", 0) or 0)
|
||||||
@@ -2154,7 +2436,51 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
"report_image": report_image,
|
"report_image": report_image,
|
||||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
self._save_daily_report_cache(room_id, anchor_day, result)
|
self._save_daily_report_cache(room_id, anchor_day, result, report_kind="operator")
|
||||||
|
result["cached"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _get_or_create_fans_daily_report_result(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
anchor_day: str,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
force_regenerate: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
粉丝向日报使用独立缓存:
|
||||||
|
1. 避免和运营版相互覆盖;
|
||||||
|
2. 便于后续单独升级风格、模板、提示词;
|
||||||
|
3. 手动调试时也能明确区分当前命中的到底是哪一类结果。
|
||||||
|
"""
|
||||||
|
cached = {} if force_regenerate else (
|
||||||
|
self._load_daily_report_cache(room_id, anchor_day, report_kind="fans") or {}
|
||||||
|
)
|
||||||
|
cached_image = self._resolve_existing_report_image(cached.get("report_image"))
|
||||||
|
cached_text = str(cached.get("report_text") or "").strip()
|
||||||
|
cached_version = int(cached.get("cache_version", 0) or 0)
|
||||||
|
if cached_version >= self._DAILY_REPORT_CACHE_VERSION and (cached_image or cached_text):
|
||||||
|
return {
|
||||||
|
"report_text": cached_text,
|
||||||
|
"report_image": cached_image,
|
||||||
|
"cached": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
report_text = await self._generate_fans_daily_report_text(payload)
|
||||||
|
report_image = None
|
||||||
|
if self._daily_report_send_image:
|
||||||
|
report_image = await self._render_fans_daily_report_image(payload)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"room_id": room_id,
|
||||||
|
"anchor_day": anchor_day,
|
||||||
|
"cache_version": self._DAILY_REPORT_CACHE_VERSION,
|
||||||
|
"report_text": report_text,
|
||||||
|
"report_image": report_image,
|
||||||
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
self._save_daily_report_cache(room_id, anchor_day, result, report_kind="fans")
|
||||||
result["cached"] = False
|
result["cached"] = False
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -2225,6 +2551,72 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
)
|
)
|
||||||
return delivered_any
|
return delivered_any
|
||||||
|
|
||||||
|
async def _send_fans_daily_reports(
|
||||||
|
self,
|
||||||
|
anchor_day: str,
|
||||||
|
target_group_id: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
force_regenerate: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
发送粉丝向恶搞日报。
|
||||||
|
当前刻意不复用“已发送标记”:
|
||||||
|
1. 它不是原有定时任务的一部分,默认按手动召回理解;
|
||||||
|
2. 群里想反复看不同版本文案时,不会被“今天已经发过”拦住。
|
||||||
|
"""
|
||||||
|
rooms = (
|
||||||
|
set(self.redis_manager.list_group_rooms(target_group_id))
|
||||||
|
if target_group_id
|
||||||
|
else self.redis_manager.all_subscribed_rooms()
|
||||||
|
)
|
||||||
|
if not rooms:
|
||||||
|
logger.info(
|
||||||
|
f"斗鱼粉丝日报无可处理房间: day={anchor_day}, target_group={target_group_id or 'ALL'}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
delivered_any = False
|
||||||
|
for room_id in rooms:
|
||||||
|
sessions = self._load_sessions_for_anchor_day(room_id, anchor_day)
|
||||||
|
if not sessions:
|
||||||
|
logger.info(f"斗鱼粉丝日报无 session: room={room_id}, day={anchor_day}")
|
||||||
|
continue
|
||||||
|
if any(bool(session.get("is_live")) for session in sessions):
|
||||||
|
logger.info(f"斗鱼粉丝日报存在直播中场次,跳过: room={room_id}, day={anchor_day}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = self._build_daily_report_payload(room_id, anchor_day, sessions)
|
||||||
|
if not payload:
|
||||||
|
logger.info(
|
||||||
|
f"斗鱼粉丝日报 payload 为空: room={room_id}, day={anchor_day}, "
|
||||||
|
f"sessions={len(sessions)}, min_messages={self._daily_report_min_messages}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
report_result = await self._get_or_create_fans_daily_report_result(
|
||||||
|
room_id,
|
||||||
|
anchor_day,
|
||||||
|
payload,
|
||||||
|
force_regenerate=force_regenerate,
|
||||||
|
)
|
||||||
|
report_text = str(report_result.get("report_text") or "").strip()
|
||||||
|
report_image = self._resolve_existing_report_image(report_result.get("report_image"))
|
||||||
|
groups = [target_group_id] if target_group_id else self.redis_manager.groups_for_room(room_id)
|
||||||
|
for gid in groups:
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
if GroupBotManager.get_group_permission(gid, self.feature) != PermissionStatus.ENABLED:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if report_image:
|
||||||
|
await self.bot.send_image_message(gid, Path(report_image))
|
||||||
|
else:
|
||||||
|
await self.bot.send_text_message(gid, report_text)
|
||||||
|
delivered_any = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送斗鱼粉丝日报失败(room={room_id}, group={gid}): {e}")
|
||||||
|
return delivered_any
|
||||||
|
|
||||||
def _start_danmu_record(self, room_id: str):
|
def _start_danmu_record(self, room_id: str):
|
||||||
recorder = self._get_danmu_recorder(room_id)
|
recorder = self._get_danmu_recorder(room_id)
|
||||||
recorder.start()
|
recorder.start()
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ def _escape(value: Any) -> str:
|
|||||||
return html.escape(str(value or ""))
|
return html.escape(str(value or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_bullet_text(line: str) -> str:
|
||||||
|
"""
|
||||||
|
统一清洗模型输出里的 bullet 前缀。
|
||||||
|
这样做的目的有两个:
|
||||||
|
1. 兼容 `-`、`•`、`1.` 这类常见列表格式;
|
||||||
|
2. 让模板层只拿到干净文本,避免在 HTML 里再做重复判断。
|
||||||
|
"""
|
||||||
|
text = str(line or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.startswith("- "):
|
||||||
|
return text[2:].strip()
|
||||||
|
if text.startswith("•"):
|
||||||
|
return text[1:].strip()
|
||||||
|
if len(text) > 2 and text[0].isdigit() and text[1] in {".", "、"}:
|
||||||
|
return text[2:].strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _render_metric_card(label: str, value: Any, hint: str = "") -> str:
|
def _render_metric_card(label: str, value: Any, hint: str = "") -> str:
|
||||||
return (
|
return (
|
||||||
'<div class="metric-card">'
|
'<div class="metric-card">'
|
||||||
@@ -61,6 +80,60 @@ def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str], List[str]
|
|||||||
return lead, insight_items, fans_extract_items
|
return lead, insight_items, fans_extract_items
|
||||||
|
|
||||||
|
|
||||||
|
def _split_fans_report_blocks(report_text: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将“粉丝向恶搞日报”文本拆成模板需要的结构化区块。
|
||||||
|
约定模型尽量输出如下标题:
|
||||||
|
- 【今日笑点】
|
||||||
|
- 【弹幕名场面】
|
||||||
|
- 【梗王榜】
|
||||||
|
- 【收尾播报】
|
||||||
|
即便模型没有完全按约定输出,这里也会尽量兜底,保证页面不空。
|
||||||
|
"""
|
||||||
|
header_alias_map = {
|
||||||
|
"今日笑点": "laugh_points",
|
||||||
|
"笑点": "laugh_points",
|
||||||
|
"欢乐总结": "laugh_points",
|
||||||
|
"弹幕名场面": "famous_scenes",
|
||||||
|
"名场面": "famous_scenes",
|
||||||
|
"现场整活": "famous_scenes",
|
||||||
|
"梗王榜": "meme_rank",
|
||||||
|
"梗榜": "meme_rank",
|
||||||
|
"复读冠军": "meme_rank",
|
||||||
|
"收尾播报": "closing",
|
||||||
|
"结尾播报": "closing",
|
||||||
|
"结尾": "closing",
|
||||||
|
}
|
||||||
|
sections = {
|
||||||
|
"lead": "",
|
||||||
|
"laugh_points": [],
|
||||||
|
"famous_scenes": [],
|
||||||
|
"meme_rank": [],
|
||||||
|
"closing": [],
|
||||||
|
}
|
||||||
|
current_key = "lead"
|
||||||
|
lead_parts: List[str] = []
|
||||||
|
|
||||||
|
for raw_line in str(report_text or "").splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
normalized_title = stripped.strip("【】:#: ").replace(":", "").replace(":", "")
|
||||||
|
if normalized_title in header_alias_map:
|
||||||
|
current_key = header_alias_map[normalized_title]
|
||||||
|
continue
|
||||||
|
clean_text = _clean_bullet_text(stripped)
|
||||||
|
if not clean_text:
|
||||||
|
continue
|
||||||
|
if current_key == "lead":
|
||||||
|
lead_parts.append(clean_text)
|
||||||
|
else:
|
||||||
|
sections[current_key].append(clean_text)
|
||||||
|
|
||||||
|
sections["lead"] = " ".join(lead_parts).strip()
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
|
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()]
|
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
|
||||||
if len(normalized) >= target_count:
|
if len(normalized) >= target_count:
|
||||||
@@ -138,6 +211,130 @@ def _normalize_fans_extract_bullets(payload: Dict[str, Any], items: List[str], t
|
|||||||
return normalized[:target_count]
|
return normalized[:target_count]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_funny_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 4) -> 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]
|
||||||
|
|
||||||
|
supplements: List[str] = []
|
||||||
|
for item in (payload.get("merged_templates", []) or [])[:4]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"同一句梗反复刷了 {count} 次,直播间默认进入复读机模式:{text[:30]}。")
|
||||||
|
|
||||||
|
for item in (payload.get("burst_terms", []) or [])[:4]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"情绪词「{text}」高频刷屏 {count} 次,说明这一段大家已经集体上头。")
|
||||||
|
|
||||||
|
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 _normalize_scene_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]
|
||||||
|
|
||||||
|
supplements: List[str] = []
|
||||||
|
for item in (payload.get("representative_messages", []) or [])[:10]:
|
||||||
|
nickname = str(item.get("nickname") or "").strip() or "观众"
|
||||||
|
content = str(item.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
supplements.append(f"{nickname}:{content[:42]}")
|
||||||
|
|
||||||
|
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 _normalize_rank_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 3) -> List[str]:
|
||||||
|
"""
|
||||||
|
“梗王榜”兜底来源按优先级走:
|
||||||
|
1. 已聚合的长模板梗;
|
||||||
|
2. 重复短句;
|
||||||
|
3. 爆发情绪词。
|
||||||
|
这样即便模型漏写榜单,页面也能稳定展示“今天到底大家在刷什么”。
|
||||||
|
"""
|
||||||
|
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
|
||||||
|
if len(normalized) >= target_count:
|
||||||
|
return normalized[:target_count]
|
||||||
|
|
||||||
|
supplements: List[str] = []
|
||||||
|
for item in (payload.get("merged_templates", []) or [])[:3]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"{text[:30]}|全场 {count} 次")
|
||||||
|
|
||||||
|
for item in (payload.get("repeated_messages", []) or [])[:3]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"{text[:30]}|复读 {count} 次")
|
||||||
|
|
||||||
|
for item in (payload.get("burst_terms", []) or [])[:3]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"{text}|情绪爆发 {count} 次")
|
||||||
|
|
||||||
|
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 _normalize_closing_text(payload: Dict[str, Any], closing_items: List[str]) -> str:
|
||||||
|
"""
|
||||||
|
收尾句只有一句,优先保留模型原话;
|
||||||
|
如果模型没给,就用当天最高峰时段和热词拼一个轻松结尾。
|
||||||
|
"""
|
||||||
|
for item in closing_items:
|
||||||
|
value = str(item or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
peak_buckets = payload.get("peak_buckets", []) or []
|
||||||
|
if peak_buckets:
|
||||||
|
top_bucket = peak_buckets[0]
|
||||||
|
top_terms = [
|
||||||
|
str(term.get("term") or "").strip()
|
||||||
|
for term in (top_bucket.get("top_terms", []) or [])[:3]
|
||||||
|
if str(term.get("term") or "").strip()
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
f"今晚最佳观影时段锁定 {str(top_bucket.get('start_time') or '')[-8:-3]},"
|
||||||
|
f"大家围着 {'、'.join(top_terms) or '节目效果'} 一起起哄,收工时空气里都还是梗。"
|
||||||
|
)
|
||||||
|
return "今天的直播结论很简单:操作未必全记住了,但弹幕梗已经自动住进群友脑回路。"
|
||||||
|
|
||||||
|
|
||||||
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
|
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
|
||||||
items: List[str] = []
|
items: List[str] = []
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -193,6 +390,78 @@ def _render_insight_cards(items: List[str]) -> str:
|
|||||||
return "".join(blocks)
|
return "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_fans_scene_cards(items: List[str]) -> str:
|
||||||
|
blocks = []
|
||||||
|
for item in items[:6]:
|
||||||
|
blocks.append(
|
||||||
|
'<div class="fans-scene-card">'
|
||||||
|
f'<div class="fans-scene-quote">{_escape(item)}</div>'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
return "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_rank_cards(items: List[str]) -> str:
|
||||||
|
blocks = []
|
||||||
|
for idx, item in enumerate(items[:3], start=1):
|
||||||
|
blocks.append(
|
||||||
|
'<div class="meme-rank-card">'
|
||||||
|
f'<div class="meme-rank-no">TOP {idx}</div>'
|
||||||
|
f'<div class="meme-rank-text">{_escape(item)}</div>'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
return "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fans_fun_metrics(payload: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
粉丝版避免直接沿用“运营指标”命名,改成更轻松的展示口径。
|
||||||
|
底层仍然来自同一份 payload,所以既分风格,又不损失真实性。
|
||||||
|
"""
|
||||||
|
meta = payload.get("report_meta", {}) or {}
|
||||||
|
peak_buckets = payload.get("peak_buckets", []) or []
|
||||||
|
repeated_messages = payload.get("repeated_messages", []) or []
|
||||||
|
burst_terms = payload.get("burst_terms", []) or []
|
||||||
|
top_bucket = peak_buckets[0] if peak_buckets else {}
|
||||||
|
top_repeat = repeated_messages[0] if repeated_messages else {}
|
||||||
|
top_burst = burst_terms[0] if burst_terms else {}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": "今日弹幕量",
|
||||||
|
"value": str(meta.get("message_count", 0) or 0),
|
||||||
|
"hint": "今天一共刷了多少句",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "围观群众",
|
||||||
|
"value": str(meta.get("unique_user_count", 0) or 0),
|
||||||
|
"hint": "参与一起起哄的人数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "最高能时段",
|
||||||
|
"value": str(top_bucket.get("start_time") or "")[-8:-3] or "--:--",
|
||||||
|
"hint": "弹幕最炸裂的时间点",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "今日爆词",
|
||||||
|
"value": str(top_burst.get("text") or top_repeat.get("text") or "乐"),
|
||||||
|
"hint": "刷得最凶的那句",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_fans_metric_cards(metrics: List[Dict[str, str]]) -> str:
|
||||||
|
blocks = []
|
||||||
|
for item in metrics:
|
||||||
|
blocks.append(
|
||||||
|
'<div class="fans-metric-card">'
|
||||||
|
f'<div class="fans-metric-label">{_escape(item.get("label", ""))}</div>'
|
||||||
|
f'<div class="fans-metric-value">{_escape(item.get("value", ""))}</div>'
|
||||||
|
f'<div class="fans-metric-hint">{_escape(item.get("hint", ""))}</div>'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
return "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
def _render_badges(top_badges: List[Dict[str, Any]]) -> str:
|
def _render_badges(top_badges: List[Dict[str, Any]]) -> str:
|
||||||
blocks = []
|
blocks = []
|
||||||
for item in top_badges[:6]:
|
for item in top_badges[:6]:
|
||||||
@@ -489,3 +758,44 @@ def render_daily_report_html(
|
|||||||
"active_users_html": Markup(_render_active_users(top_active_users)),
|
"active_users_html": Markup(_render_active_users(top_active_users)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_fans_daily_report_html(
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
fans_report_text: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
渲染“粉丝向恶搞日报”。
|
||||||
|
这份报告的设计重点不是运营洞察,而是把当天最有节目效果的弹幕氛围单独做成一页,
|
||||||
|
方便群里直接转发、看图找乐子。
|
||||||
|
"""
|
||||||
|
meta = payload.get("report_meta", {}) 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('message_count', 0)} 条"
|
||||||
|
f" | 围观群众 {meta.get('unique_user_count', 0)} 人"
|
||||||
|
)
|
||||||
|
sections = _split_fans_report_blocks(fans_report_text)
|
||||||
|
laugh_points = _normalize_funny_bullets(payload, sections.get("laugh_points", []), target_count=4)
|
||||||
|
famous_scenes = _normalize_scene_bullets(payload, sections.get("famous_scenes", []), target_count=5)
|
||||||
|
meme_rank = _normalize_rank_bullets(payload, sections.get("meme_rank", []), target_count=3)
|
||||||
|
closing_text = _normalize_closing_text(payload, sections.get("closing", []))
|
||||||
|
lead_text = str(sections.get("lead") or "").strip()
|
||||||
|
if not lead_text:
|
||||||
|
lead_text = "今天这场直播的弹幕主打一个集体上头,观众一边盯着画面,一边忙着把梗越刷越离谱。"
|
||||||
|
|
||||||
|
renderer = HtmlTemplateRenderer()
|
||||||
|
return renderer.render(
|
||||||
|
"plugins/douyu/templates/daily_fans_report.html",
|
||||||
|
{
|
||||||
|
"title_name": title_name,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"lead_text": lead_text,
|
||||||
|
# 粉丝版刻意弱化“分析感”,下面几块都只展示娱乐化后的结果。
|
||||||
|
"fans_metrics_html": Markup(_render_fans_metric_cards(_build_fans_fun_metrics(payload))),
|
||||||
|
"laugh_points_html": Markup(_render_list(laugh_points, item_class="funny-list")),
|
||||||
|
"famous_scenes_html": Markup(_render_fans_scene_cards(famous_scenes)),
|
||||||
|
"meme_rank_html": Markup(_render_rank_cards(meme_rank)),
|
||||||
|
"closing_text": closing_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
286
plugins/douyu/templates/daily_fans_report.html
Normal file
286
plugins/douyu/templates/daily_fans_report.html
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-1: #fff4d9;
|
||||||
|
--bg-2: #ffe8d6;
|
||||||
|
--bg-3: #ffeef5;
|
||||||
|
--paper: rgba(255, 251, 245, 0.98);
|
||||||
|
--text: #2f1b1b;
|
||||||
|
--muted: #7b5e57;
|
||||||
|
--line: rgba(122, 77, 54, 0.14);
|
||||||
|
--hot-pink: #ff5d8f;
|
||||||
|
--orange: #ff8a3d;
|
||||||
|
--yellow: #ffc857;
|
||||||
|
--red: #f25f5c;
|
||||||
|
--shadow: 0 28px 64px rgba(160, 84, 58, 0.18);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 34px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.4), transparent 22%),
|
||||||
|
radial-gradient(circle at 88% 10%, rgba(255, 93, 143, 0.14), transparent 24%),
|
||||||
|
linear-gradient(135deg, var(--bg-1) 0%, var(--bg-2) 52%, var(--bg-3) 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.75);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 34px 38px 30px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 22%, rgba(255,255,255,0.18), transparent 18%),
|
||||||
|
radial-gradient(circle at 90% 20%, rgba(255,255,255,0.16), transparent 20%),
|
||||||
|
linear-gradient(135deg, #ff7b54 0%, #ff5d8f 45%, #ffb703 100%);
|
||||||
|
color: #fffdf7;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
content: "哈哈哈哈";
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
top: 20px;
|
||||||
|
font-size: 66px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: rgba(255,255,255,0.12);
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.16);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hero-title {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(255, 251, 245, 0.92);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 28px 30px 34px;
|
||||||
|
}
|
||||||
|
.lead-box {
|
||||||
|
padding: 22px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,243,237,0.96));
|
||||||
|
border: 1px solid rgba(255, 138, 61, 0.18);
|
||||||
|
box-shadow: 0 14px 30px rgba(186, 101, 69, 0.08);
|
||||||
|
}
|
||||||
|
.lead-kicker {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b45309;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.lead-text {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: #4b2e2b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fans-metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.fans-metric-card {
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(255,245,240,0.92));
|
||||||
|
border: 1px solid rgba(255, 138, 61, 0.16);
|
||||||
|
}
|
||||||
|
.fans-metric-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fans-metric-value {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #d9485f;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.fans-metric-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b6b63;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,248,244,0.94));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #5f2a2a;
|
||||||
|
}
|
||||||
|
.section-title .icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #ff5d8f, #ffb703);
|
||||||
|
box-shadow: 0 6px 16px rgba(255, 93, 143, 0.22);
|
||||||
|
}
|
||||||
|
.two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.funny-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.funny-list li {
|
||||||
|
margin: 10px 0;
|
||||||
|
color: #533835;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.76;
|
||||||
|
}
|
||||||
|
.meme-rank-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.meme-rank-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(255,240,232,0.95));
|
||||||
|
border: 1px solid rgba(242, 95, 92, 0.18);
|
||||||
|
}
|
||||||
|
.meme-rank-no {
|
||||||
|
color: #d9485f;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.meme-rank-text {
|
||||||
|
color: #4b2e2b;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.66;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.scene-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.fans-scene-card {
|
||||||
|
padding: 16px 16px 15px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(255,245,240,0.95));
|
||||||
|
border: 1px solid rgba(255, 138, 61, 0.16);
|
||||||
|
min-height: 108px;
|
||||||
|
}
|
||||||
|
.fans-scene-quote {
|
||||||
|
color: #5b3c37;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.closing-box {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 93, 143, 0.1), rgba(255, 184, 0, 0.12));
|
||||||
|
border: 1px solid rgba(255, 93, 143, 0.16);
|
||||||
|
}
|
||||||
|
.closing-kicker {
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.closing-text {
|
||||||
|
color: #613b34;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.footer-note {
|
||||||
|
margin-top: 18px;
|
||||||
|
text-align: right;
|
||||||
|
color: #8f736c;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sheet">
|
||||||
|
<!-- 头图区:强调“粉丝向整活日报”的氛围,先把情绪拉起来。 -->
|
||||||
|
<div class="hero">
|
||||||
|
<div class="eyebrow">DOUYU FANS FUN REPORT</div>
|
||||||
|
<div class="hero-title">{{ title_name }} 的弹幕乐子日报</div>
|
||||||
|
<div class="hero-subtitle">{{ subtitle }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<!-- 总述区:用一段话快速交代今天直播间到底好笑在哪。 -->
|
||||||
|
<div class="lead-box">
|
||||||
|
<div class="lead-kicker">今晚气氛速报</div>
|
||||||
|
<div class="lead-text">{{ lead_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 轻量指标区:保留真实数据来源,但展示口径更偏“乐子视角”。 -->
|
||||||
|
<div class="fans-metric-grid">{{ fans_metrics_html }}</div>
|
||||||
|
|
||||||
|
<!-- 笑点区:左侧放梗概,右侧放梗王榜,方便一眼扫完。 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title"><span class="icon"></span><span>今日笑点</span></div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div>{{ laugh_points_html }}</div>
|
||||||
|
<div class="meme-rank-stack">{{ meme_rank_html }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 名场面区:尽量让内容保留“弹幕原声感”,增强现场回放味道。 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title"><span class="icon"></span><span>弹幕名场面</span></div>
|
||||||
|
<div class="scene-grid">{{ famous_scenes_html }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 收尾句:给整张图一个群聊转发时更像“梗图文案”的落点。 -->
|
||||||
|
<div class="closing-box">
|
||||||
|
<div class="closing-kicker">收尾播报</div>
|
||||||
|
<div class="closing-text">{{ closing_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-note">ABOT · Douyu Fans Fun Report</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user