Scrapy (/ˈskreɪpaɪ/) 是一个爬取网页并提取结构化数据的应用框架,使用 Python 编写。官方文档:Scrapy at a glance — Scrapy 2.11.1 documentation 。网上有许多教程,爬虫做是做出来了,但其中的原理可能讲得还不够清晰。我认为阅读官方文档将更有助于理解。
文章内容主要基于官方文档第一章 FIRST STEPS 进行补充。本文将通过爬取英文名人名言网站 https://quotes.toscrape.com 的例子初步理解 Scrapy,学会自己编写一个简单的爬虫。
框架:是一个半成品软件,是一套可重用的、通用的、软件基础代码模型。基于框架进行开发,更加快捷、更加高效。
安装
可以使用 pip 进行安装:
推荐单独为 Scrapy 创建一个 Python 虚拟环境(venv),不管是在哪个平台,以避免和系统中的其它包产生冲突。
创建 Scrapy 项目
在你想要创建 Scrapy 项目的位置执行以下命令:
1 scrapy startproject tutorial
成功效果如图:
这时将会多出一个名为 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 Pathimport scrapyclass 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} " )
回到项目的顶层目录,执行以下命令运行我们刚刚创建的爬虫:
项目下多出以下两个文件,这两个文件就是两个网站的 HTML 数据。我们将在下一小节实现对数据的提取。
让我们看看执行这个命令发生了什么?
start_requests()
方法返回 scrapy.Request
类供 Scrapy 进行调度
收到每一个 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 Pathimport scrapyclass 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-充分利用浏览器中的开发者工具 )。
使用 CSS 选择器
按照官方文档中的教程,我们可以使用一个工具 Scrapy shell 对爬取的页面进行分析从而选择我们需要提取的数据。
在终端执行命令,我们会进入一个交互程序:
1 2 scrapy shell 'https://quotes.toscrape.com/page/1/'
输入 quit()
可退出 Scrapy shell
我们可以 CSS 选择器选择 response 的元素。比如:response.css("title")
上图显示,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>' >] 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>' ] In [4 ]: response.css("title::text" ).get() Out[4 ]: 'Quotes to Scrape' 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(), }
终端运行以下命令:
你可以在终端输出的日志中读到爬取的内容:
存储抓取的数据
最简单的方式为 Feed exports:
1 2 3 4 scrapy crawl quotes -O quotes.json
我们可以指定输出文件的格式。上例为 JSON,但是如果我们追加输出到已经存在的文件时会导致生成无效的 json 文件。为此我们可以使用 JSON Lines 格式:
1 scrapy crawl quotes -o quotes.jsonlines
使用 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>' In [2 ]: response.css("li.next a::attr(href)" ).get() Out[2 ]: '/page/2/' 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 scrapyclass 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) yield scrapy.Request(next_page, callback=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))
我们还可以对上面的代码进行简化。我们将最后四行代码(从 next_page 开始)简化为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 next_page = response.css("li.next a::attr(href)" ).get() if next_page is not None : yield response.follow(next_page, callback=self.parse) for href in response.css("ul.pager a::attr(href)" ): yield response.follow(href, callback=self.parse) for a in response.css("ul.pager a" ): yield response.follow(a, callback=self.parse) 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 import scrapyclass 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 scrapyclass 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 scrapyclass 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 。
本文参考