最近实现了在网易云音乐搜索关键词爬取歌单内容的方法,涉及到部分对 JavaScript 的分析,稍有难度,这里作一些介绍并附上代码!
0 需求分析和结果展示#
本次爬虫的目标是在数据库中按歌单名存放歌单中歌曲的歌名、时长、歌手和专辑等信息。以下是爬取的结果展示:
1 请求和加密分析#
先分析处理一下网易云音乐网站对关键词请求的流程。搜索关键词 abc
并切换到歌单搜索。在 Chrome 直接查找网页中出现的搜索结果,可以发现一页中所有的数据在 https://music.163.com/weapi/cloudsearch/get/web
这个接口下出现,该接口为一个 POST 请求,请求的内容经过了加密:
搜索 encSecKey
发现出现在一个 JavaScript 文件中,于是加入断点分析处理过程,找到:
1
2
3
4
5
6
7
8
| 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
则存储有所需要的 params
和 encSecKey
,因此重点便放在 bVV1x
的生成上,也就是上图中的 window.asrsea
函数。这个函数接受了 4 个参数,后 3 个经过测试均为常数值不变,不用改动,第 1 个参数中的 i3x
如下:
其中的 s
正是输入的关键词,limit
为每页显示条目数,offset
为跳过的条目数,显示第 i
页的条目则 offset = (i - 1) * limit
,其余部分保持不变。JSON.stringify
则将 i3x
字符串化,这样一来第 1 个参数的组成也弄清楚了。接下来开始分析 window.asrsea
函数。找到函数原型如下:
1
2
3
4
5
6
7
8
| 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 加密,encText
是 d
先后使用 g
和 i
加密后的结果;函数 c
是 RSA 加密,encSecKey
是 i
经过 e
和 f
加密的结果。
2 实现加密#
经过上述分析,我们初步理清整个加密的过程,接下来通过 Python 实现相同的加密效果。
2.1 AES 部分#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| 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 部分#
1
2
3
4
5
6
7
8
9
10
11
12
| 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 数据#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 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 获取搜索结果#
1
2
3
4
| 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
即可获得加密数据。
1
2
3
| 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
,仍然是同样的加密手段。
1
2
3
| post_data = '{{"id":"{0}","c":"[{{\\"id\\":\\"{0}\\"}}]","csrf_token":""}}'.format(
歌曲 id
)
|
返回的结果不仅给出了以毫秒为单位的时长信息,也有歌名、歌手和专辑的信息。