GGn TheLounge User Data Enhancement

Append GGn class emoji to usernames with a customizable UI

// ==UserScript==
// @name         GGn TheLounge User Data Enhancement
// @version      2.4
// @author       SleepingGiant
// @description  Append GGn class emoji to usernames with a customizable UI
// @namespace    https://gf.qytechs.cn/users/1395131
// @include      *:9000/*
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.listValues
// @connect      gazellegames.net
// ==/UserScript==

(function () {
    'use strict';

    if (typeof window.requestIdleCallback !== 'function') {
      window.requestIdleCallback = function (cb) { return setTimeout(cb, 0); };
    }

    console.debug("GGn TheLounge User Data Enhancement starting...");

    const API_URL = 'https://gazellegames.net/api.php';
    const RATE_LIMIT_MS = 3000;
    const CACHE_DURATION_MS = 36 * 60 * 60 * 1000; // 1.5 days. Makes loading smoother and people's user classes don't change much.
    let lastApiCallTime = Date.now();
    const uncachedUsers = new Set();

    const defaultClassToEmoji = {
        "Amateur": "👶",
        "Gamer": "🎲",
        "Pro Gamer": "🕹️",
        "Elite Gamer": "🎮",
        "Legendary Gamer": "🌟",
        "Master Gamer": "⚡",
        "Gaming God": "🏆",
        "Staff Trainee": "🛠️",
        "Moderator": "🧹",
        "Senior Moderator": "🛡️",
        "Team Leader": "🥇",
        "Junior Developer": "💻",
        "Senior Developer": "💻",
        "SysOp": "⚙️",
        "Administrator": "👑",
        "Uploader": "📦",
        "VIP": "💎",
        "VIP+": "💎",
        "Legend": "💎",
        "Bot": "🤖",
        "undefined": "❓",
    };

    let classToEmoji = {};

    async function loadClassMap() {
        console.debug("Loading class emoji map...");
        const saved = await GM.getValue("customClassMap");
        classToEmoji = saved ? JSON.parse(saved) : { ...defaultClassToEmoji };
        console.debug("Loaded classToEmoji:", classToEmoji);
    }

    async function saveClassMap() {
        console.debug("Saving class emoji map:", classToEmoji);
        await GM.setValue("customClassMap", JSON.stringify(classToEmoji));
    }

    async function getApiKey() {
        console.debug("Getting API key...");
        let apiKey = await GM.getValue("apiKey");
        if (!apiKey) {
            apiKey = prompt("Enter your GazelleGames API key. Required permissions: \"User\":")?.trim();
            if (apiKey) await GM.setValue("apiKey", apiKey);
        }
        return apiKey;
    }

    async function getCached(username) {
        const key = `userCache_${username}`;
        const raw = await GM.getValue(key);
        if (!raw) return null;
        const { timestamp, data } = JSON.parse(raw);
        const expired = (Date.now() - timestamp) > CACHE_DURATION_MS;
        if (!expired && data && Object.keys(data).length > 0) return data;
        console.debug(`Cache miss or expired for ${username}`);
        return null;
    }

    async function setCached(username, data) {
        console.debug(`Caching data for user: ${username}`, data);
        const key = `userCache_${username}`;
        await GM.setValue(key, JSON.stringify({ timestamp: Date.now(), data }));
    }

    async function fetchUserInfo(username) {
        console.debug(`Fetching user info for: ${username}`);
        const now = Date.now();
        if ((now - lastApiCallTime) < RATE_LIMIT_MS) {
            console.debug("Rate limit hit, skipping request");
            return null;
        }
        lastApiCallTime = now;
        const apiKey = await getApiKey();
        if (!apiKey) {
            console.debug("No API key available");
            return null;
        }

        const url = `${API_URL}?key=${encodeURIComponent(apiKey)}&request=user&name=${encodeURIComponent(username)}`;
        return new Promise(resolve => {
            GM.xmlHttpRequest({
                method: 'GET',
                url,
                onload: res => {
                    try {
                        const data = JSON.parse(res.responseText);
                        console.log("Response data:")
                        console.log(data);
                        if (res.status !== 200 || data?.status === "failure") {
                            console.debug("API error response:", data);
                            resolve(data?.error === "no such user" ? { invalidUser: true } : null);
                        } else resolve(data);
                    } catch {
                        resolve(null);
                    }
                },
                onerror: (e) => {
                    console.debug("Request error:", e);
                    resolve(null);
                }
            });
        });
    }

    async function annotateUser(userEl) {
        const username = userEl.getAttribute('data-name');
        if (!username || userEl.dataset.emojiAppended === "true") return;
    
        let data = await getCached(username);
        if (!data) {
            console.debug(`User ${username} not cached, adding to queue`);
            uncachedUsers.add(username);
            return;
        }
    
        const personal = data?.response?.personal;
        const userClass = personal?.class;
        const warned = personal?.warned;
    
        const emoji = classToEmoji[userClass];
        const prependEmoji = warned ? "⚠️" : "";
    
        userEl.dataset.emojiAppended = "true";
    
        if (emoji) userEl.dataset.emoji = emoji;
        if (prependEmoji) userEl.dataset.warnEmoji = prependEmoji;
    
        userEl.classList.add('user-with-emoji');
    }
    


    async function scanRecentMessages() {
        console.debug("Scanning recent messages...");
        const seen = new WeakSet();
        const messages = Array.from(document.querySelectorAll('.msg[data-type="message"] .user')).reverse();
        for (const userEl of messages) {
            if (!seen.has(userEl) && userEl.dataset.emojiAppended !== "true") {
                seen.add(userEl);
                requestIdleCallback(() => annotateUser(userEl));
            }
        }
    }

    function init() {
        console.debug("Initializing message observer...");
        const msgContainer = document.querySelector('.messages');
        if (!msgContainer) {
            console.debug("No message container found.");
            return;
        }
        const style = document.createElement('style');
        style.textContent = `
        .user-with-emoji::before {
            content: attr(data-warn-emoji);
            pointer-events: none;
            user-select: none;
            margin-right: 2px;
        }
    
        .user-with-emoji::after {
            content: " " attr(data-emoji);
            pointer-events: none;
            user-select: none;
        }
    `;
        document.head.appendChild(style);
        new MutationObserver(scanRecentMessages).observe(msgContainer, { childList: true, subtree: true });
        scanRecentMessages();
    }




    (async () => {
        console.debug("Running main async block");
        await loadClassMap();
        const appCheck = setInterval(() => {
            const app = document.getElementById('app');
            if (app) {
                clearInterval(appCheck);
                console.debug("App container found, initializing UI");
                addEmojiEditButton();
                init();
                observeUserContextMenu();
            }
        }, 100);

        // Background fetching loop. If you see 50 uncached users on first load for example, queue them up and iterate over, makes initial load easier.
        setInterval(async () => {
          const [username] = uncachedUsers;
          if (!username) return;

          const data = await fetchUserInfo(username);
          if (data) {
            await setCached(username, data);
            uncachedUsers.delete(username);   // only now!
            scanRecentMessages();             // will paint the emoji
          }
        }, RATE_LIMIT_MS);

        setInterval(async () => {
            scanRecentMessages();
        }, 60000) // Every 60 seconds do a rescan. Force pulse check (attempting to debug emojis stopping rendering. This does not need to stay)
        
    })();



    // ------------ Custom Emoji Edit button below (nothing to do with actual user polling) ----------
    function createSettingsUI() {
        console.debug("Creating settings UI");

        const getSelectedFields = () => JSON.parse(localStorage.getItem('selectedUserFields') || '[]');
        const setSelectedFields = (fields) => localStorage.setItem('selectedUserFields', JSON.stringify(fields));

        const allFields = {
            isFriend: "Is Friend",
            gold: "Gold",
            profile: "Profile Link",
            joinedDate: "Joined Date",
            uploaded: "Uploaded",
            downloaded: "Downloaded",
            fullDownloaded: "Full Downloaded",
            ratio: "Ratio",
            shareScore: "Share Score",
            class: "Class",
            donor: "Donor",
            warned: "Warned",
            paranoia: "Paranoia",
            torrentsUploaded: "Torrents Uploaded",
            hourlyGold: "Hourly Gold",
            actualPosts: "Raw Forum Posts",
            threads: "Threads",
            forumLikes: "Forum Likes",
            forumDislikes: "Forum Dislikes",
            ircActualLines: "Raw IRC Lines",
            seedSize: "Seed Size"
        };

        const modal = document.createElement('div');
        modal.style = `
            position: fixed; top: 50%; left: 50%;
            transform: translate(-50%, -50%);
            background: #fff; color: #000;
            border: 2px solid #ccc; border-radius: 10px;
            padding: 0; z-index: 9999; width: 400px;
            box-shadow: 0 0 10px rgba(0,0,0,0.3);
            overflow: hidden;
            font-family: sans-serif;
        `;

        const titleBar = document.createElement('div');
        titleBar.style = `
            background-color: #eee;
            padding: 10px 40px 10px 10px;
            font-weight: bold;
            cursor: move;
            border-bottom: 1px solid #ccc;
            position: relative;
        `;
        titleBar.textContent = 'Edit Viewed Details';

        const closeBtn = document.createElement('span');
        closeBtn.innerHTML = '&times;';
        closeBtn.style = `
            position: absolute;
            top: 8px;
            right: 12px;
            color: red;
            font-size: 18px;
            font-weight: bold;
            cursor: pointer;
        `;
        closeBtn.onclick = () => modal.remove();
        titleBar.appendChild(closeBtn);

        const content = document.createElement('div');
        content.style.padding = '10px';

        content.innerHTML = `
            <div id="emojiForm" style="max-height: 300px; overflow-y: auto;"></div>
            <div style="display: flex; justify-content: center; gap: 10px; margin-top: 5px;">
                <button id="resetBtn" style="
                    background-color: #f44336;
                    color: white;
                    padding: 6px 12px;
                    border: none;
                    border-radius: 5px;
                    cursor: pointer;
                    font-size: 12px;
                ">Reset</button>
                <button id="saveBtn" style="
                    background-color: #4CAF50;
                    color: white;
                    padding: 10px 20px;
                    border: none;
                    border-radius: 5px;
                    cursor: pointer;
                    font-size: 14px;
                ">Save</button>
            </div>
        `;

        modal.appendChild(titleBar);
        modal.appendChild(content);
        document.body.appendChild(modal);

        let isDragging = false;
        let dragOffsetX = 0, dragOffsetY = 0;

        titleBar.addEventListener('mousedown', (e) => {
            isDragging = true;
            dragOffsetX = e.clientX - modal.offsetLeft;
            dragOffsetY = e.clientY - modal.offsetTop;
        });

        document.addEventListener('mousemove', (e) => {
            if (isDragging) {
                modal.style.left = `${e.clientX - dragOffsetX}px`;
                modal.style.top = `${e.clientY - dragOffsetY}px`;
            }
        });

        document.addEventListener('mouseup', () => {
            isDragging = false;
        });

        const formContainer = content.querySelector('#emojiForm');

        // Emoji editor
        Object.entries(classToEmoji).forEach(([cls, emoji]) => {
            const row = document.createElement('div');
            row.innerHTML = `
                <label style="display:flex;justify-content:space-between;margin-bottom:6px">
                    <span>${cls}</span>
                    <input type="text" value="${emoji}" data-class="${cls}" style="width: 50px; text-align:center" />
                </label>
            `;
            formContainer.appendChild(row);
        });

        // Extra Fields section
        const selected = new Set(getSelectedFields());

        const extraOptions = document.createElement('div');
        extraOptions.style.marginTop = '10px';
        extraOptions.innerHTML = `<hr/><div><strong>Show Extra Fields:</strong></div>`;

        Object.entries(allFields).forEach(([key, label]) => {
            const wrapper = document.createElement('label');
            wrapper.style.display = 'block';
            wrapper.innerHTML = `
                <input type="checkbox" value="${key}" ${selected.has(key) ? 'checked' : ''} />
                ${label}
            `;
            extraOptions.appendChild(wrapper);
        });

        formContainer.appendChild(extraOptions);

        // Save button logic
        content.querySelector('#saveBtn').onclick = async () => {
            const inputs = formContainer.querySelectorAll('input[type="text"]');
            classToEmoji = {};
            inputs.forEach(input => {
                const cls = input.getAttribute('data-class');
                if (cls) classToEmoji[cls] = input.value;
            });

            const checkedFields = Array.from(extraOptions.querySelectorAll('input[type="checkbox"]:checked')).map(el => el.value);
            setSelectedFields(checkedFields);

            await saveClassMap();
            modal.remove();
        };

        // Reset button logic
        content.querySelector('#resetBtn').onclick = () => {
            classToEmoji = { ...defaultClassToEmoji };
            localStorage.removeItem('selectedUserFields');

            formContainer.innerHTML = '';

            // Rebuild emoji inputs
            Object.entries(classToEmoji).forEach(([cls, emoji]) => {
                const row = document.createElement('div');
                row.innerHTML = `
                    <label style="display:flex;justify-content:space-between;margin-bottom:6px">
                        <span>${cls}</span>
                        <input type="text" value="${emoji}" data-class="${cls}" style="width: 50px; text-align:center" />
                    </label>
                `;
                formContainer.appendChild(row);
            });

            // Rebuild extra fields
            const newExtraOptions = document.createElement('div');
            newExtraOptions.style.marginTop = '10px';
            newExtraOptions.innerHTML = `<hr/><div><strong>Show Extra Fields:</strong></div>`;

            Object.entries(allFields).forEach(([key, label]) => {
                const wrapper = document.createElement('label');
                wrapper.style.display = 'block';
                wrapper.innerHTML = `
                    <input type="checkbox" value="${key}" />
                    ${label}
                `;
                newExtraOptions.appendChild(wrapper);
            });

            formContainer.appendChild(newExtraOptions);
        };
    }


    function addEmojiEditButton() {
        console.debug("Attempting to add emoji edit button");
        const footerCheck = setInterval(() => {
            const footer = document.getElementById('footer');
            if (footer) {
                clearInterval(footerCheck);
                console.debug("Footer found, adding button");
                const btn = document.createElement('button');
                btn.textContent = 'Edit Enhancer';
                btn.style = 'margin-left: 10px; padding: 4px 8px; font-size: 12px;';
                btn.onclick = createSettingsUI;
                footer.appendChild(btn);
            }
        }, 1000);
    }



    // ------------ Custom User Selection Data Enhancement below (nothing to do with actual user polling) ----------

    function observeUserContextMenu() {
        const observer = new MutationObserver(async (mutations) => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (
                        node.nodeType === 1 &&
                        node.id === 'context-menu-container'
                    ) {
                        const menu = node.querySelector('#context-menu');
                        const userItem = menu?.querySelector('.context-menu-user');
                        const username = userItem?.textContent?.trim();
                        if (!username) return;

                        const data = await getCached(username);
                        if (!data?.response) return;

                        const { isFriend, stats, id } = data.response;
                        const gold = stats?.gold ?? 'N/A';
                        const profileURL = `https://gazellegames.net/user.php?id=${id}`;

                        // Wrap existing items in a new container
                        const existingItems = Array.from(menu.children).filter(child => child.tagName === 'LI' || child.classList.contains('context-menu-divider'));
                        const leftDiv = document.createElement('div');
                        leftDiv.style.flex = '1';
                        existingItems.forEach(item => leftDiv.appendChild(item));

                        const bg = window.getComputedStyle(menu).backgroundColor;
                        const rgb = bg.match(/\d+/g).map(Number);
                        const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 255000; // Normalize 0-1.

                        const textColor = brightness < 0.4 ? 'white' : 'black'; // Give slight favor to black (up to 60%)

                        const rightDiv = document.createElement('div');
                        rightDiv.style.flex = '1';
                        rightDiv.style.fontSize = '0.85em';
                        rightDiv.style.color = textColor; // poor man's darkmode awareness applied
                        rightDiv.style.paddingLeft = '10px';
                        rightDiv.style.userSelect = 'text';  // allow text selection. Without this cannot highlight.
                        rightDiv.style.pointerEvents = 'auto';
                        rightDiv.addEventListener('contextmenu', (e) => { // dirty hack to allow right clicking on the right side.
                            e.stopPropagation();
                        });

                        const selectedFields = getSelectedFields();

                        const rows = [];

                        function addRow(label, value) {
                            if (value !== undefined && value !== null)
                                rows.push(`<div><strong>${label}:</strong> ${value}</div>`);
                        }

                        // Conditionally render fields
                        if (selectedFields.includes('isFriend')) addRow('Friend', isFriend ? '✅' : '❌');
                        if (selectedFields.includes('gold')) addRow('Gold', stats.gold);
                        if (selectedFields.includes('profile')) {
                            rows.push(`<div><a href="${profileURL}" target="_blank" style="color: #4ea1d3;">Profile</a></div>`);
                        }

                        if (selectedFields.includes('joinedDate')) addRow('Joined', formatDate(stats.joinedDate));
                        if (selectedFields.includes('uploaded')) addRow('Uploaded', formatBytes(stats.uploaded));
                        if (selectedFields.includes('downloaded')) addRow('Downloaded', formatBytes(stats.downloaded));
                        if (selectedFields.includes('fullDownloaded')) addRow('Full Downloaded', formatBytes(stats.fullDownloaded));
                        if (selectedFields.includes('ratio')) addRow('Ratio', stats.ratio);
                        if (selectedFields.includes('shareScore')) addRow('Share Score', stats.shareScore);

                        const p = data.response.personal;
                        if (selectedFields.includes('class')) addRow('Class', p.class);
                        if (selectedFields.includes('donor')) addRow('Donor', p.donor ? '✅' : '❌');
                        if (selectedFields.includes('warned')) addRow('Warned', p.warned ? '⚠️' : 'No');
                        if (selectedFields.includes('paranoia')) addRow('Paranoia', p.paranoiaText);

                        const c = data.response.community;
                        if (selectedFields.includes('torrentsUploaded')) addRow('Torrents Uploaded', c.uploaded);
                        if (selectedFields.includes('hourlyGold')) addRow('Hourly Gold', c.hourlyGold);
                        if (selectedFields.includes('actualPosts')) addRow('Forum Posts', c.actualPosts);
                        if (selectedFields.includes('threads')) addRow('Threads', c.threads);
                        if (selectedFields.includes('forumLikes')) addRow('Likes', c.forumLikes);
                        if (selectedFields.includes('forumDislikes')) addRow('Dislikes', c.forumDislikes);
                        if (selectedFields.includes('ircActualLines')) addRow('IRC Lines', c.ircActualLines);
                        if (selectedFields.includes('seedSize')) addRow('Seed Size', formatBytes(c.seedSize));

                        rightDiv.innerHTML = rows.join('');

                        // Clear menu and insert flex container
                        menu.innerHTML = '';
                        menu.style.display = 'flex';
                        menu.style.flexDirection = 'row';
                        menu.style.minWidth = '500px';

                        menu.appendChild(leftDiv);
                        menu.appendChild(rightDiv);
                    }
                }
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    function formatBytes(bytes) {

        if (bytes === null) {
            return null;
        }

        const MB = 1024 ** 2;
        const GB = 1024 ** 3;
        const TB = 1024 ** 4;
        const PB = 1024 ** 5;

        if (bytes >= PB) {
            return (bytes / PB).toFixed(3) + ' PB';
        } else if (bytes >= TB) {
            return (bytes / TB).toFixed(3) + ' TB';
        } else if (bytes >= GB) {
            return (bytes / GB).toFixed(3) + ' GB';
        } else {
            return (bytes / MB).toFixed(3) + ' MB';
        }
    }

    function formatDate(dateStr) {
        return dateStr.split(' ')[0]; // YYYY-MM-DD
    }

    function getSelectedFields() {
        return JSON.parse(localStorage.getItem('selectedUserFields') || '[]');
    }


})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址