[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 是不用改變的,甚至測試都可以省略了

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

後記

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

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

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

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

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

Read more

[LeetCode] #12 Integer to Roman 解題

題目連結 題型解說 這是一題難度為普通的題目 需要設計一個方法,此方法會傳入一個整數 num 要求是把整數轉換成羅馬字母,轉換清單如下 I => 1 V => 5 X => 10 L => 50 C => 100 D => 500 M => 1000 但羅馬字母有一些特殊規則 4 並非 IIII 而是 IV,9 並非 VIIII 而是 IX 這規則同樣可以套用到 40 90 400 900 解題思路 既然知道特殊規則是一樣的,變得是使用的符號,那麼先從 num 取個位數開始 轉換完成後,把 num 除上 10,消除個位數,

By Michael

[LeetCode] #11 Container With Most Water 解題

題目連結 題型解說 這是一題難度為中等的題目 需要設計一個方法,此方法會傳入一個數字陣列 height 陣列中的元素代表每一個柱子的高度 現在需要計算出,該陣列中以某兩隻柱子為邊界,最多可以裝多少水 以範例來說 height = [1,8,6,2,5,4,8,3,7] 最多可以裝 7 * 7 = 49 單位的水 解題思路 計算面積就是底乘上高 底的計算方式為 「右邊柱子的 index」 減去 「左邊柱子的 index」 高就取最短的那一根柱子高度 拿題目給的例子來當範例 建立三個變數 result、left、right left、right 代表左右兩邊的 index result 代表目前最大容量,初始值 0 第一步,找出最短的柱子高度,

By Michael

[LeetCode] #941 Valid Mountain Array 解題

題目連結 題型解說 這是一題難度為簡單的題目 需要設計一個方法,此方法會傳入一個數字陣列 arr 判斷陣列中的元素是不是由低到高再從高到低(山形)的排序,且不連續一個以上數字 比如說 [1,2,3,2] 就是一個山形陣列,但 [1,2,2,3,2] 不是,因為有兩個 2 [1,2,3,4,5] 和 [5,4,3,2,1] 也不算是山形陣列,前者只有往上沒有往下,後者相反 解題思路 準備一個數字變數(temp)和布林變數(asc),跑一次迴圈,有可能遇到如下狀況 1. 某個數字與前一個數字相同,這時候直接回傳 false

By Michael

[LeetCode] #944 Delete Columns to Make Sorted 解題

題目連結 題型解說 這是一題難度為簡單的題目 需要設計一個方法,此方法會傳入一個字串陣列 strs 這個陣列中每個字串的長度都相同,字串內容都是小寫英文 需要檢查每個元素的第 N 個字元是不是由小至大排列,並回傳有幾個錯誤排列 比如傳入的陣列長這樣 ["cba","daf","ghi"] 取第一個字元 = cdg 取第二個字元 = bah 取第三個字元 = afi 其中第二組的結果(bah)並不是由小至大排列,故回傳 1 解題思路 這一題就用兩個迴圈各別把字元取出來,並比較是否比上一個字元大(Java 中的字元可以直接比較),如果不是就將結果+1 程式碼 Java class Solution { public int minDeletionSize(String[] strs) { int result = 0; for (int i = 0,

By Michael