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

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

说起爬虫,大家能够想起 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 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3165 引用 • 8206 回帖

相关帖子

欢迎来到这里!

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

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

推荐标签 标签

  • 以太坊

    以太坊(Ethereum)并不是一个机构,而是一款能够在区块链上实现智能合约、开源的底层系统。以太坊是一个平台和一种编程语言 Solidity,使开发人员能够建立和发布下一代去中心化应用。 以太坊可以用来编程、分散、担保和交易任何事物:投票、域名、金融交易所、众筹、公司管理、合同和知识产权等等。

    34 引用 • 367 回帖 • 2 关注
  • H2

    H2 是一个开源的嵌入式数据库引擎,采用 Java 语言编写,不受平台的限制,同时 H2 提供了一个十分方便的 web 控制台用于操作和管理数据库内容。H2 还提供兼容模式,可以兼容一些主流的数据库,因此采用 H2 作为开发期的数据库非常方便。

    11 引用 • 54 回帖 • 638 关注
  • Kotlin

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

    19 引用 • 33 回帖 • 21 关注
  • Python

    Python 是一种面向对象、直译式电脑编程语言,具有近二十年的发展历史,成熟且稳定。它包含了一组完善而且容易理解的标准库,能够轻松完成很多常见的任务。它的语法简捷和清晰,尽量使用无异义的英语单词,与其它大多数程序设计语言使用大括号不一样,它使用缩进来定义语句块。

    534 引用 • 671 回帖
  • Webswing

    Webswing 是一个能将任何 Swing 应用通过纯 HTML5 运行在浏览器中的 Web 服务器,详细介绍请看 将 Java Swing 应用变成 Web 应用

    1 引用 • 15 回帖 • 635 关注
  • NGINX

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

    311 引用 • 546 回帖 • 56 关注
  • HBase

    HBase 是一个分布式的、面向列的开源数据库,该技术来源于 Fay Chang 所撰写的 Google 论文 “Bigtable:一个结构化数据的分布式存储系统”。就像 Bigtable 利用了 Google 文件系统所提供的分布式数据存储一样,HBase 在 Hadoop 之上提供了类似于 Bigtable 的能力。

    17 引用 • 6 回帖 • 32 关注
  • WordPress

    WordPress 是一个使用 PHP 语言开发的博客平台,用户可以在支持 PHP 和 MySQL 数据库的服务器上架设自己的博客。也可以把 WordPress 当作一个内容管理系统(CMS)来使用。WordPress 是一个免费的开源项目,在 GNU 通用公共许可证(GPLv2)下授权发布。

    45 引用 • 113 回帖 • 321 关注
  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖 • 2 关注
  • 心情

    心是产生任何想法的源泉,心本体会陷入到对自己本体不能理解的状态中,因为心能产生任何想法,不能分出对错,不能分出自己。

    59 引用 • 369 回帖
  • Quicker

    Quicker 您的指尖工具箱!操作更少,收获更多!

    16 引用 • 68 回帖
  • 架构

    我们平时所说的“架构”主要是指软件架构,这是有关软件整体结构与组件的抽象描述,用于指导软件系统各个方面的设计。另外还有“业务架构”、“网络架构”、“硬件架构”等细分领域。

    139 引用 • 441 回帖
  • Hprose

    Hprose 是一款先进的轻量级、跨语言、跨平台、无侵入式、高性能动态远程对象调用引擎库。它不仅简单易用,而且功能强大。你无需专门学习,只需看上几眼,就能用它轻松构建分布式应用系统。

    9 引用 • 17 回帖 • 591 关注
  • RIP

    愿逝者安息!

    8 引用 • 92 回帖 • 285 关注
  • 招聘

    哪里都缺人,哪里都不缺人。

    189 引用 • 1056 回帖
  • FlowUs

    FlowUs.息流 个人及团队的新一代生产力工具。

    让复杂的信息管理更轻松、自由、充满创意。

    1 引用 • 3 关注
  • Rust

    Rust 是一门赋予每个人构建可靠且高效软件能力的语言。Rust 由 Mozilla 开发,最早发布于 2014 年 9 月。

    57 引用 • 22 回帖
  • 数据库

    据说 99% 的性能瓶颈都在数据库。

    330 引用 • 614 回帖
  • Love2D

    Love2D 是一个开源的, 跨平台的 2D 游戏引擎。使用纯 Lua 脚本来进行游戏开发。目前支持的平台有 Windows, Mac OS X, Linux, Android 和 iOS。

    14 引用 • 53 回帖 • 506 关注
  • Electron

    Electron 基于 Chromium 和 Node.js,让你可以使用 HTML、CSS 和 JavaScript 构建应用。它是一个由 GitHub 及众多贡献者组成的活跃社区共同维护的开源项目,兼容 Mac、Windows 和 Linux,它构建的应用可在这三个操作系统上面运行。

    15 引用 • 136 回帖
  • CSDN

    CSDN (Chinese Software Developer Network) 创立于 1999 年,是中国的 IT 社区和服务平台,为中国的软件开发者和 IT 从业者提供知识传播、职业发展、软件开发等全生命周期服务,满足他们在职业发展中学习及共享知识和信息、建立职业发展社交圈、通过软件开发实现技术商业化等刚性需求。

    14 引用 • 155 回帖
  • 运维

    互联网运维工作,以服务为中心,以稳定、安全、高效为三个基本点,确保公司的互联网业务能够 7×24 小时为用户提供高质量的服务。

    148 引用 • 257 回帖 • 1 关注
  • JetBrains

    JetBrains 是一家捷克的软件开发公司,该公司位于捷克的布拉格,并在俄国的圣彼得堡及美国麻州波士顿都设有办公室,该公司最为人所熟知的产品是 Java 编程语言开发撰写时所用的集成开发环境:IntelliJ IDEA

    18 引用 • 54 回帖
  • IDEA

    IDEA 全称 IntelliJ IDEA,是一款 Java 语言开发的集成环境,在业界被公认为最好的 Java 开发工具之一。IDEA 是 JetBrains 公司的产品,这家公司总部位于捷克共和国的首都布拉格,开发人员以严谨著称的东欧程序员为主。

    180 引用 • 400 回帖
  • GitBook

    GitBook 使您的团队可以轻松编写和维护高质量的文档。 分享知识,提高团队的工作效率,让用户满意。

    3 引用 • 8 回帖 • 1 关注
  • SendCloud

    SendCloud 由搜狐武汉研发中心孵化的项目,是致力于为开发者提供高质量的触发邮件服务的云端邮件发送平台,为开发者提供便利的 API 接口来调用服务,让邮件准确迅速到达用户收件箱并获得强大的追踪数据。

    2 引用 • 8 回帖 • 429 关注
  • CodeMirror
    1 引用 • 2 回帖 • 109 关注