
Привет, Хабр! Я Самсон Авакян, фронтенд-разработчик команды Т-Путешествий в Т-Банке. Расскажу, как мы собираем просмотры офферов для обучения ML-модели ценообразования, когда цена и условия в карточках меняются прямо на экране.
Делюсь решением на React и TypeScript с использованием Intersection Observer.
А еще моя статья — часть проекта «20 в 20», в котором мы рассказываем о жизни наших ИТ-хабов. Из моего выпуска вы узнаете о южных, ростовских традициях.
Какие данные нужно было собрать
Выдача отелей состоит из списка карточек и карты. Пока пользователь сравнивает предложения, результаты поиска продолжают загружаться и обновляться.
Скриншот выдачи: картинка с картой и список карточек
Оффер в этой задаче — предложение отеля с конкретной ценой, кэшбэком, способом оплаты, условиями отмены и питанием. Если цена или условия меняются, это новый оффер.
Для обучения модели нужны данные о просмотренных офферах, в том числе тех, которые пользователь не выбрал. По кликам мы знаем только о выбранных отелях, а в ответе поиска есть и карточки, до которых пользователь не долистал.
Поиск работает через поллинг: приложение повторно запрашивает результаты, пока поиск не завершится. Пользователь уже видит выдачу, а новые ответы обновляют предложения в карточках.
Карточки отелей на выдаче. Поллинг
Например, в карточке показана цена 7 000 ₽, а после следующего ответа поиска она меняется на 6 500 ₽, так как нашлось более выгодное предложение. Пользователь остается на странице выдачи без перезагрузки и перехода на страницу отеля.
Трекинг начинаем с появления офферов: пользователь может увидеть предложение и выбрать отель еще до завершения поиска.
Нужно было:
-
определять, какие офферы пользователь успел увидеть;
-
учитывать обновления оффера в уже видимой карточке;
-
сохранять цену и условия на момент просмотра;
-
убирать повторы в накопленных просмотрах;
-
собирать просмотры офферов и отправлять их вместе при открытии отеля, смене поиска или скрытии вкладки;
-
регистрировать просмотры перед событием выбора отеля, чтобы сохранить порядок действий пользователя.
Что считаем просмотром
Мы отслеживаем блок цены: это ключевая часть оффера, а рядом с ней обычно видна и часть карточки. Просмотр засчитываем, если блок хотя бы частично виден в течение секунды по данным Intersection Observer. Так мы отсеиваем карточки, которые пользователь быстро пролистал.
Отсчет начинается заново, когда Intersection Observer сообщает об исчезновении блока с экрана, меняются цена или условия оффера либо вызывается flush. Два интервала по 600 миллисекунд не складываются в просмотр. Клик по отелю тоже не отменяет секундный порог.
Длительность проверяем при завершении интервала. Например, пользователь видел цену 7 000 ₽ в течение 1,2 секунды. Затем она сменилась на 6 500 ₽, а еще через 1,2 секунды пользователь скрыл вкладку. Оба оффера прошли секундный порог и отправятся одной порцией.
Время в скрытой вкладке не учитываем. После возвращения отсчет начинается заново и новые просмотры попадут в следующую порцию.
Как устроен трекер
Я выбрал Intersection Observer для отслеживания блока цены. Он асинхронно сообщает о пересечении элемента с viewport. Один observer может наблюдать за несколькими карточками, поэтому нам не нужен собственный обработчик прокрутки с измерением координат каждого блока.
Под видимостью здесь мы понимаем геометрическое пересечение с viewport с учетом обрезки родительскими контейнерами. При такой проверке observer не учитывает перекрытие другими элементами и прозрачность блока. Если React обновил цену в существующем блоке, его положение могло вообще не измениться и уведомления от observer не будет. Поэтому приложение отдельно сообщает трекеру об обновлении оффера. Оба источника изменений управляют одним интервалом. Я вынес сбор в объект трекера, который хранит состояние и принимает эти сообщения, а React отвечает за связь между DOM-элементом и данными карточки.
Для списка и карты используется один трекер на страницу. У него две коллекции: наблюдаемые элементы с текущими интервалами и снимки офферов, которые прошли критерий просмотра и ждут отправки. Колбэк observer открывает или завершает интервал, а действия страницы вызывают общий flush.

Перед отправкой flush завершает еще открытые интервалы — это показывает пунктирная стрелка. В приложении сбор находится в хуке, а отправка выполняется через слой действий. Между ними нужен небольшой интерфейс: завершить интервалы, забрать накопитель, очистить отправленную порцию. Так сбор не зависит от конкретного сценария перехода, а действия не хранят ссылки на DOM. В примерах я объединил этот интерфейс в объект с методами updateTarget, removeTarget, flush, suspend и resume, чтобы разобрать механику без деталей нашего фреймворка.
Начнем с того, что сохраняем при регистрации блока:
type OfferSnapshot = Readonly;
objectId идентифицирует отель, searchId связывает оффер с поиском, а source различает показы в списке и на карте. Функция getOfferKey формирует ключ через JSON.stringify из этих полей и условий оффера: цены, кэшбэка, способа оплаты, отмены и питания. Поля для аналитики в ключ не входят.
Если цена или другие значимые условия изменились, оффер учитываем отдельно.
Сохраненные условия нельзя перезаписывать при обновлении карточки. Например, можно сохранить старую цену, а кэшбэк позже прочитать из текущего состояния приложения. В событии получится сочетание, которое пользователь никогда не видел. Поэтому трекер хранит снимок, полученный при регистрации, и не читает поля из состояния приложения позже. Копия в updateTarget защищает и от изменения исходного объекта на месте.
Как собираем просмотры
Для каждого DOM-элемента храним текущий оффер и начало его просмотра:
type ActiveView = { key: string; snapshot: OfferSnapshot; startedAt: number | null; isIntersecting: boolean;};const targets = new Map();const viewed = new Map();const MIN_VIEW_MS = 1000;
targets отвечает за наблюдаемые элементы. viewed одновременно служит накопителем и убирает повторы по ключу. В этом варианте не нужен таймер на каждую карточку. Мы запоминаем начало, а при завершении интервала проверяем разницу:
function finishView(view: ActiveView, endedAt: number): void { if (view.startedAt === null) return; if ( endedAt - view.startedAt >= MIN_VIEW_MS && !viewed.has(view.key) ) { viewed.set(view.key, view.snapshot); } view.startedAt = null;}
Просмотр может быть сохранен позже, чем прошла секунда. Например, пользователь оставил карточку на экране на пять секунд. Запись попадет в накопитель, когда закончится интервал или сработает flush. Для пакетной отправки это подходит: нам не требуется событие ровно на тысячной миллисекунде. Дальше в коде встречаются observer, общий IntersectionObserver трекера, getObserver(), который создает его при первом вызове, и флаг приостановки suspended. canTrack() разрешает открывать интервалы, только когда трекер не приостановлен и вкладка видима. Теперь обработчик видимости:
function onIntersection( entries: IntersectionObserverEntry[],): void { for (const entry of entries) { const view = targets.get(entry.target); if (!view) continue; const { isIntersecting } = entry; if (!isIntersecting) { finishView(view, entry.time); } else if (canTrack()) { view.startedAt ??= entry.time; } view.isIntersecting = isIntersecting; }}
Как и в приложении, observer создается с threshold: 1, а колбэк проверяет только isIntersecting. Поэтому отсчет может начаться уже при частичном пересечении, например если при подключении observer на экране видна лишь узкая полоска блока. threshold задает порог уведомлений, а не условие зачета просмотра. При таком пороге уведомление о полном выходе блока может прийти не сразу, поэтому измеренный интервал не гарантирует непрерывного пересечения с экраном.
Для времени я использую entry.time и performance.now(). Они позволяют считать интервалы по одной шкале времени. При уходе блока из viewport берем время записи observer, а при изменении данных оффера — текущий момент. Есть еще очередь записей, которые observer уже сформировал, но колбэк пока не получил. Перед обновлением оффера и перед flush я обрабатываю ее:
function consumeObserverRecords(): void { if (observer) onIntersection(observer.takeRecords());}
Без обработки очереди можно завершить интервал по устаревшему состоянию видимости. Например, блок уже ушел с экрана, запись об этом ждет обработки, а действие страницы прямо сейчас вызывает отправку. takeRecords() забирает готовые записи. Он не заставляет браузер немедленно измерить геометрию заново, поэтому точность остается в пределах механизма Intersection Observer.
При смене значимых условий сначала обрабатываем накопленные записи observer, а затем завершаем старый интервал. Новый открываем, если по последним обработанным записям observer блок цены пересекается с viewport и вкладка видима. Уведомление observer для этого не требуется. Эту логику выполняет updateTarget:
function updateTarget( element: Element, snapshot: OfferSnapshot,): void { consumeObserverRecords(); const previous = targets.get(element); const key = getOfferKey(snapshot); if (previous?.key === key) return; const now = performance.now(); if (previous) finishView(previous, now); const isIntersecting = previous?.isIntersecting ?? false; targets.set(element, { key, snapshot: { ...snapshot }, isIntersecting, startedAt: canTrack() && isIntersecting ? now : null, }); if (!previous && canTrack()) { getObserver().observe(element); }}
Если ключ остался прежним, повторный рендер карточки ничего не меняет. Это важно: React может создать новый объект с теми же значениями, но пользователь продолжает видеть тот же оффер. Если ключ изменился, finishView проверяет старую версию. В нашем сценарии она была видна 1,2 секунды и попадает в viewed. Для новой версии начало становится равным текущему моменту. Если бы обновление пришло через 400 миллисекунд, первый оффер был бы отброшен. Нельзя приписать новой версии время, в течение которого пользователь видел старую.
На скрытой вкладке данные поиска могут продолжать обновляться, но считать это просмотром мы не будем. Карточка использует хук, который регистрирует DOM-элемент и сообщает об обновлениях снимка:
function OfferCard({ title, offer,}: { title: string; offer: OfferSnapshot;}) { const priceRef = useTrackedPriceRef(offer); return ( {title}
{offer.price} ₽ );}
В списке мы регистрируем блок цены каждой карточки, на карте — тот же блок в раскрытой карточке отеля. Ценовые маркеры карты в этот трекинг не входят. Карточка на карте может исчезнуть при выборе другого отеля даже без прокрутки страницы. Внутри useTrackedPriceRef передаем обновление снимка после изменения DOM до следующей отрисовки браузером. Для этого в клиентском коде используем useLayoutEffect:
useLayoutEffect(() => { snapshotRef.current = snapshot; if (nodeRef.current) { tracker.updateTarget(nodeRef.current, snapshot); }}, [snapshot, tracker]);
Ref-колбэк мемоизирован через useCallback с зависимостью от экземпляра трекера. Пока эта зависимость не меняется, обновление оффера не требует переподключать ref. Колбэк регистрирует новый элемент с актуальным снимком из snapshotRef и снимает наблюдение со старого. При удалении элемента removeTarget завершает его интервал, вызывает unobserve и убирает запись из targets. Один экземпляр трекера живет в provider страницы, а карточки получают его через контекст. Изменение интервалов и накопителя не требует обновления React state и перерисовки карточек.
Когда отправлять просмотры
Отправку нельзя связать только с кликом по карточке. Пользователь может изменить поиск, уйти по другой ссылке или просто закрыть вкладку. При разборе страницы я проверял, в каких местах меняется контекст выбора или заканчивается возможность отправить накопленные данные.
Ситуация
Что нужно сделать
Пользователь открывает отель из списка или с карты
Отправить просмотры в аналитику до события выбора
Начинается новый поиск
Завершить просмотры старого поиска до замены его контекста
Пользователь переходит в избранное
Вызвать flush перед переходом
SPA переходит на другую страницу
Вызвать flush в сценарии перехода
Документ становится скрытым
Остановить интервалы и отправить накопитель
Происходит pagehide
Повторить остановку и попытку отправки
Для перехода порядок вызовов остается простым:
tracker.flush();openHotelPage();
Порядок вызовов задает порядок, в котором события уходят в аналитику. Ждать сетевого ответа перед открытием страницы не нужно. Действия страницы обращаются к одному методу отправки. Когда появляется новый сценарий, например еще один способ открыть отель, в нем достаточно вызвать тот же flush. Логику проверки секунды и ключа оффера копировать не приходится.
К этому моменту трекер умеет завершать интервал при потере видимости и обновлении оффера. Но в момент отправки блок цены может оставаться в viewport, поэтому уведомления о его исчезновении не будет. Перед чтением накопителя flush завершает и эти интервалы. Если трекер не приостановлен и вкладка видима, он сразу начинает новые:
function completeVisibleViews(now: number): void { for (const view of targets.values()) { finishView(view, now); if (canTrack() && view.isIntersecting) { view.startedAt = now; } }}
При скрытии вкладки трекер перед flush приостанавливается методом suspend() — его разберем в следующем разделе. Тогда canTrack() вернет false и новые интервалы не откроются. Сам flush выглядит так:
function flush(): void { consumeObserverRecords(); completeVisibleViews(performance.now()); const batches = new Map(); for (const offer of viewed.values()) { const batch = batches.get(offer.searchId) ?? []; batch.push(offer); batches.set(offer.searchId, batch); } for (const [searchId, offers] of batches) { enqueue({ type: 'offersViewed', searchId, offers }); } viewed.clear();}
enqueue в этом примере синхронно ставит событие в очередь отправки в аналитику и не бросает исключений. Сетевые запросы и повторные попытки остаются за этой очередью. Мы используем searchId из сохраненного снимка. Если к моменту отправки приложение уже переключилось на другой поиск, старые просмотры не должны получить его идентификатор. При скрытии вкладки завершается и интервал оффера A за 6 500 ₽, который был виден еще 1,2 секунды. Поэтому первая порция в нашем сценарии содержит обе версии оффера:
{ type: 'offersViewed', searchId: 'search-1', offers: [ { objectId: 'A', price: 7000, cashback: 350, /* ... */ }, { objectId: 'A', price: 6500, cashback: 325, /* ... */ }, ],}
Длительности нужны трекеру для принятия решения, а в событии мы передаем данные просмотренных офферов. До очередной отправки один и тот же оффер хранится в viewed один раз, даже если пользователь возвращался к нему несколько раз. После flush накопитель очищается и новый просмотр этого оффера может попасть в следующее событие. Мы не храним отдельный набор просмотренных ключей на всю сессию.
Уход со страницы и возвращение
При скрытии вкладки нужно завершить интервалы и отправить накопленное, а после возвращения продолжить наблюдение. Флаг вида hasFlushed = true здесь не подходит. Он защитит от повторного события ухода, но заблокирует и новую порцию после возвращения. Поэтому я останавливаю наблюдение отдельным методом:
function suspend(): void { consumeObserverRecords(); suspended = true; const now = performance.now(); for (const view of targets.values()) { finishView(view, now); view.isIntersecting = false; } observer?.disconnect();}
Метод suspend() закрывает интервалы и сбрасывает прежние сведения о пересечении с видимой областью. После возвращения observer должен заново сообщить положение элементов:
function resume(): void { if (!suspended || document.visibilityState !== 'visible') { return; } suspended = false; for (const [element, view] of targets) { view.startedAt = null; view.isIntersecting = false; getObserver().observe(element); }}
Подписываемся на visibilitychange, pagehide и pageshow:
useEffect(() => { const stopAndFlush = () => { tracker.suspend(); tracker.flush(); }; const onVisibilityChange = () => { if (document.visibilityState === 'hidden') { stopAndFlush(); } else { tracker.resume(); } }; document.addEventListener('visibilitychange', onVisibilityChange); window.addEventListener('pagehide', stopAndFlush); window.addEventListener('pageshow', tracker.resume); onVisibilityChange(); return () => { document.removeEventListener('visibilitychange', onVisibilityChange); window.removeEventListener('pagehide', stopAndFlush); window.removeEventListener('pageshow', tracker.resume); tracker.suspend(); };}, [tracker]);
Состояние hidden покрывает переключение вкладок и сворачивание браузера. Событие pagehide срабатывает при уходе из документа и совместимо с bfcache, а pageshow возобновляет наблюдение, когда страница восстанавливается из него. Если оба события ухода сработают подряд, второй flush увидит пустой накопитель. Трекер уже приостановлен, и новые интервалы между ними не откроются.
На beforeunload полагаться не стоит. На мобильных он может не сработать, а в Firefox постоянная подписка на него мешает bfcache.
При закрытии документа обычный запрос может не успеть завершиться, поэтому для аналитики есть navigator.sendBeacon(). В примере payload это событие offersViewed из flush:
const body = new Blob( [JSON.stringify(payload)], { type: 'application/json' },);const accepted = navigator.sendBeacon('/analytics', body);
accepted === true значит только то, что браузер поставил данные в очередь. Обработал ли их сервер, отсюда не узнать. В приложении этот вызов спрятан за той же очередью отправки, трекер его не делает. Ни один обработчик не гарантирует отправку при любом завершении браузера, процесс могут убить без событий. Поэтому мы пробуем отправить данные во всех доступных точках жизненного цикла страницы.
Ростовский ИТ-хаб
Расскажу о том, где я занимался описанной задачей. У Т-Банка есть офис в Ростове-на-Дону с видом на Музыкальный театр и Ростов Арену. На мой взгляд, одна из лучших локаций в городе.
Ростов — один из крупнейших городов юга России с развитой системой подготовки ИТ-специалистов. Мы активно сотрудничаем с вузами региона: Институтом математики, механики и компьютерных наук Южного федерального университета и Донским государственным техническим университетом. Особенно тесная связь сложилась с мехматом ЮФУ: многие его студенты приходят в хаб на стажировку, а затем продолжают работу уже в роли джунов.
Ростовский ИТ-хаб открылся в 2017 году и стал первым хабом Т-Банка в Южном регионе. Сегодня у нас работает более 200 сотрудников, а сам хаб объединяет команды и специалистов разных технологических направлений. Раньше Ростов был прежде всего точкой притяжения для дата-инженеров, но сейчас экспертный охват хаба стал шире: здесь работают Java- и JavaScript-разработчики, SRE-инженеры, аналитики и представители других направлений.
Мы южный ИТ-хаб, и нам нравится сочетать тепло, открытость и гостеприимство. Одной из локальных традиций стал день шаурмы. Специально для него мы создали шаурма-бота. Каждый четверг он помогает коллегам собрать общий заказ — небольшой внутренний проект, который хорошо передает атмосферу хаба: технологии у нас используются не только для решения серьезных рабочих задач, но и для того, чтобы делать жизнь команды удобнее и веселее.
Так мы встречаем Масленицу
Заключение
Я показал, как мы решили задачу трекинга просмотров в условиях динамической выдачи. Ключевая идея — разделить DOM-элемент и оффер: первый задает место наблюдения, второй определяет условия, которые мы учитываем. Intersection Observer сообщает о видимости, а приложение — о смене оффера. Это позволило обрабатывать случаи, когда карточка остается на месте, но ее содержимое обновляется.
При смене оффера или получении уведомления о выходе блока из видимой области трекер завершает текущий интервал и проверяет его длительность. Общий flush одинаково обрабатывает переходы, новый поиск и скрытие вкладки, а после возвращения отсчет начинается заново. Точность такого сбора можно проверить по конкретным переходам состояния: старая цена не смешивается с новым кэшбэком, время одного оффера не достается другому, два коротких появления не складываются в секундный просмотр. Сам секундный порог остается эвристикой: он означает, что у человека была возможность рассмотреть оффер, но не измеряет его внимание. Именно с таким смыслом событие и должно попадать в данные, на которых учится модель.
Если вы сталкивались с похожими задачами трекинга в динамическом контенте, поделитесь опытом в комментариях. Как вы определяете момент просмотра? Используете Intersection Observer или свой механизм? Какие эвристики применяете для отсечения случайных показов?
Буду рад обсудить нюансы реализации и узнать о ваших подходах к сбору аналитики в реактивных интерфейсах.
Источник: habr.com