開眼シェルスクリプト2013年11月号

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

23. 開眼シェルスクリプト 第23回 表記揺れ・綴りをチェックする

 前回「文章を扱う」というお題でコマンドの操作をいくつか紹介しました。
今回は「文章を扱う道具」つまりコマンドをシェルスクリプトで作ってみます。

 作るコマンドは語尾のチェックコマンドとスペルチェックのコマンドです。
いずれのコマンドも、既存のコマンドをうまく組み合わせて、
短いものを作ります。

 今回はコマンド作者となるわけですから、
GancarzのUNIX哲学(脚注:http://ja.wikipedia.org/wiki/UNIX哲学)
を全部頭に叩き込んで、先にお進み下さい。
特に、

  • 各プログラムが一つのことをうまくやるようにせよ。
  • 全てのプログラムはフィルタとして振る舞うようにせよ。

が大事です。

23.1. 環境等

 今回は、Macに溜まった開眼シェルスクリプトの原稿について、
いろいろチェックするものを作っていきます。
Macには、GNU sed( gsed )とGNU awk ( gawk
がインストールされているものとします。
リスト1に環境を示します。

  • リスト1: 環境
1
2
3
4
5
6
7
8
uedamac:SD_GENKOU ueda$ uname -a
Darwin uedamac.local 12.4.0 Darwin Kernel Version 12.4.0: Wed May  1 17:57:12 PDT 2013; root:xnu-2050.24.15~1/RELEASE_X86_64 x86_64
uedamac:SD_GENKOU ueda$ gsed --version
gsed (GNU sed) 4.2.2
(略)
uedamac:SD_GENKOU ueda$ gawk --version
GNU Awk 4.1.0, API: 1.0
Copyright (C) 1989, 1991-2013 Free Software Foundation.

 原稿はテキストファイルです。
reStructuredText という形式でマークアップされていますが、
それはあまり気にしなくて大丈夫です。
拡張子はリスト2のように .rst です。

  • リスト2: 原稿のファイル
1
2
3
4
5
uedamac:SD_GENKOU ueda$ ls *.rst
201201.rst        201210.rst        201306.rst
201202.rst        201211.rst        201306SPECIAL.rst
201203.rst        201212.rst        201307.rst
(略)

原稿にはリスト3のように、
だいたい30字くらいで改行を入れています。

  • リスト3: 原稿
1
2
3
4
5
6
uedamac:SD_GENKOU ueda$ tail -n 5 201302.rst
lsとwcを使えば事足ります。captiveでないので、なんとかなります。

 今回は正直言いまして、
かなりエクストリームなプログラミングになってしまいましたので、
次回からはもうちょっとマイルドな話題を扱いたいと思います。

 コマンド等、作ったものはディレクトリ SD_GENKOU の下に bin
というディレクトリを作ってそこに置く事にします。

23.2. ですます・だであるチェック

 まず、表記揺れの基本中の基本、
「ですます調」と「だである調」
のチェックを行うシェルスクリプトを作ってみましょう。
「です。」「ます。」等の数を数え、
次に「だ。」「である。」等の数を数え、
どちらも1以上だったら「怪しい」という簡素なものです。

 まずはリスト4のように作ってみましょう。
これだと、例えば一行に「です。」が2回出てきても1とカウントされますが、
自分で使うには十分でしょう。
もちろん、たとえばこれを公開しようとすれば、
いろいろ細かく修正が必要です。

  • リスト4: 語尾を数えるコマンド
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
uedamac:201311 ueda$ cat deathmath1
#!/bin/bash

tmp=/tmp/$$

cat < /dev/stdin > $tmp-text

death="(です。|ます。|でした。|ました。|でしょう。|ません。)"
da="(だ。|である。|ない。|か。)"

grep -E "$death" $tmp-text      |
wc -l                           |
tr -d ' ' > $tmp-death

grep -E "$da" $tmp-text         |
wc -l                           |
tr -d ' ' > $tmp-da

echo "ですます" $(cat $tmp-death)
echo "だである" $(cat $tmp-da)

rm -f $tmp-*
exit 0

  コードの説明をしておくと、6行目で標準入力を $tmp-text
に一度溜めています。
$tmp-text/tmp/ 下のファイルですが、
このコードだと /tmp/ 下にファイルが残ってしまう可能性があります。
不特性多数の人々が使うUNIX環境のときは別のところに
一時ファイルを置きましょう。
今はあまりそういうこともないでしょうが、一応お断りを。
6行目の「 < /dev/stdin 」は、
このシェルスクリプトの標準入力を読み込むリダイレクトですが、
書かなくても cat が標準入力を読んでくれます。
筆者はそれだと分かりにくいので、明記しています。

 8,9行目で、正規表現を作ります。
語尾はたくさん種類があるので、ここにずらずら並べておきます。
おそらく全部網羅することは難しいし、
きりがないので自分の困らない範囲で列挙しておけばよいでしょう。
ただ、全く対処できないかというとそうでもなく、
例えばリスト5のようにワンライナーで語尾を抽出して、
後から解析することはできます。
身も蓋もないことを言うと、
形態素解析のコマンドをシェルスクリプトの中で使うと
完璧に近いものができるかもしれません。
それがシェルスクリプトの良いところなので、
使えるものは何でも使いましょう。

  • リスト5: 語尾を抽出
1
2
3
4
5
6
7
8
9
uedamac:201311 ueda$ cat ../*.rst |
gsed 's/....。/\n&\n/g' | grep 。|
sed 's/。.*/。/' | sort -u
(略)
(縦)軸。
(?)を。
)を作れ。
)を知る。
:私です。

 使ってみましょう。リスト6は実行例です。
原稿はですます調で書かれていますが、1.5%程度、
したがっていない部分があるように見えます。

  • リスト6: deathmath1 を使う
1
2
3
uedamac:SD_GENKOU ueda$ cat *.rst | ./bin/deathmath1
ですます 2252
だである 32

23.3. コマンドを書き直す

 さて、「だ、である」がちょっと混ざっているようなのですが、
今度はどこを修正しなければならないのか知りたくなってきます。
grep を使えばよいのですが、
deathmath1 では8,9行目で複数の語尾を指定しているので、
これをいちいち手打ちするのは面倒くさいし、
いちいち覚えてられません。
コマンドを新たに作るか、 deathmath1 を拡張するか、
どちらかをした方がよいでしょう。

 ここで、「コマンドの作り手」はとても悩みます。
コマンドは単機能にしておく方が、
後から手を入れるときに楽です。肥大化もしません。
これはGancarzのUNIX哲学にもあります。
例えば、 deathmath1 を拡張するとなると、
オプションを新たに設けなければいけませんし、
追加箇所と既存の箇所を分けるために、
関数で分けなければなりません。
つまり、余計な情報を追加しなければなりません。

 一方でコマンドを新たに作るとなると、
deathmath1 の8,9行目にある変数をうまく共有する仕組みが必要です。
しかし今のところ、わざわざ辞書ファイルを外に出すほどのものでもありません。

 筆者の出した答えは次のようなものです。

  • deathmath1 を、行数でなく、当該箇所を出力するように書き直し
  • 行数を数えたかったら、他のコマンドで

 つまるところ、筆者は deathmath1 が「作り過ぎ」だったと判断しました。
せっかく作った deathmath1 を放棄して、手戻りをします。
普通、雑誌にコードをのっけるときはこういう放棄はしないものですが、
こういう考え方でツールを改善する例ですので容赦ください。

 作り直し版では、 grep を使うと中間ファイルを作らざるを得ず面倒なので、
awk で印をつける方式に変更します。
リスト7のようにしました。

  • リスト7: deathmath2
1
2
3
4
5
6
7
8
9
uedamac:SD_GENKOU ueda$ cat ./bin/deathmath2
#!/bin/bash

death="です。|ます。|でしょう。|ません。"
da="だ。|である。|ない。|か。"

gawk '{print FILENAME ":" FNR ":" ,$0}' "$@"    |
gawk -v death=$death -v da=$da \
     '$0~death{print "+",$0}$0~da{print "-",$0}'

 リスト7のコードについて補足説明しておきます。
まず、7行目の "$@" は、
deathmath2 がもらったオプションをそのまま awk
に渡すための方法です。 "$*" だと、
複数のファイル名がオプションに入っている場合、
うまくいきません。
ファイル名がずらずら並んだ文字列を一個のオプションとみなすからです。
リスト8の例では、 "201311.rst 201211.rst"
が一つのファイル名だと解釈されるため、
cat がエラーを出します。
ところで、この方法は面白いことに、ファイル名を指定せずに、
標準入力からの文字列を入力しても動作します。
このときは、 "$@" が空になり、
その場合、 gawk はオプション無しと判断して標準入力を読みに行きます。

 次に、検索で引っ掛ける文字列は4,5行目で、
bashの変数として定義しています。
これを12行目で gawk に引き渡しています。
正規表現を変数に渡しているわけですが、
/ は不要なようです。

 7行目の FNR は行番号が格納された変数ですが、
NR と違って、読み込んだファイルごとの行番号が格納されます。
ですので、この例のように「あるファイルの何行目」
を出力するときに便利です。

  • リスト8: $* でうまくいかない場合
1
2
3
4
5
6
uedamac:SD_GENKOU ueda$ cat ./bin/hoge
#!/bin/bash
cat "$*"
uedamac:SD_GENKOU ueda$ chmod +x ./bin/hoge
uedamac:SD_GENKOU ueda$ ./bin/hoge 201311.rst 201211.rst
cat: 201311.rst 201211.rst: No such file or directory

 使ってみましょう。リスト8のような出力が得られました。
実際に使う場合は、 deathmath2 の出力から
grep "^-" で「だである調」の行を抜き出し、
目で検査することになるでしょう。
もしこれで分からなければ、
ファイル名と行番号が書いてあるので、
当該のファイルを開いて前後の文脈を見ればよいことになります。

 ところで細かいですが、リスト8を見ると「でしょうか。」
が「だである調」に分類されています。
ただ、疑わしいものを抽出するという意味では、
これでもいいでしょう(脚注:納得いかない場合は、
「第一種過誤」と「第二種過誤」で検索を。)。

  • リスト8: deathmath2 を使う
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
uedamac:SD_GENKOU ueda$ cat *.rst | ./bin/deathmath2 | tail -n 3
+ -:13184:      //--dont-suggestを指定すると、候補が出てきません。
+ -:13195: エディタを開かなくてもどこに疑わしい単語があるかチェックできます。
+ -:13197: エディタから独立させておくと、思わぬところで助けられることがあります。
uedamac:SD_GENKOU ueda$ ./bin/deathmath2 *.rst | tail -n 3
+ 201311.rst:338:       //--dont-suggestを指定すると、候補が出てきません。
+ 201311.rst:349: エディタを開かなくてもどこに疑わしい単語があるかチェックできます。
+ 201311.rst:351: エディタから独立させておくと、思わぬところで助けられることがあります。
uedamac:SD_GENKOU ueda$ cat *.rst | ./bin/deathmath2 | awk '{print $1}' | sort | uniq -c
2268 +
  33 -
uedamac:SD_GENKOU ueda$ ./bin/deathmath2 *.rst | grep "^-" | head -n 3
- 201202.rst:562: 寒さに負けず端末を叩いておられますでしょうか。
- 201202.rst:565: ドア用の close コマンドがないものか。
- 201202.rst:607: * プログラマの時間は貴重である。(略)

 リスト8のように、 deathmath2 の出力を awk, sort, uniq で加工すると、
deathmath1 のような答えが得られます。
deathmath1 の方が、
コマンド一発で数を数えてくれるので一見よさそうですが、

  • コマンドが二つに分かれると使う側として覚えるのが面倒
  • コマンドのコードが汚くなるのは作る側が面倒
  • そもそも数は最初に述べたように不正確

ということで、筆者は deathmath2 の方がよいかなと考えます。
UNIXのコマンドを作ったときの善し悪しは、
他の主要なコマンドとの連携の上で決定されます。

23.4. 英単語をチェックする

 次に、英単語のスペルチェックを行うスクリプトを作ってみましょう。
スペルチェッカーは通常、エディタから読み出して使いますが、
ここではコマンド仕立てにします。
作ると言っても、単にラッパーを作るだけですからご安心を。

 まず、スペルチェッカーをインストールします。
一昔前、筆者の周辺の人はIspell
というスペルチェッカーを使っていましたが、
今はGNU Aspellというツールを用いるようです。
MacだとHomebrewでリスト9のようにインストールできました。

  • リスト9: Aspell のインストール
1
uedamac:SD_GENKOU ueda$ brew install aspell

 シェルスクリプトからAspellを使いたいので、
対話形式ではなく、フィルタとして(= 標準入出力だけで)使えるかどうか調べます。
man で調べると、リスト10のような記述と、他にパイプについての記述が見つかりました。
どうやら -a を指定するとフィルタとして使えるようです。

  • リスト10: man でオプションを調査
1
2
3
4
uedamac:SD_GENKOU ueda$ man aspell
(略)
pipe, -a
        Run Aspell in ispell -a compatibility mode.

 試しに使ってみましょう。リスト11のように、環境変数 LANG を、
デフォルトの C にしないと動きません。

  • リスト11: Aspell をフィルタモードで使う
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
uedamac:SD_GENKOU ueda$ echo "All your base are berong to us." | LANG=C aspell -a
@(#) International Ispell Version 3.1.20 (but really Aspell 0.60.6.1)
(略)
*
& berong 25 18: Bering, bronc, belong, Behring, bearing, (略)
//--dont-suggestを指定すると、候補が出てきません。
uedamac:SD_GENKOU ueda$ echo "All your base are berong to us." | LANG=C aspell -a --dont-suggest
@(#) International Ispell Version 3.1.20 (but really Aspell 0.60.6.1)
(略)
*
# berong 18
(略)

 さて、これを使って、疑わしき単語のある行を行番号付きで
出力するラッパーのシェルスクリプトを書いてみましょう。
これができれば、とりあえず、
エディタを開かなくてもどこに疑わしい単語があるかチェックできます。
結局、エディタで開いて修正しなければならないかもしれませんが、
エディタから独立させておくと、思わぬところで助けられることがあります。

 まず、補助的なコマンドとして、
疑わしいスペルのリストを表示するコマンドをリスト12のように作ります。
これはこれで独立で使えます。
aspellはバッククォートなどの記号類にも反応する事があり、
また、日本語が入ると何が起こるか分かったもんじゃないので、
4行目の sed で、単語に使う文字だけ残してあとは空白に変換しています。

  • リスト12: 疑わしいスペル抽出コマンド
1
2
3
4
5
6
7
uedamac:SD_GENKOU ueda$ cat ./bin/henspell-list
#!/bin/bash

sed "s/[^a-zA-Z0-9']/ /g" "$@"  |
LANG=C aspell -a --dont-suggest |
awk '/^#/{print $2}'            |
sort -u

 使ってみましょう。リスト13のようにまともな単語も引っかかりますが、
これはAspellの辞書にこれらの単語を登録することで、
出なくなります。

  • リスト13: henspell-list を使う
1
2
3
4
5
6
uedamac:SD_GENKOU ueda$ ./bin/henspell-list 201311.rst
FILENAME
FNR
()
berong
()

 辞書ファイルいろいろ種類があるようですが、
とりあえずリスト14のように、1行目におまじないを書いて、
あとは引っかかった正しい単語をひたすら書いていくと作れます。

  • リスト14: Aspell の辞書ファイル
1
2
3
4
5
6
7
uedamac:SD_GENKOU ueda$ head -n 5 ./bin/dict
personal_ws-1.1 en 0
FILENAME
FNR
GENKOU
Gancarz
(略)

 これを henspell-list に読み込ませるとよいということになります。
パスの指定が面倒ですが、コマンドのパスと一緒の所に置くなら、
リスト15のように dirname というコマンドを使って指定できます。

  • リスト15: henspell-list を改良して使ってみる。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
uedamac:SD_GENKOU ueda$ cat ./bin/henspell-list
#!/bin/bash

dict=$(dirname $0)/dict

sed "s/[^a-zA-Z0-9']/ /g" "$@"                  |
LANG=C aspell -p "$dict" -a --dont-suggest      |
awk '/^#/{print $2}'                            |
sort -u
//使う
uedamac:SD_GENKOU ueda$ ./bin/henspell-list 201311.rst
berong
da
deathmeth
dirname
zA

 次に、このコードを利用して、
もとの原稿のどこに変なスペルがありそうなのかを表示します。
リスト16に作成したコマンドを示します。
このコードの場合は、標準入力から文字列を入力する場合と、
ファイル名をオプションで指定する場合について、
場合分けをせざるを得ませんでした。
grep のオプションですが、
-w は、単語の検索を行う(脚注:つまり検索語がmarchでも、
部分一致のdeathmarchは引っかからないということです。)、
-n は行番号を入れる、
-f <FILE> が検索対象の文字列を FILE
から読み込む、です。

  • リスト16: henspell
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
uedamac:SD_GENKOU ueda$ cat ./bin/henspell
#!/bin/bash

tmp=/tmp/$$

if [ "$#" -eq 0 ] ; then
        cat < /dev/stdin                |
        tee $tmp-stdin                  |
        $(dirname $0)/henspell-list > $tmp-list
        grep -w -n -f $tmp-list < $tmp-stdin
else
        $(dirname $0)/henspell-list "$@" > $tmp-list
        grep -w -n -f $tmp-list "$@"
fi

rm $tmp-*
exit 0

 利用するときはリスト17のように less で受けて、
本当にスペルミスがないか探す事になるでしょう。
リスト中のミスは、「仕込み」です。

  • リスト17: henspell を使う
1
2
3
4
5
6
uedamac:SD_GENKOU ueda$ ./bin/henspell 201311.rst | less
14:Macには、GNU sed( ``gdes`` )がインストールされているものとします。
87:     da="(だ。|である。|ない。|か。)"
...
217:``deathmarch2`` がもらったオプションをそのまま ``awk``
...

23.5. おわりに

 今回は、シェルスクリプトで文章チェックのためのコマンドを作ってみました。
文章の仕事というのは、そのときそのときで特殊な作業が必要になることが多いので、
今回のようにシェルスクリプトでコマンドを作ることを覚えると、
1日かけていた作業が数秒で終わるという幸運なことに何回か巡り会うことができます。
シェルスクリプトでコマンドを作ると他のコマンドも呼び出せますから、
この方法はオススメです。

 一方、今回のようにコマンドを自作しても、
後日使い回すことになることはあまり無いかもしれません。
grep の使い方は忘れることはないでしょうが、
ニッチな自作コマンドなど、すぐに使い方を忘れてしまうものです。

 それはそれでいいと思います。
もし100個自作して、1個お気に入りのコマンドになれば、
そのコマンドは何年にもわたって永続的に力になるわけですから、
たとえ生存率1/100であっても、御利益はあるのです。

 次回はcrontabの使い方を扱います。

Pocket
LINEで送る