Torn User List Stats

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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