RJ博客

网易云音乐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://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




相关推荐

发表评论