iPhone の Apple Health を Next.js サイトに自動で流し込む ― 翌朝コンディション付きランブログの作り方
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして読んでもらえると幸いです。
はじめに
走った記録を載せているランナーのブログは、いくらでもある。距離、ペース、心拍。でも、その走りの「前の晩どれだけ眠れたか」「今朝の心拍変動はどうだったか」まで横に並べているサイトは、自分はほとんど見たことがない。
そこを空けておくのはもったいない、と思った。トップに「今朝のコンディション」を出し、/condition では Apple Health の指標を時系列で見せる。そのために、iPhone の Apple Health を Next.js のサイトへ自動で流し込む土管を1本通した。
派手な機能には見えないと思う。実際、できあがってみれば地味なパイプラインだ。ただ、この地味な配管に意外と手こずったので、通し方とハマりどころを残しておく。
なぜ翌朝コンディションを出したいのか
きっかけは2つある。1つは、走りそのものと、その日の体の状態を並べて見たかったこと。もう1つは、正直に言えば差別化だ。
学生のころから「体調管理も練習のうち」と言われ続けてきた。体は資本で、ここを崩すとレースにも出られなくなる。当たり前の話なのだけど、続けるほどこの当たり前が効いてくる。だから自分が走るうえで、どこまで体調を見ながら走れているかは、ずっと気にしている部分だった。
ランニングは前夜の睡眠や自律神経の状態にかなり引っ張られる。よく眠れた朝は同じペースが軽いし、寝不足の朝は心拍が上がりやすい。その対応を、頭の中の感覚で終わらせずに自分のサイトの上で見えるようにしたかった、というのが動機だ。
iPhone の Health Auto Export を仕込む
Apple Health のデータを外に出す入口には、Health Auto Export(HAE)というアプリを使った。HAE には REST API へ定期 POST する Automation 機能があり、これを使うと iPhone から自分のサーバーへ JSON を自動で送れる。
設定の勘どころは2つ。
- 送信先を自分の
/api/health/ingestにして、ヘッダにAuthorization: Bearer <トークン>を付ける。 - 1リクエストが大きくなりすぎないよう、HAE 側の Batching を ON にする。過去全期間を一度に送ると平気で何十 MB にもなるので、Vercel の body 上限を考えると分割は必須だった。
Bearer 認証つき ingest API を作る
受信側の /api/health/ingest は、認証 → サイズチェック → JSON parse → Zod 検証 → 圧縮 → KV 保存、という素直な段構えにした。認証は timing-safe な Bearer 比較で、Cron 用の CRON_SECRET とは別のトークン(HEALTH_INGEST_TOKEN)にしている。漏れたときに影響範囲を切り分けたかったからだ。
// app/api/health/ingest/route.ts(認証部)
function verifyAuth(
request: Request,
): { ok: true } | { ok: false; status: number; reason: string } {
const secret = process.env.HEALTH_INGEST_TOKEN;
if (!secret) {
// 本番で未設定のまま公開しないためのフェイルセーフ。
return { ok: false, status: 500, reason: "HEALTH_INGEST_TOKEN is not configured" };
}
const header = request.headers.get("authorization") ?? "";
if (!timingSafeEqual(header, `Bearer ${secret}`)) {
return { ok: false, status: 401, reason: "unauthorized" };
}
return { ok: true };
}
body のサイズチェックは Content-Length ヘッダではなく実バイト長で行う。問い合わせフォームや購読 API と同じ方針で、ヘッダを信用しない。
const MAX_BODY_BYTES = 8 * 1024 * 1024;
function utf8ByteLength(text: string): number {
return new TextEncoder().encode(text).byteLength;
}
外部から来る JSON は当然信用できないので、Zod でスキーマ検証してから扱う。safeParse に失敗したら 400 を返し、最初の数件の issue を一緒に返してデバッグできるようにした。
const parsed = HaeIngestPayloadSchema.safeParse(raw);
if (!parsed.success) {
return Response.json(
{ error: "スキーマ検証に失敗しました", issues: parsed.error.issues.slice(0, 5) },
{ status: 400 },
);
}
生 HAE JSON は重いので、1日1件に圧縮して KV へ
HAE が送ってくる JSON は、そのまま貯めるには重い。心拍やステップは1日の中に何点もあるし、1リクエストに複数日が混ざって届くこともある(睡眠は前夜、心拍は当日、というふうに)。
なので受信時に 生 HAE JSON を「1日1件の DailyHealthSummary」に圧縮してから KV に書く。lib/health-extract.ts がその変換を持っていて、指標ごとに集約方法を変えている。
- 安静時心拍・VO2 Max は1日1点しかないので最後の値を採用
- HRV は日内に複数点あるので平均
- 歩数・各種カロリー・距離・時間系は合計
// lib/health-extract.ts のヘッダコメントより
// 集約方法は指標ごとに違う:
// last: resting_heart_rate / vo2_max → 1 日 1 点しかないので最後
// mean: hrv → 日内複数点 → 平均
// sum: step_count / active_energy / walking_running_distance / 各時間系 → 合計
// - HAE の active_energy は kJ なので kcal に変換 (× 0.239) して保存。
集約日は文字列の日付(YYYY-MM-DD)で取る。睡眠だけは特別扱いで、HAE が data[].date に「目覚めた日」を入れてくるので、それを計測日として採用している。
保存先は Upstash KV。日次キー(health:daily:<YYYY-MM-DD>)に summary を置き、日付の Sorted Set にも入れて範囲取得できるようにした。TTL は最初90日にしていたが、/condition で長期トレンドを見せたくなったので途中で撤廃して無期限保持にした。
HAE V2 の罠:avgHeartRate が {qty, units} の object だった
ここで一度ハマった。
HAE のワークアウト系フィールドは、バージョンによって素の数値で来る場合と、{qty, units} のオブジェクトで来る場合がある。distance や avgHeartRate、maxHeartRate がそれで、V2 ではオブジェクト形式だった。数値前提で Zod スキーマを書いていると、ここで検証が落ちる。
対処は、数値と {qty} の両方を受ける union にすることだった。
// lib/health-types.ts
// HAE V2 の workout フィールドは数値とオブジェクト {qty, units} の両形態がある。
// V1 互換と将来の HAE 更新に耐えるため、数値 or {qty} の union で受ける。
const QtyOrNumber = z.union([
z.number(),
z.object({ qty: z.number(), units: z.string().optional() }).passthrough(),
]);
厄介だったのは、これが派手に落ちてくれないことだった。iPhone の HAE は POST を投げて非 200 が返ってもアプリ上は静かなままで、サイト側を見ても「コンディションが更新されない」だけ。落ちている自覚がないまま半日くらい放置していた。
気づいたのは、/api/health/ingest のレスポンスを手元で叩き直して、返ってきた 400 の issues に avgHeartRate の型不一致が並んでいるのを見たときだ。Zod に最初の数件の issue を返させておいたのが、ここで効いた。エラーを握りつぶさずに「どのフィールドがどう違うか」を本文に出していたおかげで、原因のフィールドが一発で分かった。
外部のエクスポートアプリが相手だと、こういう「同じ意味のフィールドが別の形で来る」差分は避けられない。union で逃がしておくと、HAE 側のアップデートにも巻き込まれにくくなる。
自律神経スコアを作る ― 固定係数だと乱高下するので14日 z-score に
トップの「今朝のコンディション」には、HRV・安静時心拍・呼吸数から合成した自律神経スコア(0〜100)を出している。最初は素朴に固定係数で組んでいた。「安静時心拍が1bpm 上がったら何点引く」というやつだ。
これが、うまくいかなかった。
安静時心拍は普通に過ごしていても日々2〜3bpm は揺れる。固定係数だと、その普段の揺れだけでスコアが数十点も乱高下する。「今日は調子が悪い」と出ても、ただの計測のばらつきだった、ということが頻発した。
そこで、固定係数をやめて 14日ベースラインからの z-score に切り替えた。直近14日のその人の平均と標準偏差を取り、「いつもの揺れ幅に対して今朝はどれだけ外れているか」で測る。
// lib/health-aggregate.ts
function zComponent(
today: number | null,
mean: number | null,
sd: number | null,
/** good 方向が「高い」なら +1、「低い」なら -1 */
goodSign: 1 | -1,
weight: number,
): ZComponent | null {
if (today == null || mean == null || sd == null || sd < 1e-6) return null;
return { z: (goodSign * (today - mean)) / sd, weight };
}
export function composeAutonomicScore(components: ReadonlyArray<ZComponent | null>): number | null {
const valid = components.filter((c): c is ZComponent => c != null);
if (valid.length === 0) return null;
const totalWeight = valid.reduce((s, c) => s + c.weight, 0);
const weightedZ = valid.reduce((s, c) => s + c.z * c.weight, 0) / totalWeight;
return Math.round(clamp(50 + weightedZ * 15, 0, 100));
}
この実装で意図的に効かせている判断が4つある。
- 標準偏差で割るので、その人の普段の揺れに対して相対化される。固定係数のような「2〜3bpm でスコアが暴れる」が起きない。
- 良い方向を符号でそろえる。HRV は高いほど良い、安静時心拍と呼吸数は低いほど良いので、後者は符号を反転させる。
50 + z * 15を 0〜100 にクランプ。z が ±1 で 35/65、±2 で 20/80 あたりになる。- 重みは HRV 0.4・安静時心拍 0.4・呼吸数 0.2。呼吸数は欠けやすいので補助扱いで、欠けても残り2つで算出できるよう重みを再正規化する。
固定係数のころは、同じくらいの体調でも前日が 70 で翌日が 40、みたいな出方をすることがあった。スコアを見て「そんなに落ちてたっけ」と首をかしげる場面が多くて、これだと数字を信用しなくなる。表示している側が信用できないものを、見にきた人に出すわけにもいかない。
z-score に切り替えてからは、スコアの動き方が体感とそろってきた。普段どおりの朝は 50 前後で落ち着き、明らかに寝不足の朝はちゃんと下がる。「いつもの揺れ幅に対して今朝はどうか」で測るようにしただけなのだけど、グラフを見て納得できる動きになったのが大きい。
まとめ
走りの数字に体調の数字を重ねる。やりたいことは一行で言えるのに、間に通すパイプラインは思ったより地味で手数が多かった。通った道筋はこうだ。
- Health Auto Export の REST Automation で iPhone から自動 POST
- Bearer 認証つきの ingest API(Zod 検証 + 実バイト長の body cap)で受ける
- 生 HAE JSON を1日1件に圧縮して Upstash KV に無期限保存
- HAE V2 の
{qty, units}object 形式は union で逃がす - 自律神経スコアは固定係数をやめ、14日 z-score 合成で安定させた
走りとコンディションの相関について、ここで何か断言できる発見があったか、と言われると、正直まだない。作ったばかりで、データもこれから貯まっていく段階だ。寝不足の翌朝はスコアが下がる、くらいの当たり前は見えるが、それを「発見」と書くのは早い。むしろこの先、距離を踏んだ週の翌週に何が起きるか、レース前後でどう動くか――そういうのを、これから自分のデータで見ていきたいと思っている。
差別化として効いているかも、まだ答え合わせの途中だ。ただ、他のランナーブログに無いものを、と思って作った機能ではある。データの土管自体は地味だが、トップに毎朝のコンディションが出るようになって、走りの記録に体調という軸が1本増えた。その軸が意味を持ち始めるのは、たぶんもう少しデータが溜まってからだ。
シリーズナビ
関連する記事
この記事を書いた人

気に入ったら、次の記事もメールで。月1〜2通だけ。
[ Next Action / 次の動き ]
走行データは、こちらから。
読了ありがとうございます。気が向いたら、生データもどうぞ。