您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Travel status widget with integrated API settings for Torn.com header, with robust API key management for desktop and Torn PDA.
// ==UserScript== // @name Torn Travel Widget // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Travel status widget with integrated API settings for Torn.com header, with robust API key management for desktop and Torn PDA. // @author TheProgrammer [2782979] - https://www.torn.com/profiles.php?XID=2782979 // @match *://*.torn.com/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @connect api.torn.com // @license MIT // ==/UserScript== (function() { 'use strict'; // --- PROMISE-BASED GM STORAGE & HELPERS --- const GM_get = (key, def) => new Promise(resolve => resolve(GM_getValue(key, def))); const GM_set = (key, val) => new Promise(resolve => resolve(GM_setValue(key, val))); const GM_del = (key) => new Promise(resolve => resolve(GM_deleteValue(key))); const API_KEY_STORAGE_KEY = 'torn_travel_widget_api_key'; const PDA_API_KEY_PLACEHOLDER = '###PDA-APIKEY###'; // --- PDA DETECTION --- const isPDA = typeof window.flutter_inappwebview !== 'undefined' && typeof window.flutter_inappwebview.callHandler === 'function'; // --- STYLES --- GM_addStyle(` .ttw-header-button-li { position: relative; } .ttw-popup { position: fixed; top: 60px; right: 10px; width: 350px; background-color: #333; border: 1px solid #555; border-radius: 5px; box-shadow: 0 5px 15px rgba(0,0,0,0.5); z-index: 10000; color: #ccc; font-family: 'Signika', 'Verdana', sans-serif; padding: 10px; display: block; } .ttw-popup-hidden { display: none !important; } .ttw-popup-content { font-size: 12px; } .ttw-popup-content .error { color: #ff6347; } .ttw-popup-content .success { color: #90ee90; } .ttw-tab-container { margin-bottom: 10px; } .ttw-tab-buttons { display: flex; border-bottom: 1px solid #444; margin-bottom: 10px; } .ttw-tab-button { flex: 1; padding: 8px 12px; background: transparent; border: none; color: #ccc; cursor: pointer; font-size: 11px; border-bottom: 2px solid transparent; } .ttw-tab-button.active { color: #fff; border-bottom-color: #4CAF50; } .ttw-tab-button:hover { background-color: #444; } .ttw-tab-content { display: none; } .ttw-tab-content.active { display: block; } .ttw-settings-tab .tos-table { width: 100%; border-collapse: collapse; font-size: 10px; margin: 10px 0; } .ttw-settings-tab .tos-table th, .ttw-settings-tab .tos-table td { border: 1px solid #555; padding: 4px; text-align: left; } .ttw-settings-tab input[type="password"] { width: 100%; padding: 6px; border-radius: 3px; border: 1px solid #555; background-color: #222; color: white; margin-bottom: 10px; box-sizing: border-box; } .ttw-settings-tab .api-button-group { display: flex; gap: 8px; justify-content: flex-end; } .ttw-settings-tab .api-button { padding: 5px 10px; border: none; border-radius: 3px; cursor: pointer; font-size: 11px; } .ttw-settings-tab .save-key { background-color: #4CAF50; color: white; } .ttw-settings-tab .clear-key { background-color: #f44336; color: white; } .ttw-settings-tab .pda-note { color: #87ceeb; font-size: 11px; margin-top: -5px; margin-bottom: 10px; } `); // --- API & DATA MANAGER --- const ApiManager = { apiKey: null, async setup() { let storedKey = await GM_get(API_KEY_STORAGE_KEY, null); if (!storedKey && isPDA) { await GM_set(API_KEY_STORAGE_KEY, PDA_API_KEY_PLACEHOLDER); storedKey = PDA_API_KEY_PLACEHOLDER; } this.apiKey = storedKey; }, fetchTornApi(selections, id = '') { return new Promise((resolve, reject) => { let keyToUse = this.apiKey; if (keyToUse === PDA_API_KEY_PLACEHOLDER) { if (window.tornium && window.tornium.apiKey) { keyToUse = window.tornium.apiKey; } else { return reject(new Error("Torn PDA key not found.")); } } if (!keyToUse) { return reject(new Error("API Key not set. Use the Settings tab to add one.")); } const url = `https://api.torn.com/user/${id}?selections=${selections}&key=${keyToUse}`; GM_xmlhttpRequest({ method: 'GET', url: url, onload: (response) => { const json = JSON.parse(response.responseText); if (json.error) return reject(json.error); resolve(json); }, onerror: (error) => reject(error) }); }); } }; // --- HEADER BUTTON MANAGER --- const HeaderButtonManager = { buttons: [], activePopup: null, activeButton: null, register(buttonDef) { this.buttons.push(buttonDef); }, async initialize() { const targetUl = await this.waitForElement('.header-buttons-wrapper ul.toolbar'); if (!targetUl) return; const insertionPoint = targetUl.querySelector('.tc-clock'); this.buttons.forEach(buttonDef => { const { li, button } = this.createButtonElements(buttonDef); targetUl.insertBefore(li, insertionPoint); button.addEventListener('click', (e) => { e.stopPropagation(); this.togglePopup(li.querySelector('.ttw-popup'), buttonDef, button); }); }); }, createButtonElements(buttonDef) { const li = document.createElement('li'); li.className = `ttw-header-button-li ${buttonDef.id}`; const button = document.createElement('button'); button.type = 'button'; button.className = `top_header_button button ${buttonDef.id}`; button.setAttribute('aria-label', `Open ${buttonDef.name}`); button.innerHTML = buttonDef.icon; const popup = document.createElement('div'); popup.className = 'ttw-popup ttw-popup-hidden'; popup.id = `ttw-popup-${buttonDef.id}`; popup.innerHTML = `<div class="ttw-popup-content">Loading...</div>`; popup.addEventListener('click', e => e.stopPropagation()); li.appendChild(button); li.appendChild(popup); return { li, button }; }, togglePopup(popup, buttonDef, button) { const isOpening = popup.classList.contains('ttw-popup-hidden'); if (isOpening) { // Opening popup this.hideAllPopups(); // Close any other popups first popup.classList.remove('ttw-popup-hidden'); this.activePopup = popup; this.activeButton = button; if (typeof buttonDef.updateFunction === 'function') { const contentElement = popup.querySelector('.ttw-popup-content'); buttonDef.updateFunction(contentElement); } } else { // Closing popup this.hideAllPopups(); } }, hideAllPopups() { document.querySelectorAll('.ttw-popup').forEach(p => p.classList.add('ttw-popup-hidden')); this.activePopup = null; this.activeButton = null; }, waitForElement(selector) { return new Promise(resolve => { const el = document.querySelector(selector); if (el) return resolve(el); const observer = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { resolve(el); observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } }; // --- BUTTON DEFINITIONS --- /** * Travel Widget with integrated API Settings * Uses the "airplane" icon and contains both travel info and settings tabs. */ const travelWidget = { id: "ttw-travel-widget", name: "Travel Widget", countdown: null, icon: `<svg xmlns="http://www.w3.org/2000/svg" class="default___XXAGt" width="28" height="28" viewBox="0 0 30 30" fill="#fff"><path d="M22 2L15 22L11 13L2 9L22 2Z"></path></svg>`, async updateFunction(contentElement) { // Create tabbed interface without title contentElement.innerHTML = ` <div class="ttw-tab-container"> <div class="ttw-tab-buttons"> <button class="ttw-tab-button active" data-tab="travel">Travel</button> <button class="ttw-tab-button" data-tab="settings">Settings</button> </div> <div class="ttw-tab-content active" id="ttw-travel-tab"> <div id="ttw-travel-content">Loading travel status...</div> </div> <div class="ttw-tab-content ttw-settings-tab" id="ttw-settings-tab"> <div id="ttw-settings-content">Loading settings...</div> </div> </div> `; // Set up tab switching const tabButtons = contentElement.querySelectorAll('.ttw-tab-button'); const tabContents = contentElement.querySelectorAll('.ttw-tab-content'); tabButtons.forEach(button => { button.addEventListener('click', () => { const tabName = button.dataset.tab; // Update button states tabButtons.forEach(b => b.classList.remove('active')); button.classList.add('active'); // Update content visibility tabContents.forEach(content => content.classList.remove('active')); contentElement.querySelector(`#ttw-${tabName}-tab`).classList.add('active'); // Load content for the active tab if (tabName === 'travel') { this.loadTravelContent(); } else if (tabName === 'settings') { this.loadSettingsContent(); } }); }); // Load initial travel content this.loadTravelContent(); }, async loadTravelContent() { const travelContent = document.querySelector('#ttw-travel-content'); if (!travelContent) return; try { const data = await ApiManager.fetchTornApi('travel'); // Check if user is in Torn City or already arrived if (data.travel.destination === "Torn" || data.travel.time_left === 0) { travelContent.innerHTML = `<span class="success">Currently in Torn City.</span>`; // Clear any existing countdown if (this.countdown) { clearInterval(this.countdown); this.countdown = null; } } else { // Start live countdown using the timestamp (arrival time) this.startLiveCountdown(travelContent, data.travel.timestamp, data.travel.destination); } } catch (error) { travelContent.innerHTML = `<span class="error">API Error: ${error.error || error.message}</span>`; // Clear any existing countdown on error if (this.countdown) { clearInterval(this.countdown); this.countdown = null; } } }, startLiveCountdown(el, arrivalTimestamp, destination) { // Clear any existing countdown if (this.countdown) { clearInterval(this.countdown); } const updateCountdown = () => { // Get current time in seconds (Unix timestamp) const currentTime = Math.floor(Date.now() / 1000); // Calculate seconds remaining until arrival const secondsLeft = arrivalTimestamp - currentTime; if (secondsLeft <= 0) { el.innerHTML = `<span class="success">Arrived in ${destination}!</span>`; clearInterval(this.countdown); this.countdown = null; } else { // Convert seconds to hours, minutes, seconds const hours = Math.floor(secondsLeft / 3600); const minutes = Math.floor((secondsLeft % 3600) / 60); const seconds = secondsLeft % 60; // Format with leading zeros const hoursStr = String(hours).padStart(2, '0'); const minutesStr = String(minutes).padStart(2, '0'); const secondsStr = String(seconds).padStart(2, '0'); el.innerHTML = `Flying to <b>${destination}</b>.<br>Arrives in: ${hoursStr}:${minutesStr}:${secondsStr}`; } }; // Update immediately, then every second updateCountdown(); this.countdown = setInterval(updateCountdown, 1000); }, async loadSettingsContent() { const settingsContent = document.querySelector('#ttw-settings-content'); if (!settingsContent) return; const currentKey = await GM_get(API_KEY_STORAGE_KEY, ''); let contentHTML = ` <p style="font-size: 11px;">Manage your API key. The key is stored locally in your browser and is never shared.</p> <table class="tos-table"> <thead><tr><th>Key Access Level</th><th>Purpose of Use</th></tr></thead> <tbody><tr><td>Limited Access</td><td>Non-malicious statistical analysis for widgets</td></tr></tbody> </table> <input type="password" id="ttw-api-key-input" placeholder="Enter your Limited Access API Key" value="${currentKey === PDA_API_KEY_PLACEHOLDER ? '' : currentKey}"> `; if (currentKey === PDA_API_KEY_PLACEHOLDER) { contentHTML += `<p class="pda-note">Using Torn PDA managed API key. No entry needed.</p>`; } contentHTML += ` <div class="api-button-group"> <button class="api-button clear-key">Clear Key</button> <button class="api-button save-key">Save Key</button> </div> <div id="ttw-api-status" style="font-size: 11px; margin-top: 8px;"></div> `; settingsContent.innerHTML = contentHTML; const input = settingsContent.querySelector('#ttw-api-key-input'); const saveBtn = settingsContent.querySelector('.save-key'); const clearBtn = settingsContent.querySelector('.clear-key'); const statusDiv = settingsContent.querySelector('#ttw-api-status'); if (currentKey === PDA_API_KEY_PLACEHOLDER) { input.disabled = true; saveBtn.disabled = true; } saveBtn.onclick = async () => { const newKey = input.value.trim(); if (newKey) { await GM_set(API_KEY_STORAGE_KEY, newKey); ApiManager.apiKey = newKey; statusDiv.innerHTML = `<span class="success">API Key saved successfully!</span>`; // Refresh travel tab if it was showing an error const activeTab = document.querySelector('.ttw-tab-button.active'); if (activeTab && activeTab.dataset.tab === 'travel') { this.loadTravelContent(); } } else { statusDiv.innerHTML = `<span class="error">API Key cannot be empty.</span>`; } setTimeout(() => statusDiv.innerHTML = '', 3000); }; clearBtn.onclick = async () => { if (confirm('Are you sure you want to clear your stored API key?')) { await GM_del(API_KEY_STORAGE_KEY); ApiManager.apiKey = null; input.value = ''; input.disabled = false; saveBtn.disabled = false; settingsContent.querySelector('.pda-note')?.remove(); statusDiv.innerHTML = `<span class="success">API Key cleared.</span>`; setTimeout(() => statusDiv.innerHTML = '', 3000); } }; } }; // --- SCRIPT INITIALIZATION --- async function main() { await ApiManager.setup(); HeaderButtonManager.register(travelWidget); HeaderButtonManager.initialize(); } main(); })();