Python迅速爬蟲技巧

本文摘自人民郵電出版社非同步社區《精通Python爬蟲框架Scrapy》一書

關注微信公眾號【非同步社區】每周送書

而在本文中,我們將看到更多特殊的例子,以便讓你更加熟悉Scrapy的兩個最重要的類——RequestResponse

1.1 需要登錄的爬蟲

通常情況下,你會發現自己想要抽取數據的網站存在登錄機制。大部分情況下,網站會要求你提供用戶名和密碼用於登錄。你可以從http://web:9312/dynamic(從dev機器訪問)或http://localhost:9312/ dynamic(從宿主機瀏覽器訪問)找到我們要使用的例子。如果使用"user"作為用戶名,"pass"作為密碼的話,你就可以訪問到包含3個房產頁面鏈接的網頁。不過現在的問題是,要如何使用Scrapy執行相同的操作?

讓我們使用Google Chrome瀏覽器的開發者工具來嘗試理解登錄的工作過程(見圖1.1)。首先,打開Network選項卡(1)。然後,填寫用戶名和密碼,並單擊Login(2)。如果用戶名和密碼正確,你將會看到包含3個鏈接的頁面。如果用戶名和密碼不匹配,將會看到一個錯誤頁。

圖1.1 登錄網站時的請求和響應

當按下Login按鈕時,會在Google Chrome瀏覽器開發者工具的Network選項卡中看到一個包含Request Method: POST的請求,其目的地址為http://localhost:9312/dynamic/login

當你單擊該請求時(3),可以看到發送給服務端的數據,包括Form Data(4),其中包含了我們輸入的用戶名和密碼。這些數據都是以文本形式傳輸給服務端的。Chrome瀏覽器只是將其組織起來,向我們更好地顯示這些數據。服務端的響應是302 Found(5),使我們跳轉到一個新的頁面:/dynamic/gated。該頁面只有在登錄成功後才會出現。如果嘗試直接訪問http://localhost:9312/dynamic/gated,而不輸入正確的用戶名和密碼的話,服務端會發現你在作弊,並跳轉到錯誤頁,其地址是http:// localhost:9312/dynamic/error。服務端是如何知道你和你的密碼的呢?如果你單擊開發者工具左側的gated(6),就會發現在Request Headers區域下面(7)設置了一個Cookie值(8)。

總之,即使是一個單一的操作,比如登錄,也可能涉及包括POST請求和HTTP跳轉的多次服務端往返。Scrapy能夠自動處理大部分操作,而我們需要編寫的代碼也很簡單。

我們從第3章中名為easy的爬蟲開始,創建一個新的爬蟲,命名為login,保留原有文件,並修改爬蟲中的name屬性(如下所示):

class LoginSpider(CrawlSpider):n  name = loginn

我們需要通過執行到http://localhost:9312/dynamic/login的POST請求,發送登錄的初始請求。這將通過Scrapy的FormRequest類實現該功能。要想使用該類,首先需要引入如下模塊。

from scrapy.http import FormRequestn

然後,將start_urls語句替換為start_requests()方法。這樣做是因為在本例中,我們需要從一些更加定製化的請求開始,而不僅僅是幾個URL。更確切地說就是,我們從該函數中創建並返回一個FormRequest

# Start with a login requestndef start_requests(self):n return [n  FormRequest(n   "http://web:9312/dynamic/login",n   formdata={"user": "user", "pass": "pass"}n     )]n

雖然聽起來不可思議,但是CrawlSpiderLoginSpider的基類)默認的parse()方法確實處理了Response,並且仍然能夠使用第3章中的RuleLinkExtractor。我們只編寫了非常少的額外代碼,這是因為Scrapy為我們透明處理了Cookie,並且一旦我們登錄成功,就會在後續的請求中傳輸這些Cookie,就和瀏覽器執行的方式一樣。接下來可以像平常一樣,使用scrapy crwal運行。

<strong>$ scrapy crawl login</strong>n<strong>INFO: Scrapy 1.0.3 started (bot: properties)</strong>n<strong>...</strong>n<strong>DEBUG: Redirecting (302) to <GET .../gated> from <POST .../login ></strong>n<strong>DEBUG: Crawled (200) <GET .../data.php></strong>n<strong>DEBUG: Crawled (200) <GET .../property_000001.html> (referer: .../data.</strong>n<strong>php)</strong>n<strong>DEBUG: Scraped from <200 .../property_000001.html></strong>n<strong>{address: [uPlaistow, London],</strong>n<strong> date: [datetime.datetime(2015, 11, 25, 12, 7, 27, 120119)],</strong>n<strong> description: [ufeatures],</strong>n<strong> image_urls: [uhttp://web:9312/images/i02.jpg],</strong>n<strong>...</strong>n<strong>INFO: Closing spider (finished)</strong>n<strong>INFO: Dumping Scrapy stats:</strong>n<strong> {...</strong>n<strong>  downloader/request_method_count/GET: 4,</strong>n<strong>  downloader/request_method_count/POST: 1,</strong>n<strong>...</strong>n<strong>  item_scraped_count: 3,</strong>n

我們可以在日誌中看到從dynamic/logindynamic/gated的跳轉,然後就會像平時那樣抓取Item了。在統計中,可以看到1個POST請求和4個GET請求(一個是前往dynamic/gated索引頁,另外3個是房產頁面)。

如果使用了錯誤的用戶名和密碼,將會跳轉到一個沒有任何項目的頁面,並且此時爬取過程會被終止,如下面的執行情況所示。

<strong>$ scrapy crawl login</strong>n<strong>INFO: Scrapy 1.0.3 started (bot: properties)</strong>n<strong>...</strong>n<strong>DEBUG: Redirecting (302) to <GET .../dynamic/error > from <POST .../</strong>n<strong>dynamic/login></strong>n<strong>DEBUG: Crawled (200) <GET .../dynamic/error></strong>n<strong>...</strong>n<strong>INFO: Spider closed (closespider_itemcount)</strong>n

這是一個簡單的登錄示例,用於演示基本的登錄機制。大多數網站都會擁有一些更加複雜的機制,不過Scrapy也都能夠輕鬆處理。比如,一些網站要求你在執行POST請求時,將表單頁中的某些表單變數傳輸到登錄頁,以便確認Cookie是啟用的,同樣也會讓你在嘗試暴力破解成千上萬次用戶名/密碼的組合時更加困難。圖1.2所示即為此種情況的一個示例。

圖1.2 使用一次性隨機數的一個更加高級的登錄示例的請求和響應情況

比如,當訪問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.from_response()粗略模擬了一次在頁面的第一個表單上的提交單擊,此時所有欄位留空。

該方法對於我們來說非常有用,因為它能夠毫不費力地原樣包含表單中的所有隱藏欄位。我們所需要做的就是使用formdata參數填充userpass欄位以及返回FormRequest。下面是其相關代碼。

# Start on the welcome pagendef start_requests(self):n  return [n    Request(n      "http://web:9312/dynamic/nonce",n      callback=self.parse_welcome)n  ]n# Post welcome pages first form with the given user/passndef parse_welcome(self, response):n  return FormRequest.from_response(n    response,n    formdata={"user": "user", "pass": "pass"}n  )n

我們可以像平時一樣運行爬蟲。

<strong>$ scrapy crawl noncelogin</strong>n<strong>INFO: Scrapy 1.0.3 started (bot: properties)</strong>n<strong>...</strong>n<strong>DEBUG: Crawled (200) <GET .../dynamic/nonce></strong>n<strong>DEBUG: Redirecting (302) to <GET .../dynamic/gated > from <POST .../</strong>n<strong>dynamic/login-nonce></strong>n<strong>DEBUG: Crawled (200) <GET .../dynamic/gated></strong>n<strong>...</strong>n<strong>INFO: Dumping Scrapy stats:</strong>n<strong> {...</strong>n<strong>  downloader/request_method_count/GET: 5,</strong>n<strong>  downloader/request_method_count/POST: 1,</strong>n<strong>...</strong>n<strong>  item_scraped_count: 3,</strong>n

可以看到,第一個GET請求前往/dynamic/nonce頁面,然後是POST請求,跳轉到/dynamic/nonce-login頁面,之後像前面的例子一樣跳轉到/dynamic/gated頁面。關於登錄的討論就到這裡。該示例使用兩個步驟完成登錄。只要你有足夠的耐心,就可以形成任意長鏈,來執行幾乎所有的登錄操作。

5.2 使用JSON API和AJAX頁面的爬蟲

有時,你會發現自己在頁面尋找的數據無法從HTML頁面中找到。比如,當訪問http://localhost:9312/static/時(見圖5.3),在頁面任意位置右鍵單擊inspect element(1, 2),可以看到其中包含所有常見HTML元素的DOM樹。但是,當你使用scrapy shell請求,或是在Chrome瀏覽器中右鍵單擊View Page Source(3, 4)時,則會發現該頁面的HTML代碼中並不包含關於房產的任何信息。那麼,這些數據是從哪裡來的呢?

圖1.3 動態載入JSON對象時的頁面請求與響應

與平常一樣,遇到這類例子時,下一步操作應當是打開Chrome瀏覽器開發者工具的Network選項卡,來看看發生了什麼。在左側的列表中,可以看到載入本頁面時Chrome執行的請求。在這個簡單的頁面中,只有3個請求:static/是剛才已經檢查過的請求;jquery.min.js用於獲取一個流行的Javascript框架的代碼;而api.json看起來會讓我們產生興趣。當單擊該請求(6),並單擊右側的Preview選項卡(7)時,就會發現這裡面包含了我們正在尋找的數據。實際上,http://localhost:9312/properties/api.json包含了房產的ID和名稱(8),如下所示。

[{n  "id": 0,n  "title": "better set unique family well"n},n... {n  "id": 29,n  "title": "better portered mile"n}]n

這是一個非常簡單的JSON API的示例。更複雜的API可能需要你登錄,使用POST請求,或返回更有趣的數據結構。無論在哪種情況下,JSON都是最簡單的解析格式之一,因為你不需要編寫任何XPath表達式就可以從中抽取出數據。

Python提供了一個非常好的JSON解析庫。當我們執行import json時,就可以使用json.loads(response.body)解析JSON,將其轉換為由Python原語、列表和字典組成的等效Python對象。

我們將第3章的manual.py拷貝過來,用於實現該功能。在本例中,這是最佳的起始選項,因為我們需要通過在JSON對象中找到的ID,手動創建房產URL以及Request對象。我們將該文件重命名為api.py,並將爬蟲類重命名為ApiSpidername屬性修改為api。新的start_urls將會是JSON API的URL,如下所示。

start_urls = (n  http://web:9312/properties/api.json,n)n

如果你想執行POST請求,或是更複雜的操作,可以使用前一節中介紹的start_requests()方法。此時,Scrapy將會打開該URL,並調用包含以Response為參數的parse()方法。可以通過import json,使用如下代碼解析JSON對象。

def parse(self, response):n  base_url = "http://web:9312/properties/"n  js = json.loads(response.body)n  for item in js:n    id = item["id"]n    url = base_url + "property_%06d.html" % idn    yield Request(url, callback=self.parse_item)n

前面的代碼使用了json.loads(response.body),將Response這個JSON對象解析為Python列表,然後迭代該列表。對於列表中的每一項,我們將URL的3個部分(base_urlproperty_%06d以及.html)組合到一起。base_url是在前面定義的URL前綴。%06d是Python語法中非常有用的一部分,它可以讓我們結合Python變數創建新的字元串。在本例中,%06d將會被變數id的值替換(本行結尾處%後面的變數)。id將會被視為數字(%d表示視為數字),並且如果不滿6位,則會在前面加上0,擴展成6位字元。比如,id值為5,%06d將會被替換為000005,而如果id為34322,%06d則會被替換為034322。最終結果正是我們房產頁面的有效URL。我們使用該URL形成一個新的Request對象,並像第3章一樣使用yield。然後可以像平時那樣使用scrapy crawl運行該示例。

<strong>$ scrapy crawl api</strong>n<strong>INFO: Scrapy 1.0.3 started (bot: properties)</strong>n<strong>...</strong>n<strong>DEBUG: Crawled (200) <GET ...properties/api.json></strong>n<strong>DEBUG: Crawled (200) <GET .../property_000029.html></strong>n<strong>...</strong>n<strong>INFO: Closing spider (finished)</strong>n<strong>INFO: Dumping Scrapy stats:</strong>n<strong>...</strong>n<strong>  downloader/request_count: 31, ...</strong>n<strong>  item_scraped_count: 30,</strong>n

你可能會注意到結尾處的狀態是31個請求——每個Item一個請求,以及最初的api.json的請求。

1.2.1 在響應間傳參

很多情況下,在JSON API中會有感興趣的信息,你可能想要將它們存儲到Item中。在我們的示例中,為了演示這種情況,JSON API會在給定房產信息的標題前面加上"better"。比如,房產標題是"Covent Garden",API就會將標題寫為"Better Covent Garden"。假設我們想要將這些"better"開頭的標題存儲到Items中,要如何將信息從parse()方法傳遞到parse_item()方法呢?

不要感到驚訝,通過在parse()生成的Request中設置一些東西,就能實現該功能。之後,可以從parse_item()接收到的Response中取得這些信息。Request有一個名為meta的字典,能夠直接訪問Response。比如在我們的例子中,可以在該字典中設置標題值,以存儲來自JSON對象的標題。

title = item["title"]nyield Request(url, meta={"title": title},callback=self.parse_item)n

parse_item()內部,可以使用該值替代之前使用過的XPath表達式。

l.add_value(title, response.meta[title],n       MapCompose(unicode.strip, unicode.title))n

你會發現我們不再調用add_xpath(),而是轉為調用add_value(),這是因為我們在該欄位中將不會再使用到任何XPath表達式。現在,可以使用scrapy crawl運行這個新的爬蟲,並且可以在PropertyItems中看到來自api.json的標題。

1.3 30倍速的房產爬蟲

有這樣一種趨勢,當你開始使用一個框架時,做任何事情都可能會使用最複雜的方式。你在使用Scrapy時也會發現自己在做這樣的事情。在瘋狂於XPath等技術之前,值得停下來想一想:我選擇的方式是從網站中抽取數據最簡單的方式嗎?

如果你能從索引頁中抽取出基本相同的信息,就可以避免抓取每個房源頁,從而得到數量級的提升。

比如,在房產示例中,我們所需要的所有信息都存在於索引頁中,包括標題、描述、價格和圖片。這就意味著只抓取一個索引頁,就能抽取其中的30個條目以及前往下一頁的鏈接。通過爬取100個索引頁,我們只需要100個請求,而不是3000個請求,就能夠得到3000個條目。太棒了!

在真實的Gumtree網站中,索引頁的描述信息要比列表頁中完整的描述信息稍短一些。不過此時這種抓取方式可能也是可行的,甚至也能令人滿意。

在我們的例子中,當查看任何一個索引頁的HTML代碼時,就會發現索引頁中的每個房源都有其自己的節點,並使用itemtype="http://schema.org/Product"來表示。在該節點中,我們擁有與詳情頁完全相同的方式為每個屬性註解的所有信息,如圖5.4所示。

圖5.4 從單一索引頁抽取多個房產信息

我們在Scrapy shell中載入第一個索引頁,並使用XPath表達式進行測試。

<strong>$ scrapy shell http://web:9312/properties/index_00000.html</strong>n

在Scrapy shell中,嘗試選取所有帶有Product標籤的內容:

<strong>>>> p=response.xpath(//*[@itemtype="http://schema.org/Product"])</strong>n<strong>>>> len(p)</strong>n<strong>30</strong>n<strong>>>> p</strong>n<strong>[<Selector xpath=//*[@itemtype="http://schema.org/Product"] data=u<li </strong>n<strong>class="listing-maxi" itemscopeitemt...]</strong>n

可以看到我們得到了一個包含30個Selector對象的列表,每個對象指向一個房源。在某種意義上,Selector對象與Response對象有些相似,我們可以在其中使用XPath表達式,並且只從它們指向的地方獲取信息。唯一需要說明的是,這些表達式應該是相對XPath表達式。相對XPath表達式與我們之前看到的基本一樣,不過在前面增加了一個.點號。舉例說明,讓我們看一下使用.//*[@itemprop="name"][1]/text()這個相對XPath表達式,從第4個房源抽取標題時是如何工作的。

<strong>>>> selector = p[3]</strong>n<strong>>>> selector</strong>n<strong><Selector xpath=//*[@itemtype="http://schema.org/Product"] ... ></strong>n<strong>>>> selector.xpath(.//*[@itemprop="name"][1]/text()).extract()</strong>n<strong>[ul fun broadband clean people brompton european]</strong>n

可以在Selector對象的列表中使用for循環,抽取索引頁中全部30個條目的信息。

為了實現該目的,我們再一次從第3章的manual.py著手,將爬蟲重命名為"fast",並重命名文件為fast.py。我們將復用大部分代碼,只在parse()parse_items()方法中進行少量修改。最新方法的代碼如下。

def parse(self, response):n  # Get the next index URLs and yield Requestsn  next_sel = response.xpath(//*[contains(@class,"next")]//@href)n  for url in next_sel.extract():n    yield Request(urlparse.urljoin(response.url, url))n  # Iterate through products and create PropertiesItemsn  selectors = response.xpath(n    //*[@itemtype="http://schema.org/Product"])n  for selector in selectors:n    yield self.parse_item(selector, response)n

在代碼的第一部分中,對前往下一個索引頁的Requestyield操作的代碼沒有變化。唯一改變的內容在第二部分,不再使用yield為每個詳情頁創建請求,而是迭代選擇器並調用parse_item()。其中,parse_item()的代碼也和原始代碼非常相似,如下所示。

def parse_item(self, selector, response):n  # Create the loader using the selectorn  l = ItemLoader(item=PropertiesItem(), selector=selector)n  # Load fields using XPath expressionsn  l.add_xpath(title, .//*[@itemprop="name"][1]/text(),n        MapCompose(unicode.strip, unicode.title))n  l.add_xpath(price, .//*[@itemprop="price"][1]/text(),n        MapCompose(lambda i: i.replace(,, ), float),n        re=[,.0-9]+)n  l.add_xpath(description,n        .//*[@itemprop="description"][1]/text(),n        MapCompose(unicode.strip), Join())n  l.add_xpath(address,n        .//*[@itemtype="http://schema.org/Place"]n        [1]/*/text(),n        MapCompose(unicode.strip))n  make_url = lambda i: urlparse.urljoin(response.url, i)n  l.add_xpath(image_urls, .//*[@itemprop="image"][1]/@src,n        MapCompose(make_url))n  # Housekeeping fieldsn  l.add_xpath(url, .//*[@itemprop="url"][1]/@href,n        MapCompose(make_url))n  l.add_value(project, self.settings.get(BOT_NAME))n  l.add_value(spider, self.name)n  l.add_value(server, socket.gethostname())n  l.add_value(date, datetime.datetime.now())n  return l.load_item()n

我們所做的細微變更如下所示。

  • ItemLoader現在使用selector作為源,而不再是Response。這是ItemLoader API一個非常便捷的功能,能夠讓我們從當前選取的部分(而不是整個頁面)抽取數據。
  • XPath表達式通過使用前綴點號(.)轉為相對XPath。
  • 我們必須自己編輯Item的URL。之前,response.url已經給出了房源頁的URL。而現在,它給出的是索引頁的URL,因為該頁面才是我們要爬取的。我們需要使用熟悉的.//*[@itemprop="url"][1]/@href這個XPath表達式抽取出房源的URL,然後使用MapCompose處理器將其轉換為絕對URL。

小的改變能夠節省巨大的工作量。現在,我們可以使用如下代碼運行該爬蟲。

<strong>$ scrapy crawl fast -s CLOSESPIDER_PAGECOUNT=3</strong>n<strong>...</strong>n<strong>INFO: Dumping Scrapy stats:</strong>n<strong>  downloader/request_count: 3, ...</strong>n<strong>  item_scraped_count: 90,...</strong>n

和預期一樣,只用了3個請求,就抓取了90個條目。如果我們沒有在索引頁中獲取到的話,則需要93個請求。這種方式太明智了!

如果你想使用scrapy parse進行調試,那麼現在必須設置spider參數,如下所示。

<strong>$ scrapy parse --spider=fast http://web:9312/properties/index_00000.html</strong>n<strong>...</strong>n<strong>>>> STATUS DEPTH LEVEL 1 <<<</strong>n<strong># Scraped Items --------------------------------------------</strong>n<strong>[{address: [uAngel, London],</strong>n<strong>... 30 items...</strong>n<strong># Requests ---------------------------------------------------</strong>n<strong>[<GET http://web:9312/properties/index_00001.html>]</strong>n

正如期望的那樣,parse()返回了30Item以及一個前往下一索引頁的Request。請使用scrapy parse隨意試驗,比如傳輸--depth=2

本文摘自《精通Python爬蟲框架Scrapy》

Scrapy是使用Python開發的一個快速、高層次的屏幕抓取和Web抓取框架,用於抓Web站點並從頁面中提取結構化的數據。本書以Scrapy 1.0版本為基礎,講解了Scrapy的基礎知識,以及如何使用Python和三方API提取、整理數據,以滿足自己的需求。

點擊關鍵詞閱讀更多新書:

Python|機器學習|Kotlin|Java|移動開發|機器人|有獎活動|Web前端|書單

在「非同步圖書」後台回復「關注」,即可免費獲得2000門在線視頻課程;推薦朋友關注根據提示獲取贈書鏈接,免費得非同步圖書一本。趕緊來參加哦!

點擊閱讀原文,查看本書更多信息

掃一掃上方二維碼,回復「關注」參與活動!

點擊下方閱讀原文,查看更多內容

推薦閱讀:

Python3爬蟲(2)多網頁爬取圖片
為什麼這個網頁的源代碼用python爬下來後用beautifulsoup解析後會出現字元丟失?
使用python爬取pixiv.net的圖片?
當大家都在討論金剛狼3的時候,他們到底在說些什麼~

TAG:Python | 网页爬虫 | python爬虫 |