概要
Google App Script やシェルスクリプトを酷使して、セル内改行を含むスプレッドシートをもとに、なるべく体裁の崩れない置換を行う。
awk
や sed
のセパレータに ASCII 制御コードを利用することで実現可能となった。
経緯
XML のメールテンプレートの置換作業をする必要に迫られた。
置換作業用スプレッドシートが存在するが、セル内改行を含むため何かと面倒。
それでも、量が量だからスクリプト化したい。
行単位でなく、任意の文章ブロック単位で置換を行うにはどうすればよいだろうか...
手順
置換作業のおおまかな流れは以下。
- スプレッドシートをGASでエクスポート
- 置換対象ファイルをリストアップ
- 置換対象ファイルごとにレコードを抽出
- 抽出したレコードごとに置換
スプレッドシート側(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 を使ってエクスポートする。
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 制御コードを変数に格納
まず、後の処理を行うための変数を定義する。
awk
や sed
で用いるセパレータは通常、改行 \n
や空白 \s
、 スラッシュ /
などがデフォルト値として設定されている。しかし、それらを含むような文字列に対しては、エスケープ処理や代替文字への置換を施す必要がある。毎回エスケープ処理を行うのは煩雑で、通常の表記文字を代替文字とするのも文字の選定が難しい。
そこで、以下のように変数の定義を行い、その変数を awk
や sed
のセパレータとして利用する。
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 追記
FS=$( printf '\034' ) # File Separator
GS=$( printf '\035' ) # Group Separator
RS=$( printf '\036' ) # Record Separator
US=$( printf '\037' ) # Unit Separator
置換対象ファイルをリストアップ
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
で回す。
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
で回し、レコード単位で置換作業を行う。
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_text
とtarget_text
の文字数を比較 - 文字数の差が
length_checker_buffer
を超過する場合は、置換前の文章target_text
を出力 - 文字数の差が
length_checker_buffer
以下であれば、置換適用箇所とみなして、置換後の文章replace_text
を出力
AWKの組み込み変数
当記事で扱ったものは以下。
変数名 | 説明 | デフォルト値 |
---|---|---|
FS | 入力フィールドのセパレータ 入力フィールドの分割に使用する文字 -F オプションでも指定可能 |
空白 |
RS | 入力レコードのセパレータ 入力レコードの分割に使用する文字 |
改行 |
OFS | 出力フィールドのセパレータ 出力フィールドの分割に使用する文字 |
空白 |
ORS | 出力レコードのセパレータ 出力レコードの分割に使用する文字 |
改行 |
NR | 現在までに読み込んだレコードの合計 |
シェルスクリプト全体
実際に使う際は適度に関数化した方が良い。
#!/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