Scrapy 对接 Selenium
之前我们都是使用 Scrapy 中的 Request 对象来发起请求的,其实这个 Request 发起的请求和 requests 是类似的,均是直接模拟 HTTP 请求。因此,如果一个网站的内容是由 JavaScript 渲染而成的,那么直接利用 Scrapy 的 Request 请求对应的 URL 是无法进行抓取的。
前面我们也讲到了,应对 JavaScript 渲染而成的网站主要有两种方式:一种是分析 Ajax 请求,找到其对应的接口抓取,用 Scrapy 同样可以实现;另一种是直接用 Selenium、Splash、Pyppeteer 等模拟浏览器进行抓取,在这种情况下,我们不需要关心页面后台发生的请求,也不需要分析渲染过程,关心页面的最终结果即可,可见即可爬。
所以,如果我们能够在 Scrapy 中实现 Selenium 的对接,就可以实现 JavaScript 渲染页面的爬取了,本节我们就来了解一下 Scrapy 对接 Selenium 的原理和实现。
本节目标
本节中我们来了解一下 Scrapy 框架如何通过对接 Selenium 来实现 JavaScript 染页面的爬取,爬取的目标网站为 https://spa5.scrape.center/ ,这是一个图书网站,展示了多本图书的信息,如图 15-14 所示。
(省略)
点击任意一个图书条自即可进人对应的详情页面,如图 15-15 所示。
(省略)
图 15-15 所示的信息是经过 Ajax 获取并通过 JavaScript 渲染出来的,我们要实现的就是使用 Scrapy 对接 Selenium,对图书详情进行爬取,包括名称、评分、标签等。
准备工作
本节开始之前请确保安装好 Scrapy 框架,另外还需要安装好 Selenium 库,这次 Selenium 库对应的浏览器依然还是 Chrome:请确保已经安装好了 Chrome 浏览器并配置好了 ChromeDriver,具体的安装过程可以参考: https://setup.scrape.center/selenium 。
准备工作完成之后,我们就可以开始本节的学习了。
对接原理
在实现之前,我们需要先了解 Scrapy 如何对接 Selenium,即对接的原理是什么。
我们已经了解了 Downloader Middleware 的用法,非常简单,实现 process_request,process_response、process_exception 中的任意一个方法即可,同时不同方法的返回值不同,其产生的效果也不同。
其中有一个知识点我们可以利用。在 process_request 方法中,当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_request 和 process_exception 方法不会被继续调用,每个 Downloader Middleware 的 process_response 方法转而被依次调用。调用完之后,直接将 Response 对象发送给 Spider 来处理。
那也就是说,如果我们实现一个 Downloader Middleware,在 process_request 方法中直接返回一个 Response 对象,那么 process_request 所接收的 Request 对象就不会再传给 Spider 处理了,而是经由 process_response 方法处理后交给 Spider,Spider 直接解析 Response 中的结果。
在 15.4 节中,我们其实已经演示了这个过程的实现和最终效果,在 process_request 方法中直接返回了一个 HtmlResponse 对象并被 Spider 接收、处理了。
所以,原理其实就很清楚了,我们可以自定义一个 Downloader Middleware 并实现 process_request 方法,在 process_request 中,我们可以直接获取 Request 对象的 URL,然后在 process_request 方法中完成使用 Selenium 请求 URL 的过程,获取 JavaScript 渲染后的 HTML 代码,最后把 HTML 代码构造为 HtmlResponse 返回即可。这样 HtmlResponse 就会被传给 Spider,Spider 拿到的结果就是 JavaScript 渲染后的结果了。
对接实战
首先新建项目,名为 scrapyseleniumdemo,命令如下所示:
scrapy startproject scrapyseleniumdemo
然后进入项目目录,新建一个 Spider,命令如下所示:
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 对象。
接着我们来实现一下主要的爬取逻辑,先定义初始的爬取请求,使用 start_requests 方法定义即可:
from scrapy import Request, Spider
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 Request(start_url, callback=self.parse_index)
这单我们就构造了列表页第一页的 URL,然后将其构造为 Request 对象并返回了,也就是说最开始爬取第一页的内容,爬取的结果会回调 parse_index 方法。
那么 parse_index 方法自然就要实现列表页的解析,得到详情页的一个个 URL,与此同时还要解析下一页列表页的 URL,逻辑比较清晰,代码实现如下:
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 Request(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 Request(next_url, callback=self.parse_index)
在 parse_index 方法中实现了两部分逻辑:第一部分逻辑是解析每本书对应的详情页 URL,然后构造新的 Request 并返回,将回调方法设置为 parse_detail,并设置优先级为 2;另一部分逻辑就是获取当前列表页的页码,然后将其加 1 构造下一页的 URL,构造新的 Request 并返回,将回调方法设置为 parse_index。
最后的逻辑就是 parse_detail 方法,即解析详情页提取最终结果的逻辑。我们需要在这个方法里实现提取书名、标签、评分、封面和价格的任务,然后构造 BookItem 并返回,代码实现如下:
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 的基本逻辑实现,但运行这个 Spider 是得不到任何爬取内容的。因为原网站的页面信息是经由 JavaScript 渲染出来的,所以单纯使用 Scrapy 的 Request 得到的 Response Body 并不是 JavaScript 渲染后的 HTML 代码。为了使得 Response Body 的结果都是 JavaScript 渲染后的 HTML 代码、我们需要像上文所说的,把 Selenium 对接进来。
我们需要定义一个 Downloader Middleware 并在 process_request 方法里实现 Selenium 的爬取,相关代码如下:
class SeleniumMiddleware(object):
def process_request(self, request, spider):
url = request.url
browser = webdriver.Chrome()
browser.get(url)
time.sleep(5)
html = browser.page_source
browser.close()
return HtmlResponse(url=request.url,
body=html,
request=request,
encoding='utf-8',
status=200)
这里完成了最基本的逻辑实现。在 process_request 方法中,我们首先获取了正在爬取的页面 URL;然后开启 Chrome 浏览器请求这个 URL,简单地加个固定的等待时间,获取最终的 HTML 代码;接着使用 HTML 代码构造 HtmlResponse 并返回。由于返回的是 HtmlResponse 对象,所以原本的 Request 就会被忽略了,这个 HtmlResponse 对象会被发送给 Spider 来解析,所以 Response 拿到的就是 Selenium 渲染后的结果了。
接下来我们还需要在 settings.py 里面做一些设置,开启这个 Downloader Middleware,同时禁用 robots.txt:
ROBOTSTXT_OBEY = False
DOWNLOADER_MIDDLEWARES = {
'scrapyseleniumdemo.middlewares.SeleniumMiddleware':543,
}
这样就成功开启了 SeleniumMiddleware,每次爬取 Scrapy 都会使用 Selenium 来渲染页面了。
然后我们运行一下 Spider,命令如下:
scrapy crawl book
在运行过程中,Chrome 浏览器就弹出来了,被爬取页面的 URL 会被浏览器染出来,最终 Spider 得到的 Response 就是 JavaScript 渲染后的结果了。
同时可以看到,控制台显示的运行结果如下:
省略
这样我们就成功对接 Selenium 实现了 JavaScript 渲染页面的爬取。
对接优化
细心的读者也许会发现,我们刚才实现的 SeleniumMiddleware 的功能太粗糙了,简单列举几点。
-
Chrome 初始化的时候没有指定任何参数,比如 headless,proxy 等,而且没有把参数可配置化。
-
没有实现异常处理,比如出现 TimeException 后如何进行重试。
-
加载过程简单指定了固定的等待时间,没有设置等待某一特定节点。
-
没有设置 Cookie、执行 JavaScript、截图等一系列扩展功能
-
整个爬取过程变成了阻塞式爬取,同一时刻只有一个页面能被爬取,爬取效率大大降低。
优化过程我就不一列举了,我写了一个 Python 包,对以上的 SeleniumMiddleware 做了一些优化:
-
Chrome 的初始化参数可配置,可以通过全局 settings 配置或 Request 对象配置。
-
实现了异常处理,出现了加载异常会按照 Scrapy 的重试逻辑进行重试。
-
加载过程可以指定特定节点进行等待,节点加载出来之后立即继续向下执行。
-
增加了设置 Cookie、执行 JavaScript,截图、代理设置等一系列功能并将参数可配置化。
-
将爬取过程改为非阻塞式,同一时刻支持多个浏览器同时加载,并可通过 CONCURRENT_REQUESTS 控制。
-
增加了 SeleniumRequest,定义 Request 更加方便而且支持多个扩展参数,
-
增加了 WebDriver 反屏蔽功能,将浏览器伪装成正常的浏览器防止被检测。
这个包叫作 GerapySelenium,安装方式如下:
pip3 install gerapy-selenium
安装之后我们只需要启用对应的 Downloader Middleware 并改写 Request 为 SeleniumRequest 即可:
DOWNLOADER_MIDDLEWARES = {
'gerapy_selenium.downloadermiddlewares.SeleniumMiddleware': 543,
}
另外我们还可以控制爬取时并发的 Request 数量,比如:
CONCURRENT_REQUESTS = 6
这里我们将并发量修改为了 6,这样在爬取过程中就会同时使用 Chrome 渲染 6 个页面了,如果你的电脑性能比较不错的话,可以将这个数字调得更大一些。
在 Spider 中,我们还需要修改 Request 为 SeleniumRequest,同时还可以增加一些其他的配置,比如通过 wait_for 来等待某一特定节点加载出来,比如原来的:
yield Request(start_url, callback=self.parse_index)
就可以修改为:
yield SeleniumRequest(start_url, callback=self.parse_index, wait_for='.item .name')
其他两处进行同样的修改即可。重新运行 Spider:
scrapy crawl book
可以看到这次浏览器没有再弹出来了,这是因为在默认情况下,GerapySelenium 启用了 Chrome 的 Headless 模式:同时可以看到控制台也有对应的输出结果,爬取速度相比之前有成倍提高:运行结果如下:
省略
这样我们就使用 GerapySelenium 提供的 Downloader Middleware 实现了 Scrapy 与 Selenium 的对接,非常方便。我们也不需要再去自定义 Downloader Middleware 了,同时爬取效率有成倍提升,实现了参数可配置化。
另外,GerapySelenium 还提供了很多其他实用配置。
-
关闭 Headless 模式
将 GERAPY_SELENIUM_HEADLESS 设置为 False 即可,settings.py 增加如下代码:
GERAPY_SELENIUM_HEADLESS = True
-
忽略 HTTPS 错误
将 GERAPY_SELENIUM_IGNORE_HTTPS_ERRORS 设置为 True 即可,settings.py 增加如下代码:
GERAPY_SELENIUM_IGNORE_HTTPS_ERRORS = True
-
开启 WebDriver 反屏蔽功能
该功能默认是开启的,就是将当前浏览器伪装成正常的浏览器,隐藏 WebDriver 的一些特征,如需关闭,则给 settings.py 增加如下代码:
GERAPY_SELENIUM_PRETEND = False
-
设置加载超时时间
将 GERAPY_SELENIUM_DOWNLOAD_TIMEOUT 设置为默认的秒数,默认等待 30 秒,例如设置超时 60 秒 settings.py 增加如下代码:
GERAPY_SELENIUM_DOWNLOAD_TIMEOUT = 60
-
设置代理
设置代理可以借助于 seleniumRequest,设置 proxy 参数即可,例如:
yield SeleniumRequest(start_url, callback=self.parse_index, wait_for='.item .name', proxy='127.0.0.1:7890')
更多用法可以直接参考 GerapySelenium 的 GitHub 仓库地止: https://github.com/Gerapy/GerapySelenium