UECジャーナル

開眼☆シェルスクリプト 文章を扱うときに便利な技―sed、awk、find、grepの組み合わせ

メモ書きや原稿など不定形のテキストファイルのハンドリングを扱う。シェルスクリプトというよりは便利なコマンドの使い方を羅列していく。

ドキュメントを扱う方法はテキストファイルでHTML、reStructuredText、TeXを書くといったやり方と、ドキュメント作成用のアプリケーションを使って書く方法などに分かれる。

どの方法が良いかはその人に依るのだが、おそらく、これらのやり方の違いが大きく増幅されるのは検索するときである。ワープロアプリケーションや表計算アプリケーションにテキストを書いておいて後から複数のファイルから文字を探すときは、そのソフトウェアベンダが作った機能やツールを使う事になる。テキストファイルの場合にはfind(1)やgrep(1)といったコマンドを使う。

余談だが、人が読むのは最終出力だけで、原本はテキストでというのが大事だと筆者は考えている。

環境

コマンドの用例ではgsed、gawkで統一してあるがLinuxの多くのディストリビューションではgsedはsed、 gawkはawkとなる。

リスト1: 環境

$ uname -a
Darwin uedamac.local 12.2.1 Darwin Kernel Version 12.2.1: Thu Oct 18 16:32:48 PDT 2012; root:xnu-2050.20.9~2/RELEASE_X86_64 x86_64
uedamac:~ ueda$ gawk --version
GNU Awk 4.0.2
Copyright (C) 1989, 1991-2012 Free Software Foundation.
 (以下略) 
$ gsed --version
GNU sed version 4.2.1
Copyright (C) 2009 Free Software Foundation, Inc.
 (以下略) 
$ 

環境にはMac OS Xを使っているが、LinuxでもFreeBSDでも使い慣れた環境で試してもられえればと思う。

日本語原稿の文字数を数える

作文をしていて、何文字書いたか調べたいときがある。たとえば、リスト2のファイル mistery内の文字w数は、全角スペースを入れてちょうど60文字だ。

リスト2: 例題ファイルその1

$ cat mistery 
 朝目覚めると、私は全身を繭で
覆われた蛹になっていたのである。
私は大変困ってしまった。「
会社に休みの連絡ができない。」
$ 

こういうときは、リスト3のようにやる。

リスト3: 文字数を数える

$ cat mistery | wc -m
      64
$ 

リスト3のように、 wc にオプション -m をつけると今のロケールに合わせて文字数を数えてくれる。ロケールを変えるとリスト4のように出力に違いが出る。

リスト4: ロケール (環境変数 LANG ) で挙動が変わる

$ echo $LANG
ja_JP.UTF-8
$ cat mistery | LANG=C wc -m
     184
$ cat mistery | LANG=ja_JP.UTF-8 wc -m
      64
$ 

この方法だと改行や記号も文字数にカウントしている。次のように tr や sedで字を削っておくと正解が出るので、正確に数えたいならこのようにする。

リスト5: 文字数を正確に数える

$ cat mistery | tr -d '\n' | wc -m
      60
#全角スペースも数えたくない場合
$ cat mistery | tr -d '\n' | gsed 's/ //g' | wc -m
      59
$ 

もういくつかの数え方をリスト6に紹介しておく。 gsed(1)を使う方法は筆者の手癖になっているものだ。gawk(1)はロケールが日本語でもAWKのコマンドの種類によってはバイト数になってしまうので注意が必要。

リスト6: 文字数を正確に数える

$ cat mistery | gsed 's/./&\n/g' | wc -l
      64
$ cat mistery | gsed 's/./&\n/g' | gawk 'NF!=0' | wc -l
      60
$ cat mistery | gawk '{a+=length($0)}END{print a}' 
60
#Mac等ではgawkを明示的に指定しないとこのようになってしまうので注意
$ cat mistery | awk '{a+=length($0)}END{print a}' 
180
$ 

もっと長い文章について どれだけ書いたかざっくり知りたい場合はバイト数で考えてもよいだろう。たとえば筆者はこの連載を毎月6ページずつ書くのだが、 他の月と比較してどれだけ書いたかwc(1)コマンドでリスト7のように調査している。

リスト7: どれだけ書いたかバイト数や行数でざっくり調べる

$ wc 201???.rst
     549     803   24653 201201.rst
     428    1064   19469 201202.rst
      (中略) 
     514     945   20703 201307.rst
     554     948   18805 201308.rst
     482     905   20520 201309.rst
     165     314    6616 201310.rst
   11016   21368  448677 total
$ 

文章の抜き出し

次はテキストファイルの一部分を抜き出すテクニックを紹介する。たとえば、次のような連絡先メモがあるとする。

リスト8: 例題ファイルその2

$ cat address 
<幹事会>

- 鎌田
	- 略称: (鎌)
	- TEL: 090-1234-xxxx
	- email: kama@kama.gov

- 濱田
	- 略称: (ハ)
	- TEL: 080-5678-xxxx
	- email: ha@haisyou.ac.jp
$ 

たとえば濱田さんの電話番号が知りたいとする。このようなとき、普通にgrep(1)を使おうとしても、

$ grep 濱田 address 
- 濱田
$ 

という結果が得られる。

grep(1)には-Aというオプションがある。これを使うとリスト9のように検索で引っかかった行の後ろも出力してくれる。これでいちいちless(1)を使ったりエディタ開いたりしなくて済む。

リスト9: grep(1)の -A オプション

$ grep 濱田 -A 3 address 
- 濱田
	- 略称: (ハ)
	- TEL: 080-5678-xxxx
	- email: ha@haisyou.ac.jp
$ 

電話番号から人の名前を検索してみよう。リスト10のようにする。-Bは-Aの逆で、一致する前の行を表示してくれる。

リスト10: grep(1)の-Bオプション

$ grep 080-5678-xxxx -B 2 address 
- 濱田
	- 略称: (ハ)
	- TEL: 080-5678-xxxx
#補足:-Aと-Bを併用することも可能
$ grep 080-5678-xxxx -B 2 -A 1 address 
- 濱田
	- 略称: (ハ)
	- TEL: 080-5678-xxxx
	- email: ha@haisyou.ac.jp
$ 

次はHTMLファイルを扱う。HTMLから狙ったところをワンライナーで切り出してみよう。これから扱うような処理は、ブラウザでソースを表示してマウスでコピペでもよいのだが、何十、何百も同じ処理を繰り返すことになったらそうもいかない。

まず、筆者のブログからコードの部分だけ切り取るということをやってみる。2013年7月14日現在で、筆者のブログのトップページにはいくつかコードが掲載されているのだが、コードはHTML上で<pre>と</pre>に囲まれている。リスト11のリストのようにcurl(1)コマンドでHTMLを取得してless(1)で読んでみよう。このような部分がいくつか出現する。

リスト11: 例題のHTML

$ curl http://blog.ueda.asia | less
 (略) 
<pre class="brush: bash; title: ; notranslate" title="">
Python 2.7.2 (default, Oct 11 2012, 20:14:37) 
 (略) 
&gt;&gt;&gt; round(-1.1,-1)*1.0
-0.0
</pre>
 (略) 
$ 

このような抽出にはsed(1)コマンドが適している。リスト12のようにコマンドを書けばコード (pre要素) だけを抽出することができる。

リスト12: コードだけ取り出す

...
$ curl http://blog.ueda.asia 2> /dev/null | nkf -wLux | gsed -n '/<pre/,/<\/pre/p' > ans
$ less ans
 (略) 
<pre class="brush: bash; title: ; notranslate" title="">
Python 2.7.2 (default, Oct 11 2012, 20:14:37) 
 (略) 
</pre>
<pre class="brush: bash; title: ; notranslate" title="">
$ cat hoge.sh
#!/bin/sh -xv
 (略) 
</pre>
...
$ 

ここでのポイントはsed(1)の使い方と、 curl(1)の出力をすぐにnkf(1)で処理することにある。

sed(1)は/<正規表現1>/,/<正規表現2/p (pコマンド)で正規表現1にマッチする行から正規表現2にマッチする行まで抜き出すことができる。この処理は正規表現2のマッチが終わると再度実行されるので、上の例ではいくつもpre要素を抜き出す事ができている。オプション-nはsed(1)はデフォルトで全行を出力するのでそれを抑制するために使う。-nをつけておかないと、pコマンドの出力対象行が2行ずつ、その他の行が1行ずつ出力されてしまう。

curl(1)の出力は、例え読み取ったHTMLがUTF-8で書いてあっても改行コードがUNIX標準のものと違っている可能性があるので、このようなときは必ず通す。オプションは-wLuxが私の場合は手癖になっており、

w : UTF-8に変換、Lu : 改行コードをLF (0x0a) に、x : 半角カナから全角カナへの変換を抑制

という意味がある。

ただ、このようにHTMLがきれいに改行されていればあまり苦労もないのだが、実際はそうもいかない。リスト13のようなHTMLもある。

リスト13: 例題ファイルその3

$ cat kitanai.html 
<pre>#!/bin/bash

echo "きたない"</pre>あははは<pre>
#!/bin/sh

echo "きたなすぎる"
</pre>
$ 

こういうときは、リスト14のように自分で掃除する。このsed(1)のワンライナーはお世辞にもきれいとは言えないので、ちゃんとプログラムを書いた方がいいかもしれない。ただ、結局この方が早いことが多い。

リスト14: きたないHTMLを掃除するワンライナー

$ cat kitanai.html |
#<pre>の後に何か文字があると改行を差し込む
gsed 's;\(<pre[^>]*>\)\(..*\);\1\n\2;g' |
#<pre>の前に何か文字があると改行を差し込む
gsed 's;\(..*\)\(<pre[^>]*>\);\1\n\2;g' |
#</pre>の前に何か文字があると改行を差し込む
gsed 's;\(..*\)</pre>;\1\n</pre>;g' |
#</pre>の後に何か文字があると改行を差し込む
gsed 's;</pre>\(..*\);</pre>\n\1;g'
<pre>
#!/bin/sh

echo "きたない"
</pre>
あははは
<pre>
#!/bin/sh

echo "きたなすぎる"
</pre>
$ 

さきほどpreで抜き出したHTMLには

&gt;&gt;&gt; round(-1.1,-1)

など、記号の一部が文字実体参照に変換されている。たとえば&gt;は>が置き換わったものだ。

また、次のように数値参照になっているときもある。

&#x4e0a;&#x7530;&#x53c2;&#x4e0a;

HTMLから抜き出して来たら、このままにするより元に戻した方がよいだろう。

数値参照の方はリスト15のようにnkf(1)で変換できる。

リスト15: 数値参照をnkf(1)でデコードする

$ echo '&#x4e0a;&#x7530;&#x53c2;&#x4e0a;' | nkf --numchar-input
上田参上
$ 

文字実体参照の方はnkf(1)では できない。しかし、「"&<>」とスペース程度ならあまり個数がないのでリスト16のようにsedスクリプトを書くとよいだろう。コマンド化してもいいだろう。

リスト16: 文字実体参照を置換するsedスクリプトを作って使う

#このようなsedスクリプトを作る
$ cat ref.sed 
s/&lt;/</g
s/&gt;/>/g
s/&quot;/"/g
s/&amp;/\&/g
s/&nbsp;/ /g
$ curl http://blog.ueda.asia 2> /dev/null | sed -n '/<pre/,/<\/pre/p' | gsed -n '1,/<\/pre/p' | sed -f ./ref.sed 
<pre class="brush: bash; title: ; notranslate" title="">
 (略) 
>>> round(-1.1,-1)*1.0
-0.0
</pre>
$ 

他の文字実体参照も変換しなければならないときは、他の言語のライブラリを使って変換コマンドを書くのが一番簡単な方法だ。

find、grep、xargsの組み合わせ

最後にファイルの検索をやる。ディレクトリの中から何かテキストを探すときは、find(1)とxargs(1)を組み合わせるとさまざまなことができる。

find(1)指定したディレクトリの下のファイルやディレクトリを延々と出力する。find(1)はオプションが多い事でも知られているが、リスト17のような使い方だけ知っておけばよいだろう。オプションの.はカレントディレクトリ、-type fはファイルだけ表示しろということである。

リスト17: find を使う

$ find . -type f | head -n 4
./.201203.rst.swp
./.201310.rst.swp
./.DS_Store
./.git/COMMIT_EDITMSG
$ 

たとえば、ミーティング中にとっさにとったメモをどこに保存したか忘れたが、何を書いたかはうっすら覚えている場合、 (そしてメモを取るときは必ずファイル名にmemoかMEMO を入れている場合) リスト18のようなワンライナーで探し出すことができる。

リスト18: findとgrep、xargsを組み合わせる

$ find ~ -type f | fgrep -v "/." | grep -i memo | xargs grep 徹夜 | gsed 's/:.*//' > hoge
$ cat hoge 
/Users/ueda/Dropbox/ユニバーサル・シェル・プログラミング研究所/memo/memo
$ cat hoge | xargs cat
この作業は徹夜になるような気がする。悲しいです・・・
$ 

find(1)やgrep(1)で検索をかけるときによく使うイディオムを挙げておく。

find . | grep hoge : ファイル名の検索
grep -r hoge ./ : ディレクトリ下の全ファイルの中身を検索
grep -r hoge ./ | gsed 's/:.*/' | uniq : ディレクトリ下の全ファイルの中身を検索し、ファイル名のリストを抽出
grep -r hoge ./ | gsed 's/:.*/' | uniq | xargs cat : ディレクトリ下の全ファイルの中身を検索し、ファイル名のリストを抽出し、抽出したファイルの中身を表示

おわりに

この手のノウハウは無数にあるものの、そのうちちょっとでも知っておくと生産効率が大きく変わってくる。

Software Design 2013年10月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【22】文章を扱うときに便利な技―sed、awk、find、grepの組み合わせ」より加筆修正後転載

last modified: 2014-01-24 20:01:24