制作一个获取网易云音乐下载链接的服务器

制作一个获取网易云音乐下载链接的服务器

十二月 20, 2020

技术栈

  • Koa基础

推荐一篇博客,把koa讲的非常易懂,几乎看懂他提供的例子后koa就会得差不多了,如果有时间,我可能也会写一篇教程。

  • JavaScript基础
  • HTML基础

用到的Node.js模块

  • koa
  • koa-route
  • axios
  • crypto-js

如果你用的WebStorm,直接写

1
2
3
4
5
6
7
const Koa = require("koa")
const querystring = require("querystring")
const CryptoJS = require("crypto-js")
const axios = require('axios')
const fs = require("fs");
const route = require('koa-route');
const app = new Koa();

即可,WebStorm会自动提示你安装。

其实自己安装也很简单,先切换到工作目录,用cmd或shell运行:

1
2
3
4
npm install koa
npm install koa-route
npm install axios
npm isntall crypto-js

实现

1. 抓包、定位加密代码

首先打开网易云音乐网页版,进入任意一首歌,打开浏览器的DevTools,选择Network,点击播放,稍加分析,不难看出,这个post请求是用来获取音乐链接。

抓包

切换到调用栈(Initiator),看看发送它的函数在哪

调用栈

打开,发现是一大坨看不懂的代码

一大坨不想看的代码

看来这样走不通,那就搜搜post请求的data吧,Ctrl+F,搜encSecKey

请求头

定位加密代码

嗯,完美,很显然,这里的两个参数来自第13297行(可能你看到的行数和我不一样)的window.asrsea()函数

先刷新一下,再在那一行打个断点,点击播放

断点触发,进入window.asrsea()函数

进入加密函数

再在那个d(d,e,f,g)函数的第一行打个断点,可以看到这就是我们要找的加密函数。

2. 分析加密代码

加密代码

在左边的局部变量中看出,d保存的是一个字符串化的json,保存着要获取的歌曲id

1
2
3
4
5
6
7
8
{
id: [
32102297
],
level: "standard",
encodeType: "aac",
csrf_token: ""
}

经过多次测试,e是一个定值:"010001",来自["流泪", "强"]两个表情转换为对应的代码,转换映射如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
"色": "00e0b",
"流感": "509f6",
"这边": "259df",
"弱": "8642d",
"嘴唇": "bc356",
"亲": "62901",
"开心": "477df",
"呲牙": "22677",
"憨笑": "ec152",
"猫": "b5ff6",
"皱眉": "8ace6",
"幽灵": "15bb7",
"蛋糕": "b7251",
"发怒": "52b3a",
"大哭": "b17a8",
"兔子": "76aea",
"星星": "8a5aa",
"钟情": "76d2e",
"牵手": "41762",
"公鸡": "9ec4e",
"爱意": "e341f",
"禁止": "56135",
"狗": "fccf6",
"亲亲": "95280",
"叉": "104e0",
"礼物": "312ec",
"晕": "bda92",
"呆": "557c9",
"生病": "38701",
"钻石": "14af6",
"拜": "c9d05",
"怒": "c4f7f",
"示爱": "0c368",
"汗": "5b7a4",
"小鸡": "6bee2",
"痛苦": "55932",
"撇嘴": "575cc",
"惶恐": "e10b4",
"口罩": "24d81",
"吐舌": "3cfe4",
"心碎": "875d3",
"生气": "e8204",
"可爱": "7b97d",
"鬼脸": "def52",
"跳舞": "741d5",
"男孩": "46b8e",
"奸笑": "289dc",
"猪": "6935b",
"圈": "3ece0",
"便便": "462db",
"外星": "0a22b",
"圣诞": "8e7",
"流泪": "01000",
"强": "1",
"爱心": "0CoJU",
"女孩": "m6Qyw",
"惊恐": "8W8ju",
"大笑": "d"
}

f同样是定值,来自一下表情转换为代码

1
["色", "流感", "这边", "弱", "嘴唇", "亲", "开心", "呲牙", "憨笑", "猫", "皱眉", "幽灵", "蛋糕", "发怒", "大哭", "兔子", "星星", "钟情", "牵手", "公鸡", "爱意", "禁止", "狗", "亲亲", "叉", "礼物", "晕", "呆", "生病", "钻石", "拜", "怒", "示爱", "汗", "小鸡", "痛苦", "撇嘴", "惶恐", "口罩", "吐舌", "心碎", "生气", "可爱", "鬼脸", "跳舞", "男孩", "奸笑", "猪", "圈", "便便", "外星", "圣诞"]

g同上,是["爱心", "女孩", "惊恐", "大笑"]转换为代码

所以

1
2
3
e = "010001"
f = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g = "0CoJUm6Qyw8W8jud"

嗯,常量搞清楚了,再看看加密方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
!function () {
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
}
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()
}
function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b, "", c),
e = encryptedString(d, a)
}
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
}
function e(a, b, d, e) {
var f = {};
return f.encText = c(a + e, b, d),
f
}
window.asrsea = d,
window.ecnonasr = e
}();

enText就是post请求里的params参数,来自b函数加密两次

encSecKey来自c函数加密一次

a函数

1
2
3
4
5
6
7
8
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
}

显然,用来生成指定长度的随机字符串

b函数

1
2
3
4
5
6
7
8
9
10
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()
}

只使用了CryptoJS的函数,由于我们也用JavaScript写代码,直接复制即可,管他干嘛的-_-,只要在开头加个

1
const CryptoJS = require("crypto-js")

即可。

c函数

1
2
3
4
5
6
function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b, "", c),
e = encryptedString(d, a)
}

看着很短,实际上调用了自定义的类,这样就不能用对付b函数的方法了,这里我们先不去看c函数干了什么

我们看看它的参数

c函数的参数 d函数里对应的变量
a i 长度位16的随机字符串
b e “010001”
c f “00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7”

站在网易的角度想想,向网易传了一串加密后的字符串,这个字符串经过一个随机数和三个常量的加密,要想解密,必然需要那个随机数,而encSecKey显然是用来提供那个随机数的,encSecKey只来自c函数且c函数接受一个随机数和两个常量,可以解密出那个随机数,因此:

如果那个a函数得到的随机数如果我们用定值代替,嘿嘿嘿,c函数得出的encSecKey也将是定值!

我们同调试函数即可获取一个随机数和与之对应的加密后的encSecKey

我获得的是:

1
2
i = "bEjJE2aqLOyTEZiv"
encSecKey = "3e7ad1dbe03a65fc32268930314b88bcbfc1e9782c3b398c30b62776e39b66a048a7122d282a13d99f9b63bd4e1940b136169fbedf56c1887933fa59a01f95c4c0e78a6d9bb7f91605408e9c1c3c2e57873c53cdf09a3d79a43cfe26260741097089e4bd19808aab395190274e687b807ffddee89f39d75f2288e28a582f3d08"

写代码

经过上面的分析,我们可以得出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const CryptoJS = require("crypto-js")

function b(a, b) {
const 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()
}
function maker(d) {
var h = {}
, i = "bEjJE2aqLOyTEZiv";
h.encText = b(d, "0CoJUm6Qyw8W8jud");
h.encText = b(h.encText, i);
h.encSecKey = "3e7ad1dbe03a65fc32268930314b88bcbfc1e9782c3b398c30b62776e39b66a048a7122d282a13d99f9b63bd4e1940b136169fbedf56c1887933fa59a01f95c4c0e78a6d9bb7f91605408e9c1c3c2e57873c53cdf09a3d79a43cfe26260741097089e4bd19808aab395190274e687b807ffddee89f39d75f2288e28a582f3d08";
return [h.encText, h.encSecKey];
}

是不是很简单?

3. 实现后端

这部分没什么可讲的,要讲的话也只是将web,因此,跳过。。

完整代码如下:

app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
const Koa = require("koa")
const querystring = require("querystring")
const axios = require('axios')
const fs = require("fs");
const route = require('koa-route');
const app = new Koa();
const CryptoJS = require("crypto-js")

function b(a, b) {
const 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()
}
function maker(d) {
var h = {}
, i = "bEjJE2aqLOyTEZiv";
h.encText = b(d, "0CoJUm6Qyw8W8jud");
h.encText = b(h.encText, i);
h.encSecKey = "3e7ad1dbe03a65fc32268930314b88bcbfc1e9782c3b398c30b62776e39b66a048a7122d282a13d99f9b63bd4e1940b136169fbedf56c1887933fa59a01f95c4c0e78a6d9bb7f91605408e9c1c3c2e57873c53cdf09a3d79a43cfe26260741097089e4bd19808aab395190274e687b807ffddee89f39d75f2288e28a582f3d08";
return [h.encText, h.encSecKey];
}

const page = (ctx) => {
ctx.response.type = "html";
ctx.response.body = fs.createReadStream("./index.html");
}
const request = async (id) => {
const answer = {status: 500, body: {}}
let params = maker(JSON.stringify({
ids: [id],
level: "standard",
encodeType: "aac",
csrf_token: ""
}));
const settings = {
method: "post",
url: "https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=",
data: querystring.stringify({
params: params[0],
encSecKey: params[1]
})
};
await axios(settings)
.then(res => {
answer.body = res.data;
answer.status = true;
})
.catch((err => {
answer.body = err;
answer.status = false;
}));
return answer;
}
const analyze = async (ctx) => {
let result = "<p>服务器出错!请联系管理员</p>";
await request(ctx.request.query.id)
.then((res) => {
let data;
if (res.status) {
data = res.body;
if (data.code === 200) {
result = '<p>歌曲id为:' + data.data[0].id + '</p><p>点击<a href="' + data.data[0].url + '">链接</a>下载</p>';
} else {
result = '<p>输入错误!</p>';
}
}
})
.catch((err) => {
result = '<p>输入错误!</p><br />' + JSON.stringify(err);
})
ctx.response.body = result;
ctx.response.type = "html";
}
app.use(route.get("/", page))
app.use(route.get("/url", analyze))

app.listen(3000);

4. 实现前端

额,前端我真的不太会,随便写一个吧,能用就行

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>网易云音乐链接分析器</title>
</head>
<body>
输入歌曲id:<br>
<label for="id"></label><input id="id" type="text" name="id" value="">
<br>
<button onclick="getWyyyy()">获取下载链接</button>
</body>
<script>
function getWyyyy() {
let input = document.getElementById("id").value
window.location.href = "http://localhost:3000/url?id=" + input
}
</script>
</html>

记得完成后把localhost换成自己的IP或域名哦(如果像远程使用的话)

测试

运行命令:

1
node app.js

打开浏览器,输入网址:

1
http://localhost:3000/

如果一切正常,你将看到你写的前端界面

测试01

输入歌曲id,点击按钮

测试02

测试03

弹出下载界面,成功!