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 | 粉丝关注变动 |