背景
因为想拿到一些知乎弹幕的数据 以及做一个直播播报机器人,所以最近在研究知乎直播的弹幕
分析
抓取比较简单,不多说了…都是正常的操作
但是 拿到的数据却很奇怪
为了演示方便,我们以 rest接口示范,本质上和websocket接口是一样的。
我们以直播间11529为例子
拿取弹幕的接口是: https://www.zhihu.com/api/v4/drama/theaters/11529/recent-messages
可以看到弹幕数据应该在messages里面,但是数据好像经过了某种加密
js 大搜查
首先全局搜索 recent-messages,找到需要的js文件(这边也就是查找哪个js请求了拿弹幕的网址)
把js文件下载到本地格式化后,搜索recent-messages
搜索LOAD_RECENT_MESSAGES
找到了如何解析message的第一步 base64解密
js中atob函数解释
并且 转换后的结果传给了函数 p
继续搜索p 往上搜索(记得搜索模式选择全词匹配与区分大小写) 要不然搜索结果太多了…
运气好 上面第一个就是
为了验证可以替换知乎js 到你本地的js
加两行console.log就行了
代码如下…
1 2 3 4 5 6
| function p(e) { console.log("before:", e); var t = d.EventMessage.decode(e), n = t.eventCode, r = t.event; console.log("after:", t);
|
可以发现这就是我们想要的
那么现在只要搞清楚 EventMessage.decode 这个方法干了啥就可以了…
然后搜到了具体的代码
就一步一步debug,发现好像是某种编码规范?
难道是知乎自己定义的吗…
在这边搞了一周…还没有搞明白
大概说下 我迷惑的点在哪
如上这个 Uint8Array
先是对第一位 >>> 操作,判断这个 字节表示的是 什么含义
然后后面的xxx 个字节表示具体的值,但是xxx个字节到底是多少个,是怎么区分的 我没有弄明白
特别是如下 这三个明明都是 int64,他们的字节长度却不一样
timestampMs 是 6个字节
theaterId 是 2个字节
dramaId 是 9个字节
我拿个小本本一边debug一边记…(本来字段少的话,看多了是可以直接找到规律 这样解决的,但是其中一个字段event是个字典,有40个key…
我看到代码的时候 就炸了…
所以我就想 算了 不了解它到底怎么实现的把,我直接吧这段js抠出来…然后搭个nodejs的服务得了
扣js
扣的时候 还比较简单,除了这一句的s
1
| e instanceof s || (e = s.create(e));
|
这个s到这我就找不到它到底从哪来的了
所以我就只能google.(搜了好多次)
真是惊喜!发现竟然是protobuf
所以这个所谓的加密 是一种通用的协议…
至此,问题就简单了
Protocol Buffers
官方的定义如下:
Protocol buffers是一种与语言无关、与平台无关的可扩展机制,用于序列化结构化数据。
更多介绍可以去看protocol-buffers官网
下面的内容来自 Burpsuite中protobuf数据流的解析
Varint编码
Protobuf的二进制使用Varint编码。Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。
Varint 中的每个 byte 的最高位 bit 有特殊的含义,如果该位为 1,表示后续的 byte 也是该数字的一部分,如果该位为 0,则结束。其他的 7 个 bit 都用来表示数字。因此小于 128 的数字都可以用一个 byte 表示。大于 128 的数字,比如 300,会用两个字节来表示:1010 1100 0000 0010。
下图演示了protobuf如何解析两个 bytes。注意到最终计算前将两个 byte 的位置相互交换过一次,这是因为protobuf 字节序采用 little-endian 的方式。
所以我们上面那个疑惑解决了…
就是怎么确定某个字段到底应该几个字节(或者说现在能划分数据了)
数值类型
Protobuf经序列化后以二进制数据流形式存储,这个数据流是一系列key-Value对。Key用来标识具体的Field,在解包的时候,Protobuf根据 Key 就可以知道相应的 Value 应该对应于消息中的哪一个 Field。
Key 的定义如下:
(field_number << 3) | wire_type
Key由两部分组成。第一部分是 field_number,比如消息 tutorial .Person中 field name 的 field_number 为 1。第二部分为 wire_type。表示 Value 的传输类型。Wire Type 可能的类型如下表所示:
| Type |
Meaning |
Used For |
| 0 |
Varint |
int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 |
64-bit |
fixed64, sfixed64, double |
| 2 |
Length-delimi |
string, bytes, embedded messages, packed repeated fields |
| 3 |
Start group |
Groups (deprecated) |
| 4 |
End group |
Groups (deprecated) |
| 5 |
32-bit |
fixed32, sfixed32, float |
以数据流:08 96 01为例分析计算key-value的值:
1 2 3 4 5 6 7 8 9
| #!bash 08 = 0000 1000b => 000 1000b(去掉最高位) => field_num = 0001b(中间4位), type = 000(后3位) => field_num = 1, type = 0(即Varint) 96 01 = 1001 0110 0000 0001b => 001 0110 0000 0001b(去掉最高位) => 1 001 0110b(因为是little-endian) => 128+16+4+2=150
|
最后得到的结构化数据为:
1:150
其中1表示为field_num,150为value。
手动反序列化
以上面例子中序列化后的二进制数据流进行反序列化分析:
1 2 3 4 5
| #!bash 0A = 0000 1010b => field_num=1, type=2; 2E = 0010 1110b => value=46; 0A = 0000 1010b => field_num=1, type=2; 07 = 0000 0111b => value=7;
|
读取7个字符“Vincent”;
1 2 3 4 5
| #!bash 10 = 0001 0000 => field_num=2, type=0; 09 = 0000 1001 => value=9; 1A = 0001 1010 => field_num=3, type=2; 10 = 0001 0000 => value=16;
|
读取10个字符“Vincent@test.com”;
1 2 3 4 5
| #!bash 22 = 0010 0010 => field_num=4, type=2; 0F = 0000 1111 => value=15; 0A = 0000 1010 => field_num=1, type=2; 0B = 0000 1011 => value=11;
|
读取11个字符“15011111111”;
1 2 3
| #!bash 10 = 0001 0000 => field_num=2, type=0; 02 = 0000 0010 => value=2;
|
最后得到的结构化数据为:
1 2 3 4 5 6 7 8 9 10
| #!bash 1 { 1: "Vincent" 2: 9 3: "Vincent@test.com" 4 { 1: "15011111111" 2: 2 } }
|
使用protoc反序列化
实现操作经常碰到较复杂、较长的流数据,手动分析确实麻烦,好在protoc加“decode_raw”参数可以解流数据,我实现了一个python脚本供使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import subprocess, sys import json import base64
def decode(data): process = subprocess.Popen( ["protoc", "--decode_raw"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, )
output = error = None try: output, error = process.communicate(data) except OSError: pass finally: if process.poll() != 0: process.wait() return output
with open(sys.argv[1], "rb") as f: data = f.read() print('',decode(data))
|
回到知乎直播
那么就先测试解析一条吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import subprocess, sys import json import base64
def decode(data): process = subprocess.Popen( ["protoc", "--decode_raw"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, )
output = error = None try: output, error = process.communicate(data) except OSError: pass finally: if process.poll() != 0: process.wait() return output
a1 = "CAESpgMKowMKhgMKIDQwYjQ3Y2NiZmM0NDc1YjAxOGE1YTQxN2UxY2Y5ODk3EhLlsI/pgI/mmI7niLHlvrfljY4aDGR1LXlhby0xMy04NiJBaHR0cHM6Ly9waWM0LnpoaW1nLmNvbS92Mi1kMDYxNjFiMWQzOWNkNjRlYmRhNDBmOWMwNjVhNmNhNV94cy5qcGcqswEIiVoSCemFkueqneeqnRgBIAAoJTABOpoBCAEQABpAaHR0cHM6Ly9waWMxLnpoaW1nLmNvbS92Mi0xZDEyNTg1YzdhOTY2MTNkM2JlZjQxMTcyY2Q4ZWYxNV9yLnBuZyJAaHR0cHM6Ly9waWM0LnpoaW1nLmNvbS92Mi1iMDM1ZWRkNTA3NjgwNzU3MmJkNGU3YTg5MjRjZTEzYl9yLnBuZyoHIzcyQkJGRioHIzAwODRGRjJHCAkQjGAaQGh0dHBzOi8vcGljNC56aGltZy5jb20vdjItODI1NTRlYzgzYmViMzJlOWVjNDQxNGY0YzYyMmFjMmNfci5wbmcQARoM5oiR5Lmf6KeJ5b6XIICA8cTaz6SEERiplO2Pki4giVoogKDrtNOUlYQRMhUxLTEyMjczOTE5NjY4NTU4Mzk3NDQ4AQ=="
message = base64.b64decode(a1)
print(decode(message))
|
结果如下
1 2 3 4 5
| Out[7]: b'1: 1\n2 {\n 1 {\n 1 {\n 1: "40b47ccbfc4475b018a5a417e1cf9897"\n 2: "\\345\\260\\217\\351\\200\\217\\346\\230\\216\\347\\210\\261\\345 \\276\\267\\345\\215\\216"\n 3: "du-yao-13-86"\n 4: "https://pic4.zhimg.com/v2-d06161b1d39cd64ebda40f9c065a6ca5_xs.jpg"\n 5 {\n 1: 1152 9\n 2: "\\351\\205\\222\\347\\252\\235\\347\\252\\235"\n 3: 1\n 4: 0\n 5: 37\n 6: 1\n 7 {\n 1: 1\n 2: 0\n 3: "https://pic1.zhimg.com/v2-1d12585c7a96613d3bef41172cd8ef15_r.png"\n 4: "https://pic4.zhimg.com/v2-b035edd5076807572bd4e7a8924c e13b_r.png"\n 5: "#72BBFF"\n 5: "#0084FF"\n }\n }\n 6 {\n 1: 9\n 2: 12300\n 3: "https://pic4.zhimg.com/v2-82554ec83beb32e9ec4414f4c622ac2c_r.png"\n }\n }\n 2: 1\n 3: "\\346\\210\\221\\344\\271\\237\\350\\247\\211\\345\\276\\227"\n 4: 1227391966855839744\n }\n}\n3: 1585413048873\n4: 11529\n5: 1227323967020912640\n6: "1-1227391966855839744"\n7: 1\n'
|
可以看到解析成功了,那么后面的工作就比较简单了…
只要按照知乎的js对应出某个位置的具体字段名字就好了…
最后看一个成功的截图
本文作者:高金
本文地址: https://igaojin.me/2020/03/29/知乎直播弹幕抓取与解析/
版权声明:转载请注明出处!