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

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

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

    3169 引用 • 8207 回帖

相关帖子

欢迎来到这里!

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

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

推荐标签 标签

  • 前端

    前端技术一般分为前端设计和前端开发,前端设计可以理解为网站的视觉设计,前端开发则是网站的前台代码实现,包括 HTML、CSS 以及 JavaScript 等。

    247 引用 • 1347 回帖
  • Lute

    Lute 是一款结构化的 Markdown 引擎,支持 Go 和 JavaScript。

    25 引用 • 191 回帖 • 21 关注
  • flomo

    flomo 是新一代 「卡片笔记」 ,专注在碎片化时代,促进你的记录,帮你积累更多知识资产。

    3 引用 • 83 回帖 • 1 关注
  • Flutter

    Flutter 是谷歌的移动 UI 框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。 Flutter 可以与现有的代码一起工作,它正在被越来越多的开发者和组织使用,并且 Flutter 是完全免费、开源的。

    39 引用 • 92 回帖 • 6 关注
  • 知乎

    知乎是网络问答社区,连接各行各业的用户。用户分享着彼此的知识、经验和见解,为中文互联网源源不断地提供多种多样的信息。

    10 引用 • 66 回帖
  • 分享

    有什么新发现就分享给大家吧!

    241 引用 • 1746 回帖
  • WebSocket

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

    48 引用 • 206 回帖 • 388 关注
  • 房星科技

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

    6 引用 • 141 回帖 • 561 关注
  • uTools

    uTools 是一个极简、插件化、跨平台的现代桌面软件。通过自由选配丰富的插件,打造你得心应手的工具集合。

    5 引用 • 13 回帖 • 1 关注
  • Maven

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

    186 引用 • 318 回帖 • 338 关注
  • H2

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

    11 引用 • 54 回帖 • 642 关注
  • SpaceVim

    SpaceVim 是一个社区驱动的模块化 vim/neovim 配置集合,以模块的方式组织管理插件以
    及相关配置,为不同的语言开发量身定制了相关的开发模块,该模块提供代码自动补全,
    语法检查、格式化、调试、REPL 等特性。用户仅需载入相关语言的模块即可得到一个开箱
    即用的 Vim-IDE。

    3 引用 • 31 回帖 • 73 关注
  • 宕机

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

    13 引用 • 82 回帖 • 36 关注
  • PWL

    组织简介

    用爱发电 (Programming With Love) 是一个以开源精神为核心的民间开源爱好者技术组织,“用爱发电”象征开源与贡献精神,加入组织,代表你将遵守组织的“个人开源爱好者”的各项条款。申请加入:用爱发电组织邀请帖
    用爱发电组织官网:https://programmingwithlove.stackoverflow.wiki/

    用爱发电组织的核心驱动力:

    • 遵守开源守则,体现开源&贡献精神:以分享为目的,拒绝非法牟利。
    • 自我保护:使用适当的 License 保护自己的原创作品。
    • 尊重他人:不以各种理由、各种漏洞进行未经允许的抄袭、散播、洩露;以礼相待,尊重所有对社区做出贡献的开发者;通过他人的分享习得知识,要留下足迹,表示感谢。
    • 热爱编程、热爱学习:加入组织,热爱编程是首当其要的。我们欢迎热爱讨论、分享、提问的朋友,也同样欢迎默默成就的朋友。
    • 倾听:正确并恳切对待、处理问题与建议,及时修复开源项目的 Bug ,及时与反馈者沟通。不抬杠、不无视、不辱骂。
    • 平视:不诋毁、轻视、嘲讽其他开发者,主动提出建议、施以帮助,以和谐为本。只要他人肯努力,你也可能会被昔日小看的人所超越,所以请保持谦虚。
    • 乐观且活跃:你的努力决定了你的高度。不要放弃,多年后回头俯瞰,才会发现自己已经成就往日所仰望的水平。积极地将项目开源,帮助他人学习、改进,自己也会获得相应的提升、成就与成就感。
    1 引用 • 487 回帖 • 4 关注
  • 黑曜石

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

    A second brain, for you, forever.

    10 引用 • 85 回帖
  • abitmean

    有点意思就行了

    24 关注
  • Sublime

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

    10 引用 • 5 回帖 • 2 关注
  • Vim

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

    27 引用 • 66 回帖 • 2 关注
  • Git

    Git 是 Linux Torvalds 为了帮助管理 Linux 内核开发而开发的一个开放源码的版本控制软件。

    205 引用 • 357 回帖
  • 职场

    找到自己的位置,萌新烦恼少。

    126 引用 • 1699 回帖
  • jsoup

    jsoup 是一款 Java 的 HTML 解析器,可直接解析某个 URL 地址、HTML 文本内容。它提供了一套非常省力的 API,可通过 DOM,CSS 以及类似于 jQuery 的操作方法来取出和操作数据。

    6 引用 • 1 回帖 • 463 关注
  • Hexo

    Hexo 是一款快速、简洁且高效的博客框架,使用 Node.js 编写。

    21 引用 • 140 回帖 • 30 关注
  • Docker

    Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的操作系统上。容器完全使用沙箱机制,几乎没有性能开销,可以很容易地在机器和数据中心中运行。

    478 引用 • 902 回帖
  • Vue.js

    Vue.js(读音 /vju ː/,类似于 view)是一个构建数据驱动的 Web 界面库。Vue.js 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。

    261 引用 • 662 回帖
  • OkHttp

    OkHttp 是一款 HTTP & HTTP/2 客户端库,专为 Android 和 Java 应用打造。

    16 引用 • 6 回帖 • 55 关注
  • Love2D

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

    14 引用 • 53 回帖 • 512 关注
  • ActiveMQ

    ActiveMQ 是 Apache 旗下的一款开源消息总线系统,它完整实现了 JMS 规范,是一个企业级的消息中间件。

    19 引用 • 13 回帖 • 626 关注