Scrapy-Redis原理和源码解析
一两个月没有发博客了。。。虽然没有发博客,但是一直在努力学习哇。暑假的时候会把一些爬虫的知识点和js逆向的知识点上传。
文章目录
一级目录
二级目录
三级目录
1.scrapy-reids的原理和源码解析:
Scrapy-Redis库已经为我们实现了Scrapy分布式的队列、调度器、去重等功能,github地址:https://github.com/rmax/scrapy-redis
1.爬取队列:
queue.py文件中有三个队列的实现,分别是:FifoQueue, PriorityQueue, LifoQueue。但是它们都继承了一个父类Base,代码如下:
1.Base:
class Base(object):
def __init__(self, server, spider, key, serializer=None):
if serializer is None:
# Backward compatibility.
# TODO: deprecate pickle.
serializer = picklecompat
if not hasattr(serializer, 'loads'):
raise TypeError(f"serializer does not implement 'loads' function: {serializer}")
if not hasattr(serializer, 'dumps'):
raise TypeError(f"serializer does not implement 'dumps' function: {serializer}")
self.server = server
self.spider = spider
self.key = key % {'spider': spider.name}
self.serializer = serializer
def _encode_request(self, request):
"""Encode a request object"""
try:
obj = request.to_dict(spider=self.spider)
except AttributeError:
obj = request_to_dict(request, self.spider)
return self.serializer.dumps(obj)
def _decode_request(self, encoded_request):
"""Decode an request previously encoded"""
obj = self.serializer.loads(encoded_request)
return request_from_dict(obj, spider=self.spider)
def __len__(self):
"""Return the length of the queue"""
raise NotImplementedError
def push(self, request):
"""Push a request"""
raise NotImplementedError
def pop(self, timeout=0):
"""Pop a request"""
raise NotImplementedError
def clear(self):
"""Clear queue/stack"""
self.server.delete(self.key)
值得注意的是 _encode_request 和 _ decode_request 方法,因为通过 scrapy-redis 的原理可知,我们需要把一个request对象push到数据库中,但是数据库没有办法直接存储对象啊,怎么办?这里 _encode_request 就发挥作用了,它先是把一个 request 对象转成 dict 对象赋值给 obj,然后self.serializer.dumps(obj)
将obj
序列化为一个字符串,用于redis存储。同样,如果我们需要从数据库中pop出数据库,我们会调用 _decode_request 方法来反序列化,把一个存储在redis的字符串变成request对象。
细心的同学会发现父类中的 len push pop方法都是没有实现的,直接调用会抛出NotImplementedError的错误,因此这个类是不能直接被使用的,需要我们实现子类来对父类方法进行重写。
2.FifoQueue:
class FifoQueue(Base):
"""Per-spider FIFO queue"""
def __len__(self):
"""Return the length of the queue"""
return self.server.llen(self.key)
def push(self, request):
"""Push a request"""
self.server.lpush(self.key, self._encode_request(request))
def pop(self, timeout=0):
"""Pop a request"""
if timeout > 0:
data = self.server.brpop(self.key, timeout)
if isinstance(data, tuple):
data = data[1]
else:
data = self.server.rpop(self.key)
if data:
return self._decode_request(data)
这里我们发现FifoQueue方法重写了 len push pop方法,这三个方法都是对server对象的重写,这里的server对象其实就是一个redis的连接对象。在这里我们看到对redis的操作有:llen,lpush 和 rpop 等方法,这表明爬取的队列使用的是redis的列表。序列化的request存入列表后,成为列表的一个元素。 __len__方法是获取列表的长度,push方法是从列表左侧插入数据,pop方法是从列表右侧获取数据。
因此request 在 FifoQueue中的存取顺序是先进先出(First input first out)。
3.LifoQueue:
LifoQueue这个类与FifoQueue恰好相反,存取方式类似于栈(Last input first out)。
class LifoQueue(Base):
"""Per-spider LIFO queue."""
def __len__(self):
"""Return the length of the stack"""
return self.server.llen(self.key)
def push(self, request):
"""Push a request"""
self.server.lpush(self.key, self._encode_request(request))
4. PriorityQueue:
顾名思义就是优先级队列,代码如下:
class PriorityQueue(Base):
"""Per-spider priority queue abstraction using redis' sorted set"""
def __len__(self):
"""Return the length of the queue"""
return self.server.zcard(self.key)
def push(self, request):
"""Push a request"""
data = self._encode_request(request)
score = -request.priority
# We don't use zadd method as the order of arguments change depending on
# whether the class is Redis or StrictRedis, and the option of using
# kwargs only accepts strings, not bytes.
self.server.execute_command('ZADD', self.key, score, data)
def pop(self, timeout=0):
"""
Pop a request
timeout not support in this queue class
"""
# use atomic range/remove using multi/exec
pipe = self.server.pipeline()
pipe.multi()
pipe.zrange(self.key, 0, 0).zremrangebyrank(self.key, 0, 0)
results, count = pipe.execute()
if results:
return self._decode_request(results[0])
PriorityQueue队列是默认使用的队列,也就是爬取队列的时候默认使用有序集合来存储。
在这里我们看到__len__,push,pop方法中使用了server对象的zcard,zadd,zrange操作。zcard是来统计有序集合的大小,即爬取队列的长度。zadd操作就是向集合里添加元素,学过redis的有序集合就能知道,在这个集合中,每个元素需要设定一个分数作为优先级,分数越小,优先级越高。这里 score = -request.priority这句话是把分数指定成Request优先级的相反数,所以高优先级的Request会排在集合最前面。pop方法首先调用zrange方法取出集合中第一个元素,并zremrangebyrank将这个元素删除。完成了取出并且删除的操作。
2.去重过滤:
class RFPDupeFilter(BaseDupeFilter):
logger = logger
def __init__(self, server, key, debug=False):
self.server = server
self.key = key
self.debug = debug
self.logdupes = True
@classmethod
def from_settings(cls, settings):
server = get_redis_from_settings(settings)
key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(server, key=key, debug=debug)
@classmethod
def from_crawler(cls, crawler):
return cls.from_settings(crawler.settings)
def request_seen(self, request):
fp = self.request_fingerprint(request)
added = self.server.sadd(self.key, fp)
return added == 0
def request_fingerprint(self, request):
fingerprint_data = {
"method": to_unicode(request.method),
"url": canonicalize_url(request.url),
"body": (request.body or b"").hex(),
}
fingerprint_json = json.dumps(fingerprint_data, sort_keys=True)
return hashlib.sha1(fingerprint_json.encode()).hexdigest()
@classmethod
def from_spider(cls, spider):
settings = spider.settings
server = get_redis_from_settings(settings)
dupefilter_key = settings.get("SCHEDULER_DUPEFILTER_KEY", defaults.SCHEDULER_DUPEFILTER_KEY)
key = dupefilter_key % {'spider': spider.name}
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(server, key=key, debug=debug)
def close(self, reason=''):
self.clear()
def clear(self):
self.server.delete(self.key)
def log(self, request, spider):
if self.debug:
msg = "Filtered duplicate request: %(request)s"
self.logger.debug(msg, {'request': request}, extra={'spider': spider})
elif self.logdupes:
msg = ("Filtered duplicate request %(request)s"
" - no more duplicates will be shown"
" (see DUPEFILTER_DEBUG to show all duplicates)")
self.logger.debug(msg, {'request': request}, extra={'spider': spider})
self.logdupes = False
这个类的重点方法是request_seen和request_fingerprint方法,这两个方法和scrapy内置的去重中间件极为类似(scrapy.dupefilters.RFPDupeFilter)。
request_fingerprint:这个函数首先创建一个字典fingerprint_data
,然后,这个字典被转化为JSON字符串,并被编码为字节字符串。该字节字符串被用作输入数据计算SHA1哈希。结果是一个40个字符长度的十六进制字符串,代表了原始请求的唯一"fingerprint"(或散列)。
request_seen:这个函数检查一个请求是否在服务器上已经被"看过"。首先,它计算请求的"fingerprint"。然后,这个"fingerprint"尝试被添加到Redis服务器的一个set中(此处键为self.key
)。Redis的SADD
命令会添加元素到set,如果set添加成功,add值便会是1,表明这个指纹不存在于原先的set中,那么代码最后判断结果就是False,换句话说就是不重复,否则判断为重复。
3.调度器:
Scrapy-Redis帮助我们实现了调度器Scheduler来配合Queue和DupeFilter。我们可以在scrapy的settings文件中指定一些配置,如SCHEDULER_FLUSH_ON_START 即是否在爬取开始的时候清空爬取队列,默认是False,一般不配置,在分布式中使用重爬机制会导致数据混乱。如SCHEDULER_PERSIST 表示是否在关闭时候保留原来的调度器和去重记录,True=保留,False=清空。
class Scheduler(object):
def __init__(self, server,
persist=False,
flush_on_start=False,
queue_key=defaults.SCHEDULER_QUEUE_KEY,
queue_cls=defaults.SCHEDULER_QUEUE_CLASS,
dupefilter_key=defaults.SCHEDULER_DUPEFILTER_KEY,
dupefilter_cls=defaults.SCHEDULER_DUPEFILTER_CLASS,
idle_before_close=0,
serializer=None):
if idle_before_close < 0:
raise TypeError("idle_before_close cannot be negative")
self.server = server
self.persist = persist
self.flush_on_start = flush_on_start
self.queue_key = queue_key
self.queue_cls = queue_cls
self.dupefilter_cls = dupefilter_cls
self.dupefilter_key = dupefilter_key
self.idle_before_close = idle_before_close
self.serializer = serializer
self.stats = None
def __len__(self):
return len(self.queue)
@classmethod
def from_settings(cls, settings):
kwargs = {
'persist': settings.getbool('SCHEDULER_PERSIST'),
'flush_on_start': settings.getbool('SCHEDULER_FLUSH_ON_START'),
'idle_before_close': settings.getint('SCHEDULER_IDLE_BEFORE_CLOSE'),
}
# If these values are missing, it means we want to use the defaults.
optional = {
# TODO: Use custom prefixes for this settings to note that are
# specific to scrapy-redis.
'queue_key': 'SCHEDULER_QUEUE_KEY',
'queue_cls': 'SCHEDULER_QUEUE_CLASS',
'dupefilter_key': 'SCHEDULER_DUPEFILTER_KEY',
# We use the default setting name to keep compatibility.
'dupefilter_cls': 'DUPEFILTER_CLASS',
'serializer': 'SCHEDULER_SERIALIZER',
}
for name, setting_name in optional.items():
val = settings.get(setting_name)
if val:
kwargs[name] = val
# Support serializer as a path to a module.
if isinstance(kwargs.get('serializer'), six.string_types):
kwargs['serializer'] = importlib.import_module(kwargs['serializer'])
server = connection.from_settings(settings)
# Ensure the connection is working.
server.ping()
return cls(server=server, **kwargs)
@classmethod
def from_crawler(cls, crawler):
instance = cls.from_settings(crawler.settings)
# FIXME: for now, stats are only supported from this constructor
instance.stats = crawler.stats
return instance
def open(self, spider):
self.spider = spider
try:
self.queue = load_object(self.queue_cls)(
server=self.server,
spider=spider,
key=self.queue_key % {'spider': spider.name},
serializer=self.serializer,
)
except TypeError as e:
raise ValueError(f"Failed to instantiate queue class '{self.queue_cls}': {e}")
self.df = load_object(self.dupefilter_cls).from_spider(spider)
if self.flush_on_start:
self.flush()
# notice if there are requests already in the queue to resume the crawl
if len(self.queue):
spider.log(f"Resuming crawl ({len(self.queue)} requests scheduled)")
def close(self, reason):
if not self.persist:
self.flush()
def flush(self):
self.df.clear()
self.queue.clear()
def enqueue_request(self, request):
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
def next_request(self):
block_pop_timeout = self.idle_before_close
request = self.queue.pop(block_pop_timeout)
if request and self.stats:
self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider)
return request
def has_pending_requests(self):
return len(self) > 0
Scheduler有两个核心的方法enqueue_request和next_request。两个方法的核心操作分别是调用Queue的push和pop操作。对于pop操作,如果队列中还有Request,则Request会直接被取出来,爬取继续;如果队列为空,则爬取会重新开始。
4.总结:
1.爬取队列的实现:提供了三种队列,使用redis的列表或者有序集合来维护。
2.去重的实现:使用redis的集合来保存request指纹,以实现重复过滤。
3.中断后重新爬取的实现:中断后的redis队列并没有清空,再次启动时调度器的next_request会从队列中取到下一个request,继续爬取。
Redis Queue(队列):在Scrapy-Redis中,请求是储存在Redis的队列中的。每当Spider产生新的请求时,它们被添加到队列里。每当需要新的请求去下载时,就会从队列里取出。在分布式环境中,多个Spider实例可以共享这个队列。
Redis Dupefilter(去重过滤器):在每次将请求加入队列之前,Scrapy-Redis会先通过Dupefilter来检查这个请求是否存在重复。这是为了避免重复执行相同的请求,节约资源。在Scrapy-Redis中,请求的去重信息储存在Redis中,这使得多个Spider实例可以共享这个去重信息。
Redis Scheduler(调度器):Scheduler是Scrapy-Redis中的核心组件。它从队列中获取请求并交给Downloader去下载,然后把新产生的请求加入队列,同时还会使用Dupefilter来过滤掉重复的请求。Scheduler是Scrapy-Redis实现分布式爬虫的关键,因为它协调了Spider、Downloader、Queue和Dupefilter之间的工作。