1. 程式人生 > >爬蟲的增量式抓取和資料更新

爬蟲的增量式抓取和資料更新

爬蟲的增量式抓取和資料更新

頁面爬的多了,量上去了之後,就會遇到其他的問題,其實不管做什麼技術量大了都會有問題。一般情況下,我認為解決"大量"問題的思路有兩個:一種是著力於優化系統的能力,讓原本只能一分鐘處理100條的系統提升到一分鐘1000條之類的,在我看來並行、分散式、叢集都屬於這個範疇,這種思路下,系統處理的內容沒有變化只是單純的處理速度變快了;另一種是著力於提高系統的工作效率, 比如說降低某演算法的複雜度。

爬蟲領域的增量式爬取屬於後者,每種網站都有每種網站的特點。比如說小說連載網站、新聞或者知乎首頁,這裡拿知乎時間線舉例,我基本每天醒來和睡覺前都會刷一波知乎,從頭開始看直到看到上次載入的地方,假設我要抓取知乎的資料並儲存到本地,不難發現最好的選擇其實是每次只抓取上次沒讀過的新內容,抓評論也是一樣,最優的選擇是每次只抓取在上次抓取之後出現的新評論,然後再進行儲存。有的時候,還有另外一種情況,就是原本存在的網頁內容更新了,比如說有人在知乎上修改了他的回答。這時候,我們的爬蟲就需要有分辨這些區別變化的能力。但這幾個都是很簡單的例子,實際情況會複雜很多。

不管是產生新頁面,還是原本的頁面更新,這種變化都被稱為增量, 而爬取過程則被稱為增量爬取。那如何進行增量式的爬取工作呢?回想一下爬蟲的工作流程:

傳送URL請求 ----- 獲得響應 ----- 解析內容 ----- 儲存內容

我們可以從幾種思路入手:

  • 在傳送請求之前判斷這個URL是不是之前爬取過
  • 在解析內容後判斷這部分內容是不是之前爬取過
  • 寫入儲存介質時判斷內容是不是已經在介質中存在

實現增量式爬取

不難發現,其實增量爬取的核心是去重, 至於去重的操作在哪個步驟起作用,只能說各有利弊,就像我說的,everything is tradeoff。

在我看來,前兩種思路需要根據實際情況取一個(也可能都用)。第一種思路適合不斷有新頁面出現的網站,比如說小說的新章節,每天的最新新聞等等;第二種思路則適合頁面內容會更新的網站。第三個思路是相當於是最後的一道防線。這樣做可以最大程度上達到去重的目的。

去重的方法

最簡單的去重方式自然是將所有訪問過的URL和其對應的內容儲存下來,然後過一段時間重新爬取一次並進行比較,然後決定是否需要覆蓋。這顯然是不實際的,因為會消耗很多資源。目前比較實際的做法就是給URL或者其內容(取決於這個網站採用哪種更新方式)上一個標識,這個標識有個比較好聽的名字,叫資料指紋

這裡很容易想到的一種資料指紋就是雜湊值

,根據雜湊函式的特性,我們可以為任意內容生成一個獨一無二的定長字串,之後只要比較這個雜湊值就行了。雜湊值是一個很偉大的發明,幾乎在任何地方都有它的影子,它利用數學特性,計算機只要經過簡單的計算就可以得到唯一的特徵值,這個計算過程的開銷基本可以忽略不計,當然這是題外話了。

不過即使用了雜湊值,你仍需要一個地方儲存所有的雜湊值,並且要能做到方便的取用。如果你的儲存介質是資料庫,一般的資料庫系統都能提供索引,如果把雜湊值作為唯一索引呢,這應該是可行的。有些資料庫也提供查詢後再插入的操作,不過本質上應該也是索引。和雜湊值類似的還有MD5校驗碼,殊途同歸。

除了自建指紋,其實在傳送請求時還有一些技巧,比如說304狀態碼,Last-modified欄位,檔案大小和MD5簽名。具體參考[8],很好理解,就不細說了。

綜上所述,在資料量不大的時候,幾百個或者就幾千個的時候,簡單自己寫個小函式或者利用集合的特性去重就行了。如果資料量夠大,資料指紋的價值就體現出來了,它可以節省可觀的空間,同時可以引入BloomFilter作為去重的手段。另外,如果要對資料做持久化(簡單說就是去重操作不會被事故影響,比如說斷電),就需要用到Redis資料庫

BloomFilter

布朗過濾器雖然不是因為爬蟲才出現的,但是卻在這種情況下顯得異常有用。布朗過濾器可以通過計算來判斷某項資料是否存在於集合中,它原理和概念可以參考1和英文版的維基百科Bloom filter, 裡面有詳細的數學推理,它解釋了為什麼布朗會有誤判情況出現,感興趣可以學習一下,並不難。這裡只提幾點:

  • 布朗過濾器是有誤判率的,它會把原本不屬於這個集合的資料誤判為屬於,但不會把原本屬於集合的資料誤判為不屬於。
  • 它是一個典型且高效的空間換時間的例子。
  • 它的誤判率是:
\left(1-\left[1-\frac{1}{m}\right]^{kn}\right)^k \approx \left( 1-e^{-kn/m} \right)^k

這裡元素的數量n、 過濾容器的大小m(bits)和雜湊函式的數量k存在的一定關係,它們三個共同確定了誤判率;同樣如果已知其中兩項,通過調整另外一項也可以達到降低誤判率的目的,具體參見Bloom Filters - the math

Redis的集合使用

簡單來說,Redis的集合就是Redis資料庫中的集合型別,它具有無序不重複的特點。Python有redis客戶端庫,這裡主要涉及到的就是SADDSISMEMBER命令。下面會具體解釋。

具體實現

BloomFilter

這裡我們使用pybloom庫,需要pip或者原始碼安裝。pybloom庫用起來非常簡單,這裡給兩段最基本的程式碼:

from pybloom import BloomFilter

# 新建一個過濾器,長度為m,錯誤率為0.1%
bf = BloomFilter(capacity=1000, error_rate=0.001)

‘’’
不難理解,這句就相當於
for x in range(bf.capacity):
bd.add(x)
但說實話這種寫法我第一次見到
‘’’

[bf.add(x) for x in range(bf.capacity)]

print (0 in bf)
print (5 in bf)
print (10 in bf)

# 這裡是計算它的錯誤率
count = 0
amount = bf.capacity
for i in range(bf.capacity, bf.capacity + amount + 1):
if i in bf:
count += 1

print (“FP: {:2.4f}”.format(count / float(amount)))

我從網上搜到文章大多隻是介紹瞭如何新建一個Filter、怎麼add以及檢視元素是否屬於這個Filter。實際上,如果閱讀過原始碼,其實filter還提供了很多其他方法,同時這個庫還提供了一個可自動擴充套件的Filter,作者比較推薦後者。

from pybloom import BloomFilter

# 新建
bf1 = BloomFilter(capacity=1000, error_rate=0.001)
bf2 = BloomFilter(capacity=1000, error_rate=0.001)

# 新增
[bf1.add(x) for x in range(3)]
[bf2.add(x) for x in range(3,6)]

# 複製
bf3 = bf1.copy()

# | 操作,三種都行
bf3.union(bf1)
bf3 = bf3 | bf2
bf3 = bf3 or bf2

# & 操作, 三種都行
bf3.intersection(bf1)
bf3 = bf3 & bf1
bf3 = bf3 and bf1

# 成員變數和支援的操作符
len(bf3)
3 in bf3
bf3.capacity
bf3.error_rate

# 也支援tofile和fromfile操作
# 具體的程式碼可參照原始碼中tests.py中的test_serialization()方法

可擴充套件的過濾器:

from pybloom import ScalableBloomFilter

# 新建, mode目前只有2種
# SMALL_SET_GROWTH = 2, LARGE_SET_GROWTH = 4
# 前者佔記憶體少但速度慢,後者消耗記憶體快但速度快
bf1 = ScalableBloomFilter(initial_capacity=100, error_rate=0.001, mode=ScalableBloomFilter.SMALL_SET_GROWTH)
bf2 = ScalableBloomFilter(initial_capacity=1000, error_rate=0.001, mode=ScalableBloomFilter.LARGE_SET_GROWTH)

# 新增
[bf1.add(x) for x in range(3)]
[bf2.add(x) for x in range(3,6)]

# 兩個屬性(裝飾器)
bf1.capacity
bf1.count

# 成員變數和支援的操作符
len(bf1)
3 in bf1
bf1.initial_capacity
bf1.error_rate

# 也支援tofile和fromfile操作
# 具體的程式碼可參照原始碼中tests.py中的test_serialization()方法

這裡我建議看下這個庫原始碼,核心部分差不多500行,裡面很多寫法很值得學習,而且都很容易理解。裡面也涉及到了如何選取雜湊函式。

Redis

Python的Redis客戶端庫也是開源的,地址是:redis-py。不過在開始之前,你首先需要一個有Redis資料庫執行的主機(搭建一個很簡單)。

這裡需要解釋不少東西,首先,上文中有一節本來不叫“Redis的集合”而是叫“Redis集合”,我一開始以為這是一種名叫Redis的特殊集合,然後這個集合帶有不可插入重複內容的特性,事實上這裡大錯特錯了。還記得我們的初衷是“去重”,實際上,包括Python在內的很多語言已經實現了具有無序不重複特性的內建資料結構:集合(Set)。也就是說從去重這點看的話,有集合這種資料結構就夠了,跟Redis並沒有什麼關係。

那麼Redis是什麼?它是一種資料庫,它的官網是這樣描述的:

Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker. 

關於Redis資料庫還有幾個關鍵詞:key-value,高效能,資料持久化,資料備份,原子操作以及跟這裡相關的一個特性:支援集合資料型別。這才是為什麼做增量爬取時我們要用到Redis資料庫:我們可以通過將URL或者頁面內容的指紋作為key存入Redis資料庫中的集合裡,利用集合的不重複性達到去重的目的,每次爬蟲要處理URL或者頁面時會先去Redis資料庫裡檢查一下是否已經存在,因為Redis資料庫著力於key-value形式的儲存,所以這一步的速度將會很可觀;其次Redis可以將記憶體中的內容持久化到磁碟,並且其每一次操作都是原子操作,這就保證了爬蟲的可靠性,即爬蟲不會應為意外停止而損失資料。

說了這麼多,現在就能知道為什麼這裡要用到Redis的集合。如果只考慮本文相關的內容,那麼和本文有關的Redis資料庫操作命令只有兩個:SADD和SISMEMBER,前者可以向集合中插入一條資料,成功返回1,失敗返回0;後者可以查詢某元素是否在集合中存在,存在返回1,不存在返回0。

我在一臺虛擬機器Ubuntu-14.04上安裝了Redis資料庫並配置了遠端連線,客戶端測試如下:

>>> import redis
>>> r = redis.StrictRedis(host='192.168.153.131', port=6379, db=0)
>>> r.sadd('1','aa')
1
>>> r.sadd('1','aa')
0
>>> r.sadd('2','aa')
1
>>> r.sadd('3','aa')
1
>>> r.sismember('1','aa')
True
>>> r.sismember('1','b')
False
>>>

但應該如何將這一特性融入到爬蟲中呢?如果是自己寫的爬蟲程式碼,新增上述程式碼即可;如果使用的是scrapy框架,我們可以在middleware上下功夫,在spider模組收到要處理的URL時,寫一個Spider中介軟體用來判斷這條URL的指紋是否在Redis資料庫中存在,如果存在的話,就直接捨棄這條URL;如果是要判斷頁面的內容是否更新,可以在Download中介軟體中新增程式碼來校驗,原理一樣。當然,資料庫的操作可以用類似write()和query()的方法進行封裝,此處不表。

參考

  1. Bloom Filter概念和原理
  2. Scrapy如何藉助於BloomFilter實現增量爬取
  3. 爬蟲:5.增量爬取和去重
  4. scrapy實現增量式爬取
  5. Scrapy結合Redis實現增量爬取
  6. scrapy利用redis實現url去重與增量爬取
  7. Scrapy進行大規模抓取
  8. 網路爬蟲判斷頁面是否更新
  9. 海量資料處理之Bloom Filter詳解
      </div>
    </div>
</div>