Whoop 数据采集管道:从 OAuth 到实时推送的完整方案

通过 Whoop 官方 API 和 Webhook 实现健康数据自动采集、结构化存储和实时推送的完整管道。

问题

Whoop 提供恢复分、HRV、睡眠质量等高价值健康数据,但只能在官方 App 里查看。需要一种方式自动拉取数据,用于自定义分析和跨设备整合。

好消息:Whoop 有官方开发者 API,文档清晰,标准 OAuth2。坏消息在 Garmin 篇里。

架构总览

两条数据路径:
  定时拉取:
    触发: 每天 21:00 cron
    流程: API 拉取 → 生成快照 → 存储落盘 → 推送通知
    适用: 每日汇总、周报生成

  实时推送:
    触发: Whoop 服务端 Webhook(睡醒/运动结束时自动触发)
    流程: Webhook 接收 → 防抖 → 延迟 10 秒 → 拉取最新数据 → 推送通知
    适用: 实时感知身体状态变化

认证:OAuth2 完整流程(从零到拿到 Token)

Step 1 · 创建 Whoop 应用

开发者后台: https://developer.whoop.com/console

操作:
  1. 注册开发者账号
  2. 创建应用
  3. 记录以下凭证:
     - client_id: 应用标识
     - client_secret: 应用密钥
  4. 配置 Redirect URL:
     - 推荐用 Postman 回调: https://oauth.pstmn.io/v1/callback
     - 这是已验证最稳定的方案,避免自己搭回调服务器
     - 如果用自己的服务器: https://<你的域名>/callback

Step 2 · 发起 OAuth 授权(拿 code)

授权 URL 模板:
  https://api.prod.whoop.com/oauth/oauth2/auth
    ?client_id=<WHOOP_CLIENT_ID>
    &response_type=code
    &redirect_uri=https://oauth.pstmn.io/v1/callback
    &scope=offline read:recovery read:cycles read:sleep read:workout read:profile
    &state=whoop_auth_<随机数字>

关键参数:
  scope 必须包含 "offline":
    - 没有 offline scope → 拿不到 refresh_token
    - 没有 refresh_token → token 过期后必须人工重新授权
    - 有 refresh_token → 可以无人值守自动续期

  scope 建议包含 "read:cycles":
    - cycles 包含 Whoop 的“周期”数据(每日循环)
    - 某些端点可能需要这个权限

  redirect_uri 推荐用 Postman 回调:
    - 已验证可靠,避免自己搭回调服务器
    - 用自己服务器回调容易因 HTTPS/域名/路径问题失败

流程:
  1. 在浏览器中打开上面的 URL
  2. 登录你的 Whoop 账号并授权
  3. 授权成功后浏览器会跳转到 redirect_uri
  4. URL 参数里会带一个 code=<AUTH_CODE>
  5. 复制这个 code,有效期很短,必须立刻用

Step 3 · 用 code 换 Token

请求:
  方法: POST
  端点: https://api.prod.whoop.com/oauth/oauth2/token
  Content-Type: application/x-www-form-urlencoded
  参数:
    grant_type: authorization_code
    code: <上一步拿到的 AUTH_CODE>
    redirect_uri: <和授权时一致的 REDIRECT_URI>
    client_id: <WHOOP_CLIENT_ID>
    client_secret: <WHOOP_CLIENT_SECRET>

返回示例:
  access_token: "eyJhbG..."    # 访问令牌
  refresh_token: "abc123..."   # 刷新令牌(关键!必须保存)
  expires_in: 3600             # 有效秒数
  token_type: "bearer"

保存:
  将返回 JSON 加上 client_id、client_secret、created_at 字段
  保存到本地文件(权限设为 600,仅拥有者可读)
  这个文件就是后续所有 API 调用和自动刷新的基础

Token 安全规则

必须做:
  - Token 文件权限 chmod 600(仅拥有者可读)
  - 不要提交到 Git(加入 .gitignore)
  - 不要发到群聊/聊天工具里
  - refresh_token 更新后必须立刻保存覆盖文件

泄露处理:
  - 立刻在 Whoop 开发者后台重置密钥
  - 重新走一遍 OAuth 授权流程

Token 生命周期

access_token:
  有效期: 约 1 小时(由 expires_in 字段指定)
  刷新方式: 使用 refresh_token 换新
  注意: 服务端可能提前吊销(即使本地计算未过期)

refresh_token:
  有效期: 较长(数周到数月)
  使用后: 服务端可能返回新的 refresh_token,必须保存

token 文件结构:
  - access_token: 当前访问令牌
  - refresh_token: 刷新令牌
  - client_id: 应用 ID
  - client_secret: 应用密钥
  - expires_in: 有效秒数
  - created_at: 创建时间(ISO 格式,注意时区是 UTC)

Token 刷新策略

正常刷新:
  时机: 距过期不足 5 分钟时主动刷新
  端点: https://api.prod.whoop.com/oauth/oauth2/token
  参数:
    grant_type: refresh_token
    refresh_token: <当前 refresh_token>
    client_id: <应用 ID>
    client_secret: <应用密钥>

异常处理(已验证的坑):
  场景: API 返回 401 但本地计算 token 未过期
  原因: 凌晨 cron 多次刷新导致旧 token 被服务端提前吊销
  解法: 遇到 401/403 时强制刷新 token 并重试一次
  规则: 只重试一次,避免死循环

API 端点

base_url: https://api.prod.whoop.com/developer/v2/

核心端点:
  恢复数据:
    path: /recovery?limit=3
    返回: 恢复分、静息心率、HRV RMSSD、血氧、皮温
    注意: 按时间倒序,取 36 小时内最新的有效记录

  睡眠数据:
    path: /activity/sleep?limit=3
    返回: 睡眠时长、深睡/REM/浅睡分布、效率、一致性、呼吸频率
    注意: stage_summary 里的时间单位是毫秒

  运动数据:
    path: /activity/workout?limit=3
    返回: 运动类型、时长、Strain、卡路里(单位 kilojoule,需除以 4.184 转 kcal)
    注意: 只取 24 小时内的记录

认证方式:
  header: Authorization: Bearer <access_token>

Webhook 实时推送

工作原理

注册方式: Whoop 开发者后台配置 Webhook URL

订阅事件:
  - sleep.updated: 睡眠数据更新
  - recovery.updated: 恢复分更新
  - workout.updated: 运动数据更新

推送内容: 事件通知(不含完整数据,收到后需主动调 API 拉取最新数据)

接收端需要:
  - 公网可达的 HTTPS 端点
  - 返回 200 表示收到

重要细节:
  - sleep.updated 和 recovery.updated 经常同时触发(同一次睡眠结束)
  - 必须做防抖合并,否则会收到重复推送

Webhook Server 设计

核心逻辑:
  1. 接收 POST /webhook
  2. 保存原始 payload(调试用)
  3. 防抖检查(15 秒内重复推送只处理一次)
  4. 延迟 10 秒等待 Whoop 服务端数据同步
  5. 调用数据拉取脚本获取最新数据
  6. 生成简报 + 落盘 + 推送通知

为什么要延迟 10 秒:
  Webhook 触发时数据可能还没同步到 API,立刻拉取会拿到旧数据

为什么要防抖:
  Whoop 可能在短时间内连续触发多个 Webhook(同一次睡眠结束触发 sleep + recovery)

端口: 3456
路由:
  - POST /webhook 或 /whoop: 接收推送
  - GET /health: 健康检查
  - GET /callback: OAuth 回调(用于初次授权)

公网暴露方案

方案: Cloudflare Tunnel(Zero Trust)
优势:
  - 不需要开放端口
  - 自动 HTTPS
  - 不暴露真实 IP

配置:
  域名映射: <你的域名> → http://localhost:3456
  在 Tunnel 配置文件中添加 ingress 规则
  Webhook URL 填写: https://<你的域名>/webhook

注意:
  - 断电后 Tunnel 和 Webhook Server 都需要重启
  - 建议用 LaunchAgent 或 systemd 设置自启动

数据格式:人机双读快照

格式: Markdown 正文 + HTML 注释内嵌 JSON

结构:
  人类层:
    - "# WHOOP — YYYY-MM-DD"
    - Sleep 段:时长、深睡、REM、效率、一致性
    - Recovery 段:恢复分、心率、HRV、血氧、皮温
    - Workout 段:类型、时长、Strain、卡路里
    - Debts 段:睡眠债、恢复债

  机器层:
    包裹在 "<!--WHOOP_DAILY_JSON ... WHOOP_DAILY_JSON-->" 中
    包含所有字段的结构化 JSON
    Agent 用正则提取: /<!--WHOOP_DAILY_JSON\s*([\s\S]*?)\s*WHOOP_DAILY_JSON-->/

关键指标计算:
  实际睡眠时长: total_in_bed_time_milli - total_awake_time_milli
  睡眠债: max(0, 目标睡眠时长 - 实际睡眠时长)
  恢复债: max(0, 67 - 恢复分)  # 67 是绿区门槛

已验证的踩坑经验

坑1_Token时区:
  现象: 本地判断 token 未过期,但 API 返回 401
  原因: created_at 是 UTC 时区,本地如果按本地时间计算会偏移
  解法: 统一用 UTC 计算过期时间

坑2_服务端吊销:
  现象: 凌晨 cron 和 Webhook 各自刷新 token,导致互相覆盖
  原因: 每次刷新旧 access_token 就失效,但另一条路径还在用旧的
  解法: Token 文件加锁,或统一只有一条路径刷新

坑3_Webhook重复注册:
  现象: 同一事件推送了多次
  原因: 开发者后台注册了多个 Webhook URL
  解法: 只保留一个活跃的 Webhook URL

坑4_断电恢复:
  现象: Webhook Server 停了但没人知道
  解法: 设置健康检查端点(GET /health),配合定期探测
  更好的解法: 用 LaunchAgent/systemd 设置自启动

坑5_推送内容与App不一致:
  现象: 推送的数据看起来和 App 里显示的不一样
  原因: App 的"今天"口径和 API 返回的时间口径可能不同
  解法: 每条数据都附上数据时间字段(恢复: created_at,睡眠: end,运动: end)
  教训: 别用"今天"这个词,用具体日期

坑6_拉取空白数据:
  现象: Webhook 触发后立刻拉取,拿到的是旧数据
  原因: Whoop 服务端数据同步有延迟
  解法: 收到 Webhook 后延迟 10-20 秒再拉取
  教训: 如果拉取后还是没数据,不要推送空白消息

坑7_Tunnel域名不匹配:
  重要程度: ⭐⭐⭐⭐⭐
  现象: Webhook Server 在跑、端口在监听、本地测试正常,但外部请求收不到
  原因: Whoop 后台填的域名(如 whoop-callback.example.com)没有配在 Tunnel 的 ingress 规则里
  解法: 确保 Tunnel 配置文件的 hostname 和 Whoop 后台的 Webhook URL 域名完全一致
  教训: Tunnel 配置改动后必须交叉检查所有依赖这个 Tunnel 的服务的域名是否还在

坑8_LaunchAgent环境变量缺失:
  重要程度: ⭐⭐⭐⭐
  现象: 手动跑脚本正常,但 LaunchAgent 启动的服务调同一脚本报 "command not found"
  原因: LaunchAgent 的 PATH 只有系统默认路径,不包含用户安装的工具(如 npm global bin)
  解法: 脚本中调用外部命令用绝对路径,不要依赖 PATH
  示例: 用 "/Users/xxx/.npm-global/bin/openclaw" 而不是 "openclaw"
  教训: 任何被 LaunchAgent/systemd 调度的脚本,所有外部命令都必须用绝对路径

坑9_Cloudflare拦截特定路径:
  重要程度: ⭐⭐⭐
  现象: 本地直连 /webhook 和 /whoop 都正常,但经 Cloudflare Tunnel 后 /webhook 返回 404
  原因: Cloudflare 的 WAF/安全规则可能对某些通用路径名有默认拦截
  解法: 用不常见的路径名(如 /whoop)代替通用名(如 /webhook)
  教训: 部署后必须从外部测试实际 URL,不能只测本地

适用场景

这套管道适用于:

  • 需要自动获取 Whoop 数据用于自定义分析
  • 需要实时感知身体状态变化(睡醒推送、运动结束推送)
  • 需要将 Whoop 数据与其他设备(Garmin、Apple Watch)交叉分析
  • 需要长期积累健康数据做趋势分析

核心优势:官方 API + Webhook 双通道,既能定时汇总,又能实时响应。

半胆浣熊

文科生,不会代码,但很幸运 —— 赶上了 AI 的年代。
这里是我的实战学习笔记。

← 返回文章列表