网易云音乐Web API 加密算法分析

前段时间在网上搜索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

image.png

借助谷歌浏览器的一个扩展插件Advanced REST client,尝试把加密数据post到这个接口试试:


image.png

发现是能正常返回数据的(已省略部分评论):

{
"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,可以找到这里


image.png

那么问题就变成得到这个bIg4k,它是由window.asrsea这个函数得到的,可以看到有4个参数,如果研究每个参数肯定是痛苦的,也没有必要,可以先把它们输出来看一下,这时候就需要线上调试js,我选择了Fiddler,下载官网:

http://www.telerik.com/fiddler
在Fiddler的AutoResponder页添加Rule,大概长这样:

image.png

配置后按Ctrl+F5强制刷新网易云音乐歌曲页,点击下图红色框所在行,点击上图的Test,如果出现下图框框,则表示可以正常捕获替换js文件了。


image.png

之后网页加载使用的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输入,点击解密,结果如下:

image.png

这是第一层解密,把上图的明文复制到下面,继续第二次解密:


image.png

可以看到能解密出json数据了,这也就是参数一的数据:

{"rid":"R_SO_4_25713016","offset":"0","total":"true","limit":"20","csrf_token":"41d29463443a3b3258d938ef3c155437"}

References:
http://nearby.wang/s_51.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容