《Learning Scrapy》(中文版)第3章 爬虫基础


序言
第1章 Scrapy介绍
第2章 理解HTML和XPath
第3章 爬虫基础
第4章 从Scrapy到移动应用
第5章 快速构建爬虫
第6章 Scrapinghub部署
第7章 配置和管理
第8章 Scrapy编程
第9章 使用Pipeline
第10章 理解Scrapy的性能
第11章(完) Scrapyd分布式抓取和实时分析


本章非常重要,你可能需要读几遍,或是从中查找解决问题的方法。我们会从如何安装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'../images/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'../images/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'../images/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": ["../images/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": ["../images/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,,../images/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:pass@ftp.scrapybook.com/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'../images/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:9312/images/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填充数据。


序言
第1章 Scrapy介绍
第2章 理解HTML和XPath
第3章 爬虫基础
第4章 从Scrapy到移动应用
第5章 快速构建爬虫
第6章 Scrapinghub部署
第7章 配置和管理
第8章 Scrapy编程
第9章 使用Pipeline
第10章 理解Scrapy的性能
第11章(完) Scrapyd分布式抓取和实时分析


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容