[Kotlin] JSoupを使ってHTML解析する方法

##はじめに##

JSoupをYahoo NewsのHTMLから内容を取得する方法を説明します。
ちなみにKotlinのようなスッキリ感はないがJAVAでも同様です。

先ずはMavenやGradleでJsoupを導入

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

必要ではないが、僕は”これがあれば便利”の拡張機能とNULL POINTER防止の為を以下を作成した。
JSoupのAPIはElements.first(), Elements.last(), Element.selectFirst()などはNULL POINTER発生します。
そのまま使うと細かくチェックしないとNPE発生するので厄介です。

```Kotlin:JSoupDoc.kt
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.util.*
import java.util.function.Consumer
import kotlin.streams.toList

class JSoupDoc(private val document: Optional<Document>) {
    companion object {
        const val CONNECTION_TIMEOUT: Int = 150000
        var errorHandler: Consumer<Throwable> = Consumer { }

        @JvmStatic
        fun fromUrl(url: String): JSoupDoc {
            val htmlDoc = kotlin.runCatching {
                Jsoup.connect(url).ignoreHttpErrors(true).timeout(CONNECTION_TIMEOUT).get()
            }.fold(
                onSuccess = { Optional.ofNullable(it) },
                onFailure = {
                    errorHandler.accept(it)
                    Optional.empty()
                }
            )
            return JSoupDoc(htmlDoc)
        }
    }

    val isEmpty = document.isEmpty
    val isPresent = document.isPresent
    val body: Optional<Element> = document.map { it.body() }

    fun listElement(query: String): List<Element> {
        return document.map { it.select(query).stream().toList() }.orElse(listOf())
    }
}

fun Element.selectFirstOpt(query: String): Optional<Element> {
    return Optional.ofNullable(this.select(query).first())
}

fun Element.textWithout(text: String): String = this.text().replace(text, "")

fun Element.href(): String = this.attr("href")

fun Element.absHref(): String = this.attr("abs:href")

fun Element.firstText(query: String): String = this.selectFirstOpt(query).map { it.text() }.orElse("")

fun Element.firstHref(query: String): String = this.selectFirstOpt(query).map { it.href() }.orElse("")

fun Element.firstAbsHref(query: String): String = this.selectFirstOpt(query).map { it.absHref() }.orElse("")
```

##例１：Yahoo News##
###取得したい内容を定義###

例として、news.yahoo.co.jp/ranking/access/newsの内容を取得するプログラムをつくります。
![p14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397757/86ba25c3-6357-0e0f-eab2-1090fc3a8fba.png)

パッと見れば一つのニュースでは以下の構成
* タイトル
* 内容の連結
* 新聞の元
* 時間
* 画像

```Kotlin:NewsItem.kt
    class NewsItem(
        val title: String,
        val url: String,
        val source: String,
        val date: String,
        val imageUrl: String
    )
```

###ChromeのToolsを使ってHTML分析###
![p11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397757/41c7bbd5-b960-479a-5842-10de69e6de2a.png)

![p12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397757/f63e0f49-58b1-0656-3354-eb141a773d38.png)

まず、一つのニュース内容は全部newsFeed_item_linkというaの中にいます（赤の部分）。
この中で、欲しい内容は何処にいるかを探します（緑の部分）。
それぞれの唯一無二の特徴を探します。
* タイトル　ー　newsFeed_item_titleというdiv内
* 内容の連結　ー　最初のaのhref
* 新聞の元　ー　newsFeed_item_mediaというspan内
* 時間　ー　newsFeed_item_dateというtime内
* 画像の連結　ー　唯一のimg内のsrc

###Coding###
```Kotlin:YahooNewsParser.kt
object YahooNewsParser {
    fun get() {
        val doc = JSoupDoc.fromUrl("https://news.yahoo.co.jp/ranking/access/news")
        val newsList: List<NewsItem> = doc.listElement("a.newsFeed_item_link").map { parseSingleItem(it) }

        val gson = GsonBuilder()
            .setPrettyPrinting()
            .create()
        gson.toJson(newsList, FileWriter(Paths.get("news.json").toFile()));
    }

    private fun parseSingleItem(element: Element): NewsItem {
        val url = element.href()
        val title = element.firstText("div.newsFeed_item_title")
        val source = element.firstText("span.newsFeed_item_media")
        val date = element.firstText("time.newsFeed_item_date")
        val imageUrl = element.selectFirstOpt("img")
            .map { it.attr("src") }.orElse("").substringBefore("?")
        return NewsItem(title, url, source, date, imageUrl)
    }
}
```

###結果###
![p13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397757/36cb4a8d-a35e-87cb-afdd-8e7ffdfccff7.png)

##例２：楽天市場##
###取得したい内容とHTML分析###
![p21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397757/462c8825-c373-1de2-93c5-58f9e2045b33.png)
一つの内容　ー　dui-card searchresultitemというdiv
* タイトル　ー　content titleというdiv > h2 > aの文字
* 内容の連結　ー　content titleというdiv > h2 > aのhref
* 価格　ー　content description priceというdiv > spanの文字
* 商店　ー　content merchant _ellipsisというdiv > aの文字
* 画像の連結　ー　imageというdiv > a > imgのsrc

###Coding###
```Kotlin:RakutenShoppingParser.kt
object RakutenShoppingParser {
    class ShopItem(
        val title: String,
        val url: String,
        val price: String,
        val shop: String,
        val imageUrl: String
    )

    fun get(keyWord: String) {
        val doc = JSoupDoc.fromUrl("https://search.rakuten.co.jp/search/mall/$keyWord/")
        val itemList: List<ShopItem> =
            doc.listElement("div[class=dui-card searchresultitem]").map { parserSingleItem(it) }

        val gson = GsonBuilder()
            .setPrettyPrinting()
            .create()
        gson.toJson(itemList, FileWriter(Paths.get("items.json").toFile()));
    }

    private fun parserSingleItem(element: Element): ShopItem {
        val titleElement = element.selectFirstOpt("div[class=content title] h2 a")
        val title = titleElement.map { it.text() }.orElse("")
        val url = titleElement.map { it.href() }.orElse("")
        val price = element.firstText("div[class=content description price] span")
        val shop = element.firstText("div[class=content merchant _ellipsis] a")
        val imageUrl = element.selectFirstOpt("div.image a img").map { it.attr("src") }
            .orElse("").substringBefore("?")
        return ShopItem(title, url, price, shop, imageUrl)
    }
}
```

###結果###
        RakutenShoppingParser.get("Kotlin")
![p22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397757/221aaf07-7c6d-ceb9-2509-6ac1fd436617.png)