Node系列-爬蟲踩坑筆記

weixinjie發表於2018-10-21

1. 寫在前面

上個月寫了一篇《我的大前端之旅》,裡面介紹了一下我對大前端時代到來的一點個人觀點。簡單來說,我更喜歡把自己的未來規劃成一專多能的工程師,畢竟技多不壓身,在深入研究本職領域的前提下多涉獵一下其他的領域對自己的成長總是有益處的。

先概括一下本文的主要內容:

  • 目標: 通過做一個更加複雜的爬蟲模組加深對 JavaScript 這門語言的理解,也加深對 Node 這門技術的理解。
  • 方法論: 《我的大前端之旅》裡面介紹到的知識點(JS基本語法、Node、Cherrio等)。
  • 結果:把 自如 的北京地區房產資訊爬取下來。

2. 分析目標網站,制定爬取策略

先說結論(房產類網站可通用):

  • 開啟目標平臺的首頁,把對應的地標(比如:東城-崇文門)資訊抓取下來
  • 分析目標平臺二級頁的URL地址拼接規則,用第一條抓取下來的地標資訊進行二級頁URL地址拼接
  • 寫抓取二級頁的爬蟲程式碼,對爬取結果進行儲存。

2.1.1 抓取地標資訊

簡單抽取一下具體的爬取步驟,以自如(北京地區)為例:

Node系列-爬蟲踩坑筆記

通過主頁的佈局,可以看到房產類的網站基本上都是上方是地標(比如:東城-崇文門),下面是該地標附近的房產資訊。所以通過分析這塊的網頁結構就可以抓到所有的地標資訊。

2.1.2 拼接二級頁面的URL

以自如網站為例,比如我們想看安定門的租房資訊,直接在首頁的搜尋框中輸入“安定門”然後點選搜尋按鈕。

Node系列-爬蟲踩坑筆記
通過上圖我標紅的兩個地方可以看到,二級頁的地址就是地標+page(當前是第幾頁)。鏈家的二級頁也是一樣的,這裡就不貼圖了。

3.開始寫程式碼

根據上一小節的方法論,開始動手寫程式碼。這裡以自如為例(自如的資訊比鏈家難爬,但是原理都是通用的)。

3.1 爬取首頁地標資訊

開啟自如首頁,開啟 Chrome 的開發者工具,開始分析網頁元素。

Node系列-爬蟲踩坑筆記
通過Chrome的 element選擇器 我們很快可以定位到 “東城” 這個元素的位置。此元素的 class 為 tag ,開啟此元素下面的 class 為 con 的 div ,我們發現,“東城”包含的所有地標資訊都被包裹在此 div 中。由於所有的地標資訊都是 a 標籤包裹,所以我們可以寫出抓取地標資訊的核心程式碼。

 let allParentLocation = $('ul.clearfix.filterList', 'dl.clearfix.zIndex6');
        for (let i = 1; i < allParentLocation.children().length; i++) {
            let parentLocation = allParentLocation.children().eq(i);
            let parentLocationText = parentLocation.children().eq(0).text(); // 東城 西城...
            let allChildren = $(parentLocation.children().eq(1)).find('a');
            for (let j = 1; j <allChildren.length; j++) {
                let childrenLocationText = allChildren.eq(j).text(); //子行政區
               //TODO 上面的childrenLocationText變數就是地標資訊
            }
        }
複製程式碼

3.2 拼接二級頁的地址

如2.1.2所述,自如二級頁面基本上是 baseUrl+地標+page 組成。所以我們們可以完善一下3.1中的程式碼。下面我們封裝一個函式用來解析地標並且生成所有二級頁地址的陣列。注:這個函式返回的是一個 Promise ,後面會用 async 函式來組織所有 Promise 。

/**
 * 獲取行政區
 * @param data
 * @returns {Promise<any>}
 */
function parseLocationAndInitTargetPath(data) {
    let targetPaths = [];
    let promise = new Promise(function (resolve, reject) {
        let $ = cheerio.load(data);
        let allParentLocation = $('ul.clearfix.filterList', 'dl.clearfix.zIndex6');
        for (let i = 1; i < allParentLocation.children().length; i++) {
            let parentLocation = allParentLocation.children().eq(i);
            let parentLocationText = parentLocation.children().eq(0).text(); // 東城 西城...
            let allChildren = $(parentLocation.children().eq(1)).find('a');
            for (let j = 1; j <allChildren.length; j++) {
                let childrenLocationText = allChildren.eq(j).text(); //子行政區
                let encodeChildrenLocationText = encodeURI(childrenLocationText);
                for (let page = 1; page < 50; page++) { //只獲取前50頁的資料
                    targetPaths.push(`${basePath}qwd=${encodeChildrenLocationText}&p=${page}`);
                }
            }
        }
        resolve(targetPaths);
    });
    return promise;
}
複製程式碼

3.3 解析二級頁

先觀察一下二級頁的佈局,例如我們想把圖片、標題、tags、價格這幾個資訊抓取下來。

Node系列-爬蟲踩坑筆記
同樣的,我們可以寫出如下核心程式碼。

/**
 * 解析每一條的資料
 */
async function parseItemData(targetPaths) {
    let promises = [];
    for (let path of targetPaths) {
        let data = await getHtmlSource(path);
        let allText = '';
        try{
            allText = await ziRoomPriceUtil.getTextFromImage(data);
        }catch(err){
            console.log('抓取失敗--->>> '+path);
            continue;
        }
        let promise = new Promise((resolve, reject) => {
            let $ = cheerio.load(data);
            let result = $('#houseList');
            let allResults = [];
            for (let i = 0; i < result.children().length; i++) {
                let item = result.children().eq(i);
                let imgSrc = $('img', item).attr('src');
                let title = $('a', $('.txt', item)).eq(0).text();
                let detail = $('a', $('.txt', item)).eq(1).text();
                let label = '';
                $('span', $('.txt', item)).each(function (i, elem) {
                    label = label + ' ' + $(this).text();
                });
                let price = '';
                if (allText.length !== 10) {
                    price =  '未抓取到價格資訊'+allText;
                }else{
                    let priceContain = $('span', $('.priceDetail', item));
                    for(let i = 0;i<priceContain.length;i++){
                        if(i === 0 || i === priceContain.length-1){
                            price = price +' '+ priceContain.eq(i).text(); //首位: ¥ 末尾: 每月/每季度
                        }else {
                            price = price + ziRoomPriceUtil.style2Price(priceContain.eq(i).attr('style'),allText);
                        }
                    }
                }
                allResults.push({'imgSrc':imgSrc,'title':title,'detail':detail,'label':label,'price':price});
            }
            resolve(allResults);
        });
        promises.push(promise);
    }

    return Promise.all(promises);
}
複製程式碼

注意 上面有幾個點需要解釋一下

  • getHtmlSource 函式(文末會貼這個函式的程式碼):這個函式是用 PhantomJS 來模擬瀏覽器做渲染。這裡解釋一下 PhantomJS 簡單來說 PhantomJS 就是一個沒有介面的Web瀏覽器,用它可以更好的模擬使用者操作(比如可以抓取需要ajax非同步渲染的dom節點)。但是 PhantomJS 是一個單獨的程式,跟Node不是一個程式,所以在 Node 中使用 PhantomJS 的話就得單獨跑一個子程式,然後 Node 跟這個子程式通訊把 PhantomJS 抓取到的網頁 Source 拿到再做解析。不過與子程式做通訊這件事比較複雜,暫時還不想深入研究,所以我就用了 amir20 開發的 phantomjs-node 。 phantomjs-node 是可以作為node的一個子模組安裝的,雖然用法跟 PhantomJS 還是有點區別,但是應付我們的需求足夠了。
  • ziRoomPriceUtil.getTextFromImage(文末會貼這個函式的程式碼):自如網站對價格這個元素增加了反爬策略,所有與價格有關的數字都是通過擷取網頁中暗藏著的一張隨機數字圖片中的某一部分來展示的。這麼說可能比較難以理解,直接上圖。

Node系列-爬蟲踩坑筆記
上圖箭頭標註出來的是一串隨機陣列成的圖片,左側的價格資訊(比如 ¥7290 )都是通過計算相對位移擷取的這串數字中的某一個數字來顯示。我的思路是通過百度AI開放平臺把圖片中的10位文字識別出來,然後按照規律( 偏移/30 + 1 )將“價格”標籤中的相對位移轉化成真實的數字(不過經過實際檢測,百度的sdk能夠正確識別出10位數字的時候不多。。。正在考慮優化策略。比如把這個圖片文字變成黑色,底部變成白色)。

  • 細心的同學可能會觀察到這個函式的返回值是 Promise.all(promises) 。這其實是ES6中把一個 Promise 陣列合併成一個 Promsie 的方式,合併後的 Promise 呼叫 then 方法後返回的是一個陣列,此陣列的順序跟合併之前 Promise 陣列的順序是一致。這裡有兩點需要注意: 1. Promise.all 接受的Promise陣列如果其中有一個 Promise 執行失敗,則 Promise.all 返回 reject ,我的解決方案是傳入到 Promise.all 中的所有 Promise 都使用 resolve 來返回資訊,比如失敗的時候可以使用 resolve('error') 這樣保證 Promise.all 可以正常執行,執行完畢後通過檢查各個 Promsie 的返回結果來判斷該 Promise 是否是成功的狀態。2. Promise.all 是支援併發的,如果你想限制他的併發數量,可以使用第三方庫 tiny-async-pool,這個庫的原理是通過 Promise.race 來控制 Promise 陣列的例項化。

3.4 整理一下所有程式碼

3.4.1 爬蟲主體類 SpliderZiroom.js

//自如爬蟲指令碼 http://www.ziroom.com/

let schedule = require('node-schedule');
let superagent = require('superagent');
let cheerio = require('cheerio');
let charset = require('superagent-charset'); //解決亂碼問題:
charset(superagent);
let ziRoomPriceUtil = require('../utils/ZiRoomPriceUtil');

var phantom = require("phantom");
var _ph, _page, _outObj;

let basePath = 'http://www.ziroom.com/z/nl/z3.html?';


/**
 * 使用phantom獲取網頁原始碼
 * @param path
 * @param callback
 */
function getHtmlSource(path) {
    let promise = new Promise(function (resolve, reject) {
        phantom.create().then(function (ph) {
            _ph = ph;
            return _ph.createPage();
        }).then(function (page) {
            _page = page;
            return _page.open(path);
        }).then(function (status) {
            return _page.property('content')
        }).then(function (content) {
            resolve(content);
            _page.close();
            _ph.exit();
        }).catch(function (e) {
            console.log(e);
        });
    });
    return promise;
}


/**
 * 獲取行政區
 * @param data
 * @returns {Promise<any>}
 */
function parseLocationAndInitTargetPath(data) {
    let targetPaths = [];
    let promise = new Promise(function (resolve, reject) {
        let $ = cheerio.load(data);
        let allParentLocation = $('ul.clearfix.filterList', 'dl.clearfix.zIndex6');
        for (let i = 1; i < allParentLocation.children().length; i++) {
            let parentLocation = allParentLocation.children().eq(i);
            let parentLocationText = parentLocation.children().eq(0).text(); // 東城 西城...
            let allChildren = $(parentLocation.children().eq(1)).find('a');
            for (let j = 1; j <allChildren.length; j++) {
                let childrenLocationText = allChildren.eq(j).text(); //子行政區
                let encodeChildrenLocationText = encodeURI(childrenLocationText);
                for (let page = 1; page < 50; page++) { //只獲取前三頁的資料
                    targetPaths.push(`${basePath}qwd=${encodeChildrenLocationText}&p=${page}`);
                }
            }
        }
        resolve(targetPaths);
    });
    return promise;
}

/**
 * 解析每一條的資料
 */
async function parseItemData(targetPaths) {
    let promises = [];
    for (let path of targetPaths) {
        let data = await getHtmlSource(path);
        let allText = '';
        try{
            allText = await ziRoomPriceUtil.getTextFromImage(data);
        }catch(err){
            console.log('抓取失敗--->>> '+path);
            continue;
        }
        let promise = new Promise((resolve, reject) => {
            let $ = cheerio.load(data);
            let result = $('#houseList');
            let allResults = [];
            for (let i = 0; i < result.children().length; i++) {
                let item = result.children().eq(i);
                let imgSrc = $('img', item).attr('src');
                let title = $('a', $('.txt', item)).eq(0).text();
                let detail = $('a', $('.txt', item)).eq(1).text();
                let label = '';
                $('span', $('.txt', item)).each(function (i, elem) {
                    label = label + ' ' + $(this).text();
                });
                let price = '';
                if (allText.length !== 10) {
                    price =  '未抓取到價格資訊'+allText;
                }else{
                    let priceContain = $('span', $('.priceDetail', item));
                    for(let i = 0;i<priceContain.length;i++){
                        if(i === 0 || i === priceContain.length-1){
                            price = price +' '+ priceContain.eq(i).text(); //首位: ¥ 末尾: 每月/每季度
                        }else {
                            price = price + ziRoomPriceUtil.style2Price(priceContain.eq(i).attr('style'),allText);
                        }
                    }
                }
                allResults.push({'imgSrc':imgSrc,'title':title,'detail':detail,'label':label,'price':price});
            }
            resolve(allResults);
        });
        promises.push(promise);
    }

    return Promise.all(promises);
}

 
/**
 * 初始化目標網頁
 */
async function init() {
    let basePathSource = await getHtmlSource(basePath);
    let targetPaths = await parseLocationAndInitTargetPath(basePathSource);
    let result  = await parseItemData(targetPaths);
    return  result ;
}


/**
 * 開始爬取
 */
function startSplider() {
    console.log('自如爬蟲已啟動...');
    let startTime = new Date();
    init().then(function (data) {
        let endTime = new Date();
        console.log('自如爬蟲執行完畢 共消耗時間'+(endTime - startTime)/1000+'秒');
    }, function (error) {
        console.log(error);
    });
}

startSplider();

// module.exports = {
//     startSplider,
// };
複製程式碼

3.4.2 自如價格轉化工具類 ZiRoomPriceUtil.js

let md5=require("md5")

let baiduAiUtil = require('./BaiduAiUtil');

function style2Price(style,allText) {
    let position =  style.match('[1-9]\\d*')/30;
    return allText.substr(position,1);
}

function getTextFromImage(pageSrouce) {
    let promise = new Promise(function (resolve, reject) {
        try {
            let matchStr = pageSrouce.match('static8.ziroom.com/phoenix/pc/images/price/[^\\s]+.png')[0];
            let path = `http://${matchStr}`;
            baiduAiUtil.identifyImageByUrl(path).then(function(result) {
                        resolve(result.words_result[0].words);
                    }).catch(function(err) {
                        // 如果發生網路錯誤
                        reject(err)
                    });
        } catch (err) {
            reject(err);
        }
    });

    return promise;
}



module.exports = {
    style2Price,
    getTextFromImage
}
複製程式碼

3.4.3 百度AI開放平臺識別工具類 BaiduAiUtil.js

let fs = require('fs');
let AipOcrClient = require("baidu-aip-sdk").ocr;

// 設定APPID/AK/SK
let APP_ID = "需替換你的 APPID";
let API_KEY = "需替換你的 AK";
let SECRET_KEY = "需替換你的 SK";

// 新建一個物件,建議只儲存一個物件呼叫服務介面
let client = new AipOcrClient(APP_ID, API_KEY, SECRET_KEY);


/**
 * 通過本地檔案識別資料
 * @param imagePath  本地file path
 * @returns {Promise}
 */
function identifyImageByFile(imagePath){
    let image = fs.readFileSync(imagePath).toString("base64");
    return client.generalBasic(image);
}


/**
 * 通過遠端url識別資料
 * @param url 遠端url地址
 * @returns {Promise}
 */
function identifyImageByUrl(url){
    return client.generalBasicUrl(url);
}

module.exports = {
    identifyImageByUrl,
    identifyImageByFile
}
複製程式碼

執行程式碼檢視結果

注:這是我存到mysql中的爬取結果,由於 Node 連結 Mysql 不是本文重點,所以沒貼程式碼。你可以選擇把 startSplider 函式獲取到的結果放到檔案裡、MongooDB 或者其他地方。

Node系列-爬蟲踩坑筆記

4. 寫在最後

這段時間寫了很多各大網站的爬蟲程式碼,發現很多工作量是重複的。比如:租房類的網站大部分都是 先爬地標再爬二級頁 這種套路。本著 “以可配置為榮 以硬編碼為恥” 的程式設計師價值觀,後期會考慮把爬蟲模組做成可配置的。這裡跟大家分享一個開源庫: 牛咖


About Me

contact way value
mail weixinjie1993@gmail.com
wechat W2006292
github github.com/weixinjie
blog juejin.im/user/57673c…

相關文章