我戴的是 Polar Loop Gen 2,想把训练、睡眠和夜间恢复的数据拉到自己的服务器上,按自己的方式看。Polar 有一套官方接口叫 AccessLink API,个人开发者也能免费申请。这篇把整个接入过程从头到尾写一遍:申请 Client ID 和 Secret、OAuth 授权、注册用户、拉数据、补历史,以及我踩过的坑。最后附一个能直接跑的 Python 脚本。
先看最终效果。下面是我自己用这套接口做的私人看板:





能拿到哪些数据
| 数据 | 接口 | 能拿多久 |
|---|---|---|
| 训练(时长、心率、区间、负荷,可带逐秒心率) | GET /v3/exercises |
最近 30 天,且只有注册之后上传的 |
| 睡眠(入睡/醒来、分期、睡眠分、睡眠图) | GET /v3/users/sleep |
最近 28 天 |
| 夜间恢复 Nightly Recharge(HRV、夜间心率、呼吸、ANS charge) | GET /v3/users/nightly-recharge |
最近 28 天 |
| 每日活动(步数、卡路里) | GET /v3/users/activities?from=&to= |
按日期范围查 |
| 心肺负荷(strain、tolerance、比值、状态) | GET /v3/users/cardio-load |
默认返回最近 28 天 |
| 个人资料(体重、身高、最大心率、VO2max 等) | GET /v3/users/physical-info |
只有当前值,没有历史 |
所有接口的前缀都是 https://www.polaraccesslink.com。上面这些都是官方文档里的“非事务(non-transactional)”接口;老的“事务(transaction)”接口文档里已经标成 deprecated,新项目不用碰。
整体流程
1. 在 Polar 开发者后台创建 client → 拿到 Client ID + Client Secret
2. 让用户在浏览器里授权(OAuth 2.0) → 回调地址收到 code
3. 用 code + Secret 换 access token → 同时拿到 x_user_id
4. POST /v3/users 注册这个用户 → 之后上传的训练才会推给你
5. 定时 GET 各个数据接口,存进自己的库
6. 更早的历史:用 Polar 账户的“下载数据”导出包补
第一步:申请 Client ID 和 Client Secret
这一步只需要一个 Polar Flow 账号(就是手表 App 登录用的那个),不用另外注册开发者。
- 打开 admin.polaraccesslink.com,用 Polar Flow 账号登录。
- 进入创建 client 的页面(Create client),表单分两部分。
组织信息(Organisation):组织名称、地址、城市、省/州、邮编、国家。个人使用就填自己的名字或网站名、自己的地址,不需要公司。
应用信息(Application):
| 字段 | 填什么 |
|---|---|
| Application Name | 应用名,授权页上会显示给用户看,比如 “xxx health” |
| Business contact (email) | 联系邮箱 |
| Description | 用一两句话说明用途,例如:个人健康看板,只同步我自己的训练、睡眠和恢复数据,不分享给第三方 |
| Application Web site | 网站地址,要带 https:// |
| Authorization redirect URL | 最重要的一项:授权完成后 Polar 跳回的地址,例如 https://health.example.com/polar/callback |
填完提交,页面上会显示这个 client 的 Client ID 和 Client Secret。马上把它们存进密码管理器或服务器的环境变量文件里。
这一步的几个注意点:
- 回调地址要一字不差。后面授权和换 token 时传的
redirect_uri必须和这里填的完全一样,协议、域名、路径、结尾斜杠都要对上。我第一次就把它写成了http:://,多了一个冒号,表单也没拦。 - 回调地址最好是 HTTPS,并且是你真正能收到请求的地址。如果暂时没有服务器,也可以先随便填一个自己的域名:授权后浏览器会跳过去,页面打不开没关系,地址栏里的
code=照样能复制出来用(见下面的脚本)。 - Secret 不要发给任何人,不要写进代码仓库,也不要贴到聊天里。泄露了就回后台删掉这个 client 重新建一个。
第二步:OAuth 授权,拿到 code
让用户(也就是你自己)在浏览器里打开下面这个地址:
https://flow.polar.com/oauth2/authorization?response_type=code&client_id=你的ClientID&redirect_uri=你的回调地址&state=一串随机字符
登录 Polar Flow、点同意之后,浏览器会跳到 回调地址?code=xxxx&state=一串随机字符。
state是防 CSRF 用的:发起时随机生成,回调时比对是否一致,不一致就拒绝。- 官方文档说明:code 10 分钟内有效,只能用一次;重复使用会被拒绝,而且会把已经发出的 token 全部作废。
第三步:用 code 换 access token
curl -X POST https://polarremote.com/v2/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Accept: application/json" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="$REDIRECT_URI"
注意换 token 的域名是 polarremote.com,和授权页(flow.polar.com)、数据接口(polaraccesslink.com)都不一样。Client ID 和 Secret 走 HTTP Basic 认证。返回内容大致是:
{"access_token": "...", "token_type": "bearer", "expires_in": 31535999, "x_user_id": 12345678}
把 access_token 和 x_user_id 存好。文档里没有 refresh token。文档一边说 token 除非被撤销否则不会过期,一边又返回了约一年的 expires_in。稳妥的做法是:接口一旦返回 401,就提示自己重新授权一次。
第四步:注册用户
拿到 token 后,还要调用一次注册接口,Polar 才会把这个用户的数据开放给你的 client:
curl -X POST https://www.polaraccesslink.com/v3/users \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"member-id": "my-own-id-001"}'
member-id 是你自己给这个用户起的编号。返回 409 表示已经注册过,可以当成功处理。
这里有一个大坑:训练接口只返回注册之后上传到 Flow 的训练。我刚接好时调 /v3/exercises,拿到的是空列表,原因就是所有训练都在注册之前。睡眠、恢复、活动、负荷不受这个限制,能拿到最近 28 天。
第五步:拉数据
所有请求都带上 Authorization: Bearer token 和 Accept: application/json。没有数据时会返回 204(没有响应体),代码里要单独处理,别直接解析 JSON。
H="Authorization: Bearer $ACCESS_TOKEN"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/exercises?zones=true"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/exercises/训练id?samples=true"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/sleep"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/nightly-recharge"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/activities?from=2026-10-07&to=2026-11-04"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/cardio-load"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/physical-info"
几个解析时要知道的细节:
- 训练:
zones=true会带上heart_rate_zones(每个区间的上下限和停留时间,时间是 ISO 8601 格式,比如PT4M3S)。时长duration也是这种格式。 - 逐秒心率:单条训练加
samples=true,在samples里找sample-type为"0"的那组,就是心率(bpm)。data是逗号分隔的字符串,recording-rate是采样间隔(秒)。数据量很大,建议只拉一次,降采样后存起来。 - 睡眠图
hypnogram是“时刻 → 阶段编号”的字典。官方文档没给编号含义,我拿导出包逐条对照过:0 清醒、1 REM、2 和 3 浅睡、4 深睡、5 未识别。 - 睡眠列表在
nights字段里,恢复列表在recharges字段里,不是直接返回数组。 - 心肺负荷:
strain和tolerance我用导出包验算过,分别等于最近 7 天和最近 28 天的日均训练负荷。
同步频率:数据接口只覆盖最近 28–30 天,所以至少每隔几天拉一次。我是每 6 小时拉一次,按(类型,日期或训练 id)为主键 upsert,重复拉不会产生重复数据。也可以用官方的 Webhook(POST /v3/webhooks,支持 EXERCISE、SLEEP、ACTIVITY_SUMMARY 等事件)让 Polar 主动推送;但推送连续 7 天失败会被自动停用,个人项目用定时轮询更省事。
速率限制:按注册用户数动态计算,15 分钟内 500 + 用户数 × 20 次,24 小时内 5000 + 用户数 × 100 次,超了返回 429。个人使用远远碰不到。
第六步:用导出包补历史
接口只有近一个月的数据,更早的要靠 Polar 账户的数据导出:登录 account.polar.com,找到下载数据(Download your data),等 Polar 打包好后下载一个 zip。
解压后是一堆 JSON 文件。和 Polar 帮助页的说法不同,我拿到的导出包里有睡眠和夜间恢复数据:
| 文件 | 内容 |
|---|---|
training-session_*.json |
每次训练,含心率区间和逐点心率 |
sleep_result_*.json / sleep_score_*.json |
睡眠分期和睡眠分 |
nightly_recovery_*.json |
夜间恢复(HRV、心率、ANS) |
activity-*.json |
每日活动 |
fitness-test-results-*.json |
体能测试(VO2max),接口拿不到 |
导出包的字段名和接口不一样,需要做一次转换。我对照同一晚的数据核对过这几个换算:
- 夜间心率 =
floor(60000 / meanNightlyRecoveryRri) - HRV =
meanNightlyRecoveryRmssd - ANS charge =
ansStatus - 睡眠阶段
WAKE / REM / NONREM1 / NONREM2 / NONREM3对应接口编码 0 / 1 / 2 / 3 / 4 recoveryTimeMillis是字符串,记得先转成数字
导入时注意去重:同一次训练可能接口里也有,按开始时间(精确到分钟)比对;并且不要让导出包覆盖接口来的数据。
完整示例脚本
下面这个脚本把前面的步骤串起来,只依赖 httpx。我用自己的账号实际跑过 pull,各个接口都正常返回。
"""Polar AccessLink 最小接入脚本:授权 → 换 token → 注册用户 → 拉数据。
准备:pip install httpx
export POLAR_CLIENT_ID=你的ClientID
export POLAR_CLIENT_SECRET=你的ClientSecret
export POLAR_REDIRECT_URI=https://你的域名/polar/callback # 必须和后台填的完全一致
python polar_sync.py auth # 1. 打印授权链接,浏览器打开并同意
python polar_sync.py token <code> # 2. 用回调地址里的 code 换 token,并注册用户
python polar_sync.py pull # 3. 拉最近的数据,存到 data/*.json
"""
import json
import os
import secrets
import sys
from datetime import date, timedelta
from pathlib import Path
from urllib.parse import urlencode
import httpx
CID = os.environ["POLAR_CLIENT_ID"]
SECRET = os.environ["POLAR_CLIENT_SECRET"]
REDIRECT = os.environ["POLAR_REDIRECT_URI"]
API = "https://www.polaraccesslink.com/v3"
TOKEN_FILE = Path("polar_token.json")
def auth():
q = {"response_type": "code", "client_id": CID, "redirect_uri": REDIRECT, "state": secrets.token_urlsafe(16)}
print("https://flow.polar.com/oauth2/authorization?" + urlencode(q))
def token(code):
r = httpx.post(
"https://polarremote.com/v2/oauth2/token",
data={"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT},
auth=(CID, SECRET), # HTTP Basic:client_id:client_secret
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
tok = r.json() # access_token, token_type, expires_in, x_user_id
reg = httpx.post(
f"{API}/users",
json={"member-id": f"me-{secrets.token_hex(4)}"},
headers={"Authorization": f"Bearer {tok['access_token']}", "Accept": "application/json"},
timeout=30,
)
if reg.status_code != 409: # 409 = 这个用户已经注册过,可以忽略
reg.raise_for_status()
TOKEN_FILE.write_text(json.dumps(tok))
TOKEN_FILE.chmod(0o600)
print("token 已保存,Polar 用户 id:", tok["x_user_id"])
def pull():
tok = json.loads(TOKEN_FILE.read_text())
headers = {"Authorization": f"Bearer {tok['access_token']}", "Accept": "application/json"}
out = Path("data")
out.mkdir(exist_ok=True)
today = date.today()
with httpx.Client(base_url=API, headers=headers, timeout=60) as c:
def get(path, **params):
r = c.get(path, params=params or None)
if r.status_code == 204: # 没有数据
return None
r.raise_for_status()
return r.json()
data = {
"exercises": get("/exercises", zones="true"),
"sleep": (get("/users/sleep") or {}).get("nights", []),
"recharge": (get("/users/nightly-recharge") or {}).get("recharges", []),
"activities": get("/users/activities", **{"from": (today - timedelta(days=28)).isoformat(), "to": today.isoformat()}),
"cardio_load": get("/users/cardio-load"),
"physical": get("/users/physical-info"),
}
for name, v in data.items():
(out / f"{name}.json").write_text(json.dumps(v, ensure_ascii=False, indent=1))
print(f"{name:12s}", len(v) if isinstance(v, list) else ("有" if v else "无"))
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "auth":
auth()
elif cmd == "token" and len(sys.argv) == 3:
token(sys.argv[2])
elif cmd == "pull":
pull()
else:
print(__doc__)
踩坑清单
- 回调地址前后不一致(包括 http/https、结尾斜杠),换 token 会失败。
- code 只能用一次,10 分钟过期;重复使用会把已发的 token 全部作废。
- 忘了
POST /v3/users注册,或者训练是在注册之前上传的,训练列表就是空的。 - 没有数据时返回 204,没有响应体,直接
.json()会报错。 - 接口只给 28–30 天,停同步一个月就会丢数据,要靠导出包补。
- 个人资料接口只能读、不能写,体重这些要在 Polar Flow App 里改。
参考:Polar AccessLink API v3 官方文档。
请我喝杯咖啡
