1. 何を作ったか
Fitbit Charge 6 で記録した自分のデータを Google Health API から取得して、週次の振り返り用ダッシュボードを HTML で自動生成する仕組みを作りました。
- 1日1本・0:00〜24:00 の帯グラフを7日分、縦に並べる
- 1本のトラックに睡眠(ステージ別)・運動セッション・座りっぱなしを重ねる
- 背景に歩数の濃淡を敷いて、活動の山がいつ来たかを見る
- 毎週月曜の朝に launchd が走り、生成された HTML がブラウザで開く
外部ライブラリは使っていません。Python の標準ライブラリだけで、取得・正規化・描画の3段構成です。出力は CSS と JS をインラインした単一 HTML ファイル。

ソースは全文公開しています → https://github.com/ebi-ebi-dev/google_health
ただ、この記事の主役はダッシュボードではなく、Google Health API を実際に叩いて、返ってきたエラーから仕様を1つずつ確定させていった過程のほうです。
日本語の情報がほとんど無い領域で、自分も最初の1時間はひたすら 400 を眺めていました。同じところで詰まった人が、フィルタ文字列とエラーコードの対応表をコピペして先に進めるように書きます。
前提:調査日は 2026年8月29日、対象は Fitbit Charge 6 と readonly スコープ8種です。API の仕様は変わりえます。以下は「公式ドキュメントに書かれていない」と断定するものではなく、自分が探した範囲では見つけられず、実測で確認したという話として読んでください。
2. なぜ自作したか
公式アプリ(Google Health)は「その日」を見るのがとても得意です。昨夜の睡眠ステージ、今日の歩数、直近の心拍。推移で見れてもそれは睡眠や運動など個別にしか見られません。

ただ、自分が知りたかったのはそうではありませんでした。
「運動した日は、その夜の就寝時刻が早いのか」。この手の問いは、同じ時間軸の上に別々の指標を並べて、7日分まとめて眺めないと見えてきません。睡眠タブと運動タブを往復していると、比較する前に記憶のほうが薄れてしまいます。
軸を選ぶのも、並べる順を決めるのも、本来は見る側の自由でいいはずです。データは自分のものなので、取ってきて自分で描けばいい、というだけの話でした。実際に作ってみたら、API そのものの調査のほうが本編になってしまったのですが。
3. 構成
3段に分けています。
scripts/fetch.py API → data/YYYY-Www/*.json.gz ※要ネットワーク
scripts/normalize.py 生JSON → normalized.json.gz ※ネットワーク不要
scripts/render.py normalized.json.gz → 単一HTML ※ネットワーク不要
scripts/weekly.sh 上の3段を一括実行
ポイントは ネットワークを必要とする段が fetch.py だけという点です。この分離が後でとても効きました。
API から返ってきた生レスポンスは加工せず gzip でそのまま保存しています。仕様の理解が後から変わっても取り直しが要りませんし、実測で1週間 2.9MB が gzip で 78KB、年間でも 4MB 程度なので、残さない理由がありません。
正規化以降は API に触れないので、描画の試行錯誤は何度でもやり直せます。ガントバーの高さを変えるたびに OAuth を通す、というのは無駄が多いです。
正規化後の中間形式は「1レコード = 1セグメント」(date, kind, stage, start_at, end_at, source)にしてあります。将来 DB に移すときにテーブルへそのまま写せる粒度を意識しました。CSV が欲しくなっても、ここから落とせます。
描画はチャートライブラリを使わず、CSS の絶対配置 div で済ませました。1日24時間を 100% とみなして left: x%; width: y% を置くだけなので、依存ゼロで十分に読める図になります。
4. ハマりどころ
ここからが本題です。最初のスクリプトを走らせたとき、取得対象のほぼ全部が 400 か 403 で落ちました。そこからの切り分けの記録になります。
① まず、エラーの種類が2つあることに気づく
返ってきたメッセージをよく見ると、400 の中身が2種類ありました。
The data type ID is not supported
INVALID_DATA_POINT_FILTER_DATA_TYPE_RESTRICTION
前者は「データ型そのものがこのエンドポイントに対応していない」、後者は「フィルタの書き方が違う」。同じ 400 でも原因が別だと分かった時点で、調査が2本に分かれました。
② データ型ごとに対応エンドポイントが違う
The data type ID is not supported が返る型は、そもそも GET .../dataPoints(= list)を持っていませんでした。全データ型が list を持っているわけではない、というのが今回の最大の発見です。
自分が踏んだのは次の3つ。
| データ型 | 対応しているエンドポイント |
|---|---|
floors | reconcile / rollUp / dailyRollUp |
total-calories | rollUp / dailyRollUp |
calories-in-heart-rate-zone | rollUp / dailyRollUp |
いずれも「集計してこそ意味がある」型で、生の時系列は提供しない設計に見えます。rollUp(windowSizeSeconds 指定)か dailyRollUp を使えば取れます。total-calories にはさらに 時間区間フィルタが必須で、指定できるのは最大14日 という制約もついていました。
紛らわしいのは、time-in-heart-rate-zone は list に対応している点です。心拍ゾーンでも「時間」は list でき、「カロリー」はできません。名前が似ているので、片方が通ったからもう片方も通るだろうと考えると、また 400 を踏みます。
③ filter の接頭辞は snake_case
もう一方の 400、INVALID_DATA_POINT_FILTER_DATA_TYPE_RESTRICTION のほうが厄介でした。
原因は、レスポンスの JSON キーを見ながらフィルタを書いたことでした。レスポンスは camelCase(activeMinutes)で返りますが、フィルタでは通りません。データ型 ID のハイフンをアンダースコアに置き換えた snake_case で書きます。
実際に通った組み合わせが以下です。ここが一番コピペしてほしいところ。
| dataType | 使えるフィールド | 値の形式 |
|---|---|---|
sleep | sleep.interval.end_time | RFC-3339 |
exercise | exercise.interval.civil_start_time | "2026-08-17"(日付のみ) |
steps | steps.interval.start_time | RFC-3339 |
active-minutes | active_minutes.interval.start_time | RFC-3339 |
sedentary-period | sedentary_period.interval.start_time | RFC-3339 |
クエリはこの形になります。
GET https://health.googleapis.com/v4/users/me/dataTypes/sleep/dataPoints
?pageSize=25
&filter=sleep.interval.end_time >= "2026-08-24T00:00:00+09:00"
AND sleep.interval.end_time < "2026-08-31T00:00:00+09:00"
ベース URL も、https://health.googleapis.com/v4 と https://www.googleapis.com/health/v4 の両方の記述を見かけて迷いましたが、前者で通りました。
そして切り分けに一番効いたのが、エラーコードで原因が分かるという点です。
INVALID_DATA_POINT_FILTER_DATA_TYPE_MEMBER→ フィールド名が違うINVALID_DATA_POINT_FILTER_DATA_TYPE_RESTRICTION→ 接頭辞が違う
MEMBER が出たら接頭辞ではなくフィールドを疑えばいい、と分かってから一気に進みました。
実際、exercise に exercise.interval.start_time を渡すと MEMBER が返ります。セッション型は civil_start_time しか使えず、値も RFC-3339 ではなく日付文字列でした。sleep だけは例外で、こちらは開始ではなく 終了時刻でしか絞れません。
最初は「なぜ end だけ」と思いましたが、これは都合がよかったです。23:30 に寝て 7:00 に起きた睡眠が、終了時刻で絞れば取得範囲の初日に正しく入るからです。
細かいところでは、sleep と exercise は pageSize の上限が 25 でした。他の型で 10000 が通ったので、同じ値を投げて怒られました。
④ 読み取り手段が存在しないデータがある
moods がどうしても取れませんでした。フィルタを全部外しても通りません。
対応エンドポイントを確認して納得しました。moods は create / update / batchDelete しか持っていません。 list も get も無く、つまり API 経由での読み取り手段がそもそも存在しないわけです。
スコープ側から見ても整合していました。スコープ一覧(全18種)には .mindfulness.writeonly はありますが .mindfulness.readonly はありません。にもかかわらず、GCP コンソールの選択肢には .mindfulness.readonly が表示されます。コンソールに出ているのに API が対応していない、という食い違いです。同じ状態が logged_symptoms と reproductive_health にもありました。
結果として moods / symptoms / menstrual-period / ovulation-test の4つは、デバイスに記録があっても取得できません。 回避策は見つかりませんでした。コンソールでスコープが選べるぶん、「許可すれば取れるはず」と思い込みやすいところです。
⑤ 403 の原因は専用スコープだった
electrocardiogram と irregular-rhythm-notification だけが 403 でした。この2つは health_metrics_and_measurements に含まれておらず、独立したスコープを持っています。
https://www.googleapis.com/auth/googlehealth.ecg.readonly

https://www.googleapis.com/auth/googlehealth.irn.readonly
GCP の「データアクセス」で2つを追加して再同意すれば通ります。エンドポイントは両方とも list のみです。400 は書き方の問題、403 はスコープの問題と分けてしまえば、ここは素直でした。
⑥ ハードウェア起因で空になるもの
最後に残ったのが「200 は返るのに中身が空」というグループです。API を疑い続けましたが、原因はデバイス側でした。
altitude/floors… Charge 5 以降、気圧高度計が非搭載です。原理的に取れません。floorsが list 非対応なのと合わせて、この線は諦めましたvo2-max系 … Fitbit では心肺機能スコアに相当し、GPS を伴うランニングのデータが必要です。自分のexerciseは WALKING しか無いので未算出でした
空レスポンスを見たら、まずそのデバイスがそのセンサーを積んでいるかを疑ったほうが早いです。
5. 設計上の判断
API が一通り通ってからは、データの扱いで判断が要る場面が続きました。
心拍データが1週間で 92MB
心拍は約5秒粒度で返ってきます。実測で 1週間 151,129件・92MB。生のまま保存すると年 4.8GB になり、個人のアーカイブとしては現実的ではありません。
そこで取得後に 5分バケットへ集約してから保存することにしました({date, bin, n, bpm_min, bpm_avg, bpm_max} の形)。同じ1週間が 108KB になります。
ただし、捨てる前に1つ手順を挟みました。この心拍は後述の「装着していたか」の判定に使うので、集約の前後で判定結果が完全に一致することを実データで確認してから採用しています。捨てても結論が変わらないことを確認してから捨てる。この順番を守るだけで、あとから「あのとき間引いたせいでは」と疑う時間が消えます。生が要るときは --keep-raw-heart-rate を付けます。
「デバイスを装着していたか」は API に存在しない
帯グラフを描いていて最初に困ったのが、空白の意味が2つあることでした。「その時間は寝ていなかった」のか「そもそもデバイスを着けていなかった」のか、図の上では区別がつきません。
装着状態を表すデータ型もフィールドも見つかりませんでした。データ型一覧(43種)にそれらしいものは無く、heart-rate に sensorLocation(WRIST など)はありますが、これはセンサーの取り付け位置であって、実際に着けていたかではありません。
そこで 心拍を代理信号にしました。心拍は装着中しか記録されないので、心拍が無い時間帯 ≒ 外していた時間帯 とみなせます。
- 1日を5分バケット288個に分割する
- バケット内に心拍が1件でもあれば「装着していた」
- 装着中でも数分の欠測はあるので、2バケット(10分)以下の穴は埋める
描画では未装着帯をハッチで敷き、睡眠・運動の帯より下のレイヤーに置きました。何も記録が無い時間帯だけハッチが見えるので、「本当に寝ていない」と「着けていなかった」が一目で区別できます。
あくまで推定なので、充電中や同期前も未装着に見えます。5分粒度なので、短時間の着脱も捉えられません。そこは割り切っています。
[スクショ挿入:未装着ハッチが出ている日の拡大。睡眠帯とハッチが重ならず、記録が無い時間だけ斜線が見える状態を見せたい]
歩数が二重計上になる
steps を素直に合計したら、体感より明らかに多い値になりました。platform に FITBIT(Charge 6)と HEALTH_KIT(iPhone / Apple Health 由来)の両方が入っていて、単純合計は二重計上になります。
実データ1週間で 1421件中 191件が HealthKit 由来でした。既定では FITBIT のみを採用し、除外した件数を注記として HTML に出すようにしています。黙って捨てると、後から自分で見たときに数字を疑えなくなるからです。
日付をまたぐ睡眠をどう数えるか
23:30〜7:00 の睡眠は、どちらの日の睡眠なのか。ここは描画と集計で別のルールにしました。
- 描画:0:00 の境界でセグメントを分割し、両日の帯に描く(物理的な時刻どおり)
- 集計:セッション単位のまま、起床した日に丸ごと帰属させる
「火曜の睡眠=月曜の夜に寝て火曜の朝に起きた分」のほうが、振り返りの体感に合います。見た目は分かれるが集計は分かれない。この2つを混同しないよう、normalize の段で明示的に分けてあります。
睡眠ステージに ASLEEP が混ざる
ステージは AWAKE / REM / LIGHT / DEEP だと思っていたら、ASLEEP 単独のレコードが混ざっていました。ステージ判定が行われなかった記録(Fitbit の classic ログ)で、短い昼寝で発生しやすいようです。
未知の値として落とすと睡眠が短く出てしまうので、ダッシュボードでは「ステージなし」として縞模様で描き分けています。
トークンの寿命
OAuth クライアントが Testing ステータスのままだと、refresh token が約7日で失効します。週次実行の周期とちょうど重なるので、「毎週動いたり動かなかったり」という一番たちの悪い挙動になりやすいところです。
公開ステータスを本番にすれば解決しますが、Health API のスコープは機微データ扱いで審査が要る可能性が高く、個人の趣味プロジェクトには重すぎます。
そこで invalid_grant を検出したら、その場で認可 URL を組み立てて標準エラーに出して終わる導線を組み込みました。終了コードも分けてあります(fetch 失敗=2)。月曜のログの末尾に URL が出ているので、開いて同意して credentials を差し替えれば復帰できます。直せないなら、せめて直し方をエラーに書いておく、という妥協です。
6. 運用と、当日を含めるかどうか
launchd で毎週月曜に weekly.sh を回しています。StartCalendarInterval は指定時刻に Mac が寝ていても復帰後に一度だけ走ってくれるので、実用上は十分でした(launchctl load は非推奨で Load failed: 5 を返すことがあるため、bootstrap / bootout を使います)。
範囲指定には --days N を用意しましたが、既定では当日を含めない設計にしています。
実際に踏んだからです。当日は同期の途中でデータが欠けることがあり、「寝返りでデバイスが外れた夜」が極端に短い睡眠として記録され、週の平均を大きく引き下げる例が出ました。値としては嘘ではないのですが、振り返りの材料としては邪魔になります。
./scripts/weekly.sh # 先週(月〜日)
./scripts/weekly.sh --days 10 # 昨日から遡る10日分
./scripts/weekly.sh --days 1 --include-today # 今日だけ
後から --include-today を opt-in で足しました。既定は安全側、必要なら明示的に踏み込む。 自動実行するものほど、既定値は「黙って間違った数字を出さない側」に倒しておきたいです。
7. 作ってみて
一番効いたのは、具体的な数値として”健康であるか”が分かることでした。
アプリでその日のスコアを見るだけでは、じゃあ早く寝よう、昼寝しすぎないようにしよう、散歩しようなど、ちょっとした意識改革にしかなりません。
このダッシュボードを見れば、それが何日続いたのか、座ってる時間がどれぐらいだ、といった統計値で捉えることができます。(そもそも座ってる時間なんてアプリだとみられないですしね。)
ソースは全部置いてあります。同じことをやろうとして 400 を眺めている人の役に立てば。
https://github.com/ebi-ebi-dev/google_health

