Bazaars in Item Market 2.0

Displays bazaar listings with sorting controls via TornPal & IronNerd

目前為 2025-02-22 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Bazaars in Item Market 2.0
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  Displays bazaar listings with sorting controls via TornPal & IronNerd
// @author       Weav3r [1853324]
// @match        https://www.torn.com/page.php?sid=ItemMarket*
// @grant        GM_xmlhttpRequest
// @connect      tornpal.com
// @connect      www.ironnerd.me
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const CACHE_DURATION_MS = 60000;
    let currentSortKey = "price";
    let currentSortOrder = "asc";

    function getCache(itemId) {
        try {
            const key = "tornBazaarCache_" + itemId;
            const cached = localStorage.getItem(key);
            if (cached) {
                const payload = JSON.parse(cached);
                if (Date.now() - payload.timestamp < CACHE_DURATION_MS) {
                    return payload.data;
                }
            }
        } catch(e) {}
        return null;
    }
    function setCache(itemId, data) {
        try {
            const key = "tornBazaarCache_" + itemId;
            const payload = { timestamp: Date.now(), data: data };
            localStorage.setItem(key, JSON.stringify(payload));
        } catch(e) {}
    }

    function getRelativeTime(timestampSeconds) {
        const now = Date.now();
        const diffSec = Math.floor((now - timestampSeconds * 1000) / 1000);
        if (diffSec < 60) return diffSec + 's ago';
        if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm ago';
        if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h ago';
        return Math.floor(diffSec / 86400) + 'd ago';
    }

    function openModal(url) {
        const originalBodyOverflow = document.body.style.overflow;
        document.body.style.overflow = 'hidden';

        const modalOverlay = document.createElement('div');
        modalOverlay.id = 'bazaar-modal-overlay';
        Object.assign(modalOverlay.style, {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '100%',
            height: '100%',
            backgroundColor: 'rgba(0, 0, 0, 0.7)',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            zIndex: '10000',
            overflow: 'visible'
        });

        const modalContainer = document.createElement('div');
        modalContainer.id = 'bazaar-modal-container';
        Object.assign(modalContainer.style, {
            backgroundColor: '#fff',
            border: '8px solid #000',
            boxShadow: '0 0 10px rgba(0,0,0,0.5)',
            borderRadius: '8px',
            position: 'relative',
            resize: 'both'
        });

        const savedSize = localStorage.getItem('bazaarModalSize');
        if (savedSize) {
            try {
                const { width, height } = JSON.parse(savedSize);
                modalContainer.style.width = (width < 200 ? '80%' : width + 'px');
                modalContainer.style.height = (height < 200 ? '80%' : height + 'px');
            } catch(e) {
                modalContainer.style.width = '80%';
                modalContainer.style.height = '80%';
            }
        } else {
            modalContainer.style.width = '80%';
            modalContainer.style.height = '80%';
        }

        const closeButton = document.createElement('button');
        closeButton.textContent = '×';
        Object.assign(closeButton.style, {
            position: 'absolute',
            top: '-20px',
            right: '-20px',
            width: '40px',
            height: '40px',
            backgroundColor: '#ff0000',
            color: '#fff',
            border: 'none',
            borderRadius: '50%',
            fontSize: '24px',
            cursor: 'pointer',
            boxShadow: '0 0 5px rgba(0,0,0,0.5)'
        });
        closeButton.addEventListener('click', () => {
            modalOverlay.remove();
            document.body.style.overflow = originalBodyOverflow;
        });

        modalOverlay.addEventListener('click', (e) => {
            if (e.target === modalOverlay) {
                modalOverlay.remove();
                document.body.style.overflow = originalBodyOverflow;
            }
        });

        const iframe = document.createElement('iframe');
        Object.assign(iframe.style, {
            width: '100%',
            height: '100%',
            border: 'none'
        });
        iframe.src = url;

        modalContainer.appendChild(closeButton);
        modalContainer.appendChild(iframe);
        modalOverlay.appendChild(modalContainer);
        document.body.appendChild(modalOverlay);

        if (window.ResizeObserver) {
            const resizeObserver = new ResizeObserver(entries => {
                for (let entry of entries) {
                    const { width, height } = entry.contentRect;
                    localStorage.setItem('bazaarModalSize', JSON.stringify({
                        width: Math.round(width),
                        height: Math.round(height)
                    }));
                }
            });
            resizeObserver.observe(modalContainer);
        }
    }

    function createInfoContainer(itemName, itemId) {
        const container = document.createElement('div');
        container.id = 'item-info-container';
        container.setAttribute('data-itemid', itemId);
        Object.assign(container.style, {
            backgroundColor: '#2f2f2f',
            color: '#ccc',
            fontSize: '13px',
            border: '1px solid #444',
            borderRadius: '4px',
            margin: '5px 0',
            padding: '10px',
            display: 'flex',
            flexDirection: 'column',
            gap: '8px'
        });

        const header = document.createElement('div');
        header.className = 'info-header';
        Object.assign(header.style, {
            fontSize: '16px',
            fontWeight: 'bold',
            color: '#fff'
        });
        header.textContent = `Item: ${itemName} (ID: ${itemId})`;
        container.appendChild(header);

        const sortControls = document.createElement('div');
        sortControls.className = 'sort-controls';
        Object.assign(sortControls.style, {
            display: 'flex',
            alignItems: 'center',
            gap: '5px',
            fontSize: '12px',
            padding: '5px',
            backgroundColor: '#333',
            borderRadius: '4px'
        });

        const sortLabel = document.createElement('span');
        sortLabel.textContent = "Sort by:";
        sortControls.appendChild(sortLabel);

        const sortSelect = document.createElement('select');
        Object.assign(sortSelect.style, {
            padding: '2px',
            border: '1px solid #444',
            borderRadius: '2px',
            backgroundColor: '#1a1a1a',
            color: '#fff'
        });
        [
            { value: "price", text: "Price" },
            { value: "quantity", text: "Quantity" },
            { value: "updated", text: "Last Updated" }
        ].forEach(opt => {
            const option = document.createElement('option');
            option.value = opt.value;
            option.textContent = opt.text;
            sortSelect.appendChild(option);
        });
        sortSelect.value = currentSortKey;
        sortControls.appendChild(sortSelect);

        const orderToggle = document.createElement('button');
        Object.assign(orderToggle.style, {
            padding: '2px 4px',
            border: '1px solid #444',
            borderRadius: '2px',
            backgroundColor: '#1a1a1a',
            color: '#fff',
            cursor: 'pointer'
        });
        orderToggle.textContent = (currentSortOrder === "asc") ? "Asc" : "Desc";
        sortControls.appendChild(orderToggle);

        container.appendChild(sortControls);

        // Listings row
        const scrollWrapper = document.createElement('div');
        Object.assign(scrollWrapper.style, {
            overflowX: 'auto',
            overflowY: 'hidden',
            height: '120px',
            whiteSpace: 'nowrap',
            paddingBottom: '3px'
        });

        const cardContainer = document.createElement('div');
        cardContainer.className = 'card-container';
        Object.assign(cardContainer.style, {
            display: 'flex',
            flexWrap: 'nowrap',
            gap: '10px'
        });

        scrollWrapper.appendChild(cardContainer);
        container.appendChild(scrollWrapper);

        const poweredBy = document.createElement('div');
        poweredBy.style.fontSize = '10px';
        poweredBy.style.textAlign = 'right';
        poweredBy.style.marginTop = '6px';
poweredBy.innerHTML = `
  <span style="color:#666;">Powered by </span>
  <a href="https://tornpal.com/" target="_blank" style="color:#aaa; text-decoration:underline;">TornPal</a>
  <span style="color:#666;"> &amp; </span>
  <a href="https://ironnerd.me/" target="_blank" style="color:#aaa; text-decoration:underline;">IronNerd</a>
`;

        container.appendChild(poweredBy);

        sortSelect.addEventListener('change', () => {
            currentSortKey = sortSelect.value;
            if (container.filteredListings) renderCards(container, container.filteredListings);
        });
        orderToggle.addEventListener('click', () => {
            currentSortOrder = (currentSortOrder === "asc") ? "desc" : "asc";
            orderToggle.textContent = (currentSortOrder === "asc") ? "Asc" : "Desc";
            if (container.filteredListings) renderCards(container, container.filteredListings);
        });

        return container;
    }

    function renderCards(infoContainer, listings) {
        const sorted = listings.slice().sort((a, b) => {
            let diff = 0;
            if (currentSortKey === "price") diff = a.price - b.price;
            else if (currentSortKey === "quantity") diff = a.quantity - b.quantity;
            else if (currentSortKey === "updated") diff = a.updated - b.updated;
            return (currentSortOrder === "asc") ? diff : -diff;
        });
        const cardContainer = infoContainer.querySelector('.card-container');
        cardContainer.innerHTML = '';
        sorted.forEach(listing => {
            const card = createListingCard(listing);
            cardContainer.appendChild(card);
        });
    }

    function createListingCard(listing) {
        const card = document.createElement('div');
        card.className = 'listing-card';
        Object.assign(card.style, {
            backgroundColor: '#1a1a1a',
            color: '#fff',
            border: '1px solid #444',
            borderRadius: '4px',
            padding: '8px',
            width: 'calc((100% - 20px) / 3)',
            fontSize: 'clamp(12px, 1vw, 16px)',
            boxSizing: 'border-box'
        });

        const linkContainer = document.createElement('div');
        Object.assign(linkContainer.style, {
            display: 'flex',
            alignItems: 'center',
            gap: '5px',
            marginBottom: '6px'
        });

        const playerLink = document.createElement('a');
        playerLink.href = `https://www.torn.com/bazaar.php?userId=${listing.player_id}#/`;
        playerLink.textContent = `Player: ${listing.player_id}`;
        Object.assign(playerLink.style, {
            fontWeight: 'bold',
            color: '#00aaff',
            textDecoration: 'underline'
        });
        linkContainer.appendChild(playerLink);

        const iconSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        iconSvg.setAttribute("viewBox", "0 0 512 512");
        iconSvg.setAttribute("width", "16");
        iconSvg.setAttribute("height", "16");
        iconSvg.style.cursor = "pointer";
        iconSvg.style.color = "#ffa500";
        iconSvg.title = "Open in modal";
        iconSvg.innerHTML = `
            <path d="M432 64L208 64c-8.8 0-16 7.2-16 16l0 16-64 0 0-16c0-44.2 35.8-80 80-80L432 0c44.2 0 80 35.8 80 80l0 224c0 44.2-35.8 80-80 80l-16 0 0-64 16 0c8.8 0 16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zM0 192c0-35.3 28.7-64 64-64l256 0c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 192zm64 32c0 17.7 14.3 32 32 32l192 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 192c-17.7 0-32 14.3-32 32z"/>
        `;
        iconSvg.addEventListener('click', (e) => {
            e.preventDefault();
            openModal(playerLink.href);
        });
        linkContainer.appendChild(iconSvg);

        const details = document.createElement('div');
        details.innerHTML = `
            <div><strong>Price:</strong> $${listing.price.toLocaleString()}</div>
            <div><strong>Qty:</strong> ${listing.quantity}</div>
        `;
        details.style.marginBottom = '6px';

        const footnote = document.createElement('div');
        Object.assign(footnote.style, {
            fontSize: '11px',
            color: '#aaa',
            textAlign: 'right'
        });
        footnote.textContent = `Updated: ${getRelativeTime(listing.updated)}`;

        const sourceInfo = document.createElement('div');
        sourceInfo.style.fontSize = '10px';
        sourceInfo.style.color = '#aaa';
        sourceInfo.style.textAlign = 'right';
        let sourceDisplay = (listing.source === "ironnerd") ? "IronNerd" :
                            (listing.source === "bazaar") ? "TornPal" : listing.source;
        sourceInfo.textContent = "Source: " + sourceDisplay;

        card.appendChild(linkContainer);
        card.appendChild(details);
        card.appendChild(footnote);
        card.appendChild(sourceInfo);
        return card;
    }

    function updateInfoContainer(wrapper, itemId, itemName) {
        let infoContainer = document.querySelector(`#item-info-container[data-itemid="${itemId}"]`);
        if (!infoContainer) {
            infoContainer = createInfoContainer(itemName, itemId);
            wrapper.insertBefore(infoContainer, wrapper.firstChild);
        } else {
            const header = infoContainer.querySelector('.info-header');
            if (header) header.textContent = `Item: ${itemName} (ID: ${itemId})`;
            const cardContainer = infoContainer.querySelector('.card-container');
            if (cardContainer) cardContainer.innerHTML = '';
        }

        const cachedData = getCache(itemId);
        if (cachedData) {
            infoContainer.filteredListings = cachedData.listings;
            renderCards(infoContainer, cachedData.listings);
            return;
        }

        let listings = [];
        let responsesReceived = 0;
        function processResponse(newListings) {
            newListings.forEach(newItem => {
                let normalized;
                if (newItem.user_id !== undefined) {
                    normalized = {
                        item_id: newItem.item_id,
                        player_id: newItem.user_id,
                        quantity: newItem.quantity,
                        price: newItem.price,
                        updated: newItem.last_updated,
                        source: "ironnerd"
                    };
                } else {
                    normalized = newItem;
                }
                let duplicate = listings.find(item =>
                    item.player_id === normalized.player_id &&
                    item.price === normalized.price &&
                    item.quantity === normalized.quantity
                );
                if (duplicate) {
                    if (duplicate.source !== normalized.source) {
                        duplicate.source = "TornPal & IronNerd";
                    }
                } else {
                    listings.push(normalized);
                }
            });
            responsesReceived++;
            if (responsesReceived === 2) {
                setCache(itemId, { listings: listings });
                infoContainer.filteredListings = listings;
                renderCards(infoContainer, listings);
            }
        }

        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://tornpal.com/api/v1/markets/clist/${itemId}`,
            onload: function(response) {
                try {
                    const data = JSON.parse(response.responseText);
                    if (data.listings && Array.isArray(data.listings)) {
                        const filtered = data.listings.filter(l => l.source === "bazaar");
                        processResponse(filtered);
                    } else {
                        processResponse([]);
                    }
                } catch (e) { processResponse([]); }
            },
            onerror: function() { processResponse([]); }
        });

        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.ironnerd.me/get_bazaar_items/${itemId}`,
            onload: function(response) {
                try {
                    const data = JSON.parse(response.responseText);
                    if (data.bazaar_items && Array.isArray(data.bazaar_items)) {
                        processResponse(data.bazaar_items);
                    } else {
                        processResponse([]);
                    }
                } catch (e) { processResponse([]); }
            },
            onerror: function() { processResponse([]); }
        });
    }

    function processSellerWrapper(wrapper) {
        if (!wrapper || wrapper.id === 'item-info-container') return;
        const itemTile = wrapper.previousElementSibling;
        if (!itemTile) return;
        const nameEl = itemTile.querySelector('.name___ukdHN');
        const btn = itemTile.querySelector('button[aria-controls^="wai-itemInfo-"]');
        if (nameEl && btn) {
            const itemName = nameEl.textContent.trim();
            const idParts = btn.getAttribute('aria-controls').split('-');
            const itemId = idParts[idParts.length - 1];
            updateInfoContainer(wrapper, itemId, itemName);
        }
    }

    function processMobileSellerList() {
        if (window.innerWidth >= 784) return;
        const sellerList = document.querySelector('ul.sellerList___e4C9_');
        if (!sellerList) {
            const existing = document.querySelector('#item-info-container');
            if (existing) existing.remove();
            return;
        }
        const headerEl = document.querySelector('.itemsHeader___ZTO9r .title___ruNCT');
        const itemName = headerEl ? headerEl.textContent.trim() : "Unknown";
        const btn = document.querySelector('.itemsHeader___ZTO9r button[aria-controls^="wai-itemInfo-"]');
        let itemId = "unknown";
        if (btn) {
            const parts = btn.getAttribute('aria-controls').split('-');
            itemId = (parts.length > 2) ? parts[parts.length - 2] : parts[parts.length - 1];
        }
        if (document.querySelector(`#item-info-container[data-itemid="${itemId}"]`)) return;
        const infoContainer = createInfoContainer(itemName, itemId);
        sellerList.parentNode.insertBefore(infoContainer, sellerList);
        updateInfoContainer(infoContainer, itemId, itemName);
    }

    function processAllSellerWrappers(root = document.body) {
        if (window.innerWidth < 784) return;
        const wrappers = root.querySelectorAll('[class*="sellerListWrapper"]');
        wrappers.forEach(wrapper => processSellerWrapper(wrapper));
    }

    processAllSellerWrappers();
    processMobileSellerList();

    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    if (window.innerWidth < 784) {
                        if (node.matches('ul.sellerList___e4C9_')) {
                            processMobileSellerList();
                        }
                    } else {
                        if (node.matches('[class*="sellerListWrapper"]')) {
                            processSellerWrapper(node);
                        }
                        processAllSellerWrappers(node);
                    }
                }
            });
            mutation.removedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE && node.matches('ul.sellerList___e4C9_')) {
                    if (window.innerWidth < 784) {
                        const container = document.querySelector('#item-info-container');
                        if (container) container.remove();
                    }
                }
            });
        });
    });
    observer.observe(document.body, { childList: true, subtree: true });
})();

QingJ © 2025

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