标签: 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 {'all':true}
/api/user/bindings/76980626
/api/dj/program/76980626 {'limit':5,'offset':0}

三个参数,长的跟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代码!



这样虽说生成了代码,但请求头和返回都很长。经过的反复尝试(这点东西调了一天啊),决定保留这些:

  1. var options = {
  2. "protocol": 'http:',
  3. "method": "POST",
  4. "hostname": "music.163.com",
  5. "path": "/api/batch",
  6. "headers": {
  7. "user-agent": "android",
  8. "accept-encoding": "gzip",
  9. "content-type": "application/x-www-form-urlencoded",
  10. "host": "music.163.com",
  11. "connection": "Keep-Alive",
  12. "cookie": "MUSIC_A=17d8dda86b092bd628e6efb951d4dc6134f4eee4a3dc5eab6d1d5a05b2290cea3b873d710a9f4ce80af3bb97fd207b7f989e5cca1a78fb6410a30504a6c1324ada80406b02449f800fe035ea4cdbd2c4c3061cd18d77b7a0; deviceId=0; appver=2.0.2; os=android;",
  13. }
  14. };
  15. 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 之前,返回值也是一通乱码。

再看看请求头吧,发现了什么?

  1. "headers": {
  2. "user-agent": "android",
  3. "accept-encoding": "gzip",
  4. "content-type": "application/x-www-form-urlencoded",
  5. "host": "music.163.com",
  6. "connection": "Keep-Alive",
  7. "cookie": "MUSIC_A=.....",
  8. }

里面的 accept-encoding 就是关键。返回值用Gzip压缩过了。

3 Gzip 解压

NodeJS提供了原生的gzip库 zlib

  1. const zlib = require('zlib');

在这之前,还是看一下 Postman自动生成的代码吧,要对它动刀,先看看它是怎么做的:

  1. var req = http.request(options, function (res) {
  2. var chunks = [];
  3. res.on("data", function (chunk) {
  4. chunks.push(chunk);
  5. });
  6. res.on("end", function () {
  7. var body = Buffer.concat(chunks);
  8. console.log(body.toString());
  9. });
  10. });
  11. req.write(qs.stringify({ '/api/user/detail/76980626': '{\'all\':true}' }));
  12. req.end();

requestdata 事件是在每次数据流入时触发,将数据推入 chunks。当请求结束触发 end 事件时,把数据通过命令行输出。。。似乎是这样的吧。但 Buffer 是个什么东西?

算了,还是查一下 http.ClientRequest.API的文档 ,我找到了这个 response 事件。它会给回调函数传入一个 http.IncomingMessage 型的参数,里面包含了响应的数据;而它本身又是一个 Readable Stream ,可以直接 pipe() 到其它流。

那么再看一下 Zlib的API文档,正好提供了“解压缩流”,就是 Class: zlib.Gunzip

好了,那皆大欢喜,直接把返回数据流pipe到解压流,然后继续pipe到文件流就好了!这样还可以顺便把返回的文件存起来,加速之后的API调用(有缓存而且数据比较新鲜,直接读文件返回,不用看网易云服务器的脸色;而且网易云的统计数据貌似都是每天早上6点才刷新一次,请求太频繁了也没用)。

好,那么我直接贴代码了!

  1. 'use strict';
  2. const qs = require("querystring");
  3. const fs = require('fs');
  4. const http = require("http");
  5. const zlib = require('zlib');
  6. var outputFileName = 'netease_record.json';
  7. var options = {
  8. "protocol": 'http:',
  9. "method": "POST",
  10. "hostname": "music.163.com",
  11. "path": "/api/batch",
  12. "headers": {
  13. "user-agent": "android",
  14. "accept-encoding": "gzip",
  15. "content-type": "application/x-www-form-urlencoded",
  16. "host": "music.163.com",
  17. "connection": "Keep-Alive",
  18. "cookie": "MUSIC_A=17d8dda86b092bd628e6efb951d4dc6134f4eee4a3dc5eab6d1d5a05b2290cea3b873d710a9f4ce80af3bb97fd207b7f989e5cca1a78fb6410a30504a6c1324ada80406b02449f800fe035ea4cdbd2c4c3061cd18d77b7a0; deviceId=0; appver=2.0.2; os=android;",
  19. }
  20. };
  21. var output = fs.createWriteStream(outputFileName);
  22. var req = http.request(options);
  23. req.write(qs.stringify({ '/api/user/detail/76980626': '{\'all\':true}' }));
  24. req.on('response', (response) => {
  25. console.log('[Netease API] Record Data Received!');
  26. response.pipe(zlib.createGunzip()).pipe(output);
  27. fs.readFile(outputFileName, (err, data) => {
  28. console.log(`[File] ${data.toString()}`);
  29. });
  30. })
  31. req.on('error', (para) => {
  32. console.log(`[Netease API] ${para.message}`);
  33. })
  34. req.end();
  35. console.log(`[Netease API] Get Record Request Sent!`);

二话不说,直接开跑:

解压成功!接下来只需要把函数打包,加到首页的API路径里就好了!

4 实现一个 “API 模块”

那个 server.js 已经够长了,看起来头晕。。。再把上面的代码加进去,岂不是更乱了?还是把它写成一个单独的 js 文件吧,用 require 方法去引用它。

文件就是一个模块,模块的名字就是文件名(去掉.js后缀),所以hello.js文件就是名为hello的模块。

———— 模块 - 廖雪峰的官方网站

所以我们需要改写一下,把API调用打包成函数,最好可以自定义输出的文件名:

  1. function fileName(name) {
  2. if (name) {
  3. return outputFileName = name;
  4. } else return outputFileName;
  5. }
  6. function getRecord(callback) {
  7. var output = fs.createWriteStream(outputFileName);
  8. var req = http.request(options);
  9. req.write(qs.stringify({ '/api/user/detail/76980626': '{\'all\':true}' }));
  10. req.on('response', (response) => {
  11. console.log('[Netease API] Record Data Received!');
  12. response.pipe(zlib.createGunzip()).pipe(output);
  13. // invoke callback and pass parameter
  14. callback && callback(outputFileName);
  15. })
  16. req.on('error', (para) => {
  17. console.log(`[Netease API] ${para.message}`);
  18. })
  19. req.end();
  20. console.log(`[Netease API] Get Record Request Sent!`);
  21. }
  22. module.exports = {
  23. fileName: fileName,
  24. updateData: getRecord
  25. }

这样,就可以通过require的方式引用这个模块里的函数;我的文件名是 NeteaseApiAndroid ,因为是在 Andorid 客户端抓的包嘛:

  1. const NeteaseApi = require('./NeteaseApiAndroid');
  2. NeteaseApi.fileName(); // get
  3. NeteaseApi.fileName('temp_list.json'); // set
  4. NeteaseApi.updateData((fName) => { // invoke
  5. // do something
  6. });

而且给回调函数传入的参数是输出文件名,让调用者做出自己的判断,是直接读文件,还是改更新缓存了。我的想法是,如果上一次请求网易API的事件超过一个小时,那么就更新列表缓存。

这里就只贴一个case好了,贴多了显得我是来凑字数的。

  1. case '/api/music-record':
  2. fs.stat(NeteaseApi.fileName(), (err, stats) => {
  3. // got file
  4. if (!err) {
  5. var now = Date.now();
  6. // now - last_modified_time >= an hour
  7. if (now - stats.mtime >= 3600 * 1000) {
  8. // update cache
  9. NeteaseApi.updateData((fName) => {
  10. sendMusicRecord(fName, response);
  11. });
  12. } else {
  13. // read file and send request
  14. sendMusicRecord(NeteaseApi.fileName(), response);
  15. }
  16. // no file: update cache
  17. } else {
  18. NeteaseApi.updateData((fName) => {
  19. sendMusicRecord(fName, response);
  20. });
  21. }
  22. });
  23. break;

然后是这个响应处理函数,实际上就是一个读文件发送的过程:

  1. function sendMusicRecord(fileName, response) {
  2. fs.readFile(fileName, (err, data) => {
  3. if (err) {
  4. response.writeHead(400, { 'Content-Type': 'application/json' });
  5. response.end(JSON.stringify(err));
  6. } else {
  7. response.writeHead(200, { 'Content-Type': 'application/json' });
  8. response.end(data);
  9. }
  10. });
  11. }

5 在页面上加载列表

这没什么好说的。请求API,然后解析JSON就是了。不过先要在首页加上负责显示的列表:

  1. <h3>Rcecntly Listened</h3>
  2. <ul id="index-music-record"></ul>

然后就是请求了。这里仍然只贴函数:

  1. function loadMusicRecord() {
  2. var ul = document.getElementById('index-music-record');
  3. function success(data) {
  4. var rawList = data.listenedSongs;
  5. rawList.forEach((value, index) => {
  6. // display 10 item only
  7. if(index > 9) return;
  8. var li = document.createElement('li');
  9. var a = document.createElement('a');
  10. a.innerText = `${value.name} - ${value.artists[0].name}`;
  11. a.setAttribute('href', `http://music.163.com/#/song?id=${value.id}`);
  12. a.setAttribute('target', '_blank');
  13. li.appendChild(a);
  14. ul.appendChild(li);
  15. });
  16. }
  17. function fail(code) {
  18. ul.innerText = 'Load Faild: Please Refresh Page And Try Again.';
  19. ul.innerText += `Error Code: ${code}`;
  20. }
  21. var request = new XMLHttpRequest();
  22. request.onreadystatechange = () => {
  23. if (request.readyState === 4) {
  24. if (request.status === 200) {
  25. return success(JSON.parse(request.response)['/api/user/detail/76980626']);
  26. } else {
  27. return fail(request.status);
  28. }
  29. }
  30. }
  31. request.open('GET', `/api/music-record`);
  32. request.send();
  33. }

这次还是用了原生的 XMLHttpRequest 。这样让我发现了一个小细节问题。

之前一直用 jQ 的 ajax 方法,大概要这样写:

  1. $.ajax({
  2. url: '/api/music-record',
  3. mehtod: 'GET',
  4. contentType: 'application/json',
  5. success: (data) => {
  6. // invoke here
  7. }
  8. });

这样的话,success 函数里面得到的 data 就会是js对象了,可以直接.出来。

但如果用原生xhr方法的话,会有个小坑:没有地方(或者说我没找到)去设置这个 contentType,success回调得到的其实只是一个字符串,处理之前还是要parse一下。

最后,在 window.onload 里面调用它!起飞吧,少年(不要吐槽样式,以后会有的)!!

仓库地址

GitHub仓库:BlogNode

主仓库,以后的代码都在这里更新。

HerokuApp:rocka-blog-node

上面GitHub仓库的实时构建结果。

从零开始,做一个NodeJS博客(三):API实现-加载网易云音乐听歌排行的更多相关文章

  1. 从零开始,做一个NodeJS博客(四):服务器渲染页面与Pjax

    标签: NodeJS 0 一个星期没更新了 = = 一直在忙着重构代码,以及解决重构后出现的各种bug 现在CSS也有一点了,是时候把遇到的各种坑盘点一下了 1 听歌排行 API 修复与重构 1.1 ...

  2. 从零开始,做一个NodeJS博客(零):整体规(chui)划(niu)

    标签:NodeJS,Heroku 0 搭建一个个人独立博客,这是我好久之前就在计划的一件事了. 这个暑假,我学习了廖雪峰老师的NodeJS教程,又偶然在V2EX上发现了Heroku这个平台,可以免费在 ...

  3. 从零开始,做一个NodeJS博客(二):实现首页-加载文章列表和详情

    标签: NodeJS 0 这个伪系列的第二篇,不过和之前的几篇是同一天写的.三分钟热度貌似还没过. 1 静态资源代理 上一篇,我们是通过判断请求的路径来直接返回结果的.简单粗暴,缺点明显:如果url后 ...

  4. 从零开始,做一个NodeJS博客(一):Heroku上的最简NodeJS服务器

    标签:NodeJS,Heroku 0 这里是这个伪系列的第一篇,因为我也不知道自己能不能做完,然后到底能做成什么样子.总之,尽力而为吧,加油. 1 Heroku App 的构成 Heroku 所谓的 ...

  5. Web前端:博客美化:四、网易云音乐单曲播放器

    1.页面定制CSS代码 /*3.音乐播放器*/ .content-wrap { overflow-y: scroll; -webkit-overflow-scrolling: touch; } /* ...

  6. 这几天有django和python做了一个多用户博客系统(可选择模板)

    这几天有django和python做了一个多用户博客系统(可选择模板) 没完成,先分享下 断断续续2周时间吧,用django做了一个多用户博客系统,现在还没有做完,做分享下,以后等完善了再慢慢说 做的 ...

  7. 如何搭建一个独立博客——简明Github Pages与Hexo教程

    摘要:这是一篇很详尽的独立博客搭建教程,里面介绍了域名注册.DNS设置.github和Hexo设置等过程,这是我写得最长的一篇教程.我想将我搭建独立博客的过程在一篇文章中尽可能详细地写出来,希望能给后 ...

  8. Do-Now—团队冲刺博客三

    Do-Now-团队 冲刺博客三 作者:仇夏 前言 不知不觉我们的项目已经做了三个多礼拜了,团队冲刺博客也写到了这第三篇,看着一个基本成型的APP安装在自己的手机上,一种喜悦感油然而生.好了,现在来看看 ...

  9. 前端设计师也有必要学习seo,推荐一个seo博客

    做前端设计师有一段时间了,现在越来越觉得作为一个前端设计师,必须要懂一些seo的知识. 因为公司的seo们,总是在网站做好以后,提出各种各样的网站修改的需求. 如果前端设计师,能够了解一些基本的seo ...

随机推荐

  1. Linux - 日志文件

    Linux日志文件绝大多数存放在/var/log目录,其中一些日志文件由应用程序创建,其他的则通过syslog来创建. Linux系统日志文件通过syslog守护程序在syslog套接字/dev/lo ...

  2. Schema – 模块化,响应式的前端开发框架

    Schema 是一个模块化的,响应式的前端框架,方便,快捷地帮助您迅速启动你的 Web 项目.Schema 配备完整的创建多个视图的能力.从桌面显示器到移动设备,它的12列网格提供强大的灵活性. Sc ...

  3. javascript学习8

    JavaScript 浏览器检测 实例 检测浏览器及版本 使用 JavaScript 检测关于访问者的浏览器名称及其版本. 检测浏览器的更多信息 使用 JavaScript 检测关于访问者浏览器的更多 ...

  4. 在Mac下配置php开发环境:Apache+php+MySql

    /private/etc/apache2/httpd.conf 一.启动Apache sudo apachectl start sudo apachectl -v   可以查看到Apache的版本信息 ...

  5. JAVA之IO文件读写

    IO概述:                                                          IO(Input output)流 作用:IO流用来处理设备之间的数据传输 ...

  6. JAVA 设计模式 中介者模式

    用途 中介者模式 (Mediator) 用一个中介对象来封装一系列的对象交互.中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互. 中介者模式是一种行为型模式. 结 ...

  7. jQuery 实现bootstrapValidator下的全局验证

    前置: 引入jQuery.bootstrap.bootstrapValidator 问题描述: 项目中要求所有的表单输入框中都不能输入&符号.没有在bootstrap中找到有方法可用,只能自己 ...

  8. C#简单问题,不简单的原理:不能局部定义自定义类型(不含匿名类型)

    今天在进行代码测试时发现,尝试在一个方法中定义一个委托,注意是定义一个委托,而不是声明一个委托变量,在编写的时候没有报错,VS也能智能提示,但在编译时却报语法不完整,缺少方括号,但实际查询并没有缺少, ...

  9. 转自coolshell--vim的基本操作

    开始前导语: 在正式转入python开发后,日常的工作中会和大量linux相关命令和工具接触,从另外一个层面,学习的东西相当的多,而VIM在整个的linux体系中所占据的角色就更不用说了,之前在处理g ...

  10. 30天C#基础巩固----Lambda表达式

         这几天有点不在状态,每一次自己很想认真的学习,写点东西的时候都会被各种小事情耽误,执行力太差.所以自己反思了下最近的学习情况,对于基础的知识,可以从书中和视频中学习到,自己还是需要注意下关于 ...