開眼シェルスクリプト2012年12月号

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

12. 開眼シェルスクリプト 第12回メールを操る

12.1. はじめに

 開眼シェルスクリプトも今回で12回目、つまり1年になります。
ネタが続くのか周囲は心配してましたが、まだまだ大丈夫そうです。

 編集サマの当初の企画意図を引っ張り出してみると、

「さまざまなLight Languageが流行っていますが、ちょっとした処理なら
プログラミングするまでもなくシェルスクリプトだけで実現できるものも
あります。(中略)身近なシェルを使いこなして、
もっと業務に生かそうというのが趣旨。」

とあります。そうです。シェルスクリプトを使うと、
ちょっとした処理を普段のシェル操作の延長線上で
さっさと片付けてしまうことができます。
ちょっとした処理なら身の回りにたくさんありますので、
今後も身の回りのことを次々に取り上げて、
皆様をCUIから離れられないようにしたいと思います。

 今回は電子メールを扱います。
Maildirに溜まったメールを仕分けたり、
中から文章などを切り出したりということをやってみます。

12.2. 環境等

 今回は、CentOS 6.3、Ubuntu 12.04 で動作確認しました。
FreeBSD でも動かそうとしたのですが、
dateコマンドにフィルタモードが見当たりませんでした。
while文で逃げるか、
標準入力からUNIX時間を受けて日付に変換するコマンドの自作をお願いします。
pythonで書いた例をリスト1に示しておきます。
(脚注:無理にシェルでやってもいいのですが、
コマンドは1プロセスで動かないとなにかと面倒です。)

・リスト1: UNIX時間を日付に変換するpythonスクリプト

$ cat epoc2date
#!/usr/bin/python

import sys
import time

for line in sys.stdin:
        unixtime = line.rstrip()
        t = time.gmtime(int(unixtime))
        print "%d %d %d"  % (t.tm_year, t.tm_mon, t.tm_mday)
$ date +%s | ./epoc2date
2012 9 21

12.3. Mairdir

 筆者は、とあるサーバの自分のアカウントに、
自分が管理しているサーバからの自動送信メールを貯めています。
メールをホームに置いているのはこのサーバのpostfixです。

 このpostfixの設定ファイルで、
ホームにメールを置くときの形式をMaildirにしています。
ヘビーにUNIXを使っている人はご存知だと思いますが、
Maildirは、メールを置いておくときの方法の一つです。
Maildir形式では、電子メール一通が一つのファイルになります。

 念のため、postfixの設定ファイルの一部をリスト2に示しておきます。
main.cfhome_mailbox の値を Maildir/
にしておくと、Maildirになります。
デフォルトでは、 Mailbox になっているのですが、
これだと複数のメールが一つの mbox
というファイルに固まって置かれてしまっていろいろとたちの悪いことになります。

・リスト2: main.cfの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ vi /etc/postfix/main.cf
...
# DELIVERY TO MAILBOX
#
# The home_mailbox parameter specifies the optional pathname of a
# mailbox file relative to a user's home directory. The default
# mailbox file is /var/spool/mail/user or /var/mail/user.  Specify
# "Maildir/" for qmail-style delivery (the / is required).
#
#home_mailbox = Mailbox         <- mboxを使う
home_mailbox = Maildir/         <- Maildirを使う
...

 Maildir形式を選ぶと、ホーム下には Maildir
というディレクトリができます。
今までのpostfixの話がちんぷんかんぷんでも、
ホームの下に Maildir がいたら、
今回の方法はいろいろ試すことができることでしょう。
深いことを知らなくても目の前にテキストが
あったらやっちまえというスタンスでいきましょう。
そうでないと、
理屈ばっかりで肝心のコンピューティングがつまらなくなります。
道具は使ってナンボのモンです。

  ~/Maildir の下には、リスト3のように
cur, new, tmp という三つのディレクトリがあります。

・リスト3: Maildirディレクトリの下

1
2
$ ls ~/Maildir
cur  new  tmp

 メーラーで見たファイルはcur、新しいメールはnewに入るようですが、
私はメーラーなどというナンパなものを使ってないので、
全部newに入ったままです(脚注:実際はgmailのヘビーユーザーですごめんなさい。)。
リスト4に、 new の下の様子を示します。

・リスト4: メールファイル

1
2
3
4
5
6
7
$ ls new/ | head -n 3
1339304183.Vfc03I46017dM943925.sakura1
1339305265.Vfc03I46062cM458553.sakura1
1339306807.Vfc03I4607c6M993984.sakura1
#2万5千件程度入ってます。
$ ls ~/Maildir/new/ | wc -l
25094

 実際問題、このメールアカウントに溜まっているのはログばっかりなので、
これを全部メーラーに入れてしまって一つずつ見るのは疲れます。
また、メーラーでいろいろ設定して振り分けるのも、
メーラーの癖や制限があって大変です。
結局見なくなるので、なにか有用な統計と取ったほうがよいでしょう。
こんなときにシェルスクリプトです。奥さん。

12.4. ファイル名を眺める

 もう少し観察してみましょう。
ファイル名は、重複しないように一意になるように工夫されているようです。
リスト5のファイル名をまじまじと見ると、
postfixの置くファイルには、先頭に時刻が入っているようです。

・リスト5: メールのファイル名

1
1339304183.Vfc03I46017dM943925.sakura1

この「1339304183」は、「UNIX時間」というやつで、
1970年1月1日0時0分0秒からの積算秒数を表しています。
Linuxのdateコマンドなら、こんなふうに変換できます。

・リスト6: UNIX時間の変換

1
2
$ date -d @1339304183
2012年  6月 10日 日曜日 13:56:23 JST

 ちなみに、dateコマンドには-fというオプションがあって、
以下のような使い方ができます。これは便利です。

・リスト7: dateのフィルタモード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#使ったバージョンはこれ。
$ date --version
date (GNU coreutils) 8.13
(略)
$ head -n 3 datefile
@1339304183
@1339305265
@1339306807
$ head -n 3 datefile | date -f -
2012年  6月 10日 日曜日 13:56:23 JST
2012年  6月 10日 日曜日 14:14:25 JST
2012年  6月 10日 日曜日 14:40:07 JST
$ head -n 3 datefile | date -f - "+%Y%m%d %H%M%S"
20120610 135623
20120610 141425
20120610 144007

12.5. まずは振り分けてみる(whileを使わずに)

 まず肩慣らし程度にメールを日付で振り分けてみます。
こうしておけば、例えばある日からある日までのメールを処理したいときに、
いちいちUNIX時間を変換しなくてもよくなります。

 ホーム下に MAIL というディレクトリを作って、
その下に日別のディレクトリを自動で作り、その下にメールをコピーします。

 ただ、肩慣らしと言っても一筋縄ではいかないのがこの連載。
最近、while使うなとあまり言ってませんが、
忘れたわけではありません。whileは避けるべきです。
ここでもwhileを抜く効用を示してみます。

 まずはベタにリスト8のように書いてみます。
ファイルを一個ずつ日付のディレクトリに放り込んでいきます。

・リスト8: DISTRIBUTE_BY_DATE.betabeta (ベタベタな例)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#ベタベタバージョン
$ cat DISTRIBUTE_BY_DATE.betabeta
#!/bin/bash

sdir=/home/ueda/Maildir/new
ddir=/home/ueda/MAIL

tmp=/home/ueda/tmp/$$

cd $sdir || exit 1

######################################
#ファイルのリストを作る
echo *.*.*                                      |
tr ' ' '\n'                                     |
while read f ; do
        UNIXTIME="@"$(echo $f | awk -F. '{print $1}')
        DATE=$(date -d $UNIXTIME "+%Y%m%d")

        [ -e "$ddir/$DATE" ] || mkdir $ddir/$DATE
        cp -p $f $ddir/$DATE/
done

13行目のechoは、ファイル名を空白区切りで出力してくれます。
いつもlsを使っている人は、適当なディレクトリで echo *
と打ってみてください。ファイルの一覧が取得できます。
ファイル名が取得できたら、trで空白を改行に変換し、
一つ一つ while で読んで処理していきます。

 上のスクリプトは何のソツもありません。
まあ、世の99%の人がこのように書くと思います。
しかし、あえて言います。

失格!!!!

です。

 サーバで試してみます。結果はリスト9のようになりました。

・リスト9: DISTRIBUTE_BY_DATE.betabeta は時間がかかる。

1
2
3
4
5
$ time ./DISTRIBUTE_BY_DATE.betabeta

real    7m21.673s
user    1m24.858s
sys     5m51.464s

筆者が書いた失格でないスクリプトについて、
お見せする前に実行時間を測ってみましょう。
リスト10のようになりました。

・リスト10: これくらいは高速化できる。

1
2
3
4
5
$ time ./DISTRIBUTE_BY_DATE

real    0m43.866s
user    0m16.774s
sys     0m43.599s

というように、10倍以上の差がついてしまいます。
今回みたいに何万もファイルを扱うときは、
この差は大きくなります。

 ではどう書いたかというのを次に見せます。
ちょっと長くなってしまったので良し悪しですが・・・
(じゃあ失格とか言うな)。

・リスト11: DISTRIBUTE_BY_DATE

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
$ cat DISTRIBUTE_BY_DATE
#!/bin/bash

sdir=/home/ueda/Maildir/new
ddir=/home/ueda/MAIL
tmp=/home/ueda/tmp/$$

cd $sdir || exit 1

######################################
#ファイルのリストを作る
echo *.*.*                      |
tr ' ' '\n'                     |
#1:ファイル名
awk -F. '{print "@" $1,$0}'     > $tmp-files
#1:UNIX時間 2:ファイル名

# $tmp-filesの例:
#@1348117807 1348117807.Vfc03I4670eaM254446.www5276ue.sakura.ne.jp

######################################
#ファイルのリストに年月日をくっつける
self 1 $tmp-files       |
date -f - "+%Y%m%d"     |
#1:年月日
ycat - $tmp-files       |
#1:年月日 2:UNIX時間 3:ファイル名
delf 2 > $tmp-ymd-file
#1:年月日 2:ファイル名

# $tmp-ymd-fileの例
#20120920 1348116008.Vfc03I4670ecM186337.www5276ue.sakura.ne.jp

cd $ddir || exit 1

######################################
#日別のディレクトリを作る
self 1 $tmp-ymd-file    |
uniq                    |
xargs -P 0 -i_ mkdir -p _

cat $tmp-ymd-file       |
awk -v sd="$sdir" '{print sd "/" $2, "./" $1 "/"}'      |
#コピー元、コピー先を読み込んでcpに渡す。
xargs -P 0 -n 2 cp -p

rm -f $tmp-*
exit 0

このスクリプトでは、一個一個ファイルを処理するのではなく、
作るディレクトリのリストとコピーするファイルのリストを作成し、
xargsで一気に作っています。
速いのはxargsそのものの速さの寄与も大きいのですが、
dateとawkをwhileで何回も呼ぶ必要がなくなっていることも原因です。

 速い方のスクリプトを詳しく見ていきましょう。
まず、15行目の処理が終わって出力される
$tmp-files は、19行目の例のようなレコードが縦に並んだファイルです。
そして、23行目でUNIX時間だけとってきて、24行目のdateコマンドに流し込んでいます。
ここで、ファイルごとにdateを読むのではなく、
全ファイルに対して一回だけしかdateを読まなくてよくなります。

 26行目のycatは、Open usp Tukubai のコマンドです。
「横キャット」と発音します。横にファイルをくっつけます。
例をリスト12のように示します。Open usp Tukubaiの詳細は、
UEC( https://uec.usp-lab.com )のサイトでご確認を。

・リスト12: ycat の使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ cat file1
1
2
3
$ cat file2
a
b
c
$ ycat file{1,2}
1 a
2 b
3 c

これで、dateで作った日付が、もとの $tmp-files のレコードにくっつきます。
32行目に、 $tmp-ymd-file のレコードを抜き取った例がありますが、
この時点で、日付とファイル名という、処理に必要なデータが揃います。

 後は、日付のディレクトリを作り、
その中にファイルをコピーしていきます。
xargsについては、1,5,9月号に出てきました。
まず、40行目の

1
xargs -P 0 mkdir -p

は、入力から日付を次々に受け取って、
mkdirコマンドを実行していきます。
mkdirの-pオプションは、
すでにディレクトリがあってもエラーにならないように指定しています。
xargsの -P 0 ですが、
これは、xargsで指定したコマンドを、
できるだけ多くのプロセスで実行するという意味になります。
manには「できるだけ」としか書いていないのが気になりますが、
並列化してくれるようです。
ここでは「できるだけ」にツッコミは入れず、
-P 0 の有無で結果だけリスト13に示します。
筆者の環境では、有意な差が出ています。

 後日談:何回も実験しているうちに、
一回だけものすごい数のcpが立ち上がって自分のノートPCが暴走しました・・・。
めったに起こらないのですが、せいぜい -P 100 くらいにしておいてください。
今のマシンやカーネルは頑丈なので、100プロセスぐらいなら何の問題もありません。

・リスト13: -P オプション有無での時間比較

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ time ./DISTRIBUTE_BY_DATE.nop

real    2m58.583s
user    0m27.764s
sys     2m2.736s
$ rm -Rf 2*
$ time ./DISTRIBUTE_BY_DATE

real    0m41.221s
user    0m16.201s
sys     0m40.139s

 45行目のxargsは、さらに複雑なことをしています。
このxargsには、次のようなテキストが流れ込みます。

・リスト14: xargsに入力するテキスト

1
2
3
4
/home/ueda/Maildir/new/1339308608.Vfc03I4609ebM178619.sakura1 ./20120610/
/home/ueda/Maildir/new/1339308909.Vfc03I4609ecM601364.sakura1 ./20120610/
/home/ueda/Maildir/new/1339309208.Vfc03I4609edM55303.sakura1 ./20120610/
...

つまり、コピー元のファイルとコピー先のディレクトリがxargsに流れ込みます。
xargsには、 -n 2 というオプションがついていますが、
これは、「二個ずつ文字列を読み込む」という意味になります。
つまり、空白・改行で区切られた文字列を二つ取ってきては、
cp -p の後ろのオプションとして cp を実行します。

 ちなみに、 cp -p-p は、
ファイルの時刻や持ち主などをなるべく変えずにコピーしたいときに使います。

 このスクリプトの説明はこの辺にしておきます。
大事なことは、このようなファイルやシステムの操作を繰り返すときは、
大きなwhileループを書かず、
リスト14のように、もうすでにやりたいことが書いてある状態のテキストを作っておいて、
後から一気に処理すると速度の点やデバッグの点で有利になることが多いということです。
特に今回のようにコピーなどの具体的なファイル移動が絡むと、
スクリプトを書いて動作確認して・・・という作業が面倒になります。

  ./DISTRIBUTE_BY_DATE を実行して、リスト15のように、
MAIL ディレクトリの下に日付のディレクトリができ、
各日付のディレクトリ下にメールのファイルが配られていることを確認しましょう。

・リスト15: 実行結果

1
2
3
4
5
6
7
$ ls
20120610  20120624  20120708  20120722  20120805  20120819  20120902  20120916
20120611  20120625  20120709  20120723  20120806  20120820  20120903  20120917
$ ls 20120920 | head -n 3
1348066810.Vfc03I467066M309422.www5276ue.sakura.ne.jp
1348067409.Vfc03I467067M503001.www5276ue.sakura.ne.jp
1348068009.Vfc03I467068M641721.www5276ue.sakura.ne.jp

12.6. UTF-8にする

 振り分けたら今度はエンコードの問題に取り組みましょう。
メールのヘッダは、ISO-2022-JPやらBエンコードやらQエンコードやら、
普通に暮らしていれば一生触れることもないものであふれています。
リスト16のhogeファイルは、あるメールのヘッダを抜粋したものです。
「To:」のところがわけがわからなくなっています。
さらに困ったことに、「To:」のところとメールアドレスが違う行に渡っていて、
grepしてもメールアドレスが取れません。

・リスト16: 難しいエンコーディングが施されたメール

1
2
3
4
5
$ cat hoge
From: Ryuichi UEDA <r-ueda@usp-lab.com>
To: =?ISO-2022-JP?B?GyRCJCokKiQqJCokKiQqJCokKiEqMjYkTyEmISYhJkMvJEAhQSFBIUEbKEI=?=
        =?ISO-2022-JP?B?GyRCIUEhKSEpISkbKEI=?= <watashiha@dare.com>
Content-Type: text/plain; charset=ISO-2022-JP

 しかし我々にはnkfという味方がいます。我々は何も知らなくても、
リスト17のようにnkfに突っ込んでUTF-8にすればよいのです。

・リスト17: nkfで変換

1
2
3
4
$ nkf -w hoge
From: Ryuichi UEDA <r-ueda@usp-lab.com>
To: おおおおおおおお!俺は・・・誰だ〜〜〜〜??? <watashiha@dare.com>
Content-Type: text/plain; charset=ISO-2022-JP

ちゃんと日本語になって、余計な改行も取れてます。
(Toに複数のアドレスがあったら、改行されてしまうので、
これは自分で補正しなければなりませんが。)
ということで、とりあえずメールはnkfに突っ込んで変換して置いておけば、
あとの処理が楽になります。

 とは言っても、もしかしたら変換前のメールも必要になるかもしれません。
DISTRIBUTE_BY_DATE にコードを追加し、
日付のディレクトリに、UTF-8化する前のメールと後のメール、
両方置いておくことにしましょう。今回はこれでおしまいです。

  DISTRIBUTE_BY_DATErm -f $tmp-* の前に、
リスト18のコードを加えます。
もう一回、メールを <日付>.utf8
というディレクトリにコピーして、
その中のメールに xargsで一気にnkfを適用しています。
nkfには、 --overwrite を指定して、
もとのファイルを上書きするようにしました。
xargsを使っているのでリダイレクトができないからです。
(もしかしたらリダイレクトする方法もあるかもしれません。)

・リスト18: UTF-8で変換したメールを保存するスクリプト片

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
######################################
#UTF-8に変換

#日別のディレクトリを作る
self 1 $tmp-ymd-file            |
uniq                            |
awk '{print $1 ".utf8"}'        |
xargs -P 100 mkdir -p

#コピー
cat $tmp-ymd-file                                       |
awk -v sd="$sdir" '{print sd "/" $2, "./" $1 ".utf8/"}' |
xargs -P 100 -n 2 cp -p

#変換
echo ./*.utf8/*                 |
tr ' ' '\n'                     |
xargs -n 1 nkf -w --overwrite

 これでもう一度 DISTRIBUTE_BY_DATE を実行してみると、
MAIL 下にリスト19のようにディレクトリができます。

・リスト19: ディレクトリの確認

1
2
3
4
$ ls
20120610       20120623.utf8  20120707       20120720.utf8 ...
20120610.utf8  20120624       20120707.utf8  20120721 ..
...

 grepして、違いをみてみましょう。
リスト20のようになっていれば成功です。

・リスト20: UTF-8への変換を確認

1
2
3
4
5
6
$ grep "^From:" ????????/* | head -n 2
20120610/1339304183.Vfc03I46017dM943925.sakura1:From: Ryuichi UEDA <r-ueda@usp-lab.com>
20120610/1339305265.Vfc03I46062cM458553.sakura1:From: =?ISO-2022-JP?B?R21haWwgGyRCJUEhPCVgGyhC?= <mail-noreply@google.com>
$ grep "^From:" ????????.utf8/* | head -n 2
20120610.utf8/1339304183.Vfc03I46017dM943925.sakura1:From: Ryuichi UEDA <r-ueda@usp-lab.com>
20120610.utf8/1339305265.Vfc03I46062cM458553.sakura1:From: Gmail チーム <mail-noreply@google.com>

12.7. おわりに

 今回は、Maildirにたまったメールの操作を扱いました。
久しぶりに書き方にこだわり、whileなしでシェルスクリプトを仕上げました。
ただ単に見かけの問題からwhileを嫌うだけでなく、

  • コマンドを呼ぶ回数を減らす
  • xargsで並列化

など、効用も得られることも示しました。

 メールには、エンコードや添付、
不定形文の処理などいろいろテーマがありそうです。
次回以降もいろいろいじくってみたいと思います。

Pocket
LINEで送る