如何優雅的在scrapy中使用selenium —— 在scrapy中實現瀏覽器池

1

使用 scrapy 作採集實在是爽,可是遇到網站反爬措施作的比較好的就讓人頭大了。除了硬着頭皮上之外,還能夠使用爬蟲利器 seleniumselenium 因其良好的模擬能力成爲爬蟲愛(cai)好(ji)者愛不釋手的武器。可是其速度又每每使人感到美中不足,特別是在與 scrapy 集成使用時,嚴重拖了 scrapy 的後腿,整個採集過程讓人看着實在不爽,那麼有沒有更好的方式來使用呢?答案固然是必須的。python

2

twisted 開發者在遇到與 MySQL 數據庫交互時,也有一樣的問題:如何在異步循環中更好的調用一個IO阻塞的函數?因而他們實現了 adbapi,將阻塞方法放進了線程池中執行。基於此,咱們也能夠將 selenium 相關的方法放入線程池中執行,這樣就能夠極大的減小等待的時間。react

3

因爲 scrapy 是基於 twisted 開發的,所以基於 twisted 線程池實現 selenium 瀏覽器池,就能很好的與 scrapy 融合在一塊兒了,因此本次就基於 twistedthreadpool 開發,手把手寫一個下載中間件,用來實現 scrapyselenium 的優雅配合。git

4

首先是對於請求類的定義,咱們讓 selenium 只接受自定義的請求類調用,考慮到 selenium 中可等待,可執行 JavaScript,所以爲其定義了 wait_untilwait_timescript 三個屬性,同時考慮到可能會在請求成功後對 webdriver 作自定製的操做,所以還定義了一個 handler 屬性,該屬性接受一個方法,僅可接受 driverrequestspider 三個參數,分別表示當前瀏覽器實例、當前請求實例、當前爬蟲實例,該方法能夠有返回值,當該方法返回一個 RequestResponse 對象時,與在 scrapy 中的下載中間中的 process_request 方法返回值具備同等做用:github

import scrapy

class SeleniumRequest(scrapy.Request):

    def __init__(self,
                 url,
                 callback=None,
                 wait_until=None,
                 wait_time=10,
                 script=None,
                 handler=None,
                 **kwargs):
        self.wait_until = wait_until
        self.wait_time = wait_time
        self.script = script
        self.handler = handler
        super().__init__(url, callback, **kwargs)

5

定義好請求類後,還須要實現瀏覽器類,用於建立 webdriver 實例,同時作一些規避檢測和簡單優化的動做,並支持不一樣的瀏覽器,鑑於精力有限,這裏僅支持 chromefirefox 瀏覽器:web

from scrapy.http import HtmlResponse
from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver


class Browser(object):
    """Browser to make drivers"""
    # 支持的瀏覽器名稱及對應的類
    support_driver_map = {
        'firefox': webdriver.Firefox,
        'chrome': webdriver.Chrome
    }

    def __init__(self, driver_name='chrome', executable_path=None, options=None, **opt_kw):
        assert driver_name in self.support_driver_map, f'{driver_name} not be supported!'
        self.driver_name = driver_name
        self.executable_path = executable_path
        if options is not None:
            self.options = options
        else:
            self.options = make_options(self.driver_name, **opt_kw)

    def driver(self):
        kwargs = {'executable_path': self.executable_path, 'options': self.options}
        # 關閉日誌文件,僅適用於windows平臺
        if self.driver_name == 'firefox':
            kwargs['service_log_path'] = 'nul'
        driver = self.support_driver_map[self.driver_name](**kwargs)
        self.prepare_driver(driver)
        return _WebDriver(driver)

    def prepare_driver(self, driver):
        if isinstance(driver, webdriver.Chrome):
            # 移除 `window.navigator.webdriver`.
            driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
                "source": """
                Object.defineProperty(navigator, 'webdriver', {
                  get: () => undefined
                })
              """
            })

def make_options(driver_name, headless=True, disable_image=True, user_agent=None):
    """
    params headless: 是否隱藏界面
    params disable_image: 是否關閉圖像
    params user_agent: 瀏覽器標誌
    """
    if driver_name == 'chrome':
        options = webdriver.ChromeOptions()
        options.headless = headless
        # 關閉 gpu 渲染
        options.add_argument('--disable-gpu')
        if user_agent:
            options.add_argument(f"--user-agent={user_agent}")
        if disable_image:
            options.add_experimental_option('prefs', {'profile.default_content_setting_values': {'images': 2}})
        # 規避檢測
        options.add_experimental_option('excludeSwitches', ['enable-automation', ])
        return options

    elif driver_name == 'firefox':
        options = webdriver.FirefoxOptions()
        options.headless = headless
        if disable_image:
            options.set_preference('permissions.default.image', 2)
        if user_agent:
            options.set_preference('general.useragent.override', user_agent)
        return options

其中,Browser 類的 driver 方法用於建立 webdriver 實例,注意到其返回的並非原生的 seleniumwebdriver 實例,而是一個通過自定義的類,由於筆者有意爲其實現一個特殊的方法,因此使用了代理類(其方法調用和 selenium 中的 webdriver 並沒有不一樣,只是多了一個新的方法),代碼以下:ajax

class _WebDriver(object):

    def __init__(self, driver: RemoteWebDriver):
        self._driver = driver
        self._is_idle = False

    def __getattr__(self, item):
        return getattr(self._driver, item)

    def current_response(self, request):
        """返回當前頁面的 response 對象"""
        return HtmlResponse(self.current_url,
                            body=str.encode(self.page_source),
                            encoding='utf-8',
                            request=request)

6

到此,終於到了最重要的一步:基於 selenium 的瀏覽器池實現,其實也就是進程池,只不過將初始化瀏覽器以及經過瀏覽器請求的操做交給了不一樣的進程而已。鑑於使用下載中間件的方式實現,所以能夠將可配置屬性放入 scrapy 項目中的settings.py文件中,初始化時候方便直接讀取。這裏先對可配置字段及其默認值說明:chrome

# 最小 driver 實例數量
SELENIUM_MIN_DRIVERS = 3

# 最大 driver 實例數量
SELENIUM_MAX_DRIVERS = 5

# 是否隱藏界面
SELENIUM_HEADLESS = True

# 是否關閉圖像加載
SELENIUM_DISABLE_IMAGE = True

# driver 初始化時的執行路徑
SELENIUM_DRIVER_PATH = None

# 瀏覽器名稱
SELENIUM_DRIVER_NAME = 'chrome'

# 瀏覽器標誌
USER_AGENT = ...

接下來,就是中間件代碼實現及其相應說明:數據庫

import logging
import threading

from scrapy import signals
from scrapy.http import Request, Response
from selenium.webdriver.support.ui import WebDriverWait

from scrapy_ajax_utils.selenium.browser import Browser
from scrapy_ajax_utils.selenium.request import SeleniumRequest

from twisted.internet import threads, reactor
from twisted.python.threadpool import ThreadPool

logger = logging.getLogger(__name__)


class SeleniumDownloaderMiddleware(object):

    @classmethod
    def from_crawler(cls, crawler):
        settings = crawler.settings
        min_drivers = settings.get('SELENIUM_MIN_DRIVERS', 3)
        max_drivers = settings.get('SELENIUM_MAX_DRIVERS', 5)
        # 初始化瀏覽器
        browser = _make_browser_from_settings(settings)
        dm = cls(browser, min_drivers, max_drivers)
        # 綁定方法用於在爬蟲結束後執行
        crawler.signals.connect(dm.spider_closed, signal=signals.spider_closed)
        return dm

    def __init__(self, browser, min_drivers, max_drivers):
        self._browser = browser
        self._drivers = set()  # 存儲啓動的 driver 實例
        self._data = threading.local()  # 使用 ThreadLocal 綁定線程與 driver
        self._threadpool = ThreadPool(min_drivers, max_drivers)  # 建立線程池

    def process_request(self, request, spider):
        # 過濾非目標請求實例
        if not isinstance(request, SeleniumRequest):
            return

        # 檢測線程池是否啓動
        if not self._threadpool.started:
            self._threadpool.start()

        # 調用線程池執行瀏覽器請求
        return threads.deferToThreadPool(
            reactor, self._threadpool, self.download_by_driver, request, spider
        )

    def download_by_driver(self, request, spider):
        driver = self.get_driver()
        driver.get(request.url)

        # 等待條件
        if request.wait_until:
            WebDriverWait(driver, request.wait_time).until(request.wait_until)

        # 執行 JavaScript 並將執行結果放入 meta 中
        if request.script:
            request.meta['js_result'] = driver.execute_script(request.script)

        # 調用自定製操做方法並檢測返回值
        if request.handler:
            result = request.handler(driver, request, spider)
            if isinstance(result, (Request, Response)):
                return result

        # 返回當前頁面的 response 對象
        return driver.current_response(request)

    def get_driver(self):
        """
        獲取當前線程綁定的 driver 對象
        若是沒有則建立新的對象
        並綁定到當前線程中
        同時添加到已啓動 driver 中
        最後返回
        """
        try:
            driver = self._data.driver
        except AttributeError:
            driver = self._browser.driver()
            self._drivers.add(driver)
            self._data.driver = driver
        return driver

    def spider_closed(self):
        """關閉全部啓動的 driver 對象,並關閉線程池"""
        for driver in self._drivers:
            driver.quit()
        logger.debug('all webdriver closed.')
        self._threadpool.stop()


def _make_browser_from_settings(settings):
    headless = settings.getbool('SELENIUM_HEADLESS', True)
    disable_image = settings.get('SELENIUM_DISABLE_IMAGE', True)
    driver_name = settings.get('SELENIUM_DRIVER_NAME', 'chrome')
    executable_path = settings.get('SELENIUM_DRIVER_PATH')
    user_agent = settings.get('USER_AGENT')
    return Browser(headless=headless,
                   disable_image=disable_image,
                   driver_name=driver_name,
                   executable_path=executable_path,
                   user_agent=user_agent)

7

嫌代碼寫着麻煩?不要緊,這裏有一份已經寫好的代碼:https://github.com/kingron117/scrapy_ajax_utils
只須要 pip install scrapy-ajax-utils 便可食用~windows

8

本次代碼實現主要參(chao)考(xi)瞭如下兩個項目:api

  1. https://github.com/scrapy-plugins/scrapy-headless
  2. https://github.com/clemfromspace/scrapy-selenium
相關文章
相關標籤/搜索