Google Apps Script で TimeCrowd を操作する

勉強時間の管理として、時間管理ツール TimeCrowd を使用している。このサービスには API が用意されているものの、リファレンスや実装例は十分でないのが現状である。ここでは、Google Apps Script (GAS) を用いて TimeCrowd から情報の取得を試みる。

GAS の下準備

  1. Google Apps Script から新規プロジェクトを作成。
  2. 公開 > ウェブアプリケーションとして実装 > 導入 と進み、「アプリケーションにアクセスできるユーザー」の部分を「全員(匿名ユーザーを含む)」として「更新」を選択。「現在のウェブアプリケーションの URL」をコピーしておく。

TimeCrowd を用いたアプリケーションの作成

  1. ブラウザで TimeCrowd にログインしておく。
  2. こちら からアプリケーション作成フォームに入る。リダイレクト URL には前節 2 でコピーした URL の末尾「exec」を「usercallback」に置換したものを入力する。スコープに関してはドキュメントが見当たらず詳細不明だが、空白のままでも今回は問題なく動作した。
  3. 「登録」をクリックして表示された画面で、「アプリケーション ID」と「シークレット」をコピーしておく。

GAS の記述

Google Apps Script に下記のスクリプトを記述する。CLIENT_IDCLIENT_SECRET には前節 3 で取得したアプリケーション ID とシークレットを入れる。基本的な流れとしては、このアプリケーションに対して GET リクエストがあった際に関数 doGet が実行され、API を叩くのに必要なアクセストークンを取得するようになっている。なおリフレッシュトークンのようなものは実装されていないようだが、トークンに有効期限があるのかは不明。

var CLIENT_ID = ''; // Application ID
var CLIENT_SECRET = ''; // Secret

function doGet(e) {
    var scriptProperties = PropertiesService.getScriptProperties();
    var accessToken = scriptProperties.getProperty('access_token');
    if (accessToken == null) {
        var param = {
            response_type: 'code',
            client_id: CLIENT_ID,
            redirect_uri: getCallbackURL_(),
            state: ScriptApp.newStateToken()
                .withMethod('callback')
                .withArgument('name', 'value')
                .withTimeout(2000)
                .createToken(),
            approval_prompt: 'force'
        };
        var params = [];
        for (var name in param) params.push(name + '=' + encodeURIComponent(param[name]));
        var url = 'https://timecrowd.net/oauth/authorize?' + params.join('&');
        return HtmlService.createHtmlOutput('<a href="' + url + '" target="_blank">認証</a>');
    }
    return HtmlService.createHtmlOutput('<p>設定済です</p>');
}

function getCallbackURL_() {
    var url = ScriptApp.getService().getUrl();
    if (url.indexOf('/exec') >= 0) return url.slice(0, -4) + 'usercallback';
    return url.slice(0, -3) + 'usercallback';
}

function callback(e) {
    var credentials = fetchAccessToken_(e.parameter.code);
    var scriptProperties = PropertiesService.getScriptProperties();
    scriptProperties.setProperty('access_token', credentials.access_token);
}

function fetchAccessToken_(code) {
    var prop = PropertiesService.getScriptProperties();
    var res = UrlFetchApp.fetch('https://timecrowd.net/oauth/token', {
        method: 'POST',
        payload: {
            code: code,
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
            redirect_uri: getCallbackURL_(),
            grant_type: 'authorization_code'
        },
        muteHttpExceptions: true
    });
    return JSON.parse(res.getContentText());
}

認証

  1. TimeCrowd のアプリケーション登録完了画面に戻り、「コールバック URL」横の「認証」ボタンをクリック。
  2. 新しいタブに表示されたリンク「認証」をクリックし、認証プロセスを経て「スクリプトが完了しましたが、何も返されませんでした。」の画面が表示されれば成功である。
  3. GAS に戻り、ファイル > プロジェクトのプロパティ > スクリプトのプロパティ で、access_token が設定されていることを確認しておく。

API を叩いてみる

以下は TimeCrowd の API を叩いてその日のアクティビティを取得するスクリプトの一例である。

function getTimeCrowdEntries() {
    var scriptProperties = PropertiesService.getScriptProperties();
    var accessToken = scriptProperties.getProperty('access_token');
    var requestUrl = 'https://timecrowd.net/api/v1/calendar';
    var response = UrlFetchApp.fetch(requestUrl, {
        method: 'GET',
        headers: {
            authorization: 'Bearer ' + accessToken
        },
        contentType: 'application/json',
        muteHttpExceptions: true
    });
    var json = JSON.parse(response);
    return json;
}

ドキュメントには何の記載もないが、レスポンスの形式は以下のようである。タスクの継続時間は time_entries 以下の配列の「duration」に秒単位で格納されているものと考えられる。

{
    "date": "2019-09-01",
    "time_entries": [
        {
            "id": xxxxxxx,
            "started_at": "2019-09-01T14:29:00.000+09:00",
            "stopped_at": "2019-09-01T15:50:00.000+09:00",
            "time_trackable_id": xxxxxx,
            "time_trackable_type": "Task",
            "time_tracker_id": xxxxx,
            "time_tracker_type": "User",
            "created_at": "2019-09-01T14:29:26.000+09:00",
            "updated_at": "2019-09-01T14:41:37.000+09:00",
            "deleted_at": null,
            "duration": 4860,
            "amount": 1350,
            "team_id": xxxxx,
            "input_type": "realtime",
            "stopped_at_seconds": 1567320600,
            "deleted_at_seconds": 0,
            "task": {
                "id": xxxxxx,
                "key": "免疫膠原病内科",
                "title": "免疫膠原病内科",
                "url": "",
                "team_id": xxxxx,
                "created_at": "2019-08-29T15:14:52.000+09:00",
                "updated_at": "2019-09-01T14:41:37.000+09:00",
                "deleted_at": null,
                "description": null,
                "category": {
                    "id": xxxxxx,
                    "key": "医学",
                    "title": "医学",
                    "url": null,
                    "team_id": xxxxx,
                    "created_at": "2019-08-29T14:43:01.000+09:00",
                    "updated_at": "2019-09-01T14:41:37.000+09:00",
                    "deleted_at": null,
                    "description": null,
                    "category": true,
                    "sequential_id": 22,
                    "state": "open",
                    "bill": 0,
                    "ancestry": null,
                    "ancestry_depth": 0,
                    "closed_at": null,
                    "color": 10,
                    "position": 4
                },
                "sequential_id": 24,
                "state": "open",
                "bill": 0,
                "ancestry": "xxxxxx",
                "ancestry_depth": 1,
                "closed_at": null,
                "color": 1,
                "position": null,
                "team": {
                    "id": xxxxx,
                    "name": "個人用",
                    "created_at": "2019-03-03T15:53:42.000+09:00",
                    "updated_at": "2019-09-01T14:41:38.000+09:00",
                    "deleted_at": null,
                    "time_limit": 0,
                    "slack_webhook_url": null,
                    "slack_notification_message": null,
                    "avatar": null,
                    "personal": true,
                    "premium": false,
                    "ancestry": null,
                    "rounding": "floor",
                    "new_design": true,
                    "capacity": null,
                    "hierarchized": false,
                    "default_duration": 15
                }
            },
            "user": {
                "id": xxxxx,
                "nickname": "hoge",
                "avatar_url": "https://example.com"
            }
        }
    ],
    "total": {
        "hours": 1,
        "minutes": 21,
        "duration": 4860
    },
    "user": {
        "id": 13085,
        "nickname": "hoge",
        "image": "https://example.com",
        "created_at": "2019-03-03T15:53:42.000+09:00",
        "updated_at": "2019-09-01T14:41:37.000+09:00",
        "deleted_at": null,
        "options": null,
        "avatar": null,
        "daily": false,
        "daily_time": "2000-01-01T17:00:00.000+09:00",
        "weekly": false,
        "time_zone": "Tokyo",
        "locale": "ja",
        "profile": null,
        "domain": "timecrowd.net",
        "reset_password_sent_at": null,
        "remember_created_at": null,
        "sign_in_count": 0,
        "current_sign_in_at": null,
        "last_sign_in_at": null,
        "confirmation_token": null,
        "confirmed_at": null,
        "confirmation_sent_at": null,
        "getting_started": true,
        "team_getting_started": false,
        "notify_exported": true,
        "deactivated_at": null
    }
}

参考にしたページ

  • GAS での OAuth2 の利用

qiita.com

medium.com