Motivation
年末に購入した Google Pixel Watch 2 であるが、それまで使用していた Apple Watch との違いを特に感じず、モヤモヤしていたのだが、Pixel Watch には Fitbit アプリがデフォルトで入っており、睡眠データなどのヘルスデータや歩数などのアクティビティデータを取得してくれるので、それを使って何かしたいと考えた。
Fitbit アプリや web のダッシュボード(https://www.fitbit.com) では各日のデータを確認できるが、複数日にまたがって一覧でまとめてデータを俯瞰したり、他の記録(e.g. 習慣トラッカーなど)と合わせて記録を残したい場合にはそれがなかなか難しいので、それらを全て Notion に突っ込むことで自分だけの活動記録テーブルを作ろうと考えた。
TL;DR
タイトルにあるツール群を用いて、ヘルスデータ(歩数、睡眠時間、ランニング時間など)を自動的に管理下に置くためのパイプラインを敷設した。
具体的には、「データを Fitbit API で取得して、それを Notion API で書き込む」サーバーレス環境を構築した。
前提条件
本パイプライン構築のために必要となるものについて記載する。
今回は Fitbit と Notion を利用しているが、仮に Fitbit ではなく Garmin だろうが、Notion ではなく Google Sheets だろうが、その部分を自分でカスタマイズすれば同様のパイプラインの構築は可能だと思う。
開発環境
- 開発環境: Mac/MacOS 14.0
- npm: 9.8.1
- node: v18.18.0
その他必要なもの
- Fitbit 対応デバイスでデータを取得していること(今回は Google Pixel Watch2(Wi-fi モデル) を使用)
アーキテクチャ
大まかな流れは、以下の通り。
- Cloud Scheduler(定刻のPub/Sub トリガー起動)
- Pub/Sub(Cloud Scheduler からのメッセージを受けて、Cloud Functions 関数をキックする)
- Cloud Functions(Fitbit データの取得、Notion へのデータ書き込み)
- Firestore(Fitbit API refresh token の保存と取得)
各種セットアップ
ここから実際に手を動かして構築していく部分の説明となる。
各ツール群の詳細な手順とはなっていないため、不明点は別途他の情報に当たって頂きたい。(特に GCP まわりは説明端折ってます)
Fitbit
-
こちらから開発者アカウントを作成する
-
こちらからアプリケーションを新規登録する
-
登録が完了すると、
MANAGE MY APPS
ページにOAuth 2.0 Client ID
とClient Secret
が作成されているので確認する -
こちらからリフレッシュトークンを発行する
- step 4 で
refresh_token
を発行したら、それを後で利用するため控えておく1
- step 4 で
-
また、こちらで Fitbit のアクティビティデータが入っていることを確認しておくと良いだろう
Notion
-
ページを作成する
-
作成したページ配下でデータベースを作成する
-
作成したデータベースに対して
- デフォルトで一番左側のプロパティ(
Name
という名前で、Type=Title
)をDay
に変更する- Type=
Title
のプロパティは、データベースにおけるキーとなる
- Type=
- 以下のプロパティを追加する
-
RunningTime
: その日にランニングした時間(分)- Type:
Number
- Type:
-
SleepDuration
: 前日からその日にかけての睡眠時間(分)- Type:
Number
- Type:
-
Steps
: その日の歩数- Type:
Number
- Type:
-
- デフォルトで一番左側のプロパティ(
-
シークレットを作成する
-
こちらで新規インテグレーションを作成
-
Associated workspace
から作成したページやデータベースが属するワークスペースを選択し、Name
に任意の名前を入力して Submit する - 作成されたインテグレーションページに行き、
Internal Integration Secret
の内容を控える
-
こちらで新規インテグレーションを作成
GCP
Cloud Functions
環境変数
必要な以下の項目を環境変数を .env
に格納する
本来アクセストークンやそれに準ずる秘匿性の高い情報を環境変数で扱うのはあまりセキュリティ的に良くないので、個人利用以外のプロジェクトの場合は GCP では Secret Managerなどの利用の検討をお勧めする。
COLLECTION_PATH=credentials
FITBIT_DOC_ID=fitbit
NOTION_DOC_ID=notion
CLIENT_ID=XXXXXX
CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_ACCESS_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_PARENT_PAGE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
PUBSUB_TOPIC_NAME=habit-tracker-pubsub-topic # for パラメータ化された構成
ここで、各変数は以下のような意味である:
-
COLLECTION_PATH
: Firestore ルートコレクション名 -
FITBIT_DOC_ID
: Firestore Fitbit 関連のドキュメント名 -
NOTION_DOC_ID
: Firestore Notion 関連のドキュメント名 -
CLIENT_ID
: Fitbit OAuth 2.0 Client ID -
CLIENT_SECRET
: Fitbit client secret -
NOTION_ACCESS_TOKEN
: Notion API access token(secrets) -
NOTION_PARENT_PAGE_ID
: Notion ページの Page ID。取得時は 32 文字の英数文字列のため、これを UUID の形式に変換して記述する(適当な位置に-
を挿入する) -
PUBSUB_TOPIC_NAME
: Pub/sub トピック名
コーディング
要所を少し紹介(Githubで全ソースコード公開)
- Fitbit アクティビティデータ取得
- fitbit.ts > fetchData で、
dateForFitbit
で指定された日付のデータを取得する - sleep.ts > fetchSleepData は Fitbit API2 とのインターフェース
- fitbit.ts > fetchData で、
import { fetchSleepData } from '@/api/fitbit/sleep';
export const fetchData = async (dateForFitbit: string, accessToken: string) => {
const sleep = await fetchSleepData(dateForFitbit, accessToken);
const sleepDurationInMin = sleep.summary.totalMinutesAsleep;
...
};
export const fetchSleepData = async (dateString: string, accessToken: string) => {
const url = `https://api.fitbit.com/1.2/user/-/sleep/date/${dateString}.json`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const data = await response.json();
return data;
};
- Notion への書き込み
- notion.ts > updateDBPage で
targetDate
で指定された日の記録を更新する-
updatePageData
の詳細は公式ページを参照
-
- page.ts > updatePage は、Notion API とのインターフェース
- notion.ts > updateDBPage で
import { updatePage } from '@/api/notion/page';
export const updateDBPage = async (targetDate: DayjsDate) => {
// filter pages in database that matches "`day`日" for property 'Day'.
const accessToken = await issueTokenPair(oldRefreshToken);
const { ..., sleepDurationInMin } = { ...(await fetchData(dateForFitbit, accessToken)) };
const day = targetDate.format('D');
const pageIds = await findPageByDay(day);
const updatePageData = {
page_id: pageIds[0],
properties: {
..
SleepDuration: {
number: sleepDurationInMin,
}
}
};
await updatePage(updatePageData, NOTION_ACCESS_TOKEN);
};
import { Client } from '@notionhq/client';
import { UpdatePageParameters } from '@notionhq/client/build/src/api-endpoints';
export const updatePage = async (params: UpdatePageParameters, accessToken: string) => {
const notion = new Client({
auth: accessToken,
});
const response = await notion.pages.update(params);
};
デプロイ
デプロイにより、Pub/Sub トピックは自動生成される
# Firebase の CLI ツールのインストール
npm install -g firebase-tools
# デプロイ(Firebase Cloud Function のプロジェクトを CLI で作成すると、デフォルトでデプロイコマンドがセットされている)
npm run deploy
Firestore コレクション/ドキュメント用意
以下の構造のコレクションを作成する。
このセクションで取得したリフレッシュトークンと、このセクションで取得したデータベースID をあらかじめ入れておく。
これらの情報は初回のみ格納すればよく、以降はプログラム実行時自動更新される。
COLLECTION_PATH # root collection
├── FITBIT_DOC_ID # document
│ └── refreshToken: <xxxxxxxxxxx...>
└── NOTION_DOC_ID # document
└── databaseId: <xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx>
Cloud Scheduler
定刻に処理実行されるように job 定義(e.g. 10 0 * * *
= 毎日0:10)
あとは深夜0:10 を待つのみ!
Cloud Scheduler によりメッセージが Pub/Sub トピックめがけて送信されると、以下の処理が実行される:
- (毎日)その日のデータ行を一行追加し、前日のデータを書き込む
- (毎月1日のみ)データベース(テーブル)を追加する
これにより、月替わり対応も自動で行い、基本的にはどんどん新しいデータを書き込んでいってくれるようになっている。
データ書き込みされた Notion 画面イメージ
Fitbit のデータが書き込むのと併せて、習慣トラッカーの項目もチェックボックスで組み込んでいる。
現状はこれらは手でぽちぽちやっているが、これも API 経由で操作可能なので、何かの判断材料をもとに自動で記録することができる。
Notion では各プロパティは Type に基づいて集計関数が用意されているため、Number 型データの場合、平均値や合計値などをテーブル下方に表示することができる。
また、実際に時間に関するデータは単なる分表記ではなく「HH時間MM分」などの形式にした方が可読性は高いが、その変換を Cloud Functions 側で行った結果を Notion に書き込む場合 Type=Text
となるため、上述の集計関数に制限が出てしまうのがモヤモヤポイントだ(平均睡眠時間などが出せない)。
所感
- Notion はシステムインテグレーション周りが非常によく整備されていて、API エンドポイントを直たたきせずとも SDK が用意されており、型補完を効かせることができて開発しやすい
- データ挿入位置がデータベース上意図しない場所になされるため、順番に挿入されるようにしたい
- Fitbit 以外のデータを連携してみて何か面白い示唆が現れるかをみてみたい
-
初回のみリフレッシュトークンを手動で取得することで、OAuth 認証認可プロセスにおけるユーザーの同意を得るプロセスをスキップしている。一度リフレッシュトークンが手に入れば、あとは API 経由でトークンを再発行すれば良い。 ↩
-
本エンドポイントの詳細はこちら: https://dev.fitbit.com/build/reference/web-api/activity/get-daily-activity-summary/ ↩