增量採集中的幾種去重方案

kingron發表於2020-09-20

引言

資料採集工作中,難免會遇到增量採集。而在增量採集中,如何去重是一個大問題,因為實際的需要採集的資料也許並不多,但往往要在判斷是否已經採集過這件事上花點時間。比如對於資訊採集,如果釋出網站每天只更新幾條或者根本就不更新,那麼如何讓採集程式每次只採集這更新的幾條(或不採集)是一件很簡單的事,資料庫就是一種實現方式。不過當面臨大量的目標網站時,每次採集前也許就需要先對資料庫進行大量的查詢操作,這是一件費時的事情,難免降低採集程式的效能,使得每次採集耗時變大。本文從資訊採集角度出發,以新浪新聞排行(地址:http://news.sina.com.cn/hotnews/)資訊採集為例,針對增量採集提出了去重方案。

資料庫去重

考慮到資料庫查詢亦需耗費時間,因此去重欄位可單獨存放到一張表上,或使用 redis 等查詢耗時較少的資料庫。一般來說,可以將文章詳情頁源地址作為去重欄位。考慮到某些連線字元過長,可以使用 md5 將其轉化為統一長度的字元。而後在每次採集時,先抓取列表頁,解析其中的文章資訊,將連結 md5 化,然後再將得到的結果到資料庫中查詢,如果存在則略過,否則深入採集。以 redis 為例,實現程式碼如下:

import hashlib

import redis
import requests
import parsel


class NewsSpider(object):

    start_url = 'http://news.sina.com.cn/hotnews/'

    def __init__(self):
        self.db = RedisClient('news')

    def start(self):
        r = requests.get(self.start_url)
        for url in self.parse_article(r):
            
            fingerprint = get_url_fingerprint(url)
            
            if self.db.is_existed(fingerprint):
                continue
            
            try:
                self.parse_detail(requests.get(url))
            except Exception as e:
                print(e)
            else:
                # 如果解析正常則將地址放入資料庫
                self.db.add(fingerprint)

    def parse_article(self, response):
        """解析文章列表頁並返回文章地址"""
        selector = parsel.Selector(text=response.text)
        # 獲取所有文章地址
        return selector.css('.ConsTi a::attr(href)').getall()

    def parse_detail(self, response):
        """詳情頁解析邏輯省略"""
        pass


class RedisClient(object):
    """Redis 客戶端"""
    def __init__(self, key):
        self._db = redis.Redis(
            host='localhost',
            port=6379,
            decode_responses=True
        )
        self.key = key

    def is_existed(self, value):
        """檢測 value 是否已經存在
        如果存在則返回 True 否則 False
        """
        return self._db.sismember(self.key, value)

    def add(self, value):
        """存入資料庫"""
        return self._db.sadd(self.key, value)


def get_url_fingerprint(url):
    """對 url 進行 md5 加密並返回加密後的字串"""
    md5 = hashlib.md5()
    md5.update(url.encode('utf-8'))
    return md5.hexdigest()

注意以上程式碼中,對請求的 md5 並沒有考慮對 POST 請求的 md5 加密,如有需要可自行實現。

根據 HTTP 快取機制去重

設若我們有大量的列表頁中的文章需要採集,而距離上次採集時,有的更新了幾條新聞,有的則沒有更新。那麼如何判斷資料庫去重雖然能解決具體文章的去重,但對於文章索引頁仍需要每次請求,因為 HEAD 請求所花費的時間要比 GET/POST 請求要少,所以可用 HEAD 請求獲取索引頁的相關資訊,從而判斷索引頁是否有更新。通過對目標地址請求可以檢視具體資訊:

>>> r = requests.head('http://news.sina.com.cn/hotnews/')
>>> for k, v in r.headers.items():
...     print(k, v)
...
Server: nginx
Date: Fri, 31 Jan 2020 09:33:22 GMT
Content-Type: text/html
Content-Length: 37360
Connection: keep-alive
Vary: Accept-Encoding
ETag: "5e33f39e-28e01"V=CCD0B746
X-Powered-By: shci_v1.03
Expires: Fri, 31 Jan 2020 09:34:16 GMT
Cache-Control: max-age=60
Content-Encoding: gzip
Age: 6
Via: http/1.1 ctc.guangzhou.union.182 (ApacheTrafficServer/6.2.1 [cSsNfU]), http/1.1 ctc.chongqing.union.138 (ApacheTrafficServer/6.2.1 [cHs f ])
X-Via-Edge: 158046320209969a5527d9b2299db18a555d7
X-Cache: HIT.138
X-Via-CDN: f=edge,s=ctc.chongqing.union.144.nb.sinaedge.com,c=125.82.165.105;f=Edge,s=ctc.chongqing.union.138,c=219.153.34.144

在此,基於 HTTP 快取機制,我們有以下幾種方式來判斷頁面是否有更新:

1. Content-Length

注意以上資訊中的 Content-Length 欄位,它指明瞭索引頁字元的長度。由於一般有更新的頁面字元長度也會有所不同,因此可以使用它來判定索引頁是否有更新。

2. ETag

Etag 響應頭欄位表示資源的版本,在傳送請求時帶上 If-None-Match 頭欄位,來詢問伺服器該版本是否仍然可用。如果伺服器發現該版本仍然是最新的,就可以返回 304 狀態碼指示 UA 繼續使用快取,那麼就不用再採集具體的頁面了。本例中亦可使用:

# ETag 完整欄位為  "5e33f39e-28e01"V=CCD0B746
# 實際需要的則是 5e33f39e-28e01
>>> r = requests.get('http://news.sina.com.cn/hotnews/', headers={'If-None-Match': '5e33f39e-28e01'})
>>> r.status_code
304
>>> r.text
''

3. Last-Modified

本例並沒有 Last-Modified 欄位,不過考慮到其他網站可能會有該欄位,因此亦可考慮作為去重方式之一。該欄位與 Etag 類似,Last-Modified HTTP 響應頭也用來標識資源的有效性。不同的是使用修改時間而不是實體標籤。對應的請求頭欄位為 If-Modified-Since

實現思路

以上三種都可以將上次請求得到的資訊存入到資料庫中,再次請求時則取出相應資訊併傳送相應請求,如果與本地一致(或響應碼為 304)則判斷為未更新,不然則繼續請求。鑑於有的網站並沒有實現 HTTP 快取機制,有的則只實現某一種。因此可以考慮在採集程式中將以上三種機制全部實現,從而保證最大化的減少無效請求。具體實現思路為:

請求目標網站
    
    - 以 Content-Length 判斷 -> HEAD 請求
        與本地儲存長度對比
            一致    -> 忽略
            不一致  -> 傳送 GET/POST 請求
                       同時更新本地資料
    
    - 以 ETag/Last-Modified 判斷 -> GET/POST 請求
      並在請求頭中帶上相應資訊
        判斷響應碼
            304 -> 忽略
            200 -> 解析並更新本地資料
    
    - 無相應欄位 -> GET/POST 請求

根據更新頻率分配優先權

根據 HTTP 快取機制並不能完美的適配所有網站,因此,可以記錄各個目標網站的更新頻率,並將更新較為頻繁的網站作為優先採集物件。

參考

  1. 使用 HTTP 快取:Etag, Last-Modified 與 Cache-Control: https://harttle.land/2017/04/04/using-http-cache.html

相關文章