Scrapy 对接 Pyppeteer
前面两节我们了解了 Scrapy 对接 Selenium 和 Splash 的流程,还差一个 Pyppeteer,本节我们来了解 Scrapy 和 Pyppeteer 的对接方式。
爬取目标
本节的爬取目标与 15.8 节和 15.9 节是一样的,这里不展开介绍了:本节我们改为用 Scrapy 和 Pyppeteer 实现。
Scrapy 对接 Pyppeteer 的流程和原理与对接 Selenium 的流程基本一致,同样借助于 Downloader Middleware 来实现。最大的不同是,Pyppeteer 需要基于 asyncio 异步执行,这就需要我们用到 Scrapy 对 asyncio 的支持。
准备工作
在本节开始之前,请确保已经安装好了 Scrapy,这里要求 Scrapy 的版本不能低于 2.0,2.0 版本以下的 Scrapy 是不支持 asyncio 的。另外还需要安装好 Pyppeteer 并能正常启动。如尚未安装可以参考 https://setup.scrape.center/pyppeteer 里面的介绍。
对接原理
在实现之前,我们还是来了解一下 Scrapy 对接 Pyppeteer 的原理。
在前面我们已经了解了 Scrapy 对接 Selenium 的实现方式,Scrapy 和 Pyppeteer 的对接方式和它是基本一致的。我们可以自定义一个 Downloader Middleware 并实现 process_request 方法,在 process_request 中直接获取 Request 对象的 URL,然后在 process_request 方法中完成使用 Pyppeteer 请求 URL 的过程,获取 JavaScript 渲染后的 HTML 代码,最后把 HTML 代码构造为 HtmlResponse 返回。这样,HtmlResponse 就会被传给 Spider,Spider 拿到的结果就是 JavaScript 渲染后的结果了。
这里唯一不太一样的是,Pyppeteer 需要借助 asyncio 实现异步爬取,也就是说调用的必须是 async 修饰的方法。虽然 Scrapy 也支持异步,但其异步是基于 Twisted 实现的,二者怎么实现兼容呢?Scrapy 开发团队为此做了很多工作,从 Scrapy 2.0 版本开始,Scrapy 已经可以支持 asyncio 了。我们知道,Twisted 的异步对象叫作 Deffered,而 asyncio 里面的异步对象叫作 Future,其支持的原理就是实现了 Future 到 Deffered 的转换,代码如下:
import asyncio
from twisted.internet.defer import Deferred
def as_deferred(f):
return Deferred.fromFuture(asyncio.ensure_future(f))
Scrapy 提供了一个 fromFuture 方法,它可以接收一个 Future 对象,返回一个 Deffered 对象,另外还需要更换 Twisted 的 Reactor 对象,在 Scrapy 的 settings.py 中需要添加如下代码:
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
这样便可以实现 Scrapy 对 Future 的异步执行,从而实现 Scrapy 对 asyncio 的支持。
好,以上便是基本的原理,下面让我们门来动手实现一下上面的流程吧。
对接实现
首先我们新建一个项目,叫作 scrapypyppeteerdemo,命令如下:
scrapy startproject scrapypyppeteerdemo
接着进入项目,然后新建一个 Spider,名称为 book,命令如下:
scrapy genspider book spa5.scrape.center
同样地,首先定义 Item 对象,名称为 BookItem,代码如下所示:
from scrapy.item import Item, Field
class BookItem(Item):
name = Field()
tags = Field()
score = Field()
cover = Field()
price = Field()
这里我们定义了 5 个 Field,分别代表书名,标签、评分、封面、价格,我们要爬取的结果会赋值为一个个 BookItem 对象。
接着我们定义主要的爬取逻辑,包括初始请求,解析列表页,解析详情页:整个流程和对接 Selenium 的流程基本是一致的。
初始请求 start_requests 的代码定义如下:
import logging
import re
from scrapy import Request,Spider
from scrapypyppeteerdemo.items import BookItem
class BookSpider(Spider):
name = 'book'
allowed_domains = ['spa5.scrape.center']
base_url = 'https://spa5.scrape.center'
def start_requests(self):
start_url = f'{self.base_url}/page/1'
yield PyppeteerRequest(start_url, callback=self.parse_index)
其实就是在 start_requests 方法里面构造了第一页的爬取请求并返回,回调方法指定为 parse_index。parse_index 方法自然就是实现列表页的解析,得到详情页的一个个 URL。与此同时还要解析下一页的 URL,逻辑和 Selenium 一节也是一样的,代码实现如下:
import re
def parse_index(self, response):
items = response.css('.item')
for item in items:
href = item.css('.top a::attr(href)').extract_first()
detail_url = response.urljoin(href)
yield PyppeteerRequest(detail_url, callback=self.parse_detail, priority=2)
# next page
match = re.search(r'page/(\d+)', response.url)
if not match: return
page = int(match.group(1)) + 1
next_url = f'{self.base_url}/page/{page}'
yield PyppeteerRequest(next_url, callback=self.parse_index)
在 parse_index 方法中我们实现了两部分逻辑。第一部分逻辑是解析每一本书对应的详情页 URL,然后构造新的 Request 并返回:将回调方法设置为 parse_detail 方法,并设置优先级为 2:另一部分逻辑就是获取当前列表页的页码,然后将其加 1,构造下一页的 URL,构造新的 Request 并返回:将回调方法设置为 parse_index 方法。
那最后的逻辑就是 parse_detail 方法,即解析详情页提取最终结果的逻辑了,这个方法里面我们需要将书名、标签,评分,封面,价格都提取出来,然后构造 BookItem 并返回,整个过程和对接 Selenium 一节也是一样的,代码实现如下:
def parse_detail(self, response):
name = response.css('.name::text').extract_first()
tags = response.css('.tags button span::text').extract()
score = response.css('.score::text').extract_first()
price = response.css('.price span::text').extract_first()
cover = response.css('.cover::attr(src)').extract_first()
tags = [tag.strip() for tag in tags] if tags else []
score = score.strip() if score else None
item = BookItem(name=name, tags=tags, score=score, price=price, cover=cover)
yield item
这样一来,每爬取一个详情页,就会生成一个 BookItem 对象并返回。
同样地,现在我们只是完成了 Spider 的主逻辑,现在运行同样是得不到任何爬取结果的,因为当前 Response 里面包含的并不是 JavaScript 渲染页面后的 HTML 代码。
所以下面至关重要的就是利用 Downloader Middleware 实现与 Pyppeteer 的对接。
我们新建一个 PyppeteerMiddleware,实现下:
from pyppeteer import launch
from scrapy.http import HtmlResponse
import asyncio
import logging
from twisted.internet.defer import Deferred
logging.getLogger('websockets').setLevel('INFO')
logging.getLogger('pyppeteer').setLevel('INFO')
def as_deferred(f):
return Deferred.fromFuture(asyncio.ensure_future(f))
class PyppeteerMiddleware(object):
async def _process_request(self, request, spider):
browser = await launch(headless=False)
page = await browser.newPage()
pyppeteer_response = await page.goto(request.url)
await asyncio.sleep(5)
html = await page.content()
pyppeteer_response.headers.pop('content-encoding', None)
pyppeteer_response.headers.pop('Content-Encoding', None)
response = HtmlResponse(
page.url,
status=pyppeteer_response.status,
headers=pyppeteer_response.headers,
body=str.encode(html),
encoding='utf-8',
request=request
)
await page.close()
await browser.close()
return response
def process_request(self, request, spider):
return as_deferred(self._process_request(request, spider))
首先我们声明了 Pyppeteer 的日志级别,防止控制台输出过多的日志。然后我们声明了一个 as_deferred 方法,如上文所述,它可以将 Future 对象转化为 Deffered 对象。接着在 process_request 方法中,我们调用了 as_deferred 方法,它的参数是 _process_request 方法返回的 Future 对象,该 Future 对象会被转换为 Deffered 对象。_process_request 方法中实现了 Scrapy 对接 Pyppeteer 的核心逻辑,主要流程就是获取 Request 对象的 URL,然后使用 Pyppeteer 把它打开,将最终的渲染结果构造一个 HtmlResponse 对象并返回,这里我们将 Pyppeteer 的 headless 参数设置为了 False,以便观察爬取效果。
定义好 PyppeteerMiddleware 之后,我们还需要在 settings.py 里面增加一些配置。
第一个至关重要的就是更换 Twister 的 Reactor 对象,在 settings.py 中增加如下定义:
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
接着我们可以再定义一下并发数,Downloader Middleware 的配置和其他配置,settings.py 配置如下:
ROBOTSTXT_OBEY = False
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
# Configure maximum concurrent requests performed by Scrapy (default: 16)
CONCURRENT_REQUESTS = 3
以上我们初步完成了 Scrapy 和 Pyppeteer 的对接流程。
下面我们运行一下 Spider,命令如下:
scrapy crawl book
可以看到 Spider 在的运行过程中,与 Pyppeteer 对应的 Chromium 浏览器弹出来并加载了对应的页面,控制台输出如下:
省略
我们爬取到了渲染后的页面,至此我们就借助 Pyppeteer 实现了 Scrapy 对 JavaScript 渲染页面的爬取。
对接优化
同样,我们刚才实现的 PyppeteerMiddleware 功能也是比较粗糙的,简单列举几点。
-
Pyppeteer 初始化的时候仅指定了 headless 参数,还有很多配置项并不支持自定义配置。
-
没有实现异常处理,比如出现 PageError 或 TimeoutError 如何进行重试。
-
加载过程简单指定了固定等待时间,没有设置等待某一特定节点。
-
没有设置 Cookie、执行 JavaScript、截图等一系列扩展功能。
为了解决这些问题,我写了一个 Python 包,对以上的 PyppeteerMiddleware 做了一些优化。
-
Pyppeteer 的初始化参数可配置,可以通过全局 settings 或 Request 对象进行配置。
-
实现了异常处理,出现加载异常会按照 Scrapy 的重试逻辑进行重试。
-
加载过程可以指定在特定节点处进行等待:节点加载出来立即继续向下执行。
-
增加了设置 Cookie,执行 JavaScript、截图、代理设置等一系列功能并将参数可配置化。
-
增加了 PyppeteerRequest,定义 Request 更加方便而且支持多个扩展参数。
-
增加了 WebDriver 反屏蔽功能,将树览器伪装成正常的浏览器防止被检测。
-
增加了对 Twister 的 Reactor 对象的设置,不用额外在 settings.py 里面声明 TWISTED_REACTOR 。
这个功能和 GerapySelenium 的功能基本是一样的,这个包名叫作 GerapyPyppeteer,我们可以借助 pip3 来安装,命令如下:
pip3 install gerapy-pyppeteer
同样地,GerapyPyppeteer 提供了两部分内容,一部分是 Downloader Middleware,一部分是 Request 。
首先我们需要开启中间件,在 settings 里面开启 PyppeteerMiddleware,配置如下:
DOWNLOADER_MIDDLEWARES = {
'gerapy_pyppeteer,downloadermiddlewares.PyppeteerMiddleware': 543,
}
定义了 PyppeteerMiddleware 之后,我们无须额外声明 TWISTED_REACTOR,可以把刚才 TWISTED_REACTOR 的定义去掉。
然后我们把上文定义的 Request 修改为 PyppeteerRequest 即可:
class BookSpider(Spider):
name = 'book'
allowed_domains = ['spa5.scrape.center']
base_url = 'https://spa5.scrape.center'
def start_requests(self):
start_url = f'{self.base_url}/page/1'
yield PyppeteerRequest(start_url, callback=self.parse_index, wait_for='.item .name')
def parse_index(self, response):
items = response.css('.item')
for item in items:
href = item.css('.top a::attr(href)').extract_first()
detail_url = response.urljoin(href)
yield PyppeteerRequest(detail_url, callback=self.parse_detail, priority=2, wait_for='.item .name')
# next page
match = re.search(r'page/(\d+)', response.url)
if not match: return
page = int(match.group(1)) + 1
next_url = f'{self.base_url}/page/{page}'
yield PyppeteerRequest(next_url, callback=self.parse_index, wait_for='.item .name')
这样其实就完成了 Pyppeteer 的对接了,非常简单。
这里 PyppeteerRequest 和原本的 Request 多提供了一个参数:wait_for。通过这个参数我们可以指定 Pyppeteer 需要等待特定的内容加载出来才算结束,然后返回对应的结果。
为了方便观察效果,我们把并发限制修改得小一点,然后把 Pyppeteer 的 Headless 模式设置为 False,在 settings.py 中进行如下配置:
CONCURRENT_REQUESTS = 3
GERAPY_PYPPETEER_HEADLESS = False
这时候我们重新运行下 Spider,就可以看到在爬取的过程中,Pyppeteer 对应的 Chromium 浏览器弹出来了,并且逐个加载对应的页面内容,加载完成之后浏览器关闭。
控制台输出如下:
省略
这样我们就借助 GerapyPyppeteer 完成了 JavaScript 渲染页面的爬取。
另外 PyppeteerMiddleware 还提供了很多配置项,下面我们来展开说一下。
-
开启 WebDriver 反屏蔽功能
该功能默认是开启的,就是将当前浏览器伪装成正常的浏览器,隐藏 WebDriver 的一些特征,如需关闭,在 settings.py 中增加如下代码:
GERAPY_PYPPETEER_PRETEND = False
-
开启 Headless 模式
在默认情况下,Headless 模式是开启的,刚才我们将 GERAPY_PYPPETEER_HEADLESS 配置为 False 取消了 Headless 模式,如果想开启可以将其配置为 True,或者不执行任何配置:
GERAPY_PYPPETEER_HEADLESS = True
-
超时时间
我们可以设置 Pyppeteer 加载所需的超时时间,单位为秒。如果该时间内页面没有加载出来或者 PyppteeerRequest 指定的等待目标没有加载出来,就会触发超时,默认情况下会进行重试爬取,超时时间配置如下:
GERAPY_PYPPETEER_DOWNLOAD_TIMEOUT = 30
-
窗口大小
我们可以设置 Pyppeteer 的窗口大小,例如:
GERAPY_PYPPETEER_WINDOW_HEIGHT = 700
GERAPY_PYPPETEER_WINDOW_WIDTH = 14O0
-
Pyppeteer 启动参数
Pyppetter 在启动时可以配置多个参数,如 devtools、dumpio 等,这些参数在 GerapyPyppeteer 中也得到了支持,可以直接进行如下配置:
-
GERAPY_PYPPETEER_DUMPIO = False
-
GERAPY_PYPPETEER_DEVTOOLS = False
-
GERAPY_PYPPETEER_EXECUTABLE_PATH = None
-
GERAPY_PYPPETEER_DISABLE_EXTENSIONS = True
-
GERAPY_PYPPETEER_HIDE_SCROLLBARS = True
-
GERAPY_PYPPETEER_MUTE_AUDIO = True
-
GERAPY_PYPPETEER_NO_SANDBOX = True
-
GERAPY_PYPPETEER_DISABLE_SETUID_SANDBOX = True
-
GERAPY_PYPPETEER_DISABLE_GPU = True
这里的一些配置和 Pyppetter 的启动参数是一一对应的,具体可以参考 Pyppeteer 的官方文档: https://pyppeteer.github.io/pyppeteer/reference.html#launcher
-
忽略加载资源类型
Pyppteer 可以自定义忽略特定的资源类型的加载,比如忽略图片文件、字体文件的加载,这样做可以大大提高爬取效率,常见类型如下:
-
document:HTML 文档。
-
stylesheet:CSS 文件。
-
script:JavaScript 文件。
-
image:图片。
-
media:媒体文件,如音频、视频。
-
font:字体文件。
-
texttrack:字幕文件。
-
xhr:Ajax 请求。
-
fetch:Fetch 请求。
-
eventsource:事件源。
-
websocket:WebSocket 请求。
-
manifest:Manifest 文件。
-
other:其他。
比如我们想要在爬取过程中忽略图片,字体文件的加载,可以进行如下配置:
CERAPY_PYPPETEER_IGNORE_RESOURCE_TYPES['image','font']
默认情况下是留空的,即加载所有内容。
GerapyPyppeteer 提供了截图功能,其参数可以在 PyppeteerRequest 的 screenshot 中定义,格式和 Pyppeteer 的 screenshot 的参数一致,可以参考官方文档: https://pyppeteer.github.io/pyppeteer/reference.html#pyppeteer.page.Page.screenshot
例如我们可以在 PyppeteerReguest 中增加 screenshot 参数,配置如下:
yieid PyppeteerRequest(start_url,callback=self.parse_index,wait_for='.item.name', screenshot={
'type': 'png',
'fullPage': True
})
然后对应的 Response 对象的 meta 属性里面便会多了一个 screenshot 属性,比如在回调方法里面便可以使用下面的方法将截图保存为文件:
def parse_index(self, response):
with open('screenshot.png','wb') as f:
f.write(response.meta['screenshot'].getbuffer())
以上我们便介绍了 GerapyPyppeteer 的基本用法,通过 GerapyPyppeteer 我们可以更方便地实现 Scrapy 和 Pyppeteer 的对接,更多的用法可以参考 GerapyPyppeteer 的仓库地址: https://github.com/Gerapy/GerapyPyppeteer 。