前言
上一篇教学实作了一个简单的爬虫并成功的爬到了 PTT 的文章列表
这次就继续将 PTT 文章内容给爬回来然后储存到电脑上
必备知识
上一篇所列的知识多型介面执行绪档案处理获取文章内容
这边就直接放出程式码了,大多都是上一篇说明过的部分
在 ptt.crawler.Reader 中加入以下 Method
public String getBody(Article article) throws IOException { /* 如果看板需要成年检查 */ if (article.getParent().getAdultCheck()) { runAdultCheck(article.getUrl()); } /* 抓取目标页面 */ Request request = new Request.Builder() .url(Config.PTT_URL + article.getUrl()) .get() .build(); Response response = okHttpClient.newCall(request).execute(); String body = response.body().string(); Document doc = Jsoup.parse(body); Elements articleBody = doc.select("#main-content"); /* 移除部份不需要的资讯 */ articleBody.select(".article-metaline").remove(); articleBody.select(".article-metaline-right").remove(); articleBody.select(".push").remove(); // 回应内容 /* 回传文章内容 */ return articleBody.text();}
PTT 的文章内容页面不像列表那样比较有规则,只能大略的处理
比如移除标题、作者、时间、回应内容...等
如果想要更精準的话,可能需要另外处理一下原始码
测试
在 ptt.crawler.ReaderTest 中加入一个新的 Method
PS: 如果不习惯用 Junit,可以自己创一个 Class 运行,效果是一样的
@Testvoid article() { try { List<Article> result = reader.getList("Gossiping"); for (Article article: result) { System.out.println(reader.getBody(article)); Thread.sleep(2000); } } catch (IOException e) { e.printStackTrace(); } catch (ParseException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); }}
爬虫比较忌讳一瞬间大量发送要求,因为等同对对方的伺服器进行攻击
所以刻意等待 2 秒后再抓下一篇文章的内容
运行后来看看效果怎么样
执行绪
PS: 多执行绪与非同步并不是等价的,有一些差别,但这边粗略的将非同步定义为 “同时间做一个以上的任务”
到上一步为止,我们都是在写同步的 Method
同步的最大特徵是有顺序性,必须取得结果才会进行下一个步骤
比如刚刚写的 article 测试,取得文章列表之后就开始一篇一篇取得文章内容
第一篇的 reader.getBody 没有执行完毕之前是不会执行下一篇的 reader.getBody
这样的方法看起来比较直觉,但最大的缺点就是耗时
为了要提高程式的效率通常会使用多执行绪的方式来让任务同时进行,进而减少等待的时间
刚好 OkHttp 有实现自己的非同步方法,所以下一步就要试试看非同步的效果如何
Callback
在使用非同步方式时,因为不知道什么时候执行完成
所以通常会传入一个 Callback method 让执行绪完成后进行通知呼叫
这意思就像你(主程式)拜託朋友(多执行绪)出门帮你买东西,但你并不知道他什么时候买完
所以你跟他说: 「你买完东西后传个简讯(Callback method)跟我说,我才能执行下一步的动作」
大致了解 Callback 是个什么东西后,我们就来写一只吧
在 ptt.crawler.Reader 加入以下 Interface
public class Reader { ... interface callback { void succeeded(Article article); void failed(Article article); }}
这个 Interface 很简单,只有两个方法需要实现
如果文章内容取得成功就呼叫 succeeded 失败就呼叫 failed
非同步的 getBody
public void getBody(Article article, Callback callback) throws IOException { /* 如果看板需要成年检查 */ if (article.getParent().getAdultCheck()) { runAdultCheck(article.getUrl()); } /* 抓取目标页面 */ Request request = new Request.Builder() .url(Config.PTT_URL + article.getUrl()) .get() .build(); okHttpClient.newCall(request).enqueue(new okhttp3.Callback() { @Override public void onFailure(Call call, IOException e) { callback.failed(article); } @Override public void onResponse(Call call, Response response) throws IOException { String body = response.body().string(); Document doc = Jsoup.parse(body); Elements articleBody = doc.select("#main-content"); /* 移除部份不需要的资讯 */ articleBody.select(".article-metaline").remove(); articleBody.select(".article-metaline-right").remove(); articleBody.select(".push").remove(); // 回应内容 /* 将内容直接设定给 Model */ article.setBody(articleBody.text()); callback.succeeded(article); } });}
与上一个 getBody 来比较看看,传入的参数多了一个 Callback
实际抓资料的 Method 从 execute 改成 enqueue
也实现了一个 OkHttp 规定的 Callback
PS: Callback 这个概念在很多地方都可以看得到,尤其是 JavaScript 或 Node.js 上,建议多熟悉
测试
在 ptt.crawler.ReaderTest 中加入一个新的 Method
@Testvoid articleAsync() { try { List<Article> result = reader.getList("Gossiping"); for (Article article: result) { reader.getBody(article, new Reader.Callback() { @Override public void succeeded(Article article) { System.out.println(article.getBody()); } @Override public void failed(Article article) { System.out.println("失败"); } }); Thread.sleep(2000); } } catch (IOException e) { e.printStackTrace(); } catch (ParseException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); }}
执行后你大概会有疑惑怎么感觉跟之前的同步写法时间差不多
原因在于 Thread.sleep(2000); 上
开个网页通常都在 2 秒内结束,尤其是 PTT 这种基本上只有文字的网站
我们把 2 秒改成 0 秒,就可以很直观地看到效果了
很明显非同步方式快了 10 秒左右
PS: 如果有兴趣的读者可以试着优化 “成年检查” 的部分,可以再把速度加快
至于什么时候用同步什么时候用非同步,端看需求而定,没有一定的準则
资料储存
接着来实作一下资料储存的部分
不然爬虫爬得那么辛苦,程式一结束资料就消失了,不就白爬了?
储存的地方看是要放在 云端、资料库、本地档案 都可以
这次就用最简单的 本地档案 来实现
介面
新增一个新的介面 ptt.crawler.data.Writer
内容如下,只有一个简单的 Method save
package ptt.crawler.data;import ptt.crawler.model.Article;public interface Writer { void save(Article article) throws Exception;}
实作
新增一个新的类别 ptt.crawler.data.FileWriter 实作 Writer
package ptt.crawler.data;import ptt.crawler.model.Article;import java.io.*;public class FileWriter implements Writer { @Override public void save(Article article) throws IOException { File file = new File( String.format("data/%s/%s.txt", article.getParent().getNameEN(), article.getTitle()) ); file.getParentFile().mkdirs(); file.createNewFile(); java.io.FileWriter writer = new java.io.FileWriter(file); writer.append(String.format("%s\r\n%s", article.getAuthor(), article.getBody())); writer.close(); }}
直接把文章输出成 txt 档,如果改天要把文章存到资料库,只要再实作另一个 Writer 就好
测试
在 ptt.crawler.ReaderTest 中加入一个新的 Method
@Testvoid saveArticle() { try { List<Article> result = reader.getList("Gossiping"); Writer writer = new FileWriter(); for (Article article: result) { reader.getBody(article, new Reader.Callback() { @Override public void succeeded(Article article) { try { writer.save(article); } catch (Exception e) { e.printStackTrace(); } } @Override public void failed(Article article) { System.out.println("失败"); } }); Thread.sleep(0); } } catch (IOException e) { e.printStackTrace(); } catch (ParseException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); }}
执行后就可以成功看到爬到的文章被储存在我们的电脑中啦
延伸阅读
介面(多型)的好处
可能会有读者有疑问说,为什么要特地开一个介面 Writer 呢?
直接写 FileWriter 不行吗? 答案是可以的,功能上完全不影响
那么多此一举的意义是? 下面来介绍一下使用介面的几个好处
隔离性介面是一种对外界的承诺,换句话说就是介面保证了它所宣告的方法都一定会被实作
如此一来,外界不需要关心到底是谁来实作这个介面,也不需要担心会洩漏内部的资讯
举个例子
public interface Test { void Hello();}class TestImpl implements Test { public String output = "Hello"; @Override public void Hello() { System.out.println(output); } public void Hello2() { System.out.println(output + 2); }}class TestPolymorphism { public static void main(String[] args) { Test test = new TestImpl(); test.Hello(); test.Hello2(); }}
除了介面规定的 Hello 外,TestPolymorphism 无法呼叫到 Hello2,就算该 Method 是 public 也是一样的
到这边可能又会有读者提出,这些东西用抽象类别也是可以办到的不是吗?
对,就某些方面来说,介面与抽象类别其实是可以互相取代的
但是什么时候使用介面(like a ...),什么时候使用抽象类别(is a ...)
就需要看你想要抽象的概念而定
取代性用上面刚刚写好的 FileWriter 来举例
如果需要 FileWriter 参数的 Method 并不是宣告成 Writer 而是直接使用 FileWriter
public void saveArticle(FileWriter fw) { ...}
那么今天预设的储存方式从 File 改成 DB 呢?
你可能回答: 「不就把 FileWriter 改成 DBWriter 吗?」
对,但如果要改的地方不是只有一个地方而是遍布整份专案呢?
或许现代的 IDE 有很多方便的功能可以让你做重构
但是这样的方式始终上还是有些问题的
如果今天是宣告成
public void saveArticle(Writer fw) { ...}
除非动到了介面内宣告的方法,不然就可以保证其他使用到 Writer 的 Method 是不用改变的,甚至测试都可以省略了
以上两点是我认为使用介面的好处,当然每个人的看法不一样,欢迎留言提供你的看法
后记
首先距离上一篇教学好像过了快一个月,本人真的感到非常的抱歉
期间修修改改的,也在考虑怎么让爬虫这个主题可以结合一些我平常开发的心得
让阅读文章的你们除了知道如何爬网站之外还能学习到一些其他的东西
比如开发方式、小技巧、设计模式、坑...等等
这边保证会在文章中尽量多塞一些内容,往后也希望大家多多支持
本文章同步张贴于 本人部落格
觉得这篇文章有帮助到你的话,请帮我点个 Like