第15章 Scrapy框架的使用

前面的章节给大家展示了很多案例,其中大多实现了爬虫的整个流程,将不同的功能定义成不同 的方法,甚至抽象出模块的概念。比如在9.5节,我们已经有了爬虫框架的雏形,实现了调度器、队 列、请求对象、异常重试机制等,如果我们将各个组件独立出来,把它们定义成不同的模块,其实也 就慢慢形成了一个框架。有了框架之后,我们就不必关心爬虫的流程了,异常处理、任务调度等都会 集成在框架中。我们只需要关心爬虫的核心逻辑即可,如页面信息的提取、下一步请求的生成等。这 样,不仅开发效率会提高很多,而且爬虫的健壮性也更强。 9.5 节的实现算是一个爬虫框架的雏形,但其距离一个标准的爬虫框架还很远。我们要以它为基 础,继续完善,编写一个爬虫框架吗?可以是可以,但是没必要。因为Python爬虫生态圈中已经有一 个成熟、稳定且强大的爬虫框架了,它就是 Scrapy。

15.1 Scrapy 框架介绍

Scrapy 是一个基于Python开发的爬虫框架,可以说它是当前 Python 爬虫生态中最流行的爬虫框 架,该框架提供了非常多爬虫相关的基础组件,架构清晰,可扩展性极强。基于Scrapy,我们可以灵 活高效地完成各种爬虫需求。 本节会首先介绍 Scrapy 框架的基本架构和功能。

1. 简介

在本章之前,我们大多是基于 requests 或 aiohttp来实现爬虫的整个逻辑的。可以发现,在整个过 程中,我们需要实现爬虫相关的所有操作,例如爬取逻辑、异常处理、数据解析、数据存储等,但其 实这些步骤很多都是通用或者重复的。既然如此,我们完全可以把这些步骤的逻辑抽离出来,把其中 通用的功能做成一个个基础的组件。 抽离出基础组件以后,我们每次写爬虫只需要在这些组件基础上加上特定的逻辑就可以实现爬取 的流程了,而不用再把爬虫每个细小的流程都实现一遍。比如说我们想实现这样一个爬取逻辑:遇到 服务器返回 403 状态码的时候就发起重试,遇到404状态码的时候就直接跳过。这个逻辑其实很多爬 虫都是类似的,那么我们就可以把这个逻辑封装成一个通用的方法或类来直接调用,而不用每次都把 这个过程再完整实现一遍,这就大大简化了开发成本,同时在慢慢积累的过程中,这个通用方法或 类也会变得越来越健壮,从而进一步保障了项目的稳定性,框架就是基于这种思想逐渐诞生出来的。

Scrapy 框架几乎是 Python 爬虫学习和工作过程中必须掌握的框架,需要好好钻研和掌握。

这里给出 Scrapy 框架的一些相关资源,包括官网、文档、GitHub地址,建议不熟悉相关知识的 读者在阅读之前浏览一下基本介绍。 □官网:https://scrapy.org/。 □文档:https://docs.scrapy.org/。

GitHub: https://github.com/scrapy/scrapy。

2. 架构

说了这么多,Scrapy 框架的功能到底强在哪里?组件丰富在哪里?扩展性好在哪里?不要着急, 本章后文会逐一讲解它的功能和各个组件的用法。 首先从整体上看一下Scrapy 框架的架构,如图15-1所示。

图15-1 Scrapy 框架的架构
图15-1 Scrapy 框架的架构

图15-1 来源于 Scrapy 官方文档,初看上去可能比较复杂,下面我们来介绍一下。

Engine:图中最中间的部分,中文可以称为引擎,用来处理整个系统的数据流和事件,是整个 框架的核心,可以理解为整个框架的中央处理器,负责数据的流转和逻辑的处理。

Item:它是一个抽象的数据结构,所以图中没有体现出来,它定义了爬取结果的数据结构,爬 取的数据会被赋值成 Item对象。每个Item 就是一个类,类里面定义了爬取结果的数据字段, 可以理解为它用来规定爬取数据的存储格式。”

Scheduler:图中下方的部分,中文可以称为调度器,它用来接受 Engine 发过来的 Request 并 将其加入队列中,同时也可以将 Request 发回给 Engine 供 Downloader 执行,它主要维护 Request 的调度逻辑,比如先进先出、先进后出、优先级进出等等。

Spiders:图中上方的部分,中文可以称为蜘蛛,Spiders 是一个复数统称,其可以对应多个 Spider, 每个 Spider 里面定义了站点的爬取逻辑和页面的解析规则,它主要负责解析响应并生成 Item 和新的请求然后发给 Engine 进行处理。

Downloader:图中右侧部分,中文可以称为下载器,即完成“向服务器发送请求,然后拿到响 应”的过程,得到的响应会再发送给 Engine处理。

Item Pipelines:图中左侧部分,中文可以称为项目管道,这也是一个复数统称,可以对应多个 Item Pipeline. Item Pipeline 主要负责处理由 Spider 从页面中抽取的Item,做一些数据清洗、 验证和存储等工作,比如将 Item的某些字段进行规整,将Item 存储到数据库等操作都可以由 Item Pipeline 来完成。

Downloader Middlewares:图中 Engine 和 Downloader 之间的方块部分,中文可以称为下载器 中间件,同样这也是复数统称,其包含多个 Downloader Middleware,它是位于 Engine 和 Downloader 之间的Hook框架,负责实现 Downloader 和Engine之间的请求和响应的处理过程。

Spider Middlewares:图中 Engine 和Spiders 之间的方块部分,中文可以称为蜘蛛中间件,它是 位于 Engine 和 Spiders 之间的Hook 框架,负责实现 Spiders 和 Engine 之间的Item、请求和响 应的处理过程。 以上便是 Scrapy 中所有的核心组件,初看起来可能觉得非常复杂并且难以理解,但上手之后我们 会慢慢发现其架构设计之精妙,后面让我们来一点点了解和学习。

3. 数据流

上文我们了解了Scrapy 的基本组件和功能,通过图和描述我们可以知道,在整个爬虫运行的过程 中,Engine负责了整个数据流的分配和处理,数据流主要包括 Item、Request、Response 这三大部分, 那它们又是怎么被 Engine 控制和流转的呢? 下面我们结合图 15-1 来对数据流做一个简单说明。 (1)启动爬虫项目时,Engine根据要爬取的目标站点找到处理该站点的Spider, Spider 会生成最初 需要爬取的页面对应的一个或多个 Request,然后发给 Engine。 (2) Engine 从 Spider 中获取这些 Request,然后把它们交给 Scheduler 等待被调度。 (3) Engine 向 Scheduler 索取下一个要处理的 Request,这时候 Scheduler 根据其调度逻辑选择合适 的Request 发送给 Engine。 (4) Engine 将 Scheduler 发来的 Request 转发给 Downloader 进行下载执行,将 Request 发送给 Downloader 的过程会经由许多定义好的Downloader Middlewares 的处理。 (5) Downloader 将 Request 发送给目标服务器,得到对应的Response,然后将其返回给 Engine。 将 Response 返回 Engine 的过程同样会经由许多定义好的 Downloader Middlewares 的处理。 (6) Engine 从 Downloder 处接收到的 Response 里包含了爬取的目标站点的内容,Engine 会将此 Response 发送给对应的 Spider 进行处理,将Response 发送给 Spider 的过程中会经由定义好的 Spider Middlewares 的处理。 (7) Spider 处理 Response,解析 Response 的内容,这时候 Spider 会产生一个或多个爬取结果 Item 或者后续要爬取的目标页面对应的一个或多个 Request,然后再将这些 Item 或 Request 发送给 Engine 进行处理,将 Item或Request 发送给 Engine 的过程会经由定义好的 Spider Middlewares 的处理。 (8) Engine 将 Spider 发回的一个或多个 Item 转发给定义好的 Item Pipelines 进行数据处理或存储的 一系列操作,将Spider发回的一个或多个 Request 转发给 Scheduler 等待下一次被调度。 重复第(2)步到第(8)步,直到Scheduler 中没有更多的Request,这时候 Engine 会关闭 Spider, 整个爬取过程结束。 以上步骤介绍了爬虫执行过程中的数据流转过程,起初看起来确实比较复杂,但不用担心,后文 我们会结合一些实战案例来慢慢理解这些过程。 从整体上看来,各个组件都只专注于一个功能,组件和组件之间的耦合度非常低,也非常容易扩 展。再由 Engine将各个组件组合起来,使得各个组件各司其职,互相配合,共同完成爬取工作。另外 加上Scrapy 对异步处理的支持,Scrapy 还可以最大限度地利用网络带宽,提高数据爬取和处理的效率。

4. 项目结构

了解了 Scrapy 的基本架构和数据流过程之后,我们再来大致一看下其项目代码的整体架构是怎样的。 在这之前我们需要先安装 Scrapy框架,一般情况下,使用pip3直接安装即可:

pip3 install scrapy

但 Scrapy 框架往往需要很多依赖库,如果依赖库没有安装好,Scrapy的安装过程是比较容易失败

的。如果安装有问题,可以参考 https://setup.scrape.center/scrapy 里面的详细说明。 安装成功之后,我们就可以使用scrapy命令行了,在命令行输入scrapy 可以得到类似如图15-2所 示的结果。

图15-2 运行结果
图15-2 运行结果

Scrapy 可以通过命令行来创建一个爬虫项目,比如我们要创建一个专门用来爬取新闻的项目,取 名为 news,那么我们可以执行如下命令:

scrapy startproject news

这里我们使用 startproject 命令加上项目的名称就创建了一个名为news的Scrapy 爬虫项目。执行 完毕之后,当前运行目录下便会出现一个名为 news的文件夹,该文件夹就对应一个Scrapy 爬虫项目。 接着进入 news 文件夹,我们可以再利用命令行创建一个Spider 用来专门爬取某个站点的新闻, 比如新浪新闻,我们可以使用如下命令创建一个Spider:

scrapy genspider sina news.sina.com.cn

这里我们利用 genspider 命令加上Spider 的名称再加上对应的域名,成功创建了一个Spider,这 个Spider 会对应一个Python文件,出现在项目的 spiders目录下。 现在项目文件的结构如下: news __init__.py

items.py

middlewares.py pipelines.py settings.py spiders __init__.py sina.py scrapy.cfg 在此将各个文件的功能描述如下。

scrapy.cfg:Scrapy 项目的配置文件,其中定义了项目的配置文件路径、部署信息等。

items.py:定义了 Item 数据结构,所有Item的定义都可以放这里。

pipelines.py:定义了 Item Pipeline 的实现,所有的 Item Pipeline 的实现都可以放在这里。

settings.py:定义了项目的全局配置。 middlewares.py:定义了Spider Middlewares 和 Downloader Middlewares 的实现。

spiders:里面包含一个个 Spider的实现,每个 Spider 都对应一个Python文件。 在此我们仅需要对这些文件的结构和用途做初步的了解,后文会对它们进行深入讲解。

5. 总结

本节介绍了 Scrapy 框架的基本架构、数据流过程以及项目结构,如果你之前没有接触过Scrapy, 可能觉得本节的内容很难理解,这个很正常。 不用担心,后面我们会结合实战案例逐节了解 Scrapy 每个组件的用法,在学习的过程中,你会慢 慢了解到 Scrapy 的强大和设计精妙之处,到时候再回过头来看看这一节,就会融会贯通了。

15.2 Scrapy 入门

上一节我们介绍了Scrapy 框架的基本架构、数据流过程和项目架构,对Scrapy有了初步的认识。 接下来我们用 Scrapy 实现一个简单的项目,完成一遍 Scrapy 抓取流程。通过这个过程,我们可以对 Scrapy 的基本用法和原理有大体了解。

1. 本节目标

本节要完成的目标如下。

创建一个 Scrapy项目,熟悉 Scrapy项目的创建流程。 口编写一个 -Spider 来抓取站点和处理数据,了解Spider 的基本用法。

初步了解 Item Pipeline 的功能,将抓取的内容保存到 MongoDB 数据库。 运行 Scrapy 爬虫项目,了解Scrapy项目的运行流程。 这里我们以 Scrapy 推荐的官方练习项目为例进行实战演练,抓取的目标站点为 https://quotes. toscrape.com/,页面如图15-3 所示。

图15-3 目标站点
图15-3 目标站点

I am unable to perform OCR directly as I do not have an ocr_file tool available. My current capabilities do not include optical character recognition.

name 是每个项目唯一的名字,用来区分不同的Spider。

allowed_domains 是允许爬取的域名,如果初始或后续的请求链接不是这个域名下的,则请求 链接会被过滤掉。

start_urls 包含了Spider 在启动时爬取的URL列表,初始请求是由它来定义的。

parse 是 Spider 的一个方法。在默认情况下,start_urls 里面的链接构成的请求完成下载后, parse 方法就会被调用,返回的响应就会作为唯一的参数传递给 parse方法。该方法负责解析返 回的响应、提取数据或者进一步生成要处理的请求。

5. 创建 Item

上一节我们讲过,Item是保存爬取数据的容器,定义了爬取结果的数据结构。它的使用方法和字 典类似。不过相比字典,Item 多了额外的保护机制,可以避免拼写错误或者定义字段错误。 创建 Item 需要继承scrapy 的Item类,并且定义类型为Field 的字段,这个字段就是我们要爬取 的字段。 那我们需要爬哪些字段呢?观察目标网站,我们可以获取到的内容有下面几项。

text:文本,即每条名言的内容,是一个字符串。

author:作者,即每条名言的作者,是一个字符串。

tags:标签,即每条名言的标签,是字符串组成的列表。 这样的话,每条爬取数据就包含这3个字段,那么我们就可以定义对应的Item,此时将 items.py 修 改如下:

import scrapy
class QuoteItem(scrapy.Item):
text = scrapy.Field()
author = scrapy.Field()
tags = scrapy.Field()

这里我们声明了 QuoteItem,继承了Item类,然后使用 Field 定义了3个字段,接下来爬取时我 们会使用到这个Item。

6. 解析 Response

前面我们看到,parse 方法的参数 response 是 start_urls 里面的链接爬取后的结果,即页面 请求后得到的 Response, Scrapy 将其转化为了一个数据对象,里面包含了页面请求后得到的 Response Status、Body等内容。所以在 parse 方法中,我们可以直接对 response 变量包含的内容 进行解析,比如浏览请求结果的网页源代码,进一步分析源代码内容,或者找出结果中的链接而得 到下一个请求。 我们可以看到网页中既有我们想要的结果,又有下一页的链接,这两部分内容我们都要进行 处理。 首先看看网页结构,如图15-4所示。每一页都有多个class为 quote 的区块,每个区块内都包含 text、author、tags。那么我们先找出所有的 quote,然后提取每个 quote 中的内容。

图15-4 网页结构
图15-4 网页结构

我们可以使用CSS选择器或XPath 选择器进行提取,这个过程我们可以直接借助 response 的css 或 xpath 方法实现,这都是 Scrapy 给我们封装好的方法,直接调用即可。 在这里我们使用CSS 选择器进行选择,可以将parse方法的内容进行如下改写:

def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
text = quote.css('.text::text').extract_first()
author = quote.css('.author::text').extract_first()
tags = quote.css('.tags.tag::text').extract()

这里首先利用 CSS 选择器选取所有的 quote 并将其赋值为 quotes变量,然后利用for循环遍历 每个 quote,解析每个 quote 的内容。 对 text 来说,观察到它的class为text,所以可以用.text选择器来选取,这个结果实际上是整 个带有标签的节点,要获取它的正文内容,可以加::text。这时的结果是长度为1的列表,所以还需 要用 extract_first 方法来获取第一个元素。而对于 tags 来说,由于我们要获取所有的标签,所以用 extract 方法获取整个列表即可。 为了更好地理解以上内容的提取过程,我们以第一个quote 的结果为例,看一下各个提取写法会 得到怎样的提取结果。源码如下:

<div class="quote" itemscope=""itemtype="http://schema.org/CreativeWork">
<span class="text" itemprop="text">"The world as we have created it is a process of our thinking. It
cannot be changed without changing our thinking."</span>
<span>by <small class="author" itemprop="author">Albert Einstein</small>
<a href="/author/Albert-Einstein"> (about)</a>
</span>
<div class="tags">

Tags:

<meta class="keywords" itemprop="keywords" content="change, deep-thoughts, thinking, world">
<a class="tag" href="/tag/change/page/1/">change</a>
<a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>
<a class="tag" href="/tag/thinking/page/1/">thinking</a>
<a class="tag" href="/tag/world/page/1/">world</a>
</div>
</div>

不同选择器的返回结果如下:

quote.css('.text')
[<Selector xpath="descendant-or-self:: *[@class and contains (concat('', normalize-space(@class), ''), 'text
')]"data='<span class="text" itemprop="text">"The '>]
quote.css('.text::text')
[<Selector xpath="descendant-or-self::*[@class and contains (concat('', normalize-space(@class), ''), 'text
')]/text()"data='"The world as we have created it is a pr'>]
quote.css('.text').extract()
['<span class="text" itemprop="text">"The world as we have created it is a process of our thinking. It cannot
be changed without changing our thinking."</span>']
quote.css('.text::text').extract()
["The world as we have created it is a process of our thinking. It cannot be changed without changing our

thinking."']

quote.css('.text::text').extract_first()

"The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking." 这里我们演示了不同提取过程的写法,其提取结果也是各不相同,比如单独调用CSS 方法我们得 到的是 Selector 对象组成的列表;调用 extract 方法会进一步从 Selector 对象里提取其内容,再加 上::text则会从 HTML 代码中提取出正文文本。 因此对于 text,我们只需要获取结果的第一个元素即可,所以使用 extract_first方法,得到的 就是一个字符串。而对于tags,我们要获取所有结果组成的列表,所以使用extract方法,得到的就 是所有标签字符串组成的列表。

7. 使用 Item

上文我们已经定义了 QuoteItem,接下来就要使用它了。 我们可以把 Item 理解为一个字典,和字典还不太相同,其本质是一个类,所以在使用的时候需 要实例化。实例化之后,我们依次用刚才解析的结果赋值 Item 的每一个字段,最后将Item返回。 Quotes Spider 的改写如下:

import scrapy
from scrapytutorial.items import QuoteItem
class Quotes Spider (scrapy. Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['https://quotes.toscrape.com/']
def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
item = QuoteItem()
item['text'] = quote.css('.text::text').extract_first()
item['author'] = quote.css('.author::text').extract_first()
item['tags'] = quote.css('.tags .tag::text').extract()
yield item

如此一来,首页的所有内容就被解析出来并被赋值成了一个个 QuoteItem了,每个QuoteItem 就 代表一条名言,包含名言的内容、作者和标签。

8. 后续 Request

上面的操作实现了从首页抓取内容,如果运行它,我们其实已经可以从首页提取到所有 quote 信 息并将其转化为一个个 QuoteItem 对象了。

但是,这样还不够,下一页的内容该如何抓取呢?这就需要我们从当前页面中找到信息来生成下 一个 Request,利用同样的方式进行请求并解析就好了。那再下一页呢?也是一样的原理,我们可以 在下一个页面里找到信息再构造再下一个Request。这样循环往复迭代,从而实现整站的爬取。 我们将刚才的页面拉到最底部,如图15-5所示。

图15-5 页面最底部
图15-5 页面最底部

这里我们发现有一个Next 按钮,查看一下源代码,可以看到它的链接是/page/2/,实际上全链接 就是 https://quotes.toscrape.com/page/2,通过这个链接我们就可以构造下一个 Request了。 构造 Request 时需要用到scrapy 的Request类。这里我们传递两个参数,分别是url 和 callback, 这两个参数的说明如下。

url:目标页面的链接。

callback:回调方法,当指定了该回调方法的Request 完成下载之后,获取Response, Engine 会 将该 Response 作为参数传递给这个回调方法。回调方法进行 Response 的解析生成一个或多个 Item 或 Request,比如上文的parse 方法就是回调方法。 由于刚才所定义的 parse 方法就是用来提取名言 text、author、tags的方法,而下一页的结构和 刚才已经解析的页面结构是一样的,所以我们可以再次使用 parse 方法来做页面解析。 接下来我们要做的就是利用选择器得到下一页链接并生成请求,在parse 方法后追加如下的代码:

next = response.css('.pager .next a::attr(href)').extract_first()
url = response.urljoin(next)
yield scrapy. Request(url=url, callback=self.parse)

第一行代码首先通过CSS 选择器获取下一个页面的链接,即要获取超链接a中的href属性,这 里用到了::attr(href)进行提取,其中attr 代表提取节点的属性,href则为要提取的属性名,然后 再下一步调用 extract_first 方法获取内容。 第二行代码调用了 urljoin 方法,urljoin方法可以将相对URL构造成一个绝对URL。例如,获取 到的下一页地址是/page/2/, urljoin方法处理后得到的结果就是https://quotes.toscrape.com/page/2/。 第三行代码通过 url 和 callback 变量构造了一个新的Request,回调方法 callback 依然使用 parse 方法。这个 Request 执行完成后,其对应的Response 会重新经过 parse 方法处理,得到第二页的解析 结果,然后以此类推,生成第二页的下一页,也就是第三页的请求。这样爬虫就进入了一个循环,直 到最后一页。

通过几行代码,我们就轻松实现了一个抓取循环,将每个页面的结果抓取下来了。 现在,改写之后的整个Spider 类如下所示:

import scrapy
from scrapytutorial.items import QuoteItem
class QuotesSpider(scrapy.Spider):
    name = "quotes"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ['https://quotes.toscrape.com/']
    def parse(self, response):
        quotes = response.css('.quote')
        for quote in quotes:
            item = QuoteItem()
            item['text'] = quote.css('.text::text').extract_first()
            item['author'] = quote.css('.author::text').extract_first()
            item['tags'] = quote.css('.tags .tag::text').extract()
            yield item
        next = response.css('.pager .next a::attr("href")').extract_first()
        url = response.urljoin(next)
        yield scrapy.Request(url=url, callback=self.parse)

可以看到整个站点的抓取逻辑就轻松完成了,不需要再去编写怎样发送Request,不需要去关心异 常处理,因为这些工作 Scrapy 都帮我们完成了,我们只需要关注 Spider本身的抓取和提取逻辑即可。

9. 运行

接下来就是运行项目了,进入项目目录,运行如下命令:

scrapy crawl quotes

就可以看到 Scrapy 的运行结果了:

2020-08-29 19:55:46 [scrapy.utils.log] INFO: Scrapy 2.2.1 started (bot: scrapytutorial)
2020-08-29 19:55:46 [scrapy.utils.log] INFO: Versions: 1xml 4.3.3.0, libxml2 2.9.9, cssselect 1.1.0, parsel
1.6.0, w3lib 1.22.0, Twisted 20.3.0, Python 3.7.3 (default, Apr 24 2020, 18:51:23) - [Clang 11.0.3
(clang-1103.0.32.62)], pyOpenSSL 19.1.0 (OpenSSL 1.1.1g 21 Apr 2020), cryptography 2.9.2, Platform
Darwin-19.4.0-x86_64-1386-64bit
2020-08-29 19:55:46 [scrapy.utils.log] DEBUG: Using reactor: twisted.internet.selectreactor.SelectReactor
2020-08-29 19:55:46 [scrapy.crawler] INFO: Overridden settings:
{'BOT_NAME': 'scrapytutorial',
'NEWSPIDER_MODULE': 'scrapytutorial.spiders',
'ROBOTSTXT_OBEY': True,
'SPIDER_MODULES': ['scrapytutorial.spiders']}
2020-08-29 19:55:46 [scrapy.extensions.telnet] INFO: Telnet Password: 69146568e6fe206c
2020-08-29 19:55:46 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.corestats.CoreStats',
'scrapy.extensions.telnet.TelnetConsole',
'scrapy.extensions.memusage.MemoryUsage',
'scrapy.extensions.logstats.LogStats']
2020-08-29 19:55:46 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
'scrapy.downloadermiddlewares.retry.RetryMiddleware',
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware',
'scrapy.downloadermiddlewares.stats.DownloaderStats']
2020-08-29 19:55:46 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite. OffsiteMiddleware',
'scrapy.spidermiddlewares.referer. RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'scrapy.spidermiddlewares.depth.DepthMiddleware']
2020-08-29 19:55:46 [scrapy.middleware] INFO: Enabled item pipelines:
['scrapytutorial.pipelines.TextPipeline', 'scrapytutorial.pipelines.MongoPipeline']
2020-08-29 19:55:46 [scrapy.core.engine] INFO: Spider opened
2020-08-29 19:55:46 [scrapy.extensions.logstats] INFO: Crawled o pages (at 0 pages/min), scraped 0 items (at
0 items/min)
2020-08-29 19:55:46 [scrapy.extensions.telnet] INFO: Telnet console listening on 127.0.0.1:6023
2020-08-29 19:55:47 [scrapy.core.engine] DEBUG: Crawled (404) <GET https://quotes.toscrape.com/robots.txt>
(referer: None)
2020-08-29 19:55:48 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://quotes.toscrape.com/> (referer:
None)
2020-08-29 19:55:48 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/>
{'author': 'Albert Einstein',
'tags': ['change', 'deep-thoughts', 'thinking', 'world'],
'text': '"The world as we have created it is a process of o...'}
2020-08-29 19:55:48 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/>
{'author': 'J.K. Rowling',
'tags': ['abilities', 'choices'],
'text': '"It is our choices, Harry, that show what we truly...'}
2020-08-29 19:55:48 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/>
{'author': 'Albert Einstein',
'tags': ['inspirational', 'life', 'live', 'miracle', 'miracles'],
'text': '"There are only two ways to live your life. One is...'}
2020-08-29 19:55:48 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/>
{'author': 'Jane Austen',
'tags': ['aliteracy', 'books', 'classic', 'humor'],
'text': '"The person, be it gentleman or lady, who has not...'}
2020-08-29 19:56:32 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 2881,
'downloader/request_count': 11,
'downloader/request_method_count/GET': 11,
'downloader/response_bytes': 24911,
'downloader/response_count': 11,
'downloader/response_status_count/200': 10,
'downloader/response_status_count/404': 1,
'dupefilter/filtered': 1,
'elapsed_time_seconds': 10.565782,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2020, 8, 29, 11, 56, 32, 514837),
'item_scraped_count': 100,
'log_count/DEBUG': 112,
'log_count/INFO': 10,
'memusage/max': 57008128,
'memusage/startup': 57004032,
'request_depth_max': 10,
'response_received_count': 11,
'robotstxt/request_count': 1,
'robotstxt/response_count': 1,
'robotstxt/response_status_count/404': 1,
'scheduler/dequeued': 10,
'scheduler/dequeued/memory': 10,
'scheduler/enqueued': 10,
'scheduler/enqueued/memory': 10,
'start_time': datetime.datetime(2020, 8, 29, 11, 56, 21, 949055)}
2020-08-29 19:56:32 [scrapy.core.engine] INFO: Spider closed (finished)

这里只是部分运行结果,省略了一些中间的抓取结果。 首先,Scrapy 输出了当前的版本号以及正在启动的项目名称。然后输出了当前 settings.py 中一些 重写后的配置。接着输出了当前所应用的Middlewares 和 Item Pipelines。Middlewares 和 Item Pipelines 都沿用了 Scrapy 的默认配置,我们可以在 settings.py 中配置它们的开启和关闭,后文会对它们的用法

进行讲解。 接下来就是输出各个页面的抓取结果了,可以看到爬虫一边解析,一边翻页,直到将所有内容抓 取完毕,然后终止。 最后,Scrapy 输出了整个抓取过程的统计信息,如请求的字节数、请求次数、响应次数、完成原 因等。 整个 Scrapy 程序成功运行。我们通过非常简单的代码就完成了一个站点内容的爬取,所有的名言 都被我们抓取下来了。

10. 保存到文件

运行完 Scrapy 后,我们只在控制台上看到了输出结果。如果想保存结果该怎么办呢? 要完成这个任务其实不需要任何额外的代码,Scrapy 提供的 Feed Exports 可以轻松将抓取结果输 出。例如,如果我们想将上面的结果保存成 JSON 文件,那么可以执行如下命令:

scrapy crawl quotes -o quotes.json

命令运行后,项目内多了一个 quotes.json 文件,文件包含了刚才抓取的所有内容,内容是 JSON 格式。 另外我们还可以让每一个 Item 输出一行 JSON,输出后缀为 jl,为 jsonline 的缩写,命令如下 所示:

scrapy crawl quotes -o quotes.jl

scrapy crawl quotes -o quotes.jsonlines

Feed Exports 支持从输出格式还有很多,例如 csv、xml、pickle、marshal 等,同时它支持 ftp、s3 等 远程输出,另外还可以通过自定义 ItemExporter 来实现其他的输出。 例如,下面命令对应的输出分别为 csv、xml、pickle、marshal 格式以及 ftp 远程输出:

scrapy crawl quotes -o quotes.csv
scrapy crawl quotes -o quotes.xml
scrapy crawl quotes -o quotes.pickle
scrapy crawl quotes -o quotes.marshal
scrapy crawl quotes -o ftp://user:[email protected]/path/to/quotes.csv

其中,ftp 输出需要正确配置用户名、密码、地址、输出路径,否则会报错。 通过 Scrapy 提供的 Feed Exports,我们可以轻松地将抓取结果到输出到文件中。对于一些小型项 目来说,这应该足够了。 如果想要更复杂的输出,如输出到数据库等,我们可以使用 Item Pipeline 来完成。

11. 使用 Item Pipeline

如果想进行更复杂的操作,如将结果保存到 MongoDB 数据库中或者筛选某些有用的 Item,那么 我们可以定义 Item Pipeline 来实现。 Item Pipeline 为项目管道。当 Item 生成后,它会自动被送到 Item Pipeline 处进行处理,我们可以 用 Item Pipeline 来做如下操作: □ 清洗 HTML 数据; □ 验证爬取数据,检查爬取字段; □ 查重并丢弃重复内容;

将爬取结果储存到数据库。 要实现 Item Pipeline很简单,只需要定义一个类并实现 process_item方法即可。启用 Item Pipeline 后,Item Pipeline 会自动调用这个方法。process_item方法必须返回包含数据的字典或 Item 对象,或 者抛出 DropItem异常。 process_item 方法有两个参数。一个参数是item,每次Spider 生成的 Item 都会作为参数传递过 来。另一个参数是spider,就是Spider 的实例。 接下来,我们实现一个Item Pipeline,筛掉text 长度大于50的Item,并将结果保存到 MongoDB。 修改项目里的 pipelines.py文件,之前用命令行自动生成的文件内容可以删掉,增加一个 TextPipeline类,内容如下所示:

from scrapy.exceptions import DropItem
class TextPipeline(object):
def __init__(self):
self.limit = 50
def process_item(self, item, spider):
if item['text']:
if len(item['text']) > self.limit:
item['text'] = item['text'][0:self.limit].rstrip() + '...'
return item
else:
return DropItem('Missing Text')

这段代码在构造方法里定义了限制长度为50,实现了 process_item方法,其参数是 item 和 spider。首先该方法判断item的text属性是否存在,如果不存在,则抛出 DropItem 异常。如果存在, 再判断长度是否大于50,如果大于,那就截断然后拼接省略号,再将 item 返回。 接下来,我们将处理后的item存入 MongoDB,定义另外一个Pipeline。同样在 pipelines.py中, 我们实现另一个类 MongoPipeline,内容如下所示:

import pymongo
class MongoDBPipeline(object):
def __init__(self, connection_string, database):
self.connection_string = connection_string
self.database = database

@classmethod

def from_crawler(cls, crawler):
return cls(
connection_string=crawler.settings.get('MONGODB_CONNECTION_STRING'),
database=crawler.settings.get('MONGODB_DATABASE')
)
def open_spider(self, spider):
self.client = pymongo.MongoClient(self.connection_string)
self.db = self.client[self.database]
def process_item(self, item, spider):
name = item.__class__.__name__
self.db[name].insert_one(dict(item))
return item
def close_spider(self, spider):
self.client.close()

MongoPipeline 类实现了另外几个API定义的方法。

from_crawler:一个类方法,用@classmethod 标识,这个方法是以依赖注入的方式实现的,方

法的参数就是 crawler。通过 crawler,我们能拿到全局配置的每个配置信息,在全局配置 settings.py中,可以通过定义 MONGO_URI 和 MONGO_DB 来指定 MongoDB 连接需要的地址和数据 库名称,拿到配置信息之后返回类对象即可。所以这个方法的定义主要是用来获取 settings.py 中的配置的。 open_spider:当Spider 被开启时,这个方法被调用,主要进行了一些初始化操作。 close_spider:当Spider 被关闭时,这个方法被调用,将数据库连接关闭。 最主要的 process_item 方法则执行了数据插入操作,这里直接调用 insert_one 方法传入 item 对 象即可将数据存储到 MongoDB。 定义好 TextPipeline 和 MongoDBPipeline 这两个类后,我们需要在 settings.py 中使用它们。 MongoDB 的连接信息还需要定义。 我们在 settings.py 中加入如下内容:

ITEM_PIPELINES = {
'scrapytutorial.pipelines. TextPipeline': 300,
'scrapytutorial.pipelines. MongoDBPipeline': 400,
}
MONGODB_CONNECTION_STRING = 'localhost'
MONGODB_DATABASE = 'scrapytutorial'

这里我们声明了 ITEM_PIPELINES 字典,键名是 Pipeline 的类名称,键值是调用优先级,是一个 数字,数字越小则对应的Pipeline 越先被调用,另外我们声明了 MongoDB的连接字符串和存储的数 据库名称。 再重新执行爬取,命令还是一样的:

scrapy crawl quotes

爬取结束后,我们可以看到MongoDB 中创建了一个scrapytutorial 的数据库和 QuoteItem 的表, 内容如图15-6所示。

图15-6 爬取结果
图15-6 爬取结果

长的text 已经被处理并追加了省略号,短的text 保持不变,author 和 tags 也都相应保存到了数 据中。

12. 总结

本节我们通过抓取 Quotes 网站完成了整个 Scrapy 的简单入门,到此为止我们应该能对 Scrapy 的 基本用法有一个初步的概念了。 不过本节内容仅仅是Scrapy 所有功能的冰山一角,还有很多内容等待我们去探索,我们后续章节 继续学习。 本节代码参见:https://github.com/Python3WebSpider/ScrapyTutorial。

15.3 Selector 的使用

我们之前介绍了利用 Beautiful Soup、pyquery以及正则表达式来提取网页数据的方法,确实非常 方便。不过 Scrapy 提供了自己的数据提取方法,即内置的, 在3.4节我们已经初步了解了 parsel 库的基本用法,Scrapy中的Selector 是就是基于parsel 库来构 建的,而同时 parsel又依赖于lxml, Selector 对 parsel 进行了封装,使其能更好地与Scrapy 结合使用。 Selector 支持 XPath 选择器、CSS 选择器以及正则表达式,功能全面,解析速度和准确度非常高。 本节我们就来详细介绍一下 Selector 的用法。

1. 直接使用

Selector 其实并不一定非要在 Scrapy 中使用,它也是一个可以独立使用的模块。我们可以直接利 用Selector 这个类来构建一个选择器对象,然后调用它的相关方法(如xpath、css等)来提取数据。 例如,针对一段HTML代码,我们可以用如下方式构建 Selector 对象来提取数据:

from scrapy import Selector
body = '<html><head><title>Hello World</title></head><body></body></html>'
selector = Selector(text=body)
title = selector.xpath('//title/text()').extract_first()
print(title)

运行结果如下: Hello World 这里没有在 Scrapy 框架中运行,而是把Scrapy中的Selector单独拿出来使用了,构建的时候传入 text 参数,就生成了一个Selector 选择器对象,然后就可以像 Scrapy 中的解析方式一样,调用xpath、 CSS 等方法来提取数据了。 在这里我们查找的是源代码中title内的文本,在XPath选择器最后加 text方法就可以实现文本 的提取了。 以上内容就是 Selector 的直接使用方式。同Beautiful Soup 等库类似,Selector 也是强大的网页解 析库。如果方便的话,我们也可以在其他项目中直接使用 Selector 来提取数据。 接下来,我们用实例来详细讲解 Selector 的用法。

2. Scrapy Shell

由于 Selector 主要是与Scrapy 结合使用,如Scrapy的回调函数中的参数 response 直接调用 xpath 或者 CSS 方法来提取数据,所以在这里我们借助 Scrapy shell 来模拟 Scrapy 请求的过程,讲解相关的

提取方法。 我们用官方文档的一个样例页面来做演示:https://doc.scrapy.org/en/latest/_static/selectors-sample1.html。 开启 Scrapy shell,在命令行输入如下命令:

scrapy shell https://doc.scrapy.org/en/latest/_static/selectors-sample1.html

我们就进入 Scrapy shell 模式了。这个过程其实是 Scrapy 发起了一次请求,请求的URL 就是刚才 命令行下输入的URL,把一些可操作的变量传递给我们,如 request、response 等,如图15-7所示。

图15-7 Scrapy shell 模式
图15-7 Scrapy shell 模式

我们可以在命令行模式下输入命令,调用对象的一些操作方法,按下回车之后实时显示结果。这 与Python 的命令行交互模式类似。 接下来演示的实例都将页面的源码作为分析目标,页面源码如下所示:

3. XPath 选择器

<html>
<head>
<base href='http://example.com/'/>
<title>Example website</title>
</head>
<body>
<div id='images'>
<a href='image1.html'>Name: My image 1 <br/><img src='image1_thumb.jpg'/></a>
<a href='image2.html'>Name: My image 2 <br/><img src='image2_thumb.jpg'/></a>
<a href='image3.html'>Name: My image 3 <br/><img src='image3_thumb.jpg'/></a>
<a href='image4.html'>Name: My image 4 <br/><img src='image4_thumb.jpg'/></a>
<a href='image5.html'>Name: My image 5 <br/><img src='image5_thumb.jpg'/></a>
</div>
</body>
</html>

进入 Scrapy Shell后,我们主要通过操作 response 变量进行解析。因为我们解析的是HTML代码, Selector 将自动使用HTML 语法来分析。 response 有一个属性 selector,我们调用 response.selector 返回的内容就相当于用 response 的 text 构造了一个 Selector 对象。通过这个Selector对象,我们可以调用如 xpath、css 等解析方法, 向方法传入 XPath 或CSS 选择器参数就可以实现信息的提取。

我们用一个实例感受一下,代码如下所示:

>>> result = response.selector.xpath('//a')
>>> result
[<Selector xpath='//a' data='<a href="image1.html">Name: My image 1 <'>,
<Selector xpath='//a' data='<a href="image2.html">Name: My image 2 <'>,
<Selector xpath='//a' data='<a href="image3.html">Name: My image 3 <'>,
<Selector xpath='//a' data='<a href="image4.html">Name: My image 4 <'>,
<Selector xpath='//a' data='<a href="image5.html">Name: My image 5 <'>]
>>> type(result)

scrapy.selector.unified.SelectorList 打印结果的形式是 Selector 组成的列表,其实它是 SelectorList 类型,SelectorList 和 Selector 都可以继续调用xpath 和css等方法来进一步提取数据。 在上面的例子中,我们提取了a节点。接下来,我们尝试继续调用 xpath 方法来提取a节点内包 含的img 节点,代码如下所示:

>>> result.xpath('./img')
[<Selector xpath='./img' data='<img src="image1_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image2_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image3_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image4_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image5_thumb.jpg">'>]

我们获得了a节点里面的所有img节点,结果为5。 值得注意的是,选择器的最前方加.(一个点)代表提取元素内部的数据,如果没有加点,则代 表从根节点开始提取。此处我们用了./img的提取方式,代表从a节点里进行提取。如果此处我们用 //img,则还是从html 节点里进行提取。 我们刚才使用 response.selector.xpath 方法对数据进行了提取。Scrapy 提供了两个实用的快捷 方法,response.xpath 和 response.css,二者的功能完全等同于 response.selector.xpath 和

response.selector.css。

方便起见,后面我们统一直接调用 response 的 xpath 和css方法进行选择。 现在我们得到的是 SelectorList 类型的变量,该变量是由Selector 对象组成的列表。可以用索 引单独取出其中某个 Selector元素,代码如下所示:

>>> result[0]
<Selector xpath='//a' data='<a href="image1.html">Name: My image 1 <'>

我们可以像操作列表一样操作这个 SelectorList。 但是现在获取的内容是 Selector 或者 SelectorList类型,并不是真正的文本内容。具体的内容 怎么提取呢? 比如我们现在想提取a节点元素,就可以利用extract方法,代码如下所示:

>>> result.extract()
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>', '<a href="image2.html">Name:
My image 2 <br><img src="image2_thumb.jpg"></a>', '<a href="image3.html">Name: My image 3 <br><img
src="image3_thumb.jpg"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg"></a>',
'<a href="image5.html">Name: My image 5 <br><img src="images_thumb.jpg"></a>']

这里使用了 extract方法,我们可以把真实需要的内容获取下来。 我们还可以改写 XPath 表达式,来选取节点的内部文本和属性,代码如下所示:

>>> response.xpath('//a/text()').extract()
['Name: My image 1', 'Name: My image 2', 'Name: My image 3', 'Name: My image 4', 'Name: My image 5']
>>> response.xpath('//a/@href').extract()
['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html']

我们只需要再加一层/text()就可以获取节点的内部文本,或者加一层/@href就可以获取节点的 href 属性。其中,@符号后面内容就是要获取的属性名称。 现在,我们可以用一个规则获取所有符合要求的节点,返回的类型是列表类型。 但是这里有一个问题:如果符合要求的节点只有一个,那么返回的结果会是什么呢?我们再用一 个实例来感受一下,代码如下所示:

>>> response.xpath('//a[@href="image1.html"]/text()').extract()
['Name: My image 1']

我们用属性限制了匹配的范围,使XPath只可以匹配到一个元素。然后用 extract 方法提取结果, 其结果还是一个列表形式,文本是列表的第一个元素。但很多情况下,我们想要的数据其实就是第一 个元素内容,这里我们通过加一个索引来获取,代码如下所示:

>>> response.xpath('//a[@href="image1.html"]/text()').extract()[0]
'Name: My image 1

但是,这个写法很明显是有风险的。一旦XPath 有问题,extract 后的结果可能是一个空列表。 如果我们再用索引来获取,就可能导致数组越界。 所以,另外一个方法可以专门提取单个元素,它叫作 extract_first。我们可以改写上面的例子, 相关代码如下:

>>> response.xpath('//a[@href="image1.html"]/text()').extract_first()
'Name: My image 1 '

这样,我们直接利用 extract_first 方法将匹配的第一个结果提取出来,同时也不用担心数组越 界的问题了。 另外,我们也可以为 extract_first方法设置一个默认值,这样当XPath 规则提取不到内容时, 就会直接使用默认值。例如将XPath 改成一个不存在的规则,重新执行代码,代码如下所示:

>>>
response.xpath('//a[@href="image1"]/text()').extract_first()
>>> response.xpath('//a[@href="image1"]/text()').extract_first('Default Image')
'Default Image'

这里,如果XPath 匹配不到任何元素,调用extract_first 会返回空,也不会报错。 在第二行代码中,我们还传递了一个参数当作默认值,如Default Image。这样,如果 XPath 匹 配不到结果,返回值会使用这个参数来代替,可以看到输出正是如此。 到现在为止,我们了解了Scrapy 中的XPath的相关用法,包括嵌套查询、提取内容、提取单个内 容、获取文本和属性等。

4. CSS 选择器

接下来,我们看看 CSS 选择器的用法。 Scrapy 的选择器同时还对接了CSS 选择器,使用response.css 方法就可以使用CSS 选择器来选 择对应的元素了。 例如在上文我们选取了所有的a节点,那么CSS选择器同样可以做到,相关代码如下:

>>> response.css('a')
[<Selector xpath='descendant-or-self::a' data='<a href="image1.html">Name: My image 1 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image2.html">Name: My image 2 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image3.html">Name: My image 3 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image4.html">Name: My image 4 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image5.html">Name: My image 5 <'>]

同样,调用 extract 方法就可以提取节点,代码如下所示:

>>> response.css('a').extract()
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>', '<a href="image2.html">Name:
My image 2 <br><img src="image2_thumb.jpg"></a>', '<a href="image3.html">Name: My image 3 <br><img
src="image3_thumb.jpg"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg"></a>',
'<a href="image5.html">Name: My image 5 <br><img src="images_thumb.jpg"></a>']

可以看到,用法和XPath 选择是完全一样的。 另外,我们也可以进行属性选择和嵌套选择,代码如下所示:

>>> response.css('a[href="image1.html"]').extract()
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>']
>>> response.css('a[href="image1.html"] img').extract()
['<img src="image1_thumb.jpg">']

这里用[href="image.html"]限定了href属性,可以看到匹配结果就只有一个了。另外如果想查 找a节点内的img节点,只需要再加一个空格和img。选择器的写法和标准CSS 选择器写法如出一辙。 我们也可以使用 extract_first 方法提取列表的第一个元素,比如:

>>> response.css('a[href="image1.html"] img').extract_first()
'<img src="image1_thumb.jpg">'

接下来的两个用法不太一样。节点的内部文本和属性的获取是这样实现的:

>>> response.css('a[href="image1.html"]::text').extract_first()

'Name: My image 1 '

>>> response.css('a[href="image1.html"] img::attr(src)').extract_first()

'image1_thumb.jpg' 获取文本和属性需要用::text和::attr的写法,而其他库如 Beautiful Soup 或 pyquery 都有单独 的方法。 另外,CSS 选择器和XPath 选择器一样,能够嵌套选择。我们可以先用XPath 选择器选中所有节 点,再利用 CSS 选择器选中 img 节点,然后用XPath 选择器获取属性。我们用一个实例来感受一下, 代码如下所示:

>>> response.xpath('//a').css('img').xpath('@src').extract()
['image1_thumb.jpg', 'image2_thumb.jpg', 'image3_thumb.jpg', 'image4_thumb.jpg', 'image5_thumb.jpg']

我们成功获取了所有 img 节点的src 属性。 因此,我们可以随意使用 xpath 和css方法,二者自由组合实现嵌套查询,它们是完全兼容的。

5. 正则匹配

Scrapy 的选择器还支持正则匹配。比如在示例的a节点中,文本类似于 Name: My image 1,现在 我们只想把 Name:后面的内容提取出来,就可以借助re方法,代码实现如下:

>>> response.xpath('//a/text()').re('Name:\s(.*)')
['My image 1', 'My image 2', 'My image 3', 'My image 4', 'My image 5']

我们给re 方法传了一个正则表达式,其中(.*)就是要匹配的內容,输出的结果就是正则表达式 匹配的分组,结果会依次输出。 如果同时存在两个分组,那么结果依然会被按序输出,代码如下所示:

>>> response.xpath('//a/text()').re('(.*?):\s(.*)')
['Name', 'My image 1', 'Name', 'My image 2', 'Name', 'My image 3', 'Name', 'My image 4', 'Name', 'My image 5']

类似 extract_first 方法,re_first方法可以选取列表的第一个元素,用法如下:

>>> response.xpath('//a/text()').re_first('(.*?):\s(.*)')

'Name'

>>> response.xpath('//a/text()').re_first('Name:\s(.*)')

'My image 1 '

不论正则匹配了几个分组,结果都会等于列表的第一个元素。

15.4 Spider 的使用

值得注意的是,response 对象不能直接调用re 和re_first方法。如果想要对全文进行正则匹配, 可以先调用 xpath 方法再正则匹配,代码如下所示:

>>> response.re('Name:\s(.*)')
Traceback (most recent call last):
File "<console>", line 1, in <module>
AttributeError: 'HtmlResponse' object has no attribute 're'
>>> response.xpath('.').re('Name:\s(.*)<br>')
['My image 1', 'My image 2', 'My image 3', 'My image 4', 'My image 5']
>>> response.xpath('.').re_first('Name:\s(.*)<br>')
'My image 1

通过上面的例子我们可以看到,直接调用re 方法会提示没有re属性。但是这里首先调用了 xpath('.')选中全文,然后调用了re和re_first 方法,就可以进行正则匹配了。

6. 总结

以上便是 Scrapy 选择器的用法,它包括两个常用选择器和正则匹配功能。熟练掌握XPath 语法、 CSS 选择器语法和正则表达式语法,可以大大提高我们的数据提取效率。

在Scrapy中,网站的链接配置、抓取逻辑、解析逻辑其实都是在 Spider 中配置的。在前一节的 实例中,我们发现抓取逻辑也是在Spider 中完成的。本节我们就来专门了解一下Spider 的基本用法。

1. Spider 运行流程

在实现 Scrapy 爬虫项目时,最核心的类便是 Spider类了,它定义了如何爬取某个网站的流程和 解析方式。简单来讲,Spider 就是要做如下两件事:

定义爬取网站的动作;

分析爬取下来的网页。 对于 Spider 类来说,整个爬取循环如下所述。 (1)以初始的URL 初始化 Request 并设置回调方法。当该Request 成功请求并返回时,将生成 Response 并将其作为参数传给该回调方法。 (2) 在回调方法内分析返回的网页内容。返回结果可以有两种形式,一种是将解析到的有效结果 返回字典或 Item对象,下一步可直接保存或者经过处理后保存;另一种是解析的下一个(如下一页) 链接,可以利用此链接构造 Request 并设置新的回调方法,返回 Request。 (3) 如果返回的是字典或 Item 对象,可通过 Feed Exports 等形式存入文件,如果设置了 Pipeline, 可以经由 Pipeline处理(如过滤、修正等)并保存。 (4) 如果返回的是Reqeust,那么 Request 执行成功得到 Response 之后会再次传递给 Request 中定 义的回调方法,可以再次使用选择器来分析新得到的网页内容,并根据分析的数据生成 Item。 循环进行以上几步,便完成了站点的爬取。

2. Spider 类分析

在上一节的例子中,我们定义的Spider 继承自scrapy.spiders.Spider,即 scrapy.Spider类,二 者指代的是同一个类,这个类是最简单最基本的Spider类,其他的Spider 必须继承这个类。 这个类里提供了 start_requests 方法的默认实现,读取并请求 start_urls 属性,然后根据返回 的结果调用 parse方法解析结果。另外它还有一些基础属性,下面对其进行讲解。

name: 爬虫名称,是定义 Spider 名字的字符串。Spider 的名字定义了 Scrapy 如何定位并初 始化 Spider,所以它必须是唯一的。不过我们可以生成多个相同的 Spider 实例,这没有任何限制。 name 是 Spider 最重要的属性,而且是必须的。如果该 Spider 爬取单个网站,一个常见的 做法是以该网站的域名名称来命名 Spider。例如 Spider 爬取 mywebsite.com,该 Spider 通常 会被命名为 mywebsite。

allowed_domains:允许爬取的域名,是一个可选的配置,不在此范围的链接不会被跟进爬取。

start_urls: 起始 URL 列表,当我们没有实现 start_requests 方法时,默认会从这个列表开 始抓取。

custom_settings: 一个字典,是专属于本 Spider 的配置,此设置会覆盖项目全局的设置,而 且此设置必须在初始化前被更新,所以它必须定义成类变量。

crawler: 此属性是由 from_crawler 方法设置的,代表的是本 Spider 类对应的 Crawler 对象, Crawler 对象中包含了很多项目组件,利用它我们可以获取项目的一些配置信息,常见的就是 获取项目的设置信息,即 Settings。

settings:一个 Settings 对象,利用它我们可以直接获取项目的全局设置变量。

除了一些基础属性,Spider 还有一些常用的方法,在此介绍如下。

start_requests: 此方法用于生成初始请求,它必须返回一个可迭代对象,此方法会默认使用 start_urls 里面的 URL 来构造 Request,而且 Request 是 GET 请求方式。如果我们想在启动时以 POST 方式访问某个站点,可以直接重写这个方法,发送 POST 请求时我们使用 FormRequest 即可。

parse: 当 Response 没有指定回调方法时,该方法会默认被调用,它负责处理 Response,并从 中提取想要的数据和下一步的请求,然后返回。该方法需要返回一个包含 Request 或 Item 的 可迭代对象。

closed: 当 Spider 关闭时,该方法会被调用,这里一般会定义释放资源的一些操作或其他收 尾操作。

3. 实例演示

接下来我们以一个实例来演示一下 Spider 的一些基本用法。首先我们创建一个 Scrapy 项目,名 字叫作 scrapyspiderdemo,创建项目的命令如下:

scrapy startproject scrapyspiderdemo

运行完毕后,当前运行目录便出现了一个 scrapyspiderdemo 文件夹,即对应的 Scrapy 项目就创建 成功了。

接着我们进入 demo 文件夹,来针对 www.httpbin.org 这个网站创建一个 Spider,命令如下:

scrapy genspider httpbin www.httpbin.org

这时候我们可以看到项目目录下生成了一个 HttpbinSpider,内容如下:

import scrapy
class HttpbinSpider(scrapy.Spider):
name = 'httpbin'
allowed_domains = ['www.httpbin.org']
start_urls = ['https://www.httpbin.org/']
def parse(self, response):

pass

这时候我们可以在 parse 方法中打印输出一些 response 对象的基础信息,同时修改 start_urls 为 https://www.httpbin.org/get,这个链接可以返回 GET 请求的一些详情信息,最终我们可以将 Spider

name: 爬虫名称,是定义 Spider 名字的字符串。Spider 的名字定义了 Scrapy 如何定位并初 始化 Spider,所以它必须是唯一的。不过我们可以生成多个相同的 Spider 实例,这没有任何限 制。name 是 Spider 最重要的属性,而且是必须的。如果该 Spider 爬取单个网站,一个常见的 做法是以该网站的域名名称来命名 Spider。例如 Spider 爬取 mywebsite.com,该 Spider 通常 会被命名为 mywebsite。

allowed_domains:允许爬取的域名,是一个可选的配置,不在此范围的链接不会被跟进爬取。

start_urls: 起始 URL 列表,当我们没有实现 start_requests 方法时,默认会从这个列表开 始抓取。

custom_settings: 一个字典,是专属于本 Spider 的配置,此设置会覆盖项目全局的设置,而 且此设置必须在初始化前被更新,所以它必须定义成类变量。

crawler: 此属性是由 from_crawler 方法设置的,代表的是本 Spider 类对应的 Crawler 对象, Crawler 对象中包含了很多项目组件,利用它我们可以获取项目的一些配置信息,常见的就是 获取项目的设置信息,即 Settings。

settings:一个 Settings 对象,利用它我们可以直接获取项目的全局设置变量。

除了一些基础属性,Spider 还有一些常用的方法,在此介绍如下。

start_requests: 此方法用于生成初始请求,它必须返回一个可迭代对象,此方法会默认使用 start_urls 里面的 URL 来构造 Request,而且 Request 是 GET 请求方式。如果我们想在启动时以 POST 方式访问某个站点,可以直接重写这个方法,发送 POST 请求时我们使用 FormRequest 即可。

parse: 当 Response 没有指定回调方法时,该方法会默认被调用,它负责处理 Response,并从 中提取想要的数据和下一步的请求,然后返回。该方法需要返回一个包含 Request 或 Item 的 可迭代对象。

closed: 当 Spider 关闭时,该方法会被调用,这里一般会定义释放资源的一些操作或其他收 尾操作。

3. 实例演示

接下来我们以一个实例来演示一下 Spider 的一些基本用法。首先我们创建一个 Scrapy 项目,名 字叫作 scrapyspiderdemo,创建项目的命令如下:

scrapy startproject scrapyspiderdemo

运行完毕后,当前运行目录便出现了一个 scrapyspiderdemo 文件夹,即对应的 Scrapy 项目就创建 成功了。

接着我们进入 demo 文件夹,来针对 www.httpbin.org 这个网站创建一个 Spider,命令如下:

scrapy genspider httpbin www.httpbin.org

这时候我们可以看到项目目录下生成了一个 HttpbinSpider,内容如下:

import scrapy
class HttpbinSpider(scrapy.Spider):
name = 'httpbin'
allowed_domains = ['www.httpbin.org']
start_urls = ['https://www.httpbin.org/']
def parse(self, response):

pass

这时候我们可以在 parse 方法中打印输出一些 response 对象的基础信息,同时修改 start_urls 为 https://www.httpbin.org/get,这个链接可以返回 GET 请求的一些详情信息,最终我们可以将 Spider

修改如下:

import scrapy

class HttpbinSpider (scrapy.Spider):
    name = 'httpbin'
    allowed_domains = ['www.httpbin.org']
    start_urls = ['https://www.httpbin.org/get']

    def parse(self, response):
        print('url', response.url)
        print('request', response.request)
        print('status', response.status)
        print('headers', response.headers)
        print('text', response.text)
        print('meta', response.meta)

这里我们打印了 response 的多个属性。 url: 请求的页面 URL,即 Request URL。 request: response 对应的 request 对象。 status: 状态码,即 Response Status Code。 headers: 响应头,即 Response Headers。 □ text: 响应体,即 Response Body。 meta: 一些附加信息,这些参数往往会附在 meta 属性里。

运行该 Spider,命令如下:

scrapy crawl httpbin

运行结果如下:

...
2020-08-30 01:37:11 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.httpbin.org/get> (referer: None)
url https://www.httpbin.org/get
request <GET https://www.httpbin.org/get>
status 200
headers {b'Date': [b'Sat, 29 Aug 2020 17:37:11 GMT'], b'Content-Type': [b'application/json'], b'Server':
[b'gunicorn/19.9.0'], b'Access-Control-Allow-Origin': [b'*'], b'Access-Control-Allow-Credentials':
[b'true']}
text {
    "args": {},
    "headers": {
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Encoding": "gzip, deflate",
        "Accept-Language": "en",
        "Host": "www.httpbin.org",
        "User-Agent": "Scrapy/2.2.1 (+https://scrapy.org)",
        "X-Amzn-Trace-Id": "Root=1-5f4a9247-770f344d08df9a6c18daa553"
    },
    "origin": "219.142.145.226",
    "url": "https://www.httpbin.org/get"
}
meta {'download_timeout': 180.0, 'download_slot': 'www.httpbin.org', 'download_latency': 0.277271032333374}
2020-08-30 01:37:11 [scrapy.core.engine] INFO: Closing spider (finished)

以上省略了部分结果,只摘取了关键的 parse 方法的输出内容。 可以看到,这里分别打印输出了 url、request、status、headers、text、meta 信息。我们可以观 察一下,text 的内容中包含了我们请求所使用的 User-Agent、请求 IP 等信息,另外 meta 中包含了几 个默认设置的参数。

,这里并没有显式地声明初始请求,是因为Spider 默认为我们实现了一个 start_requests 方 法,代码如下:
def start_requests(self):
for url in self.start urls:
yield Request (url, dont_filter=True)

可以看到,逻辑就是读取 start_urls 然后生成 Request,这里并没有为Request 指定 callback, 默认就是 parse 方法。它是一个生成器,返回的所有Request 都会作为初始 Request 加入调度队列。 因此,如果我们想要自定义初始请求,就可以在 Spider 中重写 start_requests 方法,比如我们想 自定义请求页面链接和回调方法,可以把start_requests 方法修改为下面这样:

import scrapy
from scrapy import Request
class HttpbinSpider (scrapy. Spider):
name = 'httpbin'

allowed domains = ['www.httpbin.org']

start_url = 'https://www.httpbin.org/get'
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko)

Chrome/83.0.4103.116 Safari/537.36'

}
cookies = {'name': 'germey', 'age': '26'}
def start_requests(self):
for offset in range(5):
url = self.start_url + f'?offset={offset}'
yield Request(url, headers=self.headers,
cookies=self.cookies,
callback=self.parse_response,
meta={'offset': offset})
def parse_response(self, response):
print('url', response.url)
print('request', response.request)
print('status', response.status)
print('headers', response.headers)
print('text', response.text)
print('meta', response.meta)

这里我们自定义了如下内容。

url:我们不再依赖 start_urls 生成url,而是声明了一个 start_url,然后利用循环给 URL 加 上了Query 参数,如offset=0,拼接到 https://www.httpbin.org/get后面,这样请求的链接就变 成了https://www.httpbin.org/get?offset=0。

headers:这里我们还声明了 headers 变量,为它添加了User-Agent 属性并将其传递给 Request 的headers 参数进行赋值。

cookies:另外我们还声明了 Cookie,以一个字典的形式声明,然后传给Request的cookies 参数。 callback: 在 HttpbinSpider中,我们声明了一个 parse_response 方法,同时我们也将 Request 的 callback 参数设置为 parse_response,这样当该 Request请求成功时就会回调 parse_response方 法进行处理。

meta: meta可以用来传递额外参数,这里我们将offset 的值也赋值给Request,通过response.meta 就能获取这个内容了,这样就实现了 Request 到 Response 的额外信息传递。 重新运行看看效果,输出内容如下:

2020-08-30 01:54:19 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.httpbin.org/get?offset=1> (referer: None)
2020-08-30 01:54:19 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.httpbin.org/get?offset=2> (referer: None)
2020-08-30 01:54:19 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.httpbin.org/get?offset=3> (referer: None)
2020-08-30 01:54:19 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.httpbin.org/get?offset=4> (referer: None)

url https://www.httpbin.org/get?offset=1

request <GET https://www.httpbin.org/get?offset=1>

status 200

headers {b'Date': [b'Sat, 29 Aug 2020 17:54:19 GMT'], b'Content-Type': [b'application/json'], b'Server':
[b'gunicorn/19.9.0'], b'Access-Control-Allow-Origin': [b'*'], b'Access-Control-Allow-Credentials': [b'true']}
text {
"args": {
"offset": "1"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en",
"Cookie": "name=germey; age=26",
"Host": "www.httpbin.org",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko)

Chrome/83.0.4103.116 Safari/537.36",

"X-Amzn-Trace-Id": "Root=1-5f4a964b-8948a0c4fc6cd3ac2e5573ac"
},
"origin": "219.142.145.226",
"url": "https://www.httpbin.org/get?offset=1"
}
meta {'offset': 1, 'download_timeout': 180.0, 'download_slot': 'www.httpbin.org', 'download_latency':
0.6104061603546143}

url https://www.httpbin.org/get?offset=2

request <GET https://www.httpbin.org/get?offset=2>

这时候我们看到相应的设置就成功了。

url:url 上多了我们添加的 Query 参数。

text: 结果的 headers 可以看到 Cookie 和 User-Agent, 说明 Request 的 Cookie 和 User-Agent 都 设置成功了。

meta:meta 中看到了 offset 这个参数, 说明通过 meta 可以成功传递额外的参数。

通过上面的案例, 我们就大致知道了 Spider 的基本流程和配置, 可以发现其实现还是很灵活的。

当然除了发起 GET 请求, 我们还可以发起 POST 请求。 POST 请求主要分为两种, 一种是以 Form Data 的形式提交表单, 一种是发送 JSON 数据, 二者分别可以使用 FormRequest 和 JsonRequest 来实 现。 例如我们可以分别发起两种 POST 请求, 对比一下结果:

import scrapy
from scrapy.http import JsonRequest, FormRequest
class HttpbinSpider(scrapy.Spider):
name = 'httpbin'
allowed_domains = ['www.httpbin.org']
start_url = 'https://www.httpbin.org/post'
data = {'name': 'germey', 'age': '26'}
def start_requests(self):
yield FormRequest(self.start_url,
callback=self.parse_response,
formdata=self.data)
yield JsonRequest(self.start_url,
callback=self.parse_response,
data=self.data)
def parse_response(self, response):
print('text', response.text)

这里我们利用 start_requests 方法生成了一个FormRequest 和JsonRequest, 请求的页面链接修改

为了https://www.httpbin.org/post,它可以把POST请求的详情返回,另外 data 保持不变。 运行结果如下:

2020-08-30 02:11:38 [scrapy.core.engine] DEBUG: Crawled (200) <POST https://www.httpbin.org/post> (referer: None)
text {
"args": {},
"data": "",
"files": {},
"form": {
"age": "26",
"name": "germey"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en",
"Content-Length": "18",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "www.httpbin.org",
"User-Agent": "Scrapy/2.2.1 (+https://scrapy.org)",
"X-Amzn-Trace-Id": "Root=1-5f4a9a59-f0ad26f76d7577cfcb201dc6"
},
"json": null,
"origin": "219.142.145.226",
"url": "https://www.httpbin.org/post"
}
2020-08-30 02:11:38 [scrapy.core.engine] DEBUG: Crawled (200) <POST https://www.httpbin.org/post> (referer: None)
text {
"args": {},
"data": "{\"age\":\"26\", \"name\": \"germey\"}",
"files": {},
"form": {},
"headers": {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en",
"Content-Length": "31",
"Content-Type": "application/json",
"Host": "www.httpbin.org",
"User-Agent": "Scrapy/2.2.1 (+https://scrapy.org)",
"X-Amzn-Trace-Id": "Root=1-5f4a9a5a-50a95cdc881a9dc0bf3c9f28"
},
"json": {
"age": "26",
"name": "germey"
},
"origin": "219.142.145.226",
"url": "https://www.httpbin.org/post"
}

这里我们可以看到两种请求的效果。 第一个 FormRequest,我们可以观察到页面返回结果的form 字段就是我们请求时添加的。 data 内 容,这说明实际上是发送了Content-Type为application/x-www-form-urlencoded 的POST请求,这种 对应的就是表单提交。 第二个 JsonRequest,我们可以观察到页面返回结果的json字段就是我们所请求时添加的data 内 容,这说明实际上是发送了 Content-Type 为 application/json 的POST 请求,这种对应的就是发送 JSON数据。 这两种 POST请求的发送方式我们需要区分清楚,并根据服务器的实际需要进行选择。

4. Request 和 Response

在上面的 Spider 例子中,大部分流程实际是在构造 Request 对象和解析 Response 对象,因此对于 它们的用法和参数我们需要详细了解一下。

Request

在Scrapy中,Request 对象实际上指的就是scrapy.http.Request的一个实例,它包含了HTTP请 求的基本信息,用这个 Request 类我们可以构造 Request 对象发送 HTTP请求,它会被 Engine 交给 Downloader 进行处理执行,返回一个 Response 对象。 这个 Request 类怎么使用呢?那自然要了解一下它的构造参数都有什么,梳理如下。 url: Request 的页面链接,即 Request URL。 callback: Request 的回调方法,通常这个方法需要定义在Spider 类里面,并且需要对应一个 response 参数,代表Request 执行请求后得到的 Response 对象。如果这个 callback 参数不指 定,默认会使用 Spider 类里面的 parse 方法。 method: Request 的方法,默认是GET,还可以设置为 POST、PUT、DELETE等。 meta: Request 请求携带的额外参数,利用meta,我们可以指定任意处理参数,特定的参数经 由 Scrapy 各个组件的处理,可以得到不同的效果。另外,meta还可以用来向回调方法传递信息。 body: Request 的内容,即 Request Body,往往 Request Body 对应的是POST请求,我们可以 使用 FormRequest 或 JsonRequest 更方便地实现 POST请求。 headers: Request Headers,是字典形式。 cookies: Request 携带的Cookie,可以是字典或列表形式。 encoding: Request的编码,默认是utf-8。 prority: Request 优先级,默认是0,这个优先级是给 Scheduler 做 Request 调度使用的,数值 越大,就越被优先调度并执行。 dont_filter: Request 不去重,Scrapy 默认会根据 Request 的信息进行去重,使得在爬取过程 中不会出现重复请求,设置为 True 代表这个 Request 会被忽略去重操作,默认是 False。 errback:错误处理方法,如果在请求处理过程中出现了错误,这个方法就会被调用。 flags:请求的标志,可以用于记录类似的处理。 cb_kwargs:回调方法的额外参数,可以作为字典传递。 以上便是 Request 的构造参数,利用这些参数,我们可以灵活地实现 Request 的构造。 值得注意的是,meta参数是一个十分有用而且易扩展的参数,它可以以字典的形式传递,包含的 信息不受限制,所以很多 Scrapy 的插件会基于 meta 参数做一些特殊处理。在默认情况下,Scrapy 就 预留了一些特殊的key 作为特殊处理。 比如 request.meta['proxy']可以用来设置请求时使用的代理,request.meta['max_retry_times'] 可以设置用来设置请求的最大重试次数等。 更多具体内容可以参见:https://docs.scrapy.org/en/latest/topics/request-response.html#request-meta- special-keys. 另外如上文所介绍的,Scrapy还专门为POST请求提供了两个类——— FormRequest 和 JsonRequest, 它们都是 Request 类的子类,我们可以利用 FormRequest 的 formdata 参数传递表单内容,利用 JsonRequest 的 json 参数传递 JSON 内容,其他的参数和 Request 基本是一致的。二者的详细介绍可 以参考官方文档:

FormRequest: https://docs.scrapy.org/en/latest/topics/request-response.html#formrequest-objects JsonRequest: https://docs.scrapy.org/en/latest/topics/request-response.html#jsonrequest

Response

Request 由 Downloader 执行之后,得到的就是Response 结果了,它代表的是HTTP请求得到的响 应结果,同样地我们可以梳理一下其可用的属性和方法,以便我们做解析处理使用。 ☐ url: Request URL。 ☐ status: Response 状态码,如果请求成功就是200。 ☐ headers: Response Headers,是一个字典,字段是一一对应的。 ☐ body: Response Body,这个通常就是访问页面之后得到的源代码结果了,比如里面包含的是 HTML 或者 JSON 字符串,但注意其结果是 bytes 类型。 ☐ request: Response 对应的 Request 对象。 ☐ certificate:是twisted.internet.ssl.Certificate 类型的对象,通常代表一个SSL证书对象。 ☐ ip_address:是一个ipaddress. IPv4Address 或ipaddress. IPv6Address类型的对象,代表服务 器的IP地址。 ☐ urljoin:是对 URL 的一个处理方法,可以传入当前页面的相对URL,该方法处理后返回的 就是绝对URL。 ☐ follow / follow_all:是一个根据 URL 来生成后续 Request 的方法,和直接构造 Request 不同 的是,该方法接收的url可以是相对 URL,不必一定是绝对URL。 另外 Response 还有几个常用的子类,如TextResponse 和 HtmlResponse, HtmlResponse 又是 TextResponse 的子类,实际上回调方法接收的 response 参数就是一个 HtmlResponse 对象,它还有几 个常用的方法或属性。 ☐ text:同 body 属性,但结果是 str类型。 ☐ encoding: Response 的编码,默认是 utf-8。 ☐ selector:根据 Response 的内容构造而成的 Selector 对象, Selector 在上一节我们已经了解 过,利用它我们可以进一步调用 xpath、css 等方法进行结果的提取。 ☐ xpath:传入 XPath 进行内容提取,等同于调用 selector 的 xpath 方法。 ☐ css:传入 CSS 选择器进行内容提取,等同于调用 selector 的css方法。 ☐ json:是 Scrapy 2.2 新增的方法,利用该方法可以直接将 text 属性转为 JSON 对象。 以上便是对 Response 的基本介绍,关于 Response 更详细的解释可以参考官方文档:https://docs. scrapy.org/en/latest/topics/request-response.html#response-subclasses。

5. 总结

本节中我们介绍了 Spider 的基本使用方法以及 Request、Response 对象的基本数据结构,通过了 解本节内容,我们便可以灵活地完成爬取逻辑的定制了。 本节代码参见:https://github.com/Python3 WebSpider/ScrapySpiderDemo。

15.5 Downloader Middleware 的使用

Downloader Middleware 即下载中间件。在15.1节我们已经提到过,它是处于Scrapy 的Engine 和 Downloader 之间的处理模块。在Engine 把从 Scheduler 获取的Request 发送给 Downloader 的过程中, 以及 Downloader 把 Response 发送回 Engine 的过程中,Request 和 Response 都会经过 Downloader Middleware 的处理,如图15-8所示。

图15-8 Downloader Middleware
图15-8 Downloader Middleware

也就是说,Downloader Middleware 在整个架构中起作用的位置是以下两个。 □ Engine 从 Scheduler 获取 Request 发送给 Downloader,在 Request 被 Engine 发送给 Downloader 执行下载之前,Downloader Middleware 可以对 Request 进行修改。 □ Downloader 执行 Request 后生成 Response,在Response 被 Engine 发送给 Spider 之前,也就是 在Resposne 被 Spider 解析之前,Downloder Middleware 可以对 Response 进行修改。 不要小看 Downloder Middleware,其实它在整个爬虫执行过程中能起到非常重要的作用,功能十 分强大,修改 User-Agent、处理重定向、设置代理、失败重试、设置Cookie等功能都需要借助它来实 现。 本节我们来了解一下 Downloader Middleware的详细用法。

1. 使用说明

需要说明的是,Scrapy已经提供了许多 Downloader Middleware,比如负责失败重试、自动重定向 等功能的 Downloader Middleware,它们被 DOWNLOADER_MIDDLEWARES_BASE 变量所定义。 DOWNLOADER_MIDDLEWARES_BASE 变量的内容如下所示:

{
'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100,
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300,
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware': 350,
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware':

400,

'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
'scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware': 560,
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware': 580,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 590,
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': 600,
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
'scrapy.downloadermiddlewares.stats.DownloaderStats': 850,
'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,
}

这是一个字典格式,字典的键名是Scrapy 内置的 Downloader Middleware 的名称,键值代表了调 用的优先级,优先级是一个数字,数字越小代表越靠近 Engine,数字越大代表越靠近 Downloader。 在默认情况下,Scrapy已经为我们开启了 DOWNLOADER_MIDDLEWARES_BASE 所定义的 Downloader Middleware,比如 RetryMiddleware 带有自动重试功能,RedirectMiddleware 带有自动处理重定向功能, 这些功能默认都是开启的。 那 Downloader Middleware 里面究竟是怎么实现的呢? 其实每个 Downloader Middleware 都可以通过定义 process_request 和 process_response 方法来 分别处理 Request 和Response,被开启的 Downloader Middleware 的 process_request 方法和 process_response 方法会根据优先级被顺次调用。 由于 Request 是从 Engine 发送给 Downloader 的,并且优先级数字越小的 Downloader Middleware 越靠近 Engine,所以优先级数字越小的 Downloader Middleware 的 process_request 方法越先被调用。

process_response 方法则相反,由于 Response 是由Downloder 发送给Engine的,优先级数字越大的 Downloader Middleware 越靠近 Downloader,所以优先级数字越大的 Downloader Middleware 的 proces_response 越先被调用。

如果我们想将自定义的 Downloader Middleware 添加到项目中,不要直接修改 DOWNLOADER_ MIDDLEWARES_BASE变量。Scrapy 提供了另外一个设置变量 DOWNLOADER_MIDDLEWARES,我们直接修改这 个变量就可以添加自己定义的Downloader Middleware,以及禁用 DOWNLOADER_MIDDLEWARES_BASE 里面 定义的 Downloader Middleware了。

说了这么多可能比较抽象,下面我们具体来看一看 Downloader Middleware 的使用方法,然后结 合案例来体会一下 Downloader Middleware 的使用方法。

2. 核心方法

Scrapy 内置的 Downloader Middleware为Scrapy 提供了基础的功能,但在项目实战中,我们往往 需要单独定义 Downloader Middleware。不用担心,这个过程非常简单,我们只需要实现几个方法。 每个 Downloader Middleware 都定义了一个或多个方法的类,核心的方法有如下3个:

process_request(request, spider)

process_response (request, response, spider)

process_exception(request, exception, spider)

我们只需要实现至少一个方法,就可以定义一个Downloader Middleware。下面我们来看看这3个 方法的详细用法。

process_request(request, spider)

Request 被 Engine 发送给 Downloader 之前,process_request 方法就会被调用,也就是在 Request 从 Scheduler 里被调度出来发送到 Downloader 下载执行之前,我们都可以用 process_request 方法对 Request 进行处理。 这个方法的返回值必须为None、Response 对象、Request对象三者之一,或者抛出 IgnoreRequest 异常。 process_request 方法的参数有两个。 request: Request对象,即被处理的Request。 spider: Spdier对象,即此 Request 对应的Spider 对象。 返回类型不同,产生的效果也不同。下面归纳一下不同的返回情况。

当返回是 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 方法就会回调。如果 该异常还没有被处理,那么它便会被忽略。

• process_response(request, response, spider)

Downloader 执行 Request 下载之后,会得到对应的 Response。Engine 便会将 Response 发送给 Spider 进行解析。在发送给 Spider 之前,我们都可以用 process_response 方法来对 Response 进行处 理。process_response 方法的返回值必须为 Request 对象和 Response 对象两者之一,或者抛出 IgnoreRequest 异常。 process_response 方法的参数有3个。 □ request: Request对象,即此 Response 对应的Request。 □ response: Response 对象,即被处理的Response。 □ spider: Spider 对象,即此 Response 对应的 Spider对象。 下面对不同的返回情况做一下归纳。 □ 当返回为 Request 对象时,更低优先级的Downloader Middleware 的 process_response 方法不 会继续调用。该 Request 对象会重新放到调度队列里等待被调度,相当于一个全新的Request。 然后,该 Request 会被 process_request 方法顺次处理。 □ 当返回为 Response 对象时,更低优先级的Downloader Middleware 的 process_response 方法会 继续被调用,对该 Response 对象进行处理。 □ 如果抛出 IgnoreRequest异常,则Request的errorback 方法会回调。如果该异常还没有被处理, 那么它会被忽略。

• process_exception(request, exception, spider)

当Downloader 或 process_request 方法抛出异常时,例如抛出 IgnoreRequest 异常,process_exception 方法就会被调用。方法的返回值必须为 None、Response 对象、Request 对象三者之一。 process_exception 方法的参数有3个。 □ request: Request对象,即产生异常的Request。 □ exception: Exception对象,即抛出的异常。 □ spdier: Spider 对象,即 Request 对应的 Spider。 下面归纳一下不同的返回值。 □ 当返回为 None时,更低优先级的 Downloader Middleware 的 process_exception 会被继续顺次 调用,直到所有的方法都被调用完毕。 □ 当返回为 Response 对象时,更低优先级的Downloader Middleware 的 process_exception 方法 不再被继续调用,每个 Downloader Middleware 的 process_response 方法转而被依次调用。 □ 当返回为 Request对象时,更低优先级的Downloader Middleware 的 process_exception 也不再 被继续调用,该 Request 对象会重新放到调度队列里面等待被调度,相当于一个全新的 Request。然后,该Request又会被 process_request 方法顺次处理。 以上内容便是这3个方法的详细使用逻辑。在使用它们之前,请先对这3个方法的返回值的处 理情况有一个清晰认识。在自定义 Downloader Middleware 的时候,也一定要注意每个方法的返回 类型。

3. 项目实战

上面的内容确实有点难以理解,下面我们可以结合一个实战项目来加深对 Downloader Middleware 的认识。 首先让我们新建一个Scrapy项目,名字叫作 scrapydownloadermiddlewaredemo,命令如下所示:

scrapy startproject scrapydownloadermiddlewaredemo

接下来进入项目,新建一个Spider,我们还是以 https://www.httpbin.org/为例来进行演示,命令如 下所示:

scrapy genspider httpbin www.httpbin.org

命令执行完毕后,就新建了一个Spider,名为httpbin。 接下来我们修改 start_urls为:['https://www.httpbin.org/get']。随后将 parse 方法添加一行 打印输出,将 response 变量的 text 属性输出,这样我们便可以看到Scrapy 发送的Request 信息了。 修改 Spider 内容如下所示:

import scrapy
class HttpbinSpider (scrapy. Spider):
name = 'httpbin'
allowed_domains = ['www.httpbin.org']
start_urls = ['https://www.httpbin.org/get']
def parse(self, response):
print(response.text)

接下来运行此 Spider,执行如下命令:

scrapy crawl httpbin

Scrapy 的运行结果包含Scrapy 发送的Request信息,内容如下所示:

{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en",
"Host": "www.httpbin.org",
"User-Agent": "Scrapy/2.2.1 (+https://scrapy.org)",
"X-Amzn-Trace-Id": "Root=1-5f4bd897-5343c5d080342b8069f69600"
},
"origin": "219.142.145.226",
"url": "https://www.httpbin.org/get"
}

我们观察一下 headers, Scrapy 发送的 Request 使用的 User-Agent 是 Scrapy/2.2.1 (+https:// scrapy.org),这其实是由 Scrapy 内置的 UserAgentMiddleware 设置的,UserAgentMiddleware 的源码如 下所示:

from scrapy import signals
class UserAgentMiddleware(object):
def __init__(self, user_agent='Scrapy'):
self.user_agent = user_agent

@classmethod

def from_crawler (cls, crawler):
o = cls(crawler.settings['USER_AGENT'])
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
def spider_opened (self, spider):
self.user_agent = getattr(spider, 'user_agent', self.user_agent)
def process_request(self, request, spider):
if self.user_agent:
request.headers.setdefault (b'User-Agent', self.user_agent)

在from_crawler方法中,UserAgentMiddleware 首先尝试获取 settings 里面的USER_AGENT,然后 把 USER_AGENT 传递给__init__方法进行初始化,其参数就是 user_agent。如果没有传递 USER_AGENT 参 数,就会默认将其设置为 Scrapy 字符串。我们新建的项目没有设置 USER_AGENT,所以这里的 user_agent 变量就是 Scrapy。

接下来,在 process_request 方法中,将user_agent 变量设置为 headers 变量的一个属性,这样就 成功设置了 User-Agent。因此,User-Agent 就是通过此 Downloader Middleware 的 process_request 方法 设置的,这就是一个典型的Downloder Middleware 的实例,我们再看一下 DOWNLOADER_MIDDLEWARES_BASE 的配置,UserAgentMiddleware 的配置如下:

{
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500
}

可以看到,UserAgentMiddleware 被配置在了默认的 DOWNLOADER_MIDDLEWARES_BASE里,优先级为 500,这样每次 Request 在被 Downloader 执行前都会被 UserAgentMiddleware 的 process_request 方法 加上默认的 User-Agent。

但如果这个默认的 User-Agent 直接去请求目标网站,很容易被检测出来,我们需要将 User-Agent 修改为常见浏览器的User-Agent。修改 User-Agent 可以有两种方式:

一是修改 settings 里面的USER_AGENT 变量。

二是通过 Downloader Middleware 的 process_request 方法来修改。

第一种方法非常简单,我们只需要在 setting.py 里面加一行对 USER_AGENT 的定义即可:

`USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36'`

一般推荐使用此方法来进行设置。但是如果想设置得更灵活,比如设置随机的 User-Agent,那就 需要借助 Downloader Middleware了。所以接下来我们用 Downloader Middleware 实现一个随机 User-Agent 的设置。

在middlewares.py 里面添加一个 RandomUserAgentMiddleware类,代码如下所示:

import random

class RandomUserAgentMiddleware(object):
    def __init__(self):
        self.user_agents = [
            'Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)',
            'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.2 (KHTML, like Gecko) Chrome/22.0.1216.0
            Safari/537.2',
            'Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:15.0) Gecko/20100101 Firefox/15.0.1'
        ]

    def process_request(self, request, spider):
        request.headers['User-Agent'] = random.choice(self.user_agents)

我们首先在类的__init__方法中定义了3个不同的User-Agent,并用一个列表来表示。接下来实 现了 process_request 方法,它有一个参数 request,我们直接修改 request 的属性即可。在这里直接 设置了 request 对象的 headers 属性的 User-Agent,设置内容是随机选择的 User-Agent,这样一个 Downloader Middleware 就写好了。

不过,要使之生效还需要去调用这个 Downloader Middleware。在settings.py中,将DOWNLOADER_ MIDDLEWARES 取消注释,并设置成如下内容:

DOWNLOADER_MIDDLEWARES = {
    'scrapydownloadermiddlewaredemo.middlewares.RandomUserAgentMiddleware': 543,
}

接下来我们重新运行 Spider,就可以看到 User-Agent 被成功修改为列表中所定义的随机的一个 User-Agent了:

{
    "args": {},
    "headers": {
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Encoding": "gzip, deflate",
        "Accept-Language": "en",
        "Host": "www.httpbin.org",
        "User-Agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.2 (KHTML, like Gecko) Chrome/22.0.1216.0 Safari/537.2",
        "X-Amzn-Trace-Id": "Root=1-5f4bdb4d-287f2430aa14d37a6ab50ff6"
    },
    "origin": "219.142.145.226",
    "url": "https://www.httpbin.org/get"
}

我们通过实现 Downloader Middleware 并利用 process_request方法,成功设置了随机的 User-Agent。 另外我们还可以借助 Downloader Middleware 来设置代理。比如这里我有一个HTTP代理运行在 203.184.132.103:7890,如果我想使用此代理请求目标站点,可以通过定义一个Downloader Middleware 来设置:

class ProxyMiddleware(object):
    def process_request(self, request, spider):
        request.meta['proxy'] = 'http://203.184.132.103:7890'

这里我们定义了一个ProxyMiddleware,在它的 process_request 方法里面,修改了request 的 meta 属性的 proxy 属性,赋值为 http://203.184.132.103:7890,这样就相当于设置了一个HTTP代理。 注意此代理并不一定是长期可用代理,你需要将其更换成你自己的可用HTTP代理,有关代理的 具体获取方案可以参考本书前文代理的使用相关章节。 要使 ProxyMiddleware生效,我们需要进一步启用这个ProxyMiddleware,修改 DOWNLOADER_MIDDLEWARES 为如下内容:

DOWNLOADER_MIDDLEWARES = {
    'scrapydownloadermiddlewaredemo.middlewares.RandomUserAgentMiddleware': 543,
    'scrapydownloadermiddlewaredemo.middlewares.ProxyMiddleware': 544
}

这样我们就启用了两个自定义的 Downloader Middleware,执行优先级分别为543和544。 RandomUserAgentMiddleware 的 process_request 方法会首先被调用,为Request 赋值 User-Agent,随 后 ProxyMiddleware 的 process_request 会被调用,为Request 赋值 meta 的proxy 属性。 重新运行,可以发现输出结果如下:

{
    "args": {},
    "headers": {
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Encoding": "gzip, deflate",
        "Accept-Language": "en",
        "Host": "www.httpbin.org",
}
},
"User-Agent": "Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)",
"X-Amzn-Trace-Id": "Root=1-5f4bdcb5-ccb0bd00a31cc4806ab91780"
"origin": "203.184.132.103",
"url": "https://www.httpbin.org/get"

这里我们看到网站返回结果的 origin 字段就是代理的IP,所以可以验证出:User-Agent 和代理 的设置都生效了。至于代理为什么生效,是因为 Scrapy 对 meta 的 proxy 属性做了针对性处理,使得 最终发送的HTTP 请求启用我们配置的代理服务器,具体的处理逻辑可以查看 Scrapy 的 Downloader Middleware 和Downloader 的源代码。

我们使用 process_request 对 Request 进行了修改,但刚才写的两个 Downloader Middleware 的 process_request 都没有返回值,即返回值为None,这样一个个 Downloader Middleware 的 process_request 就会被顺次执行。

上文我们还提到了 process_request,如果返回其他形式的内容会怎样?比如 process_request 直 接返回 Request。我们修改 ProxyMiddleware 试一下:

class ProxyMiddleware(object):
    def process_request(self, request, spider):
        request.meta['proxy'] = 'http://127.0.0.1:7890'
        return request

这里我们在方法的最后加上了返回 Request 的逻辑,根据前文介绍的内容,如果process_request 返回的是一个 Request,那么后续其他 Downloader Middleware 的 process_request 就不会被调用,这 个Request 会直接发送给 Engine 并加回到 Scheduler,等待下一次被调度。由于现在我们只发起了一个 Request,所以下一个被调度的Request 还是这个 Request,然后会再次经过 process_request 方法处理, 接着再次被返回,又一次被加回到 Scheduler,这样这个Request 就不断从 Scheduler 取出来放回去, 导致无限循环。

因此,这时候运行会得到一个递归错误的报错信息:

RecursionError: maximum recursion depth exceeded while calling a Python object

所以说,这一句简单的返回逻辑就整个改变了 Scrapy 爬虫的执行逻辑,一定要注意。

另外,如果我们返回一个 Response 会怎么办呢?根据前文所述,更低优先级的 Downloader Middleware 的 process_request 和 process_exception 方法就不会被继续调用,每个 Downloader Middleware 的 process_response 方法转而被依次调用。调用完毕后,直接将 Response 对象发送给 Spider 来处理。所以说,如果返回的是Response,会直接被 process_response 处理完毕后发送给Spider, 而该 Request 就不会再经由 Downloader 执行下载了。

我们再尝试改写一下 ProxyMiddleware,修改如下:

from scrapy.http import HtmlResponse
class ProxyMiddleware(object):
    def process_request(self, request, spider):
        return HtmlResponse(
            url=request.url,
            status=200,
            encoding='utf-8',
            body='Test Downloader Middleware')

这里我们直接把代理设置的逻辑去掉了,返回了一个 HtmlResponse 对象,构造 HtmlResponse 对 象时传入了 url、status、encoding、body参数,其中直接赋给 body一个字符串。

重新运行一下,看看输出结果: Test Downloader Middleware 这就是 parse 方法的输出结果,可以看到原本 Request 应该去请求 https://www.httpbin.org/get 得到 返回结果,但是这里 Response 的内容直接变成了刚才我们所定义的HtmlResponse 的内容,丢弃了原 本的Request。因此,如果我们在 process_request 方法中直接返回 Response 对象,原先的Request 就 会被直接丢弃,该 Response 经过 process_response 方法处理后会直接传递给 Spider 解析。 到现在为止,我们应该能够明白 process_request 的用法及其不同的返回值所起到的作用了。 上面我们讲了 process_request 的用法,它是用来处理 Request的,相应地,process_response 就 是用来处理 Response的了,我们再来看一下 process_response 的用法。 Downloader 对 Request 执行下载之后会得到 Response, 随后 Engine 会将 Response 发送回 Spider 进 行处理。但是在Response 被发送给Spider 之前,我们同样可以使用process_response 方法对 Response 进行处理。 比如这里修改一下 Response 的状态码,添加一个 ChangeResponseMiddleware 的 Downloader Middleware,代码如下:

class ChangeResponseMiddleware(object):
def process_response(self, request, response, spider):
response.status = 201
return response

我们将 response 对象的 status 属性修改为201,随后将 response 返回,这个被修改的Response 就 会被发送到 Spider。 我们再在 Spider 里面输出修改后的状态码,在parse方法中添加如下的输出语句:

print('Status Code:', response.status)

然后将 DOWNLOADER_MIDDLEWARES 修改为如下内容:

DOWNLOADER_MIDDLEWARES = {
'scrapydownloadermiddlewaredemo.middlewares.RandomUserAgentMiddleware': 543,
'scrapydownloadermiddlewaredemo.middlewares.ChangeResponseMiddleware': 544,
}

接着将 ProxyMiddleware 换成了ChangeResponseMiddleware,重新运行,控制台输出了如下内容: Status Code: 201 可以发现,Response 的状态码被成功修改了。因此如果要想对 Response 进行处理,就可以借助 process_response 方法。 当然 process_response 方法的不同返回值有不同的作用,如果返回 Request对象,更低优先级的 Downloader Middleware 的 process_request 方法会停止执行。这个Request 会重新放到调度队列里, 其实它就是一个全新的Request,等待被调度。感兴趣的话可以尝试一下。 另外还有一个 process_exception 方法,它是专门用来处理异常的方法。如果需要进行异常处理, 我们可以调用此方法。不过这个方法的使用频率相对低一些,不在进行实例演示。

4. 总结

本节讲解了 Downloader Middleware 的基本用法。此组件非常重要,后面我们进行代理设置、反 爬处理、动态渲染处理都需要用到 Downloader Middleware。 本节代码参见:https://github.com/Python3WebSpider/ScrapyDownloaderMiddlewareDemo。

15.6 Spider Middleware 的使用

Spider Middleware,中文可以翻译为爬虫中间件,但我个人认为英文的叫法更为合适。它是处于 Spider 和 Engine 之间的处理模块。当Downloader 生成 Response 之后,Response 会被发送给 Spider, 在发送给 Spider 之前,Response 会首先经过 Spider Middleware 的处理,当Spider 处理生成 Item 和 Request 之后,Item 和Request 还会经过 Spider Middleware 的处理。 Spider Middleware 有如下3个作用。

Downloader 生成 Response 之后,Engine 会将其发送给 Spider 进行解析,在Response 发送给 Spider 之前,可以借助 Spider Middleware 对 Response 进行处理。

Spider 生成 Request 之后会被发送至Engine,然后 Request 会被转发到Scheduler,在Request 被 发送给 Engine 之前,可以借助 Spider Middleware 对 Request 进行处理。

Spider 生成 Item 之后会被发送至Engine,然后 Item 会被转发到 Item Pipeline,在Item 被发送 给 Engine 之前,可以借助 Spider Middleware 对 Item 进行处理。 总的来说,Spider Middleware 可以用来处理输入给 Spider 的Response 和 Spider 输出的 Item 以及 Request.

1. 使用说明

同样需要说明的是,Scrapy其实已经提供了许多 Spider Middleware,与Downloader Middleware类 似,它们被 SPIDER_MIDDLEWARES_BASE 变量所定义。 SPIDER_MIDDLEWARES_BASE 变量的内容如下:

{
'scrapy.spidermiddlewares.httperror.HttpErrorMiddleware': 50,
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware': 500,
'scrapy.spidermiddlewares.referer.RefererMiddleware': 700,
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware': 800,
'scrapy.spidermiddlewares.depth.DepthMiddleware': 900,
}

SPIDER_MIDDLEWARES_BASE 里定义的 Spider Middleware 是默认生效的,如果我们要自定义 Spider Middleware,可以和 Downloader Middleware一样,创建 Spider Middleware 并将其加入SPIDER_MIDDLEWARES。 直接修改这个变量就可以添加自己定义的Spider Middleware,以及禁用SPIDER_MIDDLEWARES_BASE 里面 定义的 Spider Middleware。 这些 Spider Middleware 的调用优先级和 Downloader Middleware 也是类似的,数字越小的 Spider Middleware 是越靠近 Engine的,数字越大的Spider Middleware 是越靠近Spider的。

2. 核心方法

Scrapy 内置的 Spider Middleware为Scrapy 提供了基础的功能。如果我们想要扩展其功能,只需 要实现某几个方法。 每个 Spider Middleware 都定义了以下一个或多个方法的类,核心方法有如下4个。

process_spider_input(response, spider)

process_spider_output(response, result, spider)

process_spider_exception(response, exception, spider)

process_start_requests(start_requests, spider)

只需要实现其中一个方法就可以定义一个Spider Middleware。下面我们来看看这4个方法的详细用法。

• process_spider_input(response, spider)

当Response 通过 Spider Middleware 时, process_spider_input 方法被调用,处理该 Response。 它有两个参数。 response: Response 对象,即被处理的Response。 spider: Spider 对象,即该 Response 对应的Spider 对象。 process_spider_input 应该返回 None 或者抛出一个异常。 如果它返回 None, Scrapy 会继续处理该 Response,调用所有其他的 Spider Middleware 直到 Spider 处理该 Response。 如果它抛出一个异常,Scrapy 不会调用任何其他 Spider Middleware 的 process_spider_input 方法, 并调用 Request 的 errback 方法。errback 的输出将会以另一个方向被重新输入中间件, 使用 process_spider_output 方法来处理,当其抛出异常时则调用 process_spider_exception 来处理。

• process_spider_output(response, result, spider)

当 Spider 处理 Response 返回结果时,process_spider_output 方法被调用。它有3个参数。 response: Response 对象,即生成该输出的 Response。 result:包含 Request 或 Item 对象的可迭代对象,即Spider返回的结果。 spider: Spider 对象,即结果对应的Spider 对象。 process_spider_output 必须返回包含 Request 或 Item 对象的可迭代对象。

• process_spider_exception(response, exception, spider)

当Spider 或 Spider Middleware 的 process_spider_input 方法抛出异常时,process_spider_exception 方法被调用。它有3个参数。 response: Response 对象,即异常被抛出时被处理的 Response。

exception: Exception 对象,被抛出的异常。

spider: Spider对象,即抛出该异常的Spider对象。 process_spider_exception 必须返回 None 或者一个(包含Response 或Item对象的)可迭代对象。 如果它返回 None,那么 Scrapy 将继续处理该异常,调用其他 Spider Middleware 中的 process_spider_exception 方法,直到所有 Spider Middleware 都被调用。 如果它返回一个可迭代对象,则其他 Spider Middleware 的 process_spider_output 方法被调用, 其他的 process_spider_exception 不会被调用。

• process_start_requests(start_requests, spider)

process_start_requests 方法以 Spider 启动的Request 为参数被调用,执行的过程类似于 process_spider_output,只不过它没有相关联的 Response 并且必须返回 Request。它有两个参数。 start_requests:包含 Request 的可迭代对象,即 Start Requests。 spider: Spider对象,即 Start Requests 所属的 Spider。 process_start_requests 方法必须返回另一个包含 Request 对象的可迭代对象。

3. 实战

上面的内容理解起来还是有点抽象,下面我们结合一个实战项目来加深一下对 Spider Middleware 的认识。 首先我们新建一个Scrapy 项目叫作 scrapyspidermiddlewaredemo,命令如下所示:

scrapy startproject scrapyspidermiddlewaredemo

然后进入项目,新建一个Spider。我们还是以 https://www.httpbin.org/为例来进行演示,命令如下 所示:

scrapy genspider httpbin www.httpbin.org

命令执行完毕后,新建了一个名为 httpbin 的 Spider。接下来我们修改 start_url 为 https:// www.httpbin.org/get,然后自定义 start_requests 方法,构造几个 Request,回调方法还是定义为 parse 方法。随后将 parse 方法添加一行打印输出,将 response 变量的 text 属性输出,这样我们便可以看 到 Scrapy 发送的 Request 信息了。

修改 Spider 内容如下所示:

from scrapy import Spider, Request

class HttpbinSpider (Spider):
    name = 'httpbin'
    allowed_domains = ['www.httpbin.org']
    start_url = 'https://www.httpbin.org/get'

    def start_requests(self):
        for i in range(5):
            url = f'{self.start_url}?query={i}'
            yield Request(url, callback=self.parse)

    def parse(self, response):
        print(response.text)

接下来运行此 Spider,执行如下命令:

scrapy crawl httpbin

Scrapy 运行结果包含 Scrapy 发送的 Request 信息,内容如下所示:

{
"args": {
"query": "0"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en",
"Host": "www.httpbin.org",
"User-Agent": "Scrapy/2.2.1 (+https://scrapy.org)",
"X-Amzn-Trace-Id": "Root=1-5f4bf132-365da06dcbc13e3aafa1d2a3"
},
"origin": "219.142.145.226",
"url": "https://www.httpbin.org/get?query=1"
}
...
{
"args": {
"query": "4"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en",
}
"Host": "www.httpbin.org",
"User-Agent": "Scrapy/2.2.1 (+https://scrapy.org)",
"X-Amzn-Trace-Id": "Root=1-5f4bf132-36f95f671162688adeac46b2"
},
"origin": "219.142.145.226",
"url": "https://www.httpbin.org/get?query=4"
}

这里我们可以看到几个 Request 对应的 Response 的内容就被输出了,每个返回结果带有 args 参 数,query为0~4。 另外我们可以定义一个Item,4个字段就是目标站点返回的字段,相关代码如下:

import scrapy
class DemoItem(scrapy.Item):
    origin = scrapy.Field()
    headers = scrapy.Field()
    args = scrapy.Field()
    url = scrapy.Field()

可以在 parse 方法中将返回的Response 的内容转化为DemoItem,将parse方法做如下修改:

def parse(self, response):
    item = DemoItem(**response.json())
    yield item

这样重新运行,最终 Spider 就会产生对应的DemoItem了,运行效果如下:

2020-08-31 02:38:14 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.httpbin.org/get?query=4>
{'args': {'query': '4'},
 'headers': {'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
             'Accept-Encoding': 'gzip, deflate',
             'Accept-Language': 'en',
             'Host': 'www.httpbin.org',
             'User-Agent': 'Scrapy/2.2.1 (+https://scrapy.org)',
              'X-Amzn-Trace-Id': 'Root=1-5f4bf215-c48272f87238b41453d5a482'},
 'origin': '219.142.145.226',
 'url': 'https://www.httpbin.org/get?query=4'}

可以看到原本 Response 的JSON数据就被转化为了DemoItem并返回。 接下来我们实现一个 Spider Middleware,看看如何实现 Response、Item、Request 的处理吧! 在middlewares.py 中重新声明一个 CustomizeMiddleware 类,内容如下:

class CustomizeMiddleware(object):
    def process_start_requests(self, start_requests, spider):
        for request in start_requests:
            url = request.url
            url += '&name=germey'
            request = request.replace(url=url)
            yield request

这里实现了 process_start_requests 方法,它可以对 start_requests 表示的每个 Request 进行处 理,我们首先获取了每个Request 的URL,然后在URL的后面又拼接上了另外一个Query 参数,name 等于 germey,然后我们利用 request 的 replace 方法将url 属性替换,这样就成功为 Request 赋值了 新的URL。 接着我们需要将此 CustomizeMiddleware 开启,在settings.py 中进行如下的定义:

SPIDER_MIDDLEWARES = {
    'scrapyspidermiddlewaredemo.middlewares.CustomizeMiddleware': 543,
}

这样我们就开启了 CustomizeMiddleware 这个 Spider Middleware。

重新运行 Spider,这时候我们可以看到输出结果就变成了类似下面这样的结果:

2020-08-31 02:43:29 [scrapy.core.scraper] DEBUG: Scraped from <200
https://www.httpbin.org/get?query=2&name=germey>
{'args': {'name': 'germey', 'query': '2'},
'headers': {'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'en',
'Host': 'www.httpbin.org',
'User-Agent': 'Scrapy/2.2.1 (+https://scrapy.org)',
'X-Amzn-Trace-Id': 'Root=1-5f4bf350-fbe66f706f2b8ca805dbf388'},
'origin': '203.184.132.103',
'url': 'https://www.httpbin.org/get?query=2&name=germey'}

可以观察到 url 属性成功添加了 name=germey的内容,这说明我们利用 Spider Middleware 成功改 写了Request。 除了改写 start_requests,我们还可以对 Response 和Item 进行改写,比如对 Response 进行改写, 我们可以尝试更改其状态码,在CustomizeMiddleware 里面增加如下定义:

def process_spider_input(self, response, spider):
    response.status = 201

def process_spider_output(self, response, result, spider):
    for i in result:
        if isinstance(i, DemoItem):
            i['origin'] = None
        yield i

这里我们定义了 process_spider_input 和 process_spider_output方法,分别来处理 Spider 的输 入和输出。对于process_spider_input方法来说,输入自然就是 Response对象,所以第一个参数就是 response,我们在这里直接修改了状态码。对于 process_spider_output方法来说,输出就是 Request 或 Item了,但是这里二者是混合在一起的,作为 result 参数传递过来。result 是一个可迭代的对象, 我们遍历了 result,然后判断了每个元素的类型,在这里使用 isinstance 方法进行判定:如果i是 DemoItem 类型,就把它的origin属性设置为空。当然这里还可以针对 Request类型做类似的处理,此 处略去。 另外在 parse 方法里面添加 Response 状态码的输出结果:

print('Status:', response.status)

重新运行一下 Spider,可以看到输出结果类似下面这样:

Status: 201
2020-08-31 02:57:33 [scrapy.core.scraper] DEBUG: Scraped from <201
https://www.httpbin.org/get?query=1&name=germey>
{'args': {'name': 'germey', 'query': '1'},
'headers': {'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Scrapy/2.2.1 (+https://scrapy.org)',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'en',
'Host': 'www.httpbin.org',
'origin': None,
'url': 'https://www.httpbin.org/get?query=1&name=germey'}
'X-Amzn-Trace-Id': 'Root=1-5f4bf69c-9ef0cea4c9c353a3fb46706b'},

状态码变成了201,Item的 origin字段变成了None,证明 CustomizeMiddleware 对 Spider 输入的 Response 和输出的 Item 都实现了处理。 到这里,我们通过自定义 Spider Middleware 的方式,实现了对Spider 输入的Response以及输出 的Request 和 Item 的处理。 另外在 Scrapy中,还有几个内置的 Spider Middleware,我们简单介绍一下。

HttpErrorMiddleware

HttpErrorMiddleware的主要作用是过滤我们需要忽略的Response,比如状态码为200~299的会处 理,500以上的不会处理。其核心实现代码如下:

def __init__(self, settings):
self.handle_httpstatus_all = settings.getbool('HTTPERROR_ALLOW_ALL')
self.handle_httpstatus_list = settings.getlist('HTTPERROR_ALLOWED_CODES')
def process_spider_input(self, response, spider):
if 200 <= response.status < 300:
return
meta = response.meta
if 'handle_httpstatus_all' in meta:
return
if 'handle_httpstatus_list' in meta:
allowed_statuses = meta['handle_httpstatus_list']
elif self.handle_httpstatus_all:
return
else:
allowed_statuses = getattr(spider, 'handle_httpstatus_list', self.handle_httpstatus_list)
if response.status in allowed_statuses:
return
raise HttpError (response, 'Ignoring non-200 response')

可以看到它实现了 process_spider_input方法,然后判断了状态码为200~299就直接返回,否则会根 据 handle_httpstatus_all 和 handle_httpstatus_list来进行处理。例如状态码在 handle_httpstatus_list 定义的范围内,就会直接处理,否则抛出 HttpError异常。这也解释了为什么刚才我们把 Respons 的状态码修改为201却依然能被正常处理的原因,如果我们修改为非 200~299的状态码,就会抛出 异常了。 另外,如果想要针对一些错误类型的状态码进行处理,可以修改Spider 的handle_httpstatus_list 属性,也可以修改 Request meta 的 handle_httpstatus_list 属性,还可以修改全局 setttings HTTPERROR_ALLOWED_CODES。 比如我们想要处理404状态码,可以进行如下设置:

OffsiteMiddleware

HTTPERROR_ALLOWED_CODES = [404]

OffsiteMiddleware 的主要作用是过滤不符合 allowed_domains 的Request, Spider 里面定义的 allowed_domains 其实就是在这个 Spider Middleware里生效的。其核心代码实现如下:

def process_spider_output(self, response, result, spider):
for x in result:
if isinstance(x, Request):
if x.dont_filter or self.should_follow(x, spider):
yield x
else:
domain = urlparse_cached(x).hostname
if domain and domain not in self.domains_seen:
self.domains_seen.add(domain)
logger.debug(

"Filtered offsite request to % (domain)r: % (request)s",

{'domain': domain, 'request': x}, extra={'spider': spider})
self.stats.inc_value('offsite/domains', spider=spider)
self.stats.inc_value('offsite/filtered', spider=spider)
else:
yield x

可以看到,这里首先遍历了 result,然后判断了 Request 类型的元素并赋值为x。然后根据x的 dont_filter、url 和 Spider 的 allowed_domains 进行了过滤,如果不符合 allowed_domains,就直接输

15.7 Item Pipeline 的使用

出日志并不再返回 Request,只有符合要求的 Request 才会被返回并继续调用。

UrlLengthMiddleware

UrlLengthMiddleware 的主要作用是根据 Request 的URL 长度对 Request 进行过滤,如果URL的 长度过长,此 Request 就会被忽略。其核心代码实现如下:

@classmethod
def from_settings(cls, settings):
    maxlength = settings.getint('URLLENGTH_LIMIT')

def process_spider_output(self, response, result, spider):
    def def_filter(request):
        if isinstance(request, Request) and len(request.url) > self.maxlength:
            logger.debug("Ignoring link (url length > % (maxlength)d): %(url)s ",
                         {'maxlength': self.maxlength, 'url': request.url},
                         extra={'spider': spider})
            return False
        else:
            return True
    return (r for r in result or () if def_filter())

可以看到,这里利用了 process_spider_output 对 result 里面的 Request进行过滤,如果是 Request 类型并且URL长度超过最大限制,就会被过滤。我们可以从中了解到,如果想要根据 URL 的长度进 行过滤,可以设置 URLLENGTH_LIMIT。 比如我们只想爬取URL长度小于50的页面,那么就可以进行如下设置:

URLLENGTH_LIMIT = 50

可见 Spider Middleware 能够非常灵活地对 Spider 的输入和输出进行处理,内置的一些 Spider Middleware 在某些场景下也发挥了重要作用。另外,还有一些其他的内置 Spider Middleware,就不在 此一一赘述了,更多内容可以参考官方文档:https://docs.scrapy.org/en/latest/topics/spider-middleware. html#built-in-spider-middleware-reference。

4. 总结

本节介绍了 Spider Middleware 的基本原理和自定义 Spider Middleware的方法,在必要的情况下, 我们可以利用它来对Spider 的输入和输出进行处理,在某些场景下还是很有用的。 本节代码参见:https://github.com/Python3WebSpider/ScrapySpiderMiddlewareDemo。

在前面的章节,我们初步介绍了 Item Pipeline的作用,本节我们再详细了解一下它的用法。 Item Pipeline e即项目管道,它的调用发生在 Spider产生 Item之后。当Spider 解析完 Response, Item 就会被 Engine 传递到 Item Pipeline,被定义的 Item Pipeline 组件会顺次被调用,完成一连串的处理过 程,比如数据清洗、存储等。 Item Pipeline 的主要功能如下。

清洗 HTML 数据。

验证爬取数据,检查爬取字段。

查重并丢弃重复内容。

将爬取结果储存到数据库中。

1. 核心方法

我们可以自定义 Item Pipeline,只需要实现指定的方法就好,其中必须实现的一个方法是:

process_item(item, spider)

另外还有几个比较实用的方法,它们分别是:

open_spider(spider)

close_spider(spider)
from_crawler(cls, crawler)

下面我们对这几个方法的用法进行详细介绍。

process_item(item, spider)

process_item 是必须实现的方法,被定义的Item Pipeline 会默认调用这个方法对 Item 进行处理, 比如进行数据处理或者将数据写入数据库等操作。process_item方法必须返回 Item 类型的值或者抛出 一个DropItem 异常。 process_item 方法的参数有两个。

item:Item 对象,即被处理的Item。

spider: Spider对象,即生成该 Item 的Spider。 该方法的返回类型如下。

如果返回的是Item对象,那么此 Item 会接着被低优先级的 Item Pipeline 的 process_item 方法 处理,直到所有的方法被调用完毕。

如果抛出 DropItem异常,那么此Item 就会被丢弃,不再进行处理。

open_spider(self, spider)

open_spider 方法是在 Spider 开启的时候被自动调用的,在这里我们可以做一些初始化操作,如 开启数据库连接等。其中参数 spider 就是被开启的Spider 对象。

close_spider(spider)

close_spider 方法是在Spider 关闭的时候自动调用的,在这里,我们可以做一些收尾工作,如关 闭数据库连接等,其中参数 spider 就是被关闭的Spider 对象。

from_crawler(cls, crawler)

from_crawler 方法是一个类方法,用@classmethod标识,它接收一个参数crawler。通过crawler

对象,我们可以拿到Scrapy的所有核心组件,如全局配置的每个信息。然后可以在这个方法里面创建 一个Pipeline 实例。参数cls就是Class,最后返回一个Class实例。 下面我们用一个实例来加深对 Item Pipeline 用法的理解。

2. 本节目标

本节我们要爬取的目标网站是 https://ssrl.scrape.center/,我们需要把每部电影的名称、类别、评 分、简介、导演、演员的信息以及相关图片爬取下来,同时把每部电影的导演、演员的相关图片保存 成一个文件夹,并将每部电影的完整数据保存到 MongoDB 和 Elasticsearch里。 这里使用 Scrapy 来实现这个电影数据爬虫,主要是为了了解 Item Pipeline的用法。我们会使用 Item Pipeline 分别实现 MongoDB 存储、Elasticsearch 存储、Image图片存储这3个Pipeline。

在开始之前,请确保已经安装好 MongoDB 和 Elasticsearch,另外安装好 Python 的PyMongo、 Elasticsearch、Scrapy包,安装参考如下。 Scrapy: https://setup.scrape.center/scrapy。 MongoDB: https://setup.scrape.center/mongodb。 PyMongo: https://setup.scrape.center/pymongo。 Elasticsearch: https://setup.scrape.center/elasticsearch。 Elasticsearch Python包:https://setup.scrape.center/elasticsearch-py。 做好如上准备工作之后,我们就可以开始本节的实战练习了。

3. 实战

我们之前已经分析过此站点的页面逻辑了,在此就不再逐一分析了,直接上手用 Scrapy 编写此站 点的爬虫,同时实现几个 Item Pipeline。

首先新建一个项目,我们取名为scrapyitempipelinedemo,命令如下:

scrapy startproject scrapyitempipelinedemo

接下来新建一个Spider,命令如下:

scrapy genspider scrape ssr1.scrape.center

这样我们就成功创建了一个Spider,名字为scrape,允许爬取的域名为ssr1.scrape.center。 接下来我们来实现列表页的爬取。本站点一共有10页数据,所以我们可以新建10个初始请求, 实现 start_requests 方法的代码如下:

from scrapy import Request, Spider
class ScrapeSpider (Spider):
name = 'scrape'
allowed_domains = ['ssr1.scrape.center']
base_url = 'https://ssr1.scrape.center'
max_page = 10
def start_requests(self):
for i in range(1, self.max_page + 1):
url = f'{self.base_url}/page/{i}'
yield Request(url, callback=self.parse_index)
def parse_index(self, response):
print(response)

在这里我们声明了 max_page 即最大翻页数量,然后实现了 start_requests 方法,构造了10个初 始请求分别爬取每一个列表页,Request 对应的回调方法修改为了 parse_index,最后我们暂时在 parse_index 方法里面打印输出了 resposne 对象。

运行这个 Spider 的命令如下:

scrapy crawl scrape

运行结果类似如下:

2020-08-31 21:06:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/1>

(referer: None)

2020-08-31 21:06:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/5>

(referer: None)

2020-08-31 21:06:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/4>

(referer: None)

<200 https://ssr1.scrape.center/page/1>
<200 https://ssr1.scrape.center/page/5>
<200 https://ssr1.scrape.center/page/4>
2020-08-31 21:06:16 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/9>

(referer: None)

<200 https://ssr1.scrape.center/page/9>
2020-08-31 21:06:16 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/8>

(referer: None)

<200 https://ssr1.scrape.center/page/8>
2020-08-31 21:06:16 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/2>

(referer: None)

<200 https://ssr1.scrape.center/page/2>
2020-08-31 21:06:16 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/10>

(referer: None)

<200 https://ssr1.scrape.center/page/10>

可以看到对应的列表页的数据就被爬取下来了,Response 的状态码为200。 接着我们可以在 parse_index 方法里对 response 的内容进行解析,提取每部电影的详情页链接, 通过审查源代码可以发现,其标题对应的CSS 选择器为.item.name,如图15-9所示。

图15-9 页面源代码
图15-9 页面源代码

所以这里我们可以借助 response 的css方法进行提取,提取链接之后生成详情页的Request。可 以把 parse_index 方法改写如下:

def parse_index(self, response):
for item in response.css('.item'):
href = item.css('.name::attr(href)').extract_first()
url = response.urljoin(href)
yield Request(url, callback=self.parse_detail)
def parse_detail(self, response):
print(response)

在这里我们首先筛选了每部电影对应的节点,即.item,然后遍历这些节点提取其中的.name 选 择器对应的详情页链接,接着通过response 的urljoin方法拼接成完整的详情页URL,最后构造新的 详情页 Request,回调方法设置为 parse_detail,同时在 parse_detail 方法里面打印输出 response。

重新运行,我们可以看到详情页的内容就被爬取下来了,类似的输出如下:

2020-08-31 21:18:01 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/6>

(referer: https://ssr1.scrape.center/page/1)

<200 https://ssr1.scrape.center/detail/6>
2020-08-31 21:18:02 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/3>

(referer: https://ssr1.scrape.center/page/1)

2020-08-31 21:18:02 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/4>

(referer: https://ssr1.scrape.center/page/1)

<200 https://ssr1.scrape.center/detail/3>
<200 https://ssr1.scrape.center/detail/4>
2020-08-31 21:18:02 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/10>

(referer: https://ssr1.scrape.center/page/1)

<200 https://ssr1.scrape.center/detail/10>
2020-08-31 21:18:02 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/8>

(referer: https://ssr1.scrape.center/page/1)

<200 https://ssr1.scrape.center/detail/8>
2020-08-31 21:18:03 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/50>

(referer: https://ssr1.scrape.center/page/5)

<200 https://ssr1.scrape.center/detail/50>

其实现在 parse_detail 里面的 response 就是详情页的内容了,我们可以进一步对详情页的内容 进行解析,提取每部电影的名称、类别、评分、简介、导演、演员等信息。

首先让我们新建一个 Item,叫作 MovieItem,定义如下:

from scrapy import Item, Field

class MovieItem(Item):
    name = Field()
    categories = Field()
    score = Field()
    drama = Field()
    directors = Field()
    actors = Field()

这里我们定义的几个字段 name、categories、score、drama、directors、actors 分别代表电影名 称、类别、评分、简介、导演、演员。接下来我们就可以提取详情页了,修改parse_detail 方法如下:

def parse_detail(self, response):
    item = MovieItem()
    item['name'] = response.xpath('//div[contains(@class, "item")]//h2/text()').extract_first()
    item['categories'] = response.xpath('//button[contains(@class, "category")]/span/text()').extract()
    item['score'] = response.css('.score::text').re_first('[\d\.]+')
    item['drama'] = response.css('.drama p::text').extract_first().strip()
    item['directors'] = []
    directors = response.xpath('//div[contains(@class, "directors")]//div[contains(@class, "director")]')
    for director in directors:
        director_image = director.xpath('.//img[@class="image"]/@src').extract_first()
        director_name = director.xpath('.//p[contains(@class, "name")]/text()').extract_first()
        item['directors'].append({
            'name': director_name,
            'image': director_image
        })
    item['actors'] = []
    actors = response.css('.actors .actor')
    for actor in actors:
        actor_image = actor.css('.actor .image::attr(src)').extract_first()
        actor_name = actor.css('.actor .name::text').extract_first()
        item['actors'].append({
            'name': actor_name,
            'image': actor_image
        })
    yield item

在这里我们首先创建了一个MovieItem对象,赋值为item。然后我们使用xpath方法提取了 name、 categories 两个字段。为了让大家不仅仅掌握 xpath 的提取方式,我们还使用CSS 选择器提取了 score 和 drama 字段,同时 score字段最后还调用了re_first方法传入正则表达式提取了分数的内容。对于 导演 directors 和演员 actors,我们首先提取了单个 director 和 actor节点,然后分别从中提取了姓 名和照片,最后组合成一个列表赋值给 directors 和 actors 字段。 重新运行一下,可以发现提取结果类似如下:

2020-08-31 22:04:44 [scrapy.core.scraper] DEBUG: Scraped from <200 https://ssr1.scrape.center/detail/33>
{'actors': [{'image': 'https://p1.meituan.net/movie/404c98822552575b9061c6be12c1df408840.jpg@128w_170h_1e_1c',
             'name':'里克·奥巴瑞'},
            {'image': 'https://p1.meituan.net/movie/7b3a7d3ed65b5e0f0cf89d4d0d34b1e126818.jpg@128w_170h_1e_1c',
             'name':'路易·西霍尤斯'},
            {'image': 'https://po.meituan.net/movie/6893bef07e0af2ef829be6c5ae99283027633.jpg@128w_170h_1e_1c',
             'name':'哈迪·琼斯'},
            {'image': 'https://p1.meituan.net/mmdb/3a2061d771d98566d3e5fa5c08c5e0b33685.png@128w_170h_1e_1c',
             'name':'迈克尔·利弗'},
            {'image': 'https://p1.meituan.net/movie/a1d84af3ad30917431a749a6068be18a16670.jpg@128w_170h_1e_1c',
             'name': "Richard O'Barry"},
            {'image': 'https://po.meituan.net/movie/d4a4f85a25dfbe086ec74a7128ac2ee210261.jpg@128w_170h_1e_1c',
             'name': 'Hans Peter Roth'}],
 'categories': ['纪录片'],
 'directors': [{'image': 'https://p1.meituan.net/movie/7b3a7d3ed65b5e0f0cf89d4d0d34b1e126818.jpg@128w_170h_1e_1c',
                'name':'路易·西霍尤斯'}],
 'drama':'日本和歌山县太地,是一个景色优美的小渔村,然而这里却常年上演着惨无人道的一幕。每年,数以万计的海豚
        经过这片海域,他们的旅程却在太地戛然而止。渔民们将海豚驱赶到靠近岸边的一个地方,来自豚训练师挑选合适的
        对象,剩下的大批海豚则被渔民毫无理由地赶尽杀绝。这些屠杀,这些罪行,因为种种利益而被政府和相关组织所
        隐瞒。理查德·贝瑞年轻时曾是一名海豚训练师,他所参与拍摄电影《海豚的故事》备爱的朋友......',
 'name':'海豚湾- The Cove',
 'score': '8.8'}

可以看到这里我们已经成功提取了各个字段然后生成了 MovieItem 对象了。 下一步就是本节的重点内容了,我们需要把当前爬取到的内容存储到 MongoDB 和 Elasticsearch 中,然后将导演和演员的图片也下载下来。 要实现这个操作,我们需要创建3个Item Pipeline,其中两个分别用来将数据存储到 MongoDB、 Elasticsearch,另外一个用来下载图片。

MongoDB

之前我们已经实现过 MongoDB 相关的Pipeline了,这里我们再简略说一下。 首先确保 MongoDB 已经安装并且正常运行,既可以运行在本地,也可以运行在远程,我们需要 把它的连接字符串构造好,连接字符串的格式如下:

mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]]

比如运行在本地27017 端口的无密码的 MongoDB 可以直接写为:

mongodb://localhost:27017

如果是远程 MongoDB,可以根据用户名、密码、地址、端口等构造。 我们实现一个 MongoDBPipeline,将信息保存到 MongoDB,在 pipelines.py 里添加如下类的实现:

import pymongo
from scrapyitempipelinedemo.items import MovieItem

class MongoDBPipeline(object):
    @classmethod
    def from_crawler (cls, crawler):
cls.connection_string = crawler.settings.get('MONGODB_CONNECTION_STRING')
cls.database = crawler.settings.get('MONGODB_DATABASE')
cls.collection = crawler.settings.get('MONGODB_COLLECTION')
return cls()
def open_spider (self, spider):
self.client = pymongo.MongoClient(self.connection_string)
self.db = self.client [self.database]
def process_item(self, item, spider):
self.db[self.collection].update_one({
'name': item['name']
}, {
'$set': dict(item)
}, True)
return item
def close_spider (self, spider):
self.client.close()

这里我们首先利用 from_crawler 获取了全局配置 MONGODB_CONNECTION_STRING、MONGODB_DATABASE 和 MONGODB_COLLECTION,即 MongoDB连接字符串、数据库名称、集合名词,然后将三者赋值为类属性。 接着我们实现了 open_spider方法,该方法就是利用 from_crawler 赋值的 connection_string 创 建一个 MongoDB连接对象,然后声明数据库操作对象,close_spider 则是在 Spider 运行结束时关闭 MongoDB 连接。 接着最重要的就是 process_item 方法了,这个方法接收的参数 item 就是从 Spider 生成的 Item对 象,该方法需要将此 Item 存储到 MongoDB中。这里我们使用了 update_one 方法实现了存在即更新, 不存在则插入的功能。 接下来我们需要在 settings.py 里添加 MONGODB_CONNECTION_STRING、MONGODB_DATABASE 和 MONGODB_ COLLECTION 这3个变量,相关代码如下:

MONGODB_CONNECTION_STRING = os.getenv('MONGODB_CONNECTION_STRING')
MONGODB_DATABASE = 'movies'
MONGODB_COLLECTION = 'movies'

这里可以将 MONGODB_CONNECTION_STRING 设置为从环境变量中读取,而不用将明文将密码等信息 写到代码里。 如果是本地无密码的 MongoDB,直接写为如下内容即可:

MONGODB_CONNECTION_STRING = 'mongodb://localhost:27017' # or just use 'localhost'

这样,一个保存到 MongoDB的Pipeline 就创建好了,利用process_item方法我们即可完成数据 插入到 MongoDB 的操作,最后会返回 Item 对象。

Elasticsearch

存储到 Elasticsearch 也是一样,我们需要先创建一个Pipeline,代码实现如下:

from elasticsearch import Elasticsearch
class ElasticsearchPipeline (object):

@classmethod

def from_crawler(cls, crawler):
cls.connection_string = crawler.settings.get('ELASTICSEARCH_CONNECTION_STRING')
cls.index = crawler.settings.get('ELASTICSEARCH_INDEX')
return cls()
def open_spider(self, spider):
self.conn = Elasticsearch([self.connection_string])
if not self.conn.indices.exists(self.index):
self.conn.indices.create(index=self.index)
def process_item(self, item, spider):
self.conn.index(index=self.index, body=dict(item), id=hash(item['name']))
return item
def close_spider (self, spider):
self.conn.transport.close()

这里同样定义了ELASTICSEARCH_CONNECTION_STRING 代表 Elasticsearch的连接字符串,ELASTICSEARCH_ INDEX 代表索引名称,具体初始化的操作和 MongoDBPipeline 的原理是类似的。 在 process_item 方法中,我们调用了 index方法对数据进行索引,我们指定了3个参数,第一个 参数 index 代表索引名称,第二个参数 body 代表数据对象,在这里我们将 Item 转为了字典类型,第 三个参数 id 则是索引数据的id,这里我们直接使用电影名称的hash值作为id,或者自行指定其他 id 也可以的。 同样地,我们需要在 settings.py 里面添加 ELASTICSEARCH_CONNECTION_STRING 和ELASTICSEARCH_INDEX:

ELASTICSEARCH_CONNECTION_STRING = os.getenv('ELASTICSEARCH_CONNECTION_STRING')
ELASTICSEARCH_INDEX = 'movies'

这里的 ELASTICSEARCH_CONNECTION_STRING 同样是从环境变量中读取的,它的格式如下: http[s]://[username: password@]host[:port] 比如我实际使用的 ELASTICSEARCH_CONNECTION_STRING 值就类似:

https://user:[email protected]:9200

这里你可以根据实际情况更换成你的连接字符串,这样 ElasticsearchPipeline 就完成了。

Image Pipeline

Scrapy 提供了专门处理下载的Pipeline,包括文件下载和图片下载。下载文件和图片的原理与抓 取页面的原理一样,因此下载过程支持异步和多线程,十分高效。下面我们来看看具体的实现过程。 官方文档地址为:https://doc.scrapy.org/en/latest/topics/media-pipeline.html。 首先定义存储文件的路径,需要定义一个 IMAGES_STORE 变量,在settings.py 中添加如下代码:

IMAGES_STORE = './images'

在这里我们将路径定义为当前路径下的 images子文件夹,即下载的图片都会保存到本项目的 images 文件夹中。 内置的 ImagesPipeline 会默认读取 Item 的 image_urls 字段,并认为它是列表形式,接着遍历该 字段后取出每个URL 进行图片下载。 但是现在生成的 Item的图片链接字段并不是 image_urls 字段表示的,我们是想下载 directors 和 actors 的每张图片。所以为了实现下载,我们需要重新定义下载的部分逻辑,即自定义ImagePipeline 继承内置的 ImagesPipeline,重写几个方法。 我们定义的 ImagePipeline 代码如下:

from scrapy import Request
from scrapy.exceptions import DropItem
from scrapy.pipelines.images import ImagesPipeline
class ImagePipeline (ImagesPipeline):
def file_path(self, request, response=None, info=None):
movie = request.meta['movie']
type = request.meta['type']
name = request.meta['name']
file_name = f'{movie}/{type}/{name}.jpg'
return file_name
def item_completed(self, results, item, info):
image_paths = [x['path'] for ok, x in results if ok]
if not image_paths:
raise DropItem('Image Downloaded Failed')
return item
def get_media_requests(self, item, info):
for director in item['directors']:
director_name = director['name']
director_image = director['image']
yield Request(director_image, meta={
'name': director_name,
'type': 'director',
})
'movie': item['name']
for actor in item['actors']:
actor_name = actor['name']
actor_image = actor['image']
yield Request(actor_image, meta={
'name': actor_name,
'type': 'actor',
})
'movie': item['name']

在这里我们实现了 ImagePipeline,继承 Scrapy 内置的 ImagesPipeline,重写下面几个方法。

get_media_requests:第一个参数 item 是爬取生成的Item对象,我们要下载的图片链接保存 在Item 的 directors 和 actors 每个元素的 image字段中。所以我们将URL逐个取出,然后构 造 Request 发起下载请求。同时我们指定了 meta信息,方便构造图片的存储路径,以便在下 载完成时使用。

file_path:第一个参数 request 就是当前下载对应的Request对象。这个方法用来返回保存的 文件名,在这里我们获取了刚才生成的Request 的 meta 信息,包括 movie(电影名称)、type (电影类型)和 name(导演或演员姓名),最终三者拼合为file_name作为最终的图片路径。

item_completed:单个Item完成下载时的处理方法。因为并不是每张图片都会下载成功,所以 我们需要分析下载结果并剔除下载失败的图片。如果某张图片下载失败,那么我们就不需将此 Item 保存到数据库。item_completed 方法的第一个参数 results 就是该 Item 对应的下载结果, 它是一个列表,列表的每个元素是一个元组,其中包含了下载成功或失败的信息。这里我们遍 历下载结果,找出所有成功的下载列表。如果列表为空,那么该 Item 对应的图片下载失败, 随即拋出 DropItem 异常,忽略该 Item;否则返回该 Item,说明此 Item 有效。 现在为止,3个Item Pipeline 的定义就完成了。最后只需要启用就可以了,修改 settings.py,设置 ITEM_PIPELINES 的代码如下所示:

ITEM_PIPELINES = {
'scrapyitempipelinedemo.pipelines.ImagePipeline': 300,
'scrapyitempipelinedemo.pipelines.MongoDBPipeline': 301,
'scrapyitempipelinedemo.pipelines.ElasticsearchPipeline': 302
}

这里要注意调用的顺序。我们需要优先调用 Image Pipeline 对 Item 做下载后的筛选,下载失败的 Item 就直接忽略,它们不会保存到MongoDB 和 MySQL里。随后再调用其他两个存储的Pipeline,这 样就能确保存入数据库的图片都是下载成功的。

接下来运行程序,执行爬取,命令如下所示:

scrapy crawl images

爬虫一边爬取一边下载,速度非常快,对应的输出日志如图15-10所示。

图15-10 输出日志
图15-10 输出日志

查看本地 images文件夹,发现图片都已经成功下载,如图15-11所示。

图15-11 images 文件夹
图15-11 images 文件夹

可以看到图片已经分路径存储了,一部电影一个文件夹,演员和导演分二级文件夹,图片名直接 以演员和导演名命名。 然后我们用 Kibana 查看 Elasticsearch,相应的电影数据也成功存储,如图15-12所示。

图15-12 查看 Elasticsearch
图15-12 查看 Elasticsearch

查看 MongoDB,下载成功的图片信息同样已成功保存,如图15-13 所示。

图15-13 查看 MongoDB
图15-13 查看 MongoDB

这样我们就可以成功实现图片的下载并把图片的信息存入数据库了。

4. 总结

Item Pipeline 是 Scrapy 非常重要的组件,数据存储几乎都是通过此组件实现的,请认真掌握此内容。 本节代码参见:https://github.com/Python3WebSpider/ScrapyItemPipelineDemo。

15.8 Extension 的使用

前面我们已经了解了 Scrapy 的常用的基本组件,如 Spider、Downloder Middleware、Spider Middleware、Item Pipeline 等,其实另外还有一个比较实用的组件 Extension,中文翻译叫作扩展。利 用它,我们可以完成我们想自定义的功能。 本节中我们就来了解下 Scrapy 中 Extension 的用法。

1. Extension 介绍

Scrapy 提供了一个 Extension 机制,可以让我们添加和扩展一些自定义的功能。利用 Extension 我 们可以注册一些处理方法并监听 Scrapy 运行过程中的各个信号,做到在发生某个事件时执行我们自定 义的方法。 Scrapy 已经内置了一些 Extension,如 LogStats 这个 Extension 用于记录一些基本的爬取信息,比 如爬取的页面数量、提取的 Item 数量等,CoreStats 这个 Extension 用于统计爬取过程中的核心统计信 息,如开始爬取时间、爬取结束时间等。 和 Downloader Middleware、Spider Middleware 以及 Item Pipeline 一样,Extension 也是通过 settings.py 中的配置来控制是否被启用的,是通过 EXTENSION 这个配置项来实现的,例如:

EXTENSIONS = {
'scrapy.extensions.corestats.CoreStats': 500,
'scrapy.extensions.telnet.TelnetConsole': 501,
}

通过如上配置我们就开启了 CoreStats 和 TelnetConsole 这两个 Extension。 另外我们也可以实现自定义的 Extension,实现过程其实非常简单,主要分为两步: □ 实现一个 Python 类,然后实现对应的处理方法,如实现一个 spider_opened 方法用于处理 Spider 开始爬取时执行的操作,可以接收一个 spider 参数并对其进行操作。 □ 定义 from_crawler 类方法,其第一个参数是 cls 类对象,第二个参数是 crawler。利用 crawler 的 signals 对象将 Scrapy 的各个信号和已经定义的处理方法关联起来。 接下来我们就用一个实例来演示一下 Extension 的实现过程。

2. 准备工作

本节我们来尝试利用 Extension 实现爬取事件的消息通知。在爬取开始时、爬取到数据时、爬取 结束时通知指定的服务器,将这些事件和对应的数据通过 HTTP 请求发送给服务器。 开始本节的学习之前,请确保已经成功安装好了 Scrapy 框架并对 Scrapy 有一定的了解。本节的 实例是以 15.2 节的内容为基础进行编写的,所以请确保已经理解了 15.2 节的全部内容并准备好了 15.2 节的代码。 另外本节我们需要用到 Flask 来搭建一个简易的测试服务器,也需要利用 requests 来实现 HTTP 请 求的发送,因此需要安装好 Flask、requests 和 loguru 这 3 个库,使用 pip3 安装即可:

pip3 install flask requests loguru

3. 实战

为了方便验证,这里可以用 Flask 定义一个轻量级的服务器,用于接收 POST 请求并输出接收到 的事件和数据,server.py的代码如下:

from flask import Flask, request, jsonify
from loguru import logger
app = Flask(_name__)
@app.route('/notify', methods=['POST'])
def receive():
post_data = request.get_json()
event = post_data.get('event')
data = post_data.get('data')
logger.debug(f'received event (event), data {data}')
return jsonify(status='success')
if name == 'main':
app.run(debug=True, host='0.0.0.0', port=5000)

然后运行它: python3 server.py 这样 Flask 服务器就在本地5000端口上运行起来了。 接下来我们基于15.2节的代码,在scrapytutorial 文件夹下新建一个extensions.py文件,先实现几 个对应的事件处理方法:

import requests
NOTIFICATION_URL = 'http://localhost:5000/notify'
class NotificationExtension(object):
def spider_opened (self, spider):
requests.post(NOTIFICATION_URL, json={
'event': 'SPIDER_OPENED',
'data': {'spider_name': spider.name}
})
def spider_closed (self, spider):
requests.post(NOTIFICATION_URL, json={
'event': 'SPIDER_OPENED',
'data': {'spider_name': spider.name}
})
def item_scraped(self, item, spider):
requests.post(NOTIFICATION_URL, json={
'event': 'ITEM_SCAPED,
'data': {'spider_name': spider.name, 'item': dict(item)}
})

这里我们定义了一个 NotificationExtension类,然后实现了3个方法,spider_opened、spider_ closed 和 item_scraped,分别对应爬取开始、爬取结束和爬取到Item的处理。接着调用了 requests 向 刚才我们搭建的HTTP服务器发送了对应的事件,其中包含两个字段:一个是event,代表事件的名 称;另一个是 data,代表一些附加数据,如 Spider 的名称、Item的具体内容等。. 但仅仅这么定义其实还不够,现在启用这个 Extension 其实没有任何效果的,我们还需要将这些 方法和对应的 Scrapy 信号关联起来,再在 NotificationExtension 类中添加如下类方法: @classmethod

def from crawler (cls, crawler):
ext = cls()
crawler.signals.connect(ext.spider_opened, signal-signals.spider_opened)
crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed)
crawler.signals.connect(ext.item_scraped, signal=signals.item_scraped)
return ext

这里我们用到了Scrapy 中的signals 对象,所以还需要额外导入一下:

from scrapy import signals

其中,from_crawler 是一个类方法,第一个参数就是cls 类对象,第二个参数 crawler 代表了 Scrapy 运行过程中全局的 Crawler 对象。 Crawler 对象里有一个子对象叫作 signals,通过调用 signals 对象的connect方法,我们可以将 Scrapy 运行过程中的某个信号和我们自定义的处理方法关联起来。这样在某个事件发生的时候,被关 联的处理方法就会被调用。比如这里,connect方法第一个参数我们传入ext.spider_opened 这个对象, 而ext 是由 cls 类对象初始化的,所以ext.spider_opened 就代表我们在 NotificationExtension 类中 定义的 spider_opened 方法。connect方法的第二个参数我们传入了 signals.spider_opened 这个对象, 这就指定了 spider_opened 方法可以被 spider_opened 信号触发。这样在Spider开始运行的时候, 会产生 signals.spider_opened 信号,NotificationExtension 类中定义的spider_opened 方法就会被调 用了。 完成如上定义之后,我们还需要开启这个 Extension,在settings.py 中添加如下内容即可:

EXTENSIONS = {
'scrapytutorial.extensions.NotificationExtension': 100,
}

我们成功启用了 NotificationExtension 这个Extension。 下面我们来运行一下 quotes:

scrapy crawl quotes

这时候爬取结果和15.2节的内容大致一样,不同的是日志中多了类似如下的几行:

2020-11-26 01:46:00 [urllib3.connectionpool] DEBUG: Starting new HTTP connection (1): localhost:5000
2020-11-26 01:46:00 [urllib3.connectionpool] DEBUG: http://localhost:5000 "POST /notify HTTP/1.1" 200 26

有了这样的日志,说明成功调用了 requests 的post 方法完成了对服务器的请求。 这时候我们回到Flask 服务器,看一下控制台的输出结果:

2020-11-26 01:45:57.829 | DEBUG | _main_: receive: 12 received event SPIDER_OPENED, data {'spider_name':
'quotes'}
2020-11-26 01:46:02.888 | DEBUG | _main_: receive: 12 received event ITEM_SCRAPED, data {'spider_name':
'quotes', 'item': {'text': ""A person's a person, no matter how small."", 'author': 'Dr. Seuss', 'tags':
['inspirational']}}

127.0.0.1 [26/Jul/2021 01:46:02] "POST /notify HTTP/1.1" 200

2020-11-26 01:46:02.891 | DEBUG | _main_: receive: 12 received event ITEM_SCRAPED, data {'spider_name':
'quotes', 'item': {'text':

a mind needs books as a sword needs a whetsto...', 'author': 'George R.R.

Martin', 'tags': ['books', 'mind']}}

127.0.0.1 [26/Jul/2021 01:46:02] "POST /notify HTTP/1.1" 200

2020-11-26 01:46:02.897 | DEBUG | _main_: receive: 12 - received event SPIDER_OPENED, data {'spider_name':
'quotes'}

可以看到 Flask 服务器成功接收到了各个事件(SPIDER_OPENED、ITEM_SCRAPED、SPIDER_ OPENED)并输出了对应的数据,这说明在Scrapy 爬取过程中,成功调用了 Extension 并在适当的时 机将数据发送到服务器了,验证成功! 我们通过一个自定义的Extension,成功实现了Scrapy 爬取过程中和远程服务器的通信,远程服

务器接收到这些事件之后就可以对事件和数据做进一步的处理了。

4. 总结

当然,本节的内容仅仅是一个 Extension 的样例。通过本节的内容,我们体会到了 Extension 强大 又灵活的功能,以后我们想实现一些自定义的功能可以借助于 Extension 来实现了。 另外 Scrapy 中已经内置了许多 Extension,实现了日志统计、内存用量统计、邮件通知等各种功 能,可以参考官方文档的说明:https://docs.scrapy.org/en/latest/topics/extensions.html 另外也可以参考其源码实现来学习更详细的 Extension 的实现流程。 本节代码参见:https://github.com/Python3WebSpider/ScrapyExtensionDemo。

15.9 Scrapy 对接 Selenium

之前我们都是使用 Scrapy 中的 Request 对象来发起请求的,其实这个 Request 发起的请求和 requests 是类似的,均是直接模拟 HTTP 请求。因此,如果一个网站的内容是由 JavaScript 渲染而成的, 那么直接利用 Scrapy 的 Request 请求对应的 URL 是无法进行抓取的。 前面我们也讲到了,应对 JavaScript 渲染而成的网站主要有两种方式:一种是分析 Ajax 请求,找 到其对应的接口抓取,用 Scrapy 同样可以实现;另一种是直接用 Selenium、Splash、Pyppeteer 等模拟 浏览器进行抓取,在这种情况下,我们不需要关心页面后台发生的请求,也不需要分析渲染过程,关 心页面的最终结果即可,可见即可爬。 所以,如果我们能够在 Scrapy 中实现 Selenium 的对接,就可以实现 JavaScript 渲染页面的爬取了, 本节我们就来了解一下 Scrapy 对接 Selenium 的原理和实现。

1. 本节目标

本节中我们来了解一下 Scrapy 框架如何通过对接 Selenium 来实现 JavaScript 渲染页面的爬取, 爬取的目标网站为 https://spa5.scrape.center/,这是一个图书网站,展示了多本图书的信息,如图 15-14 所示。

图15-14 图书网站
图15-14 图书网站

点击任意一个图书条目即可进入对应的详情页面,如图15-15所示。

图15-15 图书的详情页面
图15-15 图书的详情页面

图15-15 所示的信息是经过 Ajax 获取并通过 JavaScript 渲染出来的,我们要实现的就是使用

Scrapy 对接 Selenium,对图书详情进行爬取,包括名称、评分、标签等。

2. 准备工作

本节开始之前请确保安装好 Scrapy 框架,另外还需要安装好 Selenium库,这次 Selenium 库对应 的浏览器依然还是Chrome,请确保已经安装好了Chrome 浏览器并配置好了 ChromeDriver,具体的安 装过程可以参考:https://setup.scrape.center/selenium。 准备工作完成之后,我们就可以开始本节的学习了。

3. 对接原理

在实现之前,我们需要先了解 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 渲染后的结果了。

4. 对接实战

首先新建项目,名为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,逻辑比较清晰,代码实现如下:

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 Request(detail_url, callback=self.parse_detail, priority=2)
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 的爬取, 相关代码如下:

from scrapy.http import HtmlResponse
from selenium import webdriver
import time
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 渲染后的结果了。 同时可以看到, 控制台显示的运行结果如下:

2020-09-13 22:42:09 [scrapy.extensions.logstats] INFO: Crawled 21 pages (at 21 pages/min), scraped o items

(at 0 items/min)

2020-09-13 22:42:10 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/1692648>
{'cover': 'https://img9.doubanio.com/view/subject/l/public/s9018034.jpg',
'name': '一个人的村庄',
'price': '28.00元',
'score': '8.9',
'tags': ['刘亮程', '散文', '乡土', '一个人的村庄', '散文随笔']}
2020-09-13 22:42:10 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/1055976>
{'cover': 'https://img3.doubanio.com/view/subject/l/public/s2157331.jpg',
'name': '碧血剑(上下)',
'price': '23.00元',
'score': '7.2',
'tags': ['金庸', '武侠', '小说', '碧血剑', '武侠小说']}

这样我们就成功对接 Selenium 实现了JavaScript 渲染页面的爬取。

5. 对接优化

细心的读者也许会发现, 我们刚才实现的 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

可以看到这次浏览器没有再弹出来了,这是因为在默认情况下,Gerapy Selenium 启用了Chrome的 Headless 模式,同时可以看到控制台也有对应的输出结果,爬取速度相比之前有成倍提高,运行结果 如下:

2020-09-13 23:44:05 [gerapy.selenium] DEBUG: selenium_meta {'wait_for': '.item.name', 'script': None, 'sleep'
None, 'proxy': None, 'pretend': None, 'timeout': None, 'screenshot': None}
2020-09-13 23:44:05 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/1851672>

(referer: https://spa5.scrape.center/page/2)

2020-09-13 23:44:05 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/1927763>
{'cover': 'https://img3.doubanio.com/view/subject/l/public/s9128381.jpg',
'name':'鲁迅家庭家族和当年绍兴民俗',
'price': '18.00元,
'score': None,
'tags': ['鲁迅','人物传记与研究','绍兴','传记','鲁迅周作人胡适']}
2020-09-13 23:44:05 [gerapy.selenium] DEBUG: selenium_meta {'wait_for': '.item.name', 'script': None, 'sleep':
None, 'proxy': None, 'pretend': None, 'timeout': None, 'screenshot': None}
2020-09-13 23:44:05 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/1690744>

(referer: https://spa5.scrape.center/page/2) 这样我们就使用 GerapySelenium 提供的 Downloader Middleware 实现了 Scrapy与Selenium 的对 接,非常方便。我们也不需要再去自定义 Downloader Middleware了,同时爬取效率有成倍提升,实 现了参数可配置化。 另外,GerapySelenium 还提供了很多其他实用配置。

关闭 Headless 模式

将 GERAPY_SELENIUM_HEADLESS 设置为False即可,settings.py增加如下代码:

忽略 HTTPS 错误

GERAPY_SELENIUM_HEADLESS = True

将 GERAPY_SELENIUM_IGNORE_HTTPS_ERRORS 设置为 True 即可,settings.py 增加如下代码:

开启 WebDriver反屏蔽功能

GERAPY_SELENIUM_IGNORE_HTTPS_ERRORS = True

该功能默认是开启的,就是将当前浏览器伪装成正常的浏览器,隐藏 WebDriver 的一些特征,如 需关闭,则给 settings.py 增加如下代码:

GERAPY_SELENIUM_PRETEND = False

15.10 Scrapy 对接 Splash

设置加载超时时间

将 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')

更多用法可以直接参考 Gerapy Selenium的GitHub仓库地址: https://github.com/Gerapy/GerapySelenium。

6. 总结

本节我们介绍了Scrapy 和 Selenium的对接解决方案,有了这个方案,Scrapy爬取 JavaScript 渲染 的页面不再是难事了。 本节代码参见:https://github.com/Python3 WebSpider/ScrapySeleniumDemo。

上一节我们了解了 Scrapy 对接 Selenium 的原理和实现流程,当然这是一种实现 Scrapy 爬取 JavaScript 渲染页面的方案,但方案不止这一种,利用Splash 和 Pyppeteer 同样可以实现。 本节我们来了解一下 Scrapy 对接 Splash 爬取 JavaScript 渲染页面的流程。

1. 准备工作

本节要爬取的目标网站和需求与上一节是一致的,在这里就不再展开介绍了。不同的是实现方案 由 Selenium 切换为了Splash,所以我们要实现 Scrapy 和 Splash 的对接。 要实现 Scrapy 和Splash的对接,我们需要借助于Scrapy-Splash库,另外还需要一个可以正常使 用的 Splash服务。 在开始本节的学习之前,请确保安装好 Scrapy 框架,另外请确保 Splash 已经正确安装并正常运 行,另外我们还需要安装好 Scrapy-Splash库,具体的安装过程可以参考:https://setup.scrape.center/ scrapy-splash。

2. 对接原理

Scrapy 对接 Splash 和 Selenium 的原理是不同的,上一节对接 Selenium 是借助于 Downloader Middleware 实现的,在Downloader Middleware里,我们实现了Chrome 浏览器渲染页面的过程,并构 造了 HtmlResponse 返回给 Spider。 而 Splash 本身就是一个 JavaScript 页面渲染服务,我们只需要将需要渲染页面的 URL 发送给 Splash 就能得到对应的 JavaScript 渲染结果,而 Scrapy-Splash 则是提供了这个过程基本功能的封装, 比如 Cookie 的处理、URL的转换等。 下面我们来具体了解下它的用法。

3. 对接实战

首先新建一个项目,名为scrapysplashdemo,命令如下所示:

scrapy startproject scrapysplashdemo

进入项目,新建一个Spider,命令如下所示:

scrapy genspider book spa5.scrape.center

这样我们便创建了初始的Spider,然后创建一个同样的BookItem,代码如下:

from scrapy.item import Item, Field
class BookItem(Item):
name = Field()
tags = Field()
score = Field()
cover = Field()
price = Field()

接下来就需要进行Scrapy-Splash相关的配置了,可以参考Scrapy-Splash 的配置说明:https://github.com/scrapy-plugins/scrapy-splash#configuration。 修改 settings.py,配置 SPLASH_URL。这里的Splash 运行在本地,所以可以直接配置本地的地址:

SPLASH_URL = 'http://localhost:8050'

如果 Splash 是在远程服务器运行的,那么此处就应该配置为远程的地址,例如我配置了一个Splash 集群,地址为 https://splash.scrape.center,用户名和密码均为admin,则此处的配置应该是这样的:

SPLASH_URL = 'https://splash.scrape.center'

另外还需要在 Spider 里面增加下面两个变量的定义以支持HTTP Basic Authentication:

class BookSpider (Spider):
http_user = 'admin'
http_pass = 'admin'

接着配置几个Middleware,代码如下所示:

DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashCookiesMiddleware': 723,
'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}
SPIDER_MIDDLEWARES = {
'scrapy_splash.SplashDeduplicateArgsMiddleware': 100
}

这里配置了3个Downloader Middleware 和一个 Spider Middleware,这是Scrapy-Splash 的核心部 分。我们不再需要像对接 Selenium 那样实现一个 Downloader Middleware, Scrapy-Splash 库都为我们 准备好了,直接配置即可。 还需要配置一个去重的类 DUPEFILTER_CLASS,代码如下所示:

DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter'

最后配置一个 Cache 存储 HTTPCACHE_STORAGE,代码如下所示:

HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage'

配置完成之后,我们就可以利用 Splash 来抓取页面了。我们可以直接生成一个 SplashRequest 对 象并传递相应的参数,Scrapy会将此请求转发给 Splash, Splash 对页面进行渲染加载,再将渲染结果 传递回来。此时 Response 的内容就是渲染完成的结果了,最后交给 Spider 解析即可。 我们来看一个示例,代码如下所示:

yield SplashRequest(url, self.parse_result,
args={
'wait': 0.5, #等待时间
},

在这里构造了一个 SplashRequest 对象,前两个参数依然是请求的 URL 和回调函数,可以通过 args 传递一些渲染参数,例如等待时间 wait 等,还可以根据 endpoint 参数指定渲染接口,更多参数 可以参考文档的说明:https://github.com/scrapy-plugins/scrapy-splash#requests。 另外我们也可以生成 Request 对象,关于 Splash 的配置通过 meta 属性配置即可,代码如下:

yield scrapy.Request(url, self.parse_result, meta={
'splash': {
'args': {
'html': 1,
'png': 1,
},
# 以下为可选参数
'endpoint': 'render.json',  # 可选参数,Splash 渲染终端,默认为 render.json
'splash_url': '<url>',      # 可选参数,覆盖 SPLASH URL
'splash_headers': {},       # 可选参数,发送给 Splash 渲染时候设置的Headers
'dont_process_response': True,  # 可选参数,不处理 Response,默认是 False
'dont_send_headers': True,  # 可选参数,不发送 Headers,默认是 False
}
})

通过 args 来配置 SplashRequest 对象与通过 meta 来配置 Request 对象,两种方式达到的效果是相 同的。 我们可以首先定义一个 Lua 脚本,来实现页面加载,代码如下所示:

function main(splash, args)
assert(splash:go(args.url))
assert(splash:wait(5))
return {
html = splash:html(),
png = splash:png(),
har = splash:har()
}

end 逻辑非常简单,就是获取参数中的 url 属性并访问,然后等待 5 秒,最后把截图、HTML 代码、 HAR 信息返回。 我们将脚本放到 Splash 中运行,同时设置目标 URL 为 https://spa5.scrape.center,如图 15-16 所示。

图15-16 Splash 配置
图15-16 Splash 配置

点击 Render me 按钮,可以看到 Splash 中就出现了对应的渲染结果,如图 15-17 所示。

图15-17 渲染结果
图15-17 渲染结果

测试成功之后,我们只需要在 Spider 里用 SplashRequest 对接 Lua 脚本就好了,代码如下所示:

from scrapy import Spider
from scrapy_splash import SplashRequest
script = """
function main(splash, args)
assert(splash:go(args.url))
assert(splash:wait(5))
return splash:html()

end """

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 SplashRequest(start_url, callback=self.parse_index,
args={'lua_source': script}, endpoint='execute')

这里我们把 Lua 脚本定义成长字符串,通过SplashRequest 的 args 来传递参数。另外,args 参数 里有一个 lua_source字段,它可以用于指定 Lua 脚本内容。于是我们成功构造了一个SplashRequest, 对接 Splash 的工作就完成了。 实现其他方法也一样,我们需要把 Request 都按照要求修改为 SplashRequest,相关代码改写如下:

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 SplashRequest(detail_url, callback=self.parse_detail, priority=2,
args={'lua_source': script}, endpoint='execute')
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 SplashRequest(next_url, callback=self.parse_index,
args={'lua_source': script), endpoint='execute')
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

这里我们参考上一节的内容,将SeleniumRequest 修改为了 SplashRequest,同时增加了 args 参 数配置,其他的逻辑基本一致。这样一来,Scrapy 和 Splash 的对接就全部完成了。 接下来,我们通过如下命令运行爬取:

scrapy crawl book

可以看到我们同样完成了结果的爬取,运行结果如下:

2020-09-14 00:57:34 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/3026840>
{'cover': 'https://img9.doubanio.com/view/subject/l/public/s2990855.jpg',
'name':'真理之剑1巫师第一守则(上)',
'price': 'NT$ 280',
'score': '8.0',
'tags': ['奇幻','小说','魔幻','美国','TerryGoodkind']}
2020-09-14 00:57:35 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/11601310

via

https://splash.cuiqingcai.com/execute> (referer: None)
2020-09-14 00:57:35 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/21993088
via https://splash.cuiqingcai.com/execute> (referer: None)
2020-09-14 00:57:35 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/21365989
via https://splash.cuiqingcai.com/execute> (referer: None)
2020-09-14 00:57:35 [scrapy.core.engine] DEBUG: Crawled (200) <GET
via https://splash.cuiqingcai.com/execute> (referer: None)
https://spa5.scrape.center/detail/22231998
2020-09-14 00:57:35 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/24845099
via https://splash.cuiqingcai.com/execute> (referer: None)
2020-09-14 00:57:35 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/11601310>
{'cover': 'https://img3.doubanio.com/view/subject/l/public/s27290132.jpg',
'name':'传统下的独白',
'price': '38.00元',
'score': '8.2',
'tags': ['李敖','杂文','台湾','散文随笔','历史']}

由于 Splash 和 Scrapy 都支持异步处理,我们可以看到同时会有多个抓取成功的结果。另外在使 用了Splash 之后,爬虫的主体逻辑和JavaScript 渲染流程是完全分开的,因此只要 Splash 能够承受对 应的渲染并发量,爬取效率还是不错的。 为了提高 Splash 的渲染能力,我们可以将 Splash 配置为集群,这样一来其渲染能力会成倍提升, 具体的配置方案可以参考 https://setup.scrape.center/splash-cluster 里面的说明。

4. 总结

本节中我们介绍了Scrapy 对接 Splash 实现爬取 JavaScript 渲染页面的流程,同样不失为一个不错 的方案。 本节代码参见:https://github.com/Python3WebSpider/ScrapySplashDemo。

15.11 Scrapy 对接 Pyppeteer

前面两节我们了解了 Scrapy 对接 Selenium 和 Splash 的流程,还差一个Pyppeteer,本节我们来了 解 Scrapy 和 Pyppeteer 的对接方式。

1. 爬取目标

本节的爬取目标与15.8节和15.9节是一样的,这里不展开介绍了,本节我们改为用 Scrapy 和 Pyppeteer 实现。 Scrapy 对接 Pyppeteer 的流程和原理与对接 Selenium 的流程基本一致,同样借助于 Downloader Middleware 来实现。最大的不同是,Pyppeteer 需要基于 asyncio 异步执行,这就需要我们用到 Scrapy 对 asyncio 的支持。

2. 准备工作

在本节开始之前,请确保已经安装好了Scrapy,这里要求 Scrapy 的版本不能低于2.0, 2.0版本以 下的 Scrapy 是不支持 asyncio的。另外还需要安装好 Pyppeteer 并能正常启动。如尚未安装可以参考

3. 对接原理

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 提供了一个 from Future 方法,它可以接收一个 Future 对象,返回一个 Deffered 对象,另 外还需要更换 Twisted 的 Reactor 对象,在Scrapy的settings.py 中需要添加如下代码:

TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'

这样便可以实现 Scrapy 对 Future 的异步执行,从而实现 Scrapy 对 asyncio 的支持。 好,以上便是基本的原理,下面让我们来动手实现一下上面的流程吧。

4. 对接实现

首先我们新建一个项目,叫作 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 Request(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 Request(detail_url, callback=self.parse_detail, priority=2)
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并返回,整个过程和对接 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

CONCURRENT_REQUESTS = 3
DOWNLOADER MIDDLEWARES = {
'scrapypyppeteerdemo.middlewares. PyppeteerMiddleware': 543,
}

以上我们初步完成了Scrapy 和 Pyppeteer 的对接流程。 下面我们运行一下Spider,命令如下:

scrapy crawl book

可以看到 Spider在的运行过程中,与Pyppeteer 对应的 Chromium 浏览器弹出来并加载了对应的 页面,控制台输出如下:

2020-09-19 14:58:25 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/
34672176> (referer: https://spa5.scrape.center/page/1)
[I:pyppeteer.launcher] Browser listening on: ws://127.0.0.1:62777/devtools/browser/65d32e49-48d6-4d1e-

bc53-134f5090ba32

2020-09-19 14:58:26 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/
34672176>
{'cover': 'https://img1.doubanio.com/view/subject/l/public/s33519539.jpg',
'name':'呼吸',
'price': '42',
'score': '8.6',
'tags': ['科幻','科幻小说','特德·姜','小说','短篇小说']}

:pyppeteer.launcher] terminate chrome process...

2020-09-19 14:58:26 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/6082808>

(referer: https://spa5.scrape.center/page/1)

[I:pyppeteer.launcher] Browser listening on: ws://127.0.0.1:63033/devtools/browser/d7ea1c79-2787-4668-9596-

9e894c61865

2020-09-19 14:58:27 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/6082808>
{'cover': 'https://img9.doubanio.com/view/subject/1/public/s6384944.jpg',
'name':'百年孤独',
'price': '39.50元',
'score': '9.2',
'tags': ['百年孤独','加西亚·马尔克斯','魔幻现实主义','经典','拉美文学']}

我们爬取到了渲染后的页面,至此我们就借助 Pyppeteer 实现了 Scrapy 对 JavaScript 渲染页面的 爬取。

5. 对接优化

同样,我们刚才实现的PyppeteerMiddleware 功能也是比较粗糙的,简单列举几点。

Pyppeteer 初始化的时候仅指定了 headless 参数,还有很多配置项并不支持自定义配置。

没有实现异常处理,比如出现PageError 或TimeoutError 如何进行重试。

加载过程简单指定了固定等待时间,没有设置等待某一特定节点。 口没有设置 Cookie、执行JavaScript、截图等一系列扩展功能。 为了解决这些问题,我写了一个Python包,对以上的 PyppeteerMiddleware 做了一些优化。

Pyppeteer 的初始化参数可配置,可以通过全局 settings 或 Request 对象进行配置。

实现了异常处理,出现加载异常会按照Scrapy 的重试逻辑进行重试。 ■ 加载过程可以指定在特定节点处进行等待,节点加载出来立即继续向下执行。

增加了设置 Cookie、执行 JavaScript、截图、代理设置等一系列功能并将参数可配置化。 增加了 PyppeteerRequest,定义 Request 更加方便而且支持多个扩展参数。 增加了 WebDriver 反屏蔽功能,将浏览器伪装成正常的浏览器防止被检测。 增加了对 Twister 的 Reactor 对象的设置,不用额外在 setttings.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 即可:

import logging
import re
from gerapy_pyppeteer import PyppeteerRequest
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, wait_for='.item .name')
def parse_index(self, response):

""" extract books and get next page :param response: :return: """

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')
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 浏览器 弹出来了,并且逐个加载对应的页面内容,加载完成之后浏览器关闭。 控制台输出如下:

2020-09-19 15:16:48 [gerapy.pyppeteer] DEBUG: set options { 'headless': True, 'dumpio': False, 'devtools': False,
'args': ['--window-size=1400,700', '--disable-extensions', '--hide-scrollbars', '--mute-audio', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-gpu'], 'ignoreDefaultArgs': ['--enable-automation'], 'handleSIGINT':
True, 'handleSIGTERM': True, 'handleSIGHUP': True, 'autoClose': True}
2020-09-19 15:16:48 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/26838522>
{'cover': 'https://img1.doubanio.com/view/subject/l/public/s28904947.jpg',
'name':'发展心理学:儿童与青少年(第9版)(万千心理)',
'price': '88.00',
'score': '9.2',
'tags': ['心理学','育儿','儿童心理学','发展心理学','教育']}
2020-09-19 15:16:48 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://spa5.scrape.center/detail/
30143042> (referer: https://spa5.scrape.center/page/1)
2020-09-19 15:16:48 [gerapy.pyppeteer] DEBUG: processing request <GET https://spa5.scrape.center/detail/30356718>
2020-09-19 15:16:48 [gerapy.pyppeteer] DEBUG: pyppeteer_meta {}
2020-09-19 15:16:48 [gerapy.pyppeteer] DEBUG: set options {'headless': True, 'dumpio': False, 'devtools': False,
'args': ['--window-size=1400, 700', '--disable-extensions', '--hide-scrollbars', '--mute-audio', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-gpu'], 'ignoreDefaultArgs': ['--enable-automation'], 'handleSIGINT':
True, 'handleSIGTERM': True, 'handleSIGHUP': True, 'autoClose': True}
2020-09-19 15:16:48 [scrapy.core.scraper] DEBUG: Scraped from <200 https://spa5.scrape.center/detail/30143042>
{'cover': 'https://img1.doubanio.com/view/subject/1/public/s29688547.jpg',
'name': 'OVERLORD (11)',
'price': None,
'score': '7.6',
'tags': ['轻小说','丸山くがね','日本','奇幻','骨傲天']}

这样我们就借助 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_WIDTH = 1400
GERAPY_PYPPETEER_WINDOW_HEIGHT = 700

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:其他。 比如我们想要在爬取过程中忽略图片、字体文件的加载,可以进行如下配置:

GERAPY_PYPPETEER_IGNORE_RESOURCE_TYPES = ['image', 'font']

默认情况下是留空的,即加载所有内容。

截图

GerapyPyppeteer 提供了截图功能,其参数可以在PyppteeerRequest的screenshot 中定义,格式 和 Pyppeteer 的 screenshot 的参数一致,可以参考官方文档:https://pyppeteer.github.io/pyppeteer/reference. html#pyppeteer.page.Page.screenshot。 例如我们可以在PyppteerRequest 中增加 screenshot 参数,配置如下:

yield 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 的对接,更多的用法可以参考 GerapyPyppteer 的仓库地址:https://github.com/ Gerapy/GerapyPyppeteer。

6. 总结

本节我们介绍了 Scrapy 和 Pyppteer 的对接解决方案和优化方案。至此,Scrapy 对接 Selenium、 Splash、Pyppeteer 的方案就都介绍完了,大家可以根据情况自行选择对应的方案。 本节代码参见:https://github.com/Python3WebSpider/ScrapyPyppeteerDemo。

15.12 Scrapy 规则化爬虫

前文我们了解了Scrapy 中 Spider的用法,在实现 Spider 的过程中,我们需要定义特定的方法完 成一系列操作,比如生成 Response、解析 Response、生成Item等。由于整个过程是由代码实现的, 所以逻辑控制比较灵活,但是可扩展性和可维护性相对比较差。 试想,如果我们现在要实现对非常多站点的爬取,比如爬取各大站点的新闻内容,那么可能需要 为每个站点单独创建一个Spider, 然后在 Spider 中定义爬取列表页、详情页的逻辑。其实这些 Spider 的 基本实现思路是差不多的,可能包含很多重复代码,因此可维护性就变得比较差。 如果我们可以保留各个站点的Spider的公共部分,提取不同的部分进行单独配置(如将爬取规则、 页面解析方式等抽离出来,做成一个配置文件),那么我们在新增一个爬虫的时候,只需要实现这些 网站的爬取规则和提取规则,而且还可以单独管理和维护这些规则。 本节,我们就来探究一下 Scrapy 规则化爬虫的实现方法。

1. CrawlSpider

在实现规则化爬虫之前,我们需要了解一下 CrawlSpider用法。它是 Spider 类的子类,利用它我 们可以方便地实现站点的规则化爬取,其官方文档链接为:http://scrapy.readthedocs.io/en/latest/topics/ spiders.html#crawlspider。 在CrawlSpider里,我们可以指定特定的爬取规则来实现页面的解析和爬取逻辑,这些规则由一 个专门的数据结构 Rule表示。Rule里包含提取和跟进页面的配置,CrawlSpider 会根据 Rule 来确定当 前页面中哪些链接需要继续爬取,哪些页面的爬取结果需要用哪个方法解析等。 CrawlSpider 继承自Spider类,除了Spider 类的所有方法和属性,它还提供了一个非常重要的属 性 rules。rules 是爬取规则属性,是包含一个或多个Rule对象的列表。每个Rule 对爬取网站的规则都 做了定义,CrawlSpider 会读取 rules 的每一个 Rule 并执行对应的爬取逻辑。 它的定义和参数如下所示:

class scrapy.spiders.Rule(link_extractor=None, callback=None, cb_kwargs=None, follow=None,
process_links=None, process_request=None, errback=None)

下面对其参数依次说明。 link_extractor:一个 LinkExtractor 对象。通过它,Spider可以知道从爬取的页面中提取哪 些链接进行后续爬取,提取出的链接会自动生成 Request,这些提取逻辑依赖 LinkExtractor 对 象里面定义的各种属性,下文会具体介绍。

callback:回调方法,和之前定义 Request的callback 有相同的意义。每次从link_extractor 中提取到链接时,该方法将会被调用。该回调方法接收 response 作为其第一个参数并返回一 个包含 Item 或 Request 对象的列表。需要注意的是,避免使用parse 方法作为回调方法,因 为CrawlSpider 使用parse 方法来实现其解析逻辑,如果parse方法被重写了,CrawlSpider可 能无法正常运行。

cb_kwargs:一个字典类型,使用它我们可以定义传递给回调方法的参数。

follow:一个布尔值,它指定根据该规则从 response 提取的链接是否需要跟进爬取。跟进的 意思就是将提取到的链接进一步生成 Request 进行爬取;如果不跟进的话,一般可以定义回调 方法解析内容,生成Item。如果 callback 参数为 None, follow 值默认设置为 True,否则默 认为False。

process_links:可以是一个callable 方法,也可以是一个字符串(需要和 CrawlSpider 里面 定义的方法名保持一致)。它用来处理该 Rule 中的link_extractor 提取到的链接,比如可以进 行链接的过滤或对链接进行进一步修改。

process_request:可以是一个callable方法,也可以是一个字符串(需要和 CrawlSpider 里 面定义的方法名保持一致)。根据该 Rule 提取到每个后续 Request时,该方法都会被调用,该 方法可以对 Request 进行进一步处理,必须返回 Request 对象或者 None。

Errback:该参数是 Scrapy 2.0版本之后新增的参数,它也可以是一个callable 方法,也可以是 一个字符串(需要和 CrawlSpider 里面定义的方法名保持一致)。当该Rule提取出的Request 在被 处理的过程中发生错误时,该方法会被调用,该方法第一个参数接收一个 Twisted Failure 对象。 以上内容便是 CrawlSpider 中的核心数据结构 Rule的基本用法,利用Rule 我们可以方便地实现 爬取逻辑的规则化。

2. LinkExtractor

上文我们了解了Rule的基本用法,其中一个重要的属性就是link_extractor,下面我们再来专门 了解一下它的用法。 LinkExtractor 定义了从 Reponse 中提取后续链接的逻辑,在Scrapy 中它指的就是 scrapy. linkextractors.lxmlhtml.LxmlLinkExtractor 这个类,为了方便调用,Scrapy 为其定义了一个别名, 叫作 LinkExtractor,二者是指的都是LxmlLinkExtractor。 LxmlLinkExtractor 接收多个用于提取链接的参数,下面依次对其进行说明。

allow:一个正则表达式或正则表达式列表,它定义了从当前页面提取出的链接需要符合的规 则,只有符合对应规则的链接才会被提取。

deny:和allow 正好相反,它也是一个正则表达式或正则表达式列表,定义了从当前页面中禁 止被提取的链接对应的规则,相当于黑名单,它的优先级比 allow高。

allow_domains:定义了符合要求的域名,只有此域名的链接才会被提取出来,它相当于域名 白名单。

deny_domains: 和 allow_domains相反,相当于域名黑名单,该域名所对应的链接都不会被提 取出来。

deny_extensions:在提取链接的过程中可能会遇到一些特殊的后缀,即扩展名。deny_extensions 定义了后缀黑名单,包含这些后缀的链接都不会被提取出来。deny_extensions 的默认值由 scrapy.linkextractors.IGNORED_EXTENSIONS 变量定义。在Scrapy 2.0中,IGNORED_EXTENSIONS 包含了7z、7zip、apk、bz2、cdr、dmg、ico、iso、tar、tar.gz、webm、xz等类型,这些后缀 的链接都会被忽略。

restrict_xpaths:如果定义了该参数,那么 Spider 将会从当前页面中 XPath 匹配的区域提取 链接,其值是XPath 表达式或XPath 表达式列表。

restrict_css:和 restrict_xpaths类似,如果定义了 restrict_css, Spider 将会从当前页面 中CSS 选择器匹配的区域提取链接,其值是CSS 选择器或CSS 选择器列表。

tags:指定了从什么节点中提取链接,默认是('a', 'area'),即从a节点和 area 节点中提取 链接。

attrs:指定了从节点的什么属性中提取链接,默认是('href',),和tags 属性配合起来,那 将会从a 节点和 area 节点的 href 属性中提取链接。比如我们需要从img 节点的src 属性中 提取链接,那可以将tags 定义为('a', 'area', 'img'), attrs 定义为('href','src')。

canonicalize:是否需要对提取到的链接进行规范化处理,处理流程借助 w3lib.url. canonicalize_url模块,该参数默认为 False。

unique:是否需要对提取到的链接进行去重,默认是True。

process_value:是一个callable方法,可以通过这个方法来定义一个逻辑,这个逻辑负责完 成提取内容到最终链接的转换。比如说 href属性里面的值是一段 JavaScript 变量,值为 javascript:goToPage('../other/page.html'),这明显不是一个有效的链接,process_value 对 应的方法可以接收这个值并对这个值进行处理,提取真实的链接再返回。

strip:如果从节点对应的属性值中提取到了结果,是否要去掉首尾的空格,默认是 True。 以上便是LinkExtractor 的一些参数的用法,其中前几个参数使用频率较高,可以重点关注。有关 LinkExtractor 更详细的介绍可以参考官方文档: http://scrapy.readthedocs.io/en/latest/topics/link-extractors. html#module-scrapy.linkextractors.lxmlhtml。

3. Item Loaders

我们了解了利用 CrawlSpider 的Rule来定义页面的爬取逻辑,这是可配置化的一部分内容,借助 Rule,我们可以实现页面内容的提取和爬取逻辑。但是,Rule并没有对Item的提取方式做规则定义。 对于 Item 的提取,我们需要借助另一个模块 Item Loaders 来实现。 可以这么理解,Item提供的是保存抓取数据的容器,而 Item Loaders 提供的是填充容器的机制。 尽管 Item 可以直接由代码进行构造,但Item Loaders 提供一种便捷的机制来帮助我们方便地提取 Item, 它提供了更灵活、可扩展的机制来实现 Item 的提取逻辑,同时也有助于我们实现爬虫的规则化。

Item Loaders 的用法如下所示:

class scrapy.loader.ItemLoader([item, selector, response,] **kwargs)

这里我们使用的 Scrapy 提供的 ItemLoader 类,ItemLoader 的返回一个新的 ItemLoader 来填充 给定的Item。如果没有给出 Item,则使用 default_item_class 中的类自动实例化。另外,它传入 selector 和 response 参数来使用选择器或响应参数实例化。

下面将依次说明 Item Loader 的参数。

item:Item 对象,可以调用add_xpath、add_css 或 add_value 等方法来填充 Item 对象。

selector:Selector对象,用来提取填充数据的选择器。

response:Response 对象,用于使用构造选择器的 Response。

一个比较典型的 ItemLoader 实例如下:

from scrapy.loader import ItemLoader
from project.items import Product
def parse(self, response):
loader = ItemLoader(item=Product(), response=response)
loader.add_xpath('name', '//div[@class="product_name"]')
loader.add_xpath('name', '//div[@class="product_title"]')
loader.add_xpath('price', '//p[@id="price"]')
loader.add_css('stock', 'p#stock]')
loader.add_value('last_updated', 'today')
return loader.load_item()

这里首先声明一个 Product Item,用该 Item 和 Response 对象实例化 ItemLoader,调用 add_xpath 方 法把来自两个不同位置的数据提取出来,分配给 name 属性,再用 add_xpath、add_css、add_value 等 方法对不同属性依次赋值,最后调用 load_item 方法实现对 Item 的解析。这种方式比较规则化,我们 可以把一些参数和规则单独提取出来,做成配置文件或存到数据库,实现可配置化。 另外,Item Loader 的每个字段中都包含了一个 Input Processor(输入处理器)和一个 Output Processor(输出处理器),利用它们我们可以灵活地对 Item 的每个字段进行处理。Input Processor 收到 数据时立刻提取数据,Input Processor 的结果被收集起来并且保存在 ItemLoader 内,但是不分配给 Item。收集到所有的数据后,load_item 方法被调用来填充再生成 Item 对象。在调用时会先调用 Output Processor 来处理之前收集到的数据,然后再存入 Item 中,这样就生成了 Item。 类似的用法如下:

from itemloaders.processors import TakeFirst, MapCompose, Join
from scrapy.loader import ItemLoader
class ProductItemLoader(ItemLoader):
default_output_processor = TakeFirst()
name_in = MapCompose (unicode.title)
name_out = Join()
price_in = MapCompose (unicode.strip)

这里我们定义了一个 ProductItemLoader 继承了 ItemLoader 类,并定义了几个属性的 Input Processor 和 Output Processor,比如 name 属性的 Input Processor 就使用了 MapCompose, Output Processor 就使用了 Join,这样利用 ProductItemLoader,我们就可以灵活地实现特定属性的数据收集和处理。 另外可以看到这里用到了 TakeFirst、MapCompose、Join,这些都是 Scrapy 提供的一些 Processor, 分别可以实现提取首个内容、迭代处理、字符串拼接的操作,利用这些 Processor 的组合,我们可以 灵活地实现对特定字段数据的处理。 其实 Scrapy 已经给我们提供了不少 Processor,我们来了解一下。

Identity

Identity 是最简单的 Processor,不进行任何处理,直接返回原来的数据。

TakeFirst

TakeFirst 返回列表的第一个非空值,类似 extract_first 的功能,常用作 Output Processor,示 例代码如下:

from scrapy.loader.processors import TakeFirst
processor = TakeFirst()
print(processor(['', 1, 2, 3]))

输出结果如下所示:

经过此 Processor 处理后的结果返回了第一个不为空的值。

Join

Join 方法相当于字符串的 join 方法,可以把列表拼合成字符串,字符串默认使用空格分隔,示

例代码如下:

from scrapy.loader.processors import Join
processor = Join()
print(processor(['one', 'two', 'three']))

输出结果如下: one two three 它也可以通过参数更改默认的分隔符,例如改成逗号:

from scrapy.loader.processors import Join
processor = Join(',')
print(processor (['one', 'two', 'three']))

运行结果如下: one, two, three

Compose

Compose 是使用多个函数组合构造而成的 Processor,每个输入值被传递到第一个函数,其输出再 传递到第二个函数,以此类推,直到最后一个函数返回整个处理器的输出,示例代码如下:

from scrapy.loader.processors import Compose
processor = Compose (str.upper, lambda s: s.strip())
print(processor('hello world'))

运行结果如下: HELLO WORLD 在这里我们构造了一个Compose Processor,传入一个开头带有空格的字符串。Compose Processor 的参数有两个:第一个是str.upper,它可以将字母全部转为大写;第二个是一个匿名函数,它调用 strip 方法去除头尾空白字符。Compose 会顺次调用两个参数,最后返回结果的字符串全部转化为大 写并且去除了开头的空格。

MapCompose

与Compose 类似,MapCompose 可以迭代处理一个列表输入值,示例代码如下:

from scrapy.loader.processors import MapCompose
processor = MapCompose (str.upper, lambda s: s.strip())
print(processor (['Hello', 'World', 'Python']))

运行结果如下:

['HELLO', 'WORLD', 'PYTHON']

被处理的内容是一个可迭代对象,MapCompose 会将该对象遍历然后依次处理。

SelectJmes

SelectJmes 可以查询JSON,传入Key,返回查询所得的Value。不过需要先安装jmespath 库才可 以使用它,安装命令如下:

pip3 install jmespath

安装好 jmespath 之后,便可以使用这个Processor了,示例代码如下:

from scrapy.loader.processors import SelectJmes
processor = SelectJmes('foo')
print(processor({'foo': 'bar'}))

运行结果如下: bar

以上内容便是 ItemLoader 和一些常用的 Processor 的用法。 我们一下子又接触了不少新概念,如CrawlSpider、Rule、LinkExtractor、Item Loaders、Processor, 你可能感觉有点懵,不知道如何使用。不用担心,下面我们通过一个实例来将这些内容综合运用一下, 实现一个规则化的Scrapy爬虫。

4. 本节目标

本节我们以前文所爬取过的电影示例网站作为练习来实现一下 Scrapy 规则化爬虫的实现方式,爬 取的目标站点是https://ssrl.scrape.center/,如图15-18所示。

图15-18 目标页面
图15-18 目标页面

和之前不同,这次我们需要利用 CrawlSpider、Rule、Item Loaders 等实现对该站点的爬取,最后 我们还需要将爬取规则进行进一步的抽取,变成JSON文件,实现爬取规则的灵活可配置化。 在开始之前请确保已经安装好了 Scrapy 框架。

5. 实战

首先新建一个 Scrapy项目,名为scrapyuniversaldemo,命令如下所示:

scrapy startproject scrapyuniversaldemo

然后我们进入到该文件夹,这次我们便需要创建一个CrawlSpider,而不再单纯的是Spider。要创 建 CrawlSpider,需要指定一个模板。 我们可以先看看有哪些可用模板,命令如下所示:

scrapy genspider -1

运行结果如下所示: Available templates: basic crawl csvfeed xmlfeed 之前创建 Spider 的时候,我们默认使用了第一个模板 basic。这次要创建CrawlSpider,需要使用 第二个模板 crawl,创建命令如下所示:

scrapy genspider -t crawl movie ssr1.scrape.center

运行之后便会生成一个 CrawlSpider,其内容如下所示:

import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
class MovieSpider(CrawlSpider):
name = 'movie'
allowed_domains = ['ssr1.scrape.center']
start_urls = ['http://ssr1.scrape.center/']
rules = (
Rule(LinkExtractor(allow=r'Items/'), callback='parse_item', follow=True),
)
def parse_item(self, response):
item = {}
#item['domain_id'] = response.xpath('//input[@id="sid"]/@value').get()
#item['name'] = response.xpath('//div[@id="name"]').get()
#item['description'] = response.xpath('//div[@id="description"]').get()
return item

这次生成的 Spider 内容多了一个对 rules 属性的定义。Rule的第一个参数是 LinkExtractor,就是 上文所说的 LxmlLinkExtractor。同时,默认的回调方法也不再是parse,而是 parse_item。在parse_item 里面定义了 Response 的解析逻辑,用于生成 Item。 接下来我们需要完善一下 Rule,使用Rule 来定义好爬取逻辑和解析逻辑,下面我们来一步步实 现这个过程。 由于当前需要爬取的目标网站的首页就是第一页列表页,所以start_urls 这边我们不需要做额外 更改了。运行该 CrawlSpider, CrawlSpider 就会从首页开始爬取,得到首页 Response 之后,CrawlSpider 便会使用 rules 属性里面配置的 Rule从 Response 中抽取下一步需要爬取的链接,生成进一步的 Request。所以,接下来我们就需要配置 Rule 来指定下一步的链接提取和爬取逻辑了。 我们再看下页面的源代码,如图15-19所示。

图15-19 页面的源代码
图15-19 页面的源代码

我们要提取的详情页链接处于 class 为 item 对应的节点中,对应的是 class 为 name 的 a 节点, 其中 href 属性就是需要提取的内容,每页有10个。 此处我们可以用 LinkExtractor 的 restrict_css 属性来指定要提取的链接所在的位置,之后 CrawlSpider 就会从这个区域提取所有的超链接并生成 Request。默认情况下会提取所有 a 节点和 area 节点的 href 属性,符合我们的需求,所以无须额外配置 tags、attrs。

接下来我们将 rules 修改为如下内容:

rules = (
Rule(LinkExtractor(restrict_css='.item .name'), follow=True, callback='parse_detail'),
)

这里我们指定了 LinkExtractor 并声明了 restrict_css 属性,另外 follow 属性设置为 True 代表 Spider 需要跟进这些提取到的链接进行爬取,同时还指定了 callback 为字符串 parse_detail,这样 提取到的链接被爬取之后会回调 parse_detail 方法进行解析。

这里我们可以简单定义一个 parse_detail 方法打印输出被爬取到的链接内容:

def parse_detail(self, response):
print(response.url)

运行一下当前 CrawlSpider,命令如下:

scrapy crawl movie

便可以看到如下输出:

2020-10-09 11:20:53 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/9>

(referer: https://ssr1.scrape.center/)

https://ssr1.scrape.center/detail/3
2020-10-09 11:20:53 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/6>

(referer: https://ssr1.scrape.center/)

https://ssr1.scrape.center/detail/9
2020-10-09 11:20:53 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/1>

(referer: https://ssr1.scrape.center/)

2020-10-09 11:20:53 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/5>

(referer: https://ssr1.scrape.center/)

https://ssr1.scrape.center/detail/2
https://ssr1.scrape.center/detail/1
https://ssr1.scrape.center/detail/5

由于内容过多,这里省略了部分输出结果。我们可以看到首页对应的10个详情页链接就被提取 出来了,同时这些链接又被进一步构造成了 Request 执行了爬取,爬取成功后,通过回调 parse_detail 方法,打印输出了对应的链接。这些逻辑我们通过一个简单的 Rule 的配置就完成了,是不是感觉比 之前方便多了?

好,到现在我们仅仅爬取了首页的内容,后续的列表页怎么办呢?不用担心,我们可以定义另外 一个 Rule 来实现翻页。

我们再看下一页的页面源码,查看下一页链接对应的节点信息,如图 15-20 所示。

图15-20 查看页面源码
图15-20 查看页面源码

这里可以观察到,下一页链接对应的是 class 为 next 的 a 节点,其 href 属性就是下一页的内容。 相似地,我们可以修改 Rule 为如下内容:

rules = (
Rule(LinkExtractor(restrict_css='.item .name'), follow=True, callback='parse_detail'),
Rule(LinkExtractor(restrict_css='.next'), follow=True),
)

这里我们又增加了一条 Rule,定义了 restrict_css 为 .next,同时指定了 follow 为 True。但因 为这次我们不需要从列表页提取 Item,所以这里我们无须额外指定 callback。 这样整个爬取逻辑就已经定义好了,我们重新运行一下 CrawlSpider,可以看到 CrawlSpider 就可 以爬取分页信息了,输出结果如下:

2020-10-09 11:52:29 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/76>

(referer: https://ssr1.scrape.center/page/8)

https://ssr1.scrape.center/detail/75
2020-10-09 11:52:29 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/72>

(referer: https://ssr1.scrape.center/page/8)

https://ssr1.scrape.center/detail/74
2020-10-09 11:52:29 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/page/10>

(referer: https://ssr1.scrape.center/page/9)

接下来我们需要做的就是解析页面内容了,刚才我们只是简单定义了 parse_detail 方法,下面 我们来使用 Item Loaders 实现内容的提取。 首先我们还是需要定义一个 MovieItem,内容如下:

from scrapy import Field, Item
class MovieItem(Item):
cover = Field()
categories = Field()
published_at = Field()
drama = Field()
score = Field()

这里的字段分别指电影名称、封面、类别、上映时间、剧情简介、评分,定义好 MovieItem 之后, 我们如果不使用 ItemLoader 正常提取内容,就直接调用 response 变量的xpath、css等方法即可, parse_detail 方法可以实现为如下内容:

def parse_detail(self, response):
item = MovieItem()
item['name'] = response.css('.item h2::text').extract_first()
item['categories'] = response.css('.categories button span::text').extract()
item['cover'] = response.css('.cover::attr(src)').extract_first()
item['published_at'] = response.css('.info span::text').re_first('(\d{4}-\d{2}-\d{2})\s?上映')
item['score'] = response.xpath('//p[contains(@class, "score")]/text()').extract_first().strip()
item['drama'] = response.xpath('//div[contains(@class, "drama")]/p/text()').extract_first().strip()
yield item

这样我们就把每条新闻的信息提取形成了一个 MovieItem对象。 这时实际上我们就已经完成了Item的提取。再运行一下 CrawlSpider:

scrapy crawl movie

可以看到一部部电影信息就被提取出来了,运行结果类似如下:

2020-10-09 12:31:03 [scrapy.core.scraper] DEBUG: Scraped from <200 https://ssr1.scrape.center/detail/6>
{'categories': ['喜剧', '爱情', '古装'],
'cover': 'https://po.meituan.net/movie/da64660f82b98cdc1b8a3804e69609e04108.jpg@464w_644h_1e_1c',
'drama': '唐伯虎(周星驰饰)身为江南四大才子之首,却有道不尽的心酸。宁王想让唐伯虎帮其图谋作反,被唐伯虎

拒绝意淫结仇。唐伯虎在与朋友出游时,遇到了貌若天仙的秋香(巩俐饰)并对她一见钟情,决心要到华府当家丁以追 求秋香,唐伯虎被取名华安。期间华太师遇到了宁王上门刁难,幸好有唐伯虎出面相助,并暴露了自己是唐伯虎的身 份。秋香才知道华安是自己欣赏的唐伯虎。华夫人跟唐家有怨,因此二人便开始斗法。怎料宁王跟夺命书生再次上门, 华夫人不是对手,幸得唐伯虎出手,华夫人也答应把秋香许配给唐伯虎。',

'name': '唐伯虎点秋香 - Flirting Scholar',
'published_at': '1993-07-01',
'score': '9.5'}
2020-10-09 12:31:03 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ssr1.scrape.center/detail/9>

(referer: https://ssr1.scrape.center/)

但现在这种实现方式并不能实现可配置化,下面我们尝试将这个方法改写为 Item Loaders 来实现。 通过 add_xpath、add_css、add_value 等方式实现配置化提取。我们可以改写 parse_detail,如下所示:

def parse_detail (self, response):
loader = MovieItemLoader(item=MovieItem(), response=response)
loader.add_css('name', '.item h2::text')
loader.add_css('categories', 'categories button span::text')
loader.add_css('cover', '.cover::attr(src)')

loader.add_css('published_at', '.info span::text', re='(\d{4}-\d{2}-\d{2})\s?上映')

loader.add_xpath('score', '//p[contains(@class, "score")]/text()')
loader.add_xpath('drama', '//div[contains(@class, "drama")]/p/text()')
yield loader.load_item()

这里我们定义了一个 ItemLoader的子类,名为MovieItemLoader,其实现如下所示:

from scrapy.loader import ItemLoader
from itemloaders.processors import TakeFirst, Identity, Compose
class MovieItemLoader (ItemLoader):
default_output_processor = TakeFirst()
categories_out = Identity()
score_out = Compose (TakeFirst(), str.strip)
drama_out = Compose (TakeFirst(), str.strip)

这里我们定义了4个字段,说明如下:

default_output_processor: 上文中,由于大多数字段需要利用 extract_first 方法来获得第

一个提取结果,而在 parse_detail 方法中我们并没有指定抽取第一个结果,所以最终的结果 仍然是一个列表形式。那 extract_first 方法对应的逻辑我们需要放到哪里实现呢?答案是需 要 MovieItemLoader 来实现。这里我们定义了一个 default_output_processor,意思是通用的 输出处理器,这里指定为了TakeFirst。这样默认情况下,每个字段的第一个提取结果就会作 为该字段的最终结果,相当于默认情况下每个字段提取完毕之后就调用了 extract_first 方 法。比如 name字段,原本抽取结果为['少年派的奇幻漂流 - Life of Pi'],经过 TakeFirst 处 理后,结果就是少年派的奇幻漂流 - Life of Pi。 categories_out: 原本的提取结果是一个列表,而我们希望最终获取的也是列表,所以需要保 持原来的结果不变,而刚才我们已经定义了 default_output_processor 来提取第一个结果作 为字段内容,这里我们需要将其覆盖,定义categories_out 字段,覆盖默认的 default_output_ processor,这里定义为Identity,保持原结果不变。

score_out: 使用默认的 TakeFirst 提取之后,结果前后包含一些空格信息,我们需要进一步 将其去除,所以这里使用了 Compose,参数依次传入了 TakeFirst 和 str.strip,这样就能取 出第一个结果并去除前后的空格了。 drama_out: 和 score_out 也是一样的逻辑。 好,这时候我们重新运行一下 CrawlSpider,结果和刚才是完全一样的。 至此,我们已经实现了爬虫的半规则化。

6. 配置抽取

为什么现在只做到了半规则化?一方面,我们在代码层面上使用了 Rule 将爬取逻辑进行了规则 化,但这样可扩展性和维护性依然没有那么强。如果我们需要扩展其他站点,仍然需要创建一个新的 CrawlSpider,定义这个站点的 Rule,单独实现 parse_detail 方法。还有很多代码是重复的,如 CrawlSpider 的变量、方法名几乎都是一样的。那么我们可不可以把多个类似的几个爬虫的代码共用, 把完全不相同的地方抽离出来,做成可配置文件呢? 当然可以。那我们可以抽离出哪些部分?所有的变量都可以抽取,如name、allowed_domains、 start_urls、rules 等。这些变量在 CrawlSpider 初始化的时候赋值即可。我们就可以新建一个通用的 Spider 来实现这个功能,命令如下所示:

scrapy genspider -t crawl universal universal

这个全新的 Spider 名为 universal。接下来,我们将刚才所写的 Spider 内的属性抽离出来配置成一 个JSON,命名为movie.json,放到 configs 文件夹内,和spiders 文件夹并列,JSON文件内容如下所示:

{
"spider": "universal",
"type":"电影",
"home": "https://ssr1.scrape.center/",
"settings": {
"USER_AGENT": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko)

Chrome/60.0.3112.90 Safari/537.36"

"start_urls": [

"https://ssr1.scrape.center/"

"allowed_domains": [
},
],
],
"rules": [
{
"link_extractor": {

"ssr1.scrape.center"

"restrict_css": ".item .name"
},
"follow": true,
"callback": "parse_detail"
},
"link_extractor": {
"restrict_css": ".next"
},
"follow": true
}
]
}

这里我们将一些配置进行了抽离,第一个字段 spider 即 Spider 的名称,在这里是 universal。然 后定义了一些描述字段,比如 type、home 等说明爬取目标站点的类别、首页等。 然后就是一些重要配置了,比如可以使用 settings 定义 CrawlSpider 的custom_settings 属性,使 用 start_urls 定义初始爬取链接,使用allowed_domains 定义允许爬取的域名,这些信息都会被读取 然后初始化为 CrawlSpider 的属性。 另外我们还将 rules进行了抽离,配置为了JSON形式,是列表类型,每个成员都代表一个Rule 的 配置。进一步地,每个Rule的配置又单独分离了link_extractor 并配置上对应的属性,比如restrict_css 代表 LinkExtractor 的 restrict_css 属性。我们会使用rules 字段的信息来初始化CrawlSpider 的rules 属性。 这样我们将基本的配置抽取出来。如果要启动爬虫,只需要从该配置文件中读取,然后动态加载 到Spider 中即可。所以我们需要定义一个读取该JSON文件的方法,新建一个utils.py文件,和 items.py 文件并列,内容如下所示:

from os.path import realpath, dirname, join
import json
def get_config(name):
path = join(dirname(realpath(__file__)), 'configs', f'{name}.json')
with open(path, 'r', encoding='utf-8') as f:
return json.loads(f.read())

定义了 get_config方法之后,我们只需要向其传入JSON配置文件的名称即可获取此 JSON 配置 信息。随后我们定义入口文件 run.py,把它放在项目根目录下,命名为run.py,它的作用是启动Spider, 代码如下所示:

from scrapy.utils.project import get_project_settings
from scrapyuniversaldemo.utils import get_config
from scrapy.crawler import CrawlerProcess
import argparse
parser = argparse.ArgumentParser(description= 'Universal Spider')
parser.add_argument('name', help='name of spider to run')
args = parser.parse_args()
name = args.name
def run():
config = get_config(name)
spider = config.get('spider', 'universal')
project_settings = get_project_settings()
settings = dict(project_settings.copy())
settings.update(config.get('settings'))
process = CrawlerProcess(settings)
process.crawl(spider, **{'name': name})
process.start()
if __name__ == '__main__':
run()

这里我们使用了 argparse 要求运行时指定 name 参数,即对应的 JSON 配置文件的名称。我们首 先利用 get_config 方法传入该名称,读取刚才定义的配置文件。获取爬取使用的 Spider 的名称以及配 置文件中的 settings 配置,然后将获取到的 settings 配置和项目全局的 settings 配置做了合并。 随后我们新建了一个 CrawlerProcess, 利用 CrawlerProcess 我们可以通过代码更加灵活地自定义需 要运行的 Spider 和启动配置,更加详细的用法可以参考官方文档: https://docs.scrapy.org/en/latest/topics/ practices.html。 在 universal.py 中,我们新建一个 __init__ 方法,进行初始化配置,实现如下所示:

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from ..utils import get_config
class UniversalSpider(CrawlSpider):
name = 'universal'
def __init__(self, name, *args, **kwargs):
config = get_config(name)
self.config = config
self.start_urls = config.get('start_urls')
self.allowed_domains = config.get('allowed_domains')
rules = []
for rule_kwargs in config.get('rules'):
link_extractor = LinkExtractor(**rule_kwargs.get('link_extractor'))

rule_kwargs['link_extractor'] = link_extractor

rule = Rule(**rule_kwargs)
rules.append(rule)
self.rules = rules
super(UniversalSpider, self).__init__(*args, **kwargs)

在 __init__ 方法中,我们接收了 name 参数,然后通过 get_config 方法读取了配置文件的内容, 接着将 start_urls、allowed_domains、rules 进行了初始化。 其中 rules 的初始化过程相对复杂,这里首先遍历了 rules 配置,每个 rule 的配置赋值为 rule_kwargs 字典,然后读取了 rule_kwargs 的 link_extractor 属性,将其构造为 LinkExtractor 对象, 接着将 link_extractor 属性赋值到 rule_kwargs 字典中,最后使用 rule_kwargs 初始化一个 Rule 对象。 多个 Rule 对象最终构造成一个列表赋值给 CrawlSpider,这样就完成了 rules 的初始化。 现在我们已经实现了 Spider 基础属性的可配置化。剩下的解析部分同样需要实现可配置化,原来 的解析方法如下所示:

def parse_detail(self, response):
loader = MovieItemLoader(item=MovieItem(), response=response)
loader.add_css('name', '.item h2::text')
loader.add_css('categories', 'categories button span::text')
loader.add_css('cover', '.cover::attr(src)')

loader.add_css('published_at', '.info span::text', re='(\d{4}-\d{2}-\d{2})\s?上映')

loader.add_xpath('score', '//p[contains(@class, "score")]/text()')
loader.add_xpath('drama', '//div[contains(@class, "drama")]/p/text()')
yield loader.load_item()

我们需要将这些配置也抽离出来。这里的变量主要有 ItemLoader 类的选用、Item 类的选用、 ItemLoader 方法参数的定义。我们将可变参数进行抽离,在 JSON 文件中添加 item 的配置,参考如下:

{
"spider": "universal",
"rules": [
...
],
...
}
"item": {
"class": "MovieItem",
"loader": "MovieItemLoader",
"attrs": {
"name": [
{
"method": "css",
"arg": ".item h2::text"
}
],
"categories": [
{
"method": "css",
"arg": ".categories button span::text"
}
],
"cover": [
{
"method": "css",
"arg": ".cover::attr(src)"
}
],
"published_at": [
{
"method": "css",
"arg": ".info span::text",
"re": "(\\d{4}-\\d{2}-\\d{2})\\s?上映”
}
],
"score": [
{
"method": "xpath",
"arg": "//p[contains(@class, \"score\")]/text()"
}
],
"drama": [
{
"method": "xpath",
"arg": "//div[contains(@class, \"drama\")]/p/text()"
}
]
}
}
,item 的配置和 rules 是并列的。在 item 中,我们定义了 class 和 loader 属性,它们分别 代表 Item 和 ItemLoader 所使用的类。定义了 attrs 属性来定义每个字段的提取规则,例如, title 定 义的每一项都包含一个 method 属性,它代表使用的提取方法,如 xpath 代表调用 Item Loader 的 add_xpath 方法。arg 即参数,它是 add_xpath 方法的第二个参数,代表的是 XPath 表达式。另外针对 正则提取,这里还可以定义一个 re 参数来传递提取时所使用的正则表达式。 我们还要将这些配置动态加载到 parse_detail 方法里,实现 parse_detail 方法如下:
def parse_detail(self, response):
item = self.config.get('item')
if item:
cls = getattr(items, item.get('class'))()
loader = getattr(loaders, item.get('loader'))(cls, response=response)
for key, value in item.get('attrs').items():
for extractor in value:
if extractor.get('method') == 'xpath':
loader.add_xpath(key, extractor.get('arg'), **{'re': extractor.get('re')})
if extractor.get('method') == 'css':
loader.add_css(key, extractor.get('arg'), **{'re': extractor.get('re')})
if extractor.get('method') == 'value':
loader.add_value(key, extractor.get('args'), **{'re': extractor.get('re')})
yield loader.load_item()

这里首先获取 Item的配置信息,然后获取class 的配置,将Item进行初始化。接着利用 Item再 初始化 ItemLoader,赋值为loader对象。 接下来我们遍历Item的attrs代表的各个属性依次进行提取。首先我们需要判断 method 字段,调 用对应的处理方法进行处理。如method为css,就调用ItemLoader的add_css方法进行提取。所有配 置动态加载完毕之后,调用load_item方法将 Item 提取出来。 至此,Spider 的设置、起始链接、属性、提取方法全部实现了可配置化。 这时候我们就可以使用配置文件来启动 CrawlSpider了,运行命令如下: python3 run.py movie 运行结果如下:

2020-10-09 18:42:24 [scrapy.core.scraper] DEBUG: Scraped from <200 https://ssr1.scrape.center/detail/6>
{'categories': ['喜剧','爱情','古装'],
'cover': 'https://po.meituan.net/movie/da64660f82b98cdc1b8a3804e69609e041108.jpg@464w_644h_1e_1c',
'drama':'唐伯虎(周星驰饰)身为江南四大才子之首,却有道不尽的心酸。宁王想让唐伯虎帮其图谋作反,被唐伯虎

拒绝意淫结仇。唐伯虎在与朋友出游时,遇到了貌若天仙的秋香(巩俐饰)并对她一见钟情,决心要到华府当家丁 以追求秋香,唐伯虎被取名华安。期间华太师遇到了宁王上门刁难,幸好有唐伯虎出面相助,并暴露了自己是唐伯 虎的身份。秋香才知道华安是自己欣赏的唐伯虎。华夫人跟唐家有怨,因此二人便开始斗法。怎料宁王跟夺命书生 再次上门,华夫人不是对手,幸得唐伯虎出手,华夫人也答应把秋香许配给唐伯虎。',

'name':'唐伯虎点秋香 - Flirting Scholar',
'published_at': '1993-07-01',
'score': '9.5'}

可以看到爬取结果和之前也是完全相同的,抽离规则成功! 综上所述,整个项目的规则化包括如下内容。

spider:指定所使用的 Spider 的名称。

settings:可以专门为 Spider 定制配置信息,会覆盖项目级别的配置。

start_urls:指定爬虫爬取的起始链接。

allowed_domains:允许爬取的站点。

rules:站点的爬取规则。

item:数据的提取规则。 到现在,就可以灵活地对爬取逻辑进行控制了。

7. 总结

当然,本节仅仅是示例,主要介绍规则化爬虫的配置和抽离规则的基本思路。更多更复杂的配置 大家可以举一反三,灵活处理。 我们既然已经将配置抽离成JSON 格式的文件了,那么就可以将这些配置文件的内容存到数据库 中,然后对接可视化配置,这样我们就可以更加方便地管理爬虫项目了。 本节代码参见:https://github.com/Python3WebSpider/ScrapyUniversalDemo。

15.13 Scrapy 实战

通过本章前面几节的学习,我们已经了解了 Scrapy的基本用法、规则化爬虫、JavaScript 渲染页 面的爬取,而且在之前的章节,我们还学习了运用代理池、账号池等规避反爬措施。本节中我们就来 综合一下前面所学的知识,完成一个Scrapy 实战项目,加深对 Scrapy 的理解。

1. 本节目标

本节我们需要爬取的站点为https://antispider7.scrape.center/,这个站点需要登录才能爬取,登录之 后(测试账号的用户名和密码均为admin)我们便可以看到类似图15-21 所示的页面。

图15-21 登录后的页面
图15-21 登录后的页面

这里是一些书籍信息,我们需要进入每一本书对应的详情页,将该本书的信息爬取下来,总数将 近一万本。 不过,这个站点设置了一些反爬措施。它限制单个账号5分钟内最多访问页面 10次,超过的话 账号会被封禁;另外该站点限制了单个IP的访问频率,同样是5分钟内最多访问10次,超过这个频 率,IP便会被封禁。因此,该站点从账号层面、IP层面都做了限制,如果仅用一个账号和一个IP,那 么在短时间内是无法完成爬取的。 总而言之,这个限制算是相对严格了,要爬取这个站点,我们就需要结合之前的知识综合实现。 现在主要面临两个问题。 □ 封禁账号:前文我们已经讲解了账号池的用法,利用账号池,我们可以采用分流策略大大降低 单个账号的请求频率,从而降低账号被封禁的概率。 □ 封禁 IP:前文我们已经讲解了代理池的用法,利用代理池,我们可以维护大量IP,每次请求 都随机切换一个IP,这样一来就可以解决IP被封禁的问题。 因此,本节我们需要实现以下几点功能。 ■利用 Scrapy 实现站点的爬取逻辑。 □ 对接账号池,突破账号访问频率的限制。 □ 对接代理池,突破IP访问频率的限制。

2. 准备

在本节开始之前,请确保你已经安装好了Scrapy 框架,并准备好了前面章节所讲解的代理池和账 号池并可以成功运行,具体说明如下:

对于代理池,可以参考 https://github.com/Python3WebSpider/ProxyPool 里面的说明来安装,具 体的原理和实现可以参考本书9.2节。

对于账号池,可以参考 https://github.com/Python3WebSpider/AccountPool 里面的说明来安装, 具体的原理和实现可以参考本书10.4节。另外注意本节我们需要基于10.4节所述的内容对账 号池进行进一步改写,所以建议下载 antispider6 这个分支的账号池代码,可以直接使用如下命 令更新对应代码:

git clone --single-branch --branch antispider6 https://github.com/Python3WebSpider/AccountPool.git

这样下载的代码就是 antispider6 这个分支的代码,本节我们需要基于它来扩展 antispider7 这个 站点的账号池逻辑。

3. 分析

首先我们来分析一下这个站点如何来爬取,直接爬取页面还是利用 Ajax 接口? 打开 https://antispider7.scrape.center/,首先页面会提示需要登录,我们可以先使用用户名 admin, 密码 admin 登录。然后分析页面的呈现逻辑,综合前面的知识,我们可以轻易分析出来每一页的列表 数据是通过 Ajax 加载的,接口如图15-22所示。

图15-22 分析页面的呈现逻辑
图15-22 分析页面的呈现逻辑

接着切换到 Preview 选项卡看返回结果,如图15-23 所示。

图15-23 切换到 Preview 选项卡
图15-23 切换到 Preview 选项卡

这里我们可以看到返回结果只包含了 id、name、score、cover、authors 这几个字段,明显还不 全。我们进入书籍详情页面来看一下,例如进入https://antispider7.scrape.center/detail/26607683 这个页 面,可以看到更全的信息,如图15-24所示。

图15-24 书籍详情页面
图15-24 书籍详情页面

这里我们可以看到还有标签、定价、出版时间、ISBN、评价等内容,分析其数据来源发现这些也 是通过 Ajax 接口加载的,如图15-25所示。

图15-25 Preview 选项卡
图15-25 Preview 选项卡

因此,要爬取全部数据,我们需要从列表页接口获取书籍的ID,然后根据ID从详情页接口爬取 每一本书的详情。 到现在,爬取逻辑就已经梳理清楚了。接下来我们看看怎样解决权限的问题,分析一下 Reqeust Headers,可以看到有一个 authorization 字段,以jwt开头,内容类似下面这样:

authorization: jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhw

IjoxNjAzNTYwMDEzLCJ1bWFpbCI6ImFkbWluLmNvbSIsIm9yaWdfaWFOIjoxNjAzNTE2ODEzfQ.IT1wRGt4lem0JyDH-kMvLz PyN4dP8BzqCxc-1IBj-61 另外 Requests Headers 也有 cookie 字段,里面包含了 Session ID 相关的信息。 为了验证其认证方式,我们可以对 authorization 和 cookie 进行删减测试,比如去掉 cookie 字 段,仅使用 authorization 仍然可以成功获取数据,那就证明其权限认证需要 authorizat ion 字段而不需要 cookie。 最后经验证可以得到,其权限认证是基于 JWT 的,我们仅使用 authorization 就可以成功获取数 据。如此一来,权限认证我们就大体清楚了。 接下来我们就来实现该站点的爬取流程吧!

4. 实战

下面我们就开始利用 Scrapy 实现对示例网站 https://antispider7.scrape.center/ 的爬取了。

主逻辑实现

首先我们可以利用 Scrapy 实现一下基本的爬取逻辑,首先新建一个 Scrapy 项目,名字叫作 scrapycompositedemo,创建命令如下:

scrapy startproject scrapycompositedemo

接下来进入项目,然后新建一个 Spider,名称为 book,命令如下:

scrapy genspider book antispider7.scrape.center

然后我们来定义一个 Item,定义需要爬取的字段,这里我们直接和详情页接口返回的字段一致就 好了,在 items.py 里面定义一个 BookItem,代码如下:

from scrapy import Field, Item
class BookItem(Item):
authors = Field()
catalog = Field()
comments = Field()
cover = Field()
id = Field()
introduction = Field()
isbn = Field()
name = Field()
page_number = Field()
price = Field()
published_at = Field()
publisher = Field()
score = Field()
tags = Field()
translators = Field()

定义好 BookItem 之后,我们便可以将爬取结果转换成一个个 BookItem 了。 然后我们来实现主要的爬取逻辑,这里我们先直接实现爬取 Ajax 接口的逻辑,在 book.py 里面改 写代码如下:

from scrapy import Request, Spider
class BookSpider (Spider):
name = 'book'
allowed_domains = ['antispider7.scrape.center']
base_url = 'https://antispider7.scrape.center'
max_page = 512
def start_requests(self):
for page in range(1, self.max_page + 1):
url = f'{self.base_url}/api/book/?limit=18&offset={(page - 1) * 18}'
yield Request(url, callback=self.parse_index)
def parse_index(self, response):
print(response)

这里我们构造了512页的列表页Ajax请求,指定 limit 和 offset 参数, offset 根据页码动态计 算,构造 URL 之后生成 Request,然后回调方法设置为 parse_index 方法,打印输出 response 对象。

这里我们仅仅是实现了基本的请求逻辑,并没有加任何模拟登录操作,运行结果会是怎样的呢? 我们来尝试一下,运行该 Spider,命令如下:

scrapy crawl book

我们会得到如下的运行结果: 运行结果如下:

可以看到状态码都是401,而401就是代表未授权的意思,这就是因为没有登录造成的。

模拟登录

前面我们也已经分析过了怎样以登录身份请求接口,其实就是在 Request Headers 中加上 authorization 这个字段就好了,怎么来实现呢?在前面我们也已经学习了 Downloader Middleware 的 用法,它可以在 Request 被下载执行前对 Request 做一些处理,所以这里我们可以借助于 Downloader Middleware 来实现。

接下来我们在 middlewares.py 里面添加一个 Downloader Middleware,代码如下:

class AuthorizationMiddleware(object):
authorization = 'jwt eyJ0eXAi0iJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluliwi

ZXhwIjoxNjAzNTYwMDEzLCJ1bWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWFOIjoxNjAzNTE2ODEzfQ.IT1wRGt4lem0JyDH-k MvLzPyN4dP8BzqQcxc-1IBj-6I'

def process_request(self, request, spider):
request.headers['authorization'] = self.authorization

这里实现了一个AuthorizationMiddleware类,并实现了一个 process_request 方法,在这个方法 里,我们为 request 变量的 headers 属性添加了 authorization 字段。注意这里 authorization 的值 你可以改写成自己的,当前的authorization 可能已经无法使用了,请登录站点并分析 Ajax接口,复 制 authorization 字段并替换。

定义之后我们还需要开启对 AuthorizationMiddleware的调用,在settings.py 里面添加代码如下:

DOWNLOADER_MIDDLEWARES = {
'scrapycompositedemo.middlewares. Authorization Middleware': 543,
}

好,这样我们就开启了 AuthorizationMiddleware了,重新运行一下Spider,可以看到如下结果: 运行结果如下:

https://antispider7.scrape.center/api/book/?limit=18&offset=18> (referer: None)
2020-10-24 19:39:57 [scrapy.core.engine] DEBUG: Crawled (403) <GET
https://antispider7.scrape.center/api/book/?limit=18&offset=252> (referer: None)

不幸的事情又发生了,最初的几次请求结果的状态码是200,代表爬取成功,说明模拟登录已经 成功了。可是后续的请求状态码又变成了403,403 代表禁止访问,其实这就是因为爬取频率过高, 当前账号或IP 已经被禁止访问了,因为这个站点有IP和单个账号请求频率限制。

出现现在这个情况,如果我们不知道当前站点的反爬策略,一般得经过一些实验来找出来其中的 封禁规律。比如这时候可以通过一些控制变量法的实验来进行验证。

如果想验证是不是IP 被封禁,我们可以尝试更换当前计算机的IP或者使用代理来更换IP重 新进行请求,如果这时候可以正常请求了,那就证明是 IP 被封禁了。

如果想验证是不是账号被封禁,可以尝试更换账号重新进行请求,如果这时候可以正常请求了, 那就证明是账号被封禁了。 所以一般在不知道封禁原因的情况下,可以多进行尝试。在这里我就不再进行尝试了,这个站点 就是既封禁IP,又封禁账号,有双重反爬。 好,那我们就来一个个解决吧。

解决封IP问题

在这里我们重新将前面所讲的代理池运行起 来,代理池运行之后,便可以通过URL来获取一个 随机代理,例如访问 http://localhost:5555/random 就 可以获取一个随机代理,如图15-26所示。

图15-26 获取一个随机代理
图15-26 获取一个随机代理

这样我们就可以把该代理对接到爬虫项目中 了。我们可以再实现一个Downloader Middleware, 实现如下:

import aiohttp
import logging
class ProxyMiddleware(object):
proxypool_url = 'http://localhost:5555/random'
logger = logging.getLogger('middlewares.proxy')
async def process_request(self, request, spider):
async with aiohttp.ClientSession() as client:
response = await client.get(self.proxypool_url)
if not response.status == 200:
return
proxy = await response.text()
self.logger.debug(f'set proxy {proxy}')
request.meta['proxy'] = f'http://{proxy}'

这里我们实现了一个 ProxyMiddleware,它的主要逻辑就是请求该代理池然后获取其返回内容, 返回的内容便是一个代理地址。接着我们直接将代理赋值给 request 的 meta 属性的 proxy 字段即可。

值得注意的是,由于Scrapy 2.0及以上版本支持 asyncio,所以这里我们获取代理使用的是aiohttp, 可以更方便地实现异步操作,可以看到我们给 process_request 方法加上了 async 关键字,这样在方 法内便可以使用 asyncio 的相关特性了。

为了开启 Scrapy 对 asyncio的支持,我们需要手动配置一下 TWISTED_REACTOR,在 settings.py 里面

添加设置如下:

TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'

接着我们再开启 ProxyMiddleware 的调用,配置如下:

DOWNLOADER_MIDDLEWARES = {
'scrapycompositedemo.middlewares.AuthorizationMiddleware': 543,
'scrapycompositedemo.middlewares.ProxyMiddleware': 544,
}

好,这样我们就可以在每次发起一个请求的时候随机切换代理池中的代理了,IP 被封禁的问题就 解决了。

解决封账号问题

不过这样可没完,实际运行仍然会出现 403 状态码,这是因为账号也被封禁了。为了解决账号封 禁的问题,我们需要进一步对接一个账号池。 关于账号池的原理,就不再过多叙述了。接下来我们需要基于 10.4 节的账号池对该站点进行扩展, 使其可以应用于目标站点。 首先我们需要在 setting.py 里面修改配置,改成 antispider7 站点,改写 Generator 和 Tester 的类的 配置,修改如下:

GENERATOR_MAP = {
'antispider7': 'Antispider7Generator'
}
TESTER_MAP = {
'antispider7': 'Antispider7Tester',
}
TEST_URL_MAP = {
'antispider7': 'https://antispider7.scrape.center/api/book/?limit=18&offset=0'
}

这里 Antispider7Generator 就是负责当前站点模拟登录的类,Antispider7Tester 就是负责当前站 点测试登录的类,我们需要分别实现一下对应的逻辑。 接下来我们在 generator.py 里面定义 Antispider7Generator,实现如下:

import requests
class Antispider7Generator(BaseGenerator):
def generate(self, username, password):
if self.credential_operator.get(username):
logger.debug(f'credential of {username} exists, skip')
return
login_url = 'https://antispider7.scrape.center/api/login'
s = requests.Session()
r = s.post(login_url, json={
'username': username,
'password': password
})
if r.status_code != 200:
return
token = r.json().get('token')
logger.debug(f'get credential {token}')
self.credential_operator.set(username, token)

这里的主要逻辑就是实现 generate 方法,利用用户名和密码,模拟请求登录接口 API,然后获取 返回结果的 token。模拟登录的返回结果类似如下:

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNjAzNj

A3NDYxLCJ1bWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWFOIjoxNjAzNTY0MjYxfQ.X_agHfQZCG1IE2YdVj90x34Gou90uBbr

AgtKomyiflA"}

这里 token 的值其实就是 Request Headers 里面 authorization 字段 jwt 后面跟的内容,我们利 用此 token 来构造 authorization 即可。 接下来我们再实现一下 Antispider7Tester,实现如下:

class Antispider7Tester (BaseTester):
def __init__(self, website=None):
BaseTester.__init__(self, website)
def test(self, username, credential):
logger.info(f'testing credential for {username}')
try:
test_url = TEST_URL_MAP[self.website]
response = requests.get(test_url, headers={
'authorization': f'jwt {credential}'
}, timeout=5, allow_redirects=False)
if response.status_code == 200:
logger.info('credential is valid')
else:
logger.info('credential is not valid, delete it')
self.credential_operator.delete(username)
except ConnectionError:
logger.info('test failed')

由于 credential 就是刚才我们存的token值,所以这里我们只需要获取 credential 构造 authorization 即可完成模拟登录检查。这里我们添加了 Request Headers 的 authorization 之后,请 求 TEST_URL_MAP 里面指定的首页 URL,即 https://antispider7.scrape.center/api/book/?limit=18&offset=0, 如果返回状态码是200,则证明接口正常请求,模拟登录成功,当前的账号没有被封禁;否则就删除 该账号对应的 credential 内容,等待 Antispider7Generator 再次生成。 这样我们就完成了账号池的credential 生成和测试逻辑了。 另外,由于该站点有单账号访问频率限制,所以这里我们不能让 Tester 的测试频率设定得太高, 不然它占用了请求次数就得不偿失了。 比如我们可以在 setting.py 里面把CYCLE_TESTER 设置得很大,比如600就是10分钟检查一次,1800 就是半小时检查一次,可以自行设定。 接下来我们就可以导入一些账号来运行账号池了,这里我们可以简单实现一个脚本,导入一些账 号和密码,那账号密码怎么来呢?我们可以自己利用注册接口注册,也可以使用我已经注册好的一些 账号,用户名和密码都是一样的,有admin1、admin2、admin3……………。 这里我们可以先导入100个账号来测试下,编写脚本如下:

from accountpool.storages.redis import RedisClient
conn = RedisClient('account', 'antispider7')
start = 1
end = 100
for i in range(start, end + 1):
username = password = f'admin{i}'
conn.set(username, password)

这样 100个账号就被导入 Redis 数据库中了,结果如图15-27所示。

图15-27 将账号导入 Redis 数据库
图15-27 将账号导入 Redis 数据库

接下来我们来运行一下账号池,就可以看到账号池开始执行这些账号的模拟登录和测试流程,运 行账号池,命令如下:

python3 run.py antispider7

类似运行结果如下:

2020-10-25 02:54:24.375 DEBUG | accountpool.scheduler: run_tester: 31 tester loop o start...
2020-10-25 02:54:24.376 | DEBUG | accountpool.scheduler:run_generator: 46 getter loop o start...
* Serving Flask app "accountpool.processors.server" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
2020-10-25 02:54:24.376 | DEBUG | accountpool.processors.generator:run:39 start to run generator
2020-10-25 02:54:24.377 | DEBUG | accountpool.processors.generator:run:43 start to generate credential
of admin1
* Running on http://0.0.0.0:6789/ (Press CTRL+C to quit)
2020-10-25 02:54:31.273 | DEBUG | accountpool.processors.generator: generate: 68 - get credential
eyJ0eXAi0iJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMCwidXNlcm5hbWUiOiJhZG1pbjMiLCJleHAiOjE2MDM2MDg4NzEs
ImVtYWlsIjoiliwib3JpZ19pYXQi0jE2MDM1NjU2NzF9.mQNw2zeW3m8olYPliY1zoSutJgsWv_hc4mxSzf6BTAI

这里我们看到 Antispider7Generator 和 Antispider7Tester 就成功运行起来了,它会遍历已经导 入的账号,然后不断模拟登录并生成 credential 存储到 Redis 数据库中,结果类似图 15-28所示。

图15-28 生成的 credential 被存储到 Redis 数据库中
图15-28 生成的 credential 被存储到 Redis 数据库中

这时候我们也可以访问账号池提供的API,账号池目前运行在 6789 端口,我们可以使用 API 来 获取随机的 credential,如图 15-29所示。

图15-29 使用 API 来获取随机的 credential
图15-29 使用 API 来获取随机的 credential

接下来我们再修改一下 Downloader Middleware,使其使用该账号池里面的 credential 来进行爬 取,修改 Scrapy 项目中的 AuthorizationMiddleware 如下:

class AuthorizationMiddleware(object):
accountpool_url = 'http://localhost:6789/antispider7/random'
logger = logging.getLogger('middlewares.authorization')
async def process_request(self, request, spider):
async with aiohttp.ClientSession() as client:
response = await client.get(self.accountpool_url)
if not response.status == 200:
return
credential = await response.text()
authorization = f'jwt {credential}'
self.logger.debug(f'set authorization {authorization}')
request.headers['authorization'] = authorization

通过修改,我们利用 aiohttp请求了账号池的接口获取随机 credential,然后将其转化为 authorization 字段的格式,前面拼接上 jwt 即可,构造了 authorization 之后,我们将其赋值到 request 的 headers 属性的 authorization 字段即可。 这样我们就可以实现随机 authorization 的设定了,每次请求相当于随机取用了一个账号信息, 这样单个账号的访问频率就大大降低了。

运行测试

接下来再次运行 Spider,结果如下:

2020-10-25 03:14:58 [scrapy.core.engine] DEBUG: Crawled (200) <GET
https://antispider7.scrape.center/api/book/?limit=18&offset=504> (referer: None)
2020-10-25 03:14:58 [middlewares.authorization] DEBUG: set authorization jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.

eyJ1c2VyX2lkIjooNywidXNlcm5hbWUiOiJhZG1pbjM4IiwiZXhwIjoxNjAzNjA5NDk1LCJlbWFpbCI6IiIsIm9yaWdfaWFOIjoxNjAzN TY2Mjk1fQ.MXfQ7A6-qj2_HBohbeH9-yWfD70lZZ-DwvN3JWFdFpI

2020-10-25 03:14:58 [middlewares.proxy] DEBUG: set proxy 125.26.56.87:8080
<200 https://antispider7.scrape.center/api/book/?limit=18&offset=504>
2020-10-25 03:15:03 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://antispider7.scrape.center/api/
book/?limit=18&offset=684> (referer: None)
2020-10-25 03:15:03 [middlewares.authorization] DEBUG: set authorization jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.

eyJ1c2VyX2lkIjozNiwidXNlcm5hbWUiOiJhZG1pbjI3IiwiZXhwIjoxNjAzNjA5NDUxLCJ1bWFpbCI6IiIsIm9yaWdfaWFOIjoxNjAzN TY2MjUxfQ.iojBYnykCGI41HBc-Ei5h98wENUq81xPX78QG1yYkWc

2020-10-25 03:15:03 [middlewares.proxy] DEBUG: set proxy 43.225.195.90:50878

这时候我们就可以发现,绝大多数的请求都经成功返回了200状态码,持续爬取一段时间,依然 没有问题。 到现在,封IP 和封账号的问题就被解决了。最后让我们完善一下 Spider 的逻辑:

import json
from scrapy import Request, Spider
from scrapycompositedemo.items import BookItem
class BookSpider (Spider):
name = 'book'
allowed_domains = ['antispider7.scrape.center']
base_url = 'https://antispider7.scrape.center'
max_page = 512
def start_requests (self):
for page in range(1, self.max_page + 1):
url = f'{self.base_url}/api/book/?limit=18&offset={(page - 1) * 18}'
yield Request(url, callback=self.parse_index)
def parse_index(self, response):
data = json.loads(response.text)
results = data.get('results', [])
for result in results:
id = result.get('id')
url = f'{self.base_url}/api/book/{id}/'
yield Request(url, callback=self.parse_detail, priority=2)
def parse_detail(self, response):
data = json.loads(response.text)
item = BookItem()
for field in item.fields:
item[field] = data.get(field)
yield item

另外,个人推荐再配置一些参数: ROBOTSTXT OBEY = False

RETRY_HTTP_CODES = [401, 403, 500, 502, 503, 504]
CONCURRENT_REQUESTS = 10

DOWNLOAD TIMEOUT = 10

RETRY_TIMES = 10

下面解释一下这些参数。

ROBOTSTXT_OBEY:是否遵守 robots协议,这里设置为了False,在爬取时不遵守 robots.txt 协议, 可以免去最开始 robots.txt 的爬取步骤。

RETRY_HTTP_CODES:需要重试的状态码,这里设置为[401,403, 500, 502, 503,504]。这样 如果遇到这些状态码,该请求会重新发起,如果不进行这样的设置,该请求失败了就会被丢弃。

CONCURRENT_REQUESTS:并发量,这里设置为10,稍微降低了并发数目,降低账号被封禁的概 率。当然,如果账号和IP足够多,可以将该值调高。

DOWNLOAD_TIMEOUT:超时时间,这里设置为了10,默认是180,默认的超时时间太长,这里设 置短一点,如果请求不成功,可以尽早重试。

RETRY_TIMES:重试次数,这里设置为了10,默认是2,提高重试次数,可以提高总的爬取成 功率。

最后,可以看到运行结果如下:

2020-10-25 03:33:54 [scrapy.core.scraper] DEBUG: Scraped from <200
https://antispider7.scrape.center/api/book/1322342/>
{'authors': ['
[加拿大]

亦舒','亦舒新经典'],

'catalog': None,
'comments': [{'content':'所有家庭主妇都是政治高手,上有公婆下有子女,还要巴结伴侣,都得软硬兼施,才摆得平,

对时间及金钱运用,均有心得,否则不能应付日常生活。\r\n',

'id': '22823135'},
{'content':'亦舒的文,女人有着独立自我的骄傲。\n'

「未婚的乃娟做了个婚姻的问题专家。一针见血哦为别人指出彼此的不足,婚姻的出路。 但在工作中谨小慎微,还有点自卑。\n' '是个处处考虑,啊肯人前落下笑柄的人。去拜访朋友,师长,点头之交都会买花束果篮。\n' '其实,主要是自己认知了婚姻的不可信,又受了新式自主自立自由的教育。不肯委身于

任何一个家庭伏低做小,苟且为生。又怕红颜弹指,过得不愉。\n' '看到后面乃娟与李至中志同道合平静踏入殿堂...',

'id': '2219254915'}],
'cover': 'https://img3.doubanio.com/view/subject/l/public/s1331501.jpg',
'id': '1322342',
'introduction': '',
'isbn': '9787801876362',
'name':'花常好月常圓人长久',
'page_number': 190,
'price': '16.00元',
'published_at': '2005-05-20T16:00:00Z',
'publisher': '新世界出版社',
'score': '7.6',
'tags': ['亦舒','小说','花常好月常圆人长久','香港','爱情','师太','言情','女性'],
'translators': []}

这时候,我们便可以成功绕过反爬手段,爬取到大量内容了。

5. 总结

本节是 Scrapy 综合实战练习,为了解决与反爬虫相关的问题,我们综合了代理池、账号池和 Scrapy 的一些优化设置,完成了对站点反爬虫的绕过和数据的爬取。 在进行对其他站点的实际爬取时,可以借鉴本节的思路,希望大家好好体会。 本节代码的参考来源如下。

Scrapy 项目:https://github.com/Python3WebSpider/ScrapyCompositeDemo

账号池:https://github.com/Python3WebSpider/AccountPool/tree/antispider7

代理池:https://github.com/Python3WebSpider/ProxyPool