使用JavaScript中的WebSocket获取b站直播间弹幕
鱼肉烧
2021年11月21日 19:53

参考:

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

代码块
JavaScript
自动换行
复制代码
{"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

代码块
JavaScript
自动换行
复制代码
{"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、数据包格式

        数据包由头部和数据主体两部分组成。

代码块
JavaScript
自动换行
复制代码
头部格式:
偏移量    长度   类型       含义
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连接

代码块
JavaScript
自动换行
复制代码
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秒要发送一个心跳包,连接成功后开始设置心跳包定时器,关闭连接后停止心跳包定时器。

代码块
JavaScript
自动换行
复制代码
//生成认证数据
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导入,注释最后一行);另外解压后得到的有可能是几个连着的数据包,需要分离处理(这里按所有数据包都有可能是连着的处理)。

代码块
JavaScript
自动换行
复制代码
//处理服务器发送过来的数据,初步打包
/*打包格式(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){
                //具体处理
            }
        }
    });
}
复制成功

具体的处理方法大家可以自行决定,我这里选择事件机制。

代码块
JavaScript
自动换行
复制代码
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
自动换行
复制代码
<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>
复制成功

效果图:

运行结果


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