Quantcast
Channel: Bashタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 2722

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

$
0
0

概要

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

経緯

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

手順

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

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

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

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

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

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

改行含む
文章
body

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

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

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

gas
functionmain(){vardata=importData();exportToDriveFolder(data);return;}functionimportData(){varspreadSheet=SpreadsheetApp.openById('対象スプレッドシートのID');vartargetSheet=spreadSheet.getSheetByName('抽出対象シート名');varcells=targetSheet.getRange('A1:C327');varvalues=cells.getValues();varrowsCount=values.length;vardata='';for(vari=0;i<rowsCount;i++){values[i]=values[i].join('___EOF___');}data=values.join('___EOR___');returndata;}functionexportToDriveFolder(data){vardriveFolderId='Google Driveにある任意のフォルダのID';vardriveFolder=DriveApp.getFolderById(driveFolderId);varfileName='replace.txt';varcontentType='text/plain';varcharset='utf-8';varblob=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 SeparatorGS=$(echo @ | tr @ '\035')# Group SeparatorRS=$(echo @ | tr @ '\036')# Record SeparatorUS=$(echo @ | tr @ '\037')# Unit Separator

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

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

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

shell
cat$IMPORT_DATA | # GASでエクスポートしたデータを読み込むsed-e"s${GS}___EOF___${GS}${FS}${GS}g"\-e"s${GS}___EOR___${GS}${RS}${GS}g" |
awk-vFS=${FS}-vRS=${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-vFS=${FS}-vRS=${RS}-vOFS=${FS}-vORS=${RS}\-vurl_checker=$1'
        { url = $1; default = $3; replace = $8; }
        ( 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>[ \n]*<!\[CDATA\[[ \n]*\)${GS}\1${FS}${GS}g"\-e"s${GS}\(<content>[ \n]*<!\[CDATA\[[ \n]*\)${GS}\1${FS}${GS}g"\-e"s${GS}</titleText>${GS}${RS}</titleText>${GS}g"\-e"s${GS}\]\]>${GS}${RS}\]\]>${GS}g" |

    awk-vFS=${FS}-vRS=${RS}-vOFS=-vORS=\ # OFSとORSにはNULLを設定しておく-vdefault_text="$1"-vreplace_text="$2"'
        {
            non_target_text = $1
            target_text = $2
            length_checker = sqrt( ( length(target_text) - length(default_text) ) ^ 2 )
            length_checker_buffer = 4
        }
        ( length_checker > length_checker_buffer ) {
            print non_target_text
            print target_text
        }
        ( length_checker <= length_checker_buffer ) {
            print non_target_text
            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以下であれば、置換適用箇所とみなして、 replace_textを適用
  • 文字数の差が length_checker_bufferを超過する場合は、ファイルの元の文章をそのまま出力

AWKの組み込み変数

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

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

シェルスクリプト全体

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

replace.sh
#!/bin/bashIMPORT_DATA="GASでエクスポートしたデータのパス"FS=$(echo @ | tr @ '\034')# File SeparatorGS=$(echo @ | tr @ '\035')# Group SeparatorRS=$(echo @ | tr @ '\036')# Record SeparatorUS=$(echo @ | tr @ '\037')# Unit Separatorcat$IMPORT_DATA |
sed-e"s${GS}___EOF___${GS}${FS}${GS}g"\-e"s${GS}___EOR___${GS}${RS}${GS}g" |
awk-vFS=${FS}-vRS=${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-vFS=${FS}-vRS=${RS}-vOFS=${FS}-vORS=${RS}\-vurl_checker=$1'
        { url = $1; default = $3; replace = $8; }
        ( 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>[ \n]*<!\[CDATA\[[ \n]*\)${GS}\1${FS}${GS}g"\-e"s${GS}\(<content>[ \n]*<!\[CDATA\[[ \n]*\)${GS}\1${FS}${GS}g"\-e"s${GS}</titleText>${GS}${RS}</titleText>${GS}g"\-e"s${GS}\]\]>${GS}${RS}\]\]>${GS}g" |

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

    rm-f$temporary_filedone

参考

AWK

GAWK

ASCIIコード

GoogleAppsScript


Viewing all articles
Browse latest Browse all 2722

Trending Articles