feat(douyu): add audience trend chart to daily report

This commit is contained in:
liuwei
2026-04-08 17:03:43 +08:00
parent 8909b8c386
commit 867ed0a2ec
17 changed files with 21493 additions and 10 deletions

View File

@@ -34,9 +34,11 @@ from wechat_ipad.models.appmsg_xml import DOUYU_MESSAGE_XML
class DouyuDanmuRecorder:
def __init__(self, room_id: str, user_agent: str):
def __init__(self, room_id: str, user_agent: str, stats_callback=None, stats_sample_interval_seconds: int = 60):
self.room_id = room_id
self.user_agent = user_agent
self.stats_callback = stats_callback
self.stats_sample_interval_seconds = max(0, int(stats_sample_interval_seconds or 0))
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._ws: Optional[websocket.WebSocketApp] = None
@@ -45,6 +47,9 @@ class DouyuDanmuRecorder:
self._buffer_date: Optional[str] = None
self._lock = threading.Lock()
self._websocket_available = websocket is not None
self._latest_vip_count: Optional[int] = None
self._latest_diamond_count: Optional[int] = None
self._last_stats_signature: Tuple[Optional[int], Optional[int]] = (None, None)
def _encode(self, msg: str) -> bytes:
content = msg.encode("utf-8") + b"\x00"
@@ -54,6 +59,42 @@ class DouyuDanmuRecorder:
head += b"\x00\x00"
return head + content
@staticmethod
def _parse_parts(line: str) -> Dict[str, Any]:
parts: Dict[str, Any] = {}
for pair in line.split("/"):
if "@=" in pair:
key, value = pair.split("@=", 1)
parts[key] = value
return parts
@staticmethod
def _safe_int(value: Any, default: Optional[int] = None) -> Optional[int]:
try:
return int(str(value))
except Exception:
return default
def _maybe_emit_stats(self, force: bool = False) -> None:
if not self.stats_callback:
return
if self._latest_vip_count is None and self._latest_diamond_count is None:
return
signature = (self._latest_vip_count, self._latest_diamond_count)
if not force:
if signature == self._last_stats_signature:
return
point = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"vip_count": self._latest_vip_count,
"diamond_count": self._latest_diamond_count,
}
try:
self.stats_callback(self.room_id, point)
self._last_stats_signature = signature
except Exception as e:
logger.warning(f"斗鱼人数采样回调失败({self.room_id}): {e}")
def _on_message(self, ws, message):
try:
decompressed = zlib.decompress(message, -zlib.MAX_WBITS)
@@ -64,13 +105,25 @@ class DouyuDanmuRecorder:
line = line.strip()
if not line:
continue
if "type@=chatmsg" not in line:
parts = self._parse_parts(line)
msg_type = str(parts.get("type") or "").strip()
if msg_type == "oni":
vip_count = self._safe_int(parts.get("vn"))
if vip_count is not None:
self._latest_vip_count = vip_count
self._maybe_emit_stats()
continue
if msg_type == "dfnum":
diamond_count = self._safe_int(parts.get("dfc"))
if diamond_count is not None:
self._latest_diamond_count = diamond_count
self._maybe_emit_stats()
continue
if msg_type != "chatmsg":
continue
parts: Dict[str, Any] = {}
for pair in line.split("/"):
if "@=" in pair:
key, value = pair.split("@=", 1)
parts[key] = value
nick = parts.get("nn", "未知")
txt = parts.get("txt", "")
uid = parts.get("uid", "未知")
@@ -129,7 +182,7 @@ class DouyuDanmuRecorder:
def _on_open(self, ws):
ws.send(self._encode(f"type@=loginreq/roomid@={self.room_id}/dmbt@=chrome/dmbv@=0/"))
ws.send(self._encode(f"type@=joingroup/rid@={self.room_id}/gid@={self.room_id}/"))
ws.send(self._encode(f"type@=joingroup/rid@={self.room_id}/gid@=-9999/"))
def heartbeat():
while ws.sock and ws.sock.connected and not self._stop_event.is_set():
@@ -199,6 +252,7 @@ class DouyuDanmuRecorder:
self._thread.start()
def stop(self):
self._maybe_emit_stats(force=True)
self._flush()
self._stop_event.set()
if self._ws:
@@ -379,7 +433,7 @@ class DouyuRedisManager:
class DouyuPlugin(MessagePluginInterface):
_DAILY_REPORT_CACHE_VERSION = 2
_DAILY_REPORT_CACHE_VERSION = 3
FEATURE_KEY = "DOUYU_MONITOR"
FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]"
@@ -435,6 +489,7 @@ class DouyuPlugin(MessagePluginInterface):
self._daily_report_max_sessions = 4
self._daily_report_max_length = 1800
self._daily_report_send_image = True
self._audience_stats_sample_interval_seconds = 60
self._daily_report_llm_client: Optional[LLMClient] = None
self._danmu_recorders: Dict[str, DouyuDanmuRecorder] = {}
async_job.every_minutes(self._check_interval)(self._scheduled_unified_check_job)
@@ -482,6 +537,9 @@ class DouyuPlugin(MessagePluginInterface):
self._daily_report_max_sessions = int(cfg.get("daily_report_max_sessions", self._daily_report_max_sessions))
self._daily_report_max_length = int(cfg.get("daily_report_max_length", self._daily_report_max_length))
self._daily_report_send_image = bool(cfg.get("daily_report_send_image", self._daily_report_send_image))
self._audience_stats_sample_interval_seconds = int(
cfg.get("audience_stats_sample_interval_seconds", self._audience_stats_sample_interval_seconds)
)
report_api_cfg = cfg.get("report_api", {}) or {}
if report_api_cfg:
@@ -822,10 +880,48 @@ class DouyuPlugin(MessagePluginInterface):
def _get_danmu_recorder(self, room_id: str) -> DouyuDanmuRecorder:
recorder = self._danmu_recorders.get(room_id)
if not recorder:
recorder = DouyuDanmuRecorder(room_id, self._user_agent)
recorder = DouyuDanmuRecorder(
room_id,
self._user_agent,
stats_callback=self._record_room_audience_point,
stats_sample_interval_seconds=self._audience_stats_sample_interval_seconds,
)
self._danmu_recorders[room_id] = recorder
return recorder
@staticmethod
def _normalize_audience_points(points: List[Dict[str, Any]], limit: int = 720) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
seen = set()
for item in points or []:
timestamp = str(item.get("timestamp") or "").strip()
if not timestamp or timestamp in seen:
continue
seen.add(timestamp)
normalized.append({
"timestamp": timestamp,
"vip_count": int(item.get("vip_count", 0) or 0),
"diamond_count": int(item.get("diamond_count", 0) or 0),
})
normalized.sort(key=lambda row: row.get("timestamp", ""))
if len(normalized) > limit:
normalized = normalized[-limit:]
return normalized
def _record_room_audience_point(self, room_id: str, point: Dict[str, Any]) -> None:
if not self.redis_manager or not room_id:
return
session = self.redis_manager.get_latest_room_session(room_id)
if not session or not bool(session.get("is_live")):
return
current_points = self._normalize_audience_points(list(session.get("audience_points", []) or []))
merged_points = self._normalize_audience_points(current_points + [point])
if merged_points == current_points:
return
session["audience_points"] = merged_points
session["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.redis_manager.save_room_session(room_id, session)
def _resolve_anchor_day(self, target_dt: datetime) -> str:
if target_dt.hour < self._session_cutoff_hour:
target_dt = target_dt - timedelta(days=1)
@@ -885,6 +981,7 @@ class DouyuPlugin(MessagePluginInterface):
"nickname": nickname,
"room_name": room_name,
"segments": [{"start_time": now_str, "end_time": ""}],
"audience_points": [],
"is_live": True,
"summary_status": "pending",
"summary_generated_at": "",
@@ -893,6 +990,7 @@ class DouyuPlugin(MessagePluginInterface):
session["nickname"] = nickname or session.get("nickname", "")
session["room_name"] = room_name or session.get("room_name", "")
session["audience_points"] = self._normalize_audience_points(list(session.get("audience_points", []) or []))
session["is_live"] = True
session["updated_at"] = now_str
session["last_live_at"] = now_str
@@ -1083,6 +1181,37 @@ class DouyuPlugin(MessagePluginInterface):
"source": "fallback_full_day",
}]
def _build_audience_trend(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
points: List[Dict[str, Any]] = []
for session in sessions:
for item in session.get("audience_points", []) or []:
point = {
"timestamp": str(item.get("timestamp") or "").strip(),
"vip_count": int(item.get("vip_count", 0) or 0),
"diamond_count": int(item.get("diamond_count", 0) or 0),
}
if point["timestamp"]:
points.append(point)
points = self._normalize_audience_points(points, limit=1440)
if not points:
return {"points": [], "summary": {}}
vip_values = [int(item.get("vip_count", 0) or 0) for item in points]
diamond_values = [int(item.get("diamond_count", 0) or 0) for item in points]
labels = [str(item.get("timestamp") or "")[-8:-3] for item in points]
return {
"points": points,
"summary": {
"point_count": len(points),
"vip_min": min(vip_values),
"vip_max": max(vip_values),
"vip_latest": vip_values[-1],
"diamond_min": min(diamond_values),
"diamond_max": max(diamond_values),
"diamond_latest": diamond_values[-1],
"labels": labels,
},
}
def _build_daily_report_payload(self, room_id: str, anchor_day: str, sessions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not sessions:
return None
@@ -1203,6 +1332,7 @@ class DouyuPlugin(MessagePluginInterface):
artifact_dir = os.path.join("temp", "douyu_materials")
os.makedirs(artifact_dir, exist_ok=True)
audience_trend = self._build_audience_trend(sessions)
payload = {
"report_meta": {
"room_id": room_id,
@@ -1244,6 +1374,7 @@ class DouyuPlugin(MessagePluginInterface):
}
for item in session_payloads
],
"audience_trend": audience_trend,
"merged_templates": merged_templates[:24],
"repeated_messages": repeated_messages[:24],
"top_terms": [{"term": term, "count": count} for term, count in top_terms_counter.most_common(24)],
@@ -1437,6 +1568,16 @@ class DouyuPlugin(MessagePluginInterface):
f"- 用户质量:房间等级 30 级以上活跃用户 {high_room_users} 人,说明高等级老观众参与度不低。",
]
audience_summary = (payload.get("audience_trend", {}) or {}).get("summary", {}) or {}
if audience_summary:
vip_min = int(audience_summary.get("vip_min", 0) or 0)
vip_max = int(audience_summary.get("vip_max", 0) or 0)
diamond_latest = int(audience_summary.get("diamond_latest", 0) or 0)
point_count = int(audience_summary.get("point_count", 0) or 0)
lines.append(
f"- 人数走势WS 侧共采样 {point_count} 个时间点,贵宾在 {vip_min}-{vip_max} 区间波动,钻粉收盘约 {diamond_latest}"
)
top_badges = payload.get("operator_metrics", {}).get("top_badges", []) or []
if top_badges:
badge_parts = []