菜鸟学爬虫之分布式百科爬虫

1,343 阅读7分钟

scrapy 百度百科爬虫

最近有个项目需求,需要对百科类网站的词条进行抓取,然后把现学了一下scrapy这个框架,发现确实有它的独到之处,在一定程度上来说,节省很多工作,解放了很大部分劳动力(虽然是廉价的,hiahia)。不过这个框架也有它的局限性,至少对我个人来说,比较难调试,并且在一定程度上来说,使用框架并不灵活。好啦,上面都是个人看法,吐槽归吐槽,并不妨碍我们学习scrapy的实现与设计。我这儿就简单的讲讲如何使用scrapy来完成一个简单的百度百科爬虫吧。(注意:大佬请绕行,如果你是新手,那么不妨花几分钟看看)。不废话了,开始了。

爬虫框架图

我们先来看看要实现的百度百科爬虫的架构图吧

百度百科架构图

可以看到,首先由用户配置初始任务列表,然后此任务列表会在爬虫启动时添加到任务消息队列中,调度器会接受爬虫引擎的请求并从任务队列中获取一批任务种子,并将种子交由爬虫引擎。爬虫引擎获取种子,并将它们发送给下载器。下载提取网页并将数据反馈给爬虫引擎,引擎将网页递交由爬虫处理。爬虫解析网页提取item项目,并交由爬虫引擎发送到数据管道中。数据管道处理item项目,一是数据持久化,存入数据库中。二是将item中包含的任务种子交由任务过滤器验证新一批的任务种子是否已经爬取过,如果存在未抓取的任务种子,则将这批种子放入任务队列中。由此完成一轮任务种子到最终数据保存以及新一批任务种子入队列的任务过程。

开始

确认你安装了python,然后pip install scrapy后,就可以开始了。这里我不详细介绍怎么使用创建scrapy项目,我假设你们都是会了这一步,毕竟本文的重点是实现一个百度百科爬虫。

首先我们先来确认一下百度百科中有哪些字段是值得我们去采集的,还是来看段代码吧:

from scrapy.item import Field, Item

class BaiduSpiderItem(Item):
    """ 百度百科 """
    title = Field()  # 此词条名称
    url = Field()  # 词条url
    summary = Field()  # 词条简介
    basic_info = Field()  # 词条基本信息
    catalog = Field()  # 词条目录
    description = Field()  # 词条内容
    keywords_url = Field()  # 此词条内容所包含的其他词条
    embed_image_url = Field()  # 词条插图图片地址 list
    album_pic_url = Field()  # 词条相册地址
    update_time = Field()  # 词条更新时间
    reference_material = Field()  # 参考资料
    item_tag = Field()  # 词条标签
    html = Field()  # 网页源码
    js = Field()  # 网页js
    css = Field()  # 网页css样式

上面这段代码就是我们的items.py这个文件的对应item字段了,每个字段以及写了注释,就不在详细说了。

对应的spider解析response,没有什么难度,我直接贴代码(对应文件\spiders\baidu_spider.py

class BaiduSpider(RedisCrawlSpider):
    task_queue = baidu_task_queue
    base_url = "https://baike.baidu.com"
    name = baidu_spider_name
    allowed_domains = ['baike.baidu.com']
    rules = (
        Rule(LinkExtractor(allow=('https://baike.baidu.com/item/',)), callback='parse', follow=True),
    )
    def parse(self, response):
        items = BaiduSpiderItem()
        selector = Selector(response)
        items['url'] = unquote(response.url)
        items['html'] = response.text
        title = selector.xpath("/html/head/title/text()").extract()
        if title:
            items['title'] = title[0].strip().encode('utf-8', errors='ignore').decode('utf-8')
        else:
            items['title'] = ''
        summary = selector.xpath("//div[@class=\"lemma-summary\"]").xpath("string(.)").extract()
        if summary:
            tmps = summary[0].encode('utf-8', errors='ignore').decode('utf-8')
            items['summary'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmps)
        else:
            items['summary'] = ''

        basic_info = selector.xpath("//div[@class=\"basic-info cmn-clearfix\"]").xpath("string(.)").extract()
        if basic_info:
            tmpb = basic_info[0].encode('utf-8', errors='ignore').decode('utf-8')
            items['basic_info'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmpb)
        else:
            items['basic_info'] = ''

        catalog = selector.xpath("//div[@class=\"lemmaWgt-lemmaCatalog\"]").xpath("string(.)").extract()
        if catalog:
            tmpc = catalog[0].encode('utf-8', errors='ignore').decode('utf-8')
            items['catalog'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmpc)
        else:
            items['catalog'] = ''

        # 进行迭代抓取的item链接
        urls = [unquote(item) for item in
                selector.xpath("//div[@class=\"para\"]//a[@target=\"_blank\"]/@href").extract()]
        items['keywords_url'] = list(set(filter(lambda x: 'item' in x, urls)))

        description = selector.xpath("//div[@class=\"content-wrapper\"]").xpath("string(.)").extract()
        if description:
            tmpd = description[0].encode('utf-8', errors='ignore').decode('utf-8')
            items['description'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmpd)
        else:
            items['description'] = ''

        # 匹配pic、js、css
        items['embed_image_url'] = CacheTool.parse_img(items['html'])
        items['js'] = CacheTool.parse_js(items['html'])
        items['css'] = CacheTool.parse_css(items['html'])

        album_pic_url = selector.xpath("//div[@class=\"album-list\"]//a[@class=\"more-link\"]/@href").extract()
        if album_pic_url:
            items['album_pic_url'] = self.base_url + unquote(album_pic_url[0])
        else:
            items['album_pic_url'] = ''

        update_time = selector.xpath("//span[@class = 'j-modified-time']").xpath("string(.)").extract()
        if update_time:
            tmpu = update_time[0].strip().encode('utf-8', errors='ignore').decode('utf-8')
            items['update_time'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmpu)
        else:
            items['update_time'] = ''
            
        reference_material = selector.xpath(
            "//dl[@class ='lemma-reference collapse nslog-area log-set-param']").xpath("string(.)").extract()
        if reference_material:
            tmpr = reference_material[0].encode('utf-8', errors='ignore').decode('utf-8')
            items['reference_material'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmpr)
        else:
            items['reference_material'] = ''

        item_tag = selector.xpath("//dd[@id = \"open-tag-item\"]").xpath("string(.)").extract()
        if item_tag:
            tmpi = item_tag[0].encode('utf-8', errors='ignore').decode('utf-8')
            items['item_tag'] = re.sub('(\r\n){2,}|\n{2,}|\r{2,}', '\n', tmpi)
        else:
            items['item_tag'] = ''
        yield copy.deepcopy(items)  # 深拷贝的目的是默认浅拷贝item会在后面的pipelines传递过程中会出现错误,比如串数据了

parse()这个函数就是对downloader下载返回的response进行解析了,写xpath吧,这个很简单了吧!这个spider呢,和平时我们写的略有不同。大家可能发现了这个spider继承的不是CrawlSpider或者Spider,而是继承的RedisCrawlSpider,这就是本文的重点了。RedisCrawlSpider是我自己实现的一个类,它继承了CrawlSpiderRedisMixin这两个类,而RedisMixin这混入类也是自己实现(对应文件\spiders\redis_spider.py

class RedisMixin(object):
    """Mixin class to implement reading urls from a redis queue."""
    task_queue = None
    redis_batch_size = SPIDER_FEED_SIZE
    redis_encoding = 'utf8'
    redis_con = None
    def start_requests(self):
        """Returns a batch of start requests from redis."""
        return self.next_requests()

    def setup_redis(self, crawler=None):
        """Setup redis connection and idle signal.
        send idle signal when spider is free
        """
        self.redis_con = common_con
        crawler.signals.connect(self.spider_idle, signal=signals.spider_idle)

    def next_requests(self):
        """Returns a request to be scheduled or none."""
        fetch_one = self.redis_con.lpop
        found = 0
        while found < self.redis_batch_size:
            data = fetch_one(self.task_queue)
            if not data:
                # Queue empty.
                print("队列是空的")
                break
            req = self.make_request_from_data(data)
            if req:
                found += 1
                yield req
            else:
                print('没有下一个req')

    def make_request_from_data(self, data):
        url = bytes2str(data, self.redis_encoding)
        return self.make_requests_from_url(url)

    def schedule_next_requests(self):
        """Schedules a request if available"""
        # TODO: While there is capacity, schedule a batch of redis requests.
        for req in self.next_requests():
            self.crawler.engine.crawl(req, spider=self)

    def spider_idle(self):
        """Schedules a request if available, otherwise waits."""
        # XXX: Handle a sentinel to close the spider.
        self.schedule_next_requests()
        raise DontCloseSpider
        
class RedisCrawlSpider(RedisMixin, CrawlSpider):
    @classmethod
    def from_crawler(self, crawler, *args, **kwargs):
        obj = super(RedisCrawlSpider, self).from_crawler(crawler, *args, **kwargs)
        obj.setup_redis(crawler)
        return obj

混入类RedisMixin的主要逻辑是当调度器调度获取下一个request的时候,这个url是从redis中抽取出来的,并拿这个url封装为一个request对象并返回的(我们重点看next_request()这个函数)。 既然我们是从一个redis队列中拿出的url,那我们在什么地方往这个队列写入url呢,相信大家已经知道了,就是在我们做数据持久化的时候,我们来看看pipelines.py这个文件

class SpiderRedisPipeline(object):
    """ use bloomfilter to filter the request which had been sent """
    # 百度百科
    base_url = "https://baike.baidu.com"
    bf = BloomFilterRedis(block=filter_blocks, key=baidu_bloom_key)
    def process_item(self, item, spider):
        if not item['keywords_url']:
            return item
        for url in item['keywords_url']:
            if self.bf.is_exists(url):
                continue
            else:
                new_url = self.base_url + url
                common_con.lpush(baidu_task_queue, new_url)
        return item

这个url是由我们在爬虫解析response的时候抽取的关键词与base_url拼接而来的。因为已经爬取或者存在的词条我们是没必要再对他们进行爬取了,这里我们使用了布隆过滤器对关键词进行了过滤,这样就能保证我们的爬虫不会抓取重复的词条。这里我们将拼接好的ulrpush到了指定的任务队列中,这样就能保证我们的任务队列时刻都有新的且不重复的任务种子了。 当然在这个爬虫第一次启动的时候,我们需要给定一些初始的任务种子,为了保证能够尽可能的抓取所有的百科词条,我们需要给定相对全面的词条,一般就是从百度百科的每个分类中抽取若干词条初始化任务队列。

zh_seeds = ['猫', '薄一波', '路飞', '铁树', '梅花', '阿卡迪亚的牧人', '隆中对', '台球', '足球', '北京大学', '贝勒大学',
            '狗', '鸟', '中国', '美国', '日本', '唐朝', '宋朝', '南极', '北极', '太平洋', '泰山', '世界大战', '北京', '四川',
            '血液病', '癌症', '甲苯', '染色体', '大数定律', '计算机', '冰川', '第二次世界大战', '太岁', '腾讯', '黑洞', '台风',
            '地震', '雪崩', '泥石流']
common_con.lpush(baidu_task_queue, 'https://baike.baidu.com/item/{}'.format(i))

特点

  • 可以断点续爬,程序意外中断后,能够重新上次失败处重新开始任务;
  • 可实现分布式部署;
  • 以广度优先的方式,能够尽可能的抓取百度词条;

因为篇幅有限,这里只讲百度百科爬虫的实现要点,具体细节的话,大家可以看看源码 传送门-GitHub/baikeSpider 如果你们觉得喜欢,不妨在github上点个小心心吧。