Bilibili 直播弹幕 WebSocket 协议
- tcp服务器为:
broadcastlv.chat.bilibili.com
当前关于ws重要信息
WS_OP_HEARTBEAT: 2, //心跳
WS_OP_HEARTBEAT_REPLY: 3, //心跳回应
WS_OP_MESSAGE: 5, //弹幕,消息等
WS_OP_USER_AUTHENTICATION: 7,//用户进入房间
WS_OP_CONNECT_SUCCESS: 8, //进房回应
WS_PACKAGE_HEADER_TOTAL_LENGTH: 16,//头部字节大小
WS_PACKAGE_OFFSET: 0,
WS_HEADER_OFFSET: 4,
WS_VERSION_OFFSET: 6,
WS_OPERATION_OFFSET: 8,
WS_SEQUENCE_OFFSET: 12,
WS_BODY_PROTOCOL_VERSION_NORMAL: 0,//普通消息
WS_BODY_PROTOCOL_VERSION_BROTLI: 3,//brotli压缩信息
WS_HEADER_DEFAULT_VERSION: 1,
WS_HEADER_DEFAULT_OPERATION: 1,
WS_HEADER_DEFAULT_SEQUENCE: 1,
WS_AUTH_OK: 0,
WS_AUTH_TOKEN_ERROR: -101
- 普通未加密的 WebSocket 连接:
ws://broadcastlv.chat.bilibili.com:2244/sub - 使用 SSL 的 WebSocket 连接:
wss://broadcastlv.chat.bilibili.com/sub
发送和接收的包都是这种格式。
| 偏移 | 长度 | 类型 | 字节序 | 名称 | 说明 |
|---|---|---|---|---|---|
| 0 | 4 | int | Big Endian | Packet Length | 数据包长度 |
| 4 | 2 | int | Big Endian | Header Length | 数据包头部长度(固定为 16) |
| 6 | 2 | int | Big Endian | Protocol Version | 协议版本(见下文) |
| 8 | 4 | int | Big Endian | Operation | 操作类型(见下文) |
| 12 | 4 | int | Big Endian | Sequence Id | 数据包头部长度(固定为 1) |
| 16 | - | byte[] | - | Body | 数据内容 |
同一个 WebSocket Frame 可能包含多个 Bilibili 直播数据包,每个 Bilibili 直播数据包 直接首尾相连,数据包长度只表示 Bilibili 直播数据包 的长度,并非 WebSocket Frame 的长度。
2020.6.4 现版本弹幕协议中数据包长度就是整个WebSocket Frame的长度,而并非直播数据包长度,因此已无法通过offset来切割相邻的直播数据包。获得数据后也不能直接使用JSON.parse进行解析,需要将多条json数据切割。
2021.7.12 现版本协议增加brotli压缩信息
| 值 | Body 格式 | 说明 |
|---|---|---|
| 0 | JSON | JSON纯文本,可以直接通过 JSON.stringify 解析 |
| 1 | Int 32 Big Endian | Body 内容为房间人气值 |
| 2 | Buffer | 压缩过的 Buffer,Body 内容需要用zlib.inflate解压出一个新的数据包,然后从数据包格式那一步重新操作一遍 |
| 3 | Buffer | 压缩信息,需要brotli解压,然后从数据包格式 那一步重新操作一遍 |
| 值 | 发送者 | Body 格式 | 名称 | 说明 |
|---|---|---|---|---|
| 2 | 客户端 | (空) | 心跳 | 不发送心跳包,70 秒之后会断开连接,通常每 30 秒发送 1 次 |
| 3 | 服务器 | Int 32 Big Endian | 心跳回应 | Body 内容为房间人气值 |
| 5 | 服务器 | JSON | 通知 | 弹幕、广播等全部信息 |
| 7 | 客户端 | JSON | 进房 | WebSocket 连接成功后的发送的第一个数据包,发送要进入房间 ID |
| 8 | 服务器 | (空) | 进房回应 |
{
"clientver": "1.6.3",
"platform": "web",
"protover": 2,
"roomid": 23058,
"uid": 0,
"type": 2
}| 字段 | 必选 | 类型 | 说明 |
|---|---|---|---|
| clientver | false | string | 例如 "1.5.10.1" |
| platform | false | string | 例如 "web" |
| protover | false | number | 1 或者 2 |
| roomid | true | number | 房间长 ID,可以通过 room_init API 获取 |
| uid | false | number | uin,可以通过 getUserInfo API 获取 |
| type | false | number | 不知道啥,总之写 2 |
- protover 为
1时不会使用zlib压缩,为2时会发送带有zlib压缩的包,也就是数据包协议为2。
1.弹幕类
| 字段 | 说明 |
|---|---|
| DANMU_MSG | 弹幕消息 |
| WELCOME_GUARD | 欢迎xxx老爷 |
| ENTRY_EFFECT | 欢迎舰长进入房间 |
| WELCOME | 欢迎xxx进入房间 |
| SUPER_CHAT_MESSAGE_JPN | |
| SUPER_CHAT_MESSAGE | 二个都是SC留言 |
2.礼物类
| 字段 | 说明 |
|---|---|
| SEND_GIFT | 投喂礼物 |
| COMBO_SEND | 连击礼物 |
3.天选之人类
| 字段 | 说明 |
|---|---|
| ANCHOR_LOT_START | 天选之人开始完整信息 |
| ANCHOR_LOT_END | 天选之人获奖id |
| ANCHOR_LOT_AWARD | 天选之人获奖完整信息 |
4.上船类
| 字段 | 说明 |
|---|---|
| GUARD_BUY | 上舰长 |
| USER_TOAST_MSG | 续费了舰长 |
| NOTICE_MSG | 在本房间续费了舰长 |
5.分区排行类
| 字段 | 说明 |
|---|---|
| ACTIVITY_BANNER_UPDATE_V2 | 小时榜变动 |
6.关注数变化类
| 字段 | 说明 |
|---|---|
| ROOM_REAL_TIME_MESSAGE_UPDATE | 粉丝关注变动 |
内容是一个 4 字节的 Big Endian 的 整数,表示房间人气
这里以浏览器 JavaScript 自带的 WebSocket 说明
- 声明encode和decode方法
const textEncoder = new TextEncoder('utf-8');
const textDecoder = new TextDecoder('utf-8');
const readInt = function(buffer,start,len){
let result = 0
for(let i=len - 1;i >= 0;i--){
result += Math.pow(256,len - i - 1) * buffer[start + i]
}
return result
}
const writeInt = function(buffer,start,len,value){
let i=0
while(i<len){
buffer[start + i] = value/Math.pow(256,len - i - 1)
i++
}
}
const encode = function(str,op){
let data = textEncoder.encode(str);
let packetLen = 16 + data.byteLength;
let header = [0,0,0,0,0,16,0,1,0,0,0,op,0,0,0,1]
writeInt(header,0,4,packetLen)
return (new Uint8Array(header.concat(...data))).buffer
}
const decode = function(blob){
return new Promise(function(resolve, reject) {
let reader = new FileReader();
reader.onload = function (e){
let buffer = new Uint8Array(e.target.result)
let result = {}
result.packetLen = readInt(buffer,0,4)
result.headerLen = readInt(buffer,4,2)
result.ver = readInt(buffer,6,2)
result.op = readInt(buffer,8,4)
result.seq = readInt(buffer,12,4)
if(result.op === 5){
result.body = []
let offset = 0;
while(offset < buffer.length){
let packetLen = readInt(buffer,offset + 0,4)
let headerLen = 16// readInt(buffer,offset + 4,4)
let data = buffer.slice(offset + headerLen, offset + packetLen);
let body = textDecoder.decode(data);
if(body){
result.body.push(JSON.parse(body));
}
offset += packetLen;
}
}else if(result.op === 3){
result.body = {
count: readInt(buffer,16,4)
};
}
resolve(result)
}
reader.readAsArrayBuffer(blob);
});
}- 连接 WebSocket并发送进入房间请求
const ws = new WebSocket('wss://broadcastlv.chat.bilibili.com:2245/sub');
ws.onopen = function () {
ws.send(encode(JSON.stringify({
roomid: 23058
}), 7));
};
// 如果使用的是控制台,这两句一定要一起执行,否侧onopen不会被触发这个数据包必须为连接以后的第一个数据包,5 秒内不发送进房数据包,服务器主动断开连接,任何数据格式错误将直接导致服务器主动断开连接。
- 每隔 30 秒发送一次心跳
setInterval(function () {
ws.send(encode('', 2));
}, 30000);- 接收
ws.onmessage = async function (msgEvent) {
const packet = await decode(msgEvent.data);
switch (packet.op) {
case 8:
console.log('加入房间');
break;
case 3:
const count = packet.body.count
console.log(`人气:${count}`);
break;
case 5:
packet.body.forEach((body)=>{
switch (body.cmd) {
case 'DANMU_MSG':
console.log(`${body.info[2][1]}: ${body.info[1]}`);
break;
case 'SEND_GIFT':
console.log(`${body.data.uname} ${body.data.action} ${body.data.num} 个 ${body.data.giftName}`);
break;
case 'WELCOME':
console.log(`欢迎 ${body.data.uname}`);
break;
// 此处省略很多其他通知类型
default:
console.log(body);
}
})
break;
default:
console.log(packet);
}
};5.PS
有时候弹幕消息主体经过压缩,导致不能解析
浏览器中,decode方法改写如下:
const decode = function(blob){
return new Promise(function(resolve, reject) {
let reader = new FileReader();
reader.onload = function (e){
let buffer = new Uint8Array(e.target.result)
let result = {}
result.packetLen = readInt(buffer,0,4)
result.headerLen = readInt(buffer,4,2)
result.ver = readInt(buffer,6,2)
result.op = readInt(buffer,8,4)
result.seq = readInt(buffer,12,4)
if(result.op === 5){
result.body = []
let offset = 0;
while(offset < buffer.length){
let packetLen = readInt(buffer,offset + 0,4)
let headerLen = 16// readInt(buffer,offset + 4,4)
let data = buffer.slice(offset + headerLen, offset + packetLen);
/**
* 仅有两处更改
* 1. 引入pako做message解压处理,具体代码链接如下
* https://github.com/nodeca/pako/blob/master/dist/pako.js
* 2. message文本中截断掉不需要的部分,避免JSON.parse时出现问题
*/
/** let body = textDecoder.decode(pako.inflate(data));
if (body) {
// 同一条 message 中可能存在多条信息,用正则筛出来
const group = body.split(/[\x00-\x1f]+/);
group.forEach(item => {
try {
result.body.push(JSON.parse(item));
}
catch(e) {
// 忽略非 JSON 字符串,通常情况下为分隔符
}
});
}**/
let body = '';
try {
// pako可能无法解压
body = textDecoder.decode(pako.inflate(data));
}
catch (e){
body = textDecoder.decode(data)
}
if (body) {
// 同一条 message 中可能存在多条信息,用正则筛出来
const group = body.split(/[\x00-\x1f]+/);
group.forEach(item => {
try {
const parsedItem = JSON.parse(item);
if (typeof parsedItem === 'object') {
result.body.push(parsedItem);
} else {
// 这里item可能会解析出number
// 此时可以尝试重新用pako解压data(携带转换参数)
// const newBody = textDecoder.decode(pako.inflate(data, {to: 'String'}))
// 重复上面的逻辑,筛选可能存在的多条信息
// 初步验证,这里可以解析到INTERACT_WORD、DANMU_MSG、ONLINE_RANK_COUNT
// SEND_GIFT、SUPER_CHAT_MESSAGE
}
}
catch(e) {
// 忽略非 JSON 字符串,通常情况下为分隔符
}
});
}
offset += packetLen;
}
}else if(result.op === 3){
result.body = {
count: readInt(buffer,16,4)
};
}
resolve(result)
}
reader.readAsArrayBuffer(blob);
});
}nodejs中,decode方法改写如下:
const zlib = require('zlib');
const decoder = function (blob) {
let buffer = new Uint8Array(blob)
let result = {}
result.packetLen = readInt(buffer, 0, 4)
result.headerLen = readInt(buffer, 4, 2)
result.ver = readInt(buffer, 6, 2)
result.op = readInt(buffer, 8, 4)
result.seq = readInt(buffer, 12, 4)
if (result.op === 5) {
result.body = []
let offset = 0;
while (offset < buffer.length) {
let packetLen = readInt(buffer, offset + 0, 4)
let headerLen = 16// readInt(buffer,offset + 4,4)
if (result.ver == 2) {
let data = buffer.slice(offset + headerLen, offset + packetLen);
let newBuffer = zlib.inflateSync(new Uint8Array(data));
const obj = decoder(newBuffer);
const body = obj.body;
result.body = result.body.concat(body);
} else {
let data = buffer.slice(offset + headerLen, offset + packetLen);
let body = textDecoder.decode(data);
if (body) {
result.body.push(JSON.parse(body));
}
}
let body = textDecoder.decode(pako.inflate(data));
if (body) {
result.body.push(JSON.parse(body.slice(body.indexOf("{"))));
}
offset += packetLen;
}
} else if (result.op === 3) {
result.body = {
count: readInt(buffer, 16, 4)
};
}
return result;
}
const decode = function (blob) {
return new Promise(function (resolve, reject) {
const result = decoder(blob);
resolve(result)
});
}