Greasy Fork is available in English.

LZT_Profile_Viewers_Analytics

Аналитика просмотров профиля.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         LZT_Profile_Viewers_Analytics
// @namespace    MeloniuM/LZT
// @version      2.3
// @description  Аналитика просмотров профиля.
// @author       MeloniuM
// @match        https://zelenka.guru/*
// @match        https://lolz.live/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    $('<style id="LZT_Profile_Viewers_Analytics_Style">').text(`
        .ViewsStats-button .icon {
            background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='20' height='20' stroke='rgb(140,140,140)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'%3E%3C/path%3E%3Ccircle cx='12' cy='12' r='3'%3E%3C/circle%3E%3C/svg%3E");
        }
        .ViewsStats-button:hover .icon {
            background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='20' height='20' stroke='rgba(0, 186, 120, 1)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'%3E%3C/path%3E%3Ccircle cx='12' cy='12' r='3'%3E%3C/circle%3E%3C/svg%3E");
        }  
    `).appendTo('head');

    /* =========================
       Utils / XenForo ajax
    ========================= */

    function xfAjax(url, data = {}) {
        return new Promise(resolve => {
            XenForo.ajax(url, data, resolve);
        });
    }

    function getProfileUserId() {
        return window.ThreadNotify?.channel?.split(":")[1] | null;
    }

    function viewersUrl(userId, page) {
        return `/members/${userId}/show-viewers?page=${page}`;
    }

    function isNoAccessResponse(res) {
        return res && Array.isArray(res.error);
    }

    function downloadFile(name, content, type) {
        const blob = new Blob([content], { type });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = name;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
    }

    function exportJSON(records) {
        const data = records.map(r => ({
            userId: r.userId,
            name: r.name,
            timestamp: r.timestamp.toISOString()
        }));
        downloadFile(
            'profile_views.json',
            JSON.stringify(data, null, 2),
            'application/json'
        );
    }

    function exportCSV(records) {
        const header = 'userId,name,timestamp\n';
        const rows = records.map(r =>
            `${r.userId},"${r.name.replace(/"/g, '""')}",${r.timestamp.toISOString()}`
        ).join('\n');

        downloadFile(
            'profile_views.csv',
            header + rows,
            'text/csv;charset=utf-8'
        );
    }


    
    /* =========================
       HTML parsing
    ========================= */

    function extractUserIdFromAvatar(el) {
        if (!el || !el.classList) return null;
        const cls = [...el.classList].find(c => /^Av\d+s$/.test(c));
        return cls ? Number(cls.replace(/\D/g, '')) : null;
    }

    function parseHtml(html) {
        const $root = $(html);
        const out = [];

        $root.find('.viewersItem').each(function () {
            const $it = $(this);
            const avatarA = $it.find('.userAvatar a')[0];
            const name = $it.find('.username > span:not(.uniqUsernameIcon--custom)').first().text().trim();
            const timestamp = $it.find('.userTimeView .DateTime').attr('data-time');
            const date = new Date(parseInt(timestamp, 10) * 1000);
            out.push({
                userId: extractUserIdFromAvatar(avatarA),
                name: name,
                timestamp: date
            });
        });

        return out.filter(x => x.timestamp !== null);
    }


    /* =========================
       Fetch logic
    ========================= */

    async function fetchAllViews(userId, onProgress, overlay) {
        const first = await xfAjax(viewersUrl(userId, 1));

        if (isNoAccessResponse(first)) {
            return { error: first.error[0] };
        }

        const $first = $(first.templateHtml);
        const last = Number($first.find('.PageNav').data('last') || 1);

        let out = parseHtml(first.templateHtml);

        for (let p = 2; p <= last; p++) {
            if (!overlay.isOpened()) {
                return
            }
            // Пауза между запросами (~350ms)
            await new Promise(r => setTimeout(r, 350));

            const res = await xfAjax(viewersUrl(userId, p));
            if (isNoAccessResponse(res)) break;

            out = out.concat(parseHtml(res.templateHtml));

            if (onProgress) onProgress(p, last);
        }

        return { records: out };
    }

    /* =========================
    Analytics
    ========================= */

    function computeHourly(records) {
        const h = Array(24).fill(0);
        records.forEach(r => h[new Date(r.timestamp).getHours()]++);
        return h;
    }

    function computeWeekday(records) {
        const d = Array(7).fill(0);
        records.forEach(r => d[new Date(r.timestamp).getDay()]++);
        return d;
    }

    function heatmapMatrix(records) {
        const m = Array.from({ length: 7 }, () => Array(24).fill(0));
        records.forEach(r => {
            const dt = new Date(r.timestamp);
            m[dt.getDay()][dt.getHours()]++;
        });
        return m;
    }

    function movingAverage(series, w = 3) {
        return series.map((_, i) => {
            const s = series.slice(Math.max(0, i - w + 1), i + 1);
            return s.reduce((a, b) => a + b, 0) / s.length;
        });
    }

    function uniqueUsersPerDay(records) {
        const map = new Map();
        records.forEach(r => {
            const d = new Date(r.timestamp);
            d.setHours(0, 0, 0, 0);
            const k = d.getTime();
            if (!map.has(k)) map.set(k, new Set());
            map.get(k).add(r.userId);
        });
        return [...map.entries()]
            .sort((a, b) => a[0] - b[0])
            .map(([k, s]) => ({ day: k, unique: s.size }));
    }

    function median(arr) {
        const a = arr.filter(Boolean).sort((x, y) => x - y);
        if (!a.length) return 0;
        const m = Math.floor(a.length / 2);
        return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
    }

    function detectSpikes(hourly, factor = 3) {
        const med = median(hourly);
        return hourly
            .map((v, h) => v > med * factor ? { hour: h, value: v } : null)
            .filter(Boolean);
    }


    /* =========================
       UI / Overlay
    ========================= */

    function renderError($root, text) {
        $root.html(`<div class="error">${text}</div>`);
    }

    function renderAnalytics($root, records) {
        $root.empty();

        if (!records.length) {
            $root.text('Нет данных');
            return;
        }

        const hourly = computeHourly(records);
        const weekday = computeWeekday(records);
        const heat = heatmapMatrix(records);
        const ma = movingAverage(hourly, 3);
        const uniqueDay = uniqueUsersPerDay(records);
        const spikes = detectSpikes(hourly);

        /* ===== Summary ===== */
        $root.append(`
            <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px">
                <div>Всего: <b>${records.length}</b></div>
                <div>Уникальных: <b>${new Set(records.map(r => r.userId)).size}</b></div>
                <div>Повторных: <b>${records.length - new Set(records.map(r => r.userId)).size}</b></div>
            </div>
        `);

        const exportBox = $(`
            <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
                Экспорт:
                <a class="button button--primary export-json">JSON</a>
                <a class="button export-csv">CSV</a>
            </div>
        `);
        $root.append(exportBox);

        exportBox.find('.export-json').on('click', () => exportJSON(records));
        exportBox.find('.export-csv').on('click', () => exportCSV(records));


        /* ===== Hourly ===== */
        const cHour = canvas();
        $root.append(cHour);
        new Chart(cHour, {
            type: 'bar',
            data: {
                labels: [...Array(24).keys()],
                datasets: [{ label: 'По часам', data: hourly, backgroundColor: '#1C6E49' }]
            },
            options: { scales: { y: { beginAtZero: true } } }
        });

        $root.append('<hr style="margin:16px 0; border-color:#ccc">');

        /* ===== Moving Average ===== */
       // Определяем минимальный и максимальный час с ненулевыми значениями
        const nonZeroHours = hourly
            .map((v, i) => v > 0 ? i : -1)
            .filter(i => i >= 0);

        const minHour = Math.min(...nonZeroHours);
        const maxHour = Math.max(...nonZeroHours);

        // Обрезаем данные и метки
        const labels = [...Array(maxHour - minHour + 1).keys()].map(h => h + minHour);
        const dataFact = hourly.slice(minHour, maxHour + 1);
        const dataMA = ma.slice(minHour, maxHour + 1);

        const cMA = canvas();
        $root.append(cMA);
        $root.append('<hr style="margin:16px 0; border-color:#ccc">');
        new Chart(cMA, {
            type: 'line',
            data: {
                labels,
                datasets: [
                    { label: 'Факт', data: dataFact, borderColor: '#228E5D', backgroundColor: 'rgba(76,175,80,0.3)' },
                    { label: 'MA(3)', data: dataMA, borderColor: '#e91e63', backgroundColor: 'rgba(233,30,99,0.2)' }
                ]
            },
            options: {
                scales: {
                    x: { title: { display: true, text: 'Час' } },
                    y: { title: { display: true, text: 'Просмотры' } }
                }
            }
        });


        /* ===== Weekday ===== */
        const cDay = canvas();
        $root.append(cDay);
        $root.append('<hr style="margin:16px 0; border-color:#ccc">');
        new Chart(cDay, {
            type: 'bar',
            data: {
                labels: ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'],
                datasets: [{ label: 'По дням', data: weekday, backgroundColor: '#1C6E49' }]
            }
        });

        /* ===== Unique per day ===== */
        const cUniq = canvas();
        $root.append(cUniq);
        $root.append('<hr style="margin:16px 0; border-color:#ccc">');
        new Chart(cUniq, {
            type: 'bar',
            data: {
                labels: uniqueDay.map(d => new Date(d.day).toLocaleDateString()),
                datasets: [{ label: 'Уникальные/день', data: uniqueDay.map(d => d.unique), backgroundColor: '#1C6E49' }]
            }
        });

        /* ===== Heatmap (table) ===== */
        const max = Math.max(...heat.flat(), 1);
        const tbl = $(`
            <table class="heatmap" style="
                width:100%;
                border-collapse: collapse;
                font-size:11px;
                margin-top:12px;
                table-layout: fixed;
            "></table>
        `);

        // Заголовок
        tbl.append('<tr><th style="border:1px solid #333; padding:2px">Д/Ч</th>' +
            [...Array(24).keys()].map(h =>
                `<th style="border:1px solid #333; padding:2px">${h}</th>`
            ).join('')
        + '</tr>');

        // Данные
        heat.forEach((row, d) => {
            const tr = $(`<tr><td style="border:1px solid #333; padding:2px"><b>${['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][d]}</b></td></tr>`);
            row.forEach(v => {
                const a = v ? Math.max(v / max, 0.05) : 0; // минимальная прозрачность 0.05
                tr.append(`
                    <td style="
                        border:1px solid #333;
                        text-align:center;
                        background: rgba(28,110,73,${a});
                        padding:2px
                    ">${v||''}</td>
                `);
            });
            tbl.append(tr);
        });

        $root.append(tbl);
        $root.append('<hr style="margin:16px 0; border-color:#ccc">');

        /* ===== Spikes ===== */
        const spikeBox = $('<div style="margin-top:12px"></div>');
        if (spikes.length) {
            spikes.forEach(s => spikeBox.append(`<div>Всплеск: ${s.hour}:00 — ${s.value}</div>`));
        } else {
            spikeBox.text('Аномалий не найдено');
        }
        $root.append('<h3>Аномалии</h3>', spikeBox);
    }


    function canvas(w = 800, h = 240) {
        return $(`<canvas width="${w}" height="${h}"></canvas>`)[0];
    }


    function injectButton($target, userId, username) {
        if (!!$target.find('.ViewsStats-button').length) {
            return
        }
        const $button = $(`
            <a class="button ViewsStats-button">
		        <span class="icon"></span>
                Аналитика просмотров
            </a>
        `);

        $target.append($button);

        $button.on('click', async function (ev) {
            ev.preventDefault();

            if (!$button.data('overlay')) {
                const $modal = $(`
                    <div class="sectionMain">
                        <h2 class="heading h1">Информация о просмотрах профиля ${username}</h2>
                        <div class="overlayContent" style="padding:15px">Загрузка…</div>
                    </div>
                `);

                XenForo.createOverlay(null, $modal, {
                    className: 'ViewsStats-modal',
                    trigger: $button,
                    severalModals: true
                });

                const overlay = $button.data('overlay');

                overlay.refresh = async function () {
                    const $root = this.getOverlay().find('.overlayContent');
                    $root.html(`
                        <div>Загрузка…</div>
                        <div style="border:1px solid #ccc;width:100%;height:12px;margin-top:8px">
                            <div class="progress-bar" style="width:0%;height:100%;background:#1c6e49db"></div>
                        </div>
                    `);
                    const $bar = $root.find('.progress-bar');

                    if (!userId) {
                        renderError($root, 'Не профиль пользователя');
                        return;
                    }

                    const result = await fetchAllViews(userId, (page, last) => {
                        const percent = Math.round((page / last) * 100);
                        $bar.css('width', percent + '%');
                        $root.find('div').first().text(`Загрузка страницы ${page} из ${last} (${percent}%)…`);
                    }, overlay);

                    if (!overlay.isOpened()) {
                        return;
                    }

                    if (result?.error) {
                        renderError($root, result.error);
                        return;
                    }

                    let records = result.records;
                    renderAnalytics($root, records);
                };

            }

            const overlay = $button.data('overlay');
            overlay.load();
            overlay.refresh();
        });
    }

    let profile_id = getProfileUserId();
    if (profile_id) {
        injectButton($('.profilePage .userContentLinks'), profile_id, $("#page_info_wrap .username[itemprop='name'] >").prop("outerHTML"));
    }

    $(document).on('XFOverlay', function(e){
        let $overlay = e.overlay.getOverlay();
        if (!$overlay.is('.memberCard')) return;
        let user_id = parseInt($overlay.find('.memberCardInner').attr('id').match(/\d+$/)[0], 10);
        let username = $overlay.find('.usernameAndStatus .username .username').prop("outerHTML");
        injectButton($overlay.find('.userContentLinks'),  user_id, username);
    });

})();