学习目标:
python学习二十八——简单数据抓取八
学习内容:
1、scrapy_redis实现增量式爬虫
2、Scrapy-Redis中对接Bloom Filter去重
1、scrapy_redis实现增量式爬虫
- 增量式爬虫就是通过redis实现调度器的功能,可以实现增量式爬取,让人们可以一起调用同一个爬虫程序,进行分布式爬取
1、Scrapy_redis在scrapy的基础上实现了更多,更强大的功能,具体体现在:
- 请求对象的持久化
- 去重的持久化
- 和实现分布式
2、scrapy_redis的工作特点:
- 在scrapy_redis中,所有的带抓取的对象和去重的指纹都存在所有的服务器公用的redis中
- 所有的服务器公用一个redis中的request对象
- 所有的request对象存入redis前,都会在同一个redis中进行判断,之前是否已经存入过
- 在默认情况下所有的数据会保存在redis中
3、官方案例分析
-
在github网站获取源码文件https://github.com/rolando/scrapy-redis.git,获取到源码包打开其中的案列scrapy-redis/example-project~/scrapyredis-project
-
dmoz.py爬虫文件:
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
class DmozSpider(CrawlSpider):
"""Follow categories and extract links."""
name = 'dmoz'
allowed_domains = ['dmoz-odp.org']
start_urls = ['http://www.dmoz-odp.org/']
# 选择了scrapy的Rule模板编写代码,制定相应的爬取规则,并且根据规则整站跟进爬取
rules = [
Rule(LinkExtractor(
# 规则就是根据css选定范围,范围包括class分别为.top-cat、.sub-cat、.cat-item,依次跟进爬取,指定parse_directory函数继续往下回调
restrict_css=('.top-cat', '.sub-cat', '.cat-item')
), callback='parse_directory', follow=True),
]
def parse_directory(self, response):
for div in response.css('.title-and-desc'):
yield {
'name': div.css('.site-title::text').extract_first(),
'description': div.css('.site-descr::text').extract_first().strip(),
'link': div.css('a::attr(href)').extract_first(),
}
- 项目的配置文件settings.py,以下配置重新实现了去重的类
注:scrapy_redis包需要用 pip install scrapy_redis指令下载
# Scrapy settings for example project
ER_MODULES = ['example.spiders']
NEWSPIDER_MODULE = 'example.spiders'
USER_AGENT = 'scrapy-redis (+https://github.com/rolando/scrapy-redis)'
# 指定scrapy_redis过滤器
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
# 指定连接redis
REDIS_URL = 'redis://127.0.0.1:6379'
# 指定使用scrapy的调度器
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 清空缓存
SCHEDULER_PERSIST = True
#SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.SpiderPriorityQueue"
#SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.SpiderQueue"
#SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.SpiderStack"
ITEM_PIPELINES = {
'example.pipelines.ExamplePipeline': 300,
'scrapy_redis.pipelines.RedisPipeline': 400,
}
LOG_LEVEL = 'DEBUG'
# Introduce an artifical delay to make use of parallelism. to speed up the
# crawl.
DOWNLOAD_DELAY = 1
- 运行项目在redis中的显示结果:
继续执行程序,会发现程序在前一次的基础之上继续往后执行,所以domz爬虫是一个基于url地址的增量式的爬虫
4、原理分析
- settings.py中的三个配置来进行分析 分别是:
RedisPipeline
RFPDupeFilter
Scheduler - 观察scrapy_redis包下redispipline中的process_item
def process_item(self, item, spider):
# 使用process_item方法实现数据的保存
return deferToThread(self._process_item, item, spider)
# 调用一个异步线程去处理item
def _process_item(self, item, spider):
key = self.item_key(item, spider)
data = self.serialize(item)
# 向redis中的dmoz添加item数据
self.server.rpush(key, data)
return item
def request_fingerprint(self, request):
"""Returns a fingerprint for a given request.
Parameters
----------
request : scrapy.http.Request
Returns
-------
str
"""
# 此处引用到request_fingerprint方法,摁住ctrl鼠标左击可以找到该方法
return request_fingerprint(request)
- 观察scrapy/utils/包下request.py文件中的request_fingerprint方法:
def request_fingerprint(request, include_headers=None, keep_fragments=False):
"""
Return the request fingerprint.
The request fingerprint is a hash that uniquely identifies the resource the
request points to. For example, take the following two urls:
http://www.example.com/query?id=111&cat=222
http://www.example.com/query?cat=222&id=111
Even though those are two different URLs both point to the same resource
and are equivalent (i.e. they should return the same response).
Another example are cookies used to store session ids. Suppose the
following page is only accessible to authenticated users:
http://www.example.com/members/offers.html
Lot of sites use a cookie to store the session id, which adds a random
component to the HTTP Request and thus should be ignored when calculating
the fingerprint.
For this reason, request headers are ignored by default when calculating
the fingeprint. If you want to include specific headers use the
include_headers argument, which is a list of Request headers to include.
Also, servers usually ignore fragments in urls when handling requests,
so they are also ignored by default when calculating the fingerprint.
If you want to include them, set the keep_fragments argument to True
(for instance when handling requests with a headless browser).
"""
if include_headers:
include_headers = tuple(to_bytes(h.lower()) for h in sorted(include_headers))
cache = _fingerprint_cache.setdefault(request, {})
cache_key = (include_headers, keep_fragments)
if cache_key not in cache:
# sha1加密
fp = hashlib.sha1()
# 请求的方法
fp.update(to_bytes(request.method))
# 请求地址
fp.update(to_bytes(canonicalize_url(request.url, keep_fragments=keep_fragments)))
# 请求体post请求才会有
fp.update(request.body or b'')
# 添加请求头,默认是不会添加的(header的cookie中含有session id, 在不同网站里是随机的,会给sha1结果带来误差)
if include_headers:
for hdr in include_headers:
if hdr in request.headers:
fp.update(hdr)
for v in request.headers.getlist(hdr):
fp.update(v)
cache[cache_key] = fp.hexdigest()
# 返回加密过后的16进制
return cache[cache_key]
- 观察Scrapy_redis中的Scheduler文件:
def close(self, reason):
# 如果在setting中设置为不持久,在退出时会清空
if not self.persist:
self.flush()
def flush(self):
# 指存放dupefilter的redis
self.df.clear()
# 指存放request的redis
self.queue.clear()
def enqueue_request(self, request):
# 如果dont_filter不是False,也在redis,就不能被爬取
# 如果想要爬取类似于百度贴吧那样随时更新的网站可以更改dont_filter为Ture
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
if self.stats:
self.stats.inc_value('scheduler/enqueued/redis', spider=self.spider)
self.queue.push(request)
return True
- 可以加入redis队列的条件是:
- request之前没有见过
- request的dont_filter为True,即不过滤
- start_urls中的url地址会入队,因为他们默认是不过滤
2、Scrapy-Redis中对接Bloom Filter去重
1、Bloom Filter就是布隆过滤器,可以用来检测一个元素是否在一个集合中
- Bloom Filter的算法:
a、设置一个包含m位的位数组,它的所有位都是0,如下图所示:
b、再设置一个待检测集合,其表示为S={x1, x2, …, xn},检测S中的x元素是否在这个S集合中
c、使用k个相互独立、随机的散列函数来将集合S中的每个元素x1, x2, …, xn映射到位数为m的位数组上,散列函数得到的结果记作位置索引,然后将位数组该位置索引的位置1
d、新的元素x,判断x是否属于S集合,仍然用k个散列函数对x求映射结果。如果所有结果对应的位数组位置均为1,那么x属于S这个集合;如果有一个不为1,则x不属于S集合
e、由m位的位数组,含有n个x元素的待测S集合,k个独立的函数,可以得到误认为某个元素属于这个集合的概率:
2、将Bloom Filter去重的算法对接到Sscrapy_redis中
需要亿级别的数据的去重,算法中的n为1亿以上,散列函数的个数k大约取10左右的量级。而m>kn,这里m值大约保底在10亿,由于这个数值比较大,所以这里用移位操作来实现,传入位数bit,将其定义为30,然后做一个移位操作1<<30,相当于2的30次方,等于1073741824,量级也是恰好在10亿左右,由于是位数组,所以这个位数组占用的大小就是2^30
b=128 MB
-
创建一个tests项目名叫ScrapyRedisBloomFilter-master:
爬虫tests包、改写的包含布隆去重的scrapy_redis包scrapy_redis_bloomfilter包
-
为了实现bloom_filter,在爬虫tests包的settings.py文件中,配置了该项目中的爬虫选用的调度器和过滤器
settings.py文件:
.............
# 选择调度器
SCHEDULER = "scrapy_redis_bloomfilter.scheduler.Scheduler"
# Ensure all spiders share same duplicates filter through redis.
# 选择抽改写过后的布隆过滤器,去重类
DUPEFILTER_CLASS = "scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter"
# Redis URL
REDIS_URL = 'redis://127.0.0.1:6379'
# 以下是bloom_filter算法会用到的默认值
# Number of Hash Functions to use, defaults to 6
# 散列函数的个数,此处默认为6,可以修改
BLOOMFILTER_HASH_NUMBER = 6
# Bloom Filter的bit参数,默认30,占用128MB空间,去重量级1亿,可根据相应的爬取量级调高该数值
BLOOMFILTER_BIT = 10
# Persist
SCHEDULER_PERSIST = True
- 在scrapy_redis_bloomfilter包编写一个bloomfilter.py文件实现bloom_filter算法:
from .defaults import BLOOMFILTER_BIT, BLOOMFILTER_HASH_NUMBER
# 一个基本的散列算法,将一个值经过散列运算后映射到一个m位数组的某一位上
# 构造函数传入两个值,一个是m位数组的位数,另一个是种子值seed。不同的散列函数需要有不同的seed,保证不同的散列函数的结果不会碰撞
class HashMap(object):
def __init__(self, m, seed):
self.m = m
self.seed = seed
# hash()方法的实现中,value是要被处理的内容。这里遍历了value的每一位,
# 并利用ord()方法取到每一位的ASCII码值,然后混淆seed进行迭代求和运算,最终得到一个数值。
# 这个数值的结果就由value和seed唯一确定。
# 我们再将这个数值和m进行按位与运算,即可获取到m位数组的映射结果,这样就实现了一个由字符串和seed来确定的散列函数。
# 当m固定时,只要seed值相同,散列函数就是相同的,相同的value必然会映射到相同的位置。
# 所以如果想要构造几个不同的散列函数,只需要改变其seed
def hash(self, value):
"""
Hash Algorithm
:param value: Value
:return: Hash Value
"""
ret = 0
for i in range(len(value)):
ret += self.seed * ret + ord(value[i])
return (self.m - 1) & ret
# 实现Bloom Filter。Bloom Filter里面需要用到k个散列函数,这里要对这几个散列函数指定相同的m值和不同的seed值,
# 相关的默认值在tests包的settings.py文件中
class BloomFilter(object):
def __init__(self, server, key, bit=BLOOMFILTER_BIT, hash_number=BLOOMFILTER_HASH_NUMBER):
"""
Initialize BloomFilter
:param server: Redis Server
:param key: BloomFilter Key
:param bit: m = 2 ^ bit
:param hash_number: the number of hash function
"""
# default to 1 << 30 = 10,7374,1824 = 2^30 = 128MB, max filter 2^30/hash_number = 1,7895,6970 fingerprints
self.m = 1 << bit
self.seeds = range(hash_number)
self.server = server
# key就是这个m位数组的名称
self.key = key
# 遍历seed,构造带有不同seed值的HashMap对象,然后将HashMap对象保存成变量maps供后续使用
self.maps = [HashMap(self.m, seed) for seed in self.seeds]
# 实现比较关键的两个方法:一个是判定元素是否重复的方法exists(),另一个是添加元素到集合中的方法insert()
# 方法参数value为待判断的元素,首先定义一个变量exist,遍历所有散列函数对value进行散列运算,得到映射位置,
# 用getbit()方法取得该映射位置的结果,循环进行与运算。
# 这样只有每次getbit()得到的结果都为1时,最后的exist才为True,表示value属于这个集合。
# 只要有一次getbit()得到的结果为0,即m位数组中有对应的0位,结果exist就为False,表示value不属于这个集合
def exists(self, value):
"""
if value exists
:param value:
:return:
"""
if not value:
return False
exist = True
for map in self.maps:
offset = map.hash(value)
exist = exist & self.server.getbit(self.key, offset)
return exist
# Bloom Filter算法会逐个调用散列函数对放入集合中的元素进行运算,得到在m位位数组中的映射位置,
# 然后将位数组对应的位置置1。这里代码遍历了初始化好的散列函数,
# 然后调用其hash()方法算出映射位置offset,
# 再利用Redis的setbit()方法将该位置1
def insert(self, value):
"""
add value to bloom
:param value:
:return:
"""
for f in self.maps:
offset = f.hash(value)
self.server.setbit(self.key, offset, 1)
# 用下面的实例测试bloom_filter
conn = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
bf = BloomFilter(conn, 'test', 5, 6)
bf.insert('abc')
result = bf.exists('abc')
print(33333, result)
- 修改Scrapy-Redis的源码,将它的dupefilter.py文件逻辑替换为Bloom
Filter的逻辑,修改RFPDupeFilter类的request_seen()方法:
def request_seen(self, request):
# 利用request_fingerprint()方法获取Request的指纹,
# 调用Bloom Filter的exists()方法判定该指纹是否存在。
# 如果存在,则说明该Request是重复的,返回True,
# 否则调用Bloom Filter的insert()方法将该指纹添加并返回False。
# 这样就成功利用Bloom Filter替换了Scrapy-Redis的集合去重
"""Returns True if request was already seen.
Parameters
----------
request : scrapy.http.Request
Returns
-------
bool
"""
fp = self.request_fingerprint(request)
# This returns the number of values added, zero if already exists.
if self.bf.exists(fp):
return True
self.bf.insert(fp)
return False
- 同样在dupefilter.py文件中Bloom Filter的初始化定义,我们可以将__init__()方法:
def __init__(self, server, key, debug, bit, hash_number):
"""Initialize the duplicates filter.
Parameters
----------
server : redis.StrictRedis
The redis server instance.
key : str
Redis key Where to store fingerprints.
debug : bool, optional
Whether to log filtered requests.
"""
self.server = server
self.key = key
self.debug = debug
self.bit = bit
self.hash_number = hash_number
self.logdupes = True
self.bf = BloomFilter(server, self.key, bit, hash_number)
- 在dupefilter.py文件中 bit和hash_number需要使用from_settings()方法传递:
@classmethod
def from_settings(cls, settings):
"""Returns an instance from given settings.
This uses by default the key ``dupefilter:<timestamp>``. When using the
``scrapy_redis.scheduler.Scheduler`` class, this method is not used as
it needs to pass the spider name in the key.
Parameters
----------
settings : scrapy.settings.Settings
Returns
-------
RFPDupeFilter
A RFPDupeFilter instance.
"""
server = get_redis_from_settings(settings)
# XXX: This creates one-time key. needed to support to use this
# class as standalone dupefilter with scrapy's default scheduler
# if scrapy passes spider on open() method this wouldn't be needed
# TODO: Use SCRAPY_JOB env as default and fallback to timestamp.
key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
debug = settings.getbool('DUPEFILTER_DEBUG', DUPEFILTER_DEBUG)
bit = settings.getint('BLOOMFILTER_BIT', BLOOMFILTER_BIT)
hash_number = settings.getint('BLOOMFILTER_HASH_NUMBER', BLOOMFILTER_HASH_NUMBER)
return cls(server, key=key, debug=debug, bit=bit, hash_number=hash_number)
- 常量DUPEFILTER_DEBUG和BLOOMFILTER_BIT统一定义在defaults.py中:
BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30
DUPEFILTER_DEBUG = False
SCHEDULER_DUPEFILTER_KEY = '%(spider)s:bloomfilter'
- 最后运行在项目包中的tests爬虫
from scrapy import Request, Spider
# start_requests()方法首先循环10次,构造参数为0~9的URL,
# 然后重新循环了100次,构造了参数为0~99的URL,
# 那么这里就会包含10个重复的Request
class TestSpider(Spider):
name = 'test'
base_url = 'https://www.baidu.com/s?wd='
def start_requests(self):
for i in range(10):
url = self.base_url + str(i)
yield Request(url, callback=self.parse)
for i in range(100):
url = self.base_url + str(i)
yield Request(url, callback=self.parse)
def parse(self, response):
self.logger.debug('Response of ' + response.url)
成功的结果中会看到:
'bloomfilter/filtered': 10,