読者です 読者をやめる 読者になる 読者になる

日頃の行い

個人的な日頃の行いをつらつら書いてます\\\\ ٩( 'ω' )و ////

サークルの雑務をしてくれるマネージャーbotが欲しくなりますよね。

Hubot JavaScript 日記 cheerio-httpcli AdventCalendar

このブログはVOYAGE GROUPのアドベントカレンダー9日目として書かれています。techlog.voyagegroup.com


こんにちは @ara_ta3 です。
突然ですが、VOYAGE GROUPにはサークルという制度(?)があります。
詳しくはこちらとか。culture.voyagegroup.com

私は現在バスケットボールサークルの代表をしているのですが、サークルの代表って往々にして雑務に追われると思うんですね。
仕事の振り方が下手とか言わないでください。

バスケットボールサークルの場合、体育館の抽選・予約が主に重要な雑務です。
その他には大会予約や会計等ありますがまあ・・・うん・・・許容範囲内です。
体育館の抽選・予約にはいくつかしなければいけないことがあります。

  1. まず抽選・予約というタスクを忘れない
  2. Web上にある予約システムから抽選または予約を行う
  3. 抽選結果を確認する
  4. (Optional)先払いで体育館代を支払いに行く(物理)

人生の主軸がサークルの活動ではないので、普段これらを常に気にし続けるストレスフルな生活をしたくはありません。必要なタイミングで気がつけるようにしたくなりますね。
あぁ・・・マネージャーが欲しい。

そこで・・・Hubotですよッ!!
Hubot https://hubot.github.com/

社内でのコミュニケーションツールとしてSlackを利用しているため、連携も容易です。
ではマネージャーの錬成しましょう。時間と等価交換です。
その名も
_人人人人人人_
> harukosan <
 ̄Y^Y^Y^Y^Y ̄
名前に聞き覚えがある?気のせいかと思われます。
まず錬成の前にそれぞれの仕事を自動化する機能を実装します。

1. まず抽選・予約というタスクを忘れない

抽選を忘れるとほぼ体育館の予約は不可能となり、活動自体が行えなくなってしまいます。
アラートレベルはCRITICALです。
それほど東京での体育館の抽選は熾烈です。つらい。
なので、毎月あるタイミングで通知してくれる機能が欲しくなります。
そこで利用したのがこちら。

matsukaz/hubot-schedule · GitHub

できれば、抽選登録をやってなければ締め切り前に通知を投げてくれる等の機能も欲しいですが、今回は割愛します。
インストールは簡単。

$npm install hubot-schedule --save

そして、external-scripts.json に追加すれば反映されます。

[
  "hubot-help",
  "hubot-redis-brain",
  "hubot-schedule"
]

hubotのbrainという機能を使わないと再起動時にスケジュールのデータが消えてしまうので、hubot-redis-brainを利用してデータの永続化をしています。
残るはスケジュールの登録ですね。cronの文法でスケジュールを登録できます。
毎月1日から抽選の応募を開始し、4日あたりに締め切りが来るので、毎月1日の9:30に通知が来れば良さそうですね。
なのでharukosan起動後に下記のように伝えれば問題ないですね。

harukosan schedule add "30 9 1 * *" バスケットはお好きですか?

2. Web上にある予約システムから抽選または予約を行う

これができれば、1. で設定したものもいらなくなるので最高です。
また、抽選に落ちたとしても低い確率で開いている日程が存在するので、それにも気が付きたいところです。
今回はとりあえず第一歩として、認証がいらない体育館の予約が空いている日を教えてくれる機能を実装しようと思いました。
そう、あの時の私はそう思っていたんだ・・・

How

下記のライブラリたちを利用してスクレイピングしました。

cheeriojs/cheerio · GitHub
jQuery Likeに使えるHTMLパーサ

request/request · GitHub
HTTP Client

bnoordhuis/node-iconv · GitHub
Node.js用の文字コード変換ツール

erikdubbelboer/node-sleep · GitHub
Node.js用のスリープライブラリ

caolan/async · GitHub
同期処理を行えるようにするライブラリ

予約が空いているかどうかはログインしていなくてもできるので、単なるGETリクエストのオンパレードになると思っていました。
現実は厳しく、全ての遷移がPOSTリクエストで文字コードはShift-Jisでした。
あひるの空の九頭龍高校の監督の言葉がよぎりましたが、私は限界です。


スクレイピングのコード

とはいえちょっとは頑張ったので軽くどうやったか書こうと思います。

var req = require('request');
var cheerio = require('cheerio');
var iconv = require("iconv");
var async = require("async");
var sleep = require("sleep");

// 遷移していくページに必要なPOSTパラメータを返す無名関数の配列
var pageOptions = [
    function() {
        return {
            uri:"...",
            form:{
                TOKEN_KEY:"",  //あと7個ぐらいのパラメータ
                ...
            },
            encoding:null
        };
    },
    function(token) {
        return {
            uri:"...",
            form:{
                TOKEN_KEY:token,
            }
        };
    },
    ...
];

// 今訪れたページのHTMLから次のページ遷移に必要なTOKENを取得する手続き
var getToken = function(topPageSrc) {
    var $ = cheerio.load(topPageSrc);
    return $('input[name=TOKEN_KEY]').val();
};

// 今訪れたページに来たときにセットされたCookieをClientにセットする手続き
var setCookie = function(topPageResponse) {
    var setCookies = topPageResponse.caseless.dict['set-cookie'];
    for (var i=0; i < setCookies.length; ++i) {
        req.cookie(setCookies[i]);
    }
};

// ・・・
var toShiftjis = function(v) {
    var i = new iconv.Iconv('UTF-8//TRANSLIT//IGNORE', 'shift-jis');
    return i.convert(v).toString();
}

// ・・・
var toUtf = function(body) {
    var conv = new iconv.Iconv('shift-jis','UTF-8//TRANSLIT//IGNORE');
    return conv.convert(body).toString();
};

var next, token = "", res = "",body = "", first = true;
async.series(pageOptions.map(function(v) {
// async.seriesにcallback関数を第一引数とした無名関数の配列を渡します。
    return function(callback) {
        next = v(token);
        if( !first ) {
            sleep.sleep(1);
        }
        first = false;
        req.post(next, function(e, r, b) {
            body = toUtf(b);
            token = getToken(body);
            setCookie(r);
            callback(); // callback関数が呼ばれるとasync.seriesの次の関数が呼ばれます。
        });
    };
}), function(err, result) {
    console.log(body);
});

pageOptionsの配列の順序に沿ってページ遷移していくプログラムが出来ました。
実際に実行したらこんな感じになりました。

<table width="500" border="0" cellpadding="0" cellspacing="0" class="txt16">
    <tr>
        <td style="color:#CC0000;font-weight:bold;">ソスVソスXソスeソスソスソスGソスソスソス[ソスソスソスソスソスソスソスソスソスワゑソスソスソスソスB</td>
    </tr>

    <TR>
        <TD class="MBATR" align="left">ソス¥ソスソスソスゑソスソスワゑソスソスェ、ソスナ擾ソスソスソスソス迹ソスソスソスソスソスソスソス闥シソスソスソストゑソスソスソスソスソスソスソスソスB</TD>
    </TR>

    <TR>
        <TD class="MBATR" align="left">ソスuソスソスソスEソスUソスフ「ソス゚ゑソスソスvソス{ソス^ソスソスソスソスソスgソスpソスソスソス黷スソス鼾ソスAソスソスソスフソスソスbソスZソス[ソスwソスソスソス¥ソスソスソスソスソスソスソスソスソス鼾ソスソスソスソスソスソスソスワゑソスソスB</TD>
    </TR>

    <TR>
        <TD class="MBATR" align="left">ソスuソスソスソスEソスUソスフ「ソス゚ゑソスソスvソスヘ使ソスpソスソスソスネゑソスソス謔、ソスノゑソスソス閧「ソスソスソスソスソスソスソスワゑソスソスB</TD>
    </TR>

    <TR>
        <TD height="80" align="center"><p>&nbsp;</p><BR></TD>

    </TR>
    <TR>
        <TD align="center">ソス゚ゑソスソス{ソス^ソスソスソスソスソスNソスソスソスbソスNソスソスソスソスソスニソスソスCソスソスソスソスソスjソスソスソス[ソスノ戻ゑソスソスワゑソスソスB</TD>
    </TR>
</table>
雑感

_人人人人人_
> ソスソスソス <
 ̄Y^Y^Y^Y ̄
ソスソスソスってなんだよ!▂▅▇█▓▒░(’ω’)░▒▓█▇▅▂うわあああああああ
スクレイピングなんてするもんじゃないですね。
予約システムもWebAPI用意とかしてくれないかな・・・

3. 抽選結果を確認する

次に体育館の抽選結果の話です。
抽選結果が出ると、登録してある私の個人メールアドレスにメールが届くのですが、いちいちメールを開きないのでSlackに通知させます。

hubotでgmailのメールを取得したりパースしたりするのめんどいのでIFTTTを利用しました。(完全にbot関係ない)
そしたら文字化けしたので、ZapierというIFTTTに似たサービスを利用しました。
く・・・文字コードめ。

zapier.com

4 (Optional)先払いで体育館代を支払いに行く(物理)

特定の区だと、現地まで行かなければ支払いができません。
なぜ銀行振込とかができn・・・げふんげふん。
仕方ないので下記のようなサービスを利用しましょう。

https://www.uber.com/ja/cities/tokyo

錬成

最後に 起動 錬成です。
コマンドとしては下記のようなコマンドを用意してます。
Makefileインターフェイスとしてとても使いやすいです。
起動は↓のようなコマンドで行えます。

$make start credential={path to credential}
node=$(shell which node)
npm=$(shell which npm)
credential=./credentials/development

install:
	$(npm) install

start:
	./bin/hubot-slack $(credential)


run:
	$(node) ./resources/main.js
  • bin/hubot-slack
#! /bin/sh

trap 'kill $(jobs -p)' EXIT
file=${1:- "./credentials/sample"}
. $file; \
    export HUBOT_SLACK_TOKEN; \
    export HUBOT_SLACK_TEAM; \
    export HUBOT_SLACK_BOTNAME;

./bin/hubot --adapter slack
  • credential/sample
HUBOT_SLACK_TOKEN={slack token}
HUBOT_SLACK_TEAM={slack team name}
HUBOT_SLACK_BOTNAME=harukosan


接続が切れた時などにプロセスが突然死を遂げることがあるので、私はSupervisorを利用していたりします。
その辺りは他の記事書いたのでもしよかったら見てください。

arata.hatenadiary.com

これでサークルの雑務をしてくれるマネージャーbotが錬成されました。
楽になると良いなぁ・・・

まとめ

  • バスケサークルは外部の方の参加も募集してたりします。参加されたい方は是非Twitter等でお声がけください。
  • 人がやらなくても良い仕事はbotに任せ、誰しもが気軽に代表を出来るようにしましょう。
  • この後Chrome拡張に逃げる気持ちになったのですが、その話はまた今度。
  • 予約システムのスクレイピングには断固たる決意が必要なんだ。
  • スクレイピング対策としてセッションに全ての状態をもたせ、POSTパラメータを大量にしましょう。安西先生も諦めてくれると思います。
  • 私は、あひるの空だと里見西高校が一番好きです。
  • あと、あひるの空の言葉だとニノの「名前は轟くもんやろ」が一番好きです。

明日のアドベントカレンダーは同期の @karahiyo_n による記事です。
乞うご期待。