Torn User List Stats

Display health and last online status for users in any user list

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Torn User List Stats
// @namespace    https://torn.com/
// @version      2.1
// @description  Display health and last online status for users in any user list
// @author       You
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_KEYS = {
        API_KEY: 'torn_api_key',
        SHOW_USER_LISTS: 'torn_show_user_lists',
        SHOW_TRAVEL_LISTS: 'torn_show_travel_lists'
    };

    const FETCH_DELAY = 150;

    let apiKey = null;
    let showUserLists = true;
    let showTravelLists = true;
    let fetchQueue = [];
    let isFetching = false;
    let processedUsers = new Set();
    let settingsInjected = false;

    function loadSettings() {
        apiKey = GM_getValue(STORAGE_KEYS.API_KEY, null);
        showUserLists = GM_getValue(STORAGE_KEYS.SHOW_USER_LISTS, true);
        showTravelLists = GM_getValue(STORAGE_KEYS.SHOW_TRAVEL_LISTS, true);
    }

    function saveSettings(newApiKey, newShowUserLists, newShowTravelLists) {
        if (newApiKey !== undefined) {
            GM_setValue(STORAGE_KEYS.API_KEY, newApiKey);
            apiKey = newApiKey;
        }
        if (newShowUserLists !== undefined) {
            GM_setValue(STORAGE_KEYS.SHOW_USER_LISTS, newShowUserLists);
            showUserLists = newShowUserLists;
        }
        if (newShowTravelLists !== undefined) {
            GM_setValue(STORAGE_KEYS.SHOW_TRAVEL_LISTS, newShowTravelLists);
            showTravelLists = newShowTravelLists;
        }
    }

    function maskApiKey(key) {
        if (!key || key.length < 8) return key || '';
        return '••••••••' + key.slice(-6);
    }

    function createModal() {
        const existingModal = document.getElementById('tuls-settings-modal');
        if (existingModal) {
            existingModal.style.display = 'flex';
            return;
        }

        const overlay = document.createElement('div');
        overlay.id = 'tuls-settings-modal';
        overlay.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.7);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 999999;
        `;

        const modal = document.createElement('div');
        modal.style.cssText = `
            background: #1a1a1a;
            border: 1px solid #333;
            border-radius: 5px;
            padding: 20px;
            min-width: 320px;
            max-width: 400px;
            color: #ccc;
            font-family: Arial, sans-serif;
        `;

        const title = document.createElement('h3');
        title.textContent = 'User List Stats Settings';
        title.style.cssText = `
            margin: 0 0 20px 0;
            padding: 0 0 10px 0;
            border-bottom: 1px solid #333;
            color: #fff;
            font-size: 16px;
        `;
        modal.appendChild(title);

        const apiSection = document.createElement('div');
        apiSection.style.cssText = 'margin-bottom: 20px;';

        const apiLabel = document.createElement('label');
        apiLabel.textContent = 'API Key';
        apiLabel.style.cssText = 'display: block; margin-bottom: 8px; font-size: 13px;';
        apiSection.appendChild(apiLabel);

        const apiInputWrapper = document.createElement('div');
        apiInputWrapper.style.cssText = 'display: flex; gap: 8px;';

        const apiInput = document.createElement('input');
        apiInput.type = 'text';
        apiInput.id = 'tuls-api-key-input';
        apiInput.placeholder = 'Enter API key...';
        apiInput.value = apiKey ? maskApiKey(apiKey) : '';
        apiInput.style.cssText = `
            flex: 1;
            padding: 8px 10px;
            background: #2a2a2a;
            border: 1px solid #444;
            border-radius: 3px;
            color: #fff;
            font-size: 13px;
        `;

        let isEditing = !apiKey;
        apiInput.addEventListener('focus', function() {
            if (!isEditing) {
                this.value = '';
                this.placeholder = 'Enter new API key...';
                isEditing = true;
            }
        });

        apiInput.addEventListener('blur', function() {
            if (isEditing && this.value === '') {
                if (apiKey) {
                    this.value = maskApiKey(apiKey);
                    this.placeholder = 'Enter API key...';
                    isEditing = false;
                }
            }
        });

        apiInputWrapper.appendChild(apiInput);
        apiSection.appendChild(apiInputWrapper);
        modal.appendChild(apiSection);

        const togglesSection = document.createElement('div');
        togglesSection.style.cssText = 'margin-bottom: 20px;';

        function createToggle(id, label, checked) {
            const container = document.createElement('div');
            container.style.cssText = `
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 10px 0;
                border-bottom: 1px solid #333;
            `;

            const labelEl = document.createElement('span');
            labelEl.textContent = label;
            labelEl.style.cssText = 'font-size: 13px;';
            container.appendChild(labelEl);

            const toggleWrapper = document.createElement('div');
            toggleWrapper.style.cssText = 'position: relative;';

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.id = id;
            checkbox.checked = checked;
            checkbox.style.cssText = `
                width: 40px;
                height: 20px;
                appearance: none;
                background: #444;
                border-radius: 10px;
                cursor: pointer;
                position: relative;
                transition: background 0.2s;
            `;

            checkbox.addEventListener('change', function() {
                this.style.background = this.checked ? '#7ca900' : '#444';
            });

            if (checked) {
                checkbox.style.background = '#7ca900';
            }

            const style = document.createElement('style');
            style.textContent = `
                #${id}::before {
                    content: '';
                    position: absolute;
                    width: 16px;
                    height: 16px;
                    background: #fff;
                    border-radius: 50%;
                    top: 2px;
                    left: 2px;
                    transition: transform 0.2s;
                }
                #${id}:checked::before {
                    transform: translateX(20px);
                }
            `;
            document.head.appendChild(style);

            toggleWrapper.appendChild(checkbox);
            container.appendChild(toggleWrapper);

            return container;
        }

        togglesSection.appendChild(createToggle('tuls-toggle-user-lists', 'Show on user lists', showUserLists));
        togglesSection.appendChild(createToggle('tuls-toggle-travel-lists', 'Show on travel lists', showTravelLists));
        modal.appendChild(togglesSection);

        const buttonSection = document.createElement('div');
        buttonSection.style.cssText = 'display: flex; gap: 10px; justify-content: flex-end;';

        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Cancel';
        cancelBtn.style.cssText = `
            padding: 8px 16px;
            background: #333;
            border: 1px solid #444;
            border-radius: 3px;
            color: #ccc;
            cursor: pointer;
            font-size: 13px;
        `;
        cancelBtn.addEventListener('click', function() {
            overlay.style.display = 'none';
        });
        buttonSection.appendChild(cancelBtn);

        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save';
        saveBtn.style.cssText = `
            padding: 8px 16px;
            background: #7ca900;
            border: none;
            border-radius: 3px;
            color: #fff;
            cursor: pointer;
            font-size: 13px;
        `;
        saveBtn.addEventListener('click', function() {
            const inputVal = apiInput.value.trim();
            const newUserLists = document.getElementById('tuls-toggle-user-lists').checked;
            const newTravelLists = document.getElementById('tuls-toggle-travel-lists').checked;

            if (isEditing && inputVal && !inputVal.startsWith('••••')) {
                saveSettings(inputVal, newUserLists, newTravelLists);
                apiInput.value = maskApiKey(inputVal);
                isEditing = false;
            } else {
                saveSettings(undefined, newUserLists, newTravelLists);
            }

            overlay.style.display = 'none';
            processedUsers.clear();
            setTimeout(scanForUsers, 100);
        });
        buttonSection.appendChild(saveBtn);

        modal.appendChild(buttonSection);
        overlay.appendChild(modal);

        overlay.addEventListener('click', function(e) {
            if (e.target === overlay) {
                overlay.style.display = 'none';
            }
        });

        document.body.appendChild(overlay);
    }

    function injectSettingsMenuItem() {
        if (settingsInjected) return;

        const settingsMenu = document.querySelector('ul.settings-menu');
        if (!settingsMenu) return;

        const existingItem = document.getElementById('tuls-settings-menu-item');
        if (existingItem) return;

        const menuItem = document.createElement('li');
        menuItem.id = 'tuls-settings-menu-item';
        menuItem.className = 'link';
        menuItem.innerHTML = `
            <a href="#" style="display: flex; align-items: center;">
                <div class="icon-wrapper">
                    <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="-6 -4 28 28">
                        <path d="M8,0C3.58,0,0,3.58,0,8s3.58,8,8,8,8-3.58,8-8S12.42,0,8,0Zm0,14c-3.31,0-6-2.69-6-6s2.69-6,6-6,6,2.69,6,6-2.69,6-6,6Zm0-10c-2.21,0-4,1.79-4,4s1.79,4,4,4,4-1.79,4-4-1.79-4-4-4Zm0,6c-1.1,0-2-.9-2-2s.9-2,2-2,2,.9,2,2-.9,2-2,2Z"/>
                    </svg>
                </div>
                <span class="link-text">User List Stats</span>
            </a>
        `;

        menuItem.querySelector('a').addEventListener('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            createModal();
        });

        const logoutItem = settingsMenu.querySelector('li.link a[href*="logout"]')?.parentElement;
        if (logoutItem) {
            settingsMenu.insertBefore(menuItem, logoutItem);
        } else {
            settingsMenu.appendChild(menuItem);
        }

        settingsInjected = true;
    }

    function fetchUserData(userId) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.torn.com/user/${userId}?selections=profile&key=${apiKey}`,
                onload: function(response) {
                    try {
                        const data = JSON.parse(response.responseText);
                        if (data.error) {
                            reject(data.error);
                        } else {
                            resolve(data);
                        }
                    } catch (e) {
                        reject(e);
                    }
                },
                onerror: function(error) {
                    reject(error);
                }
            });
        });
    }

    function formatLastAction(timestamp) {
        if (!timestamp) return '?';

        const now = Math.floor(Date.now() / 1000);
        const diff = now - timestamp;

        if (diff < 60) return `${diff}s ago`;
        if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
        if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
        return `${Math.floor(diff / 86400)}d ago`;
    }

    function formatHealth(current, max) {
        if (current === undefined || max === undefined) return '?';
        return `${current.toLocaleString()}/${max.toLocaleString()}`;
    }

    function createStatsCell(health, lastAction) {
        const cell = document.createElement('div');
        cell.className = 'torn-userlist-stats';
        cell.style.cssText = 'font-size: 12px; padding: 8px 10px; display: flex; flex-direction: column; justify-content: center; min-width: 100px;';

        const healthColor = health.current === health.max ? '#7ca900' :
                           health.current > health.max * 0.5 ? '#e6a400' : '#c90000';

        cell.innerHTML = `
            <span style="color: ${healthColor};">${formatHealth(health.current, health.max)}</span>
            <span style="color: #999; font-size: 11px;">${formatLastAction(lastAction)}</span>
        `;

        return cell;
    }

    function addColumnHeader() {
        if (document.querySelector('.torn-userlist-stats-header')) return;

        const tableHead = document.querySelector('[class*="tableHead___"]');
        if (!tableHead) return;

        const levelHeader = tableHead.querySelector('[class*="level___"]');
        if (!levelHeader) return;

        const headerDiv = document.createElement('div');
        headerDiv.className = 'torn-userlist-stats-header';
        headerDiv.style.cssText = 'padding: 8px 10px; font-weight: bold; min-width: 100px; display: flex; align-items: center;';
        headerDiv.textContent = 'HP / Activity';

        levelHeader.insertAdjacentElement('afterend', headerDiv);
    }

    function injectStats(userRow, data, listType) {
        const existing = userRow.querySelector('.torn-userlist-stats');
        if (existing) existing.remove();

        const health = {
            current: data.life?.current,
            max: data.life?.maximum
        };
        const lastAction = data.last_action?.timestamp;

        if (listType === 'new') {
            addColumnHeader();

            const levelDiv = userRow.querySelector('[class*="level___"]');
            if (levelDiv) {
                const statsCell = createStatsCell(health, lastAction);
                levelDiv.insertAdjacentElement('afterend', statsCell);
            }
        } else {
            const levelSpan = userRow.querySelector('.left-side .level');
            if (levelSpan) {
                const span = document.createElement('span');
                span.className = 'torn-userlist-stats';
                span.style.cssText = 'margin-left: 8px; font-size: 11px; color: #999;';

                const healthColor = health.current === health.max ? '#7ca900' :
                                   health.current > health.max * 0.5 ? '#e6a400' : '#c90000';

                span.innerHTML = `<span style="color: ${healthColor};">HP: ${formatHealth(health.current, health.max)}</span> | <span>${formatLastAction(lastAction)}</span>`;
                levelSpan.insertAdjacentElement('afterend', span);
            }
        }
    }

    async function processQueue() {
        if (isFetching || fetchQueue.length === 0 || !apiKey) return;

        isFetching = true;

        while (fetchQueue.length > 0) {
            const { userId, element, listType } = fetchQueue.shift();

            try {
                const data = await fetchUserData(userId);
                injectStats(element, data, listType);
            } catch (error) {
                console.error(`Failed to fetch data for user ${userId}:`, error);

                if (error.code === 2) {
                    console.log('Torn User List Stats: Invalid API key');
                    fetchQueue = [];
                    break;
                }
            }

            if (fetchQueue.length > 0) {
                await new Promise(r => setTimeout(r, FETCH_DELAY));
            }
        }

        isFetching = false;
    }

    function extractUserId(element) {
        const selectors = [
            'a[href*="profiles.php?XID="]',
            'a.user.name[href*="XID="]'
        ];

        for (const selector of selectors) {
            const link = element.querySelector(selector);
            if (link) {
                const match = link.href.match(/XID=(\d+)/);
                if (match) return match[1];
            }
        }
        return null;
    }

    function isTravelPage() {
        return window.location.pathname.includes('/abroad') ||
               window.location.pathname.includes('/travel') ||
               document.querySelector('.travel-agency') !== null ||
               document.querySelector('[class*="travel"]') !== null;
    }

    function scanForUsers() {
        if (!apiKey) return;

        const onTravelPage = isTravelPage();

        if (showUserLists && !onTravelPage) {
            const newFormatRows = document.querySelectorAll('li[class*="tableRow___"]');
            newFormatRows.forEach(row => {
                const userId = extractUserId(row);
                if (!userId || processedUsers.has(userId)) return;
                processedUsers.add(userId);
                fetchQueue.push({ userId, element: row, listType: 'new' });
            });
        }

        if (showTravelLists && onTravelPage) {
            const newFormatRows = document.querySelectorAll('li[class*="tableRow___"]');
            newFormatRows.forEach(row => {
                const userId = extractUserId(row);
                if (!userId || processedUsers.has(userId)) return;
                processedUsers.add(userId);
                fetchQueue.push({ userId, element: row, listType: 'new' });
            });
        }

        if (showTravelLists) {
            const oldFormatLists = document.querySelectorAll('.users-list');
            oldFormatLists.forEach(list => {
                const users = list.querySelectorAll('li');
                users.forEach(userLi => {
                    const userId = extractUserId(userLi);
                    if (!userId || processedUsers.has(userId)) return;
                    processedUsers.add(userId);
                    fetchQueue.push({ userId, element: userLi, listType: 'old' });
                });
            });
        }

        processQueue();
    }

    function setupObserver() {
        const observer = new MutationObserver((mutations) => {
            let shouldScan = false;
            let shouldCheckSettings = false;

            for (const mutation of mutations) {
                if (mutation.addedNodes.length > 0) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            if (node.matches?.('ul.settings-menu') ||
                                node.querySelector?.('ul.settings-menu')) {
                                shouldCheckSettings = true;
                            }

                            if (node.matches?.('li[class*="tableRow___"]') ||
                                node.querySelector?.('li[class*="tableRow___"]')) {
                                shouldScan = true;
                            }

                            if (node.classList?.contains('users-list') ||
                                node.querySelector?.('.users-list') ||
                                (node.tagName === 'LI' && node.closest('.users-list'))) {
                                shouldScan = true;
                            }
                        }
                    }
                }
            }

            if (shouldCheckSettings) {
                setTimeout(injectSettingsMenuItem, 50);
            }

            if (shouldScan) {
                setTimeout(scanForUsers, 100);
            }
        });

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

    function init() {
        loadSettings();

        setTimeout(injectSettingsMenuItem, 500);
        setTimeout(scanForUsers, 500);
        setupObserver();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();