scrapy-redis分布式爬虫

一.知识储备

Scrapy本身是不支持分布式的,scrapy_redis是为了更方便的实现scrapy分布式爬取,而提供了一些以redis为基础的组件(仅有组件)。在 Scrapy 中最出名的分布式插件就是scrapy-redis了,scrapy-redis的作用就是让你的爬虫快、更快、超级快。

1.单机爬虫与分布式爬虫的区别

单机爬虫:一台电脑运行一个项目。去重采用了set()和queue(),但是这两个都是在内存中存在的。

1)其他电脑是无法获取另外一台电脑内存中的数据的。

2)程序终止,内存消失。

分布式爬虫:将一个项目拷贝到多台电脑上,同时爬取数据。
1) 必须保证所有电脑上的代码是相同的配置。
2) 在其中一台电脑上启动redis和mysql的数据库服务。
3) 同时将所有的爬虫项目运行起来。
4) 在启动redis和mysql数据库的电脑上,向redis中添加起始的url。

只需要在众多电脑中,选择其中一台开启redis服务,目的就是在redis中创建公用的queue和公用的set,然后剩余电脑只需要连接redis服务即可,剩余电脑不需要开启redis-server服务。多台电脑的爬虫项目连接同一个redis数据库。

2.分布式问题

1) 多台电脑如何统一的对URL进行去重?
2) 多台电脑之间如何共用相同的队列?多台电脑获取的request,如何在多台电脑之间进行同步?
3) 多台电脑运行同一个爬虫项目,如果有机器爬虫意外终止,如何保证可以继续从队列中获取新的request,而不是从头开始爬取?

前两个问题:可以基于redis实现。相当于将set()和queue()从scrapy框架中抽离出来,将其保存在一个公共的平台中(redis)。
第三个问题:scrapy_redis已经实现了,重启爬虫不会从头开始重新爬取,而是会继续从队列中获取request。不用担心爬虫意外终止。

 二.scrapy_redis第三方库实现分布的部署具体步骤如下
1.在虚拟环境中安装pip install redis
2.去github上搜索scrapy_redis库,下载后解压到桌面

下载地址:https://github.com/rmax/scrapy-redis

scrapy-redis分布式爬虫_第1张图片

 解压后找到C:\Users\Administrator\Desktop\scrapy-redis-master\scrapy-redis-master\src下的scrapy_redis,将该文件放到项目根目录下

项目结构如下:

scrapy-redis分布式爬虫_第2张图片

根据提供的用例,配置我们的项目,大致三部分:

 1)settings.py文件;
    SCHEDULER = "scrapy_redis.scheduler.Scheduler"
    DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
    ITEM_PIPELINES = {
        'scrapy_redis.pipelines.RedisPipeline': 300
    }
    # myroot: 自定义的redis链接。IP:开启redis-server服务的这台电脑的IP
    REDIS_URL = 'redis://myroot:@192.168.70.205:6379'
    
    2)jobbole.py文件;
    from scrapy_redis.spiders import RedisSpider
    class JobboleSpider(RedisSpider):
        name = 'bole'
        allowed_domains = ['jobbole.com']
        # start_urls = ['http://blog.jobbole.com/all-posts/']
    
    # 添加键
        redis_key = 'jobbole:start_urls'

    3)有关数据库部分;
    安装MySQL的时候,默认生成的用户root只有本地登录权限localhost,如果需要远程连接MySQL,需要分配一个拥有远程连接权限的新用户。
    第一步:通过mysql -uroot -p登录MySQL服务。(默认支持的端口是3306,如果你的端口是其它的则需要指定即可)

 scrapy-redis分布式爬虫_第3张图片

scrapy-redis分布式爬虫_第4张图片
    第二步:通过grant all privileges on *.*  to 'myroot'@'%' identified by '123456';(注意一定要带上分号)。
    # *.* 表示所有数据库中的所有表,都能够被远程连接
    # '%' 表示任意IP都可以进行链接
    # 'myroot' 具有远程链接权限的用户名,自定义。之后就使用这个User进行链接数据库
    mysql->grant all privileges on *.*  to 'myroot'@'%' identified by '123456';  回车即可。
    然后在MySQL数据库中新建一个连接,将"主机名或IP地址"写成你的电脑的IP,将"用户名"写成myroot
    第三步:再去修改爬虫项目中有关数据库的配置。
    MYSQL_HOST = '192.168.70.205'
    MYSQL_DBNAME = 'article_db'
    MYSQL_USER = 'myroot'
    MYSQL_PASSWORD = '123456'
    MYSQL_CHARSET = 'utf8'

3.将配置好的项目,拷贝到不同的机器中;
4.选择其中一台机器,开启redis-server服务,并修改redis.windows.conf配置文件:

# 配置远程IP地址,供其他的电脑进行连接redis
bind: (当前电脑IP) 192.168.70.205

# 关闭redis保护模式
protected-mode: no

Redis安装卸载服务:https://www.cnblogs.com/oneTOinf/p/7928033.html

scrapy-redis分布式爬虫_第5张图片

5.其中一台电脑启动redis-server服务
6.让所有爬虫项目都运行起来,由于没有起始的url,所有爬虫会暂时处于停滞状态
7.所有爬虫都启动之后,部署redis-server服务的电脑,通过命令

lpush bole:start_urls http://blog.jobbole.com/all-posts/

向redis的queue中添加起始的url,输入keys *后可以发现多了个"bole:start_urls";然后启动爬虫项目,输入keys *后可以发现多了个"bole:dupefilter".

scrapy-redis分布式爬虫_第6张图片

注意:

如果通过命令lpush bole:start_urls http://blog.jobbole.com/all-posts/向redis的queue中添加起始的url后,启动爬虫项目发现迟迟不往下走,只需输入命令: del bole:dupefilter 后再重新添加起始的url,再次启动爬虫项目即可

8.所有爬虫开始运行,爬取数据,同时所有的数据都会保存到该爬虫所连接的远程数据库以及远程redis中

 

三.具体代码如下

#bole.py


import scrapy
from ..items import JobboleItem
from urllib.parse import urljoin
from scrapy.loader import ItemLoader
from scrapy_redis.spiders import RedisSpider


class BoleSpider(RedisSpider):
    name = 'bole'
    allowed_domains = ['jobbole.com']
    # start_urls = ['http://blog.jobbole.com/all-posts/page/559/']

    # 添加键
    redis_key = 'bole:start_urls'

    def parse(self, response):
        """
        解析列表页
        :param response:
        :return:
        """

        divs = response.xpath('//div[@id="archive"]/div[@class="post floated-thumb"]')
        for article_div in divs:
            # 获取class="post-thumb"的标签,如果有,说明这个文章含有图片,反之没有图片。
            img_src = urljoin('http://blog.jobbole.com', article_div.xpath('./div[@class="post-thumb"]/a/img/@src').extract_first(''))
            if img_src == 'http://blog.jobbole.com':
                img_src = 'https://image.baidu.com/search/detail?ct=503316480&z=0&ipn=d&word=%E5%BF%83%E6%80%81%E7%82%B8%E8%A3%82%E5%9B%BE%E7%89%87&hs=2&pn=0&spn=0&di=142010103620&pi=0&rn=1&tn=baiduimagedetail&is=0%2C0&ie=utf-8&oe=utf-8&cl=2&lm=-1&cs=1400778086%2C2582284514&os=1208831830%2C738191100&simid=0%2C0&adpicid=0&lpn=0&ln=30&fr=ala&fm=&sme=&cg=&bdtype=0&oriquery=%E5%BF%83%E6%80%81%E7%82%B8%E8%A3%82%E5%9B%BE%E7%89%87&objurl=http%3A%2F%2Fwww.tshyqs.com%2Fupload%2Fimg%2F14960294.jpg&fromurl=ippr_z2C%24qAzdH3FAzdH3Fooo_z%26e3Bpfiyqf_z%26e3Bv54AzdH3Fvi7xtg2AzdH3F0lml9_z%26e3Bip4s&gsm=0&islist=&querylist='
            # 获取class="post-meta"内部的详情页地址
            detail_url = article_div.xpath('./div[@class="post-meta"]/p/a[@class="archive-title"]/@href').extract_first('')

            yield scrapy.Request(detail_url, callback=self.parse_detail_page, meta={'img_src': img_src})

        # 获取下一页的url地址
        # try:
        #     next_url = response.xpath('//a[contains(@class, "next")]/@href').extract_first('')
        # except:
        #     pass
        # else:
        #     yield scrapy.Request(next_url, callback=self.parse)

    def parse_detail_page(self, response):
        """
        解析详情页数据
        :param response:
        :return:
        """
        # 使用Item Loaders对Item数据进行提取和解析(整理)。作用:
        # 之前的方式,是将数据的提取和解析混合在一起,但是Item Loaders是将这两个部分分开处理了;
        # 爬虫文件bole.py中只负责数据的提取;
        # Items.py文件负责数据的整理;(可以实现数据解析代码的重用。相当于将功能相同的解析函数封装成为一个公用的函数,任何爬虫需要这个函数,都可以来调用。)

        # 1. 使关于数据的提取代码更加简洁,结构更加清晰;
        # 2. 可以实现数据解析(整理)部分的代码的重用;
        # 3. 提高代码的可维护性;

        """
        1. 当创建item对象(item=JobboleItem())的时候,会去Items.py文件中初始化对应的input/output_processor处理器; 
        2. 当item中的处理器初始化完成,回到bole.py爬虫文件中,创建item_loader对象;
        3. item_loader对象创建完成,开始通过add_xpath/add_css/add_value收集数据;
        4. 每收集到一个数据,就会将该数据传递给对应字段对应的input_processor绑定的函数进行数据的处理;数据处理完成,会暂时保存在ItemLoader中;
        5. 循环第4步,将每一个字段的数据提取并交给input_processor,直到所有数据提取完毕,所有数据都会被保存在ItemLoader中;
        6. 调用load_item()函数,给item对象进行赋值;
        """
        item_loader = ItemLoader(item=JobboleItem(), response=response)
        item_loader.add_xpath('title', '//div[@class="entry-header"]/h1/text()')
        item_loader.add_xpath('date_time', '//p[@class="entry-meta-hide-on-mobile"]/text()')
        item_loader.add_xpath('tags', '//p[@class="entry-meta-hide-on-mobile"]/a/text()')
        item_loader.add_xpath('content', '//div[@class="entry"]//text()')
        item_loader.add_xpath('zan_num', '//div[@class="post-adds"]/span[contains(@class, "vote-post-up")]//text()')
        item_loader.add_xpath('keep_num', '//div[@class="post-adds"]/span[contains(@class, "bookmark-btn")]/text()')
        item_loader.add_xpath('comment_num', '//div[@class="post-adds"]/a/span/text()')
        item_loader.add_value('img_src', [response.meta['img_src']])

        item = item_loader.load_item()
        yield item
#items.py


import scrapy,re
from datetime import datetime
from scrapy.contrib.loader.processor import Join, MapCompose, TakeFirst

def convert_datetime(value):
    # 将字符串类型转化成datetime类型
    value = value.replace('·', '').strip()
    try:
        # strptime(时间字符串,转化后的格式): 函数返回值是datetime类型的对象
        date_time = datetime.strptime(value, '%Y/%m/%d')
    except:
        # 如果转化失败,将当前时间作为默认值。
        date_time = datetime.now()

    return date_time

def convert_tags(value):
    # ['自由职业', '1 评论', '职业']
    # 过滤 "评论"
    if "评论" in value:
        return ""
    return value

def zan_number(value):
    if value.strip() != "":
        pattern = re.compile(r'\d+')
        num = re.findall(pattern, value)
        if num:
            num = int(num[0])
        else:
            num = 0
        return num

def get_number(value):
    # 提取评论、点赞数
    pattern = re.compile(r'\d+')
    num = re.findall(pattern, value)
    if num:
        num = int(num[0])
    else:
        num = 0
    return num

def process_image(value):
    # 拼接图片地址
    return value


class JobboleItem(scrapy.Item):
    title = scrapy.Field(
        # MapCompose映射类,可以将ItemLoader传递过来的列表中的元素,依次作用到test_title函数上,类似于map()函数。
        # input_processor=MapCompose(input_test_title),
        # Join(): 对列表进行合并,add_xpath/add_css/add_value传过来的列表数据。
        output_processor=TakeFirst()
    )
    date_time = scrapy.Field(
        input_processor=MapCompose(convert_datetime),
        # TakeFirst(): 获取列表中的首个元素
        output_processor=TakeFirst()
    )
    tags = scrapy.Field(
        input_processor=MapCompose(convert_tags),
        # 覆盖默认的default_output_processor = TakeFirst()
        output_processor=Join()
    )
    content = scrapy.Field(
        output_processor=Join()
    )
    zan_num = scrapy.Field(
        # ['', '1', ' 赞']
        input_processor=MapCompose(zan_number),
        output_processor=TakeFirst()
    )
    keep_num = scrapy.Field(
        input_processor=MapCompose(get_number),
        output_processor=TakeFirst()
    )
    comment_num = scrapy.Field(
        input_processor=MapCompose(get_number),
        output_processor=TakeFirst()
    )
    # 图片的源地址
    img_src = scrapy.Field()
    # 图片在本地的下载路径, 该字段只有在图片下载完成以后,才能进行赋值。
    img_path = scrapy.Field()

 

#pipelines.py



from scrapy.pipelines.images import ImagesPipeline


class JobbolePipeline(object):
    def process_item(self, item, spider):
        return item


# 定义处理图片的Pipeline
class ImagePipeline(ImagesPipeline):
    # 图片下载完成以后的调用方法。
    def item_completed(self, results, item, info):
        print('---',results)
        # return item
        # 如果图片能够下载成功,说明这个文章是有图片的。如果results中不存在path路径,说明是没有图片的。
        # [(True, {'path': ''})]
        if results:
            try:
                img_path = results[0][1]['path']
            except Exception as e:
                print('img_path获取异常,',e)
                img_path = '没有图片'
        else:
            img_path = '没有图片'

        # 对item对象中的img_path进行赋值
        item['img_path'] = img_path

        # 判断完成,需要将变量img_path重新保存到item中。

        return item


# 数据库pymysql的commit()和execute()在提交数据时,都是同步提交至数据库,由于scrapy框架数据的解析和异步多线程的,所以scrapy的数据解析速度,要远高于数据的写入数据库的速度。如果数据写入过慢,会造成数据库写入的阻塞,影响数据库写入的效率。
# 通过多线程异步的形式对数据进行写入,可以提高数据的写入速度。
from pymysql import cursors

# 使用twsited异步IO框架,实现数据的异步写入。
from twisted.enterprise import adbapi


class MySQLTwistedPipeline(object):
    def __init__(self, dbpool):
        self.dbpool = dbpool

    @classmethod
    def from_settings(cls, settings):
        params = dict(
            host=settings['MYSQL_HOST'],
            db=settings['MYSQL_DB'],
            user=settings['MYSQL_USER'],
            passwd=settings['MYSQL_PASSWD'],
            charset=settings['MYSQL_CHARSET'],
            port=settings['MYSQL_PORT'],
            cursorclass=cursors.DictCursor,
        )
        # 初始化数据库连接池(线程池)
        # 参数一:mysql的驱动
        # 参数二:连接mysql的配置信息
        dbpool = adbapi.ConnectionPool('pymysql', **params)
        return cls(dbpool)

    def process_item(self, item, spider):
        # 在该函数内,利用连接池对象,开始操作数据,将数据写入到数据库中。
        # pool.map(self.insert_db, [1,2,3])
        # 同步阻塞的方式: cursor.execute() commit()
        # 异步非阻塞的方式
        # 参数1:在异步任务中要执行的函数insert_db;
        # 参数2:给该函数insert_db传递的参数
        query = self.dbpool.runInteraction(self.insert_db, item)

        # 如果异步任务执行失败的话,可以通过ErrBack()进行监听, 给insert_db添加一个执行失败的回调事件
        query.addErrback(self.handle_error)

        return item

    def handle_error(self, field):
        print('-----数据库写入失败:',field)

    def insert_db(self, cursor, item):
        insert_sql = "INSERT INTO bole(title, date_time, tags, content, zan_num, keep_num, comment_num, img_src, img_path) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)"
        cursor.execute(insert_sql, (item['title'], item['date_time'], item['tags'], item['content'], item['zan_num'], item['keep_num'], item['comment_num'], item['img_src'], item['img_path']))

        # 在execute()之后,不需要再进行commit(),连接池内部会进行提交的操作。

 

#settings.py



BOT_NAME = 'jobbole'

SPIDER_MODULES = ['jobbole.spiders']
NEWSPIDER_MODULE = 'jobbole.spiders'


# Crawl responsibly by identifying yourself (and your website) on the user-agent
#USER_AGENT = 'jobbole (+http://www.yourdomain.com)'

# Obey robots.txt rules
ROBOTSTXT_OBEY = False

# Configure maximum concurrent requests performed by Scrapy (default: 16)
#CONCURRENT_REQUESTS = 32

# Configure a delay for requests for the same website (default: 0)
# See https://doc.scrapy.org/en/latest/topics/settings.html#download-delay
# See also autothrottle settings and docs
#DOWNLOAD_DELAY = 3
# The download delay setting will honor only one of:
#CONCURRENT_REQUESTS_PER_DOMAIN = 16
#CONCURRENT_REQUESTS_PER_IP = 16

# Disable cookies (enabled by default)
#COOKIES_ENABLED = False

# Disable Telnet Console (enabled by default)
#TELNETCONSOLE_ENABLED = False

# Override the default request headers:
#DEFAULT_REQUEST_HEADERS = {
#   'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
#   'Accept-Language': 'en',
#}

# Enable or disable spider middlewares
# See https://doc.scrapy.org/en/latest/topics/spider-middleware.html
#SPIDER_MIDDLEWARES = {
#    'jobbole.middlewares.JobboleSpiderMiddleware': 543,
#}

# Enable or disable downloader middlewares
# See https://doc.scrapy.org/en/latest/topics/downloader-middleware.html
#DOWNLOADER_MIDDLEWARES = {
#    'jobbole.middlewares.JobboleDownloaderMiddleware': 543,
#}

# Enable or disable extensions
# See https://doc.scrapy.org/en/latest/topics/extensions.html
#EXTENSIONS = {
#    'scrapy.extensions.telnet.TelnetConsole': None,
#}

# Configure item pipelines
# See https://doc.scrapy.org/en/latest/topics/item-pipeline.html
# ITEM_PIPELINES = {
#     'scrapy.pipelines.images.ImagesPipeline': None,
#     'jobbole.pipelines.ImagePipeline': 300,
#     'jobbole.pipelines.MySQLTwistedPipeline': 301,
# }

IMAGES_STORE = 'imgs'
IMAGES_URLS_FIELD = 'img_src'

# Enable and configure the AutoThrottle extension (disabled by default)
# See https://doc.scrapy.org/en/latest/topics/autothrottle.html
#AUTOTHROTTLE_ENABLED = True
# The initial download delay
#AUTOTHROTTLE_START_DELAY = 5
# The maximum download delay to be set in case of high latencies
#AUTOTHROTTLE_MAX_DELAY = 60
# The average number of requests Scrapy should be sending in parallel to
# each remote server
#AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
# Enable showing throttling stats for every response received:
#AUTOTHROTTLE_DEBUG = False

# Enable and configure HTTP caching (disabled by default)
# See https://doc.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings
#HTTPCACHE_ENABLED = True
#HTTPCACHE_EXPIRATION_SECS = 0
#HTTPCACHE_DIR = 'httpcache'
#HTTPCACHE_IGNORE_HTTP_CODES = []
#HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'

# 配置HOST为局域网IP,或者公网IP。
MYSQL_HOST = '192.168.70.205'
MYSQL_DB = 'jobbole'
MYSQL_USER = 'myroot'
MYSQL_PASSWD = '123456'
MYSQL_CHARSET = 'utf8'
MYSQL_PORT = 330

# 配置scrapy_redis第三方库

# 所有电脑配置调度器,这个调度器重写了scrapy框架内置的调度器。
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 所有电脑配置去重,这个也是重写了scrapy内置的去重。
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
# 所有电脑配置redis的连接地址,设置局域网IP或者公网IP,保证所有电脑都能连接到同一个redis。6379是redis的默认端口号。
# redis数据库默认只允许本地连接localhost,如需配置远程连接,需要修改redis的配置文件。修改完成以后,要重启redis-server服务。

# 1. (A B C) 如果用的是局域网部署的分布式,选择其中一台电脑(A)开启redis-server服务,REDIS_URL就配置成A电脑的局域网IP地址。如果B电脑开启redis-server->REDIS_URL B电脑的IP地址。
# 2. (A B C) 如果用的是公网IP(阿里云),那么所有电脑都不需要开启redis-server服务,只需要将REDIS_URL的主机地址配置成公网IP即可。
REDIS_URL = 'redis://myroot:@192.168.70.205:6379'




# 可以配置,也可以不用配置。如果配置的话:所有下载的item,除了会被保存在数据库MySQL中,还会被保存在Redis数据库中。没有配置:所有的item不会存储在Redis数据库中。
# ITEM_PIPELINES = {
#     'scrapy_redis.pipelines.RedisPipeline': 300
# }


 

 

你可能感兴趣的:(scrapy-redis分布式爬虫)