从零开始,做一个NodeJS博客(三):API实现-加载网易云音乐听歌排行
标签: NodeJS
0
研究了一天,翻遍了GitHub上各种网易云API库,也没有找到我想要的听歌排行API,可能这功能比较小众吧。但收获也不是没有,在 这里 明白了云音乐API加密的凶险,我等蒟蒻还是敬而远之的好。
等会,不过之前的旧API好像没有加密?
赶紧跑到 隔壁乐园,下载云音乐Android版2.0.2
。然后 酷安 扒来 Packet Capture,可以在Andorid上实现免Root抓包。
模拟请求,NodeJS肯定可以,不过这一块我还不熟(废话,这个模块的目的不就是熟悉一下网络请求么)。那么我们需要一个神奇的 Chrome 应用:Postman。如果要设定请求头的话,还要加上 Postman Interceptor 这个插件进行辅助,否则无法设置除 Content-Type
以外的 Http Headers
。
准备工作完成了,正片开始。
1 抓包网易云
应用装好,先不急着开抓包工具。打开网易云,等升级提示,首页推广加载完成。之后到搜索页面搜自己的用户名,查看资料,看看是不是加载了听歌排行。
好了,打开那个 最近常听。App并没有出现加载中的提示,网络流量也没有跑。那说明在加载用户资料的时候,已经把这些东西下载好了。
明确了网络请求发出的时机,我们来开启 Packet Capture 进行抓包。这个东西的原理是设置一个VPN接管设备的所有网络连接,没root也就只能这么干了吧。
然后退出用户详情界面,再次点进去,又在加载了。加载完成,我们再到 最近常听 里面看看。没问题,加载的很好。现在切回 Packet Capture,看看抓到了什么。
里面有两个网易云的请求,挨个进去看看。
点击右上角的 HTTP
, 可以把收到的内容进行 HTTP Decode
。
GET 的地址是 /api/user/playlist?MUSIC_A=******
,后面是一堆不明所以的东西。再往下看看,请求内容是用户的歌单信息,包括收藏的和创建的。这没啥意思,不是我想要的。去看下一个请求。
这个是 POST 请求,地址是 /api/batch
。batch?不是批处理么?这有意思,接着看。
请求头的Cookie有一大串,里面有各种客户端信息,还有刚才的 MUSIC_A
。
再看body。第一个是 MUSIC_A
。这啥玩意,怎么到处都有!!?不管,接着看。
key | value |
---|---|
/api/user/detail/76980626 | |
/api/user/bindings/76980626 | |
/api/dj/program/76980626 |
三个参数,长的跟url一样,还有值,像个JSON字符串。后面的 76980626
应该是我的UID之类的东西了。
再看响应。这么长!这个JSON有两千多行,稍微划分一下结构的话,分了五部分:
- Object //这是根节点
- code
- MUSIC_A
- /api/user/detail/76980626
- /api/user/bindings/76980626
- /api/dj/program/76980626
code
的值是200,而且后面的每个数组里都有 "code": 200
这一元素,猜测是个状态码。然后继续分析。
- /api/user/detail/76980626
- listenedSongs
这不就是听过的歌么!!里面还有 id
album
artisist
name
等各种信息,得,不用看下面了,就是你了。
退出 HTML Encode 页面,点击右上角菜单的 Save Upstream(<--)
,把请求存起来,开始模拟请求。
2 模拟请求
在电脑上打开得到的请求文件,是个纯文本文件。就是Http协议的信息流嘛。
前一部分是headers,后一部分是body。现在照样把它填到Postman里面。不过注意,要先在Postman主界面右上角打开 Interceptor
的开关,还要保证Chrome中有一个标签页,空白的新标签页也可以,有一个就行,否则是无法模拟HttpHeaders的。
然后照样把请求填进去,headers和body。
按下Send按钮,loading一会,成功返回了!!和之前抓到的数据一毛一样!!!
然后可以按下一旁的 Generate Code
,选择直接生成NodeJS代码!
这样虽说生成了代码,但请求头和返回都很长。经过的反复尝试(这点东西调了一天啊),决定保留这些:
var options = {
"protocol": 'http:',
"method": "POST",
"hostname": "music.163.com",
"path": "/api/batch",
"headers": {
"user-agent": "android",
"accept-encoding": "gzip",
"content-type": "application/x-www-form-urlencoded",
"host": "music.163.com",
"connection": "Keep-Alive",
"cookie": "MUSIC_A=17d8dda86b092bd628e6efb951d4dc6134f4eee4a3dc5eab6d1d5a05b2290cea3b873d710a9f4ce80af3bb97fd207b7f989e5cca1a78fb6410a30504a6c1324ada80406b02449f800fe035ea4cdbd2c4c3061cd18d77b7a0; deviceId=0; appver=2.0.2; os=android;",
}
};
var postData = { '/api/user/detail/76980626': '{\'all\':true}' }
这里的 Cookie
是必须的,而且 MUSIC_A
必须包含在Cookie里面,根据后来的抓包结果,是跟用户验证有关的。即使你不进行登录,也会有一个匿名的账号分配给你。
把 postData
减少到一条,请求还是可以正常返回的,不过就只有用户的个人基本资料和我们需要的听歌排行内容了。返回值对象的结构也有所改变:
- Object //这是根节点
- code
- MUSIC_A
- /api/user/detail/76980626
这样东西就少了一些了。
!!!!!!!注意!!!!!!!这里有一个大坑!!!!!!!
当你测试精简请求参数的时候,一定要先在Chrome里面把 music.163.com
Cookie 清理掉!!!!
因为 NodeJS 不是浏览器,不会保存返回的 Cookie,下一次请求还是新的;Postman 可是用了 Chrome 核心,它会共享浏览器的 Cookie,而这个请求的返回则会给客户端 set cookie
,而这个 cookie 则是与 POST
请求提交的数据和 Request Headers
有关的。这样,你的下一次 Postman 请求实际上继承了之前的所有 cookie ,再改参数,那些继承来的cookie也不会消失,会随着请求一起发出去,影响返回结果。
切记要 清除Cookie 啊!!!
把生成的代码放在 Node 里运行一下:
什么鬼,怎么还乱码!难道API坏了?不可能,刚才还在Postman里跑的好好的啊!
还记得刚刚抓包的时候,在打开 HTTP Decode 之前,返回值也是一通乱码。
再看看请求头吧,发现了什么?
"headers": {
"user-agent": "android",
"accept-encoding": "gzip",
"content-type": "application/x-www-form-urlencoded",
"host": "music.163.com",
"connection": "Keep-Alive",
"cookie": "MUSIC_A=.....",
}
里面的 accept-encoding
就是关键。返回值用Gzip压缩过了。
3 Gzip 解压
NodeJS提供了原生的gzip库 zlib
:
const zlib = require('zlib');
在这之前,还是看一下 Postman自动生成的代码吧,要对它动刀,先看看它是怎么做的:
var req = http.request(options, function (res) {
var chunks = [];
res.on("data", function (chunk) {
chunks.push(chunk);
});
res.on("end", function () {
var body = Buffer.concat(chunks);
console.log(body.toString());
});
});
req.write(qs.stringify({ '/api/user/detail/76980626': '{\'all\':true}' }));
req.end();
request
的 data
事件是在每次数据流入时触发,将数据推入 chunks
。当请求结束触发 end
事件时,把数据通过命令行输出。。。似乎是这样的吧。但 Buffer
是个什么东西?
算了,还是查一下 http.ClientRequest.API的文档 ,我找到了这个 response
事件。它会给回调函数传入一个 http.IncomingMessage
型的参数,里面包含了响应的数据;而它本身又是一个 Readable Stream
,可以直接 pipe()
到其它流。
那么再看一下 Zlib的API文档,正好提供了“解压缩流”,就是 Class: zlib.Gunzip 。
好了,那皆大欢喜,直接把返回数据流pipe到解压流,然后继续pipe到文件流就好了!这样还可以顺便把返回的文件存起来,加速之后的API调用(有缓存而且数据比较新鲜,直接读文件返回,不用看网易云服务器的脸色;而且网易云的统计数据貌似都是每天早上6点才刷新一次,请求太频繁了也没用)。
好,那么我直接贴代码了!
'use strict';
const qs = require("querystring");
const fs = require('fs');
const http = require("http");
const zlib = require('zlib');
var outputFileName = 'netease_record.json';
var options = {
"protocol": 'http:',
"method": "POST",
"hostname": "music.163.com",
"path": "/api/batch",
"headers": {
"user-agent": "android",
"accept-encoding": "gzip",
"content-type": "application/x-www-form-urlencoded",
"host": "music.163.com",
"connection": "Keep-Alive",
"cookie": "MUSIC_A=17d8dda86b092bd628e6efb951d4dc6134f4eee4a3dc5eab6d1d5a05b2290cea3b873d710a9f4ce80af3bb97fd207b7f989e5cca1a78fb6410a30504a6c1324ada80406b02449f800fe035ea4cdbd2c4c3061cd18d77b7a0; deviceId=0; appver=2.0.2; os=android;",
}
};
var output = fs.createWriteStream(outputFileName);
var req = http.request(options);
req.write(qs.stringify({ '/api/user/detail/76980626': '{\'all\':true}' }));
req.on('response', (response) => {
console.log('[Netease API] Record Data Received!');
response.pipe(zlib.createGunzip()).pipe(output);
fs.readFile(outputFileName, (err, data) => {
console.log(`[File] ${data.toString()}`);
});
})
req.on('error', (para) => {
console.log(`[Netease API] ${para.message}`);
})
req.end();
console.log(`[Netease API] Get Record Request Sent!`);
二话不说,直接开跑:
解压成功!接下来只需要把函数打包,加到首页的API路径里就好了!
4 实现一个 “API 模块”
那个 server.js 已经够长了,看起来头晕。。。再把上面的代码加进去,岂不是更乱了?还是把它写成一个单独的 js 文件吧,用 require
方法去引用它。
文件就是一个模块,模块的名字就是文件名(去掉.js后缀),所以hello.js文件就是名为hello的模块。
———— 模块 - 廖雪峰的官方网站
所以我们需要改写一下,把API调用打包成函数,最好可以自定义输出的文件名:
function fileName(name) {
if (name) {
return outputFileName = name;
} else return outputFileName;
}
function getRecord(callback) {
var output = fs.createWriteStream(outputFileName);
var req = http.request(options);
req.write(qs.stringify({ '/api/user/detail/76980626': '{\'all\':true}' }));
req.on('response', (response) => {
console.log('[Netease API] Record Data Received!');
response.pipe(zlib.createGunzip()).pipe(output);
// invoke callback and pass parameter
callback && callback(outputFileName);
})
req.on('error', (para) => {
console.log(`[Netease API] ${para.message}`);
})
req.end();
console.log(`[Netease API] Get Record Request Sent!`);
}
module.exports = {
fileName: fileName,
updateData: getRecord
}
这样,就可以通过require的方式引用这个模块里的函数;我的文件名是 NeteaseApiAndroid
,因为是在 Andorid 客户端抓的包嘛:
const NeteaseApi = require('./NeteaseApiAndroid');
NeteaseApi.fileName(); // get
NeteaseApi.fileName('temp_list.json'); // set
NeteaseApi.updateData((fName) => { // invoke
// do something
});
而且给回调函数传入的参数是输出文件名,让调用者做出自己的判断,是直接读文件,还是改更新缓存了。我的想法是,如果上一次请求网易API的事件超过一个小时,那么就更新列表缓存。
这里就只贴一个case好了,贴多了显得我是来凑字数的。
case '/api/music-record':
fs.stat(NeteaseApi.fileName(), (err, stats) => {
// got file
if (!err) {
var now = Date.now();
// now - last_modified_time >= an hour
if (now - stats.mtime >= 3600 * 1000) {
// update cache
NeteaseApi.updateData((fName) => {
sendMusicRecord(fName, response);
});
} else {
// read file and send request
sendMusicRecord(NeteaseApi.fileName(), response);
}
// no file: update cache
} else {
NeteaseApi.updateData((fName) => {
sendMusicRecord(fName, response);
});
}
});
break;
然后是这个响应处理函数,实际上就是一个读文件发送的过程:
function sendMusicRecord(fileName, response) {
fs.readFile(fileName, (err, data) => {
if (err) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(err));
} else {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(data);
}
});
}
5 在页面上加载列表
这没什么好说的。请求API,然后解析JSON就是了。不过先要在首页加上负责显示的列表:
<h3>Rcecntly Listened</h3>
<ul id="index-music-record"></ul>
然后就是请求了。这里仍然只贴函数:
function loadMusicRecord() {
var ul = document.getElementById('index-music-record');
function success(data) {
var rawList = data.listenedSongs;
rawList.forEach((value, index) => {
// display 10 item only
if(index > 9) return;
var li = document.createElement('li');
var a = document.createElement('a');
a.innerText = `${value.name} - ${value.artists[0].name}`;
a.setAttribute('href', `http://music.163.com/#/song?id=${value.id}`);
a.setAttribute('target', '_blank');
li.appendChild(a);
ul.appendChild(li);
});
}
function fail(code) {
ul.innerText = 'Load Faild: Please Refresh Page And Try Again.';
ul.innerText += `Error Code: ${code}`;
}
var request = new XMLHttpRequest();
request.onreadystatechange = () => {
if (request.readyState === 4) {
if (request.status === 200) {
return success(JSON.parse(request.response)['/api/user/detail/76980626']);
} else {
return fail(request.status);
}
}
}
request.open('GET', `/api/music-record`);
request.send();
}
这次还是用了原生的 XMLHttpRequest
。这样让我发现了一个小细节问题。
之前一直用 jQ 的 ajax 方法,大概要这样写:
$.ajax({
url: '/api/music-record',
mehtod: 'GET',
contentType: 'application/json',
success: (data) => {
// invoke here
}
});
这样的话,success
函数里面得到的 data
就会是js对象了,可以直接.
出来。
但如果用原生xhr方法的话,会有个小坑:没有地方(或者说我没找到)去设置这个 contentType
,success回调得到的其实只是一个字符串,处理之前还是要parse一下。
最后,在 window.onload
里面调用它!起飞吧,少年(不要吐槽样式,以后会有的)!!
仓库地址
GitHub仓库:BlogNode
主仓库,以后的代码都在这里更新。
HerokuApp:rocka-blog-node
上面GitHub仓库的实时构建结果。