知乎直播弹幕抓取与解析

背景

因为想拿到一些知乎弹幕的数据 以及做一个直播播报机器人,所以最近在研究知乎直播的弹幕

分析

抓取比较简单,不多说了…都是正常的操作

但是 拿到的数据却很奇怪

为了演示方便,我们以 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 可能的类型如下表所示:

TypeMeaningUsed For
Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bitfixed64, sfixed64, double
2Length-delimistring, bytes, embedded messages, packed repeated fields
3Start groupGroups (deprecated)
4End groupGroups (deprecated)
532-bitfixed32, 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对应出某个位置的具体字段名字就好了…

最后看一个成功的截图

推荐文章