2010年6月10日木曜日

RubyのMechanizeで文字化けするページがある件について

Mechanizeがときどき日本語のページで文字化けするので、原因と解決方法を調べてみた。確認したバージョンは次の通り。

  • Ruby 1.9.1-p378と1.8.6-p111(Ubuntuのパッケージ)
  • Mechanize 1.0.0
  • Nokogiri 1.4.2
  • libxml2 2.6.31(Ubuntuのパッケージ)

エンコーディングの扱い

Mechanizeの内部エンコーディングはUTF-8に固定されていて、ドキュメントのエンコーディングが何であれUTF-8に変換される。これはMechanizeがHTMLパーサに使っているNokogiriの仕様であり、Nokogiriが依存しているlibxml2の仕様でもある。そういう事情でMechanizeから取り出した文字列はすべてUTF-8になっている。Ruby 1.9だとString#encodingEncoding::UTF_8にセットされる。

上述したように、MechanizeはHTMLのパースをNokogiriで行っていて、具体的にはNokogiri::HTML.parseが呼ばれるようになっている。Nokogiri::HTML.parseは第3引数にHTMLのエンコーディングを受け取り、そのエンコーディングからUTF-8に変換してパースを行う。デフォルト値はnilなのだけど、その場合はmetaタグのcharsetから自動認識する。これらコード変換も含めたパースを実際に行っているのはlibxml2で、NokogiriはhtmlReadMemory()関数またはhtmlReadIO()関数を呼んでいるだけだったりする。

MechanizeはPage#initializeの中で、Nokogiri::HTML.parseの第3引数に渡すエンコーディングを以下の順序で決定している。

  1. metaタグでcharsetが指定されている場合はnil(Nokogiriに任せる)
  2. HTTPヘッダでcharsetが指定されていたらその値
  3. 上記以外の場合はNKF.guessで返ってきたエンコーディング

文字化けの原因

libxml2で起きたパースエラーはNokogiri::HTML::Document#errorsNokogiri::XML::SyntaxErrorオブジェクトの配列として格納される。また、Mechanize::Page#parserがそのページのパース結果であるNokogiri::HTML::Documentオブジェクトを返すので、

p mechanize.page.parser.errors

とすればパースエラーが表示される。そうすると、文字化けが起きるときは必ずInput is not proper UTF-8, indicate encoding !というエラーが含まれていることがわかった。libxml2のドキュメントによると、このエラーが起きるのはエンコーディングの指定がなく、且つドキュメントのエンコーディングがUTF-8でもUTF-16でもない場合とのこと。

metaタグのcharsetは正しく指定されていると思うのだけど、もう一度うまくいくページと、うまくいかないページを見比べてみた。よく見ると、エラーになるページはtitleタグよりあとでcharsetが指定されている。つまり、titleタグはエンコーディング指定なしのまま、デフォルトのUTF-8/UTF-16ハンドラで処理が進み、UTF-8の妥当性チェックでエラーが起きたということのようだ。

正攻法では解決しない

結局はMechanizeからNokogiriに正しいエンコーディングを渡すことができればうまくいくはずだけど、Mechanizeが用意しているのは、パース終了後に改めてMechanize::Page#encoding=でエンコーディングを指定して、もう一度パースをやり直すという方法だけ。しかも、これは上で述べたケースでは全然うまくいかない。

次のようにEUC-JPのHTMLドキュメントをリクエストして、libxml2でUTF-8の妥当性エラーが起きたとする。

mechanize.get("http://example.com/EUC-JP.html")

エラーが起きてもNokogiri::DocumentのエンコーディングはEUC-JPにセットされる。

mechanize.page.parser.encoding # => "EUC-JP"

その後、Page#encoding=で改めてエンコーディングを指定するのだけど、

mechanize.page.encoding = "EUC-JP" # 何も起きない

引数で与えられたエンコーディングとPage#parserのエンコーディングが等しい場合は何もしないようになっているので、パースのやり直しは起きない。ということで、このケースだとMechanize::Page#encoding=はまったく役に立たない。

力押しで解決

Mechanizeには、pre_connect_hookspost_connect_hooksというメソッドがあって、それぞれリクエスト前とリクエスト後に任意の処理を差し込めるようになっている。これを利用して、Nokogiriがパースする前にHTTPヘッダとmetaタグのcharset、XML宣言のencoding、HTML自体のエンコーディングをUTF-8に変えてしまうことにする。

リクエスト後なのでMechanize#post_connect_hooksを使うのだけど、中身はただのProcオブジェクトの配列なので、Procオブジェクトを作って追加するだけでいい。

def fix_charset_hook(more_nkf_options = "")
  lambda do |params|
    if params[:response]["Content-Type"]
      params[:response]["Content-Type"].sub!(/charset\s*=\s*[^;\s]+/i, "charset=UTF-8")
    end

    response_body = NKF.nkf("-w -m0 #{more_nkf_options}", params[:response_body])
    if m = response_body.match(/<\?xml[^>]+encoding\s*=\s*["']?([^>"'\s]+)[^>]*\?>/i)
      response_body[Range.new(m.begin(1), m.end(1) - 1)] = "UTF-8"
    end
    if m = response_body.match(/<meta[^>]+charset\s*=\s*["']?([^>"'\s;\/]+)[^>]*>/i)
      response_body[Range.new(m.begin(1), m.end(1) - 1)] = "UTF-8"
    end
    params[:response_body] = response_body
  end
end

mechanize.post_connect_hooks << fix_charset_hook

これでほとんどの場合はうまくいくけど、入力コードはNKFが推測するので間違うこともある。そういうときは引数でNKFに与える入力コードを指定すればいい。たとえばCP932だと次のようにする。

mechanize.post_connect_hooks[0] = fix_charset_hook("--ic=CP932")

metaタグなどから入力コードを決定することも考えたのだけど、実際とは異なるエンコーディングが間違って指定されているページもあるので、これで十分じゃないかと思う。