目标

来爬网易云音乐的所有热评,输入歌曲 id ,输出热评。(网易系的 Web 爬取好像都是这个流程)

请求接口

通过 F12 查看到了热评查询接口:https://music.163com/weapi/comment/resource/comments/get,请求方式 POST。

但是请求接口的参数,被加了密:

1
2
3
4
params:   
AZmpF0q2Xd7XkTpxUoqAKAaTsvs28bvL09Jg1X5cYKpMxIcOpa48xqeAqVjqyummoOPGHVEnRafFSmhsIriymftVHdDhyeh2v+rgLGy7W4w7nWPSHSOEzhDNXPLVLJBrgrFJzl/zWUh1f50AwC+qkKa4Z8MvvFZMHAR1/1/aCYBIvsS0i6IAV1DgVLwGitM4r6jyPNVmKua240dgLSw7tIOqjFxeRXFC/ZXLM8SNzL2b19kdQk/moaSILa87zUKPaZZ1YyQb1ulEPU+4MmqW3maGxqAX92LLA2I0voz4JA0=

encSecKey: 27f97b1ce8c6a382428151cea84d9a37cdcce637e83c7dd264b3977ab46c6065e9e226c40c8abe8fa7e1344a46c843f5140be441b87b2a423b476346a43ac996445e5b66d916c93f2d2e204a2ee61625e1ee98dea7674a3e4c16f30eaafa6afefc8f0af88e78f6ad71d07635685250091aaa7b19b41ca36a6c47ede90aeaad59

所以我们要破解整套加密系统,实现手动构造请求参数并加密。过程很简单,找到加密的函数,查看加密前的参数格式,和加密的过程。

定位请求发送函数

首先从本次请求发起程序的调用栈的最顶层开始,一级一级回溯,注意看 params 是否被加密。

340.jpg

在网络一栏下面的第一个调用函数,就是执行发送请求的函数,打上断点,观察局部变量,不停的放请求,直到从局部变量的 url 里面看到是我们要找的请求,再停下来开始分析本次请求的函数调用栈。

341.jpg

这个时候就可以看到,这个发送函数的调用栈,已经和我们在 网络 一栏看到的请求调用栈是一样的了。

然后我们根据这个请求的调用栈,依次去找执行加密的函数。

定位加密函数

依次回溯调用栈,观察局部变量的 param,找到数据没加密状态的函数:

342.jpg

成功找到,证明就是在 be8W() 中完成了加密。

然后在这个函数内打断点,一步一步执行,观察局部变量,继续跟踪具体执行加密的函数。

发现是一开始就将上图参数转化为了字符串,然后在 windows.asrsea() 中完成了对参数字符串的加密。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
!function() {  
function a(a) {
var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
for (d = 0; a > d; d += 1)
e = Math.random() * b.length,
e = Math.floor(e),
c += b.charAt(e);
return c
}
function b(a, b) {
var c = CryptoJS.enc.Utf8.parse(b)
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a)
, f = CryptoJS.AES.encrypt(e, c, {
iv: d,
mode: CryptoJS.mode.CBC
});
return f.toString()
}
function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b,"",c),
e = encryptedString(d, a)
}
function d(d, e, f, g) {
var h = {}
, i = a(16);
return h.encText = b(d, g),
h.encText = b(h.encText, i),
h.encSecKey = c(i, e, f),
h
}
function e(a, b, d, e) {
var f = {};
return f.encText = c(a + e, b, d),
f
}
window.asrsea = d,
window.ecnonasr = e
}();

开始分析 window.asrsea() 的执行过程。

解析加密函数

首先需要明确 window.asrsea() 被调用的时候,填入的参数为:

JSON.stringify(i7b), bsR1x(["流泪", "强"]), bsR1x(Xp4t.md), bsR1x(["爱心", "女孩", "惊恐", "大笑"])

里面只有 i7b 是变量,是我们要加密的参数,被从 JSON 对象转化为了字符串,其他部分都是不变的常量。

然后去看 window.asrsea() 的具体代码,可以看到这个代码实际上就是函数 d (window.asrsea = d,

那就从 d() 入手,先调用了 a(16) 保存为 i。经过阅读发现,a() 函数输入一个长度数字,返回一个对应长度的随机数。因此这个东西可以保留不变,因此现在不变的参数我们都能获得了,也就是 i e f g:

343.jpg

1
2
3
4
e: "010001"  
f: "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g: "0CoJUm6Qyw8W8jud"
i: "xGsMkCrcKlP3OnHT"

上面的 g 和 i 都是 16字节,128 位的密钥。

接下来,就是获取加密 param 的过程,也就是:

1
2
h.encText = b(d, g)  
h.encText = b(h.encText, i)

分析 b,发现就是就是将输入的两个参数 var1 var2,执行 AES 加密,并返回结果。

1
2
3
4
AES.encrypt(var1, var2, {  
iv: "0102030405060708",
mode: CryptoJS.mode.CBC
});

查一查这个函数的文档, var1 是明文,var2 是密钥,后面的字典 iv 是偏移量,mode 是 CBC 加密模式。我们就可以用 Python 的 AES 加密模块模拟两次这个过程,获取请求的加密结果 encText。需要注意的是,最后加密完,调用加密结果的 toString() 的时候,使用的是 Base64 编码(这里需要看在创建 AES 对象的时候,传入的 format 对象的 stringify 指定使用 CryptoJS.enc.Base64),所以我们也要进行 base64 编码之后输出。

然后再来看 encSecKey 的获取,发现函数输入的三个数都是前面的常数字符串,并且里面没有用到随机数,这意味着我们确定了输入参数,就确定了结果。于是就也直接用现成它计算好的了,直接从内存中复制:

encSecKey = “54af20a9dcdc7522eac4be5f10fb17c534c8feb59b7117138afc3852dbcddaa4b2f54bce5b1b8a489f059be6b680a31d582f4f68c82d5ab9ebf0b6591a6c0d42718dc363daf643aa17f4aa44667a4d327402f7ec01b1d7ca1d7960aa48a5828ccb8532dbdc6ac1dd1d373a4d4b77418a1fd05377b4e0fbb34e4a772f61d1e839”

好了,现在我们 encSeckey 直接拿到了,encText 也知道了该如何加密,可以写 Python 脚本了。

AES

填充方式

从里面的两次 AES 加密可以看出来,使用的是 128bit 加密。因为 AES 是对明文进行分块,所以也要求明文长度要是 128bit 的整数倍,所以需要对明文进行补齐,默认使用的是 PKCS5Padding 填充方式。

1
2
PKCS5Padding:如果明文块少于16个字节(128bit),在明文块末尾补足相应数量的字符,且每个字节的值等于缺少的字符数。  
比如明文:{1,2,3,4,5,a,b,c,d,e},缺少6个字节,则补全为{1,2,3,4,5,a,b,c,d,e,6,6,6,6,6,6}

CBC 模式

CBC模式(Cipher Block Chaining)引入了一个新的概念:初始向量IV(Initialization Vector)。

IV是做什么用的呢?它的作用和MD5的加盐有些类似,目的是防止同样的明文块始终加密成同样的密文块。

344.jpg

从图中可以看出,CBC模式在每一个明文块加密前会让明文块和一个值先做异或操作。IV作为初始化变量,参与第一个明文块的异或,后续的每一个明文块和它前一个明文块所加密出的密文块相异或。

这样以来,相同的明文块加密出的密文块显然是不一样的。

代码

今天太晚了,就去睡觉了,今天早上起来再把这个爬虫做一个终极版本,做一个像下面一样的网易云终极无敌操作器。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
from fake_useragent import UserAgent  
from Crypto.Cipher import AES

import base64
import requests, pprint,json

class WangYiYun():

def __init__(self):
self.params = ""
self._i = "l6Brr86UeZ6C3Bsw" # 默认使用此字符串
# 使用默认_i 配套的encSecKey
self.encSecKey = "7ca9b5ba8b13044f47ed74c388df912ac84758122acbedc64111f2ac83232b01d3ce16f7195a39c7e064b4c0240b5c1d52624dc13c22ec820d76dfe32db43e496aeacced5be3ca9108c78a85bb389f1edf8d8c9fced02024ba9490401b4ce062cc50764d0a24294e07bb229271391b5a3640e924ee1ed15435dc6e288f1fa873"
self.headers = {
'authority': 'music.163.com',
'user-agent': UserAgent().random,
'content-type': 'application/x-www-form-urlencoded',
'accept': '*/*',
'origin': 'https://music.163.com',
'sec-fetch-site': 'same-origin',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'empty',
'referer': 'https://music.163.com/song?id=1426301364',
'accept-language': 'zh-CN,zh;q=0.9',
'cookie': '_iuqxldmzr_=32; _ntes_nnid=5f8ee04e745645d13d3f711c76769afe,1593048942478; _ntes_nuid=5f8ee04e745645d13d3f711c76769afe; WM_TID=XqvK2%2FtWaSBEUBRBEEN7XejGE%2FL0h6Vq; WM_NI=iN6dugAs39cIm2K2R9ox28GszTm5oRjcvJCcyIuaI1dccEVSjaHEwhc8FuERfkh3s%2FFP0zniMA5P4vqS4H3TJKdQofPqezDPP4IR5ApTjuqeNIJNZkCvHMSY6TtEkCZUS3k%3D; WM_NIKE=9ca17ae2e6ffcda170e2e6eeb2e57dbababf88b879a8b08fa2d84f869f9fbaaa50a3f599a5d650939b8dadd52af0fea7c3b92aab92fa85f86d83adfddae243afee85d3d133ada8fed9c679ba8ca3d6ee5aaabdbaabc269bb97bb82cc3ba8bdada6d559aabf88a6f664a1e88a96c85aa6b5a8d4f2258690009bed638f9ffbb1b77eb38dfca9b2608a95acb2ee6e94afab9bc75c94ec87b3b84bb48ca696f46f8e9786afd96181aa88aed253f68cbca6ea499a8b9dd4ea37e2a3; JSESSIONID-WYYY=tI8MIKMCRBuyCYnUJMCyUTlp%2Fufv5xIfCquvp7PJ4%2BuXod%5CXH%5CB0icDZw8TNlwHUHOW%2B2t%2BCuXyC4VZ%5C19OrzaDE%5Ck0F0dAZQh7KcVxUoHKpqUdiVzPu8NxCK9cJRG%5C%5CPTvtqxjFerd1%2BBa4%2F%5C8PESa4pvvRaQF6jljjsibX%5CrcPsH0I%3A1593347447142',
}

# 搜索歌曲接口
API_Serch_Songs = 'https://music.163.com/weapi/cloudsearch/get/web?csrf_token='
# 歌曲评论
API_Comments_Song = 'https://music.163.com/weapi/v1/resource/comments/R_SO_4_{}?csrf_token=' # 音乐ID可替换
# 歌曲歌词
API_Lyric_Songs = 'https://music.163.com/weapi/song/lyric?csrf_token='

# crypt_js_complex python 复写cryptjs
def crypt_js_complex(self,text):
BS = AES.block_size
pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS).encode('utf-8')
unpad = lambda s: s[0:-s[-1]]

key = bytes(self._i, encoding="utf-8")
text = text.encode("utf-8")
IV = b'0102030405060708'

cipher = AES.new(key, mode=AES.MODE_CBC, IV=IV)
# cipher2 = AES.new(key, mode=AES.MODE_CBC, IV=IV) # 加密和解密,cipher对象只能用一次

# print(text)
encrypted = pad(text)
# print(encrypted)
encrypted = cipher.encrypt(encrypted)
# print(encrypted)
encrypted = base64.b64encode(encrypted).decode("utf-8")
# print("第二次加密结果", encrypted)

return encrypted

# crypt_js_complex 的基础
def crypt_js_complex_base(self,text):
BS = AES.block_size
pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS).encode('utf-8')
unpad = lambda s: s[0:-s[-1]]

key = b'0CoJUm6Qyw8W8jud'
text = text.encode("utf-8")
IV = b'0102030405060708'

cipher = AES.new(key, mode=AES.MODE_CBC, IV=IV)
# cipher2 = AES.new(key, mode=AES.MODE_CBC, IV=IV) # 加密和解密,cipher对象只能用一次

# print(text)
encrypted = pad(text)
# print(encrypted)
encrypted = cipher.encrypt(encrypted)
# print(encrypted)
encrypted = base64.b64encode(encrypted).decode("utf-8")
# print("第一次加密结果", encrypted)
return encrypted

# 获得parms参数值
def get_params(self,text):
return self.crypt_js_complex(
self.crypt_js_complex_base(text),)

# 搜索歌曲接口
def serch_songs(self,name,offset=0):
"""

:param name:str
:param offset:int 偏移量 默认第一页 例如 0 30 60 90
:return 接口数据
"""
text = '{"hlpretag":"<span class=\\"s-fc7\\">","hlposttag":"</span>","#/discover":"","s":"%s","type":"1","offset":"%s","total":"false","limit":"30","csrf_token":""}'%(name,offset*30)
# payload = 'params={params}&encSecKey={encSecKey}'.format(params=self.get_params(text),encSecKey=self.encSecKey)

params = (
('csrf_token', ''),
)

data = {
'params': self.get_params(text),
'encSecKey': self.encSecKey
}

response = requests.post(self.API_Serch_Songs, headers=self.headers, params=params,
data=data)
self._dispose(json.loads(response.text))

# 歌曲评论抓取
def comment_song(self,songid:str,offset:int=0):
""""
:param str 歌曲ID
:param int 翻页 默认第一页 0 20 40
:return 接口数据
"""
text = '{"rid":"R_SO_4_%s","offset":"%s","total":"true","limit":"20","csrf_token":""}'%(songid,offset*20)

params = (
('csrf_token', ''),
)

data = {
'params': self.get_params(text),
'encSecKey': self.encSecKey
}
response = requests.post(self.API_Comments_Song.format(songid), headers=self.headers,
params=params, data=data)
self._dispose(json.loads(response.text))
# 歌词爬取
def lyric_song(self,songid:str):
"""
:param songid str 歌曲ID
:return 接口数据
"""
# 歌词接口加密参数原型
text = '{"id":"%s","lv":-1,"tv":-1,"csrf_token":""}'%(songid)

params = (
('csrf_token', ''),
)

data = {
'params': self.get_params(text),
'encSecKey': self.encSecKey
}

response = requests.post(self.API_Lyric_Songs, headers=self.headers, params=params, data=data)
self._dispose(json.loads(response.text))

# 处理爬虫获取到的数据,这里我就输出值
def _dispose(self, data):
pprint.pprint(data)
return data

# 主函数 测试
def wangyi_main(self):
# 搜索接口
# self.serch_songs("旧账",0)
#歌曲评论接口
self.comment_song("25639331",0)
# 歌词接口
# self.lyric_song("1351615757") # 旧账
pass
if __name__ == '__main__':
wangyi = WangYiYun()
wangyi.wangyi_main()