// ==UserScript== // @name Xbout // @namespace https://github.com/Yorkian/Xbout // @version 1.41 // @description Display a user's account location ๐ŸŒ, device type (๐ŸŽ Apple / ๐Ÿค– Android), and registration year directly on X (Twitter) pages. // @author Yorkian // @match https://x.com/* // @match https://twitter.com/* // @grant GM_addStyle // @run-at document-end // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; if (window.__xboutLoaded) return; window.__xboutLoaded = true; console.log('[Xbout] Script loaded'); // Inject styles GM_addStyle(` /* Xbout - Styles */ .xbout-badge { display: inline !important; font-size: 13px; vertical-align: middle; white-space: nowrap; flex-shrink: 0; } .xbout-dot { color: rgb(83, 100, 113); font-size: 13px; } .xbout-sep { color: #536471; margin: 0 1px; font-size: 12px; } .xbout-year { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-weight: 700; color: #536471; font-size: 12px; } .xbout-device-icon { width: 14px; height: 14px; vertical-align: middle; display: inline-block; } /* Flag wrapper for hover label */ .xbout-flag-wrapper { display: inline; cursor: pointer; } /* Flag emoji */ .xbout-flag-text { display: inline; } /* Label - hidden by default */ .xbout-flag-label { display: none; padding: 1px 4px; background: linear-gradient(135deg, #1d9bf0 0%, #1a8cd8 50%, #0d7ac5 100%); color: #fff; font-size: 9px; font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-weight: 600; letter-spacing: 0.2px; white-space: nowrap; border-radius: 3px; border: 1px solid rgba(255, 255, 255, 0.35); box-shadow: 0 1px 4px rgba(29, 155, 240, 0.4); vertical-align: middle; } /* On hover: hide flag, show label */ .xbout-flag-wrapper:hover .xbout-flag-text { display: none; } .xbout-flag-wrapper:hover .xbout-flag-label { display: inline; } /* Flag container for VPN badge positioning */ .xbout-flag-container { display: inline; } /* VPN badge - inline superscript style */ .xbout-vpn-badge { font-size: 5px; font-weight: 700; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: #f4212e; background: rgba(244, 33, 46, 0.1); padding: 0.5px 1px; border-radius: 1px; line-height: 1; letter-spacing: -0.2px; vertical-align: super; margin-left: 1px; } /* Toast notification */ .xbout-toast { position: fixed; top: 16px; right: 16px; background: #1d9bf0; color: #fff; padding: 12px 16px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 9999; opacity: 0; transform: translateX(100%); transition: opacity 0.3s ease, transform 0.3s ease; } .xbout-toast.xbout-toast-show { opacity: 1; transform: translateX(0); } .xbout-toast.xbout-toast-warning { background: #f4212e; } `); const CONFIG = { INIT_DELAY: 3000, REQUEST_DELAY: 3000, SCAN_DEBOUNCE: 200, CACHE_DURATION: 24 * 60 * 60 * 1000, CACHE_ERROR_DURATION: 30 * 60 * 1000, MAX_REQUESTS_PER_MINUTE: 10, RATE_LIMIT_WAIT: 60 * 1000, STORAGE_KEY: 'xbout_cache', BEARER_TOKEN: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', FALLBACK_QUERY_ID: 'zs_jFPFT78rBpXv9Z3U2YQ', CHROME_ICON_URL: 'https://www.google.com/chrome/static/images/chrome-logo-m100.svg', }; const countryToFlag = { 'china': '๐Ÿ‡จ๐Ÿ‡ณ', 'japan': '๐Ÿ‡ฏ๐Ÿ‡ต', 'south korea': '๐Ÿ‡ฐ๐Ÿ‡ท', 'korea': '๐Ÿ‡ฐ๐Ÿ‡ท', 'taiwan': '๐Ÿ‡น๐Ÿ‡ผ', 'hong kong': '๐Ÿ‡ญ๐Ÿ‡ฐ', 'singapore': '๐Ÿ‡ธ๐Ÿ‡ฌ', 'india': '๐Ÿ‡ฎ๐Ÿ‡ณ', 'thailand': '๐Ÿ‡น๐Ÿ‡ญ', 'viet nam': '๐Ÿ‡ป๐Ÿ‡ณ', 'malaysia': '๐Ÿ‡ฒ๐Ÿ‡พ', 'indonesia': '๐Ÿ‡ฎ๐Ÿ‡ฉ', 'philippines': '๐Ÿ‡ต๐Ÿ‡ญ', 'pakistan': '๐Ÿ‡ต๐Ÿ‡ฐ', 'bangladesh': '๐Ÿ‡ง๐Ÿ‡ฉ', 'nepal': '๐Ÿ‡ณ๐Ÿ‡ต', 'sri lanka': '๐Ÿ‡ฑ๐Ÿ‡ฐ', 'myanmar': '๐Ÿ‡ฒ๐Ÿ‡ฒ', 'cambodia': '๐Ÿ‡ฐ๐Ÿ‡ญ', 'mongolia': '๐Ÿ‡ฒ๐Ÿ‡ณ', 'saudi arabia': '๐Ÿ‡ธ๐Ÿ‡ฆ', 'united arab emirates': '๐Ÿ‡ฆ๐Ÿ‡ช', 'uae': '๐Ÿ‡ฆ๐Ÿ‡ช', 'israel': '๐Ÿ‡ฎ๐Ÿ‡ฑ', 'turkey': '๐Ÿ‡น๐Ÿ‡ท', 'tรผrkiye': '๐Ÿ‡น๐Ÿ‡ท', 'iran': '๐Ÿ‡ฎ๐Ÿ‡ท', 'iraq': '๐Ÿ‡ฎ๐Ÿ‡ถ', 'qatar': '๐Ÿ‡ถ๐Ÿ‡ฆ', 'kuwait': '๐Ÿ‡ฐ๐Ÿ‡ผ', 'jordan': '๐Ÿ‡ฏ๐Ÿ‡ด', 'lebanon': '๐Ÿ‡ฑ๐Ÿ‡ง', 'bahrain': '๐Ÿ‡ง๐Ÿ‡ญ', 'oman': '๐Ÿ‡ด๐Ÿ‡ฒ', 'united kingdom': '๐Ÿ‡ฌ๐Ÿ‡ง', 'uk': '๐Ÿ‡ฌ๐Ÿ‡ง', 'england': '๐Ÿ‡ฌ๐Ÿ‡ง', 'france': '๐Ÿ‡ซ๐Ÿ‡ท', 'germany': '๐Ÿ‡ฉ๐Ÿ‡ช', 'italy': '๐Ÿ‡ฎ๐Ÿ‡น', 'spain': '๐Ÿ‡ช๐Ÿ‡ธ', 'portugal': '๐Ÿ‡ต๐Ÿ‡น', 'netherlands': '๐Ÿ‡ณ๐Ÿ‡ฑ', 'belgium': '๐Ÿ‡ง๐Ÿ‡ช', 'switzerland': '๐Ÿ‡จ๐Ÿ‡ญ', 'austria': '๐Ÿ‡ฆ๐Ÿ‡น', 'sweden': '๐Ÿ‡ธ๐Ÿ‡ช', 'norway': '๐Ÿ‡ณ๐Ÿ‡ด', 'denmark': '๐Ÿ‡ฉ๐Ÿ‡ฐ', 'finland': '๐Ÿ‡ซ๐Ÿ‡ฎ', 'poland': '๐Ÿ‡ต๐Ÿ‡ฑ', 'russia': '๐Ÿ‡ท๐Ÿ‡บ', 'ukraine': '๐Ÿ‡บ๐Ÿ‡ฆ', 'greece': '๐Ÿ‡ฌ๐Ÿ‡ท', 'czech republic': '๐Ÿ‡จ๐Ÿ‡ฟ', 'czechia': '๐Ÿ‡จ๐Ÿ‡ฟ', 'hungary': '๐Ÿ‡ญ๐Ÿ‡บ', 'romania': '๐Ÿ‡ท๐Ÿ‡ด', 'ireland': '๐Ÿ‡ฎ๐Ÿ‡ช', 'scotland': '๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ', 'united states': '๐Ÿ‡บ๐Ÿ‡ธ', 'usa': '๐Ÿ‡บ๐Ÿ‡ธ', 'us': '๐Ÿ‡บ๐Ÿ‡ธ', 'canada': '๐Ÿ‡จ๐Ÿ‡ฆ', 'mexico': '๐Ÿ‡ฒ๐Ÿ‡ฝ', 'brazil': '๐Ÿ‡ง๐Ÿ‡ท', 'argentina': '๐Ÿ‡ฆ๐Ÿ‡ท', 'chile': '๐Ÿ‡จ๐Ÿ‡ฑ', 'colombia': '๐Ÿ‡จ๐Ÿ‡ด', 'peru': '๐Ÿ‡ต๐Ÿ‡ช', 'venezuela': '๐Ÿ‡ป๐Ÿ‡ช', 'australia': '๐Ÿ‡ฆ๐Ÿ‡บ', 'new zealand': '๐Ÿ‡ณ๐Ÿ‡ฟ', 'south africa': '๐Ÿ‡ฟ๐Ÿ‡ฆ', 'egypt': '๐Ÿ‡ช๐Ÿ‡ฌ', 'nigeria': '๐Ÿ‡ณ๐Ÿ‡ฌ', 'kenya': '๐Ÿ‡ฐ๐Ÿ‡ช', 'morocco': '๐Ÿ‡ฒ๐Ÿ‡ฆ', 'ethiopia': '๐Ÿ‡ช๐Ÿ‡น', 'ghana': '๐Ÿ‡ฌ๐Ÿ‡ญ', }; class CacheManager { constructor() { this.memoryCache = new Map(); this.loadFromStorage(); } loadFromStorage() { try { const stored = localStorage.getItem(CONFIG.STORAGE_KEY); if (stored) { const data = JSON.parse(stored); const now = Date.now(); for (const [key, value] of Object.entries(data)) { if (value.expiry > now) { this.memoryCache.set(key, value); } } console.log(`[Xbout] Loaded ${this.memoryCache.size} cached users`); } } catch (e) { console.warn('[Xbout] Cache load error:', e); } } saveToStorage() { try { const data = Object.fromEntries(this.memoryCache); localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(data)); } catch (e) { console.warn('[Xbout] Cache save error:', e); } } get(username) { const cached = this.memoryCache.get(username); if (!cached) return null; if (Date.now() > cached.expiry) { this.memoryCache.delete(username); return null; } return cached.data; } set(username, data, isError = false) { const duration = isError ? CONFIG.CACHE_ERROR_DURATION : CONFIG.CACHE_DURATION; this.memoryCache.set(username, { data: data, expiry: Date.now() + duration, isError: isError }); this.saveToStorage(); } has(username) { return this.get(username) !== null; } isErrorCached(username) { const cached = this.memoryCache.get(username); return cached && cached.isError && Date.now() < cached.expiry; } } class RateLimiter { constructor() { this.requests = []; this.isRateLimited = false; this.rateLimitEndTime = 0; } canMakeRequest() { if (this.isRateLimited) { if (Date.now() < this.rateLimitEndTime) { return false; } this.isRateLimited = false; } const oneMinuteAgo = Date.now() - 60 * 1000; this.requests = this.requests.filter(t => t > oneMinuteAgo); return this.requests.length < CONFIG.MAX_REQUESTS_PER_MINUTE; } recordRequest() { this.requests.push(Date.now()); } setRateLimited() { this.isRateLimited = true; this.rateLimitEndTime = Date.now() + CONFIG.RATE_LIMIT_WAIT; console.log(`[Xbout] Rate limited, waiting until ${new Date(this.rateLimitEndTime).toLocaleTimeString()}`); showToast('Xbout: Rate limited by X API. Please wait a moment.', 5000, 'warning'); } getWaitTime() { if (this.isRateLimited) { return Math.max(0, this.rateLimitEndTime - Date.now()); } return 0; } } const cache = new CacheManager(); const rateLimiter = new RateLimiter(); const processedElements = new WeakSet(); const pendingUsers = new Set(); let queryId = null; let scanTimeout = null; let mutationObserver = null; // Toast notification function function showToast(message, duration = 5000, type = 'warning') { // Remove existing toast if any const existingToast = document.querySelector('.xbout-toast'); if (existingToast) { existingToast.remove(); } const toast = document.createElement('div'); toast.className = `xbout-toast xbout-toast-${type}`; toast.textContent = message; document.body.appendChild(toast); // Trigger animation requestAnimationFrame(() => { toast.classList.add('xbout-toast-show'); }); // Auto remove after duration setTimeout(() => { toast.classList.remove('xbout-toast-show'); setTimeout(() => { toast.remove(); }, 300); }, duration); } function getFlag(location) { if (!location) return null; const loc = location.toLowerCase().trim(); // Area determination - using different earth emoji // ๐ŸŒ Asia, Pacific, Oceania // ๐ŸŒŽ America // ๐ŸŒ Europe, Africa if (loc.includes('asia') || loc.includes('pacific') || loc.includes('oceania')) { return '๐ŸŒ'; } if (loc.includes('america')) { return '๐ŸŒŽ'; } if (loc.includes('europe')) { return '๐ŸŒ'; } if (loc.includes('africa')) { return '๐ŸŒ'; } // Exact match country if (countryToFlag[loc]) return countryToFlag[loc]; // Partial match for (const [country, flag] of Object.entries(countryToFlag)) { if (loc.includes(country) || country.includes(loc)) { return flag; } } // Unknown region - default display Earth return '๐ŸŒ'; } // Format location name for tooltip display (capitalize each word) function formatLocationName(location) { if (!location) return ''; return location .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } function getDeviceHtml(source) { if (!source) return ''; const s = source.toLowerCase(); if (s.includes('iphone') || s.includes('ios') || s.includes('ipad') || s.includes('app store')) { return '๐ŸŽ'; } if (s.includes('android') || s.includes('play store') || s.includes('google play')) { return '๐Ÿค–'; } if (s === 'web' || s.includes('web app') || s.includes('browser')) { return `Web`; } return ''; } function getYear(createdAt) { if (!createdAt) return ''; const match = createdAt.match(/(\d{4})$/); if (match) return match[1]; return ''; } function getCsrfToken() { const match = document.cookie.match(/ct0=([^;]+)/); return match ? match[1] : null; } async function fetchQueryId() { try { const entries = performance.getEntriesByType('resource'); for (const entry of entries) { const match = entry.name.match(/graphql\/([^/]+)\/AboutAccountQuery/); if (match) { console.log('[Xbout] Found queryId from network:', match[1]); return match[1]; } } } catch (e) {} return null; } function setupQueryIdObserver() { try { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const match = entry.name.match(/graphql\/([^/]+)\/AboutAccountQuery/); if (match && match[1] !== queryId) { queryId = match[1]; console.log('[Xbout] Updated queryId:', queryId); } } }); observer.observe({ entryTypes: ['resource'] }); } catch (e) {} } let requestQueue = []; let isProcessing = false; async function fetchAboutInfo(username) { const csrfToken = getCsrfToken(); if (!csrfToken) return null; const currentQueryId = queryId || CONFIG.FALLBACK_QUERY_ID; const variables = JSON.stringify({ screenName: username }); const url = `https://x.com/i/api/graphql/${currentQueryId}/AboutAccountQuery?variables=${encodeURIComponent(variables)}`; try { rateLimiter.recordRequest(); const resp = await fetch(url, { method: 'GET', credentials: 'include', headers: { 'accept': '*/*', 'accept-language': 'en-US,en;q=0.9', 'authorization': `Bearer ${CONFIG.BEARER_TOKEN}`, 'content-type': 'application/json', 'x-csrf-token': csrfToken, 'x-twitter-active-user': 'yes', 'x-twitter-auth-type': 'OAuth2Session', 'x-twitter-client-language': 'en', } }); if (resp.status === 429) { rateLimiter.setRateLimited(); return { error: 'rate_limited' }; } if (!resp.ok) { console.warn(`[Xbout] API error for ${username}: ${resp.status}`); return { error: resp.status }; } const data = await resp.json(); const result = data?.data?.user_result_by_screen_name?.result; if (result) { const aboutProfile = result.about_profile || {}; const core = result.core || {}; return { location: aboutProfile.account_based_in || null, locationAccurate: aboutProfile.location_accurate !== false, // true if undefined or true source: aboutProfile.source || null, createdAt: core.created_at || null }; } return null; } catch (e) { console.warn(`[Xbout] Fetch error for ${username}:`, e.message); return { error: 'network' }; } } async function processQueue() { if (isProcessing || requestQueue.length === 0) return; isProcessing = true; while (requestQueue.length > 0) { if (!rateLimiter.canMakeRequest()) { const waitTime = rateLimiter.getWaitTime(); if (waitTime > 0) { console.log(`[Xbout] Waiting ${Math.ceil(waitTime/1000)}s before next request...`); await new Promise(r => setTimeout(r, waitTime)); continue; } } const { username, callback } = requestQueue.shift(); if (cache.has(username)) { callback(cache.get(username)); continue; } const info = await fetchAboutInfo(username); if (info?.error === 'rate_limited') { requestQueue.unshift({ username, callback }); await new Promise(r => setTimeout(r, CONFIG.RATE_LIMIT_WAIT)); continue; } if (info?.error) { cache.set(username, null, true); pendingUsers.delete(username); callback(null); } else if (info) { console.log(`[Xbout] ${username}: ${info.location} โ†’ ${getFlag(info.location)}${info.locationAccurate ? '' : ' (VPN)'}`); cache.set(username, info); pendingUsers.delete(username); callback(info); } else { cache.set(username, null, true); pendingUsers.delete(username); callback(null); } await new Promise(r => setTimeout(r, CONFIG.REQUEST_DELAY)); } isProcessing = false; } function getUserInfo(username, callback) { if (cache.has(username)) { const cached = cache.get(username); callback(cached); return; } if (cache.isErrorCached(username)) { callback(null); return; } if (pendingUsers.has(username)) { return; } pendingUsers.add(username); requestQueue.push({ username, callback }); processQueue(); } function findDateElement(usernameLink) { let container = usernameLink.parentElement; for (let i = 0; i < 5 && container; i++) { const timeElement = container.querySelector('time'); if (timeElement) { let dateContainer = timeElement.closest('a') || timeElement.parentElement; return dateContainer; } container = container.parentElement; } return null; } function addBadge(element, username) { if (processedElements.has(element)) return; processedElements.add(element); getUserInfo(username, (info) => { if (!info) return; const flag = getFlag(info.location); const deviceHtml = getDeviceHtml(info.source); const year = getYear(info.createdAt); if (!flag && !deviceHtml && !year) return; const article = element.closest('article'); if (article) { const existingBadge = article.querySelector(`.xbout-badge[data-user="${username}"]`); if (existingBadge) return; } const dateElement = findDateElement(element); const badge = document.createElement('span'); badge.className = 'xbout-badge'; badge.setAttribute('data-user', username); const parts = []; // Build flag part with label and optional VPN badge if (flag) { const locationName = formatLocationName(info.location); const escapedLocationName = locationName.replace(//g, '>').replace(/"/g, '"'); if (info.locationAccurate === false) { // Location is not accurate - add VPN badge parts.push(`${flag}VPN${escapedLocationName}`); } else { parts.push(`${flag}${escapedLocationName}`); } } if (deviceHtml) parts.push(deviceHtml); if (year) parts.push(`${year}`); const content = parts.join('๏ฝœ'); // Only add the ยท separator when the date element is present. if (dateElement) { badge.innerHTML = ' ยท ' + content; } else { badge.innerHTML = content; } try { if (dateElement) { dateElement.after(badge); } else { element.after(badge); } } catch (e) { console.warn('[Xbout] Insert error:', e); } }); } function scan() { const blacklist = ['home', 'explore', 'notifications', 'messages', 'settings', 'i', 'search', 'compose', 'login', 'signup', 'tos', 'privacy', 'about', 'jobs', 'help', 'download']; document.querySelectorAll('a[href^="/"]').forEach(link => { const text = (link.textContent || '').trim(); if (!/^@[a-zA-Z0-9_]+$/.test(text)) return; const username = text.slice(1); if (blacklist.includes(username.toLowerCase())) return; addBadge(link, username); }); } // Debounced scan function for MutationObserver function debouncedScan() { if (scanTimeout) { clearTimeout(scanTimeout); } scanTimeout = setTimeout(scan, CONFIG.SCAN_DEBOUNCE); } // Setup MutationObserver to watch for DOM changes function setupMutationObserver() { if (mutationObserver) { mutationObserver.disconnect(); } mutationObserver = new MutationObserver((mutations) => { // Check if any mutation added new nodes let hasNewNodes = false; for (const mutation of mutations) { if (mutation.addedNodes.length > 0) { hasNewNodes = true; break; } } if (hasNewNodes) { debouncedScan(); } }); // Observe the entire document for added nodes mutationObserver.observe(document.body, { childList: true, subtree: true }); console.log('[Xbout] MutationObserver started'); } async function init() { console.log('[Xbout] Initializing...'); const csrf = getCsrfToken(); if (csrf) { console.log('[Xbout] CSRF token found'); } else { console.warn('[Xbout] No CSRF token'); } queryId = await fetchQueryId(); if (!queryId) { queryId = CONFIG.FALLBACK_QUERY_ID; console.log('[Xbout] Using fallback queryId:', queryId); } setupQueryIdObserver(); // Use MutationObserver instead of setInterval setupMutationObserver(); // Initial scan scan(); console.log('[Xbout] Ready'); } setTimeout(() => { if (document.querySelector('main')) { init(); } else { setTimeout(init, 3000); } }, CONFIG.INIT_DELAY); })();