B 站直播弹幕的 WebSocket 获取尝试与抽奖程序实现

grtsinry43
1/17/2025(更新于 1/28/2025
99 views
预计阅读时长 28 分钟

网上对于 B 站的直播 ws 协议研究已经很多了,但是一是相互 copy 未形成完整的解决方案,二是 B 站收紧了未登录用户查看的弹幕信息。正巧学校团委部门直播使用,于是就有了我的相关尝试

提示

这个方法不知道以后会不会再被限制,不过其原理上是完全模拟用户操作,与真实用户使用浏览器相同。

原理

弹幕服务器

B 站是通过 WebSocket 连接形式来向客户端发送通知和弹幕,因此我们的想法就是加入 ws 会话,接受消息并解析

其过程就是 拿到房间号-> 获取服务器地址(登录态)-> 加入会话-> 解析消息-> 拿到弹幕

我们接下来一步一步解决

房间号

首先 B 站的直播有短房间号和真实房间号,我们要调用 API 获取真实房间号

[GET] https://api.live.bilibili.com/room/v1/Room/room_init?id =${shortId}

返回内容是这样的:

JSON
1{
2    "code": 0,
3    "msg": "ok",
4    "message": "ok",
5    "data": {
6        "room_id": long_id,
7        "short_id": 0,
8        "uid": user_id,
9        "need_p2p": 0,
10        "is_hidden": false,
11        "is_locked": false,
12        "is_portrait": false,
13        "live_status": 1,
14        "hidden_till": 0,
15        "lock_till": 0,
16        "encrypted": false,
17        "pwd_verified": false,
18        "live_time": 1735877407,
19        "room_shield": 0,
20        "is_sp": 0,
21        "special_type": 0
22    }
23}

弹幕服务器地址和密钥

为了获取弹幕,我们要先拿到对应的服务器地址,和加入的密钥

[GET] https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id =${roomId}

返回内容是这样的:

JSON
1{
2    "code": 0,
3    "message": "0",
4    "ttl": 1,
5    "data": {
6        "group": "live",
7        "business_id": 0,
8        "refresh_row_factor": 0.125,
9        "refresh_rate": 100,
10        "max_delay": 5000,
11        "token": "token",
12        "host_list": [
13            {
14                "host": "zj-cn-live-comet.chat.bilibili.com",
15                "port": 2243,
16                "wss_port": 2245,
17                "ws_port": 2244
18            },
19            ...
20        ]
21    }
22}
提示

问题来了,这里拿到的 token 和服务器,你会发现是否登录都能拿到,但是如果你用未登录的 token 配合 uid,会被直接断开连接,所以这里请求的时候就要带好登录态。B 站的登录态就是靠得 cookie,所以你可以 F12 直接从网络请求中拿到:

拿到 Cookie

在这之后,你也会发现,诶嘿,拿到的弹幕服务器也变多了!

通知数据格式

鉴权包

首先,我们需要向 ws 服务器推送一个鉴权包,来验证身份后续得到消息推送

包头部分

包头用于描述数据包的元信息。这里的 headerBuffer 长度是 16 字节,按照协议的规定,需要在包头中写入以下内容:

字段名描述数据类型大小代码解释
包总长度数据包的总长度(包括包头和包体)UInt324 字节headerBuffer.writeUInt32BE(headerLength + bodyBuffer.length, 0)
包头长度包头的长度UInt162 字节headerBuffer.writeUInt16BE(headerLength, 4)
协议版本协议版本号UInt162 字节headerBuffer.writeUInt16BE(protocol, 6)
数据类型数据包类型UInt324 字节headerBuffer.writeUInt32BE(type, 8)
序列号序列号,用于标识该请求包的唯一性UInt324 字节headerBuffer.writeUInt32BE(sequence, 12)

包体部分

包体包含实际的业务数据。body 作为 JSON 字符串,包含了用户、房间、平台等信息。转成二进制数据后存放在 bodyBuffer 中。

字段名描述数据类型说明
uid用户 ID数字用户的唯一标识
roomid房间 ID数字当前的房间 ID
protover协议版本数字当前协议版本号,固定为 2
buvid设备 ID字符串用户设备的唯一标识
platform平台类型字符串设备平台,值为 'web'
type数据类型数字设为 2 表示这是鉴权数据
key鉴权 Token字符串用于验证用户身份的 Token

心跳包

根据咱们上边的格式,我们就可以生成一个心跳包啦,B 站的心跳包的内容是 [Object object]

TYPESCRIPT
1// 生成心跳包
2function generateHeartbeat(): Buffer {
3    const headerLength = 16;
4    const protocol = 1;
5    const type = 2;
6    const sequence = 2;
7    // 好小众的内容格式(
8    const body = '[Object object]';
9
10    const bodyBuffer = Buffer.from(body);
11    const headerBuffer = Buffer.alloc(headerLength);
12    headerBuffer.writeUInt32BE(headerLength + bodyBuffer.length, 0);
13    headerBuffer.writeUInt16BE(headerLength, 4);
14    headerBuffer.writeUInt16BE(protocol, 6);
15    headerBuffer.writeUInt32BE(type, 8);
16    headerBuffer.writeUInt32BE(sequence, 12);
17
18    return Buffer.concat([headerBuffer, bodyBuffer]);
19}

数据包

诶嘿

这里就是比较恶心的地方啦,不过也不用担心,马上就可以看到效果嘞

我们拿到的数据根据其 op 的值对应着不同的操作类型:

op操作类型说明
3心跳包服务器定期发送的心跳包,用于保持连接活跃。
5弹幕消息包客户端发送的弹幕消息,包含弹幕的内容。
8直播开始包服务器发送的直播开始的包,通常包含直播间的元数据。
9用户加入包用户加入直播间的包,通常包含加入用户的相关信息。
7礼物消息包用户赠送礼物的包,通常包含礼物信息。
2配置更新包直播间的配置信息更新包。
1初始化包用于初始化连接或配置的包,通常包含一些初始数据。

我们这里着重处理弹幕内容哦:

获取数据包的元信息

操作描述代码片段
数据包长度获取数据包的总长度(包括包头和包体)。const packetLen = parseInt(data.slice(0, 4).toString('hex'), 16);
协议类型获取协议类型(通常为 1 或 2)。const proto = parseInt(data.slice(6, 8).toString('hex'), 16);
操作类型获取操作类型(例如,心跳包为 3,弹幕消息为 5)。const op = parseInt(data.slice(8, 12).toString('hex'), 16);

说明:通过读取数据的前 12 字节,我们可以知道数据包的长度、协议类型和操作类型,这些都是数据包的元信息。

数据包切分

操作描述代码片段
包切分如果数据包是连着的,则递归处理剩余部分。if (data.length > packetLen) { this.getDmMsg(data.slice(packetLen)); data = data.slice(0, packetLen); }

说明:若 data.length 大于 packetLen,说明这是一个分包,需要切分数据并递归处理后续的数据包。

解压处理

操作描述代码片段
协议判断判断协议类型是否为 2,若是则解压。if (proto === 2)
数据解压使用 zlib.inflateSync 解压数据包的有效负载部分(跳过前 16 字节的包头)。data = zlib.inflateSync(data.slice(16));

说明:如果 proto 为 2,说明数据包采用了压缩算法,需要对包体部分进行解压。解压后再次调用 getDmMsg 递归处理。

心跳包判断

操作描述代码片段
操作类型判断判断操作类型是否为 3,若是则表示心跳包。if (op === 3)
日志输出打印 "HeartBeat" 提示信息。()console.info("HeartBeat");

说明:操作类型为 3 时,表明这是一个心跳包,通常用于保持与服务器的连接活跃。这个不用处理捏,我就打印了一下()

弹幕消息解析与事件触发

操作描述代码片段
操作类型判断判断操作类型是否为 5,若是则表示弹幕消息包。if (op === 5)
数据解析解析数据包的有效负载部分为 JSON 对象。const content = JSON.parse(data.slice(16).toString());

说明:操作类型为 5 表示这是一个弹幕消息包,包体中包含了弹幕信息。我们将其解析为 JSON 对象,并通过 emit 触发 MsgData 事件,供其他部分的代码进行处理。

程序设计

好啦,在上文中,我们已经明白大致的思路,感觉复杂也没关系,我们一步步来完成这些操作

技术栈选择

我这里选择的是 Electron + Vite + React 的方式进行开发,比较快速构建用户界面+node 的能力+跨平台

electron 还有一个好处,由于其本身就是浏览器,因此搞登录操作非常方便,获取 cookie 也十分简单

登录获取

我们首先要在新窗口打开 B 站并登录,ipc 什么的就不说了,当我们关闭窗口时,我们可以得到 cookie:

TYPESCRIPT
1win2.on('close', () => {
2        win2?.webContents.session.cookies.get({url: 'https://www.bilibili.com'}).then((cookies) => {
3            // 这里拿到 cookie 了,就可以做一些事情了(嘿嘿)
4            COOKIES = cookies;
5        });
6    })

为了带着 cookie 请求,我们可以用 fetch-cookie 这个库:

TYPESCRIPT
1import fetchCookie from 'fetch-cookie';
2
3const fetchWithCookies = fetchCookie(fetch);

如何确认用户登录成功了呢?我们只要请求用户的信息看到能不能拿到就行了呗,比如主页 navbar 的头像昵称:

[GET] https://api.bilibili.com/x/web-interface/nav

TYPESCRIPT
1// 窗口关闭时尝试获取用户信息,并发送给渲染进程
2    win2.on('closed', async () => {
3        try {
4            const response = await fetchWithCookies("https://api.bilibili.com/x/web-interface/nav", {
5                headers: {
6                    'Cookie': COOKIES.map((cookie: Cookie) => `${cookie.name}=${cookie.value}`).join('; ')
7                }
8            });
9
10            const data = await response.json();
11            if (data.code !== 0) {
12                console.error(data);
13                win1?.webContents.send('user-info', null);
14                return;
15            }
16            win1?.webContents.send('user-info', data.data);
17            uid = data.data.mid;
18        } catch (error) {
19            console.error('Error fetching user info:', error);
20            win1?.webContents.send('user-info', null);
21        }
22    });

获取服务器信息

这里比较简单,发两个请求就行了,带好 cookie:

TYPESCRIPT
1// 获取真实房间号
2export async function getRoomId(shortId: string): Promise<number> {
3    const response = await fetch(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${shortId}`);
4    const data = await response.json() as RoomInitResponse;
5    console.log("获取真实房间号", data.data.room_id);
6    return data.data.room_id;
7}
8
9// 获取消息流服务器和密钥
10export async function getDanmuInfo(roomId: number): Promise<DanmuInfoResponse['data']> {
11    // 这里请求的时候需要带上全部的 cookie,否则拿到的 key 无法登录使用(!小坑)
12    const response = await fetchWithCookies(`https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=${roomId}`, {
13        headers: {
14            'Cookie': getCookies().map((cookie: Cookie) => `${cookie.name}=${cookie.value}`).join('; ')
15        }
16    });
17    const data = await response.json() as DanmuInfoResponse;
18    // console.log("获取消息流服务器和密钥", data.data);
19    return data.data;
20}

连接 ws 服务器

拿到信息然后就用 ws 库连接,记得带一个 UA(应该这个库才可以带)

TYPESCRIPT
1const roomId = await getRoomId(shortId);
2    const danmuInfo = await getDanmuInfo(roomId);
3    console.log("socket服务器地址", `wss://${danmuInfo.host_list[0].host}:${danmuInfo.host_list[0].wss_port}/sub`);
4
5    // 为 ws 设置 userAgent
6    const ws = new WebSocket(`wss://${danmuInfo.host_list[0].host}:${danmuInfo.host_list[0].wss_port}/sub`, {
7        headers: {
8            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
9        }
10    });
11
12    ws.on('open', () => {
13        console.log('WebSocket 连接成功');
14        sendCertificate(ws, roomId, danmuInfo.token, getUid(), getBuvid3());
15    });
16
17    ws.on('message', (data) => {
18        handleWebSocketMessages(ws, data);
19    });

发送鉴权包

这里的 uid 可以从之前获取的用户信息中拿到,buvid 从 cookie 拿到,key 是之前拿到的 token

TYPESCRIPT
1// 生成鉴权包
2function generateCertificate(roomId: number, token: string, _uid: number, buvid: string): Buffer {
3    // console.log("生成鉴权包", roomId, token, uid, buvid);
4    const headerLength = 16;
5    const protocol = 1;
6    const type = 7;
7    const sequence = 2;
8    const body = JSON.stringify({
9        uid: _uid,
10        roomid: roomId,
11        protover: 2, // 这里协议版本一定要是 2,否则无法解析数据!哭,3 还不行
12        buvid: buvid,
13        platform: 'web',
14        type: 2,
15        key: token,
16    });
17
18    // console.log("生成鉴权包", body);
19
20    const bodyBuffer = Buffer.from(body);
21    const headerBuffer = Buffer.alloc(headerLength);
22    headerBuffer.writeUInt32BE(headerLength + bodyBuffer.length, 0);
23    headerBuffer.writeUInt16BE(headerLength, 4);
24    headerBuffer.writeUInt16BE(protocol, 6);
25    headerBuffer.writeUInt32BE(type, 8);
26    headerBuffer.writeUInt32BE(sequence, 12);
27
28    return Buffer.concat([headerBuffer, bodyBuffer]);
29}

心跳包类似实现一下就好。

解析数据

为了方便进程间通信,我加了 EventEmitter,然后按照我们之前的分析就可以解析到数据啦

TYPESCRIPT
1class DanmuExtractor extends EventEmitter {
2    async getDmMsg(data: Buffer) {
3        // console.log(data.toString('hex'));
4        // 获取数据包长度,协议类型和操作类型
5        const packetLen = parseInt(data.slice(0, 4).toString('hex'), 16);
6        const proto = parseInt(data.slice(6, 8).toString('hex'), 16);
7        const op = parseInt(data.slice(8, 12).toString('hex'), 16);
8
9        // 若数据包是连着的,则根据第一个数据包的长度进行切分
10        if (data.length > packetLen) {
11            this.getDmMsg(data.slice(packetLen));
12            data = data.slice(0, packetLen);
13        }
14
15        // 判断协议类型,若为 2 则用 zlib 解压
16        if (proto === 2) {
17            // console.log("解压数据");
18            data = zlib.inflateSync(data.slice(16));
19            this.getDmMsg(data);
20            return;
21        }
22
23        if (op === 3) {
24            console.info("HeartBeat");
25        }
26
27        // 判断消息类型
28        if (op === 5) {
29            try {
30                // 解析 json
31                // console.log("解析数据");
32                const content = JSON.parse(data.slice(16).toString());
33                // 发送数据
34                this.emit('MsgData', content);
35            } catch (e) {
36                console.error(`[GETDATA ERROR]: ${e}`);
37            }
38        }
39    }
40}
41
42export default DanmuExtractor;

补充

其他就是我的业务逻辑了,比如关键词匹配进入队列,比如用 shadcn 画一个勉强能看的 UI,然后随机数出队。

其中...开发还是挺恶心的,解析方法,请求数据,还有分包,每一个都卡了好久,这个是研究好久得到的成品了,如果对你有帮助,恳请能到 github 上给个 star,我后期有时间会进一步完善更多信息和操作。

截图效果

小结

这里就是一个简单的弹幕获取流程,原理就是模仿用户操作,拿到数据并解析

相关推荐

使用 BeautifulSoup 配合请求库实现简单的爬虫程序

事情起源于课内的课程实验作业...因为要求要用爬虫,~~不必说课内讲的一言难尽,更不必说就算讲了我也...

grtsinry43
1/7/2025
67
0
0

用一个月的时间写一个自己的博客系统——Grtblog的技术介绍

终于,历时一个多月的开发 ~~bug~~ 和测试,这个目前问题很多很不成熟很难用的系统终于上线了.....

grtsinry43
12/14/2024
117
4
0

学习分享|Kubernetes快速上手,快速了解及开始使用

最近感觉 K8s 挺火的,也许是时下比较热门的技术栈了,学习过程比较曲折复杂,分享一下自己的学习...

grtsinry43
6/28/2024
10
0
0

学习分享|Vue3项目中使用微信SDK开发微信网页

最近在开发一个微信 H5 的项目,采用的是 Vue3+FastAPI,正好学习下微信用户登录,a...

grtsinry43
7/3/2024
161
0
0

学习分享|跨域解决、安卓开发探索、油猴脚本探索

最近学习的一些内容,包括跨域问题及其解决方案,安卓开发的简单探索,OpenAI的api做了个小插...

grtsinry43
6/10/2024
35
0
0
COMMENT 7285922556752826368

发表评论

登录之后评论体验更好哦 ~
支持 Markdown 语法 0 / 3000

在风雨飘摇之中

本站已运行了

一路走来,感谢陪伴与支持

愿我们不负热爱,继续前行

全站通知
更新通知