從零開始,做一個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 抓包網易雲

應用裝好,先不急着開抓包工具。打開網易雲,等升級提示,首頁推廣加載完成。之后到搜索頁面搜自己的用戶名,查看資料,看看是不是加載了聽歌排行。
網易雲音樂 Android版 2.0.2

好了,打開那個 最近常聽。App並沒有出現加載中的提示,網絡流量也沒有跑。那說明在加載用戶資料的時候,已經把這些東西下載好了。
最近常聽

明確了網絡請求發出的時機,我們來開啟 Packet Capture 進行抓包。這個東西的原理是設置一個VPN接管設備的所有網絡連接,沒root也就只能這么干了吧。

然后退出用戶詳情界面,再次點進去,又在加載了。加載完成,我們再到 最近常聽 里面看看。沒問題,加載的很好。現在切回 Packet Capture,看看抓到了什么。
嘗試抓取數據

里面有兩個網易雲的請求,挨個進去看看。
原始數據

點擊右上角的 HTTP, 可以把收到的內容進行 HTTP Decode
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的。
啟用 Interceptor

然后照樣把請求填進去,headers和body。
填入Headers

填入Body

按下Send按鈕,loading一會,成功返回了!!和之前抓到的數據一毛一樣!!!

然后可以按下一旁的 Generate Code,選擇直接生成NodeJS代碼!
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();

requestdata 事件是在每次數據流入時觸發,將數據推入 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倉庫的實時構建結果。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM