知乎直播弹幕抓取与解析

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

分析

抓取比较简单,不多说了…都是正常的操作
但是 拿到的数据却很奇怪
为了演示方便,我们以 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
就行了
代码如下…

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

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
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的值:

#!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。

手动反序列化

以上面例子中序列化后的二进制数据流进行反序列化分析:

#!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”;

#!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个字符“
[email protected]

”;

#!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”;

#!bash
10 = 0001 0000 => field_num=2, type=0;
02 = 0000 0010 => value=2;

最后得到的结构化数据为:

#!bash
1 {
1: "Vincent"
2: 9
3: "[email protected]"
4 {
1: "15011111111"
2: 2
}
}

使用protoc反序列化

实现操作经常碰到较复杂、较长的流数据,手动分析确实麻烦,好在protoc加“decode_raw”参数可以解流数据,我实现了一个python脚本供使用:

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))

回到知乎直播

那么就先测试解析一条吧

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))

结果如下

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/知乎直播弹幕抓取与解析/

版权声明:转载请注明出处!