前言

上一篇教學實作了一個簡單的爬蟲並成功的爬到了 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 是不用改變的,甚至測試都可以省略了

以上兩點是我認為使用介面的好處,當然每個人的看法不一樣,歡迎留言提供你的看法

後記

首先距離上一篇教學好像過了快一個月,本人真的感到非常的抱歉

期間修修改改的,也在考慮怎麼讓爬蟲這個主題可以結合一些我平常開發的心得

讓閱讀文章的你們除了知道如何爬網站之外還能學習到一些其他的東西

比如開發方式、小技巧、設計模式、坑...等等

這邊保證會在文章中盡量多塞一些內容,往後也希望大家多多支持