您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a copy button next to user names on profile, faction, and ranked war pages for easy sharing.
当前为
// ==UserScript== // @name Torn Profile Link Formatter // @namespace GNSC4 [268863] // @version 1.5.1 // @description Adds a copy button next to user names on profile, faction, and ranked war pages for easy sharing. // @author GNSC4 [268863] // @match https://www.torn.com/profiles.php?XID=* // @match https://www.torn.com/factions.php* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // ==/UserScript== (function() { 'use strict'; // --- Global cache for live hospital data --- let hospTime = {}; // --- GM Polyfills for environments without native support --- const GNSC_setValue = typeof GM_setValue !== 'undefined' ? GM_setValue : (key, value) => localStorage.setItem(key, JSON.stringify(value)); const GNSC_getValue = typeof GM_getValue !== 'undefined' ? GM_getValue : (key, def) => JSON.parse(localStorage.getItem(key)) || def; // --- Add Styles for the UI --- if (typeof GM_addStyle !== 'undefined') { GM_addStyle(` .gnsc-copy-container { display: inline-flex; align-items: center; vertical-align: middle; gap: 5px; margin-left: 10px; } .gnsc-btn { background-color: #333; color: #DDD; border: 1px solid #555; border-radius: 5px; padding: 3px 8px; text-decoration: none; font-size: 12px; line-height: 1.5; font-weight: bold; cursor: pointer; white-space: nowrap; } .gnsc-btn:hover { background-color: #444; } .gnsc-list-btn { margin-left: 5px; cursor: pointer; font-size: 14px; display: inline-block; vertical-align: middle; width: 18px; text-align: center; } .gnsc-settings-panel { display: none; position: absolute; background-color: #2c2c2c; border: 1px solid #555; border-radius: 5px; padding: 10px; z-index: 1000; top: 100%; left: 0; min-width: 200px; } .gnsc-settings-panel div { margin-bottom: 5px; display: flex; align-items: center; } .gnsc-settings-panel label { color: #DDD; flex-grow: 1; } .gnsc-settings-panel input[type="checkbox"] { margin-left: 5px; } .gnsc-settings-panel label.disabled { color: #888; } .gnsc-settings-container { position: relative; } .gnsc-api-key-wrapper { display: flex; flex-direction: column; align-items: flex-start !important; } .gnsc-api-key-wrapper label { margin-bottom: 4px; } .gnsc-api-key-input-wrapper { display: flex; width: 100%; } .gnsc-api-key-input { width: 100%; background-color: #1e1e1e; border: 1px solid #555; color: #ddd; border-radius: 3px; padding: 2px 4px;} #gnsc-show-api-key-btn { font-size: 10px; margin-left: 4px; padding: 2px 4px; } .buttons-wrap .gnsc-list-btn { padding: 4px; font-size: 16px; height: 34px; line-height: 26px; } /* Mini profile button style */ `); } // --- Page Initialization Logic --- function initProfilePage() { const nameElement = document.querySelector('#skip-to-content'); const infoTable = document.querySelector('.basic-information .info-table'); const alreadyInjected = document.querySelector('.gnsc-copy-container'); if (nameElement && infoTable && infoTable.children.length > 5 && !alreadyInjected) { mainProfile(nameElement, infoTable); return true; } return false; } function initFactionPage() { const memberLists = document.querySelectorAll('.members-list, .enemy-list, .your-faction'); if (memberLists.length > 0) { memberLists.forEach(list => injectButtonsIntoList(list)); return true; } return false; } function initMiniProfile() { const miniProfile = document.querySelector('.profile-mini-_wrapper___Arw8R:not(.gnsc-injected), .mini-profile-wrapper:not(.gnsc-injected)'); if (miniProfile) { miniProfile.classList.add('gnsc-injected'); let attempts = 0; const maxAttempts = 25; // Try for 5 seconds const interval = setInterval(() => { const buttonContainer = miniProfile.querySelector('.buttons-wrap'); const nameLink = miniProfile.querySelector('a[href*="profiles.php?XID="]'); if (buttonContainer && nameLink && !buttonContainer.querySelector('.gnsc-list-btn')) { clearInterval(interval); const button = document.createElement('span'); button.className = 'gnsc-list-btn'; button.textContent = '📄'; button.title = 'Copy Formatted Link'; button.addEventListener('click', (e) => handleListCopyClick(e, button, miniProfile)); buttonContainer.insertAdjacentElement('afterbegin', button); } else if (attempts >= maxAttempts) { clearInterval(interval); } attempts++; }, 200); } } function injectButtonsIntoList(listElement) { const members = listElement.querySelectorAll('li.member, li.table-row, li.enemy, li.your'); members.forEach(member => { const nameLink = member.querySelector('a[href*="profiles.php"]'); if (nameLink && !member.querySelector('.gnsc-list-btn')) { const button = document.createElement('span'); button.className = 'gnsc-list-btn'; button.textContent = '📄'; button.title = 'Copy Formatted Link'; button.addEventListener('click', (e) => handleListCopyClick(e, button, member)); nameLink.insertAdjacentElement('afterend', button); } }); } // --- Profile Page Specific Functions --- function mainProfile(nameElement, infoTable) { const urlParams = new URLSearchParams(window.location.search); const userId = urlParams.get('XID'); if (!userId) return; const cleanedName = nameElement.textContent.replace("'s Profile", "").split(' [')[0].trim(); let factionLinkEl = null; let companyLinkEl = null; let activityStatus = 'Offline'; const infoListItems = infoTable.querySelectorAll('li'); infoListItems.forEach(item => { const titleEl = item.querySelector('.user-information-section .bold'); if (!titleEl) return; const title = titleEl.textContent.trim(); if (title === 'Faction') factionLinkEl = item.querySelector('.user-info-value a'); if (title === 'Job') companyLinkEl = item.querySelector('.user-info-value a'); }); const statusIconEl = document.querySelector('li[id^="icon1-profile-"], li[id^="icon2-profile-"], li[id^="icon62-profile-"]'); if (statusIconEl) { if (statusIconEl.className.includes('-Online')) activityStatus = 'Online'; else if (statusIconEl.className.includes('-Away')) activityStatus = 'Idle'; } // Check for hospital status directly on the profile page const statusDescEl = document.querySelector('.profile-status.hospital .main-desc'); const isInHospital = !!statusDescEl; const hospitalTimeStr = isInHospital ? statusDescEl.textContent.trim().replace(/\s+/g, ' ') : null; const userInfo = { id: userId, name: cleanedName, profileUrl: `https://www.torn.com/profiles.php?XID=${userId}`, attackUrl: `https://www.torn.com/loader2.php?sid=getInAttack&user2ID=${userId}`, factionUrl: factionLinkEl ? factionLinkEl.href : null, companyUrl: companyLinkEl ? companyLinkEl.href : null, activityStatus: activityStatus, isInHospital: isInHospital, hospitalTimeStr: hospitalTimeStr }; createUI(nameElement, userInfo); } function createUI(targetElement, userInfo) { const container = document.createElement('div'); container.className = 'gnsc-copy-container'; const copyButton = document.createElement('a'); copyButton.href = "#"; copyButton.className = 'gnsc-btn'; copyButton.innerHTML = '<span>Copy</span>'; copyButton.addEventListener('click', (e) => handleCopyClick(e, copyButton, userInfo)); const settingsContainer = document.createElement('div'); settingsContainer.className = 'gnsc-settings-container'; const settingsButton = document.createElement('a'); settingsButton.href = "#"; settingsButton.className = 'gnsc-btn'; settingsButton.innerHTML = '⚙️'; const settingsPanel = createSettingsPanel(userInfo); settingsButton.addEventListener('click', (e) => { e.preventDefault(); settingsPanel.style.display = settingsPanel.style.display === 'block' ? 'none' : 'block'; }); document.addEventListener('click', (e) => { if (!settingsContainer.contains(e.target)) { settingsPanel.style.display = 'none'; } }); settingsContainer.appendChild(settingsButton); settingsContainer.appendChild(settingsPanel); container.appendChild(copyButton); container.appendChild(settingsContainer); targetElement.insertAdjacentElement('afterend', container); } function createSettingsPanel(userInfo) { const panel = document.createElement('div'); panel.className = 'gnsc-settings-panel'; const settings = loadSettings(); const apiKeyWrapper = document.createElement('div'); apiKeyWrapper.className = 'gnsc-api-key-wrapper'; apiKeyWrapper.innerHTML = `<label for="gnsc-api-key">TornStats API Key</label>`; const inputWrapper = document.createElement('div'); inputWrapper.className = 'gnsc-api-key-input-wrapper'; const apiKeyInput = document.createElement('input'); apiKeyInput.type = 'password'; apiKeyInput.id = 'gnsc-api-key'; apiKeyInput.className = 'gnsc-api-key-input'; apiKeyInput.value = settings.apiKey || ''; apiKeyInput.addEventListener('input', () => { updateBattleStatsAvailability(); saveSettings(); }); const showApiKeyBtn = document.createElement('button'); showApiKeyBtn.id = 'gnsc-show-api-key-btn'; showApiKeyBtn.className = 'gnsc-btn'; showApiKeyBtn.textContent = 'Show'; showApiKeyBtn.addEventListener('click', (e) => { e.preventDefault(); const isPassword = apiKeyInput.type === 'password'; apiKeyInput.type = isPassword ? 'text' : 'password'; showApiKeyBtn.textContent = isPassword ? 'Hide' : 'Show'; }); inputWrapper.appendChild(apiKeyInput); inputWrapper.appendChild(showApiKeyBtn); apiKeyWrapper.appendChild(inputWrapper); panel.appendChild(apiKeyWrapper); panel.appendChild(document.createElement('hr')); const options = [ { key: 'attack', label: 'Attack', available: true }, { key: 'activity', label: 'Activity Status', available: true }, { key: 'faction', label: 'Faction', available: !!userInfo.factionUrl }, { key: 'company', label: 'Company', available: !!userInfo.companyUrl }, { key: 'timeRemaining', label: 'Time Remaining', available: userInfo.isInHospital }, { key: 'releaseTime', label: 'Release Time (TCT)', available: userInfo.isInHospital }, { key: 'battlestats', label: 'Battle Stats', available: true } ]; options.forEach(option => { const wrapper = document.createElement('div'); const checkbox = document.createElement('input'); const label = document.createElement('label'); checkbox.type = 'checkbox'; checkbox.id = `gnsc-check-${option.key}`; checkbox.checked = option.available && settings[option.key]; checkbox.disabled = !option.available; checkbox.addEventListener('change', () => saveSettings()); label.htmlFor = `gnsc-check-${option.key}`; label.textContent = option.label; if (!option.available) label.classList.add('disabled'); wrapper.appendChild(label); wrapper.appendChild(checkbox); panel.appendChild(wrapper); }); updateBattleStatsAvailability(); return panel; } async function handleCopyClick(e, button, userInfo) { e.preventDefault(); const settings = loadSettings(); let battleStatsStr = null; let hospitalStr = null; let statusEmoji = ''; if (settings.activity) { if (userInfo.activityStatus === 'Online') statusEmoji = '🟢 '; else if (userInfo.activityStatus === 'Idle') statusEmoji = '🟡 '; else statusEmoji = '⚫ '; } // --- HOSPITAL TIME LOGIC --- // Prioritize intercepted data (more precise, from faction pages), then fall back to parsed profile string. const releaseTimestamp = hospTime[userInfo.id] || null; if (releaseTimestamp) { // Logic for when we have a precise timestamp (from faction page intercept) const timeParts = []; if (settings.timeRemaining) { const remainingSeconds = releaseTimestamp - (Date.now() / 1000); if (remainingSeconds > 0) { timeParts.push(`In hospital for ${formatRemainingTime(remainingSeconds)}`); } } if (settings.releaseTime) { const releaseDate = new Date(releaseTimestamp * 1000); const tctTimeString = releaseDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'UTC' }); timeParts.push(`Out at ${tctTimeString} TCT`); } if (timeParts.length > 0) hospitalStr = `(${timeParts.join(' | ')})`; } else if (userInfo.hospitalTimeStr && settings.timeRemaining) { // Fallback logic for profile pages where we only have the parsed string. // Note: 'releaseTime' setting is ignored here as it cannot be calculated. hospitalStr = `(${userInfo.hospitalTimeStr})`; } if (settings.battlestats && settings.apiKey) { button.innerHTML = '<span>Fetching...</span>'; try { const spyData = await fetchTornStatsSpy(settings.apiKey, userInfo.id); const spyResult = spyData?.spy; if (spyResult?.status === true && typeof spyResult?.total !== 'undefined') { battleStatsStr = `(Str: ${formatNumber(spyResult.strength)} | Def: ${formatNumber(spyResult.defense)} | Spd: ${formatNumber(spyResult.speed)} | Dex: ${formatNumber(spyResult.dexterity)} | Total: ${formatNumber(spyResult.total)} | Spy: ${formatTimeDifference(spyResult.timestamp)})`; } else { battleStatsStr = "(Stats: N/A)"; } } catch (error) { console.error("Torn Profile Link Formatter: Failed to fetch TornStats data.", error); battleStatsStr = "(Stats: API Error)"; } } const linkedName = `<a href="${userInfo.profileUrl}">${userInfo.name} [${userInfo.id}]</a>`; const details = []; if (settings.attack) details.push(`<a href="${userInfo.attackUrl}">Attack</a>`); if (settings.faction && userInfo.factionUrl) details.push(`<a href="${userInfo.factionUrl}">Faction</a>`); if (settings.company && userInfo.companyUrl) details.push(`<a href="${userInfo.companyUrl}">Company</a>`); if (hospitalStr) details.push(hospitalStr); if (battleStatsStr) details.push(battleStatsStr); const formattedString = details.length > 0 ? `${statusEmoji}${linkedName} - ${details.join(' - ')}` : `${statusEmoji}${linkedName}`; copyToClipboard(formattedString); const originalText = '<span>Copy</span>'; button.innerHTML = '<span>Copied!</span>'; button.style.backgroundColor = '#2a633a'; setTimeout(() => { button.innerHTML = originalText; button.style.backgroundColor = ''; }, 2000); } async function handleListCopyClick(e, button, memberElement) { e.preventDefault(); e.stopPropagation(); const nameLink = memberElement.querySelector('a[href*="profiles.php"]'); if (!nameLink) return; const name = nameLink.textContent.trim(); const idMatch = nameLink.href.match(/XID=(\d+)/); if (!idMatch) return; const id = idMatch[1]; const settings = loadSettings(); let statusEmoji = ''; let healthStr = null; let battleStatsStr = null; if (settings.activity) { const statusEl = memberElement.querySelector('.userStatusWrap___ljSJG svg, li[class*="user-status-16-"]'); statusEmoji = '⚫ '; // Default if (statusEl) { const fillAttr = statusEl.getAttribute('fill'); const className = statusEl.className.toString(); if(fillAttr && fillAttr.includes('online') || className.includes('-Online')) statusEmoji = '🟢 '; else if (fillAttr && fillAttr.includes('idle') || className.includes('-Away') || className.includes('-Idle')) statusEmoji = '🟡 '; } } // Only show hospital info if we have live data from the fetch interception const releaseTimestamp = hospTime[id] || null; if (releaseTimestamp && (settings.timeRemaining || settings.releaseTime)) { const timeParts = []; if (settings.timeRemaining) { const remainingSeconds = releaseTimestamp - (Date.now() / 1000); if (remainingSeconds > 0) { timeParts.push(`In hospital for ${formatRemainingTime(remainingSeconds)}`); } } if(settings.releaseTime) { const releaseDate = new Date(releaseTimestamp * 1000); const tctTimeString = releaseDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'UTC' }); timeParts.push(`Out at ${tctTimeString} TCT`); } if (timeParts.length > 0) healthStr = `(${timeParts.join(' | ')})`; } if (settings.battlestats && settings.apiKey) { button.textContent = '...'; try { const spyData = await fetchTornStatsSpy(settings.apiKey, id); const spyResult = spyData?.spy; if (spyResult?.status === true && typeof spyResult?.total !== 'undefined') { battleStatsStr = `(Str: ${formatNumber(spyResult.strength)} | Def: ${formatNumber(spyResult.defense)} | Spd: ${formatNumber(spyResult.speed)} | Dex: ${formatNumber(spyResult.dexterity)} | Total: ${formatNumber(spyResult.total)} | Spy: ${formatTimeDifference(spyResult.timestamp)})`; } else { battleStatsStr = "(Stats: N/A)"; } } catch (error) { console.error("Torn Profile Link Formatter: Failed to fetch TornStats data.", error); battleStatsStr = "(Stats: API Error)"; } } const linkedName = `<a href="https://www.torn.com/profiles.php?XID=${id}">${name} [${id}]</a>`; const attackLink = `<a href="https://www.torn.com/loader2.php?sid=getInAttack&user2ID=${id}">Attack</a>`; const details = [attackLink]; if (healthStr) details.push(healthStr); if (battleStatsStr) details.push(battleStatsStr); const formattedString = `${statusEmoji}${linkedName} - ${details.join(' - ')}`; copyToClipboard(formattedString); button.textContent = '✅'; setTimeout(() => { button.textContent = '📄'; }, 1500); } // --- Utility Functions --- function formatNumber(num) { if (num < 1e3) return num; if (num >= 1e3 && num < 1e6) return +(num / 1e3).toFixed(2) + "K"; if (num >= 1e6 && num < 1e9) return +(num / 1e6).toFixed(2) + "M"; if (num >= 1e9 && num < 1e12) return +(num / 1e9).toFixed(2) + "B"; if (num >= 1e12 && num < 1e15) return +(num / 1e12).toFixed(2) + "T"; if (num >= 1e15) return +(num / 1e15).toFixed(2) + "Q"; }; function formatRemainingTime(totalSeconds) { if (totalSeconds <= 0) return "0s"; let timeParts = []; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = Math.floor(totalSeconds % 60); if (hours > 0) timeParts.push(`${hours}h`); if (minutes > 0) timeParts.push(`${minutes}m`); if (seconds > 0) timeParts.push(`${seconds}s`); return timeParts.join(' '); } function formatTimeDifference(timestamp) { const now = Math.floor(Date.now() / 1000); const seconds = now - timestamp; let interval = seconds / 31536000; if (interval > 1) return Math.floor(interval) + " years ago"; interval = seconds / 2592000; if (interval > 1) return Math.floor(interval) + " months ago"; interval = seconds / 86400; if (interval > 1) return Math.floor(interval) + " days ago"; interval = seconds / 3600; if (interval > 1) return Math.floor(interval) + " hours ago"; interval = seconds / 60; if (interval > 1) return Math.floor(interval) + " minutes ago"; return Math.floor(seconds) + " seconds ago"; } function fetchTornStatsSpy(apiKey, userId) { const requestUrl = `https://www.tornstats.com/api/v2/${apiKey}/spy/user/${userId}`; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: requestUrl, onload: (response) => { if (response.status === 200) { try { resolve(JSON.parse(response.responseText)); } catch(e) { reject(new Error("Failed to parse JSON from TornStats.")); } } else { reject(new Error(`API responded with status ${response.status}`)); } }, onerror: (error) => reject(error) }); }); } function loadSettings() { return GNSC_getValue('tornProfileFormatterSettings', { attack: true, faction: false, company: false, timeRemaining: true, releaseTime: true, battlestats: false, activity: true, apiKey: '' }); } function updateBattleStatsAvailability() { const battleStatsCheckbox = document.getElementById('gnsc-check-battlestats'); const battleStatsLabel = document.querySelector('label[for="gnsc-check-battlestats"]'); const apiKeyInput = document.getElementById('gnsc-api-key'); if (!battleStatsCheckbox || !battleStatsLabel || !apiKeyInput) return; const hasApiKey = !!apiKeyInput.value.trim(); battleStatsCheckbox.disabled = !hasApiKey; battleStatsLabel.classList.toggle('disabled', !hasApiKey); if (!hasApiKey) battleStatsCheckbox.checked = false; } function saveSettings() { const battleStatsCheckbox = document.getElementById('gnsc-check-battlestats'); const apiKeyInput = document.getElementById('gnsc-api-key'); const hasApiKey = !!(apiKeyInput && apiKeyInput.value.trim()); const settings = { attack: document.getElementById('gnsc-check-attack').checked, faction: document.getElementById('gnsc-check-faction')?.checked || false, company: document.getElementById('gnsc-check-company')?.checked || false, timeRemaining: document.getElementById('gnsc-check-timeRemaining')?.checked || false, releaseTime: document.getElementById('gnsc-check-releaseTime')?.checked || false, activity: document.getElementById('gnsc-check-activity').checked, battlestats: hasApiKey && battleStatsCheckbox?.checked || false, apiKey: apiKeyInput?.value || '' }; GNSC_setValue('tornProfileFormatterSettings', settings); } function copyToClipboard(text) { const tempTextarea = document.createElement('textarea'); tempTextarea.style.position = 'fixed'; tempTextarea.style.left = '-9999px'; tempTextarea.value = text; document.body.appendChild(tempTextarea); tempTextarea.select(); document.execCommand('copy'); document.body.removeChild(tempTextarea); } // --- Script Entry Point --- const observer = new MutationObserver(() => { const onProfilePage = window.location.href.includes('profiles.php'); const onFactionPage = window.location.href.includes('factions.php'); if (onProfilePage) { initProfilePage(); } else if (onFactionPage) { initFactionPage(); } initMiniProfile(); }); observer.observe(document.body, { childList: true, subtree: true }); // --- Live Data Interception --- const originalFetch = unsafeWindow.fetch; unsafeWindow.fetch = async (...args) => { const url = args[0] instanceof Request ? args[0].url : args[0]; // Define the data sources we know how to handle. const isKnownDataSource = url.includes("step=getwarusers") || url.includes("step=getProcessBarRefreshData") || url.includes("sidebarAjaxAction.php?q=sync"); // If it's not a known source, just pass the request through without processing it. if (!isKnownDataSource) { return originalFetch(...args); } const response = await originalFetch(...args); const clone = response.clone(); clone.json().then(json => { // Handle data from war lists if (json.warDesc?.members) { const members = json.warDesc.members; Object.keys(members).forEach((id) => { const status = members[id].status || members[id]; const userId = members[id].userID || id; if (status.text === "Hospital") { hospTime[userId] = status.updateAt; } else if (hospTime[userId]) { delete hospTime[userId]; } }); // Handle data from the sidebar status processor (covers sync and getProcessBarRefreshData) } else if (json.userStatuses) { const members = json.userStatuses; Object.keys(members).forEach((id) => { const status = members[id].status || members[id]; const userId = members[id].userID || id; if (status.text === "Hospital") { hospTime[userId] = status.updateAt; } else if (hospTime[userId]) { delete hospTime[userId]; } }); // Handle the specific structure for the user's own status from sidebar sync } else if (json.status?.bar?.hospital?.end) { const userId = json.user.userID; if (userId) { hospTime[userId] = json.status.bar.hospital.end; } } else if (json.user?.userID) { // If the sync call happens but the user is not in the hospital, // ensure their hospital time is cleared from the cache. const userId = json.user.userID; if (hospTime[userId]) { delete hospTime[userId]; } } }).catch(err => console.error("Torn Profile Link Formatter: Error parsing fetch JSON.", err)); return response; }; })();