local-research: разобран формат экспорта Apple за 5.5 лет

- девять экспортов, версии 11→13→14; ни один тип записи не исчез, только
  добавлялись — разбор можно писать аддитивно
- Correlation (давление) появился только в 2026 и ломает парсер по одним Record;
  имя файла локализовано, DTD расходится с данными
- коды HealthKit переименовываются: те же записи сна приезжают как Asleep из
  экспорта 2021 и как AsleepUnspecified из свежего
This commit is contained in:
av
2026-08-01 14:20:54 +03:00
parent 7ed82af450
commit 7ee55057e0
4 changed files with 169 additions and 10 deletions
+109
View File
@@ -1318,6 +1318,115 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
более полная точка, а не последняя пришедшая, — иначе бедная доставка стирает
`start`/`end` у богатой.
## 42. Формат экспорта за 5.5 лет: типы только добавляются
Девять экспортов из `~/MediaEverything/HealthData/apple_health`, с декабря
2021 по август 2026. Версия формата растёт медленно и давно стоит на месте:
```
2021-12 Export Version 11
2024-06 Export Version 13
2025-06 Export Version 14
… 14 ← пять экспортов подряд, больше года без изменений
2026-08 Export Version 14
```
Раскладка архива одинакова во всех девяти: `экспорт.xml`, `export_cda.xml`
(клинический формат, нам не нужен), `workout-routes/` с GPX, `electrocardiograms/`
с CSV. Объём вырос вчетверо — 369 МБ XML и 944 887 записей в 2021 против
1610 МБ и 3 616 171 записи в 2026.
**Главное для импорта: ни один тип не исчез.** Сверка четырёх экспортов
(v11, v13, v14 первый, v14 последний) по всем типам записей не нашла ни одного
случая пропажи — только появление новых:
| появился | типы |
|---|---|
| к 2024 (v13) | `PhysicalEffort`, `TimeInDaylight`, `DistanceCycling`, `HeartRateRecoveryOneMinute`, `AudioExposureEvent`, `LowCardioFitnessEvent` |
| к 2025 (v14) | `AppleSleepingWristTemperature`, `BodyTemperature`, `SexualActivity` |
| к 2026 | `DietaryFiber`/`FatTotal`/`Protein`/`Carbohydrates`, `BloodPressureSystolic`/`Diastolic`, `HighHeartRateEvent` |
Значит разбор экспорта можно писать «аддитивно»: незнакомый тип — это новый
тип, а не сломанный парсер, и падать на нём нельзя.
Появлялись и **структурные** элементы, а это уже опаснее:
`WorkoutStatistics` внутри тренировки (с 2024) и `Correlation` (см. находку 44).
## 43. Коды HealthKit не вечны — Apple переписывает историю при экспорте
Те же самые записи сна, экспортированные с разницей в пять лет, несут **разные
коды**:
```
экспорт 2021-12 338 × HKCategoryValueSleepAnalysisAsleep
экспорт 2026-08 338 × HKCategoryValueSleepAnalysisAsleepUnspecified
62 × HKCategoryValueSleepAnalysisInBed ← в обоих одинаково
```
Совпадение счётчиков до единицы означает, что это одни и те же исторические
записи: `Asleep` переименован в `AsleepUnspecified`, и старые данные при
экспорте переписываются новым именем.
**Следствие:** код HealthKit устойчивее локализованной строки, но не абсолютен.
Словарь категориальных значений обязан переживать переименование самих кодов —
иначе после очередного обновления iOS история расколется вторично, теперь уже
на «стабильной» стороне.
### Словарь фаз сна выводится из данных
В свежих записях экспорта фазы полные, и они однозначно ложатся на локализованные
строки HAE из находки 37:
```
Основная 692 → HKCategoryValueSleepAnalysisAsleepCore
Бодрствование 568 → HKCategoryValueSleepAnalysisAwake
БДГ 206 → HKCategoryValueSleepAnalysisAsleepREM
Во сне 171 → HKCategoryValueSleepAnalysisAsleepUnspecified
Глубокий 94 → HKCategoryValueSleepAnalysisAsleepDeep
В кровати 38 → HKCategoryValueSleepAnalysisInBed
```
То есть первую и главную часть словаря не надо составлять вручную — она
выводится сопоставлением потока с экспортом за тот же период.
## 44. `Correlation` — структурный элемент, и он появился только что
Давление приезжает не записью, а обёрткой из двух записей:
```xml
<Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="PICOOC"
startDate="2026-01-01 09:41:49 +0300" endDate="2026-01-01 09:41:49 +0300">
<Record type="HKQuantityTypeIdentifierBloodPressureSystolic" />
<Record type="HKQuantityTypeIdentifierBloodPressureDiastolic" />
</Correlation>
```
Арифметика сходится: 9 элементов `Correlation` и по 18 записей систолического и
диастолического давления — ровно две записи на обёртку.
`Correlation` объявлен в DTD наравне с `Record` и `Workout`, но в данных до
2026 года не встречался ни разу. Парсер, написанный по одним лишь `Record`,
давление разберёт как две несвязанные метрики и потеряет то, что делает его
измерением — их **парность**.
## 45. DTD экспорта врёт, а имя файла локализовано
Две мелочи, каждая из которых ломает разбор на ровном месте.
**DTD расходится с данными.** В экспорте 2021 года (v11) `<!ATTLIST Me>`
объявляет четыре атрибута, а сам элемент `<Me>` несёт пять — лишний
`HKCharacteristicTypeIdentifierCardioFitnessMedicationsUse`. Валидировать
документ по его собственному DTD нельзя; разбираем то, что есть.
**Имя файла переведено.** Внутри архива лежит `экспорт.xml`, а не `export.xml`
— и так во всех девяти архивах начиная с 2021 года. Имя зависит от языка
телефона (`locale="ru_RU"` в корневом элементе). Захардкоженное `export.xml`
не найдёт ничего.
Там же, в `<Me>`, значение локализовано: `CardioFitnessMedicationsUse="Нет"`.
То есть правило «экспорт говорит кодами» верно для типов записей и категориальных
значений, но не для всего документа.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная