Duplicate Elimination in Scrapy(转)
之前介紹 Scrapy?的時(shí)候提過(guò) Spider Trap ,實(shí)際上,就算是正常的網(wǎng)絡(luò)拓?fù)?#xff0c;也是很復(fù)雜的相互鏈接,雖然我當(dāng)時(shí)給的那個(gè)例子對(duì)于我感興趣的內(nèi)容是可以有一個(gè)線性順序依次爬下來(lái)的,但是這樣的情況在真正的網(wǎng)絡(luò)結(jié)構(gòu)中通常是少之又少,一但鏈接網(wǎng)絡(luò)出現(xiàn)環(huán)路,就無(wú)法進(jìn)行拓?fù)渑判蚨贸鲆粋€(gè)依次遍歷的順序了,所以 duplicate elimination 可以說(shuō)是每一個(gè) non-trivial 的必備組件之一,這樣就算在遍歷的過(guò)程中遇到環(huán)路也不用怕,排重組件會(huì)檢測(cè)到已經(jīng)訪問(wèn)過(guò)的地址,從而避免在環(huán)路上無(wú)限地循環(huán)下去。最簡(jiǎn)單的辦法也就是每次抓取頁(yè)面的時(shí)候記錄下 URL ,然后每次抓取新的 URL 之前先檢測(cè)一下是否已經(jīng)有記錄了。不過(guò),通常我們并不直接按字符比較 URL ,因?yàn)槟菢油ǔ?huì)漏掉許多本來(lái)確實(shí)是重復(fù)的 URL ,特別是現(xiàn)在動(dòng)態(tài)頁(yè)面盛行的情況,例如在 cc98 (ZJU 的一個(gè)校內(nèi)論壇)上下面幾個(gè) URL 路徑是等價(jià)的:
?
1 和 2 是參數(shù)位置交換,這個(gè)問(wèn)題幾乎存在于所有動(dòng)態(tài)頁(yè)面上,因?yàn)橥ǔ5?CGI (姑且統(tǒng)稱為 CGI 吧)并不在意參數(shù)出現(xiàn)的順序,而 3 則是 cc98 自己的問(wèn)題,實(shí)際上 page 這個(gè)參數(shù)對(duì)于現(xiàn)實(shí)一個(gè)帖子沒有什么用處,寫成多少都無(wú)所謂,它是帖子標(biāo)題列表那個(gè)頁(yè)面的頁(yè)數(shù),但是 cc98 有時(shí)確實(shí)會(huì)在現(xiàn)實(shí)帖子的時(shí)候把那個(gè)參數(shù)也附上。所以,判重組件要做到火眼金睛還是相當(dāng)困難的,事實(shí)上,Internet 上的 URL 和它對(duì)應(yīng)的內(nèi)容是多對(duì)多的關(guān)系,即使同一個(gè) URL 在不同時(shí)間訪問(wèn)也有可能得到不同的結(jié)果(例如一個(gè) Google 的搜索結(jié)果頁(yè)面),所以,判重組件錯(cuò)判和漏判都是有可能的,雖然如此,我們可以利用一些經(jīng)驗(yàn)知識(shí)來(lái)做到盡量完善,另外,和上次說(shuō)的一樣,如果問(wèn)題被限制在一個(gè)已知的領(lǐng)域(比如,某個(gè)特定的網(wǎng)站而不是混亂的 Internet ),問(wèn)題又會(huì)變得簡(jiǎn)單許多了。
扯了半天,再回到 Scrapy 。因?yàn)樽约褐白龅囊恍┬?shí)驗(yàn)發(fā)現(xiàn)如果給他重復(fù)的 URL 的話,它是會(huì)義無(wú)反顧的地再抓一遍的,而在它的 Tutorial 里也只字未提相關(guān)的東西,所以我一直以為它沒有提供現(xiàn)成的東西,雖然一個(gè)號(hào)稱已經(jīng)在實(shí)際中使用了的爬蟲框架沒有判重組件多少是一件有點(diǎn)讓人難以置信的事。不過(guò)事實(shí)證明它其實(shí)是有判重組件的,從它的結(jié)構(gòu)圖(見上一篇介紹 Scrapy 的 blog?)中可以看到,判重組件如果要自己寫的話,應(yīng)該是一個(gè) Scheduler Middleware ,本來(lái)想看一下 Scheduler Middleware 的接口是怎樣的,打開文檔一看,才發(fā)現(xiàn)已經(jīng)有了一個(gè)現(xiàn)成的 DuplicatesFilterMiddleware 了。
如果要添加自己的 Scheduler Middleware ,應(yīng)該在 settings.py 里定義 SCHEDULER_MIDDLEWARES 變量,這是一個(gè) dict 對(duì)象,key 是中間件的完整類名,value 則是 priority 。不過(guò)在系統(tǒng)級(jí)別的 SCHEDULER_MIDDLEWARES_BASE 里已經(jīng)有了這個(gè)中間件了:
SCHEDULER_MIDDLEWARES_BASE = {'scrapy.contrib.schedulermiddleware.duplicatesfilter.DuplicatesFilterMiddleware': 500, }再經(jīng)過(guò)各種跟蹤(之間還不會(huì)用 Python 調(diào)試器,都是直接打開庫(kù)的源代碼插入 print 語(yǔ)句 -,-bb),發(fā)現(xiàn)中間件確實(shí)被啟動(dòng)起來(lái)了,而且判重的方法也被調(diào)用了,并且也檢測(cè)到了重復(fù),不過(guò),問(wèn)題出在這里:
def enqueue_request(self, domain, request):seen = self.dupefilter.request_seen(domain, request)if seen and not request.dont_filter:raise IgnoreRequest('Skipped (request already seen)')那個(gè) dont_filter 屬性在作怪,由于 spider 對(duì)象的 make_requests_from_url 方法把 Request 的 dont_filter 屬性設(shè)成了 True ,因此導(dǎo)致判重組件失效了:
def make_requests_from_url(self, url):return Request(url, callback=self.parse, dont_filter=True)可以看到這個(gè)方法其實(shí)非常簡(jiǎn)單,也可以自己手工構(gòu)建 Request 對(duì)象,指定 callback ,并且 dont_filter 默認(rèn)是 False 的,這樣就能得到想要的效果了。
其實(shí) Scrapy 提供的 duplicate filter 是相當(dāng)靈活的,它把中間件和判重算法分離開來(lái),預(yù)置了兩種判重的實(shí)現(xiàn),一個(gè)是 NullDupeFilter ,什么都不管,只會(huì)返回“不重復(fù)”,另一個(gè)是 RequestFingerprintDupeFilter (也是默認(rèn)裝配的那個(gè)),使用一個(gè) Request 的 fingerprint 來(lái)進(jìn)行比對(duì)。fingerprint 主要是通過(guò) url 取 hash 計(jì)算出來(lái)的,當(dāng)然為了能處理簡(jiǎn)單的參數(shù)位置變換的情況,減少漏判,具體可以參見 utils/request.py 的 request_fingerprint 方法。
要實(shí)現(xiàn)自己的 Duplicate Filter 有兩種方法,一種是以算法的形式,在 settings.py 里將 DUPEFILTER_CLASS 指定為自己定義的類,這樣會(huì)用自己的算法替換掉系統(tǒng)的算法;另一種方法是不影響系統(tǒng)默認(rèn)的 filter ,另外再實(shí)現(xiàn)一個(gè) filter middleware 添加到 SCHEDULER_MIDDLEWARES 里,寫法大同小異,只是接口有稍許不同,下面介紹第二種寫法。新建一個(gè)文件 scheduler_middleware.py (其實(shí)名字可以隨便取),在里面實(shí)現(xiàn)我們的判重中間件:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | from scrapy.core.exceptions import IgnoreRequest from scrapy.extension import extensionsfrom crawl.cc98_util import extract_url, DOMAINclass DuplicatesFilterMiddleware(object):def open_domain(self, domain):if domain == DOMAIN:self.init_fingerprints()def close_domain(self, domain):if domain == DOMAIN:self.fingerprints = Nonedef enqueue_request(self, domain, request):if domain != DOMAIN or request.dont_filter:returnfp = self.make_fingerprint(extract_url(request.url))if fp in self.fingerprints:raise IgnoreRequest('Skipped (request already seen)')self.fingerprints.add(fp)def make_fingerprint(self, dic):return '%s,%s,%s' % (dic['board_id'],dic['thread_id'],dic['page_num'])def init_fingerprints(self):self.fingerprints = set() |
主要是要實(shí)現(xiàn)三個(gè)方法:open_domain, close_domain 和 enqueue_request ,如果發(fā)現(xiàn) Request 對(duì)象應(yīng)該丟棄的話,直接拋出 IgnoreRequest 異常即可。這里我用 extract_url 方法(就是正則匹配,就不細(xì)說(shuō)了)提取出 board_id, thread_id 和 page_num 三個(gè)參數(shù),將他們的值排列起來(lái)做成一個(gè) fingerprint ,用在 cc98 這里是正好的。然后在 settings.py 里加入:
SCHEDULER_MIDDLEWARES = {'crawl.scheduler_middlewares.DuplicatesFilterMiddleware': 500 }就可以用上我們自己的判重過(guò)濾了。??到此為止本來(lái)關(guān)于本文標(biāo)題的東西可以說(shuō)已經(jīng)講完了,不過(guò)這個(gè) crawler 要完整還需要一些額外的東西,我就順便多說(shuō)一下吧。
首先是抓取結(jié)果的處理,這次我并不是直接存儲(chǔ) raw 的 HTML 頁(yè)面,而是將內(nèi)容解析之后按照帖子結(jié)構(gòu)存儲(chǔ)在數(shù)據(jù)庫(kù)里。在最近更新過(guò)之后發(fā)現(xiàn)原來(lái)的 ScrapedItem 在將來(lái)的版本里將會(huì)由 Item 來(lái)替代了,現(xiàn)在可以用類似于 ORM 的方式來(lái)定義 Item ,也許以后會(huì)做得像 Django 的 Model 那樣方便地用于數(shù)據(jù)庫(kù)上吧:
from scrapy.item import Item, Fieldclass CrawlItem(Item):board_id = Field()thread_id = Field()page_num = Field()raw = Field()def __str__(self):return '<CrawlItem %s,%s,%s>' % (self['board_id'],self['thread_id'],self['page_num'])class PostBundleItem(Item):posts = Field()def __str__(self):return '<PostBundleItem %d>' % len(self['posts'])一次下載的一個(gè)頁(yè)面會(huì)得到一個(gè) CrawlItem 對(duì)象,這是論壇里一頁(yè)的內(nèi)容,一頁(yè)內(nèi)通常有多個(gè) post ,所以我再添加了一個(gè) pipeline 來(lái)將一個(gè)頁(yè)面解析成多個(gè) post ,存儲(chǔ)在一個(gè) PostBundleItem 對(duì)象中。pipeline 就不細(xì)說(shuō)了,上次介紹過(guò),只要定義 process_item 方法即可,這樣在 settings.py 里我就依次有兩個(gè) pipeline :
ITEM_PIPELINES = ['crawl.pipelines.PostParsePipeline', 'crawl.pipelines.PostStorePipeline']代碼也不多帖了,畫一個(gè)圖直觀一點(diǎn)(畫這個(gè)圖里的字體實(shí)在是太丑了,但是手邊沒有好用的工具,也只能暫時(shí)將就了 -,-bb):
不過(guò),如果你有注意到,不管是 Scrapy 內(nèi)置的判重組件還是我上面的組件,所用的數(shù)據(jù)結(jié)構(gòu)都是直接放在內(nèi)存里的,所以說(shuō)如果你一次 crawl 結(jié)束(包括正常結(jié)束,或者斷電、斷網(wǎng)、程序出錯(cuò)等異常結(jié)束)之后,如果再重新啟動(dòng) crawler ,判重組件會(huì)從零開始,于是許多的頁(yè)面又要重新下載一次。這當(dāng)然不是我們說(shuō)希望的,因此我要在 crawler 啟動(dòng)的時(shí)候從數(shù)據(jù)庫(kù)里提取出已經(jīng)抓取了的頁(yè)面來(lái)初始化 duplicates filter ;另外,為了達(dá)到增量抓取的目的,我希望每次 crawler 啟動(dòng)的時(shí)候從上一次結(jié)束的地方開始抓取,而不是每次都使用同一個(gè)固定的 seed url ,這也需要用到數(shù)據(jù)庫(kù)里已經(jīng)存在的數(shù)據(jù)。
由于各個(gè)組件都要訪問(wèn)數(shù)據(jù)庫(kù),因此我做一個(gè) Scrapy Extension 來(lái)管理數(shù)據(jù)庫(kù)連接。在 Scrapy 中做一個(gè) Extension 也是一件很容易的事情,隨意寫一個(gè)類就可以作為 Extension ,沒有任何限制或規(guī)定,例如:
| 1 2 3 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 | import sqlite3 from os import pathfrom scrapy.conf import settings from scrapy.core import signals from scrapy.xlib.pydispatch import dispatcher from scrapy.core.exceptions import NotConfiguredclass SqliteManager(object):def __init__(self):if settings.get('SQLITE_DB_FILE') is None:raise NotConfiguredself.conn = Noneself.initialize()dispatcher.connect(self.finalize, signals.engine_stopped)def initialize(self):filename = settings['SQLITE_DB_FILE']if path.exists(filename):self.conn = sqlite3.connect(filename)else:self.conn = self.create_table(filename)def finalize(self):if self.conn is not None:self.conn.commit()self.conn.close()self.conn = Nonedef create_table(self, filename):# ... snipped ... |
然后在 settings.py 里指定加載該 Extension 即可:
EXTENSIONS = {'crawl.extensions.SqliteManager': 500 }同 middleware 一樣,后面那個(gè) 500 表示優(yōu)先級(jí)。另外,上面的代碼中如果發(fā)現(xiàn)沒有定義 SQLITE_DB_FILE 變量(也是在 settings.py 中)的話會(huì)拋出 NotConfigured 異常,這個(gè)異常并不會(huì)導(dǎo)致 crawler 啟動(dòng)出錯(cuò),此時(shí) Scrapy 只是會(huì)簡(jiǎn)單地選擇不啟用該 Extension 。其實(shí)我這里的 SqliteManager 是一個(gè)相當(dāng)核心的組件,如果不啟用的話整個(gè)系統(tǒng)就沒法正常工作了,所以這樣的行為似乎應(yīng)該修改一下。?
Extension 定義好之后在程序中引用也很方便,把 scrapy.extension 里的 extensions 對(duì)象 import 進(jìn)來(lái),然后用 extensions.enabled['SqliteManager'] 就可以引用到系統(tǒng)為你初始化好的那個(gè) Extension 對(duì)象了,以這種引用方式看來(lái),Extension 的類名似乎得是 unique 的才行。
有一點(diǎn)要注意的地方就是各個(gè)組件之間的依賴關(guān)系,特別是在初始化的時(shí)候,例如,我這里 DuplicatesFilterMiddleware 和 spider 在初始化的時(shí)候都會(huì)用到 SqliteManager 的數(shù)據(jù)庫(kù)連接,因此 SqliteManager 需要在對(duì)象構(gòu)造的時(shí)候就建立好連接(或者惰性按需建立也可以),而不是像上一篇文章中那樣在 signals.engine_started 的時(shí)候再建立連接。而且,由于 Scrapy 建立在 Twisted 這個(gè)看起來(lái)非常魔幻的異步網(wǎng)絡(luò)庫(kù)的基礎(chǔ)上,程序出錯(cuò)之后想要輕松地調(diào)試幾乎是不可能的,得到的錯(cuò)誤信息和 trackback 通常都是風(fēng)馬牛不相及,這個(gè)時(shí)候似乎只有反復(fù)檢查代碼是最終有效的“調(diào)試”方式了。?
這樣,我們將前面定義的 init_fingerprints 方法稍作修改,不再是只建立一個(gè)空的 set ,而是從數(shù)據(jù)庫(kù)里做一些初始化工作:
| 26 27 28 29 30 31 32 | def init_fingerprints(self):self.fingerprints = set()mgr = extensions.enabled['SqliteManager']cursor = mgr.conn.execute('select distinct board_id, thread_id, page_num from posts')for board_id, thread_id, page_num in cursor:fp = self.make_fingerprint({'board_id':board_id,'thread_id':thread_id,'page_num':page_num})self.fingerprints.add(fp) |
在上一篇文章的介紹中,spider 使用 start_urls 屬性作為 seed url ,其實(shí)實(shí)際使用的是一個(gè) start_requests 方法,不過(guò) BaseSpider 提供了一個(gè)默認(rèn)實(shí)現(xiàn),就是從 start_urls 構(gòu)建初始 Requests ,我們?yōu)榱藢?shí)現(xiàn)增量 crawler ,只要重新定義 spider 的該方法即可:
| 15 16 17 18 19 20 21 22 23 24 25 26 | def start_requests(self):mgr = extensions.enabled['SqliteManager']val = mgr.conn.execute('select max(page_num) from posts').fetchone()[0]if val is None:page_num = 1else:page_num = val# the last page may be incomplete, so we set dont_filter to be True to# force re-crawling itreturn [Request(make_url(board_id=self.board_id, thread_id=self.thread_id,page_num=page_num), callback=self.parse, dont_filter=True)] |
雖然跑題已經(jīng)跑得有點(diǎn)遠(yuǎn)了,不過(guò)這樣一來(lái),我們就得到了一個(gè)比先前更加完善的爬蟲了。?
總結(jié)
以上是生活随笔為你收集整理的Duplicate Elimination in Scrapy(转)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 正则小记
- 下一篇: std::remove