您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Comprehensive inventory management and market posting tool for Torn.com
当前为
// ==UserScript== // @name Scrap2Mark // @namespace http://tampermonkey.net/ // @version 1.2 // @description Comprehensive inventory management and market posting tool for Torn.com // @author vALT0r [767373] // @match https://www.torn.com/item.php* // @match https://www.torn.com/page.php?sid=ItemMarket* // @grant none // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; // Configuration const SCRIPT_NAME = 'Torn Inventory Scanner'; const STORAGE_KEY = 'torn_inventory_data'; const API_KEY_STORAGE_KEY = 'torn_api_key'; const API_DATA_STORAGE_KEY = 'torn_api_items_data'; const API_EXPIRY_STORAGE_KEY = 'torn_api_items_expiry'; const API_KEY_EXPIRY_STORAGE_KEY = 'torn_api_key_expiry'; const WINDOW_STATE_STORAGE_KEY = 'torn_scanner_window_state'; const MARKET_DATA_STORAGE_KEY = 'torn_market_data'; const MARKET_EXPIRY_STORAGE_KEY = 'torn_market_expiry'; const IGNORED_ITEMS_STORAGE_KEY = 'scrap2mark_ignored_items'; const FILTER_STATE_STORAGE_KEY = 'scrap2mark_filter_state'; const API_CACHE_DURATION = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds const API_KEY_EXPIRY_DURATION = 90 * 24 * 60 * 60 * 1000; // 90 days in milliseconds const MARKET_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const OUTLIER_THRESHOLD = 3.0; // Standard deviations for outlier detection // Window size configuration const WINDOW_MIN_WIDTH = '410px'; const WINDOW_MIN_HEIGHT = '600px'; const WINDOW_DEFAULT_WIDTH = '510px'; const WINDOW_DEFAULT_HEIGHT = '600px'; const WINDOW_MINIMIZED_WIDTH = '180px'; const WINDOW_MINIMIZED_HEIGHT = '50px'; // State variables let isScanning = false; let scannedItems = new Map(); let ignoredItems = new Set(); // Track ignored items let floatingTable = null; let apiItemsData = null; let apiKey = null; let marketData = new Map(); let apiRequestQueue = []; let isProcessingQueue = false; let lastApiRequest = 0; let apiRequestCount = 0; let apiRequestResetTime = 0; // XHR/Fetch monitoring for item loading detection let itemLoadingState = { isLoading: false, lastLoadTime: 0, loadingRequests: new Set(), totalRequests: 0, completedRequests: 0 }; // Utility functions function log(message) { console.log(`[${SCRIPT_NAME}] ${message}`); } // Load ignored items from localStorage function loadIgnoredItems() { try { const stored = localStorage.getItem(IGNORED_ITEMS_STORAGE_KEY); if (stored) { const ignoredArray = JSON.parse(stored); ignoredItems = new Set(ignoredArray); } else { ignoredItems = new Set(); } } catch (e) { log('Error loading ignored items: ' + e.message); ignoredItems = new Set(); } } // Save ignored items to localStorage function saveIgnoredItems() { try { const ignoredArray = Array.from(ignoredItems); localStorage.setItem(IGNORED_ITEMS_STORAGE_KEY, JSON.stringify(ignoredArray)); } catch (e) { log('Error saving ignored items: ' + e.message); } } // Toggle ignore status for an item function toggleIgnoreItem(itemId) { // Convert to string to ensure consistency const itemIdStr = itemId.toString(); if (ignoredItems.has(itemIdStr)) { ignoredItems.delete(itemIdStr); log(`Removed item ${itemIdStr} from ignored list`); } else { ignoredItems.add(itemIdStr); log(`Added item ${itemIdStr} to ignored list`); } saveIgnoredItems(); updateTableDisplay(); // Show visual feedback with toast notification const statusDiv = document.getElementById('scan-status'); const isIgnored = ignoredItems.has(itemIdStr); const itemName = Array.from(scannedItems.values()).find(item => item.id.toString() === itemIdStr)?.name || 'Unknown'; const message = isIgnored ? `"${itemName}" ignored` : `"${itemName}" un-ignored`; // Create a temporary toast notification const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: ${isIgnored ? '#dc3545' : '#28a745'}; color: white; padding: 10px 15px; border-radius: 5px; z-index: 100002; font-size: 14px; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.3); animation: slideIn 0.3s ease-out; `; toast.textContent = message; // Add animation keyframes if not already added if (!document.querySelector('#toast-animations')) { const animationStyle = document.createElement('style'); animationStyle.id = 'toast-animations'; animationStyle.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(animationStyle); } document.body.appendChild(toast); // Remove toast after 3 seconds setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease-out'; setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); }, 3000); // Also update status briefly if available if (statusDiv) { const originalContent = statusDiv.innerHTML; statusDiv.innerHTML = `<span style="color: ${isIgnored ? '#dc3545' : '#28a745'};">${message}</span>`; setTimeout(() => { statusDiv.innerHTML = originalContent; }, 2000); } } // Check if an item is ignored function isItemIgnored(itemId) { return ignoredItems.has(itemId.toString()); } // Filter state management function loadFilterState() { try { const saved = localStorage.getItem(FILTER_STATE_STORAGE_KEY); if (saved) { return JSON.parse(saved); } } catch (e) { log('Error loading filter state: ' + e.message); } // Default state - only hideLowValue is true return { showNonTradeable: false, showIgnored: false, hideLowValue: true, minValue: 5000000 }; } function saveFilterState(state) { try { localStorage.setItem(FILTER_STATE_STORAGE_KEY, JSON.stringify(state)); } catch (e) { log('Error saving filter state: ' + e.message); } } function getCurrentFilterState() { const showNonTradeableCheckbox = document.getElementById('show-non-tradeable'); const showIgnoredCheckbox = document.getElementById('show-ignored'); const hideLowValueCheckbox = document.getElementById('hide-low-value'); const minValueInput = document.getElementById('min-value-input'); return { showNonTradeable: showNonTradeableCheckbox ? showNonTradeableCheckbox.checked : false, showIgnored: showIgnoredCheckbox ? showIgnoredCheckbox.checked : false, hideLowValue: hideLowValueCheckbox ? hideLowValueCheckbox.checked : true, minValue: minValueInput ? parseFloat(minValueInput.value.replace(/[,$]/g, '')) || 5000000 : 5000000 }; } function applyFilterState(state) { const showNonTradeableCheckbox = document.getElementById('show-non-tradeable'); const showIgnoredCheckbox = document.getElementById('show-ignored'); const hideLowValueCheckbox = document.getElementById('hide-low-value'); const minValueInput = document.getElementById('min-value-input'); if (showNonTradeableCheckbox) showNonTradeableCheckbox.checked = state.showNonTradeable; if (showIgnoredCheckbox) showIgnoredCheckbox.checked = state.showIgnored; if (hideLowValueCheckbox) hideLowValueCheckbox.checked = state.hideLowValue; if (minValueInput) minValueInput.value = state.minValue.toLocaleString(); } function waitForElement(selector, timeout = 10000) { return new Promise((resolve, reject) => { const element = document.querySelector(selector); if (element) { resolve(element); return; } const observer = new MutationObserver(() => { const element = document.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); reject(new Error(`Element ${selector} not found within ${timeout}ms`)); }, timeout); }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Window state management function loadWindowState() { try { const stateData = localStorage.getItem(WINDOW_STATE_STORAGE_KEY); if (stateData) { return JSON.parse(stateData); } } catch (error) { log(`Failed to load window state: ${error.message}`); } return { x: 20, y: 20, width: WINDOW_DEFAULT_WIDTH, height: WINDOW_DEFAULT_HEIGHT, minimized: false }; } function saveWindowState(x, y, width, height, minimized) { try { const state = { x, y, width, height, minimized }; localStorage.setItem(WINDOW_STATE_STORAGE_KEY, JSON.stringify(state)); } catch (error) { log(`Failed to save window state: ${error.message}`); } } // API Key management function loadApiKey() { try { const keyData = localStorage.getItem(API_KEY_STORAGE_KEY); const keyExpiry = localStorage.getItem(API_KEY_EXPIRY_STORAGE_KEY); if (keyData && keyExpiry) { const expiryDate = new Date(parseInt(keyExpiry)); if (new Date() < expiryDate) { apiKey = keyData; log('API key loaded from storage'); return true; } } } catch (error) { log(`Failed to load API key: ${error.message}`); } return false; } function saveApiKey(key) { try { const expiryDate = new Date(Date.now() + API_KEY_EXPIRY_DURATION); localStorage.setItem(API_KEY_STORAGE_KEY, key); localStorage.setItem(API_KEY_EXPIRY_STORAGE_KEY, expiryDate.getTime().toString()); apiKey = key; log('API key saved to storage'); } catch (error) { log(`Failed to save API key: ${error.message}`); } } // API Items data management function loadApiItemsData() { try { const itemsData = localStorage.getItem(API_DATA_STORAGE_KEY); const itemsExpiry = localStorage.getItem(API_EXPIRY_STORAGE_KEY); if (itemsData && itemsExpiry) { const expiryDate = new Date(parseInt(itemsExpiry)); if (new Date() < expiryDate) { apiItemsData = JSON.parse(itemsData); log(`Loaded ${Object.keys(apiItemsData).length} items from API cache`); return true; } } } catch (error) { log(`Failed to load API items data: ${error.message}`); } return false; } function saveApiItemsData(data) { try { const expiryDate = new Date(Date.now() + API_CACHE_DURATION); localStorage.setItem(API_DATA_STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(API_EXPIRY_STORAGE_KEY, expiryDate.getTime().toString()); apiItemsData = data; log(`Saved ${Object.keys(data).length} items to API cache`); } catch (error) { log(`Failed to save API items data: ${error.message}`); } } // Fetch items from API async function fetchItemsFromAPI(key) { try { const response = await fetch(`https://api.torn.com/v2/torn/items?cat=All&sort=ASC&key=${key}`); if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`); } const data = await response.json(); if (data.error) { throw new Error(`API error: ${data.error.error}`); } // Convert array to object with ID as key for easier lookup const itemsObject = {}; if (data.items && Array.isArray(data.items)) { data.items.forEach(item => { itemsObject[item.id] = item; }); } saveApiItemsData(itemsObject); return itemsObject; } catch (error) { throw new Error(`Failed to fetch items from API: ${error.message}`); } } // Check if item is tradeable function isItemTradeable(itemId) { if (!apiItemsData || !itemId) return null; // null means unknown const item = apiItemsData[itemId]; if (!item) return null; return item.is_tradable === true; } // Market data management function loadMarketData() { try { const marketDataStr = localStorage.getItem(MARKET_DATA_STORAGE_KEY); const marketExpiry = localStorage.getItem(MARKET_EXPIRY_STORAGE_KEY); if (marketDataStr && marketExpiry) { const expiryDate = new Date(parseInt(marketExpiry)); if (new Date() < expiryDate) { const savedData = JSON.parse(marketDataStr); marketData = new Map(savedData); log(`Loaded market data for ${marketData.size} items from cache`); return true; } } } catch (error) { log(`Failed to load market data: ${error.message}`); } return false; } function saveMarketData() { try { const expiryDate = new Date(Date.now() + MARKET_CACHE_DURATION); const dataArray = Array.from(marketData.entries()); localStorage.setItem(MARKET_DATA_STORAGE_KEY, JSON.stringify(dataArray)); localStorage.setItem(MARKET_EXPIRY_STORAGE_KEY, expiryDate.getTime().toString()); log(`Saved market data for ${marketData.size} items to cache`); } catch (error) { log(`Failed to save market data: ${error.message}`); } } // Calculate estimated value based on lowest market price after outlier removal function calculateEstimatedValue(itemId, quantity) { const marketInfo = marketData.get(itemId); if (!marketInfo || !marketInfo.listings || marketInfo.listings.length === 0) { return 0; } // Calculate price stats to get consistent pricing const stats = calculatePriceStats(marketInfo.listings); if (!stats) { return 0; } // Use the same low-end price calculation as the Price column const lowPrice = Math.min(stats.p25, stats.min * 1.2); // Calculate estimated value: low-end price * quantity return Math.round(lowPrice * quantity); } // Global sorting state let currentSortColumn = -1; let currentSortDirection = 'asc'; let currentSortField = null; // Track which field we're sorting by // Apply current sort to items array function applySortToItems(items) { if (currentSortColumn === -1 || !currentSortField) { // No active sort, use default sorting return items.sort((a, b) => { const aIgnored = isItemIgnored(a.id); const bIgnored = isItemIgnored(b.id); // If showing ignored items, put ignored items at the top for easy un-ignoring const showIgnoredCheckbox = document.getElementById('show-ignored'); const showIgnored = showIgnoredCheckbox ? showIgnoredCheckbox.checked : false; if (showIgnored) { if (aIgnored && !bIgnored) return -1; if (bIgnored && !aIgnored) return 1; } else { // If not showing ignored items, put ignored items last (they'll be filtered out anyway) if (aIgnored && !bIgnored) return 1; if (bIgnored && !aIgnored) return -1; } // If showing non-tradeable items, put them after ignored items const showNonTradeableCheckbox = document.getElementById('show-non-tradeable'); const showNonTradeable = showNonTradeableCheckbox ? showNonTradeableCheckbox.checked : false; if (showNonTradeable && !aIgnored && !bIgnored) { if (a.tradeable === false && b.tradeable !== false) return -1; if (b.tradeable === false && a.tradeable !== false) return 1; } const categoryA = a.category || ''; const categoryB = b.category || ''; const nameA = a.name || ''; const nameB = b.name || ''; if (categoryA !== categoryB) { return categoryA.localeCompare(categoryB); } return nameA.localeCompare(nameB); }); } // Apply active sort return items.sort((a, b) => { let aValue, bValue; switch (currentSortField) { case 'name': aValue = a.name || ''; bValue = b.name || ''; break; case 'category': aValue = a.category || ''; bValue = b.category || ''; break; case 'qty': aValue = a.quantity || 0; bValue = b.quantity || 0; break; case 'trade': aValue = a.tradeable === true ? 1 : 0; bValue = b.tradeable === true ? 1 : 0; break; case 'price': // Get market price for comparison const aMarketInfo = marketData.get(a.id.toString()); const bMarketInfo = marketData.get(b.id.toString()); if (aMarketInfo && a.tradeable === true) { const aStats = calculatePriceStats(aMarketInfo.listings); aValue = aStats ? Math.min(aStats.p25, aStats.min * 1.2) : 0; } else { aValue = 0; } if (bMarketInfo && b.tradeable === true) { const bStats = calculatePriceStats(bMarketInfo.listings); bValue = bStats ? Math.min(bStats.p25, bStats.min * 1.2) : 0; } else { bValue = 0; } break; case 'estimated': aValue = calculateEstimatedValue(a.id, a.quantity); bValue = calculateEstimatedValue(b.id, b.quantity); break; default: return 0; } // Apply sort direction if (currentSortDirection === 'asc') { if (typeof aValue === 'string') { return aValue.localeCompare(bValue); } return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; } else { if (typeof aValue === 'string') { return bValue.localeCompare(aValue); } return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; } }); } // Dynamic sort function that adjusts for Trade column visibility function sortTableDynamic(columnName) { const tradeHeader = document.getElementById('trade-column-header'); if (!tradeHeader) { return; } const tradeColumnVisible = tradeHeader.style.display !== 'none'; let columnIndex; // Map column names to indices, adjusting for Trade column visibility switch (columnName) { case 'name': columnIndex = 0; break; case 'category': columnIndex = 1; break; case 'qty': columnIndex = 2; break; case 'trade': columnIndex = tradeColumnVisible ? 3 : -1; break; case 'price': columnIndex = tradeColumnVisible ? 4 : 3; break; case 'estimated': columnIndex = tradeColumnVisible ? 5 : 4; break; default: return; } if (columnIndex === -1) return; // Trade column not visible // Update sort direction if (currentSortColumn === columnIndex && currentSortField === columnName) { currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; } else { currentSortDirection = 'asc'; currentSortColumn = columnIndex; currentSortField = columnName; } // Clear all sort indicators ['name', 'category', 'qty', 'trade', 'price', 'estimated'].forEach(col => { const indicator = document.getElementById(`sort-${col}`); if (indicator) indicator.textContent = '↕'; }); // Set current sort indicator const currentIndicator = document.getElementById(`sort-${columnName}`); if (currentIndicator) { currentIndicator.textContent = currentSortDirection === 'asc' ? '↑' : '↓'; } // Re-render table with new sort updateTableDisplay(); } // Make sorting functions global immediately after definition window.sortTableDynamic = sortTableDynamic; // Sort table function function sortTable(columnIndex) { const table = document.getElementById('all-items-table'); if (!table) { return; } const tbody = table.querySelector('tbody'); if (!tbody) { return; } const rows = Array.from(tbody.querySelectorAll('tr')); if (rows.length === 0) { return; } const tradeColumnVisible = document.getElementById('trade-column-header').style.display !== 'none'; // Update sort direction if (currentSortColumn === columnIndex) { currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; } else { currentSortDirection = 'asc'; currentSortColumn = columnIndex; } // Clear all sort indicators ['name', 'category', 'qty', 'trade', 'price', 'estimated'].forEach(col => { const indicator = document.getElementById(`sort-${col}`); if (indicator) indicator.textContent = '↕'; }); // Set current sort indicator const columnNames = ['name', 'category', 'qty', 'trade', 'price', 'estimated']; if (columnNames[columnIndex]) { const currentIndicator = document.getElementById(`sort-${columnNames[columnIndex]}`); if (currentIndicator) { currentIndicator.textContent = currentSortDirection === 'asc' ? '↑' : '↓'; } } // Sort rows rows.sort((a, b) => { let aValue = a.cells[columnIndex].textContent.trim(); let bValue = b.cells[columnIndex].textContent.trim(); // Handle different data types based on fixed column positions let qtyColumnIndex = 2; let tradeColumnIndex = 3; let priceColumnIndex = 4; let estimatedColumnIndex = 5; // Handle different data types if (columnIndex === qtyColumnIndex || columnIndex === priceColumnIndex || columnIndex === estimatedColumnIndex) { // Remove currency symbols and commas, convert to numbers aValue = parseFloat(aValue.replace(/[$,]/g, '')) || 0; bValue = parseFloat(bValue.replace(/[$,]/g, '')) || 0; } else if (columnIndex === tradeColumnIndex) { // Convert ✓/✗ to sortable values aValue = aValue === '✓' ? 1 : 0; bValue = bValue === '✓' ? 1 : 0; } if (currentSortDirection === 'asc') { return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; } else { return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; } }); // Re-append sorted rows rows.forEach(row => tbody.appendChild(row)); } // Make sorting functions global window.sortTable = sortTable; window.sortTableDynamic = sortTableDynamic; window.toggleIgnoreItem = toggleIgnoreItem; window.resetApiState = resetApiState; // Check if all items are likely loaded by examining scroll position and item count function checkItemLoadingStatus() { try { // Check if we're at the bottom of the page const scrollBottom = window.innerHeight + window.scrollY; const pageHeight = Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight ); const isNearBottom = scrollBottom >= (pageHeight - 1000); // Use the SAME logic as scanVisibleItems to count items accurately let currentVisibleItems = 0; // Detect page type (same as scanVisibleItems) const isMarketPage = window.location.href.includes('ItemMarket') || window.location.href.includes('itemmarket') || window.location.href.includes('addListing'); // Declare containers at function scope for later use let allItemsContainer = null; if (isMarketPage) { // Market page: try broader selectors to detect any list items const marketSelectors = [ '.items-list li, .items-list .item', // Market inventory items '.item-list li, .item-list .item', // Alternative market inventory '.inventory-items li, .inventory-items .item', // Market inventory container '.market-items li, .market-items .item', // Market-specific items '.listing-items li, .listing-items .item', // Market listing items 'ul li[data-item]', // Any list items with data-item 'li[data-item]', // Fallback: any items '.item, .inventory-item', // CSS class based items '[class*="item"]', // Any element with 'item' in class 'li[class*="item"], div[class*="item"]' // List or div items ]; for (const selector of marketSelectors) { const itemElements = document.querySelectorAll(selector); if (itemElements.length > 0) { currentVisibleItems = itemElements.length; break; } } // Special case for market page: if we can't find specific items, // but we have scanned items, assume the page is loaded if (currentVisibleItems === 0 && scannedItems.size > 0) { currentVisibleItems = scannedItems.size; } } else { // Inventory page: check for all-items container first (same as scanVisibleItems) allItemsContainer = document.querySelector('#all-items'); if (allItemsContainer && allItemsContainer.style.display !== 'none') { const itemElements = allItemsContainer.querySelectorAll('li[data-item][data-category]:not([data-action])'); currentVisibleItems = itemElements.length; } else { // If not in all-items, look for any visible inventory container with items const inventoryContainers = document.querySelectorAll('.inventory-wrap, .category-wrap, [id*="items"]'); for (const container of inventoryContainers) { if (container.style.display !== 'none' && !container.hidden) { const containerItems = container.querySelectorAll('li[data-item][data-category]:not([data-action])'); if (containerItems.length > 0) { currentVisibleItems = containerItems.length; break; } } } // Fallback: scan any visible items on the page if (currentVisibleItems === 0) { const itemElements = document.querySelectorAll('li[data-item][data-category]:not([data-action])'); currentVisibleItems = itemElements.length; } // Additional fallback: try broader selectors for inventory if (currentVisibleItems === 0) { const broaderSelectors = [ 'li[data-item]', // Any list item with data-item '.inventory-item', // CSS class based '[data-category]', // Any element with data-category '.item-wrap li' // Item wrapper lists ]; for (const selector of broaderSelectors) { const elements = document.querySelectorAll(selector); if (elements.length > 0) { currentVisibleItems = elements.length; break; } } } } } // Get network loading state const loadingState = getItemLoadingState(); // Get the items container to check if there's a loading indicator (be more specific) const loadingIndicators = [ '.loading:not(.hidden)', '.spinner:not(.hidden)', '.ajax-loader:not(.hidden)', '.loading-spinner:not(.hidden)', '[class*="loading"]:not(.hidden):not([style*="display: none"])', '[class*="spinner"]:not(.hidden):not([style*="display: none"])' ]; let hasLoadingIndicator = false; let foundIndicators = []; // Check only within the items container first for more accuracy // For market page, look for loading indicators in market-specific containers let containerToCheck = null; if (isMarketPage) { // Look for market-specific containers that might have loading indicators containerToCheck = document.querySelector('.items-list, .item-list, .inventory-items, .market-items, .listing-items') || document.querySelector('main, .content, .market-content'); } else { containerToCheck = allItemsContainer || document.querySelector('.inventory-wrap, .category-wrap'); } // If we have a specific container, check for loading indicators within it if (containerToCheck) { for (const indicator of loadingIndicators) { const elements = containerToCheck.querySelectorAll(indicator); if (elements.length > 0) { // Check if any of these elements are actually visible for (const el of elements) { const rect = el.getBoundingClientRect(); const isVisible = rect.width > 0 && rect.height > 0 && window.getComputedStyle(el).display !== 'none' && window.getComputedStyle(el).visibility !== 'hidden'; if (isVisible) { hasLoadingIndicator = true; foundIndicators.push(indicator); break; } } if (hasLoadingIndicator) break; } } } // If no container-specific indicators found, do a global check as fallback if (!hasLoadingIndicator) { for (const indicator of loadingIndicators) { const elements = document.querySelectorAll(indicator); if (elements.length > 0) { // Check if any of these elements are actually visible for (const el of elements) { const rect = el.getBoundingClientRect(); const isVisible = rect.width > 0 && rect.height > 0 && window.getComputedStyle(el).display !== 'none' && window.getComputedStyle(el).visibility !== 'hidden'; if (isVisible) { hasLoadingIndicator = true; foundIndicators.push(indicator); break; } } if (hasLoadingIndicator) break; } } } // Override loading detection if it's been too long since last network activity const timeSinceLastActivity = Date.now() - Math.max(itemLoadingState.lastLoadTime, 0); const loadingTimeout = 10000; // 10 seconds timeout const isLoadingTimedOut = timeSinceLastActivity > loadingTimeout && itemLoadingState.totalRequests > 0; // Combine visual loading indicators with network activity, but allow timeout override const isCurrentlyLoading = !isLoadingTimedOut && (hasLoadingIndicator || loadingState.isLoading); // More intelligent "all loaded" detection using network monitoring: // 1. Near bottom of page // 2. No loading indicators (visual or network) OR loading has timed out // 3. At least some items found (more lenient for market page) // 4. If we have scanned items, the visible count should be close to or greater than scanned count // 5. If network requests have been made, they should be completed for at least 2 seconds // Market page is more lenient - we mainly care about network activity completion const minimumItemsRequired = isMarketPage ? 0 : 10; let likelyAllLoaded = isNearBottom && (!isCurrentlyLoading || isLoadingTimedOut) && currentVisibleItems >= minimumItemsRequired; // Market page special case: if we have scanned items and are near bottom, assume loaded if (isMarketPage && isNearBottom && scannedItems.size > 0 && !isCurrentlyLoading) { likelyAllLoaded = true; } // Additional check: if we have scanned items, visible items should be at least as many // (unless user filtered/changed view) - skip this check for market page if (likelyAllLoaded && scannedItems.size > 0 && !isMarketPage) { // If visible items are significantly less than scanned items, we might be in wrong view or not fully loaded if (currentVisibleItems < scannedItems.size * 0.8) { likelyAllLoaded = false; } } // Network-based check: if requests were made but not settled for long enough, don't mark as complete // UNLESS we've timed out the loading detection if (likelyAllLoaded && !isLoadingTimedOut && loadingState.totalRequests > 0 && !loadingState.likelyFinishedLoading) { likelyAllLoaded = false; } // Special case: if visible items == scanned items and we're near bottom, likely all loaded // even if there are some loading indicators (they might be false positives) if (!likelyAllLoaded && isNearBottom && currentVisibleItems >= minimumItemsRequired && scannedItems.size > 0 && (isMarketPage || currentVisibleItems >= scannedItems.size * 0.95)) { likelyAllLoaded = true; } return { isNearBottom, currentVisibleItems, hasLoadingIndicator, isCurrentlyLoading, isLoadingTimedOut, likelyAllLoaded, pageHeight, scrollPosition: scrollBottom, scannedItemsCount: scannedItems.size, loadingState, foundIndicators }; } catch (error) { log('Error checking item loading status: ' + error.message); return { isNearBottom: false, currentVisibleItems: 0, hasLoadingIndicator: false, isCurrentlyLoading: false, isLoadingTimedOut: false, likelyAllLoaded: false, pageHeight: 0, scrollPosition: 0, scannedItemsCount: scannedItems.size, loadingState: getItemLoadingState(), foundIndicators: [] }; } } // Expose loading state for debugging window.getScrap2MarkLoadingState = function() { return { itemLoadingState, currentStatus: checkItemLoadingStatus(), scannedItemsCount: scannedItems.size }; }; // Manual override for stuck loading detection window.forceScrap2MarkReady = function() { log('Manual override: Forcing loading state to complete'); itemLoadingState.isLoading = false; itemLoadingState.loadingRequests.clear(); itemLoadingState.lastLoadTime = Date.now() - 15000; // Set to 15 seconds ago updateScrollReminder(); return 'Loading state reset - status should update shortly'; }; // XHR/Fetch monitoring functions function setupNetworkMonitoring() { // Monitor XMLHttpRequest const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...args) { this._scrap2mark_url = url; this._scrap2mark_method = method; return originalXHROpen.apply(this, [method, url, ...args]); }; XMLHttpRequest.prototype.send = function(...args) { const url = this._scrap2mark_url; // Check if this looks like an inventory/item loading request if (url && isItemLoadingRequest(url)) { const requestId = Date.now() + Math.random(); itemLoadingState.loadingRequests.add(requestId); itemLoadingState.isLoading = true; itemLoadingState.totalRequests++; const handleResponse = () => { itemLoadingState.loadingRequests.delete(requestId); itemLoadingState.completedRequests++; itemLoadingState.lastLoadTime = Date.now(); if (itemLoadingState.loadingRequests.size === 0) { itemLoadingState.isLoading = false; // Update status after a short delay to allow DOM updates setTimeout(updateScrollReminder, 500); } }; this.addEventListener('load', handleResponse); this.addEventListener('error', handleResponse); this.addEventListener('abort', handleResponse); } return originalXHRSend.apply(this, args); }; // Monitor fetch API const originalFetch = window.fetch; window.fetch = function(url, options = {}) { // Check if this looks like an inventory/item loading request if (url && isItemLoadingRequest(url.toString())) { const requestId = Date.now() + Math.random(); itemLoadingState.loadingRequests.add(requestId); itemLoadingState.isLoading = true; itemLoadingState.totalRequests++; const promise = originalFetch.apply(this, [url, options]); promise.finally(() => { itemLoadingState.loadingRequests.delete(requestId); itemLoadingState.completedRequests++; itemLoadingState.lastLoadTime = Date.now(); if (itemLoadingState.loadingRequests.size === 0) { itemLoadingState.isLoading = false; // Update status after a short delay to allow DOM updates setTimeout(updateScrollReminder, 500); } }); return promise; } return originalFetch.apply(this, [url, options]); }; } // Check if a URL looks like an inventory/item loading request function isItemLoadingRequest(url) { if (!url || typeof url !== 'string') return false; // Convert to lowercase for case-insensitive matching const lowerUrl = url.toLowerCase(); // Common patterns for inventory/item loading requests in Torn const itemLoadingPatterns = [ '/item.php', // Direct item page requests '/loader.php', // Generic loader that might load items 'action=load', // Action parameter for loading 'action=get', // Action parameter for getting data 'action=getdata', // Torn's getdata action 'step=get', // Step parameter 'step=load', // Step load parameter 'rfcv=', // Torn's request verification token (likely AJAX) 'torn_user=', // Torn user parameter in AJAX 'sid=inventory', // Inventory section ID 'sid=itemmarket', // Market section ID 'inventory', // Contains inventory in URL 'items', // Contains items in URL 'loadmore', // Load more functionality 'pagination', // Pagination requests 'offset=', // Offset parameter suggests pagination 'page=', // Page parameter 'limit=', // Limit parameter 'category=', // Category loading '/ajax/', // AJAX requests that might load items 'ajaxhelpers', // Torn's AJAX helper functions 'helpers.php', // Torn's helper functions 'itemmarket', // Market-specific requests 'addlisting', // Add listing functionality ]; // Check if URL matches any item loading patterns const matches = itemLoadingPatterns.some(pattern => lowerUrl.includes(pattern)); // Exclude our own API requests and external resources const isOurApiRequest = lowerUrl.includes('api.torn.com') && ( lowerUrl.includes('itemmarket') || lowerUrl.includes('/torn/items') ); const isExternalResource = lowerUrl.includes('cloudflare') || lowerUrl.includes('google') || lowerUrl.includes('facebook') || lowerUrl.includes('.css') || lowerUrl.includes('.js') || lowerUrl.includes('.png') || lowerUrl.includes('.jpg') || lowerUrl.includes('.gif') || lowerUrl.includes('.webp'); return matches && !isOurApiRequest && !isExternalResource; } // Get current loading state for display function getItemLoadingState() { const now = Date.now(); const timeSinceLastLoad = now - itemLoadingState.lastLoadTime; return { isLoading: itemLoadingState.isLoading, activeRequests: itemLoadingState.loadingRequests.size, totalRequests: itemLoadingState.totalRequests, completedRequests: itemLoadingState.completedRequests, timeSinceLastLoad, likelyFinishedLoading: !itemLoadingState.isLoading && timeSinceLastLoad > 2000 && itemLoadingState.totalRequests > 0 }; } // Update scroll reminder based on loading status function updateScrollReminder() { const reminder = document.getElementById('scroll-reminder'); const progressSpan = document.getElementById('loading-progress'); if (!reminder) return; // Detect page type for contextual messaging const isMarketPage = window.location.href.includes('ItemMarket') || window.location.href.includes('itemmarket') || window.location.href.includes('addListing'); const status = checkItemLoadingStatus(); // Update progress indicator if (progressSpan) { if (status.likelyAllLoaded) { progressSpan.innerHTML = `<span style="color: #28a745; font-weight: bold;">✅ Complete (${status.currentVisibleItems} items visible, ${status.scannedItemsCount} scanned)</span>`; } else if (status.isLoadingTimedOut) { progressSpan.innerHTML = `<span style="color: #ffc107; font-weight: bold;">⏰ Loading timed out (${status.currentVisibleItems} visible)</span>`; } else if (status.isCurrentlyLoading) { const loadingText = status.loadingState.isLoading ? `🔄 Loading via network (${status.loadingState.activeRequests} active)` : `🔄 Loading... (${status.currentVisibleItems} visible)`; progressSpan.innerHTML = `<span style="color: #ffc107; font-weight: bold;">${loadingText}</span>`; } else if (status.isNearBottom) { progressSpan.innerHTML = `<span style="color: #17a2b8; font-weight: bold;">📍 Near bottom (${status.currentVisibleItems} visible, ${status.scannedItemsCount} scanned)</span>`; } else { progressSpan.innerHTML = `<span style="color: #dc3545; font-weight: bold;">📜 Keep scrolling (${status.currentVisibleItems} visible)</span>`; } } if (status.likelyAllLoaded) { reminder.style.cssText = ` margin-bottom: 10px; font-size: 11px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; padding: 8px; `; const nextStepText = isMarketPage ? 'You can now add items to market listings' : 'Use "Get Market Prices" then "Fill Market Forms"'; reminder.innerHTML = ` <div style="display: flex; align-items: center; gap: 8px;"> <span style="color: #155724; font-weight: bold;">✅ READY:</span> <span style="color: #155724;">All items likely loaded - You can safely proceed!</span> </div> <div style="margin-top: 4px; font-size: 10px; color: #6c6c6c;"> <strong>Status:</strong> <span style="color: #28a745; font-weight: bold;">✅ Complete (${status.currentVisibleItems} visible, ${status.scannedItemsCount} scanned)</span> | <strong>Next:</strong> ${nextStepText} </div> `; } else { reminder.style.cssText = ` margin-bottom: 10px; font-size: 11px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 4px; padding: 8px; `; let statusText, progressColor, actionAdvice; if (status.isCurrentlyLoading) { if (status.loadingState.isLoading) { statusText = `Network loading in progress (${status.loadingState.activeRequests} active requests)`; actionAdvice = "Wait for loading to complete"; } else if (status.hasLoadingIndicator) { statusText = "Visual loading indicators detected"; actionAdvice = "Wait for loading indicators to disappear"; } else { statusText = "Items are still loading - please wait"; actionAdvice = "Wait for loading indicators to disappear"; } progressColor = "#ffc107"; } else if (status.isLoadingTimedOut) { statusText = "Loading detection timed out - assuming complete"; progressColor = "#28a745"; actionAdvice = "Try scanning items now or refresh if needed"; } else if (!status.isNearBottom) { statusText = "Keep scrolling to load more items"; progressColor = "#dc3545"; actionAdvice = isMarketPage ? "Scroll to see all available items" : "Scroll down to the bottom of the page"; } else { statusText = "Loading detection may be incomplete"; progressColor = "#6c757d"; actionAdvice = "Try refreshing the page or manual scan"; } // Add a manual override button if loading seems stuck const manualOverrideButton = (status.isCurrentlyLoading && !status.isLoadingTimedOut) ? `<button onclick="window.forceScrap2MarkReady && window.forceScrap2MarkReady()" style="background: #dc3545; color: white; border: none; padding: 2px 6px; border-radius: 3px; cursor: pointer; font-size: 10px; margin-left: 8px;">Force Ready</button>` : ''; reminder.innerHTML = ` <div style="display: flex; align-items: center; gap: 8px;"> <span style="color: #856404; font-weight: bold;">${status.isLoadingTimedOut ? '⏰ TIMEOUT:' : '⚠️ SCROLL NEEDED:'}</span> <span style="color: #856404;">${statusText}</span> ${manualOverrideButton} </div> <div style="margin-top: 4px; font-size: 10px; color: #6c6c6c;"> <strong>Status:</strong> <span style="color: ${progressColor}; font-weight: bold;">${status.currentVisibleItems} visible${status.scannedItemsCount > 0 ? `, ${status.scannedItemsCount} scanned` : ''}</span> | <strong>Action:</strong> ${actionAdvice} </div> `; } } // Check if market data is fresh (less than 1 hour old) function isMarketDataFresh(marketInfo) { if (!marketInfo || !marketInfo.timestamp) return false; const oneHour = 60 * 60 * 1000; // 1 hour in milliseconds return (Date.now() - marketInfo.timestamp) < oneHour; } // Remove price outliers using statistical methods (only high outliers and extreme prices) function filterOutliers(listings) { if (!listings || listings.length < 3) return listings; // Need at least 3 listings for meaningful filtering // Get all prices and sort them const prices = listings.map(l => l.price).sort((a, b) => a - b); const minPrice = prices[0]; const maxPrice = prices[prices.length - 1]; // Rule 1: Remove prices more than 10x the minimum price const priceCapFilter = listings.filter(listing => listing.price <= minPrice * 10); // Rule 2: Statistical outlier detection for high outliers only if (priceCapFilter.length < 3) return priceCapFilter; // Not enough data for statistical analysis const uniquePrices = [...new Set(priceCapFilter.map(l => l.price))]; if (uniquePrices.length < 3) return priceCapFilter; // Need at least 3 unique prices const mean = uniquePrices.reduce((a, b) => a + b, 0) / uniquePrices.length; const variance = uniquePrices.reduce((sum, price) => sum + Math.pow(price - mean, 2), 0) / uniquePrices.length; const stdDev = Math.sqrt(variance); if (stdDev === 0) return priceCapFilter; // All prices are the same // Only filter high outliers (prices significantly above the mean) const finalFiltered = priceCapFilter.filter(listing => { const zScore = (listing.price - mean) / stdDev; return zScore <= OUTLIER_THRESHOLD; // Only remove high outliers }); // More conservative approach: only apply statistical filtering if we don't remove too many sellers const uniqueOriginalPrices = [...new Set(priceCapFilter.map(l => l.price))]; const uniqueFinalPrices = [...new Set(finalFiltered.map(l => l.price))]; const sellerRetainRatio = uniqueFinalPrices.length / uniqueOriginalPrices.length; // Keep statistical filtering only if we retain at least 70% of sellers if (sellerRetainRatio >= 0.7) { return finalFiltered; } else { return priceCapFilter; // Fall back to just the 10x price cap filter } } // API rate limiting and error handling function shouldThrottleApiRequest() { const now = Date.now(); // Reset counter every minute if (now - apiRequestResetTime > 60000) { apiRequestCount = 0; apiRequestResetTime = now; } // Check if we've exceeded rate limit if (apiRequestCount >= 95) { // Leave some buffer below 100 return true; } // Check minimum time between requests (600ms = ~100 requests per minute) if (now - lastApiRequest < 600) { return true; } return false; } async function fetchMarketData(itemId) { if (!apiKey) { throw new Error('API key not available'); } try { // Add timeout for throttling wait but make it more generous let throttleAttempts = 0; const maxThrottleAttempts = 180; // Max 3 minutes of waiting (increased from 1 minute) // Wait for throttling if needed while (shouldThrottleApiRequest() && throttleAttempts < maxThrottleAttempts) { await sleep(1000); throttleAttempts++; } if (throttleAttempts >= maxThrottleAttempts) { throw new Error('Throttling timeout - API requests are being rate limited too heavily'); } lastApiRequest = Date.now(); apiRequestCount++; // Add timeout to the fetch request const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout try { const response = await fetch(`https://api.torn.com/v2/market/${itemId}/itemmarket?offset=0&key=${apiKey}`, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.error) { const errorCode = data.error.code; const errorMessage = data.error.error; // Handle specific API errors if (errorCode === 5) { // Too many requests throw new Error('Rate limit exceeded. Please wait before making more requests.'); } else if (errorCode === 8) { // IP block throw new Error('IP temporarily blocked due to abuse. Please wait.'); } else if (errorCode === 14) { // Daily limit throw new Error('Daily API read limit reached.'); } else if (errorCode === 17) { // Backend error throw new Error('Torn backend error. Please try again later.'); } throw new Error(`API Error ${errorCode}: ${errorMessage}`); } if (data.itemmarket && data.itemmarket.listings) { const marketInfo = { item: data.itemmarket.item, listings: data.itemmarket.listings, timestamp: Date.now() }; marketData.set(itemId.toString(), marketInfo); saveMarketData(); return marketInfo; } return null; } catch (fetchError) { clearTimeout(timeoutId); if (fetchError.name === 'AbortError') { throw new Error('Request timeout - API request took too long'); } throw fetchError; } } catch (error) { // If it's a rate limiting error, we should back off if (error.message.includes('Rate limit') || error.message.includes('Too many requests')) { // Exponential backoff await sleep(Math.min(30000, 1000 * Math.pow(2, apiRequestCount % 5))); } throw error; } } // Process API request queue async function processApiQueue() { if (isProcessingQueue || apiRequestQueue.length === 0) { return; } isProcessingQueue = true; while (apiRequestQueue.length > 0) { const { itemId, resolve, reject } = apiRequestQueue.shift(); try { const result = await fetchMarketData(itemId); resolve(result); } catch (error) { reject(error); } // Small delay between requests await sleep(100); } isProcessingQueue = false; } // Queue API request function queueMarketDataRequest(itemId, forceFresh = false) { return new Promise((resolve, reject) => { // Check if we already have cached data that's fresh (unless forcing fresh data) if (!forceFresh) { const cached = marketData.get(itemId.toString()); if (cached && isMarketDataFresh(cached)) { resolve(cached); return; } } // Add to queue apiRequestQueue.push({ itemId, resolve, reject }); // Start processing if not already running processApiQueue(); }); } // Calculate price statistics function calculatePriceStats(listings, filterOutliersEnabled = true) { if (!listings || listings.length === 0) { return null; } // Filter outliers if enabled const processedListings = filterOutliersEnabled ? filterOutliers(listings) : listings; if (processedListings.length === 0) { return null; } const prices = processedListings.map(l => l.price).sort((a, b) => a - b); const quantities = processedListings.reduce((sum, l) => sum + l.amount, 0); // Calculate weighted average const totalValue = processedListings.reduce((sum, l) => sum + (l.price * l.amount), 0); const weightedAverage = totalValue / quantities; // Calculate percentiles const p25Index = Math.floor(prices.length * 0.25); const p75Index = Math.floor(prices.length * 0.75); // Calculate seller-based statistics const uniquePrices = [...new Set(processedListings.map(l => l.price))]; const originalUniquePrices = [...new Set(listings.map(l => l.price))]; // Calculate filtering breakdown const priceCapFiltered = listings.filter(l => l.price > Math.min(...listings.map(p => p.price)) * 10); const statFiltered = listings.length - processedListings.length - priceCapFiltered.length; return { min: Math.min(...prices), max: Math.max(...prices), median: prices[Math.floor(prices.length / 2)], average: weightedAverage, p25: prices[p25Index], p75: prices[p75Index], listings: processedListings.length, totalListings: listings.length, uniqueSellers: uniquePrices.length, totalUniqueSellers: originalUniquePrices.length, totalQuantity: quantities, outliersFiltered: listings.length - processedListings.length, sellersFiltered: originalUniquePrices.length - uniquePrices.length, priceCapFiltered: priceCapFiltered.length, statFiltered: Math.max(0, statFiltered) }; } // Create price distribution mini chart function createPriceChart(listings, width = 60, height = 20) { if (!listings || listings.length === 0) { return `<div style="width: ${width}px; height: ${height}px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 8px; color: #999;">No data</div>`; } const filteredListings = filterOutliers(listings); const stats = calculatePriceStats(filteredListings, false); if (!stats) return ''; // Group prices into bins for visualization const bins = Math.min(10, filteredListings.length); const binWidth = (stats.max - stats.min) / bins; const binCounts = new Array(bins).fill(0); filteredListings.forEach(listing => { const binIndex = Math.min(Math.floor((listing.price - stats.min) / binWidth), bins - 1); binCounts[binIndex] += listing.amount; }); const maxBinCount = Math.max(...binCounts); // Create SVG chart const bars = binCounts.map((count, index) => { const barHeight = maxBinCount > 0 ? (count / maxBinCount) * (height - 2) : 0; const x = (index * width) / bins; const barWidth = width / bins - 1; const y = height - barHeight; return `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" fill="#007bff" opacity="0.7"/>`; }).join(''); return ` <svg width="${width}" height="${height}" style="border: 1px solid #ddd;"> ${bars} </svg> `; } // Create high-resolution popup chart with zoom functionality function createDetailedPriceChart(listings, itemName, zoomMin = null, zoomMax = null) { if (!listings || listings.length === 0) { return '<div style="padding: 20px;">No market data available</div>'; } const filteredListings = filterOutliers(listings); const stats = calculatePriceStats(filteredListings, false); if (!stats) return '<div style="padding: 20px;">No valid price data</div>'; const width = 300; const height = 200; const padding = 40; const chartWidth = width - padding * 2; const chartHeight = height - padding * 2; // Determine price range for chart (zoom or full range) const displayMin = zoomMin || stats.min; const displayMax = zoomMax || stats.max; const displayRange = displayMax - displayMin; const isZoomed = zoomMin !== null || zoomMax !== null; // Filter listings to zoom range if zooming const displayListings = isZoomed ? filteredListings.filter(l => l.price >= displayMin && l.price <= displayMax) : filteredListings; if (displayListings.length === 0) { return '<div style="padding: 20px;">No data in selected range</div>'; } // Create bins for histogram const bins = Math.min(20, displayListings.length); const binWidth = displayRange / bins; const binData = []; for (let i = 0; i < bins; i++) { binData.push({ min: displayMin + i * binWidth, max: displayMin + (i + 1) * binWidth, count: 0, totalAmount: 0 }); } displayListings.forEach(listing => { const binIndex = Math.min(Math.floor((listing.price - displayMin) / binWidth), bins - 1); if (binIndex >= 0) { binData[binIndex].count++; binData[binIndex].totalAmount += listing.amount; } }); const maxCount = Math.max(...binData.map(b => b.totalAmount)); // Create SVG bars with selection capability const bars = binData.map((bin, index) => { const barHeight = maxCount > 0 ? (bin.totalAmount / maxCount) * chartHeight : 0; const x = padding + (index * chartWidth) / bins; const barWidth = chartWidth / bins - 2; const y = padding + chartHeight - barHeight; const midPrice = (bin.min + bin.max) / 2; return ` <rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" fill="#007bff" opacity="0.7" stroke="#0056b3" stroke-width="1" data-price-min="${bin.min}" data-price-max="${bin.max}" data-amount="${bin.totalAmount}" class="chart-bar"> </rect> `; }).join(''); // Create axes const xAxis = `<line x1="${padding}" y1="${padding + chartHeight}" x2="${padding + chartWidth}" y2="${padding + chartHeight}" stroke="#666" stroke-width="1"/>`; const yAxis = `<line x1="${padding}" y1="${padding}" x2="${padding}" y2="${padding + chartHeight}" stroke="#666" stroke-width="1"/>`; // Create price labels const priceLabels = [displayMin, (displayMin + displayMax) / 2, displayMax].map((price, index) => { const x = padding + (index * chartWidth) / 2; return `<text x="${x}" y="${padding + chartHeight + 15}" text-anchor="middle" font-size="10" fill="#666">$${Math.round(price).toLocaleString()}</text>`; }).join(''); // Create quantity labels const quantityLabels = [0, maxCount / 2, maxCount].map((qty, index) => { const y = padding + chartHeight - (index * chartHeight) / 2; return `<text x="${padding - 5}" y="${y + 3}" text-anchor="end" font-size="10" fill="#666">${Math.round(qty)}</text>`; }).join(''); const outliersText = stats.outliersFiltered > 0 ? ` (${stats.outliersFiltered} high outliers filtered)` : ''; const sellersText = stats.sellersFiltered > 0 ? ` | ${stats.sellersFiltered} sellers filtered` : ''; const filterBreakdown = stats.priceCapFiltered > 0 || stats.statFiltered > 0 ? ` (${stats.priceCapFiltered} 10x+ prices, ${stats.statFiltered} statistical outliers)` : ''; const zoomText = (zoomMin !== null || zoomMax !== null) ? ` | Zoomed: $${Math.round(displayMin).toLocaleString()}-$${Math.round(displayMax).toLocaleString()}` : ''; // Selection overlay rectangle (initially hidden) const selectionOverlay = ` <rect id="selection-rect" x="0" y="0" width="0" height="0" fill="rgba(0,123,255,0.2)" stroke="rgba(0,123,255,0.8)" stroke-width="1" style="display: none; pointer-events: none;"/> `; return ` <div style="padding: 15px; background: white; border: 1px solid #ddd; border-radius: 5px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); position: relative; min-width: 320px; max-width: 400px;"> <div> <h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">${itemName} - Market Analysis${zoomText}</h4> <svg id="price-chart-svg" width="${width}" height="${height + 20}" style="background: white; cursor: crosshair; width: 100%; max-width: ${width}px;" data-padding="${padding}" data-chart-width="${chartWidth}" data-chart-height="${chartHeight}" data-display-min="${displayMin}" data-display-max="${displayMax}"> ${bars} ${xAxis} ${yAxis} ${priceLabels} ${quantityLabels} ${selectionOverlay} <text x="${width / 2}" y="15" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">Price Distribution</text> <text x="15" y="${height / 2}" text-anchor="middle" font-size="10" fill="#666" transform="rotate(-90, 15, ${height / 2})">Quantity</text> <text x="${width / 2}" y="${height + 15}" text-anchor="middle" font-size="10" fill="#666">Price ($)</text> </svg> <div style="font-size: 11px; margin-top: 10px; color: #666;"> <div><strong>Statistics:</strong></div> <div>Average: $${Math.round(stats.average).toLocaleString()} | Median: $${stats.median.toLocaleString()}</div> <div>Range: $${stats.min.toLocaleString()} - $${stats.max.toLocaleString()}</div> <div>Q1: $${stats.p25.toLocaleString()} | Q3: $${stats.p75.toLocaleString()}</div> <div>Listings: ${stats.listings}${outliersText} | Total Quantity: ${stats.totalQuantity.toLocaleString()}</div> <div>Sellers: ${stats.uniqueSellers}/${stats.totalUniqueSellers}${sellersText}${filterBreakdown}</div> ${(zoomMin !== null || zoomMax !== null) ? '<div style="margin-top: 5px;"><button id="reset-zoom" style="background: #6c757d; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 10px;">Reset Zoom</button></div>' : ''} </div> <!-- Integrated listings section (always visible, mobile-friendly) --> <div style="margin-top: 15px; border-top: 1px solid #eee; padding-top: 10px;"> <div style="font-weight: bold; font-size: 12px; margin-bottom: 8px; color: #333;"> Market Listings ${isZoomed ? '(Filtered)' : ''} </div> <div style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; border-radius: 4px;"> ${createListingsTable(displayListings)} </div> </div> </div> </div> `; } // Create listings table for the expanded panel function createListingsTable(listings) { if (!listings || listings.length === 0) { return '<div style="padding: 10px; text-align: center; color: #666;">No listings available</div>'; } // Sort listings by price (lowest first) const sortedListings = [...listings].sort((a, b) => a.price - b.price); // Group identical prices const priceGroups = new Map(); sortedListings.forEach(listing => { const price = listing.price; if (!priceGroups.has(price)) { priceGroups.set(price, { price: price, totalQuantity: 0, listings: [] }); } const group = priceGroups.get(price); group.totalQuantity += listing.amount || 1; // Use amount, fallback to 1 if not available group.listings.push(listing); }); let tableContent = ` <table style="width: 100%; font-size: 11px; border-collapse: collapse;"> <thead> <tr style="background: #f8f9fa; border-bottom: 1px solid #dee2e6;"> <th style="padding: 4px 6px; text-align: left; border-right: 1px solid #dee2e6;">Qty</th> <th style="padding: 4px 6px; text-align: right; border-right: 1px solid #dee2e6;">Price</th> <th style="padding: 4px 6px; text-align: center;">Target</th> </tr> </thead> <tbody> `; // Convert map to array and limit to first 50 entries for performance const groupArray = Array.from(priceGroups.values()).slice(0, 50); groupArray.forEach((group, index) => { const targetPrice = Math.max(1, group.price - 1); // $1 less, minimum $1 const rowId = `price-row-${group.price}`; tableContent += ` <tr style="border-bottom: 1px solid #eee; ${index % 2 === 0 ? 'background: #f9f9f9;' : ''}"> <td style="padding: 3px 6px; border-right: 1px solid #eee;">${group.totalQuantity.toLocaleString()}</td> <td style="padding: 3px 6px; text-align: right; border-right: 1px solid #eee; font-weight: bold;">$${group.price.toLocaleString()}</td> <td style="padding: 3px 6px; text-align: center;"> <div style="display: flex; align-items: center; justify-content: center; gap: 4px;"> <input type="checkbox" id="${rowId}" data-original-price="${group.price}" data-target-price="${targetPrice}" class="price-target-checkbox" style="margin: 0;"> <span style="font-size: 10px; color: #666;">$${targetPrice.toLocaleString()}</span> </div> </td> </tr> `; }); if (priceGroups.size > 50) { tableContent += ` <tr> <td colspan="3" style="padding: 6px; text-align: center; color: #666; font-style: italic;"> ... and ${priceGroups.size - 50} more price points </td> </tr> `; } tableContent += ` </tbody> </table> <div style="padding: 8px; background: #f8f9fa; border-top: 1px solid #dee2e6; font-size: 10px; color: #666;"> <div><strong>Selected Targets:</strong></div> <div id="selected-targets" style="margin-top: 4px; min-height: 16px;">None selected</div> <div style="margin-top: 6px;"> <button id="clear-targets-btn" style="background: #dc3545; color: white; border: none; padding: 2px 6px; border-radius: 2px; cursor: pointer; font-size: 10px;">Clear All</button> </div> </div> `; return tableContent; } // Extract item data from HTML element function extractItemData(element) { const itemId = element.getAttribute('data-item'); const category = element.getAttribute('data-category') || 'Unknown'; const qty = element.getAttribute('data-qty') || '1'; const sort = element.getAttribute('data-sort') || ''; const equipped = element.getAttribute('data-equipped') === 'true'; const armoryId = element.getAttribute('data-armoryid'); // Try to get item name from various sources let itemName = sort; if (!itemName) { const nameElement = element.querySelector('.name-wrap span, .item-name, .tooltip-wrap'); if (nameElement) { itemName = nameElement.textContent.trim(); } } // Clean up item name - remove quantity prefix if present (e.g., "1 Xanax" -> "Xanax") if (itemName) { // Remove leading numbers and spaces (quantity information) itemName = itemName.replace(/^\d+\s+/, '').trim(); } // Fallback if still no name found if (!itemName) { itemName = `Item ${itemId}`; } return { id: itemId, name: itemName, category: category, quantity: parseInt(qty) || 1, equipped: equipped, armoryId: armoryId, tradeable: isItemTradeable(itemId), element: element }; } // Scan currently visible items function scanVisibleItems() { // Try to find the currently active inventory tab/container let itemElements = []; const isMarketPage = window.location.href.includes('/page.php?sid=ItemMarket'); if (isMarketPage) { // Market page: look for inventory items in the add listing interface const marketSelectors = [ '.items-list li[data-item]', // Market inventory list '.item-list li[data-item]', // Alternative market inventory '.inventory-items li[data-item]', // Market inventory container '[data-reactroot] li[data-item]', // React-based inventory '.market-inventory li[data-item]', // Market-specific inventory 'li[data-item][data-category]:not([data-action])' // Fallback to standard ]; for (const selector of marketSelectors) { const elements = document.querySelectorAll(selector); if (elements.length > 0) { itemElements = elements; break; } } } else { // Inventory page: use existing logic // Check for all-items container first const allItemsContainer = document.querySelector('#all-items'); if (allItemsContainer && allItemsContainer.style.display !== 'none') { itemElements = allItemsContainer.querySelectorAll('li[data-item][data-category]:not([data-action])'); } else { // If not in all-items, look for any visible inventory container with items const inventoryContainers = document.querySelectorAll('.inventory-wrap, .category-wrap, [id*="items"]'); for (const container of inventoryContainers) { if (container.style.display !== 'none' && !container.hidden) { const containerItems = container.querySelectorAll('li[data-item][data-category]:not([data-action])'); if (containerItems.length > 0) { itemElements = containerItems; break; } } } // Fallback: scan any visible items on the page if (itemElements.length === 0) { itemElements = document.querySelectorAll('li[data-item][data-category]:not([data-action])'); } } } if (itemElements.length === 0) { const pageType = isMarketPage ? 'market page' : 'inventory page'; log(`No items found in current view on ${pageType}`); return 0; } let newItems = 0; itemElements.forEach(element => { const itemData = extractItemData(element); if (itemData.id) { const key = `${itemData.id}_${itemData.armoryId || 'base'}`; if (!scannedItems.has(key)) { scannedItems.set(key, itemData); newItems++; } } }); const pageType = isMarketPage ? 'market page' : 'inventory page'; log(`Scanned ${newItems} new items from ${pageType} (Total: ${scannedItems.size})`); // Save scanned items to localStorage if (newItems > 0) { saveScannedItems(); } return newItems; } // Save scanned items to localStorage function saveScannedItems() { try { const itemsArray = Array.from(scannedItems.values()); localStorage.setItem(STORAGE_KEY, JSON.stringify(itemsArray)); log(`Saved ${itemsArray.length} items to localStorage`); } catch (error) { log(`Failed to save items: ${error.message}`); } } // Navigate to all-items tab async function navigateToAllItems() { const allItemsTab = document.querySelector('a[href="#all-items"], #ui-id-1'); if (allItemsTab) { log('Clicking all-items tab'); allItemsTab.click(); await sleep(1000); return true; } // Check if we're already on all-items const allItemsContainer = document.querySelector('#all-items'); if (allItemsContainer && allItemsContainer.classList.contains('ui-tabs-panel')) { log('Already on all-items tab'); return true; } log('Could not find all-items tab'); return false; } // Check for new items without auto-scrolling function checkForNewItems() { // Just scan what's currently visible without scrolling return scanVisibleItems(); } // Handle market price fetching for filtered items only async function handleFetchFilteredPrices() { const marketBtn = document.getElementById('fetch-filtered-prices'); const statusDiv = document.getElementById('scan-status'); // Safety check - make sure button exists if (!marketBtn) { log('Error: fetch-filtered-prices button not found'); return; } // Reset button state if it was stuck if (marketBtn.disabled && marketBtn.textContent !== 'Fetching...') { log('Resetting stuck button state'); marketBtn.disabled = false; marketBtn.textContent = 'Get Filtered Prices'; } // Prevent multiple simultaneous executions if (marketBtn.disabled) { log('Filtered price fetch already in progress'); return; } if (!apiKey) { if (statusDiv) { statusDiv.innerHTML = '<span style="color: #dc3545;">Please save an API key first</span>'; } return; } if (scannedItems.size === 0) { if (statusDiv) { statusDiv.innerHTML = '<span style="color: #dc3545;">No items to fetch prices for. Scan items first.</span>'; } return; } // Get only the filtered/visible items const filteredItems = getFilteredItems(); const tradeableFilteredItems = filteredItems.filter(item => item.tradeable === true); if (tradeableFilteredItems.length === 0) { if (statusDiv) { statusDiv.innerHTML = '<span style="color: #ffc107;">No tradeable items in current filter to fetch prices for</span>'; } return; } log(`Starting filtered price fetch for ${tradeableFilteredItems.length} items`); log('First few items to process:', tradeableFilteredItems.slice(0, 3).map(item => `${item.name} (${item.id})`)); // Reset any stuck queue state if (isProcessingQueue) { log('Resetting stuck queue state'); isProcessingQueue = false; apiRequestQueue.length = 0; } // Reset API counters if they seem stuck const now = Date.now(); if (now - apiRequestResetTime > 120000) { // If reset time is more than 2 minutes old log('Resetting API request counters'); apiRequestCount = 0; apiRequestResetTime = now; } marketBtn.disabled = true; marketBtn.textContent = 'Fetching...'; let processed = 0; let errors = 0; const total = tradeableFilteredItems.length; const startTime = Date.now(); const maxProcessingTime = 1800000; // 30 minutes maximum try { for (const item of tradeableFilteredItems) { // Check for timeout if (Date.now() - startTime > maxProcessingTime) { log('Filtered market price fetching timed out after 30 minutes'); if (statusDiv) { statusDiv.innerHTML = '<span style="color: #ffc107;">Filtered market price fetching timed out after 30 minutes. Processed ' + processed + '/' + total + ' items.</span>'; } break; } try { if (statusDiv) { statusDiv.innerHTML = `Fetching filtered market data: ${processed + 1}/${total} (${item.name})`; } log(`Making fresh API request for item ${processed + 1}/${total}: ${item.name} (ID: ${item.id})`); // Always force fresh API requests for filtered prices const requestPromise = queueMarketDataRequest(item.id, true); // Force fresh const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Individual request timeout')), 120000); // 2 minutes per request }); await Promise.race([requestPromise, timeoutPromise]); processed++; log(`Successfully fetched fresh data for: ${item.name}`); } catch (error) { errors++; log(`Error fetching market data for ${item.name}: ${error.message}`); // Continue processing other items even if one fails if (error.message.includes('rate limit') || error.message.includes('timeout')) { // For rate limit errors, wait longer before continuing await sleep(5000); // 5 second pause for rate limits } else { await sleep(1000); // 1 second pause for other errors } } } if (statusDiv) { const successRate = Math.round((processed / total) * 100); statusDiv.innerHTML = `<span style="color: #28a745;">Filtered market data fetch complete: ${processed}/${total} items (${successRate}% success)</span>`; } // Update table to show the new market data updateTableDisplay(); } catch (error) { log(`Filtered market data fetch failed: ${error.message}`); if (statusDiv) { statusDiv.innerHTML = `<span style="color: #dc3545;">Filtered market data fetch failed: ${error.message}</span>`; } } finally { // Ensure button is always re-enabled if (marketBtn) { marketBtn.disabled = false; marketBtn.textContent = 'Get Filtered Prices'; } // Reset queue state to prevent hanging isProcessingQueue = false; // Clear any pending status after 5 seconds setTimeout(() => { if (statusDiv && statusDiv.innerHTML.includes('Filtered market data fetch')) { updateTableDisplay(); // This will restore the normal status display } }, 5000); log('Filtered price fetch completed and button state reset'); } } // Function to update market button text with item count function updateMarketButtonText() { const marketBtn = document.getElementById('fetch-market-prices'); if (marketBtn && scannedItems.size > 0) { marketBtn.textContent = `Get ${scannedItems.size} item prices`; } else if (marketBtn) { marketBtn.textContent = 'Get Market Prices'; } } // Create floating table to display results function createFloatingTable() { if (floatingTable) { floatingTable.remove(); } // Load saved window state const windowState = loadWindowState(); floatingTable = document.createElement('div'); floatingTable.id = 'inventory-scanner-table'; floatingTable.style.position = 'fixed'; floatingTable.style.left = windowState.x + 'px'; floatingTable.style.top = windowState.y + 'px'; floatingTable.style.width = windowState.width || WINDOW_DEFAULT_WIDTH; floatingTable.style.height = windowState.height || WINDOW_DEFAULT_HEIGHT; floatingTable.style.minWidth = WINDOW_MIN_WIDTH; floatingTable.style.minHeight = WINDOW_MIN_HEIGHT; floatingTable.style.maxWidth = '90vw'; floatingTable.style.maxHeight = '90vh'; floatingTable.style.background = '#fff'; floatingTable.style.border = '2px solid #333'; floatingTable.style.borderRadius = '8px'; floatingTable.style.zIndex = '100000'; floatingTable.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)'; floatingTable.style.resize = 'both'; floatingTable.style.overflow = 'hidden'; floatingTable.innerHTML = ` <div id="header-drag" class="title-bar" style="background: #333; color: #fff; padding: 10px; border-radius: 6px 6px 0 0; cursor: move; display: flex; justify-content: space-between; align-items: center; user-select: none;"> <span id="window-title" style="cursor: pointer; flex: 1;">Scrap2Mark</span> <div style="display: flex; align-items: center;"> <button id="close-table" style="background: none; border: none; color: #fff; cursor: pointer; font-size: 14px; line-height: 1; padding: 2px;">✕</button> </div> </div> <div id="table-content" style="padding: 10px; height: calc(100% - 50px); overflow-y: auto; display: flex; flex-direction: column;"> <div id="api-controls" style="margin-bottom: 10px; padding: 6px; background: #f8f9fa; border-radius: 4px;"> <div style="display: flex; gap: 6px; align-items: center;"> <input type="text" id="api-key-input" placeholder="Insert API key here" autocomplete="on" name="api_key" style="flex: 1; padding: 4px 6px; border: 1px solid #ccc; border-radius: 3px; font-size: 11px; font-family: monospace;"> <button id="save-api-key" style="background: #17a2b8; color: white; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 11px; white-space: nowrap;">Save</button> <button id="logout-api-key" style="background: #dc3545; color: white; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 11px; white-space: nowrap; display: none;">Logout</button> <button id="fetch-api-data" style="background: #ffc107; color: black; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 11px; white-space: nowrap; display: none;">Fetch</button> </div> <div style="font-size: 9px; color: #666; min-height: 12px; margin-top: 4px;"> <span id="api-status"></span> </div> </div> <div id="scan-controls" style="margin-bottom: 10px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center;"> <!-- Scanning Group --> <div style="display: flex; gap: 3px; margin-right: 12px;"> <button id="start-scan" style="background: linear-gradient(135deg, #28a745, #20c997); color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; height: 32px; font-weight: 500; box-shadow: 0 2px 4px rgba(40,167,69,0.3); transition: all 0.2s ease;">Scan Inventory</button> <button id="clear-data" style="background: linear-gradient(135deg, #dc3545, #e74c3c); color: white; border: none; padding: 6px 8px; border-radius: 6px; cursor: pointer; height: 32px; font-size: 12px; box-shadow: 0 2px 4px rgba(220,53,69,0.3); transition: all 0.2s ease;">✗</button> </div> <!-- Price Fetching Group --> <div style="display: flex; gap: 3px; margin-right: 12px;"> <button id="fetch-market-prices" style="background: linear-gradient(135deg, #ffc107, #ffb300); color: black; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; height: 32px; font-weight: 500; box-shadow: 0 2px 4px rgba(255,193,7,0.3); transition: all 0.2s ease;">Get Market Prices</button> <button id="clear-market-cache" style="background: linear-gradient(135deg, #dc3545, #e74c3c); color: white; border: none; padding: 6px 8px; border-radius: 6px; cursor: pointer; height: 32px; font-size: 12px; box-shadow: 0 2px 4px rgba(220,53,69,0.3); transition: all 0.2s ease;">✗</button> </div> <div style="display: flex; gap: 3px; margin-right: 12px;"> <button id="fetch-filtered-prices" style="background: linear-gradient(135deg, #6f42c1, #8e44ad); color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; height: 32px; font-weight: 500; box-shadow: 0 2px 4px rgba(111,66,193,0.3); transition: all 0.2s ease;">Get Filtered Prices</button> </div> <!-- Market Actions Group --> <div style="display: flex; gap: 3px;"> <button id="post-market-button" style="background: linear-gradient(135deg, #17a2b8, #3498db); color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; height: 32px; font-weight: 500; box-shadow: 0 2px 4px rgba(23,162,184,0.3); transition: all 0.2s ease;">Fill Market Forms</button> <button id="clear-price-targets" style="background: linear-gradient(135deg, #dc3545, #e74c3c); color: white; border: none; padding: 6px 8px; border-radius: 6px; cursor: pointer; height: 32px; font-size: 12px; box-shadow: 0 2px 4px rgba(220,53,69,0.3); transition: all 0.2s ease;">✗</button> </div> </div> <div id="scroll-reminder" style="margin-bottom: 10px; font-size: 11px; color: #666; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 4px; padding: 8px;"> <div style="display: flex; align-items: center; gap: 8px;"> <span style="color: #856404; font-weight: bold;">⚠️ IMPORTANT:</span> <span style="color: #856404;">Scroll down to load ALL items before using the tools</span> </div> <div style="margin-top: 4px; font-size: 10px; color: #6c6c6c;"> <strong>Loading Progress:</strong> <span id="loading-progress">Checking...</span> | <strong>Tip:</strong> Keep scrolling until no new items appear. </div> </div> <div id="scan-status" style="margin-bottom: 10px; font-weight: bold;"></div> <div style="flex: 1; overflow-y: auto; min-height: 200px;"> <div style="margin-bottom: 10px;"> <div style="display: flex; align-items: center; margin-bottom: 5px; flex-wrap: wrap; gap: 10px;"> <h4 style="margin: 0; font-size: 14px;">Scanned Items:</h4> <label style="display: flex; align-items: center; font-size: 12px; cursor: pointer;"> <input type="checkbox" id="show-non-tradeable" style="margin-right: 4px;"> Show non-tradeable </label> <label style="display: flex; align-items: center; font-size: 12px; cursor: pointer;"> <input type="checkbox" id="show-ignored" style="margin-right: 4px;"> Show ignored items </label> <label style="display: flex; align-items: center; font-size: 12px; cursor: pointer;"> <input type="checkbox" id="hide-low-value" checked style="margin-right: 4px;"> Hide below $ <input type="text" id="min-value-input" value="5,000,000" style="width: 70px; margin-left: 4px; padding: 2px; border: 1px solid #ccc; border-radius: 2px; font-size: 11px;" placeholder="5000000"> </label> </div> <table id="all-items-table" style="width: 100%; border-collapse: collapse; font-size: 12px; table-layout: fixed;"> <thead> <tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;"> <th id="header-name" style="padding: 8px; text-align: left; border: 1px solid #dee2e6; cursor: pointer; user-select: none;"> Name <span id="sort-name" style="float: right;">↕</span> </th> <th id="header-category" style="padding: 8px; text-align: center; border: 1px solid #dee2e6; width: 80px; cursor: pointer; user-select: none;"> Category <span id="sort-category" style="float: right;">↕</span> </th> <th id="header-qty" style="padding: 8px; text-align: center; border: 1px solid #dee2e6; width: 40px; cursor: pointer; user-select: none;"> Qty <span id="sort-qty" style="float: right;">↕</span> </th> <th id="trade-column-header" style="padding: 8px; text-align: center; border: 1px solid #dee2e6; width: 50px; cursor: pointer; user-select: none; display: none;"> Trade <span id="sort-trade" style="float: right;">↕</span> </th> <th id="header-price" style="padding: 8px; text-align: center; border: 1px solid #dee2e6; width: 70px; cursor: pointer; user-select: none;"> Price <span id="sort-price" style="float: right;">↕</span> </th> <th id="header-estimated" style="padding: 8px; text-align: center; border: 1px solid #dee2e6; width: 90px; cursor: pointer; user-select: none;"> Est. Value <span id="sort-estimated" style="float: right;">↕</span> </th> <th style="padding: 8px; text-align: center; border: 1px solid #dee2e6; width: 50px;"> Action </th> </tr> </thead> <tbody id="items-table-body"> </tbody> </table> </div> </div> </div> </div> `; document.body.appendChild(floatingTable); // Make draggable let isDragging = false; let dragStarted = false; let currentX, currentY, initialX, initialY; const header = document.getElementById('header-drag'); const windowTitle = document.getElementById('window-title'); function startDrag(e) { e.preventDefault(); isDragging = true; dragStarted = false; // Get the current position of the floating table const rect = floatingTable.getBoundingClientRect(); // Calculate offset from mouse to top-left corner of element initialX = e.clientX - rect.left; initialY = e.clientY - rect.top; document.addEventListener('mousemove', doDrag); document.addEventListener('mouseup', stopDrag); } function doDrag(e) { if (!isDragging) return; e.preventDefault(); // Mark that dragging has actually started (mouse moved) if (!dragStarted) { dragStarted = true; } currentX = e.clientX - initialX; currentY = e.clientY - initialY; // Keep the window within the viewport const maxX = window.innerWidth - floatingTable.offsetWidth; const maxY = window.innerHeight - floatingTable.offsetHeight; currentX = Math.max(0, Math.min(currentX, maxX)); currentY = Math.max(0, Math.min(currentY, maxY)); floatingTable.style.left = currentX + 'px'; floatingTable.style.top = currentY + 'px'; floatingTable.style.right = 'auto'; // Remove right positioning when dragging } function stopDrag() { isDragging = false; document.removeEventListener('mousemove', doDrag); document.removeEventListener('mouseup', stopDrag); // Save position when drag ends (but keep existing dimensions) const rect = floatingTable.getBoundingClientRect(); saveWindowState(rect.left, rect.top, windowState.width || WINDOW_DEFAULT_WIDTH, windowState.height || WINDOW_DEFAULT_HEIGHT, isMinimized()); } function handleTitleClick(e) { // Only toggle minimize if we didn't actually drag if (!dragStarted) { toggleMinimize(); } } function handleHeaderClick(e) { // Only toggle minimize if we didn't actually drag and didn't click on close button if (!dragStarted && e.target.id !== 'close-table') { toggleMinimize(); } } function toggleMinimize() { const content = document.getElementById('table-content'); if (content.style.display === 'none') { // Restoring from minimized state content.style.display = 'flex'; floatingTable.style.width = windowState.width || WINDOW_DEFAULT_WIDTH; floatingTable.style.height = windowState.height || WINDOW_DEFAULT_HEIGHT; floatingTable.style.resize = 'both'; // Restore normal constraints floatingTable.style.minWidth = WINDOW_MIN_WIDTH; floatingTable.style.minHeight = WINDOW_MIN_HEIGHT; floatingTable.style.maxWidth = '90vw'; floatingTable.style.maxHeight = '90vh'; } else { // Minimizing content.style.display = 'none'; floatingTable.style.width = WINDOW_MINIMIZED_WIDTH; floatingTable.style.height = WINDOW_MINIMIZED_HEIGHT; floatingTable.style.resize = 'none'; // Override constraints for minimized state floatingTable.style.minWidth = WINDOW_MINIMIZED_WIDTH; floatingTable.style.minHeight = WINDOW_MINIMIZED_HEIGHT; floatingTable.style.maxWidth = WINDOW_MINIMIZED_WIDTH; floatingTable.style.maxHeight = WINDOW_MINIMIZED_HEIGHT; } // Save minimized state const rect = floatingTable.getBoundingClientRect(); saveWindowState(rect.left, rect.top, windowState.width || WINDOW_DEFAULT_WIDTH, windowState.height || WINDOW_DEFAULT_HEIGHT, content.style.display === 'none'); } function isMinimized() { const content = document.getElementById('table-content'); return content && content.style.display === 'none'; } header.addEventListener('mousedown', startDrag); header.addEventListener('mouseup', handleHeaderClick); // Restore minimized state if (windowState.minimized) { const content = document.getElementById('table-content'); if (content) { content.style.display = 'none'; floatingTable.style.width = WINDOW_MINIMIZED_WIDTH; floatingTable.style.height = WINDOW_MINIMIZED_HEIGHT; floatingTable.style.resize = 'none'; // Force the size override any stored dimensions floatingTable.style.minWidth = WINDOW_MINIMIZED_WIDTH; floatingTable.style.minHeight = WINDOW_MINIMIZED_HEIGHT; floatingTable.style.maxWidth = WINDOW_MINIMIZED_WIDTH; floatingTable.style.maxHeight = WINDOW_MINIMIZED_HEIGHT; } } else { // Ensure resize is enabled for normal state and restore constraints floatingTable.style.resize = 'both'; floatingTable.style.minWidth = WINDOW_MIN_WIDTH; floatingTable.style.minHeight = WINDOW_MIN_HEIGHT; floatingTable.style.maxWidth = '90vw'; floatingTable.style.maxHeight = '90vh'; } // Add resize observer to save window state when resized if (window.ResizeObserver) { const resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { const rect = entry.target.getBoundingClientRect(); // Only update dimensions if not minimized if (!isMinimized()) { windowState.width = rect.width + 'px'; windowState.height = rect.height + 'px'; // Debounce the save operation clearTimeout(floatingTable.resizeTimeout); floatingTable.resizeTimeout = setTimeout(() => { saveWindowState(rect.left, rect.top, rect.width + 'px', rect.height + 'px', isMinimized()); }, 500); } } }); resizeObserver.observe(floatingTable); } // Event listeners document.getElementById('close-table').addEventListener('click', () => { floatingTable.remove(); floatingTable = null; }); document.getElementById('start-scan').addEventListener('click', startInventoryScan); document.getElementById('clear-data').addEventListener('click', () => { if (confirm('Clear all scanned inventory data? This cannot be undone.')) { clearInventoryData(); } }); document.getElementById('post-market-button').addEventListener('click', handlePostToMarket); document.getElementById('save-api-key').addEventListener('click', handleSaveApiKey); document.getElementById('logout-api-key').addEventListener('click', handleLogoutApiKey); document.getElementById('fetch-api-data').addEventListener('click', handleFetchApiData); document.getElementById('show-non-tradeable').addEventListener('change', () => { updateTableDisplay(); saveFilterState(getCurrentFilterState()); }); document.getElementById('show-ignored').addEventListener('change', () => { updateTableDisplay(); saveFilterState(getCurrentFilterState()); }); document.getElementById('hide-low-value').addEventListener('change', () => { updateTableDisplay(); saveFilterState(getCurrentFilterState()); }); document.getElementById('min-value-input').addEventListener('input', () => { updateTableDisplay(); saveFilterState(getCurrentFilterState()); }); document.getElementById('fetch-market-prices').addEventListener('click', handleFetchMarketPrices); // Add event listener for filtered prices button with retry mechanism const attachFilteredPricesListener = () => { const filteredPricesBtn = document.getElementById('fetch-filtered-prices'); if (filteredPricesBtn) { filteredPricesBtn.addEventListener('click', handleFetchFilteredPrices); log('Event listener attached to fetch-filtered-prices button'); return true; } else { log('fetch-filtered-prices button not found, retrying...'); return false; } }; // Try to attach immediately, then retry if needed if (!attachFilteredPricesListener()) { // If button not found, try again after a short delay setTimeout(() => { if (!attachFilteredPricesListener()) { log('Warning: Could not attach event listener to fetch-filtered-prices button'); } }, 100); } document.getElementById('clear-market-cache').addEventListener('click', () => { if (confirm('Clear market price cache? This will remove all cached market data.')) { handleClearMarketCache(); } }); document.getElementById('clear-price-targets').addEventListener('click', () => { if (confirm('Clear all price targets? This will remove all selected prices for market posting.')) { clearAllPriceTargets(); } }); // Add column header event listeners for sorting document.getElementById('header-name').addEventListener('click', () => sortTableDynamic('name')); document.getElementById('header-category').addEventListener('click', () => sortTableDynamic('category')); document.getElementById('header-qty').addEventListener('click', () => sortTableDynamic('qty')); document.getElementById('trade-column-header').addEventListener('click', () => sortTableDynamic('trade')); document.getElementById('header-price').addEventListener('click', () => sortTableDynamic('price')); document.getElementById('header-estimated').addEventListener('click', () => sortTableDynamic('estimated')); // Load ignored items from storage loadIgnoredItems(); // Load and apply filter state const filterState = loadFilterState(); applyFilterState(filterState); // Update scroll reminder initially updateScrollReminder(); // Set up periodic reminder updates when user scrolls let scrollTimeout; window.addEventListener('scroll', () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(updateScrollReminder, 1000); // Update 1 second after scrolling stops }); updateTableDisplay(); } // Handle API key saving function handleSaveApiKey() { const apiKeyInput = document.getElementById('api-key-input'); const apiStatus = document.getElementById('api-status'); const key = apiKeyInput.value.trim(); if (!key) { apiStatus.textContent = 'Please enter an API key'; apiStatus.style.color = '#dc3545'; return; } saveApiKey(key); apiKeyInput.value = ''; apiStatus.textContent = 'API key saved!'; apiStatus.style.color = '#28a745'; // Update button visibility updateApiButtonVisibility(); setTimeout(() => { updateApiStatusDisplay(); }, 3000); } // Handle API key logout function handleLogoutApiKey() { // Clear API key from storage localStorage.removeItem(API_KEY_STORAGE_KEY); localStorage.removeItem(API_KEY_EXPIRY_STORAGE_KEY); apiKey = null; const apiStatus = document.getElementById('api-status'); apiStatus.textContent = 'Logged out successfully'; apiStatus.style.color = '#28a745'; // Update button visibility updateApiButtonVisibility(); setTimeout(() => { updateApiStatusDisplay(); }, 3000); } // Update API button visibility based on key status function updateApiButtonVisibility() { const saveBtn = document.getElementById('save-api-key'); const logoutBtn = document.getElementById('logout-api-key'); const apiKeyInput = document.getElementById('api-key-input'); if (apiKey) { saveBtn.style.display = 'none'; logoutBtn.style.display = 'block'; if (apiKeyInput) { apiKeyInput.placeholder = 'API key is saved'; apiKeyInput.disabled = true; apiKeyInput.style.backgroundColor = '#f8f9fa'; apiKeyInput.style.color = '#6c757d'; } } else { saveBtn.style.display = 'block'; logoutBtn.style.display = 'none'; if (apiKeyInput) { apiKeyInput.placeholder = 'Insert API key here'; apiKeyInput.disabled = false; apiKeyInput.style.backgroundColor = '#fff'; apiKeyInput.style.color = '#000'; } } } // Handle API data fetching async function handleFetchApiData() { const apiStatus = document.getElementById('api-status'); const fetchBtn = document.getElementById('fetch-api-data'); if (!apiKey) { apiStatus.textContent = 'Please save an API key first'; apiStatus.style.color = '#dc3545'; return; } fetchBtn.disabled = true; apiStatus.textContent = 'Fetching items data...'; apiStatus.style.color = '#17a2b8'; try { await fetchItemsFromAPI(apiKey); apiStatus.textContent = 'Items data fetched successfully!'; apiStatus.style.color = '#28a745'; // Update display if we have scanned items updateTableDisplay(); } catch (error) { log(`Failed to fetch API data: ${error.message}`); apiStatus.textContent = `Error: ${error.message}`; apiStatus.style.color = '#dc3545'; } finally { fetchBtn.disabled = false; setTimeout(() => { apiStatus.textContent = ''; }, 5000); } } // Handle market price fetching async function handleFetchMarketPrices() { const marketBtn = document.getElementById('fetch-market-prices'); const statusDiv = document.getElementById('scan-status'); if (!apiKey) { if (statusDiv) { statusDiv.innerHTML = '<span style="color: #dc3545;">Please save an API key first</span>'; } return; } if (scannedItems.size === 0) { if (statusDiv) { statusDiv.innerHTML = '<span style="color: #dc3545;">No items to fetch prices for. Scan items first.</span>'; } return; } // Reset any stuck queue state if (isProcessingQueue) { log('Resetting stuck queue state'); isProcessingQueue = false; apiRequestQueue.length = 0; // Clear any pending requests } // Reset API counters if they seem stuck const now = Date.now(); if (now - apiRequestResetTime > 120000) { // If reset time is more than 2 minutes old log('Resetting API request counters'); apiRequestCount = 0; apiRequestResetTime = now; } marketBtn.disabled = true; marketBtn.textContent = 'Fetching...'; const tradeableItems = Array.from(scannedItems.values()).filter(item => item.tradeable === true); if (tradeableItems.length === 0) { if (statusDiv) { statusDiv.innerHTML = '<span style="color: #ffc107;">No tradeable items found to fetch prices for</span>'; } marketBtn.disabled = false; updateMarketButtonText(); return; } let processed = 0; let errors = 0; const total = tradeableItems.length; const startTime = Date.now(); const maxProcessingTime = 1800000; // 30 minutes maximum (increased from 5 minutes) try { for (let i = 0; i < tradeableItems.length; i++) { const item = tradeableItems[i]; // Check for timeout (but much more generous now) if (Date.now() - startTime > maxProcessingTime) { log('Market price fetching timed out after 30 minutes'); if (statusDiv) { statusDiv.innerHTML = '<span style="color: #ffc107;">Market price fetching timed out after 30 minutes. Processed ' + processed + '/' + total + ' items.</span>'; } break; } try { if (statusDiv) { statusDiv.innerHTML = `Fetching market data: ${processed + 1}/${total} (${item.name})`; } // Add timeout for individual requests (increased to 2 minutes) const requestPromise = queueMarketDataRequest(item.id); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Individual request timeout')), 120000); // 2 minutes per request }); await Promise.race([requestPromise, timeoutPromise]); processed++; // Yield control to UI every 10 items to prevent freezing if (i % 10 === 0 && i > 0) { await new Promise(resolve => setTimeout(resolve, 10)); } } catch (error) { errors++; log(`Failed to fetch market data for item ${item.id}: ${error.message}`); // Only stop on critical errors that won't recover if (error.message.includes('Daily API read limit') || error.message.includes('IP temporarily blocked')) { log('Critical API error encountered, stopping fetch process'); if (statusDiv) { statusDiv.innerHTML = `<span style="color: #dc3545;">Critical API error: ${error.message}. Processed ${processed}/${total} items.</span>`; } break; } // For rate limiting, wait a bit longer but continue if (error.message.includes('Rate limit') || error.message.includes('Throttling timeout')) { log('Rate limit hit, waiting 30 seconds before continuing...'); if (statusDiv) { statusDiv.innerHTML = `Rate limited. Waiting 30s... (${processed}/${total} completed)`; } await sleep(30000); // Wait 30 seconds } } // Update table periodically if (processed % 5 === 0) { updateTableDisplay(); } } updateTableDisplay(); if (statusDiv) { if (errors > 0) { statusDiv.innerHTML = `<span style="color: #ffc107;">Market data fetch completed: ${processed}/${total} successful, ${errors} errors</span>`; } else { statusDiv.innerHTML = `<span style="color: #28a745;">Market data fetch completed: ${processed}/${total} items processed</span>`; } } } catch (error) { log(`Market data fetch failed: ${error.message}`); if (statusDiv) { statusDiv.innerHTML = `<span style="color: #dc3545;">Market data fetch failed: ${error.message}</span>`; } } finally { marketBtn.disabled = false; updateMarketButtonText(); // Reset queue state to prevent hanging isProcessingQueue = false; setTimeout(() => { if (statusDiv && statusDiv.innerHTML.includes('Market data fetch')) { updateTableDisplay(); // This will restore the normal status display } }, 5000); } } // Reset API state (useful for debugging hanging issues) function resetApiState() { log('Resetting API state'); isProcessingQueue = false; apiRequestQueue.length = 0; apiRequestCount = 0; apiRequestResetTime = Date.now(); lastApiRequest = 0; } // Clear sort state function clearSortState() { currentSortColumn = -1; currentSortDirection = 'asc'; currentSortField = null; // Clear all sort indicators ['name', 'category', 'qty', 'trade', 'price', 'estimated'].forEach(col => { const indicator = document.getElementById(`sort-${col}`); if (indicator) indicator.textContent = '↕'; }); } // Handle clear market cache function handleClearMarketCache() { const clearBtn = document.getElementById('clear-market-cache'); const statusDiv = document.getElementById('scan-status'); // Clear market data from memory and storage marketData.clear(); localStorage.removeItem(MARKET_DATA_STORAGE_KEY); localStorage.removeItem(MARKET_EXPIRY_STORAGE_KEY); // Also reset API state to prevent any stuck conditions resetApiState(); // Update display updateTableDisplay(); if (statusDiv) { statusDiv.innerHTML = '<span style="color: #28a745;">Market cache cleared successfully</span>'; setTimeout(() => { updateTableDisplay(); // This will restore the normal status display }, 3000); } log('Market cache cleared and API state reset'); } // Update table display function updateTableDisplay() { if (!floatingTable) { log('Warning: floatingTable not found in updateTableDisplay'); return; } const tbody = document.getElementById('items-table-body'); const statusDiv = document.getElementById('scan-status'); const showNonTradeableCheckbox = document.getElementById('show-non-tradeable'); const showIgnoredCheckbox = document.getElementById('show-ignored'); const hideLowValueCheckbox = document.getElementById('hide-low-value'); const minValueInput = document.getElementById('min-value-input'); if (!tbody) { log('Warning: items-table-body not found in updateTableDisplay'); return; } tbody.innerHTML = ''; const showNonTradeable = showNonTradeableCheckbox ? showNonTradeableCheckbox.checked : false; const showIgnored = showIgnoredCheckbox ? showIgnoredCheckbox.checked : false; const hideLowValue = hideLowValueCheckbox ? hideLowValueCheckbox.checked : false; const minValue = minValueInput ? parseFloat(minValueInput.value.replace(/[,$]/g, '')) || 0 : 0; // Toggle Trade column visibility based on checkbox state const tradeColumnHeader = document.getElementById('trade-column-header'); if (tradeColumnHeader) { tradeColumnHeader.style.display = showNonTradeable ? 'table-cell' : 'none'; } // Sort items using current sort state or default sorting const sortedItems = applySortToItems(Array.from(scannedItems.values())); let tradeableCount = 0; let nonTradeableCount = 0; let ignoredCount = 0; let displayedCount = 0; let hiddenLowValueCount = 0; sortedItems.forEach(item => { const itemId = item.id || 'N/A'; const itemName = item.name || 'Unknown'; const itemCategory = item.category || 'Unknown'; const itemQuantity = item.quantity || 0; const itemTradeable = item.tradeable; const itemIgnored = isItemIgnored(itemId); // Count all items if (itemTradeable === true) { tradeableCount++; } else if (itemTradeable === false) { nonTradeableCount++; } if (itemIgnored) { ignoredCount++; } // Filter display based on checkboxes if (!showNonTradeable && itemTradeable === false) { return; // Skip non-tradeable items when checkbox is unchecked } if (!showIgnored && itemIgnored) { return; // Skip ignored items when checkbox is unchecked } // Calculate estimated value for low-value filtering const estimatedValueForFilter = calculateEstimatedValue(itemId, itemQuantity); // Filter by minimum value if enabled (only hide items that have a calculated value) if (hideLowValue && estimatedValueForFilter > 0 && estimatedValueForFilter < minValue) { hiddenLowValueCount++; return; // Skip low-value items when checkbox is checked } displayedCount++; // Determine tradeable status display and row styling let tradeableDisplay = ''; let tradeableColor = '#666'; let rowStyle = ''; if (itemIgnored) { rowStyle = 'background-color: #f0f0f0; opacity: 0.7;'; // Gray background for ignored items } else if (itemTradeable === true) { tradeableDisplay = '✓'; tradeableColor = '#28a745'; } else if (itemTradeable === false) { tradeableDisplay = '✗'; tradeableColor = '#dc3545'; rowStyle = 'background-color: #f8d7da;'; // Light red background for non-tradeable } else { tradeableDisplay = '?'; tradeableColor = '#ffc107'; } // Override trade display for ignored items if (itemIgnored) { tradeableDisplay = itemTradeable === true ? '✓' : itemTradeable === false ? '✗' : '?'; } // Get market data for price display let priceDisplay = ''; const marketInfo = marketData.get(itemId.toString()); if (marketInfo && itemTradeable === true) { const stats = calculatePriceStats(marketInfo.listings); if (stats) { const chart = createPriceChart(marketInfo.listings); const age = Math.round((Date.now() - marketInfo.timestamp) / (1000 * 60)); // age in minutes const ageText = age < 60 ? `${age}m` : `${Math.round(age / 60)}h`; const outliersText = stats.outliersFiltered > 0 ? ` (${stats.outliersFiltered} high outliers, ${stats.sellersFiltered} sellers filtered)` : ''; // Use a price closer to low end - take the lower of P25 or 20% above minimum const lowPrice = Math.min(stats.p25, stats.min * 1.2); const displayPrice = Math.round(lowPrice); const tooltip = `Display: $${displayPrice.toLocaleString()} (Low-end price)\nMin: $${stats.min.toLocaleString()}\nMax: $${stats.max.toLocaleString()}\nAvg: $${Math.round(stats.average).toLocaleString()}\nMedian: $${stats.median.toLocaleString()}\nQ1-Q3: $${stats.p25.toLocaleString()}-$${stats.p75.toLocaleString()}\nSellers: ${stats.uniqueSellers}/${stats.totalUniqueSellers}\nListings: ${stats.listings}${outliersText}\nFiltered: ${stats.priceCapFiltered} 10x+ prices, ${stats.statFiltered} statistical\nTotal Qty: ${stats.totalQuantity.toLocaleString()}\nAge: ${ageText}`; priceDisplay = ` <div style="display: flex; flex-direction: column; align-items: center;"> <div style="font-size: 9px; color: #666;">$${displayPrice.toLocaleString()}</div> <div class="price-chart-hover" data-item-id="${itemId}" data-item-name="${itemName}" style="cursor: help;">${chart}</div> </div> `; } } else if (itemTradeable === true) { priceDisplay = '<div style="font-size: 9px; color: #999; text-align: center;">No data</div>'; } else { priceDisplay = '<div style="font-size: 9px; color: #999; text-align: center;">-</div>'; } // Calculate estimated value const estimatedValue = calculateEstimatedValue(itemId, itemQuantity); const estimatedValueDisplay = estimatedValue > 0 ? `$${Math.round(estimatedValue).toLocaleString()}` : '-'; // Create action button (ignore) const ignoreButtonText = itemIgnored ? 'Unignore' : 'Ignore'; const ignoreButtonClass = itemIgnored ? 'ignore-btn unignore' : 'ignore-btn ignore'; // Create row const row = document.createElement('tr'); row.style.borderBottom = '1px solid #dee2e6'; row.style.cssText += rowStyle; row.innerHTML = ` <td style="padding: 4px; border: 1px solid #dee2e6; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${itemName}">${itemName}</td> <td style="padding: 4px; border: 1px solid #dee2e6; text-align: center;">${itemCategory}</td> <td style="padding: 4px; border: 1px solid #dee2e6; text-align: center;">${itemQuantity}</td> <td class="trade-column-cell" style="padding: 4px; border: 1px solid #dee2e6; color: ${tradeableColor}; font-weight: bold; text-align: center; display: none;">${tradeableDisplay}</td> <td style="padding: 2px; border: 1px solid #dee2e6; text-align: center;">${priceDisplay}</td> <td style="padding: 4px; border: 1px solid #dee2e6; text-align: center; font-weight: bold; color: ${estimatedValue > 0 ? '#28a745' : '#666'};">${estimatedValueDisplay}</td> <td style="padding: 4px; border: 1px solid #dee2e6; text-align: center;"> <button class="${ignoreButtonClass}" data-item-id="${itemId}">${ignoreButtonText}</button> </td> `; // Add event listener to the ignore button const ignoreButton = row.querySelector('.ignore-btn'); if (ignoreButton) { ignoreButton.addEventListener('click', () => { toggleIgnoreItem(itemId); }); } tbody.appendChild(row); }); // Update Trade column cell visibility for all rows const tradeColumnCells = document.querySelectorAll('.trade-column-cell'); tradeColumnCells.forEach(cell => { cell.style.display = showNonTradeable ? 'table-cell' : 'none'; }); const unknownCount = sortedItems.length - tradeableCount - nonTradeableCount; const filterText = showNonTradeable ? '' : ' (filtered)'; // Status line: Total, Tradeable, and conditionally Non-Tradeable/Unknown/Ignored/Low-Value let statusHtml = `Total: ${displayedCount}${filterText} | <span style="color: #28a745;">Tradeable: ${tradeableCount}</span>`; if (showNonTradeable && (nonTradeableCount > 0 || unknownCount > 0)) { statusHtml += ` | <span style="color: #dc3545;">Non-Tradeable: ${nonTradeableCount}</span> | <span style="color: #ffc107;">Unknown: ${unknownCount}</span>`; } if (ignoredCount > 0) { statusHtml += ` | <span style="color: #6c757d;">Ignored: ${ignoredCount}</span>`; } if (hiddenLowValueCount > 0) { statusHtml += ` | <span style="color: #17a2b8;">Low-Value Hidden: ${hiddenLowValueCount}</span>`; } if (statusDiv) { statusDiv.innerHTML = statusHtml; } else { log('Warning: scan-status element not found, unable to update status display'); } // Add hover popup functionality for price charts (with small delay to ensure DOM is updated) setTimeout(() => { addPriceChartHoverHandlers(); // Restore sort indicators after table update if (currentSortField && currentSortColumn !== -1) { // Clear all sort indicators ['name', 'category', 'qty', 'trade', 'price', 'estimated'].forEach(col => { const indicator = document.getElementById(`sort-${col}`); if (indicator) indicator.textContent = '↕'; }); // Set current sort indicator const currentIndicator = document.getElementById(`sort-${currentSortField}`); if (currentIndicator) { currentIndicator.textContent = currentSortDirection === 'asc' ? '↑' : '↓'; } } }, 10); } // Global timeout for popup hiding let globalHideTimeout; // Add hover popup functionality function addPriceChartHoverHandlers() { const priceCharts = document.querySelectorAll('.price-chart-hover'); priceCharts.forEach(chart => { let showTimeout; chart.addEventListener('mouseenter', (e) => { // Clear any existing hide timeout if (globalHideTimeout) { clearTimeout(globalHideTimeout); globalHideTimeout = null; } // Capture the data immediately before the timeout const itemId = e.currentTarget.getAttribute('data-item-id'); const itemName = e.currentTarget.getAttribute('data-item-name'); const mouseEvent = { clientX: e.clientX, clientY: e.clientY }; // Show popup after 300ms delay (reduced for faster response) showTimeout = setTimeout(() => { const marketInfo = marketData.get(itemId); if (marketInfo && marketInfo.listings) { showPricePopup(mouseEvent, marketInfo.listings, itemName, itemId); } }, 300); }); chart.addEventListener('mouseleave', () => { // Clear show timeout if still pending if (showTimeout) { clearTimeout(showTimeout); showTimeout = null; } // Hide popup after 2 seconds delay (increased to allow easier access to popup) globalHideTimeout = setTimeout(() => { hidePricePopup(); }, 2000); }); }); } // Update existing popup with new chart content function updatePricePopup(listings, itemName, zoomMin = null, zoomMax = null) { const popup = document.getElementById('price-popup'); if (!popup) return; // Update the content with new chart popup.innerHTML = createDetailedPriceChart(listings, itemName, zoomMin, zoomMax); // Re-add chart interaction handlers after content update addChartInteraction(listings, itemName); // Re-add panel handlers after content update addPanelHandlers(); // Keep popup in same general position but adjust if needed const rect = popup.getBoundingClientRect(); let left = rect.left; let top = rect.top; // Adjust if popup would go off screen after content change const newRect = popup.getBoundingClientRect(); if (left + newRect.width > window.innerWidth) { left = window.innerWidth - newRect.width - 10; } if (top + newRect.height > window.innerHeight) { top = window.innerHeight - newRect.height - 10; } if (left < 0) left = 10; if (top < 0) top = 10; popup.style.left = left + 'px'; popup.style.top = top + 'px'; } // Show detailed price popup function showPricePopup(event, listings, itemName, itemId, zoomMin = null, zoomMax = null) { // Load stored price targets for this item loadPriceTargets(itemId); // Remove existing popup hidePricePopup(); const popup = document.createElement('div'); popup.id = 'price-popup'; popup.innerHTML = createDetailedPriceChart(listings, itemName, zoomMin, zoomMax); popup.style.position = 'fixed'; popup.style.zIndex = '1000000'; popup.style.maxWidth = '350px'; popup.style.pointerEvents = 'auto'; // Make popup interactive popup.addEventListener('mouseenter', () => { // Cancel any pending hide timeouts when hovering over popup if (globalHideTimeout) { clearTimeout(globalHideTimeout); globalHideTimeout = null; } }); popup.addEventListener('mouseleave', () => { // Hide popup when leaving popup area globalHideTimeout = setTimeout(() => { hidePricePopup(); }, 500); }); document.body.appendChild(popup); // Position popup near mouse but keep it in viewport const rect = popup.getBoundingClientRect(); let left = event.clientX + 10; let top = event.clientY + 10; // Adjust if popup would go off screen if (left + rect.width > window.innerWidth) { left = event.clientX - rect.width - 10; } if (top + rect.height > window.innerHeight) { top = event.clientY - rect.height - 10; } if (left < 0) left = 10; if (top < 0) top = 10; popup.style.left = left + 'px'; popup.style.top = top + 'px'; // Add chart interaction handlers after popup is added to DOM addChartInteraction(listings, itemName); // Add panel expansion handlers addPanelHandlers(); } // Add panel interaction handlers (simplified for integrated layout) function addPanelHandlers() { // No expand/collapse buttons needed anymore since listings are integrated const clearBtn = document.getElementById('clear-targets-btn'); // Add event listeners for price target checkboxes const checkboxes = document.querySelectorAll('.price-target-checkbox'); checkboxes.forEach(checkbox => { const originalPrice = parseFloat(checkbox.getAttribute('data-original-price')); // Restore checkbox state from stored data if (selectedPriceTargets.has(originalPrice)) { checkbox.checked = true; } checkbox.addEventListener('change', (e) => { const originalPrice = parseFloat(e.target.getAttribute('data-original-price')); const targetPrice = parseFloat(e.target.getAttribute('data-target-price')); const isChecked = e.target.checked; togglePriceTarget(originalPrice, targetPrice, isChecked); }); }); // Update the display with loaded targets updateSelectedTargetsDisplay(); // Add event listeners for control buttons if (clearBtn) { clearBtn.addEventListener('click', () => { clearAllTargets(); }); } } // Global storage for selected price targets (persistent across sessions) let selectedPriceTargets = new Map(); let currentItemId = null; // Load price targets from localStorage function loadPriceTargets(itemId) { currentItemId = itemId; const storageKey = `scrap2mark_price_targets_${itemId}`; const stored = localStorage.getItem(storageKey); if (stored) { try { const parsed = JSON.parse(stored); selectedPriceTargets = new Map(Object.entries(parsed).map(([k, v]) => [parseFloat(k), v])); } catch (e) { selectedPriceTargets = new Map(); } } else { selectedPriceTargets = new Map(); } } // Save price targets to localStorage function savePriceTargets() { if (!currentItemId) return; const storageKey = `scrap2mark_price_targets_${currentItemId}`; const toStore = Object.fromEntries(selectedPriceTargets.entries()); localStorage.setItem(storageKey, JSON.stringify(toStore)); } // Toggle price target selection function togglePriceTarget(originalPrice, targetPrice, isSelected) { if (isSelected) { selectedPriceTargets.set(originalPrice, targetPrice); } else { selectedPriceTargets.delete(originalPrice); } updateSelectedTargetsDisplay(); savePriceTargets(); } // Update the display of selected targets function updateSelectedTargetsDisplay() { const targetDiv = document.getElementById('selected-targets'); if (!targetDiv) return; if (selectedPriceTargets.size === 0) { targetDiv.innerHTML = 'None selected'; targetDiv.style.color = '#666'; } else { const targets = Array.from(selectedPriceTargets.entries()) .sort((a, b) => a[1] - b[1]) // Sort by target price .map(([original, target]) => `$${target.toLocaleString()}`) .join(', '); targetDiv.innerHTML = targets; targetDiv.style.color = '#28a745'; } } // Clear all selected targets function clearAllTargets() { selectedPriceTargets.clear(); // Uncheck all checkboxes const checkboxes = document.querySelectorAll('.price-target-checkbox'); checkboxes.forEach(cb => { cb.checked = false; }); updateSelectedTargetsDisplay(); savePriceTargets(); } // Make functions global so they can be called from HTML window.togglePriceTarget = togglePriceTarget; window.clearAllTargets = clearAllTargets; // Add chart interaction functionality function addChartInteraction(listings, itemName) { const svg = document.getElementById('price-chart-svg'); const selectionRect = document.getElementById('selection-rect'); const resetZoomBtn = document.getElementById('reset-zoom'); if (!svg || !selectionRect) return; let isSelecting = false; let startX = 0; let currentX = 0; // Get chart parameters const padding = parseInt(svg.getAttribute('data-padding')); const chartWidth = parseInt(svg.getAttribute('data-chart-width')); const chartHeight = parseInt(svg.getAttribute('data-chart-height')); const displayMin = parseFloat(svg.getAttribute('data-display-min')); const displayMax = parseFloat(svg.getAttribute('data-display-max')); // Add bar hover effects const bars = svg.querySelectorAll('.chart-bar'); bars.forEach(bar => { bar.addEventListener('mouseenter', (e) => { const priceMin = parseFloat(e.target.getAttribute('data-price-min')); const priceMax = parseFloat(e.target.getAttribute('data-price-max')); const amount = parseInt(e.target.getAttribute('data-amount')); // Show tooltip for individual bar e.target.setAttribute('opacity', '1'); e.target.style.cursor = 'pointer'; // Create temporary tooltip const tooltip = document.createElement('div'); tooltip.id = 'bar-tooltip'; tooltip.innerHTML = `$${Math.round(priceMin).toLocaleString()}-$${Math.round(priceMax).toLocaleString()}<br>Quantity: ${amount.toLocaleString()}`; tooltip.style.position = 'absolute'; tooltip.style.background = 'rgba(0,0,0,0.8)'; tooltip.style.color = 'white'; tooltip.style.padding = '4px 8px'; tooltip.style.borderRadius = '4px'; tooltip.style.fontSize = '11px'; tooltip.style.pointerEvents = 'none'; tooltip.style.zIndex = '1000001'; const rect = e.target.getBoundingClientRect(); tooltip.style.left = (rect.left + rect.width / 2 + window.pageXOffset) + 'px'; tooltip.style.top = (rect.top - 40 + window.pageYOffset) + 'px'; tooltip.style.transform = 'translateX(-50%)'; document.body.appendChild(tooltip); }); bar.addEventListener('mouseleave', (e) => { e.target.setAttribute('opacity', '0.7'); const tooltip = document.getElementById('bar-tooltip'); if (tooltip) tooltip.remove(); }); }); // Selection functionality svg.addEventListener('mousedown', (e) => { const rect = svg.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Only start selection if within chart area if (x >= padding && x <= padding + chartWidth && y >= padding && y <= padding + chartHeight) { isSelecting = true; startX = x; currentX = x; selectionRect.style.display = 'block'; selectionRect.setAttribute('x', startX); selectionRect.setAttribute('y', padding); selectionRect.setAttribute('width', 0); selectionRect.setAttribute('height', chartHeight); e.preventDefault(); } }); svg.addEventListener('mousemove', (e) => { if (!isSelecting) return; const rect = svg.getBoundingClientRect(); currentX = Math.max(padding, Math.min(padding + chartWidth, e.clientX - rect.left)); const width = Math.abs(currentX - startX); const x = Math.min(startX, currentX); selectionRect.setAttribute('x', x); selectionRect.setAttribute('width', width); }); svg.addEventListener('mouseup', (e) => { if (!isSelecting) return; isSelecting = false; selectionRect.style.display = 'none'; // Calculate price range for zoom const minX = Math.min(startX, currentX); const maxX = Math.max(startX, currentX); // Only zoom if selection is wide enough if (maxX - minX > 10) { const priceRange = displayMax - displayMin; const zoomMin = displayMin + ((minX - padding) / chartWidth) * priceRange; const zoomMax = displayMin + ((maxX - padding) / chartWidth) * priceRange; // Update popup with zoomed chart (keep existing popup) updatePricePopup(listings, itemName, zoomMin, zoomMax); } }); // Reset zoom functionality if (resetZoomBtn) { resetZoomBtn.addEventListener('click', () => { // Reset zoom (keep existing popup) updatePricePopup(listings, itemName); }); } // Prevent selection outside chart area document.addEventListener('mouseup', () => { if (isSelecting) { isSelecting = false; selectionRect.style.display = 'none'; } }); } // Hide price popup function hidePricePopup() { const existingPopup = document.getElementById('price-popup'); if (existingPopup) { existingPopup.remove(); } } // Clear inventory data function clearInventoryData() { scannedItems.clear(); localStorage.removeItem(STORAGE_KEY); updateTableDisplay(); updateMarketButtonText(); log('Inventory data cleared'); } // Clear all price targets function clearAllPriceTargets() { // Get all localStorage keys that start with our price target prefix const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith('scrap2mark_price_targets_')) { keysToRemove.push(key); } } // Remove all price target keys keysToRemove.forEach(key => localStorage.removeItem(key)); // Update the display to reflect changes updateTableDisplay(); log(`Cleared ${keysToRemove.length} price targets`); } // Export inventory data // Get selected price targets for an item function getSelectedPriceTargets(itemId) { const storageKey = `scrap2mark_price_targets_${itemId}`; const stored = localStorage.getItem(storageKey); if (stored) { try { const parsed = JSON.parse(stored); return Object.entries(parsed).map(([price, data]) => ({ price: parseFloat(price), seller: data.seller, amount: data.amount })); } catch (e) { return []; } } return []; } // Export inventory data with enhanced information function exportInventoryData() { const data = Array.from(scannedItems.values()).map(item => { const itemData = { id: item.id, name: item.name, category: item.category, quantity: item.quantity, equipped: item.equipped, armoryId: item.armoryId, tradeable: item.tradeable }; // Add market data if available const marketInfo = marketData.get(item.id.toString()); if (marketInfo) { const stats = calculatePriceStats(marketInfo.listings); if (stats) { // Calculate the low-end price used in the Price column const lowPrice = Math.min(stats.p25, stats.min * 1.2); const displayPrice = Math.round(lowPrice); // Calculate estimated value const estimatedValue = calculateEstimatedValue(item.id, item.quantity); itemData.marketData = { displayPrice: displayPrice, // The low-end price shown in Price column averagePrice: Math.round(stats.average), minPrice: stats.min, maxPrice: stats.max, medianPrice: stats.median, q1Price: stats.p25, q3Price: stats.p75, estimatedTotalValue: estimatedValue, totalListings: stats.totalListings, filteredListings: stats.listings, uniqueSellers: stats.uniqueSellers, totalUniqueSellers: stats.totalUniqueSellers, outliersFiltered: stats.outliersFiltered, sellersFiltered: stats.sellersFiltered, priceCapFiltered: stats.priceCapFiltered, statFiltered: stats.statFiltered, totalQuantity: stats.totalQuantity, fetchedAt: new Date(marketInfo.timestamp).toISOString(), ageInMinutes: Math.round((Date.now() - marketInfo.timestamp) / (1000 * 60)) }; } } // Add selected price targets if any const selectedTargets = getSelectedPriceTargets(item.id); if (selectedTargets.length > 0) { itemData.selectedPriceTargets = selectedTargets; // Calculate total value of selected targets const targetValue = selectedTargets.reduce((sum, target) => sum + (target.price * target.amount), 0); itemData.selectedTargetsTotalValue = targetValue; // Calculate how many of this item's quantity could be sold at target prices const targetQuantity = selectedTargets.reduce((sum, target) => sum + target.amount, 0); itemData.selectedTargetsQuantity = targetQuantity; if (targetQuantity > 0) { itemData.averageTargetPrice = Math.round(targetValue / targetQuantity); } } return itemData; }); // Save to localStorage localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); // Create export summary const totalItems = data.length; const itemsWithMarketData = data.filter(item => item.marketData).length; const itemsWithTargets = data.filter(item => item.selectedPriceTargets).length; const totalEstimatedValue = data.reduce((sum, item) => sum + (item.marketData?.estimatedTotalValue || 0), 0); const totalTargetValue = data.reduce((sum, item) => sum + (item.selectedTargetsTotalValue || 0), 0); // Add summary to export data const exportData = { exportInfo: { exportedAt: new Date().toISOString(), scriptVersion: "Scrap2Mark v2.0", totalItems: totalItems, itemsWithMarketData: itemsWithMarketData, itemsWithPriceTargets: itemsWithTargets, totalEstimatedValue: totalEstimatedValue, totalSelectedTargetValue: totalTargetValue, priceCalculationMethod: "Low-end pricing (min of P25 or 20% above minimum)" }, items: data }; // Create downloadable file const dataStr = JSON.stringify(exportData, null, 2); const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr); const exportFileDefaultName = `scrap2mark_export_${new Date().toISOString().split('T')[0]}_${totalItems}items.json`; const linkElement = document.createElement('a'); linkElement.setAttribute('href', dataUri); linkElement.setAttribute('download', exportFileDefaultName); linkElement.click(); log(`Exported ${totalItems} items (${itemsWithMarketData} with market data, ${itemsWithTargets} with price targets) to ${exportFileDefaultName}. Total estimated value: $${totalEstimatedValue.toLocaleString()}`); } // Main scanning function (now only scans visible items) async function startInventoryScan() { if (isScanning) { log('Scan already in progress'); return; } isScanning = true; const startBtn = document.getElementById('start-scan'); const statusDiv = document.getElementById('scan-status'); if (startBtn) startBtn.disabled = true; if (statusDiv) statusDiv.textContent = 'Scanning visible items...'; try { log('Starting inventory scan of visible items'); // Wait for items to be loaded in current category await waitForElement('#all-items li[data-item], li[data-item]', 5000); // Scan currently visible items only const newItemsFound = checkForNewItems(); updateTableDisplay(); // Update scroll reminder based on current loading status updateScrollReminder(); // Save scanned items after scanning saveScannedItems(); log(`Scan completed. Found ${newItemsFound} new items (Total: ${scannedItems.size})`); updateMarketButtonText(); // Update button with new item count if (statusDiv) { // Show scan result temporarily, then update with full breakdown statusDiv.textContent = `Found ${newItemsFound} new items. Total: ${scannedItems.size} items`; setTimeout(() => { updateTableDisplay(); // This will restore the detailed breakdown }, 2000); } } catch (error) { log(`Scan failed: ${error.message}`); if (statusDiv) statusDiv.textContent = `Scan failed: ${error.message}`; } finally { isScanning = false; if (startBtn) startBtn.disabled = false; } } // Load previously saved data function loadSavedData() { try { const savedData = localStorage.getItem(STORAGE_KEY); if (savedData) { const items = JSON.parse(savedData); items.forEach(item => { const key = `${item.id}_${item.armoryId || 'base'}`; scannedItems.set(key, item); }); log(`Loaded ${items.length} saved items`); // Update table display and button text after loading data setTimeout(() => { updateTableDisplay(); updateMarketButtonText(); }, 100); } else { log('No saved data found'); } } catch (error) { log(`Failed to load saved data: ${error.message}`); } } // Initialize script function init() { log('Initializing Scrap2Mark'); // Check if we're on a supported page const isInventoryPage = window.location.href.includes('/item.php'); const isMarketPage = window.location.href.includes('/page.php?sid=ItemMarket'); if (!isInventoryPage && !isMarketPage) { log('Not on supported page, script will not run'); return; } // Set up network monitoring for item loading detection setupNetworkMonitoring(); // Load API key and items data loadApiKey(); loadApiItemsData(); loadMarketData(); // Auto-fetch API data once per day if we have an API key if (apiKey) { const lastFetch = localStorage.getItem('scrap2mark_last_api_fetch'); const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000); if (!lastFetch || parseInt(lastFetch) < oneDayAgo) { log('Auto-fetching API data (daily update)'); setTimeout(() => { handleFetchApiData().then(() => { localStorage.setItem('scrap2mark_last_api_fetch', Date.now().toString()); }); }, 2000); // Delay to let page load } } // Load saved data and ignored items loadSavedData(); loadIgnoredItems(); // Update page-specific functionality if (isInventoryPage) { log('Running on inventory page - full scanning functionality available'); } else if (isMarketPage) { log('Running on market page - posting functionality available'); // Hide inventory-specific controls on market page setTimeout(() => { const scanControls = document.getElementById('scan-controls'); if (scanControls) { // Hide scan button but keep other controls const startScanBtn = document.getElementById('start-scan'); if (startScanBtn) { startScanBtn.style.display = 'none'; } // Update the manual instruction text const manualText = floatingTable.querySelector('div[style*="font-size: 11px"]'); if (manualText && manualText.textContent.includes('Scroll down')) { manualText.textContent = 'Market page: Use data from inventory scanning and price targets to post items.'; } } }, 1000); } // Create floating table (always available for data management) createFloatingTable(); // Update API status display updateApiStatusDisplay(); // Update button visibility updateApiButtonVisibility(); // Add CSS for better styling const style = document.createElement('style'); style.textContent = ` #inventory-scanner-table button:hover { opacity: 0.8; } #inventory-scanner-table table th { position: sticky; top: 0; background: #f8f9fa; } /* Enhanced button hover effects for all styled buttons */ button[style*="linear-gradient"]:hover { transform: translateY(-1px) !important; box-shadow: 0 4px 8px rgba(0,0,0,0.2) !important; filter: brightness(1.1) !important; } /* Title bar cursor styling */ #inventory-scanner-table .title-bar, #inventory-scanner-table .title-bar * { cursor: pointer !important; } #inventory-scanner-table table tr:hover { background-color: #f8f9fa; } #api-key-input { font-family: monospace; } #inventory-scanner-table { resize: both; overflow: hidden; } #inventory-scanner-table table { table-layout: fixed; width: 100%; } #inventory-scanner-table table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #inventory-scanner-table table td:nth-child(2) { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .price-chart-hover { transition: transform 0.2s ease; } .price-chart-hover:hover { transform: scale(1.1); } #price-popup { background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); border: 1px solid #ddd; animation: fadeIn 0.2s ease-out; } @keyframes fadeIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } .ignore-btn { font-size: 10px !important; padding: 2px 6px !important; border: 1px solid #ccc !important; border-radius: 3px !important; cursor: pointer !important; transition: all 0.2s !important; font-weight: bold !important; min-width: 60px !important; } .ignore-btn.ignore { background: #6c757d !important; color: white !important; border-color: #6c757d !important; } .ignore-btn.ignore:hover { background: #5a6268 !important; border-color: #545b62 !important; transform: scale(1.05) !important; } .ignore-btn.unignore { background: #dc3545 !important; color: white !important; border-color: #dc3545 !important; animation: pulse 2s infinite !important; } .ignore-btn.unignore:hover { background: #c82333 !important; border-color: #bd2130 !important; transform: scale(1.05) !important; animation: none !important; } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(220, 53, 69, 0); } 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); } } `; document.head.appendChild(style); log('Torn Inventory Scanner initialized'); } // Show toast notification function showToast(message, type = 'info') { const toast = document.createElement('div'); const colors = { success: '#28a745', error: '#dc3545', warning: '#ffc107', info: '#17a2b8' }; toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: ${colors[type] || colors.info}; color: ${type === 'warning' ? '#212529' : 'white'}; padding: 12px 16px; border-radius: 5px; z-index: 100002; font-size: 14px; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.3); max-width: 400px; word-wrap: break-word; animation: slideIn 0.3s ease-out; `; toast.textContent = message; // Add animation keyframes if not already added if (!document.querySelector('#toast-animations')) { const animationStyle = document.createElement('style'); animationStyle.id = 'toast-animations'; animationStyle.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(animationStyle); } document.body.appendChild(toast); // Remove toast after 4 seconds setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease-out'; setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); }, 4000); } // Load all price targets by item name function loadAllPriceTargets() { const allTargets = {}; // Go through all scanned items and check for saved price targets for (const [key, item] of scannedItems) { // Extract item ID from the key (format: "itemId_armoryId") const itemId = key.split('_')[0]; const storageKey = `scrap2mark_price_targets_${itemId}`; const stored = localStorage.getItem(storageKey); if (stored) { try { const parsed = JSON.parse(stored); const targets = new Map(Object.entries(parsed).map(([k, v]) => [parseFloat(k), v])); // Find the selected price target for (const [price, isSelected] of targets) { if (isSelected) { allTargets[item.name] = { selectedPrice: price, itemId: itemId }; break; } } } catch (e) { // Silently ignore price target loading errors } } } return allTargets; } // Get filtered items based on current filter settings function getFilteredItems() { const showNonTradeableCheckbox = document.getElementById('show-non-tradeable'); const showIgnoredCheckbox = document.getElementById('show-ignored'); const hideLowValueCheckbox = document.getElementById('hide-low-value'); const minValueInput = document.getElementById('min-value-input'); const showNonTradeable = showNonTradeableCheckbox ? showNonTradeableCheckbox.checked : false; const showIgnored = showIgnoredCheckbox ? showIgnoredCheckbox.checked : false; const hideLowValue = hideLowValueCheckbox ? hideLowValueCheckbox.checked : false; const minValue = minValueInput ? parseFloat(minValueInput.value.replace(/[,$]/g, '')) || 0 : 0; // Get all items and apply filters const allItems = Array.from(scannedItems.values()); return allItems.filter(item => { const itemId = item.id || 'N/A'; const itemTradeable = item.tradeable; const itemIgnored = isItemIgnored(itemId); // Filter non-tradeable items if (!showNonTradeable && itemTradeable === false) { return false; } // Filter ignored items if (!showIgnored && itemIgnored) { return false; } // Filter low value items if (hideLowValue && itemTradeable === true) { const estimatedValue = calculateEstimatedValue(itemId, item.quantity); if (estimatedValue < minValue) { return false; } } return true; }); } // Market posting functionality async function handlePostToMarket() { // Check if all items are likely loaded first const loadingStatus = checkItemLoadingStatus(); if (!loadingStatus.likelyAllLoaded) { const proceedAnyway = confirm( `⚠️ WARNING: Only ${loadingStatus.currentVisibleItems} items visible. ` + `You may not have loaded all your items yet.\n\n` + `For best results:\n` + `1. Scroll down to load ALL items\n` + `2. Scan visible items again\n` + `3. Get market prices\n` + `4. Then fill market forms\n\n` + `Do you want to proceed anyway with current items?` ); if (!proceedAnyway) { return; } } // Auto-minimize the window when filling market forms const content = document.getElementById('table-content'); if (content && content.style.display !== 'none') { log('Auto-minimizing Scrap2Mark window for better workflow'); const windowState = loadWindowState(); // Minimizing content.style.display = 'none'; floatingTable.style.width = WINDOW_MINIMIZED_WIDTH; floatingTable.style.height = WINDOW_MINIMIZED_HEIGHT; floatingTable.style.resize = 'none'; // Override constraints for minimized state floatingTable.style.minWidth = WINDOW_MINIMIZED_WIDTH; floatingTable.style.minHeight = WINDOW_MINIMIZED_HEIGHT; floatingTable.style.maxWidth = WINDOW_MINIMIZED_WIDTH; floatingTable.style.maxHeight = WINDOW_MINIMIZED_HEIGHT; // Save minimized state const rect = floatingTable.getBoundingClientRect(); saveWindowState(rect.left, rect.top, windowState.width || WINDOW_DEFAULT_WIDTH, windowState.height || WINDOW_DEFAULT_HEIGHT, true); // Show a brief notification const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #17a2b8; color: white; padding: 10px 15px; border-radius: 5px; z-index: 100002; font-size: 12px; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.3); max-width: 250px; `; toast.textContent = 'Scrap2Mark minimized - Click header to restore'; document.body.appendChild(toast); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 4000); } const filteredItems = getFilteredItems(); const priceTargets = loadAllPriceTargets(); const targetedItems = filteredItems.filter(item => { return priceTargets[item.name] && priceTargets[item.name].selectedPrice; }); if (targetedItems.length === 0) { const totalItems = filteredItems.length; const priceTargetCount = Object.keys(priceTargets).length; showToast(`No items ready for posting. Found ${totalItems} filtered items, ${priceTargetCount} with price targets, but none match both criteria.`, 'warning'); return; } // Check if we're on the market page if (!window.location.href.includes('/page.php?sid=ItemMarket')) { const result = confirm(`You need to be on the Item Market page to post items. Would you like to navigate there now?\n\nFound ${targetedItems.length} items ready to post.`); if (result) { window.location.href = '/page.php?sid=ItemMarket#/addListing'; } return; } showPostToMarketDialog(targetedItems); } function showPostToMarketDialog(items) { // Create modal dialog const modalOverlay = document.createElement('div'); modalOverlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); z-index: 10001; display: flex; align-items: center; justify-content: center; `; const modal = document.createElement('div'); modal.style.cssText = ` background: #2b2b2b; border-radius: 8px; max-width: 800px; max-height: 80vh; overflow-y: auto; padding: 20px; color: #e0e0e0; border: 1px solid #555; `; const priceTargets = loadAllPriceTargets(); modal.innerHTML = ` <h3 style="margin-top: 0; color: #17a2b8;">Fill Market Posting Forms</h3> <p>This will automatically fill the quantity and price fields for ${items.length} items with price targets.</p> <p style="font-size: 12px; color: #ffc107; background: #333; padding: 8px; border-radius: 4px; margin: 10px 0;"> ⚠️ <strong>Note:</strong> This tool fills the forms but does NOT automatically submit them. You will need to manually review and submit each listing for compliance with Torn.com's terms. </p> <div style="max-height: 400px; overflow-y: auto; margin: 20px 0;"> ${items.map(item => { const target = priceTargets[item.name]; const itemQty = item.quantity || item.qty || 1; return ` <div style="display: flex; align-items: center; padding: 10px; border: 1px solid #444; margin-bottom: 5px; border-radius: 4px;"> <div style="flex: 1;"> <strong>${item.name}</strong><br> <small>Available: ${itemQty} | Target Price: $${target.selectedPrice?.toLocaleString()}</small> </div> <div style="margin-left: 10px; min-width: 140px;"> <label style="font-size: 12px; display: block; margin-bottom: 5px;">Quantity to post:</label> <div style="display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 5px;"> <button class="qty-btn-add" data-item="${item.name.replace(/"/g, '"')}" data-qty="1" style="padding: 4px 8px; font-size: 11px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">+1</button> <button class="qty-btn-add" data-item="${item.name.replace(/"/g, '"')}" data-qty="10" style="padding: 4px 8px; font-size: 11px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">+10</button> <button class="qty-btn-add" data-item="${item.name.replace(/"/g, '"')}" data-qty="100" style="padding: 4px 8px; font-size: 11px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">+100</button> <button class="qty-btn-add" data-item="${item.name.replace(/"/g, '"')}" data-qty="1000" style="padding: 4px 8px; font-size: 11px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">+1k</button> <button class="qty-btn" data-item="${item.name.replace(/"/g, '"')}" data-qty="${itemQty}" style="padding: 4px 8px; font-size: 11px; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">Max</button> <button class="qty-btn-reset" data-item="${item.name.replace(/"/g, '"')}" style="padding: 4px 8px; font-size: 11px; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer;">✗</button> </div> <input type="number" id="qty-${item.name.replace(/"/g, '"').replace(/[^a-zA-Z0-9]/g, '_')}" min="0" max="${itemQty}" value="0" style="width: 100%; padding: 4px; background: #444; color: #e0e0e0; border: 1px solid #666; border-radius: 3px; font-size: 12px;"> </div> </div> `; }).join('')} </div> <div style="text-align: center; margin-top: 20px;"> <button id="confirm-post" style="background: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-right: 10px;">Fill Forms</button> <button id="cancel-post" style="background: #6c757d; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">Cancel</button> </div> `; modalOverlay.appendChild(modal); document.body.appendChild(modalOverlay); // Add event listeners for quantity buttons modal.querySelectorAll('.qty-btn').forEach(btn => { btn.addEventListener('click', () => { const itemName = btn.dataset.item; const qty = parseInt(btn.dataset.qty); const sanitizedName = itemName.replace(/[^a-zA-Z0-9]/g, '_'); const input = document.getElementById(`qty-${sanitizedName}`); if (input) { input.value = qty; // Add visual feedback input.style.background = '#28a745'; setTimeout(() => { input.style.background = '#444'; }, 200); } else { console.warn(`Could not find input for item: ${itemName} (ID: qty-${sanitizedName})`); } }); }); // Add event listeners for quantity increment buttons modal.querySelectorAll('.qty-btn-add').forEach(btn => { btn.addEventListener('click', () => { const itemName = btn.dataset.item; const addQty = parseInt(btn.dataset.qty); const sanitizedName = itemName.replace(/[^a-zA-Z0-9]/g, '_'); const input = document.getElementById(`qty-${sanitizedName}`); if (input) { const currentQty = parseInt(input.value) || 0; const maxQty = parseInt(input.max); const newQty = Math.min(currentQty + addQty, maxQty); input.value = newQty; // Add visual feedback input.style.background = '#007bff'; setTimeout(() => { input.style.background = '#444'; }, 200); } else { console.warn(`Could not find input for item: ${itemName} (ID: qty-${sanitizedName})`); } }); }); // Add event listeners for quantity reset buttons modal.querySelectorAll('.qty-btn-reset').forEach(btn => { btn.addEventListener('click', () => { const itemName = btn.dataset.item; const sanitizedName = itemName.replace(/[^a-zA-Z0-9]/g, '_'); const input = document.getElementById(`qty-${sanitizedName}`); if (input) { input.value = 0; // Add visual feedback input.style.background = '#dc3545'; setTimeout(() => { input.style.background = '#444'; }, 200); } else { console.warn(`Could not find input for item: ${itemName} (ID: qty-${sanitizedName})`); } }); }); // Handle confirm posting document.getElementById('confirm-post').addEventListener('click', () => { const postingData = items.map(item => { const sanitizedName = item.name.replace(/[^a-zA-Z0-9]/g, '_'); const qtyInput = document.getElementById(`qty-${sanitizedName}`); const quantity = qtyInput ? parseInt(qtyInput.value) : 1; return { name: item.name, itemId: item.id, quantity: quantity, price: priceTargets[item.name].selectedPrice }; }).filter(data => data.quantity > 0); modalOverlay.remove(); executeMarketPosting(postingData); }); document.getElementById('cancel-post').addEventListener('click', () => { modalOverlay.remove(); }); // Close on overlay click modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) { modalOverlay.remove(); } }); } async function executeMarketPosting(postingData) { if (postingData.length === 0) { showToast('No items to post', 'warning'); return; } showToast(`Starting to fill forms for ${postingData.length} items...`, 'info'); let successCount = 0; let errorCount = 0; for (const [index, item] of postingData.entries()) { try { showToast(`Filling form for ${item.name} (${index + 1}/${postingData.length})...`, 'info'); const success = await postSingleItem(item); if (success) { successCount++; } else { errorCount++; showToast(`✗ Failed to fill form for ${item.name}`, 'error'); } // Wait between posts to avoid overwhelming the interface if (index < postingData.length - 1) { await new Promise(resolve => setTimeout(resolve, 1500)); } } catch (error) { errorCount++; console.error(`Error filling form for ${item.name}:`, error); showToast(`✗ Error filling form for ${item.name}: ${error.message}`, 'error'); } } // Final summary with instructions const summary = `Form filling complete! Filled: ${successCount}, Errors: ${errorCount}`; showToast(summary, successCount > 0 ? 'success' : 'error'); if (successCount > 0) { // Show manual submission instruction setTimeout(() => { showToast('📝 Forms filled! Please review and manually submit each listing.', 'info'); }, 2000); } } async function postSingleItem(item) { // Wait for the page to be ready await waitForMarketPageReady(); // Find the item row in the market interface const itemRow = findMarketItemRow(item.itemId, item.name); if (!itemRow) { throw new Error(`Could not find item ${item.name} in market interface`); } // Fill in quantity using the visible input (not the hidden one) const qtyInputs = itemRow.querySelectorAll('input[placeholder="Qty"]'); const visibleQtyInput = Array.from(qtyInputs).find(input => input.type !== 'hidden'); if (visibleQtyInput) { // Clear the field first visibleQtyInput.value = ''; visibleQtyInput.focus(); // Set the value and trigger events visibleQtyInput.value = item.quantity; visibleQtyInput.dispatchEvent(new Event('input', { bubbles: true })); visibleQtyInput.dispatchEvent(new Event('change', { bubbles: true })); // Simulate typing for better compatibility const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, data: item.quantity.toString() }); visibleQtyInput.dispatchEvent(inputEvent); } // Fill in price using the visible input (not the hidden one) const priceInputs = itemRow.querySelectorAll('input[placeholder="Price"]'); const visiblePriceInput = Array.from(priceInputs).find(input => input.type !== 'hidden'); if (visiblePriceInput) { // Clear the field first visiblePriceInput.value = ''; visiblePriceInput.focus(); // Set the value and trigger events visiblePriceInput.value = item.price; visiblePriceInput.dispatchEvent(new Event('input', { bubbles: true })); visiblePriceInput.dispatchEvent(new Event('change', { bubbles: true })); // Simulate typing for better compatibility const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, data: item.price.toString() }); visiblePriceInput.dispatchEvent(inputEvent); } // Show a confirmation message showToast(`✓ Filled ${item.name}: Qty ${item.quantity}, Price $${item.price.toLocaleString()}`, 'success'); // Note: Manual submission required - the script fills the forms but doesn't auto-submit // This is safer and follows Torn.com's terms of service better return true; } async function waitForMarketPageReady() { return new Promise((resolve) => { const checkReady = () => { const itemRows = document.querySelectorAll('.itemRowWrapper___cFs4O'); if (itemRows.length > 0) { resolve(); } else { setTimeout(checkReady, 500); } }; checkReady(); }); } function findMarketItemRow(itemId, itemName) { // Look for the item row by item name or ID const itemRows = document.querySelectorAll('.itemRowWrapper___cFs4O, .itemRow___Mf7bO'); for (const row of itemRows) { // Check for item name in various elements const viewButton = row.querySelector('.viewInfoButton___jOjRg, button[aria-label*="View"]'); if (viewButton) { const ariaLabel = viewButton.getAttribute('aria-label'); if (ariaLabel) { // Try exact match first if (ariaLabel.includes(itemName)) { return row.closest('.itemRowWrapper___cFs4O') || row; } // Try partial match for items with long names const cleanItemName = itemName.replace(/[^\w\s]/g, '').toLowerCase(); const cleanAriaLabel = ariaLabel.replace(/[^\w\s]/g, '').toLowerCase(); if (cleanAriaLabel.includes(cleanItemName) || cleanItemName.includes(cleanAriaLabel.split(' ')[0])) { return row.closest('.itemRowWrapper___cFs4O') || row; } } } // Alternative: check for item ID in controls attribute const controls = viewButton?.getAttribute('aria-controls'); if (controls && controls.includes(`itemInfo-${itemId}-`)) { return row.closest('.itemRowWrapper___cFs4O') || row; } // Check for item name in text content as fallback const titleElement = row.querySelector('.title___Xo6Pm, .itemName, [class*="title"]'); if (titleElement && titleElement.textContent.includes(itemName)) { return row.closest('.itemRowWrapper___cFs4O') || row; } } // If no exact match, try a broader search by scanning all text content for (const row of itemRows) { if (row.textContent.includes(itemName)) { // Double-check this isn't a false positive by looking for input fields const hasInputs = row.querySelectorAll('input[placeholder="Qty"], input[placeholder="Price"]').length >= 2; if (hasInputs) { return row.closest('.itemRowWrapper___cFs4O') || row; } } } return null; } // Update API status display function updateApiStatusDisplay() { const apiStatus = document.getElementById('api-status'); if (!apiStatus) return; if (apiKey) { apiStatus.textContent = 'API key loaded'; apiStatus.style.color = '#28a745'; } else { apiStatus.textContent = 'No API key'; apiStatus.style.color = '#dc3545'; } if (apiItemsData) { const itemCount = Object.keys(apiItemsData).length; apiStatus.textContent += ` | ${itemCount} items cached`; } } // Wait for page to be ready and initialize if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();