如何優雅的在scrapy中使用selenium —— 在scrapy中實現瀏覽器池
1
使用 scrapy
做採集實在是爽,但是遇到網站反爬措施做的比較好的就讓人頭大了。除了硬著頭皮上以外,還可以使用爬蟲利器 selenium
,selenium
因其良好的模擬能力成為爬蟲愛(cai)好(ji)者愛不釋手的武器。但是其速度又往往令人感到美中不足,特別是在與 scrapy
整合使用時,嚴重拖了 scrapy
的後腿,整個採集過程讓人看著實在不爽,那麼有沒有更好的方式來使用呢?答案當然是必須的。
2
twisted
開發者在遇到與 MySQL
資料庫互動時,也有同樣的問題:如何在非同步迴圈中更好的呼叫一個IO阻塞的函式?於是他們實現了 adbapi
,將阻塞方法放進了執行緒池中執行。基於此,我們也可以將 selenium
3
由於 scrapy
是基於 twisted
開發的,因此基於 twisted
執行緒池實現 selenium
瀏覽器池,就能很好的與 scrapy
融合在一起了,所以本次就基於 twisted
的 threadpool
開發,手把手寫一個下載中介軟體,用來實現 scrapy
與 selenium
的優雅配合。
4
首先是對於請求類的定義,我們讓 selenium
只接受自定義的請求類呼叫,考慮到 selenium
中可等待,可執行 JavaScript
,因此為其定義了 wait_until
、wait_time
、script
三個屬性,同時考慮到可能會在請求成功後對 webdriver
handler
屬性,該屬性接受一個方法,僅可接受 driver
、request
、spider
三個引數,分別表示當前瀏覽器例項、當前請求例項、當前爬蟲例項,該方法可以有返回值,當該方法返回一個 Request
或 Response
物件時,與在 scrapy
中的下載中間中的 process_request
方法返回值具有同等作用:
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
例項,同時做一些規避檢測和簡單優化的動作,並支援不同的瀏覽器,鑑於精力有限,這裡僅支援 chrome
和 firefox
瀏覽器:
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
例項,注意到其返回的並不是原生的 selenium
中 webdriver
例項,而是一個經過自定義的類,因為筆者有意為其實現一個特殊的方法,所以使用了代理類(其方法呼叫和 selenium
中的 webdriver
並無不同,只是多了一個新的方法),程式碼如下:
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
檔案中,初始化時候方便直接讀取。這裡先對可配置欄位及其預設值說明:
# 最小 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
即可食用~
8
本次程式碼實現主要參(chao)考(xi)了以下兩個專案: