Python3网络爬虫开发实战(15)Scrapy 框架的使用(第一版)

Scrapy 是一个基于 Python 开发的爬虫框架,是当前 Python 爬虫生态中最流行的爬虫框架,该框架提供了非常多爬虫相关的基础组件,架构清晰,可拓展性极强。

之前大多是基于 requests 或 aiohttp 来实现爬虫的整个逻辑的,可以发现,在整个过程中,我们需要实现爬虫相关的所有操作,例如爬取逻辑,异常处理,数据解析,数据存储等,但其实这些步骤很多都是通用或者重复的。既然如此,我们可以将这些步骤的逻辑分离出来,把其中通用的功能做成一个个基础的组件。

在抽离处基础组件之后,每次爬虫只需要在这些组件基础上加上特定的逻辑就可以实现爬取的流程了,而不用再把爬虫中每个细小的流程都实现一遍。

Scrapy 框架几乎是 Python 爬虫学习和工作过程中必须掌握的框架

一、Scrapy 框架介绍

  1. Scrapy Engine(引擎): 负责Spider、ItemPipeline、Downloader、Scheduler中间的通讯,信号、数据传递等。
  2. Item: 是一个抽象的数据结构,定义了爬取结果的数据结构,爬去的数据会被赋值成 Item 对象,每个 Item 就是一个类,类里面定义了爬取结果的数据字段,可以理解为它用来规定爬取数据的存储格式。
  3. Scheduler(调度器): 它负责接受引擎发送过来的Request请求,并按照一定的方式进行整理排列,入队,当引擎需要时,交还给引擎。
  4. Downloader(下载器):负责下载Scrapy Engine(引擎)发送的所有Requests请求,并将其获取到的Responses交还给Scrapy Engine(引擎),由引擎交给Spider来处理,
  5. Spider(爬虫):它负责处理所有Responses,从中分析提取数据,获取Item字段需要的数据,并将需要跟进的URL提交给引擎,再次进入Scheduler(调度器).
  6. Item Pipeline(管道):它负责处理Spider中获取到的Item,并进行进行后期处理(详细分析、过滤、存储等)的地方。
  7. Downloader Middlewares(下载中间件):你可以当作是一个可以自定义扩展下载功能的组件。
  8. Spider Middlewares(Spider中间件):你可以理解为是一个可以自定扩展和操作引擎和Spider中间通信的功能组件(比如进入Spider的Responses;和从Spider出去的Requests)

1.1 数据流

详细可以看这篇文章:Scrapy 入门教程 | 菜鸟教程 (runoob.com),这里十分生动的表示了 Scrapy 个组件的交流:

  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 从 Downloader 处接收到的 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 还可以最大限度地利用网络带宽,提高数据爬取和处理的效率。

1.2 项目结构

需要先安装 Scrapy 框架,可以直接使用 pip 安装

pip install scrapy

安装完毕后,可以使用命令行来创建一个爬虫项目,这里创建一个名为 news 的项目

scrapy startproject news

执行完毕后,当前目录下就会出现一个名为 news 的文件夹,该文件夹就对应一个 Scrapy 爬虫项目,接着进入 news 文件夹,然后创建一个名称为 sina 的 Spider,

# 进入news 文件夹
cd .\news
# 创建 Spider 名称为 sina 域名为 news.sina.com.cn
scrapy genspider sina news.sina.com.cn

最终会得到如下的一个文件结构

各个文件的功能描述如下:

  1. scrapy.cfg: Scrapy项目的配置文件,其中定义了项目的配置文件路径、部署信息等
  2. items.py: 定义了Item数据结构,所有Item的定义都可以放这里
  3. pipelines.py: 定义了Item Pipeline的实现,所有的Item Pipeline的实现都可以放在这里
  4. settings.py: 定义了项目的全局配置
  5. middlewares.py: 定义了Downloader Middlewares和Spider Middlewares的实现
  6. spiders: 里面包含了一个个 Spider 的实现,每个 Spider 都对应一个 Python 文件

1.3 Scrapy 入门

这里以 Scrapy 推荐的官方练习项目为例子进行爬取,抓取的目标站点为 https://quotes.toscrape.com/

创建一个项目名为 demo 的项目,spider 命名为 example,得到spider文件 example.py 如下

# example.py
import scrapy

class ExampleSpider(scrapy.Spider):
    name = "example"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ["https://quotes.toscrape.com/"]

    def parse(self, response):
        pass

name 是每个项目唯一的名字,用于区分不同的 Spider
allowed_domains 是允许爬取的域名,如果初始或者后续的请求链接不是这个域名下的,则会被过滤掉
start_urls 包含了 spider 在启动时爬取的 URL 列表,初始请求是由它来定义的
parse 是 Spider 的一个方法,在默认情况下,start_urls 里面的链接构成请求完成下载后得到一个 response,parse 方法就会调用,response 作为参数;

进入到 Items.py 文件,如下

import scrapy

class DemoItem(scrapy.Item):
    # define the fields for your item here like:
    text = scrapy.Field()
    author = scrapy.Field()
    tags = scrapy.Field()

这里 Item 类似于一个字典,但是必须使用 scrapy.Field() 来定义;对于 Response 的解析,其接口如下所示:

  1. url:Request URL;
  2. status:Response 状态码,一般情况下请求成功状态码为200;
  3. headers:Response Headers,是一个字典,字段是一一对应的;
  4. body:Response Body,这个通常就是访问页面之后得到的源代码结果了,比如里面包含的是HTML或者JSON字符串,但注意其结果是 bytes 类型。与requests模块请求后得到的响应属性content类似;
  5. request:Response 对应的 Request 对象;
  6. certificate:是twisted.internet.ssl.Certifucate类型的对象,通常代表一个SSL证书对象;
  7. ip_address:是一个ipaddress.IPv4Address或IPv6Address类型的对象,代表服务器的IP地址;
  8. urljoin:是对URL的一个处理方法,可以传入当前页面的相对URL,该方法处理后返回的就是绝对URL,urljoin 其实使用的就是: from urllib.parse import urljoin 可以去看源码;
  9. follow/follow_all:是一个根据URL来生成后续Request的方法,和直接构造Request不同的是,该方法接收的url可以是相对URL,不必一定是绝对URL,因为follow方法中有做url拼接的操作;
  10. text: 同body属性,但结果是str类型;
  11. encoding: Response的编码,默认是utf-8;
  12. selector: 根据Response的内容构造而成的Selector对象,利用它我们可以进一步调用xpath、css等方法进行结果的提取;
  13. xpath()方法: 传入XPath进行内容提取,等同于调用selector的xpath方法;
  14. css()方法: 传入CSS选择器进行内容提取,等同于调用selector的css方法;
  15. json()方法: 可以直接将text属性转换为JSON对象;

与 requests 的 Response 主要的不同在于其不需要再导入 lxml 或者 bs4 来进行解析,里面自带有解析的工具;在了解如何解析 Response 之后,我们可以将 example.py 修正如下:

import scrapy
from ..items import DemoItem

class ExampleSpider(scrapy.Spider):
    name = "example"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ["https://quotes.toscrape.com/"]

    def parse(self, response, **kwargs):
        quotes = response.css(".quote")
        for quote in quotes:
            item = DemoItem()
            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

目前只获取到首页的内容,我们需要获取到下一页的内容,可以在当前页面中寻找信息构建下一个 Request,Request 的构造参数梳理如下:

  1. url: Request 的页面链接,即 Request URL。
  2. callback:Request 的回调方法,通常这个方法需要定义在 Spider 类里面,并且需要对应一个 response 参数,代表 Request 执行请求后得到的 Response 对象。如果这个 callback 参数不指定,默认会使用 Spider 类里面的 parse 方法。
  3. method:Request 的方法,默认是 GET,还可以设置为 POST、PUT、DELETE 等。
  4. meta:Request 请求携带的额外参数,利用 meta,我们可以指定任意处理参数,特定的参数经由 Scrapy 各个组件的处理,可以得到不同的效果。另外,meta 还可以用来向回调方法传递信息。
  5. body:Request 的内容,即 Request Body,往往 Request Body 对应的是 POST 请求,我们可以使用 FormRequest 或 JsonRequest 更方便地实现 POST 请求。
  6. headers:Request Headers,是字典形式。
  7. cookies:Request 携带的 Cookies,可以是字典或列表形式。
  8. encoding:Request 的编码,默认是 utf-8。
  9. prority:Request 优先级,默认是0,这个优先级是给 Scheduler 做 Request 调度使用的,数值越大,就越被优先调度并执行。
  10. dont_filter:Request 不去重,Scrapy 默认会根据 Request 的信息进行去重,使得在爬取过程中不会出现重复的请求,设置为 True 代表这个 Request 会被忽略去重操作,默认是 False。
  11. errback:错误处理方法,如果在请求过程中出现了错误,这个方法就会被调用。
  12. flags:请求的标志,可以用于记录类似的处理。
  13. cb_kwargs:回调方法的额外参数,可以作为字典传递。

Scrapy 还专门为 POST 请求提供了两个类 —— FormRequest 和 JsonRequest,它们都是 Request 类的子类,我们可以利用 FormRequest 的 formdata 参数传递表单内容,利用 JsonRequest 的 json 参数传递 JSON 内容,其他的参数和 Request 基本是一致的。

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

example.py 修正如下:

import scrapy
from ..items import DemoItem

class ExampleSpider(scrapy.Spider):
    name = "example"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ["https://quotes.toscrape.com/"]

    def parse(self, response, **kwargs):
        quotes = response.css(".quote")
        for quote in quotes:
            item = DemoItem()
            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)

运行项目

scrapy crawl example

在运行完 Scrapy 后,只能在控制台上看到结果,需要保存数据,有两种方式:

其一是使用命令行,直接输出格式文件,例如 json, csv, xmlk, pickle, marshal 等等,完成这一任务不需要任何额外的代码,Scrapy 提供的 Feed Exports 可以轻松将抓取到的结果输出

# 保存为json
scrapy crawl example -o example.json
# 保存为一行json
scrapy crawl example -o example.jl # 或
scrapy crawl example -o example.jsonlines
# 保存为 csv
scrapy crawl example -o example.csv
# 保存为 xml
scrapy crawl example -o example.xml
# 保存为 pickle
scrapy crawl example -o example.pickle
# 保存为 marshal
scrapy crawl example -o example.marshal

其二是使用 Item Pipeline,如果要进行更复杂的操作,如将结果保存到数据库之中或者 对 Item 进行筛选操作;Item Pipeline 为项目管道,当 Item 生成后,它会自动被送到 Item Pipeline 处进行处理,可以使用 Item Pipeline 来做如下操作:

  1. 清洗 HTML 数据
  2. 验证爬取数据,检测爬取字段
  3. 查重并丢弃重复内容
  4. 将爬取结果保存到数据库

Pipeline 管道的基本类模版如下:

class XXXXPipeline(object):
	def __init__(self, a, b):
		self.a = a
		self.b = b

    def process_item(self, item, spider):
        """必须有!为每个项管道组件调用此方法"""
        pass

    @classmethod
    def from_crawler(cls, crawler):
        """如果存在,则调用此类方法以从Crawler创建管道实例。它必须返回管道的新实例。
        Crawler对象提供对所有Scrapy核心组件(如setting和signal)的访问;
        它是管道访问它们并将其功能挂钩到Scrapy的一种方式。类似于初始化a和b"""
        return cls(
            a=crawler.settings.get("a"),
            b=crawler.settings.get("b"),
        )

    def open_spider(self, spider):
        """如果存在,这个方法是在spider打开时调用的。"""
        pass

    def close_spider(self, spider):
        """如果存在,这个方法是在spider关闭时调用的。"""
        pass

在这里我们可以添加两个 Pipeline,首先是文本处理的 Pipeline ,还有存储数据库的 Pipeline;

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"][:self.limit].rstrip() + "..."
            return item
        else:
            return DropItem("Missing Text")


class MongoPipeline(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()

处理完毕后,我们还需要进入到 settings.py 中配置文件,第一个是 Mongo数据库的配置,由于 MongoPipeline 是使用 from_crawler 来进行初始化的,所以 settings.py 中需要有 MONGODB_CONNECTION_STRINGMONGODB_DATABASE 这两个字段;其次 Pipeline 有一个先后顺序,键值越小越优先执行,修改 settings.py 内容如下:

# Crawl responsibly by identifying yourself (and your website) on the user-agent
# USER_AGENT = "demo (+http://www.yourdomain.com)"

# Obey robots.txt rules
ROBOTSTXT_OBEY = True

ITEM_PIPELINES = {
    "爬虫项目名.pipelines.TextPipeline": 200,
    "爬虫项目名.pipelines.MongoPipeline": 300,
}
MONGODB_CONNECTION_STRING = "localhost"
MONGODB_DATABASE = "数据库名"

到这里就处理完毕了!开启爬虫如下:

scrapy crawl example

二、Selector 解析器

Python3网络爬虫开发实战(3)网页数据的解析提取_etree beautifulsoup parsel-CSDN博客介绍过 Parsel 解析器,parsel 是 Python 最流行的爬虫框架 Scrapy 的底层支持;

而 Selector 在使用上和 Parsel 有一点点区别,那就是原来的 get() 和 getall() 变成了 extract_first() 和 extract();同时,Selector 是可以单独使用的

2.1 XPath 和 CSS 选择器

from scrapy import Selector

html = ''
selector = Selector(text=html)

# css
items = selector.css('css选择器')
# xpath
items = selector.xpath('xpath选择器')

2.2 信息提取

  • extract_first:从 selectorlist 对象中提取第一个 Selector 对象,然后输出其中的结果
  • extract:从 selectorlist 对象中提取所有的 Selector 对象,然后以列表的形式输出其中的结果
# 提取文本
selector.css('css选择器::text()').extract_first("默认值")
selector.css('css选择器::text()').extract("默认值")
selector.xpath('xpath//text()').extract_first("默认值")
selector.xpath('xpath//text()').extract("默认值")

# 提取属性
selector.css('css选择器::attr(name)').extract_first("默认值")
selector.css('css选择器::attr(href)').extract("默认值")
selector.xpath('xpath/@name()').extract_first("默认值")
selector.xpath('xpath/@href()').extract("默认值")

2.3 正则提取

  • 如果选择器中是属性或者文本,那么 re 对属性或者文本进行匹配
  • 如果选择器中不是属性和文本,那么 re 对该节点的 html 字符进行匹配
from parsel import Selector

html = ''
selector = Selector(text=html)
result = selector.css('css选择器').re('a.*')
result = selector.xpath('xpath').re('a.*')

result = selector.css('css选择器').re_first('a.*')
result = selector.xpath('xpath').re_first('a.*')

三、Spider 的使用

在 Scrapy 中,网站的链接配置,抓取逻辑,解析逻辑其实都是在 Spider 中配置的,在前一节的实例中,我们发现抓取逻辑也是在 Spider 中完成的。

3.1 Spider 运行流程

Spider 定义了如何爬取某个网站的流程和解析方式,就是做了以下两件事:

  1. 定义爬取网站的动作
  2. 分析爬取下来的网页

对于 Spider 类来说,整个爬取循环如下:

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

循环进行以上几步,便完成了站点的爬取;

3.2 Spider 类分析

参考文档:Spiders - Scrapy 2.11.2文档 — Spiders — Scrapy 2.11.2 documentation

我们定义的 Spider 继承自 scrapy.Spider 类,这个类是最基本的 Spider 类,其他的 Spider 必须继承这个类;

这个类有一些基础的属性,如下:

  1. name:爬虫名称,是定义 Spider 名字的字符串,Spider 的名字定义了 Scrapy 如何定位并初始化 Spider,所以它必须是唯一的。 name 是 Spider 最重要的属性,而且是必须的;
  2. allowed_domains:允许爬取的域名,是一个可选的配置,不在此范围的链接不会被跟进爬取;
  3. start_urls:起始 URL 列表,当我们没有实现 start_requests 方法的,默认会从这个列表开始抓取;
  4. custom_settings:一个字典,是专属于本 Spider 的配置,此设置会覆盖项目全局的设置,而且此设置必须在初始化前被更新,所以它必须定义成类变量;Settings — Scrapy 2.11.2 documentation
  5. crawler:此属性是由 from_crawler 方法设置的,代表的是本 Spider 类对应的 Crawler 对象,Crawler 对象中包含了很多的项目组件,利用它可以获取一些项目的基本配置信息,常见的就是获取项目的设置信息,即 Settings;Core API — Scrapy 2.11.2 documentation
  6. settings:一个 Settings 对象,利用它我们可以直接获取项目的全局设置变量;Settings — Scrapy 2.11.2 documentation

还有一些基础的,主要的方法,如下:

  1. start_requests:此方法用于生成初始请求,它必须返回一个可迭代对象,此方法会默认使用 start_urls 里面的每个 URL 来构造 Request,而且 Request 是 GET 请求方式。如果我们想在启动的时候以 POST 方式访问某个站点,可以直接重写这个方法;
  2. parse:当 Response 没有指定回调方法时,该方法会默认被调用,它负责处理 Response,并从中提取想要的数据和下一步的请求,然后返回,该方法需要返回一个包含 Request 或 Item 的可迭代对象;
  3. closed:当 Spider 关闭时,该方法被调用,这里一般会定义释放资源的一些操作;

3.3 Request

Requests and Responses — Scrapy 2.11.2 documentation

在 Request 中,Request 对象实质上指的就是 scrapy.http.Request 的一个实例,它包含了 HTTP 请求的基本信息,用这个 Request 类可以构造 Request 对象发送 HTTP 请求,它会被 Engine 交给 Downloader 进行处理执行,返回一个 Response 对象;

scrapy.Requset(**kwargs)
scrapy.http.Requset(**kwargs)

# Content-Type 为 application/json
scrapy.JsonRequest(**kwargs)
scrapy.http.JsonRequest(**kwargs)

# Content-Type 为 application/x-www-form-urlencoded
scrapy.FormRequest(**kwargs)
scrapy.http.FormRequest(**kwargs)

Request 类的构造参数如下:

  1. url:Request 的页面链接,即 Request URL;
  2. callback:Request 的回调方法,通常这个方法需要定义在 Spider 类里面,并且需要对应一个 response 参数,代表 Request 执行请求后得到的 Response 对象,如果这个 callback 参数不指定,默认会使用 Spider 类里面的 parse 方法;
  3. method:Request 的方法,默认是 GET,还可以设置为 POST,PUT,DELETE 等;
  4. meta:Request 请求携带的额外参数,利用 meta ,我们可以指定任意处理参数,特定的参数经由 Scrapy 各个组件的处理,可以得到不同的效果,另外,meta 还可以用来向回调方法传递信息;
  5. body:Request 的内容,即 Request Body,往往 Request Body 对应的是 POST 请求,我们可以使用 FormRequest 或 JsonRequest 更方便地实现 POST 请求;
  6. headers:Request Header,是字典形式;
  7. cookies:Request 携带的 Cookie,可以是字典或者列表形式;
  8. encoding:Request 的编码,默认是 UTF-8
  9. prority:Request 优先级,默认是 0 ,这个优先级是给 Scheduler 做 Request 调度使用的,数值越大,就粤北优先调用执行;
  10. dont_filter :Request 不去重,Scrapy 默认会根据 Request 的信息进行去重,使得在爬取过程中不会出现重复请求,设置为 True 代表这个 Request 会被忽略去重操作,默认为 False;
  11. errback:错误处理方法,如果在请求过程中出现了错误,这个方法就会被调用;
  12. flags:请求的标志,可以用于记录类似的处理;
  13. cb_kwargs:回调方法的额外参数,可以作为字典传递;

值得注意的是,meta 参数是一个十分有用而且易扩展的参数,它可以以字典的形式传递,包含的信息不受限制,所以很多 Scrapy 的插件会基于 meta 参数做一些特殊处理,在默认情况下,Scrapy 就预留了一些特殊的 key 作为特殊处理;

Scrapy 还专门为 POST 请求提供了两个类 —— FormRequest 和 JsonRequest,它们都是 Request 类的子类,我们可以利用 FormRequest 的 formdata 参数传递表单内容,利用 JsonRequest 的 json 参数传递 JSON 内容,其他的参数和 Request 基本是一致的。

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

3.4 Response

Request 由 Downloader 执行之后,得到的就是 Response 结果了,它代表的是 HTTP 请求得到的响应结果,同样地我们可以梳理一下其可用的属性和方法,以便做解析处理使用

  1. url:Request URL;
  2. request:Response 对应的 Request 对象;
  3. status:Response 状态码;
  4. headers:Response Header,响应头;是一个字典,字段是一一对应的;
  5. body:Response Body,这个通常就是访问页面之后得到的源码结果了,比如里面包含的是 HTML 或者 JSON 字符串,但注意其结果是 bytes 类型;
  6. certificate:是 twisted.internet.ssl.Certificate 类型的对象,通常代表一个 SSL 证书对象;
  7. ip_address,是一个 ipaddress.IPv4Address 或 ipaddress.IPv6Address 类型的对象,代表服务器的 IP 地址;
  8. urljoin:是对 URL 的一个处理方法,可以传入当前页面的相对 URL,该方法处理后返回的就是绝对 URL;
  9. follow/follow_all:是一个根据 URL 来生成后续 Request 的方法,和直接构造 Request 不同的是,该方法接受的 url 可以是相对 URL,不必一定是绝对 URL;

另外,Response 还有几个常用的子类,如 TextResponse 和 HtmlResponse;HtmlResponse 又是 TextResponse 的子类,实际上回调方法接收的 response 参数就是一个 HtmlResponse 对象,它还有几个常用的方法或属性。

  1. text:同 body 属性,但结果是 str 类型;
  2. encoding:Response 的编码,默认是 utf-8;
  3. selector:根据 Response 的内容构造而成的 Selector 对象;
  4. xpath/css :等同于调用 selector.xpath/css 方法;
  5. json:可以将 text 属性转化为 JSON 对象;

四、Download Middleware 的使用

Downloader Middleware 是处于 Scrapy 的 Engine 和 Downloader 之间的处理模块。Engine 把 Scheduler 获取的 Request 发送给 Downloader 的过程中,以及 Downloader 把 Response 发送回 Engine 的过程中,Request 和 Response 都会经过 Downloader Middleware 的处理;也就是说 Downloader Middleware 在整个架构中起作用的位置是以下两个:

  1. Engine 从 Scheduler 获取 Request 发送给 Downloader Middleware,在 Request 被 Engine 发送给 Downloader Middleware 执行下载之前,Downloader Middleware 可以对 Request 进行修改;
  2. Downloader 执行 Request 后生成 Response,在 Response 被 Engine 发送给 Spider 之前,Downloader Middleware 可以对 Response 进行修改;

Downloader Middleware 在整个爬虫执行过程中能起到非常重要的作用,功能十分强大,修改 User-Agent,处理重定向,设置代理,失败重试,设置 Cookie 等功能都需要借助它来实现;

需要说明的是,Scrapy 其实已经提供了许多 Downloader Middleware,比如负责失败重试、自动重定向等功能的 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 固定内部代码如下:

class ScrapyDemoDownloaderMiddleware:
    # Not all methods need to be defined. If a method is not defined,
    # scrapy acts as if the downloader middleware does not modify the
    # passed objects.

    @classmethod
    def from_crawler(cls, crawler):
        # This method is used by Scrapy to create your spiders.
        s = cls()
        crawler.signals.connect(s.spider_opened, signal=signals.spider_opened)
        return s

    def process_request(self, request, spider):
        # Called for each request that goes through the downloader
        # middleware.

        # Must either:
        # - return None: continue processing this request
        # - or return a Response object
        # - or return a Request object
        # - or raise IgnoreRequest: process_exception() methods of
        #   installed downloader middleware will be called
        return None

    def process_response(self, request, response, spider):
        # Called with the response returned from the downloader.

        # Must either;
        # - return a Response object
        # - return a Request object
        # - or raise IgnoreRequest
        return response

    def process_exception(self, request, exception, spider):
        # Called when a download handler or a process_request()
        # (from other downloader middleware) raises an exception.

        # Must either:
        # - return None: continue processing this exception
        # - return a Response object: stops process_exception() chain
        # - return a Request object: stops process_exception() chain
        pass

    def spider_opened(self, spider):
        spider.logger.info('Spider opened: %s' % spider.name)

每个 Downloader Middleware 都可以通过定义 process_request 和 process_reponse 方法来分别处理 Request 和 Response ,被开启的 Downloader Middleware 的 process_request 方法和 process_response 方法会根据优先级顺序调用。

process_request:由于Request是从Engine发送给Downloader的,并且优先级数字越小的Downloader Middleware越靠近Engine,所以优先级数字越小的Downloader Middleware的process_request方法越先被调用。

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

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

4.1 process_request(request, spider)

Request被Engine发送给Downloader之前,process_request方法就会被调用,也就是在Request从Scheduler里被调度出来发送到Downloader下载执行之前,我们都可以用process_request方法对Request进行处理。

参数

process_request方法的参数有两个。

  • request:Request对象,即被处理的Request。
  • spider:Spider对象,即此Request对应的Spider对象。

返回值

这个方法的返回值必须为None、Response对象、Request对象三者之一,或者抛出IgnoreRequest异常。返回类型不同,产生的效果也不同,下面归纳一下不同的返回情况。

  • None:当返回的是None时,Scrapy将继续处理该Request,接着执行其他Downloader Middleware的process_request方法,一直到Downloader把Request执行得到Response才结束。这个过程其实就是修改Request的过程,不同的Downloader Middleware按照设置的优先级顺序依次对Request进行修改,最后送至Downloader执行。
  • Response:当返回为Response对象时,更低优先级的Downloader Middleware的process_request和process_exception方法就不会被继续调用,每个Downloader Middleware的process_response方法转而被依次调用,调用完毕后,直接将Response对象发送给Spider处理。
  • Request:当返回为Request对象时,更低优先级的Downloader Middleware的process_request方法会停止执行。这个Request会重新放到调度队列里,其实它就是一个全新的Request,等待被调度。如果Scheduler调度了,那么所有的Downloader Middleware的process_request方法会被重新按照顺序执行。
  • IgnoreRequest:如果抛出IgnoreRequest异常,则所有的Downloader Middleware的process_exception方法会依次执行。如果没有一个方法处理这个异常,那么Request的errorback方法就会回调。如果该异常还没有被处理,那么它便会被忽略。

4.2 process_response(request, response, spider)

Downloader执行Request下载之后,会得到对应的Response。Engine便会将Response发送给Spider进行解析,在发送给Spider之前,我们都可以用process_response方法来对Response进行处理。

参数

process_response方法的参数有3个:

  • request:Request对象,即此Response对应的Request。
  • response:Response对象,即被处理的Response。
  • spider:Spider对象,即此Response对应的Spider对象。

返回值

process_response方法的返回值必须为Request对象和Response对象两者之一。或者抛出IgnoreRequest异常。那么对不同的返回情况在下面做一下归纳。

  • Request:当返回为Request对象时,更低优先级的Downloader Middleware的process_response方法不会继续调用,该Request对象会重新放到调度队列里等待被调度,相当于一个全新的Request。然后,该Request会被process_request方法顺次处理。
  • Response:当返回为Response对象时,更低优先级的Downloader Middleware的process_response方法会继续被调用,对该Response对象进行处理。
  • IgnoreRequest:当抛出IgnoreRequest异常时,Request的errorback方法会回调。如果该异常还没有被处理,那么它会被忽略。

4.3 process_exception(request, exception, spider)

当Downloader或process_request方法抛出异常时,例如抛出IgnoreRequest异常,process_exception方法就会被调用。

参数

process_exception方法的参数有3个。

  • request:Request对象,即产生异常的Request。
  • exception:Exception对象,即抛出的异常。
  • spider:Spider对象,即Request对应的Spider。

返回值

方法的返回值必须为None、Response对象、Request对象三者之一。

  • None:当返回值为None时,更低优先级的Downloader Middleware的process_exception会被继续顺次调用,直到所有的方法都被调用完毕。
  • Response:当返回值为Response时,更低优先级的Downloader Middleware的process_exception不再被继续调用,每个Downloader Middleware的process_response方法转而被依次调用。
  • Request:当返回为Request对象时,更低优先级的Downloader Middleware的process_exception也不再被继续调用,该Request对象会重新放到调度队列里面等待被调度,相当于一个全新的Request。然后,该Request又会被process_request方法顺次处理。

关于设置 header,设置代理,返回值等操作可以看:scrapy爬虫框架(四)Downloader Middleware的使用 - 乐之之 - 博客园 (cnblogs.com)

五、Spider Middleware 的使用

Spider Middleware 的作用:

  • Downloader生成Reponse之后,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进行处理。

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的。

5.1 process_spider_input(response, spider)

当Response通过Spider Middleware时,process_spider_input方法被调用,处理该Response。它有两个参数。

参数

  • response:Response对象,即被处理的Response。
  • spider:Spider对象,即该Response对应的Spider对象。

返回值

process_spider_input应该返回None或者抛出一个异常。

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

5.2 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必须返回包含RequestItem对象的可迭代对象。

5.3 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:如果它返回None,那么Scrapy将继续处理该异常,调用其他Spider Middleware中process_spider_exception方法,直到所有Spider Middleware都被调用。
  • 可迭代对象(Response或Item):如果它返回的是一个可迭代对象,则其他Spider Middleware的process_spider_output方法被调用,其他的process_spider_exception不会被调用。

5.4 process_start_requests(start_requests, spider)

process_start_requests方法以Spider启动的Request为参数被调用,执行的过程类似于process_spider_output,只不过它没有相关联的Response并且必须返回Request。它有两个参数。

参数

  • process_start_requests:包含Request的可迭代对象,即Start Requests。
  • spider:Spider对象,即Start_Reqeusts所属的Spider。

返回值

process_start_requests方法必须返回另一个包含Request对象的可迭代对象。

5.5 内置Spider Middleware 简介

在这里我们再介绍一些scrapy框架中内置的Spider Middleware。

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

另外,如果想要针对一些错误类型的状态码进行处理,可以修改Spider的 handle_httpstatus_list属性,也可以修改 Request meta 的 handle_httpstatus_list 属性,还可以修改全局 setttings中的HTTPERROR_ALLOWED_CODES。

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

OffsiteMiddleware 首先遍历了 result,然后根据 dont_filter、url 和 Spider 的 allowed_domains 进行了过滤,如果不符合 allowed domains,就直接输出日志并不再返回 Request,只有符合要求的Request才会被返回并继续调用。

UrlLengthMiddleware:UrlLengthMiddleware 的主要作用是根据 Request 的URL长度对 Request 进行过滤,如果URL的长度过长,此Request就会被忽略。

UrlLengthMiddleware 利用了 process_spider_output 对 result 里面的 Request 进行过滤,如果是Request 类型并且 URL 长度超过最大限制,就会被过滤。我们可以从中了解到,如果想要根据URL的长度进行过滤,可以设置URLLENGTH LIMIT。

其详细介绍和使用可以看:scrapy爬虫框架(五)Spider Middleware - 乐之之 - 博客园 (cnblogs.com)

六、Item Pipeline 的使用

Item Pipeline即项目管道,它的调用发生在Spider产生Item之后。当Spider解析完Response,Item就会被Engine传递到Item Pipeline,被定义的Item Pipeline组件会顺次被调用,完成一连串的处理过程,比如数据清洗、存储等。

Item Pipeline的主要功能如下:

  • 清洗HTML数据。
  • 验证爬取数据,检查爬取字段。
  • 查重并丢弃重复内容。
  • 将爬取结果存储到数据库中。

6.1 process_item(item, spider)

process_item是必须实现的方法,被定义的Item Pipeline会默认调用这个方法对Item进行处理,比如进行数据处理或者将数据写入数据库等操作。

参数

process_item方法的参数有两个。

  • item:Item对象,即被处理的Item。
  • spider:Spider对象,即生成该Item的Spider。

返回值

process_item方法必须返回Item类型的值或者抛出一个DropItem异常。该方法的返回类型如下:

  • Item:如果返回的是Item对象,那么此Item会接着被低优先级的Item Pipeline的process_item方法处理,直到所有的方法被调用完毕。
  • DropItem异常:如果抛出DropItem异常,那么此Item就会被丢弃,不再进行处理。

6.2 open_spider(self, spider)

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

6.3 close_spider(spider)

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

6.4 from_crawler(cls, crawler)

from_crawler方法是一个类方法,用@classmethod标识,它接受一个参数crawler。通过crawler对象,我们可以拿到Scrapy的所有核心组件,如全局配置的每个信息。然后可以在这个方法里面创建一个Pipeline实例。参数cls就是Class,最后返回一个Class实例。

其对数据库详细的使用可以看这篇:scrapy爬虫框架(六)Item Pipeline的使用 - 乐之之 - 博客园 (cnblogs.com)

七、Extension 的使用

Scrapy提供了一个Extension机制,可以让我们添加和扩展一些自定义的功能。利用Extension我们可以注册一些处理方法并监听Scrapy运行过程中的各个信号,做到发生某个事件时执行我们自定义的方法。

Scrapy已经内置了一些Extension,如 LogStats 这个 Extension 用于记录一些基本的爬取信息,比如爬取的页面数量、提取的Item数量等。 CoreStats 这个 Extension 用于统计爬取过程中的核心统计信息,如开始爬取时间、爬取结束时间等。

和 Downloader Middleware、Spider Middleware 以及 Item Pipeline 一样,Extension 也是通过settings.py 中的配置来控制是否被启用的,是通过 EXTESION 这个配置项来实现的,例如:

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实现爬取事件的消息通知。在爬取开始时、爬取到数据时、爬取结束时通知指定的服务器,将这些事件和对应的数据通过HTTP请求发送给服务器。

本节通过上节Item Pipeline的代码进行演示,主要内容如下:

import scrapy
from testItemPipeline.items import TestitempipelineItem

class MovieSpiderSpider(scrapy.Spider):
    name = 'movie_spider'
    allowed_domains = ['ssr1.scrape.center']
    start_url = 'http://ssr1.scrape.center'

    def start_requests(self):
        for i in range(1,11):
            url=self.start_url+f'/page/{i}'
            yield scrapy.Request(url=url,callback=self.parse_index)

    def parse_index(self,response):
        data_list = response.xpath('//div[@class="el-col el-col-18 el-col-offset-3"]//div[@class="el-card item m-t is-hover-shadow"]')

        for item in data_list:
            href = item.xpath('./div/div/div[1]/a/@href').extract_first()
            url = response.urljoin(href)
            yield scrapy.Request(url=url,callback=self.parse_detail)

    def parse_detail(self, response):
        item = TestitempipelineItem()
        item["name"] = response.xpath('//div[@class="el-card__body"]/div[@class="item el-row"]/div[@class="p-h el-col el-col-24 el-col-xs-16 el-col-sm-12"]/a/h2/text()').extract_first()
        item["categories"] = ','.join(response.xpath('//div[@class="el-card__body"]/div[@class="item el-row"]/div[@class="p-h el-col el-col-24 el-col-xs-16 el-col-sm-12"]/div[@class="categories"]/button/span/text()').extract())
        item["score"] = ''.join(response.xpath('//div[@class="el-card__body"]/div[@class="item el-row"]/div[@class="el-col el-col-24 el-col-xs-8 el-col-sm-4"]/p/text()').extract_first()).replace("\n","").replace(" ","")
        item["drama"] = ''.join(response.xpath('//div[@class="el-card__body"]/div[@class="item el-row"]/div[@class="p-h el-col el-col-24 el-col-xs-16 el-col-sm-12"]/div[@class="drama"]/p/text()').extract_first()).replace("\n","")
        item["directors"] = []
        dd = response.xpath('//div[@class="el-col el-col-18 el-col-offset-3"]//div[@class="directors el-row"]')

        for data in dd:
            directors_name = data.xpath('./div[@class="director el-col el-col-4"]/div[@class="el-card is-hover-shadow"]/div[@class="el-card__body"]/p/text()').extract_first()
            directors_image = data.xpath('./div[@class="director el-col el-col-4"]/div[@class="el-card is-hover-shadow"]/div[@class="el-card__body"]/img/@src').extract_first()

            item["directors"].append({
                'name': directors_name,
                'image': directors_image
            })
        item["actors"] = []
        ss = response.xpath('//div[@class="actors el-row"]//div[@class="actor el-col el-col-4"]')
        for data in ss:
            actors_image = ''.join(data.xpath('./div/div/img/@src').extract_first())
            actors_name = ''.join(data.xpath('./div/div/p/text()').extract_first())
            item["actors"].append({
                "name": actors_name,
                "image": actors_image
            })
        yield item

另外本节我们需要用到Flask来搭建一个简易的测试服务器,也需要利用requests来实现HTTP请求的发送,因此需要安装好Flask、requests和loguru这3个库,使用pip安装即可:

pip install flask requests loguru

7.1 部署本地 flask 服务器

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

7.2 extensions.py

在testItemPipeline文件夹下新建一个extensions.py文件。

注意:在新建的文件夹一定要和其他组件是同一级别目录,如Spider、Item等。

接下来我们先实现几个对应的事件处理方法:

这里我们定义了一个NotificationExtension类,然后实现了3个方法,spider_opened、spider_closed和item_scraped,分别对应爬取开始、爬取结束和爬取到Item 的处理。接着调用了 requests 向刚才我们搭建的 HTTP 服务器发送了对应的事件,其中包含两个字段:一个是 event,代表事件的名称;另一个是 data,代表一些附加数据,如 Spider的名称、Item的具体内容等。

但仅仅这么定义其实还不够,现在启用这个Extension其实没有任何效果的,我们还需要将这些方法和对应的Scrapy信号关联起来,再在NotificationExtension类中添加如下类方法:

添加方法前可以先导入一下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中添加如下内容即可。

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

scrapy crawl movie_spider

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

有了这样的日志,说明成功调用了requests的post方法完成了对服务器的请求。

这时候我们回到Flask服务器,看一下控制台的输出结果:

可以看到Flask服务器成功接收到了各个事件(SPIDER OPENED、ITEM SCRAPED、SPIDEROPENED)并输出了对应的数据,这说明在 Scrapy 爬取过程中,成功调用了 Extension 并在适当的时机将数据发送到服务器了,验证成功!

我们通过一个自定义的 Extension,成功实现了 Scrapy 爬取过程中和远程服务器的通信,远程服务器收到这些事件之后就可以对事件和数据做进一步的处理了。

本节通过一个Extension的样例体会到了Extension强大又灵活的功能,以后我们想实现一些自定义的功能可以借助于Extension来实现了。而对于整个scrapy框架基础到这里也就结束了,后面对于一些不理解的地方一定要仔细琢磨认真观察,多练多思考。

八、Scrapy 自动化配置

8.1 Scrapy 对接 Splash

要实现 Scrapy 和 Splash 的对接,我们需要借助于 Scrapy-Splash 库,另外还需要一个可以正常使用的 Splash 服务;

Splash 本身就是一个 JavaScript 页面渲染服务,我们只需要将需要渲染页面的 URL 发送给 Splash 就能得到对应的 JavaScript 渲染结果,而 Scrapy-Splash 则是提供了这个过程基本功能的封装,比如 Cookie 的处理,URL 的转换等;

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

scrapy startproject scrapysplashdemo

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

scrapy genspider book spa5.scrape.center

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

# items.py
from scrapy.item import Item, Field

class BookItem(Item):
	name = Field()
	tags = Field()
	score = Field()
	cover = Field()
	price = Field()

接下来就需要进行 Scrapy-Splash 相关的配置,可以参考 Scrapy-Splash 的配置说明:scrapy-plugins/scrapy-splash: Scrapy+Splash for JavaScript integration (github.com)

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

yield SplashRequest(url, self.parse_result,
	args = {
		'wait': 0.5, # 等待时间
	},
	endpoint = "render.json", # 可选参数,Splash 渲染终端
	splash_url = "<url>", # 可选参数,覆盖 SPLASH_URL
	)

这里构造了一个 SplashRequest 对象,前两个参数依然是请求的URL和回调函数。另外我们还可以通过 args 传递一些渲染参数,例如等待时间 wait 等,还可以根据 endpoint 参数指定渲染接口。更多参数可以参考文档说明:scrapy-plugins/scrapy-splash: Scrapy+Splash for JavaScript integration (github.com)

另外我们也可以生成 Request 对象,Splash 的配置通过 meta 属性配置即可,代码如下:

yield scrapy.Request(url, self.parse_result, meta={
   'splash': {
       'args': {
           # set rendering arguments here
           'html': 1,
           'png': 1,
           # 'url' is prefilled from request url
           # 'http_method' is set to 'POST' for POST requests
           # 'body' is set to request body for POST requests
       },
       # optional parameters
       'endpoint': 'render.json',  # optional; default is render.json
       'splash_url': '<url>',      # optional; overrides SPLASH_URL
       'slot_policy': scrapy_splash.SlotPolicy.PER_DOMAIN,
       'splash_headers': {},       # optional; a dict with headers sent to Splash
       'dont_process_response': True, # optional, default is False
       'dont_send_headers': True,  # optional, default is False
       'magic_response': False,    # optional, default is True
   }
})

SplashRequest 对象通过 args 来配置和 Request 对象通过 meta 来配置,两种方式达到的效果是相同的。

可以定义一个 Lua 脚本,来实现页面加载,代码如下所示:

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

这里实现的逻辑很简单,就是获取参数中的 url 属性并访问,然后等待 5 秒,最后把截图,html 代码,har 信息返回;接下来我们只需要在 Spider 中使用 SplashRequset 对接 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()
"""

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

	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 math: return
			page = int(match.group(1)) + 1
			next_url = f'{self.base_url}/page/{page}'
			yield SplashRequest(detail_url, callback=self.parse_detail, priority=2, 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() if score else None] if tags else []
		score = score.strip() if score else None
		item = BookItem(name=name, tags=tags, score=score, price=price, cover=cover)
		yield item

接下来通过下列命令运行爬取

scrapy crawl book

8.2 Splash 对接 Selenium

Scrapy 抓取页面的方式和 requests 库类似,都是直接模拟 HTTP 请求,而 Scrapy 也不能抓取 JavaScript 动态渲染的页面。在前文中抓取 JavaScript 渲染的页面有两种方式。一种是分析 Ajax 请求,找到其对应的接口抓取,Scrapy 同样可以用此种方式抓取。另一种是直接用 Selenium 或 Splash 模拟浏览器进行抓取,我们不需要关心页面后台发生的请求,也不需要分析渲染过程,只需要关心页面最终结果即可,可见即可爬。那么,如果 Scrapy 可以对接 Selenium,那 Scrapy 就可以处理任何网站的抓取了。

本节我们来看看 Scrapy 框架如何对接 Selenium,以 PhantomJS 进行演示。我们依然抓取淘宝商品信息,抓取逻辑和前文中用 Selenium 抓取淘宝商品完全相同。

请确保 PhantomJS 和 MongoDB 已经安装好并可以正常运行,安装好 Scrapy、Selenium、PyMongo 库,安装方式可以参考第 1 章的安装说明。

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

scrapy startproject scrapyseleniumtest

新建一个 Spider,命令如下所示:

scrapy genspider taobao www.taobao.com

修改 ROBOTSTXT_OBEY 为 False,如下所示:

ROBOTSTXT_OBEY = False

首先定义 Item 对象,名为 ProductItem,代码如下所示:

from scrapy import Item, Field

class ProductItem(Item):

    collection = 'products'
    image = Field()
    price = Field()
    deal = Field()
    title = Field()
    shop = Field()
    location = Field()

这里我们定义了 6 个 Field,也就是 6 个字段,跟之前的案例完全相同。然后定义了一个 collection 属性,即此 Item 保存到 MongoDB 的 Collection 名称。

初步实现 Spider 的 start_requests() 方法,如下所示:

from scrapy import Request, Spider
from urllib.parse import quote
from scrapyseleniumtest.items import ProductItem

class TaobaoSpider(Spider):
    name = 'taobao'
    allowed_domains = ['www.taobao.com']
    base_url = 'https://s.taobao.com/search?q='

    def start_requests(self):
        for keyword in self.settings.get('KEYWORDS'):
            for page in range(1, self.settings.get('MAX_PAGE') + 1):
                url = self.base_url + quote(keyword)
                yield Request(url=url, callback=self.parse, meta={'page': page}, dont_filter=True)

首先定义了一个 base_url,即商品列表的 URL,其后拼接一个搜索关键字就是该关键字在淘宝的搜索结果商品列表页面。

关键字用 KEYWORDS 标识,定义为一个列表。最大翻页页码用 MAX_PAGE 表示。它们统一定义在 setttings.py 里面,如下所示:

KEYWORDS = ['iPad']
MAX_PAGE = 100

在 start_requests() 方法里,我们首先遍历了关键字,遍历了分页页码,构造并生成 Request。由于每次搜索的 URL 是相同的,所以分页页码用 meta 参数来传递,同时设置 dont_filter 不去重。这样爬虫启动的时候,就会生成每个关键字对应的商品列表的每一页的请求了。

接下来我们需要处理这些请求的抓取。这次我们对接 Selenium 进行抓取,采用 Downloader Middleware 来实现。在 Middleware 里面的 process_request() 方法里对每个抓取请求进行处理,启动浏览器并进行页面渲染,再将渲染后的结果构造一个 HtmlResponse 对象返回。代码实现如下所示:

from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from scrapy.http import HtmlResponse
from logging import getLogger

class SeleniumMiddleware():
    def __init__(self, timeout=None, service_args=[]):
        self.logger = getLogger(__name__)
        self.timeout = timeout
        self.browser = webdriver.PhantomJS(service_args=service_args)
        self.browser.set_window_size(1400, 700)
        self.browser.set_page_load_timeout(self.timeout)
        self.wait = WebDriverWait(self.browser, self.timeout)

    def __del__(self):
        self.browser.close()

    def process_request(self, request, spider):
        """
        用 PhantomJS 抓取页面
        :param request: Request 对象
        :param spider: Spider 对象
        :return: HtmlResponse
        """
        self.logger.debug('PhantomJS is Starting')
        page = request.meta.get('page', 1)
        try:
            self.browser.get(request.url)
            if page > 1:
                input = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '#mainsrp-pager div.form> input')))
                submit = self.wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '#mainsrp-pager div.form> span.btn.J_Submit')))
                input.clear()
                input.send_keys(page)
                submit.click()
            self.wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, '#mainsrp-pager li.item.active> span'), str(page)))
            self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '.m-itemlist .items .item')))
            return HtmlResponse(url=request.url, body=self.browser.page_source, request=request, encoding='utf-8', status=200)
        except TimeoutException:
            return HtmlResponse(url=request.url, status=500, request=request)

    @classmethod
    def from_crawler(cls, crawler):
        return cls(timeout=crawler.settings.get('SELENIUM_TIMEOUT'),
                   service_args=crawler.settings.get('PHANTOMJS_SERVICE_ARGS'))

首先我们在 __init__() 里对一些对象进行初始化,包括 PhantomJS、WebDriverWait 等对象,同时设置页面大小和页面加载超时时间。在 process_request() 方法中,我们通过 Request 的 meta 属性获取当前需要爬取的页码,调用 PhantomJS 对象的 get() 方法访问 Request 的对应的 URL。这就相当于从 Request 对象里获取请求链接,然后再用 PhantomJS 加载,而不再使用 Scrapy 里的 Downloader。

随后的处理等待和翻页的方法在此不再赘述,和前文的原理完全相同。最后,页面加载完成之后,我们调用 PhantomJS 的 page_source 属性即可获取当前页面的源代码,然后用它来直接构造并返回一个 HtmlResponse 对象。构造这个对象的时候需要传入多个参数,如 url、body 等,这些参数实际上就是它的基础属性。可以在官方文档查看 HtmlResponse 对象的结构:https://doc.scrapy.org/en/latest/topics/request-response.html,这样我们就成功利用 PhantomJS 来代替 Scrapy 完成了页面的加载,最后将 Response 返回即可。

有人可能会纳闷:为什么实现这么一个 Downloader Middleware 就可以了?之前的 Request 对象怎么办?Scrapy 不再处理了吗?Response 返回后又传递给了谁?

是的,Request 对象到这里就不会再处理了,也不会再像以前一样交给 Downloader 下载。Response 会直接传给 Spider 进行解析。

我们需要回顾一下 Downloader Middleware 的 process_request() 方法的处理逻辑,内容如下所示:

当 process_request() 方法返回 Response 对象的时候,更低优先级的 Downloader Middleware 的 process_request() 和 process_exception() 方法就不会被继续调用了,转而开始执行每个 Downloader Middleware 的 process_response() 方法,调用完毕之后直接将 Response 对象发送给 Spider 来处理。

这里直接返回了一个 HtmlResponse 对象,它是 Response 的子类,返回之后便顺次调用每个 Downloader Middleware 的 process_response() 方法。而在 process_response() 中我们没有对其做特殊处理,它会被发送给 Spider,传给 Request 的回调函数进行解析。

到现在,我们应该能了解 Downloader Middleware 实现 Selenium 对接的原理了。

在 settings.py 里,我们设置调用刚才定义的 SeleniumMiddleware、设置等待超时变量 SELENIUM_TIMEOUT、设置 PhantomJS 配置参数 PHANTOMJS_SERVICE_ARGS,如下所示:

DOWNLOADER_MIDDLEWARES = {'scrapyseleniumtest.middlewares.SeleniumMiddleware': 543,}

Response 对象就会回传给 Spider 内的回调函数进行解析。所以下一步我们就实现其回调函数,对网页来进行解析,代码如下所示:

def parse(self, response):
    products = response.xpath('//div[@id="mainsrp-itemlist"]//div[@class="items"][1]//div[contains(@class, "item")]')
    for product in products:
        item = ProductItem()
        item['price'] = ''.join(product.xpath('.//div[contains(@class, "price")]//text()').extract()).strip()
        item['title'] = ''.join(product.xpath('.//div[contains(@class, "title")]//text()').extract()).strip()
        item['shop'] = ''.join(product.xpath('.//div[contains(@class, "shop")]//text()').extract()).strip()
        item['image'] = ''.join(product.xpath('.//div[@class="pic"]//img[contains(@class, "img")]/@data-src').extract()).strip()
        item['deal'] = product.xpath('.//div[contains(@class, "deal-cnt")]//text()').extract_first()
        item['location'] = product.xpath('.//div[contains(@class, "location")]//text()').extract_first()
        yield item

在这里我们使用 XPath 进行解析,调用 response 变量的 xpath() 方法即可。首先我们传递选取所有商品对应的 XPath,可以匹配所有商品,随后对结果进行遍历,依次选取每个商品的名称、价格、图片等内容,构造并返回一个 ProductItem 对象。

最后我们实现一个 Item Pipeline,将结果保存到 MongoDB,如下所示:

import pymongo

class MongoPipeline(object):
    def __init__(self, mongo_uri, mongo_db):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db

    @classmethod
    def from_crawler(cls, crawler):
        return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DB'))

    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]

    def process_item(self, item, spider):
        self.db[item.collection].insert(dict(item))
        return item

    def close_spider(self, spider):
        self.client.close()

此实现和前文中存储到 MongoDB 的方法完全一致,原理不再赘述。记得在 settings.py 中开启它的调用,如下所示:

ITEM_PIPELINES = {'scrapyseleniumtest.pipelines.MongoPipeline': 300,}

其中,MONGO_URI 和 MONGO_DB 的定义如下所示:

MONGO_URI = 'localhost'
MONGO_DB = 'taobao'

整个项目就完成了,执行如下命令启动抓取即可:

scrapy crawl taobao

运行结果如图所示:

再查看一下 MongoDB,结果如图所示:

这样我们便成功在 Scrapy 中对接 Selenium 并实现了淘宝商品的抓取。

本节代码地址为:https://github.com/Python3WebSpider/ScrapySeleniumTest

我们通过改写 Downloader Middleware 的方式实现了 Selenium 的对接。但这种方法其实是阻塞式的,也就是说这样就破坏了 Scrapy 异步处理的逻辑,速度会受到影响。为了不破坏其异步加载逻辑,我们可以使用 Splash 实现。

8.3 关于其他

作者在这章还开发了两个包,第一个是 selenium 的包,介绍如下:

第二个是 pyppeteer 的包,介绍如下:

但是目前 pyppeteer 已停止维护,官方推荐使用 playwright 来继续;

九、Scrapy 规则化爬虫

通过 Scrapy,我们可以轻松地完成一个站点爬虫的编写。但如果抓取的站点量非常大,比如爬取各大媒体的新闻信息,多个 Spider 则可能包含很多重复代码。

如果我们将各个站点的 Spider 的公共部分保留下来,不同的部分提取出来作为单独的配置,如爬取规则、页面解析方式等抽离出来做成一个配置文件,那么我们在新增一个爬虫的时候,只需要实现这些网站的爬取规则和提取规则即可。

9.1 CrawlSpider

在实现通用爬虫之前我们需要先了解一下 CrawlSpider,其官方文档链接为:http://scrapy.readthedocs.io/en/latest/topics/spiders.html#crawlspider

CrawlSpider 是 Scrapy 提供的一个通用 Spider。在 Spider 里,我们可以指定一些爬取规则来实现页面的提取,这些爬取规则由一个专门的数据结构 Rule 表示。Rule 里包含提取和跟进页面的配置,Spider 会根据 Rule 来确定当前页面中的哪些链接需要继续爬取、哪些页面的爬取结果需要用哪个方法解析等。

CrawlSpider 继承自 Spider 类。除了 Spider 类的所有方法和属性,它还提供了一个非常重要的属性和方法。

  • rules,它是爬取规则属性,是包含一个或多个 Rule 对象的列表。每个 Rule 对爬取网站的动作都做了定义,CrawlSpider 会读取 rules 的每一个 Rule 并进行解析。
  • parse_start_url(),它是一个可重写的方法。当 start_urls 里对应的 Request 得到 Response 时,该方法被调用,它会分析 Response 并必须返回 Item 对象或者 Request 对象。

这里最重要的内容莫过于 Rule 的定义了,它的定义和参数如下所示:

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

下面对其参数依次说明:

  • link_extractor,是一个 Link Extractor 对象。通过它,Spider 可以知道从爬取的页面中提取哪些链接。提取出的链接会自动生成 Request。它又是一个数据结构,一般常用 LxmlLinkExtractor 对象作为参数,其定义和参数如下所示:
class scrapy.linkextractors.lxmlhtml.LxmlLinkExtractor(allow=(), deny=(), allow_domains=(), deny_domains=(), deny_extensions=None, restrict_xpaths=(), restrict_css=(), tags=('a', 'area'), attrs=('href',), canonicalize=False, unique=True, process_value=None, strip=True)

allow 是一个正则表达式或正则表达式列表,它定义了从当前页面提取出的链接哪些是符合要求的,只有符合要求的链接才会被跟进。deny 则相反。allow_domains 定义了符合要求的域名,只有此域名的链接才会被跟进生成新的 Request,它相当于域名白名单。deny_domains 则相反,相当于域名黑名单。restrict_xpaths 定义了从当前页面中 XPath 匹配的区域提取链接,其值是 XPath 表达式或 XPath 表达式列表。restrict_css 定义了从当前页面中 CSS 选择器匹配的区域提取链接,其值是 CSS 选择器或 CSS 选择器列表。还有一些其他参数代表了提取链接的标签、是否去重、链接的处理等内容,使用的频率不高。可以参考文档的参数说明:http://scrapy.readthedocs.io/en/latest/topics/link-extractors.html#module-scrapy.linkextractors.lxmlhtml。

  • callback,即回调函数,和之前定义 Request 的 callback 有相同的意义。每次从 link_extractor 中获取到链接时,该函数将会调用。该回调函数接收一个 response 作为其第一个参数,并返回一个包含 Item 或 Request 对象的列表。注意,避免使用 parse() 作为回调函数。由于 CrawlSpider 使用 parse() 方法来实现其逻辑,如果 parse() 方法覆盖了,CrawlSpider 将会运行失败。
  • cb_kwargs,字典,它包含传递给回调函数的参数。
  • follow,布尔值,即 True 或 False,它指定根据该规则从 response 提取的链接是否需要跟进。如果 callback 参数为 None,follow 默认设置为 True,否则默认为 False。
  • process_links,指定处理函数,从 link_extractor 中获取到链接列表时,该函数将会调用,它主要用于过滤。
  • process_request,同样是指定处理函数,根据该 Rule 提取到每个 Request 时,该函数都会调用,对 Request 进行处理。该函数必须返回 Request 或者 None。

以上内容便是 CrawlSpider 中的核心 Rule 的基本用法。但这些内容可能还不足以完成一个 CrawlSpider 爬虫。下面我们利用 CrawlSpider 实现新闻网站的爬取实例,来更好地理解 Rule 的用法。

9.2 Item Loader

我们了解了利用 CrawlSpider 的 Rule 来定义页面的爬取逻辑,这是可配置化的一部分内容。但是,Rule 并没有对 Item 的提取方式做规则定义。对于 Item 的提取,我们需要借助另一个模块 Item Loader 来实现。

Item Loader 提供一种便捷的机制来帮助我们方便地提取 Item。它提供的一系列 API 可以分析原始数据对 Item 进行赋值。Item 提供的是保存抓取数据的容器,而 Item Loader 提供的是填充容器的机制。有了它,数据的提取会变得更加规则化。

Item Loader 的 API 如下所示:

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

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

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

  • item,Item 对象,可以调用 add_xpath()、add_css() 或 add_value() 等方法来填充 Item 对象。
  • selector,Selector 对象,用来提取填充数据的选择器。
  • response,Response 对象,用于使用构造选择器的 Response。

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

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(输出处理器)。Input Processor 收到数据时立刻提取数据,Input Processor 的结果被收集起来并且保存在 ItemLoader 内,但是不分配给 Item。收集到所有的数据后,load_item() 方法被调用来填充再生成 Item 对象。在调用时会先调用 Output Processor 来处理之前收集到的数据,然后再存入 Item 中,这样就生成了 Item。

下面将介绍一些内置的 Processor。

Identity

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

TakeFirst

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

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

输出结果如下所示:

1

经过此 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
proc = SelectJmes('foo')
processor = SelectJmes('foo')
print(processor({'foo': 'bar'}))

运行结果:

bar

以上内容便是一些常用的 Processor,在本节的实例中我们会使用 Processor 来进行数据的处理。

接下来,我们用一个实例来了解 Item Loader 的用法。

我们以中华网科技类新闻为例,来了解 CrawlSpider 和 Item Loader 的用法,再提取其可配置信息实现可配置化。官网链接为:http://tech.china.com/。我们需要爬取它的科技类新闻内容,链接为:http://tech.china.com/articles/。

我们要抓取新闻列表中的所有分页的新闻详情,包括标题、正文、时间、来源等信息。

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

scrapy startproject scrapyuniversal

创建一个 CrawlSpider,需要先制定一个模板。我们可以先看看有哪些可用模板,命令如下所示:

scrapy genspider -l

运行结果如下所示:

Available templates:
  basic
  crawl
  csvfeed
  xmlfeed

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

scrapy genspider -t crawl china tech.china.com

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

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule

class ChinaSpider(CrawlSpider):
    name = 'china'
    allowed_domains = ['tech.china.com']
    start_urls = ['http://tech.china.com/']

    rules = (Rule(LinkExtractor(allow=r'Items/'), callback='parse_item', follow=True),
    )

    def parse_item(self, response):
        i = {}
        #i['domain_id'] = response.xpath('//input[@id="sid"]/@value').extract()
        #i['name'] = response.xpath('//div[@id="name"]').extract()
        #i['description'] = response.xpath('//div[@id="description"]').extract()
        return i

这次生成的 Spider 内容多了一个 rules 属性的定义。Rule 的第一个参数是 LinkExtractor,就是上文所说的 LxmlLinkExtractor,只是名称不同。同时,默认的回调函数也不再是 parse,而是 parse_item。

定义 Rule

要实现新闻的爬取,我们需要做的就是定义好 Rule,然后实现解析函数。下面我们就来一步步实现这个过程。

首先将 start_urls 修改为起始链接,代码如下所示:

start_urls = ['http://tech.china.com/articles/']

之后,Spider 爬取 start_urls 里面的每一个链接。所以这里第一个爬取的页面就是我们刚才所定义的链接。得到 Response 之后,Spider 就会根据每一个 Rule 来提取这个页面内的超链接,去生成进一步的 Request。接下来,我们就需要定义 Rule 来指定提取哪些链接。

当前页面如图所示:

这是新闻的列表页,下一步自然就是将列表中的每条新闻详情的链接提取出来。这里直接指定这些链接所在区域即可。查看源代码,所有链接都在 ID 为 left_side 的节点内,具体来说是它内部的 class 为 con_item 的节点;

此处我们可以用 LinkExtractor 的 restrict_xpaths 属性来指定,之后 Spider 就会从这个区域提取所有的超链接并生成 Request。但是,每篇文章的导航中可能还有一些其他的超链接标签,我们只想把需要的新闻链接提取出来。真正的新闻链接路径都是以 article 开头的,我们用一个正则表达式将其匹配出来再赋值给 allow 参数即可。另外,这些链接对应的页面其实就是对应的新闻详情页,而我们需要解析的就是新闻的详情信息,所以此处还需要指定一个回调函数 callback。

到现在我们就可以构造出一个 Rule 了,代码如下所示:

Rule(LinkExtractor(allow='article\/.*\.html', restrict_xpaths='//div[@id="left_side"]//div[@class="con_item"]'), callback='parse_item')

接下来,我们还要让当前页面实现分页功能,所以还需要提取下一页的链接。分析网页源码之后可以发现下一页链接是在 ID 为 pageStyle 的节点内,如图 13-22 所示。

但是,下一页节点和其他分页链接区分度不高,要取出此链接我们可以直接用 XPath 的文本匹配方式,所以这里我们直接用 LinkExtractor 的 restrict_xpaths 属性来指定提取的链接即可。另外,我们不需要像新闻详情页一样去提取此分页链接对应的页面详情信息,也就是不需要生成 Item,所以不需要加 callback 参数。另外这下一页的页面如果请求成功了就需要继续像上述情况一样分析,所以它还需要加一个 follow 参数为 True,代表继续跟进匹配分析。其实,follow 参数也可以不加,因为当 callback 为空的时候,follow 默认为 True。此处 Rule 定义为如下所示:

Rule(LinkExtractor(restrict_xpaths='//div[@id="pageStyle"]//a[contains(., "下一页")]'))

所以现在 rules 就变成了:

rules = (Rule(LinkExtractor(allow='article\/.*\.html', restrict_xpaths='//div[@id="left_side"]//div[@class="con_item"]'), callback='parse_item'),
    Rule(LinkExtractor(restrict_xpaths='//div[@id="pageStyle"]//a[contains(., "下一页")]'))
)

接着我们运行一下代码,命令如下:

scrapy crawl china

现在已经实现页面的翻页和详情页的抓取了,我们仅仅通过定义了两个 Rule 即实现了这样的功能,运行效果如图。

解析页面

接下来我们需要做的就是解析页面内容了,将标题、发布时间、正文、来源提取出来即可。首先定义一个 Item,如下所示:

from scrapy import Field, Item

class NewsItem(Item):
    title = Field()
    url = Field()
    text = Field()
    datetime = Field()
    source = Field()
    website = Field()

这里的字段分别指新闻标题、链接、正文、发布时间、来源、站点名称,其中站点名称直接赋值为中华网。因为既然是通用爬虫,肯定还有很多爬虫也来爬取同样结构的其他站点的新闻内容,所以需要一个字段来区分一下站点名称。

详情页的预览图如图所示:

如果像之前一样提取内容,就直接调用 response 变量的 xpath()、css() 等方法即可。这里 parse_item() 方法的实现如下所示:

def parse_item(self, response):
    item = NewsItem()
    item['title'] = response.xpath('//h1[@id="chan_newsTitle"]/text()').extract_first()
    item['url'] = response.url
    item['text'] = ''.join(response.xpath('//div[@id="chan_newsDetail"]//text()').extract()).strip()
    item['datetime'] = response.xpath('//div[@id="chan_newsInfo"]/text()').re_first('(\d+-\d+-\d+\s\d+:\d+:\d+)')
    item['source'] = response.xpath('//div[@id="chan_newsInfo"]/text()').re_first(' 来源:(.*)').strip()
    item['website'] = ' 中华网 '
    yield item

这样我们就把每条新闻的信息提取形成了一个 NewsItem 对象。

这时实际上我们就已经完成了 Item 的提取。再运行一下 Spider,如下所示:

scrapy crawl china

输出内容如图所示:

现在我们就可以成功将每条新闻的信息提取出来。

不过我们发现这种提取方式非常不规整。下面我们再用 Item Loader,通过 add_xpath()、add_css()、add_value() 等方式实现配置化提取。我们可以改写 parse_item(),如下所示:

def parse_item(self, response):
    loader = ChinaLoader(item=NewsItem(), response=response)
    loader.add_xpath('title', '//h1[@id="chan_newsTitle"]/text()')
    loader.add_value('url', response.url)
    loader.add_xpath('text', '//div[@id="chan_newsDetail"]//text()')
    loader.add_xpath('datetime', '//div[@id="chan_newsInfo"]/text()', re='(\d+-\d+-\d+\s\d+:\d+:\d+)')
    loader.add_xpath('source', '//div[@id="chan_newsInfo"]/text()', re=' 来源:(.*)')
    loader.add_value('website', ' 中华网 ')
    yield loader.load_item()

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

from scrapy.loader import ItemLoader
from scrapy.loader.processors import TakeFirst, Join, Compose

class NewsLoader(ItemLoader):
    default_output_processor = TakeFirst()

class ChinaLoader(NewsLoader):
    text_out = Compose(Join(), lambda s: s.strip())
    source_out = Compose(Join(), lambda s: s.strip())

ChinaLoader 继承了 NewsLoader 类,其内定义了一个通用的 Out Processor 为 TakeFirst,这相当于之前所定义的 extract_first() 方法的功能。我们在 ChinaLoader 中定义了 text_out 和 source_out 字段。这里使用了一个 Compose Processor,它有两个参数:第一个参数 Join 也是一个 Processor,它可以把列表拼合成一个字符串;第二个参数是一个匿名函数,可以将字符串的头尾空白字符去掉。经过这一系列处理之后,我们就将列表形式的提取结果转化为去除头尾空白字符的字符串。

代码重新运行,提取效果是完全一样的。

至此,我们已经实现了爬虫的半通用化配置。

通用配置抽取

为什么现在只做到了半通用化?如果我们需要扩展其他站点,仍然需要创建一个新的 CrawlSpider,定义这个站点的 Rule,单独实现 parse_item() 方法。还有很多代码是重复的,如 CrawlSpider 的变量、方法名几乎都是一样的。那么我们可不可以把多个类似的几个爬虫的代码共用,把完全不相同的地方抽离出来,做成可配置文件呢?

当然可以。那我们可以抽离出哪些部分?所有的变量都可以抽取,如 name、allowed_domains、start_urls、rules 等。这些变量在 CrawlSpider 初始化的时候赋值即可。我们就可以新建一个通用的 Spider 来实现这个功能,命令如下所示:

scrapy genspider -t crawl universal universal

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

{
  "spider": "universal",
  "website": "中华网科技",
  "type": "新闻",
  "index": "http://tech.china.com/",
  "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": ["http://tech.china.com/articles/"],
  "allowed_domains": ["tech.china.com"],
  "rules": "china"
}

第一个字段 spider 即 Spider 的名称,在这里是 universal。后面是站点的描述,比如站点名称、类型、首页等。随后的 settings 是该 Spider 特有的 settings 配置,如果要覆盖全局项目,settings.py 内的配置可以单独为其配置。随后是 Spider 的一些属性,如 start_urls、allowed_domains、rules 等。rules 也可以单独定义成一个 rules.py 文件,做成配置文件,实现 Rule 的分离,如下所示:

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import Rule

rules = {
    'china': (Rule(LinkExtractor(allow='article\/.*\.html', restrict_xpaths='//div[@id="left_side"]//div[@class="con_item"]'),
             callback='parse_item'),
        Rule(LinkExtractor(restrict_xpaths='//div[@id="pageStyle"]//a[contains(., "下一页")]'))
    )
}

这样我们将基本的配置抽取出来。如果要启动爬虫,只需要从该配置文件中读取然后动态加载到 Spider 中即可。所以我们需要定义一个读取该 JSON 文件的方法,如下所示:

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

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

import sys
from scrapy.utils.project import get_project_settings
from scrapyuniversal.spiders.universal import UniversalSpider
from scrapyuniversal.utils import get_config
from scrapy.crawler import CrawlerProcess

def run():
    name = sys.argv[1]
    custom_settings = get_config(name)
    # 爬取使用的 Spider 名称
    spider = custom_settings.get('spider', 'universal')
    project_settings = get_project_settings()
    settings = dict(project_settings.copy())
    # 合并配置
    settings.update(custom_settings.get('settings'))
    process = CrawlerProcess(settings)
    # 启动爬虫
    process.crawl(spider, **{'name': name})
    process.start()

if __name__ == '__main__':
    run()

运行入口为 run()。首先获取命令行的参数并赋值为 name,name 就是 JSON 文件的名称,其实就是要爬取的目标网站的名称。我们首先利用 get_config() 方法,传入该名称读取刚才定义的配置文件。获取爬取使用的 spider 的名称、配置文件中的 settings 配置,然后将获取到的 settings 配置和项目全局的 settings 配置做了合并。新建一个 CrawlerProcess,传入爬取使用的配置。调用 crawl() 和 start() 方法即可启动爬取。

在 universal 中,我们新建一个__init__() 方法,进行初始化配置,实现如下所示:

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapyuniversal.utils import get_config
from scrapyuniversal.rules import rules

class UniversalSpider(CrawlSpider):
    name = 'universal'
    def __init__(self, name, *args, **kwargs):
        config = get_config(name)
        self.config = config
        self.rules = rules.get(config.get('rules'))
        self.start_urls = config.get('start_urls')
        self.allowed_domains = config.get('allowed_domains')
        super(UniversalSpider, self).__init__(*args, **kwargs)
    
    def parse_item(self, response):
        i = {}
        return i

在 __init__() 方法中,start_urls、allowed_domains、rules 等属性被赋值。其中,rules 属性另外读取了 rules.py 的配置,这样就成功实现爬虫的基础配置。

接下来,执行如下命令运行爬虫:

python3 run.py china

程序会首先读取 JSON 配置文件,将配置中的一些属性赋值给 Spider,然后启动爬取。运行效果完全相同,运行结果如图所示。

现在我们已经对 Spider 的基础属性实现了可配置化。剩下的解析部分同样需要实现可配置化,原来的解析函数如下所示:

def parse_item(self, response):
    loader = ChinaLoader(item=NewsItem(), response=response)
    loader.add_xpath('title', '//h1[@id="chan_newsTitle"]/text()')
    loader.add_value('url', response.url)
    loader.add_xpath('text', '//div[@id="chan_newsDetail"]//text()')
    loader.add_xpath('datetime', '//div[@id="chan_newsInfo"]/text()', re='(\d+-\d+-\d+\s\d+:\d+:\d+)')
    loader.add_xpath('source', '//div[@id="chan_newsInfo"]/text()', re=' 来源:(.*)')
    loader.add_value('website', ' 中华网 ')
    yield loader.load_item()

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

"item": {
  "class": "NewsItem",
  "loader": "ChinaLoader",
  "attrs": {
    "title": [
      {
        "method": "xpath",
        "args": ["//h1[@id='chan_newsTitle']/text()"]
      }
    ],
    "url": [
      {
        "method": "attr",
        "args": ["url"]
      }
    ],
    "text": [
      {
        "method": "xpath",
        "args": ["//div[@id='chan_newsDetail']//text()"]
      }
    ],
    "datetime": [
      {
        "method": "xpath",
        "args": ["//div[@id='chan_newsInfo']/text()"],
        "re": "(\\d+-\\d+-\\d+\\s\\d+:\\d+:\\d+)"
      }
    ],
    "source": [
      {
        "method": "xpath",
        "args": ["//div[@id='chan_newsInfo']/text()"],
        "re": "来源:(.*)"
      }
    ],
    "website": [
      {
        "method": "value",
        "args": ["中华网"]
      }
    ]
  }
}

这里定义了 class 和 loader 属性,它们分别代表 Item 和 Item Loader 所使用的类。定义了 attrs 属性来定义每个字段的提取规则,例如,title 定义的每一项都包含一个 method 属性,它代表使用的提取方法,如 xpath 即代表调用 Item Loader 的 add_xpath() 方法。args 即参数,就是 add_xpath() 的第二个参数,即 XPath 表达式。针对 datetime 字段,我们还用了一次正则提取,所以这里还可以定义一个 re 参数来传递提取时所使用的正则表达式。

我们还要将这些配置之后动态加载到 parse_item() 方法里。最后,最重要的就是实现 parse_item() 方法,如下所示:

 def parse_item(self, response):
    item = self.config.get('item')
    if item:
        cls = eval(item.get('class'))()
        loader = eval(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('args'), **{'re': extractor.get('re')})
                if extractor.get('method') == 'css':
                    loader.add_css(key, *extractor.get('args'), **{'re': extractor.get('re')})
                if extractor.get('method') == 'value':
                    loader.add_value(key, *extractor.get('args'), **{'re': extractor.get('re')})
                if extractor.get('method') == 'attr':
                    loader.add_value(key, getattr(response, *extractor.get('args')))
        yield loader.load_item()

这里首先获取 Item 的配置信息,然后获取 class 的配置,将其初始化,初始化 Item Loader,遍历 Item 的各个属性依次进行提取。判断 method 字段,调用对应的处理方法进行处理。如 method 为 css,就调用 Item Loader 的 add_css() 方法进行提取。所有配置动态加载完毕之后,调用 load_item() 方法将 Item 提取出来。

重新运行程序,结果如图所示。

运行结果是完全相同的。

我们再回过头看一下 start_urls 的配置。这里 start_urls 只可以配置具体的链接。如果这些链接有 100 个、1000 个,我们总不能将所有的链接全部列出来吧?在某些情况下,start_urls 也需要动态配置。我们将 start_urls 分成两种,一种是直接配置 URL 列表,一种是调用方法生成,它们分别定义为 static 和 dynamic 类型。

本例中的 start_urls 很明显是 static 类型的,所以 start_urls 配置改写如下所示:

"start_urls": {"type":"static","value": ["http://tech.china.com/articles/"]}

如果 start_urls 是动态生成的,我们可以调用方法传参数,如下所示:

"start_urls": {
  "type": "dynamic",
  "method": "china",
  "args": [5, 10]
}

这里 start_urls 定义为 dynamic 类型,指定方法为 urls_china(),然后传入参数 5 和 10,来生成第 5 到 10 页的链接。这样我们只需要实现该方法即可,统一新建一个 urls.py 文件,如下所示:

def china(start, end):
    for page in range(start, end + 1):
        yield 'http://tech.china.com/articles/index_' + str(page) + '.html'

其他站点可以自行配置。如某些链接需要用到时间戳,加密参数等,均可通过自定义方法实现。

接下来在 Spider 的 __init__() 方法中,start_urls 的配置改写如下所示:

from scrapyuniversal import urls

start_urls = config.get('start_urls')
if start_urls:
    if start_urls.get('type') == 'static':
        self.start_urls = start_urls.get('value')
    elif start_urls.get('type') == 'dynamic':
        self.start_urls = list(eval('urls.' + start_urls.get('method'))(*start_urls.get('args', [])))

这里通过判定 start_urls 的类型分别进行不同的处理,这样我们就可以实现 start_urls 的配置了。

至此,Spider 的设置、起始链接、属性、提取方法都已经实现了全部的可配置化。

综上所述,整个项目的配置包括如下内容。

  • spider,指定所使用的 Spider 的名称。
  • settings,可以专门为 Spider 定制配置信息,会覆盖项目级别的配置。
  • start_urls,指定爬虫爬取的起始链接。
  • allowed_domains,允许爬取的站点。
  • rules,站点的爬取规则。
  • item,数据的提取规则。

我们实现了 Scrapy 的通用爬虫,每个站点只需要修改 JSON 文件即可实现自由配置。

本节代码地址为:https://github.com/Python3WebSpider/ScrapyUniversal

Scrapy 通用爬虫的实现:我们将所有配置抽离出来,每增加一个爬虫,就只需要增加一个 JSON 文件配置。之后我们只需要维护这些配置文件即可。如果要更加方便的管理,可以将规则存入数据库,再对接可视化管理页面即可。

十、Scrapyrt 的使用

Scrapyrt 为 Scrapy 提供了一个调度的 HTTP 接口。有了它我们不需要再执行 Scrapy 命令,而是通过请求一个 HTTP 接口即可调度 Scrapy 任务,我们就不需要借助于命令行来启动项目了。如果项目是在远程服务器运行,利用它来启动项目是个不错的选择。

我们以本章 Scrapy 入门项目为例来说明 Scrapyrt 的使用方法,项目源代码地址为:https://github.com/Python3WebSpider/ScrapyTutorial

请确保 Scrapyrt 已经正确安装并正常运行,具体安装可以参考第 1 章的说明。

首先将项目下载下来,在项目目录下运行 Scrapyrt,假设当前服务运行在 9080 端口上。下面将简单介绍 Scrapyrt 的使用方法。

9.1 GET 请求

目前,GET 请求方式支持如下的参数。

  • spider_name,Spider 名称,字符串类型,必传参数,如果传递的 Spider 名称不存在则会返回 404 错误。
  • url,爬取链接,字符串类型,如果起始链接没有定义的话就必须要传递,如果传递了该参数,Scrapy 会直接用该 URL 生成 Request,而直接忽略 start_requests() 方法和 start_urls 属性的定义。
  • callback,回调函数名称,字符串类型,可选参数,如果传递了就会使用此回调函数处理,否则会默认使用 Spider 内定义的回调函数。
  • max_requests,最大请求数量,数值类型,可选参数,它定义了 Scrapy 执行请求的 Request 的最大限制,如定义为 5,则最多只执行 5 次 Request 请求,其余的则会被忽略。
  • start_requests,是否要执行 start_request() 函数,布尔类型,可选参数,在 Scrapy 项目中如果定义了 start_requests() 方法,那么在项目启动时会默认调用该方法,但是在 Scrapyrt 就不一样了,它默认不执行 start_requests() 方法,如果要执行,需要将它设置为 true。

例如我们执行如下命令:

curl http://localhost:9080/crawl.json?spider_name=quotes&url=http://quotes.toscrape.com/

得到类似如下结果,如图所示:

返回的是一个 JSON 格式的字符串,我们解析它的结构,如下所示:

{
  "status": "ok",
  "items": [
    {
      "text": "“The world as we have created it is a process of o...",
      "author": "Albert Einstein",
      "tags": [
        "change",
        "deep-thoughts",
        "thinking",
        "world"
      ]
    },
    ...
    {
      "text": "“... a mind needs books as a sword needs a whetsto...",
      "author": "George R.R. Martin",
      "tags": [
        "books",
        "mind"
      ]
    }
  ],
  "items_dropped": [],
  "stats": {
    "downloader/request_bytes": 2892,
    "downloader/request_count": 11,
    "downloader/request_method_count/GET": 11,
    "downloader/response_bytes": 24812,
    "downloader/response_count": 11,
    "downloader/response_status_count/200": 10,
    "downloader/response_status_count/404": 1,
    "dupefilter/filtered": 1,
    "finish_reason": "finished",
    "finish_time": "2017-07-12 15:09:02",
    "item_scraped_count": 100,
    "log_count/DEBUG": 112,
    "log_count/INFO": 8,
    "memusage/max": 52510720,
    "memusage/startup": 52510720,
    "request_depth_max": 10,
    "response_received_count": 11,
    "scheduler/dequeued": 10,
    "scheduler/dequeued/memory": 10,
    "scheduler/enqueued": 10,
    "scheduler/enqueued/memory": 10,
    "start_time": "2017-07-12 15:08:56"
  },
  "spider_name": "quotes"
}

这里省略了 items 绝大部分。status 显示了爬取的状态,items 部分是 Scrapy 项目的爬取结果,items_dropped 是被忽略的 Item 列表,stats 是爬取结果的统计情况。此结果和直接运行 Scrapy 项目得到的统计是相同的。

这样一来,我们就通过 HTTP 接口调度 Scrapy 项目并获取爬取结果,如果 Scrapy 项目部署在服务器上,我们可以通过开启一个 Scrapyrt 服务实现任务的调度并直接取到爬取结果,这很方便。

9.2 POST 请求

除了 GET 请求,我们还可以通过 POST 请求来请求 Scrapyrt。但是此处 Request Body 必须是一个合法的 JSON 配置,在 JSON 里面可以配置相应的参数,支持的配置参数更多。

目前,JSON 配置支持如下参数。

  • spider_name:Spider 名称,字符串类型,必传参数。如果传递的 Spider 名称不存在,则返回 404 错误。

  • max_requests:最大请求数量,数值类型,可选参数。它定义了 Scrapy 执行请求的 Request 的最大限制,如定义为 5,则表示最多只执行 5 次 Request 请求,其余的则会被忽略。

  • request:Request 配置,JSON 对象,必传参数。通过该参数可以定义 Request 的各个参数,必须指定 url 字段来指定爬取链接,其他字段可选。

我们看一个 JSON 配置实例,如下所示:

{
    "request": {
        "url": "http://quotes.toscrape.com/",
        "callback": "parse",
        "dont_filter": "True",
        "cookies": {"foo": "bar"}
    },
    "max_requests": 2,
    "spider_name": "quotes"
}

我们执行如下命令传递该 Json 配置并发起 POST 请求:

curl http://localhost:9080/crawl.json -d '{"request": {"url": "http://quotes.toscrape.com/", "dont_filter": "True", "callback": "parse", "cookies": {"foo": "bar"}}, "max_requests": 2, "spider_name": "quotes"}'

运行结果和上文类似,同样是输出了爬取状态、结果、统计信息等内容。

以上内容便是 Scrapyrt 的相关用法介绍。通过它,我们方便地调度 Scrapy 项目的运行并获取爬取结果。更多的使用方法可以参考官方文档:http://scrapyrt.readthedocs.io。

十一、Scrapy 对接 Docker

环境配置问题可能一直是我们头疼的,我们可能遇到过如下的情况:

  • 我们在本地写好了一个 Scrapy 爬虫项目,想要把它放到服务器上运行,但是服务器上没有安装 Python 环境。
  • 别人给了我们一个 Scrapy 爬虫项目,项目中使用包的版本和我们本地环境版本不一致,无法直接运行。
  • 我们需要同时管理不同版本的 Scrapy 项目,如早期的项目依赖于 Scrapy 0.25,现在的项目依赖于 Scrapy 1.4.0。

在这些情况下,我们需要解决的就是环境的安装配置、环境的版本冲突解决等问题。

对于 Python 来说,VirtualEnv 的确可以解决版本冲突的问题。但是,VirtualEnv 不太方便做项目部署,我们还是需要安装 Python 环境,

如何解决上述问题呢?答案是用 Docker。Docker 可以提供操作系统级别的虚拟环境,一个 Docker 镜像一般都包含一个完整的操作系统,而这些系统内也有已经配置好的开发环境,如 Python 3.6 环境等。

我们可以直接使用此 Docker 的 Python 3 镜像运行一个容器,将项目直接放到容器里运行,就不用再额外配置 Python 3 环境。这样就解决了环境配置的问题。

我们也可以进一步将 Scrapy 项目制作成一个新的 Docker 镜像,镜像里只包含适用于本项目的 Python 环境。如果要部署到其他平台,只需要下载该镜像并运行就好了,因为 Docker 运行时采用虚拟环境,和宿主机是完全隔离的,所以也不需要担心环境冲突问题。

如果我们能够把 Scrapy 项目制作成一个 Docker 镜像,只要其他主机安装了 Docker,那么只要将镜像下载并运行即可,而不必再担心环境配置问题或版本冲突问题。

接下来,我们尝试把一个 Scrapy 项目制作成一个 Docker 镜像。

我们要实现把前文 Scrapy 的入门项目打包成一个 Docker 镜像的过程。项目爬取的网址为:http://quotes.toscrape.com/,本章 Scrapy 入门一节已经实现了 Scrapy 对此站点的爬取过程,项目代码为:https://github.com/Python3WebSpider/ScrapyTutorial,如果本地不存在的话可以 Clone 下来。

准备工作

请确保已经安装好 Docker 和 MongoDB 并可以正常运行,如果没有安装可以参考第 1 章的安装说明。

创建 Dockerfile

首先在项目的根目录下新建一个 requirements.txt 文件,将整个项目依赖的 Python 环境包都列出来,如下所示:

scrapy
pymongo

如果库需要特定的版本,我们还可以指定版本号,如下所示:

scrapy>=1.4.0
pymongo>=3.4.0

在项目根目录下新建一个 Dockerfile 文件,文件不加任何后缀名,修改内容如下所示:

FROM python:3.6
ENV PATH /usr/local/bin:$PATH
ADD . /code
WORKDIR /code
RUN pip3 install -r requirements.txt
CMD scrapy crawl quotes

第一行的 FROM 代表使用的 Docker 基础镜像,在这里我们直接使用 python:3.6 的镜像,在此基础上运行 Scrapy 项目。

第二行 ENV 是环境变量设置,将 /usr/local/bin:$PATH 赋值给 PATH,即增加 /usr/local/bin 这个环境变量路径。

第三行 ADD 是将本地的代码放置到虚拟容器中。它有两个参数:第一个参数是.,代表本地当前路径;第二个参数是 /code,代表虚拟容器中的路径,也就是将本地项目所有内容放置到虚拟容器的 /code 目录下,以便于在虚拟容器中运行代码。

第四行 WORKDIR 是指定工作目录,这里将刚才添加的代码路径设成工作路径。这个路径下的目录结构和当前本地目录结构是相同的,所以我们可以直接执行库安装命令、爬虫运行命令等。

第五行 RUN 是执行某些命令来做一些环境准备工作。由于 Docker 虚拟容器内只有 Python 3 环境,而没有所需要的 Python 库,所以我们运行此命令来在虚拟容器中安装相应的 Python 库如 Scrapy,这样就可以在虚拟容器中执行 Scrapy 命令了。

第六行 CMD 是容器启动命令。在容器运行时,此命令会被执行。在这里我们直接用 scrapy crawl quotes 来启动爬虫。

修改 MongoDB 连接

接下来我们需要修改 MongoDB 的连接信息。如果我们继续用 localhost 是无法找到 MongoDB 的,因为在 Docker 虚拟容器里 localhost 实际指向容器本身的运行 IP,而容器内部并没有安装 MongoDB,所以爬虫无法连接 MongoDB。

这里的 MongoDB 地址可以有如下两种选择。

  • 如果只想在本机测试,我们可以将地址修改为宿主机的 IP,也就是容器外部的本机 IP,一般是一个局域网 IP,使用 ifconfig 命令即可查看。
  • 如果要部署到远程主机运行,一般 MongoDB 都是可公网访问的地址,修改为此地址即可。

在本节中,我们的目标是将项目打包成一个镜像,让其他远程主机也可运行这个项目。所以我们直接将此处 MongoDB 地址修改为某个公网可访问的远程数据库地址,修改 MONGO_URI 如下所示:

MONGO_URI = 'mongodb://admin:admin123@120.27.34.25:27017'

此处地址可以修改为自己的远程 MongoDB 数据库地址。

这样项目的配置就完成了。

构建镜像

接下来我们便可以构建镜像了,执行如下命令:

docker build -t quotes:latest .

这样的输出就说明镜像构建成功。这时我们查看一下构建的镜像,如下所示:

Sending build context to Docker daemon 191.5 kB
Step 1/6 : FROM python:3.6
 ---> 968120d8cbe8
Step 2/6 : ENV PATH /usr/local/bin:$PATH
 ---> Using cache
 ---> 387abbba1189
Step 3/6 : ADD . /code
 ---> a844ee0db9c6
Removing intermediate container 4dc41779c573
Step 4/6 : WORKDIR /code
 ---> 619b2c064ae9
Removing intermediate container bcd7cd7f7337
Step 5/6 : RUN pip3 install -r requirements.txt
 ---> Running in 9452c83a12c5
...
Removing intermediate container 9452c83a12c5
Step 6/6 : CMD scrapy crawl quotes
 ---> Running in c092b5557ab8
 ---> c8101aca6e2a
Removing intermediate container c092b5557ab8
Successfully built c8101aca6e2a

出现类似输出就证明镜像构建成功了,这时执行如我们查看一下构建的镜像:

docker images

返回结果中其中有一行就是:

quotes  latest  41c8499ce210    2 minutes ago   769 MB

这就是我们新构建的镜像。

运行

我们可以先在本地测试运行,执行如下命令:

docker run quotes

这样我们就利用此镜像新建并运行了一个 Docker 容器,运行效果完全一致,如图所示。

如果出现类似行结果,这就证明构建的镜像没有问题。

推送至 Docker Hub

构建完成之后,我们可以将镜像 Push 到 Docker 镜像托管平台,如 Docker Hub 或者私有的 Docker Registry 等,这样我们就可以从远程服务器下拉镜像并运行了。

以 Docker Hub 为例,如果项目包含一些私有的连接信息(如数据库),我们最好将 Repository 设为私有或者直接放到私有的 Docker Registry。

首先在 https://hub.docker.com 注册一个账号,新建一个 Repository,名为 quotes。比如,我的用户名为 germey,新建的 Repository 名为 quotes,那么此 Repository 的地址就可以用 germey/quotes 来表示。

为新建的镜像打一个标签,命令如下所示:

docker tag quotes:latest germey/quotes:latest

推送镜像到 Docker Hub 即可,命令如下所示:

docker push germey/quotes

Docker Hub 便会出现新推送的 Docker 镜像了,如图所示。

如果我们想在其他的主机上运行这个镜像,主机上装好 Docker 后,可以直接执行如下命令:

docker run germey/quotes

这样就会自动下载镜像,然后启动容器运行,不需要配置 Python 环境,不需要关心版本冲突问题。

运行效果如图所示:

整个项目爬取完成后,数据就可以存储到指定的数据库中。

我们讲解了将 Scrapy 项目制作成 Docker 镜像并部署到远程服务器运行的过程。使用此种方式,我们在本节开头所列出的问题都迎刃而解。

十二、Scrapy 爬取新浪微博

前面讲解了 Scrapy 中各个模块基本使用方法以及代理池、Cookies 池。接下来我们以一个反爬比较强的网站新浪微博为例,来实现一下 Scrapy 的大规模爬取。

本次爬取的目标是新浪微博用户的公开基本信息,如用户昵称、头像、用户的关注、粉丝列表以及发布的微博等,这些信息抓取之后保存至 MongoDB。

请确保前文所讲的代理池、Cookies 池已经实现并可以正常运行,安装 Scrapy、PyMongo 库,如没有安装可以参考前文内容。

首先我们要实现用户的大规模爬取。这里采用的爬取方式是,以微博的几个大 V 为起始点,爬取他们各自的粉丝和关注列表,然后获取粉丝和关注列表的粉丝和关注列表,以此类推,这样下去就可以实现递归爬取。如果一个用户与其他用户有社交网络上的关联,那他们的信息就会被爬虫抓取到,这样我们就可以做到对所有用户的爬取。通过这种方式,我们可以得到用户的唯一 ID,再根据 ID 获取每个用户发布的微博即可。

爬取分析

这里我们选取的爬取站点是:https://m.weibo.cn,此站点是微博移动端的站点。打开该站点会跳转到登录页面,这是因为主页做了登录限制。不过我们可以直接打开某个用户详情页面,如图所示。

我们在页面最上方可以看到她的关注和粉丝数量。我们点击关注,进入到她的关注列表,如图所示。

我们打开开发者工具,切换到 XHR 过滤器,一直下拉关注列表,即可看到下方会出现很多 Ajax 请求,这些请求就是获取关注列表的 Ajax 请求。

我们打开第一个 Ajax 请求看一下,发现它的链接为:https://m.weibo.cn/api/container/getIndex?containerid=231051_-followers-_1916655407&luicode=10000011&lfid=1005051916655407&featurecode=20000320&type=uid&value=1916655407&page=2,详情如图所示。

请求类型是 GET 类型,返回结果是 JSON 格式,我们将其展开之后即可看到其关注的用户的基本信息。接下来我们只需要构造这个请求的参数。此链接一共有 7 个参数,如图 13-37 所示。

其中最主要的参数就是 containerid 和 page。有了这两个参数,我们同样可以获取请求结果。我们可以将接口精简为:https://m.weibo.cn/api/container/getIndex?containerid=231051_-followers-_1916655407&page=2,这里的 containerid 的前半部分是固定的,后半部分是用户的 id。所以这里参数就可以构造出来了,只需要修改 containerid 最后的 id 和 page 参数即可获取分页形式的关注列表信息。

利用同样的方法,我们也可以分析用户详情的 Ajax 链接、用户微博列表的 Ajax 链接,如下所示:

# 用户详情 API
user_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&value={uid}&containerid=100505{uid}'
# 关注列表 API
follow_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_followers_-_{uid}&page={page}'
# 粉丝列表 API
fan_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_fans_-_{uid}&page={page}'
# 微博列表 API
weibo_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&page={page}&containerid=107603{uid}'

此处的 uid 和 page 分别代表用户 ID 和分页页码。

注意,这个 API 可能随着时间的变化或者微博的改版而变化,以实测为准。

我们从几个大 V 开始抓取,抓取他们的粉丝、关注列表、微博信息,然后递归抓取他们的粉丝和关注列表的粉丝、关注列表、微博信息,递归抓取,最后保存微博用户的基本信息、关注和粉丝列表、发布的微博。

我们选择 MongoDB 作为存储的数据库,可以更方便地存储用户的粉丝和关注列表。

新建项目

接下来,我们用 Scrapy 来实现这个抓取过程。首先创建一个项目,命令如下所示:

scrapy startproject weibo

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

scrapy genspider weibocn m.weibo.cn

我们首先修改 Spider,配置各个 Ajax 的 URL,选取几个大 V,将他们的 ID 赋值成一个列表,实现 start_requests() 方法,也就是依次抓取各个大 V 的个人详情,然后用 parse_user() 进行解析,如下所示:

from scrapy import Request, Spider

class WeiboSpider(Spider):
    name = 'weibocn'
    allowed_domains = ['m.weibo.cn']
    user_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&value={uid}&containerid=100505{uid}'
    follow_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_followers_-_{uid}&page={page}'
    fan_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_fans_-_{uid}&page={page}'
    weibo_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&page={page}&containerid=107603{uid}'
    start_users = ['3217179555', '1742566624', '2282991915', '1288739185', '3952070245', '5878659096']

    def start_requests(self):
        for uid in self.start_users:
            yield Request(self.user_url.format(uid=uid), callback=self.parse_user)

    def parse_user(self, response):
        self.logger.debug(response)

创建 Item

接下来,我们解析用户的基本信息并生成 Item。这里我们先定义几个 Item,如用户、用户关系、微博的 Item,如下所示:

from scrapy import Item, Field

class UserItem(Item):
    collection = 'users'
    id = Field()
    name = Field()
    avatar = Field()
    cover = Field()
    gender = Field()
    description = Field()
    fans_count = Field()
    follows_count = Field()
    weibos_count = Field()
    verified = Field()
    verified_reason = Field()
    verified_type = Field()
    follows = Field()
    fans = Field()
    crawled_at = Field()

class UserRelationItem(Item):
    collection = 'users'
    id = Field()
    follows = Field()
    fans = Field()

class WeiboItem(Item):
    collection = 'weibos'
    id = Field()
    attitudes_count = Field()
    comments_count = Field()
    reposts_count = Field()
    picture = Field()
    pictures = Field()
    source = Field()
    text = Field()
    raw_text = Field()
    thumbnail = Field()
    user = Field()
    created_at = Field()
    crawled_at = Field()

这里定义了 collection 字段,指明保存的 Collection 的名称。用户的关注和粉丝列表直接定义为一个单独的 UserRelationItem,其中 id 就是用户的 ID,follows 就是用户关注列表,fans 是粉丝列表,但这并不意味着我们会将关注和粉丝列表存到一个单独的 Collection 里。后面我们会用 Pipeline 对各个 Item 进行处理、合并存储到用户的 Collection 里,因此 Item 和 Collection 并不一定是完全对应的。

提取数据

我们开始解析用户的基本信息,实现 parse_user() 方法,如下所示:

def parse_user(self, response):
    """
    解析用户信息
    :param response: Response 对象
    """
    result = json.loads(response.text)
    if result.get('userInfo'):
        user_info = result.get('userInfo')
        user_item = UserItem()
        field_map = {
            'id': 'id', 'name': 'screen_name', 'avatar': 'profile_image_url', 'cover': 'cover_image_phone',
            'gender': 'gender', 'description': 'description', 'fans_count': 'followers_count',
            'follows_count': 'follow_count', 'weibos_count': 'statuses_count', 'verified': 'verified',
            'verified_reason': 'verified_reason', 'verified_type': 'verified_type'
        }
        for field, attr in field_map.items():
            user_item[field] = user_info.get(attr)
        yield user_item
        # 关注
        uid = user_info.get('id')
        yield Request(self.follow_url.format(uid=uid, page=1), callback=self.parse_follows,
                      meta={'page': 1, 'uid': uid})
        # 粉丝
        yield Request(self.fan_url.format(uid=uid, page=1), callback=self.parse_fans,
                      meta={'page': 1, 'uid': uid})
        # 微博
        yield Request(self.weibo_url.format(uid=uid, page=1), callback=self.parse_weibos,
                      meta={'page': 1, 'uid': uid})

在这里我们一共完成了两个操作。

  • 解析 JSON 提取用户信息并生成 UserItem 返回。我们并没有采用常规的逐个赋值的方法,而是定义了一个字段映射关系。我们定义的字段名称可能和 JSON 中用户的字段名称不同,所以在这里定义成一个字典,然后遍历字典的每个字段实现逐个字段的赋值。
  • 构造用户的关注、粉丝、微博的第一页的链接,并生成 Request,这里需要的参数只有用户的 ID。另外,初始分页页码直接设置为 1 即可。

接下来,我们还需要保存用户的关注和粉丝列表。以关注列表为例,其解析方法为 parse_follows(),实现如下所示:

def parse_follows(self, response):
    """
    解析用户关注
    :param response: Response 对象
    """
    result = json.loads(response.text)
    if result.get('ok') and result.get('cards') and len(result.get('cards')) and result.get('cards')[-1].get('card_group'):
        # 解析用户
        follows = result.get('cards')[-1].get('card_group')
        for follow in follows:
            if follow.get('user'):
                uid = follow.get('user').get('id')
                yield Request(self.user_url.format(uid=uid), callback=self.parse_user)
        # 关注列表
        uid = response.meta.get('uid')
        user_relation_item = UserRelationItem()
        follows = [{'id': follow.get('user').get('id'), 'name': follow.get('user').get('screen_name')} for follow in
                   follows]
        user_relation_item['id'] = uid
        user_relation_item['follows'] = follows
        user_relation_item['fans'] = []
        yield user_relation_item
        # 下一页关注
        page = response.meta.get('page') + 1
        yield Request(self.follow_url.format(uid=uid, page=page),
                      callback=self.parse_follows, meta={'page': page, 'uid': uid})

那么在这个方法里面我们做了如下三件事。

  • 解析关注列表中的每个用户信息并发起新的解析请求。我们首先解析关注列表的信息,得到用户的 ID,然后再利用 user_url 构造访问用户详情的 Request,回调就是刚才所定义的 parse_user() 方法。
  • 提取用户关注列表内的关键信息并生成 UserRelationItem。id 字段直接设置成用户的 ID,JSON 返回数据中的用户信息有很多冗余字段。在这里我们只提取了关注用户的 ID 和用户名,然后把它们赋值给 follows 字段,fans 字段设置成空列表。这样我们就建立了一个存有用户 ID 和用户部分关注列表的 UserRelationItem,之后合并且保存具有同一个 ID 的 UserRelationItem 的关注和粉丝列表。
  • 提取下一页关注。只需要将此请求的分页页码加 1 即可。分页页码通过 Request 的 meta 属性进行传递,Response 的 meta 来接收。这样我们构造并返回下一页的关注列表的 Request。

抓取粉丝列表的原理和抓取关注列表原理相同,在此不再赘述。

接下来我们还差一个方法的实现,即 parse_weibos(),它用来抓取用户的微博信息,实现如下所示:

def parse_weibos(self, response):
    """
    解析微博列表
    :param response: Response 对象
    """
    result = json.loads(response.text)
    if result.get('ok') and result.get('cards'):
        weibos = result.get('cards')
        for weibo in weibos:
            mblog = weibo.get('mblog')
            if mblog:
                weibo_item = WeiboItem()
                field_map = {
                    'id': 'id', 'attitudes_count': 'attitudes_count', 'comments_count': 'comments_count', 'created_at': 'created_at',
                    'reposts_count': 'reposts_count', 'picture': 'original_pic', 'pictures': 'pics',
                    'source': 'source', 'text': 'text', 'raw_text': 'raw_text', 'thumbnail': 'thumbnail_pic'
                }
                for field, attr in field_map.items():
                    weibo_item[field] = mblog.get(attr)
                weibo_item['user'] = response.meta.get('uid')
                yield weibo_item
        # 下一页微博
        uid = response.meta.get('uid')
        page = response.meta.get('page') + 1
        yield Request(self.weibo_url.format(uid=uid, page=page), callback=self.parse_weibos,
                      meta={'uid': uid, 'page': page})

这里 parse_weibos() 方法完成了两件事。

  • 提取用户的微博信息,并生成 WeiboItem。这里同样建立了一个字段映射表,实现批量字段赋值。
  • 提取下一页的微博列表。这里同样需要传入用户 ID 和分页页码。

到目前为止,微博的 Spider 已经完成。后面还需要对数据进行数据清洗存储,以及对接代理池、Cookies 池来防止反爬虫。

数据清洗

有些微博的时间可能不是标准的时间,比如它可能显示为刚刚、几分钟前、几小时前、昨天等。这里我们需要统一转化这些时间,实现一个 parse_time() 方法,如下所示:

def parse_time(self, date):
    if re.match(' 刚刚 ', date):
        date = time.strftime('% Y-% m-% d % H:% M', time.localtime(time.time()))
    if re.match('\d + 分钟前 ', date):
        minute = re.match('(\d+)', date).group(1)
        date = time.strftime('% Y-% m-% d % H:% M', time.localtime(time.time() - float(minute) * 60))
    if re.match('\d + 小时前 ', date):
        hour = re.match('(\d+)', date).group(1)
        date = time.strftime('% Y-% m-% d % H:% M', time.localtime(time.time() - float(hour) * 60 * 60))
    if re.match(' 昨天.*', date):
        date = re.match(' 昨天 (.*)', date).group(1).strip()
        date = time.strftime('% Y-% m-% d', time.localtime() - 24 * 60 * 60) + ' ' + date
    if re.match('\d{2}-\d{2}', date):
        date = time.strftime('% Y-', time.localtime()) + date + ' 00:00'
    return date

我们用正则来提取一些关键数字,用 time 库来实现标准时间的转换。

以 X 分钟前的处理为例,爬取的时间会赋值为 created_at 字段。我们首先用正则匹配这个时间,表达式写作 \d + 分钟前,如果提取到的时间符合这个表达式,那么就提取出其中的数字,这样就可以获取分钟数了。接下来使用 time 模块的 strftime() 方法,第一个参数传入要转换的时间格式,第二个参数就是时间戳。这里我们用当前的时间戳减去此分钟数乘以 60 就是当时的时间戳,这样我们就可以得到格式化后的正确时间了。

然后 Pipeline 可以实现如下处理:

class WeiboPipeline():
    def process_item(self, item, spider):
        if isinstance(item, WeiboItem):
            if item.get('created_at'):
                item['created_at'] = item['created_at'].strip()
                item['created_at'] = self.parse_time(item.get('created_at'))

我们在 Spider 里没有对 crawled_at 字段赋值,它代表爬取时间,我们可以统一将其赋值为当前时间,实现如下所示:

class TimePipeline():
    def process_item(self, item, spider):
        if isinstance(item, UserItem) or isinstance(item, WeiboItem):
            now = time.strftime('% Y-% m-% d % H:% M', time.localtime())
            item['crawled_at'] = now
        return item

这里我们判断了 item 如果是 UserItem 或 WeiboItem 类型,那么就给它的 crawled_at 字段赋值为当前时间。

通过上面的两个 Pipeline,我们便完成了数据清洗工作,这里主要是时间的转换。

数据存储

数据清洗完毕之后,我们就要将数据保存到 MongoDB 数据库。我们在这里实现 MongoPipeline 类,如下所示:

import pymongo

class MongoPipeline(object):
    def __init__(self, mongo_uri, mongo_db):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db

    @classmethod
    def from_crawler(cls, crawler):
        return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DATABASE')
        )

    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]
        self.db[UserItem.collection].create_index([('id', pymongo.ASCENDING)])
        self.db[WeiboItem.collection].create_index([('id', pymongo.ASCENDING)])

    def close_spider(self, spider):
        self.client.close()

    def process_item(self, item, spider):
        if isinstance(item, UserItem) or isinstance(item, WeiboItem):
            self.db[item.collection].update({'id': item.get('id')}, {'$set': item}, True)
        if isinstance(item, UserRelationItem):
            self.db[item.collection].update({'id': item.get('id')},
                {'$addToSet':
                    {'follows': {'$each': item['follows']},
                        'fans': {'$each': item['fans']}
                    }
                }, True)
        return item

当前的 MongoPipeline 和前面我们所写的有所不同,主要有以下几点。

  • 在 open_spider() 方法里面添加了 Collection 的索引,在这里为两个 Item 都做了索引,索引的字段是 id,由于我们这次是大规模爬取,同时在爬取过程中涉及到数据的更新问题,所以我们为每个 Collection 建立了索引,建立了索引之后可以大大提高检索效率。
  • 在 process_item() 方法里存储使用的是 update() 方法,第一个参数是查询条件,第二个参数是爬取的 Item,这里我们使用了 $set 操作符,这样我们如果爬取到了重复的数据即可对数据进行更新,同时不会删除已存在的字段,如果这里不加 $set 操作符,那么会直接进行 item 替换,这样可能会导致已存在的字段如关注和粉丝列表清空,所以这里必须要加上 $set 操作符。第三个参数我们设置为了 True,这个参数起到的作用是如果数据不存在,则插入数据。这样我们就可以做到数据存在即更新、数据不存在即插入,这样就达到了去重的效果。
  • 对于用户的关注和粉丝列表,我们在这里使用了一个新的操作符,叫做 $addToSet,这个操作符可以向列表类型的字段插入数据同时去重,接下来它的值就是需要操作的字段名称,我们在这里又利用了 $each 操作符对需要插入的列表数据进行了遍历,这样就可以逐条插入用户的关注或粉丝数据到指定的字段了,关于该操作更多的解释可以参考 MongoDB 的官方文档,链接为:https://docs.mongodb.com/manual/reference/operator/update/addToSet/

Cookies 池对接

新浪微博的反爬能力非常强,我们需要做一些防范反爬虫的措施才可以顺利完成数据爬取。

如果没有登录而直接请求微博的 API 接口,这非常容易导致 403 状态码。这个情况我们在 10.2 节也提过。所以在这里我们实现一个 Middleware,为每个 Request 添加随机的 Cookies。

我们先开启 Cookies 池,使 API 模块正常运行。例如在本地运行 5000 端口,访问:http://localhost:5000/weibo/random 即可获取随机的 Cookies,当然也可以将 Cookies 池部署到远程的服务器,这样只需要更改一下访问的链接就好了。

那么在这里我们将 Cookies 池在本地启动起来,再实现一个 Middleware 如下:


class CookiesMiddleware():
    def __init__(self, cookies_url):
        self.logger = logging.getLogger(__name__)
        self.cookies_url = cookies_url

    def get_random_cookies(self):
        try:
            response = requests.get(self.cookies_url)
            if response.status_code == 200:
                cookies = json.loads(response.text)
                return cookies
        except requests.ConnectionError:
            return False

    def process_request(self, request, spider):
        self.logger.debug(' 正在获取 Cookies')
        cookies = self.get_random_cookies()
        if cookies:
            request.cookies = cookies
            self.logger.debug(' 使用 Cookies ' + json.dumps(cookies))

    @classmethod
    def from_crawler(cls, crawler):
        settings = crawler.settings
        return cls(cookies_url=settings.get('COOKIES_URL')
        )

我们首先利用 from_crawler() 方法获取了 COOKIES_URL 变量,它定义在 settings.py 里,这就是刚才我们所说的接口。接下来实现 get_random_cookies() 方法,这个方法主要就是请求此 Cookies 池接口并获取接口返回的随机 Cookies。如果成功获取,则返回 Cookies;否则返回 False。

接下来,在 process_request() 方法里,我们给 request 对象的 cookies 属性赋值,其值就是获取的随机 Cookies,这样我们就成功地为每一次请求赋值 Cookies 了。

如果启用了该 Middleware,每个请求都会被赋值随机的 Cookies。这样我们就可以模拟登录之后的请求,403 状态码基本就不会出现。

代理池对接

微博还有一个反爬措施就是,检测到同一 IP 请求量过大时就会出现 414 状态码。如果遇到这样的情况可以切换代理。例如,在本地 5555 端口运行,获取随机可用代理的地址为:http://localhost:5555/random,访问这个接口即可获取一个随机可用代理。接下来我们再实现一个 Middleware,代码如下所示:

class ProxyMiddleware():
    def __init__(self, proxy_url):
        self.logger = logging.getLogger(__name__)
        self.proxy_url = proxy_url

    def get_random_proxy(self):
        try:
            response = requests.get(self.proxy_url)
            if response.status_code == 200:
                proxy = response.text
                return proxy
        except requests.ConnectionError:
            return False

    def process_request(self, request, spider):
        if request.meta.get('retry_times'):
            proxy = self.get_random_proxy()
            if proxy:
                uri = 'https://{proxy}'.format(proxy=proxy)
                self.logger.debug(' 使用代理 ' + proxy)
                request.meta['proxy'] = uri

    @classmethod
    def from_crawler(cls, crawler):
        settings = crawler.settings
        return cls(proxy_url=settings.get('PROXY_URL')
        )

同样的原理,我们实现了一个 get_random_proxy() 方法用于请求代理池的接口获取随机代理。如果获取成功,则返回改代理,否则返回 False。在 process_request() 方法中,我们给 request 对象的 meta 属性赋值一个 proxy 字段,该字段的值就是代理。

另外,赋值代理的判断条件是当前 retry_times 不为空,也就是说第一次请求失败之后才启用代理,因为使用代理后访问速度会慢一些。所以我们在这里设置了只有重试的时候才启用代理,否则直接请求。这样就可以保证在没有被封禁的情况下直接爬取,保证了爬取速度。

启用 Middleware

接下来,我们在配置文件中启用这两个 Middleware,修改 settings.py 如下所示:

DOWNLOADER_MIDDLEWARES = {
    'weibo.middlewares.CookiesMiddleware': 554,
    'weibo.middlewares.ProxyMiddleware': 555,
}

注意这里的优先级设置,前文提到了 Scrapy 的默认 Downloader Middleware 的设置如下:

{
    '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,
}

要使得我们自定义的 CookiesMiddleware 生效,它在内置的 CookiesMiddleware 之前调用。内置的 CookiesMiddleware 的优先级为 700,所以这里我们设置一个比 700 小的数字即可。

要使得我们自定义的 ProxyMiddleware 生效,它在内置的 HttpProxyMiddleware 之前调用。内置的 HttpProxyMiddleware 的优先级为 750,所以这里我们设置一个比 750 小的数字即可。

运行

到此为止,整个微博爬虫就实现完毕了,我们运行如下命令启动一下爬虫:

scrapy crawl weibocn

类似的输出结果如下:

2017-07-11 17:27:34 [urllib3.connectionpool] DEBUG: http://localhost:5000 "GET /weibo/random HTTP/1.1" 200 339
2017-07-11 17:27:34 [weibo.middlewares] DEBUG: 使用 Cookies {"SCF": "AhzwTr_DxIGjgri_dt46_DoPzUqq-PSupu545JdozdHYJ7HyEb4pD3pe05VpbIpVyY1ciKRRWwUgojiO3jYwlBE.", "_T_WM": "8fe0bc1dad068d09b888d8177f1c1218", "SSOLoginState": "1501496388", "M_WEIBOCN_PARAMS": "uicode%3D20000174", "SUHB": "0tKqV4asxqYl4J", "SUB": "_2A250e3QUDeRhGeBM6VYX8y7NwjiIHXVXhBxcrDV6PUJbkdBeLXjckW2fUT8MWloekO4FCWVlIYJGJdGLnA.."}
2017-07-11 17:27:34 [weibocn] DEBUG: <200 https://m.weibo.cn/api/container/getIndex?uid=1742566624&type=uid&value=1742566624&containerid=1005051742566624>
2017-07-11 17:27:34 [scrapy.core.scraper] DEBUG: Scraped from <200 https://m.weibo.cn/api/container/getIndex?uid=1742566624&type=uid&value=1742566624&containerid=1005051742566624>
{'avatar': 'https://tva4.sinaimg.cn/crop.0.0.180.180.180/67dd74e0jw1e8qgp5bmzyj2050050aa8.jpg',
 'cover': 'https://tva3.sinaimg.cn/crop.0.0.640.640.640/6ce2240djw1e9oaqhwllzj20hs0hsdir.jpg',
 'crawled_at': '2017-07-11 17:27',
 'description': ' 成长,就是一个不断觉得以前的自己是个傻逼的过程 ',
 'fans_count': 19202906,
 'follows_count': 1599,
 'gender': 'm',
 'id': 1742566624,
 'name': ' 思想聚焦 ',
 'verified': True,
 'verified_reason': ' 微博知名博主,校导网编辑 ',
 'verified_type': 0,
 'weibos_count': 58393}

运行一段时间后,我们便可以到 MongoDB 数据库查看数据,爬取下来的数据如图所示。

针对用户信息,我们不仅爬取了其基本信息,还把关注和粉丝列表加到了 follows 和 fans 字段并做了去重操作。针对微博信息,我们成功进行了时间转换处理,同时还保存了微博的图片列表信息。

本节代码地址:https://github.com/Python3WebSpider/Weibo

本节实现了新浪微博的用户及其粉丝关注列表和微博信息的爬取,还对接了 Cookies 池和代理池来处理反爬虫。不过现在是针对单机的爬取,后面我们会将此项目修改为分布式爬虫,以进一步提高抓取效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值