Skip to content

Latest commit

 

History

History
929 lines (717 loc) · 43.9 KB

File metadata and controls

929 lines (717 loc) · 43.9 KB

三、爬虫基础

本章非常重要,你可能需要读几遍,或是从中查找解决问题的方法。我们会从如何安装 Scrapy 讲起,然后在案例中讲解如何编写爬虫。开始之前,说几个注意事项。

因为我们马上要进入有趣的编程部分,使用本书中的代码段会十分重要。当你看到:

$ echo hello world
hello world 

是要让你在终端中输入 echo hello world(忽略$),第二行是看到结果。

当你看到:

>>> print 'hi'
hi 

是让你在 Python 或 Scrapy 界面进行输入(忽略>>>)。同样的,第二行是输出结果。

你还需要对文件进行编辑。编辑工具取决于你的电脑环境。如果你使用 Vagrant(强烈推荐),你可以是用 Notepad、Notepad++、Sublime Text、TextMate,Eclipse、或 PyCharm 等文本编辑器。如果你更熟悉 Linux/Unix,你可以用控制台自带的 vim 或 emacs。这两个编辑器功能强大,但是有一定的学习曲线。如果你是初学者,可以选择适合初学者的 nano 编辑器。

安装 Scrapy

Scrapy 的安装相对简单,但这还取决于读者的电脑环境。为了支持更多的人,本书安装和使用 Scrapy 的方法是用 Vagrant,它可以让你在 Linux 盒中使用所有的工具,而无关于操作系统。下面提供了 Vagrant 和一些常见操作系统的指导。

MacOS

为了轻松跟随本书学习,请参照后面的 Vagrant 说明。如果你想在 MacOS 中安装 Scrapy,只需控制台中输入:

$ easy_install scrapy 

然后,所有事就可以交给电脑了。安装过程中,可能会向你询问密码或是否安装 Xcode,只需同意即可。

Windows 在 Windows 中安装 Scrapy 要麻烦些。另外,在 Windows 安装本书中所有的软件也很麻烦。我们都为你想到了可能的问题。有 Virtualbox 的 Vagrant 可以在所有 64 位电脑上顺利运行。翻阅相关章节,只需几分钟就可以安装好。如果真要在 Windows 中安装,请参考本书网站http://scrapybook.com/上面的资料。

Linux 你可能会在多种 Linux 服务器上安装 Scrapy,步骤如下:

提示:确切的安装依赖变化很快。写作本书时,Scrapy 的版本是 1.0.3(翻译此书时是 1.4)。下面只是对不同服务器的建议方法。

Ubuntu 或 Debian Linux 为了在 Ubuntu(测试机是 Ubuntu 14.04 Trusty Tahr - 64 bit)或是其它使用 apt 的服务器上安装 Scrapy,可以使用下面三条命令:

$ sudo apt-get update
$ sudo apt-get install python-pip python-lxml python-crypto python-
cssselect python-openssl python-w3lib python-twisted python-dev libxml2-
dev libxslt1-dev zlib1g-dev libffi-dev libssl-dev
$ sudo pip install scrapy 

这个方法需要进行编译,可能随时中断,但可以安装 PyPI 上最新版本的 Scrapy。如果想避开编译,安装不是最新版本的话,可以搜索“install Scrapy Ubuntu packages”,按照官方文档安装。

Red Hat 或 CentOS Linux 在使用 yum 的 Linux 上安装 Scrapy 也很简单(测试机是 Ubuntu 14.04 Trusty Tahr - 64 bit)。只需三条命令:

sudo yum update
sudo yum -y install libxslt-devel pyOpenSSL python-lxml python-devel gcc
sudo easy_install scrapy 

从 GitHub 安装 按照前面的指导,就可以安装好 Scrapy 的依赖了。Scrapy 是纯 Python 写成的,如果你想编辑源代码或是测试最新版,可以从https://github.com/scrapy/scrapy克隆最新版,只需命令行输入:

$ git clonehttps://github.com/scrapy/scrapy.git
$ cd scrapy
$ python setup.py install 

我猜如果你是这类用户,就不需要我提醒安装 virtualenv 了。

升级 Scrapy Scrapy 升级相当频繁。如果你需要升级 Scrapy,可以使用 pip、easy_install 或 aptitude:

$ sudo pip install --upgrade Scrapy 

$ sudo easy_install --upgrade scrapy 

如果你想降级或安装指定版本的 Scrapy,可以:

$ sudo pip install Scrapy==1.0.0 

$ sudo easy_install scrapy==1.0.0 

Vagrant:本书案例的运行方法 本书有的例子比较复杂,有的例子使用了许多东西。无论你是什么水平,都可以尝试运行所有例子。只需一句命令,就可以用 Vagrant 搭建操作环境。

本书使用的系统

在 Vagrant 中,你的电脑被称作“主机”。Vagrant 在主机中创建一个虚拟机。这样就可以让我们忽略主机的软硬件,来运行案例了。

本书大多数章节使用了两个服务——开发机和网络机。我们在开发机中登录运行 Scrapy,在网络机中进行抓取。后面的章节会使用更多的服务,包括数据库和大数据处理引擎。

根据附录 A 安装必备,安装 Vagrant,直到安装好 git 和 Vagrant。打开命令行,输入以下命令获取本书的代码:

$ git clone https://github.com/scalingexcellence/scrapybook.git
$ cd scrapybook 

打开 Vagrant:

$ vagrant up --no-parallel 

第一次打开 Vagrant 会需要些时间,这取决于你的网络。第二次打开就会比较快。打开之后,登录你的虚拟机,通过:

$ vagrant ssh 

代码已经从主机中复制到了开发机,现在可以在 book 的目录中看到:

$ cd book
$ ls
$ ch03 ch04 ch05 ch07 ch08 ch09 ch10 ch11 ... 

可以打开几个窗口输入 vagrant ssh,这样就可以打开几个终端。输入 vagrant halt 可以关闭系统,vagrantstatus 可以检查状态。vagrant halt 不能关闭虚拟机。如果在 VirtualBox 中碰到问题,可以手动关闭,或是使用 vagrant global-status 查找 id,用vagrant halt <ID>暂停。大多数例子可以离线运行,这是 Vagrant 的一大优点。

安装好环境之后,就可以开始学习 Scrapy 了。

UR2IM——基础抓取过程 每个网站都是不同的,对每个网站进行额外的研究不可避免,碰到特别生僻的问题,也许还要用 Scrapy 的邮件列表咨询。寻求解答,去哪里找、怎么找,前提是要熟悉整个过程和相关术语。Scrapy 的基本过程,可以写成字母缩略语 UR2IM,见下图。

The URL 一切都从 URL 开始。你需要目标网站的 URL。我的例子是https://www.gumtree.com/,Gumtree 分类网站。

例如,访问伦敦房地产首页http://www.gumtree.com/flats-houses/london,你就可以找到许多房子的 URL。右键复制链接地址,就可以复制 URL。其中一个 URL 可能是这样的:https://www.gumtree.com/p/studios-bedsits-rent/split-level。但是,Gumtree 的网站变动之后,URL 的 XPath 表达式会失效。不添加用户头的话,Gumtree 也不会响应。这个留给以后再说,现在如果你想加载一个网页,你可以使用 Scrapy 终端,如下所示:

scrapy shell -s USER_AGENT="Mozilla/5.0" <your url here  e.g. http://www.gumtree.com/p/studios-bedsits-rent/...> 

要进行调试,可以在 Scrapy 语句后面添加 –pdb,例如:

scrapy shell --pdb https://gumtree.com 

我们不想让大家如此频繁的点击 Gumtree 网站,并且 Gumtree 网站上 URL 失效很快,不适合做例子。我们还希望大家能在离线的情况下,多多练习书中的例子。这就是为什么 Vagrant 开发环境内嵌了一个网络服务器,可以生成和 Gumtree 类似的网页。这些网页可能并不好看,但是从爬虫开发者的角度,是完全合格的。如果想在 Vagrant 上访问 Gumtree,可以在 Vagrant 开发机上访问http://web:9312/,或是在浏览器中访问http://localhost:9312/

让我们在这个网页上尝试一下 Scrapy,在 Vagrant 开发机上输入:

$ scrapy shell http://web:9312/properties/property_000000.html
...
[s] Available Scrapy objects:
[s]   crawler    <scrapy.crawler.Crawler object at 0x2d4fb10>
[s]   item       {}
[s]   request    <GET http:// web:9312/.../property_000000.html>
[s]   response   <200 http://web:9312/.../property_000000.html>
[s]   settings   <scrapy.settings.Settings object at 0x2d4fa90>
[s]   spider     <DefaultSpider 'default' at 0x3ea0bd0>
[s] Useful shortcuts:
[s]   shelp()           Shell help (print this help)
[s]   fetch(req_or_url) Fetch request (or URL) and update local...
[s]   view(response)    View response in a browser
>>> 

得到一些输出,加载页面之后,就进入了 Python(可以使用 Ctrl+D 退出)。

请求和响应 在前面的输出日志中,Scrapy 自动为我们做了一些工作。我们输入了一条地址,Scrapy 做了一个 GET 请求,并得到一个成功响应值 200。这说明网页信息已经成功加载,并可以使用了。如果要打印 reponse.body 的前 50 个字母,我们可以得到:

>>> response.body[:50]
'<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8"' 

这就是这个 Gumtree 网页的 HTML 文档。有时请求和响应会很复杂,第 5 章会对其进行讲解,现在只讲最简单的情况。

抓取对象 下一步是从响应文件中提取信息,输入到 Item。因为这是个 HTML 文档,我们用 XPath 来做。首先来看一下这个网页:

页面上的信息很多,但大多是关于版面的:logo、搜索框、按钮等等。从抓取的角度,它们不重要。我们关注的是,例如,列表的标题、地址、电话。它们都对应着 HTML 里的元素,我们要在 HTML 中定位,用上一章所学的提取出来。先从标题开始。

在标题上右键点击,选择检查元素。在自动定位的 HTML 上再次右键点击,选择复制 XPath。Chrome 给的 XPath 总是很复杂,并且容易失效。我们要对其进行简化。我们只取最后面的 h1。这是因为从 SEO 的角度,每页 HTML 只有一个 h1 最好,事实上大多是网页只有一个 h1,所以不用担心重复。

提示:SEO 是搜索引擎优化的意思:通过对网页代码、内容、链接的优化,提升对搜索引擎的支持。

让我们看看 h1 标签行不行:

>>> response.xpath('//h1/text()').extract()
[u'set unique family well'] 

很好,完全行得通。我在 h1 后面加上了 text(),表示只提取 h1 标签里的文字。没有添加 text()的话,就会这样:

>>> response.xpath('//h1').extract()
[u'<h1 itemprop="name" class="space-mbs">set unique family well</h1>'] 

我们已经成功得到了 title,但是再仔细看看,还能发现更简便的方法。

Gumtree 为标签添加了属性,就是 itemprop=name。所以 XPath 可以简化为//*[@itemprop="name"][1]/text()。在 XPath 中,切记数组是从 1 开始的,所以这里[]里面是 1。

选择 itemprop="name"这个属性,是因为 Gumtree 用这个属性命名了许多其他的内容,比如“You may also like”,用数组序号提取会很方便。

接下来看价格。价格在 HTML 中的位置如下:

<strong class="ad-price txt-xlarge txt-emphasis" itemprop="price">£334.39pw</strong> 

我们又看到了 itemprop="name"这个属性,XPath 表达式为//*[@itemprop="price"][1]/text()。验证一下:

>>> response.xpath('//*[@itemprop="price"][1]/text()').extract()
[u'\xa3334.39pw'] 

注意 Unicode 字符(£符号)和价格 350.00pw。这说明要对数据进行清理。在这个例子中,我们用正则表达式提取数字和小数点。使用正则方法如下:

>>> response.xpath('//*[@itemprop="price"][1]/text()').re('[.0-9]+')
[u'334.39'] 

提取房屋描述的文字、房屋的地址也很类似,如下:

//*[@itemprop="description"][1]/text()
//*[@itemtype="http://schema.org/Place"][1]/text() 

相似的,抓取图片可以用//img[@itemprop="image"][1]/@src。注意这里没使用 text(),因为我们只想要图片的 URL。

假如这就是我们要提取的所有信息,整理如下:

目标 XPath 表达式
title //*[@itemprop="name"][1]/text()
Example value: [u'set unique family well']
Price //*[@itemprop="price"][1]/text()
Example value (using re()):[u'334.39']
description //*[@itemprop="description"][1]/text()
Example value: [u'website court warehouse\r\npool...']
Address //*[@itemtype="http://schema.org/Place"][1]/text()
Example value: [u'Angel, London']
Image_URL //*[@itemprop="image"][1]/@src
Example value: [u'img/i01.jpg']

这张表很重要,因为也许只要稍加改变表达式,就可以抓取其他页面。另外,如果要爬取数十个网站时,使用这样的表可以进行区分。

目前为止,使用的还只是 HTML 和 XPath,接下来用 Python 来做一个项目。

一个 Scrapy 项目 目前为止,我们只是在 Scrapy shell 中进行操作。学过前面的知识,现在开始一个 Scrapy 项目,Ctrl+D 退出 Scrapy shell。Scrapy shell 只是操作网页、XPath 表达式和 Scrapy 对象的工具,不要在上面浪费太多,因为只要一退出,写过的代码就会消失。我们创建一个名字是 properties 的项目:

$ scrapy startproject properties
$ cd properties
$ tree
.
├── properties
│   ├── __init__.py
│   ├── items.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       └── __init__.py
└── scrapy.cfg
2 directories, 6 files 

先看看这个 Scrapy 项目的文件目录。文件夹内包含一个同名的文件夹,里面有三个文件 items.py, pipelines.py, 和 settings.py。还有一个子文件夹 spiders,里面现在是空的。后面的章节会详谈 settings、pipelines 和 scrapy.cfg 文件。

定义 items 用编辑器打开 items.py。里面已经有代码,我们要对其修改下。用之前的表里的内容重新定义 class PropertiesItem。

还要添加些后面会用到的内容。后面会深入讲解。这里要注意的是,声明一个字段,并不要求一定要填充。所以放心添加你认为需要的字段,后面还可以修改。

字段 Python 表达式
images pipeline 根据 image_URL 会自动填充这里。后面详解。
Location 地理编码会填充这里。后面详解。

我们还会加入一些杂务字段,也许和现在的项目关系不大,但是我个人很感兴趣,以后或许能用到。你可以选择添加或不添加。观察一下这些项目,你就会明白,这些项目是怎么帮助我找到何地(server,url),何时(date),还有(爬虫)如何进行抓取的。它们可以帮助我取消项目,制定新的重复抓取,或忽略爬虫的错误。这里看不明白不要紧,后面会细讲。

杂务字段 Python 表达式
url response.url
Example value: ‘http://web.../property_000000.html'
project self.settings.get('BOT_NAME')
Example value: 'properties'
spider self.name
Example value: 'basic'
server server socket.gethostname()
Example value: 'scrapyserver1'
date datetime.datetime.now()
Example value: datetime.datetime(2015, 6, 25...)

利用这个表修改 PropertiesItem 这个类。修改文件 properties/items.py 如下:

from scrapy.item import Item, Field

class PropertiesItem(Item):
    # Primary fields
    title = Field()
    price = Field()
    description = Field()
    address = Field()
    image_URL = Field()

# Calculated fields
    images = Field()
    location = Field()

 # Housekeeping fields
    url = Field()
    project = Field()
    spider = Field()
    server = Field()
    date = Field() 

这是我们的第一段代码,要注意 Python 中是使用空格缩进的。每个字段名之前都有四个空格或是一个 tab。如果一行有四个空格,另一行有三个空格,就会报语法错误。如果一行是四个空格,另一行是一个 tab,也会报错。空格符指定了这些项目是在 PropertiesItem 下面的。其他语言有的用花括号{},有的用 begin – end,Python 则使用空格。

编写爬虫 已经完成了一半。现在来写爬虫。一般的,每个网站,或一个大网站的一部分,只有一个爬虫。爬虫代码来成 UR2IM 流程。

当然,你可以用文本编辑器一句一句写爬虫,但更便捷的方法是用 scrapy genspider 命令,如下所示:

$ scrapy genspider basic web 

使用模块中的模板“basic”创建了一个爬虫“basic”:

 properties.spiders.basic 

一个爬虫文件 basic.py 就出现在目录 properties/spiders 中。刚才的命令是,生成一个名字是 basic 的默认文件,它的限制是在 web 上爬取 URL。我们可以取消这个限制。这个爬虫使用的是 basic 这个模板。你可以用 scrapy genspider –l 查看所有的模板,然后用参数–t 利用模板生成想要的爬虫,后面会介绍一个例子。

查看 properties/spiders/basic.py file 文件, 它的代码如下:

import scrapy
class BasicSpider(scrapy.Spider):
    name = "basic"
    allowed_domains = ["web"]
start_URL = (
        'http://www.web/',
    )
    def parse(self, response):
        pass 

import 命令可以让我们使用 Scrapy 框架。然后定义了一个类 BasicSpider,继承自 scrapy.Spider。继承的意思是,虽然我们没写任何代码,这个类已经继承了 Scrapy 框架中的类 Spider 的许多特性。这允许我们只需写几行代码,就可以有一个功能完整的爬虫。然后我们看到了一些爬虫的参数,比如名字和抓取域字段名。最后,我们定义了一个空函数 parse(),它有两个参数 self 和 response。通过 self,可以使用爬虫一些有趣的功能。response 看起来很熟悉,它就是我们在 Scrapy shell 中见到的响应。

下面来开始编辑这个爬虫。start_URL 更改为在 Scrapy 命令行中使用过的 URL。然后用爬虫事先准备的 log()方法输出内容。修改后的 properties/spiders/basic.py 文件为:

import scrapy
class BasicSpider(scrapy.Spider):
    name = "basic"
    allowed_domains = ["web"]
    start_URL = (
        'http://web:9312/properties/property_000000.html',
    )
    def parse(self, response):
        self.log("title: %s" % response.xpath(
            '//*[@itemprop="name"][1]/text()').extract())
        self.log("price: %s" % response.xpath(
            '//*[@itemprop="price"][1]/text()').re('[.0-9]+'))
        self.log("description: %s" % response.xpath(
        '//*[@itemprop="description"][1]/text()').extract())
        self.log("address: %s" % response.xpath(
            '//*[@itemtype="http://schema.org/'
            'Place"][1]/text()').extract())
        self.log("image_URL: %s" % response.xpath(
            '//*[@itemprop="image"][1]/@src').extract()) 

总算到了运行爬虫的时间!让爬虫运行的命令是 scrapy crawl 接上爬虫的名字:

$ scrapy crawl basic
INFO: Scrapy 1.0.3 started (bot: properties)
...
INFO: Spider opened
DEBUG: Crawled (200) <GET http://...000.html>
DEBUG: title: [u'set unique family well']
DEBUG: price: [u'334.39']
DEBUG: description: [u'website...']
DEBUG: address: [u'Angel, London']
DEBUG: image_URL: [u'img/i01.jpg']
INFO: Closing spider (finished)
... 

成功了!不要被这么多行的命令吓到,后面我们再仔细说明。现在,我们可以看到使用这个简单的爬虫,所有的数据都用 XPath 得到了。

来看另一个命令,scrapy parse。它可以让我们选择最合适的爬虫来解析 URL。用—spider 命令可以设定爬虫:

$ scrapy parse --spider=basic http://web:9312/properties/property_000001.html 

你可以看到输出的结果和前面的很像,但却是关于另一个房产的。

填充一个项目 接下来稍稍修改一下前面的代码。你会看到,尽管改动很小,却可以解锁许多新的功能。

首先,引入类 PropertiesItem。它位于 properties 目录中的 item.py 文件,因此在模块 properties.items 中。它的导入命令是:

from properties.items import PropertiesItem 

然后我们要实例化,并进行返回。这很简单。在 parse()方法中,我们加入声明 item = PropertiesItem(),它产生了一个新项目,然后为它分配表达式:

item['title'] = response.xpath('//*[@itemprop="name"][1]/text()').extract() 

最后,我们用 return item 返回项目。更新后的 properties/spiders/basic.py 文件如下:

import scrapy
from properties.items import PropertiesItem
class BasicSpider(scrapy.Spider):
    name = "basic"
    allowed_domains = ["web"]
    start_URL = (
        'http://web:9312/properties/property_000000.html',
    )
    def parse(self, response):
        item = PropertiesItem()
        item['title'] = response.xpath(
            '//*[@itemprop="name"][1]/text()').extract()
        item['price'] = response.xpath(
            '//*[@itemprop="price"][1]/text()').re('[.0-9]+')
        item['description'] = response.xpath(
            '//*[@itemprop="description"][1]/text()').extract()
        item['address'] = response.xpath(
            '//*[@itemtype="http://schema.org/'
            'Place"][1]/text()').extract()
        item['image_URL'] = response.xpath(
            '//*[@itemprop="image"][1]/@src').extract()
        return item 

现在如果再次运行爬虫,你会注意到一个不大但很重要的改动。被抓取的值不再打印出来,没有“DEBUG:被抓取的值”了。你会看到:

DEBUG: Scraped from <200  
http://...000.html>
  {'address': [u'Angel, London'],
   'description': [u'website ... offered'],
   'image_URL': [u'img/i01.jpg'],
   'price': [u'334.39'],
   'title': [u'set unique family well']} 

这是从这个页面抓取的 PropertiesItem。这很好,因为 Scrapy 就是围绕 Items 的概念构建的,这意味着我们可以用 pipelines 填充丰富项目,或是用“Feed export”导出保存到不同的格式和位置。

保存到文件 试运行下面:

$ scrapy crawl basic -o items.json
$ cat items.json
[{"price": ["334.39"], "address": ["Angel, London"], "description": 
["website court ... offered"], "image_URL": ["img/i01.jpg"], 
"title": ["set unique family well"]}]
$ scrapy crawl basic -o items.jl
$ cat items.jl
{"price": ["334.39"], "address": ["Angel, London"], "description": 
["website court ... offered"], "image_URL": ["img/i01.jpg"], 
"title": ["set unique family well"]}
$ scrapy crawl basic -o items.csv
$ cat items.csv 
description,title,url,price,spider,image_URL...
"...offered",set unique family well,,334.39,,img/i01.jpg
$ scrapy crawl basic -o items.xml
$ cat items.xml 
<?xml version="1.0" encoding="utf-8"?>
<items><item><price><value>334.39</value></price>...</item></items> 

不用我们写任何代码,我们就可以用这些格式进行存储。Scrapy 可以自动识别输出文件的后缀名,并进行输出。这段代码中涵盖了一些常用的格式。CSV 和 XML 文件很流行,因为可以被 Excel 直接打开。JSON 文件很流行是因为它的开放性和与 JavaScript 的密切关系。JSON 和 JSON Line 格式的区别是.json 文件是在一个大数组中存储 JSON 对象。这意味着如果你有一个 1GB 的文件,你可能必须现在内存中存储,然后才能传给解析器。相对的,.jl 文件每行都有一个 JSON 对象,所以读取效率更高。

不在文件系统中存储生成的文件也很麻烦。利用下面例子的代码,你可以让 Scrapy 自动上传文件到 FTP 或亚马逊的 S3 bucket。

$ scrapy crawl basic -o "ftp://user:[email protected]/items.json "
$ scrapy crawl basic -o "s3://aws_key:aws_secret@scrapybook/items.json" 

注意,证书和 URL 必须按照主机和 S3 更新,才能顺利运行。

另一个要注意的是,如果你现在使用 scrapy parse,它会向你显示被抓取的项目和抓取中新的请求:

$ scrapy parse --spider=basic http://web:9312/properties/property_000001.html
INFO: Scrapy 1.0.3 started (bot: properties)
...
INFO: Spider closed (finished)
>>> STATUS DEPTH LEVEL 1 <<<
# Scraped Items  ------------------------------------------------
[{'address': [u'Plaistow, London'],
  'description': [u'features'],
  'image_URL': [u'img/i02.jpg'],
  'price': [u'388.03'],
  'title': [u'belsize marylebone...deal']}]
# Requests  ------------------------------------------------
[] 

当出现意外结果时,scrapy parse 可以帮你进行 debug,你会更感叹它的强大。

清洗——项目加载器和杂务字段 恭喜你,你已经创建成功一个简单爬虫了!让我们让它看起来更专业些。

我们使用一个功能类,ItemLoader,以取代看起来杂乱的 extract()和 xpath()。我们的 parse()进行如下变化:

def parse(self, response):
    l = ItemLoader(item=PropertiesItem(), response=response)
    l.add_xpath('title', '//*[@itemprop="name"][1]/text()')
    l.add_xpath('price', './/*[@itemprop="price"]'
           '[1]/text()', re='[,.0-9]+')
    l.add_xpath('description', '//*[@itemprop="description"]'
           '[1]/text()')
    l.add_xpath('address', '//*[@itemtype='
           '"http://schema.org/Place"][1]/text()')
    l.add_xpath('image_URL', '//*[@itemprop="image"][1]/@src')
    return l.load_item() 

是不是看起来好多了?事实上,它可不是看起来漂亮那么简单。它指出了我们现在要干什么,并且后面的加载项很清晰。这提高了代码的可维护性和自文档化。(自文档化,self-documenting,是说代码的可读性高,可以像文档文件一样阅读)

ItemLoaders 提供了许多有趣的方式整合数据、格式化数据、清理数据。它的更新很快,查阅文档可以更好的使用它,http://doc.scrapy.org/en/latest/topics/loaders。通过不同的类处理器,ItemLoaders 从 XPath/CSS 表达式传参。处理器函数快速小巧。举一个 Join()的例子。//p 表达式会选取所有段落,这个处理函数可以在一个入口中将所有内容整合起来。另一个函数 MapCompose(),可以与 Python 函数或 Python 函数链结合,实现复杂的功能。例如,MapCompose(float)可以将字符串转化为数字,MapCompose(unicode.strip, unicode.title)可以去除多余的空格,并将单词首字母大写。让我们看几个处理函数的例子:

处理函数 功能
Join() 合并多个结果。
MapCompose(unicode.strip) 除去空格。
MapCompose(unicode.strip, unicode.title) 除去空格,单词首字母大写。
MapCompose(float) 将字符串转化为数字。
MapCompose(lambda i: i.replace(',', ''), float) 将字符串转化为数字,逗号替换为空格。
MapCompose(lambda i: urlparse.urljoin(response.url, i)) 使用 response.url 为开头,将相对 URL 转化为绝对 URL。

你可以使用 Python 编写处理函数,或是将它们串联起来。unicode.strip()和 unicode.title()分别用单一参数实现了单一功能。其它函数,如 replace()和 urljoin()需要多个参数,我们可以使用 Lambda 函数。这是一个匿名函数,可以不声明函数就调用参数:

myFunction = lambda i: i.replace(',', '') 

可以取代下面的函数:

def myFunction(i):
    return i.replace(',', '') 

使用 Lambda 函数,打包 replace()和 urljoin(),生成一个结果,只需一个参数即可。为了更清楚前面的表,来看几个实例。在 scrapy 命令行打开任何 URL,并尝试:

 >>> from scrapy.loader.processors import MapCompose, Join
>>> Join()(['hi','John'])
u'hi John'
>>> MapCompose(unicode.strip)([u'  I',u' am\n'])
[u'I', u'am']
>>> MapCompose(unicode.strip, unicode.title)([u'nIce cODe'])
[u'Nice Code']
>>> MapCompose(float)(['3.14'])
[3.14]
>>> MapCompose(lambda i: i.replace(',', ''), float)(['1,400.23'])
[1400.23]
>>> import urlparse
>>> mc = MapCompose(lambda i: urlparse.urljoin('http://my.com/test/abc', i))
>>> mc(['example.html#check'])
['http://my.com/test/example.html#check']
>>> mc(['http://absolute/url#help'])
['http://absolute/url#help'] 

要记住,处理函数是对 XPath/CSS 结果进行后处理的的小巧函数。让我们来看几个我们爬虫中的处理函数是如何清洗结果的:

def parse(self, response):
    l.add_xpath('title', '//*[@itemprop="name"][1]/text()',
                MapCompose(unicode.strip, unicode.title))
    l.add_xpath('price', './/*[@itemprop="price"][1]/text()',
                MapCompose(lambda i: i.replace(',', ''), float),
                re='[,.0-9]+')
    l.add_xpath('description', '//*[@itemprop="description"]'
                '[1]/text()', MapCompose(unicode.strip), Join())
    l.add_xpath('address',
               '//*[@itemtype="http://schema.org/Place"][1]/text()',
                MapCompose(unicode.strip))
    l.add_xpath('image_URL', '//*[@itemprop="image"][1]/@src',
                MapCompose(
                lambda i: urlparse.urljoin(response.url, i))) 

完整的列表在本章后面给出。如果你用 scrapy crawl basic 再运行的话,你可以得到干净的结果如下:

'price': [334.39],
'title': [u'Set Unique Family Well'] 

最后,我们可以用 add_value()方法添加用 Python(不用 XPath/CSS 表达式)计算得到的值。我们用它设置我们的“杂务字段”,例如 URL、爬虫名、时间戳等等。我们直接使用前面杂务字段表里总结的表达式,如下:

l.add_value('url', response.url)
l.add_value('project', self.settings.get('BOT_NAME'))
l.add_value('spider', self.name)
l.add_value('server', socket.gethostname())
l.add_value('date', datetime.datetime.now()) 

记得 import datetime 和 socket,以使用这些功能。

现在,我们的 Items 看起来就完美了。我知道你的第一感觉是,这可能太复杂了,值得吗?回答是肯定的,这是因为或多或少,想抓取网页信息并存到 items 里,这就是你要知道的全部。这段代码如果用其他语言来写,会非常难看,很快就不能维护了。用 Scrapy,只要 25 行简洁的代码,它明确指明了意图,你可以看清每行的意义,可以清晰的进行修改、再利用和维护。

你的另一个感觉可能是处理函数和 ItemLoaders 太花费精力。如果你是一名经验丰富的 Python 开发者,你已经会使用字符串操作、lambda 表达构造列表,再学习新的知识会觉得不舒服。然而,这只是对 ItemLoader 和其功能的简单介绍,如果你再深入学习一点,你就不会这么想了。ItemLoaders 和处理函数是专为有抓取需求的爬虫编写者、维护者开发的工具集。如果你想深入学习爬虫的话,它们是绝对值得学习的。

创建协议 协议有点像爬虫的单元测试。它们能让你快速知道错误。例如,假设你几周以前写了一个抓取器,它包含几个爬虫。你想快速检测今天是否还是正确的。协议位于评论中,就在函数名后面,协议的开头是@。看下面这个协议:

def parse(self, response):
    """ This function parses a property page.
    @url http://web:9312/properties/property_000000.html
    @returns items 1
    @scrapes title price description address image_URL
    @scrapes url project spider server date
    """ 

这段代码是说,检查这个 URL,你可以在找到一个项目,它在那些字段有值。现在如果你运行 scrapy check,它会检查协议是否被满足:

$ scrapy check basic
----------------------------------------------------------------
Ran 3 contracts in 1.640s
OK
如果 url 的字段是空的被注释掉),你会得到一个描述性错误FAIL: [basic] parse (@scrapes post-hook)
------------------------------------------------------------------
ContractFail: 'url' field is missing 

当爬虫代码有错,或是 XPath 表达式过期,协议就可能失效。当然,协议不会特别详细,但是可以清楚的指出代码的错误所在。

综上所述,我们的第一个爬虫如下所示:

from scrapy.loader.processors import MapCompose, Join
from scrapy.loader import ItemLoader
from properties.items import PropertiesItem
import datetime
import urlparse
import socket
import scrapy

class BasicSpider(scrapy.Spider):
    name = "basic"
    allowed_domains = ["web"]
    # Start on a property page
    start_URL = (
        'http://web:9312/properties/property_000000.html',
    )
    def parse(self, response):
        """ This function parses a property page.
        @url http://web:9312/properties/property_000000.html
        @returns items 1
        @scrapes title price description address image_URL
        @scrapes url project spider server date
        """
        # Create the loader using the response
        l = ItemLoader(item=PropertiesItem(), response=response)
        # Load fields using XPath expressions
        l.add_xpath('title', '//*[@itemprop="name"][1]/text()',
                    MapCompose(unicode.strip, unicode.title))
        l.add_xpath('price', './/*[@itemprop="price"][1]/text()',
                    MapCompose(lambda i: i.replace(',', ''),  
                    float),
                    re='[,.0-9]+')
        l.add_xpath('description', '//*[@itemprop="description"]'
                    '[1]/text()',
                    MapCompose(unicode.strip), Join())
        l.add_xpath('address',
                    '//*[@itemtype="http://schema.org/Place"]'
                    '[1]/text()',
                    MapCompose(unicode.strip))
        l.add_xpath('image_URL', '//*[@itemprop="image"]'
                    '[1]/@src', MapCompose(
                    lambda i: urlparse.urljoin(response.url, i)))
        # Housekeeping fields
        l.add_value('url', response.url)
        l.add_value('project', self.settings.get('BOT_NAME'))
        l.add_value('spider', self.name)
        l.add_value('server', socket.gethostname())
        l.add_value('date', datetime.datetime.now())
        return l.load_item() 

提取更多的 URL 到目前为止,在爬虫的 start_URL 中我们还是只加入了一条 URL。因为这是一个元组,我们可以向里面加入多个 URL,例如:

start_URL = (
    'http://web:9312/properties/property_000000.html',
    'http://web:9312/properties/property_000001.html',
    'http://web:9312/properties/property_000002.html',
) 

不够好。我们可以用一个文件当做 URL 源文件:

start_URL = [i.strip() for i in  
open('todo.URL.txt').readlines()] 

还是不够好,但行得通。更常见的,网站可能既有索引页也有列表页。例如,Gumtree 有索引页:http://www.gumtree.com/flats-houses/london

一个典型的索引页包含许多列表页、一个分页系统,让你可以跳转到其它页面。

因此,一个典型的爬虫在两个方向移动:

  • 水平——从索引页到另一个索引页
  • 垂直——从索引页面到列表页面提取项目

在本书中,我们称前者为水平抓取,因为它在同一层次(例如索引)上抓取页面;后者为垂直抓取,因为它从更高层次(例如索引)移动到一个较低的层次(例如列表)。

做起来要容易许多。我们只需要两个 XPath 表达式。第一个,我们右键点击 Next page 按钮,URL 位于 li 中,li 的类名含有 next。因此 XPath 表达式为//*[contains(@class,"next")]//@href。

对于第二个表达式,我们在列表的标题上右键点击,选择检查元素:

这个 URL 有一个属性是 itemprop="url。因此,表达式确定为//*[@itemprop="url"]/@href。打开 scrapy 命令行进行确认:

$ scrapy shell http://web:9312/properties/index_00000.html
>>> URL = response.xpath('//*[contains(@class,"next")]//@href').extract()
>>> URL
[u'index_00001.html']
>>> import urlparse
>>> [urlparse.urljoin(response.url, i) for i in URL]
[u'http://web:9312/scrapybook/properties/index_00001.html']
>>> URL = response.xpath('//*[@itemprop="url"]/@href').extract()
>>> URL
[u'property_000000.html', ... u'property_000029.html']
>>> len(URL)
30
>>> [urlparse.urljoin(response.url, i) for i in URL]
[u'http://..._000000.html', ... /property_000029.html'] 

很好,我们看到有了这两个表达式,就可以进行水平和垂直抓取 URL 了。

使用爬虫进行二维抓取 将前一个爬虫代码复制到新的爬虫 manual.py 中:

$ ls
properties  scrapy.cfg
$ cp properties/spiders/basic.py properties/spiders/manual.py 

在 properties/spiders/manual.py 中,我们通过添加 from scrapy.http import Request 引入 Request,将爬虫的名字改为 manual,将 start_URL 改为索引首页,将 parse()重命名为 parse_item()。接下来写心得 parse()方法进行水平和垂直的抓取:

def parse(self, response):
    # Get the next index URL and yield Requests
    next_selector = response.xpath('//*[contains(@class,'
                                   '"next")]//@href')
    for url in next_selector.extract():
        yield Request(urlparse.urljoin(response.url, url))

    # Get item URL and yield Requests
    item_selector = response.xpath('//*[@itemprop="url"]/@href')
    for url in item_selector.extract():
        yield Request(urlparse.urljoin(response.url, url),
                      callback=self.parse_item) 

提示:你可能注意到了 yield 声明。它和 return 很像,不同之处是 return 会退出循环,而 yield 不会。从功能上讲,前面的例子与下面很像

next_requests = []
for url in...
   next_requests.append(Request(...))
for url in...
   next_requests.append(Request(...))
return next_requests 

yield 可以大大提高 Python 编程的效率。

做好爬虫了。但如果让它运行起来的话,它将抓取 5 万张页面。为了避免时间太长,我们可以通过命令-s CLOSESPIDER_ITEMCOUNT=90(更多的设定见第 7 章),设定爬虫在一定数量(例如,90)之后停止运行。开始运行:

$ scrapy crawl manual -s CLOSESPIDER_ITEMCOUNT=90
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Crawled (200) <...index_00000.html> (referer: None)
DEBUG: Crawled (200) <...property_000029.html> (referer: ...index_00000.html)
DEBUG: Scraped from <200 ...property_000029.html>
  {'address': [u'Clapham, London'],
   'date': [datetime.datetime(2015, 10, 4, 21, 25, 22, 801098)],
   'description': [u'situated camden facilities corner'],
   'image_URL': [u'http://web:93img/i10.jpg'],
   'price': [223.88],
   'project': ['properties'],
  'server': ['scrapyserver1'],
   'spider': ['manual'],
   'title': [u'Portered Mile'],
   'url': ['http://.../property_000029.html']}
DEBUG: Crawled (200) <...property_000028.html> (referer: ...index_00000.
html)
...
DEBUG: Crawled (200) <...index_00001.html> (referer: ...)
DEBUG: Crawled (200) <...property_000059.html> (referer: ...)
...
INFO: Dumping Scrapy stats: ...
   'downloader/request_count': 94, ...
   'item_scraped_count': 90, 

查看输出,你可以看到我们得到了水平和垂直两个方向的结果。首先读取了 index_00000.html, 然后产生了许多请求。执行请求的过程中,debug 信息指明了谁用 URL 发起了请求。例如,我们看到,property_000029.html, property_000028.html ... 和 index_00001.html 都有相同的 referer(即 index_00000.html)。然后,property_000059.html 和其它网页的 referer 是 index_00001,过程以此类推。

这个例子中,Scrapy 处理请求的机制是后进先出(LIFO),深度优先抓取。最后提交的请求先被执行。这个机制适用于大多数情况。例如,我们想先抓取完列表页再取下一个索引页。不然的话,我们必须消耗内存存储列表页的 URL。另外,许多时候你想用一个辅助的 Requests 执行一个请求,下一章有例子。你需要 Requests 越早完成越好,以便爬虫继续下面的工作。

我们可以通过设定 Request()参数修改默认的顺序,大于 0 时是高于默认的优先级,小于 0 时是低于默认的优先级。通常,Scrapy 会先执行高优先级的请求,但不会花费太多时间思考到底先执行哪一个具体的请求。在你的大多数爬虫中,你不会有超过一个或两个的请求等级。因为 URL 会被多重过滤,如果我们想向一个 URL 多次请求,我们可以设定参数 dont_filter Request()为 True。

用 CrawlSpider 二维抓取 如果你觉得这个二维抓取单调的话,说明你入门了。Scrapy 试图简化这些琐事,让编程更容易。完成之前结果的更好方法是使用 CrawlSpider,一个简化抓取的类。我们用 genspider 命令,设定一个-t 参数,用爬虫模板创建一个爬虫:

$ scrapy genspider -t crawl easy web
Created spider 'crawl' using template 'crawl' in module:
  properties.spiders.easy 

现在 properties/spiders/easy.py 文件包含如下所示:

...
class EasySpider(CrawlSpider):
    name = 'easy'
    allowed_domains = ['web']
    start_URL = ['http://www.web/']
    rules = (
        Rule(LinkExtractor(allow=r'Items/'),  
callback='parse_item', follow=True),
    )
    def parse_item(self, response):
        ... 

这段自动生成的代码和之前的很像,但是在类的定义中,这个爬虫从 CrawlSpider 定义的,而不是 Spider。CrawlSpider 提供了一个包含变量 rules 的 parse()方法,以完成之前我们手写的内容。

现在将 start_URL 设定为索引首页,并将 parse_item()方法替换。这次不再使用 parse()方法,而是将 rules 变成两个 rules,一个负责水平抓取,一个负责垂直抓取:

rules = (
Rule(LinkExtractor(restrict_xpaths='//*[contains(@class,"next")]')),
Rule(LinkExtractor(restrict_xpaths='//*[@itemprop="url"]'),
         callback='parse_item')
) 

两个 XPath 表达式与之前相同,但没有了 a 与 href 的限制。正如它们的名字,LinkExtractor 专门抽取链接,默认就是寻找 a、href 属性。你可以设定 tags 和 attrs 自定义 LinkExtractor()。对比前面的请求方法 Requests(self.parse_item),回调的字符串中含有回调方法的名字(例如,parse_item)。最后,除非设定 callback,一个 Rule 就会沿着抽取的 URL 扫描外链。设定 callback 之后,Rule 才能返回。如果你想让 Rule 跟随外链,你应该从 callback 方法 return/yield,或设定 Rule()的 follow 参数为 True。当你的列表页既有 Items 又有其它有用的导航链接时非常有用。

你现在可以运行这个爬虫,它的结果与之前相同,但简洁多了:

$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=90 

总结 对所有学习 Scrapy 的人,本章也许是最重要的。你学习了爬虫的基本流程 UR2IM、如何自定义 Items、使用 ItemLoaders,XPath 表达式、利用处理函数加载 Items、如何 yield 请求。我们使用 Requests 水平抓取多个索引页、垂直抓取列表页。最后,我们学习了如何使用 CrawlSpider 和 Rules 简化代码。多度几遍本章以加深理解、创建自己的爬虫。

我们刚刚从一个网站提取了信息。它的重要性在哪呢?答案在下一章,我们只用几页就能制作一个移动 app,并用 Scrapy 填充数据。