├── Pic.png ├── icon128.png ├── icon16.png ├── icon48.png ├── manifest.json ├── PrivacyPolicy.md ├── LICENSE ├── README.md ├── styles.css └── content.js /Pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yorkian/Xbout/HEAD/Pic.png -------------------------------------------------------------------------------- /icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yorkian/Xbout/HEAD/icon128.png -------------------------------------------------------------------------------- /icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yorkian/Xbout/HEAD/icon16.png -------------------------------------------------------------------------------- /icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yorkian/Xbout/HEAD/icon48.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Xbout", 4 | "version": "1.3", 5 | "description": "Display the user's location, device type, and registration year on the X (Twitter) page.", 6 | "permissions": [], 7 | "host_permissions": [ 8 | "https://x.com/*", 9 | "https://twitter.com/*" 10 | ], 11 | "content_scripts": [ 12 | { 13 | "matches": ["https://x.com/*", "https://twitter.com/*"], 14 | "js": ["content.js"], 15 | "css": ["styles.css"], 16 | "run_at": "document_end" 17 | } 18 | ], 19 | "icons": { 20 | "48": "icon48.png", 21 | "128": "icon128.png" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /PrivacyPolicy.md: -------------------------------------------------------------------------------- 1 | Privacy Policy for Xbout 2 | Last Updated: Nov. 2025 3 | 1. Data Collection 4 | Xbout does not transmit any user data to external servers. All data processing happens locally on your browser. 5 | 2. Authentication 6 | The extension utilizes your existing session cookies with x.com to fetch public account information. We do not store, log, or share your credentials. 7 | 3. Local Storage 8 | We use your browser's Local Storage to cache account information (location, registration year) to reduce network requests. This data resides only on your device. 9 | 4. Third-Party Services 10 | This extension interacts directly with x.com APIs. Please refer to X's privacy policy regarding their data handling. 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yorkian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xbout - Chrome Extension 2 | 3 | Display a user’s account location 🌍, device type (🍎 Apple / 🤖 Android), and registration year directly on X (Twitter) pages. 4 | 5 | ![x](https://github.com/Yorkian/Xbout/blob/main/Pic.png?raw=true) 6 | 7 | ## Features 8 | 9 | * 🌏 Asia/Oceania regions 10 | * 🌎 Americas 11 | * 🌍 Europe/Africa 12 | * 🇺🇸🇨🇳🇯🇵 Specific country flags 13 | * 🍎 Apple device users 14 | * 🤖 Android device users 15 | * Chrome icon for web users 16 | * **2009** registration year 17 | 18 | ## Display Example 19 | 20 | ``` 21 | @elonmusk · Nov 24 · 🇺🇸|🍎|2009 22 | ``` 23 | 24 | ## Installation 25 | 26 | 1. Download all files in this folder 27 | 2. Open Chrome and visit `chrome://extensions/` 28 | 3. Enable “Developer mode” in the top-right corner 29 | 4. Click “Load unpacked” 30 | 5. Select the folder containing these files 31 | 6. Done! Visit X.com to see the effect 32 | 7. Or install from the [Chrome Web Store](https://chromewebstore.google.com/detail/xbout/fbghhoaacmbjmbmekocphjnkdjoplgad) [Greasy Fork](https://greasyfork.org/zh-CN/scripts/557057-xbout) directly 33 | 34 | ## File Structure 35 | 36 | ``` 37 | xbout/ 38 | ├── manifest.json # Extension configuration 39 | ├── content.js # Core script 40 | ├── styles.css # Stylesheet 41 | ├── icon16.png # 16x16 icon 42 | ├── icon48.png # 48x48 icon 43 | ├── icon128.png # 128x128 icon 44 | └── README.md # Documentation 45 | ``` 46 | 47 | ## Data Sources 48 | 49 | * **Account location**: From `https://x.com/username/about` 50 | * **Device info**: From the client used when posting tweets 51 | * **Registration year**: From the account creation date 52 | 53 | ## Caching Mechanism 54 | 55 | * Successful data cached for 24 hours 56 | * Error data cached for 30 minutes 57 | * Max 10 API requests per minute 58 | * Data stored in localStorage 59 | 60 | ## License 61 | 62 | MIT License 63 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Xbout - Styles */ 2 | 3 | .xbout-badge { 4 | display: inline !important; 5 | font-size: 13px; 6 | vertical-align: middle; 7 | white-space: nowrap; 8 | flex-shrink: 0; 9 | } 10 | 11 | .xbout-dot { 12 | color: rgb(83, 100, 113); 13 | font-size: 13px; 14 | } 15 | 16 | .xbout-sep { 17 | color: #536471; 18 | margin: 0 1px; 19 | font-size: 12px; 20 | } 21 | 22 | .xbout-year { 23 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 24 | font-weight: 700; 25 | color: #536471; 26 | font-size: 12px; 27 | } 28 | 29 | .xbout-device-icon { 30 | width: 14px; 31 | height: 14px; 32 | vertical-align: middle; 33 | display: inline-block; 34 | } 35 | 36 | /* Flag wrapper for hover label */ 37 | .xbout-flag-wrapper { 38 | display: inline; 39 | cursor: pointer; 40 | } 41 | 42 | /* Flag emoji */ 43 | .xbout-flag-text { 44 | display: inline; 45 | } 46 | 47 | /* Label - hidden by default */ 48 | .xbout-flag-label { 49 | display: none; 50 | padding: 1px 4px; 51 | background: linear-gradient(135deg, #1d9bf0 0%, #1a8cd8 50%, #0d7ac5 100%); 52 | color: #fff; 53 | font-size: 9px; 54 | font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 55 | font-weight: 600; 56 | letter-spacing: 0.2px; 57 | white-space: nowrap; 58 | border-radius: 3px; 59 | border: 1px solid rgba(255, 255, 255, 0.35); 60 | box-shadow: 0 1px 4px rgba(29, 155, 240, 0.4); 61 | vertical-align: middle; 62 | } 63 | 64 | /* On hover: hide flag, show label */ 65 | .xbout-flag-wrapper:hover .xbout-flag-text { 66 | display: none; 67 | } 68 | 69 | .xbout-flag-wrapper:hover .xbout-flag-label { 70 | display: inline; 71 | } 72 | 73 | /* Flag container for VPN badge positioning */ 74 | .xbout-flag-container { 75 | display: inline; 76 | } 77 | 78 | /* VPN badge - inline superscript style */ 79 | .xbout-vpn-badge { 80 | font-size: 5px; 81 | font-weight: 700; 82 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 83 | color: #f4212e; 84 | background: rgba(244, 33, 46, 0.1); 85 | padding: 0.5px 1px; 86 | border-radius: 1px; 87 | line-height: 1; 88 | letter-spacing: -0.2px; 89 | vertical-align: super; 90 | margin-left: 1px; 91 | } 92 | 93 | /* Toast notification */ 94 | .xbout-toast { 95 | position: fixed; 96 | top: 16px; 97 | right: 16px; 98 | background: #1d9bf0; 99 | color: #fff; 100 | padding: 12px 16px; 101 | border-radius: 8px; 102 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 103 | font-size: 14px; 104 | font-weight: 500; 105 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 106 | z-index: 9999; 107 | opacity: 0; 108 | transform: translateX(100%); 109 | transition: opacity 0.3s ease, transform 0.3s ease; 110 | } 111 | 112 | .xbout-toast.xbout-toast-show { 113 | opacity: 1; 114 | transform: translateX(0); 115 | } 116 | 117 | .xbout-toast.xbout-toast-warning { 118 | background: #f4212e; 119 | } -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | // Xbout - Display a user's account location 🌍, device type (🍎 Apple / 🤖 Android), and registration year directly on X (Twitter) pages. 2 | // Data source: X GraphQL API - AboutAccountQuery 3 | 4 | (function() { 5 | 'use strict'; 6 | 7 | if (window.__xboutLoaded) return; 8 | window.__xboutLoaded = true; 9 | 10 | console.log('[Xbout] Script loaded'); 11 | 12 | const CONFIG = { 13 | INIT_DELAY: 3000, 14 | REQUEST_DELAY: 3000, 15 | SCAN_DEBOUNCE: 200, 16 | CACHE_DURATION: 24 * 60 * 60 * 1000, 17 | CACHE_ERROR_DURATION: 30 * 60 * 1000, 18 | MAX_REQUESTS_PER_MINUTE: 10, 19 | RATE_LIMIT_WAIT: 60 * 1000, 20 | STORAGE_KEY: 'xbout_cache', 21 | BEARER_TOKEN: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 22 | FALLBACK_QUERY_ID: 'zs_jFPFT78rBpXv9Z3U2YQ', 23 | CHROME_ICON_BASE64: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij4KICA8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSIyMiIgZmlsbD0iIzQyODVGNCIvPgogIDxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Ik0yNCAyQzEzLjUgMiA0LjYgOC42IDIuMSAxNy44TDE0LjMgMjRsNS4yLTljMS4zLTIuMyAzLjgtNCA2LjUtNGgxOS44QzQyLjMgNS42IDMzLjggMiAyNCAyeiIvPgogIDxwYXRoIGZpbGw9IiNGQkJDMDUiIGQ9Ik0yLjEgMTcuOEMuNyAyMi4zLjcgMjcuMiAyLjEgMzEuN2wxMi4yLTYuMi01LjItOWMtMS4zLTIuMy0xLjgtNS0xLjMtNy41TDIuMSAxNy44eiIvPgogIDxwYXRoIGZpbGw9IiMzNEE4NTMiIGQ9Ik0yNCA0NmM5LjUgMCAxOC01LjYgMjEuOS0xNC4zbC0xMi4yLTYuMi01LjIgOWMtMS4zIDIuMy0zLjggNC02LjUgNC01LjUgMC0xMC00LjUtMTAtMTAgMC0xLjkuNS0zLjYgMS40LTUuMkwyLjEgMzEuN0M1LjcgNDAuNCAxNC4yIDQ2IDI0IDQ2eiIvPgogIDxjaXJjbGUgY3g9IjI0IiBjeT0iMjQiIHI9IjkiIGZpbGw9IiNmZmYiLz4KICA8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSI3IiBmaWxsPSIjNDI4NUY0Ii8+Cjwvc3ZnPgo=', 24 | }; 25 | 26 | const countryToFlag = { 27 | 'china': '🇨🇳', 'japan': '🇯🇵', 'south korea': '🇰🇷', 'korea': '🇰🇷', 28 | 'taiwan': '🇹🇼', 'hong kong': '🇭🇰', 'singapore': '🇸🇬', 'india': '🇮🇳', 29 | 'thailand': '🇹🇭', 'viet nam': '🇻🇳', 'malaysia': '🇲🇾', 'indonesia': '🇮🇩', 30 | 'philippines': '🇵🇭', 'pakistan': '🇵🇰', 'bangladesh': '🇧🇩', 'nepal': '🇳🇵', 31 | 'sri lanka': '🇱🇰', 'myanmar': '🇲🇲', 'cambodia': '🇰🇭', 'mongolia': '🇲🇳', 32 | 'saudi arabia': '🇸🇦', 'united arab emirates': '🇦🇪', 'uae': '🇦🇪', 33 | 'israel': '🇮🇱', 'turkey': '🇹🇷', 'türkiye': '🇹🇷', 'iran': '🇮🇷', 34 | 'iraq': '🇮🇶', 'qatar': '🇶🇦', 'kuwait': '🇰🇼', 'jordan': '🇯🇴', 35 | 'lebanon': '🇱🇧', 'bahrain': '🇧🇭', 'oman': '🇴🇲', 36 | 'united kingdom': '🇬🇧', 'uk': '🇬🇧', 'england': '🇬🇧', 37 | 'france': '🇫🇷', 'germany': '🇩🇪', 'italy': '🇮🇹', 'spain': '🇪🇸', 38 | 'portugal': '🇵🇹', 'netherlands': '🇳🇱', 'belgium': '🇧🇪', 'switzerland': '🇨🇭', 39 | 'austria': '🇦🇹', 'sweden': '🇸🇪', 'norway': '🇳🇴', 'denmark': '🇩🇰', 40 | 'finland': '🇫🇮', 'poland': '🇵🇱', 'russia': '🇷🇺', 'ukraine': '🇺🇦', 41 | 'greece': '🇬🇷', 'czech republic': '🇨🇿', 'czechia': '🇨🇿', 'hungary': '🇭🇺', 42 | 'romania': '🇷🇴', 'ireland': '🇮🇪', 'scotland': '🏴󠁧󠁢󠁳󠁣󠁴󠁿', 43 | 'united states': '🇺🇸', 'usa': '🇺🇸', 'us': '🇺🇸', 44 | 'canada': '🇨🇦', 'mexico': '🇲🇽', 'brazil': '🇧🇷', 'argentina': '🇦🇷', 45 | 'chile': '🇨🇱', 'colombia': '🇨🇴', 'peru': '🇵🇪', 'venezuela': '🇻🇪', 46 | 'australia': '🇦🇺', 'new zealand': '🇳🇿', 'south africa': '🇿🇦', 47 | 'egypt': '🇪🇬', 'nigeria': '🇳🇬', 'kenya': '🇰🇪', 'morocco': '🇲🇦', 48 | 'ethiopia': '🇪🇹', 'ghana': '🇬🇭', 'australia': '🇦🇺', 49 | }; 50 | 51 | class CacheManager { 52 | constructor() { 53 | this.memoryCache = new Map(); 54 | this.loadFromStorage(); 55 | } 56 | 57 | loadFromStorage() { 58 | try { 59 | const stored = localStorage.getItem(CONFIG.STORAGE_KEY); 60 | if (stored) { 61 | const data = JSON.parse(stored); 62 | const now = Date.now(); 63 | for (const [key, value] of Object.entries(data)) { 64 | if (value.expiry > now) { 65 | this.memoryCache.set(key, value); 66 | } 67 | } 68 | console.log(`[Xbout] Loaded ${this.memoryCache.size} cached users`); 69 | } 70 | } catch (e) { 71 | console.warn('[Xbout] Cache load error:', e); 72 | } 73 | } 74 | 75 | saveToStorage() { 76 | try { 77 | const data = Object.fromEntries(this.memoryCache); 78 | localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(data)); 79 | } catch (e) { 80 | console.warn('[Xbout] Cache save error:', e); 81 | } 82 | } 83 | 84 | get(username) { 85 | const cached = this.memoryCache.get(username); 86 | if (!cached) return null; 87 | if (Date.now() > cached.expiry) { 88 | this.memoryCache.delete(username); 89 | return null; 90 | } 91 | return cached.data; 92 | } 93 | 94 | set(username, data, isError = false) { 95 | const duration = isError ? CONFIG.CACHE_ERROR_DURATION : CONFIG.CACHE_DURATION; 96 | this.memoryCache.set(username, { 97 | data: data, 98 | expiry: Date.now() + duration, 99 | isError: isError 100 | }); 101 | this.saveToStorage(); 102 | } 103 | 104 | has(username) { 105 | return this.get(username) !== null; 106 | } 107 | 108 | isErrorCached(username) { 109 | const cached = this.memoryCache.get(username); 110 | return cached && cached.isError && Date.now() < cached.expiry; 111 | } 112 | } 113 | 114 | class RateLimiter { 115 | constructor() { 116 | this.requests = []; 117 | this.isRateLimited = false; 118 | this.rateLimitEndTime = 0; 119 | } 120 | 121 | canMakeRequest() { 122 | if (this.isRateLimited) { 123 | if (Date.now() < this.rateLimitEndTime) { 124 | return false; 125 | } 126 | this.isRateLimited = false; 127 | } 128 | const oneMinuteAgo = Date.now() - 60 * 1000; 129 | this.requests = this.requests.filter(t => t > oneMinuteAgo); 130 | return this.requests.length < CONFIG.MAX_REQUESTS_PER_MINUTE; 131 | } 132 | 133 | recordRequest() { 134 | this.requests.push(Date.now()); 135 | } 136 | 137 | setRateLimited() { 138 | this.isRateLimited = true; 139 | this.rateLimitEndTime = Date.now() + CONFIG.RATE_LIMIT_WAIT; 140 | console.log(`[Xbout] Rate limited, waiting until ${new Date(this.rateLimitEndTime).toLocaleTimeString()}`); 141 | showToast('Xbout: Rate limited by X API. Please wait a moment.', 5000, 'warning'); 142 | } 143 | 144 | getWaitTime() { 145 | if (this.isRateLimited) { 146 | return Math.max(0, this.rateLimitEndTime - Date.now()); 147 | } 148 | return 0; 149 | } 150 | } 151 | 152 | const cache = new CacheManager(); 153 | const rateLimiter = new RateLimiter(); 154 | const processedElements = new WeakSet(); 155 | const pendingUsers = new Set(); 156 | 157 | let queryId = null; 158 | let scanTimeout = null; 159 | let mutationObserver = null; 160 | 161 | // Toast notification function 162 | function showToast(message, duration = 5000, type = 'warning') { 163 | // Remove existing toast if any 164 | const existingToast = document.querySelector('.xbout-toast'); 165 | if (existingToast) { 166 | existingToast.remove(); 167 | } 168 | 169 | const toast = document.createElement('div'); 170 | toast.className = `xbout-toast xbout-toast-${type}`; 171 | toast.textContent = message; 172 | document.body.appendChild(toast); 173 | 174 | // Trigger animation 175 | requestAnimationFrame(() => { 176 | toast.classList.add('xbout-toast-show'); 177 | }); 178 | 179 | // Auto remove after duration 180 | setTimeout(() => { 181 | toast.classList.remove('xbout-toast-show'); 182 | setTimeout(() => { 183 | toast.remove(); 184 | }, 300); 185 | }, duration); 186 | } 187 | 188 | function getFlag(location) { 189 | if (!location) return null; 190 | const loc = location.toLowerCase().trim(); 191 | 192 | // Area determination - using different earth emoji 193 | // 🌏 Asia, Pacific, Oceania 194 | // 🌎 America 195 | // 🌍 Europe, Africa 196 | 197 | if (loc.includes('asia') || loc.includes('pacific') || loc.includes('oceania')) { 198 | return '🌏'; 199 | } 200 | if (loc.includes('america')) { 201 | return '🌎'; 202 | } 203 | if (loc.includes('europe')) { 204 | return '🌍'; 205 | } 206 | if (loc.includes('africa')) { 207 | return '🌍'; 208 | } 209 | 210 | // Exact match country 211 | if (countryToFlag[loc]) return countryToFlag[loc]; 212 | 213 | // Partial match 214 | for (const [country, flag] of Object.entries(countryToFlag)) { 215 | if (loc.includes(country) || country.includes(loc)) { 216 | return flag; 217 | } 218 | } 219 | 220 | // Unknown region - default display Earth 221 | return '🌍'; 222 | } 223 | 224 | // Format location name for tooltip display (capitalize each word) 225 | function formatLocationName(location) { 226 | if (!location) return ''; 227 | return location 228 | .split(' ') 229 | .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 230 | .join(' '); 231 | } 232 | 233 | function getDeviceHtml(source) { 234 | if (!source) return ''; 235 | const s = source.toLowerCase(); 236 | 237 | if (s.includes('iphone') || s.includes('ios') || s.includes('ipad') || s.includes('app store')) { 238 | return '🍎'; 239 | } 240 | if (s.includes('android') || s.includes('play store') || s.includes('google play')) { 241 | return '🤖'; 242 | } 243 | if (s === 'web' || s.includes('web app') || s.includes('browser')) { 244 | return `Web`; 245 | } 246 | 247 | return ''; 248 | } 249 | 250 | function getYear(createdAt) { 251 | if (!createdAt) return ''; 252 | const match = createdAt.match(/(\d{4})$/); 253 | if (match) return match[1]; 254 | return ''; 255 | } 256 | 257 | function getCsrfToken() { 258 | const match = document.cookie.match(/ct0=([^;]+)/); 259 | return match ? match[1] : null; 260 | } 261 | 262 | async function fetchQueryId() { 263 | try { 264 | const entries = performance.getEntriesByType('resource'); 265 | for (const entry of entries) { 266 | const match = entry.name.match(/graphql\/([^/]+)\/AboutAccountQuery/); 267 | if (match) { 268 | console.log('[Xbout] Found queryId from network:', match[1]); 269 | return match[1]; 270 | } 271 | } 272 | } catch (e) {} 273 | return null; 274 | } 275 | 276 | function setupQueryIdObserver() { 277 | try { 278 | const observer = new PerformanceObserver((list) => { 279 | for (const entry of list.getEntries()) { 280 | const match = entry.name.match(/graphql\/([^/]+)\/AboutAccountQuery/); 281 | if (match && match[1] !== queryId) { 282 | queryId = match[1]; 283 | console.log('[Xbout] Updated queryId:', queryId); 284 | } 285 | } 286 | }); 287 | observer.observe({ entryTypes: ['resource'] }); 288 | } catch (e) {} 289 | } 290 | 291 | let requestQueue = []; 292 | let isProcessing = false; 293 | 294 | async function fetchAboutInfo(username) { 295 | const csrfToken = getCsrfToken(); 296 | if (!csrfToken) return null; 297 | 298 | const currentQueryId = queryId || CONFIG.FALLBACK_QUERY_ID; 299 | const variables = JSON.stringify({ screenName: username }); 300 | const url = `https://x.com/i/api/graphql/${currentQueryId}/AboutAccountQuery?variables=${encodeURIComponent(variables)}`; 301 | 302 | try { 303 | rateLimiter.recordRequest(); 304 | 305 | const resp = await fetch(url, { 306 | method: 'GET', 307 | credentials: 'include', 308 | headers: { 309 | 'accept': '*/*', 310 | 'accept-language': 'en-US,en;q=0.9', 311 | 'authorization': `Bearer ${CONFIG.BEARER_TOKEN}`, 312 | 'content-type': 'application/json', 313 | 'x-csrf-token': csrfToken, 314 | 'x-twitter-active-user': 'yes', 315 | 'x-twitter-auth-type': 'OAuth2Session', 316 | 'x-twitter-client-language': 'en', 317 | } 318 | }); 319 | 320 | if (resp.status === 429) { 321 | rateLimiter.setRateLimited(); 322 | return { error: 'rate_limited' }; 323 | } 324 | 325 | if (!resp.ok) { 326 | console.warn(`[Xbout] API error for ${username}: ${resp.status}`); 327 | return { error: resp.status }; 328 | } 329 | 330 | const data = await resp.json(); 331 | const result = data?.data?.user_result_by_screen_name?.result; 332 | 333 | if (result) { 334 | const aboutProfile = result.about_profile || {}; 335 | const core = result.core || {}; 336 | 337 | return { 338 | location: aboutProfile.account_based_in || null, 339 | locationAccurate: aboutProfile.location_accurate !== false, // true if undefined or true 340 | source: aboutProfile.source || null, 341 | createdAt: core.created_at || null 342 | }; 343 | } 344 | 345 | return null; 346 | } catch (e) { 347 | console.warn(`[Xbout] Fetch error for ${username}:`, e.message); 348 | return { error: 'network' }; 349 | } 350 | } 351 | 352 | async function processQueue() { 353 | if (isProcessing || requestQueue.length === 0) return; 354 | isProcessing = true; 355 | 356 | while (requestQueue.length > 0) { 357 | if (!rateLimiter.canMakeRequest()) { 358 | const waitTime = rateLimiter.getWaitTime(); 359 | if (waitTime > 0) { 360 | console.log(`[Xbout] Waiting ${Math.ceil(waitTime/1000)}s before next request...`); 361 | await new Promise(r => setTimeout(r, waitTime)); 362 | continue; 363 | } 364 | } 365 | 366 | const { username, callback } = requestQueue.shift(); 367 | 368 | if (cache.has(username)) { 369 | callback(cache.get(username)); 370 | continue; 371 | } 372 | 373 | const info = await fetchAboutInfo(username); 374 | 375 | if (info?.error === 'rate_limited') { 376 | requestQueue.unshift({ username, callback }); 377 | await new Promise(r => setTimeout(r, CONFIG.RATE_LIMIT_WAIT)); 378 | continue; 379 | } 380 | 381 | if (info?.error) { 382 | cache.set(username, null, true); 383 | pendingUsers.delete(username); 384 | callback(null); 385 | } else if (info) { 386 | console.log(`[Xbout] ${username}: ${info.location} → ${getFlag(info.location)}${info.locationAccurate ? '' : ' (VPN)'}`); 387 | cache.set(username, info); 388 | pendingUsers.delete(username); 389 | callback(info); 390 | } else { 391 | cache.set(username, null, true); 392 | pendingUsers.delete(username); 393 | callback(null); 394 | } 395 | 396 | await new Promise(r => setTimeout(r, CONFIG.REQUEST_DELAY)); 397 | } 398 | 399 | isProcessing = false; 400 | } 401 | 402 | function getUserInfo(username, callback) { 403 | if (cache.has(username)) { 404 | const cached = cache.get(username); 405 | callback(cached); 406 | return; 407 | } 408 | 409 | if (cache.isErrorCached(username)) { 410 | callback(null); 411 | return; 412 | } 413 | 414 | if (pendingUsers.has(username)) { 415 | return; 416 | } 417 | 418 | pendingUsers.add(username); 419 | requestQueue.push({ username, callback }); 420 | processQueue(); 421 | } 422 | 423 | function findDateElement(usernameLink) { 424 | let container = usernameLink.parentElement; 425 | for (let i = 0; i < 5 && container; i++) { 426 | const timeElement = container.querySelector('time'); 427 | if (timeElement) { 428 | let dateContainer = timeElement.closest('a') || timeElement.parentElement; 429 | return dateContainer; 430 | } 431 | container = container.parentElement; 432 | } 433 | return null; 434 | } 435 | 436 | function addBadge(element, username) { 437 | if (processedElements.has(element)) return; 438 | processedElements.add(element); 439 | 440 | getUserInfo(username, (info) => { 441 | if (!info) return; 442 | 443 | const flag = getFlag(info.location); 444 | const deviceHtml = getDeviceHtml(info.source); 445 | const year = getYear(info.createdAt); 446 | 447 | if (!flag && !deviceHtml && !year) return; 448 | 449 | const article = element.closest('article'); 450 | if (article) { 451 | const existingBadge = article.querySelector(`.xbout-badge[data-user="${username}"]`); 452 | if (existingBadge) return; 453 | } 454 | 455 | const dateElement = findDateElement(element); 456 | 457 | const badge = document.createElement('span'); 458 | badge.className = 'xbout-badge'; 459 | badge.setAttribute('data-user', username); 460 | 461 | const parts = []; 462 | 463 | // Build flag part with label and optional VPN badge 464 | if (flag) { 465 | const locationName = formatLocationName(info.location); 466 | const escapedLocationName = locationName.replace(//g, '>').replace(/"/g, '"'); 467 | 468 | if (info.locationAccurate === false) { 469 | // Location is not accurate - add VPN badge 470 | parts.push(`${flag}VPN${escapedLocationName}`); 471 | } else { 472 | parts.push(`${flag}${escapedLocationName}`); 473 | } 474 | } 475 | 476 | if (deviceHtml) parts.push(deviceHtml); 477 | if (year) parts.push(`${year}`); 478 | 479 | const content = parts.join(''); 480 | 481 | // Only add the · separator when the date element is present. 482 | if (dateElement) { 483 | badge.innerHTML = ' · ' + content; 484 | } else { 485 | badge.innerHTML = content; 486 | } 487 | 488 | try { 489 | if (dateElement) { 490 | dateElement.after(badge); 491 | } else { 492 | element.after(badge); 493 | } 494 | } catch (e) { 495 | console.warn('[Xbout] Insert error:', e); 496 | } 497 | }); 498 | } 499 | 500 | function scan() { 501 | const blacklist = ['home', 'explore', 'notifications', 'messages', 'settings', 502 | 'i', 'search', 'compose', 'login', 'signup', 'tos', 'privacy', 503 | 'about', 'jobs', 'help', 'download']; 504 | 505 | document.querySelectorAll('a[href^="/"]').forEach(link => { 506 | const text = (link.textContent || '').trim(); 507 | if (!/^@[a-zA-Z0-9_]+$/.test(text)) return; 508 | 509 | const username = text.slice(1); 510 | if (blacklist.includes(username.toLowerCase())) return; 511 | 512 | addBadge(link, username); 513 | }); 514 | } 515 | 516 | // Debounced scan function for MutationObserver 517 | function debouncedScan() { 518 | if (scanTimeout) { 519 | clearTimeout(scanTimeout); 520 | } 521 | scanTimeout = setTimeout(scan, CONFIG.SCAN_DEBOUNCE); 522 | } 523 | 524 | // Setup MutationObserver to watch for DOM changes 525 | function setupMutationObserver() { 526 | if (mutationObserver) { 527 | mutationObserver.disconnect(); 528 | } 529 | 530 | mutationObserver = new MutationObserver((mutations) => { 531 | // Check if any mutation added new nodes 532 | let hasNewNodes = false; 533 | for (const mutation of mutations) { 534 | if (mutation.addedNodes.length > 0) { 535 | hasNewNodes = true; 536 | break; 537 | } 538 | } 539 | 540 | if (hasNewNodes) { 541 | debouncedScan(); 542 | } 543 | }); 544 | 545 | // Observe the entire document for added nodes 546 | mutationObserver.observe(document.body, { 547 | childList: true, 548 | subtree: true 549 | }); 550 | 551 | console.log('[Xbout] MutationObserver started'); 552 | } 553 | 554 | async function init() { 555 | console.log('[Xbout] Initializing...'); 556 | 557 | const csrf = getCsrfToken(); 558 | if (csrf) { 559 | console.log('[Xbout] CSRF token found'); 560 | } else { 561 | console.warn('[Xbout] No CSRF token'); 562 | } 563 | 564 | queryId = await fetchQueryId(); 565 | if (!queryId) { 566 | queryId = CONFIG.FALLBACK_QUERY_ID; 567 | console.log('[Xbout] Using fallback queryId:', queryId); 568 | } 569 | 570 | setupQueryIdObserver(); 571 | 572 | // Use MutationObserver instead of setInterval 573 | setupMutationObserver(); 574 | 575 | // Initial scan 576 | scan(); 577 | console.log('[Xbout] Ready'); 578 | } 579 | 580 | setTimeout(() => { 581 | if (document.querySelector('main')) { 582 | init(); 583 | } else { 584 | setTimeout(init, 3000); 585 | } 586 | }, CONFIG.INIT_DELAY); 587 | 588 | })(); 589 | --------------------------------------------------------------------------------