
Kluczowe informacje
Cel: Zastąpienie śmieciowych danych (przypadkowe scrolle) metryką deep engagement w środowisku Single Page App.
Stack: GTM (Custom HTML), React/Vite, IntersectionObserver API, GA4.
Wynik: Eliminacja "fałszywych czytelników" i pełna higiena danych.
Standardowe śledzenie Scroll Depth (90%) w GTM to w dzisiejszych czasach Vanity Metric. Użytkownicy scrollują do stopki, by sprawdzić linki, adres firmy lub po prostu "przelatują" wzrokiem (skanowanie), nie konsumując treści. Traktowanie scrolla jako "przeczytanie" fałszuje statystyki zaangażowania.
Problem ten drastycznie eskaluje w środowisku SPA (Single Page Application). Reactowa architektura strony sprawia, że standardowe triggery GTM wariują, a brak przeładowania strony (Hard Reload) powoduje, że timery i listenery z poprzednich podstron działałają w tle.
Opieranie decyzji contentowych o to, że "ktoś zescrollował stronę", to proszenie się o kłopoty. Na mojej stronie potrzebowałem twardych danych: kto faktycznie poświęcił czas na lekturę.
Zdecydowałem się na wstrzyknięcie logiki przez GTM (Custom HTML Tag), aby nie nie zmieniać kodu strony przy każdej zmianie parametrów algorytmu. Data Flow: GTM Trigger (virtual_page_view) -> GTM Custom HTML (Injection & Cleanup) -> DOM (React) -> Data Layer -> GA4.
Budowa rozwiązania w GTM dla aplikacji SPA wymagała obejścia kilku krytycznych przeszkód:
Asynchroniczny Contentful vs GTM: W architekturze Headless, treść artykułu nie jest częścią statycznego kodu HTML – jest dociągana z API Contentful już po załadowaniu aplikacji. Powodowało to krytyczny race condition: GTM inicjował skrypt w milisekundach, gdy kontener div.prose był jeszcze pusty lub w ogóle nie istniał w DOM. Standardowy skrypt kończyłby się błędem „undefined” lub zerową liczbą słów. Zamiast niepewnego setTimeout, zaimplementowałem inteligentne oczekiwanie. Skrypt w pętli (co 500ms) "odpytuje" strukturę DOM o obecność treści z Contentfula. Dopiero w momencie, gdy React pomyślnie wyrenderuje dane z API, następuje "uchwycenie" tekstu (innerText), kalkulacja metryk i aktywacja obserwatora. Dzięki temu analityka jest odporna na opóźnienia sieciowe (latency) po stronie API CMS-a.
SPA Memory Leaks (Zombie Observers): W SPA zmiana adresu URL nie czyści obiektu window. Każde przejście na nowy artykuł odpalało tag GTM ponownie, tworząc nową instancję IntersectionObserver, podczas gdy stary observer nadal nasłuchiwał w tle. Przy 5 przeczytanych artykułach miałem 5 działających w tle skryptów wysyłających zduplikowane eventy. Rozwiązanie: Dodałem logikę "Garbage Collector" na początku skryptu. Kod sprawdza, czy w window istnieje już instancja mojego observera i jeśli tak – wykonuje na niej twarde .disconnect().
Matematyka "True Read": Zrezygnowałem ze sztywnego czasu. Czas potrzebny na przeczytanie jest dynamiczny (WordCount / 225 words per minute). Warunkiem zaliczenia konwersji jest:
Użytkownik dotarł do końca artykułu (Intersection Observer na stopce).
Upłynął minimalny czas (Threshold = 25% estymowanego czasu).
<script>
(function() {
var MAX_RETRIES = 10;
var RETRY_INTERVAL = 500;
if (window.trueReaderObserver) {
try { window.trueReaderObserver.disconnect(); } catch(e) {}
window.trueReaderObserver = null;
}
window.trueReaderHasRead = false;
var retryCount = 0;
var waitForContent = setInterval(function() {
var contentBlocks = document.querySelectorAll('[class*="prose"]');
if (contentBlocks.length > 0) {
clearInterval(waitForContent);
initTrueReader(contentBlocks);
}
else if (retryCount >= MAX_RETRIES) {
clearInterval(waitForContent);
}
retryCount++;
}, RETRY_INTERVAL);
function initTrueReader(contentBlocks) {
var startTime = new Date().getTime();
var allText = "";
contentBlocks.forEach(function(block) {
allText += block.innerText || block.textContent;
});
var wordCount = allText.trim().split(/\s+/).length;
if (wordCount < 50) return;
var estTime = Math.ceil((wordCount / 225) * 60);
var threshold = estTime * 0.25;
window.trueReaderObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting && !window.trueReaderHasRead) {
var currentTime = new Date().getTime();
var timeSpent = (currentTime - startTime) / 1000;
if (timeSpent >= threshold) {
window.trueReaderHasRead = true;
window.dataLayer.push({
'event': 'content_read_complete',
'time_spent': Math.round(timeSpent),
'est_total_time': estTime,
'article_title': document.title
});
window.trueReaderObserver.disconnect();
}
}
});
}, { threshold: [0.1] });
var lastBlock = contentBlocks[contentBlocks.length - 1];
if (lastBlock) {
var target = lastBlock.lastElementChild || lastBlock;
window.trueReaderObserver.observe(target);
}
}
})();
</script>Wyeliminowanie Vanity Metrics: Skończyłem z "fałszywymi scrollami" w raportach. content_read_complete oznacza teraz rzeczywiste zapoznanie się z materiałem.
Velocity Index: Dzięki parametrom w DataLayer widzę, które artykuły są czytane szybciej niż standard (bardzo angażujące) lub wolniej (trudne/nudne).
Event content_read_complete zarejestrowany po spełnieniu warunków algorytmu. W tym przypadku estymowany czas czytania wynosił 158s. Użytkownik spędził 69s (43%), co przekroczyło zdefiniowany próg walidacji (25%), kwalifikując wizytę jako "content_read_complete".

Global Scope Pollution: Wstrzykując kod JS w GTM na stronach SPA, musisz przypisywać zmienne do window (np. window._trueReaderObserver), aby mieć do nich dostęp przy kolejnym "wirtualnym przeładowaniu" i móc je wyczyścić. Zmienne lokalne var znikną z pamięci podręcznej funkcji, ale Listener na DOM zostanie.
Zmienne vs Treść: Pamiętaj, że innerText pobiera też tekst ukryty (np. w zwiniętych akordeonach). Jeśli masz dużo ukrytej treści, Twoja estymacja czasu może być zawyżona.
Umów bezpłatną konsultację i sprawdź, jak mogę pomóc Twojemu biznesowi.
Umów konsultację

