From:https://blog.csdn.net/weixin_37947156/article/details/75044971
From:https://cuiqingcai.com/6058.html
Scrapy-redis github:https://github.com/rmax/scrapy-redis
scrapy-redis分布式爬虫框架详解:https://segmentfault.com/a/1190000014333162?utm_source=channel-hottest
集群版 Scrapy-Redis:https://github.com/thsheep/scrapy_redis_cluster
scrapy-redis 和 scrapy 有什么区别?:https://www.zhihu.com/question/32302268
scrapy-redis使用以及剖析:https://www.cnblogs.com/wangyongsong/p/7485852.html
scrapy-redis 解析:https://www.cnblogs.com/zy0517/articles/9109681.html
基于 Scrapy-redis 的分布式爬虫设计:https://www.jianshu.com/p/cd4054bbc757/
小白进阶之Scrapy第三篇(基于Scrapy-Redis的分布式以及cookies池):https://cuiqingcai.com/4048.html
Scrapy+redis实现分布式爬虫简易教程:https://www.jianshu.com/p/ed5afa658ccb?from=jiantop.com
scrapy 是 python 的一个非常好用的爬虫库,功能非常强大,如果是小站的话,我们使用 scrapy 本身就可以满足。但是当我们要爬取的页面非常多的时候,面对一些比较大型的站点的时候,单个 scrapy 就显得力不从心了。单个主机的处理能力就不能满足我们的需求了(无论是处理速度还是网络请求的并发数)。
这时候分布式爬虫的优势就显现出来,人多力量大。很遗憾 Scrapy 官方并不支持多个同时采集一个站点,虽然官方给出一个方法:**将一个站点的分割成几部分 交给不同的scrapy去采集**。似乎是个解决办法,但是很麻烦诶!毕竟分割很麻烦的哇
下面就该 Scrapy-Redis 登场了。scrapy-redis 就是结合了分布式数据库 redis,重写了 scrapy 一些比较关键的代码,将 scrapy 变成一个可以在多个主机上同时运行的分布式爬虫。
scrapy-redis 是 github 上的一个开源项目,可以直接下载到他的源代码: https://github.com/rmax/scrapy-redis
scrapy-redis 的官方文档写的比较简洁,没有提及其运行原理,所以如果想全面的理解分布式爬虫的运行原理,还是得看 scrapy的源代码才行(还得先理解 scrapy 的运行原理,不然看 scrapy-redis 还是比较费劲)。
来看一看 Scrapy 的架构图
这张图大家相信大家都很熟悉了。重点看一下SCHEDULER
1. 先来看看官方对于SCHEDULER的定义:
**SCHEDULER接受来自Engine的Requests,并将它们放入队列(可以按顺序优先级),以便在之后将其提供给Engine**
官方文档:https://doc.scrapy.org/en/latest/topics/architecture.html#component-scheduler
2. 现在我们来看看SCHEDULER都提供了些什么功能:
根据官方文档说明 在我们没有没有指定 SCHEDULER 参数时,默认使用:'scrapy.core.scheduler.Scheduler' 作为SCHEDULER(调度器)
scrapy.core.scheduler.py:
class Scheduler(object):
def __init__(self, dupefilter, jobdir=None, dqclass=None, mqclass=None,
logunser=False, stats=None, pqclass=None):
self.df = dupefilter
self.dqdir = self._dqdir(jobdir)
self.pqclass = pqclass
self.dqclass = dqclass
self.mqclass = mqclass
self.logunser = logunser
self.stats = stats
@classmethod
def from_crawler(cls, crawler):
'''
注意在 scrapy 中优先注意这个方法,此方法是一个钩子 用于访问当前爬虫的配置
'''
settings = crawler.settings
# 获取去重用的类 默认:scrapy.dupefilters.RFPDupeFilter
dupefilter_cls = load_object(settings['DUPEFILTER_CLASS'])
# 对去重类进行配置from_settings 在 scrapy.dupefilters.RFPDupeFilter 43行
# 这种调用方式对于IDE跳转不是很好 所以需要自己去找
# @classmethod
# def from_settings(cls, settings):
# debug = settings.getbool('DUPEFILTER_DEBUG')
# return cls(job_dir(settings), debug)
# 上面就是from_settings方法 其实就是设置工作目录 和是否开启debug
dupefilter = dupefilter_cls.from_settings(settings)
# 获取优先级队列 类对象 默认:queuelib.pqueue.PriorityQueue
pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE'])
# 获取磁盘队列 类对象(SCHEDULER使用磁盘存储 重启不会丢失)
dqclass = load_object(settings['SCHEDULER_DISK_QUEUE'])
# 获取内存队列 类对象(SCHEDULER使用内存存储 重启会丢失)
mqclass = load_object(settings['SCHEDULER_MEMORY_QUEUE'])
# 是否开启debug
logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', settings.getbool('SCHEDULER_DEBUG'))
# 将这些参数传递给 __init__方法
return cls(dupefilter, jobdir=job_dir(settings), logunser=logunser,
stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, mqclass=mqclass)
def has_pending_requests(self):
"""检查是否有没处理的请求"""
return len(self) > 0
def open(self, spider):
"""Engine创建完毕之后会调用这个方法"""
self.spider = spider
# 创建一个有优先级的内存队列 实例化对象
# self.pqclass 默认是:queuelib.pqueue.PriorityQueue
# self._newmq 会返回一个内存队列的 实例化对象 在110 111 行
self.mqs = self.pqclass(self._newmq)
# 如果self.dqdir 有设置 就创建一个磁盘队列 否则self.dqs 为空
self.dqs = self._dq() if self.dqdir else None
# 获得一个去重实例对象 open 方法是从BaseDupeFilter继承的
# 现在我们可以用self.df来去重啦
return self.df.open()
def close(self, reason):
"""当然Engine关闭时"""
# 如果有磁盘队列 则对其进行dump后保存到active.json文件中
if self.dqs:
prios = self.dqs.close()
with open(join(self.dqdir, 'active.json'), 'w') as f:
json.dump(prios, f)
# 然后关闭去重
return self.df.close(reason)
def enqueue_request(self, request):
"""添加一个Requests进调度队列"""
# self.df.request_seen是检查这个Request是否已经请求过了 如果有会返回True
if not request.dont_filter and self.df.request_seen(request):
# 如果Request的dont_filter属性没有设置(默认为False)和 已经存在则去重
# 不push进队列
self.df.log(request, self.spider)
return False
# 先尝试将Request push进磁盘队列
dqok = self._dqpush(request)
if dqok:
# 如果成功 则在记录一次状态
self.stats.inc_value('scheduler/enqueued/disk', spider=self.spider)
else:
# 不能添加进磁盘队列则会添加进内存队列
self._mqpush(request)
self.stats.inc_value('scheduler/enqueued/memory', spider=self.spider)
self.stats.inc_value('scheduler/enqueued', spider=self.spider)
return True
def next_request(self):
"""从队列中获取一个Request"""
# 优先从内存队列中获取
request = self.mqs.pop()
if request:
self.stats.inc_value('scheduler/dequeued/memory', spider=self.spider)
else:
# 不能获取的时候从磁盘队列队里获取
request = self._dqpop()
if request:
self.stats.inc_value('scheduler/dequeued/disk', spider=self.spider)
if request:
self.stats.inc_value('scheduler/dequeued', spider=self.spider)
# 将获取的到Request返回给Engine
return request
def __len__(self):
return len(self.dqs) + len(self.mqs) if self.dqs else len(self.mqs)
def _dqpush(self, request):
if self.dqs is None:
return
try:
reqd = request_to_dict(request, self.spider)
self.dqs.push(reqd, -request.priority)
except ValueError as e: # non serializable request
if self.logunser:
msg = ("Unable to serialize request: %(request)s - reason:"
" %(reason)s - no more unserializable requests will be"
" logged (stats being collected)")
logger.warning(msg, {'request': request, 'reason': e},
exc_info=True, extra={'spider': self.spider})
self.logunser = False
self.stats.inc_value('scheduler/unserializable',
spider=self.spider)
return
else:
return True
def _mqpush(self, request):
self.mqs.push(request, -request.priority)
def _dqpop(self):
if self.dqs:
d = self.dqs.pop()
if d:
return request_from_dict(d, self.spider)
def _newmq(self, priority):
return self.mqclass()
def _newdq(self, priority):
return self.dqclass(join(self.dqdir, 'p%s' % priority))
def _dq(self):
activef = join(self.dqdir, 'active.json')
if exists(activef):
with open(activef) as f:
prios = json.load(f)
else:
prios = ()
q = self.pqclass(self._newdq, startprios=prios)
if q:
logger.info("Resuming crawl (%(queuesize)d requests scheduled)",
{'queuesize': len(q)}, extra={'spider': self.spider})
return q
def _dqdir(self, jobdir):
if jobdir:
dqdir = join(jobdir, 'requests.queue')
if not exists(dqdir):
os.makedirs(dqdir)
return dqdir
从上面的代码可以很清楚的知道 SCHEDULER 主要是完成了 push Request、pop Request 和 去重 的操作。而且 queue 操作是在内存队列中完成的。大家看 queuelib.queue 就会发现是基于内存的(deque)。
那么去重呢?
class RFPDupeFilter(BaseDupeFilter):
"""Request Fingerprint duplicates filter"""
def __init__(self, path=None, debug=False):
self.file = None
self.fingerprints = set()
self.logdupes = True
self.debug = debug
self.logger = logging.getLogger(__name__)
if path:
# 此处可以看到去重其实打开了一个名叫 requests.seen的文件
# 如果是使用的磁盘的话
self.file = open(os.path.join(path, 'requests.seen'), 'a+')
self.file.seek(0)
self.fingerprints.update(x.rstrip() for x in self.file)
@classmethod
def from_settings(cls, settings):
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(job_dir(settings), debug)
def request_seen(self, request):
fp = self.request_fingerprint(request)
if fp in self.fingerprints:
# 判断我们的请求是否在这个在集合中
return True
# 没有在集合就添加进去
self.fingerprints.add(fp)
# 如果用的磁盘队列就写进去记录一下
if self.file:
self.file.write(fp + os.linesep)
按照正常流程就是大家都会进行重复的采集;我们都知道进程之间内存中的数据不可共享的,那么你在开启多个Scrapy的时候,它们相互之间并不知道对方采集了些什么那些没有没采集。那就大家伙儿自己玩自己的了。完全没没有效率的提升啊!
怎么解决呢?
这就是我们 Scrapy-Redis 解决的问题了,不能协作不就是因为 Request 和 去重 这两个不能共享吗?
那我把这两个独立出来好了。
将 Scrapy 中的 SCHEDULER 组件独立放到大家都能访问的地方不就OK啦!加上 scrapy-redis 后流程图就应该变成这样了?
scrapy-redis 在 scrapy 的架构上增加了 redis,基于 redis 的特性拓展了如下四种组件:Scheduler,Duplication Filter,Item Pipeline,Base Spider
scrapy-redis 源码分析
scrapy-redis 的源代码很少,也比较好懂,很快就能看完。
下面开始 scrapy-redis 源码分析:
scrapy-redis 工程的主体还是 redis 和 scrapy 两个库,工程本身实现的东西不是很多,这个工程就像胶水一样,把这两个插件粘结了起来。下面我们来看看,scrapy-redis的每一个源代码文件都实现了什么功能,最后如何实现分布式的爬虫系统:
defaults.py
redis 的一些基础的默认的设置。其实就是一些默认配置:
import redis
# For standalone use.
DUPEFILTER_KEY = 'dupefilter:%(timestamp)s'
PIPELINE_KEY = '%(spider)s:items'
REDIS_CLS = redis.StrictRedis
REDIS_ENCODING = 'utf-8'
# Sane connection defaults.
REDIS_PARAMS = {
'socket_timeout': 30,
'socket_connect_timeout': 30,
'retry_on_timeout': True,
'encoding': REDIS_ENCODING,
}
SCHEDULER_QUEUE_KEY = '%(spider)s:requests'
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.PriorityQueue'
SCHEDULER_DUPEFILTER_KEY = '%(spider)s:dupefilter'
SCHEDULER_DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'
START_URLS_KEY = '%(name)s:start_urls'
START_URLS_AS_SET = False
connect.py
connect 文件引入了redis 模块,这个是 redis-python库的接口,用于通过python访问redis数据库,可见,这个文件主要是实现连接redis数据库的功能(返回的是redis库的Redis对象或者StrictRedis对象,这俩都是可以直接用来进行数据操作的对象)。这些连接接口在其他文件中经常被用到。其中,我们可以看到,要想连接到redis数据库,和其他数据库差不多,需要一个ip地址、端口号、用户名密码(可选)和一个整形的数据库编号,同时我们还可以在scrapy工程的setting文件中配置套接字的超时时间、等待时间等。
其实这个模块的功能:
- 1. 从 settings 里面获取 redis 的链接配置
- 2. 获取 redis 的 链接 实例
import six
from scrapy.utils.misc import load_object
from . import defaults
# Shortcut maps 'setting name' -> 'parmater name'.
SETTINGS_PARAMS_MAP = {
'REDIS_URL': 'url',
'REDIS_HOST': 'host',
'REDIS_PORT': 'port',
'REDIS_ENCODING': 'encoding',
}
def get_redis_from_settings(settings):
"""Returns a redis client instance from given Scrapy settings object.
This function uses ``get_client`` to instantiate the client and uses
``defaults.REDIS_PARAMS`` global as defaults values for the parameters. You
can override them using the ``REDIS_PARAMS`` setting.
Parameters
----------
settings : Settings
A scrapy settings object. See the supported settings below.
Returns
-------
server
Redis client instance.
Other Parameters
----------------
REDIS_URL : str, optional
Server connection URL.
REDIS_HOST : str, optional
Server host.
REDIS_PORT : str, optional
Server port.
REDIS_ENCODING : str, optional
Data encoding.
REDIS_PARAMS : dict, optional
Additional client parameters.
"""
params = defaults.REDIS_PARAMS.copy()
params.update(settings.getdict('REDIS_PARAMS'))
# XXX: Deprecate REDIS_* settings.
for source, dest in SETTINGS_PARAMS_MAP.items():
val = settings.get(source)
if val:
params[dest] = val
# Allow ``redis_cls`` to be a path to a class.
if isinstance(params.get('redis_cls'), six.string_types):
params['redis_cls'] = load_object(params['redis_cls'])
return get_redis(**params)
# Backwards compatible alias.
from_settings = get_redis_from_settings
def get_redis(**kwargs):
"""Returns a redis client instance.
Parameters
----------
redis_cls : class, optional
Defaults to ``redis.StrictRedis``.
url : str, optional
If given, ``redis_cls.from_url`` is used to instantiate the class.
**kwargs
Extra parameters to be passed to the ``redis_cls`` class.
Returns
-------
server
Redis client instance.
"""
redis_cls = kwargs.pop('redis_cls', defaults.REDIS_CLS)
url = kwargs.pop('url', None)
if url:
return redis_cls.from_url(url, **kwargs)
else:
return redis_cls(**kwargs)
dupefilters.py
这个主要是用来去重的。RFPDupeFilter继承自 Scrapy 的BaseDupeFilter,实现了 request 去重功能,基于 Scrapy 的 request_fingerprint 生成指纹,并在 Redis 上存储。当收到新的 request,首先生成指纹判断是否存在于已爬取的指纹库内(Redis set),若存在则返回 False,不存在返回 True.总得来说是这样的,这个文件首先获取到redis的server,然后从scrapy的request中获取request的指纹,将这个指纹进行存到redis的去重库中。达到去重的目的。
这个文件看起来比较复杂,重写了scrapy本身已经实现的 request 判重功能。因为本身 scrapy 单机跑的话,只需要读取内存中的request 队列 或者 持久化的 request 队列(scrapy默认的持久化似乎是json格式的文件,不是数据库)就能判断这次要发出的request url是否已经请求过或者正在调度(本地读就行了)。而 分布式跑的话,就需要各个主机上的scheduler都连接同一个数据库的同一个 request池 来判断这次的请求是否是重复的了。
在这个文件中,通过继承 BaseDupeFilter 重写他的方法,实现了基于redis的判重。根据源代码来看,scrapy-redis 使用了scrapy本身的一个 fingerprint 接口 request_fingerprint,这个接口很有趣,根据scrapy文档所说,他通过hash来判断两个url是否相同(相同的url会生成相同的hash结果),但是当两个url的地址相同,get型参数相同但是顺序不同时,也会生成相同的hash结果(这个真的比较神奇。。。)所以 scrapy-redis 依旧使用 url 的 fingerprint 来判断 request 请求是否已经出现过。这个类通过连接 redis,使用一个key来向redis的一个set中插入fingerprint(这个key对于同一种spider是相同的,redis 是一个key-value的数据库,如果key是相同的,访问到的值就是相同的,这里使用 spider名字+DupeFilter 的 key 就是为了在不同主机上的不同爬虫实例,只要属于同一种 spider,就会访问到同一个set,而这个 set 就是他们的url判重池 ),如果返回值为0,说明该set中该fingerprint 已经存在(因为集合是没有重复值的),则返回 False,如果返回值为 1,说明添加了一个fingerprint到set中,则说明这个 request 没有重复,于是返回True,还顺便把新fingerprint加入到数据库中了。
DupeFilter 判重会在 scheduler 类中用到,每一个 request 在进入调度之前都要进行判重,如果重复就不需要参加调度,直接舍弃就好了,不然就是白白浪费资源。
import logging
import time
from scrapy.dupefilters import BaseDupeFilter
from scrapy.utils.request import request_fingerprint
from . import defaults
from .connection import get_redis_from_settings
logger = logging.getLogger(__name__)
# TODO: Rename class to RedisDupeFilter.
class RFPDupeFilter(BaseDupeFilter):
"""Redis-based request duplicates filter.
This class can also be used with default Scrapy's scheduler.
"""
logger = logger
def __init__(self, server, key, debug=False):
"""Initialize the duplicates filter.
Parameters
----------
server : redis.StrictRedis
The redis server instance.
key : str
Redis key Where to store fingerprints