前段时间在网上搜索Python爬取网易云音乐评论的demo,找到一篇《使用Python爬一爬网易云音乐上那些评论火爆的歌曲》,运行后即可歌曲的评论数。网易云音乐为了防爬,采用AJAX调用评论数API的方式填充评论相关数据,并且API是经过加密处理的,即传递给接口的json数据是经过加密处理后再传输的。
运行程序前,python可能需要安装:
pip uninstall Crypto
pip uninstall pycrypto
pip install pycrypto
pip install bs4
pip install python-html5lib
python代码:
#!/usr/bin/env python # -*- coding:utf-8 -*- import requests from bs4 import BeautifulSoup import os, json import base64 from Crypto.Cipher import AES from prettytable import PrettyTable import warnings import sys reload(sys) sys.setdefaultencoding('utf-8') warnings.filterwarnings("ignore") BASE_URL = 'http://music.163.com/' _session = requests.session() # 要匹配大于多少评论数的歌曲 COMMENT_COUNT_LET = 100000 class Song(object): def __lt__(self, other): return self.commentCount > other.commentCount # 由于网易云音乐歌曲评论采取AJAX填充的方式所以在HTML上爬不到,需要调用评论API,而API进行了加密处理,下面是相关解决的方法 def aesEncrypt(text, secKey): pad = 16 - len(text) % 16 text = text + pad * chr(pad) encryptor = AES.new(secKey, 2, '0102030405060708') ciphertext = encryptor.encrypt(text) ciphertext = base64.b64encode(ciphertext) return ciphertext def rsaEncrypt(text, pubKey, modulus): text = text[::-1] print text print int(text.encode('hex'), 16) print int(pubKey, 16) print int(modulus, 16) rs = int(text.encode('hex'), 16) ** int(pubKey, 16) % int(modulus, 16) print format(rs) print format(rs, 'x') return format(rs, 'x').zfill(256) def createSecretKey(size): # return 'ffffffffffffffff' return (''.join(map(lambda xx: (hex(ord(xx))[2:]), os.urandom(size))))[0:16] # 通过第三方渠道获取网云音乐的所有歌曲ID # 这里偷了个懒直接从http://grri94kmi4.app.tianmaying.com/songs爬了,这哥们已经把官网的歌曲都爬过来了,省事不少 # 也可以使用getSongIdList()从官方网站爬,相对比较耗时,但更准确 def getSongIdListBy3Party(): pageMax = 1 # 要爬的页数,可以根据需求选择性设置页数 songIdList = [] for page in range(pageMax): print page url = 'http://grri94kmi4.app.tianmaying.com/songs?page=' + str(page) # print url url.decode('utf-8') soup = BeautifulSoup(_session.get(url).content) # print soup aList = soup.findAll('a', attrs={'target': '_blank'}) for a in aList: songId = a['href'].split('=')[1] songIdList.append(songId) # print songIdList # exit() return songIdList # 从官网的 发现-> 歌单 页面爬取网云音乐的所有歌曲ID def getSongIdList(): scount = 0 smin = 100000000 pageMax = 40 # 要爬的页数,目前一共42页,爬完42页需要很久很久,可以根据需求选择性设置页数 songIdList = [] for i in range(0, pageMax + 1): url = 'http://music.163.com/discover/playlist/?order=hot&cat=全部&limit=35&offset=' + str(i * 35) url.decode('utf-8') soup = BeautifulSoup(_session.get(url).content) aList = soup.findAll('a', attrs={'class': 'tit f-thide s-fc0'}) for a in aList: uri = a['href'] playListUrl = BASE_URL + uri[1:] soup = BeautifulSoup(_session.get(playListUrl).content) ul = soup.find('ul', attrs={'class': 'f-hide'}) for li in ul.findAll('li'): songId = (li.find('a'))['href'].split('=')[1] scount = scount +1 if scount%100==0: print str(scount)+':爬取歌曲ID成功 -> ' + songId.decode("utf8")+'->smin->'+str(smin) if smin > int(songId): smin = int(songId) songIdList.append(int(songId)) # 歌单里难免有重复的歌曲,去一下重复的歌曲ID print smin songIdList = list(set(songIdList)) songIdList.sort() print songIdList return songIdList # 匹配歌曲的评论数是否符合要求 # let 评论数大于值 fcount = 0 def matchSong(songId, let): url = BASE_URL + 'weapi/v1/resource/comments/R_SO_4_' + str(songId) + '/?csrf_token=' headers = {'Cookie': 'appver=1.5.0.75771;', 'Referer': 'http://music.163.com/'} text = {} # text = {'username': '', 'password': '', 'rememberLogin': 'true'} modulus = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7' nonce = '0CoJUm6Qyw8W8jud' pubKey = '010001' text = json.dumps(text) secKey = createSecretKey(16) encText = aesEncrypt(aesEncrypt(text, nonce), secKey) encSecKey = rsaEncrypt(secKey, pubKey, modulus) data = {'params': encText, 'encSecKey': encSecKey} print data print 'done' # exit() req = requests.post(url, headers=headers, data=data) print json.dumps(req.json(), sort_keys=True, indent=4, ensure_ascii=False).encode("GB18030") # print json.dumps(req.json(), sort_keys=True, indent=4, ensure_ascii=False).encode('GBK', 'ignore'); total = req.json()['total'] if int(total) > let: song = Song() song.id = songId song.commentCount = total return song else: global fcount fcount = fcount+1 print "fail"+str(fcount)+':'+str(total) # 设置歌曲的信息 def setSongInfo(song): url = BASE_URL + 'song?id=' + str(song.id) url.decode('utf-8') soup = BeautifulSoup(_session.get(url).content) strArr = soup.title.string.split(' - ') song.singer = strArr[1] name = strArr[0].encode('utf-8') # 去除歌曲名称后面()内的字,如果不想去除可以注掉下面三行代码 index = name.find('(') if index > 0: name = name[0:index] song.name = name # 获取符合条件的歌曲列表 def getSongList(): print ' ##正在爬取歌曲编号... ##'.decode("utf8") # songIdList = getSongIdList() songIdList = getSongIdListBy3Party() print ' ##爬取歌曲编号完成,共计爬取到' + str(len(songIdList)) + '首##'.decode("utf8") songList = [] print ' ##正在爬取符合评论数大于' + str(COMMENT_COUNT_LET) + '的歌曲... ##'.decode("utf8") kk=0 for id in songIdList: # if kk==1: # break # kk=kk+1 song = matchSong(id, COMMENT_COUNT_LET) if None != song: setSongInfo(song) songList.append(song) info = '成功匹配一首名称:'+song.name+'-'+song.singer+',评论数:'+str(song.commentCount) print info.decode("utf8") print ' ##爬取完成,符合条件的的共计' + str(len(songList)) + '首##'.decode("utf8") return songList def main(): songList = getSongList() # 按评论数从高往低排序 songList.sort() # 打印结果 table = PrettyTable([u'排名', u'评论数', u'歌曲名称', u'歌手']) for index, song in enumerate(songList): table.add_row([index + 1, song.commentCount, song.name, song.singer]) print table print 'End' if __name__ == '__main__': main()
下面针对《Jar Of Love》分析API接口的加密。
首先在谷歌浏览器按F12找到AJAX接口:
借助谷歌浏览器的一个扩展插件Advanced REST client,尝试把加密数据post到这个接口试试:
发现是能正常返回数据的(已省略部分评论):
{ "isMusician": false, "userId": 59986101, "topComments": [ ], "moreHot": true, "hotComments": [ { "user": { "locationInfo": null, "avatarUrl": "http://p1.music.126.net/Xerz7mfQR7_3pwii5e7wDw==/2537672839645185.jpg", "remarkName": null, "authStatus": 0, "userId": 29320819, "vipType": 0, "nickname": "我从未见过如此厚颜无耻之人", "userType": 0, "expertTags": null }, "beReplied": [ ], "liked": false, "commentId": 10289928, "likedCount": 47268, "time": 1423327489402, "content": "俺拿得伞玩,俺拿得伞伞[大哭][大哭][大哭][大哭]" }, { "user": { "locationInfo": null, "avatarUrl": "http://p1.music.126.net/7rwFIZGF0msDMmFbKNfZHQ==/18511377767194948.jpg", "remarkName": null, "authStatus": 0, "userId": 3485553, "vipType": 0, "nickname": "OBSR", "userType": 0, "expertTags": null }, "beReplied": [ ], "liked": false, "commentId": 11970909, "likedCount": 34110, "time": 1425571634151, "content": "曲婉婷唱英文毫无违和感[强]" } ], "total": 25001, "more": true }
通过刷新发现params和encSecKey每次刷新页面都是变化的,根据字段的命名大致可以做出这样的猜想:params保存的是请求参数,encSecKey保存了params相应解密参数,且这个加密过程是js实现的。
从initiator一栏里可以看到这个请求的“发起人”是core.js,一般这样的js都是没法看的,下载下来美化过后发现有近两万行,但是没关系,我们需要的只是部分数据。在这个js文件中搜索params和encSecKey,可以找到这里
那么问题就变成得到这个bIg4k,它是由window.asrsea这个函数得到的,可以看到有4个参数,如果研究每个参数肯定是痛苦的,也没有必要,可以先把它们输出来看一下,这时候就需要线上调试js,我选择了Fiddler,下载官网:
http://www.telerik.com/fiddler
在Fiddler的AutoResponder页添加Rule,大概长这样:
配置后按Ctrl+F5强制刷新网易云音乐歌曲页,点击下图红色框所在行,点击上图的Test,如果出现下图框框,则表示可以正常捕获替换js文件了。
之后网页加载使用的core.js文件就是我们本地的这个js文件了,而我们可以修改本地的这个文件来获得想要的数据。
我们修改core.js,把这j1x个参数打印出来:
console.log(JSON.stringify(j1x)); var bIg4k = window.asrsea(JSON.stringify(j1x), baJ7C(["流泪", "强"]), baJ7C(OY5d.md), baJ7C(["爱心", "女孩", "惊恐", "大笑"])); e1x.data = k1x.de2x({ params: bIg4k.encText, encSecKey: bIg4k.encSecKey })
首页:{"csrf_token":"b27bbaf127e5fa941176c5436b936ddc"}
翻页:{"rid":"R_SO_4_25713016","offset":"20","total":"false","limit":"20","csrf_token":"6d880a04facc6eb05e25b24c75b5032b"}
翻页数多试几次,可以发现rid就是R_SO_4_加上歌曲的id,offset就是(评论页数-1) * 20
把其他3个参数也打印出来:
console.log(JSON.stringify(j1x)); console.log(baJ7C(["流泪", "强"])); console.log(baJ7C(OY5d.md)); console.log(baJ7C(["爱心", "女孩", "惊恐", "大笑"])); var bIg4k = window.asrsea(JSON.stringify(j1x), baJ7C(["流泪", "强"]), baJ7C(OY5d.md), baJ7C(["爱心", "女孩", "惊恐", "大笑"])); e1x.data = k1x.de2x({ params: bIg4k.encText, encSecKey: bIg4k.encSecKey })
参数①:
{"rid":"R_SO_4_25713016","offset":"20","total":"false","limit":"20","csrf_token":"aeafba538aee576e95a9222fa765f67b"}
参数②:
010001
参数③:
00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7
参数④:
0CoJUm6Qyw8W8jud
现在我们只要知道函数window.asrsea如何处理的就可以了,定位到这个函数发现它其实是一个叫d的函数( 代码 window.asrsea = d)
! 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 }();
d函数里的i研究之后你会发现就是一个由a函数生成的长度为16的随机字符串。这个encText明显就是params,encSecKey明显就是encSecKey。
而b函数就是一个AES加密,经过了两次加密,第一次对d也就是那个json加密,key是第四个参数,第二次对第一次加密结果进行加密,key是i。在b函数中我们可以看到密钥偏移量iv是0102030405060708,模式是CBC,那么就不难写出对于这个json的加密了。
接下来是第二个参数encSecKey,这里传入c的三个参数:i第一个参数,e是第二个参数,f是第三个参数,参数二三是固定的值,这个encSecKey值只随i变化而变化,因为i是随机字符串,所以我们也可以指定它的值,这样encSecKey的值也随之固定了。所以这个encSecKey对我们来说可以是个常量,只要指定i的值,encSecKey抄一个下来就是可以使用的。
深入研究生成encSecKey的c函数,我们发现这其实是一个js版的RSA加密函数,加密内容就是i,回到我们的爬虫代码,发现有一句代码:
rs = int(text.encode('hex'), 16) ** int(pubKey, 16) % int(modulus, 16)
上面的modulus是个16进制的数,这个数由两个质数相乘得到,转换为2进制之后的长度为1024。它的长度代表了RSA加密算法的密钥的长度。目前技术,1024位长度的密钥基本不能被破解。
pubKey应该是一个小于φ(modulus) 的一个随机整数。modulus和pubKey组合起来就是RSA加密的公钥。
rs = int(text.encode('hex'), 16) ** int(pubKey, 16) % int(modulus, 16),这段代码的根据是这个公式:m^e ≡ c (mod n)。m 相当于 int(text.encode('hex'), 16), e 相当于 int(pubKey, 16), n 相当于int(modulus, 16),rs 相当于 c,也就是加密后的内容。所以,这句话的意思就是:使用公钥对text进行加密,得到rs。
encrypted_request 这个函数比较好理解。先secKey = createSecretKey(16)随机生成一个密钥。
代码中的 nonce = '0CoJUm6Qyw8W8jud'的 nonce 变量也是一个密钥,是AES加密的密钥。
encText = aesEncrypt(aesEncrypt(text, nonce), secKey)就是先使用nonce作为AES密钥对text加密一次,然后使用随机生成的密钥seckey对加密后的文字再加密一次得到 encText。
问题来了:int(text.encode('hex'), 16) ** int(pubKey, 16) % int(modulus, 16) 这不就是 RSA 加密算法么,为啥要自己写,难道没有现成的函数么。答案是有的,所以 rsa_encrypt 这个函数可以改写为:
from Crypto.PublicKey import RSA def rsa_encrypt(self, text): e = '010001' n = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615'\ 'bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf'\ '695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46'\ 'bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b'\ '8e289dc6935b3ece0462db0a22b8e7' reverse_text = text[::-1] pub_key = RSA.contruct([int(n, 16), int(e, 16)]) encrypt_text = pub_key.encrypt(reverse_text)[0] return encrypt_text
至此,我们把所知道的东西列出来:
我们知道了RSA加密算法的公钥。服务器有私钥,用来解密消息。
我们知道了网页端对内容进行了两次AES加密,才把内容发给后台,而第一次AES加密的密钥我们已经知道(服务器也知道)。但是第二次AES加密的密钥是随机生成的,程序知道,我们不知道,服务器也不知道。
所以程序对这个 随机生成的密钥 使用 RSA 加密,发给后端,后端就知道了这个随机密钥到底是多少。
最后我们针对AES来解密下试试:
我们在core.js里面把a函数指定返回16个f,即:return 'ffffffffffffffff'
这样我们得到的params为:
hQ+1LhWIT2RVSPEmLuuU0b8hGyAblYxzwoC030svHsrECTHBdgOyed6h2jptMtZGFJrMq+30GvMmOhcIqg/lqiDSfb8MLF+HQPL6l2xiG/IqiCFJHH53SOlLCsco9fMAAs9CRUo5Plv/X0Y8/kokO/4doumO98BG8l24XQaL5cUXk76MlMfTDu9CSGaTy2xuIDCRW6I1tkcTd2VtWkndYOxQkSLoZ8xMvI1q+CwkGBI=
打开AES在线解密:https://blog.zhengxianjun.com/online-tool/crypto/aes/
把params输入,点击解密,结果如下:
这是第一层解密,把上图的明文复制到下面,继续第二次解密:
可以看到能解密出json数据了,这也就是参数一的数据:
{"rid":"R_SO_4_25713016","offset":"0","total":"true","limit":"20","csrf_token":"41d29463443a3b3258d938ef3c155437"}
References:
火爆歌评:http://www.jianshu.com/p/50d99bd7ed62
评论爬取:https://www.zhihu.com/question/36081767
接口分析:https://github.com/cosven/cosven.github.io/issues/30
JS版RSA:http://www.cnblogs.com/yzryc/p/6025958.html
JS版使用: https://www.zhihu.com/question/24630018/answer/28437583
RSA原理:www.ruanyifeng.com/blog/2013/07/rsa_algorithm_part_two.html
AES原理:http://www.cnblogs.com/luop/p/4334160.html
AES动画:coolshell.cn//wp-content/uploads/2010/10/rijndael_ingles2004.swf
发表评论
欢迎评论
怎么下载下来美化处理呀?
https://github.com/finallylly/NetEase-CloudMusic-Spider
怎么下载下来美化处理呀?
I am truly grateful to the owner of this web site who haѕ shared this impressive article at here.
为啥获得按照这样的步骤获得的json总是为空啊?一直不明白是哪里错了。。。。
你可以逐步调试看是哪里出问题了
为啥获得按照这样的步骤获得的json总是为空啊?一直不明白是哪里错了。。。。
大神好厉害
999