Skrypt do kopiowania opisów nagrań z sesji w MS Clarity

Strona główna » Skrypty » Skrypt do kopiowania opisów nagrań z sesji w MS Clarity
Instrukcja użycia

→ Ściągnij wtyczkę Tampermonkey do przeglądarki
→ W opcjach wtyczki włącz opcję „Allow User Scripts” (dzięki temu będzie można korzystać z własnych skryptów)
→ Wklej w Tampermonkey poniższy skrypt widgetu
→ Wejdź do Microsoft Clarity, do sekcji nagrań, i pobieraj opisy

Skrypt widgeta

// ==UserScript==
// @name         MS Clarity Ściąganie opisów
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  Widget do generowania opisów sesji — z dosztukowywaniem (pomija już opisane), przyciskiem STOP i raportem błędów
// @author       Paweł Piekarski
// @match        https://clarity.microsoft.com/*
// @icon         https://clarity.microsoft.com/blog/wp-content/uploads/2025/02/siteIcon.png
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const clarityIconSvg = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M4 4L20 12L4 20V4Z" fill="#1960bf"/>
    </svg>`;

    const BLAD_AI_TEKST = "I'm sorry, something went wrong";

    // Stan procesu — pozwala na dosztukowywanie i przerywanie
    const stan = {
        aktywny: false,
        stop: false,
        watcherId: null,
        statystyki: { pominiete: 0, klikniete: 0 }
    };

    // --- LOGIKA WYDOBYWANIA DANYCH ---

    function pobierzKarty() {
        return Array.from(document.querySelectorAll('div[data-testid="sessionCard"]'));
    }

    function maOpis(card) {
        return !!card.querySelector('div.insightsContent');
    }

    function czyBladAI(card) {
        const content = card.querySelector('div.insightsContent');
        return content && content.textContent.includes(BLAD_AI_TEKST);
    }

    function maPoprawnyOpis(card) {
        return maOpis(card) && !czyBladAI(card);
    }

    function pobierzDostepneNagrania() {
        const countEl = document.querySelector('.sessionCount strong');
        if (!countEl) return '...';
        return countEl.textContent.replace(/\D/g, '');
    }

    function pobierzUserId(card) {
        const badges = Array.from(card.querySelectorAll('div[class*="recordings_cardBadge"]'));
        const userBadge = badges.find(el => el.textContent.includes('User ID:'));
        if (userBadge) {
            const rawText = userBadge.textContent.replace('User ID:', '').trim();
            return rawText.split(/\s+/)[0];
        }
        return '';
    }

    function pobierzMetadaneSesji(card) {
        const meta = {};

        // Entry & Exit URLs
        const rows = card.querySelectorAll('div[class*="recordings_cardRow"]');
        rows.forEach(row => {
            const strong = row.querySelector('strong');
            const span = row.querySelector('span[class*="oneLineText"]');
            if (strong && span) {
                const label = strong.textContent.trim().replace(':', '');
                if (label === 'Entry') meta.entry = row.getAttribute('title') || span.textContent.trim();
                if (label === 'Exit') meta.exit = row.getAttribute('title') || span.textContent.trim();
            }
        });

        // Referrer
        const referrerRow = Array.from(rows).find(r => {
            const s = r.querySelector('strong');
            return s && s.textContent.includes('Referrer');
        });
        if (referrerRow) {
            meta.referrer = referrerRow.getAttribute('title') || referrerRow.querySelector('span')?.textContent.trim() || '';
        }

        // Duration, Clicks, Pages — z wiersza multiValuedRow
        const multiRow = card.querySelector('div[class*="recordings_multiValuedRow"]');
        if (multiRow) {
            const strongs = multiRow.querySelectorAll('strong');
            const spans = multiRow.querySelectorAll('span');
            strongs.forEach((s, i) => {
                const label = s.textContent.trim().replace(':', '').toLowerCase();
                const val = spans[i] ? spans[i].textContent.trim() : '';
                if (label === 'duration') meta.duration = val;
                if (label === 'clicks') meta.clicks = val;
                if (label === 'pages') meta.pages = val;
            });
        }

        // Date & time
        const dateSection = card.querySelector('div[class*="recordings_cardDateSection"]');
        if (dateSection) {
            const dateSpans = dateSection.querySelectorAll('span');
            const parts = Array.from(dateSpans).map(s => s.textContent.trim());
            meta.date = parts.join(' ');
        }

        // Badges (User ID, Country, Browser, Device, itd.)
        const badges = Array.from(card.querySelectorAll('div[class*="recordings_cardBadge"]'));
        const badgeTexts = badges.map(b => b.textContent.trim()).filter(Boolean);

        meta.userId = '';
        meta.otherBadges = [];
        badgeTexts.forEach(txt => {
            if (txt.startsWith('User ID:')) {
                meta.userId = txt.replace('User ID:', '').trim().split(/\s+/)[0];
            } else {
                meta.otherBadges.push(txt);
            }
        });

        return meta;
    }

    // --- PROCES GENEROWANIA (z dosztukowywaniem) ---

    function startProcess() {
        // Drugi klik podczas pracy = STOP
        if (stan.aktywny) {
            stan.stop = true;
            updateStatus('Zatrzymywanie...');
            return;
        }

        const limit = parseInt(document.getElementById('helper-limit').value) || 0;
        const wszystkie = pobierzKarty();

        // Bierzemy tylko karty BEZ opisu — dzięki temu kolejne uruchomienie
        // "dosztukowuje" następną partię zamiast klikać od początku
        const doZrobienia = [];
        let pominiete = 0;
        for (const card of wszystkie) {
            if (limit > 0 && doZrobienia.length >= limit) break;
            if (maOpis(card)) continue; // już ma opis (lub błąd AI) — pomijamy
            const btn = card.querySelector('button[data-testid="sessionGenerateInsightsButton"]');
            if (!btn || btn.disabled) {
                pominiete++; // np. zbyt krótka sesja — nie liczy się do limitu
                continue;
            }
            doZrobienia.push({ card, btn });
        }

        stan.statystyki = { pominiete, klikniete: doZrobienia.length };

        if (doZrobienia.length === 0) {
            const zOpisem = wszystkie.filter(maPoprawnyOpis).length;
            updateStatus(zOpisem > 0
                ? `Brak nowych sesji do opisania (${pominiete > 0 ? pominiete + ' pominięto' : 'przewiń, by doładować'})`
                : 'Brak sesji do opisania');
            return;
        }

        stan.aktywny = true;
        stan.stop = false;
        ustawPrzyciskGeneruj(true);

        let index = 0;
        const procesuj = () => {
            if (stan.stop || index >= doZrobienia.length) {
                stan.statystyki.klikniete = index;
                nasluchujOpisy(doZrobienia.slice(0, index).map(d => d.card));
                return;
            }
            doZrobienia[index].btn.click();
            index++;
            updateStatus(`Klikanie: ${index}/${doZrobienia.length}`);
            setTimeout(procesuj, 700 + Math.random() * 500); // losowy odstęp
        };
        procesuj();
    }

    function nasluchujOpisy(karty) {
        if (karty.length === 0) {
            zakonczProces(0, 0);
            return;
        }
        let proba = 0;
        // Timeout skaluje się z liczbą sesji (min. 60 s, maks. 5 min)
        const maxProb = Math.min(300, Math.max(60, karty.length * 10));

        stan.watcherId = setInterval(() => {
            proba++;
            // Śledzimy KONKRETNE karty (referencje), nie indeksy na liście —
            // odporne na doładowanie/przesunięcie listy w trakcie
            const gotowe = karty.filter(maOpis);
            const sukcesy = gotowe.filter(c => !czyBladAI(c)).length;

            updateStatus(`Opisy: ${sukcesy}/${karty.length}`);

            if (gotowe.length === karty.length || proba >= maxProb || stan.stop) {
                clearInterval(stan.watcherId);
                zakonczProces(sukcesy, gotowe.length);
            }
        }, 1000);
    }

    function zakonczProces(sukcesy, odpowiedziane) {
        stan.aktywny = false;
        stan.stop = false;
        ustawPrzyciskGeneruj(false);
        pokazRaport(sukcesy, odpowiedziane);
        refreshLiczniki();
    }

    // --- UI & RAPORT ---

    function ustawPrzyciskGeneruj(pracuje) {
        const btn = document.getElementById('btn-generate');
        btn.textContent = pracuje ? 'STOP' : 'GENERUJ OPISY';
        btn.style.background = pracuje ? '#d93025' : '#1960bf';
    }

    function updateStatus(msg) {
        document.getElementById('helper-status').textContent = `Status: ${msg}`;
        document.getElementById('helper-report').style.display = 'none';
    }

    function pokazRaport(finalneSukcesy, juzOdpowiedziane) {
        const reportEl = document.getElementById('helper-report');
        const statusEl = document.getElementById('helper-status');

        const lacznie = pobierzKarty().filter(maPoprawnyOpis).length;
        statusEl.textContent = `Status: Gotowe (+${finalneSukcesy}, łącznie: ${lacznie})`;

        const bledyAI = juzOdpowiedziane - finalneSukcesy;
        const niedokonczone = stan.statystyki.klikniete - juzOdpowiedziane;
        const linie = [];

        if (stan.statystyki.pominiete > 0) {
            linie.push(`• ${stan.statystyki.pominiete} pominięto — zbyt krótka sesja`);
        }
        if (bledyAI > 0) {
            linie.push(`• ${bledyAI} błąd generowania AI`);
        }
        if (niedokonczone > 0) {
            linie.push(`• ${niedokonczone} bez odpowiedzi (timeout/stop)`);
        }

        if (linie.length > 0) {
            reportEl.innerHTML = linie.join('<br>');
            reportEl.style.display = 'block';
        }
    }

    function refreshLiczniki() {
        const maxEl = document.getElementById('helper-max-val');
        if (maxEl) maxEl.textContent = pobierzDostepneNagrania();

        const infoEl = document.getElementById('helper-info');
        if (infoEl) {
            const karty = pobierzKarty();
            const zOpisem = karty.filter(maPoprawnyOpis).length;
            infoEl.textContent = `Załadowane: ${karty.length} • z opisem: ${zOpisem}`;
        }
    }

    // --- ANONIMIZACJA URL ---

    function wykryjDomeneSerwisu(dane) {
        for (const d of dane) {
            if (d.entry) {
                try {
                    const url = new URL(d.entry);
                    return url.hostname;
                } catch { /* entry może być skrócone */ }
            }
        }
        return null;
    }

    function anonimizujTekst(tekst, domena) {
        if (!domena || !tekst) return tekst;
        const bezWww = domena.replace(/^www\./, '');
        const zWww = 'www.' + bezWww;
        const esc = s => s.replace(/\./g, '\\.');
        const wzorce = [zWww, bezWww, domena]
            .filter((v, i, a) => a.indexOf(v) === i)
            .sort((a, b) => b.length - a.length);
        let wynik = tekst;
        for (const wzorzec of wzorce) {
            wynik = wynik.replace(new RegExp(esc(wzorzec), 'gi'), 'firma.pl');
        }
        return wynik;
    }

    function usunParametryUrl(url) {
        if (!url) return url;
        try {
            const parsed = new URL(url);
            return parsed.origin + parsed.pathname;
        } catch {
            // Fallback dla URLi bez schematu lub skróconych
            return url.split('?')[0].split('#')[0];
        }
    }

    // --- POBIERANIE .MD / JSON ---
    // Eksport obejmuje WSZYSTKIE załadowane karty (limit dotyczy tylko generowania)

    function pobierzDaneSesji() {
        return pobierzKarty().map(card => {
            const meta = pobierzMetadaneSesji(card);
            const opis = maPoprawnyOpis(card)
                ? card.querySelector('div.insightsContent').textContent.trim()
                : null;
            return { ...meta, opis };
        });
    }

    function generujMarkdown(dane) {
        const dzis = new Date().toISOString().slice(0, 10);
        const projekt = document.title || 'MS Clarity';

        const zOpisem = dane.filter(d => d.opis);
        const bezOpisu = dane.length - zOpisem.length;

        const domena = wykryjDomeneSerwisu(dane);
        const anon = tekst => anonimizujTekst(tekst, domena);

        let md = `# Clarity – opisy sesji\n`;
        md += `Projekt: ${anon(projekt)} | Data eksportu: ${dzis}\n`;
        md += `Sesji: ${dane.length} (opisów: ${zOpisem.length}`;
        if (bezOpisu > 0) md += `, brak opisu: ${bezOpisu}`;
        md += `)\n\n`;

        dane.forEach((d, i) => {
            md += `## Nagranie ${i + 1}\n`;

            const pola = [];
            if (d.date) pola.push(`Data: ${d.date}`);
            if (d.duration) pola.push(`Czas: ${d.duration}`);
            if (d.clicks) pola.push(`Kliknięcia: ${d.clicks}`);
            if (d.pages) pola.push(`Strony: ${d.pages}`);
            if (d.otherBadges && d.otherBadges.length > 0) pola.push(d.otherBadges.join(', '));
            if (pola.length > 0) md += pola.join(' | ') + `\n`;

            if (d.entry) md += `Entry: ${anon(usunParametryUrl(d.entry))}\n`;
            if (d.exit) md += `Exit: ${anon(usunParametryUrl(d.exit))}\n`;
            if (d.referrer) md += `Referrer: ${usunParametryUrl(d.referrer)}\n`;

            md += `\n`;

            if (d.opis) {
                md += anon(d.opis) + `\n`;
            } else {
                md += `*Brak opisu*\n`;
            }

            md += `\n`;
        });

        return md;
    }

    function pobierzMD() {
        const dane = pobierzDaneSesji();

        if (dane.length === 0) {
            const btn = document.getElementById('btn-download');
            btn.textContent = 'BRAK SESJI!';
            btn.style.background = '#d93025';
            setTimeout(() => {
                btn.textContent = 'POBIERZ .MD';
                btn.style.background = '#2e7d32';
            }, 2000);
            return;
        }

        const md = generujMarkdown(dane);
        const dzis = new Date().toISOString().slice(0, 10);
        const nazwaPliku = `ms-clarity-${dzis}.md`;

        const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
        const link = document.createElement('a');
        link.href = URL.createObjectURL(blob);
        link.download = nazwaPliku;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        URL.revokeObjectURL(link.href);

        const btn = document.getElementById('btn-download');
        btn.textContent = 'POBRANO!';
        setTimeout(() => btn.textContent = 'POBIERZ .MD', 2000);
    }

    // --- WIDGET UI ---

    const container = document.createElement('div');
    container.id = 'clarity-helper-widget';
    container.innerHTML = `
        <div style="background: #ffffff; border: 1px solid #ced4da; border-radius: 10px; padding: 15px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); width: 220px; font-family: sans-serif; color: #333;">
            <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; border-bottom: 1px solid #f0f0f0; padding-bottom: 8px;">
                <div style="display: flex; align-items: center;">
                    <div style="margin-right: 8px; display: flex; align-items: center;">${clarityIconSvg}</div>
                    <h3 style="margin: 0; font-size: 14px; color: #000;">Ściągnij opisy nagrań</h3>
                </div>
                <div style="display: flex; align-items: center;">
                    <button id="btn-toggle" title="Zwiń / Rozwiń" style="background: none; border: none; cursor: pointer; font-size: 14px; color: #888; line-height: 1; padding: 0 2px;">▼</button>
                </div>
            </div>

            <div id="helper-body">
                <label style="font-size: 11px; color: #888; font-weight: bold;">ILE NOWYCH OPISÓW (MAX: <span id="helper-max-val">...</span>):</label>
                <input type="number" id="helper-limit" value="10" style="width: 100%; padding: 6px; margin: 4px 0 4px 0; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; box-sizing: border-box;">
                <div id="helper-info" style="font-size: 10px; color: #888; margin-bottom: 8px;">Załadowane: ... • z opisem: ...</div>

                <div id="helper-status" style="font-size: 12px; margin-bottom: 4px; color: #1960bf; font-weight: 500; background: #f0f7ff; padding: 4px 8px; border-radius: 4px;">Status: Gotowy</div>
                <div id="helper-report" style="font-size: 10px; color: #d93025; margin-bottom: 12px; padding: 0 8px; display: none; line-height: 1.2;"></div>

                <button id="btn-generate" style="width: 100%; background: #1960bf; color: white; border: none; padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-bottom: 8px; font-size: 12px;">GENERUJ OPISY</button>
                <button id="btn-copy" style="width: 100%; background: #444; color: white; border: none; padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-bottom: 8px; font-size: 12px;">KOPIUJ JSON</button>
                <button id="btn-download" style="width: 100%; background: #2e7d32; color: white; border: none; padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 12px;">POBIERZ .MD</button>

                <div style="margin-top: 15px; text-align: center; border-top: 1px solid #f0f0f0; padding-top: 12px;">
                    <a href="https://pawelpiekarski.pl" target="_blank" title="Odwiedź stronę">
                        <img src="https://pawelpiekarski.pl/wp-content/uploads/2024/02/Logo-pawelpiekarski.pl-v850.png" alt="Paweł Piekarski" style="max-width: 130px; height: auto; opacity: 0.8; transition: opacity 0.2s ease-in-out;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.8'">
                    </a>
                </div>
            </div>
        </div>
    `;

    Object.assign(container.style, { position: 'fixed', bottom: '20px', right: '20px', zIndex: '10000' });
    document.body.appendChild(container);

    // --- Toggle minimalizacji ---
    let zwiniety = false;
    document.getElementById('btn-toggle').addEventListener('click', () => {
        zwiniety = !zwiniety;
        const body = document.getElementById('helper-body');
        const toggleBtn = document.getElementById('btn-toggle');
        body.style.display = zwiniety ? 'none' : 'block';
        toggleBtn.textContent = zwiniety ? '▲' : '▼';
    });

    document.getElementById('btn-generate').addEventListener('click', startProcess);
    document.getElementById('btn-copy').addEventListener('click', () => {
        const dane = pobierzKarty().map(card => ({
            userId: pobierzUserId(card),
            description: maPoprawnyOpis(card)
                ? card.querySelector('div.insightsContent').textContent.trim()
                : 'BRAK OPISU'
        }));
        navigator.clipboard.writeText(JSON.stringify(dane, null, 2)).then(() => {
            const btn = document.getElementById('btn-copy');
            btn.textContent = "SKOPIOWANO!";
            setTimeout(() => btn.textContent = "KOPIUJ JSON", 2000);
        });
    });

    document.getElementById('btn-download').addEventListener('click', pobierzMD);

    setInterval(refreshLiczniki, 3000);
    refreshLiczniki();
})();

Dodaj komentarz

Napisz do mnie

Paweł Piekarski
Paweł Piekarski PhD, Analityk eCommerce Marketing oparty na danych

Dane kontaktowe

+48 725 473 745

poczta@pawelpiekarski.pl

linkedin.com/in/pawelpiekarskipl

    Wysyłając wiadomość, zgadzasz się na kontakt i przetwarzanie danych zgodnie z polityką prywatności.