はじめに
最近はよくGoogle apps scriptで便利ツールをササッと書くようになった。なんならちょっと前までは学業もバイトもそっちのけで,ブックマークレットとかも含めた便利ツールをJavaScriptで大量に書いていた。しかし自前でサーバー立ててリクエストとったりとかめちゃくちゃ膨大なコードを書いたりとか汚いコードになったりとかしてなかなか人に見せたいと思えるものではなくなっていたので,しばらくQiita記事を書いていなかった。
とはいえ,自分で作ったものはぜひ他の人にも使ってほしいと思うことは多々あった。サービスにはできない OR したくないが使ってほしいコードはいっぱいある。最近アーキテクチャとかコードのお作法みたいなことも随分腰を入れて学び始めたので,コードも見やすく,しかもコードの状態でも使ってもらえそうなシンプルで納得のいくコードがだんだん書けるようになってきた。それで今回久々に記事にしてみるという次第。
作ったもの
Google ToDoリストで前日に完了したタスク一覧を,毎日IFTTTに定期発行してくれるGASアプリ
レポート出力例
Daily Achievement - 2021/1/28
test
task1
task2
test2
task1
task2
構造とか
データの流れをざっくりいうとGoogle Tasks API --> GASサーバー --> IFTTT (--> Slack)
GASでのファイル構造
ご覧の通りTasksサービスを使っています。サービス欄から追加してください。
コード.gsが本体で,props.gsがScript Property(今回は環境変数として使っている)をいじるためのもの。test.gsは,動作確認。アサートしてるんじゃなくて,各関数の出力をログに吐き出させてるだけの簡易的なやつ。
ファイル内容詳細
コード.gs
今回の開発方針として,各種のTasks APIをパラメータごと関数でラップするようにした。結果としてこれは非常にうまくいった。実行したい関数を適度な大きさで,かつ変数とセットで管理できた。この書き方は今後GASを書くときに使えそうなので覚えておく。
処理内容は何も言うことが無いんだけど,とりあえず公式のドキュメントを見ながら書いた。なにかで詰まるということはなかった。タスクリストのリストを読んできて,それぞれのタスクリストから昨日終わらせたやつを読んできて,そのタイトルを並べてIFTTTに投げる。
getAllTasksが関数内関数を再帰呼び出ししててそれがちょっと複雑かな,ってぐらい。
function getTaskLists() {
const optionalArgs = {}
var response = Tasks.Tasklists.list(optionalArgs);
var taskLists = response.items;
return taskLists
}
function getAllTasks(taskListId) {
const bounds = getYesterday()
const optionalArgs = {
maxResults: 100,
completedMax: bounds.end.toISOString(),
completedMin: bounds.start.toISOString(),
showHidden: true
}
function innerLoop() {
// stateful
const response = Tasks.Tasks.list(taskListId, optionalArgs)
if (response.nextPageToken && response.nextPageToken.length > 0) {
optionalArgs["pageToken"] = response.nextPageToken
return [...response.items, ...innerLoop()]
}
return response.items || []
}
return innerLoop()
}
function createTextMessage(taskMap) {
const localeString = new Date().toLocaleString("ja-JP", {
year: 'numeric',
month: 'numeric',
day: 'numeric'
})
let result = ""
for (const [list, tasks] of taskMap.entries()) {
if (tasks.length > 0) {
result += [list.title, ...tasks.map(x => `\t${x.title}`)].join("\n")
result += "\n\n"
}
}
if (result.length === 0) {
result = "Nothing!"
}
return `Daily Achievement - ${localeString}\n\n` + result
}
function getYesterday() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return { start, end }
}
function sendToIFTTT(message) {
// !!please set your ifttt webhook url!!
const scriptProperties = PropertiesService.getScriptProperties();
const ifttt = scriptProperties.getProperty("IFTTT");
const payload = JSON.stringify({
value1: message,
})
const response = UrlFetchApp.fetch(ifttt, {
muteHttpExceptions: true,
method: 'post',
contentType: 'application/json',
payload: payload
});
return response
}
function main() {
const taskLists = getTaskLists()
let taskMap = new Map()
if (taskLists && taskLists.length > 0) {
for (const taskList of taskLists) {
const tasks = getAllTasks(taskList.id)
taskMap.set(taskList, tasks)
}
const message = createTextMessage(taskMap)
Logger.log(message)
sendToIFTTT(message)
} else {
Logger.log('No task lists found.');
}
}
props.gs
新しいエディタになって,プロパティサービスをエディタから直接いじることができなくなったので用意した。設計の発想は対話型コンソールっぽいやつ作っとくか,って感じで,最低限を揃えた。control関数をいじって操作する感じ。
const scriptProperties = PropertiesService.getScriptProperties();
function control() {
// setProp({"IFTTT": "https://~~~~"})
readProps()
}
function readProps() {
const data = scriptProperties.getProperties();
for (var key in data) {
Logger.log('キー: %s, 値: %s', key, data[key]);
}
}
function setProp(obj) {
scriptProperties.setProperties(obj);
}
function deleteProp(key) {
scriptProperties.deleteProperty(key)
}
test.gs
コード.gsの各関数をテストするためのファイル。テスト対象の各関数の名前の先頭にアンダーバーを足して,どの関数のデバッグかをしめした。でもやってることはテスト駆動ってほどでもなくて,各関数をデバッグするために書いたものを残しておくってぐらいのお気持ち。というわけで,ある程度テスト間の独立は気にしたけど,設計じゃなくて実装をテストしてる節があったりする。
testSuite関数で一斉に起動できるようにしておく。
function testSuite() {
_getAllTasks()
_getTaskLists()
_getMessageFromMap()
_getYesterday()
_sendToIFTTT()
}
function _getTaskLists() {
let val = getTaskLists().map(x => `${x.title}\t${x.id}`).join("\n")
Logger.log(val)
}
function _getAllTasks() {
let val = getTaskLists()[2]
val = getAllTasks(val.id).map(x => x.title).join("\n")
Logger.log(val)
}
function _getMessageFromMap() {
let testmap = new Map()
testmap.set({ title: "test" }, [{ title: "task1" }, { title: "task2" }])
testmap.set({ title: "test2" }, [{ title: "task1" }, { title: "task2" }])
testmap.set({ title: "test3" }, [])
let val = createTextMessage(testmap)
Logger.log(val)
}
function _getYesterday() {
let time = getYesterday()
const val = `${time.start.toLocaleString("ja-JP")} ~ ${time.end.toLocaleString("ja-JP")}`
Logger.log(val)
}
function _sendToIFTTT() {
const val = sendToIFTTT("this is a test").getContentText()
Logger.log(val)
}
実装手続き
- IFTTTでwebhook => [好きなサービス]の設定をやっておく。
- GASを前節のとおりに設定する
2. Tasksサービスを追加する
3. ファイルを配置する
4. IFTTT URLをprops.gsで登録する - GASに時間ベース実行トリガーを設定する
トリガー設定はこんな感じです。main関数を時間ベースで好きな時間に設定してください
以上!
おわりに
今回は設計?のお気持ちとしては,やはりまずはシンプル is ベストってことで,ユースケースを単純化して出力はプレーンテキストに,テキストの内容もシンプルにまとめた。それで,コードの方も今回は小規模にすむなってことで,クラスは書かずに,気持ち小さめぐらいの粒度の関数に分離するって感じだった。それで,各パラメータとかをそれぞれの関数に埋め込んだのは非常に良かったと思う。おかげで見通しが非常によいコードになった。
テストについても,こんな小さい個人開発でガチガチにTDDしても仕方ないよなってことでテストファーストとか設計をテストするとかはやらなかった。ただ,これまでの個人小規模開発で地獄を見た経験はけっこうあったので,ちゃんと単体テストっぽいものはしたいねってことで,今回はアサーションなしの,ログ出力させる関数を残しておくって形に落ち着いた。前述の方針とも結構マッチしてて快適だった。始めて6時間ぐらいで書き終わったかな。
で,これまで多くの便利ツールを書いてきたので,今後は特に使ってもらいたいと思ったコードをリファクタしながらぼちぼち記事にしていこうかと思っている。
あとGASの新しいオンラインエディタ,非常に使いやすいしかっこいい。好きだ。これでローカルでわざわざ作業する必要性がなくなったと思う。書きやすくなったGASをこれからも使い倒していく所存だ。