第46讲:遇到动态页面怎么办?详解渲染页面爬取
前面我們已經介紹了 Scrapy 的一些常見用法,包括服務端渲染頁面的抓取和 API 的抓取,Scrapy 發起 Request 之后,返回的 Response 里面就包含了想要的結果。
但是現在越來越多的網頁都已經演變為 SPA 頁面,其頁面在瀏覽器中呈現的結果是經過 JavaScript 渲染得到的,如果我們使用 Scrapy 直接對其進行抓取的話,其結果和使用 requests 沒有什么區別。
那我們真的要使用 Scrapy 完成對 JavaScript 渲染頁面的抓取應該怎么辦呢?
之前我們介紹了 Selenium 和 Pyppeteer 都可以實現 JavaScript 渲染頁面的抓取,那用了 Scrapy 之后應該這么辦呢?Scrapy 能和 Selenium 或 Pyppeteer 一起使用嗎?答案是肯定的,我們可以將 Selenium 或 Pyppeteer 通過 Downloader Middleware 和 Scrapy 融合起來,實現 JavaScript 渲染頁面的抓取,本節我們就來了解下它的實現吧。
回顧
在前面我們介紹了 Downloader Middleware 的用法,在 Downloader Middleware 中有三個我們可以實現的方法 process_request、process_response 以及 process_exception 方法。
-
我們再看下 process_request 方法和其不同的返回值的效果:
當返回為 None 時,Scrapy 將繼續處理該 Request,接著執行其他 Downloader Middleware 的 process_request 方法,一直到 Downloader 把 Request 執行完后得到 Response 才結束。這個過程其實就是修改 Request 的過程,不同的 Downloader Middleware 按照設置的優先級順序依次對 Request 進行修改,最后送至 Downloader 執行。 -
當返回為 Response 對象時,更低優先級的 Downloader Middleware 的 process_request 和 process_exception 方法就不會被繼續調用,每個 Downloader Middleware 的 process_response 方法轉而被依次調用。調用完畢之后,直接將 Response 對象發送給 Spider 來處理。
-
當返回為 Request 對象時,更低優先級的 Downloader Middleware 的 process_request 方法會停止執行。這個 Request 會重新放到調度隊列里,其實它就是一個全新的 Request,等待被調度。如果被 Scheduler 調度了,那么所有的 Downloader Middleware 的 process_request 方法都會被重新按照順序執行。
-
如果 IgnoreRequest 異常拋出,則所有的 Downloader Middleware 的 process_exception 方法會依次執行。如果沒有一個方法處理這個異常,那么 Request 的 errorback 方法就會回調。如果該異常還沒有被處理,那么它便會被忽略。
這里我們注意到第二個選項,當返回結果為 Response 對象時,低優先級的 process_request 方法就不會被繼續調用了,這個 Response 對象會直接經由 process_response 方法處理后轉交給 Spider 來解析。
然后再接著想一想,process_request 接收的參數是 request,即 Request 對象,怎么會返回 Response 對象呢?原因可想而知了,這個 Request 對象不再經由 Scrapy 的 Downloader 來處理了,而是在 process_request 方法里面直接就完成了 Request 的發送操作,然后在得到了對應的 Response 結果后再將其返回就好了。
那么對于 JavaScript 渲染的頁面來說,照這個方法來做,我們就可以把 Selenium 或 Pyppeteer 加載頁面的過程在 process_request 方法里面實現,得到網頁渲染完后的源代碼后直接構造 Response 返回即可,這樣我們就完成了借助 Downloader Middleware 實現 Scrapy 爬取動態渲染頁面的過程。
案例
本節我們就用實例來講解一下 Scrapy 和 Pyppeteer 實現 JavaScript 渲染頁面抓取的流程。
本節使用的實例網站為 https://dynamic5.scrape.center/,這是一個 JavaScript 渲染頁面,其內容是一本本的圖書信息。
同時這個網站的頁面帶有分頁功能,只需要在 URL 加上 /page/ 和頁碼就可以跳轉到下一頁,如 https://dynamic5.scrape.center/page/2 就是第二頁內容,https://dynamic5.scrape.center/page/3 就是第三頁內容。
那我們這個案例就來試著爬取前十頁的圖書信息吧。
實現
首先我們來新建一個項目,叫作 scrapypyppeteer,命令如下:
scrapy startproject scrapypyppeteer接著進入項目,然后新建一個 Spider,名稱為 book,命令如下:
cd scrapypyppeteer scrapy genspider book dynamic5.scrape.center這時候可以發現在項目的 spiders 文件夾下就出現了一個名為 spider.py 的文件,內容如下:
# -*- coding: utf-8 -*- import scrapy ? class BookSpider(scrapy.Spider):name = 'book'allowed_domains = ['dynamic5.scrape.center']start_urls = ['http://dynamic5.scrape.center/'] ?def parse(self, response):pass首先我們構造列表頁的初始請求,實現一個 start_requests 方法,如下所示:
# -*- coding: utf-8 -*- from scrapy import Request, Spiderclass BookSpider(Spider):name = 'book'allowed_domains = ['dynamic5.scrape.center']base_url = 'https://dynamic5.scrape.center/page/{page}'max_page = 10def start_requests(self):for page in range(1, self.max_page + 1):url = self.base_url.format(page=page)yield Request(url, callback=self.parse_index)def parse_index(self, response):print(response.text)這時如果我們直接運行這個 Spider,在 parse_index 方法里面打印輸出 Response 的內容,結果如下:
我們可以發現所得到的內容并不是頁面渲染后的真正 HTML 代碼。此時如果我們想要獲取 HTML 渲染結果的話就得使用 Downloader Middleware 實現了。
這里我們直接以一個我已經寫好的組件來演示了,組件的名稱叫作 GerapyPyppeteer,組件里已經寫好了 Scrapy 和 Pyppeteer 結合的中間件,下面我們來詳細介紹下。
我們可以借助于 pip3 來安裝組件,命令如下:
pip3 install gerapy-pyppeteerGerapyPyppeteer 提供了兩部分內容,一部分是 Downloader Middleware,一部分是 Request。
首先我們需要開啟中間件,在 settings 里面開啟 PyppeteerMiddleware,配置如下:
然后我們把上文定義的 Request 修改為 PyppeteerRequest 即可:
# -*- coding: utf-8 -*- from gerapy_pyppeteer import PyppeteerRequest from scrapy import Request, Spider ? class BookSpider(Spider):name = 'book'allowed_domains = ['dynamic5.scrape.center']base_url = 'https://dynamic5.scrape.center/page/{page}'max_page = 10def start_requests(self):for page in range(1, self.max_page + 1):url = self.base_url.format(page=page)yield PyppeteerRequest(url, callback=self.parse_index, wait_for='.item .name')def parse_index(self, response):print(response.text)這樣其實就完成了 Pyppeteer 的對接了,非常簡單。
這里 PyppeteerRequest 和原本的 Request 多提供了一個參數,就是 wait_for,通過這個參數我們可以指定 Pyppeteer 需要等待特定的內容加載出來才算結束,然后才返回對應的結果。
為了方便觀察效果,我們把并發限制修改得小一點,然后把 Pyppeteer 的 Headless 模式設置為 False:
CONCURRENT_REQUESTS = 3 GERAPY_PYPPETEER_HEADLESS = False這時我們重新運行 Spider,就可以看到在爬取的過程中,Pyppeteer 對應的 Chromium 瀏覽器就彈出來了,并逐個加載對應的頁面內容,加載完成之后瀏覽器關閉。
另外觀察下控制臺,我們發現對應的結果也就被提取出來了,如圖所示:
這時候我們再重新修改下 parse_index 方法,提取對應的每本書的名稱和作者即可:
重新運行,即可發現對應的名稱和作者就被提取出來了,運行結果如下:
這樣我們就借助 GerapyPyppeteer 完成了 JavaScript 渲染頁面的爬取。
原理分析
但上面僅僅是我們借助 GerapyPyppeteer 實現了 Scrapy 和 Pyppeteer 的對接,但其背后的原理是怎樣的呢?
我們可以詳細分析它的源碼,其 GitHub 地址為 https://github.com/Gerapy/GerapyPyppeteer。
首先通過分析可以發現其最核心的內容就是實現了一個 PyppeteerMiddleware,這是一個 Downloader Middleware,這里最主要的就是 process_request 的實現,核心代碼如下所示:
def process_request(self, request, spider):logger.debug('processing request %s', request) return as_deferred(self._process_request(request, spider))這里其實就是調用了一個 _process_request 方法,這個方法的返回結果被 as_deferred 方法調用了。
這個 as_deferred 是怎么定義的呢?代碼如下:
import asyncio from twisted.internet.defer import Deferred ? def as_deferred(f):return Deferred.fromFuture(asyncio.ensure_future(f))這個方法接收的就是一個 asyncio 庫的 Future 對象,然后通過 fromFuture 方法轉化成了 twisted 里面的 Deferred 對象。這是因為 Scrapy 本身的異步是借助 twisted 實現的,一個個的異步任務對應的就是一個個 Deferred 對象,而 Pyppeteer 又是基于 asyncio 的,它的異步任務是 Future 對象,所以這里我們需要借助 Deferred 的 fromFuture 方法將 Future 轉為 Deferred 對象。
另外為了支持這個功能,我們還需要在 Scrapy 中修改 reactor 對象,修改為 AsyncioSelectorReactor,實現如下:
import sys from twisted.internet.asyncioreactor import AsyncioSelectorReactor import twisted.internet ? reactor = AsyncioSelectorReactor(asyncio.get_event_loop()) ? # install AsyncioSelectorReactor twisted.internet.reactor = reactor sys.modules['twisted.internet.reactor'] = reactor這段代碼已經在 PyppeteerMiddleware 里面定義好了,在 Scrapy 正式開始爬取之前這段代碼就會被執行,將 Scrapy 中的 reactor 修改為 AsyncioSelectorReactor,從而實現 Future 的調度。
接下來我們再來看下 _process_request 方法,實現如下:
代碼內容比較多,我們慢慢來說。
首先最開始的部分是定義 Pyppeteer 的一些啟動參數:
options = {'headless': self.headless,'dumpio': self.dumpio,'devtools': self.devtools,'args': [f'--window-size={self.window_width},{self.window_height}',] } if self.executable_path: options['executable_path'] = self.executable_path if self.disable_extensions: options['args'].append('--disable-extensions') if self.hide_scrollbars: options['args'].append('--hide-scrollbars') if self.mute_audio: options['args'].append('--mute-audio') if self.no_sandbox: options['args'].append('--no-sandbox') if self.disable_setuid_sandbox: options['args'].append('--disable-setuid-sandbox') if self.disable_gpu: options['args'].append('--disable-gpu')這些參數來自 from_crawler 里面讀取項目 settings 的內容,如配置 Pyppeteer 對應瀏覽器的無頭模式、窗口大小、是否隱藏滾動條、是否棄用沙箱,等等。
緊接著就是利用 options 來啟動 Pyppeteer:
browser = await launch(options) page = await browser.newPage() await page.setViewport({'width': self.window_width, 'height': self.window_height})這里啟動了 Pyppeteer 對應的瀏覽器,將其賦值為 browser,然后新建了一個選項卡,賦值為 page,然后通過 setViewport 方法設定了窗口的寬高。
接下來就是對一些 Cookies 進行處理,如果 Request 帶有 Cookies 的話會被賦值到 Pyppeteer 中:
# set cookies if isinstance(request.cookies, dict):await page.setCookie(*[{'name': k, 'value': v}for k, v in request.cookies.items()]) else:await page.setCookie(request.cookies)再然后關鍵的步驟就是進行頁面的加載了:
try:options = {'timeout': 1000 * timeout,'waitUntil': request.wait_until}logger.debug('request %s with options %s', request.url, options)response = await page.goto(request.url,options=options) except (PageError, TimeoutError):logger.error('error rendering url %s using pyppeteer', request.url)await page.close()await browser.close()return self._retry(request, 504, spider)這里我們首先制定了加載超時時間 timeout 還有要等待完成的事件 waitUntil,接著調用 page 的 goto 方法訪問對應的頁面,同時進行了異常檢測,如果發生錯誤就關閉瀏覽器并重新發起一次重試請求。
在頁面加載出來之后,我們還需要判定我們期望的結果是不是加載出來了,所以這里又增加了 waitFor 的調用:
if request.wait_for:try:logger.debug('waiting for %s finished', request.wait_for)await page.waitFor(request.wait_for)except TimeoutError:logger.error('error waiting for %s of %s', request.wait_for, request.url)await page.close()await browser.close()return self._retry(request, 504, spider)這里 request 有個 wait_for 屬性,就可以定義想要加載的節點的選擇器,如 .item .name 等,這樣如果頁面在規定時間內加載出來就會繼續向下執行,否則就會觸發 TimeoutError 并被捕獲,關閉瀏覽器并重新發起一次重試請求。
等想要的結果加載出來之后,我們還可以執行一些自定義的 JavaScript 代碼完成我們想要自定義的功能:
# evaluate script if request.script:logger.debug('evaluating %s', request.script)await page.evaluate(request.script)最后關鍵的一步就是將當前頁面的源代碼打印出來,然后構造一個 HtmlResponse 返回即可:
content = await page.content() body = str.encode(content) ? # close page and browser logger.debug('close pyppeteer') await page.close() await browser.close() ? if not response:logger.error('get null response by pyppeteer of url %s', request.url) ? # Necessary to bypass the compression middleware (?) response.headers.pop('content-encoding', None) response.headers.pop('Content-Encoding', None) ? return HtmlResponse(page.url,status=response.status,headers=response.headers,body=body,encoding='utf-8',request=request )所以,如果代碼可以執行到最后,返回到就是一個 Response 對象,這個 Resposne 對象的 body 就是 Pyppeteer 渲染頁面后的結果,因此這個 Response 對象再傳給 Spider 解析,就是 JavaScript 渲染后的頁面結果了。
這樣我們就通過 Downloader Middleware 通過對接 Pyppeteer 完成 JavaScript 動態渲染頁面的抓取了。
總結
以上是生活随笔為你收集整理的第46讲:遇到动态页面怎么办?详解渲染页面爬取的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 第47讲:scrapy-redis分布式
- 下一篇: 第34讲:更好用的自动化工具 airte