iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 21
1
Modern Web

30天從零撰寫 Kotlin 語言並應用於 Spring Boot 開發系列 第 21

[Day 21] 遠征 Kotlin × Spring Boot 爬蟲實戰教學

今日看到有許多鐵人賽的朋友紛紛完賽,有點好奇目前還有幾位鐵人還在一起努力,於是想到可以撰寫爬蟲 Web scraper 程式來了解一下,而在 Java library 中有個 JSOUP 套件,此套件有提供許多方便易用的 API 可以解析 HTML,使用方法與 CSSjQuery 選擇器類似,也因為 Kotlin 與 Java 整合度非常好,所以 Kotlin 可以直接呼叫 Java Library 讓我們順利處理許多事情,下面我們來介紹 JSOUP 的使用方式與實作範例「鐵人賽比賽現況」

引入方法

若要使用 JSOUP 套件要記得先引入套件,下面是 MavenGradle 分別引用方式

  • Maven

    <dependency>
      <groupId>org.jsoup</groupId>
      <artifactId>jsoup</artifactId>
      <version>1.13.1</version>
    </dependency>
    
  • Gradle

    compile 'org.jsoup:jsoup:1.13.1'
    

資料輸入方法

JSOUP 主要支援四種輸入(Input)方式進行解析成 Document 物件,如下:

  1. 從 字串 解析

    此方法要注意字串必須包含 head 與 body 元素

    val html : String = "<html><head><title>First parse</title></head>" + "<body><p>Parsed HTML into a doc.</p></body></html>";
    val doc : Document = Jsoup.parse(html);
    
  2. 從 HTML 片段解析

    我們也可以將 HTML Body 元素下的部份元素進行分析,例如一部份的 Div 元素,如下:

    val html : String = "<div><p>Lorem ipsum.</p>";
    val doc : Document = Jsoup.parseBodyFragment(html);
    val body : Element = doc.body();
    
  3. 利用 URL 載入 Document

    此方式應該是最常用的方式,利用網頁 url 直接進行分析,其中會使用到 connect 方法,此方法會我們建立一個新的連線,也可以在此方法設定請求細節,例如 cookie、userAgent、timeout等設定,如下:

    val doc : Document = Jsoup.connect("http://example.com/").get();
    val title : String = doc.title();
    
  4. 利用 File 載入 Document

    我們也可以將 HTML 檔案進行讀檔分析,如下:

    val input : File = new File("/tmp/input.html");
    val doc : Document = Jsoup.parse(input, "UTF-8", "http://example.com/");
    

資料解析方法

在解析方法中,主要會推薦使用兩種方法,再看大家比較喜歡哪一種方式:

  1. DOM 方法

    此方法就是利用 DOM 操作的寫法讓我們學習如何在取得的 Document 物件進行取得元素值 Element,範例如下:

    val input : File = new File("/tmp/input.html");
    val doc : Document = Jsoup.parse(input, "UTF-8", "http://example.com/");
    
    val content : Element = doc.getElementById("content");
    val links : Elements = content.getElementsByTag("a");
    for (val link : links) {
      val linkHref : String = link.attr("href");
      val linkText : String = link.text();
    }
    
    • 尋找元素方法有以下幾種
      • [getElementById(String id)] 利用 id 進行尋找
      • [getElementsByTag(String tag)] 利用 tag 進行尋找
      • [getElementsByClass(String className)] 利用 class 進行尋找
      • [getElementsByAttribute(String key)] 利用屬性值進行尋找
      • 也可以使用下面方法找出與元素有關聯的元素
        • [siblingElements()]
        • [firstElementSibling()]
        • [lastElementSibling()]
        • [nextElementSibling()]
        • [previousElementSibling()]
        • [parent()] 
        • [children()] 
        • [child(int index)]
    • 元素細節操作方法
      • [attr(String key)] 利用元素 key 值取得元素屬性
      • [attr(String key, String value)] 設定元素屬性
      • [attributes()] 取得所有元素屬性
      • [id()][className()] and [classNames()]
      • [text()] 取得元素文字資料
      • [html()] 取得元素 HTML 資料
      • [tag()] 、[tagName()] 取得 Tag 資料
    • 控制 HTML 元素 與 文字
      • [append(String html)][prepend(String html)]
      • [appendText(String text)][prependText(String text)]
      • [appendElement(String tagName)][prependElement(String tagName)]
      • [html(String value)]
  2. 選取器方法

    此方法類似於 CSSjQuery的選取器使用方法,如下:

    val input : File = new File("/tmp/input.html");
    val doc : Document = Jsoup.parse(input, "UTF-8", "http://example.com/");
    
    val links : Elements = doc.select("a[href]");
    val pngs : Elements = doc.select("img[src$=.png]");
    
    val masthead : Element = doc.select("div.masthead").first();
    val resultLinks : Elements = doc.select("h3.r > a");
    
    • 選取器(Selector)使用方式
      • tagname 利用 Tag 找到元素,例如 a 元素
      • #id利用 # 符號加上 id 尋找元素
      • .class 利用 . 符號加上 class 值尋找元素
      • [attribute] 設定元素是否包含某個屬性進行進階條件尋找
      • [attr=value] 設定元素是否包含某個屬性欄位與對應值,例如 width=500
      • [attr^=value][attr$=value][attr*=value] 可針對屬性值使用模糊查詢
      • [attr~=regex]: 針對屬性值使用 regular expression,例如 img[src~=(?i)\.(png|jpe?g)]
    • 選取器組合(Selector combinations )方式
      • el#id  利用元素加上 id 值進行尋找,例如 div#logo
      • el.class 利用元素加上 class 值進行尋找,例如 div.masthead
      • el[attr] 利用元素搭配屬性值進行尋找,例如 a[href]
      • 或是使用任何元素與屬性進行尋找,例如 a[href].highlight

元素擷取細節

上面已經介紹如何取得 Document 物件與取得特定元素 Element,再來想要介紹如何取得元素Elements 的細節資料,例如元素的文字(Text)、連結(href)等欄位,如下範例:

val html : String = "<p>An <a href='http://example.com/'><b>example</b></a> link.</p>";
val doc : Document = Jsoup.parse(html);
val link : Element = doc.select("a").first();

val elementId = doc.id()
val elementTagName = doc.tagName()
val elementClassName = doc.className()

// 取得 An example link.
val text : String = doc.body().text(); 

// 取得 http://example.com/
val linkHref : String = link.attr("href"); 

// 取得 example
val linkText : String = link.text(); 

// 取得 <a href="http://example.com/"><b>example</b></a>
val linkOuterH : String = link.outerHtml(); 

// 取得 <b>example</b>
val linkInnerH : String = link.html(); 

實作範例

如本文開頭所述,這個範例是想了解鐵人賽還有多少參賽者還在一起努力,有多少鐵人朋友已經順利達陣完成30天目標,故我們從鐵人賽頁面的選手列表進行觀察,我們可以開啟瀏覽器的開發者工具了解網站每個元素的規則,這邊將觀察到的規則整理如下:

(1) 開啟瀏覽器開發者工具,觀察每個元素如何進行命名,找出對應的規則
https://ithelp.ithome.com.tw/upload/images/20200930/20121179Kz4OyH8kw1.png

  • contestants-list clearfix 為每一個參賽者資料區塊
  • contestants-list__title 參賽者參賽主題
  • contestants-list__name 參賽者暱稱
  • contestants-list__desc 主題描述
  • contestants-expect__number 敲碗數
  • team-dashboard__day 挑戰天數
  • contestants-group contestants-list__group 挑戰組別
  • contestants-list__date 報名日期
  • team-dashboard__box team-progress--challenge 正在挑戰的樣式
  • team-dashboard__box team-progress--fail 挑戰失敗的樣式

(2) 觀察出關鍵元素-正在挑戰 / 挑戰失敗的樣式差異,如下圖
https://ithelp.ithome.com.tw/upload/images/20200930/20121179ljsaiEjRke.png

(3) 接下來,我們利用上述整理的規則進行撰寫程式,說明如下:

@RestController
@RequestMapping("/api")
class HomeController {

    @GetMapping("/getIronManData")
    fun getData(): HashMap<String, Any> {
        // 初始化 API 輸出集合
        val response = HashMap<String, Any>()

		// 設定爬蟲會用到的基本參數
        // 鐵人賽網站連結
        val ironManUrl: String = "https://ithelp.ithome.com.tw/2020-12th-ironman/signup/list"
        var document = Jsoup.connect(ironManUrl).get()
        // 取得全部網站註冊人數
		val totalRegisterPerson = document.select(".contestants-num")[0].text().replace("報名數 ", "").toInt()
        // 取得每頁參加者數量
        val onePageCount = document.select(".contestants-list").size
        // 取得全部頁面數量
		val totalPageCount = totalRegisterPerson / onePageCount + 1
        // 初始化參數
		var challengingCount = 0 // 仍正在挑戰中的人數
        var challengeSuccessCount = 0 // 挑戰成功的人數
        var challengeFailedCount = 0 // 挑戰失敗的人數
        var unchallengedCount = 0 // 已經報名,但未開賽的人數
        // 初始化每日進度集合 
		val daysCount = HashMap<String, Int>()
		for (index in 0..30) daysCount[index.toString()] = 0
        
		// 帶入每頁頁碼參數
		for (page in 1..totalPageCount) {
			// 連結加入頁碼參數
            document = Jsoup.connect("$ironManUrl?page=$page").get()
			// 查詢此頁參加者區塊數量
            val cardSize = document.select(".contestants-list").size
			// 帶入此頁區塊數量
            for (index in 0 until cardSize) {
                // 取得區塊元素 Element 
                val item = document.select(".contestants-list")
				// 取得挑戰天數資料
                val challengeDay = item.select(".team-dashboard__day")[index].text().replace("DAY ", "").replace("尚未開賽", "0").toString()
                // 將該挑戰天數的挑賽人數 + 1
				daysCount[challengeDay] = daysCount[challengeDay]!!.toInt().plus(1)

				// 取得挑戰狀態
                val progressByChallengeStatus = ! item.select(".team-progress--challenge").isEmpty()
                val progressByFailStatus = ! item.select(".team-progress--fail").isEmpty()

				// 計算挑戰成功、挑戰中、挑戰失敗、已報名未挑戰人數
                if (progressByChallengeStatus && !progressByFailStatus && challengeDay.toInt() == 30) challengeSuccessCount++
                if (progressByChallengeStatus && !progressByFailStatus && challengeDay.toInt() != 30) challengingCount++
                if (!progressByChallengeStatus && progressByFailStatus && challengeDay.toInt() == 0) unchallengedCount++
                if (!progressByChallengeStatus && progressByFailStatus && challengeDay.toInt() > 0) challengeFailedCount++
            }
        }
        // 儲存 API 結果進行輸出
        response["全部參賽人數"] = totalRegisterPerson
        response["挑戰成功人數"] = challengeSuccessCount
        response["挑戰進行人數"] = challengingCount
        response["挑戰失敗人數"] = challengeFailedCount
        response["挑戰進度文章數量(天/篇)"] = daysCount

        return response
    }
}

(4) 接著執行程式 ,會產生如下 API 爬蟲結果:
https://ithelp.ithome.com.tw/upload/images/20200930/201211794tbE04dZuE.png

(5) 接著,當我們完成爬蟲程式並取得資料結果,後續其實就可以做很多事情,像是資料分析、資料視覺化等動作,下面也是我們針對結果產生出圖表,可以從圖表觀察出目前比賽進度的人數比例:
https://ithelp.ithome.com.tw/upload/images/20200930/2012117934bBuQXbif.png

以上是 JSOUP 爬蟲介紹,建議大家可以練習實作看看,爬蟲程式在實作上不難,但卻可以讓我們在後續實作出很多很有趣的應用。

Rerference


上一篇
[Day 20] 遠征 Kotlin × Spring Boot 使用分層架構 Layered Architecture
下一篇
[Day 22] 遠征 Kotlin × Spring Boot 介紹單元測試 (1)
系列文
30天從零撰寫 Kotlin 語言並應用於 Spring Boot 開發30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
cailiwu
iT邦新手 4 級 ‧ 2020-09-30 23:16:12

非常開心有幸能在數字裡頭

Devin iT邦新手 4 級 ‧ 2020-09-30 23:27:06 檢舉

加油!一起堅持到最後

我要留言

立即登入留言