NodeJs爬蟲抓取古代典籍,共計16000個頁面心得體會總結,附帶對應的React+ Redux 前端 和 Koa2服務端程式碼

fanyang發表於2017-12-24

前言

之前研究資料,零零散散的寫過一些資料抓取的爬蟲,不過寫的比較隨意。有很多地方現在看起來並不是很合理 這段時間比較閒,本來是想給之前的專案做重構的。 後來 利用這個週末,索性重新寫了一個專案,就是本專案 guwen-spider。目前這個爬蟲還是比較簡單的型別的, 直接抓取頁面,然後在頁面中提取資料,儲存資料到資料庫。 通過與之前寫的對比,我覺得難點在於整個程式的健壯性,以及相應的容錯機制。在昨天寫程式碼的過程中其實也有反映, 真正的主體程式碼其實很快就寫完了 ,花了大部分時間是在 做穩定性的除錯, 以及尋求一種更合理的方式來處理資料與流程控制的關係。

NodeJs爬蟲抓取古代典籍,共計16000個頁面心得體會總結,附帶對應的React+ Redux 前端 和 Koa2服務端程式碼

背景

專案的背景是抓取一個一級頁面是目錄列表 ,點選一個目錄進去 是一個章節 及篇幅列表 ,點選章節或篇幅進入具體的內容頁面。

概述

本專案github地址 : guwen-spider (PS:最後面還有彩蛋 ~~逃

專案技術細節

專案大量用到了 ES7 的async 函式, 更直觀的反應程式了的流程。為了方便,在對資料遍歷的過程中直接使用了著名的async這個庫,所以不可避免的還是用到了回撥promise ,因為資料的處理髮生在回撥函式中,不可避免的會遇到一些資料傳遞的問題,其實也可以直接用ES7的async await 寫一個方法來實現相同的功能。這裡其實最讚的一個地方是使用了 Class 的 static 方法封裝對資料庫的操作, static 顧名思義 靜態方法 就跟 prototype 一樣 ,不會佔用額外空間。 專案主要用到了

  • 1 ES7的 async await 協程做非同步有關的邏輯處理。
  • 2 使用 npm的 async庫 來做迴圈遍歷,以及併發請求操作。
  • 3 使用 log4js 來做日誌處理
  • 4 使用 cheerio 來處理dom的操作。
  • 5 使用 mongoose 來連線mongoDB 做資料的儲存以及操作。

目錄結構

├── bin              // 入口
│   ├── booklist.js         // 抓取書籍邏輯
│   ├── chapterlist.js      // 抓取章節邏輯
│   ├── content.js          // 抓取內容邏輯
│   └── index.js            // 程式入口
├── config             // 配置檔案
├── dbhelper           // 資料庫操作方法目錄
├── logs             // 專案日誌目錄
├── model         // mongoDB 集合操作例項
├── node_modules         
├── utils         // 工具函式
├── package.json       

專案實現方案分析

專案是一個典型的多級抓取案例,目前只有三級,即 書籍列表, 書籍項對應的 章節列表,一個章節連結對應的內容。 抓取這樣的結構可以採用兩種方式, 一是 直接從外層到內層 內層抓取完以後再執行下一個外層的抓取, 還有一種就是先把外層抓取完成儲存到資料庫,然後根據外層抓取到所有內層章節的連結,再次儲存,然後從資料庫查詢到對應的連結單元 對之進行內容抓取。這兩種方案各有利弊,其實兩種方式我都試過, 後者有一個好處,因為對三個層級是分開抓取的, 這樣就能夠更方便,儘可能多的儲存到對應章節的相關資料。 可以試想一下 ,如果採用前者 按照正常的邏輯 對一級目錄進行遍歷抓取到對應的二級章節目錄, 再對章節列表進行遍歷 抓取內容,到第三級 內容單元抓取完成 需要儲存時,如果需要很多的一級目錄資訊,就需要 這些分層的資料之間進行資料傳遞 ,想想其實應該是比較複雜的一件事情。所以分開儲存資料 一定程度上避開了不必要的複雜的資料傳遞。

目前我們考慮到 其實我們要抓取到的古文書籍數量並不多,古文書籍大概只有180本囊括了各種經史。其和章節內容本身是一個很小的資料 ,即一個集合裡面有180個文件記錄。 這180本書所有章節抓取下來一共有一萬六千個章節,對應需要訪問一萬六千個頁面爬取到對應的內容。所以選擇第二種應該是合理的。

專案實現

主程有三個方法 bookListInit ,chapterListInit,contentListInit, 分別是抓取書籍目錄,章節列表,書籍內容的方法對外公開暴露的初始化方法。通過async 可以實現對這三個方法的執行流程進行控制,書籍目錄抓取完成將資料儲存到資料庫,然後執行結果返回到主程式,如果執行成功 主程式則執行根據書籍列表對章節列表的抓取,同理對書籍內容進行抓取。

專案主入口

/**
 * 爬蟲抓取主入口
 */
const start = async() => {
    let booklistRes = await bookListInit();
    if (!booklistRes) {
        logger.warn('書籍列表抓取出錯,程式終止...');
        return;
    }
    logger.info('書籍列表抓取成功,現在進行書籍章節抓取...');

    let chapterlistRes = await chapterListInit();
    if (!chapterlistRes) {
        logger.warn('書籍章節列表抓取出錯,程式終止...');
        return;
    }
    logger.info('書籍章節列表抓取成功,現在進行書籍內容抓取...');

    let contentListRes = await contentListInit();
    if (!contentListRes) {
        logger.warn('書籍章節內容抓取出錯,程式終止...');
        return;
    }
    logger.info('書籍內容抓取成功');
}
// 開始入口
if (typeof bookListInit === 'function' && typeof chapterListInit === 'function') {
    // 開始抓取
    start();
}

複製程式碼

引入的 bookListInit ,chapterListInit,contentListInit, 三個方法

booklist.js

/**
 * 初始化方法 返回抓取結果 true 抓取成果 false 抓取失敗
 */
const bookListInit = async() => {
    logger.info('抓取書籍列表開始...');
    const pageUrlList = getPageUrlList(totalListPage, baseUrl);
    let res = await getBookList(pageUrlList);
    return res;
}

複製程式碼

chapterlist.js

/**
 * 初始化入口
 */
const chapterListInit = async() => {
    const list = await bookHelper.getBookList(bookListModel);
    if (!list) {
        logger.error('初始化查詢書籍目錄失敗');
    }
    logger.info('開始抓取書籍章節列表,書籍目錄共:' + list.length + '條');
    let res = await asyncGetChapter(list);
    return res;
};
複製程式碼

content.js

/**
 * 初始化入口
 */
const contentListInit = async() => {
    //獲取書籍列表
    const list = await bookHelper.getBookLi(bookListModel);
    if (!list) {
        logger.error('初始化查詢書籍目錄失敗');
        return;
    }
    const res = await mapBookList(list);
    if (!res) {
        logger.error('抓取章節資訊,呼叫 getCurBookSectionList() 進行序列遍歷操作,執行完成回撥出錯,錯誤資訊已列印,請檢視日誌!');
        return;
    }
    return res;
}

複製程式碼

內容抓取的思考

書籍目錄抓取其實邏輯非常簡單,只需要使用async.mapLimit做一個遍歷就可以儲存資料了,但是我們在儲存內容的時候 簡化的邏輯其實就是 遍歷章節列表 抓取連結裡的內容。但是實際的情況是連結數量多達幾萬 我們從記憶體佔用角度也不能全部儲存到一個陣列中,然後對其遍歷,所以我們需要對內容抓取進行單元化。 普遍的遍歷方式 是每次查詢一定的數量,來做抓取,這樣缺點是隻是以一定數量做分類,資料之間沒有關聯,以批量方式進行插入,如果出錯 則容錯會有一些小問題,而且如果我們想要把一本書作為一個集合單獨儲存會遇到問題。因此我們採用第二種就是以一個書籍單元進行內容抓取和儲存。 這裡使用了 async.mapLimit(list, 1, (series, callback) => {})這個方法來進行遍歷,不可避免的用到了回撥,感覺很噁心。async.mapLimit()的第二個引數可以設定同時請求數量。

 /* 
 * 內容抓取步驟:
 * 第一步得到書籍列表, 通過書籍列表查到一條書籍記錄下 對應的所有章節列表, 
 * 第二步 對章節列表進行遍歷獲取內容儲存到資料庫中 
 * 第三步 儲存完資料後 回到第一步 進行下一步書籍的內容抓取和儲存
 */

/**
 * 初始化入口
 */
const contentListInit = async() => {
    //獲取書籍列表
    const list = await bookHelper.getBookList(bookListModel);
    if (!list) {
        logger.error('初始化查詢書籍目錄失敗');
        return;
    }
    const res = await mapBookList(list);
    if (!res) {
        logger.error('抓取章節資訊,呼叫 getCurBookSectionList() 進行序列遍歷操作,執行完成回撥出錯,錯誤資訊已列印,請檢視日誌!');
        return;
    }
    return res;
}
/**
 * 遍歷書籍目錄下的章節列表
 * @param {*} list 
 */
const mapBookList = (list) => {
    return new Promise((resolve, reject) => {
        async.mapLimit(list, 1, (series, callback) => {
            let doc = series._doc;
            getCurBookSectionList(doc, callback);
        }, (err, result) => {
            if (err) {
                logger.error('書籍目錄抓取非同步執行出錯!');
                logger.error(err);
                reject(false);
                return;
            }
            resolve(true);
        })
    })
}

/**
 * 獲取單本書籍下章節列表 呼叫章節列表遍歷進行抓取內容
 * @param {*} series 
 * @param {*} callback 
 */
const getCurBookSectionList = async(series, callback) => {

    let num = Math.random() * 1000 + 1000;
    await sleep(num);
    let key = series.key;
    const res = await bookHelper.querySectionList(chapterListModel, {
        key: key
    });
    if (!res) {
        logger.error('獲取當前書籍: ' + series.bookName + ' 章節內容失敗,進入下一部書籍內容抓取!');
        callback(null, null);
        return;
    }
    //判斷當前資料是否已經存在
    const bookItemModel = getModel(key);
    const contentLength = await bookHelper.getCollectionLength(bookItemModel, {});
    if (contentLength === res.length) {
        logger.info('當前書籍:' + series.bookName + '資料庫已經抓取完成,進入下一條資料任務');
        callback(null, null);
        return;
    }
    await mapSectionList(res);
    callback(null, null);
}


複製程式碼

資料抓取完了 怎麼儲存是個問題

這裡我們通過key 來給資料做分類,每次按照key來獲取連結,進行遍歷,這樣的好處是儲存的資料是一個整體,現在思考資料儲存的問題

  • 1 可以以整體的方式進行插入

    優點 : 速度快 資料庫操作不浪費時間。

    缺點 : 有的書籍可能有幾百個章節 也就意味著要先儲存幾百個頁面的內容再進行插入,這樣做同樣很消耗記憶體,有可能造成程式執行不穩定。

  • 2可以以每一篇文章的形式插入資料庫。

    優點 : 頁面抓取即儲存的方式 使得資料能夠及時儲存,即使後續出錯也不需要重新儲存前面的章節,

    缺點 : 也很明顯 就是慢 ,仔細想想如果要爬幾萬個頁面 做 幾萬次*N 資料庫的操作 這裡還可以做一個快取器一次性儲存一定條數 當條數達到再做儲存這樣也是一個不錯的選擇。

/**
 * 遍歷單條書籍下所有章節 呼叫內容抓取方法
 * @param {*} list 
 */
const mapSectionList = (list) => {
    return new Promise((resolve, reject) => {
        async.mapLimit(list, 1, (series, callback) => {
            let doc = series._doc;
            getContent(doc, callback)
        }, (err, result) => {
            if (err) {
                logger.error('書籍目錄抓取非同步執行出錯!');
                logger.error(err);
                reject(false);
                return;
            }
            const bookName = list[0].bookName;
            const key = list[0].key;

            // 以整體為單元進行儲存
            saveAllContentToDB(result, bookName, key, resolve);

            //以每篇文章作為單元進行儲存
            // logger.info(bookName + '資料抓取完成,進入下一部書籍抓取函式...');
            // resolve(true);

        })
    })
}

複製程式碼

兩者各有利弊,這裡我都做了嘗試。 準備了兩個錯誤儲存的集合,errContentModel, errorCollectionModel,在插入出錯時 分別儲存資訊到對應的集合中,二者任選其一即可。增加集合來儲存資料的原因是 便於一次性檢視以及後續操作, 不用看日誌。

(PS ,其實完全用 errorCollectionModel 這個集合就可以了 ,errContentModel這個集合可以完整儲存章節資訊)

//儲存出錯的資料名稱
const errorSpider = mongoose.Schema({
    chapter: String,
    section: String,
    url: String,
    key: String,
    bookName: String,
    author: String,
})
// 儲存出錯的資料名稱 只保留key 和 bookName資訊
const errorCollection = mongoose.Schema({
    key: String,
    bookName: String,
})

複製程式碼

我們將每一條書籍資訊的內容 放到一個新的集合中,集合以key來進行命名。

總結

寫這個專案 其實主要的難點在於程式穩定性的控制,容錯機制的設定,以及錯誤的記錄,目前這個專案基本能夠實現直接執行 一次性跑通整個流程。 但是程式設計也肯定還存在許多問題 ,歡迎指正和交流。

彩蛋

寫完這個專案 做了一個基於React開的前端網站用於頁面瀏覽 和一個基於koa2.x開發的服務端, 整體技術棧相當於是 React + Redux + Koa2 ,前後端服務是分開部署的,各自獨立可以更好的去除前後端服務的耦合性,比如同一套服務端程式碼,不僅可以給web端 還可以給 移動端 ,app 提供支援。目前整個一套還很簡陋,但是可以滿足基本的查詢瀏覽功能。希望後期有時間可以把專案變得更加豐富。

專案挺簡單的 ,但是多了一個學習和研究 從前端到服務端的開發的環境。

以上です

相關文章