浅谈scrapy去重机制

前言

最近出现了两个问题

  1. url的参数或者post的数据中有随机值和签名,比如
    https://www.baidu.com?id=1&nonce=xxxxxxxx&sign=1232344
    https://www.baidu.com?id=1&nonce=sssssss&sign=2323124

这两个链接其实是同一个,nonce只是个随机值,而sign也只是对id和nonce做了签名,但是这两个链接都会被访问一次

想法1:重写过滤器,将nonce和sign从请求参数中去掉再进行去重
实际:不太可行,因为框架不是针对某个爬虫来设计的,可能其他的网站不是sign而是signature或者其他呢。要重写只能重写的彻底点,连调度器一起重写,可以根据spider中某个属性来决定是否去掉这个字段在进行去重。比如增加一个spider.filter_fields,然后传"id",或者增加一个spider.dont_filter_fields,传(“nonce”, “sign”),这样每个爬虫都可以自定义自己的去重字段和不去重字段

想法2:nonce和sign不在spider中生成,传给调度器的链接只有https://www.baidu.com?id=1,然后在中间件中生成nonce和sign
实际:可行,但会引入另一个问题(见下一个问题的想法2)

  1. 因为在中间件中对request做了一层加密,比如加了一个请求头sign:xxxxxx。大概代码如下,
def process_request(self, request, spider):
    encrypted = spider.encrypted
    is_encrypted = request.meta.get('is_encrypted', 0)
    if not encrypted or is_encrypted:
        return
    headers = request.headers
    headers["sign"] = "xxxxx"
    meta = request.meta
    meta["is_encrypted"] = 1
    # 如果return的是request对象,那么该request会作为任务重新进入调度器等待分配
    return request.replace(headers=headers, meta=meta)

但是scrapy默认去重的字段不包含headers,所以你return的request没进入调度器就直接被过滤器干掉了。

想法1:既然默认不去重headers,那我重写过滤器,让他去重headers。
实际:能解决这个问题,但是又引入了一个新的问题,可能会采集较多的重复链接,所以不太可行

想法2:我让开始的那个request不被过滤掉,那么新return的request不就不会被干掉了(增加dont_filter=True)
实际:确实可行,目前也用的这个方法。但是这也存在一个问题。假设我中间件改的不是headers,而是url,那么就会出现这样一个情况,因为是需要更新采集,链接会一直被调度器加载,进入中间件之后才被过滤掉。虽然这个链接依然不会被采集,但如果中间件中做了一些比较耗时的操作(比如加密),那么会浪费很多时间。思考的解决方案:在中间件中主动调用过滤器,去重掉这个链接,这就需要研究一下如何在中间件中主动调用过滤器去重request了

解决问题

看源码

scrapy默认的调度器是scrapy.core.scheduler.Scheduler,其中主要的去重代码都在enqueue_request这个方法里,代码如下:

def enqueue_request(self, request):
    if not request.dont_filter and self.df.request_seen(request):
        self.df.log(request, self.spider)
        return False
    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

我们知道request传入dont_filter=True时会不去重,这个逻辑就是在这里判断的。

而其中self.df.request_seen(request)则是实际去重的代码,df应该是过滤器的实例,即scrapy.dupefilters.RFPDupeFilter,看一下
request_seen的代码:

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)

request_fingerprint会对request进行hash生成一个指纹,而self.fingerprints则是已经采集的所有链接的指纹集合,默认的过滤器是直接用Python的集合去重的。self.fingerprints在过滤器的_init__方法中self.fingerprints = set()初始化成集合。

那么我们只需要在中间件中主动调用这个方法就可以过滤掉这个request了,但是如何拿到RFPDupeFilter的实例对象呢,因为你新创建一个对象self.fingerprints也不是原先的那个,只能拿到scrapy生成的那个对象才能去重成功。

从调度器中的代码可以看出,这个实例对象是在调度器中被创建的,也就是上面代码中的self.df,能不能在中间件中拿到这个对象呢?好像不能,我没找到。

从scrapy的结构图中可以看出中间件和调度器不会直接交互,中间件只会和引擎进行交互。而调度器也只是和引擎交互。
浅谈scrapy去重机制_第1张图片

看实际

在实际项目中其实用的不是scrapy默认的调度器和去重器,一般我们都会重写它。比如scrapy_redis或者scrapy_redis_bloomfilter

SCHEDULER = "scrapy_redis_bloomfilter.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter"

那么scrapy_redis能不能拿到这个实例呢?也不能,但是可以创建一个。因为是同样的redis对象,所以效果是一样的。

看一下scrapy_redis_bloomfilter的调度器中去重的逻辑

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

和默认的差不多,但是这个self.dfscrapy_redis_bloomfilter.dupefilter.RFPDupeFilter的实例,看一下初始化的代码

try:
    self.df = load_object(self.dupefilter_cls)(
        server=self.server,
        key=self.dupefilter_key % {'spider': spider.name},
        debug=spider.settings.getbool('DUPEFILTER_DEBUG'),
        bit=spider.settings.getint('BLOOMFILTER_BIT', BLOOMFILTER_BIT),
        hash_number=spider.settings.getint('BLOOMFILTER_HASH_NUMBER', BLOOMFILTER_HASH_NUMBER)
    )
except TypeError as e:
    raise ValueError("Failed to instantiate dupefilter class '%s': %s",
                     self.dupefilter_cls, e)

server是redis的实例对象,key则是redis的key,debug对我们来说没什么用,bit和hash_number都是固定的值。

server初始化的代码(其中setting是scrapy配置文件的字典):

from . import connection, defaults
server = connection.from_settings(settings)

self.dupefilter_key其实是取的默认值, 也就是scrapy_redis_bloomfilter.defaults.SCHEDULER_DUPEFILTER_KEY的值

SCHEDULER_DUPEFILTER_KEY = '%(spider)s:dupefilter'

我现在才知道原来%也可以填关键字

我们自己构造一个self.df

from scrapy_redis_bloomfilter.dupefilter import RFPDupeFilter
from scrapy_redis_bloomfilter import connection
from scrapy.utils.project import get_project_settings

settings = get_project_settings()
server = connection.from_settings(settings)
name = "xxxx" # 在中间件中可以通过spider.name获取
df = RFPDupeFilter(
	server=server,
	key=f'{name}:dupefilter',
	debug=False,
	bit=settings.getint('BLOOMFILTER_BIT'),
    hash_number=settings.getint('BLOOMFILTER_HASH_NUMBER')
)

只要调用df.request_seen(request)就可以对request进行去重了。另外,spider.name需要在中间件的process_request方法获取,这样就只能在方法内初始化了,如果调用一次process_request就初始化一次,就很不合理。有两种解决方法:中间件内整个字典,键为spider.name,值为df对象,判断一下存不存在就行,不用重复初始化;不在中间件中初始化,在spider中初始化,然后中间件中通过spider.df调用。

到这里其实就基本解决了开始的两个问题。可以再看看connection.from_settings的实现,浓缩一下就是下面两行:

import redis
server = redis.StrictRedis.from_url(redis_url)

这个redis_url其实就是settings里配置的REDIS_URL

补充

突然发现我看的还是scrapy_redis_bloomfilter 0.7的代码,然后运行发现没效果。才知道原来在0.8.1版本中做了一些改版。
浅谈scrapy去重机制_第2张图片
左边是新版,右边是旧版。去重没有生效的主要原因是default.py内容中SCHEDULER_DUPEFILTER_KEY = '%(spider)s:bloomfilter'变了

所以代码要改成

import redis
from scrapy_redis_bloomfilter.dupefilter import RFPDupeFilter
from scrapy.utils.project import get_project_settings
from scrapy_redis_bloomfilter.defaults import SCHEDULER_DUPEFILTER_KEY

settings = get_project_settings()
redis_url = settings.get("REDIS_URL")
server = redis.StrictRedis.from_url(redis_url)
df = RFPDupeFilter(
    server=server,
    key=SCHEDULER_DUPEFILTER_KEY % {"spider": name},
    debug=False,
    bit=settings.getint('BLOOMFILTER_BIT'),
    hash_number=settings.getint('BLOOMFILTER_HASH_NUMBER')
)

能import的尽量import吧,不然版本之间有差异的话还不好处理。

新版中server是这样初始化的

from scrapy_redis.connection import get_redis_from_settings
server = get_redis_from_settings(settings)

代码和之前的connection.from_settings是一个东西,原先作者拷贝了一份connection,现在直接使用了scrapy_redis了

你可能感兴趣的:(scrapy,scrapy)