一、分布式爬虫原理
- Scrapy框架虽然爬虫是异步多线程的,但是我们只能在一台主机上运行,爬取效率还是有限。
- 分布式爬虫则是将多台主机组合起来,共同完成一个爬取任务,将大大提高爬取的效率。
分布式爬虫架构
1 ) Scrapy单机架构回顾
- Scrapy单机爬虫中有一个本地爬取队列Queue,这个队列是利用deque模块实现的。
- 如果有新的Request产生,就会放到队列里面,随后Request被Scheduler调度。
- 之后Request交给Downloader执行爬取,这就是简单的调度架构。
2 ) 分布式爬虫架构
2.1 在多台主机上同时运行爬虫任务, 架构图如下:
2.2 维护爬取队列
- 爬取队列是基于内存存储的Redis, 它支持多种数据结构,如:列表、集合、有序集合等, 存取的操作也非常简单。
- Redis支持的这几种数据结构,在存储中都有各自优点:
- 列表(list)有lpush()、lpop()、rpush()、rpop()方法,可以实现先进先出的队列和先进后出的栈式爬虫队列。
- 集合(set)的元素是无序且不重复的,这样我们可以非常方便的实现随机且不重复的爬取队列。
- 有序集合有分数表示,而Scrapy的Request也有优先级的控制,我们可以用它来实现带优先级调度的队列。
2.3 数据去重
- Scrapy有自动去重,它的去重使用了Python中的集合实现。用它记录了Scrapy中每个Request的指纹(Request的散列值)。
- 对于分布式爬虫来说,我们肯定不能再用每个爬虫各自的集合来去重了,因为不能共享,各主机之间就无法做到去重了。
- 可以使用Redis的集合来存储指纹集合,那么这样去重集合也是利用Redis共享的。
- 每台主机只要将新生成Request的指纹与集合比对,判断是否重复并选择添加入到其中。即实例了分布式Request的去重。
2.4 防止中断
- 在Scrapy中,爬虫运行时的Request队列放在内存中。爬虫运行中断后,这个队列的空间就会被释放,导致爬取不能继续。
- 要做到中断后继续爬取,我们可以将队列中的Request保存起来,下次爬取直接读取保存的数据既可继续上一次爬取的队列。
- 在Scrapy中制定一个爬取队列的存储路径即可,这个路径使用
JOB_DIR
变量来标识,命令如下:
- $
scrapy crawl spider -s JOB_DIR=crawls/spider
- 官方文档:http://doc.scrapy.org/en/latest/topics/jobs.html
- 在Scrapy中,把爬取队列保存到本地,第二次爬取直接读取并恢复队列既可。
- 在分布式框架中就不用担心这个问题了,因为爬取队列本身就是用数据库存储的,中断后再启动就会接着上次中断的地方继续爬取。
- 当Redis的队列为空时,爬虫会重新爬取;当队列不为空时,爬虫便会接着上次中断处继续爬取。
2.5 架构实现
- 首先实现一个共享的爬取队列, 还要实现去重的功能。
- 重写一个Scheduer的实现, 使之可以从共享的爬取队列存取Request。
- Scrapy-Redis 分布式爬虫的开源包, 直接使用就可以很方便实现分布式爬虫。
二、Scrapy分布式爬虫
关于Scrapy-Redis
- Scrapy-Redis则是一个基于Redis的Scrapy分布式组件。
- 它利用Redis对用于爬取的请求(Requests)进行存储和调度(Schedule),并对爬取产生的项目(items)存储以供后续处理使用。
- Scrapy-redis重写了Scrapy一些比较关键的代码,将Scrapy变成一个可以在多个主机上同时运行的分布式爬虫。
- Scrapy-Redis的官方网址:https://github.com/rmax/scrapy-redis
- 架构图
准备工作
- scrapy
- scrapy-redis
- redis
- mysql
- python的mysqldb模块
- python的redis模块
模块安装
- $
pip3 install scrapy-redis
- $
pip3 install redis
关于Scrapy-redis的各个组件
1 ) connection.py
- 负责根据setting中配置实例化redis连接。
- 被dupefilter和scheduler调用。
- 总之涉及到redis存取的都要使用到这个模块。
2 ) dupefilter.py
- 负责执行requst的去重,实现的很有技巧性,使用redis的set数据结构。
- 但是注意scheduler并不使用其中用于在这个模块中实现的dupefilter键做request的调度,而是使用queue.py模块中实现的queue。
- 当request不重复时,将其存入到queue中,调度时将其弹出。
3 ) queue.py
- FIFO的SpiderQueue,SpiderPriorityQueue,以及LIFI的SpiderStack。
- 默认使用的是第二中,这也就是出现之前文章中所分析情况的原因(链接)。
4 ) pipelines.py
- 用来实现分布式处理,它将Item存储在redis中以实现分布式处理。
- 在这里需要读取配置,所以就用到了from_crawler()函数。
5 ) scheduler.py
- 此扩展是对scrapy中自带的scheduler的替代(在settings的SCHEDULER变量中指出),正是利用此扩展实现crawler的分布式调度。其利用的数据结构来自于queue中实现的数据结构。
- scrapy-redis所实现的两种分布式:爬虫分布式以及item处理分布式就是由模块scheduler和模块pipelines实现。上述其它模块作为为二者辅助的功能模块。
6 ) spider.py
- 设计的这个spider从redis中读取要爬的url, 然后执行爬取, 若爬取过程中返回更多的url, 那么继续进行直至所有的request完成。
- 之后继续从redis中读取url, 循环这个过程。
具体的配置和使用(对Scrapy改造)
1 ) 首先在settings.py中配置redis
在scrapy-redis 自带的例子中已经配置好
# 指定使用scrapy-redis的去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilters.RFPDupeFilter'
# 指定使用scrapy-redis的调度器
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 在redis中保持scrapy-redis用到的各个队列,从而允许暂停和暂停后恢复,也就是不清理redis queues
SCHEDULER_PERSIST = True
# 指定排序爬取地址时使用的队列,
# 默认的 按优先级排序(Scrapy默认),由sorted set实现的一种非FIFO、LIFO方式。
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderPriorityQueue'
REDIS_URL = None # 一般情况可以省去
REDIS_HOST = '127.0.0.1' # 也可以根据情况改成 localhost
REDIS_PORT = 6379
2 ) item.py的改造
from scrapy.item import Item, Field
from scrapy.loader import ItemLoader
from scrapy.loader.processors import MapCompose, TakeFirst, Join
class ExampleItem(Item):
name = Field()
description = Field()
link = Field()
crawled = Field()
spider = Field()
url = Field()
class ExampleLoader(ItemLoader):
default_item_class = ExampleItem
default_input_processor = MapCompose(lambda s: s.strip())
default_output_processor = TakeFirst()
description_out = Join()
3 ) spider的改造
- star_urls变成了redis_key从redis中获得request,继承的scrapy.spider变成RedisSpider。
from scrapy_redis.spiders import RedisSpider
class MySpider(RedisSpider):
"""Spider that reads urls from redis queue (myspider:start_urls)."""
name = 'myspider_redis'
redis_key = 'myspider:start_urls'
def __init__(self, *args, **kwargs):
# Dynamically define the allowed domains list.
domain = kwargs.pop('domain', '')
self.allowed_domains = filter(None, domain.split(','))
super(MySpider, self).__init__(*args, **kwargs)
def parse(self, response):
return {
'name': response.css('title::text').extract_first(),
'url': response.url,
}
- 启动爬虫 $
scrapy runspider my.py
- 可以输入多个来观察多进程的效果。
- 打开了爬虫之后你会发现爬虫处于等待爬取的状态,是因为list此时为空。
- 所以需要在redis控制台中添加启动地址,这样就可以愉快的看到所有的爬虫都动起来啦。
- $
lpush mycrawler:start_urls http://www.***.com
- 关于 settings.py 的配置
# 指定使用scrapy-redis的调度器
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 指定使用scrapy-redis的去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilters.RFPDupeFilter'
# 指定排序爬取地址时使用的队列,
# 默认的 按优先级排序(Scrapy默认),由sorted set实现的一种非FIFO、LIFO方式。
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderPriorityQueue'
# 可选的 按先进先出排序(FIFO)
# SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderQueue'
# 可选的 按后进先出排序(LIFO)
# SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderStack'
# 在redis中保持scrapy-redis用到的各个队列,从而允许暂停和暂停后恢复,也就是不清理redis queues
SCHEDULER_PERSIST = True
# 只在使用SpiderQueue或者SpiderStack是有效的参数,指定爬虫关闭的最大间隔时间
# SCHEDULER_IDLE_BEFORE_CLOSE = 10
# 通过配置RedisPipeline将item写入key为 spider.name : items 的redis的list中,供后面的分布式处理item
# 这个已经由 scrapy-redis 实现,不需要我们写代码
ITEM_PIPELINES = {
'scrapy_redis.pipelines.RedisPipeline': 400
}
# 指定redis数据库的连接参数
# REDIS_PASS是我自己加上的redis连接密码(默认不做)
REDIS_HOST = '127.0.0.1'
REDIS_PORT = 6379
#REDIS_PASS = 'redisP@ssw0rd'
# LOG等级
LOG_LEVEL = 'DEBUG'
#默认情况下,RFPDupeFilter只记录第一个重复请求。将DUPEFILTER_DEBUG设置为True会记录所有重复的请求。
DUPEFILTER_DEBUG =True
# 覆盖默认请求头,可以自己编写Downloader Middlewares设置代理和UserAgent
DEFAULT_REQUEST_HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8',
'Connection': 'keep-alive',
'Accept-Encoding': 'gzip, deflate, sdch'
}
三、Scrapy分布式应用案例
实现目标
- 实现主从分布式爬虫,爬取5i5j的楼盘信息
- URL地址:https://fang.5i5j.com/bj/loupan/
- 准备工作:开启redis数据库服务
应用案例代码仓库和架构
1 )仓库
- 点此查看项目地址
- 注意:此项目没有做反爬处理,可以使用第三方ip代理,具体请看前面博文,没有从radis中存储到mongodb中,具体实现方式参考最后的代码
2 )架构
3 ) 编写master(主)项目代码
- 编辑爬虫文件:fang.py
# -*- coding: utf-8 -*-
# python3.7.1 + scrapy1.5.1
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor
from fang_5i5j.items import MasterItem
class FangSpider(CrawlSpider):
name = 'master'
allowed_domains = ['fang.5i5j.com']
start_urls = ['https://fang.5i5j.com/bj/loupan/']
item = MasterItem()
# Rule是在定义抽取链接的规则
rules = (
Rule(LinkExtractor(allow=('https://fang.5i5j.com/bj/loupan/n[0-9]+/',)), callback='parse_item',
follow=True),
)
def parse_item(self, response):
item = self.item
item['url'] = response.url
return item
- 编辑items.py 储存url地址
# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# https://docs.scrapy.org/en/latest/topics/items.html
import scrapy
class MasterItem(scrapy.Item):
# define the fields for your item here like:
url = scrapy.Field()
# pass
- 编辑pipelines.py负责存储爬取的url地址到redis中
import redis,re
class Fang5I5JPipeline(object):
def process_item(self, item, spider):
return item
class MasterPipeline(object):
def __init__(self,host,port):
#连接redis数据库
self.r = redis.Redis(host=host, port=port, decode_responses=True)
#self.redis_url = 'redis://password:@localhost:6379/'
#self.r = redis.Redis.from_url(self.redis_url,decode_responses=True)
@classmethod
def from_crawler(cls, crawler):
'''注入实例化对象(传入参数)'''
return cls(
host = crawler.settings.get("REDIS_HOST"),
port = crawler.settings.get("REDIS_PORT"),
)
def process_item(self, item, spider):
#使用正则判断url地址是否有效,并写入redis。
if re.search('/bj/loupan/', item['url']):
self.r.lpush('fangspider:start_urls', item['url'])
else:
self.r.lpush('fangspider:no_urls', item['url'])
- 编辑配置文件:settings.py配置文件
ITEM_PIPELINES = {
'fang_5i5j.pipelines.MasterPipeline': 300,
}
# 指定使用scrapy-redis的去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'
# 指定使用scrapy-redis的调度器
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 在redis中保持scrapy-redis用到的各个队列,从而允许暂停和暂停后恢复,也就是不清理redis queues
SCHEDULER_PERSIST = True
# 指定排序爬取地址时使用的队列,
# 默认的 按优先级排序(Scrapy默认),由sorted set实现的一种非FIFO、LIFO方式。
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderPriorityQueue'
# REDIS_URL = 'redis:password//127.0.0.1:6379' # 一般情况可以省去
REDIS_HOST = '127.0.0.1' # 也可以根据情况改成 localhost
REDIS_PORT = 6379
- 测试:爬取url $
scrapy runspider fang.py
4 ) 编写slave(从)项目代码
- 编辑爬虫文件:fang.py
# -*- coding: utf-8 -*-
import scrapy
from fang_5i5j.items import FangItem
from scrapy_redis.spiders import RedisSpider
class FangSpider(RedisSpider):
name = 'fang'
#allowed_domains = ['fang.5i5j.com']
#start_urls = ['https://fang.5i5j.com/bj/loupan/']
redis_key = 'fangspider:start_urls'
def __init__(self, *args, **kwargs):
# Dynamically define the allowed domains list.
domain = kwargs.pop('domain', '')
self.allowed_domains = filter(None, domain.split(','))
super(FangSpider, self).__init__(*args, **kwargs)
def parse(self, response):
#print(response.status)
hlist = response.css("li.houst_ctn")
for vo in hlist:
item = FangItem()
item['title'] = vo.css("span.house_name::text").extract_first()
# print(item)
yield item
#pass
- 编辑 items.py
import scrapy
class FangItem(scrapy.Item):
# 此处做一个字段处理,作为演示
title = scrapy.Field()
- 查看pipelines.py (按默认来即可,不用操作)
class Fang5I5JPipeline(object):
def process_item(self, item, spider):
return item
- 编辑配置文件:settings.py配置文件
ITEM_PIPELINES = {
# 'fang_5i5j.pipelines.Fang5I5JPipeline': 300,
'scrapy_redis.pipelines.RedisPipeline': 400,
}
# 指定使用scrapy-redis的去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'
# 指定使用scrapy-redis的调度器
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 在redis中保持scrapy-redis用到的各个队列,从而允许暂停和暂停后恢复,也就是不清理redis queues
SCHEDULER_PERSIST = True
# 指定排序爬取地址时使用的队列,
# 默认的 按优先级排序(Scrapy默认),由sorted set实现的一种非FIFO、LIFO方式。
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderPriorityQueue'
# REDIS_URL = 'redis://localhost:6379' # 一般情况可以省去
REDIS_HOST = '127.0.0.1' # 也可以根据情况改成 localhost
REDIS_PORT = 6379
- 测试:爬取具体房屋信息 $
scrapy runspider fang.py
5 ) 测试radis
- 在slave项目中另启一个终端,并连接redis数据库
$ redis_cli -p 6379
6379 >lpush fangspider:start_urls https://fang.5i5j.com/bj/loupan/
- 查看终端输出及rdm中的数据信息
6 ) 将爬取到的数据存入mongodb中
- 网站的数据爬回来了,但是放在Redis里没有处理。之前我们配置文件里面没有定制自己的ITEM_PIPELINES,而是使用了RedisPipeline,所以现在这些数据都被保存在redis中,所以我们需要另外做处理。
- 写一个process_5i5j_profile.py文件,然后保持后台运行就可以不停地将爬回来的数据入库了。
import json
import redis
import pymongo
def main():
# 指定Redis数据库信息
rediscli = redis.StrictRedis(host='127.0.0.1', port=6379, db=0)
# 指定MongoDB数据库信息
mongocli = pymongo.MongoClient(host='localhost', port=27017)
# 创建数据库名
db = mongocli['demodb']
# 创建空间
sheet = db['fang']
while True:
# FIFO模式为 blpop,LIFO模式为 brpop,获取键值
source, data = rediscli.blpop(["demo:items"])
item = json.loads(data)
sheet.insert(item)
try:
print u"Processing: %(name)s <%(link)s>" % item
except KeyError:
print u"Error procesing: %r" % item
if __name__ == '__main__':
main()
- MongoDB数据库:$
sudo mongod
- 执行
python process_5i5j_mongodb.py
文件
7 ) 最终部署
- 准备好1台主机,多台从机,将master项目部署到主机上,将slave项目部署到从机上
- 将各个机器的ip地址、数据库地址替换成真实的地址
- 启动从机项目,启动主机项目并在主机上启动
process_5i5j_mongodb.py
程序 - 最终验证程序并对项目做相关调整