哔哩哔哩websocket协议逆向--制作一个属于自己的弹幕姬

Websocket是什么

在一般的爬虫中都是用到Get Post来请求,这些请求方式都是无状态的服务端无法主动向客户端发送数据。

而websocket的出现就解决了这个问题 它是有状态的请求,这就意味着它允许服务端主动向客户端推送数据,使得客户端和服务端之间的数据交换变得更加简单。

Websocket有哪些方法、属性 --- 知己知彼方能百战百胜

方法

名称

作用

close[code[, reason]]

关闭当前连接

send(data)

发送数据(排队)

属性

名称

作用

binaryType

使用二进制的数据类型连接

wbufferedAmount

没发送到服务器的字节数

extensions

服务器选择的扩展

onclose

指定关闭后的回调函数

onerror

指定失败后的回调函数

onmessage

指定从服务器接收到信息时的回调函数

onopen

指定连接成功后的回调函数

protocol

服务器选择的下属协议

readyState

当前服务器的链接状态

url

Websocket的绝对路径

其中爬虫主要关注 send 方法的调用 和 onmessage 所绑定的回调函数。

因为服务器推送的数据第一时间会调用onmessage绑定的函数 可能进行 解码 或者 解密 等操作;

而 send 方法则是我们要发送的心跳包 或者 进房数据等 与 服务端进行连接 和 保持连接 的时候会用到。

逆向过程

进房数据

随便找一个直播间点进去,打开开发者工具 (基操)

可以在过滤一栏选择ws然后刷新网页获得ws请求的数据

这里可以看到 请求的数据有两个 其中sub是二进制数据 看不懂 但是在另一个包里 虽然是明文数据但是没有我们要的弹幕数据 进房数据等 所以铁定是sub这个包

然后从启动器中 直接定位到创建Websocket对象的位置并打下断点刷新

网站停在了创建Websocket对象的位置

这个时候可以写一段js代码对send方法进行hook

// 这里一定要让 ws对象被创建以后再HOOK
// 用send_来保留原函数
this.ws.send_ = this.ws.send
// 重写原函数
this.ws.send = function(t) { // 因为send方法有个必传的参数data所以这里也要
    debugger;
    return this.ws.send_(t);
}

然后继续执行send方法被调用的时候会被 hook断下来 断下来以后往上面一个栈看

断在了30167行 但是很明显 t是在30163行的时候被赋值的

在 30163 行下断点然后刷新网页

停下来以后 不难发现 i 就是进房数据

o.a.WS_OP_USER_AUTHENTICATION 根据名字来推断 可能是拿来确认我们发的是什么包

进入函数中细看

其中 r.a.getEncoder() 是 TextEncoder Object 的UTF8

o.a.WS_PACKAGE_HEADER_TOTAL_LENGTH 是一个常量 数值为 16 根据名字推断是头包长度

o.a.WS_PACKAGE_OFFSET 为 0 是用来偏移的 0 就是不偏移

随后 定义了一个 i 变量 是个视窗用来放编码后的数据

定义一个 s 变量用来存放 t(进房数据) UTF8编码后的数据

随后的 this.wsBinaryHeaderList 是一个列表

    [{
        "name": "Header Length",
        "key": "headerLen",
        "bytes": 2,
        "offset": 4,
        "value": 16
    },
    {
        "name": "Protocol Version",
        "key": "ver",
        "bytes": 2,
        "offset": 6,
        "value": 1
    },
    {
        "name": "Operation",
        "key": "op",
        "bytes": 4,
        "offset": 8,
        "value": 1
    },
    {
        "name": "Sequence Id",
        "key": "seq",
        "bytes": 4,
        "offset": 12,
        "value": 1
    }]

使用了forEach对里头的每个元素都进行了处理

根据列表中的内容 还有处理的函数 可以知道包的样子

头包长度也就固定为int32+int16+int16+int32+int32 也就是 16字节 这也说明了为什么 o.a.WS_PACKAGE_HEADER_TOTAL_LENGTH 的值是 16

然后进房数据 头包长度 协议版本 操作类型 都是固定的 所以需要更改的 也只有数据包长度

def encode(roomid):
    a = '{"roomid":' + str(roomid) + '}'
    data = []
    for s in a:
        data.append(ord(s))
    return "000000{}001000010000000700000001".format(hex(16 + len(data))[2:]) + "".join(
        map(lambda x: x[2:], map(hex, data)))

弹幕、礼物等消息

这里刷新网页回到Websocket对象被new的地方 因为send关注完了 就要关注onmessage了

因为 onmessage所指定回调函数会在message事件被触发的时候调用

这次直接将断点下在指定回调函数的地方

再次刷新调试后 发现data只在一个地方用 调试后也确实得到了能看懂的json数据

点进去查看

初步分析来看 函数中用到了很多的 getInt32 等操作提取指定的数据 可以初步判定为TLV

TLV:

T 可以理解为 tag type 是用于标识编码格式信息

L 就是Length 表示数值长度

V 就是实际数值

一些常量:

  • o.a.WS_PACKAGE_OFFSET = 0 // 偏移量 0就是无偏移

  • o.a.WS_OP_MESSAGE = 5 // Message数据的op 其中这个op就表示这个数据包什么类型的

  • o.a.WS_OP_CONNECT_SUCCESS = 8 // 连接成功的 op

  • o.a.WS_OP_HEARTBEAT_REPLY = 3 // 心跳回复 op

  • o.a.WS_PACKAGE_HEADER_TOTAL_LENGTH = 16 // 包头种长度 其中包头会包含关于这个包的各种数据 比如这个包的长度 这个包有哪些类型

  • o.a.WS_BODY_PROTOCOL_VERSION_NORMAL = 0 // 普通消息

  • o.a.WS_BODY_PROTOCOL_VERSION_BROTLI = 3 // 压缩消息

t.prototype.convertToObject = function(t) {
    var e = new DataView(t) // 新建视图 为ArrayBuffer添加接口 方便后续处理
      , n = {
        body: []
    }; // 初始化 n 因为一次传来的数据不会是一个 会是很多个所以要用List来接收处理好的数据 最后的数据也会放在body里面


    if (n.packetLen = e.getInt32(o.a.WS_PACKAGE_OFFSET), // e.getInt32的作用是:
                                                        //   从字节开始偏移o.a.WS_PACKAGE_OFFSET个字节 读取一个int32的数
                                                       //     这里可以推断出前16个字节的头包里第一个int32数是表示包的长度
    
    this.wsBinaryHeaderList.forEach(function(t) {
        4 === t.bytes ? n[t.key] = e.getInt32(t.offset) : 2 === t.bytes && (n[t.key] = e.getInt16(t.offset))
    }),

    n.packetLen < t.byteLength && this.convertToObject(t.slice(0, n.packetLen)), 
    this.decoder || (this.decoder = r.a.getDecoder()),

    !n.op || o.a.WS_OP_MESSAGE !== n.op && n.op !== o.a.WS_OP_CONNECT_SUCCESS) // 这里是整个if语句的重点
    /*
    经过上面一堆的处理以后
    !n.op 是在n没有op属性 或者op属性为0的时候为真
    o.a.WS_OP_MESSAGE !== n.op op不是message的op的时候为真
    op 不是连接成功的op时为真
    */

        n.op && o.a.WS_OP_HEARTBEAT_REPLY === n.op && (n.body = {
            count: e.getInt32(o.a.WS_PACKAGE_HEADER_TOTAL_LENGTH)
        });

    // 这里可以分析出 主要的弹幕 礼物等消息的操作是在else的语句里执行的
    else
        for (var i = o.a.WS_PACKAGE_OFFSET, s = n.packetLen, a = "", u = ""; i < t.byteLength; i += s) {
            s = e.getInt32(i),
            a = e.getInt16(i + o.a.WS_HEADER_OFFSET);
            try {
                if (n.ver === o.a.WS_BODY_PROTOCOL_VERSION_NORMAL) {
                    var c = this.decoder.decode(t.slice(i + a, i + s));
                    u = 0 !== c.length ? JSON.parse(c) : null
                } else if (n.ver === o.a.WS_BODY_PROTOCOL_VERSION_BROTLI) {
                    var l = t.slice(i + a, i + s)
                      , h = window.BrotliDecode(new Uint8Array(l)); // zlib解压数据
                    u = this.convertToObject(h.buffer).body
                }
                u && n.body.push(u)
            } catch (e) {
                this.options.onLogger("decode body error:", new Uint8Array(t), n, e)
            }
        }
    return n
}

else里的语句翻译成python大概就是

def decode(data):
    packetLen = int(data[:4].hex(), 16)
    ver = int(data[6:8].hex(), 16)
    op = int(data[8:12].hex(), 16)

    if len(data) > packetLen:
        decode(data[packetLen:])
        data = data[:packetLen]

    if ver == 2:
        data = zlib.decompress(data[16:])
        decode(data)
        return

    if op == 5:
        try:
            jd = json.loads(data[16:].decode('utf-8', errors='ignore'))
            print(jd)
        except Exception as e:
            pass

至此逆向完成

完整代码(Python)

import asyncio
import json
import zlib

import websockets


async def onmessage(ws):
    while True:
        greeting = await ws.recv()
        decode(greeting)


async def sendHB(ws):
    while True:
        await asyncio.sleep(30)
        await ws.send(bytes.fromhex(hb))


def decode(data):
    packetLen = int(data[:4].hex(), 16)
    ver = int(data[6:8].hex(), 16)
    op = int(data[8:12].hex(), 16)

    if len(data) > packetLen: # 防止
        decode(data[packetLen:])
        data = data[:packetLen]

    if ver == 2:
        data = zlib.decompress(data[16:])
        decode(data)
        return

    if op == 5:
        try:
            jd = json.loads(data[16:].decode('utf-8', errors='ignore'))
            if jd['cmd'] == 'DANMU_MSG': # 普通弹幕消息
                print(jd['info'][2][1], ': ', jd['info'][1])
            elif jd['cmd'] == 'SUPER_CHAT_MESSAGE_JPN': # sc醒目提醒
                print(jd["data"]["user_info"]["uname"], ":", jd["data"]["message"])
        except Exception as e:
            pass


def encode(roomid):
    a = '{"roomid":' + str(roomid) + '}'
    data = []
    for s in a:
        data.append(ord(s))
    return "000000{}001000010000000700000001".format(hex(16 + len(data))[2:]) + "".join(
        map(lambda x: x[2:], map(hex, data)))


async def main():
    roomid = # 房间号
    uri = "wss://broadcastlv.chat.bilibili.com/sub"
    data_raw = bytes.fromhex(encode(roomid))
    async with websockets.connect(uri) as websocket:
        await websocket.send(data_raw)

        tasks = [asyncio.create_task(sendHB(websocket)), asyncio.create_task(onmessage(websocket))]
        await asyncio.gather(*tasks)


if __name__ == '__main__':
    hb = "00000010001000010000000200000001"
    asyncio.run(main())

消息类型

cmd

说明

DANMU_MSG

弹幕消息

WELCOME_GUARD

欢迎...老爷

ENTRY_EFFECT

舰长欢迎消息

WELCOME

欢迎...进入房间

SUPER_CHAT_MESSAGE

醒目留言

SEND_GIFT

投喂礼物

COMBO_SEND

连击礼物

ANCHOR_LOT_START

天选之人开始完整信息

ANCHOR_LOT_END

天选之人获奖id

ANCHOR_LOT_AWARD

天选之人获奖完整信息

GUARD_BUY

上舰长

USER_TOAST_MSG

续费了舰长

NOTICE_MSG

在本房间续费了舰长

ACTIVITY_BANNER_UPDATE_V2

小时榜变动

ROOM_REAL_TIME_MESSAGE_UPDATE

粉丝关注变动

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值