设计和实现一款轻量级的爬虫框架
优采云 发布时间: 2020-05-05 08:05
说起爬虫,大家能否想起 Python 里赫赫有名的 Scrapy 框架, 在本文中我们参考这个设计思想使用 Java 语言来实现一款自己的爬虫框(lun)架(zi)。 我们从起点一步一步剖析爬虫框架的诞生过程。
我把这个爬虫框架的源码置于 github上,里面有几个事例可以运行。
关于爬虫的一切
下面我们来介绍哪些是爬虫?以及爬虫框架的设计和碰到的问题。
什么是爬虫?
“爬虫”不是一只生活在泥土里的小虫子,网络爬虫(web crawler),也叫网路蜘蛛(spider),是一种拿来手动浏览网路上内容的机器人。 爬虫访问网站的过程会消耗目标系统资源,很多网站不容许被爬虫抓取(这就是你遇见过的 robots.txt 文件, 这个文件可以要求机器人只对网站的一部分进行索引,或完全不作处理)。 因此在访问大量页面时,爬虫须要考虑到规划、负载,还须要讲“礼貌”(大兄弟,慢点)。
互联网上的页面极多,即使是最大的爬虫系统也未能作出完整的索引。因此在公元2000年之前的万维网出现早期,搜索引擎常常找不到多少相关结果。 现在的搜索引擎在这方面早已进步好多,能够即刻给出高质量结果。
网络爬虫会碰到的问题
既然有人想抓取,就会有人想防御。网络爬虫在运行的过程中会碰到一些阻挠,在业内称之为 反爬虫策略 我们来列举一些常见的。
这些是传统的反爬虫手段,当然未来也会愈加先进,技术的革新永远会推动多个行业的发展,毕竟 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管理器
爬虫框架要处理好多的URL,我们须要设计一个队列储存所有要处理的URL,这种先进先出的数据结构十分符合这个需求。 将所有要下载的URL存贮在待处理队列中,每次下载会取出一个,队列中还会少一个。我们晓得有些URL的下载会有反爬虫策略, 所以针对那些恳求须要做一些特殊的设置,进而可以对URL进行封装抽出 Request。
网页下载器
在上面的简单事例中可以看出,如果没有网页下载器,用户就要编撰网路恳求的处理代码,这无疑对每位URL都是相同的动作。 所以在框架设计中我们直接加入它就好了,至于使用哪些库来进行下载都是可以的,你可以用 httpclient 也可以用 okhttp, 在本文中我们使用一个超轻量级的网路恳求库 oh-my-request (没错,就是在下搞的)。 优秀的框架设计会将这个下载组件置为可替换,提供默认的即可。
爬虫调度器
调度器和我们在开发 web 应用中的控制器是一个类似的概念,它用于在下载器、解析器之间做流转处理。 解析器可以解析到更多的URL发送给调度器,调度器再度的传输给下载器,这样才会使各个组件有条不紊的进行工作。
网页解析器
我们晓得当一个页面下载完成后就是一段 HTML 的 DOM 字符串表示,但还须要提取出真正须要的数据, 以前的做法是通过 String 的 API 或者正则表达式的形式在 DOM 中搜救,这样是很麻烦的,框架 应该提供一种合理、常用、方便的方法来帮助用户完成提取数据这件事儿。常用的手段是通过 xpath 或者 css 选择器从 DOM 中进行提取,而且学习这项技能在几乎所有的爬虫框架中都是适用的。
数据处理器
普通的爬虫程序中是把 网页解析器 和 数据处理器 合在一起的,解析到数据后马上处理。 在一个标准化的爬虫程序中,他们应当是各司其职的,我们先通过解析器将须要的数据解析下来,可能是封装成对象。 然后传递给数据处理器,处理器接收到数据后可能是储存到数据库,也可能通过插口发送给老王。
基本特点
上面说了这么多,我们设计的爬虫框架有以下几个特点,没有做到大而全,可以称得上轻量迷你很好用。
架构图
整个流程和 Scrapy 是一致的,但简化了一些操作
执行流程图
项目结构
该项目使用 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<ElvesEvent, List<Consumer<Config>>> elvesEventConsumerMap = new HashMap<>();
// 注册事件
public static void registerEvent(ElvesEvent elvesEvent, Consumer<Config> consumer) {
List<Consumer<Config>> 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<Request> pending = new LinkedBlockingQueue<>();
private BlockingQueue<Response> 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<Request> requests) {
requests.forEach(this::addRequest);
}
}
pending 存储等待处理的URL恳求,result 存储下载成功的响应,调度器负责恳求和响应的获取和添加流转。
举个栗子
设计好我们的爬虫框架后来试一下吧,这个事例我们来爬取豆瓣影片的标题。豆瓣影片中有很多分类,我们可以选择几个作为开始抓取的 URL。
public class DoubanSpider extends Spider {
public DoubanSpider(String name) {
super(name);
}
@Override
public void onStart(Config config) {
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/*敏*感*词*");
this.addPipeline((Pipeline<List<String>>) (item, request) -> log.info("保存到文件: {}", item));
}
public Result parse(Response response) {
Result<List<String>> result = new Result<>();
Elements elements = response.body().css("#content table .pl2 a");
List<String> 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 中递交你的意见。
参考文献