feat(douyu): add audience trend chart to daily report
This commit is contained in:
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user