RubyでShift JISやCP932などのCSVをUTF-8に変換して読み込む


結論

おそらくこれだけで十分。詳しい説明や検証結果は後述。

require 'charlock_holmes'
require 'csv'

# CSVファイルのパスを指定
path = 'path/to/file.csv'

# ファイルをすべて読み込んで(大きなファイルは、メモリに優しくない)
# エンコードを推測(あくまで推測)
detection = CharlockHolmes::EncodingDetector.detect(File.read(path))
# => {:type=>:text, :encoding=>"Shift_JIS", :ruby_encoding=>"Shift_JIS", :confidence=>100, :language=>"ja"}

# CharlockHolmes::EncodingDetectorのディテクション結果は、
# CP932であるShift JIS拡張文字コードを含む場合にもShift_JISと見なされてしまう為、CP932を優先して利用する。
# もしそのままShift JISを指定すれば、CSV.foreach()で変換エラーが出る原因となる。
# アップサイドは拡張文字コードを変換できることで、ダウンサイドは7種類の記号文字の見た目が完全に一致しないこと。
encoding = detection[:encoding] == 'Shift_JIS' ? 'CP932' : detection[:encoding]

# CSVを1行ずつUTF-8として読み込む(メモリに優しい!)
CSV.foreach(path,
            encoding: "#{encoding}:UTF-8",
            headers: true) do |row|
  p row.inspect
end

フローとしては、

  1. ファイル全体を読み込み(メモリを食う方法): File.read(path)
  2. 文字コードを判定する
  3. 文字コードの判定結果の修正: Shift JIS拡張であるCP932を優先する
  4. CSVを1行ずつ読み込む(メモリを食わない方法):CSV.foreach

説明

大前提

  • CharlockHolmesをインストールしてある事
  • Ruby 2.2.3で動作を確認済み

利用ファイルの読み込み

require 'charlock_holmes'
require 'csv'

CSVの読み込みと判定

detection = CharlockHolmes::EncodingDetector.detect(File.read(path))

# => "{:type=>:text, :encoding=>\"Shift_JIS\", :ruby_encoding=>\"Shift_JIS\", :confidence=>100, :language=>\"ja\"}"

ここではCharlockHolmesを使ってエンコードを見分けています。(ただし、推測に過ぎない)

File.read(path)ではファイル全体を読み込む(はず)ため、大容量のファイルを読み込む場合は注意が必要です。Webサイトの場合、通常HTTPリクエストの上限バイト数が設定されていると思いますので、ひとまずこれでよしとしました。

エンコードの修正

encoding = detection[:encoding] == 'Shift_JIS' ? 'CP932' : detection[:encoding]

ここが勘所。

Shift_JISの代わりにCP932をエンコードとして指定する

この後使うCSV.foreachCSV.foreach("/path/to/file", encoding: "Shift_JIS:UTF-8")のようにShift_JISを指定すると、含まれる文字コードによってはエラーEncoding::UndefinedConversionErrorが出てしまいます。

調べたところCP932Shift_JISから派生した拡張版であるため、CP932を指定しておくのが無難です。又、以下の参考資料によればShift_JISで作られたファイルがCP932かShift_JISかを文字コードから見分けることは不可能だと思われます。

代わりに、CSV.foreach("/path/to/file", encoding: "CP932:UTF-8")を使えば問題ないでしょう。

参考資料

問題になりそうな文字コードの変換をテストする

尚、私はShift_JISのダメ文字を参考にして「ダメ文字」と呼ばれる文字を含ませたところ、Shift_JISを指定した際は想定通りのエラーが出て、CP932を指定したところ、今のところエラーが確認できておらずUTF-8で扱うことができています。

ただし、私は文字コードにはそこまで詳しくなく、高精度な変換が求められる場合はより厳密なテストが必要でしょう。

さらに詳しくテストしてみる:Shift_JISとCP932の違い

こちらのテーブルはMySQLのsjisとcp932の違いから引用させていただきました。

以下の7つの文字の見た目が若干異なるようです。下の81618191はそれなりに見た目が異なりますが、文字コードかどちらか分からない場合はCP932として読み込んでしまうのは妥協点ではないかと思われます。(そうでなければ、ファイルの提供者にエンコードを教えてもらうか、両方で表示して好きな方を選んで頂く必要がありそうです)

+------+------+-------+
| code | sjis | cp932 |
+------+------+-------+
| 815F | \    | \    |
| 8160 | 〜   | ~    |
| 8161 | ‖    | ∥     |
| 817C | −    | -    |
| 8191 | ¢    | ¢    |
| 8192 | £    | £    |
| 81CA | ¬    | ¬    |
| 8740 | ?    | ①     |
| 8741 | ?    | ②     |
| 8742 | ?    | ③     |
| 8743 | ?    | ④     |

実際にこのファイルを2通りで保存してみました。(IntelliJ IDEAを使用)

  • UTF-8からShift JISに変換して保存した場合、CP932の文字は文字化けします。
  • 逆にUTF-8からWindows-31j(CP932)として保存した場合、sjisの文字は文字化けします。

この2つのファイルをCharlockHolmes::EncodingDetector.detectしたところ、どちらもwindows-1250と検知されます。少しひらがなを追記した程度では結果は変わらず、漢字などを書き足してみたところ、どちらもShift-JISと判定されるようになりますが、実際には片方がCP932なのです。このように文字コードが判定に頼るしかない場合、やはりCP932と仮定した方が安全です。

CSVを1行ずつ読み込む

CSV.foreach(path,
            encoding: "#{encoding}:UTF-8",
            headers: true) do |row|
  p row.inspect
end

最後になりましたが、ここではCSVを1行ずつ読み込んでいます。

  • pathでファイルへのパスを渡しています
  • encoding: "#{encoding}:UTF-8"では、判定された文字コードをUTF-8に変換しながら読み込むことを指定しています。
  • headers: trueを指定しているので、row[1]の代わりにrow[:name]のように扱うことが可能です。処理中に利用するメモリも少なくなるはずです。
  • p row.inspectでテストとして、コンソールなどにrowの中身を出力しています

参考:Importing Massive CSV Data Into Rails

おまけ

RailsでBULK INSERT

Importing Massive CSV Data Into Railsからの引用ですが、Railsで複数のINSERTクエリをまとめる場合はActiveRecord.importを利用することでパフォーマンスが向上します。

items = []
CSV.foreach('link/to/file.csv', headers: true) do |row|
  items << Item.new(row.to_h)
end
Item.import(items)

こちらについてはQiitaにもわかりやすい記事があります。
ActiveRecordで複数レコード、BULK INSERTする方法とパフォーマンスについて

CharDet

日本語いっぱいのShift_JISのファイルでも以下のような結果になってしまうので、こちらはあまり実用的ではなさそうです。

detection2 = CharDet.detect(File.read(path))
p detection2.inspect

# => "{\"encoding\"=>\"ISO-8859-2\", \"confidence\"=>0.20525522026784734}"

以上です。
何かお気づきの点がありましたら、是非コメントをお願いします!