引言
資料採集工作中,難免會遇到增量採集。而在增量採集中,如何去重是一個大問題,因為實際的需要採集的資料也許並不多,但往往要在判斷是否已經採集過這件事上花點時間。比如對於資訊採集,如果釋出網站每天只更新幾條或者根本就不更新,那麼如何讓採集程式每次只採集這更新的幾條(或不採集)是一件很簡單的事,資料庫就是一種實現方式。不過當面臨大量的目標網站時,每次採集前也許就需要先對資料庫進行大量的查詢操作,這是一件費時的事情,難免降低採集程式的效能,使得每次採集耗時變大。本文從資訊採集角度出發,以新浪新聞排行(地址: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 快取機制並不能完美的適配所有網站,因此,可以記錄各個目標網站的更新頻率,並將更新較為頻繁的網站作為優先採集物件。
參考
- 使用 HTTP 快取:Etag, Last-Modified 與 Cache-Control: https://harttle.land/2017/04/04/using-http-cache.html