接下来介绍一个简单的项目,完成一遍Scrapy抓取流程。通过这个过程,我们可以对Scrapy的基本用法和原理有大体了解。
一、准备工作
本节要完成的任务如下。
- 创建一个Scrapy项目。
- 创建一个Spider来抓取站点和处理数据。
- 通过命令行将抓取的内容导出。
- 将抓取的内容保存的到MongoDB数据库。
二、准备工作
我们需要安装好Scrapy框架、MongoDB和PyMongo库。
三、创建项目
创建一个Scrapy项目,项目文件可以直接用scrapy
命令生成,命令如下所示:
scrapy startproject tutorial
这个命令可以在任意文件夹运行。如果提示权限问题,可以加sudo运行该命令。这个命令将会创建一个名为tutorial的文件夹,文件夹结构如下所示:
代码语言:javascript复制scrapy.cfg # Scrapy部署时的配置文件
tutorial # 项目的模块,需要从这里引入
__init__.py
items.py # Items的定义,定义爬取的数据结构
middlewares.py # Middlewares的定义,定义爬取时的中间件
pipelines.py # Pipelines的定义,定义数据管道
settings.py # 配置文件
spiders # 放置Spiders的文件夹
__init__.py
四、创建Spider
Spider是自己定义的Class,Scrapy用它来从网页里抓取内容,并解析抓取的结果。不过这个Class必须继承Scrapy提供的Spider类scrapy.Spider
,还要定义Spider的名称和起始请求,以及怎样处理爬取后的结果的方法。
也可以使用命令行创建一个Spider。比如要生成Quotes这个Spider,可以执行如下命令:
代码语言:javascript复制cd tutorial
scrapy genspider quotes
进入刚才创建的tutorial文件夹,然后执行genspider
命令。第一个参数是Spider的名称,第二个参数是网站域名。执行完毕之后,spiders文件夹中多了一个quotes.py,它就是刚刚创建的Spider,内容如下所示:
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['http://quotes.toscrape.com/']
def parse(self, response):
pass
这里有三个属性——name
、allowed_domains
和start_urls
,还有一个方法parse
。
name
,它是每个项目唯一的名字,用来区分不同的Spider。allowed_domains
,它是允许爬取的域名,如果初始或后续的请求链接不是这个域名下的,则请求链接会被过滤掉。start_urls
,它包含了Spider在启动时爬取的url列表,初始请求是由它来定义的。parse
,它是Spider的一个方法。默认情况下,被调用时start_urls
里面的链接构成的请求完成下载执行后,返回的响应就会作为唯一的参数传递给这个函数。该方法负责解析返回的响应、提取数据或者进一步生成要处理的请求。
五、创建Item
Item是保存爬取数据的容器,它的使用方法和字典类似。不过,相比字典,Item多了额外的保护机制,可以避免拼写错误或者定义字段错误。
创建Item需要继承scrapy.Item
类,并且定义类型为scrapy.Field
的字段。观察目标网站,我们可以获取到到内容有text
、author
、tags
。
定义Item,此时将items.py修改如下:
代码语言:javascript复制import scrapy
class QuoteItem(scrapy.Item):
text = scrapy.Field()
author = scrapy.Field()
tags = scrapy.Field()
这里定义了三个字段,接下来爬取时我们会使用到这个Item。
六、解析Response
上文中我们看到,parse()
方法的参数resposne
是start_urls
里面的链接爬取后的结果。所以在parse
方法中,我们可以直接对response
变量包含的内容进行解析,比如浏览请求结果的网页源代码,或者进一步分析源代码内容,或者找出结果中的链接而得到下一个请求。
我们可以看到网页中既有我们想要的结果,又有下一页的链接,这两部分内容我们都要进行处理。
首先看看网页结构,如下图所示。每一页都有多个class
为quote
的区块,每个区块内都包含text
、author
、tags
。那么我们先找出所有的quote
,然后提取每一个quote
中的内容。
提取的方式可以是CSS选择器或XPath选择器。在这里我们使用CSS选择器进行选择,parse()
方法的改写如下所示:
def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
text = quote.css('.text::text').extract_first()
author = quote.css('.author::text').extract_first()
tags = quote.css('.tags .tag::text').extract()
这里首先利用选择器选取所有的quote,并将其赋值为quotes
变量,然后利用for
循环对每个quote
遍历,解析每个quote
的内容。
对text
来说,观察到它的class
为text
,所以可以用.text
选择器来选取,这个结果实际上是整个带有标签的节点,要获取它的正文内容,可以加::text
来获取。这时的结果是长度为1的列表,所以还需要用extract_first()
方法来获取第一个元素。而对于tags
来说,由于我们要获取所有的标签,所以用extract()
方法获取整个列表即可。
以第一个quote
的结果为例,各个选择方法及结果的说明如下内容。
源码如下:
代码语言:javascript复制<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>
不同选择器的返回结果如下内容。
1. quote.css('.text')
代码语言:javascript复制[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' text ')]" data='<span class="text" itemprop="text">“The '>]
2. quote.css('.text::text')
代码语言:javascript复制[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' text ')]/text()" data='“The world as we have created it is a pr'>]
3. quote.css('.text').extract()
代码语言:javascript复制['<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>']
4. quote.css('.text::text').extract()
代码语言:javascript复制['“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”']
5. quote.css('.text::text').extract_first()
代码语言:javascript复制“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”
所以,对于text
,获取结果的第一个元素即可,所以使用extract_first()
方法,对于tags
,要获取所有结果组成的列表,所以使用extract()
方法。
七、使用Item
上文定义了Item,接下来就要使用它了。Item可以理解为一个字典,不过在声明的时候需要实例化。然后依次用刚才解析的结果赋值Item的每一个字段,最后将Item返回即可。
QuotesSpider
的改写如下所示:
import scrapy
from tutorial.items import QuoteItem
class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['http://quotes.toscrape.com/']
def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
item = QuoteItem()
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
如此一来,首页的所有内容被解析出来,并被赋值成了一个个QuoteItem
。
八、后续Request
上面的操作实现了从初始页面抓取内容。那么,下一页的内容该如何抓取?这就需要我们从当前页面中找到信息来生成下一个请求,然后在下一个请求的页面里找到信息再构造再下一个请求。这样循环往复迭代,从而实现整站的爬取。
将刚才的页面拉到最底部,如下图所示。
这里有一个Next按钮。查看它的源代码,可以发现它的链接是/page/2/,全链接就是:http://quotes.toscrape.com/page/2,通过这个链接我们就可以构造下一个请求。
构造请求时需要用到scrapy.Request。这里我们传递两个参数——url
和callback
,这两个参数的说明如下。
url
:它是请求链接。callback
:它是回调函数。当指定了该回调函数的请求完成之后,获取到响应,引擎会将该响应作为参数传递给这个回调函数。回调函数进行解析或生成下一个请求,回调函数如上文的parse()
所示。
由于parse()
就是解析text
、author
、tags
的方法,而下一页的结构和刚才已经解析的页面结构是一样的,所以我们可以再次使用parse()
方法来做页面解析。
接下来我们要做的就是利用选择器得到下一页链接并生成请求,在parse()
方法后追加如下的代码:
next = response.css('.pager .next a::attr(href)').extract_first()
url = response.urljoin(next)
yield scrapy.Request(url=url, callback=self.parse)
第一句代码首先通过CSS选择器获取下一个页面的链接,即要获取a超链接中的href
属性。这里用到了::attr(href)
操作。然后再调用extract_first()
方法获取内容。
第二句代码调用了urljoin()
方法,urljoin()
方法可以将相对URL构造成一个绝对的URL。例如,获取到的下一页地址是/page/2,urljoin()
方法处理后得到的结果就是:http://quotes.toscrape.com/page/2/。
第三句代码通过url
和callback
变量构造了一个新的请求,回调函数callback
依然使用parse()
方法。这个请求完成后,响应会重新经过parse
方法处理,得到第二页的解析结果,然后生成第二页的下一页,也就是第三页的请求。这样爬虫就进入了一个循环,直到最后一页。
通过几行代码,我们就轻松实现了一个抓取循环,将每个页面的结果抓取下来了。
现在,改写之后的整个Spider
类如下所示:
import scrapy
from tutorial.items import QuoteItem
class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['http://quotes.toscrape.com/']
def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
item = QuoteItem()
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)
九、运行
接下来,进入目录,运行如下命令:
代码语言:javascript复制scrapy crawl quotes
就可以看到Scrapy的运行结果了。
代码语言:javascript复制2017-02-19 13:37:20 [scrapy.utils.log] INFO: Scrapy 1.3.0 started (bot: tutorial)
2017-02-19 13:37:20 [scrapy.utils.log] INFO: Overridden settings: {'NEWSPIDER_MODULE': 'tutorial.spiders', 'SPIDER_MODULES': ['tutorial.spiders'], 'ROBOTSTXT_OBEY': True, 'BOT_NAME': 'tutorial'}
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.logstats.LogStats',
'scrapy.extensions.telnet.TelnetConsole',
'scrapy.extensions.corestats.CoreStats']
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
'scrapy.downloadermiddlewares.retry.RetryMiddleware',
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
'scrapy.downloadermiddlewares.stats.DownloaderStats']
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
'scrapy.spidermiddlewares.referer.RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'scrapy.spidermiddlewares.depth.DepthMiddleware']
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled item pipelines:
[]
2017-02-19 13:37:20 [scrapy.core.engine] INFO: Spider opened
2017-02-19 13:37:20 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2017-02-19 13:37:20 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2017-02-19 13:37:21 [scrapy.core.engine] DEBUG: Crawled (404) <GET http://quotes.toscrape.com/robots.txt> (referer: None)
2017-02-19 13:37:21 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/> (referer: None)
2017-02-19 13:37:21 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/>
{'author': u'Albert Einstein',
'tags': [u'change', u'deep-thoughts', u'thinking', u'world'],
'text': u'u201cThe world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.u201d'}
2017-02-19 13:37:21 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/>
{'author': u'J.K. Rowling',
'tags': [u'abilities', u'choices'],
'text': u'u201cIt is our choices, Harry, that show what we truly are, far more than our abilities.u201d'}
...
2017-02-19 13:37:27 [scrapy.core.engine] INFO: Closing spider (finished)
2017-02-19 13:37:27 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 2859,
'downloader/request_count': 11,
'downloader/request_method_count/GET': 11,
'downloader/response_bytes': 24871,
'downloader/response_count': 11,
'downloader/response_status_count/200': 10,
'downloader/response_status_count/404': 1,
'dupefilter/filtered': 1,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2017, 2, 19, 5, 37, 27, 227438),
'item_scraped_count': 100,
'log_count/DEBUG': 113,
'log_count/INFO': 7,
'request_depth_max': 10,
'response_received_count': 11,
'scheduler/dequeued': 10,
'scheduler/dequeued/memory': 10,
'scheduler/enqueued': 10,
'scheduler/enqueued/memory': 10,
'start_time': datetime.datetime(2017, 2, 19, 5, 37, 20, 321557)}
2017-02-19 13:37:27 [scrapy.core.engine] INFO: Spider closed (finished)
这里只是部分运行结果,中间一些抓取结果已省略。
首先,Scrapy输出了当前的版本号以及正在启动的项目名称。接着输出了当前settings.py中一些重写后的配置。然后输出了当前所应用的Middlewares和Pipelines。Middlewares默认是启用的,可以在settings.py中修改。Pipelines默认是空,同样也可以在settings.py中配置。后面会对它们进行讲解。
接下来就是输出各个页面的抓取结果了,可以看到爬虫一边解析,一边翻页,直至将所有内容抓取完毕,然后终止。
最后,Scrapy输出了整个抓取过程的统计信息,如请求的字节数、请求次数、响应次数、完成原因等。
整个Scrapy程序成功运行。我们通过非常简单的代码就完成了一个网站内容的爬取,这样相比之前一点点写程序简洁很多。
十、保存到文件
运行完Scrapy后,我们只在控制台看到了输出结果。如果想保存结果该怎么办呢?
要完成这个任务其实不需要任何额外的代码,Scrapy提供的Feed Exports可以轻松将抓取结果输出。例如,我们想将上面的结果保存成JSON文件,可以执行如下命令:
代码语言:javascript复制scrapy crawl quotes -o quotes.json
命令运行后,项目内多了一个quotes.json文件,文件包含了刚才抓取的所有内容,内容是JSON格式。
另外我们还可以每一个Item输出一行JSON,输出后缀为jl,为jsonline的缩写,命令如下所示:
代码语言:javascript复制scrapy crawl quotes -o quotes.jl
或
代码语言:javascript复制scrapy crawl quotes -o quotes.jsonlines
输出格式还支持很多种,例如csv、xml、pickle、marshal等,还支持ftp、s3等远程输出,另外还可以通过自定义ItemExporter来实现其他的输出。
例如,下面命令对应的输出分别为csv、xml、pickle、marshal格式以及ftp远程输出:
代码语言:javascript复制scrapy crawl quotes -o quotes.csv
scrapy crawl quotes -o quotes.xml
scrapy crawl quotes -o quotes.pickle
scrapy crawl quotes -o quotes.marshal
scrapy crawl quotes -o ftp://user:pass@ftp.example.com/path/to/quotes.csv
其中,ftp输出需要正确配置用户名、密码、地址、输出路径,否则会报错。
通过Scrapy提供的Feed Exports,我们可以轻松地输出抓取结果到文件。对于一些小型项目来说,这应该足够了。不过如果想要更复杂的输出,如输出到数据库等,我们可以使用Item Pileline来完成。
十一、使用Item Pipeline
如果想进行更复杂的操作,如将结果保存到MongoDB数据库,或者筛选某些有用的Item,则我们可以定义Item Pileline来实现。
Item Pipeline为项目管道。当Item生成后,它会自动被送到Item Pipeline进行处理,我们常用Item Pipeline来做如下操作。
- 清理HTML数据。
- 验证爬取数据,检查爬取字段。
- 查重并丢弃重复内容。
- 将爬取结果保存到数据库。
要实现Item Pipeline很简单,只需要定义一个类并实现process_item()
方法即可。启用Item Pipeline后,Item Pipeline会自动调用这个方法。process_item()
方法必须返回包含数据的字典或Item对象,或者抛出DropItem异常。
process_item()
方法有两个参数。一个参数是item
,每次Spider生成的Item都会作为参数传递过来。另一个参数是spider
,就是Spider的实例。
接下来,我们实现一个Item Pipeline,筛掉text
长度大于50的Item,并将结果保存到MongoDB。
修改项目里的pipelines.py文件,之前用命令行自动生成的文件内容可以删掉,增加一个TextPipeline
类,内容如下所示:
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'][0:self.limit].rstrip() '...'
return item
else:
return DropItem('Missing Text')
这段代码在构造方法里定义了限制长度为50,实现了process_item()
方法,其参数是item
和spide
r。首先该方法判断item
的text
属性是否存在,如果不存在,则抛出DropItem
异常;如果存在,再判断长度是否大于50,如果大于,那就截断然后拼接省略号,再将item
返回即可。
接下来,我们将处理后的item
存入MongoDB,定义另外一个Pipeline。同样在pipelines.py中,我们实现另一个类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_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):
name = item.__class__.__name__
self.db[name].insert(dict(item))
return item
def close_spider(self, spider):
self.client.close()
MongoPipeline
类实现了API定义的另外几个方法。
from_crawler
。它是一个类方法,用@classmethod
标识,是一种依赖注入的方式。它的参数就是crawler
,通过crawler
我们可以拿到全局配置的每个配置信息。在全局配置settings.py中,我们可以定义MONGO_URI
和MONGO_DB
来指定MongoDB连接需要的地址和数据库名称,拿到配置信息之后返回类对象即可。所以这个方法的定义主要是用来获取settings.py中的配置的。open_spider
。当Spider开启时,这个方法被调用。上文程序中主要进行了一些初始化操作。close_spider
。当Spider关闭时,这个方法会调用。上文程序中将数据库连接关闭。
最主要的process_item()
方法则执行了数据插入操作。
定义好TextPipeline
和MongoPipeline
这两个类后,我们需要在settings.py中使用它们。MongoDB的连接信息还需要定义。
我们在settings.py中加入如下内容:
代码语言:javascript复制ITEM_PIPELINES = {
'tutorial.pipelines.TextPipeline': 300,
'tutorial.pipelines.MongoPipeline': 400,
}
MONGO_URI='localhost'
MONGO_DB='tutorial'
赋值ITEM_PIPELINES
字典,键名是Pipeline的类名称,键值是调用优先级,是一个数字,数字越小则对应的Pipeline越先被调用。
再重新执行爬取,命令如下所示:
代码语言:javascript复制scrapy crawl quotes
爬取结束后,MongoDB中创建了一个tutorial的数据库、QuoteItem的表,如下图所示。
长的text
已经被处理并追加了省略号,短的text
保持不变,author
和tags
也都相应保存。
十二、源代码
本节代码地址为:https://github.com/Python3WebSpider/ScrapyTutorial。