目标 来爬网易云音乐的所有热评,输入歌曲 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 是否被加密。
在网络一栏下面的第一个调用函数,就是执行发送请求的函数,打上断点,观察局部变量,不停的放请求,直到从局部变量的 url 里面看到是我们要找的请求,再停下来开始分析本次请求的函数调用栈。
这个时候就可以看到,这个发送函数的调用栈,已经和我们在 网络 一栏看到的请求调用栈是一样的了。
然后我们根据这个请求的调用栈,依次去找执行加密的函数。
定位加密函数 依次回溯调用栈,观察局部变量的 param,找到数据没加密状态的函数:
成功找到,证明就是在 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:
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的加盐有些类似,目的是防止同样的明文块始终加密成同样的密文块。
从图中可以看出,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" 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=' API_Lyric_Songs = 'https://music.163.com/weapi/song/lyric?csrf_token=' 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) encrypted = pad(text) encrypted = cipher.encrypt(encrypted) encrypted = base64.b64encode(encrypted).decode("utf-8" ) return encrypted 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) encrypted = pad(text) encrypted = cipher.encrypt(encrypted) encrypted = base64.b64encode(encrypted).decode("utf-8" ) return encrypted 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 ) 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.comment_song("25639331" ,0 ) pass if __name__ == '__main__' : wangyi = WangYiYun() wangyi.wangyi_main()