@xyself/blivedm-js
Version:
B站直播弹幕库的Node.js实现 (CommonJS版本)
456 lines (399 loc) • 15.4 kB
JavaScript
const WebSocket = require('isomorphic-ws');
const pako = require('pako');
const axios = require('axios');
const webModels = require('../models/web.js');
const brotli = require('brotli');
const zlib = require('zlib');
const HEADER_SIZE = 16;
const WS_BODY_PROTOCOL_VERSION_NORMAL = 0;
const WS_BODY_PROTOCOL_VERSION_INT = 1;
const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2;
const WS_PACKAGE_HEADER_TOTAL_LENGTH = 0;
const WS_PACKAGE_HEADER_LENGTH = 16;
const WS_HEADER_OFFSET = 4;
const WS_VERSION_OFFSET = 6;
const WS_OPERATION_OFFSET = 8;
const WS_SEQUENCE_OFFSET = 12;
const WS_AUTH = 7;
const WS_AUTH_REPLY = 8;
const WS_HEARTBEAT = 2;
const WS_HEARTBEAT_REPLY = 3;
const WS_MESSAGE = 5;
const WS_POPULAR = 3;
// 默认WebSocket服务器
const DEFAULT_WS_INFO = {
host: 'broadcastlv.chat.bilibili.com',
wss_port: 443,
ws_port: 2244
};
class BLiveClient {
constructor(roomId, { sessData = '', ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' } = {}) {
this.roomId = roomId;
this.sessData = sessData;
this.ua = ua;
this._websocket = null;
this._heartbeatTimer = null;
this._handler = null;
this._closed = false;
this._uid = 0;
this._roomOwnerUid = 0;
this._hostServerList = [];
this._token = '';
this._currentServer = null;
this._buvid = 'XY418E4B2B9432344C7B9785A3FA0D3809C3'; // 使用一个更真实的buvid格式
}
async start() {
if (this._websocket) {
return;
}
await this._initRoom();
await this._connectWebSocket();
this._heartbeatTimer = setInterval(() => this._sendHeartbeat(), 30000);
if (this._handler) {
this._handler.on_client_start(this);
}
}
stop() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
if (this._websocket) {
this._websocket.close();
this._websocket = null;
}
this._closed = true;
if (this._handler) {
this._handler.on_client_stop(this);
}
}
set_handler(handler) {
this._handler = handler;
}
async _initRoom() {
const headers = {
'User-Agent': this.ua,
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Origin': 'https://live.bilibili.com',
'Referer': `https://live.bilibili.com/${this.roomId}`,
'Cookie': `SESSDATA=${this.sessData}; buvid3=${this._buvid}`
};
try {
const resp = await axios.get(`https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_id=${this.roomId}`, {
headers
});
const data = resp.data;
if (data.code !== 0) {
throw new Error(`获取房间信息失败: ${data.message}`);
}
this._roomOwnerUid = data.data.room_info.uid;
if (this.sessData) {
try {
const userResp = await axios.get('https://api.live.bilibili.com/xlive/web-ucenter/user/get_user_info', {
headers
});
if (userResp.data.code === 0) {
this._uid = userResp.data.data.uid || 0;
}
} catch (e) {
// 忽略错误
}
}
const danmuInfoResp = await axios.get(`https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=${this.roomId}`, {
headers
});
if (danmuInfoResp.data.code !== 0) {
this._hostServerList = [DEFAULT_WS_INFO];
return;
}
const hostList = danmuInfoResp.data.data.host_list;
if (!hostList || hostList.length === 0) {
this._hostServerList = [DEFAULT_WS_INFO];
return;
}
this._hostServerList = hostList;
this._token = danmuInfoResp.data.data.token;
} catch (err) {
throw err;
}
}
async _connectWebSocket() {
return new Promise((resolve, reject) => {
this._currentServer = this._hostServerList[Math.floor(Math.random() * this._hostServerList.length)];
const wsUrl = `wss://${this._currentServer.host}:${this._currentServer.wss_port}/sub?platform=web&clientver=2.0.11&type=2&key=${this._token}`;
this._ws = new WebSocket(wsUrl);
this._ws.binaryType = 'arraybuffer';
this._ws.onopen = () => {
this._sendAuth();
resolve();
};
this._ws.onmessage = (ev) => {
this._onMessage(ev.data);
};
this._ws.onclose = (ev) => {
this._onClose(ev.code, ev.reason);
};
this._ws.onerror = (ev) => {
this._onError(ev);
reject(ev);
};
});
}
_sendHeartbeat() {
this._send(2, '');
}
_onMessage(data) {
const buffer = Buffer.from(data);
let offset = 0;
while (offset < buffer.length) {
const packetLen = buffer.readInt32BE(offset + 0);
const headerLen = buffer.readInt16BE(offset + 4);
const ver = buffer.readInt16BE(offset + 6);
const op = buffer.readInt32BE(offset + 8);
const seq = buffer.readInt32BE(offset + 12);
let body = buffer.slice(offset + headerLen, offset + packetLen);
if (ver === 2) {
try {
body = zlib.inflateSync(body);
this._onMessage(body);
} catch (e) {
// 忽略错误
}
} else if (ver === 3) {
try {
body = Buffer.from(brotli.decompress(body));
this._onMessage(body);
} catch (e) {
// 忽略错误
}
} else {
if (op === 3) {
const popularity = body.readInt32BE(0);
if (this._handler) {
this._handler._on_heartbeat(this, {
popularity
});
}
} else if (op === 8) {
try {
const authResp = JSON.parse(body.toString());
if (authResp.code === 0) {
this._startHeartbeat();
} else {
this._ws.close();
}
} catch (e) {
// 忽略错误
}
} else if (op === 5) {
try {
const notification = JSON.parse(body.toString());
if (this._handler) {
this._handleCommand(notification);
}
} catch (e) {
// 忽略错误
}
}
}
offset += packetLen;
}
}
_send(op, body) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
return;
}
const bodyBuffer = Buffer.from(body);
const headerBuffer = Buffer.alloc(16);
const packetLen = bodyBuffer.length + 16;
headerBuffer.writeInt32BE(packetLen, 0);
headerBuffer.writeInt16BE(16, 4);
headerBuffer.writeInt16BE(1, 6);
headerBuffer.writeInt32BE(op, 8);
headerBuffer.writeInt32BE(1, 12);
const packet = Buffer.concat([headerBuffer, bodyBuffer]);
this._ws.send(packet);
}
_sendAuth() {
const authParams = {
uid: this._uid,
roomid: this.roomId,
protover: 3,
buvid: this._buvid,
platform: 'web',
type: 2,
key: this._token,
platform_version: '2.0.11',
clientver: '2.0.11',
build: 7734200,
device_platform: 'web',
device_id: this._buvid,
version: '2.0.11',
web_display_mode: 1,
ua: this.ua,
device: 'web',
device_name: 'Chrome',
device_version: '122.0.0.0'
};
const body = JSON.stringify(authParams);
this._send(7, body);
}
_onClose(code, reason) {
this._stopHeartbeat();
this._websocket = null;
this.stop();
}
_onError(error) {
this.stop();
}
_handleMessage(buffer) {
try {
const headerLength = 16;
const packetLength = buffer.readInt32BE(0);
const protoVer = buffer.readInt16BE(6);
const operation = buffer.readInt32BE(8);
let body = buffer.slice(headerLength);
if (operation === 3) { // 心跳包回应
const popularity = body.readInt32BE(0);
this._handleHeartbeatResponse(popularity);
return;
}
if (operation === 8) { // 认证响应
const response = JSON.parse(body.toString('utf8'));
if (response.code === 0) {
this._startHeartbeat();
} else {
this._handleError(new Error(`认证失败: ${response.message}`));
}
return;
}
if (protoVer === 2) { // zlib压缩
body = pako.inflate(body);
} else if (protoVer === 3) { // brotli压缩
body = Buffer.from(brotli.decompress(body));
}
if (body.length > 0) {
const bodyText = body.toString('utf8');
if (bodyText) {
try {
const data = JSON.parse(bodyText);
this._handleCommand(data);
} catch (e) {
console.error('解析消息内容失败:', e);
}
}
}
} catch (error) {
console.error('处理消息失败:', error);
}
}
_handleCommand(command) {
if (!this._handler) {
return;
}
try {
switch (command.cmd) {
case 'DANMU_MSG':
// 弹幕消息
this._handler._on_danmaku?.(this, new webModels.DanmakuMessage(command));
break;
case 'SEND_GIFT':
// 礼物消息
this._handler._on_gift?.(this, new webModels.GiftMessage(command));
break;
case 'GUARD_BUY':
// 上舰消息
this._handler._on_buy_guard?.(this, new webModels.GuardBuyMessage(command));
break;
case 'SUPER_CHAT_MESSAGE':
// 醒目留言(SC)
this._handler._on_super_chat?.(this, new webModels.SuperChatMessage(command));
break;
case 'LIKE_INFO_V3_UPDATE':
// 点赞信息更新
this._handler._on_like?.(this, new webModels.LikeInfoV3UpdateMessage(command));
break;
case 'LIKE_INFO_V3_CLICK':
// 用户点赞
this._handler._on_like_click?.(this, new webModels.LikeClickMessage(command));
break;
case 'INTERACT_WORD':
// 用户进入直播间
this._handler._on_interact_word?.(this, new webModels.InteractWordMessage(command));
break;
case 'WATCHED_CHANGE':
// 观看人数变化
this._handler._on_watched_change?.(this, new webModels.WatchedChangeMessage(command));
break;
case 'ONLINE_RANK_COUNT':
// 在线排名计数
this._handler._on_online_rank_count?.(this, new webModels.OnlineRankCountMessage(command));
break;
case 'ONLINE_RANK_V2':
// 在线排名详情
this._handler._on_online_rank_v2?.(this, new webModels.OnlineRankV2Message(command));
break;
case 'STOP_LIVE_ROOM_LIST':
// 停播房间列表
this._handler._on_stop_live_room_list?.(this, new webModels.StopLiveRoomListMessage(command));
break;
case 'ENTRY_EFFECT':
// 进入特效
this._handler._on_entry_effect?.(this, new webModels.EntryEffectMessage(command));
break;
case 'DM_INTERACTION':
// 连续点赞消息
this._handler._on_dm_interaction?.(this, command);
break;
case 'WIDGET_BANNER':
// 横幅组件消息
this._handler._on_widget_banner?.(this, new webModels.WidgetBannerMessage(command));
break;
case 'NOTICE_MSG':
// 系统通知消息
this._handler._on_notice_msg?.(this, new webModels.NoticeMsgMessage(command));
break;
default:
// 其他消息
console.log('未处理的消息类型:', command.cmd);
break;
}
} catch (err) {
console.error('处理消息失败:', err);
}
}
_handleError(error) {
console.error('WebSocket错误:', error);
this.stop();
}
_handleClose() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
this.stop();
}
_startHeartbeat() {
this._sendHeartbeat();
this._heartbeatTimer = setInterval(() => {
this._sendHeartbeat();
}, 30000);
}
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
_handleHeartbeatResponse(popularity) {
if (this._handler) {
this._handler._on_heartbeat(this, new webModels.HeartbeatMessage({
popularity
}));
}
}
}
module.exports = {
BLiveClient
};