LZT_Profile_Viewers_Analytics

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
    });

})();