├── 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 | 
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 `
`;
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 |
--------------------------------------------------------------------------------