Torn Item Safety

Displays effects of items in Torn's various item shops with hover tooltips and disables items with warnings when toggle is active.

目前為 2025-05-01 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Torn Item Safety
// @namespace    http://tampermonkey.net/
// @version      1.0.6
// @license      GNU GPLv3
// @description  Displays effects of items in Torn's various item shops with hover tooltips and disables items with warnings when toggle is active.
// @author       Vassilios [2276659]
// @match        https://www.torn.com/item.php*
// @match        https://www.torn.com/bigalgunshop.php*
// @match        https://www.torn.com/shops.php?step=*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      api.torn.com
// ==/UserScript==

(function() {
    'use strict';

    // Register Tampermonkey menu command to enter API key
    GM_registerMenuCommand('Enter API key.', () => {
        const newApiKey = prompt('Please enter your Torn API key:', '');
        if (newApiKey && newApiKey.trim() !== '') {
            localStorage.setItem(CONFIG.STORAGE_KEYS.API_KEY, newApiKey.trim());
            // Clear cached data to force a fresh fetch with the new key
            localStorage.removeItem(CONFIG.STORAGE_KEYS.ITEM_DATA);
            localStorage.removeItem(CONFIG.STORAGE_KEYS.LAST_REQUEST_TIME);
            // Trigger a refresh of item data
            ItemEffectsApp.init();
        }
    });

    // =====================================================================
    // CONFIGURATION
    // =====================================================================

    const CONFIG = {
        getApiKey: () => localStorage.getItem('tornItemEffects_apiKey') || "", // Dynamically retrieve API key
        API_BASE_URL: 'https://api.torn.com/v2/torn/items',
        ITEM_CATEGORIES: ['Tool', 'Enhancer'],
        CACHE_DURATION: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
        OBSERVER_CONFIG: { childList: true, subtree: true },
        ELEMENT_IDS: {
            WARNINGS_TOGGLE: 'warnings-toggle',
            WARNINGS_STATUS: 'warnings-status'
        },
        SELECTORS: {
            ALL_ITEMS: '#all-items',
            SELL_ITEMS_WRAP: '.sell-items-wrap',
            CONTENT_TITLE_LINKS: '.content-title-links',
            ITEM_NAME: '.name',
            WARNING_SIGN: '.warning-sign'
        },
        STORAGE_KEYS: {
            WARNING_STATE: 'tornItemEffects_warningState',
            ITEM_DATA: 'tornItemEffects_itemData',
            LAST_REQUEST_TIME: 'tornItemEffects_lastRequestTime',
            API_KEY: 'tornItemEffects_apiKey'
        },
        STYLES: {
            WARNING_SIGN: {
                color: '#ff9900',
                fontWeight: 'bold',
                marginLeft: '5px',
                cursor: 'help'
            },
            TOOLTIP: {
                position: 'absolute',
                backgroundColor: '#191919',
                color: '#F7FAFC',
                padding: '6px 10px',
                borderRadius: '4px',
                fontSize: '12px',
                zIndex: '9999999',
                display: 'none',
                boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
                maxWidth: '250px',
                textAlign: 'left',
                fontFamily: 'Segoe UI, Arial, sans-serif',
                lineHeight: '1.3',
                border: '1px solid #4A5568',
                wordWrap: 'break-word',
                overflowWrap: 'break-word'
            },
            STATUS_INDICATOR: {
                ON: { text: 'ON', color: '#4CAF50' },
                OFF: { text: 'OFF', color: '#F44336' }
            }
        }
    };

    // =====================================================================
    // STATE MANAGEMENT
    // =====================================================================

    const State = {
        itemData: [],
        observers: { items: null, body: null }
    };

    // =====================================================================
    // API INTERFACE
    // =====================================================================

    const ApiService = {
        fetchItemCategory: function(category) {
            return new Promise((resolve, reject) => {
                if (typeof GM_xmlhttpRequest === 'undefined') {
                    console.error('GM_xmlhttpRequest is not available');
                    reject(new Error('GM_xmlhttpRequest is not defined'));
                    return;
                }

                const apiKey = CONFIG.getApiKey();
                const url = `${CONFIG.API_BASE_URL}?cat=${category}&sort=ASC`;
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    headers: {
                        'accept': 'application/json',
                        'Authorization': `ApiKey ${apiKey}`
                    },
                    onload: function(response) {
                        try {
                            const data = JSON.parse(response.responseText);
                            if (data && data.items) {
                                resolve(data.items);
                            } else {
                                console.error('API response does not contain items:', response.responseText);
                                reject(new Error('Invalid data format from API'));
                            }
                        } catch (error) {
                            console.error('Error parsing API response:', response.responseText, error);
                            reject(error);
                        }
                    },
                    onerror: function(error) {
                        console.error('GM_xmlhttpRequest error:', error);
                        reject(error);
                    }
                });
            });
        },

        fetchAllItemData: function() {
            // Check if API key is set
            const apiKey = CONFIG.getApiKey();
            if (!apiKey) {
                const cachedData = localStorage.getItem(CONFIG.STORAGE_KEYS.ITEM_DATA);
                if (cachedData) {
                    try {
                        State.itemData = JSON.parse(cachedData);
                        return Promise.resolve(State.itemData);
                    } catch (error) {
                        console.error('Error parsing cached data:', error);
                        return Promise.resolve([]);
                    }
                }
                return Promise.resolve([]);
            }

            // Check for cached data
            const cachedData = localStorage.getItem(CONFIG.STORAGE_KEYS.ITEM_DATA);
            const lastRequestTime = localStorage.getItem(CONFIG.STORAGE_KEYS.LAST_REQUEST_TIME);
            const currentTime = Date.now();

            if (cachedData && lastRequestTime && cachedData !== "[]") {
                const timeSinceLastRequest = currentTime - parseInt(lastRequestTime, 10);
                if (timeSinceLastRequest < CONFIG.CACHE_DURATION) {
                    try {
                        State.itemData = JSON.parse(cachedData);
                        return Promise.resolve(State.itemData);
                    } catch (error) {
                        console.error('Error parsing cached data:', error);
                    }
                }
            }

            // Fetch new data
            const fetchPromises = CONFIG.ITEM_CATEGORIES.map(category =>
                this.fetchItemCategory(category)
                    .then(items => {
                        const simplifiedItems = items.map(item => ({
                            name: item.name,
                            effect: item.effect
                        }));
                        State.itemData = [...State.itemData, ...simplifiedItems];
                        return simplifiedItems;
                    })
                    .catch(error => {
                        console.error(`Error fetching ${category} items:`, error);
                        return [];
                    })
            );

            return Promise.all(fetchPromises).then(() => {
                // Save to localStorage
                try {
                    localStorage.setItem(CONFIG.STORAGE_KEYS.ITEM_DATA, JSON.stringify(State.itemData));
                    localStorage.setItem(CONFIG.STORAGE_KEYS.LAST_REQUEST_TIME, currentTime.toString());
                } catch (error) {
                    console.error('Error saving to localStorage:', error);
                }
                return State.itemData;
            });
        }
    };

    // =====================================================================
    // DOM UTILITIES
    // =====================================================================

    const DomUtils = {
        find: {
            itemContainers: function() {
                const containers = [];
                const allItemsList = document.querySelector(CONFIG.SELECTORS.ALL_ITEMS);
                if (allItemsList) containers.push(allItemsList);
                const sellItemsWrap = document.querySelector(CONFIG.SELECTORS.SELL_ITEMS_WRAP);
                if (sellItemsWrap) containers.push(sellItemsWrap);
                return containers;
            },

            listItems: function() {
                const containers = this.itemContainers();
                let items = [];
                containers.forEach(container => {
                    const containerItems = Array.from(container.getElementsByTagName('li'));
                    items = [...items, ...containerItems];
                });
                return items;
            },

            navigationContainer: function() {
                return document.querySelector(CONFIG.SELECTORS.CONTENT_TITLE_LINKS);
            }
        },

        create: {
            warningSign: function(effectText) {
                const warningSign = document.createElement('span');
                warningSign.className = 'warning-sign';
                Object.assign(warningSign.style, CONFIG.STYLES.WARNING_SIGN);
                warningSign.innerHTML = '⚠️';

                const tooltip = this.tooltip(effectText);
                warningSign.appendChild(tooltip);

                warningSign.addEventListener('mouseenter', function() {
                    tooltip.style.display = 'block';
                    const rect = warningSign.getBoundingClientRect();
                    const isLongText = (tooltip.textContent || '').length > 50;
                    tooltip.style.left = '0px';
                    tooltip.style.top = isLongText ? '-45px' : '-30px';

                    setTimeout(() => {
                        const tooltipRect = tooltip.getBoundingClientRect();
                        if (tooltipRect.left < 0) tooltip.style.left = '5px';
                        if (tooltipRect.top < 0) tooltip.style.top = '20px';
                    }, 0);
                });

                warningSign.addEventListener('mouseleave', function() {
                    tooltip.style.display = 'none';
                });

                return warningSign;
            },

            tooltip: function(content) {
                const tooltip = document.createElement('div');
                tooltip.className = 'item-effect-tooltip';
                tooltip.setAttribute('role', 'tooltip');
                Object.assign(tooltip.style, CONFIG.STYLES.TOOLTIP);
                tooltip.textContent = content;
                return tooltip;
            },

            toggleButton: function() {
                const toggleButton = document.createElement('a');
                toggleButton.id = CONFIG.ELEMENT_IDS.WARNINGS_TOGGLE;
                toggleButton.className = 'warnings-toggle t-clear h c-pointer m-icon line-h24 right';
                toggleButton.setAttribute('aria-labelledby', 'warnings-toggle-label');

                const savedState = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE);
                const isActive = savedState !== null ? savedState === 'true' : true;

                if (isActive) {
                    toggleButton.classList.add('top-page-link-button--active');
                    toggleButton.classList.add('active');
                }

                toggleButton.innerHTML = `
                    <span class="icon-wrap svg-icon-wrap">
                        <span class="link-icon-svg warnings-icon">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 16">
                                <defs>
                                    <style>.cls-1{opacity:0.35;}.cls-2{fill:#fff;}.cls-3{fill:#777;}</style>
                                </defs>
                                <g>
                                    <g>
                                        <g class="cls-1">
                                            <path class="cls-2" d="M7.5,1 L15,15 L0,15 Z"></path>
                                        </g>
                                        <path class="cls-3" d="M7.5,0 L15,14 L0,14 Z"></path>
                                        <path class="cls-3" d="M7,6 L8,6 L8,10 L7,10 Z" style="fill:#fff"></path>
                                        <circle class="cls-3" cx="7.5" cy="12" r="1" style="fill:#fff"></circle>
                                    </g>
                                </g>
                            </svg>
                        </span>
                    </span>
                    <span id="warnings-toggle-label">Item Safety:</span>
                `;

                const statusIndicator = document.createElement('span');
                statusIndicator.id = CONFIG.ELEMENT_IDS.WARNINGS_STATUS;
                statusIndicator.style.marginLeft = '5px';
                statusIndicator.style.fontWeight = 'bold';
                statusIndicator.textContent = isActive ? CONFIG.STYLES.STATUS_INDICATOR.ON.text : CONFIG.STYLES.STATUS_INDICATOR.OFF.text;
                statusIndicator.style.color = isActive ? CONFIG.STYLES.STATUS_INDICATOR.ON.color : CONFIG.STYLES.STATUS_INDICATOR.OFF.color;
                toggleButton.appendChild(statusIndicator);

                return toggleButton;
            }
        }
    };

    // =====================================================================
    // CORE FUNCTIONALITY
    // =====================================================================

    const ItemEffectsApp = {
        init: function() {
            this.initializeToggleButton();

            ApiService.fetchAllItemData()
                .then(() => {
                    this.processItems();
                    this.setupObservers();
                    this.applyInitialWarningState();
                })
                .catch(error => {
                    console.error('Failed to fetch item data:', error);
                    this.processItems();
                    this.setupObservers();
                    this.applyInitialWarningState();
                });
        },

        applyInitialWarningState: function() {
            const savedState = localStorage.getItem(CONFIG.STORAGE_KEYS.WARNING_STATE);
            const isActive = savedState !== null ? savedState === 'true' : true;

            if (!isActive) return;

            const warningElements = document.querySelectorAll(CONFIG.SELECTORS.WARNING_SIGN);

            warningElements.forEach(warning => {
                const listItem = warning.closest('li[data-item]');
                if (!listItem) return;

                const isItemPage = window.location.href.includes('item.php');

                if (isItemPage) {
                    const deleteButtons = listItem.querySelectorAll('.option-delete');
                    deleteButtons.forEach(button => {
                        button.disabled = true;
                        button.style.opacity = '0.5';
                        button.style.cursor = 'not-allowed';
                        button.dataset.disabledByWarning = 'true';
                    });
                } else {
                    const inputElements = listItem.querySelectorAll('input, button, select, textarea, a.buy');
                    inputElements.forEach(input => {
                        if (input.tagName.toLowerCase() === 'a') {
                            input.dataset.originalHref = input.href;
                            input.href = 'javascript:void(0)';
                            input.style.opacity = '0.5';
                            input.style.pointerEvents = 'none';
                        } else {
                            input.disabled = true;
                            input.style.opacity = '0.5';
                            input.style.cursor = 'not-allowed';
                            input.dataset.disabledByWarning = 'true';
                            if (input.type === 'text') {
                                input.dataset.originalBg = input.style.backgroundColor;
                                input.style.backgroundColor = '#e0e0e0';
                            }
                        }
                    });
                }
            });
        },

        processItems: function() {
            const listItems = DomUtils.find.listItems();

            if (listItems.length === 0) return;

            listItems.forEach(item => this.processItemElement(item));
        },

        processItemElement: function(itemElement) {
            const nameElement = itemElement.querySelector(CONFIG.SELECTORS.ITEM_NAME);
            if (!nameElement || !nameElement.textContent) return;

            const itemName = nameElement.textContent.trim();
            const matchingItem = State.itemData.find(item => item.name === itemName);

            if (matchingItem && matchingItem.effect && !nameElement.querySelector(CONFIG.SELECTORS.WARNING_SIGN)) {
                const warningSign = DomUtils.create.warningSign(matchingItem.effect);
                nameElement.appendChild(warningSign);
            }
        },

        initializeToggleButton: function() {
            if (document.getElementById(CONFIG.ELEMENT_IDS.WARNINGS_TOGGLE)) {
                return;
            }

            const navContainer = DomUtils.find.navigationContainer();
            if (!navContainer) {
                if (document.readyState !== 'complete' && document.readyState !== 'interactive') {
                    document.addEventListener('DOMContentLoaded', () => this.initializeToggleButton());
                }
                return;
            }

            const toggleButton = DomUtils.create.toggleButton();
            toggleButton.addEventListener('click', this.toggleWarnings);
            navContainer.appendChild(toggleButton);
        },

        toggleWarnings: function() {
            this.classList.toggle('top-page-link-button--active');
            this.classList.toggle('active');

            const warningsEnabled = this.classList.contains('active');
            localStorage.setItem(CONFIG.STORAGE_KEYS.WARNING_STATE, warningsEnabled);

            const statusIndicator = document.getElementById(CONFIG.ELEMENT_IDS.WARNINGS_STATUS);
            statusIndicator.textContent = warningsEnabled ? CONFIG.STYLES.STATUS_INDICATOR.ON.text : CONFIG.STYLES.STATUS_INDICATOR.OFF.text;
            statusIndicator.style.color = warningsEnabled ? CONFIG.STYLES.STATUS_INDICATOR.ON.color : CONFIG.STYLES.STATUS_INDICATOR.OFF.color;

            const warningElements = document.querySelectorAll(CONFIG.SELECTORS.WARNING_SIGN);
            warningElements.forEach(warning => {
                const listItem = warning.closest('li[data-item]');
                if (!listItem) return;

                const isItemPage = window.location.href.includes('item.php');

                if (isItemPage) {
                    const deleteButtons = listItem.querySelectorAll('.option-delete');
                    deleteButtons.forEach(button => {
                        if (warningsEnabled) {
                            button.disabled = true;
                            button.style.opacity = '0.5';
                            button.style.cursor = 'not-allowed';
                            button.dataset.disabledByWarning = 'true';
                        } else {
                            button.disabled = false;
                            button.style.opacity = '';
                            button.style.cursor = '';
                            delete button.dataset.disabledByWarning;
                        }
                    });
                } else {
                    const inputElements = listItem.querySelectorAll('input, button, select, textarea, a.buy');
                    inputElements.forEach(input => {
                        if (warningsEnabled) {
                            if (input.tagName.toLowerCase() === 'a') {
                                input.dataset.originalHref = input.href;
                                input.href = 'javascript:void(0)';
                                input.style.opacity = '0.5';
                                input.style.pointerEvents = 'none';
                            } else {
                                input.disabled = true;
                                input.style.opacity = '0.5';
                                input.style.cursor = 'not-allowed';
                                input.dataset.disabledByWarning = 'true';
                                if (input.type === 'text') {
                                    input.dataset.originalBg = input.style.backgroundColor;
                                    input.style.backgroundColor = '#e0e0e0';
                                }
                            }
                        } else {
                            if (input.tagName.toLowerCase() === 'a') {
                                if (input.dataset.originalHref) {
                                    input.href = input.dataset.originalHref;
                                }
                                input.style.opacity = '';
                                input.style.pointerEvents = '';
                            } else {
                                input.disabled = false;
                                input.style.opacity = '';
                                input.style.cursor = '';
                                delete input.dataset.disabledByWarning;
                                if (input.type === 'text' && input.dataset.originalBg !== undefined) {
                                    input.style.backgroundColor = input.dataset.originalBg;
                                    delete input.dataset.originalBg;
                                }
                            }
                        }
                    });
                }
            });
        },

        setupObservers: function() {
            const itemContainers = DomUtils.find.itemContainers();
            State.observers.items = new MutationObserver(mutations => {
                let newItemsAdded = false;
                mutations.forEach(mutation => {
                    if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                        newItemsAdded = true;
                    }
                });
                if (newItemsAdded) {
                    this.processItems();
                }
            });

            if (itemContainers.length > 0) {
                itemContainers.forEach(container => {
                    State.observers.items.observe(container, CONFIG.OBSERVER_CONFIG);
                });
            } else {
                this.setupBodyObserver();
            }
        },

        setupBodyObserver: function() {
            State.observers.body = new MutationObserver(mutations => {
                const itemContainers = DomUtils.find.itemContainers();
                if (itemContainers.length > 0) {
                    itemContainers.forEach(container => {
                        State.observers.items.observe(container, CONFIG.OBSERVER_CONFIG);
                    });
                    this.processItems();
                    this.initializeToggleButton();
                    State.observers.body.disconnect();
                }
            });
            State.observers.body.observe(document.body, CONFIG.OBSERVER_CONFIG);
        }
    };

    // =====================================================================
    // INITIALIZATION
    // =====================================================================

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        ItemEffectsApp.init();
    } else {
        document.addEventListener('DOMContentLoaded', () => ItemEffectsApp.init());
    }

    window.TornItemEffects = {
        processItems: () => ItemEffectsApp.processItems(),
        toggleWarnings: () => {
            const warningToggleButton = document.getElementById(CONFIG.ELEMENT_IDS.WARNINGS_TOGGLE);
            if (warningToggleButton) warningToggleButton.click();
        }
    };
})();