音樂(lè)免費(fèi)下載工具開(kāi)發(fā)思路和技術(shù)實(shí)現(xiàn)
起因
事情要從一個(gè)月前說(shuō)起煤惩,我媽答應(yīng)了別人幫他下載一些音樂(lè)到手機(jī)內(nèi)存卡畜晰,媽自己覺(jué)得麻煩喻犁,于是喊我下,我溜達(dá)完一圈發(fā)現(xiàn)上岗,市面的音樂(lè)平臺(tái)下載音樂(lè)都是要VIP資格的福荸。
然后
然后發(fā)現(xiàn)了一個(gè)網(wǎng)站 VIP會(huì)員付費(fèi)音樂(lè)解析下載網(wǎng) ,這個(gè)網(wǎng)站搜索音樂(lè)肴掷、在線播放敬锐,但是下載的話會(huì)打開(kāi)文件鏈接背传,不會(huì)自動(dòng)下載,需要手動(dòng)保存音樂(lè)文件台夺,并且保存的文件名字是隨機(jī)字符串的径玖,這讓我很難受。如下颤介,是李榮浩的《麻雀》
再然后
再然后梳星,我想著我直接寫一個(gè)界面,后端去調(diào)用他家的接口拿數(shù)據(jù)并把下載做個(gè)集成滚朵,當(dāng)然下載的文件改名為 歌名 - 歌手.mp3 的格式是十分必要的冤灾,還有就是需要一個(gè)批量下載的功能,這也是十分重要的辕近。
這件事做的也還比較順韵吨,因?yàn)樗业慕涌谶€是很好理解的,接口移宅,參數(shù)归粉,返回的數(shù)據(jù)格式類型,一眼便知漏峰。
比如搜索接口:POST https://music.zhuolin.wang/api.php
參數(shù):[圖片上傳失敗...(image-24c23d-1586423660027)]
返回?cái)?shù)據(jù):
接口簡(jiǎn)單糠悼,后端我使用spring的RestTemplate去接口拿數(shù)據(jù)也是順風(fēng)順?biāo)液?jiǎn)單的寫兩個(gè)前端來(lái)顯示數(shù)據(jù)芽狗,為了簡(jiǎn)化開(kāi)發(fā),我使用的非前后端分離的方式痒蓬,就寫了一個(gè)index.html 童擎,但是用到了vue + elementui,所有的資源文件和圖片均來(lái)自于網(wǎng)上攻晒。界面如下
在后端方面我實(shí)現(xiàn)了下下載和批量下載顾复,目前只是開(kāi)發(fā)了網(wǎng)易云的音樂(lè)下載,所以資源文件的接口是 http://music.163.com/song/media/outer/url?(歌曲id).mp3鲁捏,使用簡(jiǎn)單的io流技術(shù)即可實(shí)現(xiàn)芯砸。如下是單曲下載的實(shí)現(xiàn):
@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() + " 已經(jīng)下載在本地" + systemProperties.getDOWNLOAD_PATH() + "目錄!";
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
return "0";
}
再然后
這家的接口完成單曲的下載基本沒(méi)有什么問(wèn)題给梅。但后來(lái)我發(fā)現(xiàn)一首一首搜索下載還是太過(guò)于麻煩假丧,我希望能對(duì)歌單里的歌曲直接下載。但隨之而來(lái)也產(chǎn)生了問(wèn)題动羽,這家的兄弟沒(méi)有提供歌單搜索的接口包帚。而且一個(gè)長(zhǎng)遠(yuǎn)的問(wèn)題也困擾與我,我不知道這家網(wǎng)站能運(yùn)行多久运吓。如果他掛了渴邦,我做的努力不是白費(fèi)了疯趟。
于是我需要改變戰(zhàn)略,直接去調(diào)用網(wǎng)易云的接口谋梭。大廠一時(shí)半會(huì)也不會(huì)掛信峻。而且接口肯定全面。
網(wǎng)易云接口分析
首先我們需要分析搜索的接口瓮床,當(dāng)然前提是先找到這個(gè)接口盹舞,從數(shù)不清的請(qǐng)求中我找到了這個(gè)接口:
注意區(qū)別于另一個(gè)接口,我一開(kāi)始弄混了纤垂,整了了半天矾策。
url上的 csrf_token 是用來(lái)保障賬戶安全的,目前我們不做登錄峭沦,這個(gè)參數(shù)我們不管它贾虽。
再往下就是參數(shù)了: [圖片上傳失敗...(image-c97587-1586423660028)]
params和encSecKey參數(shù)一看就知道是加密了的,這說(shuō)明它發(fā)起這個(gè)請(qǐng)求前把我們輸入的搜索內(nèi)容加密了吼鱼。如果我們希望調(diào)用這個(gè)接口蓬豁,就必須知道是如何加密的。當(dāng)然菇肃,如果你去看了其他一些接口地粪,都有這兩個(gè)參數(shù),且都需要加密琐谤。所以了解這兩參數(shù)的加密方式是調(diào)用網(wǎng)易云音樂(lè)大部分接口所必需的蟆技。
params和encSecKey如何加密的
我們需要先找到對(duì)這兩參數(shù)加密的js文件,params作為搜索條件的話不太適合(很多js文件都用到了)斗忌,所以我們選擇encSecKey作為搜索條件质礼。我們發(fā)現(xiàn)只在一個(gè)js文件中找到:
我們把這個(gè)文件保存到本地,由于這個(gè)文件是壓縮版织阳,不太好看眶蕉,我們進(jìn)行格式化再看,發(fā)現(xiàn)有三處存在encSecKey:
第一處:
第二三處:
我們大致能看出第二三處好像就是在給params和encSecKey設(shè)置值唧躲,值來(lái)源于bVj7c對(duì)象造挽,而bVj7c又指向window.asrsea函數(shù),您可別馬上以為這個(gè)函數(shù)是window自帶的弄痹,可不是的饭入,通過(guò)我們對(duì)window.asrsea的搜索贤笆,發(fā)現(xiàn)在第一處的d函數(shù)的下方有這么一句代碼:
說(shuō)了半天鱼辙,秘密都在這個(gè)d函數(shù)裕坊,d喊出最終返回h沮翔,這個(gè)h的encText就是params參數(shù)审姓,encSecKey就是encSecKey參數(shù)伦乔。
d函數(shù)分析
首先我們需要先知道d函數(shù)的四個(gè)參數(shù)是什么忌锯,怎么知道呢棠赛?看看唄,怎么看呢饭耳?
我們把下載的源文件的d函數(shù)中加入打印這四個(gè)參數(shù)的代碼串述。注意是在下載的源文件中,因?yàn)楹ε赂袷交茐牧巳缓蟪鲂╃鄱曜印?/p>
console.log('d=' + d);console.log('e=' + e);console.log('f=' + f);console.log('g=' + g);
接下來(lái)我們需要把網(wǎng)易云返回的這個(gè)js文件替換為我們修改后的寞肖。直接通過(guò)瀏覽器進(jìn)行替換我嘗試了好像不行纲酗,所以我們需要用到抓包工具進(jìn)行替換,我這里使用charles(花瓶)新蟆,網(wǎng)上也有人使用fiddler觅赊,您可以自己選擇。
這里是charles的百度云資源琼稻,破解方式請(qǐng)【參考】吮螺,不破解30分鐘后自動(dòng)關(guān)閉,這點(diǎn)很蛋疼帕翻。
抓包工具配置
抓包工具需要用到代理方式鸠补,打開(kāi)windows電腦進(jìn)行設(shè)置:
打開(kāi)charles,安裝證書(為了能看到抓到的資源嘀掸,否則顯示unknow紫岩,不安裝沒(méi)法后續(xù)):
Help -> SSL Proxying -> Install Charles root Certificate
之后下一步完成即可。
然后瀏覽器禁用緩存睬塌,并刷新網(wǎng)易云音樂(lè)網(wǎng)站泉蝌。
charles中就能看到各種網(wǎng)易云的請(qǐng)求:
替換js文件
d函數(shù)傳入?yún)?shù)都是啥
我們對(duì)網(wǎng)易云網(wǎng)站再次刷新,并查看控制臺(tái):
結(jié)論:經(jīng)過(guò)多次測(cè)試揩晴,我們發(fā)現(xiàn)勋陪,除了d參數(shù)對(duì)不同請(qǐng)求變化,其他e文狱、f粥鞋、g參數(shù)均不發(fā)生變化(即為固定值)缘挽。
再經(jīng)過(guò)我的查找瞄崇,發(fā)現(xiàn)發(fā)送搜索請(qǐng)求時(shí)的d參數(shù)為:{"hlpretag":"<span class="s-fc7">","hlposttag":"</span>","#/":"","s":"麻雀","type":"1","offset":"0","total":"true","limit":"30","csrf_token":""}
其中我大概明白s為搜索的內(nèi)容,type應(yīng)該為搜索方式(1為單曲搜索)壕曼,limit為搜索條數(shù)苏研。其他參數(shù)可以參考
回到d函數(shù)本身
i = a(16) 這個(gè)i經(jīng)過(guò)測(cè)試發(fā)現(xiàn)是一個(gè)隨機(jī)的16位字符串,然后你發(fā)現(xiàn)encText(即params參數(shù))兩次調(diào)用b函數(shù)腮郊。b函數(shù)如下:
您不需要完全懂它摹蘑,它大致是一個(gè)aes加密算法,CBC模式轧飞,偏移量為固定值:0102030405060708
再看encSecKey是經(jīng)過(guò)c函數(shù)產(chǎn)生衅鹿。并傳入i撒踪,e,f大渤。之前說(shuō)過(guò)了制妄,e,f是固定值泵三,那么如果i我們不讓它為隨機(jī)值耕捞,讓它也為固定值,那豈不是encSecKey的值便固定了烫幕。
大膽猜測(cè)俺抽,大膽嘗試,繼續(xù)編輯之前的js文件把i值改為 FFFFFFFFFFFFFFFF(即16個(gè)F)
測(cè)試數(shù)據(jù)搜索麻雀:
測(cè)試數(shù)據(jù)搜索小安:
根據(jù)測(cè)試發(fā)現(xiàn)encSecKey值未發(fā)生改變较曼,數(shù)據(jù)也成功獲取磷斧。說(shuō)明i值可以固定。
java代碼實(shí)現(xiàn)加密
我們的主要問(wèn)題就是params的產(chǎn)生了诗芜,之前說(shuō)過(guò)它使用了aes算法瞳抓,CBC模式,偏移量為0102030405060708 伏恐,java代碼實(shí)現(xiàn)如下:
/**
* 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對(duì)象("算法/工作模式/填充模式")
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
//采用AES方式將密碼轉(zhuǎn)化成密鑰
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "AES");
//初始化偏移量
IvParameterSpec iv = new IvParameterSpec(ivParameter.getBytes());
//cipher對(duì)象初始化(“加密/解密,密鑰孩哑,偏移量”)
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv);
//數(shù)據(jù)處理
byte[] encryptedBytes = cipher.doFinal(byteContent);
return new String(Base64Utils.encode(encryptedBytes), "UTF-8");
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
encSecKey值是固定的,所以:
/**
* encSecKey這個(gè)值經(jīng)測(cè)試是不變的翠桦,直接抄下來(lái)
* @return
*/
private static String getEncSecKey() {
return "257348aecb5e556c066de214e531faadd1c55d814f9be95fd06d6bff9f4c7a41f831f6394d5a3fd2e3881736d94a02ca919d952872e7d0a50ebfa1769a7a62d512f5f1ca21aec60bc3819a9c3ffca5eca9a0dba6d6f7249b06f5965ecfff3695b54e1c28f3f624750ed39e7de08fc8493242e26dbc4484a01c76f739e135637c";
}
d函數(shù)是對(duì) encText(即params) 加密兩次横蜒,所以:
/**
* 對(duì)數(shù)據(jù)兩次加密獲得參數(shù)params
* @param content 該參數(shù)應(yīng)當(dāng)是個(gè)json串 例如:{"s":"hello","csrf_token":"952d1c697b8d0647e8c7f19c16a0f753"}
* @param key 該參數(shù)是d方法的最后一個(gè)參數(shù) 例如:0CoJUm6Qyw8W8jud
* @return
*/
private static String getParams(String content, String key) {
// 第一次加密
String s = AESEncrypt(content, key);
// 第二次加密
return AESEncrypt(s, "FFFFFFFFFFFFFFFF");
}
搜索單曲的話應(yīng)該是:
/**
* 搜索單曲
* @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;
}
以下是整個(gè)AESUtil的全部代碼
/**
* @author junan
* @version V1.0
* @className AESUtil
* @disc 該類用于生成網(wǎng)易云音樂(lè)請(qǐng)求參數(shù)
* @date 2020/4/8 0:53
*/
public class AESUtil {
/**
* d方法的第2個(gè)參數(shù)
*/
private static String d_params2 = "010001";
/**
* d方法的第3個(gè)參數(shù)
*/
private static String d_params3 = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7";
/**
* d方法的第4個(gè)參數(shù)
*/
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對(duì)象,getInstance("算法/工作模式/填充模式")
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
//采用AES方式將密碼轉(zhuǎn)化成密鑰
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "AES");
//初始化偏移量
IvParameterSpec iv = new IvParameterSpec(ivParameter.getBytes());
//cipher對(duì)象初始化 init(“加密/解密,密鑰销凑,偏移量”)
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv);
//按照上面定義的方式對(duì)數(shù)據(jù)進(jìn)行處理丛晌。
byte[] encryptedBytes = cipher.doFinal(byteContent);
return new String(Base64Utils.encode(encryptedBytes), "UTF-8");
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 對(duì)數(shù)據(jù)兩次加密獲得參數(shù)params
* @param content 該參數(shù)應(yīng)當(dāng)是個(gè)json串 例如:{"s":"hello","csrf_token":"952d1c697b8d0647e8c7f19c16a0f753"}
* @param key 該參數(shù)是d方法的最后一個(gè)參數(shù) 例如:0CoJUm6Qyw8W8jud
* @return
*/
private static String getParams(String content, String key) {
// 第一次加密
String s = AESEncrypt(content, key);
// 第二次加密
return AESEncrypt(s, "FFFFFFFFFFFFFFFF");
}
/**
* encSecKey這個(gè)值經(jīng)測(cè)試是不變的,直接抄一個(gè)
* @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;
}
}
以上基本是核心的東西吧斗幼,也算是對(duì)網(wǎng)易云接口的探索澎蛛。
下面是一些接口數(shù)據(jù)的獲取的核心代碼(請(qǐng)結(jié)合項(xiàng)目看,我進(jìn)行了一些封裝):
@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)
項(xiàng)目代碼請(qǐng)參考【music-dow】,目前只寫了一部分,可以搜索下載單曲桐经,批量下載等毁兆,歌單下載還在開(kāi)發(fā),默認(rèn)下載到 d:\test 文件下阴挣,可到 application.properties 文件下修改路徑气堕。如果下載不成功,說(shuō)明那首歌需要vip,你懂的啦茎芭。
其他
1 期間很多測(cè)試接口時(shí)用到了postman揖膜,博客過(guò)濾掉了,但不影響您使用梅桩。
2 網(wǎng)易云接口思路參考了【這篇文章】次氨。
路漫漫其修遠(yuǎn)兮 吾將上下而求索