国民の祝日.csv パースプログラムの解答

前回に引き続き、こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。

blog.jnito.com

国民の祝日.csv パースプログラム

その昔、「国民の祝日.csv」という扱いづらいCSVが話題になっていました。
具体的にはこんなCSVファイルです↓

平成28年(2016年),,平成29年(2017年),,平成30年(2018年),
名称,月日,名称,月日,名称,月日
元日,2016/1/1,元日,2017/1/1,元日,2018/1/1
成人の日,2016/1/11,成人の日,2017/1/9,成人の日,2018/1/8
建国記念の日,2016/2/11,建国記念の日,2017/2/11,建国記念の日,2018/2/11
春分の日,2016/3/20,春分の日,2017/3/20,春分の日,2018/3/21
# 中略
文化の日,2016/11/3,文化の日,2017/11/3,文化の日,2018/11/3
勤労感謝の日,2016/11/23,勤労感謝の日,2017/11/23,勤労感謝の日,2018/11/23
天皇誕生日,2016/12/23,天皇誕生日,2017/12/23,天皇誕生日,2018/12/23
,,,,,
月日は表示するアプリケーションによって形式が異なる場合があります。,,,,,

これをいい感じにパースして、以下のようなデータ構造(Rubyのハッシュオブジェクト)に変換しよう、というプログラミング問題です。

{
  2016 => {
    # 実際のキーは文字列ではなくDateオブジェクト
    '2016/01/01' => '元日',
    '2016/01/11' => '成人の日',
    # ...
    '2016/11/23' => '勤労感謝の日',
    '2016/12/23' => '天皇誕生日',
  },
  2017 => {
    '2017/01/01' => '元日',
    '2017/01/09' => '成人の日',
    # ...
    '2017/11/23' => '勤労感謝の日',
    '2017/12/23' => '天皇誕生日',
  },
  2018 => {
    '2018/01/01' => '元日',
    '2018/01/08' => '成人の日',
    # ...
    '2018/11/23' => '勤労感謝の日',
    '2018/12/23' => '天皇誕生日',
  },
}

解答

class SyukujitsuParser
  CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

  def self.parse(csv_path = CSV_PATH)
    self.new.parse(csv_path)
  end

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}
          .map { |year, holiday_arr|
            holiday_hash = holiday_arr.map {|holiday, *date|
              [Date.new(*date.map(&:to_i)), holiday]
            }.to_h
            [year, holiday_hash]
          }.to_h
  end
end

RSpecは本家のgithubに記載されているので割愛します。 https://github.com/JunichiIto/parse-syukujitsu/blob/master/test/syukujitsu_parser_test.rb

解説

RubyCSVファイルを読み込むには、CSVクラスを使用するのが定石のようですが、CSVクラスを知らなかったので、通常のファイルとして読み込みました。

まず、どのように祝日名と日付部分を取り出すか考えます。公式ドキュメントのString#scan( scan (String) - Rubyリファレンス )の例に以下のようなものがあるので、同様の処理を行います。

s = "Hokkaido:Sapporo, Aomori:Aomori, Iwate:Morioka"
p s.scan(/(\w+):(\w+)/)
# => [["Hokkaido", "Sapporo"], ["Aomori", "Aomori"], ["Iwate", "Morioka"]]

祝日名と日付の部分は、

(平仮名か漢字),(年/月/日)

というペアになっており、上記のscanの例と同じようになっています。

正規表現にしていきます。まず、(平仮名か漢字)の部分ですが、これを満たす正規表現が無くはないようですが、一筋縄ではいかないようなので今回は別の方法にします。カンマより前のすべての(カンマでない)文字を取れば良いので、[^,]+という正規表現で表します。

日付部分の正規表現

[0-9]{4}/[0-9]{1,2}/[0-9]{1,2}

にします。ここまでで、マッチさせる正規表現は以下のようになります。

([^,]),([0-9]{4}/[0-9]{1,2}/[0-9]{1,2})

先々の過程で、日付の年月日が一つの変数に入っているよりも、年・月・日で別々の方が扱いやすいので、カッコでのキャプチャの位置を変更します。

([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})

それでは、ファイルを読み込んでString#scanで一致した部分を抜き出していきます。 ファイルは1行ごとに読むことが多いのですが、今回は一度にすべての行を読み込みます。IO.readlinesは指定されたファイルを全て読み込んで、その各行を要素としてもつ配列を返すメソッドです。 ( singleton method IO.readlines (Ruby 2.6.0) )

class SyukujitsuParser
  CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

  def self.parse(csv_path = CSV_PATH)
    self.new.parse(csv_path)
  end

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: "Shift_JIS:UTF-8")
  end
end

readlinesの引数のchomp: trueは各行の末尾の改行を取り除くオプションです。今回の場合、指定しなくても出力は変わらないのですが、あると将来的なバグの温床になるので取り除いておきます。読み込むCSVファイルのエンコーディングがShift-JIS、内部的にはUTFで扱いたいので、encoding: "Shift_JIS:UTF-8"を指定しています。

実行結果です。

[
  "平成28年(2016年),,平成29年(2017年),,平成30年(2018年),", 
  "名称,月日,名称,月日,名称,月日", 
  "元日,2016/1/1,元日,2017/1/1,元日,2018/1/1", 
  "成人の日,2016/1/11,成人の日,2017/1/9,成人の日,2018/1/8", 
  "建国記念の日,2016/2/11,建国記念の日,2017/2/11,建国記念の日,2018/2/11", 
  "春分の日,2016/3/20,春分の日,2017/3/20,春分の日,2018/3/21", 
  "昭和の日,2016/4/29,昭和の日,2017/4/29,昭和の日,2018/4/29", 
  # 中略
  "秋分の日,2016/9/22,秋分の日,2017/9/23,秋分の日,2018/9/23", 
  "体育の日,2016/10/10,体育の日,2017/10/9,体育の日,2018/10/8", 
  "文化の日,2016/11/3,文化の日,2017/11/3,文化の日,2018/11/3", 
  "勤労感謝の日,2016/11/23,勤労感謝の日,2017/11/23,勤労感謝の日,2018/11/23", 
  "天皇誕生日,2016/12/23,天皇誕生日,2017/12/23,天皇誕生日,2018/12/23", 
  ",,,,,", 
  "月日は表示するアプリケーションによって形式が異なる場合があります。,,,,,"
]

この各配列の要素に対してscanをかけてもよいのですが、今回は簡単のためすべての行をカンマで結合してからscanをかけます。CSVの各行を1行にまとめるのは普通はやらないのですが、今回は元となるデータの形式がイマイチなのでこのようなことをしてもしょうがないという気持ちです。配列の各要素をjoinで連結します。引数にカンマを与えて、カンマ区切りで結合します。結果的に1行のCSVが出力されます。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: "Shift_JIS:UTF-8")
          .join(',')  # 追加
  end
end

実行結果です。1行にまとまりました。

"平成28年(2016年),,平成29年(2017年),,平成30年(2018年),,名称,月日,名称,月日,名称,月日,元日,2016/1/1,元日,2017/1/1,元日,2018/1/1,成人の日,2016/1/11,成人の日,2017/1/9,成人の日,2018/1/8,建国記念の日,2016/2/11,建国記念の日,2017/2/11,建国記念の日,2018/2/11,春分の日,2016/3/20,春分の日,2017/3/20,春分の日,2018/3/21,昭和の日,2016/4/29,昭和の日,2017/4/29,昭和の日,2018/4/29,(中略)2018/9/17,秋分の日,2016/9/22,秋分の日,2017/9/23,秋分の日,2018/9/23,体育の日,2016/10/10,体育の日,2017/10/9,体育の日,2018/10/8,文化の日,2016/11/3,文化の日,2017/11/3,文化の日,2018/11/3,勤労感謝の日,2016/11/23,勤労感謝の日,2017/11/23,勤労感謝の日,2018/11/23,天皇誕生日,2016/12/23,天皇誕生日,2017/12/23,天皇誕生日,2018/12/23,,,,,,,月日は表示するアプリケーションによって形式が異なる場合があります。,,,,,"

これに対して、上で作成した正規表現で祝日名と日付を抽出します。scanの引数には正規表現オブジェクトを渡します。今回は%rを使用して正規表現オブジェクトを作成します。マッチしたい部分に記号がたくさん含まれるので!正規表現を囲みます。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: "Shift_JIS:UTF-8")
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)  # 追加
  end
end

実行結果です。[祝日名, 年, 月, 日]の配列が作成されました。

[
  ["元日", "2016", "1", "1"], 
  ["元日", "2017", "1", "1"], 
  ["元日", "2018", "1", "1"], 
  ["成人の日", "2016", "1", "11"], 
  ["成人の日", "2017", "1", "9"], 
  ["成人の日", "2018", "1", "8"], 
  ["建国記念の日", "2016", "2", "11"], 
  ["建国記念の日", "2017", "2", "11"], 
  # 中略
  ["文化の日", "2017", "11", "3"], 
  ["文化の日", "2018", "11", "3"], 
  ["勤労感謝の日", "2016", "11", "23"], 
  ["勤労感謝の日", "2017", "11", "23"], 
  ["勤労感謝の日", "2018", "11", "23"], 
  ["天皇誕生日", "2016", "12", "23"], 
  ["天皇誕生日", "2017", "12", "23"], 
  ["天皇誕生日", "2018", "12", "23"]
]

ここから年でグルーピングしてみましょう。group_byを使えば簡単です。年は配列の2番目なので、それを整数に変換したものでグルーピングします。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}  # 追加
  end
end

実行結果です。

{
  2016=>[
    ["元日", "2016", "1", "1"], 
    ["成人の日", "2016", "1", "11"],
    # 中略
    ["勤労感謝の日", "2016", "11", "23"], 
    ["天皇誕生日", "2016", "12", "23"]
  ], 
  2017=>[
    ["元日", "2017", "1", "1"], 
    ["成人の日", "2017", "1", "9"], 
    # 中略
    ["勤労感謝の日", "2017", "11", "23"], 
    ["天皇誕生日", "2017", "12", "23"]
  ], 
  2018=>[
    ["元日", "2018", "1", "1"], ["成人の日", "2018", "1", "8"], 
    ["建国記念の日", "2018", "2", "11"],
    # 中略
    ["勤労感謝の日", "2018", "11", "23"],
    ["天皇誕生日", "2018", "12", "23"]
  ]
}

だいぶ完成に近くなってきました。完成形と違うところは、ハッシュの各値が配列になっていることです。

まず、ハッシュの値(value)を変換する方法として、以下のイディオムがあります。

hash.map {|key, value|
  [key, convert(value)]
}.to_h

こちらを適用します。中の実装は仮のものにしておきます。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}
          .map { |year, holiday_arr|  # 追加
            [year, holiday_arr]  # 仮実装
          }.to_h  # 追加
  end
end

実行すると1つ前で実行したのと同じものになります。

次に

[
  ["元日", "2016", "1", "1"], 
  ["成人の日", "2016", "1", "11"],
  # 中略
  ["勤労感謝の日", "2016", "11", "23"], 
  ["天皇誕生日", "2016", "12", "23"]
]

{
  Date.parse('2016/01/01') => '元日',
  Date.parse('2016/01/11') => '成人の日',
  # 中略
  Date.parse('2016/11/23') => '勤労感謝の日',
  Date.parse('2016/12/23') => '天皇誕生日',
}

に変換します。配列からハッシュを作成するには、Array.to_hを使用します。( instance method Array#to_h (Ruby 2.6.0) ) このメソッドは[key, value]のペアの配列を、{key => value}のハッシュに変換します。

to_hで変換する前の配列は

[
  [Date.parse('2016/01/01'), '元日'],
  [Date.parse('2016/01/11'), '成人の日'],
  # 中略
  [Date.parse('2016/11/23'), '勤労感謝の日'],
  [Date.parse('2016/12/23'), '天皇誕生日'],
]

となっている必要があります。

年ごとの配列に対してmapを使用します。mapで受け取る引数は、["元日", "2016", "1", "1"]の形式です。多重代入を使用して、holiday, *date = ["元日", "2016", "1", "1"]のように受け取っています。 この場合だと、holiday = "元日", date = ["2016", "1", "1"]のように代入されます。

dateをDate( class Date (Ruby 2.6.0) ) オブジェクトに変換します。Date.newで作成するので、 はじめにdateの各日付を数値に変換します(date.map(&:to_i))これをDate.newの引数にします。配列展開を使います。Date.new(*[2016, 1, 1])と書くと、Date.new(2016, 1, 1)と書いたのと同じになります。

class SyukujitsuParser
  # 中略

  def parse(csv_path)
    IO.readlines(csv_path, chomp: true, encoding: 'Shift_JIS:UTF-8')
          .join(',')
          .scan(%r!([^,]+),([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})!)
          .group_by{|_, year| year.to_i}
          .map { |year, holiday_arr|
            holiday_hash = holiday_arr.map {|holiday, *date|  # 追加
              [Date.new(*date.map(&:to_i)), holiday]  # 追加
            }.to_h  # 追加
            [year, holiday_hash]  # 変更
          }.to_h
  end
end

これで完成です。書いている最中はプログラムの意図を理解していますが、1週間くらいすると各変数やメソッドの使用意図を忘れそうなので、適宜コメントを入れた方がよさそうです。

あとがき

元のCSVがひどいのでコードも力技になったなあと思います。

初心者向けの問題と書かれていながら、なかなか考えることの多い問題でした。

★前回の記事

yucatio.hatenablog.com

出題者の伊藤淳一さんの書籍はこちら↓