Scrapy 教程
在本教程中,我们假设 Scrapy 已经安装在您的系统上。如果不是,请参阅 安装指南。
我们将抓取 quotes.toscrape.com,这是一个列出著名作者名言的网站。
本教程将引导您完成以下任务:
- 创建一个新的 Scrapy 项目
- 编写一个 爬虫 来爬取网站并提取数据
- 使用命令行导出抓取的数据
- 更改爬虫以递归地跟踪链接
- 使用爬虫参数
Scrapy 是用 Python 编写的。您对 Python 了解得越多,就能从 Scrapy 中获得越多。
如果您已经熟悉其他语言并想快速学习 Python,Python 教程 是一个很好的资源。
如果您是编程新手并想从 Python 开始,以下书籍可能对您有用:
- Automate the Boring Stuff With Python
- How To Think Like a Computer Scientist
- Learn Python 3 The Hard Way
您还可以查看 这份面向非程序员的 Python 资源列表,以及 learnpython-subreddit 中 建议的资源。
创建项目
在开始抓取之前,您必须设置一个新的 Scrapy 项目。进入您希望存储代码的目录并运行:
scrapy startproject tutorial
这将创建一个名为 tutorial 的目录,其中包含以下内容:
tutorial/
scrapy.cfg # 部署配置文件
tutorial/ # 项目的 Python 模块,您将从这里导入代码
__init__.py
items.py # 项目 item 定义文件
middlewares.py # 项目中间件文件
pipelines.py # 项目管道文件
settings.py # 项目设置文件
spiders/ # 您稍后将放置爬虫的目录
__init__.py
我们的第一个爬虫
爬虫是您定义的类,Scrapy 使用它们从网站(或一组网站)抓取信息。它们必须是 Spider 的子类,并定义要进行的初始请求,以及可选地,如何跟踪页面中的链接和解析下载的页面内容以提取数据。
这是我们第一个爬虫的代码。将其保存为 quotes_spider.py 文件,放在项目的 tutorial/spiders 目录下:
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}")
如您所见,我们的爬虫是 scrapy.Spider 的子类,并定义了一些属性和方法:
name:标识爬虫。它在项目中必须是唯一的,即您不能为不同的爬虫设置相同的名称。start():必须是一个异步生成器,用于生成爬虫开始爬取的请求(以及可选地,items)。随后的请求将从这些初始请求中陆续生成。parse():一个将用于处理每个请求下载的响应的方法。response参数是TextResponse的实例,它包含页面内容并具有进一步有用的方法来处理它。parse()方法通常解析响应,将抓取的数据提取为字典,并查找要跟踪的新 URL 并从中创建新请求(Request)。
如何运行我们的爬虫
要让我们的爬虫工作,请进入项目的顶级目录并运行:
scrapy crawl quotes
此命令运行我们刚刚添加的名为 quotes 的爬虫,它将向 quotes.toscrape.com 域发送一些请求。您将获得类似于以下内容的输出:
... (为简洁起见已省略)
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.html 和 quotes-2.html,其中包含相应 URL 的内容,正如我们的 parse 方法所指示的那样。
幕后发生了什么?
Scrapy 发送由 start() 爬虫方法生成的第一个 scrapy.Request 对象。在收到每个请求的响应后,Scrapy 调用与请求关联的回调方法(在本例中为 parse 方法),并带有一个 Response 对象。
start 方法的快捷方式
除了实现一个从 URL 生成 Request 对象的 start() 方法外,您还可以定义一个 start_urls 类属性,其中包含一个 URL 列表。然后,此列表将由 start() 的默认实现用于为您的爬虫创建初始请求。
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)
parse() 方法将用于处理这些 URL 的每个请求,即使我们没有明确告诉 Scrapy 这样做。发生这种情况是因为 parse() 是 Scrapy 的默认回调方法,它在没有明确指定回调的请求时被调用。
提取数据
学习如何使用 Scrapy 提取数据的最佳方法是使用 Scrapy shell 尝试选择器。运行:
scrapy shell 'https://quotes.toscrape.com/page/1/'
& 字符)的 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 选择器,您可能会发现使用 view(response) 在您的网络浏览器中打开响应页面很有用。您可以使用浏览器的开发人员工具检查 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 中选择器对象的文本表示,您就会发现这一点。
虽然 XPath 表达式可能不像 CSS 选择器那样流行,但它们提供了更大的功能,因为除了导航结构之外,它还可以查看内容。使用 XPath,您可以选择诸如:“包含文本‘下一页’的链接”。这使得 XPath 非常适合抓取任务,我们鼓励您学习 XPath,即使您已经知道如何构建 CSS 选择器,它也会使抓取变得容易得多。
我们不会在这里过多介绍 XPath,但您可以在 此处 阅读更多关于 在 Scrapy 选择器中使用 XPath 的信息。要了解更多关于 XPath 的信息,我们推荐 这个通过示例学习 XPath 的教程,以及 这个学习“如何用 XPath 思考”的教程。
提取名言和作者
既然您对选择和提取有了一些了解,那么让我们通过编写从网页中提取名言的代码来完成我们的爬虫。
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 对象从该名言中提取文本、作者和标签:
>>> 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']}
...
在我们的爬虫中提取数据
让我们回到我们的爬虫。到目前为止,它还没有提取任何特定数据,只是将整个 HTML 页面保存到本地文件。让我们将上述提取逻辑集成到我们的爬虫中。
Scrapy 爬虫通常会生成许多包含从页面中提取的数据的字典。为此,我们使用回调中的 yield Python 关键字,如下所示:
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(),
}
要运行此爬虫,请通过输入以下命令退出 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 Pipeline 的占位符文件。尽管如果您只想存储抓取的 Item,则无需实现任何 Item Pipeline。
跟踪链接
假设您不想只从 https://quotes.toscrape.com 的前两页抓取内容,而是想抓取网站上所有页面的名言。
现在您已经知道如何从页面中提取数据,让我们看看如何从它们中跟踪链接。
首先要做的是提取我们想要跟踪的页面的链接。检查我们的页面,我们可以看到有一个指向下一页的链接,其标记如下:
<ul class="pager">
<li class="next">
<a href="/page/2/">Next <span aria-hidden="true">→</span></a>
</li>
</ul>
我们可以在 shell 中尝试提取它:
>>> response.css('li.next a').get()
'<a href="/page/2/">Next <span aria-hidden="true">→</span></a>'
这获取了锚点元素,但我们想要的是 href 属性。为此,Scrapy 支持一个 CSS 扩展,允许您选择属性内容,如下所示:
>>> response.css("li.next a::attr(href)").get()
'/page/2/'
还有一个可用的 attrib 属性(更多信息请参见 选择元素属性):
>>> response.css("li.next a").attrib["href"]
'/page/2/'
现在让我们看看我们的爬虫,它已修改为递归地跟踪到下一页的链接,并从中提取数据:
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = [
"https://quotes.toscrape.com/page/1/",
]
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)
yield scrapy.Request(next_page, callback=self.parse)
现在,在提取数据之后,parse() 方法查找指向下一页的链接,使用 urljoin() 方法构建一个完整的绝对 URL(因为链接可以是相对的),并生成一个指向下一页的新请求,将自身注册为回调以处理下一页的数据提取并使爬取继续遍历所有页面。
您在这里看到的是 Scrapy 跟踪链接的机制:当您在回调方法中生成一个 Request 时,Scrapy 将调度该请求以发送,并注册一个回调方法以在该请求完成时执行。
通过这种方式,您可以构建复杂的爬虫,根据您定义的规则跟踪链接,并根据访问的页面提取不同类型的数据。
在我们的示例中,它创建了一种循环,跟踪所有指向下一页的链接,直到找不到为止——这对于爬取博客、论坛和其他带有分页的网站非常方便。
创建请求的快捷方式
作为创建 Request 对象的快捷方式,您可以使用 response.follow:
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = [
"https://quotes.toscrape.com/page/1/",
]
def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("span small::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:
yield response.follow(next_page, callback=self.parse)
与 scrapy.Request 不同,response.follow 直接支持相对 URL - 无需调用 urljoin。请注意,response.follow 只返回一个 Request 实例;您仍然需要生成此 Request。
您还可以将选择器而不是字符串传递给 response.follow;此选择器应提取必要的属性:
for href in response.css("ul.pager a::attr(href)"):
yield response.follow(href, callback=self.parse)
对于 <a> 元素,有一个快捷方式:response.follow 自动使用它们的 href 属性。因此,代码可以进一步缩短:
for a in response.css("ul.pager a"):
yield response.follow(a, callback=self.parse)
要从可迭代对象创建多个请求,您可以使用 response.follow_all 代替:
anchors = response.css("ul.pager a")
yield from response.follow_all(anchors, callback=self.parse)
或者,进一步缩短:
yield from response.follow_all(css="ul.pager a", callback=self.parse)
更多示例和模式
这是另一个说明回调和跟踪链接的爬虫,这次用于抓取作者信息:
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"),
}
这个爬虫将从主页开始,它将跟踪所有指向作者页面的链接,为每个链接调用 parse_author 回调,以及像我们之前看到的 parse 回调的分页链接。
在这里,我们将回调作为位置参数传递给 response.follow_all,以使代码更短;它也适用于 Request。
parse_author 回调定义了一个辅助函数来提取和清理 CSS 查询中的数据,并生成带有作者数据的 Python 字典。
这个爬虫展示的另一个有趣的事情是,即使有许多来自同一作者的名言,我们也不必担心多次访问同一个作者页面。默认情况下,Scrapy 会过滤掉已访问 URL 的重复请求,从而避免因编程错误而过多地访问服务器的问题。这可以在 DUPEFILTER_CLASS 设置中配置。
希望现在您已经很好地理解了如何使用 Scrapy 的跟踪链接和回调机制。
作为利用跟踪链接机制的另一个示例爬虫,请查看 CrawlSpider 类,它是一个通用爬虫,实现了一个小型的规则引擎,您可以在其基础上编写您的爬虫。
此外,一个常见的模式是使用 将额外数据传递给回调的技巧,从多个页面构建 Item。
使用爬虫参数
您可以在运行爬虫时使用 -a 选项向您的爬虫提供命令行参数:
scrapy crawl quotes -O quotes-humor.json -a tag=humor
这些参数被传递给爬虫的 __init__ 方法,并默认成为爬虫属性。
在此示例中,为 tag 参数提供的值将通过 self.tag 可用。您可以使用它来使您的爬虫只抓取带有特定标签的名言,根据参数构建 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)
如果您将 tag=humor 参数传递给此爬虫,您会注意到它将只访问来自 humor 标签的 URL,例如 https://quotes.toscrape.com/tag/humor 。
您可以在 此处 了解更多关于处理爬虫参数的信息。
下一步
本教程仅涵盖了 Scrapy 的基础知识,但还有许多其他功能未在此处提及。请查看 “Scrapy 概览” 一章中的 “还有什么?” 部分,快速了解最重要的功能。
您可以从 基本概念 部分继续,了解更多关于命令行工具、爬虫、选择器以及本教程未涵盖的其他内容,例如抓取数据的建模。如果您更喜欢使用示例项目,请查看 示例 部分。