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

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

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

    3187 引用 • 8213 回帖

相关帖子

欢迎来到这里!

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

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

推荐标签 标签

  • JavaScript

    JavaScript 一种动态类型、弱类型、基于原型的直译式脚本语言,内置支持类型。它的解释器被称为 JavaScript 引擎,为浏览器的一部分,广泛用于客户端的脚本语言,最早是在 HTML 网页上使用,用来给 HTML 网页增加动态功能。

    729 引用 • 1327 回帖
  • Hprose

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

    9 引用 • 17 回帖 • 611 关注
  • 爬虫

    网络爬虫(Spider、Crawler),是一种按照一定的规则,自动地抓取万维网信息的程序。

    106 引用 • 275 回帖
  • 旅游

    希望你我能在旅途中找到人生的下一站。

    90 引用 • 899 回帖
  • Solidity

    Solidity 是一种智能合约高级语言,运行在 [以太坊] 虚拟机(EVM)之上。它的语法接近于 JavaScript,是一种面向对象的语言。

    3 引用 • 18 回帖 • 399 关注
  • JRebel

    JRebel 是一款 Java 虚拟机插件,它使得 Java 程序员能在不进行重部署的情况下,即时看到代码的改变对一个应用程序带来的影响。

    26 引用 • 78 回帖 • 664 关注
  • Firefox

    Mozilla Firefox 中文俗称“火狐”(正式缩写为 Fx 或 fx,非正式缩写为 FF),是一个开源的网页浏览器,使用 Gecko 排版引擎,支持多种操作系统,如 Windows、OSX 及 Linux 等。

    8 引用 • 30 回帖 • 407 关注
  • MongoDB

    MongoDB(来自于英文单词“Humongous”,中文含义为“庞大”)是一个基于分布式文件存储的数据库,由 C++ 语言编写。旨在为应用提供可扩展的高性能数据存储解决方案。MongoDB 是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富,最像关系数据库的。它支持的数据结构非常松散,是类似 JSON 的 BSON 格式,因此可以存储比较复杂的数据类型。

    90 引用 • 59 回帖 • 1 关注
  • Maven

    Maven 是基于项目对象模型(POM)、通过一小段描述信息来管理项目的构建、报告和文档的软件项目管理工具。

    186 引用 • 318 回帖 • 303 关注
  • 微信

    腾讯公司 2011 年 1 月 21 日推出的一款手机通讯软件。用户可以通过摇一摇、搜索号码、扫描二维码等添加好友和关注公众平台,同时可以将自己看到的精彩内容分享到微信朋友圈。

    130 引用 • 793 回帖
  • RESTful

    一种软件架构设计风格而不是标准,提供了一组设计原则和约束条件,主要用于客户端和服务器交互类的软件。基于这个风格设计的软件可以更简洁,更有层次,更易于实现缓存等机制。

    30 引用 • 114 回帖 • 1 关注
  • JSON

    JSON (JavaScript Object Notation)是一种轻量级的数据交换格式。易于人类阅读和编写。同时也易于机器解析和生成。

    52 引用 • 190 回帖
  • 小薇

    小薇是一个用 Java 写的 QQ 聊天机器人 Web 服务,可以用于社群互动。

    由于 Smart QQ 从 2019 年 1 月 1 日起停止服务,所以该项目也已经停止维护了!

    34 引用 • 467 回帖 • 742 关注
  • IDEA

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

    180 引用 • 400 回帖
  • CSS

    CSS(Cascading Style Sheet)“层叠样式表”是用于控制网页样式并允许将样式信息与网页内容分离的一种标记性语言。

    198 引用 • 550 回帖
  • VirtualBox

    VirtualBox 是一款开源虚拟机软件,最早由德国 Innotek 公司开发,由 Sun Microsystems 公司出品的软件,使用 Qt 编写,在 Sun 被 Oracle 收购后正式更名成 Oracle VM VirtualBox。

    10 引用 • 2 回帖 • 6 关注
  • 黑曜石

    黑曜石是一款强大的知识库工具,支持本地 Markdown 文件编辑,支持双向链接和关系图。

    A second brain, for you, forever.

    15 引用 • 122 回帖
  • CSDN

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

    14 引用 • 155 回帖
  • ZooKeeper

    ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,是 Google 的 Chubby 一个开源的实现,是 Hadoop 和 HBase 的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。

    59 引用 • 29 回帖 • 5 关注
  • LeetCode

    LeetCode(力扣)是一个全球极客挚爱的高质量技术成长平台,想要学习和提升专业能力从这里开始,充足技术干货等你来啃,轻松拿下 Dream Offer!

    209 引用 • 72 回帖
  • Ruby

    Ruby 是一种开源的面向对象程序设计的服务器端脚本语言,在 20 世纪 90 年代中期由日本的松本行弘(まつもとゆきひろ/Yukihiro Matsumoto)设计并开发。在 Ruby 社区,松本也被称为马茨(Matz)。

    7 引用 • 31 回帖 • 211 关注
  • 宕机

    宕机,多指一些网站、游戏、网络应用等服务器一种区别于正常运行的状态,也叫“Down 机”、“当机”或“死机”。宕机状态不仅仅是指服务器“挂掉了”、“死机了”状态,也包括服务器假死、停用、关闭等一些原因而导致出现的不能够正常运行的状态。

    13 引用 • 82 回帖 • 53 关注
  • Redis

    Redis 是一个开源的使用 ANSI C 语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value 数据库,并提供多种语言的 API。从 2010 年 3 月 15 日起,Redis 的开发工作由 VMware 主持。从 2013 年 5 月开始,Redis 的开发由 Pivotal 赞助。

    286 引用 • 248 回帖 • 62 关注
  • 大数据

    大数据(big data)是指无法在一定时间范围内用常规软件工具进行捕捉、管理和处理的数据集合,是需要新处理模式才能具有更强的决策力、洞察发现力和流程优化能力的海量、高增长率和多样化的信息资产。

    93 引用 • 113 回帖
  • 音乐

    你听到信仰的声音了么?

    60 引用 • 511 回帖
  • Sublime

    Sublime Text 是一款可以用来写代码、写文章的文本编辑器。支持代码高亮、自动完成,还支持通过插件进行扩展。

    10 引用 • 5 回帖
  • 学习

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

    169 引用 • 506 回帖