天天看點

【nodeJS爬蟲】前端爬蟲系列 -- 小爬「部落格園」

寫這篇 blog 其實一開始我是拒絕的,因為爬蟲爬的就是cnblog部落格園。搞不好編輯看到了就把我的賬号給封了:)。

言歸正傳,前端同學可能向來對爬蟲不是很感冒,覺得爬蟲需要用偏後端的語言,諸如 php , python 等。當然這是在 nodejs 前了,nodejs 的出現,使得 Javascript 也可以用來寫爬蟲了。由于 nodejs 強大的異步特性,讓我們可以輕松以異步高并發去爬取網站,當然這裡的輕松指的是 cpu 的開銷。

要讀懂本文,其實隻需要有

能看懂 Javascript 及 JQuery

簡單的nodejs基礎

http 網絡抓包 和 URL 基礎

本文較長且圖多,但如果能耐下心讀完本文,你會發現,簡單的一個爬蟲實作并不難,并且能從中學到很多東西。

看到了最終結果,那麼我們接下來看看該如何一步一步通過一個簡單的 nodejs 爬蟲拿到我們想要的資料,首先簡單科普一下爬蟲的流程,要完成一個爬蟲,主要的步驟分為:

爬蟲爬蟲,最重要的步驟就是如何把想要的頁面抓取回來。并且能兼顧時間效率,能夠并發的同時爬取多個頁面。

同時,要擷取目标内容,需要我們分析頁面結構,因為 ajax 的盛行,許多頁面内容并非是一個url就能請求的的回來的,通常一個頁面的内容是經過多次請求異步生成的。是以這就要求我們能夠利用抓包工具分析頁面結構。

如果深入做下去,你會發現要面對不同的網頁要求,比如有認證的,不同檔案格式、編碼處理,各種奇怪的url合規化處理、重複抓取問題、cookies 跟随問題、多線程多程序抓取、多節點抓取、抓取排程、資源壓縮等一系列問題。

是以第一步就是拉網頁回來,慢慢你會發現各種問題待你優化。

當把頁面内容抓回來後,一般不會直接分析,而是用一定政策存下來,個人覺得更好的架構應該是把分析和抓取分離,更加松散,每個環節出了問題能夠隔離另外一個環節可能出現的問題,好排查也好更新釋出。

那麼存檔案系統、SQL or NOSQL 資料庫、記憶體資料庫,如何去存就是這個環節的重點。

對網頁進行文本分析,提取連結也好,提取正文也好,總之看你的需求,但是一定要做的就是分析連結了。通常分析與存儲會交替進行。可以用你認為最快最優的辦法,比如正規表達式。然後将分析後的結果應用與其他環節。

要是你做了一堆事情,一點展示輸出都沒有,如何展現價值?

是以找到好的展示元件,去show出肌肉也是關鍵。

如果你為了做個站去寫爬蟲,抑或你要分析某個東西的資料,都不要忘了這個環節,更好地把結果展示出來給别人感受。

現在我們一步一步來完成我們的爬蟲,目标是爬取部落格園第1頁至第200頁内的4000篇文章,擷取其中的作者資訊,并儲存分析。

共4000篇文章,是以首先我們要獲得這個4000篇文章的入口,然後再異步并發的去請求4000篇文章的内容。但是這個4000篇文章的入口 URL 分布在200個頁面中。是以我們要做的第一步是 從這個200個頁面當中,提取出4000個 URL 。并且是通過異步并發的方式,當收集完4000個 URL 再進行下一步。那麼現在我們的目标就很明确了:

要擷取這麼多 URL ,首先還是得從分析單頁面開始,F12 打開 devtools 。很容易發現文章入口連結儲存在 class 為 titlelnk 的 <a> 标簽中,是以4000個 URL 就需要我們輪詢 200個清單頁 ,将每頁的20個 連結儲存起來。那麼該如何異步并發的從200個頁面去收集這4000個 URL 呢,繼續尋找規律,看看每一頁的清單頁的 URL 結構:

那麼,1~200頁的清單頁 URL 應該是這個樣子的:

1

2

3

<code>for</code><code>(</code><code>var</code> <code>i=1 ; i&lt;= 200 ; i++){</code>

<code>    </code><code>pageUrls.push(</code><code>'http://www.cnblogs.com/#p'</code><code>+i);</code>

<code>}</code>

有了存放200個文章清單頁的 URL ,再要擷取4000個文章入口就不難了,下面貼出關鍵代碼,一些最基本的nodejs文法(譬如如何搭建一個http伺服器)預設大家都已經會了:

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

<code>// 一些依賴庫</code>

<code>var</code> <code>http = require(</code><code>"http"</code><code>),</code>

<code>    </code><code>url = require(</code><code>"url"</code><code>),</code>

<code>    </code><code>superagent = require(</code><code>"superagent"</code><code>),</code>

<code>    </code><code>cheerio = require(</code><code>"cheerio"</code><code>),</code>

<code>    </code><code>async = require(</code><code>"async"</code><code>),</code>

<code>    </code><code>eventproxy = require(</code><code>'eventproxy'</code><code>);</code>

<code>var</code> <code>ep = </code><code>new</code> <code>eventproxy(),</code>

<code>    </code><code>urlsArray = [], </code><code>//存放爬取網址</code>

<code>    </code><code>pageUrls = [],  </code><code>//存放收集文章頁面網站</code>

<code>    </code><code>pageNum = 200;  </code><code>//要爬取文章的頁數</code>

<code>// 主start程式</code>

<code>function</code> <code>start(){</code>

<code>    </code><code>function</code> <code>onRequest(req, res){  </code>

<code>        </code><code>// 輪詢 所有文章清單頁</code>

<code>        </code><code>pageUrls.forEach(</code><code>function</code><code>(pageUrl){</code>

<code>            </code><code>superagent.get(pageUrl)</code>

<code>                </code><code>.end(</code><code>function</code><code>(err,pres){</code>

<code>              </code><code>// pres.text 裡面存儲着請求傳回的 html 内容,将它傳給 cheerio.load 之後</code>

<code>              </code><code>// 就可以得到一個實作了 jquery 接口的變量,我們習慣性地将它命名為 `$`</code>

<code>              </code><code>// 剩下就都是利用$ 使用 jquery 的文法了</code>

<code>              </code><code>var</code> <code>$ = cheerio.load(pres.text);</code>

<code>              </code><code>var</code> <code>curPageUrls = $(</code><code>'.titlelnk'</code><code>);</code>

<code>              </code><code>for</code><code>(</code><code>var</code> <code>i = 0 ; i &lt; curPageUrls.length ; i++){</code>

<code>                </code><code>var</code> <code>articleUrl = curPageUrls.eq(i).attr(</code><code>'href'</code><code>);</code>

<code>                </code><code>urlsArray.push(articleUrl);</code>

<code>                </code><code>// 相當于一個計數器</code>

<code>                </code><code>ep.emit(</code><code>'BlogArticleHtml'</code><code>, articleUrl);</code>

<code>              </code><code>}</code>

<code>            </code><code>});</code>

<code>        </code><code>});</code>

<code>        </code><code>ep.after(</code><code>'BlogArticleHtml'</code><code>, pageUrls.length*20 ,</code><code>function</code><code>(articleUrls){</code>

<code>        </code><code>// 當所有 'BlogArticleHtml' 事件完成後的回調觸發下面事件</code>

<code>        </code><code>// ...</code>

<code>    </code><code>}</code>

<code>    </code><code>http.createServer(onRequest).listen(3000);</code>

<code>exports.start= start;</code>

這裡我們用到了三個庫,superagent 、 cheerio 、 eventproxy。

分别簡單介紹一下:

用 js 寫過異步的同學應該都知道,如果你要并發異步擷取兩三個位址的資料,并且要在擷取到資料之後,對這些資料一起進行利用的話,正常的寫法是自己維護一個計數器。

先定義一個 var count = 0,然後每次抓取成功以後,就 count++。如果你是要抓取三個源的資料,由于你根本不知道這些異步操作到底誰先完成,那麼每次當抓取成功的時候,就判斷一下count === 3。當值為真時,使用另一個函數繼續完成操作。

而 eventproxy 就起到了這個計數器的作用,它來幫你管理到底這些異步操作是否完成,完成之後,它會自動調用你提供的處理函數,并将抓取到的資料當參數傳過來。

OK,運作一下上面的函數,假設上面的内容我們儲存在 server.js 中,而我們有一個這樣的啟動頁面 index.js,

現在我們在回調裡增加幾行代碼,列印出結果:

打開node指令行,鍵入指令,在浏覽器打開 http://localhost:3000/ ,可以看到:

<code>node index.js</code>

成功了!我們成功收集到了4000個 URL ,但是我将這個4000個 URL 去重後發現,隻有20個 URL 剩下,也就是說我将每個 URL  push 進數組了200次,一定是哪裡錯,看到200這個數字,我立馬回頭檢視 200 個 文章清單頁。

我發現,當我用 http://www.cnblogs.com/#p1 ~ 200 通路頁面的時候,傳回的都是部落格園的首頁。 而真正的清單頁,藏在這個異步請求下面:

看看這個請求的參數:

成功了,那麼我們稍微修改下上面的代碼:

<code>//for(var i=1 ; i&lt;= 200 ; i++){</code>

<code>//  pageUrls.push('http://www.cnblogs.com/#p'+i);</code>

<code>//}</code>

<code>//改為</code>

<code>    </code><code>pageUrls.push(</code><code>'http://www.cnblogs.com/?CategoryId=808&amp;CategoryType=%22SiteHome%22&amp;ItemListActionName=%22PostList%22&amp;PageIndex='</code><code>+ i +</code><code>'&amp;ParentCategoryId=0'</code><code>);</code>

再試一次,發現這次成功收集到了4000個沒有重複的 URL 。第二步完成!

擷取到4000個 URL ,并且回調入口也有了,接下來我們隻需要在回調函數裡繼續爬取4000個具體頁面,并收集我們想要的資訊就好了。其實剛剛我們已經經曆了第一輪爬蟲爬取,隻是有一點做的不好的地方是我們剛剛并沒有限制并發的數量,這也是我發現 cnblog 可以改善的一點,不然很容易被單IP的巨量 URL 請求攻擊到崩潰。為了做一個好公民,也為了減輕網站的壓力(其實為了不被封IP),這4000個URL 我限制了同時并發量最高為5。這裡用到了另一個非常強大的庫 async ,讓我們控制并發量變得十分輕松,簡單的介紹如下。

這次我們要介紹的是 async 的 mapLimit(arr, limit, iterator, callback) 接口。另外,還有個常用的控制并發連接配接數的接口是 queue(worker, concurrency) ,大家可以去看看它的API。

繼續我們的爬蟲,進到具體的文章頁面,發現我們想擷取的資訊也不在直接請求而來的 html 頁面中,而是如下這個 ajax 請求異步生成的,不過慶幸的是我們上一步收集的 URL 包含了這個請求所需要的參數,是以我們僅僅需要多做一層處理,将這個參數從 URL 中取出來再重新拼接成一個ajax URL 請求。

下面,貼出代碼,在我們剛剛的回調函數中,繼續我們4000個頁面的爬取,并且控制并發數為5:

<code>ep.after(</code><code>'BlogArticleHtml'</code><code>,pageUrls.length*20,</code><code>function</code><code>(articleUrls){</code>

<code>    </code><code>// 當所有 'BlogArticleHtml' 事件完成後的回調觸發下面事件</code>

<code>    </code><code>// 控制并發數</code>

<code>    </code><code>var</code> <code>curCount = 0;</code>

<code>    </code><code>var</code> <code>reptileMove = </code><code>function</code><code>(url,callback){</code>

<code>        </code><code>//延遲毫秒數</code>

<code>        </code><code>var</code> <code>delay = parseInt((Math.random() * 30000000) % 1000, 10);</code>

<code>      </code><code>curCount++;</code>

<code>      </code><code>console.log(</code><code>'現在的并發數是'</code><code>, curCount, </code><code>',正在抓取的是'</code><code>, url, </code><code>',耗時'</code> <code>+ delay + </code><code>'毫秒'</code><code>); </code>

<code>    </code> 

<code>    </code><code>superagent.get(url)</code>

<code>        </code><code>.end(</code><code>function</code><code>(err,sres){</code>

<code>            </code><code>// sres.text 裡面存儲着請求傳回的 html 内容</code>

<code>            </code><code>var</code> <code>$ = cheerio.load(sres.text);</code>

<code>            </code><code>// 收集資料</code>

<code>            </code><code>// 拼接URL</code>

<code>            </code><code>var</code> <code>currentBlogApp = url.split(</code><code>'/p/'</code><code>)[0].split(</code><code>'/'</code><code>)[3],</code>

<code>                </code><code>appUrl = </code><code>"http://www.cnblogs.com/mvc/blog/news.aspx?blogApp="</code><code>+ currentBlogApp;</code>

<code>            </code><code>// 具體收集函數</code>

<code>            </code><code>personInfo(appUrl);</code>

<code>    </code><code>setTimeout(</code><code>function</code><code>() {</code>

<code>        </code><code>curCount--;</code>

<code>        </code><code>callback(</code><code>null</code><code>,url +</code><code>'Call back content'</code><code>);</code>

<code>    </code><code>}, delay);     </code>

<code>    </code><code>};</code>

<code>// 使用async控制異步抓取   </code>

<code>// mapLimit(arr, limit, iterator, [callback])</code>

<code>// 異步回調</code>

<code>async.mapLimit(articleUrls, 5 ,</code><code>function</code> <code>(url, callback) {</code>

<code>      </code><code>reptileMove(url, callback);</code>

<code>    </code><code>}, </code><code>function</code> <code>(err,result) {</code>

<code>        </code><code>// 4000 個 URL 通路完成的回調函數</code>

<code>    </code><code>});</code>

<code>});</code>

根據重新拼接而來的 URL ,再寫一個具體的 personInfo(URL) 函數,具體擷取我們要的昵稱、園齡、粉絲數等資訊。

這樣,我們把抓取回來的資訊以 JSON 串的形式存儲在 catchDate 這個數組當中,

node index.js 運作一下程式,将結果列印出來,可以看到中間過程及結果:

至此,第三步就完成了,我們也收集到了4000條我們想要的原始資料。

本來想将爬來的資料存入 mongoDB ,但因為這裡我隻抓取了4000條資料,相對于動不動爬幾百萬幾千萬的量級而言不值一提,故就不添加額外的操作 mongoDB 代碼,專注于爬蟲本身。

下面是我不同時間段爬取,經過簡單處理後的的幾張結果圖:

(結果圖的耗時均在并發量控制為 5 的情況下)

【nodeJS爬蟲】前端爬蟲系列 -- 小爬「部落格園」
【nodeJS爬蟲】前端爬蟲系列 -- 小爬「部落格園」
【nodeJS爬蟲】前端爬蟲系列 -- 小爬「部落格園」

   後記

OK,至此,整個爬蟲就完成了,其實代碼量很少,我覺得寫爬蟲更多的時間是花在在處理各類問題,分析頁面結構。

因為代碼開源,本着負責任的心态,希望大家可以照着代碼寫寫其他網站的爬蟲,如果都拿cnblog來爬,伺服器可能會承受不住的:)

原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

本文轉自ChokCoco部落格園部落格,原文連結:http://www.cnblogs.com/coco1s/p/4954063.html

繼續閱讀