Scrapy (/ˈskreɪpaɪ/) 是一个爬取网页并提取结构化数据的应用框架,使用 Python 编写。官方文档:Scrapy at a glance — Scrapy 2.11.1 documentation。网上有许多教程,爬虫做是做出来了,但其中的原理可能讲得还不够清晰。我认为阅读官方文档将更有助于理解。

文章内容主要基于官方文档第一章 FIRST STEPS 进行补充。本文将通过爬取英文名人名言网站 https://quotes.toscrape.com 的例子初步理解 Scrapy,学会自己编写一个简单的爬虫。

框架:是一个半成品软件,是一套可重用的、通用的、软件基础代码模型。基于框架进行开发,更加快捷、更加高效。

安装

可以使用 pip 进行安装:

1
pip install Scrapy

推荐单独为 Scrapy 创建一个 Python 虚拟环境(venv),不管是在哪个平台,以避免和系统中的其它包产生冲突。

创建 Scrapy 项目

在你想要创建 Scrapy 项目的位置执行以下命令:

1
scrapy startproject tutorial # tutorial 改为你想要的项目名字

成功效果如图:

image.png

这时将会多出一个名为 tutorial,其内容如下:

1
2
3
4
5
6
7
8
9
10
.
│ scrapy.cfg # 部署参数文件
└───tutorial # 项目的Python模块,你将从这里导入你的代码
│ items.py # 项目中定义 items 的文件
│ middlewares.py # 项目middlewares文件
│ pipelines.py # 项目pipelines文件
│ settings.py # 项目设置文件
│ __init__.py
└───spiders # 存放爬虫的文件夹
__init__.py

我们需要定义一个类(这个类必须是 scrapy.Spider 的子类),Scrapy 使用这个类的信息去爬取网站。这个类定义了:

  • 爬取的第一个网站
  • 【可选】如何继续生成下一个需要爬取的网页
  • 【可选】如何解析下载下来的页面并提取信息

我们可以在文件夹 tutorial/spiders 下创建我们第一个爬虫,文件名为 quotes_spider.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pathlib import Path

import scrapy

class QuotesSpider(scrapy.Spider):
name = "quotes" # 定义标识这个爬虫的名字,必须在整个项目中唯一

def start_requests(self):
"""
定义爬虫从哪里开始爬取。
必须返回一个Requests类的可迭代对象(即一个request的列表或者写一个生成器函数,本示例为后者)。后续的请求将从这些初始请求依次生成。
"""
urls = [
"https://quotes.toscrape.com/page/1/",
"https://quotes.toscrape.com/page/2/",
]
for url in urls:
yield scrapy.Request(url=url, callback=self.parse)

def parse(self, response):
"""
用于处理每一个request对应的response的方法。
参数response是TextResponse的一个实例,它包含下载下来的页面的内容以及有用的处理函数。
parse()通常用于解析response,提取其中的数据形成字典(dict),然后提取下一个将要被爬取的URL链接并从中创建新的Request请求。
"""
page = response.url.split("/")[-2]
filename = f"quotes-{page}.html"
Path(filename).write_bytes(response.body)
self.log(f"Saved file {filename}")

回到项目的顶层目录,执行以下命令运行我们刚刚创建的爬虫:

1
scrapy crawl quotes

image.png

项目下多出以下两个文件,这两个文件就是两个网站的 HTML 数据。我们将在下一小节实现对数据的提取。

image.png

让我们看看执行这个命令发生了什么?

  1. start_requests() 方法返回 scrapy.Request 类供 Scrapy 进行调度
  2. 收到每一个 Request 的回复(response)后,Scrapy 实例化 Response,并调用对应 Request 的回调函数(在这个例子中回调函数为 parse()),将 Response 作为回调函数中的参数。

上面的代码我们还可以进行简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pathlib import Path

import scrapy

class QuotesSpider(scrapy.Spider):
name = "quotes"
# 我们可以直接定义全局变量start_urls,不用编写start_requests()函数
start_urls = [
"https://quotes.toscrape.com/page/1/",
"https://quotes.toscrape.com/page/2/",
]
# 虽然这里我们没有指明每一个request产生的response如何处理,但Scrapy默认parse()就是它们的回调函数。
def parse(self, response):
page = response.url.split("/")[-2]
filename = f"quotes-{page}.html"
Path(filename).write_bytes(response.body)

提取数据的方法

如果你掌握浏览器开发者工具的使用,我们可以很轻松的完成这件事情(详看 Scrapy-充分利用浏览器中的开发者工具)。

使用 CSS 选择器

按照官方文档中的教程,我们可以使用一个工具 Scrapy shell 对爬取的页面进行分析从而选择我们需要提取的数据。

在终端执行命令,我们会进入一个交互程序:

1
2
scrapy shell 'https://quotes.toscrape.com/page/1/' # windows中可以使用双引号
# 注意链接使用引号括住,否则在链接包含参数(即链接中存在符号`&`)的情况下命令会不起作用。

输入 quit() 可退出 Scrapy shell

我们可以 CSS 选择器选择 response 的元素。比如:response.css("title")

image.png

上图显示,CSS 选择器查询结果为 Selector 列表,SelectorList。这些选择器允许你进行更深入地查询或提取数据。

你可以继续进行交互:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
In [1]: response.css("title")
Out[1]: [<Selector xpath='descendant-or-self::title' data='<title>Quotes to Scrape</title>'>]

# 加上::text表明我们只选择标签内的文本。getall()方法将返回一个列表,包含所有可能的结果。
In [2]: response.css("title::text").getall()
Out[2]: ['Quotes to Scrape']

# 不加上的话我们将得到包含标签在内的内容
In [3]: response.css("title").getall()
Out[3]: ['<title>Quotes to Scrape</title>']

# get() 直接返回第一个结果。若选择器无结果返回None。
In [4]: response.css("title::text").get()
Out[4]: 'Quotes to Scrape'

# 除此之外还可以这样写。但是如果css没有结果将报错 IndexError: list index out of range
In [5]: response.css("title::text")[0].get()
Out[5]: 'Quotes to Scrape'

# 我们还可以使用正则表达式
In [6]: response.css("title::text").re(r"(\w+) to (\w+)")
Out[6]: ['Quotes', 'Scrape']

如果你想直接打开爬取到的页面,输入以下命令后页面将从浏览器中被打开方便你使用浏览器自带的调试工具找出正确的 CSS 选择器。

1
2
In [7]: view(response)
Out[7]: True

使用 XPath

Scrapy 支持 XPath 表达式。

1
2
3
4
5
In [8]: response.xpath("//title")
Out[8]: [<Selector xpath='//title' data='<title>Quotes to Scrape</title>'>]

In [9]: response.xpath("//title/text()").get()
Out[9]: 'Quotes to Scrape'

在 Scrapy 中,推荐使用 XPath 表达。

实际上,Scrapy 处理 CSS 选择器最终还是会转换成 XPath 表达。你可以在 shell 中发现这个过程。

关于提取的方法

不管是 css 选择器还是 XPath 表达,都可以使用以下方法提取我们需要的内容:

方法 描述
extract() 返回的是符合要求的所有的数据,存在一个列表里。
extract_first() 返回的 hrefs 列表里的第一个数据。
get() 和 extract_first() 方法返回的是一样的,都是列表里的第一个数据。
getall() 和 extract() 方法一样,返回的都是符合要求的所有的数据,存在一个列表里。

注意:

  • get() 、getall() 方法是新的方法,取不到就 raise 一个错误。
  • extract() 、extract_first() 方法是旧的方法,取不到就返回 None。

本文按照官方文档示例继续使用旧的 get、getall 方法

爬虫中编写提取数据的逻辑

在目标网站 https://quotes.toscrape.com 中,每一句名人名言的 HTML 基本结构如下(以某一句名言为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="quote" itemscope="" itemtype="http://schema.org/CreativeWork">
<span class="text" itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span>
<span>by <small class="author" itemprop="author">Albert Einstein</small>
<a href="/author/Albert-Einstein">(about)</a>
</span>
<div class="tags">
Tags:
<meta class="keywords" itemprop="keywords" content="change,deep-thoughts,thinking,world">
<a class="tag" href="/tag/change/page/1/">change</a>
<a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>
<a class="tag" href="/tag/thinking/page/1/">thinking</a>
<a class="tag" href="/tag/world/page/1/">world</a>
</div>
</div>

我们可以在 Scrapy shell 中一步步找出我们想要的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
scrapy shell 'https://quotes.toscrape.com'

In [1]: response.css("div.quote")
Out[1]:
[<Selector xpath="descendant-or-self::div[@class and contains(concat(' ', normalize-space(@class), ' '), ' quote ')]" data='<div class="quote" itemscope itemtype...'>,
......

# 定位名言
In [2]: quote = response.css("div.quote")[0]

# 定位并验证名言文本
In [3]: text = quote.css("span.text::text").get()
In [4]: text
Out[4]: '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”'

# 定位并验证名言作者
In [5]: author = quote.css("small.author::text").get()
In [6]: author
Out[6]: 'Albert Einstein'

# 定位并验证名言标签
In [7]: tags = quote.css("div.tags a.tag::text").getall()
In [8]: tags
Out[8]: ['change', 'deep-thoughts', 'thinking', 'world']

# 尝试编写一段这样的逻辑,验证一下我们上面定位是否都准确
In [13]: for quote in response.css("div.quote"):
...: text = quote.css("span.text::text").get()
...: author = quote.css("small.author::text").get()
...: tags = quote.css("div.tags a.tag::text").getall()
...: print(dict(text=text, author=author, tags=tags))
...:
{'text': '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”', 'author': 'Albert Einstein', 'tags': ['change', 'deep-thoughts', 'thinking', 'world']}
{'text': '“It is our choices, Harry, that show what we truly are, far more than our abilities.”', 'author': 'J.K. Rowling', 'tags': ['abilities', 'choices']}
......

验证无误后,我们将这段代码写入爬虫的 parse() 中:

1
2
3
4
5
6
7
def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
"tags": quote.css("div.tags a.tag::text").getall(),
}

终端运行以下命令:

1
scrapy crawl quotes # 还记得吗?quotes是我们编写的爬虫的名字

你可以在终端输出的日志中读到爬取的内容:

image.png

存储抓取的数据

最简单的方式为 Feed exports:

1
2
3
4
scrapy crawl quotes -O quotes.json
# quotes 为爬虫的名字
# -O (大写字母O) 表示将覆写已经存在的同名文件;-o 则是将内容附加在已经存在的文件
# quotes.json为输出的文件名

我们可以指定输出文件的格式。上例为 JSON,但是如果我们追加输出到已经存在的文件时会导致生成无效的 json 文件。为此我们可以使用 JSON Lines 格式:

1
scrapy crawl quotes -o quotes.jsonlines # 亲测jsonl会报错

image.png

使用 Scrapy 爬取中文数据时的注意事项

如果爬取数据为中文,存储到文件可能是一堆 unicode 编码。我们可以在 setting.py 中增加:FEED_EXPORT_ENCODING = 'utf-8' 即可

递归翻页爬取

目前我们编写的爬虫是从给定的链接中进行爬取的:

1
2
"https://quotes.toscrape.com/page/1/",
"https://quotes.toscrape.com/page/2/"

我们可不可以只给出网站首页,然后爬虫自动解析出下一页的链接持续爬取数据呢?

我们可以先定位「下一页」的链接在哪里:

1
2
3
4
5
6
7
<nav>
<ul class="pager">
<li class="next">
<a href="/page/2/">Next <span aria-hidden="true"></span></a>
</li>
</ul>
</nav>

使用 Scrapy shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
scrapy shell 'https://quotes.toscrape.com'

# 定位链接
In [1]: response.css('li.next a').get()
Out[1]: '<a href="/page/2/">Next <span aria-hidden="true">→</span></a>'

# 使用CSS的扩展,::attr(href)提取文字
In [2]: response.css("li.next a::attr(href)").get()
Out[2]: '/page/2/'

# 我们也可以使用attrib方法
In [3]: response.css("li.next a").attrib["href"]
Out[3]: '/page/2/'

因此,我们的爬虫可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import scrapy


class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = [
"https://quotes.toscrape.com",
]

def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
"tags": quote.css("div.tags a.tag::text").getall(),
}
# 当第一个页面遍历完毕后,尝试获取第二个页面的链接
next_page = response.css("li.next a::attr(href)").get()
if next_page is not None:
next_page = response.urljoin(next_page)
# 新页面的回调函数依然是parse()
yield scrapy.Request(next_page, callback=self.parse)
# 这里我们yeild了一个scrapy.Request类,Scrapy会调度这个请求,并为这个请求安排回调方法(这里指的是self.parse)

关于 urljoin() 的测试:

1
2
3
4
5
6
7
8
web1 = "https://quotes.toscrape.com"
web2 = "https://quotes.toscrape.com/page/1/"
web3 = "https://quotes.toscrape.com/a/b/c/d/"
href = "/page/2/"
print(urllib.parse.urljoin(web1, href))
print(urllib.parse.urljoin(web2, href))
print(urllib.parse.urljoin(web3, href))
# 结果都是https://quotes.toscrape.com/page/2/

我们还可以对上面的代码进行简化。我们将最后四行代码(从 next_page 开始)简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 方法 1
# follow 省去了对urljoin的调用,并直接返回Request的实例
next_page = response.css("li.next a::attr(href)").get()
if next_page is not None:
yield response.follow(next_page, callback=self.parse)

# 方法 2
# 直接向follow传入css选择器
for href in response.css("ul.pager a::attr(href)"):
yield response.follow(href, callback=self.parse)

# 方法 3
# 对于<a>标签,follow可以直接提取其href属性
for a in response.css("ul.pager a"):
yield response.follow(a, callback=self.parse)

# 方法 4
# 我们可以直接使用follow_all把选择器全部传入,省去for
anchors = response.css("ul.pager a")
yield from response.follow_all(anchors, callback=self.parse)

有一些网站不存在翻页,而是采用无限滚动的方法实现更多内容的浏览(比如这个网站:Quotes to Scrape)。在部分简单的情况下,爬取方法可以参考 Scrapy-充分利用浏览器中的开发者工具 这篇文章。

这里附一个XPath版本的爬虫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# -*- coding: utf-8 -*-
import scrapy

class ToScrapeSpiderXPath(scrapy.Spider):
name = 'toscrape-xpath'
start_urls = [
'http://quotes.toscrape.com/',
]

def parse(self, response):
for quote in response.xpath('//div[@class="quote"]'):
yield {
'text': quote.xpath('./span[@class="text"]/text()').extract_first(),
'author': quote.xpath('.//small[@class="author"]/text()').extract_first(),
'tags': quote.xpath('.//div[@class="tags"]/a[@class="tag"]/text()').extract()
}

next_page_url = response.xpath('//li[@class="next"]/a/@href').extract_first()
if next_page_url is not None:
yield scrapy.Request(response.urljoin(next_page_url))

一个简单的递归爬取名人名言的小爬虫到这里就结束了。继续阅读将学习更多内容。

【案例练习】爬取作者信息

以下这个例子将爬取作者信息。我们可以新建一个爬虫 author_spider.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import scrapy

class AuthorSpider(scrapy.Spider):
name = "author"

start_urls = ["https://quotes.toscrape.com/"]

def parse(self, response):
author_page_links = response.css(".author + a") # 相邻兄弟选择器 +
yield from response.follow_all(author_page_links, self.parse_author)

pagination_links = response.css("li.next a")
yield from response.follow_all(pagination_links, self.parse)

def parse_author(self, response):
def extract_with_css(query):
return response.css(query).get(default="").strip()

yield {
"name": extract_with_css("h3.author-title::text"),
"birthdate": extract_with_css(".author-born-date::text"),
"bio": extract_with_css(".author-description::text"),
}

执行命令:

1
scrapy crawl author -O author.jsonlines

值得一提的是,尽管一些名言都指向同一个作者页面,Scrapy 会检查这些重复链接并避免多次访问服务器。

【进阶小知识】使用爬虫参数

我们可以使用 -a 选项向爬虫提供参数。这个参数会直接传入爬虫的 __init__ 方法,使之成为爬虫的属性。比如在这个例子中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import scrapy

class QuotesSpider(scrapy.Spider):
name = "quotes"

def start_requests(self):
url = "https://quotes.toscrape.com/"
tag = getattr(self, "tag", None) # 关键
if tag is not None:
url = url + "tag/" + tag
yield scrapy.Request(url, self.parse)

def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
}

next_page = response.css("li.next a::attr(href)").get()
if next_page is not None:
yield response.follow(next_page, self.parse)

执行:

1
scrapy crawl quotes -O quotes-humor.json -a tag=humor

爬虫只会浏览 humor 标签的网页,即 https://quotes.toscrape.com/tag/humor

本文参考