UTF-16LE:Malformed LO surrogate を避ける

[を] 指定した URL へのリンクのアンカーテキストを収集するとそのはてなブックマークエントリで言及されていた、PerlのEncodeモジュールの decode で "UTF-16LE:Malformed LO surrogate xxxx at ..." というエラーが出る問題を調べてみました。

まずは再現条件を調べたところ、http://quote.yahoo.co.jp/ のページを取得して decode した時にエラーが発生することが分かりました。エラー再現までの流れは以下の通りです。

  1. http://quote.yahoo.co.jp/ の内容を取得し、変数$textに格納。
  2. Encode::Guess により$textの文字コードを判定。
  3. その結果、(文字列はEUC-JPで書かれているにも関わらず)UTF-16LEと判定される。
  4. 判定結果を元に、decode('UTF-16LE', $text) を実行。
  5. UTF-16LE:Malformed LO surrogate d8a5 at ... が発生。

そして再現コードは以下の通り。

use strict;
use Encode;
use Encode::Guess;
use LWP::Simple;

my $text = get('http://quote.yahoo.co.jp/');

Encode::Guess->set_suspects( qw( euc-jp shiftjis 7bit-jis ) );

my $guess = guess_encoding($text);
ref($guess) or die "cannot guess.\n";

my $encoding_name = $guess->name;
print "code: $encoding_name\n";
$text = decode($encoding_name, $text);  # ここでエラー

print "decode end.\n";

元々はEUC-JPの文字列なのだから、それをUTF-16とみなして無理に decode したのがよくなかったようです。

それにしても何故UTF-16なんかに判定されてしまうのかを探ってみたところ、http://quote.yahoo.co.jp/ のページでは先頭のコメント内に 0xFDFE、また末尾の </html> の後に 0x00 というEUC-JPらしからぬバイト列が含まれており、特に後者の 0x00 があるためにUTF-16LEと判定されていたようでした。(0x00 を 0x20 に変えてみると、ちゃんと euc-jp と判定されました)

* * *

さて、原因はそれとしてもこのエラーをどうやって避けることができるでしょうか。できることならば、http://quote.yahoo.co.jp/ の内容を EUC-JP と判定した上でそのように decode できればよいのですが。

ひとつ手っ取り早い対処方法。今回の例では判定結果が UTF-16LE となったわけですが、日本語で書かれたHTMLを対象とするのであれば、使われる文字コードはせいぜい EUC-JP, Shift_JIS, ISO-2022-JP, UTF-8 くらいであり、UTF-16が使われることは滅多に無いものと思います(別にUTF-16を使っていても、HTML文書としては全く問題ないのですが)。なので文字コード判定の候補からUTF-16を外すことができれば、判定の成功率も上がりそうです。

Encode::Guessでは、元々 UTF-8/16/32 が候補としてチェックされるようになっているのですが、この デフォルトの動作は $Encode::Guess::NoUTFAutoGuess を1にセットすることで無効にすることができます(参照: DESCRIPTION - Encode::Guess)。なので先ほどの再現コードを、

use strict;
use Encode;
use Encode::Guess;
use LWP::Simple;

my $text = get('http://quote.yahoo.co.jp/');

$Encode::Guess::NoUTFAutoGuess = 1;
Encode::Guess->set_suspects( qw(euc-jp shiftjis 7bit-jis utf8) );

my $guess = guess_encoding($text);
ref($guess) or die "cannot guess.\n";

my $encoding_name = $guess->name;
print "code: $encoding_name\n";
$text = decode($encoding_name, $text);

print "decode end.\n";

と変更してみたところ、今度は euc-jp と判定され、decodeもエラーを出すことなく終了できました。

* * *

また、Encode::Guess による文字コード判定は確実ではないようなので、もし文字コード識別の手がかりを他から入手できるのなら、それを使った方がよさそうです。http://quote.yahoo.co.jp/ のページでは、meta要素による文字コードの指定はありませんが、HTTPのレスポンスヘッダではきちんと Content-Type: text/html; charset=euc-jp を返しているので、それを取得してdecodeに使うのが(少々手間はかかりますが)確実でしょう。