参考:
bilibili-api(python模块):https://pypi.org/project/bilibili-api/
通过WebSocket获取bilibili直播弹幕:https://blog.csdn.net/yyznm/article/details/116543107
js中WebSocket:https://www.cnblogs.com/llljpf/p/10830651.html
获取bilibili直播弹幕的WebSocket协议:https://blog.csdn.net/xfgryujk/article/details/80306776
B站直播弹幕ws协议分析:https://daidr.me/archives/code-526.html
B站直播弹幕获取:https://www.jianshu.com/p/157297d82b8e
JavaScript二进制数组(3)DataView视图:https://zhuanlan.zhihu.com/p/54273304
JavaScript 之 ArrayBuffer:https://www.cnblogs.com/copperhaze/p/6149041.html
JavaScript 文件对象详解:https://blog.csdn.net/weixin_34026276/article/details/89694769
JavaScript 类型化数组:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Typed_arrays
FileReader:https://developer.mozilla.org/en-US/docs/Web/API/FileReader
TextDecoder:https://developer.mozilla.org/zh-CN/docs/Web/API/TextDecoder
TextEncoder:https://developer.mozilla.org/zh-CN/docs/Web/API/TextEncoder
EventTarget:https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget
CustomEvent:https://developer.mozilla.org/zh-CN/docs/Web/API/CustomEvent
Python 常用压缩库(zlib, bz2, gzip)以及压缩格式特点:https://blog.csdn.net/weixin_40005329/article/details/103488316
brotli:https://github.com/google/brotli/tree/master/js
一、前期准备
1、获取真实直播间号
以6号直播间为例,6号直播间的地址为网页链接,但在控制台可以看到发送出去的认证数据包中真实直播间号是7734200

认证数据包
这里可以通过 https://api.网页链接房间号 获取。

获取真实直播间号api
{"code":0,
"msg":"ok",
"message":"ok",
"data":{
"room_id":7734200, //真实直播间号
"short_id":6,
"uid":50329118,
"need_p2p":0,
"is_hidden":false,
"is_locked":false,
"is_portrait":false,
"live_status":1,
"hidden_till":0,
"lock_till":0,
"encrypted":false,
"pwd_verified":false,
"live_time":1632817781,
"room_shield":1,
"is_sp":0,
"special_type":0
}
}
2、获取WebSocket地址
通过 https://api.网页链接直播间号&platform=pc&player=web 获取(根据网上的例子选了其中一个网址和端口)。

获取ws地址api
{"code":0,
"msg":"ok",
"message":"ok",
"data":{
"refresh_row_factor":0.125,
"refresh_rate":100,
"max_delay":5000,
"port":2243,
"host":"broadcastlv.chat.bilibili.com",
"host_server_list":[
{
"host":"hw-bj-live-comet-07.chat.bilibili.com",
"port":2243,
"wss_port":443,
"ws_port":2244
},
{
"host":"tx-gz-live-comet-03.chat.bilibili.com",
"port":2243,
"wss_port":443,
"ws_port":2244
},
{
"host":"broadcastlv.chat.bilibili.com", //选用了这个
"port":2243,
"wss_port":443, //选用了这个
"ws_port":2244
}
],
"server_list":[
{"host":"114.116.237.1","port":2243},
{"host":"106.53.116.19","port":2243},
{"host":"broadcastlv.chat.bilibili.com","port":2243},
{"host":"114.116.237.1","port":80},
{"host":"106.53.116.19","port":80},
{"host":"broadcastlv.chat.bilibili.com","port":80}
],
"token":"N30Sjt_Rj0lQ2FaqOpAnQ6VB652DvivuXvIxXMEQ1GEFBGwLMb2nDQu2To6r-zwcgTwawmT-wFnBSM6CVeYdrsYdDqGT6Lvs2oxPgqWAjIMv1cit6p0qAfEDHf9hga6NxfQ_9TcvzhghgbWSqg=="
}
} 参考b站本身的链接格式:

3、数据包格式
数据包由头部和数据主体两部分组成。
头部格式:
偏移量 长度 类型 含义
0 4 int 数据包总长度
4 2 int 数据包头部长度
6 2 int 数据包协议版本
8 4 int 数据包类型
12 4 int 取常数1
16 - bytes[] 数据主体
数据包协议版本 含义
0 数据包有效负载为未压缩的JSON格式数据
1 客户端心跳包,或服务器心跳回应(带有人气值)
3 数据包有效负载为通过br压缩后的JSON格式数据(之前是zlib)
数据包类型 发送方 名称 含义
2 Client 心跳 不发送心跳包,50-60秒后服务器会强制断开连接
3 Server 心跳回应 有效负载为直播间人气值
5 Server 通知 有效负载为礼物、弹幕、公告等内容数据
7 Client 认证(加入房间) 客户端成功建立连接后发送的第一个数据包
8 Server 认证成功回应 服务器接受认证包后回应的第一个数据包
客户端建立连接后,需要在5秒内发出加入房间(认证)的数据包,否则会被服务器强制断开连接。
其中有效负载的key字段内容可以从之前的 https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id=房间号&platform=pc&player=web 获取。
如发送的认证包格式错误,服务器会立刻强制断开连接。
下面为认证数据包主体JSON字段的详细说明见下表。
字段 类型 含义
uid number 用户uid
roomid number 房间号
protover number 协议版本,目前为3
platform string 平台
type number 不清楚,填2
key string 应该是接口返回的token值,测试时值为空字符串也可以
二、具体实现
1、建立WebSocket连接
function webSocket(room_id){
if("WebSocket" in window){
console.log("您的浏览器支持WebSocket");
var timer; //心跳包定时器
var ws = new WebSocket("wss://broadcastlv.chat.bilibili.com:443/sub");
ws.onopen = function(e){
console.log("open");
}
ws.onmessage = function(e){
//当客户端收到服务端发来的消息时,触发onmessage事件,参数e.data包含server传递过来的数据
}
ws.onclose = function(e){
//当客户端收到服务端发送的关闭连接请求时,触发onclose事件
console.log("close");
}
ws.onerror = function(e){
//如果出现连接、处理、接收、发送数据失败的时候触发onerror事件
console.log(e);
}
}else{
console.log("您的浏览器不支持WebSocket");
}
}
2、发送认证数据包以及心跳包
客户端建立连接后,需要在5秒内发出加入房间(认证)的数据包,否则会被服务器强制断开连接;30秒要发送一个心跳包,连接成功后开始设置心跳包定时器,关闭连接后停止心跳包定时器。
//生成认证数据
function getCertification(json){
let encoder = new TextEncoder(); //编码器
let jsonView = encoder.encode(json); //utf-8编码
let buff = new ArrayBuffer(jsonView.byteLength + 16); //数据包总长度:16位头部长度+bytes长度
let view = new DataView(buff); //新建操作视窗
view.setUint32(0, jsonView.byteLength + 16); //整个数据包长度
view.setUint16(4, 16); //头部长度
view.setUint16(6, 1); //协议版本
view.setUint32(8, 7); //类型,7为加入房间认证
view.setUint32(12, 1); //填1
for(let r = 0; r < jsonView.byteLength; r++){
view.setUint8(16 + r, jsonView[r]); //填入数据
}
return buff;
}
//连接成功后发送认证信息和设置心跳包定时器
ws.onopen = function(e){
console.log("open");
var certification = {
"uid": 0,
"roomid": room_id,
"protover": 3,
"platform": "web",
"type": 2,
"key": "" //值为空字符串好像也没问题
}
ws.send(getCertification(JSON.stringify(certification)));
console.log(JSON.stringify(certification))
//发送心跳包
timer = setInterval(function(){
let buff = new ArrayBuffer(16);
let i = new DataView(buff);
i.setUint32(0, 0); //整个封包
i.setUint16(4, 16); //头部
i.setUint16(6, 1); //协议版本
i.setUint32(8, 2); //操作码,2为心跳包
i.setUint32(12, 1); //填1
ws.send(buff);
}, 30000); //30秒
}
//连接关闭后停止心跳包定时器
ws.onclose = function(e){
//当客户端收到服务端发送的关闭连接请求时,触发onclose事件
console.log("close");
if (timer != null){
clearInterval(timer); //停止发送心跳包
}
setTimeout(webSocket,4000); //4秒后重连,按需设置或不重连
}
3、处理接收到的数据
需要注意,接收到的数据如果是压缩过的,需要解压(下载https://raw.githubusercontent.com/google/brotli/master/js/decode.js导入,注释最后一行);另外解压后得到的有可能是几个连着的数据包,需要分离处理(这里按所有数据包都有可能是连着的处理)。
//处理服务器发送过来的数据,初步打包
/*打包格式(JSON)
键 值类型
Len int
HeadLen int
Ver int
Type int
Num int
body JSON(Type != 3)或者int(Type == 3)
*/
function handleMessage(blob, call){
let reader = new FileReader();
reader.onload = function(e){
let buff = e.target.result; //ArrayBuffer对象
let decoder = new TextDecoder(); //解码器
let view = new DataView(buff); //视图
let offset = 0;
let packet = {};
let result = [];
while (offset < buff.byteLength){ //数据提取
let packetLen = view.getUint32(offset + 0);
let headLen = view.getUint16(offset + 4);
let packetVer = view.getUint16(offset + 6);
let packetType = view.getUint32(offset + 8);
let num = view.getUint32(12);
if (packetVer == 3){ //解压数据
let brArray = new Uint8Array(buff, offset + headLen, packetLen - headLen);
let BrotliDecode = makeBrotliDecode(); //生成Brotli格式解压工具的实例
let buffFromBr = BrotliDecode(brArray); //返回Int8Array视图
let view = new DataView(buffFromBr.buffer);
let offset_Ver3 = 0;
while (offset_Ver3 < buffFromBr.byteLength){ //解压后数据提取
let packetLen = view.getUint32(offset_Ver3 + 0);
let headLen = view.getUint16(offset_Ver3 + 4);
let packetVer = view.getUint16(offset_Ver3 + 6);
let packetType = view.getUint32(offset_Ver3 + 8);
let num = view.getUint32(12);
packet.Len = packetLen;
packet.HeadLen = headLen;
packet.Ver = packetVer;
packet.Type = packetType;
packet.Num = num;
let dataArray = new Uint8Array(buffFromBr.buffer, offset_Ver3 + headLen, packetLen - headLen);
packet.body = decoder.decode(dataArray); //utf-8格式数据解码,获得字符串
result.push(JSON.stringify(packet)); //数据打包后传入数组
offset_Ver3 += packetLen;
}
}else{
packet.Len = packetLen;
packet.HeadLen = headLen;
packet.Ver = packetVer;
packet.Type = packetType;
packet.Num = num;
let dataArray = new Uint8Array(buff, offset + headLen, packetLen - headLen);
if (packetType == 3){ //获取人气值
packet.body = (new DataView(buff, offset + headLen, packetLen - headLen)).getUint32(0); //若入参为dataArray.buffer,会返回整段buff的视图,而不是截取后的视图
}else{
packet.body = decoder.decode(dataArray); //utf-8格式数据解码,获得字符串
}
result.push(JSON.stringify(packet)); //数据打包后传入数组
}
offset += packetLen;
}
call(result); //数据后续处理
}
reader.readAsArrayBuffer(blob); //读取服务器传来的数据转换为ArrayBuffer
}
//数据处理
ws.onmessage = function(e){
//当客户端收到服务端发来的消息时,触发onmessage事件,参数e.data包含server传递过来的数据
//console.log(e.data);
let blob = e.data;
handleMessage(blob, function(result){
//触发事件
for(let i=0; i<result.length; i++){
let json = JSON.parse(result[i]);
if (json.Type == 5){
//具体处理
}
if (json.Type == 8){
//具体处理
}
if (json.Type == 3){
//具体处理
}
}
});
}
具体的处理方法大家可以自行决定,我这里选择事件机制。
var eventTarget = new EventTarget();
//事件注册
function on(eventType, callback){
eventTarget.addEventListener(eventType, function(e){
callback(e.detail);
});
}
ws.onmessage = function(e){
//当客户端收到服务端发来的消息时,触发onmessage事件,参数e.data包含server传递过来的数据
//console.log(e.data);
let blob = e.data;
handleMessage(blob, function(result){
//触发事件
for(let i=0; i<result.length; i++){
let json = JSON.parse(result[i]);
if (json.Type == 5){
let event = new CustomEvent(JSON.parse(json.body).cmd, {detail: JSON.parse(json.body)});
eventTarget.dispatchEvent(event);
}
if (json.Type == 8){
let event = new CustomEvent("Certify_Success", {detail: JSON.parse(json.body)});
eventTarget.dispatchEvent(event);
}
if (json.Type == 3){
let event = new CustomEvent("VIEW", {detail: json.body});
eventTarget.dispatchEvent(event);
}
}
});
}
//认证成功事件
on("Certify_Success", function(e){
let data = e;
if (data.code == 0){
console.log("Certify_Success");
}
})
//人气值刷新事件
on("VIEW", function(e){
let data = e;
console.log("VIEW: " + data.toString());
})
//进入直播间或关注直播间事件
on("INTERACT_WORD", function(e){
let data = e;
let uname = data.data.uname;
let timedata = new Date(data.data.timestamp * 1000);
let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
if (data.data.msg_type == 2){ //个人推测,不一定准确
console.log(time+ " " + uname + " 关注直播间");
}else{
console.log(data.cmd + " " + time+ " " + uname + " 进入直播间");
}
})
//弹幕事件
on("DANMU_MSG", function(e){
let data = e;
let uname = data.info[2][1];
let timedata = new Date(data.info[9].ts * 1000);
let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
let text = data.info[1];
console.log(data.cmd + " " + time+ " " + uname + " :" + text);
})
//礼物赠送事件
on("SEND_GIFT", function(e){
let data = e;
let uname = data.data.uname;
let gift_num = data.data.num;
let act = data.data.action;
let gift_name = data.data.giftName;
let timedata = new Date(data.data.timestamp * 1000);
let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
console.log(data.cmd + " " + time+ " " + uname + " :" + act + " " + gift_num + " " + gift_name);
})
三、总结
这里贴出完整代码。
<html>
<head>
<title>
websocket test
</title>
<script type="text/javascript" src="decode.js"></script>
<script>
var eventTarget = new EventTarget();
//事件注册
function on(eventType, callback){
eventTarget.addEventListener(eventType, function(e){
callback(e.detail);
});
}
//生成认证数据
function getCertification(json){
let encoder = new TextEncoder(); //编码器
let jsonView = encoder.encode(json); //utf-8编码
let buff = new ArrayBuffer(jsonView.byteLength + 16); //数据包总长度:16位头部长度+bytes长度
let view = new DataView(buff); //新建操作视窗
view.setUint32(0, jsonView.byteLength + 16); //整个数据包长度
view.setUint16(4, 16); //头部长度
view.setUint16(6, 1); //协议版本
view.setUint32(8, 7); //类型,7为加入房间认证
view.setUint32(12, 1); //填1
for(let r = 0; r < jsonView.byteLength; r++){
view.setUint8(16 + r, jsonView[r]); //填入数据
}
return buff;
}
//处理服务器发送过来的数据,初步打包
/*打包格式(JSON)
键 值类型
Len int
HeadLen int
Ver int
Type int
Num int
body JSON(Type != 3)或者int(Type == 3)
*/
function handleMessage(blob, call){
let reader = new FileReader();
reader.onload = function(e){
let buff = e.target.result; //ArrayBuffer对象
let decoder = new TextDecoder(); //解码器
let view = new DataView(buff); //视图
let offset = 0;
let packet = {};
let result = [];
while (offset < buff.byteLength){ //数据提取
let packetLen = view.getUint32(offset + 0);
let headLen = view.getUint16(offset + 4);
let packetVer = view.getUint16(offset + 6);
let packetType = view.getUint32(offset + 8);
let num = view.getUint32(12);
if (packetVer == 3){ //解压数据
let brArray = new Uint8Array(buff, offset + headLen, packetLen - headLen);
let BrotliDecode = makeBrotliDecode(); //生成Brotli格式解压工具的实例
let buffFromBr = BrotliDecode(brArray); //返回Int8Array视图
let view = new DataView(buffFromBr.buffer);
let offset_Ver3 = 0;
while (offset_Ver3 < buffFromBr.byteLength){ //解压后数据提取
let packetLen = view.getUint32(offset_Ver3 + 0);
let headLen = view.getUint16(offset_Ver3 + 4);
let packetVer = view.getUint16(offset_Ver3 + 6);
let packetType = view.getUint32(offset_Ver3 + 8);
let num = view.getUint32(12);
packet.Len = packetLen;
packet.HeadLen = headLen;
packet.Ver = packetVer;
packet.Type = packetType;
packet.Num = num;
let dataArray = new Uint8Array(buffFromBr.buffer, offset_Ver3 + headLen, packetLen - headLen);
packet.body = decoder.decode(dataArray); //utf-8格式数据解码,获得字符串
result.push(JSON.stringify(packet)); //数据打包后传入数组
offset_Ver3 += packetLen;
}
}else{
packet.Len = packetLen;
packet.HeadLen = headLen;
packet.Ver = packetVer;
packet.Type = packetType;
packet.Num = num;
let dataArray = new Uint8Array(buff, offset + headLen, packetLen - headLen);
if (packetType == 3){ //获取人气值
packet.body = (new DataView(buff, offset + headLen, packetLen - headLen)).getUint32(0); //若入参为dataArray.buffer,会返回整段buff的视图,而不是截取后的视图
}else{
packet.body = decoder.decode(dataArray); //utf-8格式数据解码,获得字符串
}
result.push(JSON.stringify(packet)); //数据打包后传入数组
}
offset += packetLen;
}
call(result); //数据后续处理
}
reader.readAsArrayBuffer(blob); //读取服务器传来的数据转换为ArrayBuffer
}
function webSocket(room_id){
if("WebSocket" in window){
console.log("您的浏览器支持WebSocket");
var timer;
var ws = new WebSocket("wss://broadcastlv.chat.bilibili.com:443/sub");
ws.onopen = function(e){
console.log("open");
var certification = {
"uid": 0,
"roomid": room_id,
"protover": 3,
"platform": "web",
"type": 2,
"key": "" //值为空字符串好像也没问题
}
ws.send(getCertification(JSON.stringify(certification)));
console.log(JSON.stringify(certification))
//发送心跳包
timer = setInterval(function(){
let buff = new ArrayBuffer(16);
let i = new DataView(buff);
i.setUint32(0, 0); //整个封包
i.setUint16(4, 16); //头部
i.setUint16(6, 1); //协议版本
i.setUint32(8, 2); //操作码,2为心跳包
i.setUint32(12, 1); //填1
ws.send(buff);
}, 30000); //30秒
}
ws.onmessage = function(e){
//当客户端收到服务端发来的消息时,触发onmessage事件,参数e.data包含server传递过来的数据
//console.log(e.data);
let blob = e.data;
handleMessage(blob, function(result){
//触发事件
for(let i=0; i<result.length; i++){
let json = JSON.parse(result[i]);
if (json.Type == 5){
let event = new CustomEvent(JSON.parse(json.body).cmd, {detail: JSON.parse(json.body)});
eventTarget.dispatchEvent(event);
}
if (json.Type == 8){
let event = new CustomEvent("Certify_Success", {detail: JSON.parse(json.body)});
eventTarget.dispatchEvent(event);
}
if (json.Type == 3){
let event = new CustomEvent("VIEW", {detail: json.body});
eventTarget.dispatchEvent(event);
}
}
});
}
ws.onclose = function(e){
//当客户端收到服务端发送的关闭连接请求时,触发onclose事件
console.log("close");
if (timer != null){
clearInterval(timer); //停止发送心跳包
}
setTimeout(webSocket,4000); //4秒后重连
}
ws.onerror = function(e){
//如果出现连接、处理、接收、发送数据失败的时候触发onerror事件
console.log(e);
}
}else{
console.log("您的浏览器不支持WebSocket");
}
}
//认证成功事件
on("Certify_Success", function(e){
let data = e;
if (data.code == 0){
console.log("Certify_Success");
}
})
//人气值刷新事件
on("VIEW", function(e){
let data = e;
console.log("VIEW: " + data.toString());
})
//进入直播间或关注直播间事件
on("INTERACT_WORD", function(e){
let data = e;
let uname = data.data.uname;
let timedata = new Date(data.data.timestamp * 1000);
let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
if (data.data.msg_type == 2){ //个人推测,不一定准确
console.log(time+ " " + uname + " 关注直播间");
}else{
console.log(data.cmd + " " + time+ " " + uname + " 进入直播间");
}
})
//弹幕事件
on("DANMU_MSG", function(e){
let data = e;
let uname = data.info[2][1];
let timedata = new Date(data.info[9].ts * 1000);
let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
let text = data.info[1];
console.log(data.cmd + " " + time+ " " + uname + " :" + text);
})
//礼物赠送事件
on("SEND_GIFT", function(e){
let data = e;
let uname = data.data.uname;
let gift_num = data.data.num;
let act = data.data.action;
let gift_name = data.data.giftName;
let timedata = new Date(data.data.timestamp * 1000);
let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
console.log(data.cmd + " " + time+ " " + uname + " :" + act + " " + gift_num + " " + gift_name);
})
//直播间号码
webSocket(7734200);
</script>
</head>
<body>
</body>
</html>
效果图:

运行结果
感兴趣的小伙伴可以尝试一下,若发现文章有什么错误的地方,十分欢迎大家指出,有什么想法也欢迎交流,谢谢!