天天看點

Scrapy架構的使用之Scrapy對接Selenium

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

一、本節目标

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

二、準備工作

請確定PhantomJS和MongoDB已經安裝好并可以正常運作,安裝好Scrapy、Selenium、PyMongo庫。

三、建立項目

首先建立項目,名為scrapyseleniumtest,指令如下所示:

scrapy startproject scrapyseleniumtest
           

建立一個Spider,指令如下所示:

scrapy genspider taobao www.taobao.com
           

修改ROBOTSTXT_OBEY為False,如下所示:

ROBOTSTXT_OBEY = False
           

四、定義 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不去重。這樣爬蟲啟動的時候,就會生成每個關鍵字對應的商品清單的每一頁的請求了。

五、對接 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,如下所示:

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

六、解析頁面

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對象。

七、存儲結果

最後我們實作一個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'
           

八、運作

整個項目就完成了,執行如下指令啟動抓取即可:

scrapy crawl taobao
           

運作結果如下圖所示。

Scrapy架構的使用之Scrapy對接Selenium

檢視MongoDB,結果如下圖所示。

Scrapy架構的使用之Scrapy對接Selenium

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

九、結語

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

原文釋出時間為:2018-07-10

本文作者:崔慶才

本文來自雲栖社群合作夥伴“

Python愛好者社群

”,了解相關資訊可以關注“