CP932など特殊な文字を含むHTMLをスクレイピングする

Shift_JISで書かれたHTML、例えば下記のような文書をスクレイピングする場合。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
    <title>title</title>
  </head>
  <body>
    <div id="contents">
      <div>1.ほげ</div>
      <div>2.ふが</div>
      <div>3.ぴよ</div>
    </div>
  </body>
</html>

Content-Typeは下記のように返されるとする。

Content-Type: text/html; charset=Shift_JIS


スクレイピング用のライブラリたちは優れているのでUTF-8じゃなくても内部でうまいこと変換してくれたりするのであまり意識する必要なく、

#!/usr/bin/env perl
use strict;
use warnings;
use feature qw(say);

use Encode qw(encode_utf8);
use Web::Scraper;
use URI;

my $res = scraper {
    process '#contents div', 'div[]' => scraper {
        process 'div', 'text' => 'TEXT';
    };
}->scrape(URI->new('http://localhost:5000/sjis.html'));

for my $div (@{ $res->{div} }) {
    say encode_utf8($div->{text});
}

とか

#!/usr/bin/env ruby
require 'nokogiri'
require 'open-uri'

doc = Nokogiri::HTML(open('http://localhost:5000/sjis.html'))
doc.css('#contents div').each do |div|
  puts div.text
end

といった簡単なスクリプトスクレイピングでき、

1.ほげ
2.ふが
3.ぴよ

といった結果を得ることができる。

厄介な場合

例えば、下記のような場合。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
    <title>title</title>
  </head>
  <body>
    <div id="contents">
      <div>①.ほげ</div>
      <div>②.ふが</div>
      <div>③.ぴよ</div>
    </div>
  </body>
</html>

丸数字("\x87\x40", "\x87\x41", "\x87\x42"など)といったShift_JISには存在しない文字。「CP932」とか「Windows-31J」とか呼ばれるようなものたち。
Microsoftコードページ932 - Wikipedia
を含んでいると、

$ perl scrape.pl
?@.ほげ
?A.ふが
?B.ぴよ

と文字化けしてしまったり、

$ ruby scrape.rb
encoding error : input conversion failed due to input error, bytes 0x87 0x40 0x2E 0x82
encoding error : input conversion failed due to input error, bytes 0x87 0x40 0x2E 0x82

とエラーになってしまったりする。

原因

この例の場合、得られたコンテンツは実際には"Shift_JIS"ではなく"CP932"として処理する必要がある。
しかし、HTTPのレスポンスヘッダやmetaタグからでは"CP932"か"Shift_JIS"かを判別する術がないので、内部でUTF-8に変換しようとする場合は"Shift_JIS"として処理をするしかなくなっているから、だと思われる。

解決策

CP932が含まれてくると分かっているのであれば、明示的に指定して準備しておくことで問題は回避できそう。

Nokogiri (Ruby)

Nokogiriを使う場合、Nokogiri::HTML::Document.parseにencodingを第3引数で渡すことができる。

#!/usr/bin/env ruby
require 'nokogiri'
require 'open-uri'

doc = Nokogiri::HTML(open('http://localhost:5000/cp932.html'), nil, 'CP932')
doc.css('#contents div').each do |div|
  puts div.text
end

のように明示的に指定することで正しく変換された結果を得ることができる。内部で使用しているlibxml2やiconvのバージョンなどによってはこういった対応をしなくても上手いこと処理してくれる場合もある? よく分からない

Web::Scraper (Perl)

LWP::UserAgentによって得られるレスポンスからは、

my $ua = LWP::UserAgent->new();
my $res = $ua->get('http://localhost:5000/cp932.html');
say $res->decoded_content(charset => 'CP932');

のようにcharsetを指定してdecoded_contentを呼ぶことで正しく変換された文書を得ることができるのだけど、Web::Scraperでは、内部で呼ばれるdecoded_contentにオプションを渡すような仕組みはないので、Encode::Aliasを使って回避するのが良さそう。

#!/usr/bin/env perl
use strict;
use warnings;
use feature qw(say);

use Encode qw(encode_utf8);
use Encode::Alias;
use Web::Scraper;
use URI;

define_alias(shift_jis => 'CP932');

my $res = scraper {
    process '#contents div', 'div[]' => scraper {
        process 'div', 'text' => 'TEXT';
    };
}->scrape(URI->new('http://localhost:5000/cp932.html'));

for my $div (@{ $res->{div} }) {
    say encode_utf8($div->{text});
}

このように書くことで、内部ではShift_JISとして扱って変換しようとするのだけどaliasの効果によってCP932として変換するように動いてくれる。