[Awk & Jq活用講座] CSVファイルで読み出した文字列を複雑なルールで置換したい


2021/03/19

シェルコマンドで単純に文字列置換を行うとなると、もっとも簡単なのは文字リテラルを使ったり、sedコマンドを利用したりしますが、一般的にCSV形式の文字列置換を行う場合には、行数・列数の情報が必要になってくるケースもあるので、最初からawkかjqを使う方が色々と応用が効きます。

なお、AwkかJqかどちらを使っても良いのですが、AwkはShell標準のコマンドでそのまま使えますが、Jqはサードパーティ製のプログラムですので利用するには別途インストールが必要です。


基礎テク〜CSVをファイルから/ヒアドキュメントから読み出す

ここではExcelシートをcsv形式でテキストデータで保存したファイルをシェルコマンドで処理するような作業を想定していますが、シェルコマンドでファイルを読み込むためのもっとも単純な方法はcatコマンドを使うことです。

            
            $ cat hoge.csv
#👇hoge.csvファイルの中身
1,2,3,4,5
6,7,8,9,10
        
わざわざスクリプトのテスト用のcsvファイルを生成することの面倒な方は、ヒアドキュメントの使ってあたかもファイルがあるかのようにデータを扱えることが可能です。

            
            $ cat << EOF
1,2,3,4,5
6,7,8,9,10
EOF
#👇csvデータとして取り扱える
1,2,3,4,5
6,7,8,9,10
        
本記事中では、判例として紹介するcsvデータをヒアドキュメントで生成することを多用していますが、実際使う場合には何か実体のあるcsvファイルが存在していて、それをcatしているものとみなしてください。

ついでに言いますと、ヒアドキュメントを利用したデータの引き出しにも2パターンありまして、以下のパターンは変数
CSV_DATAにデータを格納しておいてから、後でechoで変数を呼び出し、パイプラインで後段の処理に繋げる方法です。

            
            $ CSV_DATA=$(cat << EOF
1,2,3,4
5,6,7,8
EOF
)

#👇改行を含む文字列のechoでは変数をダブルクオーテーション(")で囲う必要がある
$ echo "$CSV_DATA" | awk -F"," '{ print $0; }'
1,2,3,4
5,6,7,8
        
データを前方から読み込んでいるので、パイプライン以降の処理が見やすくなります。

もう一つは以下のように処理のコマンドにヒアドキュメントを直接入力する方法です。

            
            $ awk -F"," '{ print $0; }' << EOF
1,2,3,4
5,6,7,8
EOF
        
こちらはスクリプトの行数が少なくスッキリと書けるのですが、データを入力している位置が後方になるので、どんなデータを食わせているのかイメージしづらい場合があります。

どちらのテクニックも使いどころによっては便利ですので、覚えておくと良いと思います。


Awkによる置換の基礎

まずはAwkコマンドによるcsvデータ内の文字列の基礎的な置換方法を見ていきます。

ちなみにAwkにはいくつか種類があり、文法・用法が違います。基本的に本記事ではgawk(GNU awk)を使うように統一していますのでご了承ください。

参考: mawkからgawkに移行しよう

Awkで文字列を置換する場合には、
gsub関数という組込関数が利用できます。

            
            用法:
    gsub(<置換前の文字パターン>,<置換後の文字パターン>,<対象文字列>)
        
文字列の置き換えには他にも最初に出現した一回目の文字だけを置き換えるsubもありますが、良く使うのはgsubの方だと思います。

では以下でgsubによる簡単な置き換えを試してみます。

            
            $ awk -F"," '
BEGIN { OFS="," }
{
    #一列目の文字($1)を / から - に置き換え
    gsub("/", "-", $1);
    print $1, $2, $3;
}' << EOF
2021/04/06,出張,山田
2021/04/08,会議,佐藤
2021/04/17,リモートワーク,鈴木
EOF
#👇出力
2021-04-06,出張,山田
2021-04-08,会議,佐藤
2021-04-17,リモートワーク,鈴木
        
gsubでは正規表現も文字パターンに利用できます。

            
            $ awk -F"," '
BEGIN { OFS="," }
{
    gsub(/[0-9]{4}\/[0-9]{2}\/[0-9]{2}/, "----/--/--(未定)", $1);
    print $1, $2, $3;
}' << EOF
2021/04/06,出張,山田
2021/04/08,会議,佐藤
2021/04/17,リモートワーク,鈴木
EOF
#👇出力
----/--/--(未定),出張,山田
----/--/--(未定),会議,佐藤
----/--/--(未定),リモートワーク,鈴木
        
さらにパターンマッチングを含む複雑な正規表現ルールでの置き換えになる場合、最初からn個目まで引っかかった箇所を指定して置き換えできるgensubを利用する必要があります。

            
            用法:
    gensub(<置換前の文字パターン>,<置換後の文字パターン>,<n番目 or "g"で全て置き換え>,<対象文字列>)
        
以下は正規表現の部分でグループ(...)したところを最初のキャプチャを\\1、次のキャプチャを\\2で抽出した際のスクリプトになります。

            
            $ awk -F"," '
BEGIN { OFS="," }
{
    date_ = gensub(/[0-9]{4}\/([0-9]{2})\/([0-9]{2})/, "\\1月\\2日", "g", $1);
    print date_, $2, $3;
}' << EOF
2021/04/06,出張,山田
2021/04/08,会議,佐藤
2021/04/17,リモートワーク,鈴木
EOF
#👇出力
04月06日,出張,山田
04月08日,会議,佐藤
04月17日,リモートワーク,鈴木
        
gensubを取り扱う際の注意のポイントとしては、gsub関数は対象文字列(第3引数)を直接置き換えるような(破壊的)操作でしたが、gensub関数は対象文字列(第4引数)の値は変化させず(非破壊的)に、関数の返り値で置き換え後の結果を返すように作用します。


Jqによる置換の基礎

Jqコマンドはテキストデータをjsonとして取り扱うためのリッチな編集機能を備えたコマンドです。

特にcurlとの組合せで相性が良く、サーバーからのレスポンスなどをそのままデータ抽出・再成形などが容易に行なえますのでクラウドサービスなどからcsvデータを収集して、処理を行うときにはAwkよりも便利な場合があります。

最初にcsvをjson化してみます。

            
            $ jq -sR '
#👇mapを使うために配列化のために([...])でラップする
[
  #👇改行位置で配列に変換し、中身を取り出す
  split("\n")[]
    #👇空文字を弾く
    | select(length > 0)
      #👇コンマ切りで配列に変換する
      | split(",")
]
  #👇mapで配列の中身をjson要素に変換する
  | map({"日付": .[0], "予定": .[1], "担当": .[2]})
' << EOF
2021/04/06,出張,山田
2021/04/08,会議,佐藤
2021/04/17,リモートワーク,鈴木
EOF
#👇出力
[
  {
    "日付": "2021/04/06",
    "予定": "出張",
    "担当": "山田"
  },
  {
    "日付": "2021/04/08",
    "予定": "会議",
    "担当": "佐藤"
  },
  {
    "日付": "2021/04/17",
    "予定": "リモートワーク",
    "担当": "鈴木"
  }
]
        
jqコマンドのオプションで-Rはjson形式ではないテキスト入力データを利用することを示しています。

生のcsvからjsonに仕訳るには、とりわけ
-sオプションが重要であり、入力したデータを個別のjsonオプジェクトとみなすこと無く、単一のJSONオブジェクトとして文字列パターンとして扱うようにできます。

さて、jqでの文字の置き換えは
gsubメソッドを利用します。

            
            $ jq -sR '
[
  split("\n")[]
    | select(length > 0)
      | split(",")
]
  | map(.[0] |= gsub("/"; "-"))
  | map({"日付": .[0], "予定": .[1], "担当": .[2]})
' << EOF
2021/04/06,出張,山田
2021/04/08,会議,佐藤
2021/04/17,リモートワーク,鈴木
EOF
#👇出力
[
  {
    "日付": "2021-04-06",
    "予定": "出張",
    "担当": "山田"
  },
  {
    "日付": "2021-04-08",
    "予定": "会議",
    "担当": "佐藤"
  },
  {
    "日付": "2021-04-17",
    "予定": "リモートワーク",
    "担当": "鈴木"
  }
]
        
もし複雑な置き換えルールが必要ならば、captureメソッドによって置き換え文字をカスタマイズします。

            
            $ jq -sR '
[
  split("\n")[]
    | select(length > 0)
      | split(",")
]
  | map(.[0] |= (capture("[0-9]{4}/(?<mon>[0-9]{2})/(?<day>[0-9]{2})") as $capt | $capt.mon + "月" + $capt.day + "日"))
  | map({"日付": .[0], "予定": .[1], "担当": .[2]})
' << EOF
2021/04/06,出張,山田
2021/04/08,会議,佐藤
2021/04/17,リモートワーク,鈴木
EOF
#👇出力
[
  {
    "日付": "04月06日",
    "予定": "出張",
    "担当": "山田"
  },
  {
    "日付": "04月08日",
    "予定": "会議",
    "担当": "佐藤"
  },
  {
    "日付": "04月17日",
    "予定": "リモートワーク",
    "担当": "鈴木"
  }
]
        
中身を変形したら再びcsv形式の出力に戻します。

jsonからcsvにシリアライズする場合には、csvとして保存したい要素で二次元配列化してから
@csvを最後に処理することでフォーマットされます。

例えば先程の得られたJSON形式の結果をつかった例でいうと、

            
            $ CONVERTED_JSON=$(cat << EOF
[
  {
    "日付": "04月06日",
    "予定": "出張",
    "担当": "山田"
  },
  {
    "日付": "04月08日",
    "予定": "会議",
    "担当": "佐藤"
  },
  {
    "日付": "04月17日",
    "予定": "リモートワーク",
    "担当": "鈴木"
  }
]
EOF
)

$ echo "$CONVERTED_JSON" | jq -r '.[] | [.["日付"], .["予定"], .["担当"]] | @csv' | sed 's/"//g'
#👇出力
04月06日,出張,山田
04月08日,会議,佐藤
04月17日,リモートワーク,鈴木
        
のような感じです。

なお最終的に出力される文字列についてくるダブルクオーテーションが外れないので、sedで
"を強制的に削除をしております。


応用 〜 置換ルールをCSVに登録して、文字列を一括置換する

置き換えルールとして、以下のように1列目に置換前、2列目に置換後の変換をすることにします。

            
            1,壱
2,弐
3,参
4,肆
5,伍
6,陸
7,漆
8,捌
9,玖
        
そして、以下のようなcsvの一列目だけをこのルールで置換したい、という課題があるとします。

            
            1-4-3,4
7-5-2,3
9-2-3,6
7-4-1,9
1-4-7,8
        

awkの場合

では、awkでこの置換をするなら以下のようになるでしょう。

            
            $ cat << EOF > replace_rule.csv
1,壱
2,弐
3,参
4,肆
5,伍
6,陸
7,漆
8,捌
9,玖
EOF

$ cat << EOF > example.csv
1-4-3,4
7-5-2,3
9-2-3,6
7-4-1,9
1-4-7,8
EOF

$ awk -F "," '
BEGIN {
    OFS=",";
    count=0;
}
F == 0 {
    before_arr[count] = $1;
    after_arr[count] = $2;
    count++;
    next;
}
{
    for (i = 0; i < count; i++ ) {
        gsub(before_arr[i], after_arr[i], $1);
    }
    print $1, $2;
}
' F=0 replace_rule.csv F=1 example.csv
#👇置換した結果
壱-肆-参,4
漆-伍-弐,3
玖-弐-参,6
漆-肆-壱,9
壱-肆-漆,8
        
このテクニックでは以前の記事で解説した2つのファイル間でのデータ結合の方法を応用しております。ポイントとしては、awkの読み出すファイルにFオプションで番号を指定するために、csvファイルを先に書き出しておきます。

jqの場合

jqではイタレーションとしてforeachやwhileなどが使えるのですが、基本的にワインライナーで簡潔に書けることが魅力のjqにおいてはforeachやwhileで複雑なループを行うと何ともコードが見辛くなってしまいます。

ですので、ここは
for...inループ構文の力を借りて、以下のようなスクリプト仕立てにしてみます。

            
            csv_data=$(jq -sR '
[ split("\n")[] | select(length > 0) | split(",") ]
' << EOF
1-4-3,4
7-5-2,3
9-2-3,6
7-4-1,9
1-4-7,8
EOF
)

replacing_map=$(jq -sR '
[ split("\n")[] | select(length > 0) | split(",") ]
  | map({"before": .[0], "after": .[1]})
' << EOF
1,壱
2,弐
3,参
4,肆
5,伍
6,陸
7,漆
8,捌
9,玖
EOF
)

for item in $(echo "$replacing_map" | jq -c .[]); do
    csv_data=$(echo "$csv_data" | jq -s --argjson item "$item" '
        (.[][][0] |= gsub($item.before; $item.after))[]
    ')
done

echo "$csv_data" | jq -r '.[] | @csv' | sed 's/"//g'
#👇置換した結果
壱-肆-参,4
漆-伍-弐,3
玖-弐-参,6
漆-肆-壱,9
壱-肆-漆,8
        
ここでのポイントはjqでの配列の使いこなしです。特に、(配列)[]の用法によって、配列の外側の皮を一つひん剥いて中身だけを取り出す(例えば[[1],[2],[3]] > [1] [2] [3]のような感じ)ことができます。

また、
--argjsonオプションを使えば、外部で定義したJSON文字列から指定した変数名(ここでは$itemという名前)で、そのままjsonオブジェクトとして扱うことができます。


まとめ

以上、AwkとJqでのcsvデータの文字列のアレコレの細かなテクニックを具体例をもって見ていきました。

ローカルに保存したcsvファイルならばAwkの方を使う方が便利ですし、curlなどでサーバーからREST方式でcsvデータを取得するのであればJqの方が効率の良いデータハンドリングができます。

参考サイト

以下はヘッダ名を最初の一行目に持つcsvファイルからfrom_entriesメソッドを使ってjsonへ変換する場合の方法です。

参考|jqでcsvをjsonへ変換

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。