每天两次定时把 Keep 上的室内骑行训练记录同步到 Strava,跑在 GitHub Actions 上,无服务器、零运维。
Fork → 配置 6 个 secrets → 触发首次同步即可,全程约 15 分钟。
Keep 是国产室内骑行平台,但没有官方的 Strava 集成。这个项目做的事:
- 模拟 Android 客户端调 Keep 内部 API,把训练记录的功率、心率、速度、踏频、阻力时间序列拉下来
- 重建成 1Hz 数据(PCHIP 单调三次样条插值,把 1s/5s/15s 混采样统一到秒级)
- 生成 Garmin 兼容的 FIT 文件(
sport=cycling, sub_sport=virtual_activity→ Strava 识别为 "Virtual Ride") - 上传到 Strava,自动去重,描述里附阻力分布
部署到 GitHub Actions 之后每天 12:00 / 00:00 Asia/Shanghai 自动跑,全程无人值守。
- 三层去重:
external_id/start_time (±5s)/ 本地 FIT 缓存,重跑不会重复传,也不会浪费 Keep API - 阻力可视化:阻力档位变化用 FIT 的
REAR_GEAR_CHANGE事件编码——这正是 Di2/eTap 电子变速器写入 FIT 的方式,Strava 自带渲染逻辑,能画成阻力时间线 - 断点续跑:CI 中途失败、网络抖动都没关系,下次自动接着跑,已传的会被跳过
- 凭据隔离:全走 GitHub Action Secrets,仓库代码里没有任何凭据
- 可选 gear 绑定:
STRAVA_GEAR_ID让同步过来的活动自动挂到 Strava 上指定的单车装备 - 个人备注追溯:每个上传的活动
private_note里写 Keep 的 sport_id,方便对账
┌───────────┐ ┌─────────────────┐ ┌──────────────┐
│ Keep API │ ──▶ │ PCHIP 重采样到 │ ──▶ │ Strava API │
│ (4 endpoints) │ 1Hz + FIT 编码 │ │ upload+PUT │
└───────────┘ └─────────────────┘ └──────────────┘
│ │ │
│ │ │
cookie+token step_hold external_id
spider sleep (阻力非连续) dedup 索引
分页 sport list
每个 workout 的处理(见 process_workout):
| 步骤 | 动作 | 何时跳过 |
|---|---|---|
| 1 | 查 Strava external_id 索引 |
sport_id 已存在 → 不调 Keep |
| 2 | 调 get_sport_data 拿 startTime |
— |
| 3 | 按 startTime 算 FIT 文件名 | — |
| 4 | 查 Strava start_time 索引(±5s) |
时间匹配 → 不调 fence/chart |
| 5 | 查本地 FIT_OUT/{filename} |
本地有 → 不调 fence/chart |
| 6 | 调 get_sport_fence + get_sport_chart |
— |
| 7 | PCHIP / step-hold 重采样到 1Hz | — |
| 8 | 写 FIT 文件 | — |
| 9 | 上传 + PUT 元数据 | — |
点右上角 Fork 按钮,把仓库 fork 到你自己的账号。
强烈建议把你的 fork 设成 private:Fork 完之后到
Settings → General → Danger Zone → Change visibility → Make private。代码里没有凭据,但 workflow 跑起来的 logs 会包含你的训练时间、sport_id 等数据,私库更稳。
去 strava.com/settings/api 创建 app,记下 Client ID 和 Client Secret。Authorization Callback Domain 填 localhost 即可(只用于一次性拿 refresh_token)。
然后走一次 OAuth:
# 浏览器打开(替换 CLIENT_ID)
open "https://www.strava.com/oauth/authorize?client_id=CLIENT_ID&redirect_uri=http://localhost&response_type=code&scope=activity:write,activity:read_all&approval_prompt=force"
# 授权后浏览器跳到 http://localhost/?code=XXXXX&...
# 页面会显示连接失败 (因为本地没有 localhost server),但 URL 里的 code 就是要的,复制下来
# 用 code 换 refresh_token(三处都要替换)
curl -s -X POST https://www.strava.com/oauth/token \
-d client_id=CLIENT_ID \
-d client_secret=CLIENT_SECRET \
-d code=CODE \
-d grant_type=authorization_code返回 JSON 里 refresh_token 字段就是要存的(不是 access_token,那个 6 小时就过期)。
Scope 必须同时包含
activity:write(上传)和activity:read_all(dedup 用),缺一不可。如果你以前已经授权过这个 app 但 scope 不全,加approval_prompt=force会强制重新授权。
如果想让同步过来的活动自动挂到你 Strava 的某辆 bike 装备上:
网页:strava.com/settings/gear → 点你的单车 → URL 是 https://www.strava.com/bikes/12345678 → gear_id 就是 b12345678(记得加 b 前缀)
API:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://www.strava.com/api/v3/athlete | jq '.bikes'不需要的话跳过即可,少配一个 secret。
备注:如果你 Strava 账号里有"默认装备"设置(在 Settings → My Gear 里把某辆 bike 标为 default),新上传的活动会自动挂上去;这种情况下也不需要
STRAVA_GEAR_ID。
打开你的 fork 仓库 → Settings → Secrets and variables → Actions → New repository secret,依次加:
| Name | 值 | 必需? |
|---|---|---|
KEEP_PHONE |
Keep 账号手机号 | ✓ |
KEEP_PASSWORD |
Keep 账号密码 | ✓ |
STRAVA_CLIENT_ID |
Strava app client id | ✓ |
STRAVA_CLIENT_SECRET |
Strava app client secret | ✓ |
STRAVA_REFRESH_TOKEN |
步骤 2 拿到的 refresh token | ✓ |
STRAVA_GEAR_ID |
b12345678 这种格式 |
可选 |
加完后 Secrets 页面只看得到名字、看不到值(GitHub 加密保存)—— 正常。
Fork 过来的仓库默认 scheduled workflows 是禁用的(GitHub 防滥用机制),需要手动启用一次:
- 进 fork 的
Actionstab - 点中间的 "I understand my workflows, go ahead and enable them" 按钮(如果有)
- 左侧栏点 Sync Keep to Strava
- 右上 Run workflow → Run workflow 触发首次同步
首次运行 10–15 分钟(全量同步),之后每次 30 秒左右(只处理新 workout)。
到 strava.com/athlete/training,新上传的活动 description 应该长这样:
从 Keep 同步
sport_id: 673e065bc0d6dd0001890281_9223370...
阻力: 平均 5 / 最大 6 / 最小 2
阻力时长: lv6 30min(41%) · lv5 19min(26%) · lv4 10min(15%) · lv3 9min(12%) · ...
类型应该是 VirtualRide,挂在你指定的装备上。点进去能看到功率、心率、踏频、速度,以及 Gears(阻力档位)几张图。
所有 CLI 参数都有同名大写 env var fallback,本地开发可以走 env,CI 走 args(workflow YAML 里已经配好):
| CLI arg | Env var | 必需? | 说明 |
|---|---|---|---|
--keep-phone |
KEEP_PHONE |
✓ | Keep 账号手机号 |
--keep-password |
KEEP_PASSWORD |
✓ | Keep 账号密码 |
--strava-client-id |
STRAVA_CLIENT_ID |
✓ | Strava app client id |
--strava-client-secret |
STRAVA_CLIENT_SECRET |
✓ | Strava app client secret |
--strava-refresh-token |
STRAVA_REFRESH_TOKEN |
✓ | 不会过期(除非主动 revoke) |
--strava-gear-id |
STRAVA_GEAR_ID |
可选 | 形如 b12345678 |
--out-dir |
— | 可选 | FIT 输出目录,默认 FIT_OUT/ |
--limit |
— | 可选 | 上限新处理 workout 数(仅 debug 用) |
Strava 三个核心凭据是 all-or-nothing:齐了就走 Strava 上传,全不给就只生 FIT 不上传(local debug 模式)。
如果你想改代码、调试、或者不想等 CI:
git clone <你 fork 的 URL>
cd keep-to-strava
pip install -r requirements.txt
# 烟雾测试一个 workout
python3 export.py \
--keep-phone "<phone>" --keep-password "<password>" \
--strava-client-id "<id>" --strava-client-secret "<secret>" \
--strava-refresh-token "<token>" \
--strava-gear-id "<b12345678>" \
--limit 1需要 Python ≥ 3.10(脚本里有 match/case 语法)。
每天 04:00 UTC(12:00 Shanghai)和 16:00 UTC(00:00 Shanghai)自动跑。GitHub Actions cron 可能延迟最多 30 分钟,正常。
GitHub 自动禁用 60 天没 commit 活动的仓库的 scheduled workflow。每两个月手动 push 一次哪怕是 README 改个字就能续命,或者直接到 Actions 页面手动 trigger 一次也行。
理论上不会过期不会轮换。万一某次 refresh 调用返回 401,重做一次 OAuth 拿新 refresh_token、更新 STRAVA_REFRESH_TOKEN 这一个 secret 就行。
原仓库有 bug 修复或新功能时:
git remote add upstream https://github.com/<原仓库 owner>/keep-to-strava.git
git fetch upstream
git merge upstream/main
git push或者在 GitHub 网页上直接点 fork 页面的 "Sync fork" 按钮。
Actions tab 默认保留 90 天的 run logs。每次都是 ~10–60 秒(首次 15 分钟后稳定下来)。
| 症状 | 处理 |
|---|---|
Strava OAuth refresh failed (400) |
refresh token 过期/撤销/scope 不对,重做 OAuth |
| Strava upload 报"duplicate of activity" | 不会终止 run,已被自动当成 success 处理;可以忽略 |
| 整个 run 红了,stack trace 是 Keep 那边 | Keep 改 API 了或者你的账号被风控,等等再看 |
| 个别 workout failed,其它都成功 | 看具体 ERROR 行,多半是该 workout 数据有新的 schema 怪癖 |
| 某次 cron 没跑 | GitHub 偶尔延迟/丢运行,下一次 cron 会自动接上(resumable 设计) |
warn: activity update returned 4xx |
STRAVA_GEAR_ID 格式错了,应该是 b + 数字(如 b12345678) |
| 同一个 sport_id 一直被 skip 但 Strava 上看不到 | 你之前手动传过同一个 FIT,被 Strava 自动归类成 Ride 类型,名字是「上午/下午/傍晚骑行」之类默认值;手动找到删了,下次 CI 会重传成 VirtualRide |
| Fork 后 Actions tab 没看到 workflow | GitHub 默认禁用 fork 的 scheduled workflows,进 Actions tab 手动启用一下 |
更深入的架构说明、数据格式怪癖、为什么用 PCHIP 不用别的插值方法、为什么 sub_sport 选 virtual_activity 而不是 indoor_cycling、调试过程中踩过的所有坑——都在 CLAUDE.md 里。
本项目通过模拟 Keep 的 Android 客户端调用其内部 API。你需要使用自己的 Keep 账号凭据;用于个人数据导出,不要用于任何商业或大规模爬取场景。Keep 平台政策变化或 API 调整可能导致本项目失效,作者不承担相关责任。