1. 程式人生 > 實用技巧 >13.8 Scrapy 對接 Selenium

13.8 Scrapy 對接 Selenium

13.8 Scrapy 對接 Selenium

Scrapy 抓取頁面的方式和 requests 庫類似,都是直接模擬 HTTP 請求,而 Scrapy 也不能抓取 JavaScript 動態渲染的頁面。在前文中抓取 JavaScript 渲染的頁面有兩種方式。一種是分析 Ajax 請求,找到其對應的介面抓取,Scrapy 同樣可以用此種方式抓取。另一種是直接用 Selenium 或 Splash 模擬瀏覽器進行抓取,我們不需要關心頁面後臺發生的請求,也不需要分析渲染過程,只需要關心頁面最終結果即可,可見即可爬。那麼,如果 Scrapy 可以對接 Selenium,那 Scrapy 就可以處理任何網站的抓取了。

1. 本節目標

本節我們來看看 Scrapy 框架如何對接 Selenium,以 PhantomJS 進行演示。我們依然抓取淘寶商品資訊,抓取邏輯和前文中用 Selenium 抓取淘寶商品完全相同。

2. 準備工作

請確保 PhantomJS 和 MongoDB 已經安裝好並可以正常執行,安裝好 Scrapy、Selenium、PyMongo 庫,安裝方式可以參考第 1 章的安裝說明。

3. 新建專案

首先新建專案,名為 scrapyseleniumtest,命令如下所示:

scrapy startproject scrapyseleniumtest

新建一個 Spider,命令如下所示:

scrapy genspider taobao www.taobao.com

修改 ROBOTSTXT_OBEY 為 False,如下所示:

ROBOTSTXT_OBEY = False

4. 定義 Item

首先定義 Item 物件,名為 ProductItem,程式碼如下所示:

from scrapy import Item, Field

class ProductItem(Item):
    
    collection = 'products'
    image = Field()
    price = Field()
    deal = Field()
    title = Field()
    shop = Field()
    location = Field()

這裡我們定義了 6 個 Field,也就是 6 個欄位,跟之前的案例完全相同。然後定義了一個 collection 屬性,即此 Item 儲存到 MongoDB 的 Collection 名稱。

初步實現 Spider 的 start_requests() 方法,如下所示:

from scrapy import Request, Spider
from urllib.parse import quote
from scrapyseleniumtest.items import ProductItem

class TaobaoSpider(Spider):
    name = 'taobao'
    allowed_domains = ['www.taobao.com']
    base_url = 'https://s.taobao.com/search?q='

    def start_requests(self):
        for keyword in self.settings.get('KEYWORDS'):
            for page in range(1, self.settings.get('MAX_PAGE') + 1):
                url = self.base_url + quote(keyword)
                yield Request(url=url, callback=self.parse, meta={'page': page}, dont_filter=True)

首先定義了一個 base_url,即商品列表的 URL,其後拼接一個搜尋關鍵字就是該關鍵字在淘寶的搜尋結果商品列表頁面。

關鍵字用 KEYWORDS 標識,定義為一個列表。最大翻頁頁碼用 MAX_PAGE 表示。它們統一定義在 setttings.py 裡面,如下所示:

KEYWORDS = ['iPad']
MAX_PAGE = 100

在 start_requests() 方法裡,我們首先遍歷了關鍵字,遍歷了分頁頁碼,構造並生成 Request。由於每次搜尋的 URL 是相同的,所以分頁頁碼用 meta 引數來傳遞,同時設定 dont_filter 不去重。這樣爬蟲啟動的時候,就會生成每個關鍵字對應的商品列表的每一頁的請求了。

5. 對接 Selenium

接下來我們需要處理這些請求的抓取。這次我們對接 Selenium 進行抓取,採用 Downloader Middleware 來實現。在 Middleware 裡面的 process_request() 方法裡對每個抓取請求進行處理,啟動瀏覽器並進行頁面渲染,再將渲染後的結果構造一個 HtmlResponse 物件返回。程式碼實現如下所示:

from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from scrapy.http import HtmlResponse
from logging import getLogger

class SeleniumMiddleware():
    def __init__(self, timeout=None, service_args=[]):
        self.logger = getLogger(__name__)
        self.timeout = timeout
        self.browser = webdriver.PhantomJS(service_args=service_args)
        self.browser.set_window_size(1400, 700)
        self.browser.set_page_load_timeout(self.timeout)
        self.wait = WebDriverWait(self.browser, self.timeout)
    
    def __del__(self):
        self.browser.close()
    
    def process_request(self, request, spider):
        """
        用 PhantomJS 抓取頁面
        :param request: Request 物件
        :param spider: Spider 物件
        :return: HtmlResponse
        """
        self.logger.debug('PhantomJS is Starting')
        page = request.meta.get('page', 1)
        try:
            self.browser.get(request.url)
            if page > 1:
                input = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '#mainsrp-pager div.form> input')))
                submit = self.wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '#mainsrp-pager div.form> span.btn.J_Submit')))
                input.clear()
                input.send_keys(page)
                submit.click()
            self.wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, '#mainsrp-pager li.item.active> span'), str(page)))
            self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '.m-itemlist .items .item')))
            return HtmlResponse(url=request.url, body=self.browser.page_source, request=request, encoding='utf-8', status=200)
        except TimeoutException:
            return HtmlResponse(url=request.url, status=500, request=request)
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(timeout=crawler.settings.get('SELENIUM_TIMEOUT'),
                   service_args=crawler.settings.get('PHANTOMJS_SERVICE_ARGS'))

首先我們在 __init__() 裡對一些物件進行初始化,包括 PhantomJS、WebDriverWait 等物件,同時設定頁面大小和頁面載入超時時間。在 process_request() 方法中,我們通過 Request 的 meta 屬性獲取當前需要爬取的頁碼,呼叫 PhantomJS 物件的 get() 方法訪問 Request 的對應的 URL。這就相當於從 Request 物件裡獲取請求連結,然後再用 PhantomJS 載入,而不再使用 Scrapy 裡的 Downloader。

隨後的處理等待和翻頁的方法在此不再贅述,和前文的原理完全相同。最後,頁面載入完成之後,我們呼叫 PhantomJS 的 page_source 屬性即可獲取當前頁面的原始碼,然後用它來直接構造並返回一個 HtmlResponse 物件。構造這個物件的時候需要傳入多個引數,如 url、body 等,這些引數實際上就是它的基礎屬性。可以在官方文件檢視 HtmlResponse 物件的結構:https://doc.scrapy.org/en/latest/topics/request-response.html,這樣我們就成功利用 PhantomJS 來代替 Scrapy 完成了頁面的載入,最後將 Response 返回即可。

有人可能會納悶:為什麼實現這麼一個 Downloader Middleware 就可以了?之前的 Request 物件怎麼辦?Scrapy 不再處理了嗎?Response 返回後又傳遞給了誰?

是的,Request 物件到這裡就不會再處理了,也不會再像以前一樣交給 Downloader 下載。Response 會直接傳給 Spider 進行解析。

我們需要回顧一下 Downloader Middleware 的 process_request() 方法的處理邏輯,內容如下所示:

當 process_request() 方法返回 Response 物件的時候,更低優先順序的 Downloader Middleware 的 process_request() 和 process_exception() 方法就不會被繼續呼叫了,轉而開始執行每個 Downloader Middleware 的 process_response() 方法,呼叫完畢之後直接將 Response 物件傳送給 Spider 來處理。

這裡直接返回了一個 HtmlResponse 物件,它是 Response 的子類,返回之後便順次呼叫每個 Downloader Middleware 的 process_response() 方法。而在 process_response() 中我們沒有對其做特殊處理,它會被髮送給 Spider,傳給 Request 的回撥函式進行解析。

到現在,我們應該能瞭解 Downloader Middleware 實現 Selenium 對接的原理了。

在 settings.py 裡,我們設定呼叫剛才定義的 SeleniumMiddleware、設定等待超時變數 SELENIUM_TIMEOUT、設定 PhantomJS 配置引數 PHANTOMJS_SERVICE_ARGS,如下所示:

DOWNLOADER_MIDDLEWARES = {'scrapyseleniumtest.middlewares.SeleniumMiddleware': 543,}

6. 解析頁面

Response 物件就會回傳給 Spider 內的回撥函式進行解析。所以下一步我們就實現其回撥函式,對網頁來進行解析,程式碼如下所示:

def parse(self, response):
    products = response.xpath('//div[@id="mainsrp-itemlist"]//div[@class="items"][1]//div[contains(@class, "item")]')
    for product in products:
        item = ProductItem()
        item['price'] = ''.join(product.xpath('.//div[contains(@class, "price")]//text()').extract()).strip()
        item['title'] = ''.join(product.xpath('.//div[contains(@class, "title")]//text()').extract()).strip()
        item['shop'] = ''.join(product.xpath('.//div[contains(@class, "shop")]//text()').extract()).strip()
        item['image'] = ''.join(product.xpath('.//div[@class="pic"]//img[contains(@class, "img")]/@data-src').extract()).strip()
        item['deal'] = product.xpath('.//div[contains(@class, "deal-cnt")]//text()').extract_first()
        item['location'] = product.xpath('.//div[contains(@class, "location")]//text()').extract_first()
        yield item

在這裡我們使用 XPath 進行解析,呼叫 response 變數的 xpath() 方法即可。首先我們傳遞選取所有商品對應的 XPath,可以匹配所有商品,隨後對結果進行遍歷,依次選取每個商品的名稱、價格、圖片等內容,構造並返回一個 ProductItem 物件。

7. 儲存結果

最後我們實現一個 Item Pipeline,將結果儲存到 MongoDB,如下所示:

import pymongo

class MongoPipeline(object):
    def __init__(self, mongo_uri, mongo_db):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DB'))
    
    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]
    
    def process_item(self, item, spider):
        self.db[item.collection].insert(dict(item))
        return item
    
    def close_spider(self, spider):
        self.client.close()

此實現和前文中儲存到 MongoDB 的方法完全一致,原理不再贅述。記得在 settings.py 中開啟它的呼叫,如下所示:

ITEM_PIPELINES = {'scrapyseleniumtest.pipelines.MongoPipeline': 300,}

其中,MONGO_URI 和 MONGO_DB 的定義如下所示:

MONGO_URI = 'localhost'
MONGO_DB = 'taobao'

8. 執行

整個專案就完成了,執行如下命令啟動抓取即可:

scrapy crawl taobao

執行結果如圖 13-13 所示:

圖 13-13 執行結果

再檢視一下 MongoDB,結果如圖 13-14 所示:

圖 13-14 MongoDB 結果

這樣我們便成功在 Scrapy 中對接 Selenium 並實現了淘寶商品的抓取。

9. 本節程式碼

本節程式碼地址為:https://github.com/Python3WebSpider/ScrapySeleniumTest

10. 結語

我們通過改寫 Downloader Middleware 的方式實現了 Selenium 的對接。但這種方法其實是阻塞式的,也就是說這樣就破壞了 Scrapy 非同步處理的邏輯,速度會受到影響。為了不破壞其非同步載入邏輯,我們可以使用 Splash 實現。下一節我們再來看看 Scrapy 對接 Splash 的方式。