The Jerusalem PostAfter meeting in Turkey, can Syria's new government manage tensions with Hezbollah? - analysisPunchMany feared killed as helicopter crashes in OndoESPNTravis Hunter's juke drops Tee Higgins on nullified pickDaily MaverickSOCIAL (IN)SECURITY: A Cape Town mother buys bread on credit to stretch her children’s grants, then spends payday settling the debtRTP Desporto12h30 Henrique Calisto quer saída digna para Cristiano RonaldoSouth China Morning PostAI microdramas are a test case for China’s next big export waveZDF heuteAktuelle Pressemitteilungen des ZDFBusiness AMOekraïne heeft steeds meer gevechtsvliegtuigen, maar kampt met een nijpend tekort aan ervaren pilotenn-tv"Entlastung schmilzt zusammen": Sprit fast überall teurer als zu Beginn des Tankrabatts01netAndroid Auto ne fonctionnera bientôt plus sur des millions de smartphonesColliderThe 5 Darkest Movies of the Last 5 YearsTagesschauBND-Chef warnt vor "gewaltsamem Konflikt mit Russland"
The Daily Newsstand · Free, Always
Monday, October 5, 2026

Как посчитать статистику переписки на 500 тысяч сообщений в браузере и не увидеть ни одного

Translate

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

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

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

Для бенчмарка я взял круглую цифру: синтетический чат на 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. Есть ли способ проще или быстрее, например готовая библиотека, которую вы бы взяли?

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.