nodejs爬蟲抓取非同步資料案例
在csdn上圖片顯示有問題,可以去我的個人部落格上檢視原版:
http://tosim.top/2017/07/21/nodejs%E7%88%AC%E8%99%AB%E6%8A%93%E5%8F%96%E5%BC%82%E6%AD%A5%E6%95%B0%E6%8D%AE/#more
我們在抓取網頁的時候,如果目標站點是服務端渲染好的頁面,那麼我們在抓取網頁內容就很方便,只需要分析對應的dom節點內容就可以獲取我們需要的資料。
但是,如果資料是前端非同步請求獲取,再由js構造的節點,那麼我們直接分析抓取到的網頁是沒有用的,即使我們在瀏覽器的開發者工具中能夠看到對應的節點,
我們也無法獲取到這部分非同步重新整理的節點,因為這是js構造的,而我們通過request請求到的是還沒有js進行處理過的頁面,分辨是否非同步重新整理的方法很簡單,
右鍵網頁檢視原始碼,如果在原始碼裡面有的,就是可以直接分析得到,如果沒有,則需要我們去分析後臺介面。
案例介紹
作為ACM會長,由於暑期集訓,需要記錄集訓隊員的日常做題情況,而手動檢視rank榜極其不便,因此通過node+request編寫了一個爬取rank榜過濾出本校成員的rank,
並匯出到excel記錄,而我們訓練的 virtual judge 上的比賽列表和rank榜的資料都是通過ajax請求後臺介面獲取的,所以這裡就記錄
一下編寫爬蟲的過程
獲取比賽列表
分析網頁元素和url的關係
注意User-Agent
這裡如果過直接傳送get請求,vj網站會發現我們是爬蟲,從而拒絕返回資料
我們通過複製瀏覽器中的請求這張網頁的http請求頭,一併傳送到伺服器,就能夠獲取到這張頁面了
辨別是哪個請求
注意這裡是第一個請求,因為這個請求才是請求的這個頁面本身,其他的請求是獲取外聯css,js,圖片,獲取我們等等要說的ajax非同步請求
提取需要傳送的headers
點開來第一個請求,關注我們的request headers,這是我們要一同傳送的請求頭
獲取網頁的程式碼
request.get({
url:'https://vjudge.net/contest#category=mine&running=0&title=&owner=hrbustacm',
rejectUnauthorized: false ,
headers:{
"Accept":"application/json, text/javascript, */*; q=0.01",
"Accept-Encoding":"gzip, deflate, br",
"Accept-Language":"zh-CN,zh;q=0.8",
"Connection":"keep-alive",
"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8",
"Host":"vjudge.net",
"Referer":"https://vjudge.net/contest/",
"User-Agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36",
}
},function(err,res,body){
console.log(body);
});
網頁無資料,尋找xhr請求介面
獲取到頁面之後我們檢視輸出,並未發現比賽列表,因為他是通過ajax請求的資料,所以上面的程式碼實際上是獲取了一張沒有資料的網頁
xhr請求分析
我們現在的目標是獲取比賽列表,檢視Network下的所有xhr請求,也就是ajax非同步請求
通過觀察兩個請求返回的資料,我們發現第二個data請求返回的資料裡面有我們需要的比賽資訊
很興奮有沒有,再次觀察請求header裡面的url,這個就是我們要請求的地址
獲取比賽列表程式碼:
function getContestList(){
return new Promise(function(resolve,reject){
request.post({
url:'https://vjudge.net/contest/data',
rejectUnauthorized: false,
gzip: true,
headers:{
"Accept":"application/json, text/javascript, */*; q=0.01",
"Accept-Encoding":"gzip, deflate, br",
"Accept-Language":"zh-CN,zh;q=0.8",
"Connection":"keep-alive",
"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8",
"Host":"vjudge.net",
"Referer":"https://vjudge.net/contest/",
"User-Agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36",
},
form: queryString.parse("draw=1&columns%5B0%5D%5Bdata%5D=function&columns%5B0%5D%5Bname%5D=&columns%5B0%5D%5Bsearchable%5D=true&columns%5B0%5D%5Borderable%5D=true&columns%5B0%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B0%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B1%5D%5Bdata%5D=function&columns%5B1%5D%5Bname%5D=&columns%5B1%5D%5Bsearchable%5D=true&columns%5B1%5D%5Borderable%5D=false&columns%5B1%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B1%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B2%5D%5Bdata%5D=function&columns%5B2%5D%5Bname%5D=&columns%5B2%5D%5Bsearchable%5D=true&columns%5B2%5D%5Borderable%5D=true&columns%5B2%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B2%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B3%5D%5Bdata%5D=function&columns%5B3%5D%5Bname%5D=&columns%5B3%5D%5Bsearchable%5D=true&columns%5B3%5D%5Borderable%5D=true&columns%5B3%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B3%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B4%5D%5Bdata%5D=function&columns%5B4%5D%5Bname%5D=&columns%5B4%5D%5Bsearchable%5D=true&columns%5B4%5D%5Borderable%5D=true&columns%5B4%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B4%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B5%5D%5Bdata%5D=function&columns%5B5%5D%5Bname%5D=&columns%5B5%5D%5Bsearchable%5D=true&columns%5B5%5D%5Borderable%5D=false&columns%5B5%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B5%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B6%5D%5Bdata%5D=function&columns%5B6%5D%5Bname%5D=&columns%5B6%5D%5Bsearchable%5D=true&columns%5B6%5D%5Borderable%5D=false&columns%5B6%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B6%5D%5Bsearch%5D%5Bregex%5D=false&order%5B0%5D%5Bcolumn%5D=0&order%5B0%5D%5Bdir%5D=desc&start=0&length=20&search%5Bvalue%5D=&search%5Bregex%5D=false&category=mine&running=0&title=2017&owner=hrbustacm")
},
function(err,res,body){
// console.log(err);
// console.log(res.statusCode);
// console.log(JSON.parse(body).data);
if(err){
reject(err);
}
var contestList = JSON.parse(body).data;
resolve(contestList);
});
});
}
提取headers,注意gzip
這裡headers就是request headers 裡的內容,其中cookie等,因為不需要登入什麼的就不用加
注意response header裡面返回的gzip之後的資料,我們需要解壓才能正確看到,只需要在請求的時候加上gzip:true就行了
至於這個form裡面一大串奇怪的東西,就是
請求引數反序列化
需要反序列化的引數在header下的formdata裡,這裡也不知道他的請求引數真的有那麼複雜還是什麼,需要他這一大堆引數才能請求成功,但是這是序列化後的結果,需要用querystring反序列化過
獲得比賽id列表,開始獲取單場比賽rank榜
獲取比賽榜單
榜單資料也是非同步重新整理
費了這麼大勁,我們終於獲取到了比賽id列表,接下來就是獲取每個比賽的rank榜單了,通過檢視原始碼和分析榜單所在dom節點,我們發現,榜單資料也是非同步刷新出來的,
可見,一個網站要麼是後臺渲染好資料返回前臺,要麼是前臺非同步請求資料再構造dom節點到相應位置。
分析xhr請求
通過分析返回資料,我們發現第二個和第三個請求返回了差不多格式的資料,但是第三個介面url地址是我們已經有的比賽id,而第二個請求的地址帶了hash碼,所以我們優先考慮第三個,
如果分析不出來再看第二個
participants分析
通過觀察participants和他的英文原意,我們不難發現,這是所有的參賽者資訊,通過使用者id排序,不是根據rank排名來的
submissions分析
通過觀察submissions和他的英文原意,發現他是一個提交陣列,他的第一個引數是使用者id,第二個引數和第三個引數比較難分析,需要找一個具體使用者分析,最後我分析出來第二個引數是
題目編號0代表A,1代表B…第三個引數永遠是0或者1,所以應該代表是否爭取,第四個引數代表提交距離開始的毫秒數
沒有現成榜單資料,自己計算得出
但是分析了這個請求之後,發現並沒有我們的榜單排名陣列,於是回過去分析剛剛那個剩下的請求,發現這兩個請求的返回資料是一樣的!!!!!
於是就陷入了瓶頸,經過一番思考,我們知道這肯定是非同步刷出來的資料,但是返回的資料又沒有我們需要的rank,只有比賽者資訊和提交資訊,
但是,我們發現,通過這兩個資訊,加上已知的排名規則,我們完全可以自己計算出比賽排名,首先是根據通過的題目數量,其次是做題速度,
事實上,這個網站也是通過前端的js計算,計算出這個排名,因為他最後展現的不僅僅是比賽有效排名,還有比賽後的提交排名和各種篩選條件,所以如果他這個介面直接返回比賽排名還真有點不妥,
無奈的是這個計算的js並不好找,因為他可能被壓縮過,程式碼極其難看懂,也有可能是外聯的js,而在這麼多外聯的js中,他們也是壓縮過的,根本一個都看不懂,
所以最後還是自己寫一個模擬演算法計算出比賽的rank排名,畢竟,怎麼說也是一個ACMer去爬ACM比賽排行,給定資料下算出排名還是小菜一碟的(臭不要臉)
請求比賽者資訊和比賽提交資訊的程式碼:
//獲取單個比賽的rank資料,參與者和提交記錄
function getRankDate(id){
return new Promise(function(resolve,reject){
request.post({
url:'https://vjudge.net/contest/rank/single/' + id,
rejectUnauthorized: false,
gzip:true,
headers:{
"Accept":"application/json, text/javascript, */*; q=0.01",
"Accept-Encoding":"gzip, deflate, br",
"Accept-Language":"zh-CN,zh;q=0.8",
"Connection":"keep-alive",
"Host":"vjudge.net",
"Referer":"https://vjudge.net/contest/" + id,
"User-Agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36",
}
},
function(err,res,body){
if(err){
reject(err);
}
// console.log(JSON.parse(body));
resolve(JSON.parse(body));
});
});
}
根據比賽者資訊和提交記錄計算比賽排名的程式碼,具體規則就不說了,就是一個計算排名的邏輯,重要我們可以根據已有的資訊獲取我們想要的資訊這個思路
//根據參與者和提交記錄計算排名
function calculateRank(parts,submit,length){
// var parts = {
// 327:['a','aaa'],
// 515:['b','bbb'],
// 155:['c','ccc']
// }
// var submit = [
// [327,0,0,12],
// [515,0,1,12],
// [327,0,1,20],
// [327,0,1,33],
// [327,1,1,33],
// [155,0,1,66],
// [155,1,1,81981],
// [155,2,1,198190]
// ];
var map = {};
for(let i = 0;i < submit.length;i++){
var id = submit[i][0];
// console.log(id);
var que = submit[i][1];
var is_AC = submit[i][2];
var time = submit[i][3];
if(time > length){
continue;
}
try{
if(map[id] == null){
map[id] = {};
map[id].isSolve = {};
map[id].totalTime = {};
map[id].time = 0;
if(is_AC == 1){
map[id].solveCnt = 1;
map[id].totalTime[que] = time;
map[id].isSolve[que] = 1;
map[id].time += map[id].totalTime[que];
// console.log(map[id].time);
}else{
map[id].solveCnt = 0;
map[id].isSolve[que] = 0;
map[id].totalTime[que] = 1200;
}
}else{
if(is_AC == 1){
if(map[id].isSolve[que] == null){
map[id].totalTime[que] = time;
}else if(map[id].isSolve[que] == 0){
map[id].totalTime[que] += time;
}
map[id].solveCnt++;
map[id].isSolve[que] = 1;
map[id].time += map[id].totalTime[que];
}else{
if(map[id].isSolve[que] == null){
map[id].totalTime[que] = 1200;
}else if(map[id].isSolve[que] == 0){
map[id].totalTime[que] += 1200;
}
map[id].isSolve[que] = 0;
}
}}catch(e){
console.log(e);
throw(e);
}
}
// console.log(map);
var arr = [];
for(let i in map){
map[i].id = i;
// console.log(map[i]);
arr.push(map[i]);
}
// console.log(arr);
arr.sort(function(a,b){
if(a.solveCnt > b.solveCnt){
return -1;
}else if(a.solveCnt == b.solveCnt){
if(a.time < b.time){
return -1
}else{
return 1;
}
}else{
return 1;
}
});
// console.log(arr);
for(let i = 0;i < arr.length;i++){
// console.log(parts[arr[i].id]);
arr[i].nickName = parts[arr[i].id][0];
arr[i].name = parts[arr[i].id][1]
// console.log(nickName+'('+name+')');
// console.log(arr[i].solveCnt);
}
return arr;
// [
// {
// id:115651,
// solveCnt:6,
// time:15118,
// nickname:'a',
// name:'aaa'
// }
// ]
}
匯出rank記錄到excel
既然已經有了每場比賽的rank榜的資料,我們就可以匯出到excel便於觀察,這裡我通過exceljs這個模組匯出,具體用法參見exceljs的github
function exportToExcel(contestRanks){
var Excel = require('exceljs');
// construct a streaming XLSX workbook writer with styles and shared strings
var options = {
filename: './vj訓練記錄.xlsx',
useStyles: true,
useSharedStrings: true
};
var workbook = new Excel.stream.xlsx.WorkbookWriter(options);
workbook.creator = 'tosim';
workbook.lastModifiedBy = 'tosim';
workbook.created = new Date(2017, 7, 20);
workbook.modified = new Date();
for(let i in contestRanks){
var worksheet = workbook.addWorksheet(i);
worksheet.columns = [
{ header: 'Rank', key: 'rank', width: 10 },
{ header: 'Team', key: 'team', width: 50 },
{ header: 'Score', key: 'score', width: 10, outlineLevel: 1 },
{ header: 'Penalty', key: 'penalty', width: 10, outlineLevel: 1 }
];
for(let j = 0;j < contestRanks[i].length;j++){
var team = contestRanks[i][j].nickName+'('+contestRanks[i][j].name+')';
if(/(zust)|(浙科院)|(科院)/.test(team)){//這裡過濾出我們學校的使用者,因為我們的隊員都是帶這三個其中一個的
// console.log(team);
// console.log(contestRanks[i][j].time/60);
worksheet.addRow({rank: j+1, team: team, score:contestRanks[i][j].solveCnt,penalty:parseInt(contestRanks[i][j].time/60)});
}
}
// worksheet.addRow({rank: 1, team: '營業員', score:5,penalty:123});
worksheet.commit();
}
workbook.commit();
}
正則表示式推薦大家好好學習一下,威力無窮啊,這裡有點大才小用的感覺- -,過濾出了所有使用者裡面我們集訓隊的成員
for(let j = 0;j < contestRanks[i].length;j++){
var team = contestRanks[i][j].nickName+'('+contestRanks[i][j].name+')';
if(/(zust)|(浙科院)|(科院)/.test(team)){//這裡過濾出我們學校的使用者,因為我們的隊員都是帶這三個其中一個的
// console.log(team);
// console.log(contestRanks[i][j].time/60);
worksheet.addRow({rank: j+1, team: team, score:contestRanks[i][j].solveCnt,penalty:parseInt(contestRanks[i][j].time/60)});
}
}
組合Promise的執行邏輯
上面三個是經過抽象的函式都返回的Promise,Promise威力無窮,對於node的非同步程式設計好處多多,建議大家學習一下Promise,這裡推薦大家阮一峰的教程,啟蒙老師
組合程式碼
getContestList()//獲取比賽id
.then(function(contestList){
// console.log(contestList);
var validateList = [];
var now = new Date().getTime();
contestList.forEach(function(item){
// console.log(item);
// console.log("end");
if(/^訓練賽20170[78]\d\d$/.test(item[1])){
if(now < item[3]){//比賽還沒結束或還沒開始
return;
}
validateList.push({
id:item[0],
name:item[1]
});
}
});
// console.log(validateList);
return new Promise(function(resolve,reject){
var promiseList = [];
for(let i = 0;i < validateList.length;i++){
promiseList.push(getRankDate(validateList[i].id));
}
Promise.all(promiseList)
.then(function(results){
var contestRanks = {};//比賽名稱:比賽rank
for(let i = 0;i < results.length;i++){
// console.log(parseInt(results[i].length/1000));
contestRanks[validateList[i].name] = calculateRank(results[i].participants,results[i].submissions,parseInt(results[i].length/1000));
}
resolve(contestRanks);
})
.catch(function(err){
reject(err);
});
});
})
.then(function(contestRanks){
for(let i in contestRanks){
console.log(i);
}
// console.log(contestRanks['訓練賽20170720']);
exportToExcel(contestRanks);
console.log("done");
})
個人建議
編寫爬蟲其實並沒有你想象的那麼難,無非就是傳送請求,處理返回的結果,傳送請求我使用request模組,建議學習一下https://github.com/request/request
寫爬蟲最主要的是分析,相信很多人看到我這麼多字就不想讀下去了,急於看程式碼,看了程式碼又不懂就喪失了信心,其實只要分析出來需要請求的地址,配合上面傳送請求的api,得到了我們想要的資料,就非常簡單
這裡我沒有講到如何分析網頁中的資料,即使用cheerio模組分析服務端渲染的網頁,這部分比較簡單,主要就是分析需要的資料在哪個dom節點裡再通過類似jquery的方法提取出來,如果大家有需要,可以在下方評論留言。
希望沒有看完我文字分析的同學好好看看分析過程,寫爬蟲主要是分析,寫爬蟲主要是分析,寫爬蟲主要是分析!
希望大家多多練習ACM的題目,嘻嘻嘻
我的個人部落格: