以前研究數據,零零散散的寫過一些數據抓取的爬蟲,不過寫的比較隨意。有不少地方如今看起來並非很合理 這段時間比較閒,原本是想給以前的項目作重構的。 後來 利用這個週末,索性從新寫了一個項目,就是本項目 guwen-spider。目前這個爬蟲仍是比較簡單的類型的, 直接抓取頁面,而後在頁面中提取數據,保存數據到數據庫。 經過與以前寫的對比,我以爲難點在於整個程序的健壯性,以及相應的容錯機制。在昨天寫代碼的過程當中其實也有反映, 真正的主體代碼其實很快就寫完了 ,花了大部分時間是在 作穩定性的調試, 以及尋求一種更合理的方式來處理數據與流程控制的關係。前端
項目的背景是抓取一個一級頁面是目錄列表 ,點擊一個目錄進去 是一個章節 及篇幅列表 ,點擊章節或篇幅進入具體的內容頁面。node
本項目github地址 : guwen-spider (PS:最後面還有彩蛋 ~~逃react
項目技術細節git
項目大量用到了 ES7 的async 函數, 更直觀的反應程序了的流程。爲了方便,在對數據遍歷的過程當中直接使用了著名的async這個庫,因此不可避免的仍是用到了回調promise ,由於數據的處理髮生在回調函數中,不可避免的會遇到一些數據傳遞的問題,其實也能夠直接用ES7的async await 寫一個方法來實現相同的功能。這裏其實最讚的一個地方是使用了 Class 的 static 方法封裝對數據庫的操做, static 顧名思義 靜態方法 就跟 prototype 同樣 ,不會佔用額外空間。 項目主要用到了github
目錄結構web
├── bin // 入口 │ ├── booklist.js // 抓取書籍邏輯 │ ├── chapterlist.js // 抓取章節邏輯 │ ├── content.js // 抓取內容邏輯 │ └── index.js // 程序入口 ├── config // 配置文件 ├── dbhelper // 數據庫操做方法目錄 ├── logs // 項目日誌目錄 ├── model // mongoDB 集合操做實例 ├── node_modules ├── utils // 工具函數 ├── package.json
項目實現方案分析數據庫
項目是一個典型的多級抓取案例,目前只有三級,即 書籍列表, 書籍項對應的 章節列表,一個章節連接對應的內容。 抓取這樣的結構能夠採用兩種方式, 一是 直接從外層到內層 內層抓取完之後再執行下一個外層的抓取, 還有一種就是先把外層抓取完成保存到數據庫,而後根據外層抓取到全部內層章節的連接,再次保存,而後從數據庫查詢到對應的連接單元 對之進行內容抓取。這兩種方案各有利弊,其實兩種方式我都試過, 後者有一個好處,由於對三個層級是分開抓取的, 這樣就可以更方便,儘量多的保存到對應章節的相關數據。 能夠試想一下 ,若是採用前者 按照正常的邏輯 對一級目錄進行遍歷抓取到對應的二級章節目錄, 再對章節列表進行遍歷 抓取內容,到第三級 內容單元抓取完成 須要保存時,若是須要不少的一級目錄信息,就須要 這些分層的數據之間進行數據傳遞 ,想一想其實應該是比較複雜的一件事情。因此分開保存數據 必定程度上避開了沒必要要的複雜的數據傳遞。npm
目前咱們考慮到 其實咱們要抓取到的古文書籍數量並很少,古文書籍大概只有180本囊括了各類經史。其和章節內容自己是一個很小的數據 ,即一個集合裏面有180個文檔記錄。 這180本書全部章節抓取下來一共有一萬六千個章節,對應須要訪問一萬六千個頁面爬取到對應的內容。因此選擇第二種應該是合理的。json
項目實現後端
主程有三個方法 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 提供支持。目前整個一套還很簡陋,可是能夠知足基本的查詢瀏覽功能。但願後期有時間能夠把項目變得更加豐富。
本項目地址 地址 : guwen-spider
對應前端 React + Redux + semantic-ui 地址 : guwen-react
對應Node端 Koa2.2 + mongoose 地址 : guwen-node
項目挺簡單的 ,可是多了一個學習和研究 從前端到服務端的開發的環境。
以上です