[Java]手把手带你实作PTT爬虫(2)-文章内容及储存

前言

上一篇教学实作了一个简单的爬虫并成功的爬到了 PTT 的文章列表

这次就继续将 PTT 文章内容给爬回来然後储存到电脑上

必备知识

  1. 上一篇所列的知识
  2. 多型
  3. 介面
  4. 执行绪
  5. 档案处理

获取文章内容

这边就直接放出程序码了,大多都是上一篇说明过的部分

在 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 运行,效果是一样的

@Test
void 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 秒後再抓下一篇文章的内容

运行後来看看效果怎麽样

01

执行绪

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

@Test
void 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 秒,就可以很直观地看到效果了

02

很明显非同步方式快了 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

@Test
void 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();
    }
}

执行後就可以成功看到爬到的文章被储存在我们的电脑中啦

03

延伸阅读

介面(多型)的好处

可能会有读者有疑问说,为什麽要特地开一个介面 Writer 呢?

直接写 FileWriter 不行吗? 答案是可以的,功能上完全不影响

那麽多此一举的意义是? 下面来介绍一下使用介面的几个好处

  1. 隔离性

介面是一种对外界的承诺,换句话说就是介面保证了它所宣告的方法都一定会被实作

如此一来,外界不需要关心到底是谁来实作这个介面,也不需要担心会泄漏内部的资讯

举个例子

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 ...)

就需要看你想要抽象的概念而定

  1. 取代性

用上面刚刚写好的 FileWriter 来举例

如果需要 FileWriter 参数的 Method 并不是宣告成 Writer 而是直接使用 FileWriter

public void saveArticle(FileWriter fw) {
    ...
}

那麽今天预设的储存方式从 File 改成 DB 呢?

你可能回答: 「不就把 FileWriter 改成 DBWriter 吗?」

对,但如果要改的地方不是只有一个地方而是遍布整份专案呢?

或许现代的 IDE 有很多方便的功能可以让你做重构

但是这样的方式始终上还是有些问题的

如果今天是宣告成

public void saveArticle(Writer fw) {
    ...
}

除非动到了介面内宣告的方法,不然就可以保证其他使用到 Writer 的 Method 是不用改变的,甚至测试都可以省略了

以上两点是我认为使用介面的好处,当然每个人的看法不一样,欢迎留言提供你的看法

後记

首先距离上一篇教学好像过了快一个月,本人真的感到非常的抱歉

期间修修改改的,也在考虑怎麽让爬虫这个主题可以结合一些我平常开发的心得

让阅读文章的你们除了知道如何爬网站之外还能学习到一些其他的东西

比如开发方式、小技巧、设计模式、坑...等等

这边保证会在文章中尽量多塞一些内容,往後也希望大家多多支持

本文章同步张贴於 本人部落格

觉得这篇文章有帮助到你的话,请帮我点个 Like


<<:  5种常见网站安全攻击手段及防御方法

>>:  调查类型(Investigation Types)

[DAY 14] getRange 与 getDataRange

接下来说说我觉得非常好用的两个函数 getRange 与 getDataRange 这两个函数在取得...

[Day25] 实作 - 动画篇2

先在UI上做一个事件技能的锚点 修改一下ActionBattle_Action 修改一下算技能的距离...

PATH 到底在干嘛呢?

对於初学者来说 PATH 听起来抽象又难懂, PATH 又是什麽呢? PATH 叫做【环境变数 En...

Progressive Web App Manifest: 配置档属性深入介绍 (5)

Web App 的 manifest 是一个 JSON 形式的配置档,浏览器透过配置档就会知道 Pr...