Thu Jul 02 2020

日記 - 最近作った渾身のコマンド

qj

ターミナル / シェルスクリプトから JSON をダンプする. ずっと欲しかったので今日仕事をサボって作ってた.

シェルスクリプトからJSONを吐くことがある. 例えば Slack の incoming webhook は JSON データをPOSTで送る. Slack じゃなくても今どきの WebAPI ではよくある. シェルスクリプトから雑に curl で投げたいときに JSON データだけどうにかしてダンプする必要がある. 例えば,

cat <<EOM > payload.json
{
    "channel": "#hoge",
    "text": "$TEXT"
}
EOM

みたいにすれば簡単なテンプレート言語として機能してくれる. のだけど, $TEXT に改行が入っていたら, クオーテーション文字が入っていたら, これはおそらく正しくJSONとして機能しない(改行は大丈夫かな?どうだろう?).

これは別に JSON というデータフォーマットに限ったことじゃない. シェルスクリプトに優しいデータフォーマットなんてもはやない. YAML なら安全に吐けるというなら YAML を吐いて JSON に変換してもいいが, それも難しい.

というわけで作った. jq という JSON からデータをフィルタ(射影)するコマンドがあるが, おおよそそれの逆をするという気持ちから qj とした.

README の例だが, こんな感じ:

   qj -e .=3
3

   qj -e .x=1 -e .y=2 -e .z[0]=3
{"x":1,"y":2,"z":[3]}

   qj -e '.hello="world"'
{"hello":"world"}

   qj -e '.persons[1].name="Alice"'
{"persons":[null,{"name":"Alice"}]}

jq 方式の値一個をフィルタする式と, その値のペアを = で区切って指定してく. 値の数だけこれを並べないといけないので, 組み立てる JSON の割にかなり冗長に書いていかないといけない.

だから本当は jq がそうであるようにパイプを使って上手いこと

   qj -e '.x | (.y=2, .z=3)'
{
  "x": {"y": 2, "z": 3}
}

このくらい書けるのがいいんだけど, そういうことまでは書けない.

また, このコマンドが欲しかったモチベーションの全てが, 文字列のエスケープが面倒くさいということだったので, 値は特にパースできなかったときは丸々全て文字列ということにしている.

   qj -e '.=hoge'
"hoge"

   qj -e '.=ho"ge'
"ho\"ge"

   qj -e ".=$(seq 3)"
"1\n2\n3"

クオーテーションが入ってても改行が入ってても出来るだけ文字列として解釈するようにする.

シェルスクリプトから Slack の incoming webhook に POST は次のように出来る

qj -e .channel=#channel .username=bot .text=hello! .icon_emoji=:ghost: |
    curl -XPOST --data @- https://hooks.slack.com/services/XXXXXXXXXXXX

このくらいの単純な JSON なら記述量も減るし, エスケープのこと考えなくていいし楽.

web-grep

これは中の実装に関してはまじでただの tanakh/easy-scraper のラッパーです.

それはともかくめちゃくちゃ便利. web スクレイピングといえば, 私は次の二つを取っていた.

  1. curl して目印を grep してその前後から気合で取ってくる
  2. curl して真面目にHTMLをパース

1 で十分ということもある. これでいいときはこれでいい. 2 を選ぶより楽だし都合がいいこともある. そうでない場合は 2 をする必要がある. 問題は所望のデータの場所を指定するパスを保守するのが面倒だということ. 最初に必死こいてそのパスを探すのに, あるとき簡単に変わってしまう.

easy-scaper は XML に関するパターンマッチを実現してくれる. web-grep ではプレースホルダーとして {} を用いて例えば次のようにパターンを記述する.

<a class="link">{}</a>

これは link というクラス属性を持つ a タグに包まれた中のテキストを表現する. HTML 自体を取得したいことはないからテキストにしか {} はマッチしない.

{} は属性にも使える.

<a class="{}"></a>

これは a タグのクラス名を取得する.

このパターンマッチはHTML中の唯一つにマッチすることを初めから仮定していないのもいい. パスでデータの場所を指定する方法はちょうど一つを指しているが, このパターンで指定する方法はマッチしなければゼロ個だし, 複数にマッチすればその全てを列挙してくれる. 例えば <li> で列挙されたデータを全部取得したいというときに便利だ.

   seq 3 | sed 's#.*#<li>&</li>#' | web-grep '<li>{}</li>'
1
2
3

web-manga-check

Webで連載されてる漫画の更新情報をチェックする. master ブランチには載ってないけど, 更新されてたら Slack に通知するのまで裏で実装されている.

更新チェックするのには, 適当な web ページの適当な箇所を見つければいい. 例えば作品ページがあって, そのどこかに最終更新日が書かれてることがある. その該当の innerText を見ればいい. 作品ごとのページが与えられてない場合は, 最新話へのリンクがどこかに貼られてるのを探してそのリンクを見る, とか. ここにさっき言った web-grep を使ってる.

web ページ丸々の差分を見る, ヘッダにある ETag を見る, とかはたいてい通用しない. 出版社がきちんと運営するような web ページはたいてい毎日, 漫画更新とは関係ない箇所の変更をしているものだ. 別な漫画の新着情報が載ってたりね.

web-manga-check は徹底してシェルスクリプトとして書かれている. そこで Slack への通知にはさっき言った qj を使って組み立てた JSON を curl でポストしている. テキスト中に " が含まれているばっかりに, という経験を何度も味わっているが, これなら安心.