【どんと来い、列車遅延】 勤怠メールをサクッと送れるオレオレツールを作ってみた
こんにちは、ポキオです。
急いでるときに限って、いつも使ってる通勤列車が遅延してたりして、あたふたすることってありますよね?
そんなときに、勤務先などにサクッとメールを送れるオレオレツールを作ってみました。
Node-REDで簡単に作ってみる
「ツールを作る」と聞くと、なんだか難しそうな雰囲気がしますよね?
でも、Node-REDを使うとちょっとのコーディングで簡単にツールが作れてしまいます!たくろーどんさんの記事でもNode-REDが使われていましたが、実行したい処理をフローチャートのように、ノードとノードをつなぐことによって設計できるツールになっています。
Node-REDはその名の通り、Node.js上で動作するツールです。普段使っているPCなどNode.jsが動作している環境であればNode−REDを動かすことができますが、今回は外出先からでもアクセスできるツールを簡単に作成したかったので、Node-REDをサービスとして提供しているenebularを使ってみようと思います。
Node-REDでツールを作ってみる
はじめに、今回作ったツールがこちら。
このツールの機能は、以下の3つです。
- 外出先からでもアクセスできるWebページを作る(念の為、BASIC認証を設けておく)
- そのページ上で勤怠メールの文言をプリセットから選べるようにして、特定の宛先にメール送信できるようにする
- 「本当に電車遅延が原因で遅刻するの?」と言われないように、京急の遅延証明書ページで発行されている遅延証明書を表示して、メールに遅延証明書へのリンクを貼れるようにする
遅延状況は例によって京浜急行のWebページ上の情報を取得しています。また、不特定多数の人が勝手にこのツールを使ってしまわないように、BASIC認証を設けてアクセスを制限しています。
Node-REDでWebページを作る
Node-REDで用意されているノードのなかに、HTTPリクエストを受けるHTTP In
ノードと、それに対するレスポンスを返すHTTP Response
ノードがあります。
このHTTP In
ノードを作るとHTTPのエンドポイントができます。このノードとHTTP Response
ノードを下の絵のようにつなぎ、レスポンスとなるソースを返すようにすれば、Webページができます。
上の例では、静的なHello, world!
という文字列を返すだけですが、HTTP Response
ノードで返す情報を動的に変化させたり、Webページ上で動作させるスクリプトを記述してレスポンスとして返せば、よりリッチなWebページをNode-REDで作成することができます。
次にBASIC認証ですが、Node-REDに対して追加でモジュールをインストールすることで、簡単に機能を追加することができます。
Node-REDの設定メニューから、[Settings] > [Palette] > [Install]と進み、node-red-contrib-httpauth
を探します。
これをインストールすることで、BASIC認証が行えるノードを追加できます。
使い方は至ってシンプルで、先程のHTTP In
ノードの後ろにBASIC認証のノードをつけて、BASIC認証で使うユーザー名とパスワードをノードの設定で指定するだけです。
実際に、この状態でエンドポイントにアクセスすると、確かにBASIC認証がワークしています。
勤怠メールを送れるようにする
ここまでで、Hello, world!
が表示できるWebページができました。ここからはHTTP Response
ノードで返すHTMLをよしなに編集して、勤怠メールを作成できるようにします。
具体的には、次のような機能をHTMLやJavaScriptで記述して、それをレスポンスで返すようにしました。
- プルダウンメニューで遅刻する理由を選択できるようにする
- 選択した理由に応じて、メールの本文を変更する
- mailtoスキームを使って、メーラーを起動してメールを送れるようにする
遅刻の理由も、必ずしも電車遅延だけではないので、体調不良や保育園関係の理由も選択できるようにしました。
遅刻の理由を選択すると、メール本文が変わるようになっています。
勤怠メールを送る
を押すとメーラーが起動し、ツールで指定した文言をそのままメールとして送ることができます。
遅延証明書情報を取得する
ツール上で表示する京浜急行の運行情報は、京急線遅延証明書の発行のページから取得します。
その日に発行されている遅延証明書のリスト化し、その遅延証明書ページへのリンクをメール本文に貼り付けるか選択できるようになっています。
ただし、下記のような問題があるため、泥臭いワークアラウンドを入れています。
- 遅延証明書ページへのリンクのアドレスに、mailtoスキームでは扱えない文字が含まれているため、外部のURL短縮サービス「is.gd」を使ってアドレスを短く変換している
- URL短縮のWebAPIを叩こうとするとCORSポリシーに引っかかったため、URL短縮のために同じドメインでエンドポイントを作成し、それ経由でアドレスの変換を行う
実際のフローはこちら
Node-REDで作成したフローを、エクスポートしたものはこちらです。
[{"id":"7cbe5b40.a0b054","type":"tab","label":"Flow 1","disabled":false,"info":""},{"id":"a45bf2b.b54621","type":"http request","z":"7cbe5b40.a0b054","name":"遅延証明書ページ取得","method":"GET","ret":"txt","url":"https://delay.keikyu.co.jp/delay/","tls":"","x":360,"y":180,"wires":[["89f0f7a0.a8bda8"]]},{"id":"3a7ae4dc.fc9f5c","type":"debug","z":"7cbe5b40.a0b054","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"delayData","x":600,"y":220,"wires":[]},{"id":"89f0f7a0.a8bda8","type":"function","z":"7cbe5b40.a0b054","name":"情報をパース","func":"let table = msg.payload.split('<tbody>')[1];\ntable = table.split('</tbody>')[0];\n\nlet rows = table.split('\\n').join('');\nrows = rows.split('<tr>').join('').split('</tr>');\n\nlet data = {};\nlet dateOffset = 0;\ndata.date = rows[0 + 3 * dateOffset].split('</td>')[0].replace('<tr style=\"background-color:#ADD8E6;\">','').replace('<td rowspan=3>','').replace('<br/>','');\n\nlet morningData = rows[0 + 3 * dateOffset].split('</td>').join('').split('<td>').slice(1);\nlet dayData = rows[1 + 3 * dateOffset].split('</td>').join('').split('<td>').slice(1);\nlet nightData = rows[2 + 3 * dateOffset].split('</td>').join('').split('<td>').slice(1);\nlet dataTitles = ['品川~横浜:上り','品川~横浜:下り','横浜以南:上り','横浜以南:下り','空港線内:上り','空港線内:下り'];\n\ndata.morningTitle = morningData[0];\ndata.dayTitle = dayData[0];\ndata.nightTitle = nightData[0];\n\nmorningData = morningData.slice(1);\ndayData = dayData.slice(1);\nnightData = nightData.slice(1);\n\ndata.morningData = [];\ndata.dayData = [];\ndata.nightData = [];\ndata.allData = [];\n\nfor(let i = 0; i < 6; i++){\n if(morningData[i] !== '-'){\n let detail = morningData[i].split('>')[1].split('<')[0];\n let link = 'https://delay.keikyu.co.jp' + morningData[i].split('\"')[1];\n data.morningData.push({zone : dataTitles[i], detail : detail, link : link});\n data.allData.push({title : data.morningTitle + ' - ' + dataTitles[i] + ' - ' + detail, link : link});\n }\n \n if(dayData[i] !== '-'){\n let detail = dayData[i].split('>')[1].split('<')[0];\n let link = 'https://delay.keikyu.co.jp' + dayData[i].split('\"')[1];\n data.dayData.push({zone : dataTitles[i], detail : detail, link : link});\n data.allData.push({title : data.dayTitle + ' - ' + dataTitles[i] + ' - ' + detail, link : link});\n }\n \n if(nightData[i] !== '-'){\n let detail = nightData[i].split('>')[1].split('<')[0];\n let link = 'https://delay.keikyu.co.jp' + nightData[i].split('\"')[1];\n data.nightData.push({zone : dataTitles[i], detail : detail, link : link});\n data.allData.push({title : data.nightTitle + ' - ' + dataTitles[i] + ' - ' + detail, link : link});\n }\n}\n\nmsg.delayData = data;\nreturn msg;","outputs":1,"noerr":0,"x":340,"y":220,"wires":[["e0512d8a.0fe7f","3a7ae4dc.fc9f5c"]]},{"id":"5c37249e.aecf1c","type":"http in","z":"7cbe5b40.a0b054","name":"HTTPリクエスト","url":"/","method":"get","upload":false,"swaggerDoc":"","x":120,"y":180,"wires":[["b83cd390.27959"]]},{"id":"bbc86a41.f453e8","type":"http response","z":"7cbe5b40.a0b054","name":"HTTPレスポンス","statusCode":"","headers":{},"x":610,"y":180,"wires":[]},{"id":"e0512d8a.0fe7f","type":"function","z":"7cbe5b40.a0b054","name":"パースした情報を整形","func":"let delayBody = '';\n\ndelayBody += '<h1>' + msg.delayData.date + '</h1>\\n';\n\ndelayBody += '<h2>' + msg.delayData.morningTitle + '</h2>\\n';\ndelayBody += '<ul>';\n\nif(msg.delayData.morningData.length === 0){\n delayBody += '<li>';\n delayBody += '遅延情報なし';\n delayBody += '</li>';\n}else{\n msg.delayData.morningData.forEach(function(data){\n delayBody += '<li>';\n delayBody += '<a href=\"' + data.link + '\" target=\"_blank\">';\n delayBody += data.zone + ' (' + data.detail + ')'; \n delayBody += '</a>';\n delayBody += '</li>';\n });\n}\n\ndelayBody += '</ul>';\ndelayBody += '<h2>' + msg.delayData.dayTitle + '</h2>\\n';\ndelayBody += '<ul>';\n\nif(msg.delayData.dayData.length === 0){\n delayBody += '<li>';\n delayBody += '遅延情報なし';\n delayBody += '</li>';\n}else{\n msg.delayData.dayData.forEach(function(data){\n delayBody += '<li>';\n delayBody += '<a href=\"' + data.link + '\" target=\"_blank\">';\n delayBody += data.zone + ' (' + data.detail + ')'; \n delayBody += '</a>';\n delayBody += '</li>';\n });\n}\n\ndelayBody += '</ul>';\ndelayBody += '<h2>' + msg.delayData.nightTitle + '</h2>\\n';\ndelayBody += '<ul>';\n\nif(msg.delayData.nightData.length === 0){\n delayBody += '<li>';\n delayBody += '遅延情報なし';\n delayBody += '</li>';\n}else{\n msg.delayData.nightData.forEach(function(data){\n delayBody += '<li>';\n delayBody += '<a href=\"' + data.link + '\" target=\"_blank\">';\n delayBody += data.zone + ' (' + data.detail + ')'; \n delayBody += '</a>';\n delayBody += '</li>';\n });\n}\n\ndelayBody += '</ul>';\nmsg.delayBody = delayBody;\nreturn msg;","outputs":1,"noerr":0,"x":360,"y":260,"wires":[["5f89b7b8.249788"]]},{"id":"6e8b7f2.3c2238","type":"function","z":"7cbe5b40.a0b054","name":"ページの要素を結合","func":"let bodyHeader = '<html><header><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\"><link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css\" integrity=\"sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS\" crossorigin=\"anonymous\"></header><body><div class=\"container\"><div class=\"row\"><div class=\"col-sm my-2\">';\nlet bootstrap = '<script src=\"https://code.jquery.com/jquery-3.3.1.slim.min.js\" integrity=\"sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo\" crossorigin=\"anonymous\"></script><script src=\"https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js\" integrity=\"sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut\" crossorigin=\"anonymous\"></script><script src=\"https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js\" integrity=\"sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k\" crossorigin=\"anonymous\"></script>';\nlet bodyFooter = '</div></div></div></body></html>';\n\nmsg.payload = bodyHeader + msg.delayBody + msg.mailBody + msg.scriptBody + bootstrap + bodyFooter;\nreturn msg;","outputs":1,"noerr":0,"x":360,"y":380,"wires":[["bbc86a41.f453e8"]]},{"id":"5f89b7b8.249788","type":"function","z":"7cbe5b40.a0b054","name":"メール送信部分","func":"let mailBody = '';\n\nmailBody += '<h1>勤怠メールを作成</h1>';\n\nmailBody += '<label for=\"reason\">遅延理由を選択する</label>';\nmailBody += '<select class=\"custom-select\" name=\"reason\" id=\"reason\" onchange=\"updateMessage()\">';\nmailBody += '<option value=\"電車遅延のため\" selected>電車遅延</option>';\nmailBody += '<option value=\"腹痛のため\">体調不良(腹痛)</option>';\nmailBody += '<option value=\"頭痛のため\">体調不良(頭痛)</option>';\nmailBody += '<option value=\"体調不良のため\">体調不良(理由なし)</option>';\nmailBody += '<option value=\"保育園登園に時間がかかったため\">保育園関係</option>';\nmailBody += '<option value=\"家事都合のため\">家事都合</option>';\nmailBody += '<option value=\"私用のため\">私用</option>';\nmailBody += '</select>';\n\nmailBody += '<label for=\"chien\">遅延証明書へのリンクを貼る</label>';\nmailBody += '<select class=\"custom-select\" name=\"chien\" id=\"chien\" onchange=\"updateMessage()\">';\nmailBody += '<option value=\"\" selected>なし</option>';\n\nmsg.delayData.allData.forEach(function(value){\n mailBody += '<option value=\"' + value.link + '\">' + value.title + '</option>';\n});\n\nmailBody += '</select>';\n\nmailBody += '<label for=\"message\">メール本文</label>';\nmailBody += '<textarea class=\"form-control\" id=\"message\" rows=\"5\"></textarea>';\nmailBody += '<br><button type=\"button\" class=\"btn btn-primary btn-block\" onclick=\"sendMessage()\">勤怠メールを送る</button>';\n\nmsg.mailBody = mailBody;\nreturn msg;","outputs":1,"noerr":0,"x":340,"y":300,"wires":[["a41aa11e.86e04"]]},{"id":"a41aa11e.86e04","type":"function","z":"7cbe5b40.a0b054","name":"スクリプト部分","func":"let name = global.get('name');\nlet address = global.get('address');\n\nlet scriptBody = '<script>';\nscriptBody += 'let reasonSelect = document.getElementById(\"reason\"); let chienSelect = document.getElementById(\"chien\"); let textArea = document.getElementById(\"message\"); function updateMessage() { let reasonIndex = reasonSelect.selectedIndex; let chienIndex = chienSelect.selectedIndex; let message = \"\"; message += \"' + name + 'です。\\\\n\\\\n\"; message += \"大変申し訳ありませんが、\"; message += reasonSelect.options[reasonIndex].value; message += \"出社が遅れます。\\\\n\"; message += \"何卒宜しくお願いします。\"; if (!chienSelect.options[chienIndex].value) { textArea.value = message; return; } let request = new XMLHttpRequest(); request.onload = function () { message += \"\\\\n\\\\n遅延証明書はこちらです。\\\\n\"; message += request.responseText; textArea.value = message; }; request.open(\"GET\", \"./encode/\" + encodeURIComponent(chienSelect.options[chienIndex].value.trim()), false, \"USER_NAME\", \"PASSWORD\"); request.send(); } function sendMessage() { window.open(\"mailto:' + address + '?body=\" + encodeURIComponent(textArea.value)); } updateMessage();';\nscriptBody += '</script>';\n\nmsg.scriptBody = scriptBody;\nreturn msg;","outputs":1,"noerr":0,"x":340,"y":340,"wires":[["6e8b7f2.3c2238"]]},{"id":"867b9e9b.bb5b8","type":"http in","z":"7cbe5b40.a0b054","name":"HTTPリクエスト","url":"/encode/:url","method":"get","upload":false,"swaggerDoc":"","x":120,"y":480,"wires":[["eb246d76.1afeb","fdb9da8c.f0ebf8"]]},{"id":"55421d41.0dc1b4","type":"http response","z":"7cbe5b40.a0b054","name":"HTTPレスポンス","statusCode":"","headers":{},"x":610,"y":480,"wires":[]},{"id":"303db0f1.b72bf","type":"http request","z":"7cbe5b40.a0b054","name":"APIを叩く","method":"GET","ret":"txt","url":"","tls":"","x":330,"y":520,"wires":[["55421d41.0dc1b4"]]},{"id":"808e61ff.edc38","type":"function","z":"7cbe5b40.a0b054","name":"URL変換","func":"msg.url = 'https://is.gd/create.php?format=simple&url=' + encodeURIComponent(msg.req.params.url);\nreturn msg;","outputs":1,"noerr":0,"x":320,"y":480,"wires":[["303db0f1.b72bf"]]},{"id":"eb246d76.1afeb","type":"debug","z":"7cbe5b40.a0b054","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":310,"y":560,"wires":[]},{"id":"b475750f.2c9468","type":"comment","z":"7cbe5b40.a0b054","name":"ページへのアクセス","info":"","x":130,"y":140,"wires":[]},{"id":"b61cb0ab.67446","type":"comment","z":"7cbe5b40.a0b054","name":"URL短縮","info":"","x":100,"y":440,"wires":[]},{"id":"892cd0e6.0c4ae","type":"comment","z":"7cbe5b40.a0b054","name":"定数の宣言","info":"","x":100,"y":40,"wires":[]},{"id":"92da441f.e87958","type":"inject","z":"7cbe5b40.a0b054","name":"起動時","topic":"","payload":"","payloadType":"date","repeat":"","crontab":"","once":true,"onceDelay":0.1,"x":100,"y":80,"wires":[["7e9e582f.c0d258"]]},{"id":"7e9e582f.c0d258","type":"function","z":"7cbe5b40.a0b054","name":"差出人、宛先の設定","func":"global.set('name', 'ポキオ');\nglobal.set('address', '[email protected]');\nreturn msg;","outputs":1,"noerr":0,"x":360,"y":80,"wires":[[]]},{"id":"b83cd390.27959","type":"node-red-contrib-httpauth","z":"7cbe5b40.a0b054","name":"BASIC認証","file":"","cred":"","authType":"Basic","realm":"","username":"USER_NAME","password":"PASSWORD","hashed":false,"x":110,"y":220,"wires":[["a45bf2b.b54621"]]},{"id":"fdb9da8c.f0ebf8","type":"node-red-contrib-httpauth","z":"7cbe5b40.a0b054","name":"BASIC認証","file":"","cred":"","authType":"Basic","realm":"","username":"USER_NAME","password":"PASSWORD","hashed":false,"x":110,"y":520,"wires":[["808e61ff.edc38"]]}]
フローエディターは、こんな感じになっています。
Herokuにデプロイして本格運用
今回は、enebularが提供してるNode-REDの仕組みを利用して、フローを設計してみました。
フロー設計時にはテンポラリのエンドポイントURLが付与されるので、作りながら実際のWebページの挙動を試すことができます。
また、実際に本格運用しようと思ったときも、enebularからHerokuやAWS Lambdaにフローをデプロイ可能なので、簡単に日々の業務改善ツールとして導入可能です。enebularも無料で始められますし、HerokuやAWSも無料枠で運用することが可能です。
サクッと業務改善ツールが作れる環境が揃っているので、みなさんもぜひ作ってみてください!