(biezhi) 设计和实现一款轻量级的爬虫框架

本贴最后更新于 2264 天前,其中的信息可能已经时移世异

说起爬虫,大家能够想起 Python 里赫赫有名的 Scrapy 框架, 在本文中我们参考这个设计思想使用 Java 语言来实现一款自己的爬虫框(lun)架(zi)。 我们从起点一步一步分析爬虫框架的诞生过程。

我把这个爬虫框架的源码放在 github 上,里面有几个例子可以运行。

关于爬虫的一切

下面我们来介绍什么是爬虫?以及爬虫框架的设计和遇到的问题。

什么是爬虫?

“爬虫”不是一只生活在泥土里的小虫子,网络爬虫(web crawler),也叫网络蜘蛛(spider),是一种用来自动浏览网络上内容的机器人。 爬虫访问网站的过程会消耗目标系统资源,很多网站不允许被爬虫抓取(这就是你遇到过的 robots.txt 文件, 这个文件可以要求机器人只对网站的一部分进行索引,或完全不作处理)。 因此在访问大量页面时,爬虫需要考虑到规划、负载,还需要讲“礼貌”(大兄弟,慢点)。

互联网上的页面极多,即使是最大的爬虫系统也无法做出完整的索引。因此在公元 2000 年之前的万维网出现初期,搜索引擎经常找不到多少相关结果。 现在的搜索引擎在这方面已经进步很多,能够即刻给出高质量结果。

网络爬虫会遇到的问题

既然有人想抓取,就会有人想防御。网络爬虫在运行的过程中会遇到一些阻碍,在业内称之为 反爬虫策略 我们来列出一些常见的。

访问频率限制

Header 头信息校验

动态页面生成

IP 地址限制

Cookie 限制(或称为登录限制)

验证码限制

等等…

这些是传统的反爬虫手段,当然未来也会更加先进,技术的革新永远会带动多个行业的发展,毕竟 AI 的时代已经到来, 爬虫和反爬虫的斗争一直持续进行。

爬虫框架要考虑什么

设计我们的框架

我们要设计一款爬虫框架,是基于 Scrapy 的设计思路来完成的,先来看看在没有爬虫框架的时候我们是如何抓取页面信息的。 一个常见的例子是使用 HttpClient 包或者 Jsoup 来处理,对于一个简单的小爬虫而言这足够了。

下面来演示一段没有爬虫框架的时候抓取页面的代码,这是我在网络上搜索的

  public class Reptile {

  public static void main(String[] args) {

  //传入你所要爬取的页面地址

  String url1 = "";

  //创建输入流用于读取流

  InputStream is = null;

  //包装流,加快读取速度

  BufferedReader br = null;

  //用来保存读取页面的数据.

  StringBuffer html = new StringBuffer();

  //创建临时字符串用于保存每一次读的一行数据,然后html调用append方法写入temp;

  String temp = "";

  try {

  //获取URL;

  URL url2 = new URL(url1);

  //打开流,准备开始读取数据;

  is = url2.openStream();

  //将流包装成字符流,调用br.readLine()可以提高读取效率,每次读取一行;

  br= new BufferedReader(new InputStreamReader(is));

  //读取数据,调用br.readLine()方法每次读取一行数据,并赋值给temp,如果没数据则值==null,跳出循环;

  while ((temp = br.readLine()) != null) {

  //将temp的值追加给html,这里注意的时String跟StringBuffere的区别前者不是可变的后者是可变的;

  html.append(temp);

  }

  //接下来是关闭流,防止资源的浪费;

  if(is != null) {

  is.close();

  is = null;

  }

  //通过Jsoup解析页面,生成一个document对象;

  Document doc = Jsoup.parse(html.toString());

  //通过class的名字得到(即XX),一个数组对象Elements里面有我们想要的数据,至于这个div的值呢你打开浏览器按下F12就知道了;

  Elements elements = doc.getElementsByClass("XX");

  for (Element element : elements) {

  //打印出每一个节点的信息;你可以选择性的保留你想要的数据,一般都是获取个固定的索引;

  System.out.println(element.text());

  }

  } catch (MalformedURLException e) {

  e.printStackTrace();

  } catch (IOException e) {

  e.printStackTrace();

  }

  }

从如此丰富的注释中我感受到了作者的耐心,我们来分析一下这个爬虫在干什么?

输入一个要爬取的 URL 地址

通过 JDK 原生 API 发送网络请求获取页面信息(这里没有使用 HttpClient)

使用 Jsoup 解析 DOM

处理自己需要的数据

将它们输出在控制台

大概就是这样的步骤,代码也非常简洁,我们设计框架的目的是将这些流程统一化,把通用的功能进行抽象,减少重复工作。 还有一些没考虑到的因素添加进去,那么设计爬虫框架要有哪些组成呢?

URL 管理

网页下载器

爬虫调度器

网页解析器

数据处理器

分别来解释一下每个组成的作用是什么。

URL 管理器

爬虫框架要处理很多的 URL,我们需要设计一个队列存储所有要处理的 URL,这种先进先出的数据结构非常符合这个需求。 将所有要下载的 URL 存储在待处理队列中,每次下载会取出一个,队列中就会少一个。我们知道有些 URL 的下载会有反爬虫策略, 所以针对这些请求需要做一些特殊的设置,进而可以对 URL 进行封装抽出 Request。

网页下载器

在前面的简单例子中可以看出,如果没有网页下载器,用户就要编写网络请求的处理代码,这无疑对每个 URL 都是相同的动作。 所以在框架设计中我们直接加入它就好了,至于使用什么库来进行下载都是可以的,你可以用 httpclient 也可以用 okhttp, 在本文中我们使用一个超轻量级的网络请求库 oh-my-request (没错,就是在下搞的)。 优秀的框架设计会将这个下载组件置为可替换,提供默认的即可。

爬虫调度器

调度器和我们在开发 web 应用中的控制器是一个类似的概念,它用于在下载器、解析器之间做流转处理。 解析器可以解析到更多的 URL 发送给调度器,调度器再次的传输给下载器,这样就会让各个组件有条不紊的进行工作。

网页解析器

我们知道当一个页面下载完成后就是一段 HTML 的 DOM 字符串表示,但还需要提取出真正需要的数据, 以前的做法是通过 String 的 API 或者正则表达式的方式在 DOM 中搜寻,这样是很麻烦的,框架 应该提供一种合理、常用、方便的方式来帮助用户完成提取数据这件事儿。常用的手段是通过 xpath 或者 css 选择器从 DOM 中进行提取,而且学习这项技能在几乎所有的爬虫框架中都是适用的。

数据处理器

普通的爬虫程序中是把 网页解析器 和 数据处理器 合在一起的,解析到数据后马上处理。 在一个标准化的爬虫程序中,他们应该是各司其职的,我们先通过解析器将需要的数据解析出来,可能是封装成对象。 然后传递给数据处理器,处理器接收到数据后可能是存储到数据库,也可能通过接口发送给老王。

基本特性

上面说了这么多,我们设计的爬虫框架有以下几个特性,没有做到大而全,可以称得上轻量迷你挺好用。

易于定制: 很多站点的下载频率、浏览器要求是不同的,爬虫框架需要提供此处扩展配置

多线程下载: 当 CPU 核数多的时候多线程下载可以更快完成任务

支持 XPath 和 CSS 选择器解析

架构图

整个流程和 Scrapy 是一致的,但简化了一些操作

引擎(Engine): 用来处理整个系统的数据流处理, 触发事务(框架核心)

调度器(Scheduler): 用来接受引擎发过来的请求, 压入队列中, 并在引擎再次请求的时候返回. 可以想像成一个 URL(抓取网页的网址或者说是链接)的优先队列, 由它来决定下一个要抓取的网址是什么, 同时去除重复的网址

下载器(Downloader): 用于下载网页内容, 并将网页内容返回给调度器

爬虫(Spiders): 爬虫是主要干活的, 用于从特定的网页中提取自己需要的信息, 即所谓的实体(Item)。 用户也可以从中提取出链接,让框架继续抓取下一个页面

项目管道(Pipeline): 负责处理爬虫从网页中抽取的实体,主要的功能是持久化实体、验证实体的有效性、清除不需要的信息。 当页面被爬虫解析后,将被发送到项目管道,并经过几个特定的次序处理数据。

执行流程图

首先,引擎从调度器中取出一个链接(URL)用于接下来的抓取

引擎把 URL 封装成一个请求(Request)传给下载器,下载器把资源下载下来,并封装成应答包(Response)

然后,爬虫解析 Response

若是解析出实体(Item),则交给实体管道进行进一步的处理。

若是解析出的是链接(URL),则把 URL 交给 Scheduler 等待抓取

项目结构

该项目使用 Maven3、Java8 进行构建,代码结构如下:

.

└── elves

├── Elves.java

├── ElvesEngine.java

├── config

├── download

├── event

├── pipeline

├── request

├── response

├── scheduler

├── spider

└── utils

编码要点

前面设计思路明白之后,编程不过是顺手之作,至于写的如何考量的是程序员对编程语言的使用熟练度以及架构上的思考, 优秀的代码是经验和优化而来的,下面我们来看几个框架中的代码示例。

使用观察者模式的思想来实现基于事件驱动的功能

	public enum ElvesEvent {

	GLOBAL_STARTED,

	SPIDER_STARTED

	}

	public class EventManager {

	private static final Map>> elvesEventConsumerMap = new HashMap<>();

	// 注册事件

	public static void registerEvent(ElvesEvent elvesEvent, Consumer consumer) {

	List> consumers = elvesEventConsumerMap.get(elvesEvent);

	if (null == consumers) {

	consumers = new ArrayList<>();

	}

	consumers.add(consumer);

	elvesEventConsumerMap.put(elvesEvent, consumers);

	}

	// 执行事件

	public static void fireEvent(ElvesEvent elvesEvent, Config config) {

	Optional.ofNullable(elvesEventConsumerMap.get(elvesEvent)).ifPresent(consumers -> consumers.forEach(consumer -> consumer.accept(config)));

	}

	}

这段代码中使用一个 Map 来存储所有事件,提供两个方法:注册一个事件、执行某个事件。

阻塞队列存储请求响应

	  public class Scheduler {

	  private BlockingQueue pending = new LinkedBlockingQueue<>();

	  private BlockingQueue result = new LinkedBlockingQueue<>();

	  public void addRequest(Request request) {

	  try {

	  this.pending.put(request);

	  } catch (InterruptedException e) {

	  log.error("向调度器添加 Request 出错", e);

	  }

	  }

	  public void addResponse(Response response) {

	  try {

	  this.result.put(response);

	  } catch (InterruptedException e) {

	  log.error("向调度器添加 Response 出错", e);

	  }

	  }

	  public boolean hasRequest() {

	  return pending.size() > 0;

	  }

	  public Request nextRequest() {

	  try {

	  return pending.take();

	  } catch (InterruptedException e) {

	  log.error("从调度器获取 Request 出错", e);

	  return null;

	  }

	  }

	  public boolean hasResponse() {

	  return result.size() > 0;

	  }

	  public Response nextResponse() {

	  try {

	  return result.take();

	  } catch (InterruptedException e) {

	  log.error("从调度器获取 Response 出错", e);

	  return null;

	  }

	  }

	  public void addRequests(List requests) {

	  requests.forEach(this::addRequest);

	  }

	  }

pending 存储等待处理的 URL 请求,result 存储下载成功的响应,调度器负责请求和响应的获取和添加流转。

举个栗子

设计好我们的爬虫框架后来试一下吧,这个例子我们来爬取豆瓣电影的标题。豆瓣电影中有很多分类,我们可以选择几个作为开始抓取的 URL。

	  public class DoubanSpider extends Spider {

	  public DoubanSpider(String name) {

	  super(name);

	  this.startUrls(

	  "https://movie.douban.com/tag/爱情",

	  "https://movie.douban.com/tag/喜剧",

	  "https://movie.douban.com/tag/动画",

	  "https://movie.douban.com/tag/动作",

	  "https://movie.douban.com/tag/史诗",

	  "https://movie.douban.com/tag/犯罪");

	  }

	  @Override

	  public void onStart(Config config) {

	  this.addPipeline((Pipeline>) (item, request) -> log.info("保存到文件: {}", item));

	  }

	  public Result parse(Response response) {

	  Result> result = new Result<>();

	  Elements elements = response.body().css("#content table .pl2 a");

	  List titles = elements.stream().map(Element::text).collect(Collectors.toList());

	  result.setItem(titles);

	  // 获取下一页 URL

	  Elements nextEl = response.body().css("#content > div > div.article > div.paginator > span.next > a");

	  if (null != nextEl && nextEl.size() > 0) {

	  String nextPageUrl = nextEl.get(0).attr("href");

	  Request nextReq = this.makeRequest(nextPageUrl, this::parse);

	  result.addRequest(nextReq);

	  }

	  return result;

	  }

	  }

	  public static void main(String[] args) {

	  DoubanSpider doubanSpider = new DoubanSpider("豆瓣电影");

	  Elves.me(doubanSpider, Config.me()).start();

	  }

这段代码中在 onStart 方法是爬虫启动时的一个事件,会在启动该爬虫的时候执行,在这里我们设置了启动要抓取的 URL 列表。 然后添加了一个数据处理的 Pipeline,在这里处理管道中只进行了输出,你也可以存储。

在 parse 方法中做了两件事,首先解析当前抓取到的所有电影标题,将标题数据收集为 List 传递给 Pipeline; 其次根据当前页面继续抓取下一页,将下一页请求传递给调度器,由调度器转发给下载器。这里我们使用一个 Result 对象接收。

总结

设计一款爬虫框架的基本要点在文中已经阐述,要做的更好还有很多细节需要打磨,比如分布式、容错恢复、动态页面抓取等问题。 欢迎在 elves 中提交你的意见。

参考文献

著作权归作者所有。

商业转载请联系作者获得授权,非商业转载请注明出处。

原文: https://blog.biezhi.me/2018/01/design-and-implement-a-crawler-framework.html#%E4%BB%80%E4%B9%88%E6%98%AF%E7%88%AC%E8%99%AB

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3190 引用 • 8214 回帖 • 1 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...

推荐标签 标签

  • 房星科技

    房星网,我们不和没有钱的程序员谈理想,我们要让程序员又有理想又有钱。我们有雄厚的房地产行业线下资源,遍布昆明全城的 100 家门店、四千地产经纪人是我们坚实的后盾。

    6 引用 • 141 回帖 • 584 关注
  • C

    C 语言是一门通用计算机编程语言,应用广泛。C 语言的设计目标是提供一种能以简易的方式编译、处理低级存储器、产生少量的机器码以及不需要任何运行环境支持便能运行的编程语言。

    85 引用 • 165 回帖 • 1 关注
  • Google

    Google(Google Inc.,NASDAQ:GOOG)是一家美国上市公司(公有股份公司),于 1998 年 9 月 7 日以私有股份公司的形式创立,设计并管理一个互联网搜索引擎。Google 公司的总部称作“Googleplex”,它位于加利福尼亚山景城。Google 目前被公认为是全球规模最大的搜索引擎,它提供了简单易用的免费服务。不作恶(Don't be evil)是谷歌公司的一项非正式的公司口号。

    49 引用 • 192 回帖
  • Kubernetes

    Kubernetes 是 Google 开源的一个容器编排引擎,它支持自动化部署、大规模可伸缩、应用容器化管理。

    110 引用 • 54 回帖 • 1 关注
  • Markdown

    Markdown 是一种轻量级标记语言,用户可使用纯文本编辑器来排版文档,最终通过 Markdown 引擎将文档转换为所需格式(比如 HTML、PDF 等)。

    167 引用 • 1520 回帖
  • 学习

    “梦想从学习开始,事业从实践起步” —— 习近平

    171 引用 • 512 回帖
  • 区块链

    区块链是分布式数据存储、点对点传输、共识机制、加密算法等计算机技术的新型应用模式。所谓共识机制是区块链系统中实现不同节点之间建立信任、获取权益的数学算法 。

    91 引用 • 751 回帖 • 1 关注
  • Vim

    Vim 是类 UNIX 系统文本编辑器 Vi 的加强版本,加入了更多特性来帮助编辑源代码。Vim 的部分增强功能包括文件比较(vimdiff)、语法高亮、全面的帮助系统、本地脚本(Vimscript)和便于选择的可视化模式。

    29 引用 • 66 回帖 • 2 关注
  • 资讯

    资讯是用户因为及时地获得它并利用它而能够在相对短的时间内给自己带来价值的信息,资讯有时效性和地域性。

    55 引用 • 85 回帖
  • RYMCU

    RYMCU 致力于打造一个即严谨又活泼、专业又不失有趣,为数百万人服务的开源嵌入式知识学习交流平台。

    4 引用 • 6 回帖 • 50 关注
  • 开源

    Open Source, Open Mind, Open Sight, Open Future!

    407 引用 • 3578 回帖
  • BND

    BND(Baidu Netdisk Downloader)是一款图形界面的百度网盘不限速下载器,支持 Windows、Linux 和 Mac,详细介绍请看这里

    107 引用 • 1281 回帖 • 34 关注
  • CongSec

    本标签主要用于分享网络空间安全专业的学习笔记

    1 引用 • 1 回帖 • 15 关注
  • 单点登录

    单点登录(Single Sign On)是目前比较流行的企业业务整合的解决方案之一。SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

    9 引用 • 25 回帖
  • 链滴

    链滴是一个记录生活的地方。

    记录生活,连接点滴

    156 引用 • 3792 回帖
  • Hadoop

    Hadoop 是由 Apache 基金会所开发的一个分布式系统基础架构。用户可以在不了解分布式底层细节的情况下,开发分布式程序。充分利用集群的威力进行高速运算和存储。

    86 引用 • 122 回帖 • 626 关注
  • WebSocket

    WebSocket 是 HTML5 中定义的一种新协议,它实现了浏览器与服务器之间的全双工通信(full-duplex)。

    48 引用 • 206 回帖 • 317 关注
  • Hibernate

    Hibernate 是一个开放源代码的对象关系映射框架,它对 JDBC 进行了非常轻量级的对象封装,使得 Java 程序员可以随心所欲的使用对象编程思维来操纵数据库。

    39 引用 • 103 回帖 • 715 关注
  • Wide

    Wide 是一款基于 Web 的 Go 语言 IDE。通过浏览器就可以进行 Go 开发,并有代码自动完成、查看表达式、编译反馈、Lint、实时结果输出等功能。

    欢迎访问我们运维的实例: https://wide.b3log.org

    30 引用 • 218 回帖 • 635 关注
  • Sym

    Sym 是一款用 Java 实现的现代化社区(论坛/BBS/社交网络/博客)系统平台。

    下一代的社区系统,为未来而构建

    524 引用 • 4601 回帖 • 699 关注
  • CodeMirror
    1 引用 • 2 回帖 • 129 关注
  • ngrok

    ngrok 是一个反向代理,通过在公共的端点和本地运行的 Web 服务器之间建立一个安全的通道。

    7 引用 • 63 回帖 • 626 关注
  • 倾城之链
    23 引用 • 66 回帖 • 138 关注
  • NGINX

    NGINX 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 代理服务器。 NGINX 是由 Igor Sysoev 为俄罗斯访问量第二的 Rambler.ru 站点开发的,第一个公开版本 0.1.0 发布于 2004 年 10 月 4 日。

    313 引用 • 547 回帖
  • Kotlin

    Kotlin 是一种在 Java 虚拟机上运行的静态类型编程语言,由 JetBrains 设计开发并开源。Kotlin 可以编译成 Java 字节码,也可以编译成 JavaScript,方便在没有 JVM 的设备上运行。在 Google I/O 2017 中,Google 宣布 Kotlin 成为 Android 官方开发语言。

    19 引用 • 33 回帖 • 63 关注
  • 服务

    提供一个服务绝不仅仅是简单的把硬件和软件累加在一起,它包括了服务的可靠性、服务的标准化、以及对服务的监控、维护、技术支持等。

    41 引用 • 24 回帖
  • Mobi.css

    Mobi.css is a lightweight, flexible CSS framework that focus on mobile.

    1 引用 • 6 回帖 • 745 关注