序言
第1章 Scrapy介紹
第2章 理解HTML和XPath
第3章 爬蟲基礎
第4章 從Scrapy到移動應用
第5章 快速構建爬蟲
第6章 Scrapinghub部署
第7章 配置和管理
第8章 Scrapy編程
第9章 使用Pipeline
第10章 理解Scrapy的性能
第11章(完) Scrapyd分布式抓取和實時分析
第3章中,我們學習了如何從網頁提取信息并存儲到Items中。大多數情況都可以用這一章的知識處理。本章,我們要進一步學習抓取流程UR2IM中兩個R,Request和Response。
一個具有登錄功能的爬蟲
你常常需要從具有登錄機制的網站抓取數據。多數時候,網站要你提供用戶名和密碼才能登錄。我們的例子,你可以在http://web:9312/dynamic或http://localhost:9312/dynamic找到。用用戶名“user”、密碼“pass”登錄之后,你會進入一個有三條房產鏈接的網頁。現在的問題是,如何用Scrapy登錄?
讓我們使用谷歌Chrome瀏覽器的開發者工具搞清楚登錄的機制。首先,選擇Network標簽(1)。然后,填入用戶名和密碼,點擊Login(2)。如果用戶名和密碼是正確的,你會進入下一頁。如果是錯誤的,會看到一個錯誤頁。
一旦你點擊了Login,在開發者工具的Network標簽欄中,你就會看到一個發往http://localhost:9312/dynamic/login的請求Request Method: POST。
提示:上一章的GET請求,通常用來獲取靜止數據,例如簡單的網頁和圖片。POST請求通常用來獲取的數據,取決于我們發給服務器的數據,例如這個例子中的用戶名和密碼。
點擊這個POST請求,你就可以看到發給服務器的數據,其中包括表單信息,表單信息中有你剛才輸入的用戶名和密碼。所有數據都以文本的形式發給服務器。Chrome開發者工具將它們整理好并展示出來。服務器的響應是302 FOUND(5),然后將我們重定向到新頁面:/dynamic/gated。只有登錄成功時才會出現此頁面。如果沒有正確輸入用戶名和密碼就前往http://localhost:9312/dynamic/gated,服務器會發現你作弊,并將你重定向到錯誤頁面:http://localhost:9312/dynamic/error。服務器怎么知道你和密碼呢?如果你點擊左側的gated(6),你會發現在RequestHeaders(7)下有一個Cookie(8)。
提示:HTTP cookie是通常是一些服務器發送到瀏覽器的短文本或數字片段。反過來,在每一個后續請求中,瀏覽器把它發送回服務器,以確定你、用戶和期限。這讓你可以執行復雜的需要服務器端狀態信息的操作,如你購物車中的商品或你的用戶名和密碼。
總結一下,單單一個操作,如登錄,可能涉及多個服務器往返操作,包括POST請求和HTTP重定向。Scrapy處理大多數這些操作是自動的,我們需要編寫的代碼很簡單。
我們將第3章名為easy的爬蟲重命名為login,并修改里面名字的屬性,如下:
class LoginSpider(CrawlSpider):
name = 'login'
提示:本章的代碼github的ch05目錄中。這個例子位于ch05/properties。
我們要在http://localhost:9312/dynamic/login上面模擬一個POST請求登錄。我們用Scrapy中的類FormRequest來做。這個類和第3章中的Request很像,但有一個額外的formdata,用來傳遞參數。要使用這個類,首先必須要引入:
from scrapy.http import FormRequest
我們然后將start_URL替換為start_requests()方法。這么做是因為在本例中,比起URL,我們要做一些自定義的工作。更具體地,用下面的函數,我們創建并返回一個FormRequest:
# Start with a login request
def start_requests(self):
return [
FormRequest(
"http://web:9312/dynamic/login",
formdata={"user": "user", "pass": "pass"}
)]
就是這樣。CrawlSpider的默認parse()方法,即LoginSpider的基本類,負責處理響應,并如第3章中使用Rules和LinkExtractors。其余的代碼很少,因為Scrapy負責了cookies,當我們登錄時,Scrapy將cookies傳遞給后續請求,與瀏覽器的方式相同。還是用scrapy crawl運行:
$ scrapy crawl login
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Redirecting (302) to <GET .../gated> from <POST .../login >
DEBUG: Crawled (200) <GET .../data.php>
DEBUG: Crawled (200) <GET .../property_000001.html> (referer: .../data.
php)
DEBUG: Scraped from <200 .../property_000001.html>
{'address': [u'Plaistow, London'],
'date': [datetime.datetime(2015, 11, 25, 12, 7, 27, 120119)],
'description': [u'features'],
'image_URL': [u'http://web:9312/images/i02.jpg'],
...
INFO: Closing spider (finished)
INFO: Dumping Scrapy stats:
{...
'downloader/request_method_count/GET': 4,
'downloader/request_method_count/POST': 1,
...
'item_scraped_count': 3,
我們注意到登錄跳轉從dynamic/login到dynamic/gated,然后就可以像之前一樣抓取項目。在統計中,我們看到一個POST請求和四個GET請求;一個是dynamic/gated首頁,三個是房產網頁。
提示:在本例中,我們不保護房產頁,而是是這些網頁的鏈接。代碼在相反的情況下也是相同的。
如果我們使用了錯誤的用戶名和密碼,我們將重定向到一個沒有URL的頁面,進程并將在這里結束,如下所示:
$ scrapy crawl login
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Redirecting (302) to <GET .../dynamic/error > from <POST .../
dynamic/login>
DEBUG: Crawled (200) <GET .../dynamic/error>
...
INFO: Spider closed (closespider_itemcount)
這是一個簡單的登錄示例,演示了基本的登錄機制。大多數網站可能有更復雜的機制,但Scrapy也處理的很好。例如一些網站在執行POST請求時,需要通過從表單頁面到登錄頁面傳遞某種形式的變量以確定cookies的啟用,讓你使用大量用戶名和密碼暴力破解時變得困難。
例如,如果你訪問http://localhost:9312/dynamic/nonce,你會看到一個和之前一樣的網頁,但如果你使用Chrome開發者工具,你會發現這個頁面的表單有一個叫做nonce的隱藏字段。當你提交表單http://localhost:9312/dynamic/nonce-login時,你必須既要提供正確的用戶名密碼,還要提交正確的瀏覽器發給你的nonce值。因為這個值是隨機且只能使用一次,你很難猜到。這意味著,如果要成功登陸,必須要進行兩次請求。你必須訪問表單、登錄頁,然后傳遞數值。和以前一樣,Scrapy有內建的功能可以解決這個問題。
我們創建一個和之前相似的NonceLoginSpider爬蟲。現在,在start_requests()中,我們要向表單頁返回一個簡單的Request,并通過設定callback為名字是parse_welcome()的方法手動處理響應。在parse_welcome()中,我們使用FormRequest對象中的from_response()方法創建FormRequest,并將原始表單中的字段和值導入FormRequest。FormRequest.from_response()可以模擬提交表單。
提示:花時間看from_response()的文檔是十分值得的。他有許多有用的功能如formname和formnumber,它可以幫助你當頁面有多個表單時,選擇特定的表單。
它最大的功能是,一字不差地包含了表單中所有的隱藏字段。我們只需使用formdata參數,填入user和pass字段,并返回FormRequest。代碼如下:
# Start on the welcome page
def start_requests(self):
return [
Request(
"http://web:9312/dynamic/nonce",
callback=self.parse_welcome)
]
# Post welcome page's first form with the given user/pass
def parse_welcome(self, response):
return FormRequest.from_response(
response,
formdata={"user": "user", "pass": "pass"}
)
像之前一樣運行爬蟲:
$ scrapy crawl noncelogin
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Crawled (200) <GET .../dynamic/nonce>
DEBUG: Redirecting (302) to <GET .../dynamic/gated > from <POST .../
dynamic/login-nonce>
DEBUG: Crawled (200) <GET .../dynamic/gated>
...
INFO: Dumping Scrapy stats:
{...
'downloader/request_method_count/GET': 5,
'downloader/request_method_count/POST': 1,
...
'item_scraped_count': 3,
我們看到第一個GET請求先到/dynamic/nonce,然后POST,重定向到/dynamic/nonce-login之后,之后像之前一樣,訪問了/dynamic/gated。登錄過程結束。這個例子的登錄含有兩步。只要有足夠的耐心,無論多少步的登錄過程,都可以完成。
使用JSON APIs和AJAX頁面的爬蟲
有時,你會發現網頁的HTML找不到數據。例如,在http://localhost:9312/static/頁面上右鍵點擊檢查元素(1,2),你就可以在DOM樹種看到所有HTML元素。或者,如果你使用scrapy shell或在Chrome中右鍵點擊查看網頁源代碼(3,4),你會看到這個網頁的HTML代碼不包含任何和值有關的信息。數據都是從何而來呢?
和以前一樣,在開發者工具中打開Network標簽(5)查看發生了什么。左側列表中,可以看到所有的請求。在這個簡單的頁面中,只有三個請求:static/我們已經檢查過了,jquery.min.js是一個流行的JavaScript框架,api.json看起來不同。如果我們點擊它(6),然后在右側點擊Preview標簽(7),我們可以看到它包含我們要找的信息。事實上,http://localhost:9312/properties/api.json包含IDs和名字(8),如下所示:
[{
"id": 0,
"title": "better set unique family well"
},
... {
"id": 29,
"title": "better portered mile"
}]
這是一個很簡單的JSON API例子。更復雜的APIs可能要求你登錄,使用POST請求,或返回某種數據結結構。任何時候,JSON都是最容易解析的格式,因為不需要XPath表達式就可以提取信息。
Python提供了一個強大的JSON解析庫。當我們import json時,我們可以使用json.loads(response.body)解析JSON,并轉換成等價的Python對象,語句、列表和字典。
復制第3章中的manual.py文件。這是最好的方法,因為我們要根據JSON對象中的IDs手動創建URL和Request。將這個文件重命名為api.py,重命名類為ApiSpider、名字是api。新的start_URL變成:
start_URL = (
'http://web:9312/properties/api.json',
)
如果你要做POST請求或更復雜的操作,你可以使用start_requests()方法和前面幾章介紹的方法。這里,Scrapy會打開這個URL并使用Response作為參數調用parse()方法。我們可以import json,使用下面的代碼解析JSON:
def parse(self, response):
base_url = "http://web:9312/properties/"
js = json.loads(response.body)
for item in js:
id = item["id"]
url = base_url + "property_%06d.html" % id
yield Request(url, callback=self.parse_item)
這段代碼使用了json.loads(response.body)將響應JSON對象轉換為Python列表,然后重復這個過程。對于列表中的每個項,我們設置一個URL,它包含:base_url,property_%06d和.html.base_url,.html.base_url前面定義過的URL前綴。%06d是一個非常有用的Python詞,可以讓我們結合多個Python變量形成一個新的字符串。在本例中,用id變量替換%06d。id被當做數字(%d的意思就是當做數字進行處理),并擴展成6個字符,位數不夠時前面添加0。如果id的值是5,%06d會被替換為000005;id是34322時,%06d會被替換為034322替換。最后的結果是可用的URL。和第3章中的yield一樣,我們用URL做一個新的Request請求。運行爬蟲:
$ scrapy crawl api
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Crawled (200) <GET ...properties/api.json>
DEBUG: Crawled (200) <GET .../property_000029.html>
...
INFO: Closing spider (finished)
INFO: Dumping Scrapy stats:
...
'downloader/request_count': 31, ...
'item_scraped_count': 30,
最后一共有31次請求,每個項目一次,api.json一次。
在響應間傳遞參數
許多時候,你想把JSON APIs中的信息存儲到Item中。為了演示,在我們的例子中,對于一個項,JSON API在返回它的名字時,在前面加上“better”。例如,如果一個項的名字時“Covent Garden”,API會返回“Better Covent Garden”。我們要在Items中保存這些含有“bette”的名字。如何將數據從parse()傳遞到parse_item()中呢?
我們要做的就是在parse()方法產生的Request中進行設置。然后,我們可以從parse_item()的的Response中取回。Request有一個名為meta的字典,在Response中可以直接訪問。對于我們的例子,給字典設一個title值以存儲從JSON對象的返回值:
title = item["title"]
yield Request(url, meta={"title": title},callback=self.parse_item)
在parse_item()中,我們可以使用這個值,而不用XPath表達式:
l.add_value('title', response.meta['title'],
MapCompose(unicode.strip, unicode.title))
你會注意到,我們從調用add_xpath()切換到add_value(),因為對于這個字段不需要使用XPath。我們現在運行爬蟲,就可以在PropertyItems中看到api.json中的標題了。
一個加速30倍的項目爬蟲
當你學習使用一個框架時,這個框架越復雜,你用它做任何事都會很復雜。可能你覺得Scrapy也是這樣。當你就要為XPath和其他方法變得抓狂時,不妨停下來思考一下:我現在抓取網頁的方法是最簡單的嗎?
如果你可以從索引頁中提取相同的信息,就可以避免抓取每一個列表頁,這樣就可以節省大量的工作。
提示:許多網站的索引頁提供的項目數量是不同的。例如,一個網站可以通過調整一個參數,例如&show=50,給每個索引頁面設置10、 50或100個列表項。如果是這樣的話,將其設置為可用的最大值。
例如,對于我們的例子,我們需要的所有信息都存在于索引頁中,包括標題、描述、價格和圖片。這意味著我們抓取單個索引頁,提取30個條目和下一個索引頁的鏈接。通過抓取100個索引頁,我們得到3000個項,但只有100個請求而不是3000個。
在真實的Gumtree網站上,索引頁的描述比列表頁的完整描述要短。這是可行的,或者是更推薦的。
提示:許多情況下,您不得不在數據質量與請求數量間進行折衷。很多網站都限制請求數量(后面章節詳解),所以減少請求可能解決另一個棘手的問題。
在我們的例子中,如果我們查看一個索引頁的HTML,我們會發現,每個列表頁有自己的節點,itemtype="http://schema.org/Product"。節點有每個項的全部信息,如下所示:
讓我們在Scrapy shell中加載索引首頁,并用XPath處理:
$ scrapy shell http://web:9312/properties/index_00000.html
While within the Scrapy shell, let's try to select everything with the Product tag:
>>> p=response.xpath('//*[@itemtype="http://schema.org/Product"]')
>>> len(p)
30
>>> p
[<Selector xpath='//*[@itemtype="http://schema.org/Product"]' data=u'<li
class="listing-maxi" itemscopeitemt'...]
我們得到了一個包含30個Selector對象的表,每個都指向一個列表。Selector對象和Response對象很像,我們可以用XPath表達式從它們指向的對象中提取信息。不同的是,表達式為有相關性的XPath表達式。相關性XPath表達式與我們之前見過的很像,不同之處是它們前面有一個點“.”。然我們看看如何用.//*[@itemprop="name"][1]/text()提取標題的:
>>> selector = p[3]
>>> selector
<Selector xpath='//*[@itemtype="http://schema.org/Product"]' ... '>
>>> selector.xpath('.//*[@itemprop="name"][1]/text()').extract()
[u'l fun broadband clean people brompton european']
我們可以在Selector對象表中用for循環提取一個索引頁的所有30個項目信息。還是從第3章中的maunal.py文件開始,重命名為fast.py。重復使用大部分代碼,修改parse()和parse_item()方法。更新的方法如下所示:
def parse(self, response):
# Get the next index URL and yield Requests
next_sel = response.xpath('//*[contains(@class,"next")]//@href')
for url in next_sel.extract():
yield Request(urlparse.urljoin(response.url, url))
# Iterate through products and create PropertiesItems
selectors = response.xpath(
'//*[@itemtype="http://schema.org/Product"]')
for selector in selectors:
yield self.parse_item(selector, response)
第一部分中用于產生下一條索引請求的代碼沒有變動。不同的地方是第二部分,我們重復使用選擇器調用parse_item()方法,而不是用yield創建請求。這和原先使用的源代碼很像:
def parse_item(self, selector, response):
# Create the loader using the selector
l = ItemLoader(item=PropertiesItem(), selector=selector)
# Load fields using XPath expressions
l.add_xpath('title', './/*[@itemprop="name"][1]/text()',
MapCompose(unicode.strip, unicode.title))
l.add_xpath('price', './/*[@itemprop="price"][1]/text()',
MapCompose(lambda i: i.replace(',', ''), float),
re='[,.0-9]+')
l.add_xpath('description',
'.//*[@itemprop="description"][1]/text()',
MapCompose(unicode.strip), Join())
l.add_xpath('address',
'.//*[@itemtype="http://schema.org/Place"]'
'[1]/*/text()',
MapCompose(unicode.strip))
make_url = lambda i: urlparse.urljoin(response.url, i)
l.add_xpath('image_URL', './/*[@itemprop="image"][1]/@src',
MapCompose(make_url))
# Housekeeping fields
l.add_xpath('url', './/*[@itemprop="url"][1]/@href',
MapCompose(make_url))
l.add_value('project', self.settings.get('BOT_NAME'))
l.add_value('spider', self.name)
l.add_value('server', socket.gethostname())
l.add_value('date', datetime.datetime.now())
return l.load_item()
我們做出的變動是:
- ItemLoader現在使用selector作為源,不使用Response。這么做可以讓ItemLoader更便捷,可以讓我們從特定的區域而不是整個頁面抓取信息。
- 通過在前面添加“.”使XPath表達式變為相關XPath。
提示:碰巧的是,在我們的例子中,XPath表達式在索引頁和介紹頁中是相同的。不同的時候,你需要按照索引頁修改XPath表達式。
- 在response.url給我們列表頁的URL之前,我們必須自己編輯Item的URL。然后,它才能返回我們抓取網頁的URL。我們必須用.//*[@itemprop="url"][1]/@href提取URL,然后將它用MapCompose轉化為URL絕對路徑。
這些小小大量的工作的改動可以節省大量的工作。現在,用以下命令運行爬蟲:
$ scrapy crawl fast -s CLOSESPIDER_PAGECOUNT=3
...
INFO: Dumping Scrapy stats:
'downloader/request_count': 3, ...
'item_scraped_count': 90,...
就像之前說的,我們用三個請求,就抓取了90個項目。不從索引開始的話,就要用93個請求。
如果你想用scrapy parse來調試,你需要如下設置spider參數:
$ scrapy parse --spider=fast http://web:9312/properties/index_00000.html
...
>>> STATUS DEPTH LEVEL 1 <<<
# Scraped Items --------------------------------------------
[{'address': [u'Angel, London'],
... 30 items...
# Requests ---------------------------------------------------
[<GET http://web:9312/properties/index_00001.html>]
正如所料,parse()返回了30個Items和下一個索引頁的請求。你還可以繼續試驗scrapy parse,例如,設置—depth=2。
可以抓取Excel文件的爬蟲
大多數時候,你每抓取一個網站就使用一個爬蟲,但如果要從多個網站抓取時,不同之處就是使用不同的XPath表達式。為每一個網站配置一個爬蟲工作太大。能不能只使用一個爬蟲呢?答案是可以。
新建一個項目抓取不同的東西。當前我們是在ch05的properties目錄,向上一級:
$ pwd
/root/book/ch05/properties
$ cd ..
$ pwd
/root/book/ch05
新建一個項目,命名為generic,再創建一個名為fromcsv的爬蟲:
$ scrapy startproject generic
$ cd generic
$ scrapy genspider fromcsv example.com
新建一個.csv文件,它是我們抓取的目標。我們可以用Excel表建這個文件。如下表所示,填入URL和XPath表達式,在爬蟲的目錄中(有scrapy.cfg的文件夾)保存為todo.csv。保存格式是csv:
一切正常的話,就可以在終端看見這個文件:
$ cat todo.csv
url,name,price
a.html,"http://*[@id=""itemTitle""]/text()","http://*[@id=""prcIsum""]/text()"
b.html,//h1/text(),//span/strong/text()
c.html,"http://*[@id=""product-desc""]/span/text()"
Python中有csv文件的內建庫。只需import csv,就可以用后面的代碼一行一行以dict的形式讀取這個csv文件。在當前目錄打開Python命令行,然后輸入:
$ pwd
/root/book/ch05/generic2
$ python
>>> import csv
>>> with open("todo.csv", "rU") as f:
reader = csv.DictReader(f)
for line in reader:
print line
文件的第一行會被自動作為header,從而導出dict的鍵名。對于下面的每一行,我們得到一個包含數據的dict。用for循環執行每一行。前面代碼的結果如下:
{'url': ' http://a.html', 'price': '//*[@id="prcIsum"]/text()', 'name': '//*[@id="itemTitle"]/text()'}
{'url': ' http://b.html', 'price': '//span/strong/text()', 'name': '//h1/text()'}
{'url': ' http://c.html', 'price': '', 'name': '//*[@id="product-desc"]/span/text()'}
很好。現在編輯generic/spiders/fromcsv.py爬蟲。我們使用.csv文件中的URL,并且不希望遇到域名限制的情況。因此第一件事是移除start_URL和allowed_domains。然后再讀.csv文件。
因為從文件中讀取的URL是我們事先不了解的,所以使用一個start_requests()方法。對于每一行,我們都會創建Request。我們還要從request,meta的csv存儲字段名和XPath,以便在我們的parse()函數中使用。然后,我們使用Item和ItemLoader填充Item的字段。下面是所有代碼:
import csv
import scrapy
from scrapy.http import Request
from scrapy.loader import ItemLoader
from scrapy.item import Item, Field
class FromcsvSpider(scrapy.Spider):
name = "fromcsv"
def start_requests(self):
with open("todo.csv", "rU") as f:
reader = csv.DictReader(f)
for line in reader:
request = Request(line.pop('url'))
request.meta['fields'] = line
yield request
def parse(self, response):
item = Item()
l = ItemLoader(item=item, response=response)
for name, xpath in response.meta['fields'].iteritems():
if xpath:
item.fields[name] = Field()
l.add_xpath(name, xpath)
return l.load_item()
運行爬蟲,輸出文件保存為csv:
$ scrapy crawl fromcsv -o out.csv
INFO: Scrapy 0.0.3 started (bot: generic)
...
DEBUG: Scraped from <200 a.html>
{'name': [u'My item'], 'price': [u'128']}
DEBUG: Scraped from <200 b.html>
{'name': [u'Getting interesting'], 'price': [u'300']}
DEBUG: Scraped from <200 c.html>
{'name': [u'Buy this now']}
...
INFO: Spider closed (finished)
$ cat out.csv
price,name
128,My item
300,Getting interesting
,Buy this now
有幾點要注意。項目中沒有定義一個整個項目的Items,我們必須手動向ItemLoader提供一個:
item = Item()
l = ItemLoader(item=item, response=response)
我們還用Item的fields成員變量添加了動態字段。添加一個新的動態字段,并用ItemLoader填充,使用下面的方法:
item.fields[name] = Field()
l.add_xpath(name, xpath)
最后讓代碼再漂亮些。硬編碼todo.csv不是很好。Scrapy提供了一種便捷的向爬蟲傳遞參數的方法。如果我們使用-a參數,例如,-a variable=value,就創建了一個爬蟲項,可以用self.variable取回。為了檢查變量(沒有的話,提供一個默認變量),我們使用Python的getattr()方法:getattr(self, 'variable', 'default')。總之,原來的with open…替換為:
with open(getattr(self, "file", "todo.csv"), "rU") as f:
現在,todo.csv是默認文件,除非使用參數-a,用一個源文件覆蓋它。如果還有一個文件,another_todo.csv,我們可以運行:
$ scrapy crawl fromcsv -a file=another_todo.csv -o out.csv
總結
在本章中,我們進一步學習了Scrapy爬蟲。我們使用FormRequest進行登錄,用請求/響應中的meta傳遞變量,使用了相關的XPath表達式和Selectors,使用.csv文件作為數據源等等。
接下來在第6章學習在Scrapinghub云部署爬蟲,在第7章學習關于Scrapy的設置。
序言
第1章 Scrapy介紹
第2章 理解HTML和XPath
第3章 爬蟲基礎
第4章 從Scrapy到移動應用
第5章 快速構建爬蟲
第6章 Scrapinghub部署
第7章 配置和管理
第8章 Scrapy編程
第9章 使用Pipeline
第10章 理解Scrapy的性能
第11章(完) Scrapyd分布式抓取和實時分析