四、从 Scrapy 到移动应用
有人问,移动 app 开发平台 Appery.io 和 Scrapy 有什么关系?眼见为实。在几年前,用 Excel 向别人展示数据才可以让人印象深刻。现在,除非你的受众分布很窄,他们彼此之间是非常不同的。接下来几页,你会看到一个快速构建的移动应用,一个最小可行产品。它可以向别人清楚的展示你抓取的数据的力量,为源网站搭建的生态系统带来回报。
我尽量让这个挖掘数据价值的例子简短。要是你自己就有一个使用数据的应用,你可以跳过本章。本章就是告诉你如何用现在最流行的方式,移动应用,让你的数据面向公众。
选择移动应用框架
使用适当的工具向移动应用导入数据是相当容易的。跨平台开发移动应用的框架很多,例如 PhoneGap、Appcelerator 和 Appcelerator 云服务、jQuery Mobile 和 Sencha Touch。
本章会使用 Appery.io,因为它可以让我们用 PhoneGap 和 jQuery Mobile 快速开发 iOS、Android、Windows Phone、HTML5 移动应用。我并不是要为 Appery.io 代言,我鼓励你自己去调研下它是否符合你的需求。Appery.io 是一个付费服务,但有 14 天的试用期。在我看来,即使是外行也可以用 Appery.io 快速创建一个应用。我选择它的原因是,它提供了移动和后端两个服务,所以我们不用配置数据库、写 REST APIs、或在服务器和移动端使用不同的语言。你将看到,我们根本不用写任何代码!我们会使用它的在线工具,你可以随时下载 app 作为 PhoneGap 项目,使用 PhoneGap 的全部特性。
使用 Appery.io,你需要连接网络。另外,因为它的网站可能会发生改变,如果和截图不同不要惊讶。
创建数据库和集合
第一步是注册 Appery.io,并选择试用。提供名字、Emai 密码之后,你的账户就创立了。登录 Appery.io 工作台,你就可以创建数据库和集合了:
步骤如下:
1.点击 Databases 标签(1)。
2.然后点击绿色的 Create new database 按钮(2)。将新数据库命名为 scrapy(3)。
3.现在点击 Create 按钮(4)。自动打开 Scrapy 数据库工作台,在工作台上可以新建集合。
在 Appery.io 中,数据库是集合的整合。粗略的讲,一个应用使用一个数据库,这个数据库中有许多集合,例如用户、特性、信息等等。Appery.io 已经有了一个 Users 集合,用来存储用户名和密码(Appery.io 有许多内建的功能)。
让我们添加一个用户,用户名是 root,密码是 pass。显然,密码可以更复杂。在侧边栏点击 Users(1),然后点击+Row(2)添加 user/row。在弹出的界面中输入用户名和密码(3,4)。
再为 Scrapy 抓取的数据创建一个集合,命名为 properties。点击 Create new collection 绿色按钮(5),命名为 properties(6),点击 Add 按钮(7)。现在,我们需要自定义这个集合。点击+Col 添加列(8)。列有一些数据类型可以帮助确认值。大多数要填入的是字符串,除了价格是个数字。点击+Col(8)再添加几列,填入列的名字(9)、数据类型(10),然后点击 Create column 按钮(11)。重复五次这个步骤以创建下表:
创建好所有列之后,就可以导入数据了。
用 Scrapy 导入数据
首先,我们需要 API key,在 Settings 中可以找到(1)。复制它(2),然后点击 Collections 标签返回集合(3):
现在,修改一下上一章的代码,以导入数据。我们把名字是 easy.py 的爬虫中的代码复制到名字是 tomobile.py 的爬虫中:
$ ls
properties scrapy.cfg
$ cat properties/spiders/tomobile.py
...
class ToMobileSpider(CrawlSpider):
name = 'tomobile'
allowed_domains = ["scrapybook.s3.amazonaws.com"]
# Start on the first index page
start_URL = (
'http://scrapybook.s3.amazonaws.com/properties/'
'index_00000.html',
)
...
你可能注意到了,我们没有使用网络服务器http://web:9312。我们用的是我托管在http://scrapybook.s3.amazonaws.com上的副本。使用它,我们的图片和 URL 所有人都可以访问,更易分享我们的 app。
我们使用 Appery.io pipline 导入数据。Scrapy 的 pipelines 是后处理的、简洁的、可以存储 items 的很小的 Python 类。第 8 章中会详细讲解两者。现在,你可以用 easy_install 或 pip 安装,但如果你用 Vagrant 开发机,因为已经都安装好了,你就不用再安装了:
$ sudo easy_install -U scrapyapperyio
或
$ sudo pip install --upgrade scrapyapperyio
这时,要在 Scrapy 的设置文件中添加 API key。更多关于设置的内容会在第 7 章中介绍。现在,我们只需在在 properties/settings.py 文件后面加入如下代码:
ITEM_PIPELINES = {'scrapyapperyio.ApperyIoPipeline': 300}
APPERYIO_DB_ID = '<<Your API KEY here>>'
APPERYIO_USERNAME = 'root'
APPERYIO_PASSWORD = 'pass'
APPERYIO_COLLECTION_NAME = 'properties'
别忘了将 APPERYIO_DB_ID 替换为 API key。还要确认你的设置有和 Appery.io 相同的用户名和密码。要进行向 Appery.io 注入数据,像之前一样用 Scrapy 抓取:
$ scrapy crawl tomobile -s CLOSESPIDER_ITEMCOUNT=90
INFO: Scrapy 1.0.3 started (bot: properties)
...
INFO: Enabled item pipelines: ApperyIoPipeline
INFO: Spider opened
...
DEBUG: Crawled (200) <GET https://api.appery.io/rest/1/db/login?username=
root&password=pass>
...
DEBUG: Crawled (200) <POST https://api.appery.io/rest/1/db/collections/
properties>
...
INFO: Dumping Scrapy stats:
{'downloader/response_count': 215,
'item_scraped_count': 105,
...}
INFO: Spider closed (closespider_itemcount)
输出的结果略有不用。你可以看到代码的前几行运行了 ApperyIoPipeline 的项目 pipeline;更显著的是,大概抓取了 100 个项目,有约 200 个请求/响应。这是因为 Appery.io pipeline 为写入每个项目,都额外的做了一次请求。这些请求也出现在日志中,带有 api.appery.io URL。
如果返回 Appery.io,我们可以 properties 集合(1)中填入了数据(2)。
创建移动应用
创建移动应用有点繁琐。点击 Apps 标签(1),然后点击 Create new app(2)。将这个应用命名为 properties(3),再点击 Create 按钮(4):
创建数据库接入服务
创建应用的选项很多。使用 Appery.io 应用编辑器可以编写复杂应用,但我们的应用力求简单。让我们的应用连接 Scrapy 数据库,点击 CREATE NEW 按钮(5),选择 Datebase Services(6)。弹出一个界面让我们选择连接的对象。我们选择 scrapy 数据库(7)。点击 properties 栏(8),选择 List(9)。这些操作可以让我们爬到的数据可用于数据库。最后点击 Import selected services 完成导入(10)。
设定用户界面
接下来创建 app 的界面。我们在 DESIGN 标签下工作:
在左侧栏中点开 Pages 文件夹(1),然后点击 startScreen(2)。UI 编辑器会打开一个页面,我们在上面添加空间。先修改标题。点击标题栏,在右侧的属性栏修改标题为 Scrapy App。同时,标题栏会更新。
然后,我们添加格栅组件。从左侧的控制板中拖动 Grid 组件(5)。这个组件有两行,而我们只要一行。选择这个格栅组件,选中的时候,它在路径中会变为灰色(6)。选中之后,在右侧的属性栏中编辑 Rows 为 1,然后点击 Apply(7,8)。现在,格栅就只有一行了。
最后,再向格栅中拖进一些组件。先在左边添加一个图片组件(9),然后在右侧添加一个链接(10)。最后,在链接下添加一个标签(11)。
排版结束。接下来将数据从数据库导入用户界面。
将数据映射到用户界面
截止目前,我们只是在 DESIGN 标签下设置界面。为了连接数据和组件,我们切换到 DATA 标签(1):
我们用 Service(2)作为数据源类型,它会自动选择我们之前建立的唯一可用数据。点击 Add 按钮(3)。点击 Add 之后,可以在下方看到一系列事件,例如 Before send 和 Success。点击 Success 后面的 Mapping 可以调用服务,我们现在对它进行设置。
打开 Mapping action editor,在上面进行连线。编辑器有两个部分。左边是服务的可用响应,右边是 UI 组件的属性。两边都有一个 Expand all,展开所有的项,以查看可用的。接下来按照下表,用从左到右拖动的方式完成五个映射(5):
映射数据字段和用户组件
前面列表中的数字可能在你的例子中是不同的,但是因为每种组件的类型都是唯一的,所以连线出错的可能性很小。通过映射,我们告诉 Appery.io 当数据库查询成功时载入数据。然后点击 Save and return(6)。
返回 DATA 标签。我们需要返回 UI 编辑器,点击 DESIGN 标签(7)。屏幕下方,你会看到 EVENTS 区域(8)被展开了。利用 EVENTS,我们让 Appery.io 响应 UI 时间。下面是最后一步,就是加载 UI 时调用服务取回数据。我们打开 startScreen 作为组件,事件的默认选项是 Load。然后选择 Invoke service 作为 action,然后用 Datasource 作为默认的 restservice1 选项(9)。点击 Save(10),保存这个移动应用。
测试、分享、生成 app
现在准备测试 app。我们要做的是点击 UI 上方的 TEST 按钮(1):
这个应用直接在浏览器中运行。链接(2)是启动的,可以进行跳转。你可以设置分辨率和屏幕的横竖。你还可以点击 View on Phone,创建一个二维码,用手机扫描,然后在手机上看。你刚刚创建了一个链接,别人也可以在他们的浏览器中查看。
只需几次点击,我们就用一个移动应用展示了 Scrapy 抓取的数据。你可以在这个网页,http://devcenter.appery.io/tutorials/学习Appery.io教程,继续定制这个应用。当你准备好之后,可以点击 EXPORT 按钮输出这个 app:
你可以输出文档到你喜爱的 IDE 继续开发,或是生成在各个平台都能运行的 app。
总结
使用 Scrapy 和 Appery.io 两个工具,我们创建了一个爬虫、抓取了一个网站,并将数据存到数据库之中。我们还创建了 RESTful API 和一个简单的移动端应用。对于更高级的特点和进一步开发,你可以进一步探究这个平台,或将这个应用用于实际或科研。现在,用最少的代码,你就可以用一个小产品展示网络抓取的应用了。
鉴于这么短的开发时间,我们的 app 就有不错的效果。它有真实的数据,而不是 Lorem Ipsum 占字符,所有的链接运行良好。我们成功地制作了一个最小可行产品,它可以融合进源网站的生态,提高流量。
接下来学习在更加复杂的情况下,如何使用 Scrapy 爬虫提取信息。
五、快速构建爬虫
第 3 章中,我们学习了如何从网页提取信息并存储到 Items 中。大多数情况都可以用这一章的知识处理。本章,我们要进一步学习抓取流程 UR2IM 中两个 R,Request 和 Response。
一个具有登录功能的爬虫
你常常需要从具有登录机制的网站抓取数据。多数时候,网站要你提供用户名和密码才能登录。我们的例子,你可以在http://web:9312/dynamic或http://localhost:9312/dynamic找到。用用户名“user”、密码“pass”登录之后,你会进入一个有三条房产链接的网页。现在的问题是,如何用 Scrapy 登录?
让我们使用谷歌 Chrome 浏览器的开发者工具搞清楚登录的机制。首先,选择 Network 标签(1)。然后,填入用户名和密码,点击 Login(2)。如果用户名和密码是正确的,你会进入下一页。如果是错误的,会看到一个错误页。
一旦你点击了 Login,在开发者工具的 Network 标签栏中,你就会看到一个发往http://localhost:9312/dynamic/login的请求 Request Method: POST。
提示:上一章的 GET 请求,通常用来获取静止数据,例如简单的网页和图片。POST 请求通常用来获取的数据,取决于我们发给服务器的数据,例如这个例子中的用户名和密码。
点击这个 POST 请求,你就可以看到发给服务器的数据,其中包括表单信息,表单信息中有你刚才输入的用户名和密码。所有数据都以文本的形式发给服务器。Chrome 开发者工具将它们整理好并展示出来。服务器的响应是 302 FOUND(5),然后将我们重定向到新页面:/dynamic/gated。只有登录成功时才会出现此页面。如果没有正确输入用户名和密码就前往http://localhost:9312/dynamic/gated,服务器会发现你作弊,并将你重定向到错误页面:http://localhost:9312/dynamic/error。服务器怎么知道你和密码呢?如果你点击左侧的 gated(6),你会发现在 RequestHeaders(7)下有一个 Cookie(8)。
提示:HTTP cookie 是通常是一些服务器发送到浏览器的短文本或数字片段。反过来,在每一个后续请求中,浏览器把它发送回服务器,以确定你、用户和期限。这让你可以执行复杂的需要服务器端状态信息的操作,如你购物车中的商品或你的用户名和密码。
总结一下,单单一个操作,如登录,可能涉及多个服务器往返操作,包括 POST 请求和 HTTP 重定向。Scrapy 处理大多数这些操作是自动的,我们需要编写的代码很简单。
我们将第 3 章名为 easy 的爬虫重命名为 login,并修改里面名字的属性,如下:
class LoginSpider(CrawlSpider):
name = 'login'
提示:本章的代码 github 的 ch05 目录中。这个例子位于 ch05/properties。
我们要在http://localhost:9312/dynamic/login上面模拟一个 POST 请求登录。我们用 Scrapy 中的类 FormRequest 来做。这个类和第 3 章中的 Request 很像,但有一个额外的 formdata,用来传递参数。要使用这个类,首先必须要引入:
from scrapy.http import FormRequest
我们然后将 start_URL 替换为 start_requests()方法。这么做是因为在本例中,比起 URL,我们要做一些自定义的工作。更具体地,用下面的函数,我们创建并返回一个 FormRequest:
# Start with a login request
def start_requests(self):
return [
FormRequest(
"http://web:9312/dynamic/login",
formdata={"user": "user", "pass": "pass"}
)]
就是这样。CrawlSpider 的默认 parse()方法,即 LoginSpider 的基本类,负责处理响应,并如第 3 章中使用 Rules 和 LinkExtractors。其余的代码很少,因为 Scrapy 负责了 cookies,当我们登录时,Scrapy 将 cookies 传递给后续请求,与浏览器的方式相同。还是用 scrapy crawl 运行:
$ scrapy crawl login
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Redirecting (302) to <GET .../gated> from <POST .../login >
DEBUG: Crawled (200) <GET .../data.php>
DEBUG: Crawled (200) <GET .../property_000001.html> (referer: .../data.
php)
DEBUG: Scraped from <200 .../property_000001.html>
{'address': [u'Plaistow, London'],
'date': [datetime.datetime(2015, 11, 25, 12, 7, 27, 120119)],
'description': [u'features'],
'image_URL': [u'http://web:93img/i02.jpg'],
...
INFO: Closing spider (finished)
INFO: Dumping Scrapy stats:
{...
'downloader/request_method_count/GET': 4,
'downloader/request_method_count/POST': 1,
...
'item_scraped_count': 3,
我们注意到登录跳转从 dynamic/login 到 dynamic/gated,然后就可以像之前一样抓取项目。在统计中,我们看到一个 POST 请求和四个 GET 请求;一个是 dynamic/gated 首页,三个是房产网页。
提示:在本例中,我们不保护房产页,而是是这些网页的链接。代码在相反的情况下也是相同的。
如果我们使用了错误的用户名和密码,我们将重定向到一个没有 URL 的页面,进程并将在这里结束,如下所示:
$ scrapy crawl login
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Redirecting (302) to <GET .../dynamic/error > from <POST .../
dynamic/login>
DEBUG: Crawled (200) <GET .../dynamic/error>
...
INFO: Spider closed (closespider_itemcount)
这是一个简单的登录示例,演示了基本的登录机制。大多数网站可能有更复杂的机制,但 Scrapy 也处理的很好。例如一些网站在执行 POST 请求时,需要通过从表单页面到登录页面传递某种形式的变量以确定 cookies 的启用,让你使用大量用户名和密码暴力破解时变得困难。
例如,如果你访问http://localhost:9312/dynamic/nonce,你会看到一个和之前一样的网页,但如果你使用 Chrome 开发者工具,你会发现这个页面的表单有一个叫做 nonce 的隐藏字段。当你提交表单http://localhost:9312/dynamic/nonce-login时,你必须既要提供正确的用户名密码,还要提交正确的浏览器发给你的 nonce 值。因为这个值是随机且只能使用一次,你很难猜到。这意味着,如果要成功登陆,必须要进行两次请求。你必须访问表单、登录页,然后传递数值。和以前一样,Scrapy 有内建的功能可以解决这个问题。
我们创建一个和之前相似的 NonceLoginSpider 爬虫。现在,在 start_requests()中,我们要向表单页返回一个简单的 Request,并通过设定 callback 为名字是 parse_welcome()的方法手动处理响应。在 parse_welcome()中,我们使用 FormRequest 对象中的 from_response()方法创建 FormRequest,并将原始表单中的字段和值导入 FormRequest。FormRequest.from_response()可以模拟提交表单。
提示:花时间看 from_response()的文档是十分值得的。他有许多有用的功能如 formname 和 formnumber,它可以帮助你当页面有多个表单时,选择特定的表单。
它最大的功能是,一字不差地包含了表单中所有的隐藏字段。我们只需使用 formdata 参数,填入 user 和 pass 字段,并返回 FormRequest。代码如下:
# Start on the welcome page
def start_requests(self):
return [
Request(
"http://web:9312/dynamic/nonce",
callback=self.parse_welcome)
]
# Post welcome page's first form with the given user/pass
def parse_welcome(self, response):
return FormRequest.from_response(
response,
formdata={"user": "user", "pass": "pass"}
)
像之前一样运行爬虫:
$ scrapy crawl noncelogin
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Crawled (200) <GET .../dynamic/nonce>
DEBUG: Redirecting (302) to <GET .../dynamic/gated > from <POST .../
dynamic/login-nonce>
DEBUG: Crawled (200) <GET .../dynamic/gated>
...
INFO: Dumping Scrapy stats:
{...
'downloader/request_method_count/GET': 5,
'downloader/request_method_count/POST': 1,
...
'item_scraped_count': 3,
我们看到第一个 GET 请求先到/dynamic/nonce,然后 POST,重定向到/dynamic/nonce-login 之后,之后像之前一样,访问了/dynamic/gated。登录过程结束。这个例子的登录含有两步。只要有足够的耐心,无论多少步的登录过程,都可以完成。
使用 JSON APIs 和 AJAX 页面的爬虫
有时,你会发现网页的 HTML 找不到数据。例如,在http://localhost:9312/static/页面上右键点击检查元素(1,2),你就可以在 DOM 树种看到所有 HTML 元素。或者,如果你使用 scrapy shell 或在 Chrome 中右键点击查看网页源代码(3,4),你会看到这个网页的 HTML 代码不包含任何和值有关的信息。数据都是从何而来呢?
和以前一样,在开发者工具中打开 Network 标签(5)查看发生了什么。左侧列表中,可以看到所有的请求。在这个简单的页面中,只有三个请求:static/我们已经检查过了,jquery.min.js 是一个流行的 JavaScript 框架,api.json 看起来不同。如果我们点击它(6),然后在右侧点击 Preview 标签(7),我们可以看到它包含我们要找的信息。事实上,http://localhost:9312/properties/api.json包含 IDs 和名字(8),如下所示:
[{
"id": 0,
"title": "better set unique family well"
},
... {
"id": 29,
"title": "better portered mile"
}]
这是一个很简单的 JSON API 例子。更复杂的 APIs 可能要求你登录,使用 POST 请求,或返回某种数据结结构。任何时候,JSON 都是最容易解析的格式,因为不需要 XPath 表达式就可以提取信息。
Python 提供了一个强大的 JSON 解析库。当我们 import json 时,我们可以使用 json.loads(response.body)解析 JSON,并转换成等价的 Python 对象,语句、列表和字典。
复制第 3 章中的 manual.py 文件。这是最好的方法,因为我们要根据 JSON 对象中的 IDs 手动创建 URL 和 Request。将这个文件重命名为 api.py,重命名类为 ApiSpider、名字是 api。新的 start_URL 变成:
start_URL = (
'http://web:9312/properties/api.json',
)
如果你要做 POST 请求或更复杂的操作,你可以使用 start_requests()方法和前面几章介绍的方法。这里,Scrapy 会打开这个 URL 并使用 Response 作为参数调用 parse()方法。我们可以 import json,使用下面的代码解析 JSON:
def parse(self, response):
base_url = "http://web:9312/properties/"
js = json.loads(response.body)
for item in js:
id = item["id"]
url = base_url + "property_%06d.html" % id
yield Request(url, callback=self.parse_item)
这段代码使用了 json.loads(response.body)将响应 JSON 对象转换为 Python 列表,然后重复这个过程。对于列表中的每个项,我们设置一个 URL,它包含:base_url,property_%06d 和.html.base_url,.html.base_url 前面定义过的 URL 前缀。%06d 是一个非常有用的 Python 词,可以让我们结合多个 Python 变量形成一个新的字符串。在本例中,用 id 变量替换%06d。id 被当做数字(%d 的意思就是当做数字进行处理),并扩展成 6 个字符,位数不够时前面添加 0。如果 id 的值是 5,%06d 会被替换为 000005;id 是 34322 时,%06d 会被替换为 034322 替换。最后的结果是可用的 URL。和第 3 章中的 yield 一样,我们用 URL 做一个新的 Request 请求。运行爬虫:
$ scrapy crawl api
INFO: Scrapy 1.0.3 started (bot: properties)
...
DEBUG: Crawled (200) <GET ...properties/api.json>
DEBUG: Crawled (200) <GET .../property_000029.html>
...
INFO: Closing spider (finished)
INFO: Dumping Scrapy stats:
...
'downloader/request_count': 31, ...
'item_scraped_count': 30,
最后一共有 31 次请求,每个项目一次,api.json 一次。
在响应间传递参数
许多时候,你想把 JSON APIs 中的信息存储到 Item 中。为了演示,在我们的例子中,对于一个项,JSON API 在返回它的名字时,在前面加上“better”。例如,如果一个项的名字时“Covent Garden”,API 会返回“Better Covent Garden”。我们要在 Items 中保存这些含有“bette”的名字。如何将数据从 parse()传递到 parse_item()中呢?
我们要做的就是在 parse()方法产生的 Request 中进行设置。然后,我们可以从 parse_item()的的 Response 中取回。Request 有一个名为 meta 的字典,在 Response 中可以直接访问。对于我们的例子,给字典设一个 title 值以存储从 JSON 对象的返回值:
title = item["title"]
yield Request(url, meta={"title": title},callback=self.parse_item)
在 parse_item()中,我们可以使用这个值,而不用 XPath 表达式:
l.add_value('title', response.meta['title'],
MapCompose(unicode.strip, unicode.title))
你会注意到,我们从调用 add_xpath()切换到 add_value(),因为对于这个字段不需要使用 XPath。我们现在运行爬虫,就可以在 PropertyItems 中看到 api.json 中的标题了。
一个加速 30 倍的项目爬虫
当你学习使用一个框架时,这个框架越复杂,你用它做任何事都会很复杂。可能你觉得 Scrapy 也是这样。当你就要为 XPath 和其他方法变得抓狂时,不妨停下来思考一下:我现在抓取网页的方法是最简单的吗?
如果你可以从索引页中提取相同的信息,就可以避免抓取每一个列表页,这样就可以节省大量的工作。
提示:许多网站的索引页提供的项目数量是不同的。例如,一个网站可以通过调整一个参数,例如&show=50,给每个索引页面设置 10、 50 或 100 个列表项。如果是这样的话,将其设置为可用的最大值。
例如,对于我们的例子,我们需要的所有信息都存在于索引页中,包括标题、描述、价格和图片。这意味着我们抓取单个索引页,提取 30 个条目和下一个索引页的链接。通过抓取 100 个索引页,我们得到 3000 个项,但只有 100 个请求而不是 3000 个。
在真实的 Gumtree 网站上,索引页的描述比列表页的完整描述要短。这是可行的,或者是更推荐的。
提示:许多情况下,您不得不在数据质量与请求数量间进行折衷。很多网站都限制请求数量(后面章节详解),所以减少请求可能解决另一个棘手的问题。
在我们的例子中,如果我们查看一个索引页的 HTML,我们会发现,每个列表页有自己的节点,itemtype=“http://schema.org/Product”。节点有每个项的全部信息,如下所示:
让我们在 Scrapy shell 中加载索引首页,并用 XPath 处理:
$ scrapy shell http://web:9312/properties/index_00000.html
While within the Scrapy shell, let's try to select everything with the Product tag:
>>> p=response.xpath('//*[@itemtype="http://schema.org/Product"]')
>>> len(p)
30
>>> p
[<Selector xpath='//*[@itemtype="http://schema.org/Product"]' data=u'<li
class="listing-maxi" itemscopeitemt'...]
我们得到了一个包含 30 个 Selector 对象的表,每个都指向一个列表。Selector 对象和 Response 对象很像,我们可以用 XPath 表达式从它们指向的对象中提取信息。不同的是,表达式为有相关性的 XPath 表达式。相关性 XPath 表达式与我们之前见过的很像,不同之处是它们前面有一个点“.”。然我们看看如何用.//*[@itemprop=“name”][1]/text()提取标题的:
>>> selector = p[3]
>>> selector
<Selector xpath='//*[@itemtype="http://schema.org/Product"]' ... '>
>>> selector.xpath('.//*[@itemprop="name"][1]/text()').extract()
[u'l fun broadband clean people brompton european']
我们可以在 Selector 对象表中用 for 循环提取一个索引页的所有 30 个项目信息。还是从第 3 章中的 maunal.py 文件开始,重命名为 fast.py。重复使用大部分代码,修改 parse()和 parse_item()方法。更新的方法如下所示:
def parse(self, response):
# Get the next index URL and yield Requests
next_sel = response.xpath('//*[contains(@class,"next")]//@href')
for url in next_sel.extract():
yield Request(urlparse.urljoin(response.url, url))
# Iterate through products and create PropertiesItems
selectors = response.xpath(
'//*[@itemtype="http://schema.org/Product"]')
for selector in selectors:
yield self.parse_item(selector, response)
第一部分中用于产生下一条索引请求的代码没有变动。不同的地方是第二部分,我们重复使用选择器调用 parse_item()方法,而不是用 yield 创建请求。这和原先使用的源代码很像:
def parse_item(self, selector, response):
# Create the loader using the selector
l = ItemLoader(item=PropertiesItem(), selector=selector)
# 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))
make_url = lambda i: urlparse.urljoin(response.url, i)
l.add_xpath('image_URL', './/*[@itemprop="image"][1]/@src',
MapCompose(make_url))
# Housekeeping fields
l.add_xpath('url', './/*[@itemprop="url"][1]/@href',
MapCompose(make_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()
我们做出的变动是:
- ItemLoader 现在使用 selector 作为源,不使用 Response。这么做可以让 ItemLoader 更便捷,可以让我们从特定的区域而不是整个页面抓取信息。
- 通过在前面添加“.”使 XPath 表达式变为相关 XPath。
提示:碰巧的是,在我们的例子中,XPath 表达式在索引页和介绍页中是相同的。不同的时候,你需要按照索引页修改 XPath 表达式。
- 在 response.url 给我们列表页的 URL 之前,我们必须自己编辑 Item 的 URL。然后,它才能返回我们抓取网页的 URL。我们必须用.//*[@itemprop=“url”][1]/@href 提取 URL,然后将它用 MapCompose 转化为 URL 绝对路径。
这些小小大量的工作的改动可以节省大量的工作。现在,用以下命令运行爬虫:
$ scrapy crawl fast -s CLOSESPIDER_PAGECOUNT=3
...
INFO: Dumping Scrapy stats:
'downloader/request_count': 3, ...
'item_scraped_count': 90,...
就像之前说的,我们用三个请求,就抓取了 90 个项目。不从索引开始的话,就要用 93 个请求。
如果你想用 scrapy parse 来调试,你需要如下设置 spider 参数:
$ scrapy parse --spider=fast http://web:9312/properties/index_00000.html
...
>>> STATUS DEPTH LEVEL 1 <<<
# Scraped Items --------------------------------------------
[{'address': [u'Angel, London'],
... 30 items...
# Requests ---------------------------------------------------
[<GET http://web:9312/properties/index_00001.html>]
正如所料,parse()返回了 30 个 Items 和下一个索引页的请求。你还可以继续试验 scrapy parse,例如,设置—depth=2。
可以抓取 Excel 文件的爬虫
大多数时候,你每抓取一个网站就使用一个爬虫,但如果要从多个网站抓取时,不同之处就是使用不同的 XPath 表达式。为每一个网站配置一个爬虫工作太大。能不能只使用一个爬虫呢?答案是可以。
新建一个项目抓取不同的东西。当前我们是在 ch05 的 properties 目录,向上一级:
$ pwd
/root/book/ch05/properties
$ cd ..
$ pwd
/root/book/ch05
新建一个项目,命名为 generic,再创建一个名为 fromcsv 的爬虫:
$ scrapy startproject generic
$ cd generic
$ scrapy genspider fromcsv example.com
新建一个.csv 文件,它是我们抓取的目标。我们可以用 Excel 表建这个文件。如下表所示,填入 URL 和 XPath 表达式,在爬虫的目录中(有 scrapy.cfg 的文件夹)保存为 todo.csv。保存格式是 csv:
一切正常的话,就可以在终端看见这个文件:
$ cat todo.csv
url,name,price
a.html,"//*[@id=""itemTitle""]/text()","//*[@id=""prcIsum""]/text()"
b.html,//h1/text(),//span/strong/text()
c.html,"//*[@id=""product-desc""]/span/text()"
Python 中有 csv 文件的内建库。只需 import csv,就可以用后面的代码一行一行以 dict 的形式读取这个 csv 文件。在当前目录打开 Python 命令行,然后输入:
$ pwd
/root/book/ch05/generic2
$ python
>>> import csv
>>> with open("todo.csv", "rU") as f:
reader = csv.DictReader(f)
for line in reader:
print line
文件的第一行会被自动作为 header,从而导出 dict 的键名。对于下面的每一行,我们得到一个包含数据的 dict。用 for 循环执行每一行。前面代码的结果如下:
{'url': ' http://a.html', 'price': '//*[@id="prcIsum"]/text()', 'name': '//*[@id="itemTitle"]/text()'}
{'url': ' http://b.html', 'price': '//span/strong/text()', 'name': '//h1/text()'}
{'url': ' http://c.html', 'price': '', 'name': '//*[@id="product-desc"]/span/text()'}
很好。现在编辑 generic/spiders/fromcsv.py 爬虫。我们使用.csv 文件中的 URL,并且不希望遇到域名限制的情况。因此第一件事是移除 start_URL 和 allowed_domains。然后再读.csv 文件。
因为从文件中读取的 URL 是我们事先不了解的,所以使用一个 start_requests()方法。对于每一行,我们都会创建 Request。我们还要从 request,meta 的 csv 存储字段名和 XPath,以便在我们的 parse()函数中使用。然后,我们使用 Item 和 ItemLoader 填充 Item 的字段。下面是所有代码:
import csv
import scrapy
from scrapy.http import Request
from scrapy.loader import ItemLoader
from scrapy.item import Item, Field
class FromcsvSpider(scrapy.Spider):
name = "fromcsv"
def start_requests(self):
with open("todo.csv", "rU") as f:
reader = csv.DictReader(f)
for line in reader:
request = Request(line.pop('url'))
request.meta['fields'] = line
yield request
def parse(self, response):
item = Item()
l = ItemLoader(item=item, response=response)
for name, xpath in response.meta['fields'].iteritems():
if xpath:
item.fields[name] = Field()
l.add_xpath(name, xpath)
return l.load_item()
运行爬虫,输出文件保存为 csv:
$ scrapy crawl fromcsv -o out.csv
INFO: Scrapy 0.0.3 started (bot: generic)
...
DEBUG: Scraped from <200 a.html>
{'name': [u'My item'], 'price': [u'128']}
DEBUG: Scraped from <200 b.html>
{'name': [u'Getting interesting'], 'price': [u'300']}
DEBUG: Scraped from <200 c.html>
{'name': [u'Buy this now']}
...
INFO: Spider closed (finished)
$ cat out.csv
price,name
128,My item
300,Getting interesting
,Buy this now
有几点要注意。项目中没有定义一个整个项目的 Items,我们必须手动向 ItemLoader 提供一个:
item = Item()
l = ItemLoader(item=item, response=response)
我们还用 Item 的 fields 成员变量添加了动态字段。添加一个新的动态字段,并用 ItemLoader 填充,使用下面的方法:
item.fields[name] = Field()
l.add_xpath(name, xpath)
最后让代码再漂亮些。硬编码 todo.csv 不是很好。Scrapy 提供了一种便捷的向爬虫传递参数的方法。如果我们使用-a 参数,例如,-a variable=value,就创建了一个爬虫项,可以用 self.variable 取回。为了检查变量(没有的话,提供一个默认变量),我们使用 Python 的 getattr()方法:getattr(self, ‘variable’, ‘default’)。总之,原来的 with open…替换为:
with open(getattr(self, "file", "todo.csv"), "rU") as f:
现在,todo.csv 是默认文件,除非使用参数-a,用一个源文件覆盖它。如果还有一个文件,another_todo.csv,我们可以运行:
$ scrapy crawl fromcsv -a file=another_todo.csv -o out.csv
总结
在本章中,我们进一步学习了 Scrapy 爬虫。我们使用 FormRequest 进行登录,用请求/响应中的 meta 传递变量,使用了相关的 XPath 表达式和 Selectors,使用.csv 文件作为数据源等等。
接下来在第 6 章学习在 Scrapinghub 云部署爬虫,在第 7 章学习关于 Scrapy 的设置。
六、Scrapinghub 部署
前面几章中,我们学习了如何编写爬虫。编写好爬虫之后,我们有两个选择。如果是做单次抓取,让爬虫在开发机上运行一段时间就行了。或者,我们往往需要周期性的进行抓取。我们可以用 Amazon、RackSpace 等服务商的云主机,但这需要一些设置、配置和维护。这时候就需要 Scrapinghub 了。
Scrapinghub 是 Scrapy 高级开发者托管在 Amazon 上面的云架构。这是一个付费服务,但提供免费使用。如果想短时间内让爬虫运行在专业、有维护的平台上,本章内容很适合你。
注册、登录、创建项目
第一步是在http://scrapinghub.com/注册一个账户,只需电子邮件地址和密码。点击确认邮件的链接之后,就登录了。首先看到的是工作台,目前还没有任何项目,点击+Service 按钮(1)创建一个:
将项目命名为 properties(2),点击 Create 按钮(3)。然后点击链接 new(4)打开这个项目。
项目的工作台是最重要的界面。左侧栏中可以看到一些标签。Jobs 和 Spiders 提供运行和爬虫的信息。Periodic Jobs 可以制定周期抓取。其它四项,现在对我们不重要。
进入 Settings(1)。和许多网站的设置不同,Scrapinghub 提供许多非常有用的设置项。
现在,先关注下 Scrapy Deploy(2)。
部署爬虫并制定计划
我们从开发机直接部署。将 Scrapy Deploy 页上的 url 复制到我们项目的 scrapy.cfg 中,替换原有的[depoly]部分。不必设置密码。我们用第 4 章中的 properties 爬虫作例子。我们使用这个爬虫的原因是,目标数据可以从网页访问,访问的方式和第 4 章中一样。开始之前,我们先恢复原有的 settings.py,去除和 Appery.io pipeline 有关的内容:
提示:代码位于目录 ch06。这个例子在 ch06/properties 中。
$ pwd
/root/book/ch06/properties
$ ls
properties scrapy.cfg
$ cat scrapy.cfg
...
[settings]
default = properties.settings
# Project: properties
[deploy]
url = http://dash.scrapinghub.com/api/scrapyd/
username = 180128bc7a0.....50e8290dbf3b0
password =
project = 28814
为了部署爬虫,我们使用 Scrapinghub 提供的 shub 工具,可以用 pip install shub 安装。我们的开发机中已经有了。我们 shub login 登录 Scrapinghub,如下所示:
$ shub login
Insert your Scrapinghub API key : 180128bc7a0.....50e8290dbf3b0
Success.
我们已经在 scrapy.cfg 文件中复制了 API key,我们还可以点击 Scrapinghub 右上角的用户名找到 API key。弄好 API key 之后,就可以使用 shub deploy 部署爬虫了:
$ shub deploy
Packing version 1449092838
Deploying to project "28814"in {"status": "ok", "project": 28814,
"version":"1449092838", "spiders": 1}
Run your spiders at: https://dash.scrapinghub.com/p/28814/
Scrapy 打包了所有爬虫文件,并上传到了 Scrapinghub。我们可以看到两个新目录和一个文件,可以选择删除或不删除。
$ ls
build project.egg-info properties scrapy.cfgsetup.py
$ rm -rf build project.egg-info setup.py
现在,如果我们在 Scrapinghub 点击 Spiders 栏(1),我们可以看到上传的 tomobile 爬虫:
如果我们点击它(2),可以转到爬虫的工作台。里面的信息很多,但我们要做的是点击右上角的 Schedule 按钮(3),在弹出的界面中再点击 Schedule(4)。
几秒钟之后,Running Jobs 栏会出现新的一行,再过一会儿,Requests 和 Items 的数量开始增加。
提示:你或许不会限制抓取速度。Scrapinghub 使用算法估算在不被封的情况下,你每秒的最大请求数。
运行一段时间后,勾选这个任务(6),点击 Stop(7)。
几秒之后,可以在 Completed Jobs 看到抓取结束。要查看抓取文件,可以点击文件数(8)。
访问文件
来到任务的工作台。这里,可以查看文件(9),确认它们是否合格。我们还可以用上面的条件过滤结果。当我们向下翻动时,更多的文件被加载进来。
如果有错的话,我们可以在 Items 的上方找到有用的关于 Requests 和 Log 的信息(10)。用上方的面包屑路径(11)可以返回爬虫或项目主页。当然,可以点击左上的 Items 按钮(12)下载文件,选择合适的选项(13),保存格式可以是 CSV、JSON 和 JSON Lines。
另一种访问文件的方法是通过 Scrapinghub 的 Items API。我们要做的是查看任务页或文件页的 URL。应该看起来和下面很像:
https://dash.scrapinghub.com/p/28814/job/1/1/
在这个 URL 中,28814 是项目编号(scrapy.cfg 中也设置了它),第一个 1 是爬虫“tomobile”的 ID 编号,第二个 1 是任务编号。按顺序使用这三个数字,我们可以在控制台中用 curl 取回文件,请求发送到https://storage.scrapinghub.com/items///,并使用用户名/API key 验证,如下所示:
$ curl -u 180128bc7a0.....50e8290dbf3b0: https://storage.scrapinghub.com/items/28814/1/1
{"_type":"PropertiesItem","description":["same\r\nsmoking\r\nr...
{"_type":"PropertiesItem","description":["british bit keep eve...
...
如果询问密码的话,可以不填。用程序取回文件的话,可以使用 Scrapinghub 当做数据存储后端。存储的时间取决于订阅套餐的时间(免费试用是七天)。
制定周期抓取
只需要点击 Periodic Jobs 栏(1),点击 Add(2),设定爬虫(3),调整抓取频率(4),最后点击 Save(5)。
总结
本章中,我们首次接触了将 Scrapy 项目部署到 Scrapinghub。定时抓取数千条信息,并可以用 API 方便浏览和提取。后面的章节中,我们继续学习设置一个类似 Scrapinghub 的小型服务器。下一章先学习配置和管理。
七、配置和管理
我们已经学过了用 Scrapy 写一个抓取网络信息的简单爬虫是多么容易。通过进行设置,Scrapy 还有许多用途和功能。对于许多软件框架,用设置调节系统的运行,很让人头痛。对于 Scrapy,设置是最基础的知识,除了调节和配置,它还可以扩展框架的功能。这里只是补充官方 Scrapy 文档,让你可以尽快对设置有所了解,并找到能对你有用的东西。在做出修改时,还请查阅文档。
使用 Scrapy 设置
在 Scrapy 的设置中,你可以按照五个等级进行设置。第一级是默认设置,你不必进行修改,但是 scrapy/settings/default_settings.py 文件还是值得一读的。默认设置可以在命令级进行优化。一般来讲,除非你要插入自定义命令,否则不必修改。更经常的,我们只是修改自己项目的 settings.py 文件。这些设置只对当前项目管用。这么做很方便,因为当我们把项目部署到云主机时,可以连带设置文件一起打包,并且因为它是文件,可以用文字编辑器进行编辑。下一级是每个爬虫的设置。通过在爬虫中使用 custom_settings 属性,我们可以自定义每个爬虫的设置。例如,这可以让我们打开或关闭某个特定蜘蛛的 Pipelines。最后,要做最后的修改时,我们可以在命令行中使用-s 参数。我们做过这样的设置,例如-s CLOSESPIDER_PAGECOUNT=3,这可以限制爬虫的抓取范围。在这一级,我们可以设置 API、密码等等。不要在 settings.py 文件中保存这些设置,因为不想让它们在公共仓库中失效。
这一章,我们会学习一些非常重要且常用的设置。在任意项目中输入以下命令,可以了解设置都有多少类型:
$ scrapy settings --get CONCURRENT_REQUESTS
16
你得到的是默认值。修改这个项目的 settings.py 文件的 CONCURRENT_REQUESTS 的值,比如,14。上面命令行的结果也会变为 14,别忘了将设置改回去。在命令行中设置参数的话:
$ scrapy settings --get CONCURRENT_REQUESTS -s CONCURRENT_REQUESTS=19
19
这个结果暗示 scrapy crawl 和 scrapy settings 都是命令。每个命令都使用这样的方法加载设置。再举一个例子:
$ scrapy shell -s CONCURRENT_REQUESTS=19
>>> settings.getint('CONCURRENT_REQUESTS')
19
当你想确认设置文件中的值时,你就可以才用以上几种方法。下面详细学习 Scrapy 的设置。
基本设置
Scrapy 的设置太多,将其分类很有必要。我们从下图的基本设置开始,它可以让你明白重要的系统特性,你可能会频繁使用。
分析
通过这些设置,可以调节 Scrapy 的性能、调试信息的日志、统计、远程登录设备。
日志
Scrapy 有不同的日志等级:DEBUG(最低),INFO,WARNING,ERROR,和 CRITICAL(最高)。除此之外,还有一个 SILENT 级,没有日志输出。Scrapy 的有用扩展之一是 Log Stats,它可以打印出每分钟抓取的文件数和页数。LOGSTATS_INTERVAL 设置日志频率,默认值是 60 秒。这个间隔偏长。我习惯于将其设置为 5 秒,因为许多运行都很短。LOG_FILE 设置将日志写入文件。除非进行设定,输出会一直持续到发生标准错误,将 LOG_ENABLED 设定为 False,就不会这样了。最后,通过设定 LOG_STDOUT 为 True,你可以让 Scrapy 在日志中记录所有的输出(比如 print)。
统计
STATS_DUMP 是默认开启的,当爬虫运行完毕时,它把统计收集器(Stats Collector)中的值转移到日志。设定 DOWNLOADER_STATS,可以决定是否记录统计信息。通过 DEPTH_STATS,可以设定是否记录网站抓取深度的信息。若要记录更详细的深度信息,将 DEPTH_STATS_VERBOSE 设定为 True。STATSMAILER_RCPTS 是一个当爬虫结束时,发送 email 的列表。你不用经常设置它,但有时调试时会用到它。
远程登录
Scrapy 包括一个内建的远程登录控制台,你可以在上面用 Python 控制 Scrapy。TELNETCONSOLE_ENABLED 是默认开启的,TELNETCONSOLE_PORT 决定连接端口。在发生冲突时,可以对其修改。
案例 1——使用远程登录
有时,你想查看 Scrapy 运行时的内部状态。让我们来看看如何用远程登录来做:
笔记:本章代码位于 ch07。这个例子位于 ch07/properties 文件夹中。
$ pwd
/root/book/ch07/properties
$ ls
properties scrapy.cfg
Start a crawl as follows:
$ scrapy crawl fast
...
[scrapy] DEBUG: Telnet console listening on 127.0.0.1:6023:6023
这段信息是说远程登录被激活,监听端口是 6023。然后在另一台电脑,使用远程登录的命令连接:
$ telnet localhost 6023
>>>
现在,这台终端会给你一个在 Scrapy 中的 Python 控制台。你可以查看某些组件,例如用 engine 变量查看引擎,可以用 est()进行快速查看:
>>> est()
Execution engine status
time()-engine.start_time : 5.73892092705
engine.has_capacity() : False
len(engine.downloader.active) : 8
...
len(engine.slot.inprogress) : 10
...
len(engine.scraper.slot.active) : 2
我们在第 10 章中会继续学习里面的参数。接着输入以下命令:
>>> import time
>>> time.sleep(1) # Don't do this!
你会注意到,另一台电脑有一个短暂停。你还可以进行暂停、继续、停止爬虫。使用远程机器时,使用远程登录的功能非常有用:
>>> engine.pause()
>>> engine.unpause()
>>> engine.stop()
Connection closed by foreign host.
性能
第 10 章会详细介绍这些设置,这里只是一个概括。性能设定可以让你根据具体的工作调节爬虫的性能。CONCURRENT_REQUESTS 设置了并发请求的最大数。这是为了当你抓取很多不同的网站(域名/IPs)时,保护你的服务器性能。不是这样的话,你会发现 CONCURRENT_REQUESTS_PER_DOMAIN 和 CONCURRENT_REQUESTS_PER_IP 更多是限制性的。这两项分别通过限制每一个域名或 IP 地址的并发请求数,保护远程服务器。如果 CONCURRENT_REQUESTS_PER_IP 是非零的,CONCURRENT_REQUESTS_PER_DOMAIN 则被忽略。这些设置不是按照每秒。如果 CONCURRENT_REQUESTS = 16,请求平均消耗四分之一秒,最大极限则为每秒 16/0.25 = 64 次请求。CONCURRENT_ITEMS 设定每次请求并发处理的最大文件数。你可能会觉得这个设置没什么用,因为每个页面通常只有一个抓取项。它的默认值是 100。如果降低到,例如 10 或 1,你可能会觉得性能提升了,取决于每次请求抓取多少项和 pipelines 的复杂度。你还会注意到,当这个值是关于每次请求的,如果 CONCURRENT_REQUESTS = 16,CONCURRENT_ITEMS = 100 意味每秒有 1600 个文件同时要写入数据库。我一般把这个值设的比较小。
对于下载,DOWNLOADS_TIMEOUT 决定了取消请求前,下载器的等待时间。默认是 180 秒,这个时间太长,并发请求是 16 时,每秒的下载数是 5 页。我建议设为 10 秒。默认情况下,各个下载间的间隔是 0,以提高抓取速度。你可以设置 DOWNLOADS_DELAY 改变下载速度。有的网站会测量请求频率以判定是否是机器人行为。设定 DOWNLOADS_DELAY 的同时,还会有±50%的随机延迟。你可以设定 RANDOMIZE_DOWNLOAD_DELAY 为 False。
最后,若要使用更快的 DNS 查找,可以设定 DNSCACHE_ENABLED 打开内存 DNS 缓存。
提早结束抓取
Scrapy 的 CloseSpider 扩展可以在条件达成时,自动结束抓取。你可以用 CLOSESPIDER_TIMEOUT(in seconds), CLOSESPIDER_ITEMCOUNT, CLOSESPIDER_PAGECOUNT,和 CLOSESPIDER_ERRORCOUNT 分别设置在一段时间、抓取一定数量的文件、发出一定数量请求、发生一定数量错误时,提前关闭爬虫。你会在运行爬虫时频繁地做出这类设置:
$ scrapy crawl fast -s CLOSESPIDER_ITEMCOUNT=10
$ scrapy crawl fast -s CLOSESPIDER_PAGECOUNT=10
$ scrapy crawl fast -s CLOSESPIDER_TIMEOUT=10
HTTP 缓存和脱机工作
Scrapy 的 HttpCacheMiddleware 中间件(默认关闭)提供了一个低级的 HTTP 请求响应缓存。如果打开的话,缓存会存储每次请求和对应的响应。通过设定 HTTPCACHE_POLICY 为 scrapy.contrib.httpcache.RFC2616Policy,我们可以使用一个更为复杂的、按照 RFC2616 遵循网站提示的缓存策略。打开这项功能,设定 HTTPCACHE_ENABLED 为 True,HTTPCACHE_DIR 指向一个磁盘路径(使用相对路径的话,会存在当前文件夹内)。
你可以为缓存文件指定数据库后端,通过设定 HTTPCACHE_STORAGE 为 scrapy.contrib.httpcache.DbmCacheStorage,还可以选择调整 HTTPCACHE_DBM_MODULE。(默认为 anydbm)还有其它微调缓存的设置,但按照默认设置就可以了。
案例 2——用缓存离线工作
运行以下代码:
$ scrapy crawl fast -s LOG_LEVEL=INFO -s CLOSESPIDER_ITEMCOUNT=5000
一分钟之后才结束。如果你无法联网,就无法进行任何抓取。用下面的代码再次进行抓取:
$ scrapy crawl fast -s LOG_LEVEL=INFO -s CLOSESPIDER_ITEMCOUNT=5000 -s HTTPCACHE_ENABLED=1
...
INFO: Enabled downloader middlewares:...*HttpCacheMiddleware*
你会看到启用了 HttpCacheMiddleware,如果你查看当前目录,会发现一个隐藏文件夹,如下所示:
$ tree .scrapy | head
.scrapy
└── httpcache
└── easy
├── 00
│ ├── 002054968919f13763a7292c1907caf06d5a4810
│ │ ├── meta
│ │ ├── pickled_meta
│ │ ├── request_body
│ │ ├── request_headers
│ │ ├── response_body
...
当你再次运行不能联网的爬虫时,抓取稍少的文件,你会发现运行变快了:
$ scrapy crawl fast -s LOG_LEVEL=INFO -s CLOSESPIDER_ITEMCOUNT=4500 -s
HTTPCACHE_ENABLED=1
抓取稍少的文件,是因为使用 CLOSESPIDER_ITEMCOUNT 结束爬虫时,爬虫实际上会多抓取几页,我们不想抓取不在缓存中的内容。清理缓存的话,只需删除缓存目录:
$ rm -rf .scrapy
抓取方式
Scrapy 允许你设置从哪一页开始爬。设置 DEPTH_LIMIT,可以设置最大深度,0 代表没有限制。根据深度,通过 DEPTH_PRIORITY,可以给请求设置优先级。将其设为正值,可以让你实现广度优先抓取,并在 LIFO 和 FIFO 间切换:
DEPTH_PRIORITY = 1
SCHEDULER_DISK_QUEUE = 'scrapy.squeue.PickleFifoDiskQueue'
SCHEDULER_MEMORY_QUEUE = 'scrapy.squeue.FifoMemoryQueue'
这个功能十分有用,例如,当你抓取一个新闻网站,先抓取离首页近的最近的新闻,然后再是其它页面。默认的 Scrapy 方式是顺着第一条新闻抓取到最深,然后再进行下一条。广度优先可以先抓取层级最高的新闻,再往深抓取,当设定 DEPTH_LIMIT 为 3 时,就可以让你快速查看最近的新闻。
有的网站在根目录中用一个网络标准文件 robots.txt 规定了爬虫的规则。当设定 ROBOTSTXT_OBEY 为 True 时,Scrapy 会参考这个文件。设定为 True 之后,记得调试的时候碰到意外的错误时,可能是这个原因。
CookiesMiddleware 负责所有 cookie 相关的操作,开启 session 跟踪的话,可以实现登录。如果你想进行秘密抓取,可以设置 COOKIES_ENABLED 为 False。使 cookies 无效减少了带宽,一定程度上可以加快抓取。相似的,REFERER_ENABLED 默认是 True,可使 RefererMiddleware 生效,用它填充 Referer headers。你可以用 DEFAULT_REQUEST_HEADERS 自定义 headers。你会发现当有些奇怪的网站要求特定的请求头时,这个特别有用。最后,自动生成的 settings.py 文件建议我们设定 USER_AGENT。默认也可以,但我们应该修改它,以便网站所有者可以联系我们。
Feeds
Feeds 可以让你导出用 Scrapy 抓取的数据到本地或到服务器。存储路径取决于 FEED_URI.FEED_URI,其中可能包括参数。例如 scrapy crawl fast -o "%(name)s_%(time)s.jl,可以自动将时间和名字填入到输出文件。如果你需要你个自定义参数,例如%(foo)s, feed 输出器希望在爬虫中提供一个叫做 foo 的属性。数据的存储,例如 S3、FTP 或本地,也是在 URI 中定义。例如,FEED_URI='s3://mybucket/file.json’可以使用你的 Amazon 证书(AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY),将你的文件存储到 Amazon S3。存储的格式,JSON、JSON Lines、CSV 和 XML,取决于 FEED_FORMAT。如果没有指定的话,Scrapy 会根据 FEED_URI 的后缀猜测。你可以选择输出为空,通过设定 FEED_STORE_EMPTY 为 True。你还可以选择输出指定字段,通过设定 FEED_EXPORT_FIELDS。这对.csv 文件特别有用,可以固定 header 的列数。最后 FEED_URI_PARAMS 用于定义一个函数,对传递给 FEED_URI 的参数进行后处理。
下载媒体文件
Scrapy 可以用 Image Pipeline 下载媒体文件,它还可以将图片转换成不同的格式、生成面包屑路径、或根据图片大小进行过滤。
IMAGES_STORE 设置了图片存储的路径(选用相对路径的话,会存储在项目的根目录)。每个图片的 URL 存在各自的 image_URL 字段(它可以被 IMAGES_URL_FIELD 设置覆盖),下载下来的图片的文件名会存在一个新的 image 字段(它可以被 IMAGES_RESULT_FIELD 设置覆盖)。你可以通过 IMAGES_MIN_WIDTH 和 IMAGES_MIN_HEIGHT 筛选出小图片。IMAGES_EXPIRES 可以决定图片在缓存中存储的天数。IMAGES_THUMBS 可以设置一个或多个缩略图,还可以设置缩略图的大小。例如,你可以让 Scrapy 生成一个图标大小的缩略图或为每个图片生成一个中等的缩略图。
其它媒体文件
你可以使用 Files Pipelines 下载其它媒体文件。与图片相同 FILES_STORE 决定了存储地址,FILES_EXPIRES 决定存储时间。FILES_URL_FIELD 和 FILES_
RESULT_FIELD 的作用与之前图片的相似。文件和图片的 pipelines 可以同时工作。
案例 3——下载图片
为了使用图片功能,我们必须安装图片包,命令是 pip install image。我们的开发机已经安装好了。要启动 Image Pipeline,你需要编辑 settings.py 加入一些设置。首先在 ITEM_PIPELINES 添加 scrapy.pipelines.images.ImagesPipeline。然后,将 IMAGES_STORE 设为相对路径"images",通过设置 IMAGES_THUMBS,添加缩略图的描述,如下所示:
ITEM_PIPELINES = {
...
'scrapy.pipelines.images.ImagesPipeline': 1,
}
IMAGES_STORE = 'images'
IMAGES_THUMBS = { 'small': (30, 30) }
我们已经为 Item 安排了 image_URL 字段,然后如下运行:
$ scrapy crawl fast -s CLOSESPIDER_ITEMCOUNT=90
...
DEBUG: Scraped from <200 http://http://web:9312/.../index_00003.html/
property_000001.html>{
'image_URL': [u'http://web:93img/i02.jpg'],
'images': [{'checksum': 'c5b29f4b223218e5b5beece79fe31510',
'path': 'full/705a3112e67...a1f.jpg',
'url': 'http://web:93img/i02.jpg'}],
...
$ tree images
images
├── full
│ ├── 0abf072604df23b3be3ac51c9509999fa92ea311.jpg
│ ├── 1520131b5cc5f656bc683ddf5eab9b63e12c45b2.jpg
...
└── thumbs
└── small
├── 0abf072604df23b3be3ac51c9509999fa92ea311.jpg
├── 1520131b5cc5f656bc683ddf5eab9b63e12c45b2.jpg
...
我们看到图片成功下载下来,病生成了缩略图。Images 文件夹中存储了 jpg 文件。缩略图的路径可以很容易推测出来。删掉图片,可以使用命令 rm -rf images。
亚马逊网络服务
Scrapy 內建支持亚马逊服务。你可以将 AWS 的 access key 存储到 AWS_ACCESS_KEY_ID,将 secret key 存到 AWS_SECRET_ACCESS_KEY。这两个设置默认都是空的。使用方法如下:
- 当你用开头是 s3://(注意不是 http://)下载 URL 时
- 当你用 media pipelines 在 s3://路径存储文件或缩略图时
- 当你在 s3://目录存储输出文件时,不要在settings.py中存储这些设置,以免有一天这个文件要公开。
使用代理和爬虫
Scrapy 的 HttpProxyMiddleware 组件可以让你使用代理,它包括 http_proxy、https_proxy 和 no_proxy 环境变量。代理功能默认是开启的。
案例 4——使用代理和 Crawlera 的智慧代理
DynDNS 提供了一个免费检查你的 IP 地址的服务。使用 Scrapy shell,我们向 checkip.dyndns.org 发送一个请求,检查响应确定当前的 IP 地址:
$ scrapy shell http://checkip.dyndns.org
>>> response.body
'<html><head><title>Current IP Check</title></head><body>Current IP
Address: xxx.xxx.xxx.xxx</body></html>\r\n'
>>> exit()
要使用代理请求,退出 shell,然后使用 export 命令设置一个新代理。你可以通过搜索 HMA 的公共代理列表(http://proxylist.hidemyass.com/)测试一个免费代理。例如,假设我们选择一个代理 IP 是 10.10.1.1,端口是 80(替换成你的),如下运行:
$ # First check if you already use a proxy
$ env | grep http_proxy
$ # We should have nothing. Now let's set a proxy
$ export http_proxy=http://10.10.1.1:80
再次运行 Scrapy shell,你可以看到这次请求使用了不同的 IP。代理很慢,有时还会失败,这时可以选择另一个 IP。要关闭代理,可以退出 Scrapy shell,并使用 unset http_proxy。
Crawlera 是 Scrapinghub 的一个服务。除了使用一个大的 IP 池,它还能调整延迟并退出坏的请求,让连接变得快速稳定。这是爬虫工程师梦寐以求的产品。使用它,只需设置 http_proxy 的环境变量为:
$ export http_proxy=myusername:mypassword@proxy.crawlera.com:8010
除了 HTTP 代理,还可以通过它给 Scrapy 设计的中间件使用 Crawlera。
更多的设置
接下来看一些 Scrapy 不常用的设置和 Scrapy 的扩展设置,后者在后面的章节会详细介绍。
和项目相关的设定
这个小标题下,介绍和具体项目相关的设置,例如 BOT_NAME、SPIDER_MODULES 等等。最好在文档中查看一下,因为它们在某些具体情况下可以提高效率。但是通常来讲,Scrapy 的 startproject 和 genspider 命令的默认设置已经是合理的了,所以就不必另行设置了。和邮件相关的设置,例如 MAIL_FROM,可以让你配置 MailSender 类,它被用来发送统计数据(还可以查看 STATSMAILER_RCPTS)和内存使用(还可以查看 MEMUSAGE_NOTIFY_MAIL)。还有两个环境变量 SCRAPY_SETTINGS_MODULE 和 SCRAPY_PROJECT,它们可以让你微调 Scrapy 项目的整合,例如,整合一个 Django 项目。scrapy.cfg 还可以让你修改设置模块的名字。
扩展 Scrapy 设置
这些设定允许你扩展和修改 Scrapy 的几乎每个方面。最重要的就是 ITEM_PIPELINES。它允许你在项目中使用 Item Processing Pipelines。我们会在第 9 章中看到更多的例子。除了 pipelines,还可以用多种方式扩展 Scrapy,第 8 章总结了一些方式。COMMANDS_MODULE 允许我们设置自定义命令。例如,假设我们添加了一个 properties/hi.py 文件:
from scrapy.commands import ScrapyCommand
class Command(ScrapyCommand):
default_settings = {'LOG_ENABLED': False}
def run(self, args, opts):
print("hello")
一旦我们在 settings.py 加入了 COMMANDS_MODULE=‘properties.hi’,就可以在 Scrapy 的 help 中运行 hi 查看。在命令行的 default_settings 中定义的设置会与项目的设置合并,但是与 settings.py 文件的优先级比起来,它的优先级偏低。
Scrapy 使用-_BASE 字典(例如,FEED_EXPORTERS_BASE)来存储不同扩展框架的默认值,然后我们可以在 settings.py 文件和命令行中设置 non-_BASE 版本进行切换(例如,FEED_EXPORTERS)。
最后,Scrapy 使用设置,例如 DOWNLOADER 或 SCHEDULER,保管系统基本组件的包和类的名。我们可以继承默认的下载器(scrapy.core.downloader.Downloader),加载一些方法,在 DOWNLOADER 设置中自定义我们的类。这可以让开发者试验新特性、简化自动检测,但是只推荐专业人士这么做。
微调下载
RETRY_*, REDIRECT_*和 METAREFRESH_*设置分别配置了 Retry、Redirect、Meta-Refresh 中间件。例如,REDIRECT_PRIORITY_ 设为 2,意味着每次有重定向时,都会在没有重定向请求之后,预约一个新的请求。REDIRECT_MAX_TIMES 设为 20 意味着,在 20 次重定向之后,下载器不会再进行重定向,并返回现有值。当你抓取一些有问题的网站时,知道这些设置是很有用的,但是默认设置在大多数情况下就能应付了。HTTPERROR_ALLOWED_CODES 和 URLLENGTH_LIMIT 也类似。
自动限定扩展设置
AUTOTHROTTLE_*设置可以自动限定扩展。看起来有用,但在实际中,我发现很难用它进行调节。它使用下载延迟,并根据加载和指向服务器,调节下载器的延迟。如果你不能确定 DOWNLOAD_DELAY(默认是 0)的值,这个模块会派上用场。
内存使用扩展设置
MEMUSAGE_*设置可以配置内存使用扩展。当超出内存上限时,它会关闭爬虫。在共享环境中这会很有用,因为抓取过程要尽量小心。更多时候,你会将 MEMUSAGE_LIMIT_MB 设为 0,将自动关闭爬虫的功能取消,只接收警告 email。这个扩展只在类 Unix 平台有。
MEMDEBUG_ENABLED 和 MEMDEBUG_NOTIFY 可以配置内存调试扩展,可以在爬虫关闭时实时打印出参考的个数。阅读用 trackref 调试内存泄漏的文档,更重要的,我建议抓取过程最好简短、分批次,并匹配服务器的能力。我认为,每批次最好一千个网页、不超过几分钟。
登录和调试
最后,还有一些登录和调试的设置。LOG_ENCODING,LOG_DATEFORMAT 和 LOG_FORMAT 可以让你微调登录的方式,当你使用登录管理,比如 Splunk、Logstash 和 Kibana 时,你会觉得它很好用。DUPEFILTER_DEBUG 和 COOKIES_DEBUG 可以帮助你调试相对复杂的状况,比如,当你的请求数比预期少,或丢失 session 时。
总结
通过阅读本章,你一定会赞叹比起以前手写的爬虫,Scrapy 的功能更具深度和广度。如果你想微调或扩展 Scrapy 的功能,可以有大量的方法,见下面几章。
八、Scrapy 编程
到目前为止,我们创建爬虫的目的是抓取数据,并提取信息。除了爬虫,scrapy 可以让我们微调它的功能。例如,你会经常碰到以下状况:
你在同一个项目的爬虫间复制粘贴了很多代码。重复的代码更多是关于处理数据,而不是关于数据源。
你必须写脚本,好让 Items 复制入口或后处理数值。
你要在项目中架构中使用重复代码。例如,你要登录,并将文件传递到私有仓库,向数据库添加 Items,或当爬虫结束时触发后处理操作。
你发现 Scrapy 有些方面不好用,你想在自己的项目中自定义 Scrapy。
Scrapy 的开发者设计的架构允许我们解决上述问题。我们会在本章后面查看 Scrapy 架构。现在,首先让我们来看 Scrapy 的引擎,Twisted。
Scrapy 是一个 Twisted 应用
Scrapy 是一个用 Twisted Python 框架构建的抓取应用。Twisted 很不寻常,因为它是事件驱动的,并且鼓励我们编写异步代码。完全弄懂需要一些时间,我们只学习和 Scrapy 相关的部分。我们还会在处理错误中学习。Scrapy 在 GitHub 上的代码有更多的错误处理,我们会跳过它。
让我们从头开始。Twisted 的不同之处在于它自身的结构。
提示:在任何时候,都不要让代码发生阻塞。
这个提示很重要。发生阻塞的代码包括:
- 访问文件、数据库或网络的代码
- 产生新进程并占用输出的代码,例如,运行命令行
- 执行系统级操作的代码,例如,在系统中排队
Twisted 可以在不发生阻塞的情况下,执行以上操作。
为了展示不同,假设我们有一个典型的同步抓取应用。假设它有四个线程,在某个时刻,其中三个在等待响应而被阻塞,另一个在数据库中向 Item 文件写入而被阻塞。这时候,只能等待阻塞结束。阻塞结束时,又会有其它应用在几微秒之后占用了线程,又会发生阻塞。整体上,服务器并没有空闲,因为它上面运行着数十个程序、使用了数千个线程,因此,在微调之后,CPUs 的利用率照样很高。
Twisted/Scrapy 的方法尽量使用一个线程。它使用操作系统的 I/O 多线路函数(见 select()、poll()和 epoll())作为“挂架”。要发生阻塞时,例如,result = i_block(),Twisted 会立即返回。然而,它不是返回实际值,而是返回一个钩子,例如 deferred = i_dont_block()。我们可以在值变得可用时,例如 deferred.addCallback(process_result)),将值返回到任何可以用到该值的进程。Twisted 就是延迟操作链组成的。Twisted 的单线程被称作 Twisted 事件反应器,它负责监视“挂架”是否有资源可用(例如,一个服务器响应了我们的请求)。当可用时,事件反应器会将排在最前面的延迟项执行,它执行完之后,会调用下一个。一些延迟项可能引发更多的 I/O 操作,它会将延迟链继续挂起来,让 CPU 执行别的操作。因为是单线程,我们不需要其它线程切换上下文和保存资源。换句话,使用这种非阻塞的结构,我们使用一个线程,就相当于有数千个线程。
OS 开发者在数十年中不断优化线程操作。但是收效甚微。为一个复杂应用写出正确的多线程代码确实很难。当你搞明白延迟和调回,你会返现 Twisted 代码比线程代码简单多了。inlineCallbacks 生成器可以让代码更简单,下面会继续介绍。
笔记:可能目前最成功的非阻塞 I/O 系统是 Node.js,这主要因为从一开始 Node.js 就要求高性能和并发。每个 Node.js 只是用非阻塞的 APIs。在 Java 中,Netty 可能是最成功的 NIO 框架,例如 Apche Storm 和 Spark。C++11 的 std::future 和 std::promise(与延迟项相似)可以用库,例如 libevent 或 plain POSIX 写异步代码。
延迟项和延迟链
延迟项是 Twisted 写出异步代码的最重要机制。Twisted APIs 使用延迟项让我们定义事件发生时产生动作的顺序。
提示:本章代码位于 ch08。这个例子位于 ch08/deferreds.py file,你可以用./deferreds.py 0 运行。
你可以用 Python 控制台如下运行:
$ python
>>> from twisted.internet import defer
>>> # Experiment 1
>>> d = defer.Deferred()
>>> d.called
False
>>> d.callback(3)
>>> d.called
True
>>> d.result
3
我们看到,延迟项本质代表一个值。当我们触发 d 时(调用 callback 方法),延迟项的 called 状态变为 True,result 属性变为调用的值:
>>> # Experiment 2
>>> d = defer.Deferred()
>>> def foo(v):
... print "foo called"
... return v+1
...
>>> d.addCallback(foo)
<Deferred at 0x7f...>
>>> d.called
False
>>> d.callback(3)
foo called
>>> d.called
True
>>> d.result
4
延迟项的最强大之处是,当值确定时,可以在延迟链上添加新的项。在上面的例子中,我们使用 foo()作为 d 的回调。当我们调用 callback(3)时,函数 foo()被调用并打印出信息。返回值作为 d 的最后结果:
>>> # Experiment 3
>>> def status(*ds):
... return [(getattr(d, 'result', "N/A"), len(d.callbacks)) for d in
ds]
>>> def b_callback(arg):
... print "b_callback called with arg =", arg
... return b
>>> def on_done(arg):
... print "on_done called with arg =", arg
... return arg
>>> # Experiment 3.a
>>> a = defer.Deferred()
>>> b = defer.Deferred()
这个例子演示了延迟项更复杂的情况。我们看到了一个正常的延迟项 a,但它有两个调回。第一个是 b_callback(),返回的是 b 而不是 a。第二个是,on_done()打印函数。我们还有一个 status()函数,它可以打印延迟项的状态。对于两个调回,刚建立时,有两个相同的状态[(‘N/A’, 2), (‘N/A’, 0)],意味着两个延迟项都没有被触发,第一个有两个调回,第二个没有调回。然后,如果我们先触发 a,我们进入一个奇怪的状态 [(<Deferred at 0x10e7209e0>, 1), (‘N/A’, 1)],它显示 a 现在有一个值,这是一个延迟值(实际上就是 b),它只有一个调回,因为 b_callback()已经被调回,只留下 on_done()。意料之外的是吗,现在 b[(4, 0), (None, 0)],这正是我们想要的:
>>> # Experiment 3.b
>>> a = defer.Deferred()
>>> b = defer.Deferred()
>>> a.addCallback(b_callback).addCallback(on_done)
>>> status(a, b)
[('N/A', 2), ('N/A', 0)]
>>> b.callback(4)
>>> status(a, b)
[('N/A', 2), (4, 0)]
>>> a.callback(3)
b_callback called with arg = 3
on_done called with arg = 4
>>> status(a, b)
[(4, 0), (None, 0)]
另一方面,在设 a 为 3 之前就触发 b,b 的状态变为 [(‘N/A’, 2), (4, 0)],然后当 a 被触发时,两个调用都会被调用,最后的状态和前一个例子一样。无论触发的顺序,结果都是一样的。两者的区别是,在第一种情况中,b 的值被延迟更久,因为它是后触发的。而在第二种情况中,先触发 b,然后它的值立即被使用。
这时,你应该可以理解什么是延迟项,它们是怎么构成链的和表达值得。我们用第四个例子说明触发取决于其它延迟项的数量,通过使用 Twisted 中的类 defer.DeferredList:
>>> # Experiment 4
>>> deferreds = [defer.Deferred() for i in xrange(5)]
>>> join = defer.DeferredList(deferreds)
>>> join.addCallback(on_done)
>>> for i in xrange(4):
... deferreds[i].callback(i)
>>> deferreds[4].callback(4)
on_done called with arg = [(True, 0), (True, 1), (True, 2),
(True, 3), (True, 4)]
我们看到 for 声明 on_done()触发了五个中的四个,它们并没有被调用,直到所有延迟项都被触发,在最后的调用 deferreds[4].callback()之后。on_done()的参数是一个元组表,每个元组对应一个延迟项,包含 True 是成功/False 是失败,和延迟项的值。
理解 Twisted 和非阻塞 I/O——Python 的故事
现在我们已经有了一个大概的了解,现在让我给你讲一个 Python 的小故事。所有的角色都是虚构的,如有巧合纯属雷同:
# ~*~ Twisted - A Python tale ~*~
from time import sleep
# Hello, I'm a developer and I mainly setup Wordpress.
def install_wordpress(customer):
# Our hosting company Threads Ltd. is bad. I start installation
and...
print "Start installation for", customer
# ...then wait till the installation finishes successfully. It is
# boring and I'm spending most of my time waiting while consuming
# resources (memory and some CPU cycles). It's because the process
# is *blocking*.
sleep(3)
print "All done for", customer
# I do this all day long for our customers
def developer_day(customers):
for customer in customers:
install_wordpress(customer)
developer_day(["Bill", "Elon", "Steve", "Mark"])
让我们运行它:
$ ./deferreds.py 1
------ Running example 1 ------
Start installation for Bill
All done for Bill
Start installation
...
* Elapsed time: 12.03 seconds
结果是顺序执行的。4 名顾客,每人 3 秒,总和就是 12 秒。时间有些长,所以我们在第二个例子中,添加线程:
import threading
# The company grew. We now have many customers and I can't handle
the
# workload. We are now 5 developers doing exactly the same thing.
def developers_day(customers):
# But we now have to synchronize... a.k.a. bureaucracy
lock = threading.Lock()
#
def dev_day(id):
print "Goodmorning from developer", id
# Yuck - I hate locks...
lock.acquire()
while customers:
customer = customers.pop(0)
lock.release()
# My Python is less readable
install_wordpress(customer)
lock.acquire()
lock.release()
print "Bye from developer", id
# We go to work in the morning
devs = [threading.Thread(target=dev_day, args=(i,)) for i in
range(5)]
[dev.start() for dev in devs]
# We leave for the evening
[dev.join() for dev in devs]
# We now get more done in the same time but our dev process got more
# complex. As we grew we spend more time managing queues than doing dev
# work. We even had occasional deadlocks when processes got extremely
# complex. The fact is that we are still mostly pressing buttons and
# waiting but now we also spend some time in meetings.
developers_day(["Customer %d" % i for i in xrange(15)])
如下运行:
$ ./deferreds.py 2
------ Running example 2 ------
Goodmorning from developer 0Goodmorning from developer
1Start installation forGoodmorning from developer 2
Goodmorning from developer 3Customer 0
...
from developerCustomer 13 3Bye from developer 2
* Elapsed time: 9.02 seconds
你用 5 名工人线程并行执行。15 名顾客,每人 3 秒,单人处理要 45 秒,但是有 5 名工人的话,9 秒就够了。代码有些复杂。不再关注于算法和逻辑,它只考虑并发。另外,输出结果变得混乱且可读性变差。把简单的多线程代码写的好看也十分困难,现在我们 Twisted 怎么来做:
# For years we thought this was all there was... We kept hiring more
# developers, more managers and buying servers. We were trying harder
# optimising processes and fire-fighting while getting mediocre
# performance in return. Till luckily one day our hosting
# company decided to increase their fees and we decided to
# switch to Twisted Ltd.!
from twisted.internet import reactor
from twisted.internet import defer
from twisted.internet import task
# Twisted has a slightly different approach
def schedule_install(customer):
# They are calling us back when a Wordpress installation completes.
# They connected the caller recognition system with our CRM and
# we know exactly what a call is about and what has to be done
# next.
#
# We now design processes of what has to happen on certain events.
def schedule_install_wordpress():
def on_done():
print "Callback: Finished installation for", customer
print "Scheduling: Installation for", customer
return task.deferLater(reactor, 3, on_done)
#
def all_done(_):
print "All done for", customer
#
# For each customer, we schedule these processes on the CRM
# and that
# is all our chief-Twisted developer has to do
d = schedule_install_wordpress()
d.addCallback(all_done)
#
return d
# Yes, we don't need many developers anymore or any synchronization.
# ~~ Super-powered Twisted developer ~~
def twisted_developer_day(customers):
print "Goodmorning from Twisted developer"
#
# Here's what has to be done today
work = [schedule_install(customer) for customer in customers]
# Turn off the lights when done
join = defer.DeferredList(work)
join.addCallback(lambda _: reactor.stop())
#
print "Bye from Twisted developer!"
# Even his day is particularly short!
twisted_developer_day(["Customer %d" % i for i in xrange(15)])
# Reactor, our secretary uses the CRM and follows-up on events!
reactor.run()
让我们运行它:
$ ./deferreds.py 3
------ Running example 3 ------
Goodmorning from Twisted developer
Scheduling: Installation for Customer 0
....
Scheduling: Installation for Customer 14
Bye from Twisted developer!
Callback: Finished installation for Customer 0
All done for Customer 0
Callback: Finished installation for Customer 1
All done for Customer 1
...
All done for Customer 14
* Elapsed time: 3.18 seconds
我们没用线程就得到了十分漂亮的结果。我们并行处理了 15 名顾客,45 秒的工作在 3 秒内完成。我们的方法是让阻塞的调用进行 sleep(),而采用 task.deferLater()和调用函数。在其它地方进行处理时,我们可以轻松送出应付 15 名顾客。
笔记:我之前提到在其它地方进行处理。这是作弊吗?不是。计算仍在 CPUs 中进行。与磁盘和网络操作比起来,如今的 CPU 运算非常快。CPUs 接收发送数据或存储才是最花时间的。通过使用非阻塞 I/O 操作,我们为 CPUs 节省了这个时间。与 task.deferLater()相似,当数据传输完毕时,触发再进行调用。
另一个重点是 Goodmorning from Twisted developer 和 Bye from Twisted developer!消息。当运行代码时,它们立即就被打印出来。如果代码到达此处这么早,应用什么时候真正运行起来的呢?答案是 Twisted 应用全部都是在 reactor.run()中运行的。当你调用某个方法时,你必须有每个可能要用到的延迟项(相当于前面的故事里,在 CRM 系统中设定步骤和过程)。你的 reactor.run()监控事件并触发调回。
笔记:反应器的最主要规则是,只要是非阻塞操作就可以执行。
虽然没有线程了,调回函数还是有点不好看。看下面的例子:
# Twisted gave us utilities that make our code way more readable!
@defer.inlineCallbacks
def inline_install(customer):
print "Scheduling: Installation for", customer
yield task.deferLater(reactor, 3, lambda: None)
print "Callback: Finished installation for", customer
print "All done for", customer
def twisted_developer_day(customers):
... same as previously but using inline_install()
instead of schedule_install()
twisted_developer_day(["Customer %d" % i for i in xrange(15)])
reactor.run()
运行如下:
$ ./deferreds.py 4
... exactly the same as before
这段代码的功能和之前的一样,但是好看很多。inlineCallbacks 生成器用 Python 机制暂停和继续 inline_install()中的代码。inline_install()变成了一个延迟项,而后对每名顾客并行执行。每次 yield 时,暂停当前的 inline_install(),被触发时再继续。
唯一的问题是,当我们不是有 15 名顾客,而是 10000 名时,这段代码会同时发起 10000 个进程(可以是 HTTP 请求、写入数据库等等)。这可能可以运行,或者会产生严重的问题。在大并发应用中,我们通常会限制并发数。在这个例子中。Scrapy 使用了相似的机制,在 CONCURRENT_ITEMS 设置中限制并发数:
@defer.inlineCallbacks
def inline_install(customer):
... same as above
# The new "problem" is that we have to manage all this concurrency to
# avoid causing problems to others, but this is a nice problem to have.
def twisted_developer_day(customers):
print "Goodmorning from Twisted developer"
work = (inline_install(customer) for customer in customers)
#
# We use the Cooperator mechanism to make the secretary not
# service more than 5 customers simultaneously.
coop = task.Cooperator()
join = defer.DeferredList([coop.coiterate(work) for i in xrange(5)])
#
join.addCallback(lambda _: reactor.stop())
print "Bye from Twisted developer!"
twisted_developer_day(["Customer %d" % i for i in xrange(15)])
reactor.run()
# We are now more lean than ever, our customers happy, our hosting
# bills ridiculously low and our performance stellar.
# ~*~ THE END ~*~
运行如下:
$ ./deferreds.py 5
------ Running example 5 ------
Goodmorning from Twisted developer
Bye from Twisted developer!
Scheduling: Installation for Customer 0
...
Callback: Finished installation for Customer 4
All done for Customer 4
Scheduling: Installation for Customer 5
...
Callback: Finished installation for Customer 14
All done for Customer 14
* Elapsed time: 9.19 seconds
我们现在看到,一共有五个顾客的处理窗口。只有存在空窗口时,才能服务新顾客。因为处理每名顾客都是 3 秒,每批次可以处理 5 名顾客。最终,我们只用一个线程就达到了相同的性能,而且代码很简单。
Scrapy 架构概要
在架构操作的对象中有三个很眼熟,即 Requests,Responses 和 Items。我们的爬虫位于架构的核心。爬虫产生请求、处理响应、生成 Items 和更多的请求。
爬虫生成的每个 Item 都按照 Item Pipelins 的 process_item()方法指定的顺序,进行后处理。一般情况下,process_item()修改 Items 之后,将它们返回到随后的 pipelines。特殊情况时(例如,有两个重复的无效数据),我们需要丢掉一个 Item,我们要做的是加入 DropItem 例外。这时,后继的 pipelines 就不会接收 Item 了。如果我们还提供 open_spider()和/或 close_spider(),将会在爬虫开启和关闭时调用。这时可以进行初始化和清洗。Item Pipelines 主要是用来处理问题和底层操作,例如清洗数据或将 Items 插入到数据库。你还会在项目之间重复使用它,尤其是涉及底层操作时。第 4 章中,我们使用的 Appery.io pipeline 就是用来做底层操作,用最少的配置将 Items 上传到 Appery.io。
我们一般从爬虫发出请求,并得到返回的响应。Scrapy 负责了 cookies、认证、缓存等等,我们要做的只是偶尔进行设置。其中大部分都是靠下载器中间件完成的。下载器中间件通常很复杂,运用高深的方法处理请求响应间隔。你可以自定义下载器中间件,让请求处理可以按照自己的想法运行。好用的中间件可以在许多项目中重复使用,最好能在开发者社区中分享。如果你想查看默认的下载器中间件,可以在 Scrapy 的 GitHub 里的 settings/default_settings.py 中,找到 DOWNLOADER_MIDDLEWARES_BASE。
下载器是实际下载的引擎。你不必对其进行修改,除非你是 Scrapy 贡献者。
有时,你可能不得不要写一个爬虫中间件。它们要在爬虫之后、其它下载器中间件之前处理请求,按相反的顺序处理响应。例如,利用下载器中间件,你想重写所有的 URL 使用 HTTPS 而不是 HTTP,不管爬虫从网页抓取到什么。中间件专门为你的项目需求而设,并在爬虫间共享。下载器中间件和爬虫中间件的区别是,当下载器中间件有一个请求时,它必须回复一个单一的响应。另一方面,爬虫中间件不喜欢某个请求的话,可以丢掉这个请求,例如,忽略每一个输入请求,如果忽略对应用是有好处的话。你可以认为爬虫中间件是专为请求和响应的,item pipelines 是专为 Items 的。爬虫中间件也可以接收 Items,但通常不进行修改,因为用 item pipeline 修改更容易。如果你想查看默认的爬虫中间件,可以在 Scrapy 的 GitHub 里的 settings/default_settings.py 中,找到 SPIDER_MIDDLEWARES_BASE 设置。
最后,来看扩展。扩展很常见,是仅次于 Item Pipelines 常见的。它们是抓取启动时加载的类, 可以接入设置、爬虫、注册调用信号、并定义它们自己的信号。信号是一个基本的 Scrapy API,它可以允许系统中有事情发生时,进行调用,例如,当一个 Item 被抓取、丢弃,或当一个爬虫打开时。有许多有用的预先定义的信号,我们后面会讲到。扩展是一个万金油,因为它可以让你写任何你能想到的功能,但不会提供任何实质性的帮助(例如 Item Pipelines 的 process_item())。我们必须连接信号,并植入相关的功能。例如,抓取一定页数或 Items 之后关闭爬虫。如果你想查看默认的扩展,可以在 Scrapy 的 GitHub 里的 settings/default_settings.py 中,找到 EXTENSIONS_BASE 设置。
严格一点讲,Scrapy 将所有的中间件当做类处理(由类 MiddlewareManager 管理),允许我们通过执行 from_crawler()或 from_settings()类方法,分别启用爬虫或 Settings 对象。因为可以从爬虫轻易获取设置(crawler.settings),from_crawler()更流行一些。如果不需要 Settings 或 Crawler,可以不引入它们。
下面的表可以帮助你确定,给定一个问题时,最佳的解决方案是什么:
案例 1——一个简单的 pipeline
假设我们有一个含有若干蜘蛛的应用,它用通常的 Python 格式提供抓取日期。我们的数据库需要字符串格式以便索引它。我们不想编辑爬虫,因为它们有很多。我们该怎么做呢?一个很简单的 pipelines 可以后处理 items 和执行我们需要的转换。让我们看看它是如何做的:
from datetime import datetime
class TidyUp(object):
def process_item(self, item, spider):
item['date'] = map(datetime.isoformat, item['date'])
return item
你可以看到,这就是一个简单的类加一个 process_item()方法。这就是我们需要的 pipeline。我们可以再利用第 3 章中的爬虫,在 tidyup.py 文件中添加上述代码。
笔记:我们将 pipeline 的代码放在任何地方,但最好是在一个独立目录中。
我们现在编辑项目的 settings.py 文件,将 ITEM_PIPELINES 设为:
ITEM_PIPELINES = {'properties.pipelines.tidyup.TidyUp': 100 }
前面 dict 中的 100 设定了连接的 pipelines 的等级。如果另一个 pipeline 有更小的值,会优先将 Items 连接到这个 pipeline。
提示:完整代码位于文件夹 ch8/properties。
现在运行爬虫:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=90
...
INFO: Enabled item pipelines: TidyUp
...
DEBUG: Scraped from <200 ...property_000060.html>
...
'date': ['2015-11-08T14:47:04.148968'],
和预想的一样,日期现在的格式是 ISO 字符串了。
信号
信号提供了一个可以给系统中发生的事件添加调用的机制,例如、当打开爬虫时,或是抓取一个 Item 时。你可以使用 crawler.signals.connect()方法连接它们(例子见下章)。信号有 11 种,最好在实际使用中搞清它们。我建了一个项目,其中我创建了一个扩展,让它连接了每种可能的信号。我还建了一个 Item Pipeline、一个下载器和一个爬虫中间件,它能记录每个使用过的方法。这个爬虫非常简单,只生成两个 items,还有一个例外:
def parse(self, response):
for i in range(2):
item = HooksasyncItem()
item['name'] = "Hello %d" % i
yield item
raise Exception("dead")
对于第二个 Item,我通过 Item P ipeline 配置了一个 DropItem 例外。
提示:完整代码位于 ch08/hooksasync。
使用这个项目,,我们可以更好地理解特定信号何时发送。看下面的命令行之间的注释(为了简洁起见,进行了省略):
$ scrapy crawl test
... many lines ...
# First we get those two signals...
INFO: Extension, signals.spider_opened fired
INFO: Extension, signals.engine_started fired
# Then for each URL we get a request_scheduled signal
INFO: Extension, signals.request_scheduled fired
...# when download completes we get response_downloaded
INFO: Extension, signals.response_downloaded fired
INFO: DownloaderMiddlewareprocess_response called for example.com
# Work between response_downloaded and response_received
INFO: Extension, signals.response_received fired
INFO: SpiderMiddlewareprocess_spider_input called for example.com
# here our parse() method gets called... and then SpiderMiddleware used
INFO: SpiderMiddlewareprocess_spider_output called for example.com
# For every Item that goes through pipelines successfully...
INFO: Extension, signals.item_scraped fired
# For every Item that gets dropped using the DropItem exception...
INFO: Extension, signals.item_dropped fired
# If your spider throws something else...
INFO: Extension, signals.spider_error fired
# ... the above process repeats for each URL
# ... till we run out of them. then...
INFO: Extension, signals.spider_idle fired
# by hooking spider_idle you can schedule further Requests. If you don't
# the spider closes.
INFO: Closing spider (finished)
INFO: Extension, signals.spider_closed fired
# ... stats get printed
# and finally engine gets stopped.
INFO: Extension, signals.engine_stopped fired
你可能会觉得只有 11 的信号太少了,但每个默认的中间件都是用它们实现的,所以肯定足够了。请注意,除了 spider_idle、spider_error、request_scheduled、response_received 和 response_downloaded,你还可以用其它的信号返回的延迟项。
案例 2——一个可以测量吞吐量和延迟的扩展
用 pipelines 测量吞吐量(每秒的文件数)和延迟(从计划到完成下载的时间)的变化十分有趣。
Scrapy 已经有了一个可以测量吞吐量的扩展,Log Stats(见 Scrapy 的 GitHub 页 scrapy/extensions/logstats.py),我们用它作为起点。为了测量延迟,我们连接信号 request_scheduled、response_received 和 item_scraped。我们给每个盖上时间戳,通过相减计算延迟,然后再计算平均延迟。通过观察信号的调用参数,我们发现了一些问题。item_scraped 只得到了 Responses,request_scheduled 只得到了 Requests,response_received 两个都取得了。我们不必破解就可以传递参数。每个 Response 都有一个 Request 成员,它指向回 Request,更好的是,无论是否有重定向,它都有一个 meta dict,并与原生的 Requests 的 meta dict 相同。所以可以将时间戳存在里面。
笔记:事实上,这不是我的主意。扩展 AutoThrottle 也使用了相同的机制(scrapy/extensions/throttle.py),它使用了 request.meta.get(‘download_latency’)。,其中,通过计算器 scrapy/core/downloader/webclient.py 求得 download_latency。提高写中间件速度的方法是,熟悉 Scrapy 默认中间件的代码。
以下是扩展的代码:
class Latencies(object):
@classmethod
def from_crawler(cls, crawler):
return cls(crawler)
def __init__(self, crawler):
self.crawler = crawler
self.interval = crawler.settings.getfloat('LATENCIES_INTERVAL')
if not self.interval:
raise NotConfigured
cs = crawler.signals
cs.connect(self._spider_opened, signal=signals.spider_opened)
cs.connect(self._spider_closed, signal=signals.spider_closed)
cs.connect(self._request_scheduled, signal=signals.request_
scheduled)
cs.connect(self._response_received, signal=signals.response_
received)
cs.connect(self._item_scraped, signal=signals.item_scraped)
self.latency, self.proc_latency, self.items = 0, 0, 0
def _spider_opened(self, spider):
self.task = task.LoopingCall(self._log, spider)
self.task.start(self.interval)
def _spider_closed(self, spider, reason):
if self.task.running:
self.task.stop()
def _request_scheduled(self, request, spider):
request.meta['schedule_time'] = time()
def _response_received(self, response, request, spider):
request.meta['received_time'] = time()
def _item_scraped(self, item, response, spider):
self.latency += time() - response.meta['schedule_time']
self.proc_latency += time() - response.meta['received_time']
self.items += 1
def _log(self, spider):
irate = float(self.items) / self.interval
latency = self.latency / self.items if self.items else 0
proc_latency = self.proc_latency / self.items if self.items else 0
spider.logger.info(("Scraped %d items at %.1f items/s, avg
latency: "
"%.2f s and avg time in pipelines: %.2f s") %
(self.items, irate, latency, proc_latency))
self.latency, self.proc_latency, self.items = 0, 0, 0
头两个方法非常重要,因为它们具有普遍性。它们用一个 Crawler 对象启动中间件。你会发现每个重要的中间件都是这么做的。用 from_crawler(cls, crawler)是取得 crawler 对象。然后,我们注意init()方法引入 crawler.settings 并设置了一个 NotConfigured 例外,如果没有设置的话。你可以看到许多 FooBar 扩展控制着对应的 FOOBAR_ENABLED 设置,如果后者没有设置或为 False 时。这是一个很常见的方式,让 settings.py 设置(例如,ITEM_PIPELINES)可以包含相应的中间件,但它默认是关闭的,除非手动打开。许多默认的 Scrapy 中间件(例如,AutoThrottle 或 HttpCache)使用这种方式。在我们的例子中,我们的扩展是无效的,除非设置 LATENCIES_INTERVAL。
而后在init()中,我们用 crawler.signals.connect()给每个调用设置了信号,并且启动了一些成员变量。其余的类由信号操作。在 _spider_opened(),我们启动了一个定时器,每隔 LATENCIES_INTERVAL 秒,它会调用 _log()方法。在 _spider_closed(),我们关闭了定时器。在 _request_scheduled()和 _response_received(),我们在 request.meta 存储了时间戳。在 _item_scraped(),我们得到了两个延迟,被抓取的 items 数量增加。我们的 _log()方法计算了平均值、格式,然后打印消息,并重设了累加器以开始下一个周期。
笔记:任何在多线程中写过相似代码的人都会赞赏这种不使用互斥锁的方法。对于这个例子,他们的方法可能不会特别复杂,但是单线程代码无疑更容易,在任何场景下都不会太大。
我们可以将这个扩展的代码添加进和 settings.py 同级目录的 latencies.py 文件。要使它生效,在 settings.py 中添加两行:
EXTENSIONS = { 'properties.latencies.Latencies': 500, }
LATENCIES_INTERVAL = 5
像之前一样运行:
$ pwd
/root/book/ch08/properties
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000 -s LOG_LEVEL=INFO
...
INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
INFO: Scraped 0 items at 0.0 items/sec, average latency: 0.00 sec and
average time in pipelines: 0.00 sec
INFO: Scraped 115 items at 23.0 items/s, avg latency: 0.84 s and avg time
in pipelines: 0.12 s
INFO: Scraped 125 items at 25.0 items/s, avg latency: 0.78 s and avg time
in pipelines: 0.12 s
日志的第一行来自 Log Stats 扩展,剩下的来自我们的扩展。我们可以看到吞吐量是每秒 24 个文件,平均延迟是 0.78 秒,下载之后,我们对其处理的时间很短。Little 定律给系统中文件赋值为 N=ST=430.45≅19。无论我们设置 CONCURRENT_REQUESTS 和 CONCURRENT_REQUESTS_PER_DOMAIN 是什么,尽管我们没有达到 100% CPU,这个值很奇怪没有上过 30。更多关于此处的内容请见第 10 章。
进一步扩展中间件
这一部分是为感兴趣的读者写的。只写简单和中级的扩展,可以不用看。
如果你看一眼 scrapy/settings/default_settings.py,你会看到很少的类名。Scrapy 广泛使用了类似依赖注入的机制,允许我们自定义和扩展它的大部分内部对象。例如,除了 DOWNLOAD_HANDLERS_BASE 设置中定义的文件、HTTP、HTTPS、S3、和 FTP 协议,有人还想要支持更多的 URL 协议。要实现的话,只要创建一个 DOWNLOAD_HANDLERS 类,并在 DOWNLOAD_HANDLERS 设置中添加映射。这里的难点是,你自定义的类的接口是什么(即引入什么方法),因为大多数接口都不清晰。你必须阅读源代码,查看这些类是如何使用的。最好的方法是,采用一个现有的程序,然后改造成你的。随着 Scrapy 版本的进化,接口变得越来越稳定,我尝试将它们和 Scrapy 的核心类整理成了一篇文档(我省略了中间件等级)。
核心对象位于左上角。当有人使用 scrapy crawl,使用 CrawlerProcess 对象来创建 Crawler 对象。Crawler 对象是最重要的 Scrapy 类。它包含 settings、signals 和 spider。在一个名为 extensions.crawler 的 ExtensionManager 对象中,它还包括所有的扩展。engine 指向另一个非常重要的类 ExecutionEngine。它包含了 Scheduler、Downloader 和 Scraper。Scheduler 可以对 URL 进行计划、Downloader 用来下载、Scraper 可以后处理。Downloader 包含了 DownloaderMiddleware 和 DownloadHandler,Scraper 包含了 SpiderMiddleware 和 ItemPipeline。这四个 MiddlewareManager 有等级的区别。Scrapy 的输出 feeds 被当做扩展执行,即 FeedExporter。它使用两个独立的层级,一个定于输出类型,另一个定义存储类型。这允许我们,通过调整输出 URL,将 S3 的 XML 文件中的任何东西输出到 Pickle 编码的控制台中。两个层级可以进行独立扩展,使用 FEED_STORAGES 和 FEED_EXPORTERS 设置。最后,通过 scrapy check 命令,让协议有层级,并可以通过 SPIDER_CONTRACTS 设置进行扩展。
总结
你刚刚深度学习了 Scrapy 和 Twisted 编程。你可能要多几遍本章,将这章作为参考。目前,最流行的扩展是 Item Processing Pipeline。下章学习如何使用它解决许多常见的问题。
2321

被折叠的 条评论
为什么被折叠?



