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

总结

本节我们介绍了 Scrapy 和 Selenium 的对接解决方案,有了这个方案,Scrapy 爬取 JavaScript 渲染的页面不再是难事了。