// ==UserScript==
// @name Xbout-Userscript
// @namespace https://github.com/code-gal/Xbout-Userscript
// @version 1.11
// @description Display the user's location, device type, and registration year on the X (Twitter) page.
// @author code-gal
// @match https://x.com/*
// @match https://twitter.com/*
// @icon https://abs.twimg.com/favicons/twitter.2.ico
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/556898/Xbout-Userscript.user.js
// @updateURL https://update.greasyfork.icu/scripts/556898/Xbout-Userscript.meta.js
// ==/UserScript==
(function() {
'use strict';
// Inject CSS Styles
GM_addStyle(`
.xbout-badge {
display: flex !important;
align-items: center;
font-size: 13px;
line-height: 1;
white-space: nowrap;
margin-top: 2px;
opacity: 0;
transition: opacity 0.2s ease-out;
color: #536471;
cursor: default;
width: 100%;
flex-basis: 100%;
}
.xbout-badge.loaded {
opacity: 1;
}
.xbout-item {
display: inline-flex;
align-items: center;
margin: 0 2px;
padding: 1px 2px;
border-radius: 4px;
}
.xbout-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.xbout-dot {
color: rgb(83, 100, 113);
margin: 0 2px;
}
.xbout-sep {
color: #536471;
margin: 0 3px;
font-size: 10px;
opacity: 0.4;
}
.xbout-year {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-weight: 500;
font-size: 12px;
}
.xbout-device-icon {
width: 1.1em;
height: 1.1em;
vertical-align: -0.15em;
filter: grayscale(100%);
opacity: 0.7;
}
.xbout-ad-mark {
font-size: 10px;
border: 1px solid currentColor;
border-radius: 3px;
padding: 1px 3px;
line-height: 1;
margin-left: 2px;
opacity: 0.8;
font-weight: 500;
}
.xbout-proxy-mark {
width: 1.1em;
height: 1.1em;
margin-left: 4px;
cursor: help;
opacity: 0.7;
color: #536471;
vertical-align: text-bottom;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.xbout-badge, .xbout-dot, .xbout-year, .xbout-sep {
color: #71767b;
}
.xbout-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.xbout-device-icon {
filter: grayscale(100%) invert(1);
}
}
`);
// Configuration
const CONFIG = {
MIN_DELAY: 2000, // 2s
MAX_DELAY: 4000, // 4s
RATE_LIMIT_WAIT: 300000, // 5m
CACHE_DURATION: 7 * 24 * 60 * 60 * 1000,
STORAGE_KEY: 'xbout_cache_v2',
BEARER_TOKEN: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
FALLBACK_QUERY_ID: 'zs_jFPFT78rBpXv9Z3U2YQ',
ICONS: {
APPLE: '',
ANDROID: '',
WEB: '',
AD: '', // Warning icon
AIRPLANE: ''
}
};
// Extended Country Mapping
const countryToFlag = {
'china': '๐จ๐ณ', 'japan': '๐ฏ๐ต', 'south korea': '๐ฐ๐ท', 'korea': '๐ฐ๐ท', 'taiwan': '๐น๐ผ', 'hong kong': '๐ญ๐ฐ',
'singapore': '๐ธ๐ฌ', 'india': '๐ฎ๐ณ', 'thailand': '๐น๐ญ', 'vietnam': '๐ป๐ณ', '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': '๐ฌ๐ญ', 'algeria': '๐ฉ๐ฟ', 'angola': '๐ฆ๐ด', 'benin': '๐ง๐ฏ', 'botswana': '๐ง๐ผ',
'burkina faso': '๐ง๐ซ', 'burundi': '๐ง๐ฎ', 'cameroon': '๐จ๐ฒ', 'cape verde': '๐จ๐ป', 'chad': '๐น๐ฉ', 'comoros': '๐ฐ๐ฒ',
'congo': '๐จ๐ฌ', 'djibouti': '๐ฉ๐ฏ', 'equatorial guinea': '๐ฌt', 'eritrea': '๐ช๐ท', 'eswatini': '๐ธ๐ฟ',
'gabon': '๐ฌ๐ฆ', 'gambia': '๐ฌ๐ฒ', 'guinea': '๐ฌ๐ณ', 'guinea-bissau': '๐ฌ๐ผ', 'ivory coast': '๐จ๐ฎ', 'lesotho': '๐ฑ๐ธ',
'liberia': '๐ฑ๐ท', 'libya': '๐ฑ๐พ', 'madagascar': '๐ฒ๐ฌ', 'malawi': '๐ฒ๐ผ', 'mali': '๐ฒ๐ฑ', 'mauritania': '๐ฒ๐ท',
'mauritius': '๐ฒ๐บ', 'mozambique': '๐ฒ๐ฟ', 'namibia': '๐ณ๐ฆ', 'niger': '๐ณ๐ช', 'rwanda': '๐ท๐ผ', 'senegal': '๐ธ๐ณ',
'seychelles': '๐ธ๐จ', 'sierra leone': '๐ธ๐ฑ', 'somalia': '๐ธ๐ด', 'south sudan': '๐ธ๐ธ', 'sudan': '๐ธ๐ฉ', 'tanzania': '๐น๐ฟ',
'togo': '๐น๐ฌ', 'tunisia': '๐น๐ณ', 'uganda': '๐บ๐ฌ', 'zambia': '๐ฟ๐ฒ', 'zimbabwe': '๐ฟ๐ผ',
'afghanistan': '๐ฆ๐ซ', 'armenia': '๐ฆ๐ฒ', 'azerbaijan': '๐ฆ๐ฟ', 'bhutan': '๐ง๐น', 'brunei': '๐ง๐ณ', 'cyprus': '๐จ๐พ',
'georgia': '๐ฌ๐ช', 'kazakhstan': '๐ฐ๐ฟ', 'kyrgyzstan': '๐ฐ๐ฌ', 'laos': '๐ฑ๐ฆ', 'maldives': '๐ฒ๐ป', 'north korea': '๐ฐ๐ต',
'syria': '๐ธ๐พ', 'tajikistan': '๐น๐ฏ', 'timor-leste': '๐น๐ฑ', 'turkmenistan': '๐น๐ฒ', 'uzbekistan': '๐บ๐ฟ', 'yemen': '๐พ๐ช',
'albania': '๐ฆ๐ฑ', 'andorra': '๐ฆ๐ฉ', 'belarus': '๐ง๐พ', 'bosnia': '๐ง๐ฆ', 'bulgaria': '๐ง๐ฌ', 'croatia': '๐ญ๐ท',
'estonia': '๐ช๐ช', 'iceland': '๐ฎ๐ธ', 'kosovo': '๐ฝ๐ฐ', 'latvia': '๐ฑ๐ป', 'liechtenstein': '๐ฑ๐ฎ', 'lithuania': '๐ฑ๐น',
'luxembourg': '๐ฑ๐บ', 'malta': '๐ฒ๐น', 'moldova': '๐ฒ๐ฉ', 'monaco': '๐ฒ๐จ', 'montenegro': '๐ฒ๐ช', 'north macedonia': '๐ฒ๐ฐ',
'san marino': '๐ธ๐ฒ', 'serbia': '๐ท๐ธ', 'slovakia': '๐ธ๐ฐ', 'slovenia': '๐ธ๐ฎ', 'vatican': '๐ป๐ฆ',
'antigua': '๐ฆ๐ฌ', 'bahamas': '๐ง๐ธ', 'barbados': '๐ง๐ง', 'belize': '๐ง๐ฟ', 'costa rica': '๐จ๐ท', 'cuba': '๐จ๐บ',
'dominica': '๐ฉ๐ฒ', 'dominican republic': '๐ฉ๐ด', 'el salvador': '๐ธ๐ป', 'grenada': '๐ฌ๐ฉ', 'guatemala': '๐ฌ๐น',
'haiti': '๐ญ๐น', 'honduras': '๐ญ๐ณ', 'jamaica': '๐ฏ๐ฒ', 'nicaragua': '๐ณ๐ฎ', 'panama': '๐ต๐ฆ', 'saint kitts': '๐ฐ๐ณ',
'saint lucia': '๐ฑ๐จ', 'saint vincent': '๐ป๐จ', 'trinidad': '๐น๐น',
'bolivia': '๐ง๐ด', 'ecuador': '๐ช๐จ', 'guyana': '๐ฌ๐พ', 'paraguay': '๐ต๐พ', 'suriname': '๐ธ๐ท', 'uruguay': '๐บ๐พ',
'fiji': '๐ซ๐ฏ', 'kiribati': '๐ฐ๐ฎ', 'marshall islands': '๐ฒ๐ญ', 'micronesia': '๐ซ๐ฒ', 'nauru': '๐ณ๐ท', 'palau': '๐ต๐ผ',
'papua new guinea': '๐ต๐ฌ', 'samoa': '๐ผ๐ธ', 'solomon islands': '๐ธ๐ง', 'tonga': '๐น๐ด', 'tuvalu': '๐น๐ป', 'vanuatu': '๐ป๐บ'
};
// --- State Management ---
let queryId = null;
let requestQueue = [];
let isProcessing = false;
let rateLimitUntil = 0;
const pendingRequests = new Map();
const elementState = new WeakMap();
const sessionRequestedUsers = new Set();
// --- Cache Manager ---
const Cache = (() => {
let store = null;
let saveTimer = null;
const load = () => {
if (store) return;
try {
store = GM_getValue(CONFIG.STORAGE_KEY, {});
} catch (e) { store = {}; }
};
const save = () => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
GM_setValue(CONFIG.STORAGE_KEY, store);
saveTimer = null;
}, 2000);
};
return {
get: (username) => {
load();
const record = store[username];
if (!record) return null;
if (Date.now() > record.expiry) {
delete store[username];
return null;
}
return record.data;
},
set: (username, data) => {
load();
if (Math.random() < 0.05) {
const now = Date.now();
for(const key in store) {
if(store[key].expiry < now) delete store[key];
}
}
store[username] = { data, expiry: Date.now() + CONFIG.CACHE_DURATION };
save();
}
};
})();
// --- Helpers ---
function getFlagInfo(location) {
if (!location) return null;
const loc = location.toLowerCase().trim();
let emoji = '๐';
if (countryToFlag[loc]) {
emoji = countryToFlag[loc];
} else {
for (const [country, flag] of Object.entries(countryToFlag)) {
if (loc.includes(country) || country.includes(loc)) {
emoji = flag;
break;
}
}
}
return { emoji, name: location };
}
function getDeviceInfo(source) {
if (!source) return null;
const text = source.replace(/<[^>]*>?/gm, '');
const s = text.toLowerCase();
let icon = null;
if (s.includes('iphone') || s.includes('ipad') || s.includes('mac') || s.includes('ios') || s.includes('app store')) {
icon = CONFIG.ICONS.APPLE;
} else if (s.includes('android')) {
icon = CONFIG.ICONS.ANDROID;
} else if (s.includes('web') || s.includes('browser') || s.includes('chrome') || s.includes('edge') || s.includes('safari')) {
icon = CONFIG.ICONS.WEB;
}
if (icon) {
return { icon, name: text };
}
return null;
}
function getYear(createdAt) {
if (!createdAt) return '';
const match = createdAt.match(/(\d{4})$/);
return match ? match[1] : '';
}
function getCsrfToken() {
const match = document.cookie.match(/ct0=([^;]+)/);
return match ? match[1] : null;
}
// --- Networking ---
async function fetchQueryId() {
try {
const entries = performance.getEntriesByType('resource');
for (const entry of entries) {
const match = entry.name.match(/graphql\/([^/]+)\/AboutAccountQuery/);
if (match) return match[1];
}
} catch (e) {}
return CONFIG.FALLBACK_QUERY_ID;
}
function initQueryIdListener() {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const match = entry.name.match(/graphql\/([^/]+)\/AboutAccountQuery/);
if (match) queryId = match[1];
}
});
observer.observe({ entryTypes: ['resource'] });
} catch (e) {}
}
async function fetchUserInfo(username) {
const cached = Cache.get(username);
if (cached) return cached;
if (pendingRequests.has(username)) return pendingRequests.get(username);
const promise = (async () => {
const csrfToken = getCsrfToken();
if (!csrfToken) return null;
const qId = queryId || await fetchQueryId();
const url = `https://x.com/i/api/graphql/${qId}/AboutAccountQuery?variables=${encodeURIComponent(JSON.stringify({ screenName: username }))}`;
const resp = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'authorization': `Bearer ${CONFIG.BEARER_TOKEN}`,
'content-type': 'application/json',
'x-csrf-token': csrfToken,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session'
}
});
if (resp.status === 429) throw new Error('RATE_LIMIT');
if (!resp.ok) return null;
const data = await resp.json();
const result = data?.data?.user_result_by_screen_name?.result;
if (result) {
const info = {
location: result.about_profile?.account_based_in || null,
source: result.about_profile?.source || null,
createdAt: result.core?.created_at || null,
isAccurate: result.about_profile?.location_accurate
};
if (info.location || info.source || info.createdAt) {
Cache.set(username, info);
}
return info;
}
return null;
})();
pendingRequests.set(username, promise);
try { return await promise; }
catch (e) { throw e; }
finally { pendingRequests.delete(username); }
}
async function processQueue() {
if (isProcessing) return;
isProcessing = true;
while (requestQueue.length > 0) {
const now = Date.now();
if (now < rateLimitUntil) {
const wait = rateLimitUntil - now;
console.log(`[Xbout] Rate limited. Resuming in ${Math.ceil(wait/1000)}s`);
await new Promise(r => setTimeout(r, wait));
continue;
}
const { username, resolve } = requestQueue.shift();
try {
const info = await fetchUserInfo(username);
resolve(info);
const delay = Math.floor(Math.random() * (CONFIG.MAX_DELAY - CONFIG.MIN_DELAY + 1) + CONFIG.MIN_DELAY);
await new Promise(r => setTimeout(r, delay));
} catch (e) {
if (e.message === 'RATE_LIMIT') {
console.warn(`[Xbout] 429 Rate Limit Hit. Pausing for ${CONFIG.RATE_LIMIT_WAIT / 1000 / 60} minutes.`);
rateLimitUntil = Date.now() + CONFIG.RATE_LIMIT_WAIT;
requestQueue.unshift({ username, resolve });
} else {
resolve(null);
}
}
}
isProcessing = false;
}
function queueRequest(username) {
return new Promise((resolve) => {
if (Cache.get(username)) {
resolve(Cache.get(username));
return;
}
if (sessionRequestedUsers.has(username) && !pendingRequests.has(username)) {
resolve(null);
return;
}
sessionRequestedUsers.add(username);
requestQueue.push({ username, resolve });
processQueue();
});
}
// --- DOM & UI ---
function createBadge(info, username, isAd = false) {
if (!info) return null;
const flagInfo = getFlagInfo(info.location);
const deviceInfo = getDeviceInfo(info.source);
const year = getYear(info.createdAt);
if (!flagInfo && !deviceInfo && !year && !isAd) return null;
const badge = document.createElement('div');
badge.className = 'xbout-badge';
badge.dataset.user = username;
// Removed the dot as we are now on a new line
// const dot = document.createElement('span');
// dot.className = 'xbout-dot';
// dot.textContent = 'ยท';
// badge.appendChild(dot);
if (flagInfo) {
const item = document.createElement('span');
item.className = 'xbout-item';
item.textContent = flagInfo.emoji;
item.title = `Location: ${flagInfo.name}`;
badge.appendChild(item);
if (info.isAccurate === false) {
const mark = document.createElement('span');
mark.className = 'xbout-proxy-mark';
mark.innerHTML = CONFIG.ICONS.AIRPLANE;
mark.title = 'This data may be inaccurate.\nSome internet service providers may automatically use a proxy without user action.';
badge.appendChild(mark);
}
}
if (deviceInfo) {
if (flagInfo) badge.appendChild(createSep());
const item = document.createElement('span');
item.className = 'xbout-item';
item.innerHTML = deviceInfo.icon;
item.title = `Source: ${deviceInfo.name}`;
badge.appendChild(item);
}
if (year) {
if (flagInfo || deviceInfo) badge.appendChild(createSep());
const item = document.createElement('span');
item.className = 'xbout-item xbout-year';
item.textContent = year;
item.title = `Joined: ${year}`;
badge.appendChild(item);
}
if (isAd) {
if (flagInfo || deviceInfo || year) badge.appendChild(createSep());
const item = document.createElement('span');
item.className = 'xbout-ad-mark';
item.textContent = 'Ad';
item.title = 'Promoted Content';
badge.appendChild(item);
}
setTimeout(() => badge.classList.add('loaded'), 50);
return badge;
}
function createSep() {
const sep = document.createElement('span');
sep.className = 'xbout-sep';
sep.textContent = '|';
return sep;
}
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const target = entry.target;
const username = target.dataset.xboutUser;
intersectionObserver.unobserve(target);
const state = elementState.get(target) || {};
if (state.requested) return;
state.requested = true;
elementState.set(target, state);
queueRequest(username).then(info => {
if (info && !elementState.get(target).rendered) {
const header = target.closest('[data-testid="User-Name"]');
const isAd = header && !header.querySelector('time');
const badge = createBadge(info, username, isAd);
if (badge) {
const insertTarget = findInsertTarget(target, isAd);
if (insertTarget) {
insertTarget.after(badge);
} else {
target.after(badge);
}
const newState = elementState.get(target);
newState.rendered = true;
elementState.set(target, newState);
}
}
});
}
});
}, { rootMargin: '100px' });
function findInsertTarget(userLink, isAd) {
const header = userLink.closest('[data-testid="User-Name"]');
if (!header) return userLink;
// Insert after the header container to force a new line
// The header container usually contains Name, Verified Icon, Handle, Time, etc.
// We want to be outside of that flex container to break to a new line
return header;
}
function processLink(link) {
if (link.hasAttribute('data-xbout-user')) return;
const text = (link.textContent || '').trim();
if (!text.startsWith('@') || text.includes(' ')) return;
const username = text.slice(1);
const blacklist = ['home', 'explore', 'notifications', 'messages', 'settings', 'search', 'privacy', 'tos', 'about', 'support'];
if (blacklist.includes(username.toLowerCase())) return;
link.dataset.xboutUser = username;
elementState.set(link, { requested: false, rendered: false });
intersectionObserver.observe(link);
}
function scanNode(node) {
if (node.nodeType !== 1) return;
const check = (link) => {
if (link.closest('[data-testid="User-Name"]')) {
processLink(link);
}
};
if (node.tagName === 'A' && node.getAttribute('href')?.startsWith('/')) {
check(node);
}
node.querySelectorAll('a[href^="/"]').forEach(check);
}
// --- Optimized Mutation Observer ---
const domObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
scanNode(node);
}
}
}
});
function init() {
console.log('[Xbout] v1.11 Initialized');
initQueryIdListener();
// Initial scan
document.querySelectorAll('[data-testid="User-Name"]').forEach(container => {
const links = container.querySelectorAll('a[href^="/"]');
links.forEach(processLink);
});
// Observer config
domObserver.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();