1. 程式人生 > >nodejs爬蟲抓取非同步資料案例

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");
    })

個人建議

  1. 編寫爬蟲其實並沒有你想象的那麼難,無非就是傳送請求,處理返回的結果,傳送請求我使用request模組,建議學習一下https://github.com/request/request

  2. 寫爬蟲最主要的是分析,相信很多人看到我這麼多字就不想讀下去了,急於看程式碼,看了程式碼又不懂就喪失了信心,其實只要分析出來需要請求的地址,配合上面傳送請求的api,得到了我們想要的資料,就非常簡單

  3. 這裡我沒有講到如何分析網頁中的資料,即使用cheerio模組分析服務端渲染的網頁,這部分比較簡單,主要就是分析需要的資料在哪個dom節點裡再通過類似jquery的方法提取出來,如果大家有需要,可以在下方評論留言。

  4. 希望沒有看完我文字分析的同學好好看看分析過程,寫爬蟲主要是分析,寫爬蟲主要是分析,寫爬蟲主要是分析!

  5. 希望大家多多練習ACM的題目,嘻嘻嘻

  6. 我的個人部落格: