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 的源码流程,我们可以了解到:
- 请求生成: 从
start_urls生成初始请求并处理响应。 - 规则匹配: 根据规则提取链接并生成新请求。
- 响应处理: 通过用户定义的方法处理每个请求的响应。
- 数据提取: 用户可以自定义数据提取逻辑,提取所需信息。
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 模拟登录,可以通过以下步骤实现:
- 定义登录页面的 URL 和登录所需的表单数据。
- 在爬虫的
start_requests方法中发送登录请求,并处理登录响应。 - 在登录成功后,继续爬取其他页面。
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)
其中bit和hash_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_DEBUG和BLOOMFILTER_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
- DUPEFILTER_CLASS是去重类,如果要使用Bloom Filter,则DUPEFILTER_CLASS需要修改为该包的去重类。
- BLOOMFILTER_HASH_NUMBER是Bloom Filter使用的散列函数的个数,默认为6,可以根据去重量级自行修改。
- 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,效果也是让人满意!
 --- 追踪CrawlSpider、布隆过滤Bloom Filter、去重、解析&spm=1001.2101.3001.5002&articleId=107989280&d=1&t=3&u=3205e287d9384c36b7ba50c6586f2d08)
1886

被折叠的 条评论
为什么被折叠?



