前段时间在网上搜索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接口:
http://music.163.com/weapi/v1/resource/comments/R_SO_4_25713016?csrf_token=2fb44943e4b90b347b9a8821102a3dfa
借助谷歌浏览器的一个扩展插件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://nearby.wang/s_51.html