Learning-Scrapy-中文版-全-
Learning Scrapy 中文版(全)
零、序言
序言
第 1 章 Scrapy 介绍
第 2 章 理解 HTML 和 XPath
第 3 章 爬虫基础
第 4 章 从 Scrapy 到移动应用
第 5 章 快速构建爬虫
第 6 章 Scrapinghub 部署
第 7 章 配置和管理
第 8 章 Scrapy 编程
第 9 章 使用 Pipeline
第 10 章 理解 Scrapy 的性能
第 11 章(完) Scrapyd 分布式抓取和实时分析
作者简介
Dimitris Kouzis – Loukas 有超过 15 年的软件开发经历。同时他也参与到教学活动中,受众广泛。
他精通数个领域,包括数学、物理和微电子。对这些学科的理解帮助使他得到了提高,超越了软件的“实用方案”。他认为,好的解决方案应该像物理学一样确定,像纠错内存一样拥有健壮性,并且像数学原理一样具有通用性。
Dimitris 现在正在使用最新的数据中心技术,着手开发分布式、低延迟、高可用性的系统。他运用多个编程语言,但更偏爱 Python、C++和 Java。作为开源软硬件的坚定支持者,他希望对独立开发群体和整个人类做出贡献。
审稿人简介
Lazar Telebak 是一名网络开发自由从业者,专精于网络抓取和利用 Python 库和框架进行网页索引。
他的主要工作涉及自动化、网络抓取和数据导出,导出为 CSV、JSON、XML 和 TXT 等多种格式,或是导出到 MongoDB、SQLAlchemy 和 Postgres 等数据库。
他还会使用网络前端技术:HTML、CSS、JS 和 Jquery。
序言
让我大胆猜一下,下面两个故事肯定有一个说的是你。
你第一次碰到 Scrapy 是在搜索“Python 网络抓取”的时候。你瞟了一眼 Scrapy 想,“这个太复杂,我需要个简单的。”然后你就开始用 requests 写 Python 代码,在 BeautifulSoup 上碰到点麻烦,但最后成功了。这个程序有点慢,所以你让它昼夜不停的运行。重启了几次、忽略了一些坏链和非英文字符,早上的时候,大部分网页都存在你的硬盘里了。但是,因为一些未知的技术原因,你再也不想看这段代码了。下次你再抓取网络的时候,你直接登录 scrapy.org,这次 Scrapy 文档看起来合理多了,感觉不用费力就可以解决所有问题。并且,Scrapy 还能解决你没想到的问题。你再也不用以前的方法了。
或者,你是在做网络抓取调研时碰到的 Scrapy。你需要一个可靠快速的企业级工具,毫无疑问,就是只要轻轻一击就能进行网络抓取。这个工具不仅要简单,而且可以根据不同的数据源进行灵活的定制,提供多种的数据输出方式,可以自动 24/7 的可靠运行。比起要价很高的提供网络抓取服务的公司,你偏向于开源的解决方案。从一开始,Scrapy 就是当然的选择。
无论你是如何听说 Scrapy 的,我都热烈欢迎你翻开这本专门为 Scrapy 而写的书。Scrapy 是全世界网络抓取专家的秘密武器。在专家手中,Scrapy 节省了大量时间,表现出众,花费最少。如果你缺少经验,但想像这些专家一样,很可惜,Google 帮不上你什么忙。网上关于 Scrapy 的大部分信息不是过于简化无效,就是太过复杂。对每个想获得准确、可用、规范的 Scrapy 知识的人,这是本必备的书。希望这本书可以扩大 Scrapy 社区,让 Scrapy 被更多人采用。
本书的内容
第 1 章,Scrapy 介绍,向你介绍这本书和 Scrapy,使你对 Scrapy 框架和后面章节有清醒的认识。
第 2 章,理解 HTML 和 XPath,让爬虫初学者掌握基础的网页相关技术,以及后面会使用到的技术。
第 3 章,爬虫基础,我们会学习如何安装 Scrapy 和抓取网站。通过一步步搭建实例,让读者理解方法和背后的逻辑。学过这一章,你就可以抓取大部分简单站点了。
第 4 章,从 Scrapy 到移动应用,我们如何使用爬虫生成数据库和向移动应用提供数据支持。通过这一章,你会明白如何用网络抓取获益。
第 5 章,快速构建爬虫,介绍更多关于爬虫的特点,模拟登陆、更快抓取、使用 APIs、爬 URL 的方法。
第 6 章,Scrapinghub 部署,如何将爬虫部署到 Scrapinghub 云服务器,以尝试更快的可用性、简易部署和操作。
第 7 章,配置和管理,详细介绍利用 Scrapy 的配置文件对爬虫进行改进。
第 8 章,Scrapy 编程,使用底层 Twisted 引擎和 Scrapy 架构扩展爬虫功能。
第 9 章,如何使用 Pipelines,在不明显降低性能的条件下,举例实现 Scrapy 连接 MySQL、Elasticsearch、Redis、APIs 和应用。
第 10 章,理解 Scrapy 的性能,Scrapy 的工作机制,如何提高 Scrapy 的性能。
第 11 章,Scrapyd 分布式抓取和实时分析,最后一章介绍如何在多台服务器中使用 Scrapyd 以实现水平伸缩性,并将数据传送到 Apache Spark 进行实时分析。
序言
第 1 章 Scrapy 介绍
第 2 章 理解 HTML 和 XPath
第 3 章 爬虫基础
第 4 章 从 Scrapy 到移动应用
第 5 章 快速构建爬虫
第 6 章 Scrapinghub 部署
第 7 章 配置和管理
第 8 章 Scrapy 编程
第 9 章 使用 Pipeline
第 10 章 理解 Scrapy 的性能
第 11 章(完) Scrapyd 分布式抓取和实时分析
本书第二版会在 2018 年三月份出版。第二版的目标是对应 Scrapy 1.4 版本。但那时,恐怕 Scrapy 又要升级了。
新版内容增加了 100 页,达到了 365 页。
https://www.packtpub.com/big-data-and-business-intelligence/learning-scrapy-second-edition
九、使用 Pipelines
在上一章,我们学习了如何辨析 Scrapy 中间件。在本章中,我们通过实例学习编写 pipelines,包括使用 REST APIs、连接数据库、处理 CPU 密集型任务、与老技术结合。
我们在本章中会使用集中新的数据库,列在下图的右边:
Vagrant 已经配置好了数据库,我们可以从开发机向其发送 ping,例如 ping es 或 ping mysql。让我们先来学习 REST APIs。
使用 REST APIs
REST 是用来一套创建网络服务的技术集合。它的主要优点是,比起 SOAP 和专有 web 服务,REST 更简单和轻量。软件开发者注意到了 web 服务的 CRUD(Create、Read、Update、Delete)和 HTTP 操作(GET、POST、PUT、DELETE)的相似性。它们还注意到传统 web 服务调用需要的信息可以再 URL 源进行压缩。例如,http://api.mysite.com/customer/john是一个 URL 源,它可以让我们分辨目标服务器,,更具体的,名字是 john 的服务器(行的主键)。它与其它技术结合时,比如安全认证、无状态服务、缓存、输出 XML 或 JSON 时,可以提供一个强大但简单的跨平台服务。REST 席卷软件行业并不奇怪。
Scrapy pipeline 的功能可以用 REST API 来做。接下来,我们来学习它。
使用 treq
treq 是一个 Python 包,它在 Twisted 应用中和 Python 的 requests 包相似。它可以让我们做出 GET、POST、和其它 HTTP 请求。可以使用 pip install treq 安装,开发机中已经安装好了。
比起 Scrapy 的 Request/crawler.engine.download() API,我们使用 treq,因为后者具有性能优势,详见第 10 章。
一个写入 Elasticsearch 的 pipeline
我们从一个向 ES 服务器(Elasticsearch)写入 Items 的爬虫开始。你可能觉得从 ES 开始,而不是 MySQL,有点奇怪,但实际上 ES 是最容易的。ES 可以是无模式的,意味着我们可以不用配置就使用它。treq 也足以应付需要。如果想使用更高级的 ES 功能,我们应该使用 txes2 和其它 Python/Twisted ES 包。
有了 Vagrant,我们已经有个一个运行的 ES 服务器。登录开发机,验证 ES 是否运行:
$ curl http://es:9200
{
"name" : "Living Brain",
"cluster_name" : "elasticsearch",
"version" : { ... },
"tagline" : "You Know, for Search"
}
在浏览器中登录http://localhost:9200也可以看到相同的结果。如果访问http://localhost:9200/properties/property/_search,我们可以看到一个响应,说 ES 已经进行了全局尝试,但是没有找到索引页。
笔记:在本章中,我们会在项集合中插入新的项,如果你想恢复原始状态的话,可以用下面的命令:
$ curl -XDELETE http://es:9200/properties
本章中的 pipeline 完整代码还有错误处理的功能,但我尽量让这里的代码简短,以突出重点。
提示:本章位于目录 ch09,这个例子位于 ch09/properties/properties/pipelines/es.py。
本质上,这个爬虫只有四行:
@defer.inlineCallbacks
def process_item(self, item, spider):
data = json.dumps(dict(item), ensure_ascii=False).encode("utf- 8")
yield treq.post(self.es_url, data)
前两行定义了一个标准 process_item()方法,它可以产生延迟项。(参考第 8 章)
第三行准备了插入的 data。ensure_ascii=False 可使结果压缩,并且没有跳过非 ASCII 字符。我们然后将 JSON 字符串转化为 JSON 标准的默认编码 UTF-8。
最后一行使用了 treq 的 post()方法,模拟一个 POST 请求,将我们的文档插入 ElasticSearch。es_url,例如http://es:9200/properties/property存在settings.py文件中(ES_PIPELINE_URL 设置),它提供重要的信息,例如我们想要写入的 ES 的 IP 和端口(es:9200)、集合名(properties)和对象类型(property)。
为了是 pipeline 生效,我们要在 settings.py 中设置 ITEM_PIPELINES,并启动 ES_PIPELINE_URL 设置:
ITEM_PIPELINES = {
'properties.pipelines.tidyup.TidyUp': 100,
'properties.pipelines.es.EsWriter': 800,
}
ES_PIPELINE_URL = 'http://es:9200/properties/property'
这么做完之后,我们前往相应的目录:
$ pwd
/root/book/ch09/properties
$ ls
properties scrapy.cfg
然后运行爬虫:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=90
...
INFO: Enabled item pipelines: EsWriter...
INFO: Closing spider (closespider_itemcount)...
'item_scraped_count': 106,
如果现在访问http://localhost:9200/properties/property/_search,除了前 10 条结果,我们可以在响应的 hits/total 字段看到插入的文件数。我们还可以添加参数?size=100 以看到更多的结果。通过添加 q= URL 搜索中的参数,我们可以在全域或特定字段搜索关键词。相关性最强的结果会首先显示出来。例如,http://localhost:9200/properties/property/_search?q=title:london,可以让标题变为 London。对于更复杂的查询,可以在https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html查询 ES 文档。
ES 不需要配置,因为它根据提供的第一个文件,进行模式(字段类型)自动检测的。通过访问http://localhost:9200/properties/,我们可以看到它自动检测的映射。
再次运行 crawl easy -s CLOSESPIDER_ITEMCOUNT=1000。因为 pipelines 的平均时间从 0.12 变为 0.15 秒,平均延迟从 0.78 变为 0.81 秒。吞吐量仍保持每秒约 25 项。
笔记:用 pipelines 向数据库插入 Items 是个好方法吗?答案是否定的。通常来讲,数据库更简单的方法以大量插入数据,我们应该使用这些方法大量批次插入数据,或抓取完毕之后进行后处理。我们会在最后一章看到这些方法。然后,还是有很多人使用 pipelines 向数据库插入文件,相应的就要使用 Twisted APIs。
pipeline 使用 Google Geocoding API 进行地理编码
我们的房子有各自所在的区域,我们还想对它们进行地理编码,即找到相应的坐标(经度、纬度)。我们可以将坐标显示在地图上,或计算距离。建这样的数据库需要复杂的数据库、复杂的文本匹配,还有复杂的空间计算。使用 Google Geocoding API,我们可以避免这些。在浏览器中打开它,或使用 curl 取回以下 URL 的数据:
$ curl "https://maps.googleapis.com/maps/api/geocode/json?sensor=false&ad
dress=london"
{
"results" : [
...
"formatted_address" : "London, UK",
"geometry" : {
...
"location" : {
"lat" : 51.5073509,
"lng" : -0.1277583
},
"location_type" : "APPROXIMATE",
...
],
"status" : "OK"
}
我们看到一个 JSON 对象,如果搜索一个 location,我们可以快速获取伦敦中心的坐标。如果继续搜索,我们可以看到相同文件中海油其它地点。第一个是相关度最高的。因此如果存在 results[0].geometry.location 的话,它就是我们要的结果。
可以用前面的方法(treq)使用 Google Geocoding API。只需要几行,我们就可以找到一个地址的坐标(目录 pipelines 中的 geo.py),如下所示:
@defer.inlineCallbacks
def geocode(self, address):
endpoint = 'http://web:9312/maps/api/geocode/json'
parms = [('address', address), ('sensor', 'false')]
response = yield treq.get(endpoint, params=parms)
content = yield response.json()
geo = content['results'][0]["geometry"]["location"]
defer.returnValue({"lat": geo["lat"], "lon": geo["lng"]})
这个函数做出了一条 URL,但我们让它指向一个可以离线快速运行的假程序。你可以使用 endpoint = 'https://maps.googleapis.com/maps/api/geocode/json'连接 Google 服务器,但要记住它对请求的限制很严格。address 和 sensor 的值是 URL 自动编码的,使用 treq 的方法 get()的参数 params。对于第二个 yield,即 response.json(),我们必须等待响应主题完全加载完毕对解析为 Python 对象。此时,我们就可以找到第一个结果的地理信息,格式设为 dict,使用 defer.returnValue()返回,它使用了 inlineCallbacks。如果发生错误,这个方法会扔出例外,Scrapy 会向我们报告。
通过使用 geocode(),process_item()变成了一行语句:
item["location"] = yield self.geocode(item["address"][0])
设置让 pipeline 生效,将它添加到 ITEM_PIPELINES,并设定优先数值,该数值要小于 ES 的,以让 ES 获取坐标值:
ITEM_PIPELINES = {
...
'properties.pipelines.geo.GeoPipeline': 400,
开启数据调试,然后运行:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=90 -L DEBUG
...
{'address': [u'Greenwich, London'],
...
'image_URL': [u'http://web:93img/i06.jpg'],
'location': {'lat': 51.482577, 'lon': -0.007659},
'price': [1030.0],
...
我们现在可以看到 Items 里的 location 字段。如果使用真正的 Google API 的 URL 运行,会得到例外:
File "pipelines/geo.py" in geocode (content['status'], address))
Exception: Unexpected status="OVER_QUERY_LIMIT" for
address="*London"
这是为了检查我们在完整代码中插入了地点,以确保 Geocoding API 响应的 status 字段有 OK 值。除非是 OK,否则我们取回的数据不会有设定好的格式,进而不能使用。对于这种情况,我们会得到 OVER_QUERY_LIMIT 状态,它指明我们在某处做错了。这个问题很重要,也很常见。应用 Scrapy 的高性能引擎,进行缓存、限制请求就很必要了。
我们可以在 Geocoder API 的文档,查看它的限制,“每 24 小时,免费用户可以进行 2500 次请求,每秒 5 次请求”。即使我们使用付费版本,仍有每秒 10 次请求的限制,所以这里的分析是有意义的。
笔记:后面的代码看起来可能有些复杂,复杂度还要取决于实际情况。在多线程环境中创建这样的组件,需要线程池和同步,这样代码就会变复杂。
这是一个简易的运用 Twisted 技术的限制引擎:
class Throttler(object):
def __init__(self, rate):
self.queue = []
self.looping_call = task.LoopingCall(self._allow_one)
self.looping_call.start(1\. / float(rate))
def stop(self):
self.looping_call.stop()
def throttle(self):
d = defer.Deferred()
self.queue.append(d)
return d
def _allow_one(self):
if self.queue:
self.queue.pop(0).callback(None)
这可以让延迟项在一个列表中排队,逐个触发,调用 _allow_one();_allow_one()检查队列是否为空,如果不是,它会调用第一个延迟项的 callback()。我们使用 Twisted 的 task.LoopingCall() API,周期性调用 _allow_one()。使用 Throttler 很容易。我们在 pipeline 的init初始化它,当爬虫停止时清空它:
class GeoPipeline(object):
def __init__(self, stats):
self.throttler = Throttler(5) # 5 Requests per second
def close_spider(self, spider):
self.throttler.stop()
在使用限定源之前,我们的例子是在 process_item()中调用 geocode(),必须 yield 限制器的 throttle()方法:
yield self.throttler.throttle()
item["location"] = yield self.geocode(item["address"][0])
对于第一个 yield,代码会暂停一下,一段时间之后,会继续运行。例如,当某时有 11 个延迟项时,限制是每秒 5 次请求,即时间为 11/5=2.2 秒之后,队列变空,代码会继续。
使用 Throttler,不再有错误,但是爬虫会变慢。我们看到示例中的房子只有几个不同的地址。这时使用缓存非常好。我们使用一个简单的 Python dict 来做,但这么可能会有竞争条件,这样会造成伪造的 API 请求。下面是一个没有此类问题的缓存方法,展示了 Python 和 Twisted 的特点:
class DeferredCache(object):
def __init__(self, key_not_found_callback):
self.records = {}
self.deferreds_waiting = {}
self.key_not_found_callback = key_not_found_callback
@defer.inlineCallbacks
def find(self, key):
rv = defer.Deferred()
if key in self.deferreds_waiting:
self.deferreds_waiting[key].append(rv)
else:
self.deferreds_waiting[key] = [rv]
if not key in self.records:
try:
value = yield self.key_not_found_callback(key)
self.records[key] = lambda d: d.callback(value)
except Exception as e:
self.records[key] = lambda d: d.errback(e)
action = self.records[key]
for d in self.deferreds_waiting.pop(key):
reactor.callFromThread(action, d)
value = yield rv
defer.returnValue(value)
这个缓存看起来有些不同,它包含两个组件:
- self.deferreds_waiting:这是一个延迟项的队列,等待给键赋值
- self.records:这是键值对中出现过的 dict
在 find()方法的中间,如果没有在 self.records 找到一个键,我们会调用预先定义的 callback 函数,以取回丢失的值(yield self.key_not_found_callback(key))。这个调回函数可能会扔出一个例外。如何在 Python 中压缩存储值或例外呢?因为 Python 是一种函数语言,根据是否有例外,我们在 self.records 中保存小函数(lambdas),调用 callback 或 errback。lambda 函数定义时,就将值或例外附着在上面。将变量附着在函数上称为闭包,闭包是函数语言最重要的特性之一。
笔记:缓存例外有点不常见,但它意味着首次查找 key 时,key_not_found_callback(key)返回了一个例外。当后续查找还找这个 key 时,就免去了调用,再次返回这个例外。
find()方法其余的部分提供了一个避免竞争条件的机制。如果查找某个键已经在进程中,会在 self.deferreds_waiting dict 中有记录。这时,我们不在向 key_not_found_callback()发起另一个调用,只是在延迟项的等待列表添加这个项。当 key_not_found_callback()返回时,键有了值,我们触发所有的等待这个键的延迟项。我们可以直接发起 action(d),而不用 reactor.callFromThread(),但需要处理每个扔给下游的例外,我们必须创建不必要的很长的延迟项链。
使用这个缓存很容易。我们在init()对其初始化,设定调回函数为 API 调用。在 process_item()中,使用缓存查找的方法如下:
def __init__(self, stats):
self.cache = DeferredCache(self.cache_key_not_found_callback)
@defer.inlineCallbacks
def cache_key_not_found_callback(self, address):
yield self.throttler.enqueue()
value = yield self.geocode(address)
defer.returnValue(value)
@defer.inlineCallbacks
def process_item(self, item, spider):
item["location"] = yield self.cache.find(item["address"][0])
defer.returnValue(item)
提示:完整代码位于 ch09/properties/properties/pipelines/geo2.py。
为了使 pipeline 生效,我们使前一个方法无效,并添加当前的到 settings.py 的 ITEM_PIPELINES:
ITEM_PIPELINES = {
'properties.pipelines.tidyup.TidyUp': 100,
'properties.pipelines.es.EsWriter': 800,
# DISABLE 'properties.pipelines.geo.GeoPipeline': 400,
'properties.pipelines.geo2.GeoPipeline': 400,
}
运行爬虫,用如下代码:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000
...
Scraped... 15.8 items/s, avg latency: 1.74 s and avg time in pipelines:
0.94 s
Scraped... 32.2 items/s, avg latency: 1.76 s and avg time in pipelines:
0.97 s
Scraped... 25.6 items/s, avg latency: 0.76 s and avg time in pipelines:
0.14 s
...
: Dumping Scrapy stats:...
'geo_pipeline/misses': 35,
'item_scraped_count': 1019,
当填充缓存时,我们看到抓取的延迟变高。缓存结束时,延迟降低。数据还显示有 35 个遗漏,正好是数据集中不同地点的数目。很明显,上例中一共有 1019 - 35= 984 次 API 请求。如果我们使用真正的 Google API,并提高每秒的 API 请求数,例如通过改变 Throttler(5)到 Throttler(10),使从 5 提高到 10,我们可以将重试添加到 geo_pipeline/retries stat 记录中。如果有错误的话,例如,使用 API 找不到某个地点,会扔出一个例外,这会被 geo_pipeline/errors stat 记录。如果地点通过什么方式已经存在了,会在 geo_pipeline/already_set stat 中指明。最后,如果我们访问http://localhost:9200/properties/property/_search,以检查 ES 中的房子,我们可以看到包括地点的记录,例如{..."location": {"lat": 51.5269736, "lon": -0.0667204}...}。(运行前确保清空集合,去除旧的值)
在 Elasticsearch 进行地理索引
我们已经有了地点,我们可以将它们按距离排序。下面是一个 HTTP POST 请求,返回标题中包含 Angel 的房子,按照离点{51.54, -0.19}的距离进行排序:
$ curl http://es:9200/properties/property/_search -d '{
"query" : {"term" : { "title" : "angel" } },
"sort": [{"_geo_distance": {
"location": {"lat": 51.54, "lon": -0.19},
"order": "asc",
"unit": "km",
"distance_type": "plane"
}}]}'
唯一的问题是如果我们运行它,我们会看到一个错误信息"failed to find mapper for [location] for geo distance based sort"。它指出,我们的 location 字段没有正确的空间计算的格式。为了设定正确的格式,我们要手动覆盖默认格式。首先,我们将自动检测的映射保存起来,将它作为起点:
$ curl 'http://es:9200/properties/_mapping/property' > property.txt
然后,我们如下所示编辑 property.txt:
"location":{"properties":{"lat":{"type":"double"},"lon":{"type":"double"}}}
我们将这行代码替换为:
"location": {"type": "geo_point"}
我们还在文件最后删除了{"properties":{"mappings": and two }}。文件现在就处理完了。我们现在可以删除旧的类型,并用下面的 schema 建立新的类型:
$ curl -XDELETE 'http://es:9200/properties'
$ curl -XPUT 'http://es:9200/properties'
$ curl -XPUT 'http://es:9200/properties/_mapping/property' --data
@property.txt
我们现在可以用之前的命令,进行一个快速抓取,将结果按距离排序。我们的搜索返回的是房子的 JSONs 对象,其中包括一个额外的 sort 字段,显示房子离某个点的距离。
连接数据库与 Python 客户端
可以连接 Python Database API 2.0 的数据库有许多种,包括 MySQL、PostgreSQL、Oracle、Microsoft、SQL Server 和 SQLite。它们的驱动通常很复杂且进行过测试,为 Twisted 再进行适配会浪费很多时间。可以在 Twisted 应用中使用数据库客户端,例如,Scrapy 可以使用 twisted.enterprise.adbapi 库。我们使用 MySQL 作为例子,说明用法,原则也适用于其他数据库。
用 pipeline 写入 MySQL
MySQL 是一个好用又流行的数据库。我们来写一个 pipeline,来向其中写入文件。我们的虚拟环境中,已经有了一个 MySQL 实例。我们用 MySQL 命令行来做一些基本的管理操作,命令行工具已经在开发机中预先安装了:
$ mysql -h mysql -uroot -ppass
mysql>提示 MySQL 已经运行,我们可以建立一个简单的含有几个字段的数据表,如下所示:
mysql> create database properties;
mysql> use properties
mysql> CREATE TABLE properties (
url varchar(100) NOT NULL,
title varchar(30),
price DOUBLE,
description varchar(30),
PRIMARY KEY (url)
);
mysql> SELECT * FROM properties LIMIT 10;
Empty set (0.00 sec)
很好,现在已经建好了一个包含几个字段的 MySQL 数据表,它的名字是 properties,可以开始写 pipeline 了。保持 MySQL 控制台打开,我们过一会儿会返回查看是否有差入值。输入 exit,就可以退出。
笔记:在这一部分中,我们会向 MySQL 数据库插入 properties。如果你想删除,使用以下命令:
mysql> DELETE FROM properties;
我们使用 MySQL 的 Python 客户端。我们还要安装一个叫做 dj-database-url 的小功能模块(它可以帮我们设置不同的 IP、端口、密码等等)。我们可以用 pip install dj-database-url MySQL-python,安装这两项。我们的开发机上已经安装好了。我们的 MySQL pipeline 很简单,如下所示:
from twisted.enterprise import adbapi
...
class MysqlWriter(object):
...
def __init__(self, mysql_url):
conn_kwargs = MysqlWriter.parse_mysql_url(mysql_url)
self.dbpool = adbapi.ConnectionPool('MySQLdb',
charset='utf8',
use_unicode=True,
connect_timeout=5,
**conn_kwargs)
def close_spider(self, spider):
self.dbpool.close()
@defer.inlineCallbacks
def process_item(self, item, spider):
try:
yield self.dbpool.runInteraction(self.do_replace, item)
except:
print traceback.format_exc()
defer.returnValue(item)
@staticmethod
def do_replace(tx, item):
sql = """REPLACE INTO properties (url, title, price, description) VALUES (%s,%s,%s,%s)"""
args = (
item["url"][0][:100],
item["title"][0][:30],
item["price"][0],
item["description"][0].replace("\r\n", " ")[:30]
)
tx.execute(sql, args)
提示:完整代码位于 ch09/properties/properties/pipelines/mysql.py。
本质上,这段代码的大部分都很普通。为了简洁而省略的代码将一条保存在 MYSQL_PIPELINE_URL、格式是mysql://user:pass@ip/database的 URL,解析成了独立的参数。在爬虫的init()中,将它们传递到 adbapi.ConnectionPool(),它使用 adbapi 的底层结构,初始化 MySQL 连接池。第一个参数是我们想要引入的模块的名字。对于我们的 MySQL,它是 MySQLdb。我们为 MySQL 客户端另设了几个参数,以便正确处理 Unicode 和超时。每当 adbapi 打开新连接时,所有这些参数都要进入底层的 MySQLdb.connect()函数。爬虫关闭时,我们调用连接池的 close()方法。
我们的 process_item()方法包装了 dbpool.runInteraction()。这个方法给调回方法排队,会在当连接池中一个连接的 Transaction 对象变为可用时被调用。这个 Transaction 对象有一个和 DB-API 指针相似的 API。在我们的例子中,调回方法是 do_replace(),它定义在后面几行。@staticmethod 是说这个方法关联的是类而不是具体的类实例,因此,我们可以忽略通常的 self 参数。如果方法不使用成员的话,最好设其为静态,如果你忘了设为静态也不要紧。这个方法准备了一个 SQL 字符串、几个参数,并调用 Transaction 的 execute()函数,以进行插入。我们的 SQL 使用 REPLACE INTO,而不用更常见的 INSERT INTO,来替换键相同的项。这可以让我们的案例简化。如果我们相拥 SQL 返回数据,例如 SELECT 声明,我们使用 dbpool.runQuery(),我们可能还需要改变默认指针,方法是设置 adbapi.ConnectionPool()的参数 cursorclass 为 cursorclass=MySQLdb.cursors,这样取回数据更为简便。
使用这个 pipeline,我们要在 settings.py 的 ITEM_PIPELINES 添加它,还要设置一下 MYSQL_PIPELINE_URL:
ITEM_PIPELINES = { ...
'properties.pipelines.mysql.MysqlWriter': 700,
...
MYSQL_PIPELINE_URL = 'mysql://root:pass@mysql/properties'
执行以下命令:
scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000
运行这条命令后,返回 MySQL 控制台,可以看到如下记录:
mysql> SELECT COUNT(*) FROM properties;
+----------+
| 1006 |
+----------+
mysql> SELECT * FROM properties LIMIT 4;
+------------------+--------------------------+--------+-----------+
| url | title | price | description
+------------------+--------------------------+--------+-----------+
| http://...0.html | Set Unique Family Well | 334.39 | website c
| http://...1.html | Belsize Marylebone Shopp | 388.03 | features
| http://...2.html | Bathroom Fully Jubilee S | 365.85 | vibrant own
| http://...3.html | Residential Brentford Ot | 238.71 | go court
+------------------+--------------------------+--------+-----------+
4 rows in set (0.00 sec)
延迟和吞吐量的性能和之前相同。结果让人印象深刻。
使用 Twisted 特定客户端连接服务
目前为止,我们学习了如何用 treq 使用类 REST APIs。Scrapy 可以用 Twisted 特定客户端连接许多其它服务。例如,如果我们想连接 MongoDB,通过搜索“MongoDB Python”,我们可以找到 PyMongo,它是阻塞/同步的,除非我们使用 pipeline 处理阻塞操作中的线程,我们不能在 Twisted 中使用 PyMongo。如果我们搜索“MongoDB Twisted Python”,可以找到 txmongo,它可以完美适用于 Twisted 和 Scrapy。通常的,Twisted 客户端群体很小,但使用它比起自己写一个客户端还是要方便。下面,我们就使用这样一个 Twisted 特定客户端连接 Redis 键值对存储。
用 pipeline 读写 Redis
Google Geocoding API 是按照每个 IP 进行限制的。如果可以接入多个 IPs(例如,多台服务器),当一个地址已经被另一台机器做过地理编码,就要设法避免对发出重复的请求。如果一个地址之前已经被查阅过,也要避免再次查阅。我们不想浪费限制的额度。
笔记:与 API 商家联系,以确保这符合规定。你可能,必须每几分钟/小时,就要清空缓存记录,或者根本就不能缓存。
我们可以使用 Redis 键值缓存作为分布式 dict。Vagrant 环境中已经有了一个 Redis 实例,我们现在可以连接它,用 redis-cli 作一些基本操作:
$ redis-cli -h redis
redis:6379> info keyspace
# Keyspace
redis:6379> set key value
OK
redis:6379> info keyspace
# Keyspace
db0:keys=1,expires=0,avg_ttl=0
redis:6379> FLUSHALL
OK
redis:6379> info keyspace
# Keyspace
redis:6379> exit
通过搜索“Redis Twisted”,我们找到一个 txredisapi 库。它最大的不同是,它不仅是一个 Python 的同步封装,还是一个 Twisted 库,可以通过 reactor.connectTCP(),执行 Twisted 协议,连接 Redis。其它库也有类似用法,但是 txredisapi 对于 Twisted 效率更高。我们可以通过安装库 dj_redis_url 可以安装它,这个库通过 pip 可以解析 Redis 配置 URL(sudo pip install txredisapi dj_redis_url)。和以前一样,开发机中已经安装好了。
我们如下启动 RedisCache pipeline:
from txredisapi import lazyConnectionPool
class RedisCache(object):
...
def __init__(self, crawler, redis_url, redis_nm):
self.redis_url = redis_url
self.redis_nm = redis_nm
args = RedisCache.parse_redis_url(redis_url)
self.connection = lazyConnectionPool(connectTimeout=5,
replyTimeout=5,
**args)
crawler.signals.connect(
self.item_scraped,signal=signals.item_scraped)
这个 pipeline 比较简单。为了连接 Redis 服务器,我们需要主机、端口等等,它们全都用 URL 格式存储。我们用 parse_redis_url()方法解析这个格式。使用命名空间做键的前缀很普遍,在我们的例子中,我们存储在 redis_nm。我们然后使用 txredisapi 的 lazyConnectionPool()打开一个数据库连接。
最后一行有一个有趣的函数。我们是想用 pipeline 封装 geo-pipeline。如果在 Redis 中没有某个值,我们不会设定这个值,geo-pipeline 会用 API 像之前一样将地址进行地理编码。完毕之后,我们必须要在 Redis 中缓存键值对,我们是通过连接 signals.item_scraped 信号来做的。我们定义的调回(即 item_scraped()方法,马上会讲)只有在最后才会被调用,那时,地址就设置好了。
提示:完整代码位于 ch09/properties/properties/pipelines/redis.py。
我们简化缓存,只寻找和存储每个 Item 的地址和地点。这对 Redis 来说是合理的,因为它通常是运行在单一服务器上的,这可以让它很快。如果不是这样的话,可以加入一个 dict 结构的缓存,它与我们在 geo-pipeline 中用到的相似。以下是我们如何处理入库的 Items:
process incoming Items:
@defer.inlineCallbacks
def process_item(self, item, spider):
address = item["address"][0]
key = self.redis_nm + ":" + address
value = yield self.connection.get(key)
if value:
item["location"] = json.loads(value)
defer.returnValue(item)
和预期的相同。我们得到了地址,给它添加前缀,然后使用 txredisapi connection 的 get()在 Redis 进行查找。我们将 JSON 编码的对象在 Redis 中保存成值。如果一个值设定了,我们就使用 JSON 解码,然后将其设为地点。
当一个 Item 到达 pipelines 的末端时,我们重新取得它,将其保存为 Redis 中的地点值。以下是我们的做法:
from txredisapi import ConnectionError
def item_scraped(self, item, spider):
try:
location = item["location"]
value = json.dumps(location, ensure_ascii=False)
except KeyError:
return
address = item["address"][0]
key = self.redis_nm + ":" + address
quiet = lambda failure: failure.trap(ConnectionError)
return self.connection.set(key, value).addErrback(quiet)
如果我们找到了一个地点,我们就取得了地址,添加前缀,然后使用它作为 txredisapi 连接的 set()方法的键值对。set()方法没有使用@defer.inlineCallbacks,因为处理 signals.item_scraped 时,它不被支持。这意味着,我们不能对 connection.set()使用 yield,但是我们可以返回一个延迟项,Scrapy 可以在它后面排上其它信号对象。任何情况下,如果 Redis 的连接不能使用用 connection.set(),它就会抛出一个例外。在这个错误处理中,我们把传递的错误当做参数,我们让它 trap()任何 ConnectionError。这是 Twisted 的延迟 API 的优点之一。通过用 trap()捕获错误项,我们可以轻易忽略它们。
使这个 pipeline 生效,我们要做的是在 settings.py 的 ITEM_PIPELINES 中添加它,并提供一个 REDIS_PIPELINE_URL。必须要让它的优先级比 geo-pipeline 高,以免太晚就不能使用了:
ITEM_PIPELINES = { ...
'properties.pipelines.redis.RedisCache': 300,
'properties.pipelines.geo.GeoPipeline': 400,
...
REDIS_PIPELINE_URL = 'redis://redis:6379'
像之前一样运行。第一次运行时和以前很像,但随后的运行结果如下:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=100
...
INFO: Enabled item pipelines: TidyUp, RedisCache, GeoPipeline,
MysqlWriter, EsWriter
...
Scraped... 0.0 items/s, avg latency: 0.00 s, time in pipelines: 0.00 s
Scraped... 21.2 items/s, avg latency: 0.78 s, time in pipelines: 0.15 s
Scraped... 24.2 items/s, avg latency: 0.82 s, time in pipelines: 0.16 s
...
INFO: Dumping Scrapy stats: {...
'geo_pipeline/already_set': 106,
'item_scraped_count': 106,
我们看到 GeoPipeline 和 RedisCache 都生效了,RedisCache 第一个输出。还注意到在统计中 geo_pipeline/already_set: 106。这是 GeoPipeline 发现的 Redis 缓存中填充的数目,它不调用 Google API。如果 Redis 缓存是空的,你会看到 Google API 处理了一些键。从性能上来讲,我们看到 GeoPipeline 引发的初始行为消失了。事实上,当我们开始使用内存,我们绕过了每秒只有 5 次请求的 API 限制。如果我们使用 Redis,应该考虑使用过期键,让系统周期刷新缓存数据。
连接 CPU 密集型、阻塞或旧方法
最后一部分讲连接非 Twisted 的工作。尽管异步程序的优点很多,并不是所有库都专门为 Twisted 和 Scrapy 写的。使用 Twisted 的线程池和 reactor.spawnProcess()方法,我们可以使用任何 Python 库和任何语言写的编码。
pipeline 进行 CPU 密集型和阻塞操作
我们在第 8 章中强调,反应器适合简短非阻塞的任务。如果我们不得不要处理复杂和阻塞的任务,又该怎么做呢?Twisted 提供了线程池,有了它可以使用 reactor.callInThread() API 在分线程而不是主线程中执行慢操作。这意味着,反应器可以一直运行并对事件反馈,而不中断计算。但要记住,在线程池中运行并不安全,当你使用全局模式时,会有多线程的同步问题。让我们从一个简单的 pipeline 开始,逐渐做出完整的代码:
class UsingBlocking(object):
@defer.inlineCallbacks
def process_item(self, item, spider):
price = item["price"][0]
out = defer.Deferred()
reactor.callInThread(self._do_calculation, price, out)
item["price"][0] = yield out
defer.returnValue(item)
def _do_calculation(self, price, out):
new_price = price + 1
time.sleep(0.10)
reactor.callFromThread(out.callback, new_price)
在前面的 pipeline 中,我们看到了一些基本用法。对于每个 Item,我们提取出价格,我们相用 _do_calucation()方法处理它。这个方法使用 time.sleep(),一个阻塞操作。我们用 reactor.callInThread()调用,让它在另一个线程中运行。显然,我们传递价格,我们还创建和传递了一个名为 out 的延迟项。当 _do_calucation()完成了计算,我们使用 out 调回值。下一步,我们执行延迟项,并未价格设新的值,最后返回 Item。
在 _do_calucation()中,有一个细微之处,价格增加了 1,进而睡了 100ms。这个时间很多,如果调用进反应器主线程,每秒就不能抓取 10 页了。通过在另一个线程中运行,就不会再有这个问题。任务会在线程池中排队,每次处理耗时 100ms。最后一步是触发调回。一般的,我们可以使用 out.callback(new_price),但是因为我们现在是在另一个线程,这么做不安全。如果这么做的话,延迟项的代码会被从另一个线程调用,这样迟早会产生错误的数据。不这样做,转而使用 reactor.callFromThread(),它也可以将函数当做参数,将任意其余参数传递到函数。这个函数会排队并被调回主线程,主进程反过来会打开 process_item()对象 yield,并继续 Item 的操作。
如果我们用全局模式,例如计数器、滑动平均,又该怎么使用 _do_calucation()呢?例如,添加两个变量,beta 和 delta,如下所示:
class UsingBlocking(object):
def __init__(self):
self.beta, self.delta = 0, 0
...
def _do_calculation(self, price, out):
self.beta += 1
time.sleep(0.001)
self.delta += 1
new_price = price + self.beta - self.delta + 1
assert abs(new_price-price-1) < 0.01
time.sleep(0.10)...
这段代码是断言失败错误。这是因为如果一个线程在 self.beta 和 self.delta 间切换,另一个线程继续计算使用 beta/delta 计算价格,它会发现它们状态不一致(beta 大于 delta),因此,计算出错误的结果。短暂的睡眠可能会造成竞争条件。为了不发生这些状况,我们要使一个锁,例如 Python 的 threading.RLock()递归锁。使用它,可以确保没有两个线程在同一时间操作被保护代码:
class UsingBlocking(object):
def __init__(self):
...
self.lock = threading.RLock()
...
def _do_calculation(self, price, out):
with self.lock:
self.beta += 1
...
new_price = price + self.beta - self.delta + 1
assert abs(new_price-price-1) < 0.01 ...
代码现在就正确了。记住,我们不需要保护整段代码,就足以处理全局模式。
提示:完整代码位于 ch09/properties/properties/pipelines/computation.py。
要使用这个 pipeline,我们需要把它添加到 settings.py 的 ITEM_PIPELINES 中。如下所示:
ITEM_PIPELINES = { ...
'properties.pipelines.computation.UsingBlocking': 500,
像之前一样运行爬虫,pipeline 延迟达到了 100ms,但吞吐量没有发生变化,大概每秒 25 个 items。
pipeline 使用二进制和脚本
最麻烦的借口当属独立可执行文件和脚本。打开需要几秒(例如,从数据库加载数据),但是后面处理数值的延迟很小。即便是这种情况,Twisted 也预料到了。我们可以使用 reactor.spawnProcess() API 和相关的 protocol.ProcessProtocol 来运行任何执行文件。让我们来看一个例子。脚本如下:
#!/bin/bash
trap "" SIGINT
sleep 3
while read line
do
# 4 per second
sleep 0.25
awk "BEGIN {print 1.20 * $line}"
done
这是一个简单的 bash 脚本。它运行时,会使 Ctrl + C 无效。这是为了避免系统的一个奇怪的错误,将 Ctrl + C 增值到子流程并过早结束,导致 Scrapy 强制等待流程结果。在使 Ctrl + C 无效之后,它睡眠三秒,模拟启动时间。然后,它阅读输入的代码语句,等待 250ms,然后返回结果价格,价格的值乘以了 1.20,由 Linux 的 awk 命令计算而得。这段脚本的最大吞吐量为每秒 1/250ms=4 个 Items。用一个短 session 检测:
$ properties/pipelines/legacy.sh
12 <- If you type this quickly you will wait ~3 seconds to get results
14.40
13 <- For further numbers you will notice just a slight delay
15.60
因为 Ctrl + C 失效了,我们用 Ctrl + D 必须结束 session。我们该如何让 Scrapy 使用这个脚本呢?再一次,我们从一个简化版开始:
class CommandSlot(protocol.ProcessProtocol):
def __init__(self, args):
self._queue = []
reactor.spawnProcess(self, args[0], args)
def legacy_calculate(self, price):
d = defer.Deferred()
self._queue.append(d)
self.transport.write("%f\n" % price)
return d
# Overriding from protocol.ProcessProtocol
def outReceived(self, data):
"""Called when new output is received"""
self._queue.pop(0).callback(float(data))
class Pricing(object):
def __init__(self):
self.slot = CommandSlot(['properties/pipelines/legacy.sh'])
@defer.inlineCallbacks
def process_item(self, item, spider):
item["price"][0] = yield self.slot.legacy_calculate(item["price"][0])
defer.returnValue(item)
我们在这里找到了一个名为 CommandSlot 的 ProcessProtocol 和 Pricing 爬虫。在init()中,我们创建了新的 CommandSlot,它新建了一个空的队列,并用 reactor.spawnProcess()开启了一个新进程。它调用收发数据的 ProcessProtocol 作为第一个参数。在这个例子中,是 self 的原因是 spawnProcess()是被从类 protocol 调用的。第二个参数是可执行文件的名字,第三个参数 args,让二进制命令行参数成为字符串序列。
在 pipeline 的 process_item()中,我们用 CommandSlot 的 legacy_calculate()方法代表所有工作,CommandSlot 可以返回产生的延迟项。legacy_calculate()创建延迟项,将其排队,用 transport.write()将价格写入进程。ProcessProtocol 提供了 transport,可以让我们与进程沟通。无论何时我们从进程收到数据, outReceived()就会被调用。通过延迟项,进程依次执行,我们可以弹出最老的延迟项,用收到的值触发它。全过程就是这样。我们可以让这个 pipeline 生效,通过将它添加到 ITEM_PIPELINES:
ITEM_PIPELINES = {...
'properties.pipelines.legacy.Pricing': 600,
如果运行的话,我们会看到性能很差。进程变成了瓶颈,限制了吞吐量。为了提高性能,我们需要修改 pipeline,允许多个进程并行运行,如下所示:
class Pricing(object):
def __init__(self):
self.concurrency = 16
args = ['properties/pipelines/legacy.sh']
self.slots = [CommandSlot(args)
for i in xrange(self.concurrency)]
self.rr = 0
@defer.inlineCallbacks
def process_item(self, item, spider):
slot = self.slots[self.rr]
self.rr = (self.rr + 1) % self.concurrency
item["price"][0] = yield
slot.legacy_calculate(item["price"][0])
defer.returnValue(item)
这无非是开启 16 个实例,将价格以轮转的方式发出。这个 pipeline 的吞吐量是每秒 16*4 = 64。我们可以用下面的爬虫进行验证:
$ scrapy crawl easy -s CLOSESPIDER_ITEMCOUNT=1000
...
Scraped... 0.0 items/s, avg latency: 0.00 s and avg time in pipelines:
0.00 s
Scraped... 21.0 items/s, avg latency: 2.20 s and avg time in pipelines:
1.48 s
Scraped... 24.2 items/s, avg latency: 1.16 s and avg time in pipelines:
0.52 s
延迟增加了 250 ms,但吞吐量仍然是每秒 25。
请记住前面的方法使用了 transport.write()让所有的价格在脚本 shell 中排队。这个可能对你的应用不适用,,尤其是当数据量很大时。Git 的完整代码让值和调回都进行了排队,不想脚本发送值,除非收到前一项的结果。这种方法可能看起来更友好,但是会增加代码复杂度。
总结
你刚刚学习了复杂的 Scrapy pipelines。目前为止,你应该就掌握了所有和 Twisted 编程相关的知识。并且你学会了如何在进程中执行复杂的功能,用 Item Processing Pipelines 存储 Items。我们看到了添加 pipelines 对延迟和吞吐量的影响。通常,延迟和吞吐量是成反比的。但是,这是在恒定并发数的前提下(例如,一定数量的线程)。在我们的例子中,我们一开始的并发数为 N=ST=250.77≅19,添加 pipelines 之后,并发数为 N=25*3.33≅83,并没有引起性能的变化。这就是 Twisted 的强大之处!下面学习第 10 章,Scrapy 的性能。
十、理解 Scrapy 的性能
通常,很容易将性能理解错。对于 Scrapy,几乎一定会把它的性能理解错,因为这里有许多反直觉的地方。除非你对 Scrapy 的结构有清楚的了解,你会发现努力提升 Scrapy 的性能却收效甚微。这就是处理高性能、低延迟、高并发环境的复杂之处。对于优化瓶颈, Amdahl 定律仍然适用,但除非找到真正的瓶颈,吞吐量并不会增加。要想学习更多,可以看 Dr.Goldratt 的《目标》这本书,其中用比喻讲到了更多关于瓶延迟、吞吐量的知识。本章就是来帮你确认 Scrapy 配置的瓶颈所在,让你避免明显的错误。
请记住,本章相对较难,涉及到许多数学。但计算还算比较简单,并且有图表示意。如果你不喜欢数学,可以直接忽略公式,这样仍然可以搞明白 Scrapy 的性能是怎么回事。
Scrapy 的引擎——一个直观的方法
并行系统看起来就像管道系统。在计算机科学中,我们使用队列符表示队列并处理元素(见图 1 的左边)。队列系统的基本定律是 Little 定律,它指明平衡状态下,队列系统中的总元素个数(N)等于吞吐量(T)乘以总排队/处理时间(S),即 N=T*S。另外两种形式,T=N/S 和 S=N/T 也十分有用。
图 1 Little 定律、队列系统、管道
管道(图 1 的右边)在几何学上也有一个相似的定律。管道的体积(V)等于长度(L)乘以横截面积(A),即 V=L*A。
如果假设 L 代表处理时间 S(L≈S),体积代表总元素个数(V≈N),横截面积啊代表吞吐量(A≈T),Little 定律和体积公式就是相同的。
提示:这个类比合理吗?答案是基本合理。如果我们想象小液滴在管道中以匀速流过,那么 L≈S 就完全合理,因为管道越长,液滴流过的时间也越长。V≈N 也是合理的,因为管道越大,它能容下的液滴越多。但是,我们可以通过增大压力的方法,压入更多的液滴。A≈T 是真正的类比。在管道中,吞吐量是每秒流进/流出的液滴总数,被称为体积流速,在正常的情况下,它与 A^2 成正比。这是因为更宽的管道不仅意味更多的液体流出,还具有更快的速度,因为管壁之间的空间变大了。但对于这一章,我们可以忽略这一点,假设压力和速度是不变的,吞吐量只与横截面积成正比。
Little 定律与体积公式十分相似,所以管道模型直观上是正确的。再看看图 1 中的右半部。假设管道代表 Scrapy 的下载器。第一个十分细的管道,它的总体积/并发等级(N)=8 个并发请求。长度/延迟(S)对于一个高速网站,假设为 S=250ms。现在可以计算横街面积/吞吐量 T=N/S=8/0.25=32 请求/秒。
可以看到,延迟率是手远程服务器和网络延迟的影响,不受我们控制。我们可以控制的是下载器的并发等级(N),将 8 提升到 16 或 32,如图 1 所示。对于固定的管道长度(这也不受我们控制),我们只能增加横截面积来增加体积,即增加吞吐量。用 Little 定律来讲,并发如果是 16 个请求,就有 T=N/S=16/0.25=64 请求/秒,并发 32 个请求,就有 T=N/S=32/0.25=128 请求/秒。貌似如果并发数无限大,吞吐量也就无限大。在得出这个结论之前,我们还得考虑一下串联排队系统。
串联排队系统
当你将横截面积/吞吐量不同的管道连接起来时,直观上,人们会认为总系统会受限于最窄的管道(最小的吞吐量 T),见图 2。
图 2 不同的串联排队系统
你还可以看到最窄的管道(即瓶颈)放在不同的地方,可以影响其他管道的填充程度。如果将填充程度类比为系统内存需求,瓶颈的摆放就十分重要了。最好能将填充程度达到最高,这样单位工作的花费最小。在 Scrapy 中,单位工作(抓取一个网页)大体包括下载器之前的一条 URL(几个字节)和下载器之后的 URL 和服务器响应。
提示:这就是为什么,Scrapy 把瓶颈放在下载器。
确认瓶颈
用管道系统的比喻,可以直观的确认瓶颈所在。查看图 2,你可以看到瓶颈之前都是满的,瓶颈之后就不是满的。
对于大多数系统,可以用系统的性能指标监测排队系统是否拥挤。通过检测 Scrapy 的队列,我们可以确定出瓶颈的所在,如果瓶颈不是在下载器的话,我们可以通过调整设置使下载器成为瓶颈。瓶颈没有得到优化,吞吐量就不会有优化。调整其它部分只会使系统变得更糟,很可能将瓶颈移到别处。所以在修改代码和配置之前,你必须找到瓶颈。你会发现在大多数情况下,包括本书中的例子,瓶颈的位置都和预想的不同。
Scrapy 的性能模型
让我们回到 Scrapy,详细查看它的性能模型,见图 3。
图 3 Scrapy 的性能模型
Scrapy 包括以下部分:
- 调度器:大量的 Request 在这里排队,直到下载器处理它们。其中大部分是 URL,因此体积不大,也就是说即便有大量请求存在,也可以被下载器及时处理。
- 阻塞器:这是抓取器由后向前进行反馈的一个安全阀,如果进程中的响应大于 5MB,阻塞器就会暂停更多的请求进入下载器。这可能会造成性能的波动。
- 下载器:这是对 Scrapy 的性能最重要的组件。它用复杂的机制限制了并发数。它的延迟(管道长度)等于远程服务器的响应时间,加上网络/操作系统、Python/Twisted 的延迟。我们可以调节并发请求数,但是对其它延迟无能为力。下载器的能力受限于 CONCURRENT_REQUESTS*设置。
- 爬虫:这是抓取器将 Response 变为 Item 和其它 Request 的组件。只要我们遵循规则来写爬虫,通常它不是瓶颈。
- Item Pipelines:这是抓取器的第二部分。我们的爬虫对每个 Request 可能产生几百个 Items,只有 CONCURRENT_ITEMS 会被并行处理。这一点很重要,因为,如果你用 pipelines 连接数据库,你可能无意地向数据库导入数据,pipelines 的默认值(100)就会看起来很少。
爬虫和 pipelines 的代码是异步的,会包含必要的延迟,但二者不会是瓶颈。爬虫和 pipelines 很少会做繁重的处理工作。如果是的话,服务器的 CPU 则是瓶颈。
使用远程登录控制组件
为了理解 Requests/Items 是如何在管道中流动的,我们现在还不能真正的测量流动。然而,我们可以检测在 Scrapy 的每个阶段,有多少个 Requests/Responses/Items。
通过 Scrapy 运行远程登录,我们就可以得到性能信息。我们可以在 6023 端口运行远程登录命令。然后,会在 Scrapy 中出现一个 Python 控制台。注意,如果在这里进行中断操作,比如 time.sleep(),就会暂停爬虫。通过内建的 est()函数,可以查看一些有趣的信息。其中一些或是非常专业的,或是可以从核心数据推导出来。本章后面会展示后者。下面运行一个例子。当我们运行一个爬虫时,我们在开发机打开第二台终端,在端口 6023 远程登录,然后运行 est()。
提示:本章代码位于目录 ch10。这个例子位于 ch10/speed。
在第一台终端,运行如下命令:
$ pwd
/root/book/ch10/speed
$ ls
scrapy.cfg speed
$ scrapy crawl speed -s SPEED_PIPELINE_ASYNC_DELAY=1
INFO: Scrapy 1.0.3 started (bot: speed)
...
现在先不关注 scrapy crawl speed 和它的参数的意义,后面会详解。在第二台终端,运行如下代码:
$ telnet localhost 6023
>>> est()
...
len(engine.downloader.active) : 16
...
len(engine.slot.scheduler.mqs) : 4475
...
len(engine.scraper.slot.active) : 115
engine.scraper.slot.active_size : 117760
engine.scraper.slot.itemproc_size : 105
然后在第二台终端按 Ctrl+D 退出远程登录,返回第一台终端按 Ctrl+C 停止抓取。
提示:我们现在忽略 dqs。如果你通过设置 JOBDIR 打开了持久支持,你会得到非零的 dqs(len(engine.slot.scheduler.dqs)),你应该将它添加到 mqs 的大小中。
让我们查看这个例子中的数据的意义。mqs 指出调度器中等待的项目很少(4475 个请求)。len(engine.downloader.active)指出下载器现在正在下载 16 个请求。这与我们在 CONCURRENT_REQUESTS 的设置相同。len(engine.scraper.slot.active)说明现在正有 115 个响应在抓取器中处理。 (engine.scraper.slot.active_size)告诉我们这些响应的大小是 115kb。除了响应,105 个 Items 正在 pipelines(engine.scraper.slot.itemproc_size)中处理,这说明还有 10 个在爬虫中。经过总结,我们看到瓶颈是下载器,在下载器之前有很长的任务队列(mqs),下载器在满负荷运转;下载器之后,工作量较高并有一定波动。
另一个可以查看信息的地方是 stats 对象,抓取之后打印的内容。我们可以以 dict 的形式访问它,只需通过 via stats.get_stats()远程登录,用 p()函数打印:
$ p(stats.get_stats())
{'downloader/request_bytes': 558330,
...
'item_scraped_count': 2485,
...}
这里对我们最重要的是 item_scraped_count,它可以通过 stats.get_value ('item_scraped_count')之间访问。它告诉我们现在已经抓取了多少个 items,以及增长的速率,即吞吐量。
评分系统
我为本章写了一个简单的评分系统,它可以让我们评估在不同场景下的性能。它的代码有些复杂,你可以在 speed/spiders/speed.py 找到,但我们不会深入讲解它。
这个评分系统包括:
- 服务器上http://localhost:9312/benchmark/...的句柄(handlers)。我们可以控制这个假网站的结构(见图 4),通过调节 URL 参数/Scrapy 设置,控制网页加载的速度。不用在意细节,我们接下来会看许多例子。现在,先看一下http://localhost:9312/benchmark/index?p=1和http://localhost:9312/benchmark/id:3/rr:5/index?p=1的不同。第一个网页在半秒内加载完毕,每页只含有一个 item,第二个网页加载用了五秒,每页有三个 items。我们还可以在网页上添加垃圾信息,降低加载速度。例如,查看http://localhost:9312/benchmark/ds:100/detail?id0=0。默认条件下(见 speed/settings.py),页面渲染用时 SPEED_T_RESPONSE = 0.125 秒,假网站有 SPEED_TOTAL_ITEMS = 5000 个 Items。
图 4 评分服务器创建了一个结构可变的假网站
- 爬虫,SpeedSpider,模拟用几种方式取回被 SPEED_START_REQUESTS_STYLE 控制的 start_requests(),并给出一个 parse_item()方法。默认下,用 crawler.engine.crawl()方法将所有起始 URL 提供给调度器。
- pipeline,DummyPipeline,模拟了一些处理过程。它可以引入四种不同的延迟类型。阻塞/计算/同步延迟(SPEED_PIPELINE_BLOCKING_DELAY—很差),异步延迟(SPEED_PIPELINE_ASYNC_DELAY—不错),使用远程 treq 库进行 API 调用(SPEED_PIPELINE_API_VIA_TREQ—不错),和使用 Scrapy 的 crawler.engine.download()进行 API 调用(SPEED_PIPELINE_API_VIA_DOWNLOADER—不怎么好)。默认时,pipeline 不添加延迟。
- settings.py 中的一组高性能设置。关闭任何可能使系统降速的项。因为只在本地服务器运行,我们还关闭了每个域的请求限制。
- 一个可以记录数据的扩展,和第 8 章中的类似。它每隔一段时间,就打印出核心数据。
在上一个例子,我们已经用过了这个系统,让我们重新做一次模拟,并使用 Linux 的计时器测量总共的执行时间。核心数据打印如下:
$ time scrapy crawl speed
...
INFO: s/edule d/load scrape p/line done mem
INFO: 0 0 0 0 0 0
INFO: 4938 14 16 0 32 16384
INFO: 4831 16 6 0 147 6144
...
INFO: 119 16 16 0 4849 16384
INFO: 2 16 12 0 4970 12288
...
real 0m46.561s
Column Metric
s/edule len(engine.slot.scheduler.mqs)
d/load len(engine.downloader.active)
scrape len(engine.scraper.slot.active)
p/line engine.scraper.slot.itemproc_size
done stats.get_value('item_scraped_count')
mem engine.scraper.slot.active_size
结果这样显示出来效果很好。调度器中初始有 5000 条 URL,结束时 done 的列也有 5000 条。下载器全负荷下并发数是 16,与设置相同。抓取器主要是爬虫,因为 pipeline 是空的,它没有满负荷运转。它用 46 秒抓取了 5000 个 Items,并发数是 16,即每个请求的处理时间是 46*16/5000=147ms,而不是预想的 125ms,满足要求。
标准性能模型
当 Scrapy 正常运行且下载器为瓶颈时,就是 Scrapy 的标准性能模型。此时,调度器有一定数量的请求,下载器满负荷运行。抓取器负荷不满,并且加载的响应不会持续增加。
图 5 标准性能模型和一些试验结果
三项设置负责控制下载器的性能: CONCURRENT_REQUESTS,CONCURRENT_REQUESTS_PER_DOMAIN 和 CONCURRENT_REQUESTS_PER_IP。第一个是宏观上的控制,无论任何时候,并发数都不能超过 CONCURRENT_REQUESTS。另外,如果是单域或几个域,CONCURRENT_REQUESTS_PER_DOMAIN 也可以限制活跃请求数。如果你设置了 CONCURRENT_REQUESTS_PER_IP,CONCURRENT_REQUESTS_PER_DOMAIN 就会被忽略,活跃请求数就是每个 IP 的请求数量。对于共享站点,比如,多个域名指向一个服务器,这可以帮助你降低服务器的载荷。
为了更简明的分析,现在把 per-IP 的限制关闭,即使 CONCURRENT_REQUESTS_PER_IP 为默认值(0),并设置 CONCURRENT_REQUESTS_PER_DOMAIN 为一个超大值(1000000)。这样就可以无视其它的设置,让下载器的并发数完全受 CONCURRENT_REQUESTS 控制。
我们希望吞吐量取决于下载网页的平均时间,包括远程服务器和我们系统(Linux、Twisted/Python)的延迟,tdownload=tresponse+toverhead。还可以加上启动和关闭的时间。这包括从取得响应到 Items 离开 pipeline 的时间,和取得第一个响应的时间,还有空缓存的内部损耗。
总之,如果你要完成 N 个请求,在爬虫正常的情况下,需要花费的时间是:
所幸的是,我们只需控制一部分参数就可以了。我们可以用一台更高效的服务器控制 toverhead,和 tstart/stop,但是后者并不值得,因为每次运行只影响一次。除此之外,最值得关注的就是 CONCURRENT_REQUESTS,它取决于我们如何使用服务器。如果将其设置成一个很大的值,在某一时刻就会使服务器或我们电脑的 CPU 满负荷,这样响应就会不及时,tresponse会急剧升高,因为网站会阻塞、屏蔽进一步的访问,或者服务器会崩溃。
让我们验证一下这个理论。我们抓取 2000 个 items,tresponse∈{0.125s,0.25s,0.5s},CONCURRENT_REQUESTS∈{8,16,32,64}:
$ for delay in 0.125 0.25 0.50; do for concurrent in 8 16 32 64; do
time scrapy crawl speed -s SPEED_TOTAL_ITEMS=2000 \
-s CONCURRENT_REQUESTS=$concurrent -s SPEED_T_RESPONSE=$delay
done; done
在我的电脑上,我完成 2000 个请求的时间如下:
接下来复杂的数学推导,可以跳过。在图 5 中,可以看到一些结果。将上一个公式变形为 y=toverhead·x+ tstart/stop,其中 x=N/CONCURRENT_REQUESTS, y=tjob·x+tresponse。使用最小二乘法(LINEST Excel 函数)和前面的数据,可以计算出 toverhead=6ms,tstart/stop=3.1s。toverhead 可以忽略,但是开始时间相对较长,最好是在数千条 URL 时长时间运行。因此,可以估算出吞吐量公式是:
处理 N 个请求,我们可以估算 tjob,然后可以直接求出 T。
解决性能问题
现在我们已经明白如何使 Scrapy 的性能最大化,让我们来看看如何解决实际问题。我们会通过探究症状、运行错误、讨论原因、修复问题,讨论几个实例。呈现的顺序是从系统性的问题到 Scrapy 的小技术问题,也就是说,更为常见的问题可能会排在后面。请阅读全部章节,再开始处理你自己的问题。
实例 1——CPU 满负荷
症状:当你提高并发数时,性能并没有提高。当你降低并发数,一切工作正常。下载器没有问题,但是每个请求花费时间太长。用 Unix/Linux 命令 ps 或 Windows 的任务管理器查看 CPU 的情况,CPU 的占用率非常高。
案例:假设你运行如下命令:
$ for concurrent in 25 50 100 150 200; do
time scrapy crawl speed -s SPEED_TOTAL_ITEMS=5000 \
-s CONCURRENT_REQUESTS=$concurrent
done
求得抓取 5000 条 URL 的时间。预计时间是用之前推导的公式求出的,CPU 是用命令查看得到的(可以在另一台终端运行查看命令):
图 6 当并发数超出一定值时,性能变化趋缓。
在我们的试验中,我们没有进行任何处理工作,所以并发数可以很高。在实际中,很快就可以看到性能趋缓的情况发生。
讨论:Scrapy 使用的是单线程,当并发数很高时,CPU 可能会成为瓶颈。假设没有使用线程池,CPU 的使用率建议是 80-90%。可能你还会碰到其他系统性问题,比如带宽、内存、硬盘吞吐量,但是发生这些状况的可能性比较小,并且不属于系统管理,所以就不赘述了。
解决:假设你的代码已经是高效的。你可以通过在一台服务器上运行多个爬虫,使累积并发数超过 CONCURRENT_REQUESTS。这可以充分利用 CPU 的性能。如果还想提高并发数,你可以使用多台服务器(见 11 章),这样就可以使用更多的内存、带宽和硬盘吞吐量。检查 CPU 的使用情况是你的首要关切。
实例 2-阻塞代码
症状:系统的运行得十分奇怪。比起预期的速度,系统运行的十分缓慢。改变并发数,也没有效果。下载器几乎是空的(远小于并发数),抓取器的响应数很少。
案例:使用两个评分设置,SPEED_SPIDER_BLOCKING_DELAY 和 SPEED_PIPELINE_BLOCKING_DELAY(二者效果相同),使每个响应有 100ms 的阻塞延迟。在给定的并发数下,100 条 URL 大概要 2 到 3 秒,但结果总是 13 秒左右,并且不受并发数影响:
for concurrent in 16 32 64; do
time scrapy crawl speed -s SPEED_TOTAL_ITEMS=100 \
-s CONCURRENT_REQUESTS=$concurrent -s SPEED_SPIDER_BLOCKING_DELAY=0.1
done
讨论:任何阻塞代码都会是并发数无效,并使得 CONCURRENT_REQUESTS=1。公式:100URL*100ms(阻塞延迟)=10 秒+tstart/stop,完美解释了发生的状况。
图 7 阻塞代码使并发数无效化
无论阻塞代码位于 pipelines 还是爬虫,你都会看到抓取器满负荷,它之前和之后的部分都是空的。看起来这违背了我们之前讲的,但是由于我们并没有一个并行系统,pipeline 的规则此处并不适用。这个错误很容易犯(例如,使用了阻塞 APIs),然后就会出现之前的状况。相似的讨论也适用于计算复杂的代码。应该为每个代码使用多线程,如第 9 章所示,或在 Scrapy 的外部批次运行,第 11 章会看到例子。
解决:假设代码是继承而来的,你并不知道阻塞代码位于何处。没有 pipelines 系统也能运行的话,使 pipeline 无效,看系统能否正常运行。如果是的话,说明阻塞代码位于 pipelines。如果不是的话,逐一恢复 pipelines,看问题何时发生。如果必须所有组件都在运行,整个系统才能运行的话,给每个 pipeline 阶段添加日志消息(或者插入可以打印时间戳的伪 pipelines),就可以发现哪一步花费的时间最多。如果你想要一个长期可重复使用的解决方案,你可以用在每个 meta 字段添加时间戳的伪 pipelines 追踪请求。最后,连接 item_scraped 信号,打印出时间戳。一旦找到阻塞代码,将其转化为 Twisted/异步,或使用 Twisted 的线程池。要查看转化的效果,将 SPEED_PIPELINE_BLOCKING_DELAY 替换为 SPEED_PIPELINE_ASYNC_DELAY,然后再次运行。可以看到性能改进很大。
实例 3-下载器中有“垃圾”
症状:吞吐量比预期的低。下载器的请求数貌似比并发数多。
案例:模拟下载 1000 个网页,每个响应时间是 0.25 秒。当并发数是 16 时,根据公式,整个过程大概需要 19 秒。我们使用一个 pipeline,它使用 crawler.engine.download()向一个响应时间小于一秒的伪装 API 做另一个 HTTP 请求,。你可以在http://localhost:9312/benchmark/ar:1/api?text=hello尝试。下面运行爬虫:
$ time scrapy crawl speed -s SPEED_TOTAL_ITEMS=1000 -s SPEED_T_
RESPONSE=0.25 -s SPEED_API_T_RESPONSE=1 -s SPEED_PIPELINE_API_VIA_
DOWNLOADER=1
...
s/edule d/load scrape p/line done mem
968 32 32 32 0 32768
952 16 0 0 32 0
936 32 32 32 32 32768
...
real 0m55.151s
很奇怪,不仅时间多花了三倍,并发数也比设置的数值 16 要大。下载器明显是瓶颈,因为它已经过载了。让我们重新运行爬虫,在另一台终端,远程登录 Scrapy。然后就可以查看下载器中运行的 Requests 是哪个:
$ telnet localhost 6023
>>> engine.downloader.active
set([<POST http://web:9312/ar:1/ti:1000/rr:0.25/benchmark/api>, ... ])
貌似下载器主要是在做 APIs 请求,而不是下载网页。
讨论:你可能希望没人使用 crawler.engine.download(),因为它看起来很复杂,但在 Scrapy 的 robots.txt 中间件和媒体 pipeline,它被使用了两次。因此,当人们需要处理网络 APIs 时,自然而然要使用它。使用它远比使用阻塞 APIs 要好,例如前面看过的流行的 Python 的 requests 包。比起理解 Twisted 和使用 treq,它使用起来也更简单。这个错误很难调试,所以让我们转而查看下载器中的请求。如果看到有 API 或媒体 URL 不是直接抓取的,就说明 pipelines 使用了 crawler.engine.download()进行了 HTTP 请求。我们的 ONCURRENT_REQUESTS 限制部队这些请求生效,所以下载器中的请求数总是超过设置的并发数。除非伪请求数小于 CONCURRENT_REQUESTS,下载器不会从调度器取得新的网页请求。
图 8 伪 API 请求决定了性能
因此,当原始请求持续 1 秒(API 延迟)而不是 0.25 秒时(页面下载延迟),吞吐量自然会发生变化。这里容易让人迷惑的地方是,要是 API 的调用比网页请求还快,我们根本不会观察到性能的下降。
解决:我们可以使用 treq 而不是 crawler.engine.download()解决这个问题,你可以看到抓取器的性能大幅提高,这对 API 可能不是个好消息。我先将 CONCURRENT_REQUESTS 设置的很低,然后逐步提高,以确保不让 API 服务器过载。
下面是使用 treq 的例子:
$ time scrapy crawl speed -s SPEED_TOTAL_ITEMS=1000 -s SPEED_T_
RESPONSE=0.25 -s SPEED_API_T_RESPONSE=1 -s SPEED_PIPELINE_API_VIA_TREQ=1
...
s/edule d/load scrape p/line done mem
936 16 48 32 0 49152
887 16 65 64 32 66560
823 16 65 52 96 66560
...
real 0m19.922s
可以看到一个有趣的现象。pipeline (p/line)的 items 似乎比下载器(d/load)的还多。这并不是一个问题,弄清楚它是很有意思的。
图 9 使用长 pipelines 也符合要求
和预期一样,下载器中有 16 条请求。这意味着系统的吞吐量是 T = N/S = 16/0.25 = 64 请求/秒。done 这一列逐渐升高,可以确认这点。每条请求在下载器中耗时 0.25 秒,但它在 pipelines 中会耗时 1 秒,因为较慢的 API 请求。这意味着在 pipeline 中,平均的 N = T * S = 64 * 1 = 64 Items。这完全合理。这是说 pipelines 是瓶颈吗?不是,因为 pipelines 没有同时处理响应数量的限制。只要这个数字不持续增加,就没有问题。接下来会进一步讨论。
实例 4-大量响应造成溢出
症状:下载器几乎满负荷运转,一段时间后关闭。这种情况循环发生。抓取器的内存使用很高。
案例:设置和以前相同(使用 treq),但响应很高,有大约 120kB 的 HTML。可以看到,这次耗时 31 秒而不是 20 秒:
$ time scrapy crawl speed -s SPEED_TOTAL_ITEMS=1000 -s SPEED_T_
RESPONSE=0.25 -s SPEED_API_T_RESPONSE=1 -s SPEED_PIPELINE_API_VIA_TREQ=1
-s SPEED_DETAIL_EXTRA_SIZE=120000
s/edule d/load scrape p/line done mem
952 16 32 32 0 3842818
917 16 35 35 32 4203080
876 16 41 41 67 4923608
840 4 48 43 108 5764224
805 3 46 27 149 5524048
...
real 0m30.611s
讨论:我们可能简单的认为延迟的原因是“需要更多的时间创建、传输、处理网页”,但这并不是真正的原因。对于响应的大小有一个强制性的限制,max_active_size = 5000000。每一个响应都和响应体的大小相同,至少为 1kB。
图 10 下载器中的请求数不规律变化,说明存在响应大小限制
这个限制可能是 Scrapy 最基本的机制,当存在慢爬虫和 pipelines 时,以保证性能。如果 pipelines 的吞吐量小于下载器的吞吐量,这个机制就会起作用。当 pipelines 的处理时间很长,即便是很小的响应也可能触发这个机制。下面是一个极端的例子,pipelines 非常长,80 秒后出现问题:
$ time scrapy crawl speed -s SPEED_TOTAL_ITEMS=10000 -s SPEED_T_
RESPONSE=0.25 -s SPEED_PIPELINE_ASYNC_DELAY=85
解决:对于这个问题,在底层结构上很难做什么。当你不再需要响应体的时候,可以立即清除它。这可能是在爬虫的后续清除响应体,但是这么做不会重置抓取器的计数器。你能做的是减少 pipelines 的处理时间,减少抓取器中的响应数量。用传统的优化方法就可以做到:检查交互中的 APIs 或数据库是否支持抓取器的吞吐量,估算下载器的能力,将 pipelines 进行后批次处理,或使用性能更强的服务器或分布式抓取。
实例 5-item 并发受限/过量造成溢出
症状:爬虫对每个响应产生多个 Items。吞吐量比预期的小,和之前的实例相似,也呈现出间歇性。
案例:我们有 1000 个请求,每一个会返回 100 个 items。响应时间是 0.25 秒,pipelines 处理时间是 3 秒。进行几次试验,CONCURRENT_ITEMS 的范围是 10 到 150:
for concurrent_items in 10 20 50 100 150; do
time scrapy crawl speed -s SPEED_TOTAL_ITEMS=100000 -s \
SPEED_T_RESPONSE=0.25 -s SPEED_ITEMS_PER_DETAIL=100 -s \
SPEED_PIPELINE_ASYNC_DELAY=3 -s \
CONCURRENT_ITEMS=$concurrent_items
done
...
s/edule d/load scrape p/line done mem
952 16 32 180 0 243714
920 16 64 640 0 487426
888 16 96 960 0 731138
...
图 11 以 CONCURRENT_ITEMS 为参数的抓取时间函数
讨论:只有每个响应产生多个 Items 时才出现这种情况。这个案例的人为性太强,因为吞吐量达到了每秒 1300 个 Items。吞吐量这么高是因为稳定的低延迟、没进行处理、响应很小。这样的条件很少见。
我们首先观察到的是,以前 scrape 和 p/line 两列的数值是相同的,现在 p/line 显示的是 shows CONCURRENT_ITEMS * scrape。这是因为 scrape 显示 Reponses,而 p/line 显示 Items。
第二个是图 11 中像一个浴缸的函数。部分原因是纵坐标轴造成的。在左侧,有非常高延迟,因为达到了内存极限。右侧,并发数太大,CPU 使用率太高。取得最优化并不是那么重要,因为很容易向左或向右变动。
解决:很容易检测出这个例子中的两个错误。如果 CPU 使用率太高,就降低并发数。如果达到了 5MB 的响应限制,pipelines 就不能很好的衔接下载器的吞吐量,提高并发数就可以解决。如果不能解决问题,就查看一下前面的解决方案,并审视是否系统的其它部分可以支撑抓取器的吞吐量。
实例 6-下载器没有充分运行
症状:提高了 CONCURRENT_REQUESTS,但是下载器中的数量并没有提高,并且没有充分利用。调度器是空的。
案例:首先运行一个没有问题的例子。将响应时间设为 1 秒,这样可以简化计算,使下载器吞吐量 T = N/S = N/1 = CONCURRENT_REQUESTS。然后运行如下代码:
$ time scrapy crawl speed -s SPEED_TOTAL_ITEMS=500 \
-s SPEED_T_RESPONSE=1 -s CONCURRENT_REQUESTS=64
s/edule d/load scrape p/line done mem
436 64 0 0 0 0
...
real 0m10.99s
下载器满状态运行(64 个请求),总时长为 11 秒,和 500 条 URL、每秒 64 请求的模型相符,S=N/T+tstart/stop=500/64+3.1=10.91 秒。
现在,再做相同的抓取,不再像之前从列表中提取 URL,这次使用 SPEED_START_REQUESTS_STYLE=UseIndex 从索引页提取 URL。这与其它章的方法是一样的。每个索引页有 20 条 URL:
$ time scrapy crawl speed -s SPEED_TOTAL_ITEMS=500 \
-s SPEED_T_RESPONSE=1 -s CONCURRENT_REQUESTS=64 \
-s SPEED_START_REQUESTS_STYLE=UseIndex
s/edule d/load scrape p/line done mem
0 1 0 0 0 0
0 21 0 0 0 0
0 21 0 0 20 0
...
real 0m32.24s
很明显,与之前的结果不同。下载器没有满负荷运行,吞吐量为 T=N/S-tstart/stop=500/(32.2-3.1)=17 请求/秒。
讨论:d/load 列可以确认下载器没有满负荷运行。这是因为没有足够的 URL 进入。抓取过程产生 URL 的速度慢于处理的速度。这时,每个索引页会产生 20 个 URL+下一个索引页。吞吐量不可能超过每秒 20 个请求,因为产生 URL 的速度没有这么快。
解决:如果每个索引页有至少两个下一个索引页的链接,呢么我们就可以加快产生 URL 的速度。如果可以找到能产生更多 URL(例如 50)的索引页面则会更好。通过模拟观察变化:
$ for details in 10 20 30 40; do for nxtlinks in 1 2 3 4; do
time scrapy crawl speed -s SPEED_TOTAL_ITEMS=500 -s SPEED_T_RESPONSE=1 \
-s CONCURRENT_REQUESTS=64 -s SPEED_START_REQUESTS_STYLE=UseIndex \
-s SPEED_DETAILS_PER_INDEX_PAGE=$details \
-s SPEED_INDEX_POINTAHEAD=$nxtlinks
done; done
图 12 以每页能产生的链接数为参数的吞吐量函数
在图 12 中,我们可以看到吞吐量是如何随每页 URL 数和索引页链接数变化的。初始都是线性变化,直到到达系统限制。你可以改变爬虫的规则进行试验。如果使用 LIFO(默认项)规则,即先发出索引页请求最后收回,可以看到性能有小幅提高。你也可以将索引页的优先级设置为最高。两种方法都不会有太大的提高,但是你可以通过分别设置 SPEED_INDEX_RULE_LAST=1 和 SPEED_INDEX_HIGHER_PRIORITY=1,进行试验。请记住,这两种方法都会首先下载索引页(因为优先级高),因此会在调度器中产生大量 URL,这会提高对内存的要求。在完成索引页之前,输出的结果很少。索引页不多时推荐这种做法,有大量索引时不推荐这么做。
另一个简单但高效的方法是分享首页。这需要你使用至少两个首页 URL,并且它们之间距离最大。例如,如果首页有 100 页,你可以选择 1 和 51 作为起始。爬虫这样就可以将抓取下一页的速度提高一倍。相似的,对首页中的商品品牌或其他属性也可以这么做,将首页大致分为两个部分。你可以使用-s SPEED_INDEX_SHARDS 设置进行模拟:
$ for details in 10 20 30 40; do for shards in 1 2 3 4; do
time scrapy crawl speed -s SPEED_TOTAL_ITEMS=500 -s SPEED_T_RESPONSE=1 \
-s CONCURRENT_REQUESTS=64 -s SPEED_START_REQUESTS_STYLE=UseIndex \
-s SPEED_DETAILS_PER_INDEX_PAGE=$details -s SPEED_INDEX_SHARDS=$shards
done; done
这次的结果比之前的方法要好,并且更加简洁 。
解决问题的流程
总结一下,Scrapy 的设计初衷就是让下载器作为瓶颈。使 CONCURRENT_REQUESTS 从小开始,逐渐变大,直到发生以下的限制:
- CPU 利用率 > 80-90%
- 源网站延迟急剧升高
- 抓取器的响应达到内存 5Mb 上限
同时,进行如下操作: - 始终保持调度器(mqs/dqs)中有一定数量的请求,避免下载器是空的
- 不使用阻塞代码或 CPU 密集型代码
图 13 解决 Scrapy 性能问题的路线图
总结
在本章中,我们通过案例展示了 Scrapy 的架构是如何影响性能的。细节可能会在未来的 Scrapy 版本中变动,但是本章阐述的原理在相当长一段时间内可以帮助你理解以 Twisted、Netty Node.js 等为基础的异步框架。
谈到具体的 Scrapy 性能,有三个确定的答案:我不知道也不关心、我不知道但会查出原因,和我知道。本章已多次指出,“更多的服务器/内存/带宽”不能提高 Scrapy 的性能。唯一的方法是找到瓶颈并解决它。
在最后一章中,我们会学习如何进一步提高性能,不是使用一台服务器,而是在多台服务器上分布多个爬虫。
十一、Scrapyd 分布式抓取和实时分析
序言
第 1 章 Scrapy 介绍
第 2 章 理解 HTML 和 XPath
第 3 章 爬虫基础
第 4 章 从 Scrapy 到移动应用
第 5 章 快速构建爬虫
第 6 章 Scrapinghub 部署
第 7 章 配置和管理
第 8 章 Scrapy 编程
第 9 章 使用 Pipeline
第 10 章 理解 Scrapy 的性能
第 11 章(完) Scrapyd 分布式抓取和实时分析
我们已经学了很多东西。我们先学习了两种基础的网络技术,HTML 和 XPath,然后我们学习了使用 Scrapy 抓取复杂的网站。接着,我们深入学习了 Scrapy 的设置,然后又进一步深入学习了 Scrapy 和 Python 的内部架构和 Twisted 引擎的异步特征。在上一章中,我们学习了 Scrapy 的性能和以及处理复杂的问题以提高性能。
在本章中,我将展示如何在多台服务器上进一步提高性能。我们会发现抓取通常是一个并行问题;因此,我们可以水平延展至多台服务器。为了这么做,我们会使用一个 Scrapy 中间件,我们还会使用 Scrapyd,一个用来管理远程服务器爬虫的应用。它可以让我们像第 6 章那样进行抓取。
我们最后用 Apache Spark 对提取的数据进行实时分析。Spark 一个非常流行的大数据处理框架。收集的数据越多、结果就变得越准确,我们使用 Spark Streaming API 展示结果。最后的结果展示了 Python 的强大和成熟,单单用 Python 的简明代码就全栈开发了从抓取到分析的全过程。
房子的标题如何影响价格?
我们要研究个问题是房子的标题和价格有什么关系。我们预计像“按摩浴缸”和“游泳池”可能和高价相关,而“打折”会和低价有关。将标题与地点结合,例如,可以根据地点和描述,实时判断哪个房子最划算。
我们想计算的就是特定名词对价格造成的偏移:
例如,如果平均租金是$1000,我们观察到带有按摩浴缸的房子的平均价格是$1300,没有的价格是$995,因此按摩浴缸的偏移值为 shiftjacuzzi=(1300-995)/1000=30.5%。如果一个带有按摩浴缸的房子的价格直逼平均价格高 5%,那么它的价格就很划算。
因为名词效应会有累加,所以这个指标并不繁琐。例如,标题同时含有按摩浴缸和打折会有一个混合效果。我们收集分析的数据越多,估算就会越准确。稍后回到这个问题,接下来讲一个流媒体解决方案。
Scrapyd
现在,我们来介绍 Scrapyd。Scrapyd 是一个应用,使用它,我们可以将爬虫附属到服务器上,并对抓取进行规划。我们来看看它的使用是多么容易,我们用第 3 章的代码,只做一点修改。
我们先来看 Scrapyd 的界面,在http://localhost:6800/。
Scrapyd 的界面
你可以看到,它有几个部分,有 Jobs、Items、Logs 和 Documentation。它还给出了如何规划抓取工作的 API 方法。
为了这么做,我们必须首先将爬虫部署到服务器上。第一步是修改 scrapy.cfg,如下所示:
$ pwd
/root/book/ch03/properties
$ cat scrapy.cfg
...
[settings]
default = properties.settings
[deploy]
url = http://localhost:6800/
project = properties
我们要做的就是取消 url 的注释。默认的设置适合我们。现在,为了部署爬虫,我们使用 scrapyd-client 提供的工具 scrapyd-deploy。scrapyd-client 以前是 Scrapy 的一部分,但现在是一个独立的模块,可以用 pip install scrapyd-client 进行安装(开发机中已经安装了):
$ scrapyd-deploy
Packing version 1450044699
Deploying to project "properties" in http://localhost:6800/addversion.
json
Server response (200):
{"status": "ok", "project": "properties", "version": "1450044699",
"spiders": 3, "node_name": "dev"}
部署好之后,就可以在 Scrapyd 的界面的 Available projects 看到。我们现在可以根据提示,在当前页提交一个任务:
$ curl http://localhost:6800/schedule.json -d project=properties -d spider=easy
{"status": "ok", "jobid": " d4df...", "node_name": "dev"}
如果我们返回 Jobs,我们可以使用 jobid schedule.json,它可以在之后用 cancel.json 取消任务:
$ curl http://localhost:6800/cancel.json -d project=properties -d job=d4df...
{"status": "ok", "prevstate": "running", "node_name": "dev"}
一定要取消进程,否则会浪费计算资源。
完毕之后,访问 Logs,我们可以看到日志,在 Items 我们可以看到抓取过的 items。这些数据会被周期清空以节省空间,所以一段时间后就会失效。
如果发生冲突或有其它理由的话,我们可以通过 http_port 修改端口,它是 Scrapyd 的诸多设置之一。最好阅读文档http://scrapyd.readthedocs.org/,多了解下。我们的部署必须要设置的是 max_proc。如果使用默认值 0,任务的并行数量最多可以是 CPU 核心的四位。因为我们可能会在虚拟机中运行多个 Scrapyd 服务器,我们将 max_proc 设为 4,可以允许 4 个任务同时进行。在真实环境中,使用默认值就可以。
分布式系统概述
设计这个系统对我是个挑战。我一开始添加了许多特性,导致复杂度升高,只有高性能的机器才能完成工作。然后,又不得不进行简化,既对硬件性能要求不那么高,也可以让本章的重点仍然是 Scrapy。
最后,系统中会包括我们的开发机和几台服务器。我们用开发机进行首页的水平抓取,提取几批 URL。然后用轮训机制将 URL 分发到 Scrapyd 的结点,并进行抓取。最后,通过 FTP 传递.jl 文件和 Items 到运行 Spark 的服务器上。我选择 FTP 和本地文件系统,而不是 HDFS 或 Apache Kafka,是因为 FTP 内存需求少,并且作为 FEED_URI 被 Scrapy 支持。请记住,只要简单设置 Scrapyd 和 Spark 的配置,我们就可以使用亚马逊 S3 存储这些文件,获得冗余度和可伸缩性等便利,而不用再使用其它技术。
笔记:FTP 的缺点之一是,上传过程可能会造成文件不完整。为了避免这点,一旦上传完成,我们便使用 Pure-FTPd 和调回脚本将文件上传到/root/items。
每过几秒,Spark 都读一下目录/root/items,读取任何新文件,取一个小批次进行分析。我们使用 Spark 是因为它支持 Python 作为编程语言,也支持流分析。到现在,我们使用的爬虫都比较短,实际中有的爬虫是 24 小时运行的,不断发出数据流并进行分析,数据越多,分析的结果越准确。我们就是要用 Spark 进行这样的演示。
笔记:除了 Spark 和 Scrapy,你还可以使用 MapReduce,Apache Storm 或其它框架。
在本章中,我们不向数据库中插入 items。我们在第 9 章中用的方法也可以在这里使用,但是性能很糟。很少有数据库喜欢每秒被 pipelines 写入几千个文件。如果想进行写入的话,应该用 Spark 专用的方法,即批次导入 Items。你可以修改我们 Spark 的例子,向任何数据库进行批次导入。
还有,这个系统的弹性很差。我们假设每个结点都是健康的,任何一个损坏的话,也不会对总系统造成影响。Spark 提供高可用性的弹性配置。Scrapy 不提供此类内建的功能,除了 Scrapyd 的“持续排队”功能,即当结点恢复时,可以继续失败的任务。这个功能不一定对你有用。如果弹性对你十分重要,你就得搭建一个监督系统和分布式排队方案(例如,基于 Kafka 或 RabbitMQ),可以重启失败的任务。
修改爬虫和中间件
为了搭建这个系统,我们要稍稍修改爬虫和中间件。更具体地,我们要做如下工作:
- 微调爬虫,使抓取索引页的速度达到最大
- 写一个中间件,可以将 URL 批次发送给 scrapyd 服务器。
- 使用相同的中间件,使系统启动时就可以将 URL 分批
我们尽量用简明的方式来完成这些工作。理想状态下,整个过程应该对底层的爬虫代码简洁易懂。这是一个底层层面的要求,通过破解爬虫达到相同目的不是好主意。
抓取共享首页
第一步是优化抓取首页的速度,速度越快越好。开始之前,先明确一下目的。假设爬虫的并发数是 16,源网站的延迟大概是 0.25 秒。这样,最大吞吐量是 16/0.25=64 页/秒。首页有 5000O 个子页,每个索引页有 30 个子页,那就有 1667 个索引页。预计下载整个首页需要,1667/64=26 秒。
将第 3 章中的爬虫重命名为 easy。我们将首先进行垂直抓取的 Rule(含有 callback='parse_item'的一项)注释掉,因为现在只想抓取索引页。
提示:本章的代码位于目录 ch11。
在进行优化之前,我们让 scrapy crawl 只抓取 10 个页面,结果如下:
$ ls
properties scrapy.cfg
$ pwd
/root/book/ch11/properties
$ time scrapy crawl easy -s CLOSESPIDER_PAGECOUNT=10
...
DEBUG: Crawled (200) <GET ...index_00000.html> (referer: None)
DEBUG: Crawled (200) <GET ...index_00001.html> (referer: ...index_00000.
html)
...
real 0m4.099s
如果 10 个页面用时 4 秒,26 秒内是不可能完成 1700 个页面的。通过查看日志,我们看到每个索引页都来自前一个页面,也就是说,任何时候最多是在处理一个页面。实际上,并发数是 1。我们需要将其并行化,使达到并发数 16。我们将索引页相互共享,即 URL 互相连接,再加入一些其他的链接,以免爬虫中没有 URL。我们将首页分厂 20 个部分。实际上,任何大于 16 的数,都可以提速,但是一旦超过 20,速度反而会下降。我们用下面的方法计算每个部分的起始索引页:
>>> map(lambda x: 1667 * x / 20, range(20))
[0, 83, 166, 250, 333, 416, 500, ... 1166, 1250, 1333, 1416, 1500, 1583]
据此,设置 start_URL 如下:
start_URL = ['http://web:9312/properties/index_%05d.html' % id
for id in map(lambda x: 1667 * x / 20, range(20))]
这可能会和你的情况不同,所以就不做美化了。将并发数(CONCURRENT_REQUESTS, CONCURRENT_REQUESTS_PER_DOMAIN)设为 16,再次运行爬虫,运行如下:
$ time scrapy crawl easy -s CONCURRENT_REQUESTS=16 -s CONCURRENT_
REQUESTS_PER_DOMAIN=16
...
real 0m32.344s
结果接近了我们的目标。下载速度是 1667 页面/32 秒=52 页面/秒,也就是说,每秒可以产生 52*30=1560 个子页面。我们现在可以注释掉垂直抓取的 Rule,将文件保存成一个爬虫。我们不需要进一步修改爬虫代码,而是用一个功能强大的中间件继续来做。如果只用开发机运行爬虫,假设可以像抓取索引页一样抓取子页,可以在 50000/52=16 分钟内完成抓取。
这里有两个要点。在学习完第 10 章之后,我们在做的都是工程项目。我们可以想方设法计算出系统确切的性能。第二点是,抓取索引页会产生子页,但实际的吞吐量不大。如果产生 URL 的速度快过 scrapyd 处理 URL 的速度,URL 就会在 scrapyd 排队。或者,如果产生 URL 的速度太慢,scrapyd 就会空闲。
批次抓取 URL
现在来处理子页面的 URL,并把它们分批,然后直接发送给 scrapyds,而不是继续抓取。
如果检查 Scrapy 的架构,我们可以明白这么做就是为了做一个中间件,它可以执行 process_spider_output(),在 Requests 到达下载器之前就可以进行处理或取消。我们限定中间件只支持 CrawlSpider 的爬虫,并且只支持简单的 GET 请求。如果要提高复杂度,例如,POST 或认证请求,我们必须开发更多的功能,以传递参数、头文件、每个批次进行重新登陆。
打开 Scrapy 的 GitHub,查看 SPIDER_MIDDLEWARES_BASE 设置,看看能否重利用哪个程序。Scrapy 1.0 有以下中间件:HttpErrorMiddleware、OffsiteMiddleware、RefererMiddleware、UrlLengthMiddleware 和 DepthMiddleware。我们看到 OffsiteMiddleware(只有 60 行)好像使我们需要的。它根据爬虫属性 allowed_domains 限定 URL。我们可以用相同的方法吗?不是丢弃 URL,我们转而将它们分批,发送给 scrapyds。我们确实可以这么做,部分代码如下:
def __init__(self, crawler):
settings = crawler.settings
self._target = settings.getint('DISTRIBUTED_TARGET_RULE', -1)
self._seen = set()
self._URL = []
self._batch_size = settings.getint('DISTRIBUTED_BATCH_SIZE', 1000)
...
def process_spider_output(self, response, result, spider):
for x in result:
if not isinstance(x, Request):
yield x
else:
rule = x.meta.get('rule')
if rule == self._target:
self._add_to_batch(spider, x)
else:
yield x
def _add_to_batch(self, spider, request):
url = request.url
if not url in self._seen:
self._seen.add(url)
self._URL.append(url)
if len(self._URL) >= self._batch_size:
self._flush_URL(spider)
process_spider_output()处理 Item 和 Request。我们只需要 Request,其它就不考虑了。如果查看 CrawlSpider 的源代码,我们看到将 Request/Response 映射到 Rule 的方式是用一个 meta dict 中的名为“rule”的整数字段。我们检查这个数字,如果它指向我们想要的 Rule(DISTRIBUTED_TARGET_RULE 设置),我们调用 _add_to_batch(),将它的 URL 添加到这个批次里面。我们然后取消这个 Request。我们接着产生出其他的请求,例如下一页的链接,不进行改动。The _add_to_batch()方法起到去重的作用。但是,我们前面描述的碎片化过程,意味着有的 URL 可能要提取两次。我们使用 _seen set 检测并去除重复项。然后将这些 URL 添加到 _URL 列表,如果它的大小超过了 _batch_size(根据 DISTRIBUTED_BATCH_SIZE 设置),就会调用 _flush_URL()。这个方法提供了一下功能:
def __init__(self, crawler):
...
self._targets = settings.get("DISTRIBUTED_TARGET_HOSTS")
self._batch = 1
self._project = settings.get('BOT_NAME')
self._feed_uri = settings.get('DISTRIBUTED_TARGET_FEED_URL', None)
self._scrapyd_submits_to_wait = []
def _flush_URL(self, spider):
if not self._URL:
return
target = self._targets[(self._batch-1) % len(self._targets)]
data = [
("project", self._project),
("spider", spider.name),
("setting", "FEED_URI=%s" % self._feed_uri),
("batch", str(self._batch)),
]
json_URL = json.dumps(self._URL)
data.append(("setting", "DISTRIBUTED_START_URL=%s" % json_URL))
d = treq.post("http://%s/schedule.json" % target,
data=data, timeout=5, persistent=False)
self._scrapyd_submits_to_wait.append(d)
self._URL = []
self._batch += 1
首先,它使用了批次计数器(_batch)来决定发向哪个 scrapyd 服务器。可用服务器保存在 _targets(见 DISTRIBUTED_TARGET_HOSTS 设置)。我们然后向 scrapyd 的 schedule.json 做一个 POST 请求。这比之前用过的 curl 方法高级,因为它传递了经过仔细选择的参数。基于这些常熟,scrapyd 就规划了一次抓取,如下所示:
scrapy crawl distr \
-s DISTRIBUTED_START_URL='[".../property_000000.html", ... ]' \
-s FEED_URI='ftp://anonymous@spark/%(batch)s_%(name)s_%(time)s.jl' \
-a batch=1
除了项目和爬虫的名字,我们想爬虫传递了一个 FEED_URI 设置。它的值是从 DISTRIBUTED_TARGET_FEED_URL 得到的。
因为 Scrapy 支持 FTP,我们可以让 scrapyds 用一个匿名 FTP 将抓取的 Item 文件上传到 Spark 服务器。它的格式包括爬虫的名字(%(name)s 和时间(%(time)s)。如果只有这两项的话,那么同一时间创建出来的两个文件就会有冲突。为了避免覆盖,我们加入一个参数%(batch)。Scrapy 默认是不知道批次的,所以我们必须给设定一个值。scrapyd 的 schedule.json API 的特点之一是,每个不是设置的参数或已知的参数都被传递给了爬虫。默认时,爬虫的参数成为了爬虫的属性,然后在爬虫的属性中寻找未知的 FEED_URI 参数。因此,将一批参数传递给 schedule.json,我们就可以在 FEED_URI 中使用它,以避免冲突。
最后是将 DISTRIBUTED_START_URL 和这一批次的子页 URL 编译为 JSON,因为 JSON 是最简洁的文本格式。
笔记:用命令行将大量数据传递到 Scrapy 并不可取。如果你想将参数存储到数据库(例如 Redis),只传递给 Scrapy 一个 ID。这么做的话,需要小幅修改 _flush_URL()和 process_start_requests()。
我们用 treq.post()来做 POST 请求。Scrapyd 处理持续连接并不好,因此我们用 persistent=False 取消它。我们还设置了一个 5 秒的暂停。这个请求的的延迟项被保存在 _scrapyd_submits_to_wait 列表。要关闭这个函数,我们重置 _URL 列表,并加大当前的 _batch。
奇怪的是,关闭操作中会出现许多方法,如下所示:
def __init__(self, crawler):
...
crawler.signals.connect(self._closed, signal=signals.spider_
closed)
@defer.inlineCallbacks
def _closed(self, spider, reason, signal, sender):
# Submit any remaining URL
self._flush_URL(spider)
yield defer.DeferredList(self._scrapyd_submits_to_wait)
调用 _closed()可能是因为我们按下了 Ctrl + C 或因为抓取结束。两种情况下,我们不想失去任何最后批次的还未发送的 URL。这就是为什么在 _closed()中,第一件事是调用 _flush_URL(spider)加载最后的批次。第二个问题是,因为是非阻塞的,停止抓取时,treq.post()可能结束也可能没结束。为了避免丢失最后批次,我们要使用前面提到过的 scrapyd_submits_to_wait 列表,它包括所有的treq.post()延迟项。我们使用 defer.DeferredList()等待,直到全部完成。因为 _closed()使用了@defer.inlineCallbacks,当所有请求完成时,我们只 yield 它并继续。
总结一下,DISTRIBUTED_START_URL 设置中的批次 URL 会被发送到 scrapyds,scrapyds 上面运行着相同的爬虫。很明显,我们需要使用这个设置以启动 start_URL。
从 settings 启动 URL
中间件还提供了一个 process_start_requests()方法,使用它可以处理爬虫提供的 start_requests。检测是否设定了 DISTRIBUTED_START_URL,设定了的话,用 JSON 解码,并使用它的 URL 产生相关的请求。对于这些请求,我们设定 CrawlSpider 的 _response_downloaded()方法作为回调函数,再设定参数 meta['rule'],以让恰当的 Rule 处理响应。我们查看 Scrapy 的源码,找到 CrawlSpider 创建请求的方法,并依法而做:
def __init__(self, crawler):
...
self._start_URL = settings.get('DISTRIBUTED_START_URL', None)
self.is_worker = self._start_URL is not None
def process_start_requests(self, start_requests, spider):
if not self.is_worker:
for x in start_requests:
yield x
else:
for url in json.loads(self._start_URL):
yield Request(url, spider._response_downloaded,
meta={'rule': self._target})
中间件就准备好了。我们在 settings.py 进行设置以启动它:
SPIDER_MIDDLEWARES = {
'properties.middlewares.Distributed': 100,
}
DISTRIBUTED_TARGET_RULE = 1
DISTRIBUTED_BATCH_SIZE = 2000
DISTRIBUTED_TARGET_FEED_URL = ("ftp://anonymous@spark/"
"%(batch)s_%(name)s_%(time)s.jl")
DISTRIBUTED_TARGET_HOSTS = [
"scrapyd1:6800",
"scrapyd2:6800",
"scrapyd3:6800",
]
有人可能认为 DISTRIBUTED_TARGET_RULE 不应该作为设置,因为它会使爬虫差异化。你可以认为这是个默认值,你可以在你的爬虫中使用属性 custom_settings 覆盖它,例如:
custom_settings = {
'DISTRIBUTED_TARGET_RULE': 3
}
我们的例子并不需要这么做。我们可以做一个测试运行,只抓取一个页面:
$ scrapy crawl distr -s \
DISTRIBUTED_START_URL='["http://web:9312/properties/property_000000.html"]'
这个成功之后,我们进一步,抓取一个页面之后,用 FTP 将它传送到 Spark 服务器:
scrapy crawl distr -s \
DISTRIBUTED_START_URL='["http://web:9312/properties/property_000000.html"]' \
-s FEED_URI='ftp://anonymous@spark/%(batch)s_%(name)s_%(time)s.jl' -a batch=12
用 ssh 连接 Spark 服务器,你可以看到一个文件,例如/root/items 下的 12_distr_date_time.jl。
这个中间件的例子可以让你完成 scrapyd 的分布式抓取。你可以将它当做起点,进行改造。你可能要做如下修改:
- 爬虫的类型。除了 CrawlSpider,你必须让爬虫用恰当的 meta 标记分布式的请求,用惯用命名法执行调回。
- 向 scrapyds 传递 URL 的方式。你可能想限定域名,减少传递的量。例如,你只想传递 IDs。
- 你可以用分布式排队方案,让爬虫可以从失败恢复,让 scrapyds 执行更多的 URL 批次。
- 你可以动态扩展服务器的规模,以适应需求。
将项目部署到 scrapyd 服务器
为了将爬虫附属到三台 scrapyd 服务器上,我们必须将它们添加到 scrapy.cfg 文件。文件上的每个[deploy:target-name]定义了一个新的部署目标:
$ pwd
/root/book/ch11/properties
$ cat scrapy.cfg
...
[deploy:scrapyd1]
url = http://scrapyd1:6800/
[deploy:scrapyd2]
url = http://scrapyd2:6800/
[deploy:scrapyd3]
url = http://scrapyd3:6800/
你可以用 scrapyd-deploy -l 查询可用的服务器:
$ scrapyd-deploy -l
scrapyd1 http://scrapyd1:6800/
scrapyd2 http://scrapyd2:6800/
scrapyd3 http://scrapyd3:6800/
用 scrapyd-deploy
$ scrapyd-deploy scrapyd1
Packing version 1449991257
Deploying to project "properties" in http://scrapyd1:6800/addversion.json
Server response (200):
{"status": "ok", "project": "properties", "version": "1449991257",
"spiders": 2, "node_name": "scrapyd1"}
这个过程会产生一些新的目录和文件(build、project.egg-info、setup.py),可以删掉。其实,scrapyd-deploy 做的就是打包你的项目,并用 addversion.json,传递到目标服务器上。
之后,如果我们用 scrapyd-deploy –L 查询服务器,我们可以确认项目被成功部署了:
$ scrapyd-deploy -L scrapyd1
properties
我还用 touch 在项目的目录创建了三个空文件夹,scrapyd1-3。这样可以将 scrapyd 的名字传递给下面的文件,同时也是服务器的名字。然后可以用 bash loop 将其部署服务器: for i in scrapyd*; do scrapyd-deploy $i; done。
创建自定义监视命令
如果你想在多台 scrapyd 服务器上监视抓取的进程,你必须亲自编写程序。这是一个练习所学知识的好机会,写一个原生的 Scrapy 命令,scrapy monitor,用它监视一组 scrapyd 服务器。文件命名为monitor.py,在settings.py中添加 COMMANDS_MODULE = 'properties.monitor'。快速查看 scrapyd 的文档,listjobs.json API 给我们提供了关于任务的信息。如果我们想找到给定目标的根 URL,我们可以断定,它只能是在 scrapyd-deploy 的代码中。如果查看https://github.com/scrapy/scrapyd-client/blob/master/scrapyd-client/scrapyd-deploy,我们可以发现一个 _get_targets()函数(执行它不会添加许多值,所以略去了),它可以给出目标的名字和根 URL。我们现在就可以执行命令的第一部分了,如下所示:
class Command(ScrapyCommand):
requires_project = True
def run(self, args, opts):
self._to_monitor = {}
for name, target in self._get_targets().iteritems():
if name in args:
project = self.settings.get('BOT_NAME')
url = target['url'] + "listjobs.json?project=" + project
self._to_monitor[name] = url
l = task.LoopingCall(self._monitor)
l.start(5) # call every 5 seconds
reactor.run()
这段代码将名字和想要监视的 API 的终点提交给 dict _to_monitor。我们然后使用 task.LoopingCall()规划向 _monitor()方法发起递归调用。_monitor()使用 treq 和 deferred,我们使用@defer.inlineCallbacks 对它进行简化。方法如下(省略了一些错误处理和代码美化):
@defer.inlineCallbacks
def _monitor(self):
all_deferreds = []
for name, url in self._to_monitor.iteritems():
d = treq.get(url, timeout=5, persistent=False)
d.addBoth(lambda resp, name: (name, resp), name)
all_deferreds.append(d)
all_resp = yield defer.DeferredList(all_deferreds)
for (success, (name, resp)) in all_resp:
json_resp = yield resp.json()
print "%-20s running: %d, finished: %d, pending: %d" %
(name, len(json_resp['running']),
len(json_resp['finished']), len(json_resp['pending']))
这几行代码包括了目前我们学过的所有 Twisted 方法。我们使用 treq 调用 scrapyd 的 API 和 defer.DeferredList,立即处理所有的响应。当 all_resp 有了所有结果之后,我们重复这个过程,取回它们的 JSON 对象。treq Response'json()方法返回延迟项,而不是实际值,以与后续的实际值继续任务。我们最后打印出结果。JSON 响应的列表信息包括悬挂、运行中、结束的任务,我们打印出它的长度。
用 Apache Spark streaming 计算偏移值
我们的 Scrapy 系统现在就功能完备了。让我们来看看 Apache Spark 的使用。
让我来看如何执行。请记住这不是 Scrapy 代码,所以看起来会觉得陌生,但是是可以看懂的。你可以在 boostwords.py 文件找到这个应用,这个文件包括了复杂的测试代码,可以忽略。它的主要代码如下:
# Monitor the files and give us a DStream of term-price pairs
raw_data = raw_data = ssc.textFileStream(args[1])
word_prices = preprocess(raw_data)
# Update the counters using Spark's updateStateByKey
running_word_prices = word_prices.updateStateByKey(update_state_
function)
# Calculate shifts out of the counters
shifts = running_word_prices.transform(to_shifts)
# Print the results
shifts.foreachRDD(print_shifts)
Spark 使用 DStream 代表数据流。textFileStream()方法监督文件系统的一个目录,当检测到新文件时,就传出来。我们的 preprocess()函数将它们转化为 term/price 对。我们用 update_state_function()函数和 Spark 的 updateStateByKey()方法累加这些 term/price 对。我们最后通过运行 to_shifts()计算偏移值,并用 print_shifts()函数打印出极值。大多我们的函数修改不大,只是高效重塑了数例据。例外的是 shifts()函数:
def to_shifts(word_prices):
(sum0, cnt0) = word_prices.values().reduce(add_tuples)
avg0 = sum0 / cnt0
def calculate_shift((isum, icnt)):
avg_with = isum / icnt
avg_without = (sum0 - isum) / (cnt0 - icnt)
return (avg_with - avg_without) / avg0
return word_prices.mapValues(calculate_shift)
这段代码完全是按照公式做的。尽管很简单,Spark 的 mapValues()可以让 calculate_shift 在 Spark 服务器上用最小开销高效运行。
进行分布式抓取
我进行四台终端进行抓取。我想让这部分尽量独立,所以我还提供了 vagrant ssh 命令,可以在终端使用。
使用四台终端进行抓取
用终端 1 来检测集群的 CPU 和内存的使用。这可以确认和修复问题。设置方法如下:
$ alias provider_id="vagrant global-status --prune | grep 'docker-
provider' | awk '{print \$1}'"
$ vagrant ssh $(provider_id)
$ docker ps --format "{{.Names}}" | xargs docker stats
前两行可以让我们用 ssh 打开 docker provider VM。如果没有使用 VM,只在 docker Linux 运行,我们只需要最后一行。
终端 2 用作诊断,如下运行 scrapy monitor:
$ vagrant ssh
$ cd book/ch11/properties
$ scrapy monitor scrapyd*
使用 scrapyd*和空文件夹,空文件夹名字是 scrapy monitor,这会扩展到 scrapy monitor scrapyd1 scrapyd2 scrapyd3。
终端 3,是我们启动抓取的终端。除此之外,它基本是闲置的。开始一个新的抓取,我们操作如下:
$ vagrant ssh
$ cd book/ch11/properties
$ for i in scrapyd*; do scrapyd-deploy $i; done
$ scrapy crawl distr
最后两行很重要。首先,我们使用一个 for 循环和 scrapyd-deploy,将爬虫部署到服务器上。然后我们用 scrapy crawl distr 开始抓取。我们随时可以运行小的抓取,例如,scrapy crawl distr -s CLOSESPIDER_PAGECOUNT=100,来抓取 100 个索引页,它会产生大概 3000 个子页。
终端 4 用来连接 Spark 服务器,我们用它进行实时分析:
$ vagrant ssh spark
$ pwd
/root
$ ls
book items
$ spark-submit book/ch11/boostwords.py items
只有最后一行重要,它运行了 boostwords.py,将本地 items 目录传给了监视器。有时,我还使用 watch ls -1 items 来监视 item 文件。
到底哪个词对价格的影响最大呢?这个问题留给读者。
系统性能
系统的性能极大地依赖于硬件、CPU 的数量、虚拟机分配内存的大小。在真实情况下,我们可以进行水平扩展,使抓取提速。
理论最大吞吐量是 3 台服务器4 个 CPU16 并发数*4 页/秒=768 页/秒。
实际中,使用分配了 4G 内存、8CPU 的虚拟机的 Macbook Pro,2 分 40 秒内下载了 50000 条 URL,即 315 页/秒。在一台亚马逊 EC2 m4.large,它有 2 个 vCPUs、8G 内存,因为 CPU 频率低,用时 6 分 12 秒,即 134 页/秒。在一台台亚马逊 EC2 m4.4xlarge,它有 16 个 vCPUs、64G 内存,用时 1 分 44 秒,即 480 页/秒。在同一台机器上,我将 scrapyd 的数量提高到 6(修改 Vagrantfile、scrapy.cfg 和 settings.py),用时 1 分 15 秒,即 667 页/秒。在最后的例子中,网络服务器似乎是瓶颈。
实际和理论计算存在差距是合理的。我们的粗略计算中没有考虑许多小延迟。尽管我们声明了每个页有 250ms 的延迟,我们在前几章已经看到,实际延迟要更高,这是因为我们还有额外的 Twisted 和操作系统延迟。还有开发机向 scrapyds 传递 URL 的时间,FTP 向 Spark 传递 Items 的时间,还有 scrapyd 发现新文件和规划任务的时间(平均要 2.5 秒,根据 scrapyd 的 poll_interval 设置)。还没计算开发机和 scrapyd 的启动时间。如果不能确定可以提高吞吐量的话,我是不会试图改进这些延迟的。我的下一步是扩大抓取的规模,比如 500000 个页面、网络服务器的负载均衡,在扩大的过程中发现新的挑战。
要点
本章的要点是,如果要进行分布式抓取,一定要使用大小合适的批次。
取决于源网站的响应速度,你可能有数百、数千、上万个 URL。你希望它们越大越好(在几分钟的水平),这样就可以分摊启动的费用。另一方面,你也不希望它们太大,以免造成机器故障。在一个有容错的分布式系统中,你需要重试失败的批次,而且重试不要浪费太多时间。
总结
希望你能喜欢这本关于 Scrapy 的书。现在你对 Scrapy 应该已经有深入的了解了,并可以解决简单或复杂的问题了。你还学到了 Scrapy 复杂的结构,以及如何发挥出它的最大性能。通过抓取,你可以在应用中使用庞大的数据资源。我们已经看到了如何在移动应用中使用 Scrapy 抓取的数据并进行分析。希望你能用 Scrapy 做出更多强大的应用,为世界做出贡献。祝你好运!
序言
第 1 章 Scrapy 介绍
第 2 章 理解 HTML 和 XPath
第 3 章 爬虫基础
第 4 章 从 Scrapy 到移动应用
第 5 章 快速构建爬虫
第 6 章 Scrapinghub 部署
第 7 章 配置和管理
第 8 章 Scrapy 编程
第 9 章 使用 Pipeline
第 10 章 理解 Scrapy 的性能
第 11 章(完) Scrapyd 分布式抓取和实时分析
一、Scrapy 介绍
本书作者使用的 Scrapy 版本是 1.0.3。感兴趣的话,还可以看看Scrapy1.4 最新官方文档总结。
下载本书代码:https://github.com/scalingexcellence/scrapybook。
下载本书 PDF(英文版):http://file.allitebooks.com/20160330/Learning%20Scrapy.pdf
欢迎来到 Scrapy 之旅。通过这本书,我们希望你可以从只会一点或零基础的初学者,达到熟练使用这个强大的框架海量抓取网络和其他资源的水平。在本章里,我们会向你介绍 Scrapy,以及 Scrapy 能做什么。
HelloScrapy
Scrapy 是一个健壮的抓取网络资源的框架。作为互联网使用者,你可能经常希望可以将网上的资源保存到 Excel 中(见第 3 章),以便离线时使用或进行计算。作为开发者,你可能经常希望将不同网站的资源整合起来,但你清楚这么做的复杂性。Scrapy 可以帮助你完成简单和复杂的数据提取。
Scrapy 是利用健壮高效的方式提取网络资源的多年经验开发的。使用 Scrapy,你只需进行一项设置,就可以抵过其它框架使用多个类、插件和配置。看一眼第 7 章,你就可以知道仅需几行代码就可以完成大量工作。
从开发者的角度,你会喜欢 Scrapy 的基于事件的架构(见第 8 章和第 9 章)。它可以让我们进行串联操作,清洗、形成、丰富数据,或存入数据库等等,同时不会有太大的性能损耗。从技术上说,基于事件的机制,Scrapy 可以让吞吐量摆脱延迟,同时开放数千个连接。举一个极端的例子,假设你要从一个网站提取列表,每页有 100 个列表项。Scrapy 可以轻松的同时处理 16 个请求,假设每个请求在一秒内完成,每秒就可以抓取 16 个页面。乘以每页的列表数,每秒就可以抓取 1600 个列表项。然后,你想将每个列表项写入一个高并发的云存储,每个要花 3 秒。为了支持每秒 16 个请求,必须要并行进行 4800 个写入请求(第 9 章你会看到更多类似的计算)。对于传统的多线程应用,这需要 4800 个线程,对你和操作系统都是个挑战。在 Scrapy 中,4800 个并发请求很平常,只要操作系统支持就行。更进一步,Scrapy 的内存要求和你要抓取的列表项的数据量相关,而对于多线程应用,每个线程的大小都和一个列表的大小相当。
简而言之,速度慢或不可预测的网站、数据库或远程 API 不会对 Scrapy 的性能造成影响,因为你可以进行并发请求,用单线程管理。相比于多线程应用,使用更简单的代码反而可以同时运行几个抓取器和其它应用,这样就可以降低费用。
喜爱 Scrapy 的其它理由
Scrapy 出现已经有五年多了,现在已经成熟稳定。除了前面提到的性能的优点,以下是 Scrapy 其它让人喜爱的理由:
- Scrapy 可以读懂破损的 HTML
你可以在 Scrapy 上直接使用 BeautifulSoup 或 lxml,但 Scrapy 提供 Selector,一个相比 lxml 更高级的 XPath 解析器。它可以有效的处理破损的 HTML 代码和费解的编码。
- 社区
Scrapy 有一个活跃的社区。可以查看 Scrapy 的邮件列表https://groups.google.com/forum/#!forum/scrapy-users和 Stack Overflow 上的数千个问题http://stackoverflow.com/questions/tagged/scrapy。多数问题在数分钟之内就会得到解答。http://scrapy.org/community/有更多的社区资源。
- 由社区维护的组织清晰的代码
Scrapy 需要用标准的方式组织代码。你用 Python 来写爬虫和 pipelines,就可以自动使引擎的效率提高。如果你在网上搜索,你会发现许多人有使用 Scrapy 的经验。这意味着,可以方便地找人帮你维护或扩展代码。无论是谁加入你的团队,都不必经过学习曲线理解你特别的爬虫。
- 注重质量的更新
如果查看版本记录(http://doc.scrapy.org/en/latest/news.html),你会看到有不断的更新和稳定性/错误修正。
关于此书:目标和用法
对于此书,我们会用例子和真实的数据教你使用 Scrapy。大多数章节,要抓取的都是一个房屋租赁网站。我们选择它的原因是,它很有代表性,并可以进行一定的变化,同时也很简单。使用这个例子,可以让我们专注于 Scrapy。
我们会从抓取几百页开始,然后扩展到抓取 50000 页。在这个过程中,我们会教你如何用 Scrapy 连接 MySQL、Redis 和 Elasticsearch,使用 Google geocoding API 找到给定地点的坐标,向 Apach Spark 传入数据,预测影响价格的关键词。
你可能需要多读几遍本书。你可以粗略地浏览一遍,了解一下结构,然后仔细读一两章、进行学习和试验,然后再继续读。如果你对哪章熟悉的话,可以跳过。如果你熟悉 HTML 和 XPath 的话,就没必要在第 2 章浪费太多时间。某些章如第 8 章,既是示例也是参考,具有一定深度。它就需要你多读几遍,每章之间进行数周的练习。如果没有完全搞懂第 8 章的话,也可以读第 9 章的具体应用。后者可以帮你进一步理解概念。
我们已经尝试调整本书的结构,以让其既有趣也容易上手。但我们做不到用这本书教给你如何使用 Python。Python 的书有很多,但我建议你在学习的过程中尽量保持放松。Python 流行的原因之一是,它很简洁,可以像读英语一样读代码。对于 Python 初学者和专家,Scrapy 都是一个高级框架。你可以称它为“Scrapy 语言”。因此,我建议你直接从实例学习,如果你觉得 Python 语法有困难的话,再进行补充学习,可以是在线的 Python 教程或 Coursera 的初级课程。放心,就算不是 Python 专家,你也可以成为一个优秀的 Scrapy 开发者。
掌握自动抓取数据的重要性
对于许多人,对 Scrapy 这样的新技术有好奇心和满足感,就是学习的动力。学习这个框架的同时,我们可以从数据开发和社区,而不是代码,获得额外的好处。
开发高可靠高质量的应用 提供真实的开发进度表
为了开发新颖高质量的应用,我们需要真实和大量的数据,如果可能的话,最好在写代码之前就有数据。现在的软件开发都要实时处理海量的瑕疵数据,以获取知识和洞察力。当软件应用到海量数据时,错误和疏忽很难检测出来,就会造成后果严重的决策。例如,在进行人口统计时,很容易忽略一整个州,仅仅是因为这个州的名字太长,它的数据被丢弃了。通过细心的抓取,有高质量的、海量的真实数据,在开发和设计的过程中,就可以找到并修复 bug,然后才能做出正确的决策。
另一个例子,假设你想设计一个类似亚马逊的“如果你喜欢这个,你可能也喜欢那个”的推荐系统。如果在开始之前,你就能抓取手机真实的数据,你就可以快速知道一些问题,比如无效记录、打折商品、重复、无效字符、因为分布导致的性能问题。数据会强制你设计健壮的算法以处理被数千人抢购或无人问津的商品。相比较于数周开发之后却碰到现实问题,这两种方法可能最终会一致,但是在一开始就能对整个进程有所掌握,意义肯定是不同的。从数据开始,可以让软件的开发过程更为愉悦和有预测性。
快速开发最小化可行产品
海量真实数据对初创企业更为重要。你可能听说过“精益初创企业”,这是 Eric Ries 发明的词,用来描述高度不确定的企业发展阶段,尤其是技术初创企业。它的核心概念之一就是最小化可行产品(MVP),一个只包含有限功能的产品,快速开发并投放,以检测市场反应、验证商业假设。根据市场反应,初创企业可以选择追加投资,或选择其他更有希望的项目。
很容易忽略这个过程中的某些方面,这些方面和数据问题密切相关,用 Scrapy 可以解决数据问题。当我们让潜在用户尝试移动 App 时,例如,作为开发者或企业家,我们让用户来判断完成的 App 功能如何。这可能对非专家的用户有点困难。一个应用只展示“产品 1”、“产品 2”、“用户 433”,和另一个应用展示“Samsung UN55J6200 55-Inch TV”,用户“Richard S.”给它打了五星评价,并且有链接可以直接打开商品主页,这两个应用的差距是非常大的。很难让人们对 MVP 进行客观的评价,除非它使用的数据是真实可信的。
一些初创企业事后才想到数据,是因为考虑到采集数据很贵。事实上,我们通常都是打开表格、屏幕、手动输入数据,或者我们可以用 Scrapy 抓取几个网站,然后再开始写代码。第 4 章中,你可以看到如何快速创建一个移动 App 以使用数据。
网络抓取让你的应用快速成长 —— Google 不能使用表格
让我们来看看表格是如何影响一个产品的。假如谷歌的创始人创建了搜索引擎的第一个版本,但要求每个网站站长填入信息,并复制粘贴他们的每个网页的链接。他们然后接受谷歌的协议,让谷歌处理、存储、呈现内容,并进行收费。可以想象整个过程工作量巨大。即使市场有搜索引擎的需求,这个引擎也成为不了谷歌,因为它的成长太慢了。即使是最复杂的算法也不能抵消缺失数据。谷歌使用网络爬虫逐页抓取,填充数据库。站长完全不必做任何事。实际上,想屏蔽谷歌,还需要做一番努力。
让谷歌使用表格的主意有点搞笑,但是一个普通网站要用户填多少表呢?登录表单、列表表单、勾选表单等等。这些表单会如何遏制应用的市场扩张?如果你足够了解用户,你会知道他们还会使用其它什么网站,或许已经有了账户。例如,开发者可能有 Stack Overflow 和 GitHub 账户。经过用户同意,你能不能直接用这些账户就自动填入照片、介绍和最近的帖子呢?你能否对这些帖子做文本分析,根据结果设置网站的导航结构、推荐商品或服务呢?我希望你能看到将表格换为自动数据抓取可以更好的为用户服务,使网站快速成长。
发现并实践
抓取数据自然而然会让你发现和思考你和被抓取目标的关系。当你抓取一个数据源时,自然会有一些问题:我相信他们的数据吗?我相信提供数据的公司吗?我应该和它们正式商谈合作吗?我和他们有竞争吗?从其他渠道获得数据花费是多少?这些商业风险是必然存在的,但是抓取数据可以让我们更早的知道,进行应对。
你还想知道如何反馈给这些网站或社区?给他们免费流量,他们肯定很高兴。另一方面,如果你的应用不能提供价值,继续合作的可能就会变小,除非找到另外合作的方式。通过从各种渠道获得数据,你可以开发对现有生态更友好的产品,甚至打败旧产品。或者,老产品能帮助你扩张,例如,你的应用数据来自两个或三个不同的生态圈,每个生态圈都有十万名用户,结合起来,你的应用或许就能惠及三十万人。假如你的初创企业结合了摇滚乐和 T 恤印刷行业,就将两个生态圈结合了起来,你和这两个社区都可以得到扩张。
在充满爬虫的网络世界做守法公民
开发爬虫还有一些注意事项。不负责任的网络抓取让人不悦,有时甚至是犯罪。两个最重要的要避免的就是拒绝访问攻击(DoS)和侵犯著作权。
对于第一个,普通访问者每隔几秒才访问一个新页面。爬虫的话,每秒可能下载几十个页面。流量超过普通用户的十倍。这会让网站的拥有者不安。使用阻塞器降低流量,模仿普通用户。检测响应时间,如果看到响应时间增加,则降低抓取的强度。好消息是 Scrapy 提供了两个现成的方法(见第 7 章)。
对于著作权,可以查看网站的著作权信息,以确认什么可以抓取什么不能抓取。大多数站点允许你处理网站的信息,只要不复制并宣称是你的。一个好的方法是在你请求中使用一个 User-Agent 字段,告诉网站你是谁,你想用他们的数据做什么。Scrapy 请求默认使用你的 BOT_NAME 作为 User-Agent。如果这是一个 URL 或名字,可以直接指向你的应用,那么源网站的站长就可以访问你的站点,并知道你用他的数据做什么。另一个重要的地方,允许站长可以禁止爬虫访问网站的某个区域。Scrapy 提供了功能(RobotsTxtMiddleware),以尊重源网站列在 robots.txt 文件的意见(在http://www.google.com/robots.txt可以看到一个例子)。最后,最好提供可以让站长提出拒绝抓取的方法。至少,可以让他们很容易地找到你,并提出交涉。
每个国家的法律不同,我无意给出法律上的建议。如果你觉得需要的话,请寻求专业的法律建议。这适用于整本书的内容。
Scrapy 不是什么
最后,因为数据抓取和相关的名词定义很模糊,或相互使用,很容易误解 Scrapy。我这里解释一下,避免发生误解。
Scrapy 不是 Apache Nutch,即它不是一个原生的网络爬虫。如果 Scrapy 访问一个网站,它对网站一无所知,就不能抓取任何东西。Scrapy 是用来抓取结构化的信息,并需要手动设置 XPath 和 CSS 表达式。Apache Nutch 会取得一个原生网页并提取信息,例如关键词。它更适合某些应用,而不适合其它应用。
Scrapy 不是 Apache Solr、Elasticsearch 或 Lucene;换句话说,它和搜索引擎无关。Scrapy 不是用来给包含“爱因斯坦”的文档寻找参考。你可以使用 Scrapy 抓取的数据,并将它们插入到 Solr 或 Elasticsearch,如第 9 章所示,但这只是使用 Scrapy 的一种途径,而不是嵌入 Scrapy 的功能。
最后,Scrapy 不是类似 MySQL、MongoDB、Redis 的数据库。它不存储和索引数据。它只是提取数据。也就是说,你需要将 Scrapy 提取的数据插入到数据库中,可行的数据库有多种。虽然 Scrapy 不是数据库,它的结果可以方便地输出为文件,或不进行输出。
总结
在本章中,我们向你介绍了 Scrapy 以及它的作用,还有使用这本书的最优方法。通过开发与市场完美结合的高质量应用,我们还介绍了几种自动抓取数据能使你获益的方法。下一章会介绍两个极为重要的网络语言,HTML 和 XPath,我们在每个 Scrapy 项目中都会用到。
二、理解 HTML 和 XPath
为了从网页提取信息,了解网页的结构是非常必要的。我们会快速学习 HTML、HTML 的树结构和用来筛选网页信息的 XPath。
HTML、DOM 树结构和 XPath
从这本书的角度,键入网址到看见网页的整个过程可以分成四步:
- 在浏览器中输入网址 URL。URL 的第一部分,也即域名(例如 gumtree.com),用来搜寻网络上的服务器。URL 和其他像 cookies 等数据形成了一个发送到服务器的请求 request。
- 服务器向浏览器发送 HTML。服务器也可能发送 XML 或 JSON 等其他格式,目前我们只关注 HTML。
- HTML 在浏览器内部转化成树结构:文档对象模型(DOM)。
- 根据布局规范,树结构转化成屏幕上的真实页面。
研究下这四个步骤和树结构,可以帮助定位要抓取的文本和编写爬虫。
URL
URL 包括两部分:第一部分通过 DNS 定位服务器,例如当你在浏览器输入https://mail.google.com/mail/u/0/#inbox这个地址时,产生了一个mail.google.com的 DNS 请求,后者为你解析了一台服务器的 IP 地址,例如 173.194.71.83。也就是说,https://mail.google.com/mail/u/0/#inbox转换成了https://173.194.71.83/mail/u/0/#inbox。
URL 其余的部分告诉服务器这个请求具体是关于什么的,可能是一张图片、一份文档或是触发一个动作,例如在服务器上发送一封邮件。
HTML 文档
服务器读取 URL,了解用户请求,然后回复一个 HTML 文档。HTML 本质是一个文本文件,可以用 TextMate、Notepad、vi 或 Emacs 等软件打开。与大多数文本文件不同,HTML 严格遵循万维网联盟(World Wide Web Consortium)的规定格式。这个格式超出了本书的范畴,这里只看一个简单的 HTML 页面。如果你打开http://example.com,点击查看源代码,就可以看到 HTML 代码,如下所示:
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type"
content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1" />
<style type="text/css"> body { background-color: ...
} </style>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is established to be used for
illustrative examples examples in documents.
You may use this domain in examples without
prior coordination or asking for permission.</p>
<p><a href="http://www.iana.org/domains/example">
More information...</a></p>
</div>
</body>
</html>
为了便于阅读,我美化了这个 HTML 文档。你也可以把整篇文档放在一行里。对于 HTML,大多数情况下,空格和换行符不会造成什么影响。
尖括号里的字符称作标签,例如<html>
或<head>
。<html>
是起始标签,</html>
是结束标签。标签总是成对出现。某些网页没有结束标签,例如只用<p>
标签分隔段落,浏览器对这种行为是容许的,会智能判断哪里该有结束标签</p>
。
<p>
与</p>
之间的内容称作 HTML 的元素。元素之间可以嵌套元素,比如例子中的<div>
标签,和第二个<p>
标签,后者包含了一个<a>
标签。
树结构
不同的浏览器有不同的借以呈现网页的内部数据结构。但 DOM 树是跨平台且不依赖语言的,可以被几乎所有浏览器支持。
只需右键点击,选择查看元素,就可以在浏览器中查看网页的树结构。如果这项功能被禁止了,可以在选项的开发者工具中修改。
你看到的树结构和 HTML 很像,但不完全相同。无论原始 HTML 文件使用了多少空格和换行符,树结构看起来都会是一样的。你可以点击任意元素,或是改变属性,这样可以实时看到对 HTML 网页产生了什么变化。例如,如果你双击了一段文字,并修改了它,然后点击回车,屏幕上这段文字就会根据新的设置发生改变。在右边的方框中,在属性标签下面,你可以看到这个树结构的属性列表。在页面底部,你可以看到一个面包屑路径,指示着选中元素的所在位置。
重要的是记住,HTML 是文本,而树结构是浏览器内存中的一个对象,你可以通过程序查看、操作这个对象。在 Chrome 浏览器中,就是通过开发者工具查看。
浏览器中的页面
HTML 文本和树结构和我们平时在浏览器中看到的页面截然不同。这恰恰是 HTML 的成功之处。HTML 文件就是要具有可读性,可以区分网页的内容,但不是按照呈现在屏幕上的方式。这意味着,呈现 HTML 文档、进行美化都是浏览器的职责,无论是对于功能齐备的 Chrome、移动端浏览器、还是 Lynx 这样的文本浏览器。
也就是说,网页的发展对网页开发者和用户都提出了极大的开发网页方面的需求。CSS 就是这样被发明出来,用以服务 HTML 元素。对于 Scrapy,我们不涉及 CSS。
既然如此,树结构对呈现出来的网页有什么作用呢?答案就是盒模型。正如 DOM 树可以包含其它元素或是文字,同样的,盒模型里面也可以内嵌其它内容。所以,我们在屏幕上看到的网页是原始 HTML 的二维呈现。树结构是其中的一维,但它是隐藏的。例如,在下图中,我们看到三个 DOM 元素,一个<div>
和两个内嵌的<h1>
和<p>
,出现在浏览器和 DOM 中:
用 XPath 选择 HTML 元素
如果你以前接触过传统的软件工程,并不知道 XPath,你可能会担心,在 HTML 文档中查询某个信息,要进行复杂的字符串匹配、搜索标签、处理特殊字符、解析整个树结构等繁琐工作。对于 XPath,所有的这些都不是问题,你可以轻松提取元素、属性或是文字。
在 Chrome 中使用 XPath,在开发者工具中点击控制台标签,使用\(x 功能。例如,在网页[http://example.com/](https://link.jianshu.com?t=http://example.com/)的控制台,输入\)x('//h1'),就可以移动到<h1>
元素,如截图所示:
你在控制台中看到的是一个包含所选元素的 JavaScript 数组。如果你将光标移动到这个数组上,你可以看到被选择的元素被高亮显示。这个功能很有用。
XPath 表达式
HTML 文档的层级结构的最高级是<html>
标签,你可以使用元素名和斜杠线选择任意元素。例如,下面的表达式返回了http://example.com/上对应的内容:
$x('/html')
[ <html>...</html> ]
$x('/html/body')
[ <body>...</body> ]
$x('/html/body/div')
[ <div>...</div> ]
$x('/html/body/div/h1')
[ <h1>Example Domain</h1> ]
$x('/html/body/div/p')
[ <p>...</p>, <p>...</p> ]
$x('/html/body/div/p[1]')
[ <p>...</p> ]
$x('/html/body/div/p[2]')
[ <p>...</p> ]
注意,<p>
标签在<div>
标签内有两个,所以会返回两个。你可以用 p[1]和 p[2]分别返回两个元素。
从抓取的角度,文档的标题或许是唯一让人感兴趣的,它位于文档的头部,可以用下面的额表达式找到:
$x('//html/head/title')
[ <title>Example Domain</title> ]
对于大文档,你可能要写很长的 XPath 表达式,以获取所要的内容。为了避免这点,两个斜杠线//可以让你访问到所有的同名元素。例如,//p 可以选择所有的 p 元素,//a 可以选择所有的链接。
$x('//p')
[ <p>...</p>, <p>...</p> ]
$x('//a')
[ <a href="http://www.iana.org/domains/example">More information...</a> ]
//a 可以用在更多的地方。例如,如果要找到所有<div>
标签的链接,你可以使用//div//a。如果 a 前面只有一个斜杠,//div/a 会返回空,因为在上面的例子中<div>
标签下面没有<a>
。
$x('//div//a')
[ <a href="http://www.iana.org/domains/example">More information...</a> ]
$x('//div/a')
[ ]
你也可以选择属性。http://example.com/上唯一的属性是链接 href,可以通过下面的方式找到:
$x('//a/@href')
[href="http://www.iana.org/domains/example"]
你也可以只通过 text( )函数选择文字:
$x('//a/text()')
["More information..."]
可以使用*标志选择某层下所有的元素,例如:
$x('//div/*')
[<h1>Example Domain</h1>, <p>...</p>, <p>...</p>]
寻找特定属性,例如@class、或属性有特定值时,你会发现 XPath 非常好用。例如,//a[@href]可以找到所有链接,//a[@href="http://www.iana.org/domains/example"]则进行了指定的选择。
当属性值中包含特定字符串时,XPath 会极为方便。例如,
$x('//a[@href]')
[<a href="http://www.iana.org/domains/example">More information...</a>]
$x('//a[@href="http://www.iana.org/domains/example"]')
[<a href="http://www.iana.org/domains/example">More information...</a>]
$x('//a[contains(@href, "iana")]')
[<a href="http://www.iana.org/domains/example">More information...</a>]
$x('//a[starts-with(@href, "http://www.")]')
[<a href="http://www.iana.org/domains/example">More information...</a>]
$x('//a[not(contains(@href, "abc"))]')
[ <a href="http://www.iana.org/domains/example">More information...</a>]
在http://www.w3schools.com/xsl/xsl_functions.asp在线文档中你可以找到更多类似的函数,但并非都常用。
在 Scrapy 终端中可以使用同样的命令,在命令行中输入
scrapy shell "http://example.com"
终端会向你展示许多写爬虫时碰到的变量。其中最重要的是响应,在 HTML 中是 HtmlResponse,这个类可以让你在 Chrome 使用 xpath( )方法$x。下面是一些例子:
response.xpath('/html').extract()
[u'<html><head><title>...</body></html>']
response.xpath('/html/body/div/h1').extract()
[u'<h1>Example Domain</h1>']
response.xpath('/html/body/div/p').extract()
[u'<p>This domain ... permission.</p>', u'<p><a href="http://www.iana.org/domains/example">More information...</a></p>']
response.xpath('//html/head/title').extract()
[u'<title>Example Domain</title>']
response.xpath('//a').extract()
[u'<a href="http://www.iana.org/domains/example">More information...</a>']
response.xpath('//a/@href').extract()
[u'http://www.iana.org/domains/example']
response.xpath('//a/text()').extract()
[u'More information...']
response.xpath('//a[starts-with(@href, "http://www.")]').extract()
[u'<a href="http://www.iana.org/domains/example">More information...</a>']
这意味着,你可用 Chrome 浏览器生成 XPath 表达式,以便在 Scrapy 爬虫中使用。
使用 Chrome 浏览器获得 XPath 表达式
Chrome 浏览器可以帮助我们获取 XPath 表达式这点确实对开发者非常友好。像之前演示的那样检查一个元素:右键选择一个元素,选择检查元素。开发者工具被打开,该元素在 HTML 的树结构中被高亮显示,可以在右键打开的菜单中选择 Copy XPath,表达式就复制到粘贴板中了。
你可以在控制台中检测表达式:
$x('/html/body/div/p[2]/a')
[<a href="http://www.iana.org/domains/example">More information...</a>]
常见工作
下面展示一些 XPath 表达式的常见使用。先来看看在维基百科上是怎么使用的。维基百科的页面非常稳定,不会在短时间内改变排版。
- 取得 id 为 firstHeading 的 div 下的 span 的 text:
//h1[@id="firstHeading"]/span/text()
- 取得 id 为 toc 的 div 下的 ul 内的 URL:
//div[@id="toc"]/ul//a/@href
- 在任意 class 包含 ltr 和 class 包含 skin-vector 的元素之内,取得 h1 的 text,这两个字符串可能在同一 class 内,或不在。
//*[contains(@class,"ltr") and contains(@class,"skin-vector")]//h1//text()
实际应用中,你会在 XPath 中频繁地使用 class。在这几个例子中,你需要记住,因为 CSS 的板式原因,你会看到 HTML 的元素总会包含许多特定的 class 属性。这意味着,有的<div>
的 class 是 link,其他导航栏的<div>
的 class 就是 link active。后者是当前生效的链接,因此是可见或是用 CSS 特殊色高亮显示的。当抓取的时候,你通常是对含有某个属性的元素感兴趣的,就像之前的 link 和 link active。XPath 的 contains( )函数就可以帮你选择包含某一 class 的所有元素。
- 选择 class 属性是 infobox 的 table 的第一张图片的 URL:
//table[@class="infobox"]//img[1]/@src
- 选择 class 属性是 reflist 开头的 div 下面的所有 URL 链接:
//div[starts-with(@class,"reflist")]//a/@href
- 选择 div 下面的所有 URL 链接,并且这个 div 的下一个相邻元素的子元素包含文字 References:
//*[text()="References"]/../following-sibling::div//a
- 取得所有图片的 URL:
//img/@src
提前应对网页发生改变
爬取的目标常常位于远程服务器。这意味着,如果它的 HTML 发生了改变,XPath 表达式就无效了,我们就不得不回过头修改爬虫的程序。因为网页的改变一般就很少,爬虫的改动往往不会很大。然而,我们还是宁肯不要回头修改。一些基本原则可以帮助我们降低表达式失效的概率:
- 避免使用数组序号
Chrome 常常会在表达式中加入许多常数
//*[@id="myid"]/div/div/div[1]/div[2]/div/div[1]/div[1]/a/img
如果 HTML 上有一个广告窗的话,就会改变文档的结构,这个表达式就会失效。解决的方法是,尽量找到离 img 标签近的元素,根据该元素的 id 或 class 属性,进行抓取,例如:
//div[@class="thumbnail"]/a/img
- 用 class 抓取效果不一定好
使用 class 属性可以方便的定位要抓取的元素,但是因为 CSS 也要通过 class 修改页面的外观,所以 class 属性可能会发生改变,例如下面用到的 class:
//div[@class="thumbnail"]/a/img
过一段时间之后,可能会变成:
//div[@class="preview green"]/a/img
-
数据指向的 class 优于排版指向的 class
在上一个例子中,使用 thumbnail 和 green 两个 class 都不好。thumbnail 比 green 好,但这两个都不如 departure-time。前面两个是用来排版的,departure-time 是有语义的,和 div 中的内容有关。所以,在排版发生改变的情况下,departure-time 发生改变的可能性会比较小。应该说,网站作者在开发中十分清楚,为内容设置有意义的、一致的标记,可以让开发过程收益。 -
id 通常是最可靠的
只要 id 具有语义并且数据相关,id 通常是抓取时最好的选择。部分原因是,JavaScript 和外链锚点总是使用 id 获取文档中特定的部分。例如,下面的 XPath 非常可靠:
//*[@id="more_info"]//text( )
相反的例子是,指向唯一参考的 id,对抓取没什么帮助,因为抓取总是希望能够获取具有某个特点的所有信息。例如:
//[@id="order-F4982322"]
这是一个非常差的 XPath 表达式。还要记住,尽管 id 最好要有某种特点,但在许多 HTML 文档中,id 都很杂乱无章。
总结
编程语言的不断进化,使得创建可靠的 XPath 表达式从 HTML 抓取信息变得越来越容易。在本章中,你学到了 HTML 和 XPath 的基本知识、如何利用 Chrome 自动获取 XPath 表达式。你还学会了如何手工写 XPath 表达式,并区分可靠和不够可靠的 XPath 表达式。第 3 章中,我们会用这些知识来写几个爬虫。
三、爬虫基础
本章非常重要,你可能需要读几遍,或是从中查找解决问题的方法。我们会从如何安装 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: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'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 填充数据。
四、从 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/
$ 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。下章学习如何使用它解决许多常见的问题。