# 说在前面
逻辑参考自 B 站弹幕姬 - Github,并针对 B 站 2024 年后的协议变动做了修正。
# 封包组成
B 站弹幕协议使用 16 字节的二进制头部,后接负载。当前支持的协议版本有 0(无压缩)、2(Deflate)、3(Brotli)。
| packet_len | header_len | version | opcode | magic_number | payload |
|---|
| 含义 | 总包长度 | 头部长度 | 协议版本 | 操作码 | 幻数 | 负载 |
| 长度 (byte) | 4 | 2 | 2 | 4 | 4 | ... |
| 备注 | 包括头 | 固定 16 | 0/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; 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; } }
|
# 拉取连接配置
调用 API 拉取弹幕服务器配置:
1
| GET https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={房间号}&type=0
|
(旧版接口 /room/v1/Danmu/getConf 已失效)
返回 JSON 中 data.host_list 数组的每一项包含 host 、 wss_port 字段。任取一个,拼接为 wss://{host}:{wss_port}/sub ,使用 WebSocket 客户端连接。
使用 System.Net.WebSockets.ClientWebSocket 即可,不需要第三方 WebSocket 库。注意必须监听 Binary 消息( MessageType.Binary ),而非 Text 消息。
连接建立后执行鉴权操作。
# 鉴权
当 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 字段,增加了 buvid , protover 从 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) { stream.Position = 2; decompressionStream = new DeflateStream(stream, CompressionMode.Decompress, false); } else if (Version == 3) { 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; } } }
|
其中 Splice 为 byte[] 扩展方法:
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);
if (danmaku.MagicNumber > 3) break;
danmaku.DecodeData = Encoding.UTF8.GetString( danmaku.Data.Splice(0, danmaku.PacketLen - danmaku.HeaderLen));
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_WORD | xx 人进入了直播间 |
| DANMU_MSG | 弹幕消息,内部参数大部分可读 |
| COMBO_SEND | 连击礼物 |
| SEND_GIFT | 赠送礼物, total_coin / 1000 为金额(元) |
| WATCHED_CHANGE | 看过人数变化 |
| SUPER_CHAT_MESSAGE | SC / 醒目留言 |
| SUPER_CHAT_MESSAGE_JPN | SC(日服) |
| 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 |
| csrf | Cookie 中 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。