前言
最近出现了两个问题
- 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)
- 因为在中间件中对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默认的调度器和去重器,一般我们都会重写它。比如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.df
是scrapy_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版本中做了一些改版。
左边是新版,右边是旧版。去重没有生效的主要原因是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了