如何使用nodejs做爬蟲程序?


https://github.com/alsotang/node-lessons/tree/master/lesson3


之前研究數據,零零散散的寫過一些數據抓取的爬蟲,不過寫的比較隨意。有很多地方現在看起來並不是很合理 這段時間比較閑,本來是想給之前的項目做重構的。

後來 利用這個周末,索性重新寫了一個項目,就是本項目 guwen-spider。目前這個爬蟲還是比較簡單的類型的, 直接抓取頁面,然後在頁面中提取數據,保存數據到資料庫。

通過與之前寫的對比,我覺得難點在於整個程序的健壯性,以及相應的容錯機制。在昨天寫代碼的過程中其實也有反映, 真正的主體代碼其實很快就寫完了 ,花了大部分時間是在

做穩定性的調試, 以及尋求一種更合理的方式來處理數據與流程式控制制的關係。

背景

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

概述

本項目github地址 : [guwen-spider](yangfan0095/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 提供支持。目前整個一套還很簡陋,但是可以滿足基本的查詢瀏覽功能。希望後期有時間可以把項目變得更加豐富。

本項目地址 地址 : [guwen-spider](yangfan0095/guwen-spider)

對應前端 React + Redux + semantic-ui 地址 : [guwen-react](yangfan0095/guwen-react)

對應Node端 Koa2.2 + mongoose 地址 : [guwen-node](yangfan0095/guwen-node)

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

感謝閱讀!

以上です


最近寫了個嘀哩嘀哩的爬蟲

GitHub - Relsoul/dilidiliNode


代碼都很簡單 歡迎star


你可能會把 NodeJS 用作網路伺服器,但你知道它還可以用來做爬蟲嗎? 本教程中會介紹如何爬取靜態網頁——還有那些煩人的動態網頁——使用 NodeJS 和幾個有幫助的 NPM 模塊。

網路爬蟲的一點知識

網路爬蟲在網路編程世界中總是被鄙視——說的也很有道理。在現代編程中,API 用於大多數流行的服務,應該用它們來獲取數據,而不是用爬蟲。爬蟲有一個固有問題,就是它依賴於被爬取頁面的可視化結構。一旦 HTML 改變了——不管改變多麼微小——都有可能完全破壞之前的代碼。

忽略這些瑕疵,學習一點關於網路爬蟲的知識會很有幫助,一些工具可以幫我們完成這個任務。當一個網站沒有給 API 或任何聚合訂閱(RSS/Atom等)時,獲取內容只剩唯一的選項……爬蟲。

注意:如果無法通過 API 或訂閱獲得想要的信息,這很有可能表示擁有者不希望那些信息是可訪問的。但是,還有一些例外。

為什麼用 NodeJS?

用所有語言都可以寫爬蟲,真的。我喜歡用 Node 的原因是因為它的非同步特性,表示在進程中我的代碼任何時候都不會被阻塞。還有一個額外的優勢,就是我很熟悉 JavaScript。最後,有一些為 NodeJS 寫的新模塊可以幫助輕鬆爬取網頁,用一種可靠的方式(好吧,其實就是爬蟲的可靠性極限!)。開始吧。

用 YQL 實現簡單爬蟲

從簡單的使用場景開始:靜態網頁。這些是標準的工場網頁。對於這些,Yahoo! Query Language(YQL)可以很好的完成。對於不熟悉 YQL 的人,它就是一個類似 SQL 的語法,可以用來以一致的方式使用不同的API。

YQL 有一些很棒的表來幫助開發者獲取網頁的 HTML。我想強調的是:

  • html
  • data.html.cssselect
  • htmlstring

挨個看一下,看如何用 NodeJS 實現。

html/ table

html 表是從 URL 爬取 HTML 最基本的方式。用這個表實現的常規查詢如下:

select * from html where url="http://finance.yahoo.com/q?s=yhoo" and xpath="//div[@id="yfi_headlines"]/div[2]/ul/li/a"

這個查詢由兩個參數組成:「url」 和 「xpath」。網址大家都知道。XPath 包含一個 XPath 字元串,告訴 YQL 應該返回 HTML 的哪一部分。在這裡查詢一下試試。

還有一些可用的參數包括 browser (布爾型),charset(字元串)和 compat(字元串)。我沒有使用這些參數,但如果你有特別需要的話可以參考文檔。

XPath 感覺不舒服?

很不幸,XPath 不是一個獲取 HTML 屬性結構的常用方式。對於新手讀和寫都可能很複雜。
看看下一個表,可以完成同樣的事,但使用 CSS 做替代

data.html.cssselect

data.html.cssselect 表是我推薦的爬取頁面 HTML 方式。和 html 表用相同的方式工作,但可以用 CSS 替代 XPath。實際上,這個表默默把 CSS 轉換為 XPath,然後調用 html 表,所以會有一點慢。對於爬取網頁來說,區別可以忽略不計。

使用這個表的通常方式是:

select * from data.html.cssselect where url="www.yahoo.com" and css="#news a"

可以看到,整潔許多。我建議在嘗試用 YQL 爬取網頁的時候優先嘗試這個方法。 在這裡查詢一下試試。

htmlstring

htmlstring 表在嘗試從網頁爬取大量格式化文本的時候用。
用這個表可以用一個單獨的字元串抓取網頁的全部 HTML 內容,而不是基於 DOM 結構切分的 JSON。
例如,一個爬取 & 標籤的常規 JSON 返回:

"results": {
"a": {
"href": "...",
"target": "_blank",
"content": "Apple Chief Executive Cook To Climb on a New Stage"
}
}

看到 attribute 如何定義為 property 了吧?相反,htmlstring 表的返回看起來會像這樣:

"results": {
"result": {
"&Apple Chief Executive Cook To Climb on a New Stage&
}
}

所以,為什麼要這麼用呢?從我的經驗來看,嘗試爬取大量格式化文本的時候會相當有用。例如下面的片段:

&

Lorem ipsum &dolor sit amet&, consectetur adipiscing elit.& &

Proin nec diam magna. Sed non lorem a nisi porttitor pharetra et non arcu.&

使用 htmlstring 表,可以把這個 HTML 獲取為字元串,然後用正則移除 HTML 標籤,留下的就只有文本了。這比 JSON 根據頁面的 DOM 結構分為屬性和子對象的迭代更容易。

在 NodeJS 里用 YQL

現在我們了解了一些 YQL 中可用的表,讓我們用 YQL 和 NodeJS 實現一個網路爬蟲。幸運的是,相當簡單,感謝 Derek Gathright 寫的 node-yql 模塊。

可以用 npm 安裝它:

npm install yql

這個模塊極為簡單,只包括一個方法:YQL.exec() 方法。定義如下:

function exec (string query [, function callback] [, object params] [, object httpOptions])

我們 require 它然後調用 YQL.exec() 就可以用了。例如,假設要抓取 Nettuts 主頁所有文章的標題:

var YQL = require("yql");

new YQL.exec("select * from data.html.cssselect where url="http://net.tutsplus.com/" and css=".post_title a"", function(response) {

//response consists of JSON that you can parse

});

YQL 最棒的就是能夠實時測試查詢然後確定會返回的 JSON。去 console 用一下試試,或者點擊這裡查看原生 JSON。

params 和 httpOptions 對象是可選的。參數可以包括像 env(是否為表使用特定的環境) 和 format (xml 或 json)這樣的屬性。所有傳給 params 的屬性都是 URI 編碼然後附到查詢字元串的尾端。httpOptions 對象被傳遞到請求頭中。例如這裡你可以指定是否想啟用 SSL。

叫做 yqlServer.js 的 JavaScript 文件,包含使用 YQL 爬取所需的最少代碼。可以在終端里用以下命令來運行它:

node yqlServer.js

例外情況和其它知名工具

YQL 是我推薦的爬取靜態網頁內容的選擇,因為讀起來簡單、用起來也簡單。然而,如果網頁有 robots.txt 文件來拒絕響應,YQL 就會失敗。在這種情況下,可以看看下面提到的工具,或者用下一節會講的 PhantomJS。

Node.io 是一個實用的 Node 工具,為數據爬取而特別設計。可以創建接受輸入,處理並返回某些輸出的作業。Node.io 在 GitHub 上關注量很高,有一些實用的例子幫你上手。

JSDOM 是一個很流行的項目,用 JavaScript 實現了 W3C DOM。當提供 HTML 時,它可以構造一個能夠與之交互的 DOM。查看文檔,了解如何使用 JSDOM 和任意 JS 庫(如 jQuery )一起從網頁抓取數據。

從頁面抓取動態內容

到目前為止,我們已經看過一些工具,可以幫助我們抓取靜態內容的網頁。有了YQL,相當簡單。不幸的是,我們經常看到一些內容是用JavaScript動態載入的頁面。在這些情況下,頁面最初通常為空,然後隨後附加內容。如何處理這個問題呢?

例子

我提供了一個例子;我上傳了一個簡單的 HTML 文件到我自己的網站,document.ready() 函數被調用後兩秒通過 JavaScript 附加了一些內容。可以在這裡查看這個頁面。源文件如下:

&
&
&
&Test Page with content appended after page load&
&

&
Content on this page is appended to the DOM after the page is loaded.

&

&

&