在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的關係
https://vjudge.net/contest 這裏提供了比賽列表,但是我們需要hrbustacm哈理工發佈的比賽,所以在標題搜索欄中輸入2017,作者搜索欄中輸入hrbustacm,發現url地址變成:
https://vjudge.net/contest#category=mine&running=0&title=&owner=hrbustacm,所以我們抓取比賽列表的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,比賽名,這裏比賽id有什麼用呢,通過觀察,我們發現點擊一場比賽,他的url就變成https://vjudge.net/contest/168971,最後是比賽id
所以我們能夠訪問每一場比賽的具體信息啦,而他的rank榜的url就是https://vjudge.net/contest/168971#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的題目,嘻嘻嘻
本項目的github地址:https://github.com/tosim/craw_vj
我的個人博客: