[Awk & Jq活用] シェルコマンドで複数の条件による検索し結果をCSVで返す


2021/03/24

前の回ではCSVデータで列ごとに含まれる文字列を置換する方法で基礎的なパターンを中心に解説しました。

この回ではターゲットとなる列の要素に複数の条件を検索して、その検索結果をcsv形式で返すようなシェルスクリプトを作成してみます。


はじめに

当サイトではオフィス業務のComputer-Aidedなハイブリッドな方法を模索し、より効率的なExcel業務を実現したい多忙なオフィスワーカー向けの主にAwkとSedを使うシェル講座です。

シェルスクリプトはどこでもどんなOSでも基本的に使えて、しかも一度使い方を覚えると、Excelと組み合わせて最高に効率の良いオフィスワークツールが作れることでしょう。

合同会社タコスキングダム|蛸壺の技術ブログ


検索の基礎

Awkの場合

Awkで文字列を検索する場合、ネイティブ関数から特定のパターンを探そうと思うと、match関数を使うことになります。

            
            用法:
    match(<対象文字列>, <検索する文字パターン>)
        
基本的にmatch関数は、検索される文字列の最初の位置(1番目)から検索して、最初にマッチした文字の始まる位置を返す関数です。文字列中の複数箇所にマッチさせたい場合には他の方法を検討する必要があります。

また第二引数の
<検索する文字パターン>には文字列以外に正規表現も指定できます。

また、match関数を使いこなす上で、以下のシステム変数を併せて理解する必要があります。

            
            RSTART:
    match関数で一致した文字列の開始位置を格納。
    一致しなかった場合は0が設定される

RLENGTH:
    match関数で一致した文字列の長さを格納。
    一致しなかった場合は-1が設定される
        
特に日本語を含むデータでは、1バイト文字、2バイト文字、3バイト文字...と文字数のサイズが不特定ですので、検索で一致した文字列の最初の位置の他に、一致した文字列の最後の位置も併せて把握する必要があります。

簡単な一例を以下のように挙げてみます。

            
            $ awk -F"," '{
    num = match($3, /(山田|.?郎)/);
    print RSTART " " RLENGTH;
    print $0 " -> " $3 "が" num "バイト目に見つかりました";
}' << EOF
2021/04/06,出張,山田 モフ男
2021/04/08,会議,佐藤 ピヨ子
2021/04/17,リモートワーク,鈴木 フガ郎
EOF
#👇出力
1 6
2021/04/06,出張,山田 モフ男 -> 山田 モフ男が1バイト目に見つかりました
0 -1
2021/04/08,会議,佐藤 ピヨ子 -> 佐藤 ピヨ子が0バイト目に見つかりました
11 6
2021/04/17,リモートワーク,鈴木 フガ郎 -> 鈴木 フガ郎が11バイト目に見つかりました
        
単にパターンの文字を見つけたいだけなら、match関数が返す値(=RSTART)がゼロかそれ以外を判定すると良いでしょう。

Jqの場合

jqの場合には、検索した結果の詳細を知りたい場合にmatchメソッドか、一致するか知りたいだけならtestメソッドが利用できます。

ただし、行数の情報を残しておきたい場合には、
to_entries関数を利用して、一旦key-valueパターンに落とし込む必要があります。

            
            $ jq -sR '
[ split("\n")[] | select(length > 0) | split(",") ]
    #👇配列をkey-valueパターンに展開
    | to_entries
    #👇indexも含めたオプジェクトとして再構築
    | map({date: .value[0], task: .value[1], name: .value[2], index: .key})
    #👇csvの3列目(氏名)の中身で検索
    | map(select(.name | test("(山田|.?郎)")))
' << EOF
2021/04/06,出張,山田 モフ男
2021/04/08,会議,佐藤 ピヨ子
2021/04/17,リモートワーク,鈴木 フガ郎
EOF
#👇出力
[
  {
    "date": "2021/04/06",
    "task": "出張",
    "name": "山田 モフ男",
    "index": 0
  },
  {
    "date": "2021/04/17",
    "task": "リモートワーク",
    "name": "鈴木 フガ郎",
    "index": 2
  }
]
        

応用課題 ~ 複数条件を指定

では本題の複数の検索条件をcsvファイルから与えて、それを別のcsvデータから探すようなシェルスクリプトを考えてみます。

都道府県名と市区町村名がそれぞれ何バイト目で出現するのかを検索します。

検索するワードを与えるcsvファイルを、
keywords.csvとして以下のように作成しておきます。

            
            $ cat << EOF > keywords.csv
都,市
道,区
府,町
県,村
EOF
        
一方、検索されるcsvデータはcities.csvとして以下のようにしましょう。

            
            $ cat << EOF > cities.csv
大阪府門真市
山形県新庄市
沖縄県与那国町
奈良県十津川村
北海道倶知安町
山梨県南アルプス市
東京都港区
大分県豊後高田市
EOF
        
としておいて、このお題をAwkとJqで解いていきましょう。

Awkの場合

先にスクリプトの解答の一つを貼り付けておきます。

            
            PARR=($(awk -F"," '{ arr = arr " " $1;} END{print arr;}' keywords.csv))
SARR=($(awk -F"," '{ arr = arr " " $2;} END{print arr;}' keywords.csv))
for p in "${PARR[@]}"; do
    for s in "${SARR[@]}"; do
        awk -F "," '
        {
            s_pos1 = match($0, /'"$p"'/);
            s_pos2 = match($0, /'"$s"'/);
            if (s_pos1 > 0 && s_pos2 > 0) {
                print $0 " -> '"$p"'が" s_pos1 "バイト目と'"$s"'が" s_pos2 "バイト目に見つかりました";
            }
        }
        ' cities.csv
    done
done
#👇出力
東京都港区 -> 都が7バイト目と区が13バイト目に見つかりました
北海道倶知安町 -> 道が7バイト目と町が19バイト目に見つかりました
大阪府門真市 -> 府が7バイト目と市が16バイト目に見つかりました
山形県新庄市 -> 県が7バイト目と市が16バイト目に見つかりました
山梨県南アルプス市 -> 県が7バイト目と市が25バイト目に見つかりました
大分県豊後高田市 -> 県が7バイト目と市が22バイト目に見つかりました
沖縄県与那国町 -> 県が7バイト目と町が19バイト目に見つかりました
奈良県十津川村 -> 県が7バイト目と村が19バイト目に見つかりました
        
Awkで今回のお題を解く場合、困ったことにmatch関数の正規表現パート(/.../の中身)を動的に変えることが制御として難しいことが挙げられます。

そこで今回のシェルスクリプトで変数を呼び出す
$"<変数名>"テクニックと、Awkのスクリプト部を部分分割'...'(複数のシングルコーテーションで囲うこと)のテクニックを駆使してawkのmatch関数を動的に呼び込めるようにしています。

keywords.csvからの検索文字も2列あるので、
PARRSARRのように検索したい文字をシェルで配列として与えて、配列によるforループを扱えるようにしてあります。

というわけで、Awkで検索する表現を動的に変える必要があるだけで、かなり複雑になりますが、一応出来ないことも無さそうです。

Jqの場合

次にJqの場合も紹介します。

JqもAwkと基本的には同じで、複数のキーワード検索では、シェルスクリプトのループによる補助を利用しています。

また先ほどと同様、Jqスクリプトのシングルクォーテーションの部分分割を行って、その合間合間にシェルスクリプトの変数を
$"<変数名>"で呼び出すやり方も踏襲できます。

            
            P_LOOP=$(jq -sR '[ split("\n")[] | select(length > 0) | split(",") ] | map(.[0]) | .[]' keywords.csv)
S_LOOP=$(jq -sR '[ split("\n")[] | select(length > 0) | split(",") ] | map(.[1]) | .[]' keywords.csv)
for p in $P_LOOP; do
    for s in $S_LOOP; do
        jq -sR '
        [ split("\n")[] | select(length > 0) | split(",") ]
            | to_entries
            | map({city: .value[0], index: .key})
            | map({city_p: (.city | match("'"${p//\"/}"'"; "g")), index, city})
            | map({city_s: (.city | match("'"${s//\"/}"'"; "g")), city_p, index, city})
            | select(length > 0)[]
        ' cities.csv
    done
done
#👇出力
{
  "city_s": {
    "offset": 4,
    "length": 1,
    "string": "区",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "都",
    "captures": []
  },
  "index": 6,
  "city": "東京都港区"
}
{
  "city_s": {
    "offset": 6,
    "length": 1,
    "string": "町",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "道",
    "captures": []
  },
  "index": 4,
  "city": "北海道倶知安町"
}
{
  "city_s": {
    "offset": 5,
    "length": 1,
    "string": "市",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "府",
    "captures": []
  },
  "index": 0,
  "city": "大阪府門真市"
}
{
  "city_s": {
    "offset": 5,
    "length": 1,
    "string": "市",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "県",
    "captures": []
  },
  "index": 1,
  "city": "山形県新庄市"
}
{
  "city_s": {
    "offset": 8,
    "length": 1,
    "string": "市",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "県",
    "captures": []
  },
  "index": 5,
  "city": "山梨県南アルプス市"
}
{
  "city_s": {
    "offset": 7,
    "length": 1,
    "string": "市",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "県",
    "captures": []
  },
  "index": 7,
  "city": "大分県豊後高田市"
}
{
  "city_s": {
    "offset": 6,
    "length": 1,
    "string": "町",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "県",
    "captures": []
  },
  "index": 2,
  "city": "沖縄県与那国町"
}
{
  "city_s": {
    "offset": 6,
    "length": 1,
    "string": "村",
    "captures": []
  },
  "city_p": {
    "offset": 2,
    "length": 1,
    "string": "県",
    "captures": []
  },
  "index": 3,
  "city": "奈良県十津川村"
}
        
ここでのポイントとしては、途中のパイプライン処理でmap({city_p: (.city | match("'"${p//\"/}"'"; "g")),...})などでmapによるjsonオプジェクトの配列を拡張しつつ、match関数で検索した結果を格納するフィールドを付け加えていることです。

なお、Jqのmatch関数は文字列の検索結果をオプジェクトとして返し、Awkとは違い文字のバイト数などは気にせずに文字の位置がカウントされます。


まとめ

今回はcsv形式のデータで、特定の列に絞った文字列の検索を、複数の条件を与えて実行する方法を検討してみました。

AwkとJqどちらでも同じようなスクリプトの構成で行えるので、一度やり方を覚えておくと、何かの時に役に立つかも知れません。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

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