Переписка с близким человеком — одна из самых личных вещей на телефоне. А я делаю сервис, которому нужно её прочитать. Из файла экспорта получаются карточки: кто чаще пишет первым, как быстро отвечает, в какие часы вы на связи, какие словечки появились у пары и кто из вас их придумал. Просьба «загрузите переписку к нам на сервер» убила бы такой проект в день запуска, и правильно.

Поэтому у «Чатограммы» одно жёсткое правило: всё считается в браузере, и по умолчанию сервер не видит ни одного сообщения. Ниже — как это устроено технически, во что это обходится по скорости и где у правила есть исключения. Все цифры я замерил на днях, в том числе на своих настоящих переписках, а не только на синтетике.

Сначала про цифры

Для бенчмарка я взял круглую цифру: синтетический чат на 500 тысяч сообщений. Ещё вчера сайт такой файл не принял бы: он весит 197 МБ, а мы отказывали всем, у кого JSON больше 150 МБ. Лимит стоял из-за JSON.parse: ему нужен весь текст одной строкой, а строка в браузере не больше полугигабайта-гигабайта, на телефоне меньше.

Поводом убрать лимит стала наша же аналитика. Один человек с чатом больше 300 тысяч сообщений четыре раза подряд выбрал result.json, четыре раза получил «Файл больше 150 МБ», а через 18 минут загрузил заново, уже без медиа. Большие чаты как раз самые интересные, и именно их мы отгоняли. Теперь файл читается кусками, об этом отдельный раздел ниже. А пока цифры.

Для проверки я прогнал через движок свои переписки: 16 диалогов и бесед из Telegram, ВКонтакте и WhatsApp, 808 тысяч сообщений за 11 лет. Самая большая беседа в выгрузке ВК — 194 тысячи сообщений, самый большой личный диалог в Telegram — 92 тысячи. Плюс синтетический чат на те самые 500 тысяч. Вот сколько в браузере проходит от выбора файла до экрана с карточками:

Как мерил: настольный Ryzen 7 5800X, WebKit (движок Safari) под Playwright, медиана трёх запусков, сайт в production-сборке. В это время входят чтение файла, разбор, все метрики и два хеша чата (про них ниже). На телефонах я пока не мерил, поэтому там ожидается чуть больше.

Что тут видно:

  • Обычные личные чаты — это секунды. 92 тысячи сообщений, 41 МБ JSON — 2,3 секунды.

  • Синтетические 500 тысяч — 5,7 секунды. Этот файл на 197 МБ раньше отклонялся, теперь читается кусками.

  • Беседа ВК на 194 тысячи — 11 секунд. Это много, и это мой следующий кандидат на оптимизацию. В Node на разбор HTML в windows-1251 и на сами метрики уходит примерно поровну, около четырёх секунд на каждое.

  • Синтетика оптимистичнее жизни. Синтетика считается быстрее, чем живые чаты: те же 100 тысяч сообщений — 1,3 секунды (Node), а реальной беседе на 194 тысячи одни только метрики нужны около 4 секунд: сообщений в два раза больше, а времени втрое. Почему так, я ещё не раскладывал по шагам. Похоже, дело в более длинных текстах и в числе участников: в этой беседе их 153.

Теперь по порядку: что мы вообще разбираем и как устроен расчёт.

Что на входе

Четыре источника:

  • Экспорт Telegram Desktop: один JSON (или HTML) на чат. У активной пары за несколько лет это 50–150 тысяч сообщений и десятки мегабайт, а если выгрузить ещё и медиа, то сотни мегабайт и гигабайты: в JSON попадают длинные поля про каждое фото и видео. Внутри date_unixtime, автор, текст (строкой или массивом кусочков с разметкой), реакции, стикеры, голосовые, звонки. Экспортировать можно только с компьютера: в мобильных приложениях и веб-версии экспорта нет.

  • Архив данных ВКонтакте — Archive.zip, где каждая переписка разложена по HTML-страницам в windows-1251. У меня 82 МБ, 35 тысяч файлов и 7 195 диалогов, а нужен обычно один.

  • Экспорт WhatsApp — текстовый файл или zip. Тут самое интересное — даты. У iPhone строка начинается с [28.09.2026, 10:15:03], у Android с 28.09.2026, 10:15 -, а в английской локали вместо этого 9/28/26, 10:15 AM. Что в строке день, а что месяц, файл не подписывает: порядок приходится определять по всему файлу. А перед «AM» iPhone ставит узкий неразрывный пробел U+202F, на котором ломаются самые простые регулярки.

На выходе — ChatReport: только числа, даты, ключи участников и одиночные слова из словарей. Из него рисуются карточки.

Одноразовый воркер

Разбор и расчёт живут в Web Worker. Воркер создаётся на один файл и убивается после ответа. Упрощённо:

export function runAnalysis(source: AnalysisSource, timeZone: string, onProgress: (p: Progress) => void): Promise<AnalysisResult> {
  const worker = new Worker(new URL('./analyze.worker.ts', import.meta.url), { type: 'module', name: 'analyze' });
  return new Promise((resolve, reject) => {
    const finish = () => worker.terminate();
    worker.onmessage = (event) => {
      const msg = event.data;
      if (msg.type === 'progress') onProgress(msg);
      else if (msg.type === 'done') { finish(); resolve(msg.result); }
      else if (msg.type === 'error') { finish(); reject(new AppError(msg.code, msg.message)); }
    };
    // Например, нехватка памяти на очень большом файле.
    worker. => { event.preventDefault(); finish(); reject(new AppError('worker_failed', event.message)); };
    worker.postMessage({ op: 'analyze', source, timeZone });
  });
}

Зачем так, а не долгоживущий воркер:

  1. Интерфейс не замирает. Разбор 40-мегабайтного JSON и три десятка метрик — секунды работы процессора. В основном потоке телефон просто завис бы.

  2. Память освобождается целиком. Текст переписки живёт только внутри воркера. Наружу через postMessage выходит отчёт: для чата на 92 тысячи сообщений это 40 КБ чисел. terminate() выбрасывает всё остальное разом, и не нужно надеяться, что сборщик мусора когда-нибудь доберётся до гигантского массива строк.

  3. Граница приватности видна в коде. Всё, что пересекает postMessage, — кандидат на утечку. Таких потоков три: прогресс, отчёт и два небольших довеска для экрана — несколько цитат «по нажатию» (5 КБ) и материал для книги (26 КБ). Их легко проверить глазами.

Текстовый слой: трогаем строки один раз

Первая версия была честной и медленной. Каждая метрика сама бегала по сообщениям и сама делала toLowerCase(), регулярки и разбиение на слова: «любимые слова», «эмодзи», «кто пишет „люблю“», «кто смеётся», «словечки пары» — каждая по-своему. На 500 тысячах сообщений это давало около четырёх секунд, и почти всё — повторная работа с одними и теми же строками.

Решение скучное: текстовый слой. Один проход по всем сообщениям, после которого строки больше никто не трогает. Метрики работают с числовыми массивами:

export interface TextLayer {
  /** id → словоформа в нижнем регистре с «ё» → «е». */
  vocab: string[];
  vocabIndex: Map<string, number>;
  /** Токены всех сообщений подряд: id | BOUNDARY. */
  tokens: Uint32Array;
  /** Токены сообщения i — tokens[tokenStart[i] .. tokenStart[i + 1]). */
  tokenStart: Uint32Array;
  /** Признаки сообщения: вопрос, улыбка скобкой, точка в конце, КАПС… */
  flags: Uint32Array;
  /** Признаки словоформы по id: «люблю», «мы», «скучаю», слово-паразит… */
  lex: Uint32Array;
  /** Сколько раз встретилась словоформа. */
  count: Uint32Array;
  /** Сколько раз словоформа написана с заглавной не в начале сообщения — признак имени. */
  capitalizedMid: Uint32Array;
  // …и ещё несколько счётчиков такого же рода
}

Что это даёт:

  • Словарь словоформ вместо строк. Каждое слово встречается в vocab один раз, дальше по всему чату — только его номер в Uint32Array. «Люблю» в сотый раз — это сравнение двух чисел, а не двух строк.

  • Признаки — битами. Вопрос ли это, есть ли улыбка скобкой, стоит ли точка в конце — один Uint32 на сообщение. Метрика «кто ставит точки» превращается в цикл по flags с маской.

  • Словари проверяются один раз на словоформу, а не на каждое вхождение. Если «скучаю» помечено битом в lex, метрике «кто больше скучает» не нужна ни одна регулярка.

  • Слой ленивый и кэшируется на контексте расчёта (WeakMap<Context, TextLayer>): его строит первая метрика, которой он понадобился.

Одна мелочь, которая сэкономила нам вечер: capitalizedMid — сколько раз слово написано с заглавной не в начале сообщения. Имена собеседников почти всегда такие, обычные слова почти никогда. Без этого поля в «ваших словечках» всплывали имена друзей.

Что это дало на синтетике в 500 тысяч сообщений (замеры от 16 сентября, Node): весь расчёт — с 4,1 до 3,3 секунды, а метрика «любимые слова» — с 881 до примерно 80 миллисекунд.

Бюджет скорости

Скорость у нас не пожелание, а бюджет с журналом замеров. В CI один тест: синтетический чат на 100 тысяч сообщений должен пройти разбор и все метрики быстрее 10 секунд. Бюджет на 500 тысяч держим вручную по таблице в репозитории: если PR трогает ядро, прогоняем бенчмарк, и если время выросло, сначала находим, какой шаг, а уже потом спорим, нужна ли метрика.

Честная оговорка: с тех пор мы добавили много новых метрик, и сегодня те же 500 тысяч синтетики считаются за 4,9 секунды (всего с разбором — 5,9 с), а 100 тысяч — за 1,1. Бюджет выдерживаем, но выигрыш от слоя «съели» новыми возможностями.

Отдельно про «ваши словечки». Это слова, которые у пары встречаются намного чаще, чем в языке вообще. Для этого нужна частотность русских словоформ — статический файл на 2 МБ с нашего же домена (338 тысяч словоформ). Нет файла или нет сети — словечки просто не считаются, остальное работает.

ZIP без распаковки

С архивом ВКонтакте главная проблема — память телефона, а не скорость. Читать весь zip ради одного диалога нельзя, поэтому:

  1. Хвост файла читаем через blob.slice() и находим оглавление архива, центральный каталог. Для моих 35 тысяч файлов это порядка четверти секунды.

  2. Показываем список диалогов. Этот воркер живёт, пока человек выбирает, и заодно считает «топ собеседников».

  3. Страницы выбранного диалога читаем точечно, тем же slice(), и распаковываем встроенным DecompressionStream('deflate-raw'). Всё остальное в архиве остаётся сжатым на диске.

Для архивов ВК лимита в 150 МБ не было и раньше: из них читается один диалог. Про zip и windows-1251 подробнее — в моей прошлой статье про книгу.

Файл любого размера

С result.json Telegram так не получалось: это один JSON, и вырезать из него кусок нельзя, пока не прочитаешь. Мы переписали чтение так, чтобы в памяти всегда лежало одно сообщение, а не весь файл.

Сканер верхнего объекта. Файл приходит кусками из Blob.stream(). Сканер идёт по символам и помнит только состояние: внутри ли строки, на какой глубине скобок, где граница текущего значения. Значения ключей верхнего уровня он делит на три режима:

export type ValueMode =
  /** Пропустить, не копя в памяти. */
  | 'skip'
  /** Отдать целиком текстом (короткие значения: name, type, id). */
  | 'capture'
  /** Значение — массив: отдавать его элементы-объекты по одному. */
  | 'elements';

Массив messages идёт в режиме elements: каждое сообщение выходит наружу текстом, и дальше его разбирает обычный JSON.parse, один раз и на крошечный объект. Бессмысленно писать парсер чисел и строк заново, когда нужны только границы значений.

Тот же результат. Построитель чата у потокового и обычного разбора общий, поэтому на выходе одно и то же. Тесты сравнивают их через toEqual и режут вход на куски от одного символа до миллиона, с юникодом и экранированием посередине. Старые экспорты без date_unixtime читаются вторым проходом.

Каналы и боты отклоняются сразу, как только прочитан type, не дочитывая файл. Полный экспорт аккаунта — по ключу chats, тоже на первых байтах.

Zip и WhatsApp. Для zip сжатые блоки по 4 МБ подаются в DecompressionStream по мере чтения результата, потолка на размер записи нет. Для текста WhatsApp режем куски на строки, и самая вредная ошибка тут — пара CR LF, порванная на границе двух кусков.

Результат на синтетике: 500 тысяч сообщений, 197 МБ — 5,7 секунды в браузере. Тяжёлый случай из нашего журнала: 800 тысяч сообщений с длинными полями про медиа, 1,3 ГБ. Node читает его с пиком памяти около 0,45 ГБ, а Chrome доходит до экрана «Почти готово» за 18 секунд (эти два замера из журнала разработки, не из сегодняшнего прогона).

Как устроена приватность — не на словах

«Мы не храним ваши данные» пишут все. Нам хотелось, чтобы нарушить это обещание было трудно даже нам самим.

1. Текст из переписки — отдельный тип. Любая цитата длиннее слова получает брендированный тип, и получить его можно только через одну функцию:

declare const privateBrand: unique symbol;
/** Текст из переписки. Получить можно только через makeExcerpt; в ChatReport такого типа быть не должно. */
export type PrivateText = string & { readonly [privateBrand]: true };

А рядом — проверка на уровне типов. Если кто-то добавит в ChatReport поле с PrivateText, сборка упадёт:

const reportHasNoPrivateText: Exactly<ContainsPrivate<ChatReport>, false> = true;

2. «Канарейка». Генератор синтетических чатов умеет подмешивать в переписку заранее известные фразы, телефон и ссылку-приглашение. Тест разбирает такой чат и проверяет, что в JSON отчёта нет ни одной из них, а в «личных моментах» (их видит только сам человек, по нажатию) они есть. Так проверяется и то, что приватность не сломана, и то, что тест не пустой.

3. CSP не даёт ходить никуда, кроме своего домена. Вот заголовок с боевого сайта:

default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline';
script-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:;
frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://securepay.tinkoff.ru

Никаких сторонних счётчиков, шрифтов с CDN и рекламных пикселей: аналитика у нас своя, на нашем же домене. Даже если в зависимость завтра проберётся что-то любопытное, отправить данные на чужой адрес браузер ему не даст. Единственный внешний адрес — страница оплаты банка в form-action.

Оговорка, без которой разговор был бы нечестным: от нашего собственного сервера CSP не защищает, connect-src 'self' разрешает запросы к нему. Поэтому важен следующий пункт.

4. Что уходит на сервер. Вот вкладка Network, пока считался чат на 92 тысячи сообщений:

Разберём, чтобы ничего не оставалось тёмным.

  • Шаги пути по сайту (/api/steps): имя шага («открыл сайт», «выбрал файл», «посчитал»), тип файла, тип чата — личный или группа, откуда пришёл. Ни адреса страницы, ни текстов, ни размеров в точных числах.

  • Два хеша чата (/api/peer-access). Сразу после расчёта браузер спрашивает сервер, не купил ли этот чат собеседник и не открыл ли он его вам. Сообщений и имён в хешах нет. Первый — PBKDF2 на 210 тысяч итераций: он нарочно медленный, потому что вход состоит из коротких ID, а быстрый хеш от них перебрали бы очень быстро. Второй — обычный SHA-256 от прежнего формата, в который входит дата первого сообщения; он нужен, чтобы открывались старые покупки. Честно: если известны ID обоих участников, перебор возможен и у PBKDF2, просто очень дорогой.

  • Если человек платит — почта для чека; саму оплату принимает банк на своей странице.

  • Если человек вошёл и включил хранение отчётов — сам отчёт. Числа лежат открыто, а имена, слова, даты, несколько сообщений для карточек и фрагменты для бумажной книги зашифрованы (AES-GCM, ключ выводится из секрета). Ключ хранится в аккаунте, чтобы отчёты открывались на любом устройстве, где вы вошли. Значит, технически сервер мог бы его применить, и несколько цитат человека у нас в таком случае лежат. Раньше ключ был только у владельца, но «открыл отчёт на новом телефоне без QR-кода и пересылки ключей» оказалось для людей важнее, и мы выбрали удобство. Это не end-to-end, и об этом прямо сказано в согласии на хранение. Файл экспорта и переписка целиком не уходят всё равно, а вход и хранение человек включает сам. На сервере храним только сами ключи шифрования и ничего больше.

  • Если попросили напомнить в мессенджере — время и куда написать.

5. Офлайн. После первого открытия всё нужное для расчёта лежит в кэше service worker, так что сайт работает без сети. Это самое простое доказательство из всех.

Как проверить самому

Это та часть, ради которой всё и затевалось: проверка, не требующая нам верить.

  1. Откройте страницу и дождитесь полной загрузки.

  2. Включите авиарежим или выдерните кабель.

  3. Выберите файл экспорта.

  4. Итоги посчитаются: для расчёта сеть не нужна.

Авиарежим доказывает, что расчёт идёт на устройстве. Что после него ничего лишнего не уходит, видно во вкладке Network в DevTools: включите запись, загрузите файл и посмотрите на запросы и их тела. Как раз с авиарежимом на iPhone мы и намучились: страница там не знает, что сети нет, об этом я писал отдельной статьёй.

Что не получилось (пока)

  • Беседа на 194 тысячи — 11 секунд. Разбор HTML ВКонтакте и метрики — два основных кандидата на ускорение.

  • Телефоны. Все замеры выше сделаны на настольном процессоре, пик памяти вкладки на телефоне я не мерил. Потоковое чтение требует Blob.stream() и DecompressionStream, то есть Safari 16.4 и новее; как это выглядит на старом Android, я не знаю.

  • Полный экспорт аккаунта Telegram (все чаты одним файлом) пока не поддерживается: адаптер узнаёт его по chats.list и просит экспортировать один чат.

Итог

Если коротко: одноразовый воркер, чтение файла кусками, один проход по строкам, числовые массивы вместо строк, бюджет скорости с журналом, приватность, закреплённая типами и тестом-«канарейкой», и CSP, который не даёт ничему утечь на чужой адрес. Реальные чаты считаются за секунды, самая большая из проверенных бесед на 194 тысячи — за 11, синтетические 500 тысяч — за 5,7. Файл с перепиской при этом на сервер не уходит.

Буду рад вопросам и критике. Мне особенно интересно:

  • Как бы вы доказали пользователю, что данные не уходят? Авиарежим и вкладка Network — это то, что придумали мы. Есть ли что-то убедительнее?

  • Видите ли дыру в нашей модели? Мы осознанно оставили хеш чата и ключ в аккаунте, но только их. Если считаете, что это зря, расскажите почему.

  • Потоковый разбор JSON. Мы написали свой сканер границ, а содержимое отдаём JSON.parse. Есть ли способ проще или быстрее, например готовая библиотека, которую вы бы взяли?