❤️

AppleヘルスケアをBigQueryに貯めて、MCP経由でスマホから分析してみた

に公開

こんにちは!某メガベンチャーでデータアナリスト……を3年務めてから、データエンジニアに異動したばかりのじゃっこです🔰

Appleヘルスケアの情報をBigQueryに貯めて、MCP経由でモバイルのClaudeやChatGPTから見られるようにした話です。

Claudeのモバイルアプリからだとグラフの確認もできます=300px
Claudeのモバイルアプリからだとグラフの確認もできます

TL;DR

  • AppleヘルスケアのデータをBigQueryに貯めて、ClaudeやChatGPTから分析できるようにした
  • 目的は、体重・運動・睡眠・HRV(※心拍変動。高ければコンディションが良い、とみなすことが多い)を長期コンテキストとしてAIに渡すこと
  • Health Auto Export ProでiPhoneからCloud RunにPOSTし、BigQueryに保存している
  • BigQuery側では、生データに近いBronze、クレンジング済みのSilver、日次などに集計したGoldを分けた
  • ClaudeではBigQuery MCPで分析しやすいが、今まで相談してきたChatGPTにも読ませたくなった
  • 将来的には食事、日記、位置情報、SNS投稿、AIへの相談ログも含めて、生活全体をAIに渡したい

実装リポジトリはこちらです。
https://github.com/jackojacko05/manage_health

なぜ作ったか

私は数年ほど前からダイエットをしていた関係で、体重や運動、食事、体調を毎日Apple Watchなどで記録しています。

一方で、仕事ではBigQueryやAIエージェント、MCPまわりを触る機会が増えてきました。そうなるとだんだん、

AIやデータの仕事をしているのに、自分の体重や体調については、その日の数字にかなり一喜一憂しているな……

と思うようになりました。

もともと、悩みごとはChatGPTに投げていました。そのうち、食事アプリやAppleヘルスケアのスクリーンショットを貼って、

  • 今日これ食べすぎ?
  • この運動量で休んだ方がいい?
  • HRV低いけどどう思う?
  • 体重が増えているけど気にした方がいい?

のように聞くようになったのです。

ただ、スクリーンショットで渡せるのは基本的に「その日の状態」です。本当は、数日ではなく数ヶ月、できれば数年分の運動・睡眠・体重・食事・体調のコンテキストを、最初からAIに渡しておきたい。

そう思って、AppleヘルスケアのデータをBigQueryに貯めて、ClaudeやChatGPTから読めるようにする個人用ヘルスデータ基盤を作り始めました。

やりたかったこと

やりたかったことは、単にAppleヘルスケアのデータを保存することではありません。

AIに、自分の体調やダイエット状況を長期コンテキスト込みで見てもらえるようにしたかったです。

具体的には、次のようなことをしたいと思っていました。

  • 直近の体重増減を、単日の数字ではなく数週間の流れで見る
  • HRVが低いときに、睡眠不足なのか、運動しすぎなのか、移動が多いのかを見たい
  • 運動種目ごとの負荷を見たい
  • 「今日は運動するべきか、休むべきか」をAIに相談したい
  • 将来的には、食事記録や日記、ともつなげたい

要するに、自分の生活ログをAIが読める形にして、毎回ゼロから説明しなくてもよい状態にしたかったです。

最終的な構成

最終的には、次のような構成にしました。

iPhone / Appleヘルスケア
  -> Health Auto Export Pro Automations
  -> Cloud Run receiver
  -> BigQuery dataset
       - Bronze: raw_metrics, heart_rate, hrv, workouts
       - Silver: raw_metrics_dedup, heart_rate_dedup, hrv_dedup, workouts_dedup, sleep_daily_sources
       - Gold: sleep_daily, hrv_regression_data, hrv_regression_v2, hrv_seg_v3

Claude
  -> BigQuery MCP
  -> SQL / BQMLで分析

ChatGPT
  -> Supabase App / read facade
  -> BigQuery由来のSilver / Goldを参照

設計の軸は、Appleヘルスケアの生データに近い時系列をBigQueryに置くことです。

Cloud Run receiverは薄くして、認証、payloadの正規化、BigQueryへのinsertだけを担当します。重複排除、単位変換、睡眠sourceの選択などは、BigQuery側のviewに寄せました。

NotionやSupabaseは、正の保存先ではなく閲覧口です。時系列データのsource of truthはBigQueryに置くことにしました。

AppleヘルスケアをBigQueryに入れる

日々の同期にはHealth Auto Export Proを使っています。
https://apps.apple.com/jp/app/health-auto-export-json-csv/id1115567069

Automation機能で、REST APIにJSONをPOSTできます。

設定としては、ざっくり次のような形です。

Destination: REST API
Method: POST
Format: JSON
URL: <CLOUD_RUN_SERVICE_URL>
Header: X-Auth-Token: <TOKEN>

Metrics: Hour
Heart Rate: Seconds
HRV: Seconds
Workouts: event

これをCloud Runで受けて、BigQueryに入れています。

receiver側では、トークンを確認したうえで、payloadを次のようなテーブルに振り分けます。

  • heart_rate
  • hrv
  • raw_metrics
  • workouts

本編に載せるなら、コードはこのくらいで十分だと思います。

app.post('/', async (c) => {
  const clientToken = c.req.header('x-auth-token');
  const serverToken = await getAuthToken();

  if (!clientToken || clientToken !== serverToken) {
    return c.json({ error: 'unauthorized' }, 401);
  }

  const payload = await c.req.json();

  // payloadを heart_rate / hrv / raw_metrics / workouts に振り分ける
  // BigQueryへinsertする

  return c.json({ ok: true });
});

最初から完璧な日次テーブルを作るより、生に近い時系列としてBigQueryに置いておく方が、後から扱いやすかったです。

BigQuery側のテーブル設計

BigQuery側は、ざっくりmedallion architectureっぽく分けました。

役割
Bronze 受け取ったデータに近い形で保存 raw_metrics, heart_rate, hrv, workouts
Silver 重複排除・単位変換・名前揺れ吸収などのクレンジング raw_metrics_dedup, heart_rate_dedup, hrv_dedup, workouts_dedup
Gold 日次などでJOINしやすい形に集計した分析用データ sleep_daily, hrv_regression_data, hrv_regression_v2, hrv_seg_v3

Appleヘルスケアのデータは、思ったより「きれいな表」ではありません。

たとえば、

  • kcalとkJが混ざる
  • percentageが 0.2323 の両方で来る
  • 睡眠はApple Watch、iPhone、睡眠アプリなど複数sourceが重なる
  • Health Auto Export Proのpayload形状がmetricによって微妙に違う

みたいなことがあります。

なので、取り込み時にすべてを完璧に直すのではなく、Bronzeは生に近く残し、Silverで重複排除・単位変換・名前揺れ吸収などのクレンジングを行い、Goldでは日次などの分析しやすい粒度に集計する形にしました。

この分け方にしたのは、rawデータのままだと件数が多すぎるからです。心拍数やHRVはサンプル単位で増えていくので、睡眠・歩数・運動量などと毎回そのままJOINしようとすると、AIが混乱しがちです。よく使う分析では、日次の体重・睡眠・運動量・HRV指標のように、先に同じ粒度へ寄せたGoldを作っておく方が扱いやすいです。

テーブル 粒度 目的
raw_metrics 1時間 x metric 歩数、消費カロリー、睡眠segmentなど
heart_rate サンプル単位 心拍数の変動を見る
hrv サンプル単位 HRVの測定タイミングごとに見る
workouts ワークアウト単位 運動種目、時間、消費カロリーなどを見る

AIにSQLを書かせる前提では、安易な全期間スキャンを防ぐためにpartition filterを必須にしています。
4年分全期間スキャンしても、最も件数が多いHRVで約85万件・約0.04GBだったので、BigQueryの無料枠1TB/月を超える可能性はほぼないですが、念のためです。

CREATE TABLE IF NOT EXISTS `PROJECT.health.hrv` (
  start_at     TIMESTAMP NOT NULL,
  sdnn         FLOAT64   NOT NULL,
  source       STRING,
  ingested_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP(),
  PRIMARY KEY (start_at) NOT ENFORCED
)
PARTITION BY DATE(start_at);

ALTER TABLE `PROJECT.health.hrv`
SET OPTIONS(require_partition_filter = TRUE);

ChatGPTにも長期コンテキストとして読ませたい

BigQueryにデータを置くと、Claudeからはかなり自然に分析できます。

Claudeでは、モバイル版でも、BigQuery MCPを使ってSQLを書かせたり、結果を見ながら追加で深掘りしたりできます。グラフを書き出したり、説明変数を増やして重回帰分析をしたりする場合も、BigQuery側にデータがあるとかなり扱いやすいです。

たとえば、前日の運動時間、睡眠時間、歩数、active energyなどを説明変数にして、翌日のHRVを見るような分析は、BigQuery SQLやBQMLで進められます。

このあたりは、ClaudeとBigQuery MCPの相性がかなり良いと感じています。

一方で、私がこれまで体重や食事、体調、メンタルの相談を一番投げてきたのはChatGPTでした。

ChatGPTには、かなり長い期間、

  • 体重が増えて不安
  • 今日は運動した方がいいか
  • 食べすぎた気がする
  • HRVが低くて不安
  • 体が痛いけど休むべきか
  • そもそも今はダイエットを続けるべきか

のような相談を投げています。

つまり、単にAppleヘルスケアのデータを分析できればよいわけではなく、今までChatGPTに溜まってきた自分の「メンヘラコンテキスト」も踏まえてほしいのです。

データだけを見るならClaudeでもよい。

でも、「この人は体重の短期変動に一喜一憂しがち」「疲れているのに運動を足そうとしがち」「スクリーンショットを貼って毎回不安を確認しにくる」みたいな文脈まで含めると、ChatGPT側に読ませたい気持ちが出てきます。

そこで、ChatGPTからもBigQuery由来のデータを読めるようにするために、Supabaseを挟むことにしました。

https://x.com/supabase/status/2049149249833570540

つい最近リリースされたSupabaseのChatGPTアプリを使い、BigQueryを直接ChatGPTにつなぐのではなく、SupabaseをBigQueryのビューのような扱いで経由するという構成を試しています。

BigQuery
  -> Silver / Gold view
  -> Supabase
  -> ChatGPT

BigQueryをsource of truthにしたまま、ChatGPTには必要な範囲だけをSupabase経由で見せるイメージです。

全データをChatGPTに渡すのではなく、

  • 日次の体重
  • 睡眠時間
  • HRVの集計値
  • 運動量
  • 直近の食事サマリ
  • 必要な期間に絞ったview

のように、AIに読ませたい粒度へ一段整えてから渡す方が安全そうです。

正直なところ、ここはまだ試行錯誤中です。

ClaudeではBigQuery MCPでかなり気持ちよく分析できる一方で、ChatGPTには今までの相談履歴や自分の認知の癖が溜まっている。なので、データ分析基盤としてはClaudeが便利だけれど、生活相談の相手としてはChatGPTにも読んでほしい、という状態です。ChatGPTが早く任意のカスタムMCPに対応してくれたらいいのに、と切に願っています。

最近はChatGPT内でアプリ連携が広がってきているし、CodexもChatGPT側へかなり近づいてきています。もし噂通り、Codexとの統合やアプリ基盤の拡張で任意のMCPサーバーを自然に扱えるようになるなら、個人用のBigQueryやSupabaseをAIの長期記憶のように使う体験はかなり変わりそうです。

AIに「無理している」と言われると休みやすい

この仕組みを作ってよかったのは、単にAppleヘルスケアのデータをSQLで見られるようになったことだけではありません。

自分の体調データと、今までAIに相談してきた文脈を合わせることで、「今日は休んだ方がいい」という判断を受け入れやすくなりました。

自分だけで判断していると、

  • このくらいで休んでいたらだめでは?
  • 昨日も運動したけど、今日もできるのでは?
  • 体重が増えているから、もっと動いた方がいいのでは?

と思ってしまうことがあります。

でも、HRVや睡眠、運動量を見たうえでAIに、

  • 今日は負荷が高い状態に見える
  • この状態でさらに強い運動を足すより、回復を優先した方がよさそう
  • 体重の短期変動だけで判断しない方がよい

と言われると、少し休みやすくなります。

自分で自分に言っても聞けないことでも、データを見たAIに言われると、なぜか受け入れやすい。これはかなり大きな発見でした。

StressWatchを入れてHRVを見るのが面白くなった

HRVの計測用に役に立ちそうと思い、StressWatchというアプリも使っています。

https://apps.apple.com/jp/app/stresswatch-aiストレス測定-睡眠-習慣追跡/id6444737095

StressWatchを使い始めたあと、Appleヘルスケア側に記録されるHRVのタイミングが増えたように見えました。

ただし、これは因果としては断定していません。アプリがApple WatchのHRV測定頻度を直接制御しているのか、watchOS側の挙動、装着時間、設定、行動変化などの影響なのかは切り分けていません。

ちなみにアプリ単体のUI/UXもかなり良く、HRVが下がっている時にリアルタイムで通知したりしてくれます。過去との比較もかなり見やすいです。

これからやりたいこと

今はAppleヘルスケアのデータを中心にBigQueryへ入れています。

ただ、本当にやりたいことは、体重やHRVだけを見ることではありません。自分の生活全体を、AIが読める長期コンテキストにしたいです。

たとえば、

  • 食事記録
  • ChatGPTへの相談ログ
  • 日記
  • 位置情報
  • SNS投稿
  • カレンダー
  • 睡眠
  • 運動
  • 体重
  • HRV

のようなものを、できる範囲でひとつのコンテキストとして扱いたい。

今までは、悩みごとがあるたびにChatGPTへ文章で説明したり、食事アプリやAppleヘルスケアのスクリーンショットを貼ったりしていました。でも本当は、毎回ゼロから説明したいわけではありません。

「最近の私はこういう生活をしていて」「この数週間はこういう運動量で」「睡眠はこう変化していて」「こういう悩みを何度も相談していて」という背景を、最初からAIが知っていてほしい。

そうすると、AIに、

  • 最近疲れているのは、睡眠不足なのか、運動しすぎなのか、予定が詰まっているからなのか
  • 体重が増えたのは、食べすぎなのか、筋肉痛やむくみなのか
  • 今日は運動した方がいいのか、休んだ方がいいのか
  • 今週の日記を自動で下書きして
  • SNSに書いていたことと体調の変化を並べて見て

みたいなことを聞けるようになるはずです。

かなり極端に言うと、AIに管理されたい。

ただし、管理されるためには、その場その場のスクリーンショットではなく、長期のコンテキストが必要です。

今回のAppleヘルスケア > BigQuery > MCP のパイプラインは、そのための第一歩でした。

Appendix: ハマりどころ

ここからは実装時の細かいメモです。本文の流れからは外れるので、必要な人だけ読んでください。

Appleヘルスケアデータの整形

HAEのpayload形状が揺れる

Health Auto Export Proのpayloadは、metricによって値の入り方が少しずつ違いました。

qty のこともあれば、valuesumtotalaverage のような形で来ることもあります。そのため、receiver側では複数の形を吸収するようにしました。

function unwrapMeasurement(v: any): { value: number; unit?: string } | null {
  if (v == null) return null;
  if (typeof v === 'number') return Number.isFinite(v) ? { value: v } : null;
  if (typeof v === 'string') {
    const n = Number(v);
    return Number.isFinite(n) ? { value: n } : null;
  }
  if (typeof v !== 'object') return null;

  for (const key of ['qty', 'value', 'sum', 'total', 'average', 'avg', 'Avg']) {
    const n = Number(v[key]);
    if (Number.isFinite(n)) {
      return { value: n, unit: v.units ?? v.unit };
    }
  }
  return null;
}

睡眠sourceが重複する

睡眠はApple Watch、iPhone、睡眠アプリなど、複数sourceから似たようなデータが入ってきます。

単純にSUMすると二重計上になるので、sourceごとに日次集計し、優先順位と妥当な時間範囲で代表sourceを選ぶようにしました。

CREATE OR REPLACE VIEW `PROJECT.health.sleep_daily` AS
WITH ranked AS (
  SELECT
    *,
    ROW_NUMBER() OVER (
      PARTITION BY sleep_date
      ORDER BY IF(is_plausible, 0, 1), source_priority, sleep_hours DESC, source
    ) AS rn
  FROM `PROJECT.health.sleep_daily_sources`
  WHERE sleep_date BETWEEN DATE '1900-01-01' AND DATE '2100-01-01'
    AND is_plausible
)
SELECT * EXCEPT(rn)
FROM ranked
WHERE rn = 1;

percentage表現が揺れる

歩行安定性や体脂肪率、血中酸素などのpercentage系の値は、0.2323 のように、比率とパーセント表現が混ざることがあります。

これはSilver view側で正規化しています。

CASE
  WHEN metric_name IN (
    'apple_walking_steadiness',
    'body_fat_percentage',
    'blood_oxygen_saturation',
    'walking_asymmetry_percentage',
    'walking_double_support_percentage'
  ) AND raw_value <= 1 THEN raw_value * 100
  ELSE raw_value
END AS value

export.xmlはstreaming parseする

過去データのバックフィルにはApple純正の export.xml も使いました。

ただし、ファイルが大きくなりやすいので、DOM parseではなくstreaming parserで処理しました。

parser.on('opentag', (node) => {
  if (node.name !== 'Record') return;

  const attrs = node.attributes as Record<string, string>;
  const type = attrs.type ?? '';
  const value = Number(attrs.value);

  if (!Number.isFinite(value)) return;

  // typeごとに heart_rate / hrv / raw_metrics / workouts へ変換する
});

stdoutにログを出すと壊れる

JSONをstdoutに流す処理では、stdoutにログが混ざると次段のparseが壊れます。

データはstdout、ログはstderrに分けるようにしました。

process.stdout.write(JSON.stringify(result) + '\n');
console.error('[sync] done');

BigQuery insertとStorage Write API

今回は実装の簡単さを優先してBigQuery insertを使いました。

個人用の小規模な取り込みなら十分ですが、本格的にスループットやexactly-onceを気にするならStorage Write APIも候補になると思います。

セキュリティ(BigQuery / Supabase両方)

この記事では、実際の健康値、GCP project id、Cloud Run URL、Notion URL、Supabase URL、トークン、個人を特定できるsourceNameなどは出さないようにしています。

特にAppleヘルスケアの export.xml には、端末名やsourceNameなど、思ったより個人情報に近いものが含まれることがあります。

また、AIに外部ツールやデータソースを接続する設計では、便利さと同時にセキュリティ面の注意も必要です。

BigQuery側

BigQueryはsource of truthなので、まずここを公開しないことが前提です。datasetやtableをpublicにせず、Cloud Run receiverにはトークン認証を置き、Secret Managerやサービスアカウントの権限も必要最小限にします。

require_partition_filter はセキュリティ機能そのものではありませんが、AIや人間がうっかり全期間を読むクエリを書いたときのコストガードになります。健康データのように期間指定が自然なデータでは、必ず日付で絞る前提にしておく方が安全です。

Supabase側

Supabaseは正の保存先ではなく、ChatGPTやPostgres-aware clientから読むための経由地点として使います。そのため、全テーブルを出すのではなく、BigQuery側で整えたSilver / Goldのviewだけを見せます。

特に、健康データのような個人情報を扱う場合は、最低限このくらいは意識した方がよさそうです。

  • anon には権限を渡さない
  • authenticated にも必要なviewだけを見せる
  • 可能ならpublic schemaではなくprivate schemaに置く
  • 自前APIやsecurity definer functionで返す列・期間を絞る
  • ChatGPTから読ませる場合も、最小権限にする

実際のFDW権限は、いったん広い権限を剥がしてから、authenticatedSELECT だけ戻すようにしました。

REVOKE ALL PRIVILEGES ON
  public.raw_metrics_dedup,
  public.heart_rate_dedup,
  public.hrv_dedup,
  public.workouts_dedup,
  public.sleep_daily_sources,
  public.sleep_daily,
  public.hrv_regression_data,
  public.hrv_regression_v2,
  public.hrv_seg_v3
FROM anon, authenticated, public;

REVOKE USAGE ON FOREIGN SERVER bigquery_server FROM anon, public;
GRANT USAGE ON FOREIGN SERVER bigquery_server TO authenticated;

GRANT SELECT ON
  public.raw_metrics_dedup,
  public.heart_rate_dedup,
  public.hrv_dedup,
  public.workouts_dedup,
  public.sleep_daily_sources,
  public.sleep_daily,
  public.hrv_regression_data,
  public.hrv_regression_v2,
  public.hrv_seg_v3
TO authenticated;

AIコネクタ側

公開記事には実測値・トークン・URLを出さず、ChatGPTに読ませるデータも最初は最小限のviewやサマリから始めるのがよいと思います。

外部データ連携では、間接プロンプトインジェクションや意図しない情報流出のリスクもあります。個人の健康データや日記、位置情報まで扱うなら、便利にする前に、まず読ませる範囲を小さくするのがよいと思います。

採用しなかった案

Notionは今でもメモや記事管理には便利です。ただ、Appleヘルスケアのような時系列データの正としては、BigQueryに置く方が自分の用途には合っていました。

状態 理由
Notion DBを正にする 不採用 日次メモにはよいが、HRV・心拍数・睡眠segmentのような時系列データの正にはしづらい
Notion CSV Merge 不採用 一括投入には便利だが、継続同期や再実行には向かない
DuckDB 不採用 ローカル分析にはよいが、Claude AIやスマホから読みにくい
Google Sheets external table 不採用 日別ファイルやワイドテーブルの扱いがつらい
Supabase batch sync 保留 二重保持、watermark、重複排除の責務が増える

Discussion