音乐爬虫python 音乐爬虫api_音乐爬虫python

前言

永远相信美好的事情即将发生


背景

一直想做一个在线的音乐播放器,这个想法最早可以追溯到做毕设的那会,那时候做了个在线的商城系统, 里面有个在线听歌的模块,其实就是调用大佬们封装好的API进行搜索和播放。当时一直想着自己去找接口进行封装,但奈何一直没有时间(其实就是惰性),这段时间终于不怎么忙了,于是决定完成这个拖延了一年的 “需求”


准备

开发环境:Python 3.8 64位
开发工具:Pycharm
浏览器:Chrome


历程

接口获取

首先我们需要在浏览器中打开网易云音乐,别问我为什么要使用网易云

音乐爬虫python 音乐爬虫api_经验分享_02


打开之后随便搜索一首歌曲,然后 F12 打开开发者模式

音乐爬虫python 音乐爬虫api_爬虫_03


依次对这几条请求进行分析,其中有一条 web?csrf_token= 响应的数据比较可疑,对数据进行json格式化后如图

音乐爬虫python 音乐爬虫api_爬虫_04


凭借我小学三年级的英语水平来看,基本就可以断定这条请求就是歌曲搜索的请求。然后

音乐爬虫python 音乐爬虫api_音乐爬虫python_05

JS分析

接下来我们对请求的数据进行分析,点开 Headers 后我们可以发现,除了传统的请求头参数外,本次请求还携带了一个 Form 表单,其中有两个参数,分别是 paramsencSecKey

音乐爬虫python 音乐爬虫api_经验分享_06


经过对其他参数携带的信息分析后我们可以发现,这两个参数就是进行搜索的关键参数,于是我们对 encSecKey 参数进行如下搜索

音乐爬虫python 音乐爬虫api_python_07


双击第一个js,进行格式化后使用 Ctrl+F 进行搜索,如下图所示

音乐爬虫python 音乐爬虫api_音乐爬虫python_08


不难发现, 这两个参数都是通过一个名为 bVZ7S(asrsea) 的函数获取到的,那我们现在就对这个 asrsea 进行搜索

音乐爬虫python 音乐爬虫api_pycharm_09

搜索如图,可以看出 asrsea 函数其实就是一个名为 d 的函数

音乐爬虫python 音乐爬虫api_pycharm_10

现在对这个 d 函数进行分析,首先它执行了一次 a 函数,然后又连续执行了两次 b 函数,最后执行了一次 c 函数,然后拿到一个最终的结果

现在我们依次对这几个函数进行解析:

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
    }

a 函数的逻辑还是比较简单的,就是从大小写英文字母以及10个数字中随机抽取16个字符拼接成一个新的字符串返回结果

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()
    }

b 函数的逻辑还是稍微复杂,通过对关键词进行分析我们可以判断是通过 CBC 模式进行 AES 加密,将传入的 a 参数和 b 参数分别作为需要加密的内容和密钥,iv偏移量为一个固定的字符串 0102030405060708,完成加密

音乐爬虫python 音乐爬虫api_pycharm_11

function c(a, b, c) {
        var d, e;
        return setMaxDigits(131),
        d = new RSAKeyPair(b,"",c),
        e = encryptedString(d, a)
    }

c 函数算是个大坑了,当时看到里面有个 RSA 字样,就自然而然的以为是一个RSA加密,等到找公钥私钥的时候才发现不对劲,这咋啥都没有啊,于是苦苦搜索js文件,发现就是一连串的字符串操作,和 RSA 没一点关系,代码部分参考了大佬的写法,大家可以研究一下js


对主函数 d 的分析就算结束了,接下来我们再返回js文件对传入的参数进行分析

首先,我们在网易云音乐的搜索框中输入需要搜索的歌曲名,然后在js中 d 函数处打上断点,多打几个

音乐爬虫python 音乐爬虫api_爬虫_12

可以看到,d 函数分别传入了以下参数:

d:{"hlpretag":"<span class=\"s-fc7\">","hlposttag":"</span>","s":"Lily","type":"1","offset":"0","total":"true","limit":"30","csrf_token":""}
e:"010001"
f:"00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g:"0CoJUm6Qyw8W8jud"

通过多首歌曲的测试我们发现,参数 e,f,g 始终保持不表,由此可见它们是3个常量,只是参数 d 中的歌曲名称在变化

接下来我们在测试一下歌曲播放时的请求参数

音乐爬虫python 音乐爬虫api_python_13

依旧对传入的几个参数进行获取

d:{"ids":"[1333159921]","level":"standard","encodeType":"aac","csrf_token":""}
e:"010001"
f:"00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g:"0CoJUm6Qyw8W8jud"

可以看出,播放歌曲其实和搜索歌曲用的是同一个方法,只是传入的参数不同,准确点说只是传入的d参数不同,一个传的是歌曲的名称,而另一个则是歌曲的id

OK,到此我们就算完成了对网易云音乐Web版整个逻辑的梳理,接下来就是用Python代码去实现

音乐爬虫python 音乐爬虫api_python_14

Python模拟

#!D:/Code/python
# -*- coding: utf-8 -*-
# @Time : 2020/8/22 12:32
# @Author : Am0xil
# @Description : 网易云音乐模拟
import base64
import binascii
import json
import random
import string
from urllib import parse

import requests
from Crypto.Cipher import AES


# 从a-z,A-Z,0-9中随机获取16位字符
def get_random():
    random_str = ''.join(random.sample(string.ascii_letters + string.digits, 16))
    return random_str


# AES加密要求加密的文本长度必须是16的倍数,密钥的长度固定只能为16,24或32位,因此我们采取统一转换为16位的方法
def len_change(text):
    pad = 16 - len(text) % 16
    text = text + pad * chr(pad)
    text = text.encode("utf-8")
    return text


# AES加密方法
def aes(text, key):
    # 首先对加密的内容进行位数补全,然后使用 CBC 模式进行加密
    iv = b'0102030405060708'
    text = len_change(text)
    cipher = AES.new(key.encode(), AES.MODE_CBC, iv)
    encrypted = cipher.encrypt(text)
    encrypt = base64.b64encode(encrypted).decode()
    return encrypt


# js中的 b 函数,调用两次 AES 加密
# text 为需要加密的文本, str 为生成的16位随机数
def b(text, str):
    first_data = aes(text, '0CoJUm6Qyw8W8jud')
    second_data = aes(first_data, str)
    return second_data


# 这就是那个巨坑的 c 函数
def c(text):
    e = '010001'
    f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
    text = text[::-1]
    result = pow(int(binascii.hexlify(text.encode()), 16), int(e, 16), int(f, 16))
    return format(result, 'x').zfill(131)


# 获取最终的参数 params 和 encSecKey 的方法
def get_final_param(text, str):
    params = b(text, str)
    encSecKey = c(str)
    return {'params': params, 'encSecKey': encSecKey}


# 通过参数获取搜索歌曲的列表
def get_music_list(params, encSecKey):
    url = "https://music.163.com/weapi/cloudsearch/get/web?csrf_token="

    payload = 'params=' + parse.quote(params) + '&encSecKey=' + parse.quote(encSecKey)
    headers = {
        'authority': 'music.163.com',
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36',
        'content-type': 'application/x-www-form-urlencoded',
        'accept': '*/*',
        'origin': 'https://music.163.com',
        'sec-fetch-site': 'same-origin',
        'sec-fetch-mode': 'cors',
        'sec-fetch-dest': 'empty',
        'referer': 'https://music.163.com/search/',
        'accept-language': 'zh-CN,zh;q=0.9',
    }
    response = requests.request("POST", url, headers=headers, data=payload)
    return response.text


# 通过歌曲的id获取播放链接
def get_reply(params, encSecKey):
    url = "https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token="
    payload = 'params=' + parse.quote(params) + '&encSecKey=' + parse.quote(encSecKey)
    headers = {
        'authority': 'music.163.com',
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36',
        'content-type': 'application/x-www-form-urlencoded',
        'accept': '*/*',
        'origin': 'https://music.163.com',
        'sec-fetch-site': 'same-origin',
        'sec-fetch-mode': 'cors',
        'sec-fetch-dest': 'empty',
        'referer': 'https://music.163.com/',
        'accept-language': 'zh-CN,zh;q=0.9'
    }
    response = requests.request("POST", url, headers=headers, data=payload)
    return response.text


if __name__ == '__main__':
    song_name = input('请输入歌曲名称,按回车键搜索:')
    d = {"hlpretag": "<span class=\"s-fc7\">", "hlposttag": "</span>", "s": song_name, "type": "1", "offset": "0",
         "total": "true", "limit": "30", "csrf_token": ""}
    d = json.dumps(d)
    random_param = get_random()
    param = get_final_param(d, random_param)
    song_list = get_music_list(param['params'], param['encSecKey'])
    print('搜索结果如下:')
    if len(song_list) > 0:
        song_list = json.loads(song_list)['result']['songs']
        for i, item in enumerate(song_list):
            item = json.dumps(item)
            print(str(i) + ":" + json.loads(str(item))['name'])
            d = {"ids": "[" + str(json.loads(str(item))['id']) + "]", "level": "standard", "encodeType": "",
                 "csrf_token": ""}
            d = json.dumps(d)
            param = get_final_param(d, random_param)
            song_info = get_reply(param['params'], param['encSecKey'])
            if len(song_info) > 0:
                song_info = json.loads(song_info)
                song_url = json.dumps(song_info['data'][0]['url'], ensure_ascii=False)
                print(song_url)
            else:
                print("该首歌曲解析失败,可能是因为歌曲格式问题")
    else:
        print("很抱歉,未能搜索到相关歌曲信息")

手机端经常出现代码被吞的情况,各位看官可以移步我的 GitHub , 不胜荣幸

感谢大佬 @一只不会爬的虫子 的指点,最开始的 AES 填充方法有点问题,导致部分歌曲搜索不到,经修改后正常,如果后期有什么新的问题欢迎各位留言

测试

接下来我们启动程序进行测试,测试结果如下:

音乐爬虫python 音乐爬虫api_爬虫_15

可以看到,已经获取到歌曲 酷爱 的部分搜索结果,我们随机复制一条播放链接到浏览器(记得删掉前后的双引号)进行测试,结果如图

音乐爬虫python 音乐爬虫api_爬虫_16

OK,大功告成

其实最开始是做成直接播放的模式,但Python好像只支持播放本地文件,不支持URL的形式,当然还有可能是因为我太菜了,如果谁有好的方案也欢迎各位大佬赐教,万分感谢


总结

爬取过程中碰到过如下几个比较耗费时间的问题,特此记录

1.进行AES加密时需要将加密内容填充满16位,尽量不要使用其他字符,否则会生成无效的params和encSecKey参数,使用该参数请求接口将返回空数据;
2.在访问接口时,切记对params和encSecKey参数进行URL编码,将其中的 “=” 转换为 “%3D” ,否则返回结果也将为空;
3.请求歌曲播放地址时目前只能请求到MP3格式,M4A格式的歌曲暂时无法获取,应该是参数设置的问题,后面有时间在解决,当然也欢迎各位大佬指点一二
4.搜索歌曲时偶尔会存在搜索失败的情况,重新搜索后又正常,暂时未发现是什么Bug,后面有时间的话再改吧