您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances Torn's Target list with hospital timers, travel status details, and sorting options
// ==UserScript== // @name Torn Target List Enhanced // @namespace xentac // @version 1.1.0 // @description Enhances Torn's Target list with hospital timers, travel status details, and sorting options // @author xentac (optimized version) // @license MIT // @match https://www.torn.com/page.php?sid=list&type=targets* // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect api.torn.com // ==/UserScript== (function() { 'use strict'; // Configuration const CONFIG = { storageKey: 'xentac-torn_playerlist_enhanced-apikey', defaultKey: '###PDA-APIKEY###', refreshInterval: 15000, // Refresh player list every 15 seconds statusCheckInterval: 20000, // Check player statuses every 20 seconds hospitalWarningThreshold: 60, // 1 minute enableSorting: true, maxConcurrentRequests: 5, // Limit concurrent API requests requestDelay: 500, // Delay between API requests in ms cacheTime: 30000, // Cache player data for 30 seconds batchSize: 10, // Number of players to check in each batch playerCheckCycle: 120000, // Full cycle time to check all players (2 minutes) }; // Country abbreviations lookup const COUNTRY_ABBREVIATIONS = { 'South Africa': 'SA', 'Cayman Islands': 'CI', 'United Kingdom': 'UK', 'Argentina': 'Arg', 'Switzerland': 'Switz', 'Mexico': 'Mex', 'Canada': 'Can', 'Hawaii': 'HI', 'Japan': 'JP', 'China': 'CN', 'UAE': 'UAE', 'United Arab Emirates': 'UAE' }; // Status sort priorities (lower = higher priority) const STATUS_PRIORITIES = { 'Hospital': 1, 'Jail': 2, 'Returning': 3, 'Abroad': 4, 'Traveling': 5, 'Offline': 6, 'Online': 7, 'Default': 10 }; // Styles GM_addStyle(` .playerlist_highlight { background-color: rgba(255, 165, 0, 0.3) !important; } .playerlist_traveling .status___o6u8R span { color: #F287FF !important; } .playerlist_hospital_timer { font-weight: bold; } .playerlist_sort_controls { display: flex; justify-content: flex-end; margin: 5px 0; gap: 5px; padding: 5px; } .playerlist_status_changed { animation: flash-status 1s; } @keyframes flash-status { 0%, 100% { background-color: transparent; } 50% { background-color: rgba(255, 255, 0, 0.3); } } `); // State let apiKey = localStorage.getItem(CONFIG.storageKey) ?? CONFIG.defaultKey; const hospitalTimers = new Map(); const playerCache = new Map(); const requestQueue = []; let processingQueue = false; let refreshInterval = null; let statusCheckInterval = null; let playerStatusMap = new Map(); // Track player statuses for change detection let pendingUpdates = new Set(); // Track player IDs with pending updates // Track player check status const playerCheckTracker = { lastFullCycleTime: 0, playerLastChecked: new Map(), currentIndex: 0, // Mark a player as checked markChecked(playerId) { this.playerLastChecked.set(playerId, Date.now()); }, // Get time since last check getTimeSinceLastCheck(playerId) { const lastChecked = this.playerLastChecked.get(playerId) || 0; return Date.now() - lastChecked; }, // Reset the cycle if needed resetCycleIfNeeded() { const now = Date.now(); if (now - this.lastFullCycleTime > CONFIG.playerCheckCycle) { this.lastFullCycleTime = now; this.currentIndex = 0; } } }; // Register menu command for API key try { GM_registerMenuCommand("Set API Key", () => promptForApiKey()); } catch (error) { console.log("Menu command registration failed, likely running in Torn PDA"); } // API key management function promptForApiKey() { const userInput = prompt( "Please enter a PUBLIC API Key for basic player information:", apiKey === CONFIG.defaultKey ? "" : apiKey ); if (userInput && userInput.length === 16) { apiKey = userInput; localStorage.setItem(CONFIG.storageKey, userInput); return true; } else if (userInput !== null) { alert("Invalid API key. Please enter a 16-character key."); } return false; } // Check if API key is valid function validateApiKey() { if (apiKey === CONFIG.defaultKey || apiKey.length !== 16) { return promptForApiKey(); } return true; } // Format time remaining function formatTimeRemaining(seconds) { if (seconds <= 0) return "Okay"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } // Process travel status text function processTravelStatus(description) { if (!description) return "Unknown"; // Replace country names with abbreviations let result = description; for (const [full, abbr] of Object.entries(COUNTRY_ABBREVIATIONS)) { result = result.replace(full, abbr); } // Format based on travel direction if (result.includes("Traveling to ")) { return "► " + result.split("Traveling to ")[1]; } else if (result.includes("In ")) { return result.split("In ")[1]; } else if (result.includes("Returning")) { return "◄ " + result.split("Returning to Torn from ")[1]; } else if (result.includes("Traveling")) { return "Traveling"; } return result; } // Process API request queue async function processRequestQueue() { if (processingQueue || requestQueue.length === 0) return; processingQueue = true; try { // Process up to maxConcurrentRequests at a time const batch = requestQueue.splice(0, CONFIG.maxConcurrentRequests); // Execute requests in parallel with Promise.all await Promise.all(batch.map(async (request, index) => { // Add delay between requests if (index > 0) { await new Promise(resolve => setTimeout(resolve, CONFIG.requestDelay)); } try { const data = await fetchPlayerData(request.playerId); request.resolve(data); } catch (error) { request.reject(error); } })); } finally { processingQueue = false; // Continue processing if there are more requests if (requestQueue.length > 0) { setTimeout(processRequestQueue, CONFIG.requestDelay); } } } // Queue player data request function queuePlayerDataRequest(playerId, forceFresh = false) { return new Promise((resolve, reject) => { // Check cache first (unless forceFresh is true) if (!forceFresh) { const cachedData = playerCache.get(playerId); if (cachedData && (Date.now() - cachedData.timestamp < CONFIG.cacheTime)) { resolve(cachedData.data); return; } } // Add to queue requestQueue.push({ playerId, resolve, reject }); // Start processing queue if not already processing if (!processingQueue) { processRequestQueue(); } }); } // Fetch player data from API async function fetchPlayerData(playerId) { if (!validateApiKey()) return null; try { const response = await fetch(`https://api.torn.com/user/${playerId}?selections=profile,basic&key=${apiKey}`); const data = await response.json(); if (data.error) { console.error(`API Error: ${data.error.error}`); return null; } // Cache the result playerCache.set(playerId, { data: data, timestamp: Date.now() }); return data; } catch (error) { console.error("Error fetching player data:", error); return null; } } // Check if player status has changed function hasStatusChanged(playerId, newStatus) { if (!playerStatusMap.has(playerId)) return true; const oldStatus = playerStatusMap.get(playerId); // Check if state has changed if (oldStatus.state !== newStatus.state) return true; // If in hospital, check if time has changed significantly if (newStatus.state === 'Hospital' && oldStatus.state === 'Hospital') { // If hospital time changed by more than 5 seconds, consider it changed return Math.abs(oldStatus.until - newStatus.until) > 5; } return false; } // Update player status in our tracking map function updatePlayerStatus(playerId, status) { playerStatusMap.set(playerId, { state: status.state, until: status.until, description: status.description }); } // Update player row with status information async function updatePlayerRow(playerRow, forceFresh = false) { try { // Extract player ID from the row const playerLink = playerRow.querySelector('a[href^="/profiles.php"]'); if (!playerLink) return; const playerId = playerLink.href.split('XID=')[1]; if (!playerId) return; // Skip if already being updated if (pendingUpdates.has(playerId)) return; pendingUpdates.add(playerId); try { // Skip if already processed recently and not forcing fresh data if (!forceFresh) { const lastUpdate = parseInt(playerRow.getAttribute('data-last-update') || '0'); const timeSinceUpdate = Date.now() - lastUpdate; // If updated in the last 15 seconds and not in hospital, skip if (timeSinceUpdate < 15000 && playerRow.getAttribute('data-status') !== 'Hospital') { return; } // If updated in the last 5 seconds even if in hospital, skip if (timeSinceUpdate < 5000) { return; } } // Get player data const playerData = await queuePlayerDataRequest(playerId, forceFresh); if (!playerData) return; // Check if status has changed const statusChanged = hasStatusChanged(playerId, playerData.status); // Update our status tracking updatePlayerStatus(playerId, playerData.status); // Mark as processed playerRow.classList.add('playerlist_processed'); playerRow.setAttribute('data-last-update', Date.now().toString()); // Update status cell const statusCell = playerRow.querySelector('.status___o6u8R'); if (!statusCell) return; const statusSpan = statusCell.querySelector('span'); if (!statusSpan) return; // Set data attributes for sorting playerRow.setAttribute('data-player-id', playerId); playerRow.setAttribute('data-last-action', playerData.last_action?.timestamp || '0'); // Clear any existing timer if (hospitalTimers.has(playerId)) { clearInterval(hospitalTimers.get(playerId)); hospitalTimers.delete(playerId); } // Process based on status const status = playerData.status || { state: 'Unknown' }; playerRow.setAttribute('data-status', status.state); playerRow.setAttribute('data-until', status.until || '0'); // If status changed, add flash effect if (statusChanged && playerRow.classList.contains('playerlist_processed')) { playerRow.classList.remove('playerlist_status_changed'); // Force reflow void playerRow.offsetWidth; playerRow.classList.add('playerlist_status_changed'); } switch (status.state) { case 'Hospital': playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Hospital); playerRow.classList.remove('playerlist_traveling'); // Update the status span to show "Hospital" if it doesn't already if (!statusSpan.classList.contains('user-red-status')) { statusSpan.className = ''; statusSpan.classList.add('user-red-status'); } // Create hospital timer const timerFunction = () => { const timeRemaining = Math.round(status.until - Date.now() / 1000) - 2; if (timeRemaining <= 0) { // Don't automatically change to Okay - we'll let the status check handle this statusSpan.textContent = "Checking..."; playerRow.classList.remove('playerlist_highlight'); clearInterval(hospitalTimers.get(playerId)); hospitalTimers.delete(playerId); // Force a fresh check after hospital timer expires setTimeout(() => { const row = document.querySelector(`li[data-player-id="${playerId}"]`); if (row) updatePlayerRow(row, true); }, 2000); return; } statusSpan.textContent = formatTimeRemaining(timeRemaining); statusSpan.classList.add('playerlist_hospital_timer'); // Highlight if close to release if (timeRemaining < CONFIG.hospitalWarningThreshold) { playerRow.classList.add('playerlist_highlight'); } else { playerRow.classList.remove('playerlist_highlight'); } }; // Run immediately and then set interval timerFunction(); hospitalTimers.set(playerId, setInterval(timerFunction, 1000)); break; case 'Jail': playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Jail); playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight'); // Update the status span if (!statusSpan.classList.contains('user-red-status')) { statusSpan.className = ''; statusSpan.classList.add('user-red-status'); statusSpan.textContent = "Jail"; } break; case 'Traveling': statusSpan.textContent = processTravelStatus(status.description); playerRow.classList.add('playerlist_traveling'); playerRow.classList.remove('playerlist_highlight'); // Update the status span if (!statusSpan.classList.contains('user-blue-status')) { statusSpan.className = ''; statusSpan.classList.add('user-blue-status'); } if (status.description && status.description.includes('Returning')) { playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Returning); } else { playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Traveling); } break; case 'Abroad': statusSpan.textContent = processTravelStatus(status.description); playerRow.classList.add('playerlist_traveling'); playerRow.classList.remove('playerlist_highlight'); // Update the status span if (!statusSpan.classList.contains('user-blue-status')) { statusSpan.className = ''; statusSpan.classList.add('user-blue-status'); } playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Abroad); break; case 'Offline': playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Offline); playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight'); // Update the status span statusSpan.className = ''; statusSpan.textContent = "Offline"; break; case 'Online': playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Online); playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight'); // Update the status span statusSpan.className = ''; statusSpan.textContent = "Online"; break; default: playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Default); playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight'); // Update the status span statusSpan.className = ''; statusSpan.textContent = status.state || "Unknown"; break; } // If status changed and we're in a list, resort the list if (statusChanged) { const playerList = playerRow.closest('ul'); if (playerList) { sortPlayerList(playerList, 'status'); } } } finally { // Always remove from pending updates pendingUpdates.delete(playerId); } } catch (error) { console.error("Error updating player row:", error); pendingUpdates.delete(playerId); } } // Sort player list (optimized with memoization) function sortPlayerList(playerList, sortBy = 'status') { if (!CONFIG.enableSorting || !playerList) return; try { const rows = Array.from(playerList.querySelectorAll('li.tableRow___UgA6S')); if (rows.length === 0) return; // Create a map for memoizing sort values to avoid repeated DOM access const sortValueMap = new Map(); const getSortValue = (row, type) => { const key = `${row.getAttribute('data-player-id')}-${type}`; if (sortValueMap.has(key)) return sortValueMap.get(key); let value; switch (type) { case 'priority': value = parseInt(row.getAttribute('data-sort-priority') || '10'); break; case 'until': value = parseInt(row.getAttribute('data-until') || '0'); break; case 'lastAction': value = parseInt(row.getAttribute('data-last-action') || '0'); break; case 'level': value = parseInt(row.querySelector('.level___z78dn')?.textContent || '0'); break; } sortValueMap.set(key, value); return value; }; // Sort with optimized comparator rows.sort((a, b) => { switch (sortBy) { case 'status': // Sort by status priority first const priorityDiff = getSortValue(a, 'priority') - getSortValue(b, 'priority'); if (priorityDiff !== 0) return priorityDiff; // Then by time remaining (for hospital/jail) return getSortValue(a, 'until') - getSortValue(b, 'until'); case 'lastAction': return getSortValue(b, 'lastAction') - getSortValue(a, 'lastAction'); case 'level': return getSortValue(b, 'level') - getSortValue(a, 'level'); default: return 0; } }); // Use DocumentFragment for efficient DOM manipulation const fragment = document.createDocumentFragment(); rows.forEach(row => fragment.appendChild(row)); playerList.appendChild(fragment); } catch (error) { console.error("Error sorting player list:", error); } } // Find new player rows in a list function findNewPlayerRows(playerList) { if (!playerList) return []; try { // Use a more efficient selector to get only unprocessed rows return Array.from(playerList.querySelectorAll('li.tableRow___UgA6S:not(.playerlist_processed)')); } catch (error) { console.error("Error finding new player rows:", error); return []; } } // Process player list with throttling async function processPlayerList(tableWrapper) { if (!tableWrapper) return; try { // Get player list const playerList = tableWrapper.querySelector('ul'); if (!playerList) return; // Find new player rows const newPlayerRows = findNewPlayerRows(playerList); if (newPlayerRows.length === 0) return; // Process rows in batches to avoid freezing the page const batchSize = 10; for (let i = 0; i < newPlayerRows.length; i += batchSize) { const batch = newPlayerRows.slice(i, i + batchSize); // Process batch in parallel await Promise.all(batch.map(row => updatePlayerRow(row))); // Small delay between batches if (i + batchSize < newPlayerRows.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } // Sort the list sortPlayerList(playerList, 'status'); } catch (error) { console.error("Error processing player list:", error); } } // Check for status changes in visible players async function checkPlayerStatusChanges() { try { // Reset cycle if needed playerCheckTracker.resetCycleIfNeeded(); // Get all visible player rows const allPlayerRows = document.querySelectorAll('li.tableRow___UgA6S.playerlist_processed'); if (allPlayerRows.length === 0) return; const allRows = Array.from(allPlayerRows); // Determine which players to check in this batch let rowsToCheck = []; // 1. First priority: Hospital patients const hospitalRows = allRows.filter(row => row.getAttribute('data-status') === 'Hospital' ); // Add hospital patients to check list rowsToCheck = [...hospitalRows]; // 2. Second priority: Players in the current cycle segment if (rowsToCheck.length < CONFIG.batchSize) { const remainingSlots = CONFIG.batchSize - rowsToCheck.length; // Calculate how many players to check per interval to cover all in one cycle const playersPerInterval = Math.max(1, Math.ceil(allRows.length / (CONFIG.playerCheckCycle / CONFIG.statusCheckInterval))); // Get non-hospital rows that haven't been checked yet const otherRows = allRows.filter(row => !hospitalRows.includes(row) ); if (otherRows.length > 0) { // Get the next batch of players in sequence for (let i = 0; i < remainingSlots && i < playersPerInterval; i++) { const index = (playerCheckTracker.currentIndex + i) % otherRows.length; if (!rowsToCheck.includes(otherRows[index])) { rowsToCheck.push(otherRows[index]); } } // Update the current index for next time playerCheckTracker.currentIndex = (playerCheckTracker.currentIndex + playersPerInterval) % otherRows.length; } } // Check each player in the batch with parallel processing await Promise.all(rowsToCheck.map(async (row, index) => { // Add small staggered delay to prevent API rate limiting if (index > 0) { await new Promise(resolve => setTimeout(resolve, 200 * index)); } const playerId = row.getAttribute('data-player-id'); if (!playerId) return; await updatePlayerRow(row, true); // Force fresh data playerCheckTracker.markChecked(playerId); })); } catch (error) { console.error("Error checking player status changes:", error); } } // Clean up resources function cleanUp() { // Clear all hospital timers hospitalTimers.forEach((timer) => { clearInterval(timer); }); hospitalTimers.clear(); // Clear refresh interval if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; } // Clear status check interval if (statusCheckInterval) { clearInterval(statusCheckInterval); statusCheckInterval = null; } // Clear observer if (observer) { observer.disconnect(); } } // Set up mutation observer with debouncing let debounceTimer = null; const observer = new MutationObserver(mutations => { // Debounce to prevent excessive processing clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { // Check for player list changes let tableWrapperFound = false; let playerRowsAdded = false; for (const mutation of mutations) { // Check for added nodes if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // Check for player rows added to existing lists const playerList = mutation.target.closest('ul'); if (playerList) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('tableRow___UgA6S')) { playerRowsAdded = true; break; } } } // Check for new table wrappers for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const tableWrapper = node.classList?.contains('tableWrapper___Imc7p') ? node : node.querySelector('.tableWrapper___Imc7p'); if (tableWrapper) { tableWrapperFound = true; processPlayerList(tableWrapper); } } } } } // Process player lists if rows were added if (playerRowsAdded) { const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p'); tableWrappers.forEach(wrapper => { processPlayerList(wrapper); }); } }, 200); // 200ms debounce }); // Start observing with error handling try { observer.observe(document.body, { childList: true, subtree: true }); } catch (error) { console.error("Error starting observer:", error); } // Set up periodic refresh with error handling refreshInterval = setInterval(() => { try { const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p'); if (tableWrappers.length === 0) return; // Process all wrappers to check for new players tableWrappers.forEach(wrapper => { processPlayerList(wrapper); }); } catch (error) { console.error("Error in refresh interval:", error); } }, CONFIG.refreshInterval); // Set up status check interval statusCheckInterval = setInterval(() => { try { // Only run if there are player lists visible const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p'); if (tableWrappers.length > 0) { checkPlayerStatusChanges(); } } catch (error) { console.error("Error in status check interval:", error); } }, CONFIG.statusCheckInterval); // Initial run with delay setTimeout(() => { try { const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p'); tableWrappers.forEach(wrapper => { processPlayerList(wrapper); }); } catch (error) { console.error("Error in initial run:", error); } }, 1000); // 1 second delay to ensure the page has loaded // Clean up on page unload window.addEventListener('beforeunload', cleanUp); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址