第46讲:遇到动态页面怎么办?详解渲染页面爬取
前面我們已經(jīng)介紹了 Scrapy 的一些常見用法,包括服務(wù)端渲染頁(yè)面的抓取和 API 的抓取,Scrapy 發(fā)起 Request 之后,返回的 Response 里面就包含了想要的結(jié)果。
但是現(xiàn)在越來越多的網(wǎng)頁(yè)都已經(jīng)演變?yōu)?SPA 頁(yè)面,其頁(yè)面在瀏覽器中呈現(xiàn)的結(jié)果是經(jīng)過 JavaScript 渲染得到的,如果我們使用 Scrapy 直接對(duì)其進(jìn)行抓取的話,其結(jié)果和使用 requests 沒有什么區(qū)別。
那我們真的要使用 Scrapy 完成對(duì) JavaScript 渲染頁(yè)面的抓取應(yīng)該怎么辦呢?
之前我們介紹了 Selenium 和 Pyppeteer 都可以實(shí)現(xiàn) JavaScript 渲染頁(yè)面的抓取,那用了 Scrapy 之后應(yīng)該這么辦呢?Scrapy 能和 Selenium 或 Pyppeteer 一起使用嗎?答案是肯定的,我們可以將 Selenium 或 Pyppeteer 通過 Downloader Middleware 和 Scrapy 融合起來,實(shí)現(xiàn) JavaScript 渲染頁(yè)面的抓取,本節(jié)我們就來了解下它的實(shí)現(xiàn)吧。
回顧
在前面我們介紹了 Downloader Middleware 的用法,在 Downloader Middleware 中有三個(gè)我們可以實(shí)現(xiàn)的方法 process_request、process_response 以及 process_exception 方法。
-
我們?cè)倏聪?process_request 方法和其不同的返回值的效果:
當(dāng)返回為 None 時(shí),Scrapy 將繼續(xù)處理該 Request,接著執(zhí)行其他 Downloader Middleware 的 process_request 方法,一直到 Downloader 把 Request 執(zhí)行完后得到 Response 才結(jié)束。這個(gè)過程其實(shí)就是修改 Request 的過程,不同的 Downloader Middleware 按照設(shè)置的優(yōu)先級(jí)順序依次對(duì) Request 進(jìn)行修改,最后送至 Downloader 執(zhí)行。 -
當(dāng)返回為 Response 對(duì)象時(shí),更低優(yōu)先級(jí)的 Downloader Middleware 的 process_request 和 process_exception 方法就不會(huì)被繼續(xù)調(diào)用,每個(gè) Downloader Middleware 的 process_response 方法轉(zhuǎn)而被依次調(diào)用。調(diào)用完畢之后,直接將 Response 對(duì)象發(fā)送給 Spider 來處理。
-
當(dāng)返回為 Request 對(duì)象時(shí),更低優(yōu)先級(jí)的 Downloader Middleware 的 process_request 方法會(huì)停止執(zhí)行。這個(gè) Request 會(huì)重新放到調(diào)度隊(duì)列里,其實(shí)它就是一個(gè)全新的 Request,等待被調(diào)度。如果被 Scheduler 調(diào)度了,那么所有的 Downloader Middleware 的 process_request 方法都會(huì)被重新按照順序執(zhí)行。
-
如果 IgnoreRequest 異常拋出,則所有的 Downloader Middleware 的 process_exception 方法會(huì)依次執(zhí)行。如果沒有一個(gè)方法處理這個(gè)異常,那么 Request 的 errorback 方法就會(huì)回調(diào)。如果該異常還沒有被處理,那么它便會(huì)被忽略。
這里我們注意到第二個(gè)選項(xiàng),當(dāng)返回結(jié)果為 Response 對(duì)象時(shí),低優(yōu)先級(jí)的 process_request 方法就不會(huì)被繼續(xù)調(diào)用了,這個(gè) Response 對(duì)象會(huì)直接經(jīng)由 process_response 方法處理后轉(zhuǎn)交給 Spider 來解析。
然后再接著想一想,process_request 接收的參數(shù)是 request,即 Request 對(duì)象,怎么會(huì)返回 Response 對(duì)象呢?原因可想而知了,這個(gè) Request 對(duì)象不再經(jīng)由 Scrapy 的 Downloader 來處理了,而是在 process_request 方法里面直接就完成了 Request 的發(fā)送操作,然后在得到了對(duì)應(yīng)的 Response 結(jié)果后再將其返回就好了。
那么對(duì)于 JavaScript 渲染的頁(yè)面來說,照這個(gè)方法來做,我們就可以把 Selenium 或 Pyppeteer 加載頁(yè)面的過程在 process_request 方法里面實(shí)現(xiàn),得到網(wǎng)頁(yè)渲染完后的源代碼后直接構(gòu)造 Response 返回即可,這樣我們就完成了借助 Downloader Middleware 實(shí)現(xiàn) Scrapy 爬取動(dòng)態(tài)渲染頁(yè)面的過程。
案例
本節(jié)我們就用實(shí)例來講解一下 Scrapy 和 Pyppeteer 實(shí)現(xiàn) JavaScript 渲染頁(yè)面抓取的流程。
本節(jié)使用的實(shí)例網(wǎng)站為 https://dynamic5.scrape.center/,這是一個(gè) JavaScript 渲染頁(yè)面,其內(nèi)容是一本本的圖書信息。
同時(shí)這個(gè)網(wǎng)站的頁(yè)面帶有分頁(yè)功能,只需要在 URL 加上 /page/ 和頁(yè)碼就可以跳轉(zhuǎn)到下一頁(yè),如 https://dynamic5.scrape.center/page/2 就是第二頁(yè)內(nèi)容,https://dynamic5.scrape.center/page/3 就是第三頁(yè)內(nèi)容。
那我們這個(gè)案例就來試著爬取前十頁(yè)的圖書信息吧。
實(shí)現(xiàn)
首先我們來新建一個(gè)項(xiàng)目,叫作 scrapypyppeteer,命令如下:
scrapy startproject scrapypyppeteer接著進(jìn)入項(xiàng)目,然后新建一個(gè) Spider,名稱為 book,命令如下:
cd scrapypyppeteer scrapy genspider book dynamic5.scrape.center這時(shí)候可以發(fā)現(xiàn)在項(xiàng)目的 spiders 文件夾下就出現(xiàn)了一個(gè)名為 spider.py 的文件,內(nèi)容如下:
# -*- 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首先我們構(gòu)造列表頁(yè)的初始請(qǐng)求,實(shí)現(xiàn)一個(gè) 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)這時(shí)如果我們直接運(yùn)行這個(gè) Spider,在 parse_index 方法里面打印輸出 Response 的內(nèi)容,結(jié)果如下:
我們可以發(fā)現(xiàn)所得到的內(nèi)容并不是頁(yè)面渲染后的真正 HTML 代碼。此時(shí)如果我們想要獲取 HTML 渲染結(jié)果的話就得使用 Downloader Middleware 實(shí)現(xiàn)了。
這里我們直接以一個(gè)我已經(jīng)寫好的組件來演示了,組件的名稱叫作 GerapyPyppeteer,組件里已經(jīng)寫好了 Scrapy 和 Pyppeteer 結(jié)合的中間件,下面我們來詳細(xì)介紹下。
我們可以借助于 pip3 來安裝組件,命令如下:
pip3 install gerapy-pyppeteerGerapyPyppeteer 提供了兩部分內(nèi)容,一部分是 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)這樣其實(shí)就完成了 Pyppeteer 的對(duì)接了,非常簡(jiǎn)單。
這里 PyppeteerRequest 和原本的 Request 多提供了一個(gè)參數(shù),就是 wait_for,通過這個(gè)參數(shù)我們可以指定 Pyppeteer 需要等待特定的內(nèi)容加載出來才算結(jié)束,然后才返回對(duì)應(yīng)的結(jié)果。
為了方便觀察效果,我們把并發(fā)限制修改得小一點(diǎn),然后把 Pyppeteer 的 Headless 模式設(shè)置為 False:
CONCURRENT_REQUESTS = 3 GERAPY_PYPPETEER_HEADLESS = False這時(shí)我們重新運(yùn)行 Spider,就可以看到在爬取的過程中,Pyppeteer 對(duì)應(yīng)的 Chromium 瀏覽器就彈出來了,并逐個(gè)加載對(duì)應(yīng)的頁(yè)面內(nèi)容,加載完成之后瀏覽器關(guān)閉。
另外觀察下控制臺(tái),我們發(fā)現(xiàn)對(duì)應(yīng)的結(jié)果也就被提取出來了,如圖所示:
這時(shí)候我們?cè)僦匦滦薷南?parse_index 方法,提取對(duì)應(yīng)的每本書的名稱和作者即可:
重新運(yùn)行,即可發(fā)現(xiàn)對(duì)應(yīng)的名稱和作者就被提取出來了,運(yùn)行結(jié)果如下:
這樣我們就借助 GerapyPyppeteer 完成了 JavaScript 渲染頁(yè)面的爬取。
原理分析
但上面僅僅是我們借助 GerapyPyppeteer 實(shí)現(xiàn)了 Scrapy 和 Pyppeteer 的對(duì)接,但其背后的原理是怎樣的呢?
我們可以詳細(xì)分析它的源碼,其 GitHub 地址為 https://github.com/Gerapy/GerapyPyppeteer。
首先通過分析可以發(fā)現(xiàn)其最核心的內(nèi)容就是實(shí)現(xiàn)了一個(gè) PyppeteerMiddleware,這是一個(gè) Downloader Middleware,這里最主要的就是 process_request 的實(shí)現(xiàn),核心代碼如下所示:
def process_request(self, request, spider):logger.debug('processing request %s', request) return as_deferred(self._process_request(request, spider))這里其實(shí)就是調(diào)用了一個(gè) _process_request 方法,這個(gè)方法的返回結(jié)果被 as_deferred 方法調(diào)用了。
這個(gè) as_deferred 是怎么定義的呢?代碼如下:
import asyncio from twisted.internet.defer import Deferred ? def as_deferred(f):return Deferred.fromFuture(asyncio.ensure_future(f))這個(gè)方法接收的就是一個(gè) asyncio 庫(kù)的 Future 對(duì)象,然后通過 fromFuture 方法轉(zhuǎn)化成了 twisted 里面的 Deferred 對(duì)象。這是因?yàn)?Scrapy 本身的異步是借助 twisted 實(shí)現(xiàn)的,一個(gè)個(gè)的異步任務(wù)對(duì)應(yīng)的就是一個(gè)個(gè) Deferred 對(duì)象,而 Pyppeteer 又是基于 asyncio 的,它的異步任務(wù)是 Future 對(duì)象,所以這里我們需要借助 Deferred 的 fromFuture 方法將 Future 轉(zhuǎn)為 Deferred 對(duì)象。
另外為了支持這個(gè)功能,我們還需要在 Scrapy 中修改 reactor 對(duì)象,修改為 AsyncioSelectorReactor,實(shí)現(xiàn)如下:
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這段代碼已經(jīng)在 PyppeteerMiddleware 里面定義好了,在 Scrapy 正式開始爬取之前這段代碼就會(huì)被執(zhí)行,將 Scrapy 中的 reactor 修改為 AsyncioSelectorReactor,從而實(shí)現(xiàn) Future 的調(diào)度。
接下來我們?cè)賮砜聪?_process_request 方法,實(shí)現(xiàn)如下:
代碼內(nèi)容比較多,我們慢慢來說。
首先最開始的部分是定義 Pyppeteer 的一些啟動(dòng)參數(shù):
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')這些參數(shù)來自 from_crawler 里面讀取項(xiàng)目 settings 的內(nèi)容,如配置 Pyppeteer 對(duì)應(yīng)瀏覽器的無頭模式、窗口大小、是否隱藏滾動(dòng)條、是否棄用沙箱,等等。
緊接著就是利用 options 來啟動(dòng) Pyppeteer:
browser = await launch(options) page = await browser.newPage() await page.setViewport({'width': self.window_width, 'height': self.window_height})這里啟動(dòng)了 Pyppeteer 對(duì)應(yīng)的瀏覽器,將其賦值為 browser,然后新建了一個(gè)選項(xiàng)卡,賦值為 page,然后通過 setViewport 方法設(shè)定了窗口的寬高。
接下來就是對(duì)一些 Cookies 進(jìn)行處理,如果 Request 帶有 Cookies 的話會(huì)被賦值到 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)再然后關(guān)鍵的步驟就是進(jìn)行頁(yè)面的加載了:
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)這里我們首先制定了加載超時(shí)時(shí)間 timeout 還有要等待完成的事件 waitUntil,接著調(diào)用 page 的 goto 方法訪問對(duì)應(yīng)的頁(yè)面,同時(shí)進(jìn)行了異常檢測(cè),如果發(fā)生錯(cuò)誤就關(guān)閉瀏覽器并重新發(fā)起一次重試請(qǐng)求。
在頁(yè)面加載出來之后,我們還需要判定我們期望的結(jié)果是不是加載出來了,所以這里又增加了 waitFor 的調(diào)用:
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 有個(gè) wait_for 屬性,就可以定義想要加載的節(jié)點(diǎn)的選擇器,如 .item .name 等,這樣如果頁(yè)面在規(guī)定時(shí)間內(nèi)加載出來就會(huì)繼續(xù)向下執(zhí)行,否則就會(huì)觸發(fā) TimeoutError 并被捕獲,關(guān)閉瀏覽器并重新發(fā)起一次重試請(qǐng)求。
等想要的結(jié)果加載出來之后,我們還可以執(zhí)行一些自定義的 JavaScript 代碼完成我們想要自定義的功能:
# evaluate script if request.script:logger.debug('evaluating %s', request.script)await page.evaluate(request.script)最后關(guān)鍵的一步就是將當(dāng)前頁(yè)面的源代碼打印出來,然后構(gòu)造一個(gè) 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 )所以,如果代碼可以執(zhí)行到最后,返回到就是一個(gè) Response 對(duì)象,這個(gè) Resposne 對(duì)象的 body 就是 Pyppeteer 渲染頁(yè)面后的結(jié)果,因此這個(gè) Response 對(duì)象再傳給 Spider 解析,就是 JavaScript 渲染后的頁(yè)面結(jié)果了。
這樣我們就通過 Downloader Middleware 通過對(duì)接 Pyppeteer 完成 JavaScript 動(dòng)態(tài)渲染頁(yè)面的抓取了。
總結(jié)
以上是生活随笔為你收集整理的第46讲:遇到动态页面怎么办?详解渲染页面爬取的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 第47讲:scrapy-redis分布式
- 下一篇: 第34讲:更好用的自动化工具 airte