序言
第1章 Scrapy介紹
第2章 理解HTML和XPath
第3章 爬蟲基礎(chǔ)
第4章 從Scrapy到移動應(yīng)用
第5章 快速構(gòu)建爬蟲
第6章 Scrapinghub部署
第7章 配置和管理
第8章 Scrapy編程
第9章 使用Pipeline
第10章 理解Scrapy的性能
第11章(完) Scrapyd分布式抓取和實(shí)時(shí)分析
在上一章,我們學(xué)習(xí)了如何辨析Scrapy中間件。在本章中,我們通過實(shí)例學(xué)習(xí)編寫pipelines,包括使用REST APIs、連接數(shù)據(jù)庫、處理CPU密集型任務(wù)、與老技術(shù)結(jié)合。
我們在本章中會使用集中新的數(shù)據(jù)庫,列在下圖的右邊:
Vagrant已經(jīng)配置好了數(shù)據(jù)庫,我們可以從開發(fā)機(jī)向其發(fā)送ping,例如ping es或ping mysql。讓我們先來學(xué)習(xí)REST APIs。
使用REST APIs
REST是用來一套創(chuàng)建網(wǎng)絡(luò)服務(wù)的技術(shù)集合。它的主要優(yōu)點(diǎn)是,比起SOAP和專有web服務(wù),REST更簡單和輕量。軟件開發(fā)者注意到了web服務(wù)的CRUD(Create、Read、Update、Delete)和HTTP操作(GET、POST、PUT、DELETE)的相似性。它們還注意到傳統(tǒng)web服務(wù)調(diào)用需要的信息可以再URL源進(jìn)行壓縮。例如,http://api.mysite.com/customer/john是一個(gè)URL源,它可以讓我們分辨目標(biāo)服務(wù)器,,更具體的,名字是john的服務(wù)器(行的主鍵)。它與其它技術(shù)結(jié)合時(shí),比如安全認(rèn)證、無狀態(tài)服務(wù)、緩存、輸出XML或JSON時(shí),可以提供一個(gè)強(qiáng)大但簡單的跨平臺服務(wù)。REST席卷軟件行業(yè)并不奇怪。
Scrapy pipeline的功能可以用REST API來做。接下來,我們來學(xué)習(xí)它。
使用treq
treq是一個(gè)Python包,它在Twisted應(yīng)用中和Python的requests包相似。它可以讓我們做出GET、POST、和其它HTTP請求。可以使用pip install treq安裝,開發(fā)機(jī)中已經(jīng)安裝好了。
比起Scrapy的Request/crawler.engine.download() API,我們使用treq,因?yàn)楹笳呔哂行阅軆?yōu)勢,詳見第10章。
一個(gè)寫入Elasticsearch的pipeline
我們從一個(gè)向ES服務(wù)器(Elasticsearch)寫入Items的爬蟲開始。你可能覺得從ES開始,而不是MySQL,有點(diǎn)奇怪,但實(shí)際上ES是最容易的。ES可以是無模式的,意味著我們可以不用配置就使用它。treq也足以應(yīng)付需要。如果想使用更高級的ES功能,我們應(yīng)該使用txes2和其它Python/Twisted ES包。
有了Vagrant,我們已經(jīng)有個(gè)一個(gè)運(yùn)行的ES服務(wù)器。登錄開發(fā)機(jī),驗(yàn)證ES是否運(yùn)行:
$ curl http://es:9200
{
"name" : "Living Brain",
"cluster_name" : "elasticsearch",
"version" : { ... },
"tagline" : "You Know, for Search"
}
在瀏覽器中登錄http://localhost:9200也可以看到相同的結(jié)果。如果訪問http://localhost:9200/properties/property/_search,我們可以看到一個(gè)響應(yīng),說ES已經(jīng)進(jìn)行了全局嘗試,但是沒有找到索引頁。
筆記:在本章中,我們會在項(xiàng)集合中插入新的項(xiàng),如果你想恢復(fù)原始狀態(tài)的話,可以用下面的命令:
$ curl -XDELETE http://es:9200/properties
本章中的pipeline完整代碼還有錯(cuò)誤處理的功能,但我盡量讓這里的代碼簡短,以突出重點(diǎn)。
提示:本章位于目錄ch09,這個(gè)例子位于ch09/properties/properties/pipelines/es.py。
本質(zhì)上,這個(gè)爬蟲只有四行:
@defer.inlineCallbacks
def process_item(self, item, spider):
data = json.dumps(dict(item), ensure_ascii=False).encode("utf- 8")
yield treq.post(self.es_url, data)
前兩行定義了一個(gè)標(biāo)準(zhǔn)process_item()方法,它可以產(chǎn)生延遲項(xiàng)。(參考第8章)
第三行準(zhǔn)備了插入的data。ensure_ascii=False可使結(jié)果壓縮,并且沒有跳過非ASCII字符。我們?nèi)缓髮SON字符串轉(zhuǎn)化為JSON標(biāo)準(zhǔn)的默認(rèn)編碼UTF-8。
最后一行使用了treq的post()方法,模擬一個(gè)POST請求,將我們的文檔插入ElasticSearch。es_url,例如http://es:9200/properties/property存在settings.py文件中(ES_PIPELINE_URL設(shè)置),它提供重要的信息,例如我們想要寫入的ES的IP和端口(es:9200)、集合名(properties)和對象類型(property)。
為了是pipeline生效,我們要在settings.py中設(shè)置ITEM_PIPELINES,并啟動ES_PIPELINE_URL設(shè)置:
ITEM_PIPELINES = {
'properties.pipelines.tidyup.TidyUp': 100,
'properties.pipelines.es.EsWriter': 800,
}
ES_PIPELINE_URL = 'http://es:9200/properties/property'
這么做完之后,我們前往相應(yīng)的目錄:
$ pwd
/root/book/ch09/properties
$ ls
properties scrapy.cfg
然后運(yùn)行爬蟲:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=90
...
INFO: Enabled item pipelines: EsWriter...
INFO: Closing spider (closespider_itemcount)...
'item_scraped_count': 106,
如果現(xiàn)在訪問http://localhost:9200/properties/property/_search,除了前10條結(jié)果,我們可以在響應(yīng)的hits/total字段看到插入的文件數(shù)。我們還可以添加參數(shù)?size=100以看到更多的結(jié)果。通過添加q= URL搜索中的參數(shù),我們可以在全域或特定字段搜索關(guān)鍵詞。相關(guān)性最強(qiáng)的結(jié)果會首先顯示出來。例如,http://localhost:9200/properties/property/_search?q=title:london,可以讓標(biāo)題變?yōu)長ondon。對于更復(fù)雜的查詢,可以在https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html查詢ES文檔。
ES不需要配置,因?yàn)樗鶕?jù)提供的第一個(gè)文件,進(jìn)行模式(字段類型)自動檢測的。通過訪問http://localhost:9200/properties/,我們可以看到它自動檢測的映射。
再次運(yùn)行crawl easy -s CLOSESPIDER_ITEMCOUNT=1000。因?yàn)閜ipelines的平均時(shí)間從0.12變?yōu)?.15秒,平均延遲從0.78變?yōu)?.81秒。吞吐量仍保持每秒約25項(xiàng)。
筆記:用pipelines向數(shù)據(jù)庫插入Items是個(gè)好方法嗎?答案是否定的。通常來講,數(shù)據(jù)庫更簡單的方法以大量插入數(shù)據(jù),我們應(yīng)該使用這些方法大量批次插入數(shù)據(jù),或抓取完畢之后進(jìn)行后處理。我們會在最后一章看到這些方法。然后,還是有很多人使用pipelines向數(shù)據(jù)庫插入文件,相應(yīng)的就要使用Twisted APIs。
pipeline使用Google Geocoding API進(jìn)行地理編碼
我們的房子有各自所在的區(qū)域,我們還想對它們進(jìn)行地理編碼,即找到相應(yīng)的坐標(biāo)(經(jīng)度、緯度)。我們可以將坐標(biāo)顯示在地圖上,或計(jì)算距離。建這樣的數(shù)據(jù)庫需要復(fù)雜的數(shù)據(jù)庫、復(fù)雜的文本匹配,還有復(fù)雜的空間計(jì)算。使用Google Geocoding API,我們可以避免這些。在瀏覽器中打開它,或使用curl取回以下URL的數(shù)據(jù):
$ curl "https://maps.googleapis.com/maps/api/geocode/json?sensor=false&ad
dress=london"
{
"results" : [
...
"formatted_address" : "London, UK",
"geometry" : {
...
"location" : {
"lat" : 51.5073509,
"lng" : -0.1277583
},
"location_type" : "APPROXIMATE",
...
],
"status" : "OK"
}
我們看到一個(gè)JSON對象,如果搜索一個(gè)location,我們可以快速獲取倫敦中心的坐標(biāo)。如果繼續(xù)搜索,我們可以看到相同文件中海油其它地點(diǎn)。第一個(gè)是相關(guān)度最高的。因此如果存在results[0].geometry.location的話,它就是我們要的結(jié)果。
可以用前面的方法(treq)使用Google Geocoding API。只需要幾行,我們就可以找到一個(gè)地址的坐標(biāo)(目錄pipelines中的geo.py),如下所示:
@defer.inlineCallbacks
def geocode(self, address):
endpoint = 'http://web:9312/maps/api/geocode/json'
parms = [('address', address), ('sensor', 'false')]
response = yield treq.get(endpoint, params=parms)
content = yield response.json()
geo = content['results'][0]["geometry"]["location"]
defer.returnValue({"lat": geo["lat"], "lon": geo["lng"]})
這個(gè)函數(shù)做出了一條URL,但我們讓它指向一個(gè)可以離線快速運(yùn)行的假程序。你可以使用endpoint = 'https://maps.googleapis.com/maps/api/geocode/json'連接Google服務(wù)器,但要記住它對請求的限制很嚴(yán)格。address和sensor的值是URL自動編碼的,使用treq的方法get()的參數(shù)params。對于第二個(gè)yield,即response.json(),我們必須等待響應(yīng)主題完全加載完畢對解析為Python對象。此時(shí),我們就可以找到第一個(gè)結(jié)果的地理信息,格式設(shè)為dict,使用defer.returnValue()返回,它使用了inlineCallbacks。如果發(fā)生錯(cuò)誤,這個(gè)方法會扔出例外,Scrapy會向我們報(bào)告。
通過使用geocode(),process_item()變成了一行語句:
item["location"] = yield self.geocode(item["address"][0])
設(shè)置讓pipeline生效,將它添加到ITEM_PIPELINES,并設(shè)定優(yōu)先數(shù)值,該數(shù)值要小于ES的,以讓ES獲取坐標(biāo)值:
ITEM_PIPELINES = {
...
'properties.pipelines.geo.GeoPipeline': 400,
開啟數(shù)據(jù)調(diào)試,然后運(yùn)行:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=90 -L DEBUG
...
{'address': [u'Greenwich, London'],
...
'image_URL': [u'http://web:9312/images/i06.jpg'],
'location': {'lat': 51.482577, 'lon': -0.007659},
'price': [1030.0],
...
我們現(xiàn)在可以看到Items里的location字段。如果使用真正的Google API的URL運(yùn)行,會得到例外:
File "pipelines/geo.py" in geocode (content['status'], address))
Exception: Unexpected status="OVER_QUERY_LIMIT" for
address="*London"
這是為了檢查我們在完整代碼中插入了地點(diǎn),以確保Geocoding API響應(yīng)的status字段有OK值。除非是OK,否則我們?nèi)』氐臄?shù)據(jù)不會有設(shè)定好的格式,進(jìn)而不能使用。對于這種情況,我們會得到OVER_QUERY_LIMIT狀態(tài),它指明我們在某處做錯(cuò)了。這個(gè)問題很重要,也很常見。應(yīng)用Scrapy的高性能引擎,進(jìn)行緩存、限制請求就很必要了。
我們可以在Geocoder API的文檔,查看它的限制,“每24小時(shí),免費(fèi)用戶可以進(jìn)行2500次請求,每秒5次請求”。即使我們使用付費(fèi)版本,仍有每秒10次請求的限制,所以這里的分析是有意義的。
筆記:后面的代碼看起來可能有些復(fù)雜,復(fù)雜度還要取決于實(shí)際情況。在多線程環(huán)境中創(chuàng)建這樣的組件,需要線程池和同步,這樣代碼就會變復(fù)雜。
這是一個(gè)簡易的運(yùn)用Twisted技術(shù)的限制引擎:
class Throttler(object):
def __init__(self, rate):
self.queue = []
self.looping_call = task.LoopingCall(self._allow_one)
self.looping_call.start(1. / float(rate))
def stop(self):
self.looping_call.stop()
def throttle(self):
d = defer.Deferred()
self.queue.append(d)
return d
def _allow_one(self):
if self.queue:
self.queue.pop(0).callback(None)
這可以讓延遲項(xiàng)在一個(gè)列表中排隊(duì),逐個(gè)觸發(fā),調(diào)用_allow_one();_allow_one()檢查隊(duì)列是否為空,如果不是,它會調(diào)用第一個(gè)延遲項(xiàng)的callback()。我們使用Twisted的task.LoopingCall() API,周期性調(diào)用_allow_one()。使用Throttler很容易。我們在pipeline的init初始化它,當(dāng)爬蟲停止時(shí)清空它:
class GeoPipeline(object):
def __init__(self, stats):
self.throttler = Throttler(5) # 5 Requests per second
def close_spider(self, spider):
self.throttler.stop()
在使用限定源之前,我們的例子是在process_item()中調(diào)用geocode(),必須yield限制器的throttle()方法:
yield self.throttler.throttle()
item["location"] = yield self.geocode(item["address"][0])
對于第一個(gè)yield,代碼會暫停一下,一段時(shí)間之后,會繼續(xù)運(yùn)行。例如,當(dāng)某時(shí)有11個(gè)延遲項(xiàng)時(shí),限制是每秒5次請求,即時(shí)間為11/5=2.2秒之后,隊(duì)列變空,代碼會繼續(xù)。
使用Throttler,不再有錯(cuò)誤,但是爬蟲會變慢。我們看到示例中的房子只有幾個(gè)不同的地址。這時(shí)使用緩存非常好。我們使用一個(gè)簡單的Python dict來做,但這么可能會有競爭條件,這樣會造成偽造的API請求。下面是一個(gè)沒有此類問題的緩存方法,展示了Python和Twisted的特點(diǎn):
class DeferredCache(object):
def __init__(self, key_not_found_callback):
self.records = {}
self.deferreds_waiting = {}
self.key_not_found_callback = key_not_found_callback
@defer.inlineCallbacks
def find(self, key):
rv = defer.Deferred()
if key in self.deferreds_waiting:
self.deferreds_waiting[key].append(rv)
else:
self.deferreds_waiting[key] = [rv]
if not key in self.records:
try:
value = yield self.key_not_found_callback(key)
self.records[key] = lambda d: d.callback(value)
except Exception as e:
self.records[key] = lambda d: d.errback(e)
action = self.records[key]
for d in self.deferreds_waiting.pop(key):
reactor.callFromThread(action, d)
value = yield rv
defer.returnValue(value)
這個(gè)緩存看起來有些不同,它包含兩個(gè)組件:
- self.deferreds_waiting:這是一個(gè)延遲項(xiàng)的隊(duì)列,等待給鍵賦值
- self.records:這是鍵值對中出現(xiàn)過的dict
在find()方法的中間,如果沒有在self.records找到一個(gè)鍵,我們會調(diào)用預(yù)先定義的callback函數(shù),以取回丟失的值(yield self.key_not_found_callback(key))。這個(gè)調(diào)回函數(shù)可能會扔出一個(gè)例外。如何在Python中壓縮存儲值或例外呢?因?yàn)镻ython是一種函數(shù)語言,根據(jù)是否有例外,我們在self.records中保存小函數(shù)(lambdas),調(diào)用callback或errback。lambda函數(shù)定義時(shí),就將值或例外附著在上面。將變量附著在函數(shù)上稱為閉包,閉包是函數(shù)語言最重要的特性之一。
筆記:緩存例外有點(diǎn)不常見,但它意味著首次查找key時(shí),key_not_found_callback(key)返回了一個(gè)例外。當(dāng)后續(xù)查找還找這個(gè)key時(shí),就免去了調(diào)用,再次返回這個(gè)例外。
find()方法其余的部分提供了一個(gè)避免競爭條件的機(jī)制。如果查找某個(gè)鍵已經(jīng)在進(jìn)程中,會在self.deferreds_waiting dict中有記錄。這時(shí),我們不在向key_not_found_callback()發(fā)起另一個(gè)調(diào)用,只是在延遲項(xiàng)的等待列表添加這個(gè)項(xiàng)。當(dāng)key_not_found_callback()返回時(shí),鍵有了值,我們觸發(fā)所有的等待這個(gè)鍵的延遲項(xiàng)。我們可以直接發(fā)起action(d),而不用reactor.callFromThread(),但需要處理每個(gè)扔給下游的例外,我們必須創(chuàng)建不必要的很長的延遲項(xiàng)鏈。
使用這個(gè)緩存很容易。我們在init()對其初始化,設(shè)定調(diào)回函數(shù)為API調(diào)用。在process_item()中,使用緩存查找的方法如下:
def __init__(self, stats):
self.cache = DeferredCache(self.cache_key_not_found_callback)
@defer.inlineCallbacks
def cache_key_not_found_callback(self, address):
yield self.throttler.enqueue()
value = yield self.geocode(address)
defer.returnValue(value)
@defer.inlineCallbacks
def process_item(self, item, spider):
item["location"] = yield self.cache.find(item["address"][0])
defer.returnValue(item)
提示:完整代碼位于ch09/properties/properties/pipelines/geo2.py。
為了使pipeline生效,我們使前一個(gè)方法無效,并添加當(dāng)前的到settings.py的ITEM_PIPELINES:
ITEM_PIPELINES = {
'properties.pipelines.tidyup.TidyUp': 100,
'properties.pipelines.es.EsWriter': 800,
# DISABLE 'properties.pipelines.geo.GeoPipeline': 400,
'properties.pipelines.geo2.GeoPipeline': 400,
}
運(yùn)行爬蟲,用如下代碼:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000
...
Scraped... 15.8 items/s, avg latency: 1.74 s and avg time in pipelines:
0.94 s
Scraped... 32.2 items/s, avg latency: 1.76 s and avg time in pipelines:
0.97 s
Scraped... 25.6 items/s, avg latency: 0.76 s and avg time in pipelines:
0.14 s
...
: Dumping Scrapy stats:...
'geo_pipeline/misses': 35,
'item_scraped_count': 1019,
當(dāng)填充緩存時(shí),我們看到抓取的延遲變高。緩存結(jié)束時(shí),延遲降低。數(shù)據(jù)還顯示有35個(gè)遺漏,正好是數(shù)據(jù)集中不同地點(diǎn)的數(shù)目。很明顯,上例中一共有1019 - 35= 984次API請求。如果我們使用真正的Google API,并提高每秒的API請求數(shù),例如通過改變Throttler(5)到Throttler(10),使從5提高到10,我們可以將重試添加到geo_pipeline/retries stat記錄中。如果有錯(cuò)誤的話,例如,使用API找不到某個(gè)地點(diǎn),會扔出一個(gè)例外,這會被geo_pipeline/errors stat記錄。如果地點(diǎn)通過什么方式已經(jīng)存在了,會在geo_pipeline/already_set stat中指明。最后,如果我們訪問http://localhost:9200/properties/property/_search,以檢查ES中的房子,我們可以看到包括地點(diǎn)的記錄,例如{..."location": {"lat": 51.5269736, "lon": -0.0667204}...}。(運(yùn)行前確保清空集合,去除舊的值)
在Elasticsearch進(jìn)行地理索引
我們已經(jīng)有了地點(diǎn),我們可以將它們按距離排序。下面是一個(gè)HTTP POST請求,返回標(biāo)題中包含Angel的房子,按照離點(diǎn){51.54, -0.19}的距離進(jìn)行排序:
$ curl http://es:9200/properties/property/_search -d '{
"query" : {"term" : { "title" : "angel" } },
"sort": [{"_geo_distance": {
"location": {"lat": 51.54, "lon": -0.19},
"order": "asc",
"unit": "km",
"distance_type": "plane"
}}]}'
唯一的問題是如果我們運(yùn)行它,我們會看到一個(gè)錯(cuò)誤信息"failed to find mapper for [location] for geo distance based sort"。它指出,我們的location字段沒有正確的空間計(jì)算的格式。為了設(shè)定正確的格式,我們要手動覆蓋默認(rèn)格式。首先,我們將自動檢測的映射保存起來,將它作為起點(diǎn):
$ curl 'http://es:9200/properties/_mapping/property' > property.txt
然后,我們?nèi)缦滤揪庉媝roperty.txt:
"location":{"properties":{"lat":{"type":"double"},"lon":{"type":"double"}}}
我們將這行代碼替換為:
"location": {"type": "geo_point"}
我們還在文件最后刪除了{(lán)"properties":{"mappings": and two }}。文件現(xiàn)在就處理完了。我們現(xiàn)在可以刪除舊的類型,并用下面的schema建立新的類型:
$ curl -XDELETE 'http://es:9200/properties'
$ curl -XPUT 'http://es:9200/properties'
$ curl -XPUT 'http://es:9200/properties/_mapping/property' --data
@property.txt
我們現(xiàn)在可以用之前的命令,進(jìn)行一個(gè)快速抓取,將結(jié)果按距離排序。我們的搜索返回的是房子的JSONs對象,其中包括一個(gè)額外的sort字段,顯示房子離某個(gè)點(diǎn)的距離。
連接數(shù)據(jù)庫與Python客戶端
可以連接Python Database API 2.0的數(shù)據(jù)庫有許多種,包括MySQL、PostgreSQL、Oracle、Microsoft、SQL Server和SQLite。它們的驅(qū)動通常很復(fù)雜且進(jìn)行過測試,為Twisted再進(jìn)行適配會浪費(fèi)很多時(shí)間。可以在Twisted應(yīng)用中使用數(shù)據(jù)庫客戶端,例如,Scrapy可以使用twisted.enterprise.adbapi庫。我們使用MySQL作為例子,說明用法,原則也適用于其他數(shù)據(jù)庫。
用pipeline寫入MySQL
MySQL是一個(gè)好用又流行的數(shù)據(jù)庫。我們來寫一個(gè)pipeline,來向其中寫入文件。我們的虛擬環(huán)境中,已經(jīng)有了一個(gè)MySQL實(shí)例。我們用MySQL命令行來做一些基本的管理操作,命令行工具已經(jīng)在開發(fā)機(jī)中預(yù)先安裝了:
$ mysql -h mysql -uroot -ppass
mysql>提示MySQL已經(jīng)運(yùn)行,我們可以建立一個(gè)簡單的含有幾個(gè)字段的數(shù)據(jù)表,如下所示:
mysql> create database properties;
mysql> use properties
mysql> CREATE TABLE properties (
url varchar(100) NOT NULL,
title varchar(30),
price DOUBLE,
description varchar(30),
PRIMARY KEY (url)
);
mysql> SELECT * FROM properties LIMIT 10;
Empty set (0.00 sec)
很好,現(xiàn)在已經(jīng)建好了一個(gè)包含幾個(gè)字段的MySQL數(shù)據(jù)表,它的名字是properties,可以開始寫pipeline了。保持MySQL控制臺打開,我們過一會兒會返回查看是否有差入值。輸入exit,就可以退出。
筆記:在這一部分中,我們會向MySQL數(shù)據(jù)庫插入properties。如果你想刪除,使用以下命令:
mysql> DELETE FROM properties;
我們使用MySQL的Python客戶端。我們還要安裝一個(gè)叫做dj-database-url的小功能模塊(它可以幫我們設(shè)置不同的IP、端口、密碼等等)。我們可以用pip install dj-database-url MySQL-python,安裝這兩項(xiàng)。我們的開發(fā)機(jī)上已經(jīng)安裝好了。我們的MySQL pipeline很簡單,如下所示:
from twisted.enterprise import adbapi
...
class MysqlWriter(object):
...
def __init__(self, mysql_url):
conn_kwargs = MysqlWriter.parse_mysql_url(mysql_url)
self.dbpool = adbapi.ConnectionPool('MySQLdb',
charset='utf8',
use_unicode=True,
connect_timeout=5,
**conn_kwargs)
def close_spider(self, spider):
self.dbpool.close()
@defer.inlineCallbacks
def process_item(self, item, spider):
try:
yield self.dbpool.runInteraction(self.do_replace, item)
except:
print traceback.format_exc()
defer.returnValue(item)
@staticmethod
def do_replace(tx, item):
sql = """REPLACE INTO properties (url, title, price, description) VALUES (%s,%s,%s,%s)"""
args = (
item["url"][0][:100],
item["title"][0][:30],
item["price"][0],
item["description"][0].replace("\r\n", " ")[:30]
)
tx.execute(sql, args)
提示:完整代碼位于ch09/properties/properties/pipelines/mysql.py。
本質(zhì)上,這段代碼的大部分都很普通。為了簡潔而省略的代碼將一條保存在MYSQL_PIPELINE_URL、格式是mysql://user:pass@ip/database的URL,解析成了獨(dú)立的參數(shù)。在爬蟲的init()中,將它們傳遞到adbapi.ConnectionPool(),它使用adbapi的底層結(jié)構(gòu),初始化MySQL連接池。第一個(gè)參數(shù)是我們想要引入的模塊的名字。對于我們的MySQL,它是MySQLdb。我們?yōu)镸ySQL客戶端另設(shè)了幾個(gè)參數(shù),以便正確處理Unicode和超時(shí)。每當(dāng)adbapi打開新連接時(shí),所有這些參數(shù)都要進(jìn)入底層的MySQLdb.connect()函數(shù)。爬蟲關(guān)閉時(shí),我們調(diào)用連接池的close()方法。
我們的process_item()方法包裝了dbpool.runInteraction()。這個(gè)方法給調(diào)回方法排隊(duì),會在當(dāng)連接池中一個(gè)連接的Transaction對象變?yōu)榭捎脮r(shí)被調(diào)用。這個(gè)Transaction對象有一個(gè)和DB-API指針相似的API。在我們的例子中,調(diào)回方法是do_replace(),它定義在后面幾行。@staticmethod是說這個(gè)方法關(guān)聯(lián)的是類而不是具體的類實(shí)例,因此,我們可以忽略通常的self參數(shù)。如果方法不使用成員的話,最好設(shè)其為靜態(tài),如果你忘了設(shè)為靜態(tài)也不要緊。這個(gè)方法準(zhǔn)備了一個(gè)SQL字符串、幾個(gè)參數(shù),并調(diào)用Transaction的execute()函數(shù),以進(jìn)行插入。我們的SQL使用REPLACE INTO,而不用更常見的INSERT INTO,來替換鍵相同的項(xiàng)。這可以讓我們的案例簡化。如果我們相擁SQL返回?cái)?shù)據(jù),例如SELECT聲明,我們使用dbpool.runQuery(),我們可能還需要改變默認(rèn)指針,方法是設(shè)置adbapi.ConnectionPool()的參數(shù)cursorclass為cursorclass=MySQLdb.cursors,這樣取回?cái)?shù)據(jù)更為簡便。
使用這個(gè)pipeline,我們要在settings.py的ITEM_PIPELINES添加它,還要設(shè)置一下MYSQL_PIPELINE_URL:
ITEM_PIPELINES = { ...
'properties.pipelines.mysql.MysqlWriter': 700,
...
MYSQL_PIPELINE_URL = 'mysql://root:pass@mysql/properties'
執(zhí)行以下命令:
scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000
運(yùn)行這條命令后,返回MySQL控制臺,可以看到如下記錄:
mysql> SELECT COUNT(*) FROM properties;
+----------+
| 1006 |
+----------+
mysql> SELECT * FROM properties LIMIT 4;
+------------------+--------------------------+--------+-----------+
| url | title | price | description
+------------------+--------------------------+--------+-----------+
| http://...0.html | Set Unique Family Well | 334.39 | website c
| http://...1.html | Belsize Marylebone Shopp | 388.03 | features
| http://...2.html | Bathroom Fully Jubilee S | 365.85 | vibrant own
| http://...3.html | Residential Brentford Ot | 238.71 | go court
+------------------+--------------------------+--------+-----------+
4 rows in set (0.00 sec)
延遲和吞吐量的性能和之前相同。結(jié)果讓人印象深刻。
使用Twisted 特定客戶端連接服務(wù)
目前為止,我們學(xué)習(xí)了如何用treq使用類REST APIs。Scrapy可以用Twisted特定客戶端連接許多其它服務(wù)。例如,如果我們想連接MongoDB,通過搜索“MongoDB Python”,我們可以找到PyMongo,它是阻塞/同步的,除非我們使用pipeline處理阻塞操作中的線程,我們不能在Twisted中使用PyMongo。如果我們搜索“MongoDB Twisted Python”,可以找到txmongo,它可以完美適用于Twisted和Scrapy。通常的,Twisted客戶端群體很小,但使用它比起自己寫一個(gè)客戶端還是要方便。下面,我們就使用這樣一個(gè)Twisted特定客戶端連接Redis鍵值對存儲。
用pipeline讀寫Redis
Google Geocoding API是按照每個(gè)IP進(jìn)行限制的。如果可以接入多個(gè)IPs(例如,多臺服務(wù)器),當(dāng)一個(gè)地址已經(jīng)被另一臺機(jī)器做過地理編碼,就要設(shè)法避免對發(fā)出重復(fù)的請求。如果一個(gè)地址之前已經(jīng)被查閱過,也要避免再次查閱。我們不想浪費(fèi)限制的額度。
筆記:與API商家聯(lián)系,以確保這符合規(guī)定。你可能,必須每幾分鐘/小時(shí),就要清空緩存記錄,或者根本就不能緩存。
我們可以使用Redis鍵值緩存作為分布式dict。Vagrant環(huán)境中已經(jīng)有了一個(gè)Redis實(shí)例,我們現(xiàn)在可以連接它,用redis-cli作一些基本操作:
$ redis-cli -h redis
redis:6379> info keyspace
# Keyspace
redis:6379> set key value
OK
redis:6379> info keyspace
# Keyspace
db0:keys=1,expires=0,avg_ttl=0
redis:6379> FLUSHALL
OK
redis:6379> info keyspace
# Keyspace
redis:6379> exit
通過搜索“Redis Twisted”,我們找到一個(gè)txredisapi庫。它最大的不同是,它不僅是一個(gè)Python的同步封裝,還是一個(gè)Twisted庫,可以通過reactor.connectTCP(),執(zhí)行Twisted協(xié)議,連接Redis。其它庫也有類似用法,但是txredisapi對于Twisted效率更高。我們可以通過安裝庫dj_redis_url可以安裝它,這個(gè)庫通過pip可以解析Redis配置URL(sudo pip install txredisapi dj_redis_url)。和以前一樣,開發(fā)機(jī)中已經(jīng)安裝好了。
我們?nèi)缦聠覴edisCache pipeline:
from txredisapi import lazyConnectionPool
class RedisCache(object):
...
def __init__(self, crawler, redis_url, redis_nm):
self.redis_url = redis_url
self.redis_nm = redis_nm
args = RedisCache.parse_redis_url(redis_url)
self.connection = lazyConnectionPool(connectTimeout=5,
replyTimeout=5,
**args)
crawler.signals.connect(
self.item_scraped,signal=signals.item_scraped)
這個(gè)pipeline比較簡單。為了連接Redis服務(wù)器,我們需要主機(jī)、端口等等,它們?nèi)加肬RL格式存儲。我們用parse_redis_url()方法解析這個(gè)格式。使用命名空間做鍵的前綴很普遍,在我們的例子中,我們存儲在redis_nm。我們?nèi)缓笫褂胻xredisapi的lazyConnectionPool()打開一個(gè)數(shù)據(jù)庫連接。
最后一行有一個(gè)有趣的函數(shù)。我們是想用pipeline封裝geo-pipeline。如果在Redis中沒有某個(gè)值,我們不會設(shè)定這個(gè)值,geo-pipeline會用API像之前一樣將地址進(jìn)行地理編碼。完畢之后,我們必須要在Redis中緩存鍵值對,我們是通過連接signals.item_scraped信號來做的。我們定義的調(diào)回(即item_scraped()方法,馬上會講)只有在最后才會被調(diào)用,那時(shí),地址就設(shè)置好了。
提示:完整代碼位于ch09/properties/properties/pipelines/redis.py。
我們簡化緩存,只尋找和存儲每個(gè)Item的地址和地點(diǎn)。這對Redis來說是合理的,因?yàn)樗ǔJ沁\(yùn)行在單一服務(wù)器上的,這可以讓它很快。如果不是這樣的話,可以加入一個(gè)dict結(jié)構(gòu)的緩存,它與我們在geo-pipeline中用到的相似。以下是我們?nèi)绾翁幚砣霂斓腎tems:
process incoming Items:
@defer.inlineCallbacks
def process_item(self, item, spider):
address = item["address"][0]
key = self.redis_nm + ":" + address
value = yield self.connection.get(key)
if value:
item["location"] = json.loads(value)
defer.returnValue(item)
和預(yù)期的相同。我們得到了地址,給它添加前綴,然后使用txredisapi connection的get()在Redis進(jìn)行查找。我們將JSON編碼的對象在Redis中保存成值。如果一個(gè)值設(shè)定了,我們就使用JSON解碼,然后將其設(shè)為地點(diǎn)。
當(dāng)一個(gè)Item到達(dá)pipelines的末端時(shí),我們重新取得它,將其保存為Redis中的地點(diǎn)值。以下是我們的做法:
from txredisapi import ConnectionError
def item_scraped(self, item, spider):
try:
location = item["location"]
value = json.dumps(location, ensure_ascii=False)
except KeyError:
return
address = item["address"][0]
key = self.redis_nm + ":" + address
quiet = lambda failure: failure.trap(ConnectionError)
return self.connection.set(key, value).addErrback(quiet)
如果我們找到了一個(gè)地點(diǎn),我們就取得了地址,添加前綴,然后使用它作為txredisapi連接的set()方法的鍵值對。set()方法沒有使用@defer.inlineCallbacks,因?yàn)樘幚韘ignals.item_scraped時(shí),它不被支持。這意味著,我們不能對connection.set()使用yield,但是我們可以返回一個(gè)延遲項(xiàng),Scrapy可以在它后面排上其它信號對象。任何情況下,如果Redis的連接不能使用用connection.set(),它就會拋出一個(gè)例外。在這個(gè)錯(cuò)誤處理中,我們把傳遞的錯(cuò)誤當(dāng)做參數(shù),我們讓它trap()任何ConnectionError。這是Twisted的延遲API的優(yōu)點(diǎn)之一。通過用trap()捕獲錯(cuò)誤項(xiàng),我們可以輕易忽略它們。
使這個(gè)pipeline生效,我們要做的是在settings.py的ITEM_PIPELINES中添加它,并提供一個(gè)REDIS_PIPELINE_URL。必須要讓它的優(yōu)先級比geo-pipeline高,以免太晚就不能使用了:
ITEM_PIPELINES = { ...
'properties.pipelines.redis.RedisCache': 300,
'properties.pipelines.geo.GeoPipeline': 400,
...
REDIS_PIPELINE_URL = 'redis://redis:6379'
像之前一樣運(yùn)行。第一次運(yùn)行時(shí)和以前很像,但隨后的運(yùn)行結(jié)果如下:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=100
...
INFO: Enabled item pipelines: TidyUp, RedisCache, GeoPipeline,
MysqlWriter, EsWriter
...
Scraped... 0.0 items/s, avg latency: 0.00 s, time in pipelines: 0.00 s
Scraped... 21.2 items/s, avg latency: 0.78 s, time in pipelines: 0.15 s
Scraped... 24.2 items/s, avg latency: 0.82 s, time in pipelines: 0.16 s
...
INFO: Dumping Scrapy stats: {...
'geo_pipeline/already_set': 106,
'item_scraped_count': 106,
我們看到GeoPipeline和RedisCache都生效了,RedisCache第一個(gè)輸出。還注意到在統(tǒng)計(jì)中g(shù)eo_pipeline/already_set: 106。這是GeoPipeline發(fā)現(xiàn)的Redis緩存中填充的數(shù)目,它不調(diào)用Google API。如果Redis緩存是空的,你會看到Google API處理了一些鍵。從性能上來講,我們看到GeoPipeline引發(fā)的初始行為消失了。事實(shí)上,當(dāng)我們開始使用內(nèi)存,我們繞過了每秒只有5次請求的API限制。如果我們使用Redis,應(yīng)該考慮使用過期鍵,讓系統(tǒng)周期刷新緩存數(shù)據(jù)。
連接CPU密集型、阻塞或舊方法
最后一部分講連接非Twisted的工作。盡管異步程序的優(yōu)點(diǎn)很多,并不是所有庫都專門為Twisted和Scrapy寫的。使用Twisted的線程池和reactor.spawnProcess()方法,我們可以使用任何Python庫和任何語言寫的編碼。
pipeline進(jìn)行CPU密集型和阻塞操作
我們在第8章中強(qiáng)調(diào),反應(yīng)器適合簡短非阻塞的任務(wù)。如果我們不得不要處理復(fù)雜和阻塞的任務(wù),又該怎么做呢?Twisted提供了線程池,有了它可以使用reactor.callInThread() API在分線程而不是主線程中執(zhí)行慢操作。這意味著,反應(yīng)器可以一直運(yùn)行并對事件反饋,而不中斷計(jì)算。但要記住,在線程池中運(yùn)行并不安全,當(dāng)你使用全局模式時(shí),會有多線程的同步問題。讓我們從一個(gè)簡單的pipeline開始,逐漸做出完整的代碼:
class UsingBlocking(object):
@defer.inlineCallbacks
def process_item(self, item, spider):
price = item["price"][0]
out = defer.Deferred()
reactor.callInThread(self._do_calculation, price, out)
item["price"][0] = yield out
defer.returnValue(item)
def _do_calculation(self, price, out):
new_price = price + 1
time.sleep(0.10)
reactor.callFromThread(out.callback, new_price)
在前面的pipeline中,我們看到了一些基本用法。對于每個(gè)Item,我們提取出價(jià)格,我們相用_do_calucation()方法處理它。這個(gè)方法使用time.sleep(),一個(gè)阻塞操作。我們用reactor.callInThread()調(diào)用,讓它在另一個(gè)線程中運(yùn)行。顯然,我們傳遞價(jià)格,我們還創(chuàng)建和傳遞了一個(gè)名為out的延遲項(xiàng)。當(dāng)_do_calucation()完成了計(jì)算,我們使用out調(diào)回值。下一步,我們執(zhí)行延遲項(xiàng),并未價(jià)格設(shè)新的值,最后返回Item。
在_do_calucation()中,有一個(gè)細(xì)微之處,價(jià)格增加了1,進(jìn)而睡了100ms。這個(gè)時(shí)間很多,如果調(diào)用進(jìn)反應(yīng)器主線程,每秒就不能抓取10頁了。通過在另一個(gè)線程中運(yùn)行,就不會再有這個(gè)問題。任務(wù)會在線程池中排隊(duì),每次處理耗時(shí)100ms。最后一步是觸發(fā)調(diào)回。一般的,我們可以使用out.callback(new_price),但是因?yàn)槲覀儸F(xiàn)在是在另一個(gè)線程,這么做不安全。如果這么做的話,延遲項(xiàng)的代碼會被從另一個(gè)線程調(diào)用,這樣遲早會產(chǎn)生錯(cuò)誤的數(shù)據(jù)。不這樣做,轉(zhuǎn)而使用reactor.callFromThread(),它也可以將函數(shù)當(dāng)做參數(shù),將任意其余參數(shù)傳遞到函數(shù)。這個(gè)函數(shù)會排隊(duì)并被調(diào)回主線程,主進(jìn)程反過來會打開process_item()對象yield,并繼續(xù)Item的操作。
如果我們用全局模式,例如計(jì)數(shù)器、滑動平均,又該怎么使用_do_calucation()呢?例如,添加兩個(gè)變量,beta和delta,如下所示:
class UsingBlocking(object):
def __init__(self):
self.beta, self.delta = 0, 0
...
def _do_calculation(self, price, out):
self.beta += 1
time.sleep(0.001)
self.delta += 1
new_price = price + self.beta - self.delta + 1
assert abs(new_price-price-1) < 0.01
time.sleep(0.10)...
這段代碼是斷言失敗錯(cuò)誤。這是因?yàn)槿绻粋€(gè)線程在self.beta和self.delta間切換,另一個(gè)線程繼續(xù)計(jì)算使用beta/delta計(jì)算價(jià)格,它會發(fā)現(xiàn)它們狀態(tài)不一致(beta大于delta),因此,計(jì)算出錯(cuò)誤的結(jié)果。短暫的睡眠可能會造成競爭條件。為了不發(fā)生這些狀況,我們要使一個(gè)鎖,例如Python的threading.RLock()遞歸鎖。使用它,可以確保沒有兩個(gè)線程在同一時(shí)間操作被保護(hù)代碼:
class UsingBlocking(object):
def __init__(self):
...
self.lock = threading.RLock()
...
def _do_calculation(self, price, out):
with self.lock:
self.beta += 1
...
new_price = price + self.beta - self.delta + 1
assert abs(new_price-price-1) < 0.01 ...
代碼現(xiàn)在就正確了。記住,我們不需要保護(hù)整段代碼,就足以處理全局模式。
提示:完整代碼位于ch09/properties/properties/pipelines/computation.py。
要使用這個(gè)pipeline,我們需要把它添加到settings.py的ITEM_PIPELINES中。如下所示:
ITEM_PIPELINES = { ...
'properties.pipelines.computation.UsingBlocking': 500,
像之前一樣運(yùn)行爬蟲,pipeline延遲達(dá)到了100ms,但吞吐量沒有發(fā)生變化,大概每秒25個(gè)items。
pipeline使用二進(jìn)制和腳本
最麻煩的借口當(dāng)屬獨(dú)立可執(zhí)行文件和腳本。打開需要幾秒(例如,從數(shù)據(jù)庫加載數(shù)據(jù)),但是后面處理數(shù)值的延遲很小。即便是這種情況,Twisted也預(yù)料到了。我們可以使用reactor.spawnProcess() API和相關(guān)的protocol.ProcessProtocol來運(yùn)行任何執(zhí)行文件。讓我們來看一個(gè)例子。腳本如下:
#!/bin/bash
trap "" SIGINT
sleep 3
while read line
do
# 4 per second
sleep 0.25
awk "BEGIN {print 1.20 * $line}"
done
這是一個(gè)簡單的bash腳本。它運(yùn)行時(shí),會使Ctrl + C 無效。這是為了避免系統(tǒng)的一個(gè)奇怪的錯(cuò)誤,將Ctrl + C增值到子流程并過早結(jié)束,導(dǎo)致Scrapy強(qiáng)制等待流程結(jié)果。在使Ctrl + C無效之后,它睡眠三秒,模擬啟動時(shí)間。然后,它閱讀輸入的代碼語句,等待250ms,然后返回結(jié)果價(jià)格,價(jià)格的值乘以了1.20,由Linux的awk命令計(jì)算而得。這段腳本的最大吞吐量為每秒1/250ms=4個(gè)Items。用一個(gè)短session檢測:
$ properties/pipelines/legacy.sh
12 <- If you type this quickly you will wait ~3 seconds to get results
14.40
13 <- For further numbers you will notice just a slight delay
15.60
因?yàn)镃trl + C失效了,我們用Ctrl + D必須結(jié)束session。我們該如何讓Scrapy使用這個(gè)腳本呢?再一次,我們從一個(gè)簡化版開始:
class CommandSlot(protocol.ProcessProtocol):
def __init__(self, args):
self._queue = []
reactor.spawnProcess(self, args[0], args)
def legacy_calculate(self, price):
d = defer.Deferred()
self._queue.append(d)
self.transport.write("%f\n" % price)
return d
# Overriding from protocol.ProcessProtocol
def outReceived(self, data):
"""Called when new output is received"""
self._queue.pop(0).callback(float(data))
class Pricing(object):
def __init__(self):
self.slot = CommandSlot(['properties/pipelines/legacy.sh'])
@defer.inlineCallbacks
def process_item(self, item, spider):
item["price"][0] = yield self.slot.legacy_calculate(item["price"][0])
defer.returnValue(item)
我們在這里找到了一個(gè)名為CommandSlot的ProcessProtocol和Pricing爬蟲。在init()中,我們創(chuàng)建了新的CommandSlot,它新建了一個(gè)空的隊(duì)列,并用reactor.spawnProcess()開啟了一個(gè)新進(jìn)程。它調(diào)用收發(fā)數(shù)據(jù)的ProcessProtocol作為第一個(gè)參數(shù)。在這個(gè)例子中,是self的原因是spawnProcess()是被從類protocol調(diào)用的。第二個(gè)參數(shù)是可執(zhí)行文件的名字,第三個(gè)參數(shù)args,讓二進(jìn)制命令行參數(shù)成為字符串序列。
在pipeline的process_item()中,我們用CommandSlot的legacy_calculate()方法代表所有工作,CommandSlot可以返回產(chǎn)生的延遲項(xiàng)。legacy_calculate()創(chuàng)建延遲項(xiàng),將其排隊(duì),用transport.write()將價(jià)格寫入進(jìn)程。ProcessProtocol提供了transport,可以讓我們與進(jìn)程溝通。無論何時(shí)我們從進(jìn)程收到數(shù)據(jù), outReceived()就會被調(diào)用。通過延遲項(xiàng),進(jìn)程依次執(zhí)行,我們可以彈出最老的延遲項(xiàng),用收到的值觸發(fā)它。全過程就是這樣。我們可以讓這個(gè)pipeline生效,通過將它添加到ITEM_PIPELINES:
ITEM_PIPELINES = {...
'properties.pipelines.legacy.Pricing': 600,
如果運(yùn)行的話,我們會看到性能很差。進(jìn)程變成了瓶頸,限制了吞吐量。為了提高性能,我們需要修改pipeline,允許多個(gè)進(jìn)程并行運(yùn)行,如下所示:
class Pricing(object):
def __init__(self):
self.concurrency = 16
args = ['properties/pipelines/legacy.sh']
self.slots = [CommandSlot(args)
for i in xrange(self.concurrency)]
self.rr = 0
@defer.inlineCallbacks
def process_item(self, item, spider):
slot = self.slots[self.rr]
self.rr = (self.rr + 1) % self.concurrency
item["price"][0] = yield
slot.legacy_calculate(item["price"][0])
defer.returnValue(item)
這無非是開啟16個(gè)實(shí)例,將價(jià)格以輪轉(zhuǎn)的方式發(fā)出。這個(gè)pipeline的吞吐量是每秒16*4 = 64。我們可以用下面的爬蟲進(jìn)行驗(yàn)證:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000
...
Scraped... 0.0 items/s, avg latency: 0.00 s and avg time in pipelines:
0.00 s
Scraped... 21.0 items/s, avg latency: 2.20 s and avg time in pipelines:
1.48 s
Scraped... 24.2 items/s, avg latency: 1.16 s and avg time in pipelines:
0.52 s
延遲增加了250 ms,但吞吐量仍然是每秒25。
請記住前面的方法使用了transport.write()讓所有的價(jià)格在腳本shell中排隊(duì)。這個(gè)可能對你的應(yīng)用不適用,,尤其是當(dāng)數(shù)據(jù)量很大時(shí)。Git的完整代碼讓值和調(diào)回都進(jìn)行了排隊(duì),不想腳本發(fā)送值,除非收到前一項(xiàng)的結(jié)果。這種方法可能看起來更友好,但是會增加代碼復(fù)雜度。
總結(jié)
你剛剛學(xué)習(xí)了復(fù)雜的Scrapy pipelines。目前為止,你應(yīng)該就掌握了所有和Twisted編程相關(guān)的知識。并且你學(xué)會了如何在進(jìn)程中執(zhí)行復(fù)雜的功能,用Item Processing Pipelines存儲Items。我們看到了添加pipelines對延遲和吞吐量的影響。通常,延遲和吞吐量是成反比的。但是,這是在恒定并發(fā)數(shù)的前提下(例如,一定數(shù)量的線程)。在我們的例子中,我們一開始的并發(fā)數(shù)為N=ST=250.77?19,添加pipelines之后,并發(fā)數(shù)為N=25*3.33?83,并沒有引起性能的變化。這就是Twisted的強(qiáng)大之處!下面學(xué)習(xí)第10章,Scrapy的性能。
序言
第1章 Scrapy介紹
第2章 理解HTML和XPath
第3章 爬蟲基礎(chǔ)
第4章 從Scrapy到移動應(yīng)用
第5章 快速構(gòu)建爬蟲
第6章 Scrapinghub部署
第7章 配置和管理
第8章 Scrapy編程
第9章 使用Pipeline
第10章 理解Scrapy的性能
第11章(完) Scrapyd分布式抓取和實(shí)時(shí)分析