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

原文：https://blog.grtsinry43.com/posts/bilibili-danmaku-ws/

作者：grtsinry43

发布时间：2025-01-17T07:35:03Z

内容更新时间：2025-01-17T07:35:03Z

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

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


> [!NOTE] 提示
> 本项目已经开源在 Github 上：[Bili-LiveLuckDraw](https://github.com/grtsinry43/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](https://blog.grtsinry43.com/uploads/2025/01/17/_20250117_142922.png_a1b99dfb-0036-48bc-a833-1d7e455bd2d3.png)
>
> 在这之后，你也会发现，诶嘿，拿到的弹幕服务器也变多了！

### 通知数据格式

#### 鉴权包

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

##### 包头部分

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

| 字段名       | 描述                             | 数据类型 | 大小   | 代码解释                                                     |
| ------------ | -------------------------------- | -------- | ------ | ------------------------------------------------------------ |
| **包总长度** | 数据包的总长度（包括包头和包体） | `UInt32` | 4 字节 | `headerBuffer.writeUInt32BE(headerLength + bodyBuffer.length, 0)` |
| **包头长度** | 包头的长度                       | `UInt16` | 2 字节 | `headerBuffer.writeUInt16BE(headerLength, 4)`                |
| **协议版本** | 协议版本号                       | `UInt16` | 2 字节 | `headerBuffer.writeUInt16BE(protocol, 6)`                    |
| **数据类型** | 数据包类型                       | `UInt32` | 4 字节 | `headerBuffer.writeUInt32BE(type, 8)`                        |
| **序列号**   | 序列号，用于标识该请求包的唯一性 | `UInt32` | 4 字节 | `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，我后期有时间会进一步完善更多信息和操作。

![截图效果](https://blog.grtsinry43.com/uploads/2025/01/17/398605547-ad5d7713-c495-4fc7-9260-58a6959e216a.png_f28dc8c9-9616-40f7-95c5-2d60820afef8.png)

## 小结

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