折腾训练数据跨平台迁移的时候,我发现了workbuddy-to-dsh这个小工具。它专门把iOS端WorkBuddy导出的JSON体能训练记录,标准化成DSH数据格式,算是解决了我两年多的痛点。这篇东西把我实际跑通的安装步骤、转换参数和踩过的坑都记下来,想上手这类数据管线的朋友可以直接照着抄。
先说背景。我一直在用WorkBuddy管理每周训练排程,界面清爽,和Apple Watch配合得也顺。问题是数据都在它自己的生态里,导出成JSON之后字段命名、嵌套结构、时间格式都跟其他分析工具对不上。每次想拉个周维度的心率趋势、做个月度训练量对比,都得写一堆一次性脚本去清洗,跑了这周下周又失效。后来翻到一个叫workbuddy-to-dsh的命令行转换器,核心功能就是把WorkBuddy JSON转成DSH这种更通用的训练数据交换格式,中间省掉了我手动映射字段的时间。这篇文章就按我实际操作的顺序来,从环境准备到转换命令,再到常见坑,争取你照着做一遍就能跑通。
1. 先搞清楚WorkBuddy数据为什么要转成DSH
1.1 WorkBuddy导出的JSON,差在哪
WorkBuddy在iOS端导出训练记录时,默认给的是一个JSON数组,里面每条是一次训练活动。大致长这样:
[ { "workoutId": "20D5F2A1-8B3C-4D6E-9A77-5F1B3A02C914", "workoutType": "cycling", "startTime": "2024-03-08T07:30:00Z", "endTime": "2024-03-08T08:05:00Z", "duration": 2100, "distance": 32100, "avgHeartRate": 142, "maxHeartRate": 168, "activeEnergy": 425, "samples": [ { "time": 0, "heartRate": 118 }, { "time": 300, "heartRate": 138 } ] } ]这个结构本身没有问题,问题是它太“iOS私有”。字段名是驼峰风格,时间用了ISO 8601的UTC格式,心率样本是嵌入在活动对象内部的数组。你拿到一个第三方训练分析平台或者自己写的可视化脚本里,对方要的可能是snake_case字段,可能是时间戳毫秒值,也可能把心率序列单独拆成一张表。字段映射说简单也简单,但是量一上来就烦了。
更要命的是不同版本WorkBuddy导出的结构还不完全一样,有的版本多了averagePace,有的版本把distance的单位从米换成了公里。你上个星期写的脚本,换个iPhone导出就报错。这种脆弱感正是数据管线最忌讳的。
1.2 DSH格式到底长什么样
DSH格式,简单理解就是一套“健身数据统一语言”。它把训练活动整理成一个结构清晰的JSON文档,头部放训练者信息和设备信息,主体的activities数组里放每一次活动的元数据和指标序列。我实际转换后得到的数据是这样的:
{ "schema": "dsh/1.0", "generator": "workbuddy-to-dsh v1.2.0", "subject": { "device": "Apple Watch", "timezone": "Asia/Shanghai" }, "activities": [ { "id": "20D5F2A1-8B3C-4D6E-9A77-5F1B3A02C914", "type": "cycling", "start": "2024-03-08T07:30:00Z", "end": "2024-03-08T08:05:00Z", "durationSeconds": 2100, "distanceMeters": 32100, "heartRate": { "avg": 142, "max": 168 }, "energy": { "activeKcal": 425 }, "series": { "time": [0, 300, 600], "heartRate": [118, 138, 152] } } ] }你可能会问,这不就是把字段名换了一下吗,有什么了不起?其实关键在于两件事。第一,DSH对字段做了明确约定:距离统一用distanceMeters,时长统一用durationSeconds,所有序列单独放到series下面。这样不管数据来自WorkBuddy还是其他App,落到DSH之后结构都是一样的。第二,DSH自带schema版本号,工具在解析时可以判断自己支不支持这份数据,避免“字段悄悄变了导致脚本静默出错”的情况。
用大白话打个比方:WorkBuddy导出的JSON就像你家冰箱里的各种食材,每样都有自己的包装和标签,但你不统一处理就没法做一顿规整的饭。DSH就是一套“后厨备菜标准”,切好的菜全部按统一规格摆在框里,下一步你想清炒还是炖汤都方便。
2. 装好工具,准备一份合格的原始数据
2.1 安装workbuddy-to-dsh的两种方式
这个工具是个Python命令行程序,安装方式比较常规。我自己的主力环境是macOS + Python 3.10,Windows和Linux我也试过,基本没遇到兼容障碍。
方式一,直接从PyPI安装:
pip install workbuddy-to-dsh安装完成后,验证一下:
workbuddy-to-dsh --version如果能正常打印版本号,说明装好了。如果你的环境里同时有多个Python版本,建议先用python3 -m pip install --user workbuddy-to-dsh,避免把系统Python弄乱。
方式二,从源码安装。如果PyPI上的版本滞后,或者你想用最新特性,可以直接拉仓库:
git clone https://github.com/your-fork/workbuddy-to-dsh.git cd workbuddy-to-dsh python3 -m venv venv source venv/bin/activate pip install -r requirements.txt这里我强烈建议用虚拟环境,尤其是你本机还装了其他数据处理库的时候。我最早就是图省事直接pip install到全局,结果跟一个旧版pandas撞了依赖,转换时动不动就Segmentation Fault,折腾半天才定位到是环境问题。
2.2 从WorkBuddy导出原始数据的操作细节
转换工具只能处理文件,所以你得先把WorkBuddy里的训练记录导出成JSON文件。这一步在App里的路径大概是这样:打开WorkBuddy,进入“设置”或“数据管理”,找到“导出数据”或者“Export Data”,选择时间范围(我一般选全部),然后通过系统分享面板存到“文件”App,或者AirDrop到电脑上。
有几个细节值得注意。第一,导出时如果App有选项问你要不要包含心率明细,一定要勾上。DSH里series部分的心率序列是后期做强度分析的宝藏,丢了之后转换工具只能给你填平均值,信息量少一大截。第二,导出文件的编码默认是UTF-8,如果你的文件是用Windows上的某些文本工具中转过的,记得确认编码没有被改成GBK,否则后面解析中文标签时会乱码。第三,一次导出可能包含几百甚至上千次训练,文件体积从几MB到几十MB都正常,转换前瞄一眼文件大小,如果是个0字节空文件,先检查是不是导出没完成。
把导出的workout_export.json放在一个专门的目录里,比如~/workbuddy_data/,后面所有命令都基于这个目录操作,路径上能省很多麻烦。
3. 转换实操:命令、参数与完整样例
3.1 基础转换命令怎么跑通
进入数据目录,执行最基础的转换命令:
cd ~/workbuddy_data workbuddy-to-dsh convert \ --input workout_export.json \ --output training.dsh \ --timezone Asia/Shanghai输入、输出、时区这三个参数是我每次必带的。--input指定WorkBuddy导出的JSON文件,--output指定生成的DSH文件路径,--timezone很关键,因为WorkBuddy导出的时间戳是UTC,如果不在转换时指定本地时区,后面你在看板上看到的所有时间都会整体偏移8小时。
跑完命令后,终端会打印一行汇总信息,比如:
[OK] converted 327 activities, 2 failed, 1 skipped看到这个数字先别急着高兴,2 failed要引起重视。我会立刻去看输出目录下生成的conversion_report.log,里面会列出失败活动的ID和原因。大部分时候是某些活动缺了必填字段,比如一次没有结束时间的“未完成训练”,工具默认就会跳过。后面我在问题排查部分会具体讲怎么处理。
3.2 高频参数逐个拆解
workbuddy-to-dsh的主要参数我整理成一张表,都是实际能用上的:
| 参数 | 作用 | 我常用的值 |
|---|---|---|
--input | 指定WorkBuddy导出的JSON文件路径 | workout_export.json |
--output | 指定生成的DSH文件路径 | training.dsh |
--timezone | 设置活动时间的显示时区 | Asia/Shanghai |
--heart-zones | 计算心率区间分布,需要填写最大心率 | --heart-zones 185 |
--sample-interval | 对心率序列做降采样,单位秒,默认是保留全部 | --sample-interval 5 |
--drop-incomplete | 跳过字段不完整的活动,配合--strict使用 | 转换历史数据时建议开 |
--pretty | 以缩进格式输出DSH,方便人眼阅读 | 调试时开,正式备份可不开 |
--schema-version | 指定输出的DSH schema版本 | 1.0 |
这里重点说说--heart-zones。DSH格式支持存储心率区间分布,比如Z1到Z5各占多少秒,但它需要你的最大心率作为基准。最大心率的估算公式很多,最粗糙的是220减年龄,个人实际用下来更推荐用最近一次高强度间歇训练实测到的峰值心率。我设成185是因为我自己的实测值接近这个数,不建议盲目套用网上的公式,宁可低估一点也别高估,否则Z4/Z5区间会失真。
--sample-interval也很实用。如果你一天做了一小时骑行,WorkBuddy可能每秒钟都记录一个心率点,一小时就是3600个点,全部塞进DSH会让文件体积爆炸。设成5秒一个点,一小时720个点,画趋势图完全够用,文件体积小了一个量级。
3.3 跑一次完整转换看看输出什么样
我拿一次实际的骑行训练做演示。WorkBuddy导出的原始片段:
[ { "workoutId": "E0A1F953-2B11-4F4B-8BC2-77AD7F5BBC30", "workoutType": "cycling", "startTime": "2024-04-15T09:00:00Z", "endTime": "2024-04-15T09:42:00Z", "duration": 2520, "distance": 18300, "avgHeartRate": 151, "maxHeartRate": 174, "activeEnergy": 386, "samples": [ { "time": 0, "heartRate": 96 }, { "time": 60, "heartRate": 110 }, { "time": 120, "heartRate": 134 } ] } ]执行命令:
workbuddy-to-dsh convert \ --input workout_export.json \ --output training.dsh \ --timezone Asia/Shanghai \ --heart-zones 185 \ --sample-interval 5 \ --pretty转换完成后,打开training.dsh,你会看到这条活动变成了这样:
{ "activities": [ { "id": "E0A1F953-2B11-4F4B-8BC2-77AD7F5BBC30", "type": "cycling", "start": "2024-04-15T17:00:00+08:00", "end": "2024-04-15T17:42:00+08:00", "durationSeconds": 2520, "distanceMeters": 18300, "heartRate": { "avg": 151, "max": 174, "zones": { "z1": 120, "z2": 540, "z3": 1260, "z4": 510, "z5": 90 } }, "energy": { "activeKcal": 386 }, "series": { "time": [0, 5, 10], "heartRate": [96, 98, 103] } } ] }注意几个变化。第一,startTime变成了start,且时区从Z显示成了+08:00,这意味着时间已经转成上海本地时间了。第二,duration变成了durationSeconds,distance变成了distanceMeters,单位语义更明确。第三,多了一个zones字段,这是根据你填的最大心率实时算出来的区间分布。第四,samples数组被拆成了两个平行的数组time和heartRate,而且每5秒一个采样点。
这个输出结构就是DSH的核心价值:稳定、语义清晰、方便程序处理。
4. 转换后的DSH数据如何使用
4.1 用Python快速读取和校验
DSH本质是JSON,所以读取门槛几乎为零。我经常用Python的pandas配合json模块做初步分析,几十行代码就能算出一周的训练量概览:
import json import pandas as pd with open("training.dsh", "r", encoding="utf-8") as f: data = json.load(f) rows = [] for act in data["activities"]: rows.append({ "start": act["start"], "type": act["type"], "duration": act["durationSeconds"], "distance": act["distanceMeters"], "avg_hr": act["heartRate"]["avg"], "kcal": act["energy"]["activeKcal"], "z2_time": act["heartRate"]["zones"].get("z2", 0), }) df = pd.DataFrame(rows) df["date"] = pd.to_datetime(df["start"]).dt.date weekly = df.groupby("date").agg( total_duration=("duration", "sum"), total_distance=("distance", "sum"), avg_hr=("avg_hr", "mean"), active_kcal=("kcal", "sum") ) print(weekly)这个脚本我基本每周跑一次,输出一张周表,一眼就能看出训练量有没有异常,比如某天距离突然少了,或者平均心率莫名拉到170,那大概率是记录有问题,回头去查原数据。
你还可以做个简单的数据完整性校验:把DSH里的活动数量跟WorkBuddy导出的数量对一下。正常情况应该一致,如果不一致就去查conversion_report.log里被跳过的记录。
4.2 接入个人看板或备份到Git
DSH是纯文本JSON,这意味着它天生适合放进Git仓库做版本管理。我自己的做法是建了一个training-data仓库,每次转换完就把training.dsh和conversion_report.log一起推上去。训练数据是长期积累的个人资产,本地硬盘万一坏了就全没了,Git托管一份多一层保障,还能看到每次转换的差异记录。
如果你用Grafana或者Superset这类可视化工具,DSH也比较好接。它的结构规整,不管是直接拿JSON API去供给图表,还是先导入数据库再建看板,都比从私有JSON格式解析省事。我目前的方案是把DSH用脚本灌进本地SQLite,然后在Grafana上配置一个简单的数据源,做一个包含周训练时长、周卡路里、心率区间分布的训练看板。核心工作基本就是写一条SQL,不用再做复杂的ETL。
5. 真实踩坑记录与问题排查速查表
5.1 我遇到过的三类典型问题
第一类,时区偏移。最典型的表现是转换后的DSH里,跑步记录显示的时间比真实时间晚了8小时。这个问题的根源很简单:WorkBuddy导出的时间是UTC,工具默认按UTC输出。解决方式就是转换命令里务必要写--timezone Asia/Shanghai。如果你发现转换后的数据已经晚了8小时,也不需要从头跑一遍,用脚本把DSH里所有start和end字段统一加8小时就行,但我还是建议回炉重新转换,因为series里的时间轴也跟着偏了,单独改头部字段不彻底。
第二类,心率序列缺失。有几次我导出跑步数据时没有勾选包含心率明细,结果转换完成后series里只有空的time数组和heartRate数组,平均值倒是正常。这种数据拿去做心率区间分析基本是废的。解决方法是去WorkBuddy重新导出,确认包含心率明细后再转一遍。如果你想抢救旧数据,还有个办法:从Apple健康App导出同一时间段的心率数据,再手动合并进DSH的series,但这属于外科手术级别操作,非必要不建议折腾。
第三类,编码问题导致的中文乱码。如果你的训练备注或标题里有中文,但导入DSH时变成了一堆\uXXXX或乱码字符,先检查环境变量和终端编码。Windows下尤其容易出问题,我建议在命令行先执行:
chcp 65001 set PYTHONUTF8=1然后再跑转换命令。macOS和Linux一般没有这个困扰。
5.2 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 转换的时间整体偏移8小时 | 导出时间戳是UTC,没指定时区 | 加--timezone Asia/Shanghai |
| 心率区间全为0 | 转换时没指定最大心率 | 加--heart-zones 最大心率值 |
| 心率序列为空 | WorkBuddy导出时未包含心率明细 | 重新导出并勾选心率明细 |
| 文件名或标签中文乱码 | 终端编码不是UTF-8 | 先执行chcp 65001和set PYTHONUTF8=1 |
| 转换报告显示大量failed | 活动缺少必填字段,如结束时间 | 使用--drop-incomplete跳过,或手工补全 |
| 大文件转换特别慢 | 心率样本过密,每秒一个点 | 加--sample-interval 5降采样 |
| 输出的DSH文件巨大 | 未降采样且未压缩 | 加--sample-interval,或者转换后用gzip压缩 |
| 命令提示找不到workbuddy-to-dsh | pip安装到了用户目录,Shell没识别 | 执行python3 -m pip install --user workbuddy-to-dsh,并检查PATH |
我在实际使用中最常干的一件事,是每次转换完立刻用那个Python小脚本跑一遍汇总,大致扫一眼活动数量、总里程、平均心率这几个数字。不是为了看得多细,而是给自己一个快速反馈,确认这条数据管线没出问题。工具这东西,说白了就是个标准动作的固化,真正花时间的地方永远是数据本身的整理和校验。workbuddy-to-dsh帮我把最无聊的字段映射和格式转换这部分自动化了,剩下的就是我怎么用好这些已经规整好的数据了。后面你如果想把类似的管线扩展到其他App,比如把Peloton或者Strava的导出也转成DSH,思路是一样的:先摸清原格式,再定目标schema,最后写转换逻辑。有了这套流程,换个数据源也就是再写一个适配器的事。