最近实现了在网易云音乐搜索关键词爬取歌单内容的方法,涉及到部分对 JavaScript 的分析,稍有难度,这里作一些介绍并附上代码!

0 需求分析和结果展示

本次爬虫的目标是在数据库中按歌单名存放歌单中歌曲的歌名、时长、歌手和专辑等信息。以下是爬取的结果展示:

1 请求和加密分析

先分析处理一下网易云音乐网站对关键词请求的流程。搜索关键词 abc 并切换到歌单搜索。在 Chrome 直接查找网页中出现的搜索结果,可以发现一页中所有的数据在 https://music.163.com/weapi/cloudsearch/get/web 这个接口下出现,该接口为一个 POST 请求,请求的内容经过了加密:

搜索 encSecKey 发现出现在一个 JavaScript 文件中,于是加入断点分析处理过程,找到:

Y3x = Y3x.replace("api", "weapi");
e3x.method = "post";
delete e3x.query;
var bVV1x = window.asrsea(JSON.stringify(i3x), bqM3x(["流泪", "强"]), bqM3x(TM7F.md), bqM3x(["爱心", "女孩", "惊恐", "大笑"]));
e3x.data = k3x.cu4y({
    params: bVV1x.encText,
    encSecKey: bVV1x.encSecKey
})

其中 Y3x 为 POST 请求链接,而 bVV1x 则存储有所需要的 paramsencSecKey,因此重点便放在 bVV1x 的生成上,也就是上图中的 window.asrsea 函数。这个函数接受了 4 个参数,后 3 个经过测试均为常数值不变,不用改动,第 1 个参数中的 i3x 如下:

其中的 s 正是输入的关键词,limit 为每页显示条目数,offset 为跳过的条目数,显示第 i 页的条目则 offset = (i - 1) * limit,其余部分保持不变。JSON.stringify 则将 i3x 字符串化,这样一来第 1 个参数的组成也弄清楚了。接下来开始分析 window.asrsea 函数。找到函数原型如下:

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
}

可见其中的 h 即为要返回的对象。通过分析发现,变量 i 为一个在 a-zA-Z0-9 中随机生成的 16 位字符串,因此实际上该值可由人工指定;函数 b 是 AES 加密,encTextd 先后使用 gi 加密后的结果;函数 c 是 RSA 加密,encSecKeyi 经过 ef 加密的结果。

2 实现加密

经过上述分析,我们初步理清整个加密的过程,接下来通过 Python 实现相同的加密效果。

2.1 AES 部分

import base64
from Crypto.Cipher import AES


class AESCipher:
    def __init__(self, key):
        self.key = key
        self.iv = "0102030405060708"  # 用来填充缺失内容,JavaScript 中已给出

    def __pad(self, text):
        """填充方式,加密内容必须为 16 字节的倍数,若不足则使用 self.iv 进行填充"""
        text_length = len(text.encode('utf-8'))  # 计算 encode 之后的长度,以应对 non-ascii 字符
        amount_to_pad = AES.block_size - (text_length % AES.block_size)
        if amount_to_pad == 0:
            amount_to_pad = AES.block_size
        pad = chr(amount_to_pad)
        return text + pad * amount_to_pad

    def encrypt(self, raw):
        """加密"""
        raw = self.__pad(raw)
        cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
        return base64.b64encode(cipher.encrypt(raw)).decode("utf-8")

通过 AESCipher(key).encrypt(text) 即可获得加密后的信息。

2.2 RSA 部分

import codecs


def RSA_encrypt(random_string, key, f):
   # 字符串逆序排列
   string = random_string[::-1]
   # 转换成 byte 类型数据
   text = bytes(string, "utf-8")
   # RSA加密
   sec_key = int(codecs.encode(text, encoding="hex"), 16) ** int(key, 16) % int(f, 16)
   # 返回结果
   return format(sec_key, "x").zfill(256)

通过 RSA_encrypt(i, e, f) 即可获得加密后的信息。

2.3 获得加密后的 POST 数据

def get_encrypt_text(post_data):
    # 后 3 个参数
    str1 = "010001"
    str2 = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
    str3 = "0CoJUm6Qyw8W8jud"

    # 随机数 i 直接人工指定
    i = "0000000000000000"

    temp = AESCipher(str3).encrypt(post_data)
    encText = AESCipher(i).encrypt(temp)
    encSecKey = RSA_encrypt(i, str1, str2)

    return {"params": encText, "encSecKey": encSecKey}

只需传入第一个参数即可得到对应的加密后的数据。

3 获取搜索结果

post_data = (
    '{{"hlpretag":"<span class=\\"s-fc7\\">","hlposttag":"</span>","s":"{}","type":"1000","offset":"{}",'
    '"total":"false","limit":"30","csrf_token":""}}'.format(搜索条目, 页码 * 30)
)

4 获取歌单信息

在网易云音乐网页版,点击歌单进入后,发现只能显示前 10 首歌曲的资料:

这显然是不够用的,于是想办法获取歌单中的全部歌曲。经过研究,这个歌单页面中确实仅仅只有前 10 首歌的信息,于是决定想办法另辟蹊径。我想起来以前曾经用过网页版能完整播放自己的歌单,便猜测在网页版登录时能显示完整的自己的歌单。果不其然,在登录后自己的歌单中找到接口 https://music.163.com/weapi/v6/playlist/detail,测试发现这个接口的调用实际上并不需要登录,而加密的方法同上,只需传入 post_data 即可获得加密数据。

post_data = '{{"id":"{}","offset":"0","total":"true","limit":"1000","n":"1000","csrf_token":""}}'.format(
    歌单 id
)

返回的结果中包含有整个歌单中的歌曲 id,但仍然只有前 10 首会给出详细信息,因此下一步就是获取歌曲信息。

5 获取歌曲信息

在歌单中点击一首歌就进入了歌曲播放页面,可以看到歌名、歌手和专辑这 3 条信息都已经出现了,唯独时长没有出现,而且出现的 3 条信息都嵌入在 HTML 中,也不太方便获取。想到网页端播放音乐的时候底部播放栏也会显示歌曲信息,尤其是会有时间进度信息。于是尝试抓取开始播放时的请求,果然发现了接口 https://music.163.com/weapi/v3/song/detail,仍然是同样的加密手段。

post_data = '{{"id":"{0}","c":"[{{\\"id\\":\\"{0}\\"}}]","csrf_token":""}}'.format(
    歌曲 id
)

返回的结果不仅给出了以毫秒为单位的时长信息,也有歌名、歌手和专辑的信息。

6 Scrapy 抓取

参见 GitHub 源码