简介:小米手环数据自动化导出工具,主要面向希望深入查看Zepp Life数据库、对个人健康数据进行归档或取证分析的折腾型用户。通过对Zepp Life软件进行解包,直接获取底层数据库,配合多个Python脚本完成备份提取、数据解析与重建,省去手动导出的繁琐流程。资源共7个文件,包含6个Python脚本和1个带注释的JSON5样例文件,压缩包仅8KB,结构轻量,适合快速上手和二次修改。数据脚本覆盖了日期数据提取、全天压力解析、备份获取与解包还原等典型环节,JSON5文件则方便直观查看字段含义。目前已有11109人学习下载,说明该方案受到不少手环玩家的关注。对想彻底掌握自己身体数据、用于医疗参考或法治取证的同学来说,这份工具包能直接提供可运行的脚本与样例数据,减少从零摸索的成本。
1. 小米手环数据自动化导出工具:把云端的数据定期搬回本地
很多把小米手环戴了三四年的人,最后都会遇到同一个问题:想把自己这一年半载的心率、睡眠和步数导出来做健康分析,打开 App 却发现只能手动选月份,一次一次等邮件附件,导出回来还是零散的 Excel。小米手环数据自动化导出工具做的事,就是把这个搬数据的过程变成无人值守的定时任务——它替你登录云端账号,按天拉取原始数据,增量落进本地数据库,第二天早上醒来 csv 已经躺在文件夹里。适合那些常年佩戴、想长期分析健康趋势,或者怕哪天账号出问题数据全丢的人。接下来我会从数据链路讲起,带你搭出一套能每天自动跑的导出管线。
2. 数据从哪来:手环同步链路与自动化导出方案选型
2.1 手环数据在云端怎么存:同步链路决定了导出对象
手环不是 U 盘。手环 3、4、5 这些设备上的传感器每分每秒都在产生数据,但设备本地只保留最近几天的摘要,其余历史数据都在云端,由手机 App 负责上传同步。数据大致是这样流动的:手环通过蓝牙把心率、步数、睡眠分期推给手机 App,App 再上传到你的账号下,之后你在任意一台设备登录同一个账号,都能看到完整历史曲线。
这个链路决定了自动化导出的对象只能是云端,而不是手腕上那个硬件。想直接读手环本地存储,需要定制的固件和读取器,对绝大多数人不现实。云端才是完整数据的存放地,也是脚本每天要打交道的地方。理解这点有个实际好处:当手环一直戴在手上但很久没开 App,云端数据会存在缺口,导出脚本再勤快也拉不到手环还没上传的内容,这个问题后面会专门讲。
2.2 官方手动导出为什么只适合救急
Zepp Life 和老版本小米运动 App 里都有“数据导出”入口,但它设计的目标是给用户做一次性数据留存,不是给数据分析用的。常见限制包括:可选择的时间范围有限,通常一次最多勾选一个月,要导两年的数据,得反复操作二十多次;导出结果通过邮件附件发送,附件格式和字段在不同版本之间还不一致,有的版本只给每日汇总,不包含分钟级心率明细;App 一旦退出登录,旧的导出记录入口也不太好找。
实际用下来你会发现,官方导出更适合“哪天真想留个底”的救急场景,不适合长期定时把数据收走。要把导出变成自动化流水线,就得走另一条路:让脚本自己调用 App 背后的云端接口。下面会讲怎么选这条路,以及它的代价。
2.3 自动化选型:Zepp Life 私有接口与抓包路径
手机 App 里展示的心率曲线、睡眠分阶段,都是 App 向云端接口发请求拿到的。这条请求链路通常是 HTTPS + JSON,少数版本会启用 protobuf 压缩传输。常见的做法是:在手机和电脑之间搭一个本地 HTTP 抓包环境,比如 Fiddler、Charles 或 mitmproxy,然后正常登录一次 App,观察它调用了哪些接口、请求头里带了什么参数。整个过程只涉及你自己的账号和流量,抓包的目的只是把 App 的请求结构看清,拿到自己账号的凭证。
抓到之后会发现结构并不复杂:登录接口负责交付 token,数据接口带上 token 和日期区间,就能返回指定时间段的数据。相比申请厂商开放平台——小米和华米生态虽然有过开放平台,但手环健康数据接口并不对个人开发者公开,审批周期长、权限也有限——私有接口反而是更常见、更稳定的落地路径。它有几个特点要先接受:接口没有 Service Level Agreement,版本升级可能改字段,请求频控不透明,所以你的脚本必须留日志、做重试、保留原始返回,不能指望它永远不变。
方案对比可以看这张表,方便你判断该投入哪种路线:
| 路线 | 数据完整度 | 自动化程度 | 维护成本 | 适合场景 |
|---|---|---|---|---|
| 官方邮件导出 | 部分汇总 | 手动 | 低 | 一次性留底 |
| App 私有接口抓取 | 高,分钟级明细 | 高 | 中,需随版本微调 | 长期定时备份与分析 |
| 手机本地数据库直读 | 高,但依赖本机 | 低 | 高,碎片化严重 | 临时取证,不适合长期 |
后端脚本选私有接口路线时,接口域名和路径在不同 App 版本间会变化,抓包看清后,在脚本里单独抽出一个配置项维护,不要几十处硬编码。下面进入实现,我会用一个最小可运行的骨架把整条链路串起来。
3. 从登录到落盘:搭出第一个能跑的数据导出脚本
3.1 环境与配置准备:拿到自己的账号凭证
开始写代码前,先准备环境。Python 3.9 以上,安装requests、pyyaml,pandas 后面按需再装。抓包得到的凭证不要直接写死在代码里,放到config.yaml,一是避免改脚本,二是分享代码时不会把账号密码带出去。
我先给一份初始配置,字段含义在下面说明:
account: username: "your_phone_or_email" password: "" country_code: "86" device: device_id: "" # 留空,登录后可自动发现 api: base_url: "https://your-captured-api-host" access_token: "从抓包里复制的token" token_type: "Bearer" export: data_dir: "./export" db_path: "./export/miband_data.db" timezone: "Asia/Shanghai"这里有几个点要解释。password故意留空,如果抓到的 token 还有效,脚本不必要做登录请求;token 失效时再临时填入触发登录续期。token_type决定请求头怎么拼,有的接口要求Authorization: Bearer <token>,有的直接要token: <token>字段,这以抓包看到的请求头为准。timezone字段后面有大作用,别删,很多导出数据的日期错位就是时区没设对。
抓包时你要重点记录三样东西:登录接口的完整请求体和响应体、数据接口的路径、请求头里的User-Agent和app_version。抓包工具里可以直接复制为 curl 或 Python 请求格式,复制出来后先人工核对字段名,不要盲信工具转换。
3.2 登录接口:换取 token 的最小请求
老版本小米运动 App 的登录接口通常走账号密码,密码有的版本做 MD5,有的做 RSA,不同时期不一样。下面的函数是一个最小骨架,用注释标注容易变化的地方:
import hashlib import requests def login(username: str, password: str, country_code: str, base_url: str): # 部分版本要求密码先做加密传输,MD5/RSA 以抓包为准 pwd_hash = hashlib.md5(password.encode("utf-8")).hexdigest() session = requests.Session() payload = { "username": username, "password": pwd_hash, "country_code": country_code, } headers = { "User-Agent": "MIBand/5.2.3 (Android)", "app_version": "5.2.3", "lang": "zh-CN", } resp = session.post( f"{base_url}/v1/client/login", json=payload, headers=headers, timeout=10, ) resp.raise_for_status() data = resp.json().get("data", {}) token = data.get("token") or data.get("access_token") user_id = data.get("user_id") return token, user_id逻辑说明:登录用的Session要保留,因为后续请求可能依赖 cookie 会话;响应结构里 token 字段在不同版本叫法不同,所以写了or来兜底。参数说明:timeout=10是连接超时,登录接口慢时后端会返回 5xx,这时要重试而不是继续;app_version要和你抓包时的版本一致,有些老接口会校验它,随便填可能被拒。登录成功后把新 token 和 user_id 写回config.yaml,下次启动先检查 token 是否过期,不要每跑一次就登录一次。
新版 App 如果改成 OAuth 授权流程,账号密码登录可能失效,常见做法是抓包拿 refresh_token,再用它刷新出短期 access_token。这个流程差别较大,遇到再单独处理。
3.3 数据接口:拉取心率、睡眠、步数的请求参数与解析
登录接口拿到凭证后,核心就是按日期拉数。下面函数演示拉某一天三类数据,接口路径以抓包结果为准,这里用可替换的fitness路径做示例:
import datetime import requests def fetch_daily_data(api_cfg, user_id, target_date): """ 拉取指定日期的步数、心率和睡眠数据 target_date: datetime.date 对象 """ headers = { "Authorization": f"Bearer {api_cfg['access_token']}", "app_version": "5.2.3", "lang": "zh-CN", } params = { "user_id": user_id, "date_from": target_date.strftime("%Y-%m-%d"), "date_to": target_date.strftime("%Y-%m-%d"), } session = requests.Session() steps = session.get( f"{api_cfg['base_url']}/v1/fitness/steps", params=params, headers=headers, timeout=10, ).json() heart_rate = session.get( f"{api_cfg['base_url']}/v1/fitness/heart_rate", params=params, headers=headers, timeout=10, ).json() sleep = session.get( f"{api_cfg['base_url']}/v1/fitness/sleep", params=params, headers=headers, timeout=10, ).json() return { "date": target_date.isoformat(), "steps": steps, "heart_rate": heart_rate, "sleep": sleep, }逻辑说明:按天查,三个接口分别返回每天汇总、分钟级心率和睡眠分期结构。链式请求之间加了自然串行,没有并发,这是为了不给服务端造成瞬时压力。参数说明:date_from和date_to都传同一个日期,格式是YYYY-MM-DD;user_id必须和 token 属于同一个账号,混用会得到空数据或 401;heart_rate接口返回的通常是每分钟一个值,一天 1440 个点,缺测位置可能是 0,解析时要把 0 当成无数据,不能当成真实心率。
这里有个容易翻车的细节:如果响应拿回来是一堆乱码,而且响应头Content-Type是application/x-protobuf之类,说明这个版本启用了 protobuf,response.json()会直接报错。遇到这种情况,先看抓包里响应解码是否正常,再找对应的.proto定义反序列化。多数老设备与 Zepp Life 的组合仍然是 JSON,但新版本多遇到 protobuf,脚本需要为这两种情况各写一个解析分支。
请求频率上,我是这样控制的:批量拉日期时,循环里每个请求之间time.sleep(0.3),一天三个接口就是约一秒。三天以上的批量任务,避免并发,串行跑几分钟完全可以接受,没必要拿账号去试探频控阈值。
3.4 持久化:按日期写 CSV,顺手入库 SQLite
数据拉下来不落盘等于白跑。落盘方案我一般选 SQLite 为主库、CSV 做交换格式,因为增量导出会重复跑同一个日期,SQLite 的INSERT OR REPLACE天然适合去重。先建一张汇总表,保留一份完整原始 JSON 备查:
import json import sqlite3 def save_to_db(raw, db_path): conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS daily_summary ( date TEXT PRIMARY KEY, steps INT, total_sleep_minutes INT, deep_sleep_minutes INT, avg_heart_rate INT, raw_json TEXT ) """) summary = extract_summary(raw) conn.execute( "INSERT OR REPLACE INTO daily_summary " "(date, steps, total_sleep_minutes, deep_sleep_minutes, avg_heart_rate, raw_json) " "VALUES (?,?,?,?,?,?)", (raw["date"], summary["steps"], summary["total_sleep"], summary["deep_sleep"], summary["avg_hr"], json.dumps(raw, ensure_ascii=False)), ) conn.commit() conn.close() def extract_summary(raw): # 汇总逻辑按真实字段名解析,这里只做占位 return { "steps": 0, "total_sleep": 0, "deep_sleep": 0, "avg_hr": 0, }逻辑说明:raw_json保存完整原始返回,之后如果发现解析漏了字段,不需要重新请求,直接从库里捞原始数据重新解析就行,这是给自己留的后悔药。参数说明:date TEXT PRIMARY KEY意味着同一天只会保留一份记录,配合INSERT OR REPLACE,重跑不会产生重复行。extract_summary在这里是占位,你需要按实际返回字段提取步数、睡眠总时长和深睡时长。
CSV 导出可以和 SQLite 并存,适合直接丢进 Excel 或后续用 pandas 分析。常见做法是在每次同步完成后,把近一周的daily_summary导出成一个daily.csv,列名用英文小写下划线,避免中文编码问题。到这一步,脚本从登录到落盘的最小闭环已经通了。
4. 让导出自己跑:增量同步、定时调度与失败重试
4.1 增量窗口设计:从“今天”往回推两天
脚本跑通之后,下一步是让它自动循环。全量重导历史记录问题很大:天数多、请求多、容易被限流,重复数据还要清理。增量同步的思路是维护一个last_sync.json,记录上次同步到的日期,下次从“上次日期往前推两天”开始,导到昨天为止。
为什么要往前推两天?因为手环的数据同步不是实时的。很多人睡前戴上,早晨起来直接出门,没有打开 App,手环那一夜的缓存还没上传;还有人加班到凌晨,第二天的数据中午才同步到云端。往前推两天是给云端留缓冲窗口,配合INSERT OR REPLACE,重复拉取不会产生脏数据。首次运行没有历史记录,可以按需求回退 90 天或 365 天,脚本写法如下:
import json from datetime import date, timedelta from pathlib import Path def next_sync_window(last_sync_file: Path, today: date): if last_sync_file.exists(): last_date = date.fromisoformat( json.loads(last_sync_file.read_text())["last_sync_date"] ) else: last_date = today - timedelta(days=90) # 首次运行,回退90天 start = last_date - timedelta(days=2) # 补前端两天的迟滞数据 end = today - timedelta(days=1) # 昨天是最近完整的一天 return start, end def mark_synced(last_sync_file: Path, end: date): last_sync_file.write_text( json.dumps({"last_sync_date": end.isoformat()}) )逻辑说明:窗口是闭区间,每次从start循环到end,跑完把last_sync_date更新为end。下次窗口自动从end - 2开始,意味着最近三天会被重复拉一次。这个重复是故意的,不是浪费,它补的是“手环还没同步完”的数据。参数说明:回退天数 90 和缓冲天数 2 都是经验值。刚上手想看更久历史,把 90 改成 365;如果你每天两次开 App,习惯良好,缓冲天数可以缩成 1,但没必要,多拉三天的成本很低。
4.2 定时调度:cron 和任务计划怎么定时间点
定时任务不是随便设的,核心约束是数据完整性。很多人第一次设凌晨 2 点跑,结果睡眠数据老是缺,因为夜里手环上传依赖手机 App 在后台活跃,夜里 App 大概率没在工作。更实用的做法是每天跑两次:早上 8 点补昨夜睡眠,晚上 9 点补白天心率。窗口本身有两天缓冲,两次调度即使重复拉同一批日期,数据也会被幂等覆盖,不会重复。
Linux 或 NAS 上,crontab 直接加两行:
# 每天 08:00 和 21:00 各跑一次增量导出 0 8 * * * cd /opt/miband-exporter && /usr/bin/python3 export_daily.py >> logs/export.log 2>&1 0 21 * * * cd /opt/miband-exporter && /usr/bin/python3 export_daily.py >> logs/export.log 2>&1Windows 上,用任务计划程序或 schtasks 注册一个每日任务,命令如下:
schtasks /Create /TN "MibandExport" /SC DAILY /ST 08:00 /TR "cmd /c cd C:\miband-exporter && python export_daily.py >> logs\export.log 2>&1"参数说明:>> logs/export.log 2>&1必须保留,自动化脚本挂掉时没有任何界面提示,只能靠日志定位问题;日志建议按月份拆分,比如logs/export-202506.log,避免单文件涨到几十 MB。cron 里的 python 路径要确认,很多环境默认 python 不在/usr/bin/python3,可以先which python3查一下。如果你把脚本跑在路由器或 NAS 上,还要注意设备睡眠策略,别让任务在休眠期间被跳过。
4.3 失败重试与限流:参数怎么调才不伤账号
自动化跑起来后,失败是常态,关键是处理方式要对。请求返回常见状态码里,401 表示 token 失效,重试没用,要触发重新登录;429 是限流,要退避等待;5xx 是服务端临时问题,可以重试;超时是网络波动,也可以重试。不同错误要区别对待,不能一把梭。
指数退避是最常见的重试策略,我给一个可直接用的装饰器:
import time import requests from functools import wraps def retry(max_attempts=4, base_delay=2): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except requests.HTTPError as e: status = e.response.status_code if status == 401: raise # token失效,重试没有意义,交给上层续期 if attempt >= max_attempts - 1: raise # 已经最后一次,直接暴露问题 delay = base_delay * (2 ** attempt) # 2s, 4s, 8s time.sleep(delay) return None return wrapper return decorator逻辑说明:指数退避的核心是让服务端有恢复时间,不是快速重试轰炸。base_delay=2,三次重试分别等 2 秒、4 秒、8 秒,总计约 14 秒,这个量级对日志任务完全够用。参数说明:max_attempts建议不超过 4,超过之后问题大概率不是瞬时抖动,而是账号或网络层面的故障,继续重试只会恶化限流。429 响应如果带Retry-After头,优先按它的秒数等待,这个字段是服务端明确告诉你的冷却时间,比指数退避更精准;没有这个头再走退避。
日常拉数时,并发也要控制住。串行请求加 0.3 秒间隔,已经能避开大部分限流;如果做并发,并发数控制在 2 以内,并且单日请求总量别超过几百次。连续三次 429 时,停止当天任务并写一条告警日志,不要无限重试。IP 被限后通常几小时后自动解开,这时候改 User-Agent 重登没有实际帮助,老老实实等冷却更靠谱。
5. 避坑:自动导出跑半年后最容易翻车的五个点
这一章是血泪经验汇总。工具跑起来前两周通常风平浪静,跑满一两个月,各种边界问题才会陆续浮出来。列出的每条都按“现象、原因、解决”讲,你在自己的脚本里提前布好防线。
5.1 token 失效导致进程直接报 401
现象:某天开始,日志里全是 401 Unauthorized,手动打开 App 却发现账号完全正常,数据也能看。
原因:新版本签发的 token 有效期短,脚本是硬编码 token,过期后没有走登录流程;也可能因为你在手机上登录了新设备,服务端把旧会话踢掉了。
解决:给请求层加一个钩子——遇到 401 不直接失败,先调用登录接口换新 token,写回配置,再重发原请求;同时把 token 的获取时间记在配置里,如果 token 有效期已知,提前一周主动刷新。要特别注意,被踢下线是常态化事件,不是异常,续期逻辑必须做成自动的,不能靠手工改配置。
5.2 时区错位:睡眠数据跑到前一天
现象:导出的睡眠记录里,入睡时间总是比 App 上显示早 8 小时,或者凌晨 0 点到 6 点的数据挂在了前一天的字段里。
原因:云端返回的时间戳是 UTC 毫秒值,脚本直接用了datetime.utcfromtimestamp解析,而 App 展示时按 Asia/Shanghai 换算过,两边差一个时区,日期自然错位。
解决:统一在配置里写死timezone: "Asia/Shanghai",解析时间戳时先转东八区再取日期。这里补一个转换示例:
from datetime import datetime, timezone, timedelta tz = timezone(timedelta(hours=8)) local_dt = datetime.fromtimestamp(ts / 1000, tz=tz) # ts 单位是毫秒 local_date = local_dt.strftime("%Y-%m-%d")注意时间戳单位的坑。有的接口返回秒(10 位),有的返回毫秒(13 位),直接用错会差 1000 倍。我习惯在解析函数入口打印一次首条时间戳,人工确认单位后再批量处理。导出文件的命名、数据库里的date字段,都要按本地日期生成,不能按 UTC 生成。
5.3 手环没同步完成,导致当天数据缺段
现象:早上 8 点任务跑完,晚上打开 App,发现昨晚的睡眠分期齐全,但导出的 SQLite 里少了两段深睡。
原因:手环数据上传依赖手机 App 在后台工作。很多人晚上戴着睡,早上出门没开过 App,手环缓存还没上传,云端自然没有那段数据,脚本再准时也拉不到。
解决:每天跑两次以上,第二次放在晚上 9 点前后,这时候白天数据早已同步;增量窗口往回推两天,让漏掉的数据在第二次跑时被覆盖补齐。如果发现某个日期反复缺同一段,记一份“缺失数据清单”,字段包含日期、缺失类型、首次发现时间,连续三天仍缺就手动开一次 App 确认手环有没有正常连接。
5.4 请求太频繁被限流:429 与 IP 封禁的界线
现象:脚本第一次跑正常,几分钟后第二次跑就开始返回 429,继续重试几次直接超时。
原因:没有限速,批量日期循环里请求一股脑全打出去;家里宽带出口是共享 IP,厂商的防护阈值又比较低,瞬时请求量一高就被限。
解决:批量日期之间串行请求,每请求间隔 0.3 秒;重试时优先尊重Retry-After响应头;连续三次 429 就停止当天任务并告警,不要无限重试。判断是否被临时封禁,看同一 IP 请求其他接口是否也超时即可,超时说明到了 IP 级限制,这时候只能等几小时自动恢复,改 User-Agent 重登作用不大。
5.5 密码明文存储与脚本泄露风险
现象:config.yaml里直接写着账号密码,把代码目录同步到网盘或提交到 git 仓库后,账号出现异地登录提醒。
原因:工具越方便越容易忽视安全。私有接口虽然没有公开文档,但它同样吃账号权限,一旦凭证泄露,等于把健康数据整个交出去。
解决:密码改用环境变量或系统钥匙串读取,配置文件里只放 token;token 失效需重新登录时,临时手动输入密码,结束后清空。git 仓库用.gitignore把config.yaml、logs/、export/全部挡在提交之外,建议再定期轮换账号密码,尤其是部署在共享服务器上的情况。
6. 验证导出的数据对不对,再把它接进自己的分析流程
6.1 三分钟交叉验证法:拿 App 数字对账
自动化工具最怕的不是报错,而是报“成功”但数据是错的。每次跑完,抽一天数据,和 App 显示的数字对一遍。对账指标选三个就够:总步数、睡眠总时长、平均心率。App 展示的是“本地日期 0 点到 24 点”的统计,脚本汇总时也按同一口径,两边才对得上。
抽检可以写成一个小函数:
def verify_date(target_date): summary = load_summary(target_date) # 从SQLite读 expected = {"steps": 8246, "sleep_minutes": 421} # 手输App里看到的数字 print("steps diff:", summary["steps"] - expected["steps"]) print("sleep diff:", summary["total_sleep_minutes"] - expected["sleep_minutes"])对不上时按顺序排查:先看时区字段有没有统一,再看当天手环是否同步完整,最后确认原始 JSON 解析字段是否选错。不要拿手环屏幕显示的数去对,手环显示的是设备本地缓存,和云端不一定同时更新;要以手机 App 里看到的云端数据为准。
6.2 进阶:把导出数据接进自己的健康看板
数据进来之后,日常分析就顺理成章。SQLite 里有了按天汇总,可以直接查近两周的深睡占比,看看规律:
SELECT date, total_sleep_minutes, deep_sleep_minutes, ROUND(deep_sleep_minutes * 100.0 / total_sleep_minutes, 1) AS deep_rate FROM daily_summary ORDER BY date DESC LIMIT 14;字段名按你自己的表结构调整,深睡占比如果长期低于 15%,值得留意生活习惯。想做更复杂的分析,把daily.csv丢给 pandas 或任何可视化工具都行,分钟级心率还能做夜间心率分布曲线,观察静息心率随疲劳度的变化。如果你的目标是长期积累,SQLite 里再按月份建视图会更方便,但不要为了分析方便牺牲增量同步那套逻辑——它是数据质量的底线。
我最早跑这个工具时,以为数据拉下来就结束了,结果第三天对账发现步数少了三千,排查到凌晨才发现是时区问题。那之后,我在所有日期处理的地方统一用 Asia/Shanghai,并把中间结果和原始 JSON 都保留下来,半年多再没丢过数。把数据自动化拿回自己手里这件事,值得投入一套干净、可验证的管线,希望帮到你。
本文还有配套的精品资源,点击获取