Scrapy 教程
15 Dec 2017
前言
本文使用 Scrapy 创建一个示例爬虫。Scrapy 安装就略过了,使用 pip 安装很简单。
创建项目
执行命令:
$ scrapy startproject scrapydemo
得到输出:
New Scrapy project 'scrapydemo', using template directory '/usr/local/lib/python3.6/site-packages/scrapy/templates/project', created in:
/Users/qd-hxt/scrapydemo
You can start your first spider with:
cd scrapydemo
scrapy genspider example example.com
项目下的主要目录有:
scrapydemo/
scrapy.cfg # 配置文件
scrapydemo/ # 该项目的 Python 模块,代码目录
__init__.py
items.py # 项目 item 的定义文件
pipelines.py # 项目 pipelines 文件
settings.py # 项目设置文件
spiders/ # 存放 spiders 的目录
__init__.py
创建第一个 spider
spider 就是我们常提到的“爬虫”。在 Scrapy 中,我们自定义 spider 类,来进行网站数据的解析。自定义 spider 类继承于 scrapy.Spider,定义了初始的 url,如何跟进网页中的链接以及如何分析页面中的内容, 提取生成 item 的方法。
在 scrapydemo/spiders
目录下新建文件 stackoverflow_spider.py
:
import scrapy
class StackoverflowSpider(scrapy.Spider):
"""Spider for Stackoverflow.
"""
name = "stackoverflow"
def start_requests(self):
urls = [
'https://stackoverflow.com/questions?page=1',
'https://stackoverflow.com/questions?page=2',
]
for url in urls:
yield scrapy.Request(url=url, callback=self.parse)
def parse(self, response):
page = response.url.split("=")[-1]
filename = 'stackoverflow-%s.html' % page
with open(filename, 'wb') as stackoverflow_file:
stackoverflow_file.write(response.body)
self.log('Saved file %s' % filename)
代码中,StackoverflowSpider
继承于 scrapy.Spider
,主要的属性和方法有:
-
name
:Spider 的标识,在同一项目中不能重名。 -
start_requests
:必须返回一个可迭代的请求(也返回一个请求列表或者写一个生成器函数),Spider 将会抓取这些请求。随后的 url 将从这些初始请求后所获取到的内容中提取。 -
parse()
:请求返回的响应将会被传到这个方法中进行解析。响应参数是 TextResponse 类的实例,这个实例包含了页面内容并且有些实用的方法。
parse()
方法通常用来解析响应,提取被抓取的数据作为字典,同时寻找新的 URL,并从中创建新的请求(Request)。
运行 spider
命令行中执行:
scrapy crawl stackoverflow
便可以运行名为我们刚创建的 stackoverflow
爬虫,向 stackoverflow 网站发送请求。
输出:
......
2017-12-14 17:24:52 [stackoverflow] DEBUG: Saved file stackoverflow-1.html
2017-12-14 17:24:52 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://stackoverflow.com/questions?page=2> (referer: None)
2017-12-14 17:24:52 [stackoverflow] DEBUG: Saved file stackoverflow-2.html
2017-12-14 17:24:52 [scrapy.core.engine] INFO: Closing spider (finished)
2017-12-14 17:24:52 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 789,
'downloader/request_count': 3,
'downloader/request_method_count/GET': 3,
'downloader/response_bytes': 71545,
'downloader/response_count': 3,
'downloader/response_status_count/200': 3,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2017, 12, 14, 9, 24, 52, 344166),
'log_count/DEBUG': 6,
'log_count/INFO': 7,
'memusage/max': 49664000,
'memusage/startup': 49659904,
'response_received_count': 3,
'scheduler/dequeued': 2,
'scheduler/dequeued/memory': 2,
'scheduler/enqueued': 2,
'scheduler/enqueued/memory': 2,
'start_time': datetime.datetime(2017, 12, 14, 9, 24, 50, 901932)}
2017-12-14 17:24:52 [scrapy.core.engine] INFO: Spider closed (finished)
并且当前目录会多了两个文件 stackoverflow-1.html 和 stackoverflow-1.html,文件的内容与 url 对应。
运行原理
Scrapy 会将 Spider 通过 start_requests
方法返回的 scrapy.Request 对象放入队列中进行统一管理。每收到一个响应,便会实例化一个 Response 对象,并将 response 最为参数,调用 request 对应的回调方法(本例中就是 parse()
)。
start_requests 方法简化
我们可以通过定义 start_urls
列表来代替 start_requests()
方法。start_urls 列表将被默认的 start_requests()
方法用来为 spider 创建初始请求。
import scrapy
class StackoverflowSpider(scrapy.Spider):
"""Spider for Stackoverflow.
"""
name = "stackoverflow"
start_urls = [
'https://stackoverflow.com/questions?page=1',
'https://stackoverflow.com/questions?page=2',
]
def parse(self, response):
page = response.url.split("=")[-1]
filename = 'stackoverflow-%s.html' % page
with open(filename, 'wb') as stackoverflow_file:
stackoverflow_file.write(response.body)
Scrapy 默认会调用 parse()
方法,返回对应 request 的响应数据。
解析数据
我们可以使用 Scrapy shell 工具来学习如何使用 Scrapy 进行数据解析。执行:
scrapy shell https://stackoverflow.com/questions?page=1
得到输出:
......
2017-12-14 17:44:38 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2017-12-14 17:44:38 [scrapy.core.engine] INFO: Spider opened
2017-12-14 17:44:38 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://stackoverflow.com/robots.txt> (referer: None)
2017-12-14 17:44:39 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://stackoverflow.com/questions?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 0x10a37efd0>
[s] item {}
[s] request <GET https://stackoverflow.com/questions?page=1>
[s] response <200 https://stackoverflow.com/questions?page=1>
[s] settings <scrapy.settings.Settings object at 0x10a37e3c8>
[s] spider <DefaultSpider 'default' at 0x10a64dc50>
[s] Useful shortcuts:
[s] fetch(url[, redirect=True]) Fetch URL and update local objects (by default, redirects are followed)
[s] fetch(req) Fetch a scrapy.Request and update local objects
[s] shelp() Shell help (print this help)
[s] view(response) View response in a browser
>>>
接着可以继续执行命令来进行 CSS 解析:
>>> response.css('title')
输出:
[<Selector xpath='descendant-or-self::title' data='<title>Newest Questions - Page 1 - Stack'>]
执行 response.css('title')
命令得到的是 SelectorList
对象,存放的是用 XML/HTML 对象封装的 Selector 对象,可以进行进一步解析。
要从上面的标题中提取文本,可以执行:
>>> response.css('title::text').extract()
['Newest Questions - Page 1 - Stack Overflow']
这里有两点值得注意:
一个是由于使用了 ::text
,也就是说只会在 <title>
元素中进行文本元素的选择。如果不指定 ::text
,便会得到包含其标签的完整的标题元素:
>>> response.css('title').extract()
['<title>Newest Questions - Page 1 - Stack Overflow</title>']
其次是调用 .extract()
后,返回的结果是一个列表,也就是一个 SelectorList
实例。如果只需要第一个结果,可以这样做:
>>> response.css('title::text').extract_first()
'Newest Questions - Page 1 - Stack Overflow'
或者这样:
>>> response.css('title::text')[0].extract()
'Newest Questions - Page 1 - Stack Overflow'
当找不到匹配的元素时,使用 .extract_first()
可以避免 IndexError
。对于大多数爬虫项目来说,异常处理也是很重要的,即便其中抓取时出现了错误,但至少能获取到部分数据。
除了 extract()
和 extract_first()
方法之外,还可以使用 re(https://doc.scrapy.org/en/latest/topics/selectors.html#scrapy.selector.Selector.re)
方法使用正则表达式进行提取:
>>> response.css('title::text').re(r'Stack.*')
['Stack Overflow']
>>> response.css('title::text').re(r'S\w+')
['Stack']
使用 view(response)
可以在浏览器中打开响应页面,然后通过调试来选择合适的 CSS 选择器。
XPath
除了 CSS,也可以使用 XPath:
>>> response.xpath('//title')
[<Selector xpath='//title' data='<title>Newest Questions - Page 1 - Stack'>]
>>> response.xpath('//title/text()').extract_first()
'Newest Questions - Page 1 - Stack Overflow'
Scrapy 选择器实际是基于强大的 XPath 实现的。事实上,在底层 CSS 选择器会被转换为 XPath 选择器。以下链接可以查看更多内容:
解析 Questions
执行命令:
>>> question = response.css("div.summary")[0]
获取到第一个 question,然后执行:
>>> question_content = question.css("h3 a::text").extract_first()
>>> question_content
'How large can Protege import an Owl file?'
便获取到了第一个问题的内容。接着获取答案:
>>> answer = question.css("div.excerpt::text").extract_first()
>>> answer
"\r\n I have a 2MB OWL file that I downloaded from the web. I tried to open it in Protege 5.2, it didn't report any issue or message, but simply load nothing in the UI. I suspect it might be due to the file ...\r\n "
在 spider 中解析数据
我们把上面的命令集成到我们的代码中。通常情况下,Scrapy 的 spider 需要将从页面解析出来的内容生成为字典,所以我们使用 yield
关键字:
class StackoverflowSpider(scrapy.Spider):
"""Spider for Stackoverflow.
"""
name = "stackoverflow"
start_urls = [
'https://stackoverflow.com/questions?page=1',
'https://stackoverflow.com/questions?page=2',
]
def parse(self, response):
for question in response.css("div.summary"):
yield {
'question_content': question.css("h3 a::text").extract_first(),
#为了让内容简短一点,这里换成了问题的提问者
'user': question.css("div.user-details a::text").extract_first()
}
运行 spider,可以看到输出的内容中包含:
......
2017-12-15 10:15:37 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?page=2>
{'question_content': 'How can I get rid of this error in windows command prompt?', 'user': 'Leo Li'}
2017-12-15 10:15:37 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?page=2>
{'question_content': 'Accessing a node within a tempate called from within a for-each', 'user': 'RDay'}
......
数据存储
最简单的方式是 Feed exports,使用以下命令:
$ scrapy crawl stackoverflow -o stackoverflow.json
在当前目录下会生成 JSON 文件 stackoverflow.json
。由于历史原因,再次执行命令会进行内容追加,而不是覆盖,如果再次运行前没有进行移除,JSON 文件的格式将会被破坏。
也可以使用其他格式,比如 JSON Lines:
$ scrapy crawl stackoverflow -o stackoverflow.jl
这样多次执行并向文件中添加新的记录就不会有格式问题了。另外,由于每条记录都是单独一行,因此可以进行大文件处理而不必将所有的文件内容都读入内存中(可以使用类似 JQ 的命令行工具进行处理)。
小型项目中以上的知识点应该就够用了。但是如果要需要更复杂的处理,就需要用到 Item Pipeline 了。在项目初始化的时候已经默认建立了一个 Item Pipeline,scrapydemo/pipelines.py
。
后续爬取
有时候我们还需要爬取后续的链接内容,可以看到页面中有这样的元素:
<div class="pager fl">
<a href="/questions?page=2&sort=newest" rel="next" title="go to page 2">
<span class="page-numbers next"> next</span>
</a>
</div>
在 shell 中解析:
>>> response.css('div.pager a').extract_first()
'<a href="/questions?page=2&sort=newest" title="go to page 2"> <span class="page-numbers">2</span> </a>'
然后解析 href
属性:
>>> response.css('div.pager a::attr(href)').extract_first()
'/questions?page=2&sort=newest'
可以修改 spider 的代码,从而可以解析下一页:
class StackoverflowSpider(scrapy.Spider):
# Spider for Stackoverflow.
name = "stackoverflow"
start_urls = ['https://stackoverflow.com/questions?page=1&sort=newest']
def parse(self, response):
for question in response.css("div.summary"):
yield {
'question_content': question.css("h3 a::text").extract_first(),
# 为了让内容简短一点,这里换成了问题的提问者
'user':
question.css("div.user-details a::text").extract_first()
}
next_page = response.css(
'div.pager a:last-of-type::attr(href)').extract_first()
if next_page is not None:
next_page = response.urljoin(next_page)
yield scrapy.Request(next_page, callback=self.parse)
解析完数据之后,通过 parse()
方法解析出下一页面链接,再通过 urljoin()
方法构建完整的 URL,返回下一页的抓取请求,并将 parse()
注册为回调方法,进行数据解析。这样便完成了对所有页面的抓取。
Scrapy 内部原理为:当在回调方法中产生 request 后,Scrapy 将其放入队列并进行统一调度,并在 request 结束时执行之前所注册的回调方法。
通过这种机制,我们可以通过自定义的规则构建复杂的抓取工具,并根据抓取到的页面解析不同类型的数据。这里由于数据太多,我没有等待爬虫抓取完成。
快速创建 Requests
可以使用 response.follow
快速创建 Requests 对象:
class StackoverflowSpider(scrapy.Spider):
# Spider for Stackoverflow.
name = "stackoverflow"
start_urls = ['https://stackoverflow.com/questions?page=1&sort=newest']
def parse(self, response):
for question in response.css("div.summary"):
yield {
'question_content': question.css("h3 a::text").extract_first(),
# 为了让内容简短一点,这里换成了问题的提问者
'user':
question.css("div.user-details a::text").extract_first()
}
next_page = response.css(
'div.pager a:last-of-type::attr(href)').extract_first()
if next_page is not None:
yield response.follow(next_page, callback=self.parse)
response.follow
支持相对路径,并且直接返回 Requests 对象,只需要将 Requests 对象返回即可。response.follow
甚至可以不需要字符串,直接传入选择器就可以了。
for href in response.css('div.pager a:last-of-type::attr(href)'):
yield response.follow(href, callback=self.parse)
对于 <a>
标签,还有更简单的做法:
for href in response.css('div.pager a:last-of-type'):
yield response.follow(href, callback=self.parse)
注意:由于
response.css('div.pager a:last-of-type')
返回的是一个列表,所以 yield 需要进行 for 循环或者取第一个元素。
其他例子
class UserSpider(scrapy.Spider):
# Spider for Stackoverflow users.
name = "user"
start_urls = ['https://stackoverflow.com/questions?page=1&sort=newest']
def parse(self, response):
for href in response.css("div.user-details a::attr(href)"):
yield response.follow(href, self.parse_user)
for href in response.css('div.pager a:last-of-type'):
yield response.follow(href, callback=self.parse)
def parse_user(self, response):
name = response.css("h2.user-card-name ::text").extract_first().strip()
bio = response.css("div.bio p::text").extract_first().strip()
yield {
'name': name,
'bio': bio,
}
上面的 spider 将会从主页开始然后针对每个提问的用户,进行用户页面内容的抓取。默认情况下,Scrapy 会将已经访问的 URL 进行重复性的过滤。这可以在 DUPEFILTER_CLASS
中设置。
CrawlSpider 是一个通用的 spider,可以基于这个类来编写抓取工具。
此外,常见模式还有使用多个页面的数据构建一个项目,并用此方法将额外的数据传递给回调函数。
spider 参数设置
运行时,可以使用 -a 选项向 spider 传入命令行参数。这些参数将会被传入 spider 的 __init__
方法中,作为 spider 的默认参数。
下面的例子中,我们执行的命令为 $ scrapy crawl tag -o tag.jl -a tag=scrapy
,便可以在类中访问此属性。
class TagSpider(scrapy.Spider):
# Spider for Stackoverflow Tag.
name = "tag"
def start_requests(self):
url = 'https://stackoverflow.com/questions/'
tag = getattr(self, 'tag', None)
if tag is not None:
url = url + 'tagged/' + tag + '?sort=frequent'
yield scrapy.Request(url, self.parse)
def parse(self, response):
for question in response.css("div.summary"):
yield {
'question_content': question.css("h3 a::text").extract_first(),
# 为了让内容简短一点,这里换成了问题的提问者
'user':
question.css("div.user-details a::text").extract_first()
}
for href in response.css('div.pager a:last-of-type'):
yield response.follow(href, callback=self.parse)
这里可以查看更多关于参数处理的内容。
更进一步
当然,上面的例子过于简单,Scrapy 还有很多其他功能,这里可以查看更多的内容。
代码
文章中的代码都可以从我的 GitHub ScrapyDemo 找到。