3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

改行コードを含む文章の置換をシェルスクリプトで行う

Last updated at Posted at 2020-01-08

概要

Google App Script やシェルスクリプトを酷使して、セル内改行を含むスプレッドシートをもとに、なるべく体裁の崩れない置換を行う。
awksed のセパレータに ASCII 制御コードを利用することで実現可能となった。

経緯

XML のメールテンプレートの置換作業をする必要に迫られた。
置換作業用スプレッドシートが存在するが、セル内改行を含むため何かと面倒。
それでも、量が量だからスクリプト化したい。
行単位でなく、任意の文章ブロック単位で置換を行うにはどうすればよいだろうか...

手順

置換作業のおおまかな流れは以下。

  1. スプレッドシートをGASでエクスポート
  2. 置換対象ファイルをリストアップ
  3. 置換対象ファイルごとにレコードを抽出
  4. 抽出したレコードごとに置換

スプレッドシート側(Google Apps Script) の事前処理

対象のスプレッドシートの概要

おおよそ、このようなデータを想定している。
1列目は Github などのリモートのURLを記載して、シェルに渡した後に sed などでローカルのパスに置換をするといった運用もアリ。

ファイルパス 置換前 置換後
/User/hoge/fuga/1.xml タイトル Title
/User/hoge/fuga/1.xml 本文

改行含む
文章
body

include new line
sentence
/User/hoge/fuga/1.xml 1ファイルで
複数箇所にわたる
埋め込みも想定
embedding at
multiple locations
in one file is also assumed
/User/hoge/fuga/2.xml 2つ目のファイル 文章1 second file 1st sentence
/User/hoge/fuga/2.xml 2つ目のファイル
文章2
second file
2nd sentence
/User/hoge/fuga/3.xml 3つ目のファイル third file
... ... ...

スプレッドシートをエクスポート

CSV や TSV でエクスポートすると、改行が空白に置換されてしまう。
そこで、 Google Apps Script を使ってエクスポートする。

gas
function main() {
  var data = importData();
  exportToDriveFolder(data);

  return;
}

function importData() {
  var spreadSheet = SpreadsheetApp.openById('対象スプレッドシートのID');
  var targetSheet = spreadSheet.getSheetByName('抽出対象シート名');
  var cells = targetSheet.getRange('A1:C327'); // 置換対象範囲を指定
  var values = cells.getValues();
  var rowsCount = values.length;
  var data = '';

  for(var i = 0; i < rowsCount; i++) {
    values[i] = values[i].join('___EOF___');
  }

  data = values.join('___EOR___');

  return data;
}

function exportToDriveFolder(data) {
  var driveFolderId = 'Google Driveにある任意のフォルダのID';
  var driveFolder = DriveApp.getFolderById(driveFolderId);
  var fileName = 'replace.txt';
  var contentType = 'text/plain';
  var charset = 'utf-8';
  var blob = Utilities.newBlob('', contentType, fileName).setDataFromString(data, charset);

  driveFolder.createFile(blob);

  return;
}

Google Driveにある任意のフォルダ内に replace.txt というファイル名でエクスポートする。
列の区切りは ___EOF___ 、行の区切りは ___EOR___ とする。スプレッドシート内に存在しない文字列であれば、任意のものでよい。
なお、通常のエクスポートのカンマやタブに相当するのが ___EOF___ で、 ___EOR___ は改行に相当する。

シェルスクリプトでの置換作業

各種セパレータ用として ASCII 制御コードを変数に格納

まず、後の処理を行うための変数を定義する。

awksed で用いるセパレータは通常、改行 \n や空白 \s 、 スラッシュ / などがデフォルト値として設定されている。しかし、それらを含むような文字列に対しては、エスケープ処理や代替文字への置換を施す必要がある。毎回エスケープ処理を行うのは煩雑で、通常の表記文字を代替文字とするのも文字の選定が難しい。

そこで、以下のように変数の定義を行い、その変数を awksed のセパレータとして利用する。

shell
FS=$( echo @ | tr @ '\034' ) # File Separator
GS=$( echo @ | tr @ '\035' ) # Group Separator
RS=$( echo @ | tr @ '\036' ) # Record Separator
US=$( echo @ | tr @ '\037' ) # Unit Separator

@ 1文字を echo で出力した後に tr で制御コード(8進数コードを使用)に置換するという処理を、変数に格納している。これらの制御コードは改行コードなどと違い、通常の文章中には出現しない。

@ は任意の表記文字でよい。置換する制御コードも、動作に影響のないものであれば、上記以外のものでも可。\ によるエスケープコードが存在しないものがよさそう。

2020-01-10 追記

printf だけで事足りるようです。(コメント欄)

shell
FS=$( printf '\034' ) # File Separator
GS=$( printf '\035' ) # Group Separator
RS=$( printf '\036' ) # Record Separator
US=$( printf '\037' ) # Unit Separator

置換対象ファイルをリストアップ

shell

cat $IMPORT_DATA | # GASでエクスポートしたデータを読み込む
sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
    -e "s${GS}___EOR___${GS}${RS}${GS}g" |
awk -v FS=${FS} -v RS=${RS} 'NR>1 {print $1}' |
sort | uniq | # 重複の削除
# grep -e "必要に応じて抽出条件を指定" |
# sed -e "必要に応じてローカルのパスに置換" |
  • sed___EOF___ FS に、 ___EOR___ RS にそれぞれ置換
  • awk-v オプションで FS および RS をセパレータとして設定し、1列目(URLやファイルパス)を抽出
  • スプレッドシートのヘッダー行は NR>1 をアクションの条件とすることで除外できる
  • 置換対象の絞り込みやパスの置換作業などはこの時点で行う

置換対象ファイルごとにレコードを抽出

置換対象ファイルリストを while で回す。

shell
while read target_file #  置換対象ファイルリストを while で回す
do
    cat $IMPORT_DATA | # GASでエクスポートしたデータを読み込む
    tr $'\n' ${US} | # awkの変数に渡せるように改行を Unit Separator に置換
    sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
        -e "s${GS}___EOR___${GS}${RS}${GS}g" |
    awk -v FS=${FS} -v RS=${RS} -v OFS=${FS} -v ORS=${RS} \
        -v url_checker=$target_file '
        { url = $1; default = $2; replace = $3; }
        ( url == url_checker ) { print default,replace }
    ' | 
    tr ${RS} $'\n' # whileで回すためRecordSeparatorを改行に変換
    # | while IFS=${FS} read default_text replace_text ... (次項)
done
  • 1番目のフィールドをチェックして、置換対象のファイルに対応するレコードだけ抽出
  • 置換前に相当する列と置換後に相当する列の値を出力
  • awk-v オプションで、シェルから awk に変数を渡すことができるが、改行が含まれていると正常に渡せないため、あらかじめフィールド内の改行を Unit Separator に置換

抽出したレコードごとに置換

抽出したレコードを更に while で回し、レコード単位で置換作業を行う。

shell
while IFS=${FS} read default_text replace_text
do
    temporary_file=$(mktemp -t "temporary.xml.tmp") # 一時ファイルの作成
    cat $target_file | # 置換対象のXMLを読み込み

    # セパレータを仕込む
    sed -e "s${GS}<titleText>${GS}<titleText>${FS}${GS}g" \
        -e "s${GS}\(<plain><!\[CDATA\[\)${GS}\1${FS}${GS}g" \
        -e "s${GS}\(<content><!\[CDATA\[\)${GS}\1${FS}${GS}g" \
        -e "s${GS}</titleText>${GS}${RS}</titleText>${GS}g" \
        -e "s${GS}\]\]>${GS}${RS}\]\]>${GS}g" |

    awk -v FS=${FS} -v RS=${RS} -v OFS= -v ORS= \ # OFSとORSにはNULLを設定しておく
        -v default_text="$default_text" -v replace_text="$replace_text" '
        {
            non_target_text = $1
            target_text = $2
            length_checker = sqrt( ( length(target_text) - length(default_text) ) ^ 2 )
            length_checker_buffer = 4
            print non_target_text
        }
        ( length_checker > length_checker_buffer ) { print target_text }
        ( length_checker <= length_checker_buffer ) { print replace_text }
    ' |
    tr ${US} $'\n' > $temporary_file # Unit separator を改行に戻して、一時ファイルに出力
    cat $temporary_file > $target_file # 一時ファイルの内容を対象ファイルに出力
done
rm -f $temporary_file
  • read のIFSに File Separator を設定し、置換前に相当するフィールドを default_text 、 変更後に相当するフィールドを replace_text に格納
  • sed で置換対象の直前に File Separator を、置換対象の直後に Record Separator をうまいこと埋め込む
  • awk のlength関数などを用いて default_texttarget_text の文字数を比較
  • 文字数の差が length_checker_buffer を超過する場合は、置換前の文章 target_text を出力
  • 文字数の差が length_checker_buffer 以下であれば、置換適用箇所とみなして、置換後の文章 replace_text を出力

AWKの組み込み変数

当記事で扱ったものは以下。

変数名 説明 デフォルト値
FS 入力フィールドのセパレータ
入力フィールドの分割に使用する文字
-F オプションでも指定可能
空白
RS 入力レコードのセパレータ
入力レコードの分割に使用する文字
改行
OFS 出力フィールドのセパレータ
出力フィールドの分割に使用する文字
空白
ORS 出力レコードのセパレータ
出力レコードの分割に使用する文字
改行
NR 現在までに読み込んだレコードの合計

シェルスクリプト全体

実際に使う際は適度に関数化した方が良い。

replace.sh
#!/bin/bash

IMPORT_DATA="GASでエクスポートしたデータのパス"

FS=$( echo @ | tr @ '\034' ) # File Separator
GS=$( echo @ | tr @ '\035' ) # Group Separator
RS=$( echo @ | tr @ '\036' ) # Record Separator
US=$( echo @ | tr @ '\037' ) # Unit Separator

cat $IMPORT_DATA |
sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
    -e "s${GS}___EOR___${GS}${RS}${GS}g" |
awk -v FS=${FS} -v RS=${RS} 'NR>1 {print $1}' |
sort | uniq | # 重複の削除
# grep -e "必要に応じて抽出条件を指定" |
# sed -e "必要に応じてローカルのパスに置換"

while read target_file #  置換対象ファイルリストを while で回す
do
    cat $IMPORT_DATA |
    tr $'\n' ${US} | # awkの変数に渡せるように改行を Unit Separator に置換
    sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
        -e "s${GS}___EOR___${GS}${RS}${GS}g" |
    awk -v FS=${FS} -v RS=${RS} -v OFS=${FS} -v ORS=${RS} \
        -v url_checker=$target_file '
        { url = $1; default = $2; replace = $3; }
        ( url == url_checker ) { print default,replace }
    ' | 
    tr ${RS} $'\n' | # whileで回すためRecordSeparatorを改行に変換

    while IFS=${FS} read default_text replace_text
    do
        temporary_file=$(mktemp -t "temporary.xml.tmp") # 一時ファイルの作成
        cat $target_file | # 置換対象のXMLを読み込み

        # セパレータを仕込む
        sed -e "s${GS}<titleText>${GS}<titleText>${FS}${GS}g" \
            -e "s${GS}\(<plain>!\[CDATA\[\)${GS}\1${FS}${GS}g" \
            -e "s${GS}\(<content><!\[CDATA\[\)${GS}\1${FS}${GS}g" \
            -e "s${GS}</titleText>${GS}${RS}</titleText>${GS}g" \
            -e "s${GS}\]\]>${GS}${RS}\]\]>${GS}g" |

        awk -v FS=${FS} -v RS=${RS} -v OFS= -v ORS= \ # OFSとORSにはNULLを設定しておく
            -v default_text="$default_text" -v replace_text="$replace_text" '
            {
                non_target_text = $1
                target_text = $2
                length_checker = sqrt( ( length(target_text) - length(default_text) ) ^ 2 )
                length_checker_buffer = 4
                print non_target_text
            }
            ( length_checker > length_checker_buffer ) { print target_text }
            ( length_checker <= length_checker_buffer ) { print replace_text }
        ' |
        tr ${US} $'\n' > $temporary_file # Unit Separator を改行に戻して、一時ファイルに出力
        cat $temporary_file > $target_file # 一時ファイルの内容を対象ファイルに出力
    done

    rm -f $temporary_file
done

参考

AWK

GAWK

ASCIIコード

Google Apps Script

3
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?