arkenoi/timeline-export

GitHub: arkenoi/timeline-export

一个可重现的 headless 工具,将 Android 设备上的 Google Maps Timeline 数据库直接解码导出为包含 placeId、地点名称和 GPS 轨迹的结构化 JSON。

Stars: 0 | Forks: 0

# timeline-export 可靠、**headless、可重现**地将 Google Maps Timeline(2024+ 的端上“Location History”)导出为 JSON —— 直接从设备的 `odlh-storage.db` 解码,**无需 UI 点击**,也无需网络。 针对 redroid Android 容器运行。本项目的目的:为每次访问恢复 Google 自己的 **`placeId`** —— 这是 Google 建筑物级别的位置匹配,原始 GPS 轨迹无法重现 —— 加上语义分类,并按需将其转储为机器可读的 JSON。 ## 快速开始(一个脚本) 在安装了 `docker`、`adb`、`python3`、`sqlite3`、`curl`(以及用于无需 API key 进行名称解析的 `node`+`npm`+Chromium)的干净机器上: ``` git clone && cd timeline-export ./setup.sh ``` `setup.sh` 是幂等的,并完成整个启动过程:检查工具,启动固定分辨率的 redroid 容器,安装 lmkd/display 稳定性修复(宿主机 supervisor + 容器内的 Magisk 启动脚本 + busybox),等待启动,将您登录到 Google(使用您的凭据自动登录,或在屏幕上手动登录),导入您的 Timeline 备份,运行首次导出,可选地解析地点名称,以及可选地安装每晚刷新。它会询问三件事:容器名称、名称解析方法(browser / API key / skip)以及是否安装 cron。您可以随时重新运行它——它会检测并重用现有状态。 以下部分记录了它所配置的内容以及日常使用的命令。 ### 平台:redroid 需要 Linux 宿主机 redroid 在**宿主机 Linux 内核**上运行 Android,并且需要 `binder`/`ashmem` 内核模块,因此容器步骤仅在 Linux 上有效(裸机或带有这些模块的 Linux 虚拟机)。**macOS/Windows 上的 Docker Desktop 无法运行它** —— 其 LinuxKit 虚拟机缺少 binder/ashmem。Linux 以外的选项: - 在 Linux 虚拟机(UTM/Lima/multipass/VirtualBox;在 Apple Silicon 上使用 `arm64` 镜像)中运行 redroid 并在其中运行 `setup.sh`,或者在远程/云 Linux 机器上运行; - 或者完全跳过容器,将**处理部分**指向从*任何* Linux redroid 或已 root 的 Android 手机提取的 `odlh-storage.db` —— `odlh_export.py` / `resolve_names.js` / `place_names.py` / `travel_mode.py` 是纯 Python/Node 代码,可以在 macOS/Windows/Linux 上原生运行。 ## 用法 ``` ./export_all.sh # ONE command: decode → resolve names → comprehensive records # → out/Timeline-full.json (enriched visits + rich trips) ./export_all.sh --reimport # refresh from the cloud backup first ./fetch_and_export.sh [OUTDIR] # just decode the on-device DB → out/Timeline-latest.json ``` 生成 `OUTDIR/Timeline-.json`(+ `Timeline-latest.json` 符号链接)并保留原始 `odlh-storage.db` 的副本。随时可以重新运行;它是幂等的。 要解码您已经拥有的数据库: ``` python3 odlh_export.py path/to/odlh-storage.db -o Timeline.json --stats python3 odlh_export.py odlh-storage.db --no-paths -o visits_activities_only.json ``` 要求:`docker`(运行容器)、`python3`(仅标准库)和宿主机上的 `sqlite3`。容器名称默认为 `rd`;使用 `RD_CONTAINER=...` 覆盖。 ## 数据来源 `/data/data/com.google.android.gms/databases/odlh-storage.db` ← **ODLH = On-Device Location History** (GMS)。表 `semantic_segment_table`,每个 segment 一行;`semantic_segment` 列是一个 protobuf blob(Google 的 `SemanticSegment` 消息)。 这与 Google 自己的 *Export Timeline data* 按钮输出的数据相同 —— 但该按钮是一个脆弱的 SAF-picker UI 流程(并且在某些 Maps 构建中缺失),对于 pipeline 来说毫无用处。读取数据库是稳健的路径。 `segment_type`:1 = 访问,2 = 活动,3 = timelinePath(原始 GPS,约 2 小时为一个 bucket),4 = 旅行。 ## 输出 schema 顶层 `{"semanticSegments": [...]}`,与 Google 的端上导出匹配(`visit` / `activity` / `timelinePath`)。时间是带有 segment UTC offset 的 ISO-8601;坐标是 `"lat°, lng°"` 字符串(E7 → degrees)。*(下面的值是合成的占位符。)* ``` // visit — the valuable one { "startTime": "2025-01-02T14:01:06.171+02:00", "endTime": "2025-01-02T16:30:50.234+02:00", "startTimeTimezoneUtcOffsetMinutes": 120, "visit": { "probability": 0.81, "topCandidate": { "placeLocation": { "latLng": "12.3456789°, 98.7654321°" }, "semanticType": "HOME", // see caveats below "semanticTypeCode": 1, // raw enum — authoritative "placeTypeCode": 100, // Google place-type taxonomy id (uncategorised here) "probability": 0.72, "placeId": "ChIJ", // standard Google Maps placeId "placeUrl": "https://www.google.com/maps/place/?q=place_id:ChIJ<...>", "featureId": "0x:0x" } } } // activity: { start:{latLng}, end:{latLng}, distanceMeters, // topCandidate:{ type:"in passenger vehicle", typeCode:29, probability:0.99 } } // timelinePath: [ { point:"lat°, lng°", durationMinutesOffsetFromStartTime:"59" }, ... ] // trip: { name:"trip_" } ``` 有关完整的合成示例,请参见 `sample-output.json`。 ### placeId — 它是如何推导的(核心功能) 该 blob 存储了 Google 内部的 **FeatureId** = `(cellId, fprint)`,两个 64 位整数。Maps URL 和 Places API 使用的公开 `ChIJ…` placeId 只是这些字节重新包装而成的: ``` placeId = base64url( 0x0a 0x12 0x09 0x11 ) ``` 因此 `featureId` 和 `placeId` 是两种编码中的相同身份。使用以下方法将 `placeId` 解析为名称/地址/类别: - browser(已登录):打开 `placeUrl`; - **Places API**(需要 key):`GET places/{placeId}?fields=displayName,formattedAddress,types`; - `?cid=` URL:`https://maps.google.com/?cid=`。 名称/类别**不会**以任何可连接的形式缓存在设备上(已检查 —— 唯一的例外是您在 `gmm_myplaces.db` 中手动*保存*的地点)。因此,名称解析是基于 placeId 的一个单独的扩充步骤 —— 请参阅下面的**解析地点名称**。两种途径:浏览器渲染器(无需 API key)或 Places API(需要 key)。 ### 关于 `semanticType` 的注意事项 - `semanticTypeCode` 是原始的 protobuf enum —— 始终相信它。 - `semanticType` 标签:`1 → HOME` 已**确认** —— 其位置与 `gmm_myplaces.db → sync_item` 中 key 为 `0:0` 的端上 HOME 别名匹配。其余的都是尽力而为:`0 = UNKNOWN`/推断(访问的绝大部分),`4 = INFERRED`(经常访问的地方,但**不是**标记的 WORK 别名),`5 = SEARCHED_ADDRESS`(罕见)。按照 Google 的惯例,`2 = WORK`(可能不会出现)。该 enum 属于 Google,因此 `1 = HOME` 适用于任何账号。 - `placeTypeCode` (#1000) 是 Google 的数字地点类型分类法(餐厅/公园/…)—— 数百个代码,此处未提供公开的映射表;原样输出。 ### 其他解码说明 - `timelinePath` bucket 在存储中不包含 UTC offset;每个 bucket 继承相邻最近的 segment 的 offset。种子默认值为 `+120`;如果需要,在 `odlh_export.py` (`last_off`) 中将其更改为您账号的基础 offset。 - `durationMinutesOffsetFromStartTime` 是每个点一个字节;被视为从 bucket 开始的分钟数。少数大值表明该单位并不总是普通的分钟 —— **点本身是准确的**,每个点的时间是近似的。 - 由 `--stats` 打印的自检:按类型计数和 `errors=0`。解码器还将坐标保持为有符号的 E7,因此区域外的数据将显示为狂野的 lat/lng。 ## 解析地点名称 将 placeId 转换为 Google 真实的地点名称/地址。两种途径都是**按 placeId**(Google 自己的建筑物级别身份)解析 —— 绝不是通过对坐标进行逆地理编码,因为这会丢掉本项目赖以存在的歧义消除。 ### 选项 A — headless 浏览器,无 API key (`resolve_names.js`) Google 仅通过客户端 JS 提供地点名称,因此在 headless Chromium 中渲染公开的地点页面并读取解析后的 `/maps/place//…!1s…` URL。它会将解析出的 ftid 与请求的 ftid 进行交叉核对,因此合并/移动的地点会被标记出来,而不会被错误标记。 ``` npm install # puppeteer-core (needs a system Chromium/Chrome) ./get_consent_cookie.sh # accept the anonymous EU cookie-consent → out/consent_cookies.txt node resolve_names.js out/Timeline-latest.json -o out/Timeline-named.json ``` 为每次访问添加 `placeName` + `placeAddress`;缓存在 `out/place_cache_browser.json` 中(可恢复)。环境变量调节:`CHROME_PATH`、`RESOLVE_DELAY_MS`(默认 2500)、`RESOLVE_LIMIT`(0 = 全部)。**控制节奏** —— 从一个 IP 快速渲染 300 多个 Maps 页面可能会触发 Google 的限流/CAPTCHA;默认延迟是刻意保守的,而且缓存意味着您永远只需解析每个地点一次。这是一种抓取,因此由您自己负责保持温和并在 Google 的 ToS 范围内。 ### 选项 B — Places API,需 key (`place_names.py`) 官方、快速、更丰富(添加 `placeCategory`/类型)。需要一个启用了 *Places API (New)* 的 Google Maps Platform key;大约 300 次缓存查找包含在免费额度内。有关设置,请参阅脚本头。如果您想要类别或完全受支持的途径,请使用此方法。 无论哪种方式,解析都会向 Google 发送**它自己的 placeId**,用于您自己访问过的地点 —— 没有新的披露(不同于将坐标发送给第三方)。 ## 综合记录 (`build_records.py` → `Timeline-full.json`) `export_all.sh` 在解析后运行此命令,以生成人类可读的记录 —— 确定性的,无 LLM: - **丰富的访问** —— 每次访问的 `topCandidate` 都会获得 `placeName`、规范的 `placeAddress`(街道/门牌号/邮政编码,而不是重复的名称)、`placeCategory` 和 `placeLocation.{town,country,postalCode}`(城镇和国家是从 Google 自己的地址中解析出来的,或从 Places API 的 `addressComponents` 中提取的结构化数据)。 - **丰富的旅行** —— 一个顶层 `trips[]`,每个都带有元数据、`destination`(主要的异地城镇)、一行人类可读的 `description`、`stats`(停靠点 / distanceKm / kmByMode)、`topPlaces`,以及从旅行窗口的 timelinePath 组装而成的完整 **GPS `track`**: ``` "trips": [{ // values below are illustrative placeholders "id": "...", "startDate": "2025-01-05", "endDate": "2025-01-08", "durationDays": 4, "destination": "Springfield, Exampleland", "description": "Jan 5–8, 2025 · 4 days · Springfield, Exampleland · 600 km (mostly car) · 15 stops: …", "stats": { "stops": 15, "distanceKm": 600.0, "kmByMode": {"in passenger vehicle": 590.0, "walking": 10.0}, "trackPoints": 300 }, "topPlaces": [{ "name": "…", "town": "Springfield", "visits": 3, "hours": 48.0 }], "track": [[12.345678, 98.765432, "2025-01-05T12:08:00+02:00"], "…"] }] ``` - **移动 segment** —— 一个顶层 `movements[]`,每个交通路段一个,通过模式、距离、持续时间、速度、人类可读的 `description` 以及其自己的 GPS 路线,链接到其起点和终点**地点**(来自相邻的访问): ``` "movements": [{ "startTime": "…", "endTime": "…", "mode": "in passenger vehicle", "modeCode": 29, "speedKmh": 22.4, "distanceKm": 4.2, "durationMin": 12, "from": { "name": "Home", "town": "Springfield", "semanticType": "HOME" }, "to": { "name": "Central Park", "town": "Springfield" }, "description": "12 min · car · 4.2 km · Home → Central Park", "track": [[12.345678, 98.765432, "…"], "…"] }] ``` (相同的 `from`/`to`/`description` 也会内联折叠到每个 `activity` segment 中。) ## 出行模式分析 (`travel_mode.py`) 针对 activity segment 的确定性模态拆分报告 —— 无 LLM,无网络: ``` python3 travel_mode.py out/Timeline-latest.json # modal split + longest + cross-check python3 travel_mode.py out/Timeline-latest.json --trips # + per-trip mode breakdown python3 travel_mode.py out/Timeline-latest.json --json # machine-readable summary ``` 使用 Google 自己的每个活动的模式(`activity.topCandidate.type`/`typeCode`,由 `odlh_export.py` 输出)以及每个 segment 的距离和持续时间。报告每种模式的公里数/时间/计数、最长的行程、每次旅行的细分,以及一个**独立的速度交叉检查**,该检查会标记任何速度对于其标记模式来说不合理的移动(揭示 Google 的错误分类和 GPS 抖动伪影 —— 这正是确定 `code 7 = vehicle, not cycling` 标签的方式)。模式标签:`2/5/29` 已经过速度验证(步行/飞行/车辆);其余的是基于 Google 原始 enum 的尽力而为,并且始终保留 `typeCode`。 ## 登录(一次性,手动 —— 不要自动化) 在您的容器内,以交互方式登录到**您自己的** Google 账号,只需一次: ``` adb shell am start -a android.settings.ADD_ACCOUNT_SETTINGS --es account_types com.google ``` 然后在设备屏幕上完成登录(通过 `scrcpy localhost:5555` 或 redroid 显示屏),并确保已安装 Google Maps。 登录特意**没有脚本化**:它通过带有 2FA/passkey 的 WebView 和 Google 的滥用检测,因此自动化击键很脆弱,并且可能导致您的账号被标记。这是每个容器一次的手动步骤。 凭据存储位置:账号及其 OAuth token 由 Google Play services **存储在容器的 `/data` 卷内**(账号数据库 + GMS token 存储) —— **绝不在此 repo 中**。将该卷视为机密:不要提交它,不要发布镜像或其快照。 ## 刷新数据(headless,无 LLM) —— `reimport.sh` 新的 Google Timeline **不**在设备间同步;将手机持续的云备份拉入此设备的唯一方法是手动的 **Import** 流程。该流程是一个固定的点击序列,因此它可以确定性地编写脚本: ``` ./reimport.sh # drive Import, verify via GMS log ./reimport.sh --export # ... then run fetch_and_export.sh ``` `reimport.sh` 启动 Timeline 深度链接并点击固定目标(云图标 → 设备 ⋮ → Import → 确认),然后**通过监视 GMS 自己的日志来验证成功**(`LocationHistory: [BackupRunner] … restored` / `[BackupPreprocessor] inserting N segments`)。它会报告进入了多少新 segment,如果无法确认则以非零状态退出 —— 因此漏掉的点击会大声报错并重试,而不是默默地什么都不做。每天使用 cron 运行它(手机大约每天备份): ``` # 每天凌晨 03:30 刷新 timeline + export(log 已被 git-ignore) 30 3 * * * cd $HOME/timeline-export && ./reimport.sh --export >> out/reimport.log 2>&1 ``` 该脚本是**与设备名称无关的**:它通过单行备份上的*位置*来点击 ⋮,因此无论您的手机叫什么(Pixel、Galaxy 或其他什么)它都可以工作 —— 没有任何匹配设备名称的操作。`uiautomator` 在 redroid 中不起作用(即使在静态屏幕上也会转储空内容),因此无法使用逐元素文本点击;固定坐标是备选方案。 ## 漂移稳健性(分发前必读) 脆弱的部分是四个点击坐标。按重要性顺序排列的缓解措施: 1. **自我验证**(内置):GMS `LocationHistory` 日志是事实来源。如果 Google 移动了 UI,则不会出现 `BackupRunner` 行,`reimport.sh` 会重试然后以状态码 1 退出 —— 漂移会被检测到,绝不会静默。 2. **固定分辨率。** 坐标假定 **720×1280 @ 320 dpi**(redroid 的默认值;使用 `adb shell wm size` 验证)。使用这些 `redroid_width/height/dpi` 启动容器,坐标对于每次检出都是相同的。不同的分辨率 ⇒ 重新测量(`screencap`)。 3. **单一备份设备。** ⋮ 目标假定“Your backups”下只有一个设备。如果有多个,请调整 `TAP_OVERFLOW`(它们会垂直堆叠)。 4. 坐标位于 `reimport.sh` 顶部的标记变量中 —— 这是 Maps 重新设计后唯一需要修改的地方。 更深层次的(未实现):Import 按钮最终通过 `semanticlocationhistory` 绑定服务(`com.google.android.gms/.chimera.GmsApiService`)调用 GMS 的 `OnDemandBackupRestoreOperation`(restore=true)。一个绑定并发送该请求的自定义客户端将完全独立于 UI,但这意味着重现 GMS 的 protobuf API + 身份验证 —— 更重,且自身也很脆弱。点击流程是务实、可行的选择。 ## 从头开始重现(`setup.sh` 自动化的内容) `./setup.sh` 为您完成以下所有操作;这是手动等效操作 / 参考。这里没有捆绑任何镜像或个人数据 —— 您从**公开的** redroid 镜像、一个**全新的空** `/data` 开始,并登录您自己的账号。 1. **容器** —— 固定分辨率,并且 `/data` 位于**命名的 docker 卷**中(保留在 repo 树之外,因此账号 token 不会被提交): docker volume create rd_data docker run -d --privileged --name rd --cgroupns=host \ -v rd_data:/data -p 5555:5555 \ redroid/redroid:14.0.0_mindthegapps_magisk \ androidboot.redroid_gpu_mode=guest \ androidboot.redroid_width=720 androidboot.redroid_height=1280 androidboot.redroid_dpi=320 adb connect localhost:5555 在没有 KVM 的宿主机上,redroid 需要 lmkd/display 稳定性修复,否则 GMS 密集的屏幕会卡住 adbd —— 请参阅随附的 `redroid-stability` 单元。 2. **登录** —— 手动的一次性步骤(请参阅上面的**登录**)。 3. **导入备份一次**(引导端上存储):`./reimport.sh` —— 或手动:Maps → *Your Timeline* → 云图标 → *Your backups* → 您的设备 → ⋮ → **Import**。 4. **导出**:`./fetch_and_export.sh` → `out/Timeline-latest.json`。 5. **自动化**:cron `./reimport.sh --export`(见上文)。 ⚠️ 端上存储仅存在于 GMS 应用数据(`/data` 卷)中 —— 清除 Play services 或删除该卷会将其销毁;重新导入以重建。 ## 文件 - `setup.sh` — 在干净的机器上一键启动整个 pipeline。 - `login.sh` — 针对全新容器的尽力而为的自动 Google 登录(可选)。 - `export_all.sh` — 一条命令:解码 → 解析名称 → 综合记录。 - `build_records.py` — 确定性构建器:丰富的访问 + 丰富的旅行记录。 - `redroid/redroid-stability.sh` — 宿主机 supervisor(lmkd 看门狗 + display 保持唤醒)。 - `redroid/99-redroid-stability.sh` — 容器内的 Magisk 启动脚本(持久化部分)。 - `odlh_export.py` — 解码器(仅标准库的 protobuf 读取器 → JSON)。 - `fetch_and_export.sh` — 从容器中拉取数据库 + 解码。导出入口点。 - `reimport.sh` — headless 重新导入云备份(固定点击 + GMS 日志验证)。 - `resolve_names.js` — 通过 headless Chromium 解析 placeId → 名称(无 API key)。 - `get_consent_cookie.sh` — 获取浏览器解析器所需的 consent cookie。 - `place_names.py` — 通过 Places API 解析 placeId → 名称/类别(需 key)。 - `travel_mode.py` — 确定性的出行模式/模态拆分分析器。 - `package.json` — `resolve_names.js` 的 node 依赖(`puppeteer-core`)。 - `sample-output.json` — 输出 schema 的合成示例。 - `.gitignore` — 将生成的文件和 `node_modules/` 排除在 repo 之外。 - `LICENSE` — MIT。 - `.github/workflows/ci.yml` — CI:shell / python / node 语法检查。 - `out/` — 生成的输出(**被 git 忽略**)。
标签:Android自动化, Docker容器, JSON, MITM代理, 地理信息系统, 攻击面发现, 数据提取, 脚本工具, 请求拦截, 逆向工具