# 说在前面

逻辑参考自 B 站弹幕姬 - Github,并针对 B 站 2024 年后的协议变动做了修正。

# 封包组成

B 站弹幕协议使用 16 字节的二进制头部,后接负载。当前支持的协议版本有 0(无压缩)、2(Deflate)、3(Brotli)。

packet_lenheader_lenversionopcodemagic_numberpayload
含义总包长度头部长度协议版本操作码幻数负载
长度 (byte)42244...
备注包括头固定 160/2/3固定 1大多数情况下为 json

# 操作码(OPCode)

操作码名称释义
CLIENT_HEARTBEAT客户端发送心跳包2
POPULARITY_VALUE人气值变动(服务端主动推送)3
CMD弹幕 / 礼物等消息5
AUTHENTICATION鉴权7
AUTH_REPLY鉴权回复(旧称 SERVER_HEARTBEAT)8

注意:鉴权回复(OP 8)不再称为 "服务端心跳"。B 站会在鉴权通过后返回此操作码,收到后应立即发送一次心跳。

# 封包(Pack)

封包均为大端顺序。 IPAddress.HostToNetworkOrder() 用于大小端转换。

代码示例 (C#):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public const byte HEADER_LEN = 16;
public const byte PROTOCOL_VERSION = 1; // 外层封包协议版本始终为1
public const int MAGIC_NUMBER = 1;

public static byte[] Pack(OpCode opCode, object payload)
{
return Pack(opCode, JsonConvert.SerializeObject(payload, Formatting.None,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }));
}

public static byte[] Pack(OpCode opCode, byte[] payload)
{
using MemoryStream stream = new();
BinaryWriter bw = new(stream);
bw.Write(IPAddress.HostToNetworkOrder(payload.Length + HEADER_LEN));
bw.Write(IPAddress.HostToNetworkOrder(HEADER_LEN));
bw.Write(IPAddress.HostToNetworkOrder(PROTOCOL_VERSION));
bw.Write(IPAddress.HostToNetworkOrder((int)opCode));
bw.Write(IPAddress.HostToNetworkOrder(MAGIC_NUMBER));
bw.Write(payload);
return stream.ToArray();
}

# 解包(Unpack)

代码示例 (C#):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static RawDanmaku UnPack(byte[] data)
{
using MemoryStream stream = new(data);
BinaryReader reader = new(stream);
RawDanmaku danmaku = new();
danmaku.Data = new byte[data.Length - HEADER_LEN];
danmaku.PacketLen = IPAddress.NetworkToHostOrder(reader.ReadInt32());
danmaku.HeaderLen = (byte)IPAddress.NetworkToHostOrder(reader.ReadInt16());
danmaku.Version = (byte)IPAddress.NetworkToHostOrder(reader.ReadInt16());
danmaku.OpCode = (OpCode)IPAddress.NetworkToHostOrder(reader.ReadInt32());
danmaku.MagicNumber = IPAddress.NetworkToHostOrder(reader.ReadInt32());
Buffer.BlockCopy(data, HEADER_LEN, danmaku.Data, 0, danmaku.Data.Length);
return danmaku;
}

其中 RawDanmaku 为自定义类:

1
2
3
4
5
6
7
8
9
public class RawDanmaku
{
public int PacketLen { get; set; }
public byte HeaderLen { get; set; }
public byte Version { get; set; }
public OpCode OpCode { get; set; }
public int MagicNumber { get; set; }
public byte[] Data { get; set; }
}

# 拉取连接配置

  1. 调用 API 拉取弹幕服务器配置:

    1
    GET https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={房间号}&type=0

    (旧版接口 /room/v1/Danmu/getConf 已失效)

  2. 返回 JSON 中 data.host_list 数组的每一项包含 hostwss_port 字段。任取一个,拼接为 wss://{host}:{wss_port}/sub ,使用 WebSocket 客户端连接。

  3. 使用 System.Net.WebSockets.ClientWebSocket 即可,不需要第三方 WebSocket 库。注意必须监听 Binary 消息( MessageType.Binary ),而非 Text 消息

  4. 连接建立后执行鉴权操作。

# 鉴权

当 WebSocket 连接建立(Open)之后,发送鉴权包。2024 年后,B 站要求 protover 为 3 以开启 Brotli 压缩支持,且需附带 buvid

1
2
3
4
5
6
7
8
9
{
"uid": 87115779,
"roomid": 7546748,
"protover": 3,
"buvid": "从Cookie中提取的buvid3值",
"platform": "web",
"type": 2,
"key": "上一步获取的token"
}

相比旧版鉴权,去掉了 clientver 字段,增加了 buvidprotover 从 0 变为 3, uid 不再填 0 而是填入真实用户 ID(从 Cookie 的 DedeUserID 字段解析)。

代码示例 (C#):

1
2
3
4
5
6
7
8
9
10
11
12
object Auth = new
{
uid = UserID,
roomid = RoomID,
protover = 3,
buvid = GetCookieValue("buvid3"),
platform = "web",
type = 2,
key = Config.token,
};
var data = Helper.Pack(OpCode.AUTHENTICATION, Auth);
SocketConnect.Send(data);

如果鉴权通过,服务端返回 AUTH_REPLY (OP 8),此时应立即发送心跳包。

# 心跳

鉴权完成后,B 站维持连接的时限约 70 秒。每 30 秒发送一次心跳包可刷新。心跳包负载为空,仅将 OpCode 设为 CLIENT_HEARTBEAT 即可。

1
2
var data = Helper.Pack(OpCode.CLIENT_HEARTBEAT, "");
SocketConnect.Send(data);

# 接收弹幕

筛选 OpCode 为 CMD (5)的数据包。消息体可能为未压缩(version=0)、Deflate 压缩(version=2)或 Brotli 压缩(version=3)。部分消息使用套娃结构,本节来介绍处理方法。

# Brotli / Deflate 解压

当前 B 站 主要使用 Brotli(协议版本 3),旧版 Deflate(协议版本 2)也需兼容。解压时需跳过头部两个字节(B 站自有标记)。

代码示例 (C#):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public byte[] UncompressData
{
get
{
using MemoryStream stream = new(Data);
try
{
using MemoryStream decompressedStream = new();
Stream decompressionStream = null;

if (Version == 2) // Deflate
{
stream.Position = 2;
decompressionStream = new DeflateStream(stream, CompressionMode.Decompress, false);
}
else if (Version == 3) // Brotli
{
decompressionStream = new BrotliStream(stream, CompressionMode.Decompress, false);
}

if (decompressionStream != null)
{
decompressionStream.CopyTo(decompressedStream);
decompressionStream.Dispose();
}
else
{
decompressedStream.Write(Data, 2, Data.Length - 2);
}

return decompressedStream.ToArray();
}
catch
{
return Data;
}
}
}

其中 Splicebyte[] 扩展方法:

1
2
3
4
5
6
public static byte[] Splice(this byte[] source, int start, int count)
{
byte[] data = new byte[count];
Array.Copy(source, start, data, 0, count);
return data;
}

# 解除套娃

解压后的数据可能由多个连续的数据包拼接而成,每个包都以 16 字节协议头开始。需要在一个循环里逐一拆解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
case OpCode.CMD:
var uncompressData = danmaku.UncompressData;
int pos = 0;
while (true)
{
byte[] tmp = uncompressData.Splice(pos, uncompressData.Length - pos);
danmaku = Helper.UnPack(tmp);

// 魔数 > 3 说明已非弹幕数据包,停止解析
if (danmaku.MagicNumber > 3)
break;

// Data[0..PacketLen-HeaderLen] 即为该条弹幕的JSON
danmaku.DecodeData = Encoding.UTF8.GetString(
danmaku.Data.Splice(0, danmaku.PacketLen - danmaku.HeaderLen));

// 根据 cmd 字段分发处理
switch (danmaku.DeserializedMessage.CMD.Split(':').First())
{
case "DANMU_MSG":
var danmu = danmaku.DeserializedMessage.Data as DanmuMsg;
// 处理弹幕消息...
break;
case "SEND_GIFT":
var gift = danmaku.DeserializedMessage.Data as SendGift;
// 处理礼物消息...
break;
// ... 其他类型
}

pos += danmaku.PacketLen;
if (pos >= uncompressData.Length)
break;
}
break;

关键点:每取出一条消息后,位置向前移动 PacketLen ,直到消费完整个解压数据。魔数(MagicNumber)校验 是判断是否到达有效数据末尾的可靠方式 —— 正常协议包的魔数固定为 1,若大于 3 则终止解析。

# 弹幕常见类型

名称含义
INTERACT_WORDxx 人进入了直播间
DANMU_MSG弹幕消息,内部参数大部分可读
COMBO_SEND连击礼物
SEND_GIFT赠送礼物, total_coin / 1000 为金额(元)
WATCHED_CHANGE看过人数变化
SUPER_CHAT_MESSAGESC / 醒目留言
SUPER_CHAT_MESSAGE_JPNSC(日服)
ENTRY_EFFECT特效进场(舰长等)
GUARD_BUY购买 / 续费舰长、提督、总督
ONLINE_RANK_COUNT高能榜人数
ONLINE_RANK_TOP高能榜变化
ONLINE_RANK_V2高能榜 V2
LIVE_INTERACTIVE_GAME互动游戏
LIKE_INFO点赞信息

# 发送弹幕

发送弹幕不走 WebSocket,而是通过 REST API:

1
2
3
POST https://api.live.bilibili.com/msg/send
Content-Type: application/x-www-form-urlencoded
Cookie: {完整的Cookie字符串}

必填参数:

参数说明
msg弹幕内容
roomid房间号
rnd当前秒级时间戳
color弹幕颜色(十进制,如白色 = 16777215)
mode滚动模式,固定 1
fontsize字号,默认 25
bubble固定 0
csrfCookie 中 bili_jct 的值
csrf_token同上

代码示例 (C#):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public bool SendDanmaku(string message)
{
string url = "https://api.live.bilibili.com/msg/send";
using HttpClient client = new();
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["bubble"] = "0",
["msg"] = message,
["color"] = "16777215",
["mode"] = "1",
["fontsize"] = "25",
["rnd"] = Helper.TimeStamp.ToString(),
["roomid"] = RoomID.ToString(),
["csrf"] = GetCookieValue("bili_jct"),
["csrf_token"] = GetCookieValue("bili_jct"),
})
};
request.Headers.Add("User-Agent", UserAgent);
request.Headers.Add("Cookie", Cookie);
request.Headers.Add("Origin", "https://live.bilibili.com");
request.Headers.Add("Referer", "https://live.bilibili.com");

var response = client.SendAsync(request).Result;
var json = JObject.Parse(response.Content.ReadAsStringAsync().Result);
return json["code"]?.ToString() == "0";
}

注意:发送弹幕需要登录态 Cookie,且 csrf / csrf_token 必须与 Cookie 中的 bili_jct 一致,否则返回 403。