音乐免费下载工具开发思路和技术实现

2020-04-09  本文已影响0人  一只小安仔

音乐免费下载工具开发思路和技术实现

起因

事情要从一个月前说起,我妈答应了别人帮他下载一些音乐到手机内存卡,妈自己觉得麻烦,于是喊我下,我溜达完一圈发现,市面的音乐平台下载音乐都是要VIP资格的。

然后

然后发现了一个网站 VIP会员付费音乐解析下载网 ,这个网站搜索音乐、在线播放,但是下载的话会打开文件链接,不会自动下载,需要手动保存音乐文件,并且保存的文件名字是随机字符串的,这让我很难受。如下,是李荣浩的《麻雀》

资源文件链接
下载的资源文件

再然后

再然后,我想着我直接写一个界面,后端去调用他家的接口拿数据并把下载做个集成,当然下载的文件改名为 歌名 - 歌手.mp3 的格式是十分必要的,还有就是需要一个批量下载的功能,这也是十分重要的。

这件事做的也还比较顺,因为他家的接口还是很好理解的,接口,参数,返回的数据格式类型,一眼便知。
比如搜索接口:POST https://music.zhuolin.wang/api.php
参数:[图片上传失败...(image-24c23d-1586423660027)]
返回数据:

返回数据
接口简单,后端我使用spring的RestTemplate去接口拿数据也是顺风顺水,我简单的写两个前端来显示数据,为了简化开发,我使用的非前后端分离的方式,就写了一个index.html ,但是用到了vue + elementui,所有的资源文件和图片均来自于网上。界面如下
前端设计
在后端方面我实现了下下载和批量下载,目前只是开发了网易云的音乐下载,所以资源文件的接口是 http://music.163.com/song/media/outer/url?(歌曲id).mp3,使用简单的io流技术即可实现。如下是单曲下载的实现:
 @Override
    public String downLoadMusic(Song song) {
        // check 文件夹存在
        if(!isExist) {
            File file = new File(systemProperties.getDOWNLOAD_PATH());
            if(!file.isDirectory())
                file.mkdirs();
            isExist = true;
        }
        String url = MUSIC_163_DOWNLOAD_URL + "?id=" + song.getId() + ".mp3";
        ResponseEntity<byte[]> forEntity = restTemplate.getForEntity(url, byte[].class);
        if(forEntity.getStatusCode() == HttpStatus.OK) {
            byte[] body = forEntity.getBody();
            try {
                StringBuilder builder = new StringBuilder(systemProperties.getDOWNLOAD_PATH())
                        .append("\\")
                        .append(song.getName().replaceAll("\\\\", "\\\\\\\\"))
                        .append("-")
                        .append(song.getAr().get(0).getName())
                        .append(".mp3");
                FileOutputStream fileOutputStream = new FileOutputStream(new File(builder.toString()));
                fileOutputStream.write(body);
                fileOutputStream.close();
                return "歌曲 " + song.getName() + " 已经下载在本地" + systemProperties.getDOWNLOAD_PATH() + "目录!";
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return "0";
    }

再然后

这家的接口完成单曲的下载基本没有什么问题。但后来我发现一首一首搜索下载还是太过于麻烦,我希望能对歌单里的歌曲直接下载。但随之而来也产生了问题,这家的兄弟没有提供歌单搜索的接口。而且一个长远的问题也困扰与我,我不知道这家网站能运行多久。如果他挂了,我做的努力不是白费了。

于是我需要改变战略,直接去调用网易云的接口。大厂一时半会也不会挂。而且接口肯定全面。

网易云接口分析

首先我们需要分析搜索的接口,当然前提是先找到这个接口,从数不清的请求中我找到了这个接口:


真实接口

注意区别于另一个接口,我一开始弄混了,整了了半天。


词条提示的接口
url上的 csrf_token 是用来保障账户安全的,目前我们不做登录,这个参数我们不管它。

再往下就是参数了: [图片上传失败...(image-c97587-1586423660028)]
params和encSecKey参数一看就知道是加密了的,这说明它发起这个请求前把我们输入的搜索内容加密了。如果我们希望调用这个接口,就必须知道是如何加密的。当然,如果你去看了其他一些接口,都有这两个参数,且都需要加密。所以了解这两参数的加密方式是调用网易云音乐大部分接口所必需的。

params和encSecKey如何加密的

我们需要先找到对这两参数加密的js文件,params作为搜索条件的话不太适合(很多js文件都用到了),所以我们选择encSecKey作为搜索条件。我们发现只在一个js文件中找到:


js文件位置

我们把这个文件保存到本地,由于这个文件是压缩版,不太好看,我们进行格式化再看,发现有三处存在encSecKey:
第一处:


第一处
第二三处:
在这里插入图片描述

我们大致能看出第二三处好像就是在给params和encSecKey设置值,值来源于bVj7c对象,而bVj7c又指向window.asrsea函数,您可别马上以为这个函数是window自带的,可不是的,通过我们对window.asrsea的搜索,发现在第一处的d函数的下方有这么一句代码:


window.asrsea

说了半天,秘密都在这个d函数,d喊出最终返回h,这个h的encText就是params参数,encSecKey就是encSecKey参数。

d函数分析

首先我们需要先知道d函数的四个参数是什么,怎么知道呢?看看呗,怎么看呢?
我们把下载的源文件的d函数中加入打印这四个参数的代码。注意是在下载的源文件中,因为害怕格式化破坏了然后出些幺蛾子。

console.log('d=' + d);console.log('e=' + e);console.log('f=' + f);console.log('g=' + g);

接下来我们需要把网易云返回的这个js文件替换为我们修改后的。直接通过浏览器进行替换我尝试了好像不行,所以我们需要用到抓包工具进行替换,我这里使用charles(花瓶),网上也有人使用fiddler,您可以自己选择。
这里是charles的百度云资源,破解方式请【参考】,不破解30分钟后自动关闭,这点很蛋疼。

抓包工具配置

抓包工具需要用到代理方式,打开windows电脑进行设置:

设置代理

打开charles,安装证书(为了能看到抓到的资源,否则显示unknow,不安装没法后续):
Help -> SSL Proxying -> Install Charles root Certificate

安装证书
安装证书 在这里插入图片描述

之后下一步完成即可。

然后浏览器禁用缓存,并刷新网易云音乐网站。
charles中就能看到各种网易云的请求:

charles查看请求

替换js文件

替换js文件
替换js文件

d函数传入参数都是啥

我们对网易云网站再次刷新,并查看控制台:

参数查看

结论:经过多次测试,我们发现,除了d参数对不同请求变化,其他e、f、g参数均不发生变化(即为固定值)。

再经过我的查找,发现发送搜索请求时的d参数为:{"hlpretag":"<span class="s-fc7">","hlposttag":"</span>","#/":"","s":"麻雀","type":"1","offset":"0","total":"true","limit":"30","csrf_token":""}
其中我大概明白s为搜索的内容,type应该为搜索方式(1为单曲搜索),limit为搜索条数。其他参数可以参考

回到d函数本身

d函数本身

i = a(16) 这个i经过测试发现是一个随机的16位字符串,然后你发现encText(即params参数)两次调用b函数。b函数如下:


b函数

您不需要完全懂它,它大致是一个aes加密算法,CBC模式,偏移量为固定值:0102030405060708

再看encSecKey是经过c函数产生。并传入i,e,f。之前说过了,e,f是固定值,那么如果i我们不让它为随机值,让它也为固定值,那岂不是encSecKey的值便固定了。

大胆猜测,大胆尝试,继续编辑之前的js文件把i值改为 FFFFFFFFFFFFFFFF(即16个F)


替换i为固定值

测试数据搜索麻雀:


麻雀参数

测试数据搜索小安:


小安参数

根据测试发现encSecKey值未发生改变,数据也成功获取。说明i值可以固定。

java代码实现加密

我们的主要问题就是params的产生了,之前说过它使用了aes算法,CBC模式,偏移量为0102030405060708 ,java代码实现如下:

     /**
     * aes 加密偏移量
     */
    private static String ivParameter = "0102030405060708";


    /**
     * aes加密
     * @param content
     * @param key
     * @return
     */
    public static String AESEncrypt(String content, String key) {
        try {
            byte[] byteContent = content.getBytes("UTF-8");
            //获取cipher对象("算法/工作模式/填充模式")
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            //采用AES方式将密码转化成密钥
            SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "AES");
            //初始化偏移量
            IvParameterSpec iv = new IvParameterSpec(ivParameter.getBytes());
            //cipher对象初始化(“加密/解密,密钥,偏移量”)
            cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv);
            //数据处理
            byte[] encryptedBytes = cipher.doFinal(byteContent);
            return new String(Base64Utils.encode(encryptedBytes), "UTF-8");
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

encSecKey值是固定的,所以:

    /**
     * encSecKey这个值经测试是不变的,直接抄下来
     * @return
     */
    private static String getEncSecKey() {
        return "257348aecb5e556c066de214e531faadd1c55d814f9be95fd06d6bff9f4c7a41f831f6394d5a3fd2e3881736d94a02ca919d952872e7d0a50ebfa1769a7a62d512f5f1ca21aec60bc3819a9c3ffca5eca9a0dba6d6f7249b06f5965ecfff3695b54e1c28f3f624750ed39e7de08fc8493242e26dbc4484a01c76f739e135637c";
    }

d函数是对 encText(即params) 加密两次,所以:

    /**
     * 对数据两次加密获得参数params
     * @param content 该参数应当是个json串  例如:{"s":"hello","csrf_token":"952d1c697b8d0647e8c7f19c16a0f753"}
     * @param key 该参数是d方法的最后一个参数 例如:0CoJUm6Qyw8W8jud
     * @return
     */
    private static String getParams(String content, String key) {
        // 第一次加密
        String s = AESEncrypt(content, key);
        // 第二次加密 
        return AESEncrypt(s, "FFFFFFFFFFFFFFFF");
    }

搜索单曲的话应该是:

    /**
     * 搜索单曲
     * @param searchValue
     * @return
     */
    public static MusicParams getMusicParams(String searchValue) {
        MusicParams musicParams = new MusicParams();
        musicParams.setParams(getParams("{\"hlpretag\":\"<span class=\\\"s-fc7\\\">\",\"hlposttag\":\"</span>\",\"#/\":\"\",\"s\":\""+ searchValue +"\",\"type\":\"1\",\"offset\":\"0\",\"total\":\"true\",\"limit\":\"30\",\"csrf_token\":\"\"}", d_params4));
        musicParams.setEncSecKey(getEncSecKey());
        return musicParams;
    }

搜索歌单的话:


    /**
     * 搜索歌单
     * @param searchValue
     * @return
     */
    public static MusicParams getPlsyListParams(String searchValue) {
        MusicParams musicParams = new MusicParams();
        musicParams.setParams(getParams("{\"hlpretag\":\"<span class=\\\"s-fc7\\\">\",\"hlposttag\":\"</span>\",\"#/search/m\":\"\",\"s\":\""+ searchValue +"\",\"type\":\"1000\",\"offset\":\"0\",\"total\":\"true\",\"limit\":\"30\",\"csrf_token\":\"\"}", d_params4));
        musicParams.setEncSecKey(getEncSecKey());
        return musicParams;
    }

搜索单曲信息的话:

    /**
     * 获取单曲信息
     * @param id 歌曲ID
     * @return
     */
    public static MusicParams getSongParams(Long id) {
        MusicParams musicParams = new MusicParams();
        musicParams.setParams(getParams("{\"id\":\""+ id +"\",\"c\":\"[{\\\"id\\\":\\\""+ id +"\\\"}]\",\"csrf_token\":\"\"}", d_params4));
        musicParams.setEncSecKey(getEncSecKey());
        return musicParams;
    }

以下是整个AESUtil的全部代码

/**
 * @author junan
 * @version V1.0
 * @className AESUtil
 * @disc 该类用于生成网易云音乐请求参数
 * @date 2020/4/8 0:53
 */
public class AESUtil {

    /**
     * d方法的第2个参数
     */
    private static String d_params2 = "010001";

    /**
     * d方法的第3个参数
     */
    private static String d_params3 = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7";

    /**
     * d方法的第4个参数
     */
    private static String d_params4 = "0CoJUm6Qyw8W8jud";

    /**
     * aes 加密偏移量
     */
    private static String ivParameter = "0102030405060708";

    /**
     * aes加密
     * @param content
     * @param key
     * @return
     */
    public static String AESEncrypt(String content, String key) {
        try {
            byte[] byteContent = content.getBytes("UTF-8");
            //获取cipher对象,getInstance("算法/工作模式/填充模式")
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            //采用AES方式将密码转化成密钥
            SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "AES");
            //初始化偏移量
            IvParameterSpec iv = new IvParameterSpec(ivParameter.getBytes());
            //cipher对象初始化 init(“加密/解密,密钥,偏移量”)
            cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv);
            //按照上面定义的方式对数据进行处理。
            byte[] encryptedBytes = cipher.doFinal(byteContent);
            return new String(Base64Utils.encode(encryptedBytes), "UTF-8");
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 对数据两次加密获得参数params
     * @param content 该参数应当是个json串  例如:{"s":"hello","csrf_token":"952d1c697b8d0647e8c7f19c16a0f753"}
     * @param key 该参数是d方法的最后一个参数 例如:0CoJUm6Qyw8W8jud
     * @return
     */
    private static String getParams(String content, String key) {
        // 第一次加密
        String s = AESEncrypt(content, key);
        // 第二次加密
        return AESEncrypt(s, "FFFFFFFFFFFFFFFF");
    }


    /**
     * encSecKey这个值经测试是不变的,直接抄一个
     * @return
     */
    private static String getEncSecKey() {
        return "257348aecb5e556c066de214e531faadd1c55d814f9be95fd06d6bff9f4c7a41f831f6394d5a3fd2e3881736d94a02ca919d952872e7d0a50ebfa1769a7a62d512f5f1ca21aec60bc3819a9c3ffca5eca9a0dba6d6f7249b06f5965ecfff3695b54e1c28f3f624750ed39e7de08fc8493242e26dbc4484a01c76f739e135637c";
    }

    /**
     * 搜索单曲
     * @param searchValue
     * @return
     */
    public static MusicParams getMusicParams(String searchValue) {
        MusicParams musicParams = new MusicParams();
        musicParams.setParams(getParams("{\"hlpretag\":\"<span class=\\\"s-fc7\\\">\",\"hlposttag\":\"</span>\",\"#/\":\"\",\"s\":\""+ searchValue +"\",\"type\":\"1\",\"offset\":\"0\",\"total\":\"true\",\"limit\":\"30\",\"csrf_token\":\"\"}", d_params4));
        musicParams.setEncSecKey(getEncSecKey());
        return musicParams;
    }


    /**
     * 搜索歌单
     * @param searchValue
     * @return
     */
    public static MusicParams getPlsyListParams(String searchValue) {
        MusicParams musicParams = new MusicParams();
        musicParams.setParams(getParams("{\"hlpretag\":\"<span class=\\\"s-fc7\\\">\",\"hlposttag\":\"</span>\",\"#/search/m\":\"\",\"s\":\""+ searchValue +"\",\"type\":\"1000\",\"offset\":\"0\",\"total\":\"true\",\"limit\":\"30\",\"csrf_token\":\"\"}", d_params4));
        musicParams.setEncSecKey(getEncSecKey());
        return musicParams;
    }


    /**
     * 获取单曲信息
     * @param id 歌曲ID
     * @return
     */
    public static MusicParams getSongParams(Long id) {
        MusicParams musicParams = new MusicParams();
        musicParams.setParams(getParams("{\"id\":\""+ id +"\",\"c\":\"[{\\\"id\\\":\\\""+ id +"\\\"}]\",\"csrf_token\":\"\"}", d_params4));
        musicParams.setEncSecKey(getEncSecKey());
        return musicParams;
    }


}

以上基本是核心的东西吧,也算是对网易云接口的探索。
下面是一些接口数据的获取的核心代码(请结合项目看,我进行了一些封装):

    @Override
    public List<Song> searchMusic(String searchValue) {
        MusicParams musicParams = AESUtil.getMusicParams(searchValue);
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(musicParamsToMap(musicParams), headers);
        ResponseEntity<String> res = restTemplate.postForEntity(MUSIC_163_PARENT_URL + "weapi/cloudsearch/get/web", request, String.class);
        if(res.getStatusCode() == HttpStatus.OK) {
            String songs = FastJsonUtil.getNodeString(res.getBody(), "result");
            return FastJsonUtil.praseNodeStringToList(songs, "songs", Song.class);
        }
        return null;
    }

另外,前端方面我使用 aplyer 新增了在线播放的功能。(但它好像有些bug)


前端页面

项目代码请参考【music-dow】,目前只写了一部分,可以搜索下载单曲,批量下载等,歌单下载还在开发,默认下载到 d:\test 文件下,可到 application.properties 文件下修改路径。如果下载不成功,说明那首歌需要vip,你懂的啦。

其他

1 期间很多测试接口时用到了postman,博客过滤掉了,但不影响您使用。
2 网易云接口思路参考了【这篇文章】。

路漫漫其修远兮 吾将上下而求索

上一篇 下一篇

猜你喜欢

热点阅读