1. 程式人生 > 程式設計 >scrapy結合selenium解析動態頁面的實現

scrapy結合selenium解析動態頁面的實現

1. 問題

雖然scrapy能夠完美且快速的抓取靜態頁面,但是在現實中,目前絕大多數網站的頁面都是動態頁面,動態頁面中的部分內容是瀏覽器執行頁面中的JavaScript指令碼動態生成的,爬取相對困難;

比如你信心滿滿的寫好了一個爬蟲,寫好了目標內容的選擇器,一跑起來發現根本找不到這個元素,當時肯定一萬個黑人問號

scrapy結合selenium解析動態頁面的實現

於是你在瀏覽器裡開啟F12,一頓操作,發現原來這你妹的是ajax載入的,不然就是硬編碼在js程式碼裡的,blabla的…

然後你得去調ajax的介面,然後解析json啊,轉成python字典啊,然後才能拿到你想要的東西

妹的就不能對我們這些小爬爬友好一點嗎?

於是大傢伙肯定想過,“為啥不能瀏覽器看到是咋樣的html頁面,我們爬蟲得到的也是同樣的html頁面呢? 要是可以,那得多麼美滋滋啊”

scrapy結合selenium解析動態頁面的實現

2. 解決方案

既然是想要得到和瀏覽器一模一樣的html頁面,那我們就先用瀏覽器渲染一波目標網頁,然後再將瀏覽器渲染後的html拿給scrapy進行進一步解析不就好了嗎

scrapy結合selenium解析動態頁面的實現

2.1 獲取瀏覽器渲染後的html

有了思路,肯定是網上搜一波然後開幹啊,搜python操作瀏覽器的庫啊

貨比三家之後,找到了selenium這貨

selenium可以模擬真實瀏覽器,自動化測試工具,支援多種瀏覽器,爬蟲中主要用來解決JavaScript渲染問題。

臥槽,這就是我們要的東西啦

先試一波看看效果如何,目標網址http://quotes.toscrape.com/js/

scrapy結合selenium解析動態頁面的實現

彆著急,先來看一下網頁原始碼

scrapy結合selenium解析動態頁面的實現

我們想要的div.quote被硬編碼在js程式碼中

用selenium試一下看能不能獲取到瀏覽器渲染後的html

scrapy結合selenium解析動態頁面的實現

from selenium import webdriver

# 控制火狐瀏覽器
browser = webdriver.Firefox()

# 訪問我們的目標網址
browser.get("http://quotes.toscrape.com/js/")

# 獲取渲染後的html頁面
html = browser.page_source

perfect,到這裡我們已經順利拿到瀏覽器渲染後的html了,selenium大法好啊?

2.2 通過下載器中介軟體返回渲染過後html的Response

這裡先放一張scrapy的流程圖

在這裡插入圖片描述

所以我們只需要在scrapy下載網頁(downloader下載好網頁,構造Response返回)之前,通過下載器中介軟體返回我們自己<通過渲染後html構造的Response>不就可以了嗎?

scrapy結合selenium解析動態頁面的實現

道理我都懂,關鍵是在哪一步使用瀏覽器呢?

分析:

(1)我們的scrapy可能是有很多個爬蟲的,有些爬蟲處理的是純純的靜態頁面,而有些是處理的純純的動態頁面,又有些是動靜態結合的頁面(有可能列表頁是靜態的,正文頁是動態的),如果把<瀏覽器呼叫程式碼>放在下載器中介軟體中,那麼除非特別區分哪些爬蟲需要selenium,否則每一個爬蟲都用selenium去下載解析頁面的話,實在是太浪費資源了,就相當於殺雞用牛刀了,所以得出結論,<瀏覽器呼叫程式碼>應該是放置於Spider類中更好一點;

(2)如果放置於Spider類中,就意味著一個爬蟲佔用一個瀏覽器的一個tab頁,如果這個爬蟲裡的某些Request需要selenium,而某些不需要呢? 所以我們還要在區分一下Request;

結論:

SeleniumDownloaderMiddleware(selenium專用下載器中介軟體):負責返回瀏覽器渲染後的ResponseSeleniumSpider(selenium專用Spider):一個spider開一個瀏覽器SeleniumRequest:只是繼承一下scrapy.Request,然後pass,好區分哪些Request需要啟用selenium進行解析頁面,相當於改個名

3. 擼程式碼,盤他

3.1 自定義Request

#!usr/bin/env python 
# -*- coding:utf-8 _*-
""" 
@author:Joshua
@description:
  只是繼承一下scrapy.Request,然後pass,好區分哪些Request需要啟用selenium進行解析頁面,相當於改個名
"""
import scrapy

class SeleniumRequest(scrapy.Request):
  """
  selenium專用Request類
  """
  pass

3.2 自定義Spider

#!usr/bin/env python 
# -*- coding:utf-8 _*-
""" 
@author:Joshua 
@description:
  一個spider開一個瀏覽器
"""
import logging
import scrapy
from selenium import webdriver


class SeleniumSpider(scrapy.Spider):
  """
  Selenium專用spider

  一個spider開一個瀏覽器

  瀏覽器驅動下載地址:http://www.cnblogs.com/qiezizi/p/8632058.html
  """
  # 瀏覽器是否設定無頭模式,僅測試時可以為False
  SetHeadless = True

  # 是否允許瀏覽器使用cookies
  EnableBrowserCookies = True

  def __init__(self,*args,**kwargs):
    super(SeleniumSpider,self).__init__(*args,**kwargs)
    
    # 獲取瀏覽器操控權
    self.browser = self._get_browser()

  def _get_browser(self):
    """
    返回瀏覽器例項
    """
    # 設定selenium與urllib3的logger的日誌等級為ERROR
    # 如果不加這一步,執行爬蟲過程中將會產生一大堆無用輸出
    logging.getLogger('selenium').setLevel('ERROR')
    logging.getLogger('urllib3').setLevel('ERROR')
    
    # selenium已經放棄了PhantomJS,開始支援firefox與chrome的無頭模式
    return self._use_firefox()

  def _use_firefox(self):
    """
    使用selenium操作火狐瀏覽器
    """
    profile = webdriver.FirefoxProfile()
    options = webdriver.FirefoxOptions()
    
    # 下面一系列禁用操作是為了減少selenium的資源耗用,加速scrapy
    
    # 禁用圖片
    profile.set_preference('permissions.default.image',2)
    profile.set_preference('browser.migration.version',9001)
    # 禁用css
    profile.set_preference('permissions.default.stylesheet',2)
    # 禁用flash
    profile.set_preference('dom.ipc.plugins.enabled.libflashplayer.so','false')
    
    # 如果EnableBrowserCookies的值設為False,那麼禁用cookies
    if hasattr(self,"EnableBrowserCookies") and self.EnableBrowserCookies:
      # •值1 - 阻止所有第三方cookie。
      # •值2 - 阻止所有cookie。
      # •值3 - 阻止來自未訪問網站的cookie。
      # •值4 - 新的Cookie Jar策略(阻止對跟蹤器的儲存訪問)
      profile.set_preference("network.cookie.cookieBehavior",2)
    
    # 預設是無頭模式,意思是瀏覽器將會在後臺執行,也是為了加速scrapy
    # 我們可不想跑著爬蟲時,旁邊還顯示著瀏覽器訪問的頁面
    # 除錯的時候可以把SetHeadless設為False,看一下跑著爬蟲時候,瀏覽器在幹什麼
    if self.SetHeadless:
      # 無頭模式,無UI
      options.add_argument('-headless')

    # 禁用gpu加速
    options.add_argument('--disable-gpu')

    return webdriver.Firefox(firefox_profile=profile,options=options)

  def selenium_func(self,request):
    """
    在返回瀏覽器渲染的html前做一些事情
      1.比如等待瀏覽器頁面中的某個元素出現後,再返回渲染後的html;
      2.比如將頁面切換進iframe中的頁面;
    
    在需要使用的子類中要重寫該方法,並利用self.browser操作瀏覽器
    """
    pass

  def closed(self,reason):
    # 在爬蟲關閉後,關閉瀏覽器的所有tab頁,並關閉瀏覽器
    self.browser.quit()
    
    # 日誌記錄一下
    self.logger.info("selenium已關閉瀏覽器...")

之所以不把獲取瀏覽器的具體程式碼寫在__init__方法裡,是因為筆者之前寫的程式碼裡考慮過

  • 兩種瀏覽器的呼叫(支援firefox與chrome),雖然後來感覺還是firefox比較方便,因為所有版本的火狐瀏覽器的驅動都是一樣的,但是谷歌瀏覽器是不同版本的瀏覽器必須用不同版本的驅動(坑爹啊- -'')
  • 自動區分不同的作業系統並選擇對應作業系統的瀏覽器驅動

額… 所以上面spider的程式碼是精簡過的版本

備註: 針對selenium做了一系列的優化加速,啟用了無頭模式,禁用了css、flash、圖片、gpu加速等… 因為爬蟲嘛,肯定是跑的越快越好啦?

3.3 自定義下載器中介軟體

#!usr/bin/env python 
# -*- coding:utf-8 _*-
""" 
@author:Joshua 
@description:
  負責返回瀏覽器渲染後的Response
"""
import hashlib
import time
from scrapy.http import HtmlResponse
from twisted.internet import defer,threads
from tender_scrapy.extendsion.selenium.spider import SeleniumSpider
from tender_scrapy.extendsion.selenium.requests import SeleniumRequest


class SeleniumDownloaderMiddleware(object):
  """
  Selenium下載器中介軟體
  """
  
  def process_request(self,request,spider):
    # 如果spider為SeleniumSpider的例項,並且request為SeleniumRequest的例項
    # 那麼該Request就認定為需要啟用selenium來進行渲染html
    if isinstance(spider,SeleniumSpider) and isinstance(request,SeleniumRequest):
      # 控制瀏覽器開啟目標連結
      browser.get(request.url)
      
      # 在構造渲染後的HtmlResponse之前,做一些事情
      #1.比如等待瀏覽器頁面中的某個元素出現後,再返回渲染後的html;
      #2.比如將頁面切換進iframe中的頁面;
      spider.selenium_func(request)
      
      # 獲取瀏覽器渲染後的html
      html = browser.page_source
      
      # 構造Response
      # 這個Response將會被你的爬蟲進一步處理
      return HtmlResponse(url=browser.current_url,request=request,body=html.encode(),encoding="utf-8")

這裡要說一下下載器中介軟體的process_request方法,當每個request通過下載中介軟體時,該方法被呼叫。

  1. process_request() 必須返回其中之一: 返回 None 、返回一個 Response 物件、返回一個 Request 物件或raise IgnoreRequest 。
  2. 如果其返回 Response 物件,Scrapy將不會呼叫 任何 其他的 process_request() 或 process_exception() 方法,或相應地下載函式; 其將返回該response。 已安裝的中介軟體的 process_response() 方法則會在每個response返回時被呼叫。

更詳細的關於下載器中介軟體的資料 -> https://scrapy-chs.readthedocs.io/zh_CN/0.24/topics/downloader-middleware.html#id2

3.4 額外的工具

眼尖的讀者可能注意到SeleniumSpider類裡有個selenium_func方法,並且在SeleniumDownloaderMiddleware的process_request方法返回Resposne之前呼叫了spider的selenium_func方法

scrapy結合selenium解析動態頁面的實現

這樣做的好處是,我們可以在構造渲染後的HtmlResponse之前,做一些事情(比如…那種…很騷的那種…你懂的)

  • 比如等待瀏覽器頁面中的某個元素出現後,再返回渲染後的html;
  • 比如將頁面切換進iframe中的頁面,然後返回iframe裡面的html(夠騷嗎);

等待某個元素出現,然後再返回渲染後的html這種操作很常見的,比如你訪問一篇文章,它的正文是ajax載入然後js新增到html裡的,ajax是需要時間的,但是selenium並不會等待所有請求都完畢後再返回

解決方法:

  1. 您可以通過browser.implicitly_wait(30),來強制selenium等待30秒(無論元素是否加載出來,都必須等待30秒)
  2. 可以通過等待,直到某個元素出現,然後再返回html

所以筆者對<等待某個元素出現>這一功能做了進一步的封裝,程式碼如下

#!usr/bin/env python 
# -*- coding:utf-8 _*-
""" 
@author:Joshua 
@description:
"""
import functools
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC


def waitFor(browser,select_arg,select_method,timeout=2):
  """
  阻塞等待某個元素的出現直到timeout結束

  :param browser:瀏覽器例項
  :param select_method:所使用的選擇器方法
  :param select_arg:選擇器引數
  :param timeout:超時時間
  :return:
  """
  element = WebDriverWait(browser,timeout).until(
    EC.presence_of_element_located((select_method,select_arg))
  )


# 用xpath選擇器等待元素
waitForXpath = functools.partial(waitFor,select_method=By.XPATH)

# 用css選擇器等待元素
waitForCss = functools.partial(waitFor,select_method=By.CSS_SELECTOR)

waitForXpath與waitForCss 是waitFor函式的兩個偏函式,意思這兩個偏函式是設定了select_method引數預設值的waitFor函式,分別應用不同的選擇器來定位元素

4. 中介軟體當然要在settings中啟用一下

在這裡插入圖片描述

在我們scrapy專案的settings檔案中的DOWNLOADER_MIDDLEWARES字典中新增到適當的位置即可

5. 使用示例

5.1一個完整的爬蟲示例

# -*- coding: utf-8 -*-
"""
@author:Joshua
@description:
  整合selenium的爬蟲示例
"""
import scrapy
from my_project.requests import SeleniumRequest
from my_project.spider import SeleniumSpider
from my_project.tools import waitForXpath


# 這個爬蟲類繼承了SeleniumSpider
# 在爬蟲跑起來的時候,將啟動一個瀏覽器
class SeleniumExampleSpider(SeleniumSpider):
  """
  這一網站,他的列表頁是靜態的,但是內容頁是動態的
  所以,用selenium試一下,目標是扣出內容頁的#content
  """
  name = 'selenium_example'
  allowed_domains = ['pingdingshan.hngp.gov.cn']
  url_format = 'http://pingdingshan.hngp.gov.cn/pingdingshan/ggcx?appCode=H65&channelCode=0301&bz=0&pageSize=20&pageNo={page_num}'

  def start_requests(self):
    """
    開始發起請求,記錄頁碼
    """
    start_url = self.url_format.format(page_num=1)
    meta = dict(page_num=1)
    # 列表頁是靜態的,所以不需要啟用selenium,用普通的scrapy.Request就可以了
    yield scrapy.Request(start_url,meta=meta,callback=self.parse)

  def parse(self,response):
    """
    從列表頁解析出正文的url
    """
    meta = response.meta
    all_li = response.css("div.List2>ul>li")

    # 列表
    for li in all_li:
      content_href = li.xpath('./a/@href').extract()
      content_url = response.urljoin(content_href)
      # 內容頁是動態的,#content是ajax動態載入的,所以啟用一波selenium
      yield SeleniumRequest(url=content_url,callback=self.parse_content)

    # 翻頁
    meta['page_num'] += 1
    next_url = self.url_format.format(page_num=meta['page_num'])
    # 列表頁是靜態的,所以不需要啟用selenium,用普通的scrapy.Request就可以了
    yield scrapy.Request(url=next_url,callback=self.parse)

  def parse_content(self,response):
    """
    解析正文內容
    """
    content = response.css('#content').extract_first()
    yield dict(content=content)
   
  def selenium_func(self,request):
    # 這個方法會在我們的下載器中介軟體返回Response之前被呼叫
    
    # 等待content內容載入成功後,再繼續
    # 這樣的話,我們就能在parse_content方法裡應用選擇器扣出#content了
    waitForXpath(self.browser,"//*[@id='content']/*[1]")

5.2 更騷一點的操作…

假如內容頁的目標資訊處於iframe中,我們可以將視窗切換進目標iframe裡面,然後返回iframe的html

要實現這樣的操作,只需要重寫一下SeleniumSpider子類中的selenium_func方法

要注意到SeleniumSpider中的selenium_func其實是啥也沒做的,一個pass,所有的功能都在子類中重寫

def selenium_func(self,request):
  # 找到id為myPanel的iframe
  target = self.browser.find_element_by_xpath("//iframe[@id='myPanel']")
  # 將瀏覽器的視窗切換進該iframe中
  # 那麼切換後的self.browser的page_source將會是iframe的html
  self.browser.switch_to.frame(target)

6. selenium的一些替代(一些解決動態頁面別的方法)

scrapy官方推薦的scrapy_splash

優點

  • 是非同步的
  • 可以將部署scrapy的伺服器與部署splash的伺服器分離開
  • 留給讀者遐想的空間

本人覺得的缺點

  • 喂喂,lua指令碼很麻煩好嗎…(大牛請別打我)

最新的非同步pyppeteer操控瀏覽器

優點

  • 呼叫瀏覽器是非同步的,操控的單位是tab頁,速度更快
  • 留給讀者遐想的空間

本人覺得的缺點

  • 因為pyppeteer是python版puppeteer,所以puppeteer的一些毛病,pyppeteer無可避免的完美繼承
  • 筆者試過將pyppeteer整合至scrapy中,在非同步中,scrapy跑起來爬蟲,總會偶爾timeout之類的…

anyway,上面兩個都是不錯的替代,有興趣的讀者可以試一波

7. scrapy整合selenium的一些缺點

  • selenium是阻塞的,所以速度會慢些
  • 對於一些稍微簡單的動態頁面,最好還是自己去解析一下介面,不要太過依賴selenium,因為selenium帶來便利的同時,是更多資源的佔用
  • 整合selenium的scrapy專案不宜大規模的爬取,比如你在自己的機子上寫好了一個一個的爬蟲,跑起來也沒毛病,速度也能接受,然後你很開心地在伺服器上部署了你專案上的100+個爬蟲(裡面有50%左右的爬蟲啟用了selenium),當他們跑起來的時候,伺服器就原地爆炸了… 為啥? 因為相當於伺服器同時開了50多個瀏覽器在跑,記憶體頂不住啊(土豪忽略…)

到此這篇關於scrapy結合selenium解析動態頁面的實現的文章就介紹到這了,更多相關scrapy selenium解析動態頁面內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!