[Java]手把手帶你實作PTT爬蟲(1)-文章列表

前言

好幾年前寫過一篇 Java 的爬蟲文章,好像是我部落格內最受歡迎的一篇...

時過境遷,Eclipse 退流行了、Java 出到 15 了,加上一些因素

打算把爬蟲文章重新寫過一遍,這次會當是做一個 Site project,盡量把內容充實一點

預計這系列文章應該會寫個五六篇以上,後面也會教到資料庫及圖形化介面

必備知識

  1. 常用 Http request   Ex. get post
  2. Cookie 相關知識
  3. HTML相關知識
  4. CSS 選擇器相關知識
  5. Java OOP 相關知識

使用環境

  1. Intelli J
  2. Chrome

創建專案

這次我們使用的 IDE 是 IntelliJ,點下 New Project

01

選擇 Maven 專案,SDK 我是安裝最新的 15 版

02

專案要放哪邊就依照個人喜好,都完成後就會顯示初始畫面

03

新增 OkHttp

既然是爬蟲,就需要可以發送 Http request 的套件

上一篇文章使用的是 Apache 的 HttpClient

這次嘗試使用新的 OkHttp,這兩種套件效能其實差不多

一樣看個人喜好使用

將以下內容加入到 pom.xml 中

<dependencies>
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>3.3.1</version>
    </dependency>
</dependencies>

04

點開右邊的 Maven 選單,按下左上角的 Reload All Maven Projects 按鈕

05

安裝會需要一點時間,跑完後就可以看到套件安裝完畢

06

相關 Class

這次我們把目標放在鼎鼎大名的 PTT 八卦板上

所以先簡單開幾個 Class

  • ptt.crawler.model.Board
  • ptt.crawler.model.Article
  • ptt.crawler.config.Cofing
  • ptt.crawler.Reader

07

Board.java

這隻檔案用來儲存 PTT 中的看板資訊

package ptt.crawler.model;

public class Board {
    private String url; // 看板網址
    private String nameCN; // 中文名稱
    private String nameEN; // 英文名稱
    private Boolean adultCheck; // 成年檢查

    public Board(String url, String nameCN, String nameEN, Boolean adultCheck) {
        this.url = url;
        this.nameCN = nameCN;
        this.nameEN = nameEN;
        this.adultCheck = adultCheck;
    }

    public String getUrl() {
        return url;
    }

    public String getNameCN() {
        return nameCN;
    }

    public String getNameEN() {
        return nameEN;
    }

    public Boolean getAdultCheck() {
        return adultCheck;
    }
}

Article.java

這隻檔案用來儲存 PTT 中的文章資訊

有讀者可能會留意到建構子中怎麼沒有 body

因為目前創建 Article 的時候並不會有文章內容,所以就先不寫

package ptt.crawler.model;

import java.util.*;

public class Article {
    private Board parent; // 所屬板塊
    private String url; // 網址
    private String title; // 標題
    private String body; // 內容
    private String author; // 作者
    private Date date; // 發文時間

    public Article(Board parent, String url, String title, String author, Date date) {
        this.parent = parent;
        this.url = url;
        this.title = title;
        this.author = author;
        this.date = date;
    }

    public Board getParent() {
        return parent;
    }

    public String getUrl() {
        return url;
    }

    public String getTitle() {
        return title;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }

    public String getAuthor() {
        return author;
    }

    public Date getDate() {
        return date;
    }

    @Override
    public String toString() {
        return String.format("Article{ url='%s', title='%s', body='%s', author='%s', date='%s' }", url, title, body, author, date);
    }
}

Config.java

這隻檔案用來放一些參數,比如看板列表、網址...等

看板列表目前先用寫死的方式,之後如果有使用到資料庫,再來重構這部分

package ptt.crawler.config;

import ptt.crawler.model.Board;

import java.util.*;

public final class Config {
    public static final String PTT_URL = "https://www.ptt.cc";
    public static final Map<String, Board> BOARD_LIST = new HashMap<>() {{
        /*
            PTT 看板網址等於看板英文名稱,故直接使用英文名稱當 Map 的 Key
            Ex.
                八卦板 Gossiping,網址為 https://www.ptt.cc/bbs/Gossiping/index.html
                股板 Stock,網址為 https://www.ptt.cc/bbs/Stock/index.html
        */
        put("Gossiping", new Board(
            "/bbs/Gossiping",
            "八卦板",
            "Gossiping",
            true)
        );
    }};
}

Reader.java

這隻檔案是爬蟲的主程式,下一節會詳細的解說當中的程式碼

正篇

先來看看基本的架構

package ptt.crawler;

import org.jsoup.select.Elements;
import ptt.crawler.model.*;
import ptt.crawler.config.Config;

import okhttp3.*;
import org.jsoup.*;
import org.jsoup.nodes.*;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;

public class Reader {
    private OkHttpClient okHttpClient;
    private final Map<String, List<Cookie>> cookieStore; // 保存 Cookie
    private final CookieJar cookieJar;

    public Reader() throws IOException {
        /* 初始化 */
        cookieStore = new HashMap<>();
        cookieJar = new CookieJar() {
            /* 保存每次伺服器端回傳的 Cookie */
            @Override
            public void saveFromResponse(HttpUrl httpUrl, List<Cookie> list) {
                List<Cookie> cookies = cookieStore.getOrDefault(httpUrl.host(), new ArrayList<>());
                cookies.addAll(list);
                cookieStore.put(httpUrl.host(), cookies);
            }
            
            /* 每次發送帶上儲存的 Cookie */
            @Override
            public List<Cookie> loadForRequest(HttpUrl httpUrl) {
                return cookieStore.getOrDefault(httpUrl.host(), new ArrayList<>());
            }
        };
        okHttpClient = new OkHttpClient.Builder().cookieJar(cookieJar).build();

        /* 獲得網站的初始 Cookie */
        Request request = new Request.Builder().get().url(Config.PTT_URL).build();
        okHttpClient.newCall(request).execute();
    }
}

目前整個 Reader 只會用到以下這三個共用實體

  • okHttpClient 為 Reader 共用的請求實體
  • cookieStore 為 Cookie 保存使用,每一次發送請求都會帶著
  • cookieJar 為 OkHttp 管理 Cookie 用的 Class

建構子內除了實體化部分還另外做了一件事情「獲得網站的初始 Cookie」

這麼做的用意在於,除了盡量模擬正常使用者的使用環境外

也顧慮到網站可能會把一些驗證資訊放到 Cookie 內

如果沒帶到這些內容可能造成驗證失敗

PS: 這一步並非必要,只是一種習慣而已

第一隻 Method

接下來來分析一下如果一個從未訪問過 PTT 八卦板 的瀏覽器會遇到什麼事情

直接開啟 Chrome 的無痕模式訪問八卦板網址

PS: 養成使用無痕的習慣,用以確認爬蟲真實遇到的情況

看到的第一個頁面是一個成年檢查畫面,有兩個按鈕讓我們選擇

08

遇到這種狀況不需要慌張,按下 F12 開啟開發者工具頁面

09

PS: 開發者工具很有用,Web 開發者或是網站爬蟲開發者都要熟悉操作

點下左上方的 icon,反白後再去點選同意按鈕

可以看到右方幫你鎖定到了同意按鈕在 HTML 中的位置

10

來看看這顆按鈕按下去會發生什麼事情

11

首先可以看到按鈕是包在一個 Form 表單裡面

這個表單的方式是 Post,目標 URL 是要傳送到 /ask/over18

再來可以看到有一個隱藏的欄位,name = form,value 等於八卦板的網址

這邊可以大膽判斷對方的邏輯是透過這個欄位來決定使用者點下同意按鈕後該跳轉去哪

PS: 這樣的方法如果沒有做好安全檢查的話,對於資安上可能是一個小漏洞

同意按鈕本身就是一個 submit,name = yes、value = yes

一頓分析下來可以得到結果了,點下同意按鈕後的行為如下

  1. 發送 post 表單到網址 https://www.ptt.cc/ask/over18
  2. 一併帶過去的資料有兩個 欄位from 及 欄位yes

那麼第一隻 Method 就來寫這部分吧,直接上程式碼

/* 進行年齡確認 */
private void runAdultCheck(String url) throws IOException {
    FormBody formBody = new FormBody.Builder()
        .add("from", url)
        .add("yes", "yes")
        .build();

    Request request = new Request.Builder()
        .url(Config.PTT_URL + "/ask/over18")
        .post(formBody)
        .build();

    okHttpClient.newCall(request).execute();
}

這個方法需要傳入一個 url 字串,用來代表 form 欄位的值

formBody 的部分利用套件提供的方法直接幫我們處理完畢

request 也是一樣,利用套件幫我們處理好,post 方法代表我們要用 post 發送,很直覺

最後呼叫 newCall 創建一個請求,呼叫 execute 發送這一個請求

PS: execute 方法會回傳一個 Response 物件,但我們不需要針對回傳內容做處理

至於好奇心比較重的讀者們可能會有疑惑說:「為什麼這樣就有效果了呢?」

答案其實很單純,因為 PTT 確認使用者有沒有點擊同意按鈕的方法呢

就是檢查 Cookie 中有沒有一個叫做 over18 的內容

這張圖片是一開始訪問 PTT 時的 Cookie 列表

12

按下同意按鈕之後,Cookie 列表就多了幾條,其中一條就是 over18

13

所以只要帶上有 over18 的 Cookie, PTT 就不會跳轉到成年檢查頁面

第二隻 Method

通過成年檢查後後就可以看到一個正常的列表畫面

14

接下來確認文章列表是怎麼呈現的,一樣利用開發者工具鎖定到文章列表上

15

文章列表的每一條都是一個 div,class 都是設定 r-ent

再來看看 div 的內部長怎樣

16

標題 div,class 為 title,裡面還有一個 a
作者 div,class 為 author
日期 div,class 為 date

知道該找的資料在何方,那麼就可以來寫第二隻 Method

但在此之前需要來安裝另一個好用的套件 Jsoup

這個套件可以解析 HTML 結構,之後利用 CSS 選擇器的方式來提取需要的資料

比自己用 Split 去切字串來的方便很多,大力推薦

安裝方式就跟安裝 OkHttp 一樣,在 pom.xml 中加入以下程式碼

點開右邊的 Maven 選單,按下左上角的 Reload All Maven Projects 按鈕

<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.11.3</version>
</dependency>

17

安裝好後就來看看第二隻 Method 的內容

/* 解析看板文章列表 */
private List<Map<String, String>> parseArticle(String body) {
    List<Map<String, String>> result = new ArrayList<>();
    Document doc = Jsoup.parse(body);
    Elements articleList = doc.select(".r-ent");

    for (Element element: articleList) {
        String url = element.select(".title a").attr("href");
        String title = element.select(".title a").text();
        String author = element.select(".meta .author").text();
        String date = element.select(".meta .date").text();

        result.add(new HashMap<>(){{
            put("url", url);
            put("title", title);
            put("author", author);
            put("date", date);
        }});
    }

    return result;
}

這一隻 Method 主要負責解析出需要的資料,傳入 HTML 回傳一個 List

首先將原始的 HTML 字串交給 Jsoup 轉換成 Document

Document doc = Jsoup.parse(body);

再來就直接把所有的文章取出,利用跟 CSS 選擇器一樣的方式

articleList 中存放的就是每一個 class 為 r-ent 的 div

Elements articleList = doc.select(".r-ent");

最後利用迴圈把上面分析出來的資訊過濾出來,這部分應該不需要多做解釋

寫過前端的應該很眼熟,上手很快

for (Element element: articleList) {
    String url = element.select(".title a").attr("href");
    String title = element.select(".title a").text();
    String author = element.select(".meta .author").text();
    String date = element.select(".meta .date").text();

    result.add(new HashMap<>(){{
        put("url", url);
        put("title", title);
        put("author", author);
        put("date", date);
    }});
}

至於為什麼不直接回傳 List<Article> 而要另外存到 Map 中呢?

主要是因為這隻 Method 只是負責解析資料,如果要由 Method 來創造 Model

感覺就違反單一原則,所以只回傳一個 Map,由其它地方來處理

PS: 這只是一種 Coding style 而不是準則,所以不需要太計較

第三隻 Method

這是 Reader 的最後一隻 Method,用來讓外部呼叫的入口

當中沒有特別需要講解的部分,都是很簡單的邏輯

public List<Article> getList(String boardName) throws IOException, ParseException {
    Board board = Config.BOARD_LIST.get(boardName);

    /* 如果找不到指定的看板 */
    if (board == null) {
        return null;
    }

    /* 如果看板需要成年檢查 */
    if (board.getAdultCheck() == true) {
        runAdultCheck(board.getUrl());
    }

    /* 抓取目標頁面 */
    Request request = new Request.Builder()
        .url(Config.PTT_URL + board.getUrl())
        .get()
        .build();

    Response response = okHttpClient.newCall(request).execute();
    String body = response.body().string();

    /* 轉換 HTML 到 Article */
    List<Map<String, String>> articles = parseArticle(body);
    List<Article> result = new ArrayList<>();
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM/dd");

    for (Map<String, String> article: articles) {
        String url = article.get("url");
        String title = article.get("title");
        String author = article.get("author");
        Date date = simpleDateFormat.parse(article.get("date"));

        result.add(new Article(board, url, title, author, date));
    }

    return result;
}

完整程式碼

package ptt.crawler;

import org.jsoup.select.Elements;
import ptt.crawler.model.*;
import ptt.crawler.config.Config;

import okhttp3.*;
import org.jsoup.*;
import org.jsoup.nodes.*;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;

public class Reader {
    private OkHttpClient okHttpClient;
    private final Map<String, List<Cookie>> cookieStore; // 保存 Cookie
    private final CookieJar cookieJar;

    public Reader() throws IOException {
        /* 初始化 */
        cookieStore = new HashMap<>();
        cookieJar = new CookieJar() {
            @Override
            public void saveFromResponse(HttpUrl httpUrl, List<Cookie> list) {
                List<Cookie> cookies = cookieStore.getOrDefault(httpUrl.host(), new ArrayList<>());
                cookies.addAll(list);
                cookieStore.put(httpUrl.host(), cookies);
            }

            @Override
            public List<Cookie> loadForRequest(HttpUrl httpUrl) {
                return cookieStore.getOrDefault(httpUrl.host(), new ArrayList<>());
            }
        };
        okHttpClient = new OkHttpClient.Builder().cookieJar(cookieJar).build();

        /* 獲得網站的初始 Cookie */
        Request request = new Request.Builder().get().url(Config.PTT_URL).build();
        okHttpClient.newCall(request).execute();
    }

    public List<Article> getList(String boardName) throws IOException, ParseException {
        Board board = Config.BOARD_LIST.get(boardName);

        /* 如果找不到指定的看板 */
        if (board == null) {
            return null;
        }

        /* 如果看板需要成年檢查 */
        if (board.getAdultCheck() == true) {
            runAdultCheck(board.getUrl());
        }

        /* 抓取目標頁面 */
        Request request = new Request.Builder()
            .url(Config.PTT_URL + board.getUrl())
            .get()
            .build();

        Response response = okHttpClient.newCall(request).execute();
        String body = response.body().string();

        /* 轉換 HTML 到 Article */
        List<Map<String, String>> articles = parseArticle(body);
        List<Article> result = new ArrayList<>();
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM/dd");

        for (Map<String, String> article: articles) {
            String url = article.get("url");
            String title = article.get("title");
            String author = article.get("author");
            Date date = simpleDateFormat.parse(article.get("date"));

            result.add(new Article(board, url, title, author, date));
        }

        return result;
    }

    /* 進行年齡確認 */
    private void runAdultCheck(String url) throws IOException {
        FormBody formBody = new FormBody.Builder()
            .add("from", url)
            .add("yes", "yes")
            .build();

        Request request = new Request.Builder()
            .url(Config.PTT_URL + "/ask/over18")
            .post(formBody)
            .build();

        okHttpClient.newCall(request).execute();
    }

    /* 解析看板文章列表 */
    private List<Map<String, String>> parseArticle(String body) {
        List<Map<String, String>> result = new ArrayList<>();
        Document doc = Jsoup.parse(body);
        Elements articleList = doc.select(".r-ent");

        for (Element element: articleList) {
            String url = element.select(".title a").attr("href");
            String title = element.select(".title a").text();
            String author = element.select(".meta .author").text();
            String date = element.select(".meta .date").text();

            result.add(new HashMap<>(){{
                put("url", url);
                put("title", title);
                put("author", author);
                put("date", date);
            }});
        }

        return result;
    }
}

測試

這邊要使用的測試套件是 JUnit ,使用上很簡單

一樣先裝套件

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.8.0-M1</version>
    <scope>test</scope>
</dependency>

之後將輸入焦點放到 Class 那一行,按下鍵盤的 Alt + Enter

跳出選單後選擇 Create Test 後按下 Entrt

18

Testing library 選擇 JUnit5

setUp/@Before 打勾,按下 OK

19

IDE 就會幫你在 test 資料夾中建立好測試的 Class

20

之後將以下程式碼覆蓋原本的內容

package ptt.crawler;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import ptt.crawler.model.Article;

import java.io.IOException;
import java.text.ParseException;
import java.util.List;

class ReaderTest {
    private Reader reader;

    @org.junit.jupiter.api.BeforeEach
    void setUp() {
        try {
            reader = new Reader();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Test
    void list() {
        try {
            List<Article> result = reader.getList("Gossiping");
            Assertions.assertInstanceOf(List.class, result);
            System.out.println(result);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}

之後第 24 行旁邊應該會出現一個綠色箭頭,按下去後選擇 Run 'list()'

跑完後就可以看到文章列表的內容

22

PS: 這邊沒有講到 JUnit 的用法,有興趣的讀者可以自行去了解

如果覺得 Junit 用法上很麻煩的讀者,也可以直接開一個 Class 去 Run

效果是一樣的,任君挑選

package ptt.crawler;

import ptt.crawler.model.Article;

import java.io.IOException;
import java.text.ParseException;
import java.util.List;

public class Test {
    public static void main(String[] args) {
        try {
            Reader reader = new Reader();
            List<Article> result = reader.getList("Gossiping");
            System.out.println(result);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}

後記

首先很感謝將文章看到這邊,此系列旨意在於帶領想要了解爬蟲的讀者們入門

爬蟲說白了就是將平常使用者的操作自動化而已,沒有想像中的複雜

這次選 PTT 來當範例主要也是因為 PTT 是一個很經典的 Web 架構

沒有前後端分離、內容簡單、驗證功能幾乎沒有,是一個很好的範例

之後的幾篇會繼續擴充其他功能,敬請期待

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