// ==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 `
`;
}
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);
})();