- 本书赞誉
- 前言
- 目标读者
- 不适合阅读本书的读者
- 本书结构
- 什么是数据处理
- 遇到困难怎么办
- 排版约定
- 使用代码示例
- 致谢
- 第 1 章 Python 简介
- 第 2 章 Python 基础
- 第 3 章 供机器读取的数据
- 第 4 章 处理 Excel 文件
- 第 5 章 处理 PDF 文件 以及用 Python 解决问题
- 第 6 章 数据获取与存储
- 第 7 章 数据清洗:研究、匹配与格式化
- 第 8 章 数据清洗:标准化和脚本化
- 第 9 章 数据探索和分析
- 第 10 章 展示数据
- 第 11 章 网页抓取:获取并存储网络数据
- 第 12 章 高级网页抓取:屏幕抓取器与爬虫
- 第 13 章 应用编程接口
- 第 14 章 自动化和规模化
- 第 15 章 结论
- 附录 A 编程语言对比
- 附录 B 初学者的 Python 学习资源
- 附录 C 学习命令行
- 附录 D 高级 Python 设置
- 附录 E Python 陷阱
- 附录 F IPython 指南
- 附录 G 使用亚马逊网络服务
- 关于作者
- 关于封面
12.2 爬取网页
如果你需要从网站的多个页面上抓取数据,爬虫可能是最好的解决方案。网络爬虫(或者机器人)很适合跨越整个域名或站点(或一系列的域名或站点)寻找信息。
你可以将爬虫视为一个高级的抓取器,通过它你可以利用页面读取抓取器的能力(类似于在第 11 章中学到的),并且在整个站点中应用匹配 URL 模式的规则。
爬虫可以帮助你了解网站的结构。例如,站点可能包含一个你并不知道的完整的子章节,其中包含一些有趣的数据。使用爬虫遍历域名,你可以找到子域或其他对报告有用的相关内容。
当你构建爬虫时,首先研究感兴趣的站点,然后创建页面读取的代码来识别和读取内容。一旦爬虫构建完毕,你可以创建一个遵循的规则列表,爬虫会使用它找到其他有趣的页面和内容,同时解析器会使用你创建的页面读取抓取器收集和保存内容。
使用爬虫时,你需要事先明确想要什么内容,或者首先使用一个宽泛的方法探索站点,然后重新编写它,使其更明确。如果你选择了广撒网的方法,可能需要在之后做很多数据清洗的工作,以将发现的内容缩小至可用数据集。
我们会使用 Scrapy 开始创建第一个爬虫。
12.2.1 使用Scrapy创建一个爬虫
Scrapy(http://scrapy.org/)是最强大的 Python 网络爬虫。它赋予你在 Python 异步网络引擎 Twisted(http://twistedmatrix.com/trac/)之上使用 LXML(见 11.5 节)的能力。如果你需要一个特别快的爬虫,并能够同时处理大量的任务,我们强烈推荐 Scrapy。
Scrapy 有一些很棒的内置特性,包括导出不同格式的结果(CSV、JSON 等),一个易用的可运行满足不同需求的多个抓取器的服务端部署结构,以及其他一系列优雅的特性,比如使用中间件来处理代理请求或重试状态码失败的请求。Scrapy 会将遇到的错误打印到日志,这样你可以更新和修改代码。
为了恰当地使用 Scrapy,你需要学习 Scrapy 类系统。Scrapy 使用几个不同的 Python 类来解析网页并且返回好的内容。当定义一个爬虫类时,你也定义了规则和其他的类属性。这些规则和属性在爬虫开始抓取网页时使用。当定义一个新的爬虫的时候,你正在使用一个叫继承(inheritance)的东西。
继承
继承让你能够将一个类作为基类,在其基础上构建额外的属性或方法。
通过 Scrapy,当继承一个爬虫类时,同样继承了有用的内置方法与属性。之后通过改变一些方法和属性,它们就是专属于你的爬虫了。
Python 的继承是显而易见的:你开始定义一个类,并且将另外的类名称放置在类定义的括号内(例如,class NewAwesomeRobot(Old Robot):)。新的类(这里是 NewAwesomeRobot)继承自括号内的类(这里是 OldRobot)。Python 让我们得以使用这种直接继承,这样当编写新的类时,可以积极地复用代码。
继承允许我们使用 Scrapy 库中丰富的抓取知识,只需要重新定义一些方法和一些初始化爬虫属性。
Scrapy 使用继承来定义在页面上抓取的内容。对于每一个 Scrapy 项目,你会收集一系列的对象,并且可能会创建一些不同的爬虫。爬虫会抓取页面,使用在设置中定义的任何格式返回对象(即数据)。
相比我们使用过的其他抓取网页的库,使用 Scrapy 爬虫需要更多的组织,但是它相当直观。组织 Scrapy 抓取器便于复用、共享和更新项目。
有一些不同类型的 Scrapy 爬虫,让我们研究一下它们的主要相似点和不同处。表 12-1 提供了一个总结。
表12-1:爬虫类型
爬虫名称 | 主要目的 | 文档 |
Spider | 用来解析特定数量的站点和页面 | http://doc.scrapy.org/en/latest/topics/spiders.html#scrapy.spider.Spider |
Crawl Spider | 遵循一组关于如何解析链接和识别页面内容的正则表达式规则,解析域名 | http://doc.scrapy.org/en/latest/topics/spiders.html#crawlspider |
XMLFeed Spider | 用来解析 XML feeds(比如 RSS),从节点中拉取内容 | http://doc.scrapy.org/en/latest/topics/spiders.html#xmlfeedspider |
CSVFeed Spider | 用来解析 CSV feeds(或 URL),从行中拉取信息 | http://doc.scrapy.org/en/latest/topics/spiders.html#csvfeedspider |
SiteMap Spider | 根据给定的域名列表,解析站点地图 | http://doc.scrapy.org/en/latest/topics/spiders.html#sitemapspider |
对于通常的网页抓取,你可以使用 Spider 类。对于更高级的、遍历整个域名的抓取,使用 CrawlSpider 类。如果你有 XML 或 CSV 格式的 feeds 或文件,特别是当它们非常大时,使用 XMLFeedSpider 和 CSVFeedSpider 来解析它们。如果你需要查看站点地图(你自己的站点或其他站点),使用 SiteMapSpider。
为了进一步熟悉两个主要的类(Spider 和 CrawlSpider),构建一些不同的爬虫。首先,使用一个 Scrapy 爬虫创建一个抓取器来爬取相同的 emoji 表情页面(http://www.emoji-cheat-sheet.com)。为此,我们使用普通的 Spider 类。首先使用 pip 安装 Scrapy。
pip install scrapy
同样建议你安装 service_identity 模块,这个模块提供了一些好用的特性,在爬取网页时提供安全集成。
pip install service_identity
通过 Scrapy,你可以使用一个简单的命令来启动一个项目。确保你正在想要使用爬虫的目录下,因为这个命令会为爬虫创建一系列的文件夹和子文件夹:
scrapy startproject scrapyspider
如果你列出所有当前文件夹下面的文件,应该会看到一个有很多子文件夹和文件的新的父文件夹。正如 Scrapy 站点(https://doc.scrapy.org/en/latest/intro/tutorial.html#creating-a-project)文档中描述的,有一些不同的配置文件(主文件夹下的 scrapy.cfg 和项目文件夹下的 settings.py,以及一个放置爬虫文件的文件夹和一个用来定义对象的文件)。
在创建抓取器之前,需要定义想要在页面数据中收集的对象。打开 items.py 文件(位于项目文件夹的嵌套文件夹内),并且修改它来保存页面数据。
# -*- coding: utf-8 -*- # 在这里定义为要抓取的对象定义模型 # # 参见文档: # http://doc.scrapy.org/en/latest/topics/items.html import scrapy class EmojiSpiderItem(scrapy.Item): ➊ emoji_handle = scrapy.Field() ➋ emoji_image = scrapy.Field() section = scrapy.Field()
❶ 通过继承 scrapy.Item 创建了新的类。这意味着我们有了这个类的内置方法和属性。
❷ 为了定义每一个字段或数据值,在类中添加了一个新的行,设置了属性名称,并且通过将其设置为 scrapy.Field() 对象来初始化。这些字段支持任何普通的 Python 数据结构,包括字典、元组、列表、浮点数、小数和字符串。
你可能注意到 items.py 文件主要是事先编辑好的。这是个非常好的功能,让你能够快速开始开发,并确保有合适的项目结构。startproject 命令提供所有这些工具,是开始新 Scrapy 项目的最好方式。你同样可以看到,创建一个新的类来收集数据是很简单的。只需要几行 Python 代码,就可以定义关心的域,并准备好爬虫中使用的对象。
为了从爬虫类开始,在新项目目录结构中的 spiders 文件夹下创建一个新的文件,名为 emo_spider.py:
import scrapy from emojispider.items import EmojiSpiderItem ➊ class EmoSpider(scrapy.Spider): ➋ name = 'emo' ➌ allowed_domains = ['emoji-cheat-sheet.com'] ➍ start_urls = [ 'http://www.emoji-cheat-sheet.com/', ➎ ] def parse(self, response): ➏ self.log('A response from %s just arrived!' % response.url) ➐
❶ 所有 Scrapy 导入使用项目根目录作为模块导入起始点,所以需要在导入中包含父文件夹。这行代码从 emojispider.items module 导入了 EmojiSpiderItem 类。
❷ 使用继承定义了 EmoSpider 类,新的类基于简单的 scrapy.Spider 类。这意味着爬虫将需要特定的初始化属性(https://doc.scrapy.org/en/latest/topics/spiders.html#spider),这样它知道去抓取哪一个 URL,以及如何处理抓取的内容。我们在下面的几行中定义了这些属性(start_urls、name 和 allowed_domains)。
❸ 爬虫名称是我们在命令行任务中识别出爬虫时会用到的。
❹ allowed_domains 告诉爬虫爬取哪些域名。如果爬虫遇到一个链接指向的域名不在该列表中,它会忽略这个链接。这个属性在编写爬取抓取器时很有用,这样如果链接不符合规则,抓取器就不会尝试去爬取所有的 Twitter 或 Facebook 网页。你同时也可以传递子域。
❺ Spider 类使用 start_urls 属性来遍历要爬取的 URL 列表。在 CrawlSpider 里,这些是找到更多匹配的 URL 的起点。
❻ 这行代码重新定义了爬虫的 parse 方法,通过在类中使用 def 和方法名称定义该方法执行一些逻辑。为类定义方法时,你总是从传递 self 开始。这是因为调用方法的对象将是第一个参数(即,list.append() 首先传递列表对象本身,然后传递括号中的参数)。 parse 函数的下一个参数是响应。正如在文档(https://doc.scrapy.org/en/latest/topics/spiders.html#scrapy.spiders.Spider.parse)中提及的,parse 方法将需要一个响应对象。最后用冒号终结这一行,正如定义任何其他的函数时所做的。
❼ 为了测试爬虫,Scrapy 入门指南中的这行代码使用爬虫的 log 方法,发送一条信息到日志中。使用响应的 URL 属性来展示响应的地址。
为了运行这个 Scrapy 爬虫,我们要确保正处于恰当的目录中(scrapy spider 和其中的 scrapy.cfg 文件),之后运行命令行参数来解析页面:
scrapy crawl emo
日志会显示爬虫开始运行,并且显示出哪些中间件正在运行。之后,几乎在最后,你应该能看见类似下面的输出:
2015-06-03 15:47:48+0200 [emo] DEBUG: A resp from www.emoji-cheat-sheet.com arrived! 2015-06-03 15:47:48+0200 [emo] INFO: Closing spider (finished) 2015-06-03 15:47:48+0200 [emo] INFO: Dumping Scrapy stats: {'downloader/request_bytes': 224, 'downloader/request_count': 1, 'downloader/request_method_count/GET': 1, 'downloader/response_bytes': 143742, 'downloader/response_count': 1, 'downloader/response_status_count/200': 1, 'finish_reason': 'finished', 'finish_time': datetime.datetime(2015, 6, 3, 13, 47, 48, 274872), 'log_count/DEBUG': 4, 'log_count/INFO': 7, 'response_received_count': 1, 'scheduler/dequeued': 1, 'scheduler/dequeued/memory': 1, 'scheduler/enqueued': 1, 'scheduler/enqueued/memory': 1, 'start_time': datetime.datetime(2015, 6, 3, 13, 47, 47, 817479)}
抓取器大概一秒解析一个页面。同样可以看到来自 parse 方法的日志。酷!我们成功地定义了第一个对象和类,能够创建并且运行它们。
下一步是真正地解析页面,拉取内容。让我们尝试另外一个内置的特性,Scrapy shell。它类似于 Python 或命令行 shell,但是附带所有可用的爬虫命令。有了该 shell,研究页面和确定如何得到页面内容变得非常简单。为了启动 Scrapy shell,只需运行:
scrapy shell
你应该看到了一个可用选项或可以调用的函数的列表。其中一个为 fetch。让我们测试一下这个函数:
fetch('http://www.emoji-cheat-sheet.com/')
你现在应该看到了一些类似于抓取输出的输出结果。其中一些信息显示已爬取 URL,之后返回一个新的可用对象列表。其中一个是 response 对象。响应对象和你在 parse 方法中使用的相同。让我们看一下是否可以找到一些同响应对象交互的方式:
response.url response.status response.headers
这其中的每一项都应该返回一些数据。url 与我们编写日志信息时使用的 URL 相同。 status 告诉我们 HTTP 响应的状态码。headers 应该提供一个服务器返回的头部字典。
输入 response,点击 Tab,你会看到一个完整的可用的方法与属性列表,以及响应对象。你同样可以在 IPython 终端中对任何其他的 Python 对象做这件事。3
3如果你安装了 IPython,就会在使用的大多数 Python shell 中看到该 tab 实现。如果没有看到它,你可以添加一个 .pythonrc 文件到计算机(http://stackoverflow.com/questions/246725/how-do-i-add-tab-completion-to-the-python-shell),并且将它赋值给 PYTHONSTARTUP 环境变量。
每一个响应对象同样会有一个 xpath 和 css 方法。这些方法类似于贯穿本章和第 11 章的选择器。正如你已经猜到的,xpath 希望你发送一个 XPath 字符串,而 css 希望得到一个 CSS 选择器。让我们看一下使用已经为这个页面写好的 XPath 选择页面上的一些对象:
response.xpath('//h2|//h3')
运行该命令,你会看到一个类似于下面的列表:
[<Selector xpath='//h2|//h3' data=u'<h2>People</h2>'>, <Selector xpath='//h2|//h3' data=u'<h2>Nature</h2>'>, <Selector xpath='//h2|//h3' data=u'<h2>Objects</h2>'>, <Selector xpath='//h2|//h3' data=u'<h2>Places</h2>'>, <Selector xpath='//h2|//h3' data=u'<h2>Symbols</h2>'>, <Selector xpath='//h2|//h3' data=u'<h3>Campfire also supports a few sounds<'>]
现在让我们看一下,是否可以只读取这些头部中的文本内容。在使用 Scrapy 时,你会想要精确地抽取出正在寻找的元素;(在编写本书时)没有 get 或 text_content 方法。让我们看一下是否可以使用 XPath 知识来从头部中选择文本:
for header in response.xpath('//h2|//h3'): print header.xpath('text()').extract()
你应该会得到类似下面的输出:
[u'People'] [u'Nature'] [u'Objects'] [u'Places'] [u'Symbols'] [u'Campfire also supports a few sounds']
可以看到 extract 方法会返回一个匹配元素的列表。可以使用 @ 符号来表示属性,用 text() 方法来拉取文本。我们需要重写一些代码,但现在可以使用在 11.5.1 节中所写的许多 LXML 逻辑:
import scrapy from scrapyspider.items import EmojiSpiderItem class EmoSpider(scrapy.Spider): name = 'emo' allowed_domains = ['emoji-cheat-sheet.com'] start_urls = [ 'http://www.emoji-cheat-sheet.com/', ] def parse(self, response): headers = response.xpath('//h2|//h3') lists = response.xpath('//ul') all_items = [] ➊ for header, list_cont in zip(headers, lists): section = header.xpath('text()').extract()[0] ➋ for li in list_cont.xpath('li'): item = EmojiSpiderItem() ➌ item['section'] = section spans = li.xpath('div/span') if len(spans): link = spans[0].xpath('@data-src').extract() ➍ if link: item['emoji_link'] = response.url + link[0] ➎ handle_code = spans[1].xpath('text()').extract() else: handle_code = li.xpath('div/text()').extract() if handle_code: item['emoji_handle'] = handle_code[0] ➏ all_items.append(item) ➐ return all_items ➑
❶ 由于每个页面使用多个对象,这行代码在 parse 方法的开始使用了一个列表,并在遍历页面的过程中,保存一个找到的对象的列表。
❷ 不同于在 LXML 脚本中调用 header.text,这行代码定位到文本小节(.xpath("text()")),并且使用 extract 函数抽取它。因为我们知道这个方法会返回一个列表,所以这段代码选择每个列表中第一个并且唯一的对象,将其赋值给 section。
❸ 这行代码定义对象。对于每一个列表对象,通过调用类名称与一对空括号创建了一个新的 EmojiSpiderItem 对象。
❹ 为了抽取数据属性,这行代码使用 XPath @ 选择器。这段代码选择第一个 span,并且抽取 @data-src 属性,这会返回一个列表。
❺ 为了创建完整的 emoji_link 属性,这行代码使用响应 URL 并且添加 @data-src 属性中第一个列表对象。为了设置对象的字段,使用字典语法,将键(即字段名称)赋值。如果前面代码没有找到 @data-src,那么这行代码不会执行。
❻ 为了组合一些代码,并且不重复我们自己的代码,这段代码找到 emoji 和声音的处理字符串,赋值给 emoji_handle 字段。
❼ 在列表元素每个循环的最后,这行代码追加新的对象到 all_items 列表。
❽ 在 parse 方法的最后,这行代码返回了所有找到的对象的列表。Scrapy 会在抓取中使用一个返回的对象或对象列表(通常通过保存、清洗或者以我们可以阅读和使用的格式输出数据)。
现在添加了 extract 方法调用,并且更具体地识别出了要从页面中抓取的文本与属性。我们移除了其中的一些 None 逻辑,因为 Scrapy 对象会自动了解拥有哪一个字段,不拥有哪个字段。出于这个原因,如果导出输出到 CSV 或 JSON,它会同时显示空(null)行和找到的值。现在已经更新了同 Scrapy 工作的代码,再一次调用 crawl 方法运行它。
scrapy crawl emo
你应该看到一些类似于第一个抓取的输出,只是多出了几行! Scrapy 会在解析网页时打印每一个找到的对象到日志。在最后,你会看到相同的总结输出,显示错误、调试信息和抓取对象的数量。
2015-06-03 18:13:51+0200 [emo] DEBUG: Scraped from <200 http://www.emoji-cheat-sheet.com/> {'emoji_handle': u'/play butts', 'section': u'Campfire also supports a few sounds'} 2015-06-03 18:13:51+0200 [emo] INFO: Closing spider (finished) 2015-06-03 18:13:51+0200 [emo] INFO: Dumping Scrapy stats: {'downloader/request_bytes': 224, 'downloader/request_count': 1, 'downloader/request_method_count/GET': 1, 'downloader/response_bytes': 143742, 'downloader/response_count': 1, 'downloader/response_status_count/200': 1, 'finish_reason': 'finished', 'finish_time': datetime.datetime(2015, 6, 3, 16, 13, 51, 803765), 'item_scraped_count': 924, 'log_count/DEBUG': 927, 'log_count/INFO': 7, 'response_received_count': 1, 'scheduler/dequeued': 1, 'scheduler/dequeued/memory': 1, 'scheduler/enqueued': 1, 'scheduler/enqueued/memory': 1, 'start_time': datetime.datetime(2015, 6, 3, 16, 13, 50, 857193)} 2015-06-03 18:13:51+0200 [emo] INFO: Spider closed (finished)
Scrapy 在大约 1 秒的时间里解析 900 多个对象——令人惊讶!在查看日志时,我们看到所有的对象均被解析和添加。没有出现任何的错误;如果有的话,会在最后的输出中看到一个错误数量,类似于 DEBUG 和 INFO 输出行。
我们还没有通过脚本得到一个真正的文件或输出。可以使用一个内置的命令行参数设置一个。使用一些其他的参数选项尝试重新运行爬虫。
scrapy crawl emo -o items.csv
在抓取的最后,你的项目根目录中应该有一个 item.csv 文件。如果你打开它,应该会看到所有的数据都被导出到了 CSV 格式中。你同样可以导出 .json 和 .xml 文件,所以尽情地通过改变文件名尝试这些选项。
恭喜,你已经搭建了第一个网络爬虫!只需要几个文件和不到 50 行的代码,你就可以在 1 分钟以内解析一整个页面——超过 900 个对象,输出这些发现到一个简单可阅读并且可以轻松分享的格式文件里。正如你看到的那样,Scrapy 是一个非常强大又极其有用的工具。
12.2.2 使用Scrapy爬取整个网站
我们已经探索了使用 Scrapy shell 和 crawl 来爬取普通页面,但是如何利用 Scrapy 的能力和速度来爬取整个站点?为了研究 CrawlSpider 的能力,需要首先确定要爬取的内容。让我们尝试寻找 PyPI 主页(http://pypi.python.org)中与抓取相关的 Python 包。首先,查看一下页面,找出我们想要的数据。快速搜索“scrape”(https://pypi.python.org/pypi?:action=search&term=scrape&submit=search)显示了一整个列表的搜索结果,其中的每一个页面都有更多的信息,包括文档、一个相关包的链接、一个支持的 Python 版本的列表和最近下载的数量。
可以围绕这些数据构建一个对象模型。一般来说,如果不是与相同的数据关联,我们会为每一个抓取器创建一个新的项目;但是为了方便使用,我们使用和 emoji 抓取器相同的文件夹。从修改 items.py 文件开始:
# -*- coding: utf-8 -*- # 在这里定义为要抓取的对象定义模型 # # 参见文档: # http://doc.scrapy.org/en/latest/topics/items.html import scrapy class EmojiSpiderItem(scrapy.Item): emoji_handle = scrapy.Field() emoji_link = scrapy.Field() section = scrapy.Field() class PythonPackageItem(scrapy.Item): package_name = scrapy.Field() version_number = scrapy.Field() package_downloads = scrapy.Field() package_page = scrapy.Field() package_short_description = scrapy.Field() home_page = scrapy.Field() python_versions = scrapy.Field() last_month_downloads = scrapy.Field()
我们在旧的类下面直接定义了新的对象类。在类之间保留几个空行,这样更容易阅读文件和看到类的不同之处。这里,添加了 Python 包页面中我们感兴趣的一些字段,包括过去一个月的下载量、包的主页、支持的 Python 版本以及版本号。
有了对象定义,可以使用 Scrapy shell 来研究 Scrapely 页面上的内容。Scrapely 是 Scrapy 作者的一个项目,使用 Python 来像屏幕一样阅读 HTML。如果还没有安装它,同样建议安装 IPython,这会确保你的输入和输出看起来和本书中的一样,并且提供了一些其他的 shell 工具。在 shell 中(后文指 scrapy shell),需要首先使用下面的命令抓取内容。
fetch('https://pypi.python.org/pypi/scrapely/0.12.0')
可以尝试从页面顶端的 breadcrumb 标签中抓取版本号。它们在 ID 为 breadcrumb 的 div 中,我们可以编写一些 XPath 来找到它。
In [2]: response.xpath('//div[@id="breadcrumb"]') Out[2]: [<Selector xpath='//div[@id="breadcrumb"]' data=u'<div id="breadcrumb">\n <a h'>]
IPython 的 Out 信息显示我们已经正确地找到了 breadcrumb div。通过在浏览器的检视标签中检查元素,我们看到文本位于 div 中的一个锚标签中。我们需要使用 XPath 特化,告诉它通过下面这些行代码去查找子锚标签中的文本:
In [3]: response.xpath('//div[@id="breadcrumb"]/a/text()') Out[3]: [<Selector xpath='//div[@id="breadcrumb"]/a/text()' data=u'Package Index'>, <Selector xpath='//div[@id="breadcrumb"]/a/text()' data=u'scrapely'>, <Selector xpath='//div[@id="breadcrumb"]/a/text()' data=u'0.12.0'>]
现在可以在最后的 div 中看到版本号,在抽取的时候处理最后一个 div。使用正则表达式做一些测试,确保版本数据是一个数字(见 7.2.6 节),或者使用 Python 的 is_digit(见 7.2.3 节)。
现在看一下如何获取页面中略微复杂的部分:最近一个月的下载量。如果在浏览器中检查了这个元素,你会看到它位于一个 span 中的列表项中的无序列表中。你会注意到,其中没有任何一个元素有 CSS ID 或类。你还会注意到 span 不包括实际上的单词“month”(为了便于搜索)。让我们看一下是否可以得到一个有用的选择器。
In [4]: response.xpath('//li[contains(text(), "month")]') Out[4]: []
喔,使用 XPath 文本搜索寻找元素是不容易的。然而,在 XPath 中,一个好的技巧是如果你轻微地改变查询,解析相似的对象,有些时候会表现得完全不同。尝试运行这个命令:
In [5]: response.xpath('//li/text()[contains(., "month")]') Out[5]: [<Selector xpath='//li/text()[contains(., "month")]' data=u' downloads in the last month\n '>]
看到没?为什么一个有效,而其他的却没用呢?因为元素是位于 li 元素的 span,而其他文本位于 span 之后,这迷惑了 XPath 模式搜索的层次。页面结构越复杂,编写一个完美的选择器就越难。我们想要在第二个模式中做的有一点不同——我们说“给我位于 li 中且其中包含 month 的文本”,而不是“给我一个拥有 month 文本的 li 元素”。这里差别很小,但是处理混乱的 HTML 时,通过尝试不同的选择器处理困难的小节是有用处的。
但是我们真正需要的是包含下载数量的 span。可以使用 XPath 关系的魔力在链路上浏览并且定位 span。尝试下面的代码。
In [6]: response.xpath('//li/text()[contains(., "month")]/..') Out[6]: [<Selector xpath='//li/text()[contains(., "month")]/..' data=u'<li>\n <span>668</span> downloads in t'>]
通过使用 .. 操作符,回退到父节点,这样现在同时有了 span 后的文本和 span 本身。最后一步是选择 span,这样不需要担心剥离文本。
In [7]: response.xpath('//li/text()[contains(., "month")]/../span/text()') Out[7]: [<Selector xpath='//li/text()[contains(., "month")]/../span/text()' data=u'668'>]
棒!现在有了想要找到的数字,并且它应该在所有的页面上工作,因为它基于页面层次编写,并且没有尝试去“猜测”内容可能位于哪里。
使用 XPath 技巧在 shell 中调试和定位你想要使用的对象。随着经验的积累,你在第一次尝试编写选择器就会更容易取得成功,所以鼓励你编写更多的抓取器,通过测试更多不同的选择器进行实验。
首先编写一个能够使用 Spider 类正确解析 Scrapely 页面的抓取器,之后将它转换为使用 CrawlSpider 类的版本。循序渐进地解决一个有两三个因素的问题是个好方法,在完成一部分任务后再完成下一个部分。因为需要使用 CrawlSpider 调试两部分代码(抓取规则以找到匹配的页面和抓取页面本身),所以首先确认其中的一部分有效是比较好的做法。建议从构建一个抓取器(可以在一两个匹配的页面上工作)开始,之后编写抓取规则来测试爬取逻辑。
下面,看一下 Python 包页面的完整的 Spider。将它作为一个新的文件包含在 spiders 文件夹中,同 emo_spider.py 文件一起。我们称它为 package_spider.py。
import scrapy from scrapyspider.items import PythonPackageItem class PackageSpider(scrapy.Spider): name = 'package' allowed_domains = ['pypi.python.org'] start_urls = [ 'https://pypi.python.org/pypi/scrapely/0.12.0', 'https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1', ➊ ] def parse(self, response): item = PythonPackageItem() ➋ item['package_page'] = response.url item['package_name'] = response.xpath( '//div[@class="section"]/h1/text()').extract() item['package_short_description'] = response.xpath( '//meta[@name="description"]/@content').extract() ➌ item['home_page'] = response.xpath( '//li[contains(strong, "Home Page:")]/a/@href').extract() ➍ item['python_versions'] = [] versions = response.xpath( '//li/a[contains(text(), ":: Python ::")]/text()').extract() for v in versions: version_number = v.split("::")[-1] ➎ item['python_versions'].append(version_number.strip()) ➏ item['last_month_downloads'] = response.xpath( '//li/text()[contains(., "month")]/../span/text()').extract() item['package_downloads'] = response.xpath( '//table/tr/td/span/a[contains(@href,"pypi.python.org")]/@href' ➐ ).extract() return item ➑
❶ 这行代码添加一个我们没有研究过的额外的 URL。使用多个 URL 是一种从 Spider 转到 CrawlSpider 时快速检查代码整洁性和复用性的好方法。
❷ 对于这个抓取器,每个页面只需要一个对象。这行代码在 parse 方法的开始创建了这个对象。
❸ 在你解析时,学习一些关于搜索引擎优化(SEO)的知识是获得易读页面描述的一种很好的方式。大多数站点会为 Facebook、Pinterest 和其他共享信息的网站创建简短的描述、关键词、标题和其他元标签。这行代码为数据收集拉取描述。
❹ 包的“Home Page”URL 位于 li 中的一个 strong 标签中。一旦找到这个元素,这行代码只选择锚元素中的链接。
❺ 版本号链接位于一个使用 :: 分隔 Python 和版本号的表单对象中。版本号永远出现在最后,这样这行代码使用 :: 作为分隔符分割字符串,使用最后的元素。
❻ 这行代码追加版本文本(去除额外的空格)到 Python 版本数组。对象的 python_versions 键现在会保存所有的 Python 版本。
❼ 可以看到,在表格中有使用 pypi.python.org 域名的链接,而不是它们的 MD5 校验值。这行代码判断链接是否有正确的域名,并只抓取有正确域名的链接。
❽ 在 parse 方法的最后,Scrapy 希望我们返回一个对象(或一个对象列表)。这行代码返回这些对象。
运行这段代码(scrapy crawl package),你应该会得到两个对象,并且没有错误。然而,你会发现我们得到一些不同的数据。举个例子,对于每一个下载,我们的包数据没有一个好的支持的 Python 版本的列表。如果想要这个列表,可以从表格中的 PyVersion 字段解析,并将它与每一个下载匹配。你会怎样做这件事呢?(提示:这个字段位于每个数据行的第三列,XPath 允许你传递元素索引。)我们同样注意到数据有一些混乱,就像下面的输出(为了匹配页面进行了格式化;你的输出会看起来有一些不同)所展示的一样。
2015-09-10 08:19:34+0200 [package_test] DEBUG: Scraped from <200 https://pypi.python.org/pypi/scrapely/0.12.0> {'home_page': [u'http://github.com/scrapy/scrapely'], 'last_month_downloads': [u'668'], 'package_downloads': [u'https://pypi.python.org/packages/2.7/s/' + \ 'scrapely/scrapely-0.12.0-py2-none-any.whl', u'https://pypi.python.org/packages/source/s/' + \ 'scrapely/scrapely-0.12.0.tar.gz'], 'package_name': [u'scrapely 0.12.0'], 'package_page': 'https://pypi.python.org/pypi/scrapely/0.12.0', 'package_short_description': [u'A pure-python HTML screen-scraping library'], 'python_versions': [u'2.6', u'2.7']}
有几个字段原本希望是字符串或整数值,但是取而代之的是一个字符串数组。让我们在定义爬虫规则之前创建一个辅助方法来清洗数据。
import scrapy from scrapyspider.items import PythonPackageItem class PackageSpider(scrapy.Spider): name = 'package' allowed_domains = ['pypi.python.org'] start_urls = [ 'https://pypi.python.org/pypi/scrapely/0.12.0', 'https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1', ] def grab_data(self, response, xpath_sel): ➊ data = response.xpath(xpath_sel).extract() ➋ if len(data) > 1: ➌ return data elif len(data) == 1: if data[0].isdigit(): return int(data[0]) ➍ return data[0] ➎ return [] ➏ def parse(self, response): item = PythonPackageItem() item['package_page'] = response.url item['package_name'] = self.grab_data( response, '//div[@class="section"]/h1/text()') ➐ item['package_short_description'] = self.grab_data( response, '//meta[@name="description"]/@content') item['home_page'] = self.grab_data( response, '//li[contains(strong, "Home Page:")]/a/@href') item['python_versions'] = [] versions = self.grab_data( response, '//li/a[contains(text(), ":: Python ::")]/text()') for v in versions: item['python_versions'].append(v.split("::")[-1].strip()) item['last_month_downloads'] = self.grab_data( response, '//li/text()[contains(., "month")]/../span/text()') item['package_downloads'] = self.grab_data( response, '//table/tr/td/span/a[contains(@href,"pypi.python.org")]/@href') return item
❶ 这行定义了一个新的方法以使用 self 对象(这样爬虫可以像普通方法一样调用它)、响应对象以及长长的 XPath 选择器来查找内容。
❷ 这行代码使用新的函数变量抽取数据。
❸ 如果数据的长度大于 1,这行代码返回列表。我们可能想要所有的数据,所以原样返回。
❹ 如果数据的长度等于 1,并且数据是一个数字,这行代码返回整数。这可能会是下载数量的情况。
❺ 如果数据的长度等于 1,但是不是一个数字,这行代码只返回这个数据。这会匹配包含链接和简单文本的字符串。
❻ 如果函数没有返回,这行代码返回一个空列表。这里使用一个列表,因为我们希望 extract 在没有找到数据的时候返回空列表。如果使用 None 类型或者空字符串,你可能需要修改其他的代码,来保存它到 CSV。
❼ 这行代码调用新的函数,并且使用下面的参数触发 self.grab_data:响应对象和 XPath 选择字符串。r 使用其他内置输出功能。
现在我们有了相当干净的数据和代码,并且更少地重复自己的代码。我们可以更加深入地优化它,但是为了不让你眼花缭乱,先来定义爬取规则。爬取规则由正则表达式实现,通过定义页面位置和遵循的 URL 类型,告诉爬虫去哪里爬取。(第 7 章介绍了正则表达式,是不是很棒?你现在已经是专业的了!)如果看一下包的链接(https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1 和 https://pypi.python.org/pypi/scrapely/0.12.0),可以看到以下相似点。
· 它们都有相同的域名,pypi.python.org,并且它们都使用 https。
· 在 URL 中,它们都有相同的路径模式:/pypi/<name_of_the_library>/<version_number>。
· 库的名称使用小写字母和破折号,版本号由数字和句点组成。
可以使用这些相似性来定义正则规则。在脚本中编写它们之前,先在 Python 控制台中尝试它们。
import re urls = [ 'https://pypi.python.org/pypi/scrapely/0.12.0', 'https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1', ] to_match = 'https://pypi.python.org/pypi/[\w-]+/[\d\.]+' ➊ for u in urls: if re.match(to_match, u): print re.match(to_match, u).group() ➋
❶ 这行代码找到一个使用 https、域名为 pypi.python.org 并且还有我们研究路径的链接。第一个部分是 pypi,第二个是有着符号“-”的小写文本(使用 [\w-]+ 可以轻松地匹配),最后一部分寻找有或没有句点的数字([\d\.]+)。
❷ 这行代码输出了匹配的组。我们正在使用正则表达式的 match 方法,因为这是正则 Scrapy 爬虫所使用的。
我们有了一个匹配(确切地说,是两个!)。现在,最后看一下需要从哪里开始。Scrapy 爬虫会首先使用起始 URL 列表,然后跟随这些网页找到其他 URL。如果再看一下搜索结果页(https://pypi.python.org/pypi?:action=search&term=scrape&submit=search),我们会注意到页面使用相对 URL,这样只需要匹配 URL 路径。我们同样看到所有的链接都位于表格中,这样可以限制 Scrapy 查看以找到用来爬取链接的位置。知道这些后,通过添加爬取规则来更新文件。
from scrapy.contrib.spiders import CrawlSpider, Rule ➊ from scrapy.contrib.linkextractors import LinkExtractor ➋ from scrapyspider.items import PythonPackageItem class PackageSpider(CrawlSpider): ➌ name = 'package' allowed_domains = ['pypi.python.org'] start_urls = [ 'https://pypi.python.org/pypi?%3A' + \ 'action=search&term=scrape&submit=search', 'https://pypi.python.org/pypi?%3A' + \ 'action=search&term=scraping&submit=search', ➍ ] rules = ( Rule(LinkExtractor( allow=['/pypi/[\w-]+/[\d\.]+', ], ➎ restrict_xpaths=['//table/tr/td', ], ➏ ), follow=True, ➐ callback='parse_package', ➑ ), ) def grab_data(self, response, xpath_sel): data = response.xpath(xpath_sel).extract() if len(data) > 1: return data elif len(data) == 1: if data[0].isdigit(): return int(data[0]) return data[0] return [] def parse_package(self, response): item = PythonPackageItem() item['package_page'] = response.url item['package_name'] = self.grab_data( response, '//div[@class="section"]/h1/text()') item['package_short_description'] = self.grab_data( response, '//meta[@name="description"]/@content') item['home_page'] = self.grab_data( response, '//li[contains(strong, "Home Page:")]/a/@href') item['python_versions'] = [] versions = self.grab_data( response, '//li/a[contains(text(), ":: Python ::")]/text()') for v in versions: version = v.split("::")[-1] item['python_versions'].append(version.strip()) item['last_month_downloads'] = self.grab_data( response, '//li/text()[contains(., "month")]/../span/text()') item['package_downloads'] = self.grab_data( response, '//table/tr/td/span/a[contains(@href,"pypi.python.org")]/@href') return item
❶ 这行代码同时导入 CrawlSpider 类和 Rule 类,因为在第一个爬虫中,我们需要它们。
❷ 这行代码导入了 LinkExtractor。默认的链接抽取器使用 LXML(我们知道如何编写它!)。
❸ 这行代码重新定义了 Spider,这样它从 CrawlSpider 类继承而来。由于修改了这种继承,需要定义一个 rules 属性。
❹ 包含了检索词为 scrape 和 scraping 的搜索页,以查看是否可以找到更多的 Python 包。如果你有不同的让脚本开始搜索的起始点,可以在这里添加一个长列表。
❺ 这行代码设置了 allow 来使用正则匹配页面上的链接。因为只需要相关的链接,所以只从匹配的链接开始。allow 接受一个列表,所以如果你有不止一个类型的 URL 想要匹配,可以在这里添加多个 allow 规则。
❻ 这行代码限制了爬虫到结果表格中。这意味着爬虫只会去表格列中的数据行寻找匹配链接。
❼ 这行告诉了跟随(即加载)匹配链接的规则。有些时候,对于一些页面你可能只想解析并获取内容,但是不需要跟随它的链接。如果想要让爬虫跟随页面链接,并且打开它们,你需要使用 follow=True。
❽ 赋值给规则一个回调函数,并且重新命名 parse 方法来确认没有与 Scrapy 的 CrawlSpider 类使用的解析方法混淆。现在解析方法叫作 parse_package,并且爬虫在跟随匹配的 URL 拿到我们想要抓取的页面后会调用这个方法。
你可以同运行一个普通的抓取器一样,运行这个爬虫:
scrapy crawl package
你已经正式地完成了第一个爬虫!是否还有待完善的地方?有一个容易修复的 bug 遗留在这段代码中了。你能找到它吗?如何修复它?[提示:查看你的 Python 版本,然后查看返回版本的方式(即永远返回一个列表),与 grab_data 返回数据对比。]看看你是否能够在爬虫脚本中修复这个问题。如果不能,可以参考本书仓库(https://github.com/jackiekazil/data-wrangling),得到完整的修复后的代码。
Scrapy 是一个有效、快速、方便配置的工具。还有很多值得探索,你可以阅读该库的很棒的文档(http://doc.scrapy.org/en/latest/)。配置你的脚本来使用数据库和特殊的信息抽取工具,并且在自己的服务器上使用 Scrapyd(http://scrapyd.readthedocs.org/en/latest/)运行它们是很简单的。希望这是你之后众多 Scrapy 项目的第一个!
现在你理解了屏幕读取器、浏览器读取器和爬虫。让我们看看构建更加复杂的网页爬虫所需要知道的其他一些事情。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论