Gorący eksperyment

Jak zachowuje się użytkownik na stronie. Na co patrzy i w co klika? A konkretniej: czy patrzy i kilka w to, w co chcemy, żeby klikał (zwykle najbardziej chcemy, żeby klikał w rzeczy opisane „kup”, „zapłać” etc.) Takie dylematy towarzyszą na co dzień osobom biznesowo odpowiedzialnym za strony i aplikacje. Niedawna dyskusja na ten temat zainspirowała mnie do przygotowania prostego skryptu heatmapy. Raczej proof of concept i eksperyment niż gotowe rozwiązanie, ale coś tam napisałem. I tylko liczę na to, że typowy użytkownik przegląda mojego bloga trochę inaczej niż ja – bo zaglądałem głównie do Firebuga (łącznie z jego kodem źródłowym)

Jeśli zdarzy Wam się, że znajomego e-marketingowca dopadnie czkawka, naprędce przebierzcie się za Niską Konwersję (preferowane osoby niskiego wzrostu). Na pewno tak go przestraszycie, że zaraz mu przejdzie. Konwersja to słowo w tym kręgu kluczowe. I nie ma w tym nic dziwnego. Wszelkie piękne layouty, usprawnienia UX czy wreszcie optymalizacje kodu są na darmo, jeśli userzy nie konwertują.

Do walki o wysoką konwersję oprócz własnych pomysłów i doświadczeń marketingowcy zaprzęgają różne badania. To, jak użytkownik porusza się po konkretnej stronie serwisu można sprawdzać np. poprzez:

  • eye tracking – nieco skomplikowane badanie, w którym użytkownik korzysta ze strony wykonując pewne zaplanowane zadania (wyszukanie treści, odnalezienie przycisku, przejście przez zamówienia) a specjalna kamera śledzi ruchy jego oczu i mapuje je na miejsca, na które patrzy na stronie.
  • heat map – zwane przeze mnie eye trackingiem dla oszczędnych. Polega na śledzeniu ruchów kursora po ekranie. Zależnie od profilu badania za pomocą gorąca na mapie można oznaczać np. miejsca odwiedzane kursorem najczęściej lub przez najdłuższy czas. Moim zdaniem można to porównać do uproszczonego eye trackingu, bo z moich obserwacji wynika, że często kursorem podążamy za wzrokiem (lub odwrotnie).
  • click tracking – śledzenie kliknięć. To, że użytkownik na coś popatrzył lub gdzieś pojeździł kursorem nie zawsze jest aż tak istotne. Często znacznie ważniejsze jest, gdzie klikał.

Oczywiście to tylko kilka z bardzo wielu różnych badań stosowanych w e-marketingu. Chłopaki i dziewczyny całymi dniami wymyślają różnie cuda, żeby lepiej poznać zachowanie użytkowników swoich systemów.

A teraz do rzeczy. Podczas rozmowy o wspomnianych wyżej badaniach przyszło mi do głowy, żeby przygotować prosty skrypt tworzący heatmapy na poziomie elementów (nie pikseli). Precyzyjnie ujmując zadanie: chciałem zliczyć, ile razy elementy są odwiedzane przez kursor, a następnie przedstawić to w formie graficznej.

Żeby nie wejść jakimś innym skryptom w paradę, tworzymy własny obiekt z publicznym interfejsem (nic odkrywczego):

var heatMap = function(){
    return {
        start : function(){
        },
        stop : function(){
        },
        read : function(){
        },
        visualize : function(){
        }
    }
}();

Mamy cztery chyba dość łatwe do rozszyfrowania metody (jeśli nie dość łatwe – włączenie zbierania danych, zatrzymanie zbierania danych, wypisanie zebranych danych w postaci tekstowej i zobrazowanie graficzne). Zbieranie danych tu, czytanie danych tam. Dobrze byłoby mieć je gdzie przechowywać. I jest gdzie:

var heatMap = function(){
    var data = [];

    return {
        start : function(){
        },
        stop : function(){
        },
        read : function(){
        },
        visualize : function(){
        }
    }
}();

To teraz jak je zbierać:

var heatMap = function(){
    var data = [];

    var handler = function(){
        var identifier = getIdentifier(this);

        if (typeof data[identifier] == 'undefined'){
            data[identifier] = 1;
        } else {
            data[identifier]++;
        }
    }

    return {
        start : function(){
        },
        stop : function(){
        },
        read : function(){
        },
        visualize : function(){
        }
    }
}();

Uważni Czytelnicy dostrzegą z łatwością, że funkcja getIdentifier nie jest nigdzie zdefiniowana. Nie jest to też wbudowana metoda obiektu window. Mówiąc krótko – póki co jest to  zaślepka. Docieramy tu do najciekawszej, jak sądzę, rzeczy w tym skrypcie. Powstaje bowiem pytanie: jak jednoznacznie i odtwarzalnie zidentyfikować element? Oczywistą odpowiedzią jest atrybut id, ale ustawianie go wszystkim interesującym nas elementom pod kątem badania nie było tym, co chciałem osiągnąć. Zaświtało mi więc, że Firebug na widoku podglądu kodu HTML identyfikuje elementy za pomocą XPath (najedźcie na jakiś element i poczekajcie, aż pojawi się nad nim tooltip). Nigdy do tej pory nie korzystałem z XPatha, miałem o nim tylko blade, teoretyczne pojęcie. Ale skoro Firebug mnie zainspirował, to od razu tam sięgnąłem po jakieś szczegóły. Nie zawiodłem się – znalazłem tam krótki kawałek kodu, który radzi sobie z zadaniem.

var heatMap = function(){
    var data = [];

    var getElementTreeXPath = function(element)
    {
        var paths = [];

        for (; element && element.nodeType == Node.ELEMENT_NODE; element = element.parentNode)
        {
            var index = 0;
            for (var sibling = element.previousSibling; sibling; sibling = sibling.previousSibling)
            {
                if (sibling.nodeType == Node.DOCUMENT_TYPE_NODE)
                    continue;

                if (sibling.nodeName == element.nodeName)
                    ++index;
            }

            var tagName = (element.prefix ? element.prefix + ":" : "") + element.localName;
            var pathIndex = (index ? "[" + (index+1) + "]" : "");
            paths.splice(0, 0, tagName + pathIndex);
        }

        return paths.length ? "/" + paths.join("/") : null;
    };

    var handler = function(){
        var xpath = getElementTreeXPath(this);

        if (typeof data[xpath] == 'undefined'){
            data[xpath] = 1;
        } else {
            data[xpath]++;
        }
    }

    return {
        start : function(){
        },
        stop : function(){
        },
        read : function(){
        },
        visualize : function(){
        }
    }
}();

(Firebug rozpowszechniany jest na licencji BSD)

Teraz chwila zadumy nad modelem dokumentu. DOM jest strukturą drzewiastą (ponownie nic odkrywczego). Dla skryptu heatmapy ma to taką konsekwencję, że jeśli najedziemy np. na jakiś link a potem z niego zjedziemy, to kursorem raz odwiedzimy link, a dwa razy jego rodzica. Co sprawi, że np. kontener menu będzie znacznie gorętszy, niż linki w tym menu. Tego chciałbym uniknąć. Z możliwych sposobów wybrałem zliczanie tylko elementów nie posiadających dzieci – wyszedłem z założenia, że to, co na samym wierzchu, jest najważniejsze. W środowiskach produkcyjnych może to nie być prawda, ale mi wystarcza. Jest jeszcze drobna kwestia bąbelkowania eventów w DOMie. Na to jednak mamy prosty sposób z metodą stopPropagation przekazywanego do handlera obiektu event

var heatMap = function(){
    var data = [];

    var getElementTreeXPath = function(element)
    {
        var paths = [];

        for (; element && element.nodeType == Node.ELEMENT_NODE; element = element.parentNode)
        {
            var index = 0;
            for (var sibling = element.previousSibling; sibling; sibling = sibling.previousSibling)
            {
                if (sibling.nodeType == Node.DOCUMENT_TYPE_NODE)
                    continue;

                if (sibling.nodeName == element.nodeName)
                    ++index;
            }

            var tagName = (element.prefix ? element.prefix + ":" : "") + element.localName;
            var pathIndex = (index ? "[" + (index+1) + "]" : "");
            paths.splice(0, 0, tagName + pathIndex);
        }

        return paths.length ? "/" + paths.join("/") : null;
    };

    var handler = function(event){
        event.stopPropagation();
        if ($(this).children().length != 0){
            return;
        };

        var xpath = getElementTreeXPath(this);

        if (typeof data[xpath] == 'undefined'){
            data[xpath] = 1;
        } else {
            data[xpath]++;
        }
    }

    return {
        start : function(){
        },
        stop : function(){
        },
        read : function(){
        },
        visualize : function(){
        }
    }
}();

Mamy już fajne bebechy skryptu, ale nadal interfejs nie jest porywający. Zmieńmy to:

var heatMap = function(){
    var data = [];

    var getElementTreeXPath = function(element)
    {
        var paths = [];

        for (; element && element.nodeType == Node.ELEMENT_NODE; element = element.parentNode)
        {
            var index = 0;
            for (var sibling = element.previousSibling; sibling; sibling = sibling.previousSibling)
            {
                if (sibling.nodeType == Node.DOCUMENT_TYPE_NODE)
                    continue;

                if (sibling.nodeName == element.nodeName)
                    ++index;
            }

            var tagName = (element.prefix ? element.prefix + ":" : "") + element.localName;
            var pathIndex = (index ? "[" + (index+1) + "]" : "");
            paths.splice(0, 0, tagName + pathIndex);
        }

        return paths.length ? "/" + paths.join("/") : null;
    };

    var handler = function(event){
        event.stopPropagation();
        if ($(this).children().length != 0){
            return;
        };

        var xpath = getElementTreeXPath(this);

        if (typeof data[xpath] == 'undefined'){
            data[xpath] = 1;
        } else {
            data[xpath]++;
        }
    }

    return {
        start : function(){
            data = [];
            $('*').bind('mouseover', handler);
        },
        stop : function(){
            $('*').unbind('mouseover');
        },
        read : function(){
            console.log(data);
        },
        visualize : function(){
        }
    }
}();

Faktycznie, porywający bez dwóch zdań…

Być może metoda read nie jest zbyt wyrafinowana, ale na potrzeby tego eksperymentu wystarczy. Zbieramy dane, mamy do nich dostęp, ale miała być jeszcze wizualizacja. I będzie. Skoro mamy spisane ścieżki XPath dla odwiedzonych elementów, to musimy jeszcze znaleźć na tej podstawie konkretny element na stronie. Tutaj też przychodzi z pomocą kawałek kodu źródłowego Firebuga

var heatMap = function(){
    var data = [];

    var getElementTreeXPath = function(element)
    {
        var paths = [];

        for (; element && element.nodeType == Node.ELEMENT_NODE; element = element.parentNode)
        {
            var index = 0;
            for (var sibling = element.previousSibling; sibling; sibling = sibling.previousSibling)
            {
                if (sibling.nodeType == Node.DOCUMENT_TYPE_NODE)
                    continue;

                if (sibling.nodeName == element.nodeName)
                    ++index;
            }

            var tagName = (element.prefix ? element.prefix + ":" : "") + element.localName;
            var pathIndex = (index ? "[" + (index+1) + "]" : "");
            paths.splice(0, 0, tagName + pathIndex);
        }

        return paths.length ? "/" + paths.join("/") : null;
    };

    var evaluateXPath = function(doc, xpath)
    {
        var result = doc.evaluate(xpath, doc, null, 0, null);
        var element = result.iterateNext();
        return element;
    }

    var handler = function(event){
        event.stopPropagation();
        if ($(this).children().length != 0){
            return;
        };

        var xpath = getElementTreeXPath(this);

        if (typeof data[xpath] == 'undefined'){
            data[xpath] = 1;
        } else {
            data[xpath]++;
        }
    }

    return {
        start : function(){
            data = [];
            $('*').bind('mouseover', handler);
        },
        stop : function(){
            $('*').unbind('mouseover');
        },
        read : function(){
            console.log(data);
        },
        visualize : function(){
            for (var i in data){
                var element = evaluateXPath(document, i);
                $(element).css('background-color', 'rgb('+data[i] * 10+',0,0');
            }
        }
    }
}();

Wydaje Wam się, że ta wizualizacja jest nieco prymitywna? Jest. Używa tylko czerwonego i to też nie do końca poprawnie – coraz gorętsze miejsca są coraz jaskrawsze, zamiast coraz ciemniejsze. Ale po raz kolejny (i jeszcze nie ostatni) wykręcę się tutaj eksperymentalnym charakterem tego skryptu.

To w zasadzie wszystko. Oczywiście brakuje tu co nieco, żeby wykorzystać takie narzędzie do choćby prymitywnego badania. Chwilowo, żeby uruchomić i zatrzymać badanie oraz przedstawić jego wyniki, musimy wywołać metody obiektu heatMap z poziomu konsoli Firebuga. Wizualizacja jest możliwa tylko w tym samym oknie i dane przepadają, gdy przejdziemy gdzieś indziej. Wypadałoby podpiąć to pod jakiś interfejs użytkownika, wybrać i zaimplementować strategię przesyłania i przechowywania danych, czy wreszcie odtwarzania takiej heatmapy. Gdyby chcieć podobne badanie przeprowadzić na masową skalę na każdym użytkowniku, należałoby rozważyć temat prywatności etc. Aspektów do przemyślenia byłoby jeszcze sporo.

Wszystkie te rzeczy nie zajmowały mnie jednak, ponieważ był to tylko (tutaj jest ten ostatni wykręt) eksperyment.

Wszelkie uwagi mile widziane. Im bardziej konstruktywne, tym milej.

Możliwość komentowania jest wyłączona.