KINTO Tech Blog
Development

KotlinでOGPを取得する時に文字コードで苦労した話

Cover Image for KotlinでOGPを取得する時に文字コードで苦労した話

はじめに

こんにちは!KTCでAndroidエンジニアをしている長谷川です!
普段はmy routeというアプリの開発をしています。my routeのAndroidチームのメンバーが書いた他の記事も是非読んで見てください!

本記事ではKotlin(Android)でOG情報を取得する方法と、その過程で文字コードの扱いに困った話を紹介します。

この記事で解説すること

OGPとは

OGPとは「Open Graph Protocol」の略で、Webページなどを他のサービスにシェアしたときに、Webページのタイトルやイメージ画像を正しく伝えるためのHTML要素です。
OGPが設定されているWebページはこれらの情報を表すmetaタグが存在します。以下はその中の一部を抜粋したmetaタグです。OG情報を取得したいサービスはこれらのmetaタグから情報を読み込むことができます。

<meta property="og:title" content="ページのタイトル" />
<meta property="og:description" content="ページの説明文" />
<meta property="og:image" content="サムネイル画像のURL" />

KotlinでOGPを取得する方法

今回は通信のためにOkHttp、HTTPのパースのためにJsoupを使用します。

まずはOkHttpを使って、OG情報を取得したいURLのWebページにアクセスします。エラーハンドリングは要件によって変わりますので省略します。

val client = OkHttpClient.Builder().build()
val request =
    Request.Builder().apply {
        url("OG情報を取得したいURL")
    }.build()

client.newCall(request).enqueue(
    object : okhttp3.Callback {
        override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {}

        override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
            parseOgTag(response.body)
        }
    },
)

次にJsoupを使って中身をパースします。

private fun parseOgTag(body: ResponseBody?): Map<String, String> {
    val html = body?.string() ?: ""
    val doc = Jsoup.parse(html)

    val ogTags = mutableMapOf<String, String>()
    val metaTags = doc.select("meta[property^=og:]")

    for (tag in metaTags) {
        val property = tag.attr("property")
        val content = tag.attr("content")

        val matchResult = Regex("og:(.*)").find(property)
        val ogType = matchResult?.groupValues?.getOrNull(1)

        if (ogType != null && !content.isNullOrBlank()) {
            ogTags[ogType] = content
        }
    }
    return ogTags
}

これでogTagsに必要なOG情報が入りました。

OGPで取得した情報が文字化けする原因

ここまでで大抵のWebページのOG情報は正しく取得できると思います。しかし一部のWebページの場合、文字化けが発生してしまう可能性があります。ここではその原因を解説します。

今回は下記のようにstring()という関数を呼びました。

val html = response.body?.string() ?: ""

この関数は以下の優先順位で文字コードを選択します。

  1. BOM(Byte Order Mark)の情報
  2. レスポンスヘッダーのcharset
  3. 1,2に指定がなければUTF-8

詳しくはOkHttpのリポジトリのコメントに記載があります。

はい、つまりBOMの情報がなくて、レスポンスヘッダーのcharsetの指定がなくて、Shift_JISなどUTF-8以外でエンコードされているWebページがあったらどうなると思いますか?

...

文字化けが発生します。なぜならデフォルトのUTF-8でデコードしてしまうからです。
さて、どうしましょうか?次のセクションでは具体的な対応方法を解説します。

文字化けの対応方法

前のセクションで文字化けしてしまう原因が分かりました。実はWebページにおいて文字コードは下記のようにHTML内にも指定されている可能性があります。BOMの情報もなくて、レスポンスヘッダーのcharsetも指定されていない場合はこの情報を使用するしかありません。

<meta charset="UTF-8">  <!-- HTML5 -->
<meta http-equiv="content-type" content="text/html; charset=Shift_JIS"> <!-- HTML5より前 -->

しかし上記の文字コードが指定されたmetaタグを読み込むために、HTMLを文字コードに応じてパースする必要があるという矛盾が発生します。
と一瞬思いますが、例えばUTF-8やShift_JISはASCII文字の範囲では互換性があるため、一旦UTF-8でデコードしても問題ありません。
(この方法だとパースを2回行うことがあります。もしmetaタグのバイト配列をあらかじめ調べておけばパースする前に文字コードを判定することもできるかもしれませんが、今回はコードの分かりやすさを重視しました。)

というわけで下記のようなコードを書くことができます。

/**
  * レスポンスボディからJsoupのDocumentを取得する
  * レスポンスボディのcharsetがUTF-8以外の場合は、charsetを取得して再度パースする
  */
private fun getDocument(body: ResponseBody?): Document {
    val byte = body?.bytes() ?: byteArrayOf()

    // ResponseHeaderにcharsetが指定されている場合、そのcharsetでデコードする
    val headerCharset = body?.contentType()?.charset()
    val html = String(byte, headerCharset ?: Charsets.UTF_8)
    val doc = Jsoup.parse(html)

    // headerCharsetが指定されている場合、そのcharsetで正しくパースできているはずなので
    // そのままreturnします。
    if (headerCharset != null) {
        return doc
    }

    // HTML内のmetaタグからcharsetを取得します。
    // このcharsetがない場合は、文字コードが不明なので、UTF-8でパースされたdocを返します。
    val charsetName = extractCharsetFromMetaTag(html) ?: return doc

    val metaCharset =
        try {
            Charset.forName(charsetName)
        } catch (e: IllegalCharsetNameException) {
            Timber.w(e)
            return doc
        }

    // metaタグで指定されたcharsetとUTF-8が異なる場合、metaタグで指定されたcharsetで再度パースする
    // パースは比較的重たい処理なので、二重で行わないようにします。
    return if (metaCharset != Charsets.UTF_8) {
        Jsoup.parse(String(byte, metaCharset))
    } else {
        doc
    }
}


/**
  * HTMLのmetaタグからcharsetの文字列を取得する
  *
  * HTTP5未満 → meta[http-equiv=content-type]
  * HTTP5以上 → meta[charset]
  *
  * @return charsetの文字列 ex) "UTF-8", "SHIFT_JIS"
  * @return charsetが見つからない場合はnull
  */
private fun extractCharsetFromMetaTag(html: String): String? {
    val doc = Jsoup.parse(html)
    val metaTags = doc.select("meta[http-equiv=content-type], meta[charset]")
    for (metaTag in metaTags) {
        if (metaTag.hasAttr("charset")) {
            return metaTag.attr("charset")
        }
        val content = metaTag.attr("content")
        if (content.contains("charset=")) {
            return content.substringAfter("charset=").split(";")[0].trim()
        }
    }
    return null
}

その後JsoupのDocumentを作成する関数を、今作成した処理を使って以下のように変更しましょう。

- val html = body?.string() ?: ""
- val doc = Jsoup.parse(html)
+ val doc = getDocument(body)

おわりに

お疲れ様でした。
大抵のWebページの文字コードはUTF-8ですし、仮に異なる文字コードを使用しているとしてもBOMやレスポンスヘッダーにcharsetが指定されていることがほとんどです。したがって今回のような問題が発生することはあまりないと思います。
しかし、仮にそのようなサイトを発見してしまった場合、原因の把握や修正方法が難しい場合があります。

本記事がどなたかの助けになれば幸いです。

Facebook

関連記事 | Related Posts

We are hiring!

【iOS/Androidエンジニア】モバイルアプリ開発G/東京・大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【Webアナリスト】分析G/東京・名古屋・大阪

分析グループについてKINTOにおいて開発系部門発足時から設置されているチームであり、それほど経営としても注力しているポジションです。決まっていること、分かっていることの方が少ないぐらいですので、常に「なぜ」を考えながら、未知を楽しめるメンバーが集まっております。