一些想法
页面爬的多了,量上去了之后,就会遇到其他的问题,其实不管做什么技术量大了都会有问题。一般情况下,我认为解决"大量"问题的思路有两个:一种是着力于优化系统的能力,让原本只能一分钟处理100条的系统提升到一分钟1000条之类的,在我看来并行、分布式、集群都属于这个范畴,这种思路下,系统处理的内容没有变化只是单纯的处理速度变快了;另一种是着力于提高系统的工作效率, 比如说降低某算法的复杂度。
爬虫领域的增量式爬取属于后者,每种网站都有每种网站的特点。比如说小说连载网站、新闻或者知乎首页,这里拿知乎时间线举例,我基本每天醒来和睡觉前都会刷一波知乎,从头开始看直到看到上次载入的地方,假设我要抓取知乎的数据并保存到本地,不难发现最好的选择其实是每次只抓取上次没读过的新内容,抓评论也是一样,最优的选择是每次只抓取在上次抓取之后出现的新评论,然后再进行保存。有的时候,还有另外一种情况,就是原本存在的网页内容更新了,比如说有人在知乎上修改了他的回答。这时候,我们的爬虫就需要有分辨这些区别变化的能力。但这几个都是很简单的例子,实际情况会复杂很多。
不管是产生新页面,还是原本的页面更新,这种变化都被称为增量, 而爬取过程则被称为增量爬取。那如何进行增量式的爬取工作呢?回想一下爬虫的工作流程:
发送URL请求 ----- 获得响应 ----- 解析内容 ----- 存储内容
我们可以从几种思路入手:
- 在发送请求之前判断这个URL是不是之前爬取过
- 在解析内容后判断这部分内容是不是之前爬取过
- 写入存储介质时判断内容是不是已经在介质中存在
实现增量式爬取
不难发现,其实增量爬取的核心是去重, 至于去重的操作在哪个步骤起作用,只能说各有利弊,就像我说的,everything is tradeoff。
在我看来,前两种思路需要根据实际情况取一个(也可能都用)。第一种思路适合不断有新页面出现的网站,比如说小说的新章节,每天的最新新闻等等;第二种思路则适合页面内容会更新的网站。第三个思路是相当于是最后的一道防线。这样做可以最大程度上达到去重的目的。
去重的方法
最简单的去重方式自然是将所有访问过的URL和其对应的内容保存下来,然后过一段时间重新爬取一次并进行比较,然后决定是否需要覆盖。这显然是不实际的,因为会消耗很多资源。目前比较实际的做法就是给URL或者其内容(取决于这个网站采用哪种更新方式)上一个标识,这个标识有个比较好听的名字,叫数据指纹。
这里很容易想到的一种数据指纹就是哈希值,根据哈希函数的特性,我们可以为任意内容生成一个独一无二的定长字符串,之后只要比较这个哈希值就行了。哈希值是一个很伟大的发明,几乎在任何地方都有它的影子,它利用数学特性,计算机只要经过简单的计算就可以得到唯一的特征值,这个计算过程的开销基本可以忽略不计,当然这是题外话了。
不过即使用了哈希值,你仍需要一个地方存储所有的哈希值,并且要能做到方便的取用。如果你的存储介质是数据库,一般的数据库系统都能提供索引,如果把哈希值作为唯一索引呢,这应该是可行的。有些数据库也提供查询后再插入的操作,不过本质上应该也是索引。和哈希值类似的还有MD5校验码,殊途同归。
除了自建指纹,其实在发送请求时还有一些技巧,比如说304状态码,Last-modified字段,文件大小和MD5签名。具体参考[8],很好理解,就不细说了。
综上所述,在数据量不大的时候,几百个或者就几千个的时候,简单自己写个小函数或者利用集合的特性去重就行了。如果数据量够大,数据指纹的价值就体现出来了,它可以节省可观的空间,同时可以引入BloomFilter作为去重的手段。另外,如果要对数据做持久化(简单说就是去重操作不会被事故影响,比如说断电),就需要用到Redis数据库。
BloomFilter
布朗过滤器虽然不是因为爬虫才出现的,但是却在这种情况下显得异常有用。布朗过滤器可以通过计算来判断某项数据是否存在于集合中,它原理和概念可以参考1和英文版的维基百科Bloom filter, 里面有详细的数学推理,它解释了为什么布朗会有误判情况出现,感兴趣可以学习一下,并不难。这里只提几点:
- 布朗过滤器是有误判率的,它会把原本不属于这个集合的数据误判为属于,但不会把原本属于集合的数据误判为不属于。
- 它是一个典型且高效的空间换时间的例子。
- 它的误判率是:
\left(1-\left[1-\frac{1}{m}\right]^{kn}\right)^k \approx \left( 1-e^{-kn/m} \right)^k
这里元素的数量n、 过滤容器的大小m(bits)和哈希函数的数量k存在的一定关系,它们三个共同确定了误判率;同样如果已知其中两项,通过调整另外一项也可以达到降低误判率的目的,具体参见Bloom Filters - the math。
Redis的集合使用
简单来说,Redis的集合就是Redis数据库中的集合类型,它具有无序不重复的特点。Python有redis客户端库,这里主要涉及到的就是SADD和SISMEMBER命令。下面会具体解释。
具体实现
BloomFilter
这里我们使用pybloom库,需要pip或者源码安装。pybloom库用起来非常简单,这里给两段最基本的代码:
from pybloom import BloomFilter
# 新建一个过滤器,长度为m,错误率为0.1%
bf = BloomFilter(capacity=1000, error_rate=0.001)
‘’’
不难理解,这句就相当于
for x in range(bf.capacity):
bd.add(x)
但说实话这种写法我第一次见到
‘’’
[bf.add(x) for x in range(bf.capacity)]
print (0 in bf)
print (5 in bf)
print (10 in bf)
# 这里是计算它的错误率
count = 0
amount = bf.capacity
for i in range(bf.capacity, bf.capacity + amount + 1):
if i in bf:
count += 1
print (“FP: {:2.4f}”.format(count / float(amount)))
我从网上搜到文章大多只是介绍了如何新建一个Filter、怎么add以及查看元素是否属于这个Filter。实际上,如果阅读过源码,其实filter还提供了很多其他方法,同时这个库还提供了一个可自动扩展的Filter,作者比较推荐后者。
from pybloom import BloomFilter
# 新建
bf1 = BloomFilter(capacity=1000, error_rate=0.001)
bf2 = BloomFilter(capacity=1000, error_rate=0.001)
# 添加
[bf1.add(x) for x in range(3)]
[bf2.add(x) for x in range(3,6)]
# 复制
bf3 = bf1.copy()
# | 操作,三种都行
bf3.union(bf1)
bf3 = bf3 | bf2
bf3 = bf3 or bf2
# & 操作, 三种都行
bf3.intersection(bf1)
bf3 = bf3 & bf1
bf3 = bf3 and bf1
# 成员变量和支持的操作符
len(bf3)
3 in bf3
bf3.capacity
bf3.error_rate
# 也支持tofile和fromfile操作
# 具体的代码可参照源码中tests.py中的test_serialization()方法
可扩展的过滤器:
from pybloom import ScalableBloomFilter
# 新建, mode目前只有2种
# SMALL_SET_GROWTH = 2, LARGE_SET_GROWTH = 4
# 前者占内存少但速度慢,后者消耗内存快但速度快
bf1 = ScalableBloomFilter(initial_capacity=100, error_rate=0.001, mode=ScalableBloomFilter.SMALL_SET_GROWTH)
bf2 = ScalableBloomFilter(initial_capacity=1000, error_rate=0.001, mode=ScalableBloomFilter.LARGE_SET_GROWTH)
# 添加
[bf1.add(x) for x in range(3)]
[bf2.add(x) for x in range(3,6)]
# 两个属性(装饰器)
bf1.capacity
bf1.count
# 成员变量和支持的操作符
len(bf1)
3 in bf1
bf1.initial_capacity
bf1.error_rate
# 也支持tofile和fromfile操作
# 具体的代码可参照源码中tests.py中的test_serialization()方法
这里我建议看下这个库源码,核心部分差不多500行,里面很多写法很值得学习,而且都很容易理解。里面也涉及到了如何选取哈希函数。
Redis
Python的Redis客户端库也是开源的,地址是:redis-py。不过在开始之前,你首先需要一个有Redis数据库运行的主机(搭建一个很简单)。
这里需要解释不少东西,首先,上文中有一节本来不叫“Redis的集合”而是叫“Redis集合”,我一开始以为这是一种名叫Redis的特殊集合,然后这个集合带有不可插入重复内容的特性,事实上这里大错特错了。还记得我们的初衷是“去重”,实际上,包括Python在内的很多语言已经实现了具有无序不重复特性的内置数据结构:集合(Set)。也就是说从去重这点看的话,有集合这种数据结构就够了,跟Redis并没有什么关系。
那么Redis是什么?它是一种数据库,它的官网是这样描述的:
Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker.
关于Redis数据库还有几个关键词:key-value,高性能,数据持久化,数据备份,原子操作以及跟这里相关的一个特性:支持集合数据类型。这才是为什么做增量爬取时我们要用到Redis数据库:我们可以通过将URL或者页面内容的指纹作为key存入Redis数据库中的集合里,利用集合的不重复性达到去重的目的,每次爬虫要处理URL或者页面时会先去Redis数据库里检查一下是否已经存在,因为Redis数据库着力于key-value形式的存储,所以这一步的速度将会很可观;其次Redis可以将内存中的内容持久化到磁盘,并且其每一次操作都是原子操作,这就保证了爬虫的可靠性,即爬虫不会应为意外停止而损失数据。
说了这么多,现在就能知道为什么这里要用到Redis的集合。如果只考虑本文相关的内容,那么和本文有关的Redis数据库操作命令只有两个:SADD和SISMEMBER,前者可以向集合中插入一条数据,成功返回1,失败返回0;后者可以查询某元素是否在集合中存在,存在返回1,不存在返回0。
我在一台虚拟机Ubuntu-14.04上安装了Redis数据库并配置了远程连接,客户端测试如下:
>>> import redis
>>> r = redis.StrictRedis(host='192.168.153.131', port=6379, db=0)
>>> r.sadd('1','aa')
1
>>> r.sadd('1','aa')
0
>>> r.sadd('2','aa')
1
>>> r.sadd('3','aa')
1
>>> r.sismember('1','aa')
True
>>> r.sismember('1','b')
False
>>>
但应该如何将这一特性融入到爬虫中呢?如果是自己写的爬虫代码,添加上述代码即可;如果使用的是scrapy框架,我们可以在middleware上下功夫,在spider模块收到要处理的URL时,写一个Spider中间件用来判断这条URL的指纹是否在Redis数据库中存在,如果存在的话,就直接舍弃这条URL;如果是要判断页面的内容是否更新,可以在Download中间件中添加代码来校验,原理一样。当然,数据库的操作可以用类似write()和query()的方法进行封装,此处不表。