查看原文
其他

如何构建一个通用的垂直爬虫平台?

Kaito 水滴与银弹 2021-08-08
阅读本文大约需要15~20分钟。 
本文章内容较多,非常干货!如果手机阅读体验不好,建议先收藏后到 PC 端阅读。
之前做爬虫时,在公司设计开发了一个通用的垂直爬虫平台,后来在公司做了内部的技术分享,这篇文章把整个爬虫平台的设计思路整理了一下,分享给大家。
写一个爬虫很简单,写一个可持续稳定运行的爬虫也不难,但如何构建一个通用化的垂直爬虫平台?
这篇文章,我就来和你分享一下,一个通用垂直爬虫平台的构建思路。

爬虫简介

首先介绍一下,什么是爬虫?
搜索引擎是这样定义的:
网络爬虫(又被称为网页蜘蛛,网络机器人),是一种按照一定的规则,自动地抓取网页信息的程序或者脚本。
很简单,爬虫就是指定规则自动采集数据的程序脚本,目的在于拿到想要的数据。
而爬虫主要分为两大类:
  • 通用爬虫(搜索引擎)
  • 垂直爬虫(特定领域)
由于第一类的开发成本较高,所以只有搜索引擎公司在做,如谷歌、百度等。
而大多数企业在做的都是第二类,成本低、数据价值高。
例如一家做电商的公司只需要电商领域有价值的数据,那开发一个只采集电商领域数据的爬虫平台,意义较大。
我要和你分享的主要是针对第二类,垂直爬虫平台的设计思路。

如何写爬虫

首先,从最简单的开始,我们先了解一下如何写一个爬虫?

简单爬虫

开发爬虫最快的语言一般是 Python,它的代码写起来非常少。我们以抓取豆瓣书籍页面为例,来写一个简单的程序。
# coding: utf8

"""简单爬虫"""

import requests
from lxml import etree

def main():
    # 1. 定义页面URL和解析规则
    crawl_urls = [
        'https://book.douban.com/subject/25862578/',
        'https://book.douban.com/subject/26698660/',
        'https://book.douban.com/subject/2230208/'
    ]
    parse_rule = "//div[@id='wrapper']/h1/span/text()"

    for url in crawl_urls:
        # 2. 发起HTTP请求
        response = requests.get(url)

        # 3. 解析HTML
        result = etree.HTML(response.text).xpath(parse_rule)[0]

        # 4. 保存结果
        print result

if __name__ == '__main__':
    main()
这个爬虫比较简单,大致流程为:
  1. 定义页面URL和解析规则
  2. 发起HTTP请求
  3. 解析HTML,拿到数据
  4. 保存数据
任何爬虫,要想获取网页上的数据,都是经过这几步。
当然,这个简单爬虫效率比较低,是采用同步抓取的方式,只能抓完一个网页,再去抓下一个,有没有可以提高效率的方式呢?

异步爬虫

我们进行优化,由于爬虫的抓取请求都是阻塞在网络 IO 上,所以我们可以使用异步的方式来优化,例如多线程或协程并行抓取网页数据,这里用 Python 的协程来实现。
# coding: utf8

"""协程版本爬虫,提高抓取效率"""

from gevent import monkey
monkey.patch_all()

import requests
from lxml import etree
from gevent.pool import Pool

def main():
    # 1. 定义页面URL和解析规则
    crawl_urls = [
        'https://book.douban.com/subject/25862578/',
        'https://book.douban.com/subject/26698660/',
        'https://book.douban.com/subject/2230208/'
    ]
    rule = "//div[@id='wrapper']/h1/span/text()"

    # 2. 抓取
    pool = Pool(size=10)
    for url in crawl_urls:
        pool.spawn(crawl, url, rule)

    pool.join()

def crawl(url, rule):
    # 3. 发起HTTP请求
    response = requests.get(url)

    # 4. 解析HTML
    result = etree.HTML(response.text).xpath(rule)[0]

    # 5. 保存结果
    print result

if __name__ == '__main__':
    main()
经过优化,我们完成了异步版本的爬虫代码。
有了这些基础知识之后,我们看一个完整的例子,如何抓取一个整站数据?

整站爬虫

# coding: utf8

"""整站爬虫"""

from gevent import monkey
monkey.patch_all()

from urlparse import urljoin

import requests
from lxml import etree
from gevent.pool import Pool
from gevent.queue import Queue

base_url = 'https://book.douban.com'

# 种子URL
start_url = 'https://book.douban.com/tag/?view=type&icn=index-sorttags-all'

# 解析规则
rules = {
    # 标签页列表
    'list_urls': "//table[@class='tagCol']/tbody/tr/td/a/@href",
    # 详情页列表
    'detail_urls': "//li[@class='subject-item']/div[@class='info']/h2/a/@href",
    # 页码
    'page_urls': "//div[@id='subject_list']/div[@class='paginator']/a/@href",
    # 书名
    'title': "//div[@id='wrapper']/h1/span/text()",
}

# 定义队列
list_queue = Queue()
detail_queue = Queue()

# 定义协程池
pool = Pool(size=10)

def crawl(url):
    """首页"""
    response = requests.get(url)
    list_urls = etree.HTML(response.text).xpath(rules['list_urls'])
    for list_url in list_urls:
        list_queue.put(urljoin(base_url, list_url))

def list_loop():
    """采集列表页"""
    while True:
        list_url = list_queue.get()
        pool.spawn(crawl_list_page, list_url)

def detail_loop():
    """采集详情页"""
    while True:
        detail_url = detail_queue.get()
        pool.spawn(crawl_detail_page, detail_url)

def crawl_list_page(list_url):
    """采集列表页"""
    html = requests.get(list_url).text
    detail_urls = etree.HTML(html).xpath(rules['detail_urls'])
    # 详情页
    for detail_url in detail_urls:
        detail_queue.put(urljoin(base_url, detail_url))

    # 下一页
    list_urls = etree.HTML(html).xpath(rules['page_urls'])
    for list_url in list_urls:
        list_queue.put(urljoin(base_url, list_url))

def crawl_detail_page(list_url):
    """采集详情页"""
    html = requests.get(list_url).text
    title = etree.HTML(html).xpath(rules['title'])[0]
    print title

def main():
    # 1. 标签页
    crawl(start_url)
    # 2. 列表页
    pool.spawn(list_loop)
    # 3. 详情页
    pool.spawn(detail_loop)
    # 开始采集
    pool.join()

if __name__ == '__main__':
    main()
我们想要抓取豆瓣图书的整站数据,执行的流程是:
  1. 找到入口,也就是从书籍标签页进入,提取所有标签 URL
  2. 进入每个标签页,提取所有列表 URL
  3. 进入每个列表页,提取每一页的详情URL和下一页列表 URL
  4. 进入每个详情页,拿到书籍信息
  5. 如此往复循环,直到数据抓取完毕
这就是抓取一个整站的思路,很简单,无非就是分析我们浏览网站的行为轨迹,用程序来进行自动化的请求、抓取。
理想情况下,我们应该能够拿到整站的数据,但实际情况是,对方网站往往会采取防爬虫措施,在抓取一段时间后,我们的 IP 就会被封禁。
那如何突破这些防爬措施,拿到数据呢?我们继续优化代码。

防反爬的整站爬虫

# coding: utf8

"""防反爬的整站爬虫"""

from gevent import monkey
monkey.patch_all()

import random
from urlparse import urljoin

import requests
from lxml import etree
import gevent
from gevent.pool import Pool
from gevent.queue import Queue

base_url = 'https://book.douban.com'

# 种子URL
start_url = 'https://book.douban.com/tag/?view=type&icn=index-sorttags-all'

# 解析规则
rules = {
    # 标签页列表
    'list_urls': "//table[@class='tagCol']/tbody/tr/td/a/@href",
    # 详情页列表
    'detail_urls': "//li[@class='subject-item']/div[@class='info']/h2/a/@href",
    # 页码
    'page_urls': "//div[@id='subject_list']/div[@class='paginator']/a/@href",
    # 书名
    'title': "//div[@id='wrapper']/h1/span/text()",
}

# 定义队列
list_queue = Queue()
detail_queue = Queue()

# 定义协程池
pool = Pool(size=10)

# 定义代理池
proxy_list = [
    '118.190.147.92:15524',
    '47.92.134.176:17141',
    '119.23.32.38:20189',
]

# 定义UserAgent
user_agent_list = [
    'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36',
    'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A',
    'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0) like Gecko',
]

def fetch(url):
    """发起HTTP请求"""
    proxies = random.choice(proxy_list)
    user_agent = random.choice(user_agent_list)
    headers = {'User-Agent': user_agent}
    html = requests.get(url, headers=headers, proxies=proxies).text
    return html

def parse(html, rule):
    """解析页面"""
    return etree.HTML(html).xpath(rule)

def crawl(url):
    """首页"""
    html = fetch(url)
    list_urls = parse(html, rules['list_urls'])
    for list_url in list_urls:
        list_queue.put(urljoin(base_url, list_url))

def list_loop():
    """采集列表页"""
    while True:
        list_url = list_queue.get()
        pool.spawn(crawl_list_page, list_url)

def detail_loop():
    """采集详情页"""
    while True:
        detail_url = detail_queue.get()
        pool.spawn(crawl_detail_page, detail_url)

def crawl_list_page(list_url):
    """采集列表页"""
    html = fetch(list_url)
    detail_urls = parse(html, rules['detail_urls'])

    # 详情页
    for detail_url in detail_urls:
        detail_queue.put(urljoin(base_url, detail_url))

    # 下一页
    list_urls = parse(html, rules['page_urls'])
    for list_url in list_urls:
        list_queue.put(urljoin(base_url, list_url))

def crawl_detail_page(list_url):
    """采集详情页"""
    html = fetch(list_url)
    title = parse(html, rules['title'])[0]
    print title

def main():
    # 1. 首页
    crawl(start_url)
    # 2. 列表页
    pool.spawn(list_loop)
    # 3. 详情页
    pool.spawn(detail_loop)
    # 开始采集
    pool.join()

if __name__ == '__main__':
    main()
这个版本的代码与之前不同的是,在发起 HTTP 请求时,加上了随机代理 IP 和请求头 UserAgent,这也是突破防爬措施的常用手段。使用这些手段,加上一些质量高的代理 IP,应对一些小网站的数据抓取,不在话下。
当然,这里只为了展示一步步写爬虫、优化爬虫的思路,来达到抓取数据的目的,现实情况的抓取与反爬比想象中的更复杂,需要具体场景具体分析。

现有问题

经过上面这几步,我们想要哪个网站的数据,分析网站网页结构,写出代码应该不成问题。
但是,抓几个网站可以这么写,但抓几十个、几百个网站,你还能写下去吗?
当我们要采集的网站越来越多,编写的爬虫脚本也会越来越多,维护起来也会变得困难。由此暴露出来的问题包括:
  • 爬虫脚本繁多,管理和维护困难
  • 爬虫规则定义零散,可能会重复开发
  • 爬虫都是后台脚本,没有监控
  • 爬虫脚本输出的数据格式不统一,可能是文件,也可能也数据库
  • 业务要想使用爬虫的数据比较困难,没有统一的对接入口
这些问题都是我们在爬虫越写越多的情况下,难免会遇到的问题。
此时,我们迫切需要一个更好的解决方案,来更好地开发爬虫,所以爬虫平台应运而生。
那么如何设计一个通用化的垂直爬虫平台呢?

平台架构

我们来分析每个爬虫的共同点,结果发现:写一个爬虫无非就是规则、抓取、解析、入库这几步,那我们可不可以把每一块分别拆开呢?
根据这个思路,我们可以把爬虫平台设计成如下图:

我们的爬虫平台包括的模块有
  • 配置服务:包括抓取页面配置、解析规则配置、数据清洗配置
  • 采集服务:只专注网页的下载,并配置防爬策略
  • 代理服务:持续提供稳定、可用的代理 IP
  • 清洗服务:针对爬虫采集到的数据进行进一步清洗和规整
  • 数据服务:爬虫数据的展示,以及业务系统对接
我们把一个爬虫的每一个环节,拆开做成一个个单独的服务模块,各模块各司其职,每个模块之间通过 API 或 消息队列进行通信。
这样做的好处是,每个模块维护只维护自己领域的功能,而且每个模块可独立升级和优化,不影响其他模块。
下面我们来看一下每个模块具体是如何设计的。

详细设计

配置服务

配置服务模块,此模块主要包括采集 URL 的配置、页面解析规则的配置、数据清洗规则的配置。
我们把爬虫的规则从爬虫脚本中抽离出来,单独配置与维护,这样做的好处是便于重用和管理
由于此模块只专注配置管理,那我们可以对配置规则进一步拆开,可以支持各种方式的数据解析模式,主要包含以下几种:
  • 正则解析规则
  • CSS解析规则
  • XPATH解析规则
每种解析规则模式,只配置对应的表达式即可。
采集服务可以写一个配置解析器,与配置服务进行对接,这个配置解析器内部实现各种模式具体的解析逻辑。
数据清洗规则配置,主要包含每个页面采集数据后,针对这个页面字段做进一步清洗和规整化的配置规则。例如采集服务抓取到的数据包含特殊字符,在采集服务中不会做进一步处理,而是放到清洗服务中去处理,具体的清洗规则可以自定义,常见的有删除某些特殊字符、特殊字段类型转换等等。

采集服务

此服务模块比较纯粹,就是写爬虫逻辑。我们可以像之前那样开发、调试、运行爬虫脚本那样,在此模块来开发和调试爬虫逻辑。
但之前的方式只能在命令行脚本中编写爬虫程序,然后调试运行,有没有一种好的方案可以把它做成可视化的呢?
我们调研了市面上 Python 语言实现的,比较好的爬虫框架,发现 pyspider 符合我们的需求,此框架的特点:
  • 支持分布式
  • 配置可视化
  • 可周期采集
  • 支持优先级
  • 任务可监控
pyspider架构图如下:

正所谓站在巨人的肩膀上,这个框架基本可以满足我们的需求,但为了更好地实现我们的爬虫平台,我们决定对其进行二次开发,并增强一些组件,使爬虫开发成本更低,更符合我们的业务规则。
二次开发的功能主要包括:
  • 开发配置解析器,对接配置服务,可以解析配置服务的多种规则模式
  • spider handler模块定制爬虫模板,并把爬虫任务进行分类,定义成模板,降低开发成本
  • fetcher模块新增代理 IP 调度机制,对接代理服务,并增加代理 IP 调度策略
  • result_worker模块把输出结果定制化,用来对接清洗服务
基于这个开源框架,并且增强其组件的方式,我们可以做出一个分布式、可视化、任务可监控、可生成爬虫模板的采集服务模块。
这个模块的功能,只专注于网页数据的采集。

代理服务

做爬虫的都知道,代理是突破防抓的常用手段,如何获取稳定、持续的代理呢?
代理服务这个模块,就是用来实现这个功能的。
此模块内部维护代理 IP 的质量和数量,并输出给采集服务,供其采集使用。
该模块主要包括两部分:
  • 免费代理
  • 付费代理

免费代理

免费代理 IP 主要由我们自己的代理采集程序采集获得,大致思路为:
  • 收集代理源
  • 定时采集代理
  • 测试代理
  • 输出可用代理
具体的实现逻辑可以参考我之前写的这篇文章:如何搭建一个爬虫代理服务?

付费代理

免费代理的质量和稳定性相对较差,对于采集防爬比较厉害的网站,还是不够用。
这时我们会购买一些付费代理,专门用于采集这类防爬的网站,此代理 IP 一般为高匿代理,并定时更新。
免费代理 IP + 付费代理 IP,通过 API 的方式提供给采集服务。

清洗服务

清洗服务这个模块比较简单,主要接收采集服务输出的数据,然后根据对应的规则执行清洗逻辑。
例如网页字段与数据库字段归一转换,特殊字段清洗定制化等等。
这个服务模块运行了很多 Worker,最终把输出结果输送到数据服务。

数据服务

数据服务这个模块,会接收最终清洗后的结构化数据,统一入库。且针对其他业务系统需要的数据进行统一推送输出:
主要功能包括:
  • 数据平台展示
  • 数据推送
  • 数据API

解决的问题

好了,经过以上爬虫平台的构建,我们基本解决了最开始困扰的几个问题,现在的爬虫平台可以实现的功能包括:
  • 爬虫脚本统一管理、配置可视化
  • 爬虫模板快速生成爬虫代码,降低开发成本
  • 采集进度可监控、易跟踪
  • 采集的数据统一输出
  • 业务系统使用爬虫数据更便捷

爬虫技巧

最后,分享一下做爬虫时候的一些技巧,从整体上来说,其实核心思想就一个:尽可能地模拟人的行为
主要包括以下几方面:
  • 随机 UserAgent 模拟不同的客户端(github有UserAgent库,非常全面)
  • 随机代理 IP(高匿代理 + 代理调度策略)
  • Cookie池(针对需要登录的采集行为)
  • JavaScript渲染页面(使用无界面浏览器加载网页获取数据)
  • 验证码识别(OCR、机器学习)
当然,做爬虫是一个相互博弈的过程,有时没必要硬碰硬,遇到问题换个思路也是一种解决办法。例如,对方的移动客户端防抓厉害,那去看一看对方的PC站可不可以搞一下?WAP端是否可以尝试一下?在有限的成本拿到数据才是爬虫的目的。
爬虫做的越来越多时,你就会发现,这是一个策略和技巧同样重要的领域。
以上就是构建一个垂直爬虫平台的设计思路,从最简单的爬虫脚本,到写越来越多的爬虫,到难以维护,再到整个爬虫平台的构建,一步步都是遇到问题解决问题的产物,在我们真正发现核心问题时,解决思路也就不难了。


近期文章:




长按关注「水滴与银弹」公众号,7年资深后端研发,和你分享更多优质技术干货。

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存