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

@@ -24,6 +24,7 @@ daily_report_use_llm = true
daily_report_max_sessions = 4
daily_report_max_length = 1800
daily_report_send_image = true
audience_stats_sample_interval_seconds = 0
[Douyu.report_api]
backend = "openai_compatible_ai_auto_response"

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 = []

View File

@@ -190,6 +190,158 @@ def _render_active_users(top_active_users: List[Dict[str, Any]]) -> str:
return "".join(blocks)
def _render_audience_trend_chart(audience_trend: Dict[str, Any]) -> str:
points = audience_trend.get("points", []) or []
if len(points) < 2:
return (
'<div class="chart-empty">'
'<div class="chart-empty-title">人数趋势</div>'
'<div class="chart-empty-desc">当前直播场次还没有足够的 WS 采样点,暂时无法绘制趋势图。</div>'
'</div>'
)
width = 820
height = 276
padding_left = 62
padding_right = 64
padding_top = 24
padding_bottom = 38
plot_width = width - padding_left - padding_right
plot_height = height - padding_top - padding_bottom
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]
vip_min = min(vip_values)
vip_max = max(vip_values)
diamond_min = min(diamond_values)
diamond_max = max(diamond_values)
vip_span = max(vip_max - vip_min, 1)
diamond_span = max(diamond_max - diamond_min, 1)
step_x = plot_width / max(len(points) - 1, 1)
bar_width = max(min(step_x * 0.58, 18), 6)
def x_at(index: int) -> float:
return padding_left + step_x * index
def y_vip(value: int) -> float:
ratio = (value - vip_min) / vip_span if vip_span else 0
return padding_top + plot_height - ratio * plot_height
def y_diamond(value: int) -> float:
ratio = (value - diamond_min) / diamond_span if diamond_span else 0
return padding_top + plot_height - ratio * plot_height
grid_lines = []
left_ticks = []
right_ticks = []
for idx in range(5):
ratio = idx / 4
y = padding_top + plot_height - ratio * plot_height
vip_tick = round(vip_min + vip_span * ratio)
diamond_tick = round(diamond_min + diamond_span * ratio)
grid_lines.append(
f'<line x1="{padding_left}" y1="{y:.1f}" x2="{width - padding_right}" y2="{y:.1f}" class="chart-grid" />'
)
left_ticks.append(
f'<text x="{padding_left - 10}" y="{y + 4:.1f}" text-anchor="end" class="axis-label axis-left">{vip_tick}</text>'
)
right_ticks.append(
f'<text x="{width - padding_right + 10}" y="{y + 4:.1f}" text-anchor="start" class="axis-label axis-right">{diamond_tick}</text>'
)
bars = []
line_points = []
dot_points = []
label_marks = []
annotations = []
if len(points) <= 12:
label_indexes = list(range(len(points)))
else:
label_indexes = sorted(set([0, len(points) - 1] + [int(round((len(points) - 1) * i / 6)) for i in range(1, 6)]))
vip_peak_index = vip_values.index(vip_max)
vip_valley_index = vip_values.index(vip_min)
diamond_latest_index = len(points) - 1
for idx, item in enumerate(points):
x = x_at(idx)
vip_y = y_vip(int(item.get("vip_count", 0) or 0))
diamond_y = y_diamond(int(item.get("diamond_count", 0) or 0))
bar_height = padding_top + plot_height - vip_y
bars.append(
f'<rect x="{x - bar_width / 2:.1f}" y="{vip_y:.1f}" width="{bar_width:.1f}" height="{bar_height:.1f}" rx="7" class="vip-bar" />'
)
line_points.append(f"{x:.1f},{diamond_y:.1f}")
dot_points.append(f'<circle cx="{x:.1f}" cy="{diamond_y:.1f}" r="3.5" class="diamond-dot" />')
if idx in label_indexes:
label_marks.append(
f'<text x="{x:.1f}" y="{height - 16}" text-anchor="middle" class="time-label">{_escape(labels[idx])}</text>'
)
if idx == vip_peak_index:
annotations.append(
f'<g class="chart-annotation">'
f'<text x="{x:.1f}" y="{max(vip_y - 16, 18):.1f}" text-anchor="middle" class="value-label vip">峰值 {vip_max}</text>'
f'<text x="{x:.1f}" y="{max(vip_y - 2, 30):.1f}" text-anchor="middle" class="value-sub-label vip">{_escape(labels[idx])}</text>'
f'</g>'
)
elif idx == vip_valley_index and vip_min != vip_max:
annotations.append(
f'<g class="chart-annotation">'
f'<text x="{x:.1f}" y="{min(vip_y + 18, padding_top + plot_height - 18):.1f}" text-anchor="middle" class="value-label vip soft">低点 {vip_min}</text>'
f'<text x="{x:.1f}" y="{min(vip_y + 32, padding_top + plot_height - 6):.1f}" text-anchor="middle" class="value-sub-label vip soft">{_escape(labels[idx])}</text>'
f'</g>'
)
if idx == diamond_latest_index:
annotations.append(
f'<g class="chart-annotation">'
f'<text x="{min(x + 10, width - padding_right - 6):.1f}" y="{max(diamond_y - 14, 18):.1f}" text-anchor="start" class="value-label diamond">最新 {int(item.get("diamond_count", 0) or 0)}</text>'
f'<text x="{min(x + 10, width - padding_right - 6):.1f}" y="{max(diamond_y, 30):.1f}" text-anchor="start" class="value-sub-label diamond">{_escape(labels[idx])}</text>'
f'</g>'
)
polyline = f'<polyline points="{" ".join(line_points)}" class="diamond-line" />'
summary = audience_trend.get("summary", {}) or {}
vip_latest = int(summary.get("vip_latest", vip_values[-1]) or 0)
diamond_latest = int(summary.get("diamond_latest", diamond_values[-1]) or 0)
return f"""
<div class="chart-wrap">
<div class="chart-head">
<div class="chart-title">贵宾 / 钻粉趋势</div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-swatch vip"></span>贵宾柱状</span>
<span class="legend-item"><span class="legend-swatch diamond"></span>钻粉折线</span>
<span class="legend-meta">最新:贵宾 {_escape(vip_latest)} / 钻粉 {_escape(diamond_latest)}</span>
</div>
</div>
<svg viewBox="0 0 {width} {height}" class="audience-chart" role="img" aria-label="贵宾与钻粉趋势图">
<defs>
<linearGradient id="vipBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b59ff" stop-opacity="0.95" />
<stop offset="100%" stop-color="#7aa2ff" stop-opacity="0.72" />
</linearGradient>
<linearGradient id="diamondLineGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#f59e0b" />
<stop offset="100%" stop-color="#ffd166" />
</linearGradient>
</defs>
<rect x="0" y="0" width="{width}" height="{height}" rx="24" class="chart-bg" />
{''.join(grid_lines)}
<line x1="{padding_left}" y1="{padding_top + plot_height:.1f}" x2="{width - padding_right}" y2="{padding_top + plot_height:.1f}" class="chart-axis" />
<line x1="{padding_left}" y1="{padding_top}" x2="{padding_left}" y2="{padding_top + plot_height:.1f}" class="chart-axis soft" />
<line x1="{width - padding_right}" y1="{padding_top}" x2="{width - padding_right}" y2="{padding_top + plot_height:.1f}" class="chart-axis soft" />
{''.join(left_ticks)}
{''.join(right_ticks)}
{''.join(bars)}
{polyline}
{''.join(dot_points)}
{''.join(annotations)}
{''.join(label_marks)}
</svg>
</div>
"""
def render_daily_report_html(
payload: Dict[str, Any],
danmu_summary: str,
@@ -212,6 +364,7 @@ def render_daily_report_html(
template_items = _build_template_items(payload)
top_active_users = payload.get("operator_metrics", {}).get("top_active_users", []) or []
audience_trend = payload.get("audience_trend", {}) or {}
lead_summary, danmu_bullets = _split_summary_blocks(danmu_summary)
danmu_bullets = _normalize_summary_bullets(payload, danmu_bullets, target_count=5)
@@ -578,6 +731,146 @@ def render_daily_report_html(
font-size: 12px;
letter-spacing: .04em;
}}
.chart-wrap {{
padding: 18px 18px 14px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
border: 1px solid rgba(129,147,181,0.16);
}}
.chart-head {{
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}}
.chart-title {{
font-size: 17px;
font-weight: 800;
color: var(--navy);
}}
.chart-legend {{
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
color: #64748b;
font-size: 12px;
}}
.legend-item {{
display: inline-flex;
align-items: center;
gap: 6px;
}}
.legend-swatch {{
width: 18px;
height: 8px;
border-radius: 999px;
display: inline-block;
}}
.legend-swatch.vip {{
background: linear-gradient(90deg, #2b59ff, #7aa2ff);
}}
.legend-swatch.diamond {{
background: linear-gradient(90deg, #f59e0b, #ffd166);
}}
.legend-meta {{
color: #475569;
font-weight: 600;
}}
.audience-chart {{
width: 100%;
height: auto;
display: block;
}}
.chart-bg {{
fill: rgba(255,255,255,0.72);
}}
.chart-grid {{
stroke: rgba(148, 163, 184, 0.20);
stroke-width: 1;
stroke-dasharray: 4 6;
}}
.chart-axis {{
stroke: rgba(71, 85, 105, 0.38);
stroke-width: 1.2;
}}
.chart-axis.soft {{
stroke: rgba(148, 163, 184, 0.28);
}}
.axis-label {{
font-size: 11px;
font-weight: 600;
}}
.axis-left {{
fill: #3557c8;
}}
.axis-right {{
fill: #b26a00;
}}
.vip-bar {{
fill: url(#vipBarGradient);
}}
.diamond-line {{
fill: none;
stroke: url(#diamondLineGradient);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}}
.diamond-dot {{
fill: #ffcc66;
stroke: #ffffff;
stroke-width: 1.4;
}}
.value-label {{
font-size: 11px;
font-weight: 700;
}}
.value-label.vip {{
fill: #2449ad;
}}
.value-label.vip.soft {{
fill: #5573c7;
}}
.value-label.diamond {{
fill: #b26a00;
}}
.value-sub-label {{
font-size: 10px;
font-weight: 700;
}}
.value-sub-label.vip {{
fill: #5f7bd0;
}}
.value-sub-label.vip.soft {{
fill: #7d94d8;
}}
.value-sub-label.diamond {{
fill: #c27c16;
}}
.time-label {{
fill: #64748b;
font-size: 11px;
font-weight: 600;
}}
.chart-empty {{
padding: 26px 24px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
border: 1px solid rgba(129,147,181,0.16);
}}
.chart-empty-title {{
font-size: 17px;
font-weight: 800;
color: var(--navy);
margin-bottom: 8px;
}}
.chart-empty-desc {{
color: #64748b;
font-size: 14px;
line-height: 1.7;
}}
@media (max-width: 900px) {{
.active-user-grid {{
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -627,6 +920,9 @@ def render_daily_report_html(
<div class="section ops">
<div class="section-title"><span class="icon"></span><span>运营数据总结</span></div>
<div style="margin-top: 2px; margin-bottom: 16px;">
{_render_audience_trend_chart(audience_trend)}
</div>
<div class="summary-grid">
<div class="prose">
{_render_list(operator_summary_lines)}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,649 @@
{
"room_id": "52876",
"message_count": 6984,
"deduped_message_count": 6981,
"unique_user_count": 2929,
"top_terms": [
{
"term": "bkb",
"count": 97
},
{
"term": "甲哥",
"count": 31
},
{
"term": "连胜",
"count": 30
},
{
"term": "瘟鸡光环",
"count": 30
},
{
"term": "doom",
"count": 28
},
{
"term": "zsmj",
"count": 26
},
{
"term": "分钟",
"count": 22
},
{
"term": "dota",
"count": 19
},
{
"term": "了吧",
"count": 17
},
{
"term": "哈哈哈哈哈哈",
"count": 16
},
{
"term": "哈哈哈哈",
"count": 16
},
{
"term": "一愣的",
"count": 15
},
{
"term": "卧槽",
"count": 14
},
{
"term": "最后机会了",
"count": 12
},
{
"term": "尽力了",
"count": 11
}
],
"burst_terms": [
{
"text": "",
"count": 38,
"user_count": 36
},
{
"text": "哈哈哈",
"count": 26,
"user_count": 18
},
{
"text": ".",
"count": 17,
"user_count": 17
},
{
"text": "1",
"count": 16,
"user_count": 14
},
{
"text": "gg",
"count": 14,
"user_count": 14
},
{
"text": "。。。",
"count": 13,
"user_count": 7
},
{
"text": "",
"count": 12,
"user_count": 12
},
{
"text": "最后机会了",
"count": 12,
"user_count": 12
},
{
"text": "哈哈哈哈",
"count": 12,
"user_count": 11
},
{
"text": "秀没了啊",
"count": 11,
"user_count": 11
}
],
"peak_buckets": [
{
"start_time": "2026-04-07 21:55:00",
"message_count": 179,
"user_count": 94,
"top_terms": [
{
"term": "bkb",
"count": 15
},
{
"term": "冲水",
"count": 10
},
{
"term": "diff",
"count": 3
},
{
"term": "舒服",
"count": 2
},
{
"term": "号位",
"count": 2
},
{
"term": "show",
"count": 2
},
{
"term": "sir",
"count": 2
},
{
"term": "换成盘子就好",
"count": 2
}
],
"burst_terms": [
{
"text": "舒服",
"count": 2,
"user_count": 2
},
{
"text": "",
"count": 2,
"user_count": 2
},
{
"text": "为毛不出盘子",
"count": 1,
"user_count": 1
},
{
"text": "突破口?",
"count": 1,
"user_count": 1
},
{
"text": "虚空就等你啊",
"count": 1,
"user_count": 1
}
],
"sample_messages": [
{
"time": "2026-04-07 21:55:22",
"nickname": "一米幻影",
"content": "今天全胜nb"
},
{
"time": "2026-04-07 21:56:42",
"nickname": "椰子米",
"content": "搞个盘子"
},
{
"time": "2026-04-07 21:57:58",
"nickname": "1刀一个小朋友",
"content": "飞的好啊"
},
{
"time": "2026-04-07 21:58:24",
"nickname": "夏半仙Zzz",
"content": "手活没了"
},
{
"time": "2026-04-07 21:59:59",
"nickname": "蜻蜓队长zzzzzzzzzzz",
"content": "那你不出盘子?"
}
]
},
{
"start_time": "2026-04-07 22:00:00",
"message_count": 128,
"user_count": 66,
"top_terms": [
{
"term": "bkb",
"count": 11
},
{
"term": "炼金",
"count": 4
},
{
"term": "你数学幼儿园",
"count": 3
},
{
"term": "就行了",
"count": 2
},
{
"term": "你血精石干嘛",
"count": 2
},
{
"term": "用的",
"count": 2
},
{
"term": "说血精石的下",
"count": 2
},
{
"term": "个游戏",
"count": 2
}
],
"burst_terms": [
{
"text": "炼金",
"count": 2,
"user_count": 1
},
{
"text": "居然输了",
"count": 1,
"user_count": 1
},
{
"text": "力竭了",
"count": 1,
"user_count": 1
},
{
"text": "不如冰甲紫苑",
"count": 1,
"user_count": 1
},
{
"text": "就是缺输出呀",
"count": 1,
"user_count": 1
}
],
"sample_messages": [
{
"time": "2026-04-07 22:00:01",
"nickname": "夕立哥DY",
"content": "居然输了"
},
{
"time": "2026-04-07 22:00:50",
"nickname": "跳呀丶跳呀跳",
"content": "换个🐏那个虚空多死两次就完了"
},
{
"time": "2026-04-07 22:01:42",
"nickname": "我炸了啊大哥",
"content": "火猫两个命石都没了,能不弱嘛"
},
{
"time": "2026-04-07 22:03:01",
"nickname": "蜻蜓队长zzzzzzzzzzz",
"content": "出个羊刀对面吹风也没用,蓝猫和虚空也很怕🐏"
},
{
"time": "2026-04-07 22:04:55",
"nickname": "吃鱼小能手丶",
"content": "上把就是血精的问题自己技能释放和切入不行就出羊再不济出个紫苑打虚空和蓝猫也好"
}
]
},
{
"start_time": "2026-04-07 21:05:00",
"message_count": 123,
"user_count": 84,
"top_terms": [
{
"term": "享受",
"count": 7
},
{
"term": "bkb",
"count": 7
},
{
"term": "甲哥",
"count": 6
},
{
"term": "瘟鸡光环",
"count": 6
},
{
"term": "lol",
"count": 4
},
{
"term": "马甲",
"count": 4
},
{
"term": "可以带老板了",
"count": 3
},
{
"term": "mvp",
"count": 3
}
],
"burst_terms": [
{
"text": "炼金什么玩意",
"count": 1,
"user_count": 1
},
{
"text": "炼金太费了",
"count": 1,
"user_count": 1
},
{
"text": "融化了",
"count": 1,
"user_count": 1
},
{
"text": "",
"count": 1,
"user_count": 1
},
{
"text": "竟然还三路",
"count": 1,
"user_count": 1
}
],
"sample_messages": [
{
"time": "2026-04-07 21:05:01",
"nickname": "Vermouthlol",
"content": "这一身出的什么杂技装备啊"
},
{
"time": "2026-04-07 21:06:25",
"nickname": "又是一个灌水",
"content": "这种局我一般就直接退了,影响心情"
},
{
"time": "2026-04-07 21:06:50",
"nickname": "毒狼10号",
"content": "这种局节奏挺慢的、"
},
{
"time": "2026-04-07 21:07:16",
"nickname": "wanll",
"content": "你这样赢,让弹幕怎么喷?"
},
{
"time": "2026-04-07 21:09:52",
"nickname": "猫猫哥的女粉丝",
"content": "我云玩家 @A别喷了吧我说句实话钻粉群很需要你"
}
]
},
{
"start_time": "2026-04-07 21:35:00",
"message_count": 123,
"user_count": 79,
"top_terms": [
{
"term": "大哥炼金现在",
"count": 5
},
{
"term": "诶你别皮",
"count": 3
},
{
"term": "感动",
"count": 3
},
{
"term": "我是真问",
"count": 3
},
{
"term": "养几把",
"count": 3
},
{
"term": "再出自己装备",
"count": 3
},
{
"term": "你有",
"count": 2
},
{
"term": "是先养几把",
"count": 2
}
],
"burst_terms": [
{
"text": "",
"count": 3,
"user_count": 3
},
{
"text": "早点飞啊",
"count": 2,
"user_count": 1
},
{
"text": "t",
"count": 2,
"user_count": 2
},
{
"text": "翻了翻了",
"count": 2,
"user_count": 2
},
{
"text": "意思要9连胜",
"count": 1,
"user_count": 1
}
],
"sample_messages": [
{
"time": "2026-04-07 21:35:19",
"nickname": "清源子墨",
"content": "意思要9连胜"
},
{
"time": "2026-04-07 21:37:22",
"nickname": "诶你别皮",
"content": "兄弟我是真问大哥炼金现在养几把a再出自己装备"
},
{
"time": "2026-04-07 21:38:22",
"nickname": "里黑里",
"content": "对面阵容没了啊,后期全被克"
},
{
"time": "2026-04-07 21:39:06",
"nickname": "風初定",
"content": "t"
},
{
"time": "2026-04-07 21:39:51",
"nickname": "风起梧桐",
"content": "你的火猫真的辣啊"
}
]
},
{
"start_time": "2026-04-07 12:40:00",
"message_count": 121,
"user_count": 73,
"top_terms": [
{
"term": "doom",
"count": 5
},
{
"term": "召唤小僵尸",
"count": 3
},
{
"term": "什么东西减攻",
"count": 2
},
{
"term": "哈哈哈哈哈哈",
"count": 2
},
{
"term": "化身骷髅王",
"count": 2
},
{
"term": "秀的头皮脱落",
"count": 2
},
{
"term": "号位",
"count": 2
},
{
"term": "不吃黑龙",
"count": 2
}
],
"burst_terms": [
{
"text": "sb",
"count": 4,
"user_count": 4
},
{
"text": "化身骷髅王",
"count": 2,
"user_count": 2
},
{
"text": "秀的头皮脱落",
"count": 2,
"user_count": 2
},
{
"text": "召唤小僵尸",
"count": 2,
"user_count": 2
},
{
"text": "不吃黑龙?",
"count": 2,
"user_count": 2
}
],
"sample_messages": [
{
"time": "2026-04-07 12:40:00",
"nickname": "我炸了啊大哥",
"content": "66攻击力也能不要"
},
{
"time": "2026-04-07 12:42:09",
"nickname": "我炸了啊大哥",
"content": "哈哈哈哈哈哈"
},
{
"time": "2026-04-07 12:42:32",
"nickname": "你别好奇",
"content": "装最会切的,在那就切呗"
},
{
"time": "2026-04-07 12:43:40",
"nickname": "EternalDZW",
"content": "吃人马,抬手和范围都比蛤蟆叼"
},
{
"time": "2026-04-07 12:44:54",
"nickname": "日落尤其温柔lsj",
"content": "四级踩啊"
}
]
}
],
"representative_messages": [
{
"time": "2026-04-07 21:55:22",
"nickname": "一米幻影",
"content": "今天全胜nb"
},
{
"time": "2026-04-07 21:56:42",
"nickname": "椰子米",
"content": "搞个盘子"
},
{
"time": "2026-04-07 21:57:58",
"nickname": "1刀一个小朋友",
"content": "飞的好啊"
},
{
"time": "2026-04-07 21:58:24",
"nickname": "夏半仙Zzz",
"content": "手活没了"
},
{
"time": "2026-04-07 21:59:59",
"nickname": "蜻蜓队长zzzzzzzzzzz",
"content": "那你不出盘子?"
},
{
"time": "2026-04-07 22:00:01",
"nickname": "夕立哥DY",
"content": "居然输了"
},
{
"time": "2026-04-07 22:00:50",
"nickname": "跳呀丶跳呀跳",
"content": "换个🐏那个虚空多死两次就完了"
},
{
"time": "2026-04-07 22:01:42",
"nickname": "我炸了啊大哥",
"content": "火猫两个命石都没了,能不弱嘛"
},
{
"time": "2026-04-07 22:03:01",
"nickname": "蜻蜓队长zzzzzzzzzzz",
"content": "出个羊刀对面吹风也没用,蓝猫和虚空也很怕🐏"
},
{
"time": "2026-04-07 22:04:55",
"nickname": "吃鱼小能手丶",
"content": "上把就是血精的问题自己技能释放和切入不行就出羊再不济出个紫苑打虚空和蓝猫也好"
},
{
"time": "2026-04-07 21:05:01",
"nickname": "Vermouthlol",
"content": "这一身出的什么杂技装备啊"
},
{
"time": "2026-04-07 21:06:25",
"nickname": "又是一个灌水",
"content": "这种局我一般就直接退了,影响心情"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,649 @@
{
"room_id": "7718843",
"message_count": 8945,
"deduped_message_count": 8938,
"unique_user_count": 2677,
"top_terms": [
{
"term": "zcj",
"count": 112
},
{
"term": "bkb",
"count": 105
},
{
"term": "闭目不语任由",
"count": 61
},
{
"term": "你就忍心一辈",
"count": 59
},
{
"term": "哈哈哈哈哈哈",
"count": 55
},
{
"term": "哈哈哈哈",
"count": 52
},
{
"term": "小丑",
"count": 48
},
{
"term": "bot",
"count": 45
},
{
"term": "yyf",
"count": 44
},
{
"term": "是你吗",
"count": 42
},
{
"term": "你声音好像强",
"count": 41
},
{
"term": "怎么回事强子",
"count": 41
},
{
"term": "你是个人吗强",
"count": 41
},
{
"term": "是汗",
"count": 38
},
{
"term": "强醋摸了摸裤",
"count": 38
}
],
"burst_terms": [
{
"text": "",
"count": 113,
"user_count": 86
},
{
"text": "哈哈哈哈",
"count": 45,
"user_count": 33
},
{
"text": "哈哈哈",
"count": 43,
"user_count": 31
},
{
"text": "gg",
"count": 31,
"user_count": 27
},
{
"text": "sb",
"count": 31,
"user_count": 24
},
{
"text": "bot",
"count": 23,
"user_count": 21
},
{
"text": "哈哈",
"count": 23,
"user_count": 16
},
{
"text": "。。。",
"count": 20,
"user_count": 17
},
{
"text": "",
"count": 19,
"user_count": 19
},
{
"text": "小丑",
"count": 19,
"user_count": 16
}
],
"peak_buckets": [
{
"start_time": "2026-04-07 00:45:00",
"message_count": 412,
"user_count": 220,
"top_terms": [
{
"term": "bot",
"count": 14
},
{
"term": "see",
"count": 10
},
{
"term": "hao",
"count": 9
},
{
"term": "声带被小小的",
"count": 8
},
{
"term": "大棍子捅好了",
"count": 8
},
{
"term": "最重要的是",
"count": 8
},
{
"term": "哈哈哈哈哈哈",
"count": 7
},
{
"term": "啥比",
"count": 7
}
],
"burst_terms": [
{
"text": "bot",
"count": 10,
"user_count": 10
},
{
"text": "",
"count": 5,
"user_count": 5
},
{
"text": "哈哈哈哈哈",
"count": 4,
"user_count": 4
},
{
"text": "噗",
"count": 4,
"user_count": 1
},
{
"text": "哈哈哈哈",
"count": 3,
"user_count": 3
}
],
"sample_messages": [
{
"time": "2026-04-07 00:45:15",
"nickname": "叶落繁花醉",
"content": "一直C"
},
{
"time": "2026-04-07 00:47:18",
"nickname": "jyb7194",
"content": "@A断腿警告小小是你叫的吗叫jk爹"
},
{
"time": "2026-04-07 00:47:52",
"nickname": "落霞与孤鹜齐飞LK",
"content": "菜逼东西"
},
{
"time": "2026-04-07 00:48:13",
"nickname": "ggl73999",
"content": "HAO蠢👉🤡HAO蠢👉🤡HAO蠢👉🤡😂👉🤡😂👉🤡😂👉🤡😂"
},
{
"time": "2026-04-07 00:49:56",
"nickname": "爱在沙漠找贝壳",
"content": "如果您的直播内容是以观看他人操作为主,建议您移步“一起看”分区"
}
]
},
{
"start_time": "2026-04-07 22:45:00",
"message_count": 338,
"user_count": 208,
"top_terms": [
{
"term": "马刺",
"count": 3
},
{
"term": "送了",
"count": 3
},
{
"term": "无能",
"count": 3
},
{
"term": "连空",
"count": 3
},
{
"term": "波高",
"count": 3
},
{
"term": "主播电死你嘟",
"count": 2
},
{
"term": "bug",
"count": 2
},
{
"term": "哈哈哈哈哈",
"count": 2
}
],
"burst_terms": [
{
"text": "sb",
"count": 14,
"user_count": 11
},
{
"text": "",
"count": 8,
"user_count": 7
},
{
"text": "?",
"count": 4,
"user_count": 2
},
{
"text": "波高",
"count": 3,
"user_count": 3
},
{
"text": ".",
"count": 2,
"user_count": 1
}
],
"sample_messages": [
{
"time": "2026-04-07 22:45:06",
"nickname": "frank19422",
"content": "嘴歪眼斜?"
},
{
"time": "2026-04-07 22:46:15",
"nickname": "自律的菜逼",
"content": "是搞笑博主吗"
},
{
"time": "2026-04-07 22:46:58",
"nickname": "phoenix34356",
"content": "无缘无故送"
},
{
"time": "2026-04-07 22:47:53",
"nickname": "飞鸟还是明日香",
"content": "就这也想扣分?"
},
{
"time": "2026-04-07 22:49:58",
"nickname": "我是和蛋蛋",
"content": "秀的自己脑壳疼"
}
]
},
{
"start_time": "2026-04-07 23:45:00",
"message_count": 318,
"user_count": 206,
"top_terms": [
{
"term": "机器人",
"count": 20
},
{
"term": "测试",
"count": 20
},
{
"term": "本条弹幕用以",
"count": 20
},
{
"term": "统计机器人数",
"count": 20
},
{
"term": "非机器人用户",
"count": 20
},
{
"term": "请不要",
"count": 20
},
{
"term": "bkb",
"count": 18
},
{
"term": "原来直播间有",
"count": 15
}
],
"burst_terms": [
{
"text": "gg",
"count": 8,
"user_count": 8
},
{
"text": "g",
"count": 3,
"user_count": 3
},
{
"text": "gggg",
"count": 2,
"user_count": 2
},
{
"text": "长痛",
"count": 2,
"user_count": 2
},
{
"text": "无限!",
"count": 2,
"user_count": 2
}
],
"sample_messages": [
{
"time": "2026-04-07 23:45:00",
"nickname": "玩滑滑梯的熊猫",
"content": "这不杀鸟?"
},
{
"time": "2026-04-07 23:47:14",
"nickname": "Reic衡",
"content": "血晶真不如玲珑心"
},
{
"time": "2026-04-07 23:48:10",
"nickname": "Dy克服潮罐S码",
"content": "gg"
},
{
"time": "2026-04-07 23:48:31",
"nickname": "赏胸悦目",
"content": "秀的野怪一愣一愣"
},
{
"time": "2026-04-07 23:49:59",
"nickname": "余生伴我行m",
"content": "35"
}
]
},
{
"start_time": "2026-04-07 00:55:00",
"message_count": 317,
"user_count": 182,
"top_terms": [
{
"term": "冲水",
"count": 30
},
{
"term": "小丑",
"count": 27
},
{
"term": "刚刚偷看你直",
"count": 19
},
{
"term": "播被老板发现",
"count": 19
},
{
"term": "还好老板是蝙",
"count": 19
},
{
"term": "蝠侠",
"count": 19
},
{
"term": "拍了拍我的肩",
"count": 19
},
{
"term": "膀说继续监视",
"count": 19
}
],
"burst_terms": [
{
"text": "",
"count": 6,
"user_count": 6
},
{
"text": "哈哈哈",
"count": 5,
"user_count": 4
},
{
"text": "小丑",
"count": 5,
"user_count": 5
},
{
"text": "哈哈",
"count": 4,
"user_count": 3
},
{
"text": "bot",
"count": 4,
"user_count": 4
}
],
"sample_messages": [
{
"time": "2026-04-07 00:55:04",
"nickname": "叶落繁花醉",
"content": "秀麻了"
},
{
"time": "2026-04-07 00:57:53",
"nickname": "用户23701668",
"content": "这小小你一辈子玩不出来"
},
{
"time": "2026-04-07 00:58:21",
"nickname": "hyc193",
"content": "你好菜A"
},
{
"time": "2026-04-07 00:58:47",
"nickname": "tracywin",
"content": "没你JACK爹带躺 你这把纯纯小丑东西"
},
{
"time": "2026-04-07 00:59:59",
"nickname": "木头人来啦",
"content": "你这把的表现不值10分"
}
]
},
{
"start_time": "2026-04-07 21:55:00",
"message_count": 315,
"user_count": 207,
"top_terms": [
{
"term": "强子也就是吃",
"count": 10
},
{
"term": "了直播的红利",
"count": 10
},
{
"term": "要是不直播自",
"count": 10
},
{
"term": "己单排",
"count": 10
},
{
"term": "没有弹幕教",
"count": 10
},
{
"term": "一万分都难上",
"count": 10
},
{
"term": "和你有啥关系",
"count": 6
},
{
"term": "蝴蝶",
"count": 6
}
],
"burst_terms": [
{
"text": "",
"count": 4,
"user_count": 4
},
{
"text": "蝴蝶",
"count": 4,
"user_count": 4
},
{
"text": "",
"count": 2,
"user_count": 1
},
{
"text": "和你有关系?",
"count": 2,
"user_count": 2
},
{
"text": "",
"count": 2,
"user_count": 2
}
],
"sample_messages": [
{
"time": "2026-04-07 21:55:02",
"nickname": "五十岚麻巳子",
"content": "那你就查眼蹲队友啊"
},
{
"time": "2026-04-07 21:57:10",
"nickname": "我看川川直播学技术",
"content": "弹幕软柿子能不能别提前开香槟"
},
{
"time": "2026-04-07 21:57:37",
"nickname": "坏人爱吃跳跳糖丶",
"content": "撒旦 无敌了"
},
{
"time": "2026-04-07 21:57:56",
"nickname": "dota辉一生",
"content": "信我们???"
},
{
"time": "2026-04-07 21:59:48",
"nickname": "Guiing",
"content": "兽破小鱼被动 怎么玩"
}
]
}
],
"representative_messages": [
{
"time": "2026-04-07 00:45:15",
"nickname": "叶落繁花醉",
"content": "一直C"
},
{
"time": "2026-04-07 00:47:18",
"nickname": "jyb7194",
"content": "@A断腿警告小小是你叫的吗叫jk爹"
},
{
"time": "2026-04-07 00:47:52",
"nickname": "落霞与孤鹜齐飞LK",
"content": "菜逼东西"
},
{
"time": "2026-04-07 00:48:13",
"nickname": "ggl73999",
"content": "HAO蠢👉🤡HAO蠢👉🤡HAO蠢👉🤡😂👉🤡😂👉🤡😂👉🤡😂"
},
{
"time": "2026-04-07 00:49:56",
"nickname": "爱在沙漠找贝壳",
"content": "如果您的直播内容是以观看他人操作为主,建议您移步“一起看”分区"
},
{
"time": "2026-04-07 22:45:06",
"nickname": "frank19422",
"content": "嘴歪眼斜?"
},
{
"time": "2026-04-07 22:46:15",
"nickname": "自律的菜逼",
"content": "是搞笑博主吗"
},
{
"time": "2026-04-07 22:46:58",
"nickname": "phoenix34356",
"content": "无缘无故送"
},
{
"time": "2026-04-07 22:47:53",
"nickname": "飞鸟还是明日香",
"content": "就这也想扣分?"
},
{
"time": "2026-04-07 22:49:58",
"nickname": "我是和蛋蛋",
"content": "秀的自己脑壳疼"
},
{
"time": "2026-04-07 23:45:00",
"nickname": "玩滑滑梯的熊猫",
"content": "这不杀鸟?"
},
{
"time": "2026-04-07 23:47:14",
"nickname": "Reic衡",
"content": "血晶真不如玲珑心"
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

88
test/douyu_fans.py Normal file
View File

@@ -0,0 +1,88 @@
import requests
import time
# ====== 配置 ======
ROOM_ID = "288016" # 👉 改成你要测试的直播间ID
PAGE_LIMIT = 5 # 拉前几页(避免太重)
SLEEP_INTERVAL = 60 # 采样间隔(秒)
HEADERS = {
"User-Agent": "Mozilla/5.0",
"Referer": f"https://www.douyu.com/{ROOM_ID}"
}
def get_room_status(room_id):
"""获取直播状态"""
url = f"https://www.douyu.com/betard/{room_id}"
try:
res = requests.get(url, headers=HEADERS, timeout=5).json()
return res["room"]["show_status"] # 1=直播中, 2=未开播
except Exception as e:
print("获取直播状态失败:", e)
return None
def get_vip_count(room_id, page_limit=3):
"""获取贵宾数量(简单版)"""
vip_users = set()
for page in range(1, page_limit + 1):
url = "https://www.douyu.com/wfs/web/getFansList"
params = {
"roomid": room_id,
"page": page
}
try:
res = requests.get(url, headers=HEADERS, params=params, timeout=5).json()
data = res.get("data", {}).get("list", [])
if not data:
break
for user in data:
noble = user.get("nobleLevel", 0)
if noble and noble > 0:
uid = user.get("uid")
vip_users.add(uid)
except Exception as e:
print(f"{page}页获取失败:", e)
break
return len(vip_users)
def main():
print(f"开始监控直播间: {ROOM_ID}")
last_status = None
while True:
status = get_room_status(ROOM_ID)
if status == 1:
if last_status != 1:
print("🎬 开播了!")
vip_count = get_vip_count(ROOM_ID, PAGE_LIMIT)
now = time.strftime("%H:%M:%S")
print(f"[{now}] 当前贵宾数(估算): {vip_count}")
elif status == 2:
if last_status == 1:
print("🛑 已下播")
else:
print("未开播...")
else:
print("状态获取异常")
last_status = status
time.sleep(SLEEP_INTERVAL)
if __name__ == "__main__":
main()