「第36回シェル芸勉強会」にリモート参加しました

2018年7月7日に「第36回シェル芸勉強会 大阪サテライト」が開催される予定でしたが、西日本を中心とした大雨に対して参加者の安全を確保するため中止となりました。

そして、参加予定者は自分も含め各自「jus共催 第36回七夕・・・7は素数じゃないですか(しかも2つ)シェル芸勉強会」にリモート参加することになりました。

なお、同様に「第36回シェル芸勉強会:福岡サテライト」においても中止の発表がなされました。

――この場を借りまして、被災された方々にお見舞申し上げます。


午前の部:

シェルで文字コードに触れてみる

  • 講師:
    • 鳥海秀一(@hid_tori)さん(USP友の会)

「初心者が勉強すべきなのは基本」ということで、シェル芸がその処理のメインターゲットとするテキストデータについて、技術的基盤となっている文字コードに関する講義が行われました。

今回は、言語および国に特有の表記方法(日時・通貨など)を指定するロケールと、文字コードや数値を変換するコマンド群について学びました。

たとえば、我が国のロケール(ja_JP.utf8)における日時の表記の設定を調べてみると、

$ locale -k LC_TIME
abday="日;月;火;水;木;金;土"
day="日曜日;月曜日;火曜日;水曜日;木曜日;金曜日;土曜日"
abmon=" 1月; 2月; 3月; 4月; 5月; 6月; 7月; 8月; 9月;10月;11月;12月"
mon="1月;2月;3月;4月;5月;6月;7月;8月;9月;10月;11月;12月"
am_pm="午前;午後"
d_t_fmt="%Y年%m月%d日 %H時%M分%S秒"
d_fmt="%Y年%m月%d日"
t_fmt="%H時%M分%S秒"
t_fmt_ampm="%p%I時%M分%S秒"
era="+:2:1990/01/01:+*:平成:%EC%Ey年";"+:1:1989/01/08:1989/12/31:平成:%EC元年";"+:2:1927/01/01:1989/01/07:昭和:%EC%Ey年";"+:1:1926/12/25:1926/12/31:昭和:%EC元年";"+:2:1913/01/01:1926/12/24:大正:%EC%Ey年";"+:2:1912/07/30:1912/12/31:大正:%EC元年";"+:6:1873/01/01:1912/07/29:明治:%EC%Ey年";"+:1:0001/01/01:1872/12/31:西暦:%EC%Ey年";"+:1:-0001/12/31:-*:紀元前:%EC%Ey年"
era_year=""
era_d_fmt="%EY%m月%d日"
alt_digits="〇";"一";"二";"三";"四";"五";"六";"七";"八";"九";"十";"十一";"十二";"十三";"十四";"十五";"十六";"十七";"十八";"十九";"二十";"二十一";"二十二";"二 十三";"二十四";"二十五";"二十六";"二十七";"二十八";"二十九";"三十";"三十一";"三十二";"三十三";"三十四";"三十五";"三十六";"三十七";"三十八";"三十九";"四十";"四十一";"四十二";"四十三";"四十四";"四十五";"四十六";"四十七";"四十八";"四十九";"五十";"五十一";"五十二";"五十三";"五十四";"五十五";"五十六";"五十七";"五十八";"五十九";"六十";"六十一";"六十二";"六十三";"六十四";"六十五";"六十六";"六十七";"六十八";"六十九";"七十";"七十一";"七十二";"七十三";"七十四";"七十五";"七十六";"七十七";"七十八";"七十九";"八十";"八十一";"八十二";"八十三";"八十四";"八十五";"八十六";"八十七";"八十八";"八十九";"九十";"九十一";"九十二";"九十三";"九十四";"九十五";"九十六";"九十七";"九十八";"九十九"
era_d_t_fmt="%EY%m月%d日 %H時%M分%S秒"
era_t_fmt=""
time-era-num-entries=9
time-era-entries="+"
week-ndays=7
week-1stday=19971130
week-1stweek=1
first_weekday=1
first_workday=2
cal_direction=1
timezone=""
date_fmt="%Y年 %b %e日 %A %H:%M:%S %Z"
time-codeset="UTF-8"

のようになっており、元号を含め様々な設定が施されていることが分かりました。

そして、変換コマンドについては、

  • 文字コードの変換:
    • iconv
      • iconv -f <変換元の文字コード名> -t <変換先の文字コード名>
  • 文字から数値に変換:
    • od
    • hexdump
    • xxd
    • printf
      • \'<文字>※数値が出力される際の基数は「基数の変換」を参照
  • 数値から文字に変換:
    • xxd
      • xxd -r: 16進数から変換
    • printf
      • \nnn: 8進数から変換
      • \xnn: 16進数から変換
  • 基数の変換
    • $((<基数>#<その基数における数>))
    • bc
      • obase=<出力時の基数>
      • ibase=<入力時の基数>
    • printf
      • %d: 10進数で出力
      • %o: 8進数で出力
      • %x: 16進数で出力

などがあることを知り、自分は今まで変換コマンドといえばnkfxxd程度しか使っていなかったので非常に参考になりました。

次回も引き続き文字コードに関する講義を行われるということで、さらに濃くなるであろう講義内容に期待が止みません。


午後の部:

A1:

$ cat welcome.txt | perl -nlE 'tr/\x00\x5f/* /;say' | grep -Po '.{70}'
                                                *  * *                
 **                           ******       **    *****  *             
  *              *              **         ** *****      *    ***     
  *             *****         *******      *      *      *  *    *    
  *     *        ***         *  **  *      ***   **      ***    **    
   *****       **  ***         *****        *   **         *          
                                                                      
                                                                      
                                 *                          ***       
       * *        **          ******                        ****      
  ****            **          *******          *          **    *     
**    **         ***            *****          ***              *     
        ***     **  *   *     **   **        ****              *      
               **   ****       *****        **** **          **       
                                                                      
                                                                      
                                                                      
   **   *     *                                                       
 ******* **    *    ***                                               
    *  * **    *  *    *                                              
   *   *       ***    **                                              
  *  ***         *                                                    
                                                                      
                                                                      

ファイルwelcome.txtには、ヌル文字(\x00)と「_」(\x5f)が含まれています。これらをPerlのtr()関数で適宜置換した後、grepを使って1行につき70文字で折り返します。

perlを使わない別解としてはtrを使ったcat welcome.txt | tr '\0_' '* ' | fold -w 70もあります。

ちなみに、welcome.txtの作り方はtoilet 'しぇるげいべんきょうかい' | tr ' ' '_' | tr -d '\n' | tr -c '_' '\0' > welcome.txtとなります(当日、解答するのを忘れていました)。

toiletでバナーを生成した後にtrを3回使っていますが、操作の内容は

  1. 「 」を「_」に置換
  2. 改行文字を削除
  3. 「_」以外の文字をヌル文字に置換

となっています。

A2:

$ ls
1-B.doc  3年D組.doc   4年a組.doc  1ーC.doc  1年E組.doc  3年B組.doc  4年C組.doc
1A.doc   3年C組.doc  5年A組.doc  1ーD.doc  3年A組.doc  4年B組.doc
$ paste <(ls) <(ls | nkf -Z | sed -r 's/([1-9])(-|ー)?([A-Z])\./\1年\3組./;s/([a-z])(組)/\u\1\2/') | xargs -n2 mv
mv: '3年D組.doc''3年D組.doc' は同じファイルです
mv: '5年A組.doc''5年A組.doc' は同じファイルです
$ ls
1年A組.doc  1年C組.doc  1年E組.doc  3年B組.doc  3年D組.doc  4年B組.doc  5年A組.doc
1年B組.doc  1年D組.doc  3年A組.doc  3年C組.doc  4年A組.doc  4年C組.doc

paste <元のファイル名のリスト> <新しいファイル名のリスト>の出力からxargs -n2で使う2つの引数をmvに与えれば良いのですが、この新しいファイル名をどうやって作るかが問題です。

ここではnkf -Zで全角の英数字を半角に変換し、さらにsedの正規表現で文字列を地道に整形していくという方法を採っています。

A3:

$ dateutils.dseq 2018-01-01 2018-12-31 | perl -nlE '$cnt=0;$cnt++ while m/[2357]/g;say "$_ $cnt"' | awk '$2==4{print $1}' | xargs -I@ date -d @ | awk '{print $1,$2,$3}'
2018年 2月 22日
2018年 2月 23日
2018年 2月 25日
2018年 2月 27日
2018年 3月 22日
2018年 3月 23日
2018年 3月 25日
2018年 3月 27日
2018年 5月 22日
2018年 5月 23日
2018年 5月 25日
2018年 5月 27日
2018年 7月 22日
2018年 7月 23日
2018年 7月 25日
2018年 7月 27日
2018年 12月 22日
2018年 12月 23日
2018年 12月 25日
2018年 12月 27日

特定の期間に含まれる全ての日付を得る場合、dateutils.dseqを使うと非常に便利です。

このdateutils.dseqで2018年の全ての日付を得た後、perlで日付の中に「2」・「3」・「5」・「7」が含まれている場合はその日付とこれらの数字の出現回数を出力し、さらにawkでその出現回数が4になっているものを抽出します。

そして、xargs以降では、出題された条件に当てはまる日付を解答用の出力にふさわしい形式に整形しています。なお、この部分は解答に必須ではありません。

A4(宿題):

※フォントの環境設定によっては、短冊の形が歪んで見える場合があります。

$ paste <(printf ' あさがおに  \n つるべとられて\n もらいみず  \n' | sed 's/./& /g;s/ $//' | tac | rs -T) <(cat tanzaku | sed 's/./& /g') | awk '{if(NR>=2&&NR<=8){print $4,"_",$1," ",$2," ",$3,"_",$NF}else{print}}' | sed '1s/^[ \ ]\+//;1s/ /_/;s/[ \t]//g;s/_/ /g'
┏ ーー-┷-ーー┓
┃ も つ あ ┃
┃ ら る さ ┃
┃ い べ が ┃
┃ み と お ┃
┃ ず ら に ┃
┃   れ   ┃
┃   て   ┃
┗ーーーーーー┛

paste <縦書きに整形した俳句の文字列> <短冊のアスキーアート>の後は、awksedを駆使して短冊の枠内に俳句の文字を力技ではめ込んでいきます。

なお、俳句の文字列を縦書きに整形している箇所は次のようになっています。

$ printf ' あさがおに  \n つるべとられて\n もらいみず  \n' | sed 's/./& /g;s/ $//' | tac | rs -T
       
も  つ  あ
ら  る  さ
い  べ  が
み  と  お
ず  ら  に
   れ   
   て   

最後で登場しているrs -Tですが、これを使うと「 」で区切られているテキストデータの行と列を簡単に入れ換えることができます。

$ echo -e 'A B C\n1 2 3\nx y z'
A B C
1 2 3
x y z
$ echo -e 'A B C\n1 2 3\nx y z' | rs -T
A  1  x
B  2  y
C  3  z

A5(訂正):

当日の解答では吹き出しの位置の調整を忘れていたため、最後にsed '1,3s/^/ /;1,3s/ \+$//;s/^ //'を付け加えて吹き出しおよび各行の表示位置の修正を施しました。

$ cowsay あなたとJava今すぐダウンロード | perl -nlE 'say $_," "x30' | sed -r 's/(.{40}).*/\1/' | tr '\\()/' '/)(\\' | rev | perl -C -nlE 'if($.==2){$_=reverse($_);say}else{s/^ {6}//;say}' | sed '1,3s/^/                /;1,3s/ \+$//;s/^   //'
              ________________________________
             < あなたとJava今すぐダウンロード >
              --------------------------------
               ^__^   /
       _______/(oo)  /
   /\/(       /(__)
      | w----||
      ||     ||

手順としては、

  1. perl -nlE 'say $_," "x30'で、cowsayからの出力に対して各行の末尾に「 」を30個付加
  2. sed -r 's/(.{40}).*/\1/'で、各行に対して40文字目以降の文字を削除
  3. 左右反転した時に左右カッコおよび左右スラッシュを反転させる必要があるためtr '\\()/' '/)(\\'を適用
  4. revで左右反転
  5. perl -C -nlE 'if($.==2){$_=reverse($_);say}else{s/^ {6}//;say}'を適用
    1. 2行目のみ文字列をreverse()関数で元の並びに戻す
    2. それ以外の行に対しては行頭から「 」6文字分を削除
  6. sed '1,3s/^/ /;1,3s/ \+$//;s/^ //'で、吹き出しの箇所、そして全体の位置を調整

のようになっています。

A6(宿題):

$ seq 20 | ruby -r prime -nle '$_ = $_.to_i; puts Prime.prime?($_) ? ($_+9311).chr("UTF-8") : $_'
1
②
③
4
⑤
6
⑦
8
9
10
⑪
12
⑬
14
15
16
⑰
18
⑲
20

RubyのPrimeライブラリにあるprime?()メソッドを利用します。

行の数値が素数の場合はその値に9311を加えてUTF-8の文字に変換して出力し、素数でない場合はそのまま出力します。

ちなみに、factorおよびawkを使った別解は次の通りです。

$ seq 20 | factor | awk '{sub(/:$/,"",$1);if(NF==2)printf("%c\n",$1+9311);else print $1}'
1
②
③
4
⑤
6
⑦
8
9
10
⑪
12
⑬
14
15
16
⑰
18
⑲
20

こちらの方が簡単な気がします。

A7(訂正):

当日の解答にあるgrepの正規表現「[\x00-\x39]」について、正しくは「[\x00-\x19]」ですので訂正しました。

$ grep -a -n -P '[\x00-\x19]' text | perl -nlE 's/([\x00-\x19])/"[".ord($1)."]"/eg;say'
1: 恥の多い生涯を送って[0]来ました。
5:そうしてそれが線路をまたぎ[2]越えるために造られたものだという事に
8:とばかり思っていました。しかも、かなり永[6]い間そう思っていたので
10:けのした遊戯で[22]、それは鉄道のサーヴィスの中でも、最も気のきいた

テキストファイルにASCII制御文字が含まれている場合、grepはそれをバイナリファイルとみなすため、バイナリファイルをテキストファイルであるかのように処理する-aオプションが必要になります。

また、正規表現にマッチする行の番号を出力するために-nオプションと、ASCII制御文字を示す文字クラスの表記で16進数表現を使うためにPerl互換モードを指定する-Pオプションも必要になります。

これら3つのオプションおよび正規表現でgrepした後、どこにどのようなASCII制御文字があるのかを示すために、perlで該当するASCII制御文字を10進数表現に変換しています。

おまけとして、10進数表現ではなく16進数表現に変換する場合を紹介します。

$ grep -a -n -P '[\x00-\x19]' text | perl -nlE 's/([\x00-\x19])/"[".sprintf("0x%02x",ord($1))."]"/eg;say'
1: 恥の多い生涯を送って[0x00]来ました。
5:そうしてそれが線路をまたぎ[0x02]越えるために造られたものだという事に
8:とばかり思っていました。しかも、かなり永[0x06]い間そう思っていたので
10:けのした遊戯で[0x16]、それは鉄道のサーヴィスの中でも、最も気のきいた

A8(宿題):

※フォントの環境設定によっては、ルビの位置がずれて見える場合があります。

$ echo 嘘は嘘であると見抜ける人でないと(掲示板を使うのは)難しい | mecab -E '' | tr ',' ' ' | awk '{print $1,$(NF-1)}' | nkf --hiragana | awk '{if($1==$2){len=length($2);$2="〓";for(i=1;i<len;i++){$2=$2"〓"}};print}' | perl -C -nlE 's/^([\p{han}]+?)([\p{hiragana}]+?) ([\p{hiragana}]+?)\2$/$1$2 $3/;s/^(.*?) (.*)/$2 $1/;say' | rs -T | awk '{if(NR==1){system("echo "$0" | nkf --katakana | nkf -Z4")}else{print}}' | sed -r 's/ {2,}//g;s/〓/ /g;s/([^ ]) ([^ ])/\1    \2/g'
ウソ   ウソ        ミヌ    ヒト          ケイジバン   ツカ       ムズカ
嘘は嘘であると見抜ける人でないと(掲示板を使うのは)難しい

オープンソースの形態素解析エンジン、MeCabが無いとまず解けないであろう問題です。

問題の難度の高さに比例するかのような複雑極まりない解答になりました。手順の説明も本来であればその複雑さに見合ったものである必要があると思いますが、勝手ながらここでは概略のみにとどめることにします。

大体どのような感じのシェル芸かというと、

  1. mecab -E '' | tr ',' ' ' | awk '{print $1,$(NF-1)}' | nkf --hiraganaで、元の文から表層形と読み(平仮名)を取得
  2. awk '{if($1==$2){len=length($2);$2="〓";for(i=1;i<len;i++){$2=$2"〓"}};print}'で、平仮名や記号は表層形と読みが一致することを利用してルビが不要な部分をパディング用の「〓」に変換
  3. perl -C -nlE 's/^([\p{han}]+?)([\p{hiragana}]+?) ([\p{hiragana}]+?)\2$/$1$2 $3/;s/^(.*?) (.*)/$2 $1/;say' | rs -Tで、ルビから送り仮名の部分を削除した後、1行目にルビ、2行目に文本体が来るように出力
  4. awk '{if(NR==1){system("echo "$0" | nkf --katakana | nkf -Z4")}else{print}}'で、ルビを半角片仮名に変換
  5. sed -r 's/ {2,}//g;s/〓/ /g;s/([^ ]) ([^ ])/\1 \2/g'で、ルビの位置を調整

のような組み立てになっています。なお、最後のルビの位置調整についてはもっと良い方法があるかもしれません。

ちなみに、この解答にたどり着くまでに2時間弱ほどかかってしまいました。まだまだ自分はシェル芸初心者であることを痛感しています。


参考リンク:


Thanx:

Written on July 19, 2018