Grtsinry43的前端札记 | 大三技术成长实录 & 学习笔记 | 「岁月漫长,值得等待」
文章
折腾不止

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

2025年1月17日 12 分钟阅读 浏览 0 喜欢 0 评论 0

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

:::info{title=“提示”} 这个方法不知道以后会不会再被限制,不过其原理上是完全模拟用户操作,与真实用户使用浏览器相同。 :::

[!NOTE] 提示 本项目已经开源在 Github 上:Bili-LiveLuckDraw

原理

弹幕服务器

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

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

我们接下来一步一步解决

房间号

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

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

返回内容是这样的:

json
{
    "code": 0,
    "msg": "ok",
    "message": "ok",
    "data": {
        "room_id": long_id,
        "short_id": 0,
        "uid": user_id,
        "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": 1735877407,
        "room_shield": 0,
        "is_sp": 0,
        "special_type": 0
    }
}

弹幕服务器地址和密钥

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

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

返回内容是这样的:

json
{
    "code": 0,
    "message": "0",
    "ttl": 1,
    "data": {
        "group": "live",
        "business_id": 0,
        "refresh_row_factor": 0.125,
        "refresh_rate": 100,
        "max_delay": 5000,
        "token": "token",
        "host_list": [
            {
                "host": "zj-cn-live-comet.chat.bilibili.com",
                "port": 2243,
                "wss_port": 2245,
                "ws_port": 2244
            },
            ...
        ]
    }
}

[!NOTE] 提示

问题来了,这里拿到的 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
// 生成心跳包
function generateHeartbeat(): Buffer {
    const headerLength = 16;
    const protocol = 1;
    const type = 2;
    const sequence = 2;
    // 好小众的内容格式(
    const body = '[Object object]';

    const bodyBuffer = Buffer.from(body);
    const headerBuffer = Buffer.alloc(headerLength);
    headerBuffer.writeUInt32BE(headerLength + bodyBuffer.length, 0);
    headerBuffer.writeUInt16BE(headerLength, 4);
    headerBuffer.writeUInt16BE(protocol, 6);
    headerBuffer.writeUInt32BE(type, 8);
    headerBuffer.writeUInt32BE(sequence, 12);

    return Buffer.concat([headerBuffer, bodyBuffer]);
}

数据包

[!SUCCESS] 诶嘿

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

我们拿到的数据根据其 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
win2.on('close', () => {
        win2?.webContents.session.cookies.get({url: 'https://www.bilibili.com'}).then((cookies) => {
            // 这里拿到 cookie 了,就可以做一些事情了(嘿嘿)
            COOKIES = cookies;
        });
    })

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

typescript
import fetchCookie from 'fetch-cookie';

const fetchWithCookies = fetchCookie(fetch);

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

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

typescript
// 窗口关闭时尝试获取用户信息,并发送给渲染进程
    win2.on('closed', async () => {
        try {
            const response = await fetchWithCookies("https://api.bilibili.com/x/web-interface/nav", {
                headers: {
                    'Cookie': COOKIES.map((cookie: Cookie) => `${cookie.name}=${cookie.value}`).join('; ')
                }
            });

            const data = await response.json();
            if (data.code !== 0) {
                console.error(data);
                win1?.webContents.send('user-info', null);
                return;
            }
            win1?.webContents.send('user-info', data.data);
            uid = data.data.mid;
        } catch (error) {
            console.error('Error fetching user info:', error);
            win1?.webContents.send('user-info', null);
        }
    });

获取服务器信息

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

typescript
// 获取真实房间号
export async function getRoomId(shortId: string): Promise<number> {
    const response = await fetch(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${shortId}`);
    const data = await response.json() as RoomInitResponse;
    console.log("获取真实房间号", data.data.room_id);
    return data.data.room_id;
}

// 获取消息流服务器和密钥
export async function getDanmuInfo(roomId: number): Promise<DanmuInfoResponse['data']> {
    // 这里请求的时候需要带上全部的 cookie,否则拿到的 key 无法登录使用(!小坑)
    const response = await fetchWithCookies(`https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=${roomId}`, {
        headers: {
            'Cookie': getCookies().map((cookie: Cookie) => `${cookie.name}=${cookie.value}`).join('; ')
        }
    });
    const data = await response.json() as DanmuInfoResponse;
    // console.log("获取消息流服务器和密钥", data.data);
    return data.data;
}

连接 ws 服务器

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

typescript
const roomId = await getRoomId(shortId);
    const danmuInfo = await getDanmuInfo(roomId);
    console.log("socket服务器地址", `wss://${danmuInfo.host_list[0].host}:${danmuInfo.host_list[0].wss_port}/sub`);

    // 为 ws 设置 userAgent
    const ws = new WebSocket(`wss://${danmuInfo.host_list[0].host}:${danmuInfo.host_list[0].wss_port}/sub`, {
        headers: {
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
        }
    });

    ws.on('open', () => {
        console.log('WebSocket 连接成功');
        sendCertificate(ws, roomId, danmuInfo.token, getUid(), getBuvid3());
    });

    ws.on('message', (data) => {
        handleWebSocketMessages(ws, data);
    });

发送鉴权包

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

typescript
// 生成鉴权包
function generateCertificate(roomId: number, token: string, _uid: number, buvid: string): Buffer {
    // console.log("生成鉴权包", roomId, token, uid, buvid);
    const headerLength = 16;
    const protocol = 1;
    const type = 7;
    const sequence = 2;
    const body = JSON.stringify({
        uid: _uid,
        roomid: roomId,
        protover: 2, // 这里协议版本一定要是 2,否则无法解析数据!哭,3 还不行
        buvid: buvid,
        platform: 'web',
        type: 2,
        key: token,
    });

    // console.log("生成鉴权包", body);

    const bodyBuffer = Buffer.from(body);
    const headerBuffer = Buffer.alloc(headerLength);
    headerBuffer.writeUInt32BE(headerLength + bodyBuffer.length, 0);
    headerBuffer.writeUInt16BE(headerLength, 4);
    headerBuffer.writeUInt16BE(protocol, 6);
    headerBuffer.writeUInt32BE(type, 8);
    headerBuffer.writeUInt32BE(sequence, 12);

    return Buffer.concat([headerBuffer, bodyBuffer]);
}

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

解析数据

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

typescript
class DanmuExtractor extends EventEmitter {
    async getDmMsg(data: Buffer) {
        // console.log(data.toString('hex'));
        // 获取数据包长度,协议类型和操作类型
        const packetLen = parseInt(data.slice(0, 4).toString('hex'), 16);
        const proto = parseInt(data.slice(6, 8).toString('hex'), 16);
        const op = parseInt(data.slice(8, 12).toString('hex'), 16);

        // 若数据包是连着的,则根据第一个数据包的长度进行切分
        if (data.length > packetLen) {
            this.getDmMsg(data.slice(packetLen));
            data = data.slice(0, packetLen);
        }

        // 判断协议类型,若为 2 则用 zlib 解压
        if (proto === 2) {
            // console.log("解压数据");
            data = zlib.inflateSync(data.slice(16));
            this.getDmMsg(data);
            return;
        }

        if (op === 3) {
            console.info("HeartBeat");
        }

        // 判断消息类型
        if (op === 5) {
            try {
                // 解析 json
                // console.log("解析数据");
                const content = JSON.parse(data.slice(16).toString());
                // 发送数据
                this.emit('MsgData', content);
            } catch (e) {
                console.error(`[GETDATA ERROR]: ${e}`);
            }
        }
    }
}

export default DanmuExtractor;

补充

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

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

截图效果

小结

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

分享此文
评论区在赶来的路上...