【Rails】郵便番号をDBに保存 → 月毎にCSVの差分から更新する

##はじめに
全国の[郵便番号](https://www.post.japanpost.jp/zipcode/download.html)から住所テーブルに一括で保存し、月毎に更新するタスクを作ってみました。

しかし、郵便番号は約12万行ほどあり、更新する際にこれを総舐めするのは負荷がでかくて無駄が多いです。
これを解決すべく、月ごとの差分から更新するタスクも作ってみたので参考までに公開してみようと思います。

##やること
**1. 郵便番号一覧のDBを作成**
**2. 郵便番号をダウンロード**
**3. CSVからDBに保存**
**4. CSVの差分から更新**

##1. 郵便番号一覧のDBを作成


**- 郵便番号(`zip_code`)**
**- 都道府県(`state`)**
**- 市区町村(`city`)**
**- 番地(`street`)**

```db.rb
class CreateAddresses < ActiveRecord::Migration[5.0]
  def change
    create_table :addresses do |t|
      t.column :zip_code, "CHAR(7)", null: false
      t.string :state, null: false
      t.string :city, null: false
      t.string :street

      t.timestamps
    end

    add_index :addresses, :zip_code, unique: true, name: "add_unique_index_zip_code"
  end
end
```

##2. 郵便番号をダウンロード
郵便番号をダウンロードするタスクを作成します。
郵便番号は[こちら](https://www.post.japanpost.jp/zipcode/dl/roman-zip.html)からダウンロードが可能。
保存と更新の両方で使うのでモジュール化しておくと楽でしょう。

```lib/address_download.rb
module JapanPost
  module AddressDownload
    def download_zipcode_file
      @download_url = "https://www.post.japanpost.jp/zipcode/dl/roman/ken_all_rome.zip"
      @download_path = Rails.root.join("lib", "tasks", "data", "tmp")
      download_zip_file
      unzip
    end

    def download_zip_file
      zip_file = open(@download_url)
      @zip_name = File.basename @download_url
      File.open(@download_path + @zip_name, "w") do |file|
        file.write zip_file.read.force_encoding("UTF-8")
      end
    end

    def unzip
      Zip::File.open(@download_path + @zip_name) do |zip|
        zip.each do |entry|
          @csv_name = "KEN_ALL_#{entry.time.strftime('%Y%m')}.CSV"
          zip.extract(entry, @download_path + @csv_name) { true }
        end
      end
      File.delete @download_path + @zip_name
    end
  end
end
```
ざっくりと処理の流れ↓
1. zipファイルをダウンロード
2. 解凍して`lib/tasks/data/tmp/`配下に今月分の`KEN_ALL.CSV`を作る
3. zipファイルだけ削除

解凍の参考記事: https://qiita.com/ogontaro/items/e11d10a460e127ad29d0

##3. 郵便番号をDBに保存

```lib/tasks/tmp/import_address.rake
namespace :tmp do
  desc "郵便番号をインポート"
  task import_address: :environment do
    include JapanPost::AddressDownload

    download_zipcode_file
    import_zipcode
  end

  private

  def import_zipcode
    addresses = []
    CSV.open(@download_path + @csv_name, encoding: "Shift_JIS:UTF-8").each do |row|
      addresses << Address.new(
        zip_code: row[0],
        state: row[1],
        city: row[2],
        street: row[3].sub(/（.*|以下に掲載がない場合/, ""),
      )
    end
    Address.import addresses
  end
end
```
CSVを読み込んでデータを流し込みます。
大通西（１～１９丁目）　→　大通西
以下に掲載がない場合　→　""
となるように、`（`より後ろ、以下に掲載がない場合は削除するようにしてます。

このタスクを走らせると、`lib/tasks/data/tmp/`配下に今月分の`KEN_ALL.CSV`が残ってるはずです。

```
$ ls lib/tasks/data/tmp/
KEN_ALL_201906.CSV 
```
月毎の更新で、先月分の`KEN_ALL.CSV`から差分を抽出する際必要になるので消さないようにしましょう。

あとは更新するだけなので、このタスクは不要です。
`tmp`にしたのはこれが理由で、走らせたらは削除してもOK。

## 4. CSVの差分からDBを更新する

```lib/tasks/update_address.rake
require "diff-lcs"

namespace :address do
  desc "郵便番号を更新"
  task update: :environment do
    include JapanPost::AddressDownload

    download_zipcode_file
    update_zipcodes
  end

  private

  def update_zipcodes
    last_month_csv = CSV.read(@download_path + "KEN_ALL_#{Time.current.ago(1.month).strftime('%Y%m')}.CSV", encoding: "Shift_JIS:UTF-8")
    this_month_csv = CSV.read(@download_path + @csv_name, encoding: "Shift_JIS:UTF-8")
    diffs = Diff::LCS.diff(last_month_csv, this_month_csv)
    File.delete @download_path + "KEN_ALL_#{Time.current.ago(1.month).strftime('%Y%m')}.CSV"
    return if diffs.blank?
  
    addresses = []
    diffs.flatten.select(&:adding?).each do |diff|
      row = diff.element
      address = Address.find_or_initialize_by(zip_code: row[0])
      address.state = row[1]
      address.city = row[2]
      address.street = row[3].sub(/（.*|以下に掲載がない場合/, "")
      addresses << address
    end
    Address.import addresses, on_duplicate_key_update: [:state, :city, :street]
  end
end
```
先月分のCSV(`last_month_csv`)と, 今月分のCSV(`this_month_csv`の差分を抽出して更新しています。


`CSV`の差分はこんな感じで求めることが可能。

```
diffs = Diff::LCS.diff(変更前のCSV, 変更後のCSV)
```

差分の中身はこんな感じです。

```rb

> diffs.flatten
=> [
     ["-", 0, ["0600000", "hoge北海道", "札幌市　中央区", "以下に掲載がない場合", "HOKKAIDO", "SAPPORO SHI CHUO KU", "IKANIKEISAIGANAIBAAI"]],
     ["-", 1, ["0640941", "fuga北海道", "札幌市　中央区", "旭ケ丘", "HOKKAIDO", "SAPPORO SHI CHUO KU", "ASAHIGAOKA"]],
     ["+", 0, ["0600000", "北海道", "札幌市　中央区", "以下に掲載がない場合", "HOKKAIDO", "SAPPORO SHI CHUO KU", "IKANIKEISAIGANAIBAAI"]],
     ["+", 1, ["0640941", "北海道", "札幌市　中央区", "旭ケ丘", "HOKKAIDO", "SAPPORO SHI CHUO KU", "ASAHIGAOKA"]]
   ]
```
そこから`adding?`を使い、変更後のものに絞ればいいわけです。

```rb
> diffs.flatten.select(&:adding?)
=> [
     ["+", 0, ["0600000", "北海道", "札幌市　中央区", "以下に掲載がない場合", "HOKKAIDO", "SAPPORO SHI CHUO KU", "IKANIKEISAIGANAIBAAI"]],
     ["+", 1, ["0640941", "北海道", "札幌市　中央区", "旭ケ丘", "HOKKAIDO", "SAPPORO SHI CHUO KU", "ASAHIGAOKA"]]
   ]
```

`diff`の中身は`element`で取り出します。

```rb
> diff
=> ["+", 0, ["0600000", "北海道", "札幌市　中央区", "以下に掲載がない場合", "HOKKAIDO", "SAPPORO SHI CHUO KU", "IKANIKEISAIGANAIBAAI"]]
> diff.element
=> ["0600000", "北海道", "札幌市　中央区", "以下に掲載がない場合", "HOKKAIDO", "SAPPORO SHI CHUO KU", "IKANIKEISAIGANAIBAAI"]
```


adding?・・・変更後を取り出す
element・・・変更のシーケンスを取り出す

参考： https://www.rubydoc.info/gems/diff-lcs/1.2.5/Diff/LCS/Change#adding%3F-instance_method


**※[補足]**
ここではじめて先月分のCSVを削除しますが、

```rb

File.delete @download_path + "KEN_ALL_#{Time.current.ago(1.month).strftime('%Y%m')}.CSV"
```
今月分のCSVは残すことを忘れてはいけません。
これは`import`したときと同様、来月更新するときに必要になるからです。

```
$ ls lib/tasks/data/tmp/
KEN_ALL_201906.CSV ←　削除
KEN_ALL_201907.CSV ←　残す
```

##月毎走らせる
あとは更新タスクを毎月走らせてあげればOK。

```config/schedule.rb
# 郵便番号に変更があれば更新
# DL先: https://www.post.japanpost.jp/zipcode/dl/roman/ken_all_rome.zip
every 1.month, at: "3:00pm" do
  rake "address:update"
end
```
注意点としては、先に`import`を済ませてから走らせることです。そうでないと、先月分のCSVがなくて差分を抽出できないためです。

##文字化け対策とか(おまけ)

###Encoding::UndefinedConversionError: "\xEC" from ASCII-8BIT to UTF-8

Zipファイルを読み込む際にこのようなエラーが出た場合、`force_encoding("UTF-8")`をしてすれば解決しました。

```rb

# 変更前
zip_file.read
# 変更後
zip_file.read.force_encoding("UTF-8")
```
参考: https://blog.tanebox.com/archives/452/


###ArgumentError: invalid byte sequence in UTF-8
CSVを開くときにこのエラーが出た場合、以下のように修正しましょう。

```rb
#変更前
CSV.open("KEN_ALL.CSV")
#変更後
CSV.open("KEN_ALL.CSV", encoding: 'Shift_JIS:UTF-8')
```

CSVは`JIS`で読みこもうとするため、`UTF-8`に変換してあげる必要があります。

参考: http://log.miraoto.com/2014/04/886/