ScalaでウェブページのCharsetを取得する

サイトのエンコードは、

  • HTML, head内のmeta charset
  • レスポンスヘッダのContent-Type内

の2箇所に記述してあるケースが多いのですが、前者はサイト作成者の記述ミスが多々あるため信用できず、後者もまれにエンコード名として異常な文字列が入っていることがあります。

(以前、charset=%E6%96%87%E5%AD%97%E3%82%B3%E3%83%BC%E3%83%89 という胸騒ぎがする指定を発見し、いざデコードしてみるとcharset=文字コードが出てくるというハートウォーミングな出来事がありました。マジかよ。)

そのため、エンコードを判定する機能が必要になります。

今回、判定にはMozillaエンコード検出ライブラリuniversalchardetのJava実装版であるjuniversalchardetを利用しました。

juniversalchardet - Google Code Archive

エンコードを判別したいバイト列をUniversalDetectorクラスのインスタンスにハンドリングさせエンコード名を取得、と利用するようです。
Javaから利用するサンプルコードが公式にあります。

ただなんでもかんでもこいつに判定させるのはパフォーマンス的によろしくなさそうなので、 あくまでも「Content-Typeのcharsetが存在しない、もしくはエンコードとして扱えない文字列が指定されている場合」のみ判定をを行うのがよさそうです。

import java.nio.charset.Charset

import scala.util.{Failure, Success, Try}

import org.mozilla.universalchardet.UniversalDetector

 /**
 * Content-Typeヘッダからcharsetを取得する。
 * ただし、Content-Typeで指定されたcharsetがエンコーディング名として不正な場合は、渡されたバイト列からcharsetを検出する。
 * エンコーディング名として正しいcharsetがContent-Typeから取得できず、渡されたバイト列からも検出できない場合はNoneを返す。
 */
protected def getCharset(contentType: String, bytes: Array[Byte]): Option[String] = {
  def _detect(bytes: Array[Byte]): Option[String] = {
    val ud = new UniversalDetector(null)
    ud.handleData(bytes, 0, 1024) // パフォーマンスを考慮し、先頭1024バイトのみで判定する
    ud.dataEnd()
    Option(ud.getDetectedCharset)
  }

  """.*charset=(.+)""".r.findAllIn(contentType).matchData
   .map(_.group(1)).toList.headOption.flatMap { charsetName =>
    Try {
      Charset.forName(charsetName)
    } match {
      case Success(_) => Some(charsetName)
      case Failure(_)    => _detect(bytes)
    }
  }
}

val res = ... // レスポンス
val contentType: String = res.getContentType
val bodyAsBytes: Array[Byte] = res.getResponseBodyAsBytes
val charset: Option[String] = getCharset(contentType, bodyAsBytes)

まだ改良の余地はありますが、これでひとまず判定ができるようになります。