406 lines
16 KiB
C#
406 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Text.RegularExpressions;
|
|
using BarrageGrab.Modles.BiliProtoEntity;
|
|
using BarrageGrab.Proxy.ProxyEventArgs;
|
|
using BrotliSharpLib;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace BarrageGrab
|
|
{
|
|
/// <summary>
|
|
/// 哔哩哔哩弹幕抓取器
|
|
/// 协议格式: 自定义16字节Big-Endian帧头 + JSON body (body可能为zlib/brotli压缩)
|
|
/// </summary>
|
|
public class BiliBarrageGrab
|
|
{
|
|
// B站直播弹幕WS域名
|
|
// broadcast-msg.chat.bilibili.com (主域名)
|
|
// *-live-comet.chat.bilibili.com (新版comet服务器, 如 zj-cn-live-comet.chat.bilibili.com)
|
|
// live-trace.com (备用)
|
|
// ws.live.bilibili.com (历史域名)
|
|
private readonly Regex biliWsReg = new Regex(@"chat\.bilibili\.com|live-trace\.com|ws\.live\.bilibili\.com", RegexOptions.IgnoreCase);
|
|
private const string BILI_LIVE_HOST = "live.bilibili.com";
|
|
|
|
/// <summary>弹幕消息</summary>
|
|
public event EventHandler<BiliRoomMessageEventArgs<BiliDanmuMsg>> OnChatMessage;
|
|
/// <summary>点赞消息</summary>
|
|
public event EventHandler<BiliRoomMessageEventArgs<BiliLikeData>> OnLikeMessage;
|
|
/// <summary>礼物消息</summary>
|
|
public event EventHandler<BiliRoomMessageEventArgs<BiliGiftData>> OnGiftMessage;
|
|
/// <summary>进入直播间/关注消息</summary>
|
|
public event EventHandler<BiliRoomMessageEventArgs<BiliInteractData>> OnInteractMessage;
|
|
/// <summary>房间统计消息</summary>
|
|
public event EventHandler<BiliRoomMessageEventArgs<BiliRoomStats>> OnRoomStatsMessage;
|
|
/// <summary>分享消息 (INTERACT_WORD中msg_type=3)</summary>
|
|
public event EventHandler<BiliRoomMessageEventArgs<BiliInteractData>> OnShareMessage;
|
|
|
|
public BiliBarrageGrab()
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前认证的房间ID (从AUTH包解析,供消息处理器读取)
|
|
/// </summary>
|
|
public string CurrentRoomId { get; private set; } = "";
|
|
|
|
public bool IsBilibiliHost(string hostname)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(hostname)) return false;
|
|
hostname = hostname.Trim().ToLower();
|
|
return biliWsReg.IsMatch(hostname) || hostname == BILI_LIVE_HOST;
|
|
}
|
|
|
|
public bool IsBilibiliWebSocket(string uri)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(uri)) return false;
|
|
return biliWsReg.IsMatch(uri);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 处理代理收到的B站WS数据
|
|
/// </summary>
|
|
public void HandleWebSocketData(WsMessageEventArgs e)
|
|
{
|
|
var buff = e.Payload;
|
|
if (buff == null || buff.Length == 0) return;
|
|
|
|
try
|
|
{
|
|
// 一个WS帧可能包含多个B站数据包,需要循环解析
|
|
ProcessBuffer(buff, 0, buff.Length, e.ProcessName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (biliErrCount < 3)
|
|
{
|
|
Logger.LogError(ex, $"处理B站弹幕数据包时出错:{ex.Message}");
|
|
biliErrCount++;
|
|
}
|
|
if (biliErrCount == 3)
|
|
{
|
|
Logger.LogWarn("B站弹幕解析持续失败,已抑制后续错误日志");
|
|
biliErrCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 处理客户端发送的B站WS数据 (解析AUTH认证包提取roomid)
|
|
/// B站客户端连接WS后会发送AUTH包: {uid,roomid,protover,platform,type,key}
|
|
/// </summary>
|
|
public void HandleSentData(WsMessageEventArgs e)
|
|
{
|
|
var buff = e.Payload;
|
|
if (buff == null || buff.Length < BiliSocketFrame.HEADER_SIZE) return;
|
|
|
|
try
|
|
{
|
|
BiliSocketFrame frame;
|
|
if (!BiliSocketFrame.TryParse(buff, 0, buff.Length, out frame)) return;
|
|
|
|
// 只关注认证请求包 (Operation=7)
|
|
if (frame.Operation != BiliOperation.AUTH) return;
|
|
|
|
var payload = BiliSocketFrame.GetPayload(buff, 0, frame);
|
|
if (payload == null || payload.Length == 0) return;
|
|
|
|
var json = System.Text.Encoding.UTF8.GetString(payload);
|
|
var jobj = JObject.Parse(json);
|
|
var roomIdToken = jobj["roomid"];
|
|
if (roomIdToken == null) return;
|
|
|
|
// roomid可能是整数或字符串
|
|
string roomId = roomIdToken.Type == JTokenType.Integer
|
|
? roomIdToken.Value<long>().ToString()
|
|
: roomIdToken.Value<string>() ?? "";
|
|
|
|
if (!string.IsNullOrWhiteSpace(roomId))
|
|
{
|
|
CurrentRoomId = roomId;
|
|
Logger.LogInfo($"B站认证包解析成功,当前房间ID: {roomId}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarn($"解析B站AUTH认证包失败: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static int biliErrCount = 0;
|
|
|
|
/// <summary>
|
|
/// 递归解析缓冲区中可能包含的多个B站数据包
|
|
/// </summary>
|
|
private void ProcessBuffer(byte[] buffer, int offset, int count, string processName)
|
|
{
|
|
int processed = 0;
|
|
while (processed + BiliSocketFrame.HEADER_SIZE <= count)
|
|
{
|
|
BiliSocketFrame frame;
|
|
if (!BiliSocketFrame.TryParse(buffer, offset + processed, count - processed, out frame))
|
|
{
|
|
break;
|
|
}
|
|
|
|
// 包长超过剩余缓冲区,说明数据不完整,等下次再处理
|
|
if (processed + frame.PacketLength > count)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var payload = BiliSocketFrame.GetPayload(buffer, offset + processed, frame);
|
|
processed += (int)frame.PacketLength;
|
|
|
|
// 只处理服务器推送的消息和心跳回复
|
|
if (frame.Operation == BiliOperation.HEARTBEAT_REPLY)
|
|
{
|
|
// 心跳回复: body是4字节big-endian人气值
|
|
if (payload != null && payload.Length >= 4)
|
|
{
|
|
var online = (long)BiliSocketFrame.ReadUInt32BE(payload, 0);
|
|
var stats = new BiliRoomStats { Online = online, Watched = 0 };
|
|
OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliRoomStats>(processName, stats));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (frame.Operation != BiliOperation.MESSAGE) continue;
|
|
|
|
// 根据协议版本解压
|
|
byte[] jsonBytes;
|
|
switch (frame.ProtocolVersion)
|
|
{
|
|
case BiliProtocolVersion.PLAIN:
|
|
case BiliProtocolVersion.PLAIN_AUTH:
|
|
jsonBytes = payload;
|
|
break;
|
|
case BiliProtocolVersion.ZLIB:
|
|
jsonBytes = ZlibDecompress(payload);
|
|
break;
|
|
case BiliProtocolVersion.BROTLI:
|
|
jsonBytes = BrotliDecompress(payload);
|
|
break;
|
|
default:
|
|
jsonBytes = payload;
|
|
break;
|
|
}
|
|
|
|
if (jsonBytes == null || jsonBytes.Length == 0) continue;
|
|
|
|
// 解压后可能仍然是多个数据包 (Zlib/Brotli压缩的通常是合并包)
|
|
if (frame.ProtocolVersion == BiliProtocolVersion.ZLIB ||
|
|
frame.ProtocolVersion == BiliProtocolVersion.BROTLI)
|
|
{
|
|
ProcessBuffer(jsonBytes, 0, jsonBytes.Length, processName);
|
|
continue;
|
|
}
|
|
|
|
// 明文JSON: 解析为消息
|
|
DispatchJsonMessage(jsonBytes, processName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 将JSON字节解析为B站消息并触发对应事件
|
|
/// </summary>
|
|
private void DispatchJsonMessage(byte[] jsonBytes, string processName)
|
|
{
|
|
string json;
|
|
try
|
|
{
|
|
json = System.Text.Encoding.UTF8.GetString(jsonBytes);
|
|
}
|
|
catch { return; }
|
|
|
|
if (string.IsNullOrWhiteSpace(json)) return;
|
|
|
|
JObject jobj;
|
|
try
|
|
{
|
|
jobj = JObject.Parse(json);
|
|
}
|
|
catch { return; }
|
|
|
|
var cmdToken = jobj["cmd"];
|
|
if (cmdToken == null) return;
|
|
string cmd = cmdToken.Value<string>();
|
|
if (string.IsNullOrWhiteSpace(cmd)) return;
|
|
|
|
// 兼容cmd带数字后缀的版本,如 DANMU_MSG-4b1cb6f3
|
|
int dashIdx = cmd.IndexOf('-');
|
|
if (dashIdx > 0) cmd = cmd.Substring(0, dashIdx);
|
|
|
|
switch (cmd)
|
|
{
|
|
case BiliCmd.DANMU_MSG:
|
|
{
|
|
var msg = ParseDanmuMsg(jobj);
|
|
if (msg != null)
|
|
{
|
|
OnChatMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliDanmuMsg>(processName, msg));
|
|
}
|
|
break;
|
|
}
|
|
case BiliCmd.SEND_GIFT:
|
|
{
|
|
var msg = jobj["data"]?.ToObject<BiliGiftData>();
|
|
if (msg != null)
|
|
{
|
|
OnGiftMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliGiftData>(processName, msg));
|
|
}
|
|
break;
|
|
}
|
|
case BiliCmd.INTERACT_WORD:
|
|
{
|
|
var msg = jobj["data"]?.ToObject<BiliInteractData>();
|
|
if (msg != null)
|
|
{
|
|
// msg_type: 1=进入 2=关注 3=分享
|
|
if (msg.MsgType == 3)
|
|
{
|
|
OnShareMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliInteractData>(processName, msg));
|
|
}
|
|
else
|
|
{
|
|
OnInteractMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliInteractData>(processName, msg));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case BiliCmd.LIKE_INFO_V3_CLICK:
|
|
{
|
|
var msg = jobj["data"]?.ToObject<BiliLikeData>();
|
|
if (msg != null)
|
|
{
|
|
OnLikeMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliLikeData>(processName, msg));
|
|
}
|
|
break;
|
|
}
|
|
case BiliCmd.ONLINE_RANK_COUNT:
|
|
{
|
|
var count = jobj["data"]?["count"]?.Value<long>() ?? 0;
|
|
var stats = new BiliRoomStats { Online = count, Watched = 0 };
|
|
OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliRoomStats>(processName, stats));
|
|
break;
|
|
}
|
|
case BiliCmd.WATCHED_CHANGE:
|
|
{
|
|
var num = jobj["data"]?["num"]?.Value<long>() ?? 0;
|
|
var stats = new BiliRoomStats { Online = 0, Watched = num };
|
|
OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliRoomStats>(processName, stats));
|
|
break;
|
|
}
|
|
case BiliCmd.ROOM_REAL_TIME_MESSAGE_UPDATE:
|
|
{
|
|
// 同时包含在线人数和看过人数
|
|
var online = jobj["data"]?["online"]?.Value<long>() ?? 0;
|
|
var watched = jobj["data"]?["watched_show"]?["num"]?.Value<long>() ?? 0;
|
|
var stats = new BiliRoomStats { Online = online, Watched = watched };
|
|
OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs<BiliRoomStats>(processName, stats));
|
|
break;
|
|
}
|
|
default:
|
|
// 未知消息类型忽略
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 解析DANMU_MSG消息: info[1]=内容, info[2]=[uid,uname,isAdmin,rank,?anchor]
|
|
/// </summary>
|
|
private BiliDanmuMsg ParseDanmuMsg(JObject jobj)
|
|
{
|
|
try
|
|
{
|
|
var info = jobj["info"] as JArray;
|
|
if (info == null || info.Count < 3) return null;
|
|
|
|
var content = info[1]?.Value<string>() ?? "";
|
|
var userInfo = info[2] as JArray;
|
|
if (userInfo == null || userInfo.Count < 2) return null;
|
|
|
|
var msg = new BiliDanmuMsg
|
|
{
|
|
Content = content,
|
|
Uid = userInfo[0]?.Value<long>() ?? 0,
|
|
Uname = userInfo[1]?.Value<string>() ?? "",
|
|
IsAdmin = userInfo.Count > 2 && userInfo[2]?.Value<int>() == 1,
|
|
IsAnchor = userInfo.Count > 4 && userInfo[4] != null && userInfo[4].Type != JTokenType.Null
|
|
};
|
|
return msg;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Zlib解压</summary>
|
|
private byte[] ZlibDecompress(byte[] data)
|
|
{
|
|
if (data == null || data.Length == 0) return null;
|
|
try
|
|
{
|
|
using (var ms = new MemoryStream(data))
|
|
using (var zlib = new DeflateStream(ms, CompressionMode.Decompress))
|
|
using (var outMs = new MemoryStream())
|
|
{
|
|
// zlib前2字节是header(0x78 0x9c等),DeflateStream需要跳过
|
|
// 但 .NET Core 2.1+ 的 DeflateStream 可以处理 zlib header, .NET Framework 需要跳过
|
|
if (data.Length >= 2 && data[0] == 0x78)
|
|
{
|
|
ms.Position = 2;
|
|
}
|
|
zlib.CopyTo(outMs);
|
|
return outMs.ToArray();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarn($"B站Zlib解压失败: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Brotli解压 (使用 BrotliSharpLib)</summary>
|
|
private byte[] BrotliDecompress(byte[] data)
|
|
{
|
|
if (data == null || data.Length == 0) return null;
|
|
try
|
|
{
|
|
using (var ms = new MemoryStream(data))
|
|
using (var brotli = new BrotliSharpLib.BrotliStream(ms, CompressionMode.Decompress))
|
|
using (var outMs = new MemoryStream())
|
|
{
|
|
brotli.CopyTo(outMs);
|
|
return outMs.ToArray();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarn($"B站Brotli解压失败: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>B站消息事件参数</summary>
|
|
public class BiliRoomMessageEventArgs<T> : EventArgs where T : class
|
|
{
|
|
public string Process { get; set; }
|
|
public T Message { get; set; }
|
|
|
|
public BiliRoomMessageEventArgs() { }
|
|
|
|
public BiliRoomMessageEventArgs(string process, T data)
|
|
{
|
|
this.Process = process;
|
|
this.Message = data;
|
|
}
|
|
}
|
|
}
|