Scrapy 教程

本教程假设您的系统上已经安装了 Scrapy。如果还没有安装,请参阅安装指南

我们将要抓取quotes.toscrape.com,这是一个列出著名作者引言的网站。

本教程将引导您完成以下任务

  1. 创建新的 Scrapy 项目

  2. 编写一个spider 来抓取网站并提取数据

  3. 使用命令行导出抓取到的数据

  4. 修改 spider 以递归跟踪链接

  5. 使用 spider 参数

Scrapy 是用Python编写的。您对 Python 了解得越多,Scrapy 的作用就越大。

如果您已经熟悉其他语言并想快速学习 Python,Python 教程是一个很好的资源。

如果您是编程新手并想从 Python 开始,以下书籍可能对您有用

您还可以查看这份面向非程序员的 Python 资源列表,以及 learnpython-subreddit 中推荐的资源

创建项目

在开始抓取之前,您需要设置一个新的 Scrapy 项目。进入您想要存储代码的目录并运行

scrapy startproject tutorial

这将创建一个名为tutorial 的目录,其内容如下

tutorial/
    scrapy.cfg            # deploy configuration file

    tutorial/             # project's Python module, you'll import your code from here
        __init__.py

        items.py          # project items definition file

        middlewares.py    # project middlewares file

        pipelines.py      # project pipelines file

        settings.py       # project settings file

        spiders/          # a directory where you'll later put your spiders
            __init__.py

我们的第一个 Spider

Spider 是您定义的类,Scrapy 使用它们从网站(或一组网站)抓取信息。它们必须继承自Spider,并定义初始请求,以及(可选地)如何跟踪页面中的链接和解析下载的页面内容以提取数据。

这是我们第一个 Spider 的代码。将其保存在项目目录 tutorial/spiders 下名为 quotes_spider.py 的文件中

from pathlib import Path

import scrapy


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

    async def start(self):
        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):
        page = response.url.split("/")[-2]
        filename = f"quotes-{page}.html"
        Path(filename).write_bytes(response.body)
        self.log(f"Saved file {filename}")

正如您所看到的,我们的 Spider 继承自scrapy.Spider,并定义了一些属性和方法

  • name:用于标识 Spider。它在项目内必须是唯一的,也就是说,您不能为不同的 Spider 设置相同的名称。

  • start():必须是一个异步生成器,用于产生供 spider 开始抓取的请求(以及可选的 item)。后续请求将从这些初始请求中连续生成。

  • parse():一个将被调用的方法,用于处理每个请求下载的响应。response 参数是TextResponse 的一个实例,它包含页面内容并具有其他有用的方法来处理它。

    parse() 方法通常会解析响应,将抓取到的数据提取为字典,并找到要跟踪的新 URL 并从中创建新的请求(Request)。

如何运行我们的 spider

要运行我们的 spider,请进入项目的顶层目录并运行

scrapy crawl quotes

此命令会运行我们刚刚添加的名为 quotes 的 spider,它将向 quotes.toscrape.com 域发送一些请求。您将获得类似于以下的输出

... (omitted for brevity)
2016-12-16 21:24:05 [scrapy.core.engine] INFO: Spider opened
2016-12-16 21:24:05 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2016-12-16 21:24:05 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2016-12-16 21:24:05 [scrapy.core.engine] DEBUG: Crawled (404) <GET https://quotes.toscrape.com/robots.txt> (referer: None)
2016-12-16 21:24:05 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://quotes.toscrape.com/page/1/> (referer: None)
2016-12-16 21:24:05 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://quotes.toscrape.com/page/2/> (referer: None)
2016-12-16 21:24:05 [quotes] DEBUG: Saved file quotes-1.html
2016-12-16 21:24:05 [quotes] DEBUG: Saved file quotes-2.html
2016-12-16 21:24:05 [scrapy.core.engine] INFO: Closing spider (finished)
...

现在,检查当前目录中的文件。您应该注意到已创建了两个新文件:quotes-1.htmlquotes-2.html,包含相应 URL 的内容,正如我们的 parse 方法所指示的。

注意

如果您想知道为什么我们还没有解析 HTML,请稍等,我们很快就会介绍。

幕后发生了什么?

Scrapy 发送由 start() spider 方法产生的第一个 scrapy.Request 对象。在收到每个请求的响应后,Scrapy 会调用与请求关联的回调方法(在本例中是 parse 方法),并传入一个 Response 对象。

start 方法的快捷方式

您可以不实现一个产生来自 URL 的 Request 对象的 start() 方法,而是定义一个带有 URL 列表的 start_urls 类属性。然后,这个列表将由 start() 的默认实现用来创建您的 spider 的初始请求。

from pathlib import Path

import scrapy


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

    def parse(self, response):
        page = response.url.split("/")[-2]
        filename = f"quotes-{page}.html"
        Path(filename).write_bytes(response.body)

即使我们没有明确告诉 Scrapy 这样做,parse() 方法也会被调用来处理这些 URL 的每个请求。发生这种情况是因为 parse() 是 Scrapy 的默认回调方法,它会被用于没有明确指定回调的请求。

提取数据

学习如何使用 Scrapy 提取数据的最好方法是使用Scrapy shell尝试选择器。运行

scrapy shell 'https://quotes.toscrape.com/page/1/'

注意

请记住,从命令行运行 Scrapy shell 时,务必将 URL 用引号括起来,否则包含参数(即 & 字符)的 URL 将无法工作。

在 Windows 上,请使用双引号

scrapy shell "https://quotes.toscrape.com/page/1/"

您将看到类似以下的内容

[ ... Scrapy log here ... ]
2016-09-19 12:09:27 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://quotes.toscrape.com/page/1/> (referer: None)
[s] Available Scrapy objects:
[s]   scrapy     scrapy module (contains scrapy.Request, scrapy.Selector, etc)
[s]   crawler    <scrapy.crawler.Crawler object at 0x7fa91d888c90>
[s]   item       {}
[s]   request    <GET https://quotes.toscrape.com/page/1/>
[s]   response   <200 https://quotes.toscrape.com/page/1/>
[s]   settings   <scrapy.settings.Settings object at 0x7fa91d888c10>
[s]   spider     <DefaultSpider 'default' at 0x7fa91c8af990>
[s] Useful shortcuts:
[s]   shelp()           Shell help (print this help)
[s]   fetch(req_or_url) Fetch request (or URL) and update local objects
[s]   view(response)    View response in a browser

使用 shell,您可以使用响应对象尝试使用CSS选择元素

>>> response.css("title")
[<Selector query='descendant-or-self::title' data='<title>Quotes to Scrape</title>'>]

运行 response.css('title') 的结果是一个类似列表的对象,称为 SelectorList,它代表一个 Selector 对象的列表,这些对象包装了 XML/HTML 元素,并允许您运行进一步的查询来细化选择或提取数据。

要提取上面标题的文本,您可以这样做

>>> response.css("title::text").getall()
['Quotes to Scrape']

这里有两点需要注意:一是我们在 CSS 查询中添加了 ::text,这意味着我们只想选择 <title> 元素内部的文本元素。如果我们不指定 ::text,我们将获得完整的标题元素,包括其标签

>>> response.css("title").getall()
['<title>Quotes to Scrape</title>']

另一点是调用 .getall() 的结果是一个列表:选择器可能返回多个结果,因此我们提取所有结果。当您知道只需要第一个结果时(就像在本例中),您可以这样做

>>> response.css("title::text").get()
'Quotes to Scrape'

作为替代方案,您可以这样写

>>> response.css("title::text")[0].get()
'Quotes to Scrape'

访问 SelectorList 实例上的索引,如果没有结果,将引发 IndexError 异常

>>> response.css("noelement")[0].get()
Traceback (most recent call last):
...
IndexError: list index out of range

您可能希望直接在 SelectorList 实例上使用 .get(),这样在没有结果时会返回 None

>>> response.css("noelement").get()

这里的经验是:对于大多数抓取代码,您希望它对页面上找不到内容导致的错误具有弹性,这样即使某些部分抓取失败,您至少也能获得部分数据。

除了 getall()get() 方法外,您还可以使用 re() 方法使用正则表达式进行提取

>>> response.css("title::text").re(r"Quotes.*")
['Quotes to Scrape']
>>> response.css("title::text").re(r"Q\w+")
['Quotes']
>>> response.css("title::text").re(r"(\w+) to (\w+)")
['Quotes', 'Scrape']

为了找到合适的 CSS 选择器,您可能会发现在 shell 中使用 view(response) 在 Web 浏览器中打开响应页面很有用。您可以使用浏览器的开发者工具检查 HTML 并找出选择器(参阅使用浏览器的开发者工具进行抓取)。

Selector Gadget 也是一个很好的工具,可以快速查找视觉选择元素的 CSS 选择器,它在许多浏览器中都能工作。

XPath:简介

除了CSS,Scrapy 选择器还支持使用XPath表达式

>>> response.xpath("//title")
[<Selector query='//title' data='<title>Quotes to Scrape</title>'>]
>>> response.xpath("//title/text()").get()
'Quotes to Scrape'

XPath 表达式非常强大,是 Scrapy 选择器的基础。实际上,CSS 选择器在底层会被转换为 XPath。如果您仔细阅读 shell 中选择器对象的文本表示,就可以看到这一点。

虽然可能不如 CSS 选择器流行,但 XPath 表达式提供了更多功能,因为它除了导航结构外,还可以查看内容。使用 XPath,您可以选择诸如:包含文本“下一页”的链接之类的内容。这使得 XPath 非常适合抓取任务,我们鼓励您即使已经知道如何构建 CSS 选择器,也学习 XPath,这将使抓取变得容易得多。

我们在这里不会过多介绍 XPath,但您可以在此处阅读更多关于将 XPath 与 Scrapy 选择器一起使用的内容。要了解更多关于 XPath 的信息,我们推荐这个通过示例学习 XPath 的教程,以及这个学习“如何用 XPath 思考”的教程

提取引言和作者

现在您对选择和提取有了一些了解,让我们通过编写代码从网页中提取引言来完成我们的 spider。

https://quotes.toscrape.com 中的每个引言都由看起来像这样的 HTML 元素表示

<div class="quote">
    <span class="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">Albert Einstein</small>
        <a href="/author/Albert-Einstein">(about)</a>
    </span>
    <div class="tags">
        Tags:
        <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 并尝试一下,找出如何提取我们需要的数据

scrapy shell 'https://quotes.toscrape.com'

我们使用以下方法获取引言 HTML 元素的选择器列表

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

上面查询返回的每个选择器都允许我们对其子元素运行进一步的查询。让我们将第一个选择器分配给一个变量,以便我们可以直接在特定的引言上运行我们的 CSS 选择器

>>> quote = response.css("div.quote")[0]

现在,让我们使用刚刚创建的 quote 对象提取该引言的 textauthortags

>>> text = quote.css("span.text::text").get()
>>> text
'“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”'
>>> author = quote.css("small.author::text").get()
>>> author
'Albert Einstein'

鉴于标签是一个字符串列表,我们可以使用 .getall() 方法获取所有标签

>>> tags = quote.css("div.tags a.tag::text").getall()
>>> tags
['change', 'deep-thoughts', 'thinking', 'world']

弄清楚如何提取每个部分后,我们现在可以遍历所有引言元素并将它们组合成一个 Python 字典

>>> 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']}
...

在我们的 spider 中提取数据

让我们回到我们的 spider。到目前为止,它还没有特别提取任何数据,只是将整个 HTML 页面保存到本地文件。让我们将上面的提取逻辑集成到我们的 spider 中。

Scrapy spider 通常会生成许多包含从页面提取的数据的字典。为此,我们在回调中使用 Python 关键字 yield,如下所示

import scrapy


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

    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(),
            }

要运行此 spider,请通过输入以下内容退出 scrapy shell

quit()

然后,运行

scrapy crawl quotes

现在,它应该会输出提取的数据以及日志

2016-09-19 18:57:19 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/page/1/>
{'tags': ['life', 'love'], 'author': 'André Gide', 'text': '“It is better to be hated for what you are than to be loved for what you are not.”'}
2016-09-19 18:57:19 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/page/1/>
{'tags': ['edison', 'failure', 'inspirational', 'paraphrased'], 'author': 'Thomas A. Edison', 'text': "“I have not failed. I've just found 10,000 ways that won't work.”"}

存储抓取到的数据

存储抓取到的数据的最简单方法是使用Feed 导出,使用以下命令

scrapy crawl quotes -O quotes.json

这将生成一个 quotes.json 文件,其中包含所有抓取到的 item,以JSON格式序列化。

命令行选项 -O 会覆盖任何现有文件;请改用 -o 将新内容追加到现有文件中。但是,追加到 JSON 文件会使文件内容无效 JSON。追加文件时,请考虑使用不同的序列化格式,例如JSON Lines

scrapy crawl quotes -o quotes.jsonl

JSON Lines 格式很有用,因为它像流一样,因此您可以轻松地向其中追加新记录。当您运行两次时,它不会像 JSON 那样出现问题。此外,由于每个记录都是单独一行,您可以处理大文件而无需将所有内容都放入内存,有一些工具如JQ可以在命令行中帮助完成此操作。

在小型项目(例如本教程中的项目)中,这应该足够了。但是,如果您想对抓取到的 item 执行更复杂的操作,您可以编写一个Item Pipeline。创建项目时,已经在 tutorial/pipelines.py 中为您设置了一个 Item Pipelines 的占位文件。尽管如果您只想存储抓取到的 item,则无需实现任何 Item Pipeline。

使用 spider 参数

您可以在运行 spider 时使用 -a 选项为它们提供命令行参数

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

这些参数默认会传递给 Spider 的 __init__ 方法,并成为 spider 的属性。

在此示例中,为 tag 参数提供的值将通过 self.tag 获取。您可以使用此方法让您的 spider 仅抓取具有特定标签的引言,并根据参数构建 URL

import scrapy


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

    async def start(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)

如果您向此 spider 传递 tag=humor 参数,您会注意到它将仅访问来自 humor 标签的 URL,例如 https://quotes.toscrape.com/tag/humor

您可以在此处了解更多关于处理 spider 参数的信息

下一步

本教程仅涵盖了 Scrapy 的基础知识,但还有许多此处未提及的其他功能。请查看还有哪些?部分在Scrapy 概览章节中,快速了解最重要的功能。

您可以从基本概念部分继续学习,了解更多关于命令行工具、spiders、选择器以及本教程未涵盖的其他内容,例如抓取数据的建模。如果您喜欢使用示例项目,请查看示例部分。