爬虫教程( 6 ) --- 追踪(CrawlSpider)、布隆过滤(Bloom Filter)、去重、解析

1. 前言

学习爬虫,需要订立一个目标,让自己有持续学习的动力(没有实操项目,真的很难有动力),比如爬取 "豆瓣,草什么榴社区,91、知乎、各种妹子的联系方式等等"。

网页基本知识

  • 基本的HTML语言知识( 知道 href 等大学计算机一级内容即可)
  • 理解网站的发包和收包的概念(POST,GET)
  • 稍微一点点的 js 知识,用于理解动态网页

内容中数据提取:正则表达式、xpath、css、Beautifulsoup、

新闻文章正文提取工具(goose3):github 地址:https://github.com/goose3/goose3

2. 一个简单的爬虫

import requests
from urllib.parse import urljoin
from scrapy import Selector

def main():
    url = "https://www.rosi21.cc/"
    resp = requests.get(url)
    if 200 == resp.status_code:
        resp_text = resp.text
        html_dom = Selector(text=resp_text)
        img_list = html_dom.xpath('//img/@src').extract()
        url_list = [urljoin(url, x) for x in img_list]
        list(map(lambda x=None: print(x), url_list))
    pass

if __name__ == '__main__':
    main()

上面是一个简单的爬虫。但是爬虫程序通常需要做的事情如下:

  • 1)给定的种子 URLs,爬虫程序将所有种子 URL 页面爬取下来
  • 2)爬虫程序解析爬取到的 URL 页面中的链接,将这些链接放入待爬取 URL 集合中
  • 3)重复 1、2 步,直到达到指定条件才结束爬取

改进程序如下:

import time
import requests
from urllib.parse import urljoin
from scrapy import Selector
from bs4 import BeautifulSoup


requests.packages.urllib3.disable_warnings()


crawl_url = "https://www.rosi21.cc/"
# 种子 url
seed_url_list = [crawl_url,]

# 设定终止条件为:爬取到 100000个页面时就停止爬取
end_sum = 0


def do_save_action(text=None):
    html_dom = Selector(text=text)
    img_list = html_dom.xpath('//img/@src').extract()
    url_list = [urljoin(crawl_url, x) for x in img_list]
    list(map(lambda x=None: print(x), url_list))
    pass


def main():
    global end_sum
    while end_sum < 10000:
        print(f'第{end_sum}个请求')
        if end_sum >= len(seed_url_list):
            break
        r = requests.get(seed_url_list[end_sum])
        end_sum = end_sum + 1
        do_save_action(r.text)
        soup = BeautifulSoup(r.text, "lxml")
        tag_a_list = soup.find_all('a')  # 解析网页
        for tag_a in tag_a_list:
            url = urljoin(crawl_url, tag_a.get('href'))
            seed_url_list.append(url)
        time.sleep(2)


if __name__ == '__main__':
    main()

但是还是有很多缺点,例如:

  • 1)假设爬一个网页3秒钟,那么爬一万个网页需要3万秒钟。这纯属扯淡,所以可以使用多线程(池)爬取,或者用分布式架构去并发的爬取网页。
  • 2)种子URL 和 后续解析到的URL 都放在一个list里,如果 url 有几十万,上百万,则会占用带量内存,所以应该设计一个更合理的数据结构来存放这些待爬取的 URL ,比如:队列 ( scrapy-redis 是 种子URL redis 的 list 里面后续解析到的URL 队列里面 )
  • 3)对各个网站的 url 一视同仁,但实际是有的 URL 需要优先爬取。
  • 4)解析到网页中的 urls 后,我们没有做任何去重处理,全部放入待爬取的列表中。事实上,可能有很多链接是重复的,我们做了很多重复劳动。
  • 5)…..

现在讨论一下问题的解决方案。

  • 1)并行爬取问题。有多种方法去实现并行。多线程,线程池,分布式,一个爬虫程序内部开启多个线程。同一台机器开启多个爬虫程序,如此,我们就有N多爬取线程在同时工作。能大大减少时间。此外,当我们要爬取的任务特别多时,一台机器、一个网点肯定是不够的,我们必须考虑分布式爬虫。常见的分布式架构有:主从(Master——Slave)架构、点对点(Peer to Peer)架构,混合架构等。说到分布式架构,那我们需要考虑的问题就有很多,我们需要分派任务,各个爬虫之间需要通信合作,共同完成任务,不要重复爬取相同的网页。分派任务我们要做到公平公正,就需要考虑如何进行负载均衡。负载均衡,我们第一个想到的就是Hash,比如根据网站域名进行hash。负载均衡分派完任务之后,千万不要以为万事大吉了,万一哪台机器挂了呢?原先指派给挂掉的哪台机器的任务指派给谁?又或者哪天要增加几台机器,任务有该如何进行重新分配呢 ?一个比较好的解决方案是用一致性 Hash 算法。
  • 2)待爬取网页队列。如何对待待抓取队列,跟操作系统如何调度进程是类似的场景。不同网站,重要程度不同,因此,可以设计一个优先级队列来存放待爬起的网页链接。如此一来,每次抓取时,我们都优先爬取重要的网页。当然,你也可以效仿操作系统的进程调度策略之多级反馈队列调度算法。
  • 3)网页去重。说到网页去重,第一个想到的是垃圾邮件过滤。垃圾邮件过滤一个经典的解决方案是 Bloom Filter(布隆过滤器)。布隆过滤器原理简单来说就是:建立一个大的位数组,然后用多个 Hash 函数对同一个 url 进行 hash 得到多个数字,然后将位数组中这些数字对应的位置为1。下次再来一个url时,同样是用多个Hash函数进行hash,得到多个数字,我们只需要判断位数组中这些数字对应的为是全为1,如果全为1,那么说明这个url已经出现过。如此,便完成了url去重的问题。当然,这种方法会有误差,只要误差在我们的容忍范围之类,比如1万个网页,我只爬取到了9999个,也是可以忍受滴。。。
  • 4)数据存储的问题。数据存储同样是个很有技术含量的问题。用关系数据库存取还是用 NoSQL,或是自己设计特定的文件格式进行存储,都大有文章可做。
  • 5)……

如何实现上面这些东西 ?实现的过程中会发现,要考虑的问题远远不止上面这些。

3. 跟踪 (CrawlSpider)

crawlspider:https://docs.scrapy.net.cn/en/latest/topics/spiders.html#crawlspider

在很多情况下并不是只抓取某个页面,而是 "顺藤摸瓜",从几个种子页面,通过递归的追踪超链接,最终定位到想要的页面。scrpay 的 CrawlSpider 就是专门为这个而生。CrawlSpider 可以对匹配到的链接进行分类处理。

  • rules:包含一个(或多个)Rule对象的 列表或者元祖。用于匹配目标网站并排除干扰。每个Rule定义了抓取网站的特定行为。如果多个规则匹配同一链接,则将使用第一个规则,根据它们在此属性中定义的顺序。CrawlSpider 的重点是 Rule
  • start_urls:起始 URL 列表,爬虫会从这些 URL 开始抓取。
  • start_requests 方法:CrawlSpider 继承自 scrapy.Spider 的 start_requests 方法。该方法负责生成起始请求,并将它们传递给 Scrapy 引擎。每个 URL 被请求,并使用 parse_start_url 作为默认的回调函数。
  • parse_start_url:当 Scrapy 收到从 start_urls 请求的响应时,会调用这个方法。这个方法可以被重写,以处理起始 URL 的响应。返回值必须是:项目对象、Request对象或包含任何它们的迭代对象。
  • process_response :在处理响应时,CrawlSpider 会遍历 rules,并应用每个规则。当收到响应时,CrawlSpider 会调用 process_response 方法。
  • parse_item:数据提取和处理。每个匹配的请求会根据指定的回调处理响应。CrawlSpider 会调用用户定义的解析方法(例如 parse_item)。

通过分析 CrawlSpider 的源码流程,我们可以了解到:

  1. 请求生成: 从 start_urls 生成初始请求并处理响应。
  2. 规则匹配: 根据规则提取链接并生成新请求。
  3. 响应处理: 通过用户定义的方法处理每个请求的响应。
  4. 数据提取: 用户可以自定义数据提取逻辑,提取所需信息。

Rule、link_extractor

因为 rules 是 Rule 对象的集合,所以这里介绍下 Rule。

class scrapy.spiders.Rule(link_extractor=None, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=None, errback=None)

参数说明:

  • link_extractor 是 链接提取器 对象,它定义如何从每个已抓取页面中提取链接。每个生成的链接都将用于生成 Request 对象,该对象将在其 meta 字典中包含链接文本(在 link_text 键下)。如果省略,将使用不带参数创建的默认链接提取器,从而提取所有链接。
    LinkExtractor 主要参数为:
            allow:满足括号中“正则表达式”的值会被提取,如果为空,则全部匹配。
            deny:与这个正则表达式(或正则表达式列表)不匹配的URL一定不提取。
            allow_domains:会被提取的链接的domains。
            deny_domains:一定不会被提取链接的domains。
            restrict_xpaths:使用xpath表达式,和allow共同作用过滤链接。
            restrict_css:类似 xpath
  • callback:回调 是一个可调用的对象或字符串(在这种情况下,将使用具有该名称的爬虫对象的方法),用于针对使用指定的链接提取器提取的每个链接进行调用。此回调接收 响应 作为其第一个参数,并且必须返回单个实例或 项目对象 和/或 请求 对象(或它们的任何子类)的可迭代对象。如上所述,接收到的 响应 对象将在其 meta 字典(在 link_text 键下)中包含生成 请求 的链接文本
  • cb_kwargs 是一个包含要传递给回调函数的关键字参数的字典。
  • follow 是一个布尔值,它指定是否应从使用此规则提取的每个响应中跟踪链接。如果 callback 为 None,则 follow 默认为 True,否则默认为 False
  • process_links 是一个可调用的对象或字符串(在这种情况下,将使用具有该名称的爬虫对象的方法),它将针对使用指定的 link_extractor 从每个响应中提取的每个链接列表进行调用。这主要用于过滤目的。
  • process_request 是一个可调用的对象(或字符串,在这种情况下,将使用具有该名称的爬虫对象的方法),它将针对此规则提取的每个 请求 进行调用。此可调用的对象应将所述请求作为第一个参数,并将从中发起请求的 响应 作为第二个参数。它必须返回 请求 对象或 None(以过滤掉请求)。
  • errback 是一个可调用对象或字符串(在这种情况下,将使用具有该名称的爬虫对象的某个方法),如果在处理规则生成的一个请求时引发任何异常,将调用该对象或字符串。它将一个 Twisted Failure 实例作为第一个参数接收。

示例:

import scrapy
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor


class MySpider(CrawlSpider):
    name = "example.com"
    allowed_domains = ["example.com"]
    start_urls = ["https://www.example.com"]

    rules = (
        # Extract links matching 'category.php' (but not matching 'subsection.php')
        # and follow links from them (since no callback means follow=True by default).
        Rule(LinkExtractor(allow=(r"category\.php",), deny=(r"subsection\.php",))),
        # Extract links matching 'item.php' and parse them with the spider's method parse_item
        Rule(LinkExtractor(allow=(r"item\.php",)), callback="parse_item"),
    )

    def parse_item(self, response):
        self.logger.info("Hi, this is an item page! %s", response.url)
        item = scrapy.Item()
        item["id"] = response.xpath('//td[@id="item_id"]/text()').re(r"ID: (\d+)")
        item["name"] = response.xpath('//td[@id="item_name"]/text()').get()
        item["description"] = response.xpath(
            '//td[@id="item_description"]/text()'
        ).get()
        item["link_text"] = response.meta["link_text"]
        url = response.xpath('//td[@id="additional_data"]/@href').get()
        return response.follow(
            url, self.parse_additional_page, cb_kwargs=dict(item=item)
        )

    def parse_additional_page(self, response, item):
        item["additional_data"] = response.xpath(
            '//p[@id="additional_data"]/text()'
        ).get()
        return item

CrawlSpider 工作流程

因为 CrawlSpider 继承自 Spider,所以具有 Spider 的所有函数。

  • 首先 start_requests 对 start_urls 中的每一个 url 发起请求,这个请求会被 parse 接收。在 Spider 中 parse 是需要自己定义。重点:该 start_requests 方法只会调用一次。
  • 但是在 CrawlSpider 定义的 parse 是去解析响应的async def _parse_response(self, response, callback, cb_kwargs, follow=True):

通过源码可以看到 _parse_response 根据有无 callback,follow 和 self.follow_links 执行不同的操作。其中 _requests_to_follow 又会获取 link_extractor(这个是我们传入的LinkExtractor)解析页面得到的 link(link_extractor.extract_links(response)),对 url 进行加工(process_links,需要自定义),对符合的 link 发起 Request。使用 .process_request(需要自定义)处理响应。

CrawlSpider 模拟登录

CrawlSpider 进行模拟登陆时,和 Spider 一样,都要使用start_requests发起请求。

要使用 CrawlSpider 模拟登录,可以通过以下步骤实现:

  1. 定义登录页面的 URL 和登录所需的表单数据。
  2. 在爬虫的start_requests方法中发送登录请求,并处理登录响应。
  3. 在登录成功后,继续爬取其他页面。
import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule


class MySpider(CrawlSpider):
    name = 'myspider'
    start_urls = ['https://example.com/login']
    login_url = 'https://example.com/login'
    username = 'your_username'
    password = 'your_password'

    rules = (
        Rule(LinkExtractor(), callback='parse_item', follow=True),
    )

    def start_requests(self):
        return [scrapy.FormRequest(self.login_url,
                                    formdata={'username': self.username, 'password': self.password},
                                    callback=self.after_login)]

    def after_login(self, response):
        if "Login successful" in response.text:
            for url in self.start_urls:
                yield scrapy.Request(url, callback=self.parse_item)
        else:
            self.logger.error("Login failed")

    def parse_item(self, response):
        # 处理爬取到的页面内容
        pass

4. 过滤 去重

使用 set (集合) 去重

Scrapy 支持通过 RFPDupeFilter 来完成页面的去重(防止重复抓取)。

DUPEFILTER_CLASS = 'scrapy.dupefilters.RFPDupeFilter'

RFPDupeFilter 实际是根据 request_fingerprint 实现过滤的。

源码中实现如下:

def fingerprint(
    request: Request,
    *,
    include_headers: Optional[Iterable[Union[bytes, str]]] = None,
    keep_fragments: bool = False,
) -> bytes:
    processed_include_headers: Optional[Tuple[bytes, ...]] = None
    if include_headers:
        processed_include_headers = tuple(
            to_bytes(h.lower()) for h in sorted(include_headers)
        )
    cache = _fingerprint_cache.setdefault(request, {})
    cache_key = (processed_include_headers, keep_fragments)
    if cache_key not in cache:
        # To decode bytes reliably (JSON does not support bytes), regardless of
        # character encoding, we use bytes.hex()
        headers: Dict[str, List[str]] = {}
        if processed_include_headers:
            for header in processed_include_headers:
                if header in request.headers:
                    headers[header.hex()] = [
                        header_value.hex()
                        for header_value in request.headers.getlist(header)
                    ]
        fingerprint_data = {
            "method": to_unicode(request.method),
            "url": canonicalize_url(request.url, keep_fragments=keep_fragments),
            "body": (request.body or b"").hex(),
            "headers": headers,
        }
        fingerprint_json = json.dumps(fingerprint_data, sort_keys=True)
        cache[cache_key] = hashlib.sha1(fingerprint_json.encode()).digest()
    return cache[cache_key]

可以看到,去重指纹是 sha1(method + url + body + header),所以,实际能够去掉重复的比例并不大。如果需要自己提取去重的 finger,需要自己实现 Filter,并配置上它。下面这个 Filter 只根据 url 去重:

from scrapy.dupefilters import RFPDupeFilter


class SeenURLFilter(RFPDupeFilter):
    """A dupe filter that considers the URL"""

    def __init__(self, path=None):
        self.urls_seen = set()
        RFPDupeFilter.__init__(self, path)

    def request_seen(self, request):
        if request.url in self.urls_seen:
            return True
        else:
            self.urls_seen.add(request.url)

不要忘记配置上:

DUPEFILTER_CLASS ='scraper.custom_filters.SeenURLFilter'

使用 Bloom Filter 去重

Bloom Filter 简介

Bloom-Filter (布隆过滤器) 是一种多哈希函数映射的快速查找算法,通常应用在一些需要快速判断某个元素是否属于集合,但是并不严格要求100%正确的场合。Bloom Filter 有可能会出现错误判断,但不会漏掉判断。也就是 Bloom Filter 如果判断元素不在集合中,那肯定就是不在。如果判断元素存在集合中,有一定的概率判断错误,因此,Bloom Filter 不适合那些 "零错误" 的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter 比其他常见的算法(如 hash,折半查找)极大节省了空间。简单点说:Bloom Filter 算法 就是有几个 seeds,现在申请一段内存空间,一个 seed 可以和字符串哈希映射到这段内存上的一个位,几个位都为1即表示该字符串已经存在。插入的时候也是,将映射出的几个位都置为1

布隆 优点

  • 相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数。另外, Hash 函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。

布隆 缺点

  • 布隆过滤器的缺点和优点一样明显。误算率(False Positive)是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。另外,一般情况下不能从布隆过滤器中删除元素。

总的来说,布隆很适合来处理海量的数据,而且速度优势很强。

使用场景:假设要你写一个网络蜘蛛(web crawler)。由于网络间的链接错综复杂,蜘蛛在网络间爬行很可能会形成 “环”。为了避免形成“环”,就需要知道蜘蛛已经访问过那些 URL。给一个 URL,怎样知道蜘蛛是否已经访问过呢?稍微想想,就会有如下几种方案:1. 将访问过的 URL 保存到数据库。

  • 2. 用 HashSet 将访问过的 URL 保存起来。那只需接近 O(1) 的代价就可以查到一个 URL 是否被访问过了。
  • 3. URL 经过 MD5 或 SHA-1 等单向哈希后再保存到 HashSet 或数据库。
  • 4. Bit-Map 方法。建立一个 BitSet,将每个 URL 经过一个哈希函数映射到某一位。

方法 1~3 都是将访问过的 URL 完整保存,方法4 则只标记 URL 的一个映射位。以上方法在数据量较小的情况下都能完美解决问题,但是当数据量变得非常庞大时问题就来了。

  • 方法 1 的 缺点:数据量变得非常庞大后关系型数据库查询的效率会变得很低。而且每来一个URL就启动一次数据库查询是不是太小题大做了?
  • 方法 2 的 缺点:太消耗内存。随着 URL 的增多,占用的内存会越来越多。就算只有1亿个 URL,每个 URL 只算 50 个字符,就需要 5GB 内存。
  • 方法 3 :由于字符串经过 MD5 处理后的信息摘要长度只有128Bit,SHA-1 处理后也只有 160Bit,因此 方法3 比 方法2 节省了好几倍的内存。
  • 方法 4 :消耗内存是相对较少的,但缺点是单一哈希函数发生冲突的概率太高。还记得数据结构课上学过的 Hash 表冲突的各种解决方法么?若要降低冲突发生的概率到1%,就要将 BitSet 的长度设置为 URL 个数的 100 倍。

实质上,上面的算法都忽略了一个重要的隐含条件:允许小概率的出错,不一定要100%准确!也就是说少量 url 实际上没有没网络蜘蛛访问,而将它们错判为已访问的代价是很小的——大不了少抓几个网页呗。

Bloom Filter 算法

上面方法4的思想已经很接近 Bloom Filter 了。方法四的致命缺点是冲突概率高,为了降低冲突的概念,Bloom Filter 使用了多个哈希函数,而不是一个。

Bloom Filter 算法:创建一个 m位 BitSet,先将所有位初始化为0,然后选择 k个 不同的哈希函数。第 i个 哈希函数对 字符串str 哈希的结果记为 h(i,str),且 h(i,str)的范围是 0 到 m-1

  • (1) 加入字符串过程。下面是每个字符串处理的过程,首先是将字符串 str “记录” 到 BitSet 中的过程:对于字符串 str,分别计算 h(1,str),h(2,str)…… h(k,str)。然后将 BitSet 的第 h(1,str)、h(2,str)…… h(k,str)位设为1。下图是 Bloom Filter 加入字符串过程,很简单吧?这样就将字符串 str 映射到 BitSet 中的 k 个二进制位了。
  • (2) 检查字符串是否存在的过程。下面是检查字符串str是否被BitSet记录过的过程:对于字符串 str,分别计算 h(1,str),h(2,str)…… h(k,str)。然后检查 BitSet 的第 h(1,str)、h(2,str)…… h(k,str)位是否为1,若其中任何一位不为1则可以判定str一定没有被记录过。若全部位都是1,则 “认为” 字符串 str 存在。若一个字符串对应的 Bit 不全为1,则可以肯定该字符串一定没有被 Bloom Filter 记录过。(这是显然的,因为字符串被记录过,其对应的二进制位肯定全部被设为1了)。但是若一个字符串对应的Bit全为1,实际上是不能100%的肯定该字符串被 Bloom Filter 记录过的。(因为有可能该字符串的所有位都刚好是被其他字符串所对应)这种将该字符串划分错的情况,称为 false positive 。

Bloom Filter 参数选择

  • (1) 哈希函数选择。哈希函数的选择对性能的影响应该是很大的,一个好的哈希函数要能近似等概率的将字符串映射到各个Bit。选择k个不同的哈希函数比较麻烦,一种简单的方法是选择一个哈希函数,然后送入k个不同的参数。
  • (2) m,n,k 值,我们如何取值。定义:哈希函数的个数 k、位数组大小 m、加入的字符串数量 n 的关系。下面有一张表,m 表示内存大小(多少个位),n 表示去重对象的数量,k 表示seed的个数。例如我代码中申请了256M,即1<<31(m=2^31,约21.5亿。即 256 * 1024 *1024 * 8),seed设置了7个。看k=7那一列,当漏失率为8.56e-05时,m/n值为23。所以n = 21.5/23 = 0.93(亿),表示漏失概率为 8.56e-05 时,256M 内存可满足0.93亿条字符串的去重。同理当漏失率为 0.000112 时,256M内存可满足 0.98 亿条字符串的去重。

自定义 布隆过滤

import redis
import hashlib
from scrapy.dupefilters import BaseDupeFilter
from scrapy.utils.request import request_fingerprint


class MyBloomFilter(BaseDupeFilter):
    def __init__(self, server, key, bit_size=1 << 30, hash_count=5):
        self.server = server
        self.key = key
        self.bit_size = bit_size
        self.hash_count = hash_count

    @classmethod
    def from_settings(cls, settings):
        redis_url = settings.get('REDIS_URL')
        server = redis.from_url(redis_url)
        key = settings.get('BLOOM_FILTER_KEY', 'dupefilter')
        bit_size = settings.getint('BLOOM_FILTER_BIT_SIZE', 1 << 30)
        hash_count = settings.getint('BLOOM_FILTER_HASH_COUNT', 5)
        return cls(server, key, bit_size, hash_count)

    @classmethod
    def from_crawler(cls, crawler):
        return cls.from_settings(crawler.settings)

    def request_seen(self, request):
        fp = request_fingerprint(request)
        hashes = self.get_hashes(fp)
        for h in hashes:
            if not self.server.getbit(self.key, h):
                self.server.setbit(self.key, h, 1)
                return False
        return True

    def get_hashes(self, value):
        m = hashlib.md5()
        m.update(value.encode())
        h1 = int(m.hexdigest(), 16)
        h2 = self.hash2(h1)
        return [(h1 + i * h2) % self.bit_size for i in range(self.hash_count)]

    def hash2(self, value):
        return value % (self.bit_size - 1) + 1

settings.py

REDIS_URL = 'redis://127.0.0.1:6379'
REDIS_START_URLS_AS_SET = True
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = 'spider_demo.dupefilters.MyBloomFilter'

bloom filter 的 Python 包

pypi 直接搜索 bloom filter、Bloom:https://pypi.org/search/?q=bloom+filter

然后找时间最新的包即可,这里使用 profusion :pip install profusion

from profusion import Bloom

# 创建一个新的 Bloom filter
bf = Bloom(capacity=1000000, error_ratio=1e-5)

# 添加 元素
bf.add("apple")
bf.add("banana")
bf.add("carrot")

# 检查 元素是否在 过滤器中
print("apple" in bf)  # True
print("donut" in bf)  # False

# 保存布隆过滤到文件中
bf.save("bloom_filter.gz")

# 从文件导入布隆过滤
bf_loaded = Bloom(path="bloom_filter.gz")

# 检查元素是否在导入的布隆过滤中
print("banana" in bf_loaded)  # True
print("elderberry" in bf_loaded)  # False

集成到 scrapy-redis 中

安装:pip install scrapy-redis pybloom-live

创建一个布隆过滤器的类,用于管理布隆过滤器的初始化和操作。可以在项目目录中创建一个名为 bloom_filter.py 的文件,并添加以下内容:

# bloom_filter.py

from pybloom_live import BloomFilter
import redis

class RedisBloomFilter:
    def __init__(self, redis_host='localhost', redis_port=6379, redis_db=0, capacity=10000, error_rate=0.01):
        self.redis = redis.Redis(host=redis_host, port=redis_port, db=redis_db)
        self.bloom_filter = BloomFilter(capacity=capacity, error_rate=error_rate)
        
    def add(self, item):
        if item not in self.bloom_filter:
            self.bloom_filter.add(item)
            # 保存到 Redis
            self.redis.set(item, 1)

    def exists(self, item):
        return item in self.bloom_filter or self.redis.exists(item)

    def load_from_redis(self):
        keys = self.redis.keys()
        for key in keys:
            self.bloom_filter.add(key.decode('utf-8'))

settings.py 文件中,配置 Redis 和布隆过滤器的设置:

# settings.py

REDIS_URL = 'redis://localhost:6379'  # 替换为你的 Redis URL

# 其他 scrapy-redis 设置
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RDUPDupeFilter"

# 启用你的布隆过滤器
BLOOMFILTER_ENABLED = True

在爬虫中创建一个布隆过滤器的实例,并在每次请求之前检查 URL 是否已经存在。

# spiders/my_spider.py

import scrapy
from scrapy_redis.spiders import RedisSpider
from bloom_filter import RedisBloomFilter

class MySpider(RedisSpider):
    name = 'my_spider'
    redis_key = 'my_spider:start_urls'

    def __init__(self, *args, **kwargs):
        super(MySpider, self).__init__(*args, **kwargs)
        self.bloom_filter = RedisBloomFilter()
        self.bloom_filter.load_from_redis()

    def parse(self, response):
        # 处理响应数据
        item = {}
        item['url'] = response.url

        # 检查 URL 是否已经在布隆过滤器中
        if not self.bloom_filter.exists(response.url):
            self.bloom_filter.add(response.url)
            yield item
        
        # 添加后续请求(可选)
        next_page = response.css('a.next::attr(href)').get()
        if next_page:
            yield scrapy.Request(next_page, callback=self.parse)

集成到 scrapy-redis 中

实现 Bloom Filter 时,首先要保证不能破坏 Scrapy-Redis 分布式爬取的运行架构。所以需要修改 Scrapy-Redis 的源码,替换掉自带的去重类。同时 Bloom Filter 的实现需要借助于一个位数组,既然当前架构还是依赖于 Redis,那么位数组的维护直接使用 Redis 。

首先实现一个基本的散列算法,将一个值经过散列运算后映射到一个m位数组的某一位上

BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30


class HashMap(object):
    def __init__(self, m, seed):
        self.m = m
        self.seed = seed

    def hash(self, value):
        """
        Hash Algorithm
        :param value: Value
        :return: Hash Value
        """
        ret = 0
        for i in range(len(value)):
            ret += self.seed * ret + ord(value[i])
        return (self.m - 1) & ret


class BloomFilter(object):
    def __init__(self, server, key, bit=BLOOMFILTER_BIT, hash_number=BLOOMFILTER_HASH_NUMBER):
        """
            Initialize BloomFilter
        :param server: Redis Server
        :param key: BloomFilter Key
        :param bit: m = 2 ^ bit
        :param hash_number: the number of hash function
        """
        # default to 1 << 30 = 10,7374,1824 = 2^30 = 128MB, 
        # max filter 2^30/hash_number = 1,7895,6970 fingerprints
        self.m = 1 << bit
        self.seeds = range(hash_number)
        self.hash_map_list = [HashMap(self.m, seed) for seed in self.seeds]
        self.server = server
        self.key = key

    def exists(self, value):
        """
        if value exists
        :param value:
        :return:
        """
        if not value:
            return False
        exist = 1
        for hash_map in self.hash_map_list:
            offset = hash_map.hash(value)
            exist = exist & self.server.getbit(self.key, offset)
        return exist

    def insert(self, value):
        """
        add value to bloom
        :param value:
        :return:
        """
        for hash_map in self.hash_map_list:
            offset = hash_map.hash(value)
            self.server.setbit(self.key, offset, 1)


import redis
conn = redis.StrictRedis(host='127.0.0.1', port=6379)
bf = BloomFilter(conn, 'test_bf', 5, 6)
bf.insert('Hello')
bf.insert('World')
result = bf.exists('Hello')
print(bool(result))
result = bf.exists('Python')
print(bool(result))

这里新建了一个 HashMap 类。构造函数传入两个值,一个是 m 位数组的位数,另一个是种子值 seed。不同的散列函数需要有不同的 seed,这样可以保证不同的散列函数的结果不会碰撞。

在 hash()方法的实现中,value是要被处理的内容。这里遍历了value的每一位,并利用 ord() 方法取到每一位的ASCII码值,然后混淆 seed 进行迭代求和运算,最终得到一个数值。这个数值的结果就由 value 和 seed 唯一确定。我们再将这个数值和 m 进行按位与运算,即可获取到m位数组的映射结果,这样就实现了一个由 字符串 和 seed 来确定的散列函数。当 m 固定时,只要seed值相同,散列函数就是相同的,相同的value必然会映射到相同的位置。所以如果想要构造几个不同的散列函数,只需要改变其seed就好了。以上内容便是一个简易的散列函数的实现。

接下来就是实现 Bloom Filter。Bloom Filter里面需要用到k个散列函数,这里要对这几个散列函数指定相同的m值和不同的seed值,

由于我们需要亿级别的数据的去重,即前文介绍的算法中的n为1亿以上,散列函数的个数k大约取10左右的量级。而m>kn,这里m值大约保底在10亿,由于这个数值比较大,所以这里用移位操作来实现,传入位数bit,将其定义为30,然后做一个移位操作1<<30,相当于2的30次方,等于1073741824,量级也是恰好在10亿左右,由于是位数组,所以这个位数组占用的大小就是2^30 b=128 MB。开头我们计算过Scrapy-Redis集合去重的占用空间大约在2 GB左右,可见Bloom Filter的空间利用效率极高。

随后我们再传入散列函数的个数,用它来生成几个不同的seed。用不同的seed来定义不同的散列函数,这样我们就可以构造一个散列函数列表。遍历seed,构造带有不同seed值的HashMap对象,然后将HashMap对象保存成变量maps供后续使用。

另外,server就是Redis连接对象,key就是这个m位数组的名称。

接下来,我们要实现比较关键的两个方法:一个是判定元素是否重复的方法exists(),另一个是添加元素到集合中的方法insert()

首先看下insert()方法。Bloom Filter算法会逐个调用散列函数对放入集合中的元素进行运算,得到在m位位数组中的映射位置,然后将位数组对应的位置置1。这里代码中我们遍历了初始化好的散列函数,然后调用其hash()方法算出映射位置offset,再利用Redis的setbit()方法将该位置1。

在exists()方法中,我们要实现判定是否重复的逻辑,方法参数value为待判断的元素。我们首先定义一个变量exist,遍历所有散列函数对value进行散列运算,得到映射位置,用getbit()方法取得该映射位置的结果,循环进行与运算。这样只有每次getbit()得到的结果都为1时,最后的exist才为True,即代表value属于这个集合。如果其中只要有一次getbit()得到的结果为0,即m位数组中有对应的0位,那么最终的结果exist就为False,即代表value不属于这个集合。

Bloom Filter的实现就已经完成了, 下面 就是 用一个实例来测试一下。

这里首先定义了一个Redis连接对象,然后传递给Bloom Filter。为了避免内存占用过大,这里传的位数 bit 比较小,设置为5,散列函数的个数设置为6。

调用insert()方法插入Hello和World两个字符串,随后判断Hello和Python这两个字符串是否存在,最后输出它的结果,运行结果如下:

True
False

很明显,结果完全没有问题。这样我们就借助Redis成功实现了Bloom Filter的算法。

接下来继续修改Scrapy-Redis的源码,将它的dupefilter逻辑替换为Bloom Filter的逻辑。这里主要是修改RFPDupeFilter类的request_seen()方法,实现如下:

def request_seen(self, request):
    fp = self.request_fingerprint(request)    
    if self.bf.exists(fp):        
        return True
    self.bf.insert(fp)    
        return False

利用 request_fingerprint()方法获取Request的指纹,调用Bloom Filter的exists()方法判定该指纹是否存在。如果存在,则说明该Request是重复的,返回True,否则调用Bloom Filter的insert()方法将该指纹添加并返回False。这样就成功利用Bloom Filter替换了Scrapy-Redis的集合去重。

对于Bloom Filter的初始化定义,我们可以将__init__()方法修改为如下内容:

def __init__(self, server, key, debug, bit, hash_number):
    self.server = server
    self.key = key
    self.debug = debug
    self.bit = bit
    self.hash_number = hash_number
    self.logdupes = True
    self.bf = BloomFilter(server, self.key, bit, hash_number)

其中bithash_number需要使用from_settings()方法传递,修改如下:

@classmethod
def from_settings(cls, settings):
    server = get_redis_from_settings(settings)
    key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
    debug = settings.getbool('DUPEFILTER_DEBUG', DUPEFILTER_DEBUG)
    bit = settings.getint('BLOOMFILTER_BIT', BLOOMFILTER_BIT)
    hash_number = settings.getint('BLOOMFILTER_HASH_NUMBER', BLOOMFILTER_HASH_NUMBER)    
    return cls(server, key=key, debug=debug, bit=bit, hash_number=hash_number)

其中,常量DUPEFILTER_DEBUGBLOOMFILTER_BIT统一定义在defaults.py中,默认如下:

BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30

现在,我们成功实现了Bloom Filter和Scrapy-Redis的对接。

代码地址为:https://github.com/Python3WebSpider/ScrapyRedisBloomFilter

使用的方法和 Scrapy-Redis基本相似,在这里说明几个关键配置。

# 去重类,要使用Bloom Filter请替换DUPEFILTER_CLASS
DUPEFILTER_CLASS = "scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter"
# 散列函数的个数,默认为6,可以自行修改
BLOOMFILTER_HASH_NUMBER = 6
# Bloom Filter的bit参数,默认30,占用128MB空间,去重量级1亿
BLOOMFILTER_BIT = 30
  1. DUPEFILTER_CLASS是去重类,如果要使用Bloom Filter,则DUPEFILTER_CLASS需要修改为该包的去重类。
  2. BLOOMFILTER_HASH_NUMBER是Bloom Filter使用的散列函数的个数,默认为6,可以根据去重量级自行修改。
  3. BLOOMFILTER_BIT即前文所介绍的BloomFilter类的bit参数,它决定了位数组的位数。如果BLOOMFILTER_BIT为30,那么位数组位数为2的30次方,这将占用Redis 128 MB的存储空间,去重量级在1亿左右,即对应爬取量级1亿左右。如果爬取量级在10亿、20亿甚至100亿,请务必将此参数对应调高。

源代码附有一个测试项目,放在tests文件夹,该项目使用了ScrapyRedisBloomFilter来去重,Spider的实现如下:

from scrapy import Request, Spider

class TestSpider(Spider):
    name = 'test'
    base_url = 'https://www.baidu.com/s?wd='
 
    def start_requests(self):
        for i in range(10):
            url = self.base_url + str(i)            
            yield Request(url, callback=self.parse)
        
        # Here contains 10 duplicated Requests    
        for i in range(100): 
            url = self.base_url + str(i)            
            yield Request(url, callback=self.parse)    

    def parse(self, response):
        self.logger.debug('Response of ' + response.url)

start_requests()方法首先循环10次,构造参数为0~9的URL,然后重新循环了100次,构造了参数为0~99的URL。那么这里就会包含10个重复的Request,我们运行项目测试一下:

scrapy crawl test

最后的输出结果如下:

{'bloomfilter/filtered': 10, 
'downloader/request_bytes': 34021, 
'downloader/request_count': 100, 
'downloader/request_method_count/GET': 100, 
'downloader/response_bytes': 72943, 
'downloader/response_count': 100, 
'downloader/response_status_count/200': 100, 
'finish_reason': 'finished', 
'finish_time': datetime.datetime(2017, 8, 11, 9, 34, 30, 419597), 
'log_count/DEBUG': 202, 
'log_count/INFO': 7, 
'memusage/max': 54153216, 
'memusage/startup': 54153216, 
'response_received_count': 100, 
'scheduler/dequeued/redis': 100, 
'scheduler/enqueued/redis': 100, 
'start_time': datetime.datetime(2017, 8, 11, 9, 34, 26, 495018)}

最后统计的第一行的结果:'bloomfilter/filtered': 10,

这就是 Bloom Filter 过滤后的统计结果,它的过滤个数为10个,也就是它成功将重复的10个Reqeust 识别出来了,测试通过。

scrapy_redis 去重( 7亿数据 )

原文链接:https://blog.csdn.net/Bone_ACE/article/details/53099042

Scrapy_Redis_Bloomfilter:https://github.com/LiuXingMing/Scrapy_Redis_Bloomfilter

scrapy 是默认开启了去重的,同时 scrapy_redis 会用自己 scheduler 替代 scrapy 框架的 scheduler 进行任务调度,所以直接去 scrapy_redis 模块下查看 scheduler.py 源码即可。

在 open() 方法中 self.df = load_object(self.dupefilter_cls).from_spider(spider)

  • load_object(self.dupefilter_cls) 是根据对象的绝对路径而载入一个对象并返回,self.dupefilter_cls 就是 SCHEDULER_DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter',
  • from_spider(spider) 是返回一个  RFPDupeFilter类 的实例。

再看下面的 enqueue_request() 方法,

里面有句 if not request.dont_filter and self.df.request_seen(request) ,self.df.request_seen()这就是用来去重的了。按住Ctrl再左键点击request_seen查看它的代码,可看到下面的代码:

首先得到一个 request 的指纹,然后使用 Redis 的 set 保存指纹。可见 scrapy_redis 是利用 set 数据结构来去重的,去重的对象是 request 的 fingerprint。至于这个 fingerprint 到底是什么,可以再深入去看 request_fingerprint() 方法的源码(其实就是用 hashlib.sha1() 对 request 对象的某些字段信息进行压缩)。我们用调试也可以看到,其实 fp 就是 request 对象加密压缩后的一个字符串(40个字符,0~f)。

可以看出,想要实现布隆过滤,只要在 request_seen() 方法上修改即可。代码如下:

def request_seen(self, request):
    fp = request_fingerprint(request)
    if self.bf.isContains(fp):    # 如果已经存在
        return True
    else:
        self.bf.insert(fp)
        return False

scrapy_redis 种子优化

继 scrapy_redis 去重优化(7亿条数据)之后,Redis 的内存消耗降了许多,然而还不满足。这次对 scrapy_redis 的种子队列作了一些优化(严格来说并不能用上“优化”这词,其实就是结合自己的项目作了一些改进,对本项目能称作优化,对 scrapy_redis 未必是个优化)。

scrapy_redis 默认是将 Request 对象序列化后(变成一条字符串)存入 Redis 作为种子,需要的时候再取出来进行反序列化,还原成一个 Request 对象。

现在的问题是:序列化后的字符串太长,短则几百个字符,长则上千。我的爬虫平时至少也要维护包含几千万种子的种子队列,占用内存在20G~50G之间(Centos)。想要缩减种子的长度,这样不仅 Redis 的内存消耗会降低,各个 slaver 从 Redis 拿种子的速度也会有所提高,从而整个分布式爬虫系统的抓取速度也会有所提高(效果视具体情况而定,要看爬虫主要阻塞在哪里)。

1、首先看调度器,即 scrapy_redis 模块下的 scheduler.py 文件,可以看到 enqueue_request()方法和 next_request()方法就是种子 入队列出队列 的地方,self.queue 指的是我们在 setting.py 里面设定的 SCHEDULER_QUEUE_CLASS 值,常用的是 'scrapy_redis.queue.SpiderPriorityQueue'

2、进入 scrapy_redis 模块下的 queue.py 文件,SpiderPriorityQueue 类的代码如下:

class SpiderPriorityQueue(Base):
    """Per-spider priority queue abstraction using redis' sorted set"""

    def __len__(self):
        """Return the length of the queue"""
        return self.server.zcard(self.key)

    def push(self, request):
        """Push a request"""
        data = self._encode_request(request)
        pairs = {data: -request.priority}
        self.server.zadd(self.key, **pairs)

    def pop(self, timeout=0):
        """
        Pop a request
        timeout not support in this queue class
        """
        pipe = self.server.pipeline()
        pipe.multi()
        pipe.zrange(self.key, 0, 0).zremrangebyrank(self.key, 0, 0)
        results, count = pipe.execute()
        if results:
            return self._decode_request(results[0])

可以看到,上面用到了 Redis 的 zset 数据结构(它可以给种子加优先级),在进 Redis 之前用 _encode_request() 方法将 Request 对象转成字符串,_encode_request() 和 _decode_request 是 Base类下面的两个方法:

    def _encode_request(self, request):
        """Encode a request object"""
        return pickle.dumps(request_to_dict(request, self.spider), protocol=-1)

    def _decode_request(self, encoded_request):
        """Decode an request previously encoded"""
        return request_from_dict(pickle.loads(encoded_request), self.spider)

可以看到,这里先将 Request 对象转成一个字典,再将字典序列化成一个字符串。Request 对象怎么转成一个字典呢?看下面的代码,一目了然。

def request_to_dict(request, spider=None):
    """Convert Request object to a dict.

    If a spider is given, it will try to find out the name of the spider method
    used in the callback and store that as the callback.
    """
    cb = request.callback
    if callable(cb):
        cb = _find_method(spider, cb)
    eb = request.errback
    if callable(eb):
        eb = _find_method(spider, eb)
    d = {
        'url': to_unicode(request.url),  # urls should be safe (safe_string_url)
        'callback': cb,
        'errback': eb,
        'method': request.method,
        'headers': dict(request.headers),
        'body': request.body,
        'cookies': request.cookies,
        'meta': request.meta,
        '_encoding': request._encoding,
        'priority': request.priority,
        'dont_filter': request.dont_filter,
    }
    return d

调试截图:( 注:d 为 Request 对象转过来的字典,data 为字典序列化后的字符串。 )

3、了解完 scrapy_redis 默认的种子处理方式,现在针对自己的项目作一些调整。我的是一个全网爬虫,每个种子需要记录的信息主要有两个:url 和 callback 函数名。此时我们选择不用序列化,直接用简单粗暴的方式,将 callback 函数名和 url 拼接成一条字符串作为一条种子,这样种子的长度至少会减少一半。另外我们的种子并不需要设优先级,所以也不用 zset 了,改用 Redis 的list。以下是我新建的 SpiderSimpleQueue 类,加在 queue.py 中。如果在 settings.py 里将

SCHEDULER_QUEUE_CLASS 值设置成  'scrapy_redis.queue.SpiderSimpleQueue' 即可使用我这种野蛮粗暴的种子。

from scrapy.utils.reqser import request_to_dict, request_from_dict, _find_method

class SpiderSimpleQueue(Base):
    """ url + callback """

    def __len__(self):
        """Return the length of the queue"""
        return self.server.llen(self.key)

    def push(self, request):
        """Push a request"""
        url = request.url
        cb = request.callback
        if callable(cb):
            cb = _find_method(self.spider, cb)
            data = '%s--%s' % (cb, url)
            self.server.lpush(self.key, data)

    def pop(self, timeout=0):
        """Pop a request"""
        if timeout > 0:
            data = self.server.brpop(self.key, timeout=timeout)
            if isinstance(data, tuple):
                data = data[1]
        else:
            data = self.server.rpop(self.key)
        if data:
            cb, url = data.split('--', 1)
            try:
                cb = getattr(self.spider, str(cb))
                return Request(url=url, callback=cb)
            except AttributeError:
                raise ValueError("Method %r not found in: %s" % (cb, self.spider))

__all__ = ['SpiderQueue', 'SpiderPriorityQueue', 'SpiderSimpleQueue', 'SpiderStack']

4、另外需要提醒的是,如果 scrapy 中加了中间件 process_request(),当 yield 一个 Request 对象的时候,scrapy_redis 会直接将它丢进 Redis 种子队列,未执行 process_requset();需要一个 Request 对象的时候,scrapy_redis 会从 Redis 队列中取出种子,此时才会处理 process_request()方法,接着去抓取网页。
所以并不需要担心 process_request()里面添加的 Cookie 在 Redis 中放太久会失效,因为进 Redis 的时候它压根都还没执行process_request()。事实上 Request 对象序列化的时候带上的字段很多都是没用的默认字段,很多爬虫都可以用 “callback+url” 的方式来优化种子。

5、最后,在 Scrapy_Redis_Bloomfilter ( https://github.com/LiuXingMing/Scrapy_Redis_Bloomfilter)这个 demo 中我已作了修改,大家可以试试效果。

经过以上优化,Redis 的内存消耗从 42G 降到了 27G!里面包含7亿多条种子的去重数据 和 4000W+ 条种子。并且六台子爬虫的抓取速度都提升了一些。

两次优化,内存消耗从160G+降到现在的27G,效果也是让人满意!

原文链接:http://blog.csdn.net/bone_ace/article/details/53306629

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值