Display a user's account location ๐, device type (๐ Apple / ๐ค Android), and registration year directly on X (Twitter) pages.
// ==UserScript==
// @name Xbout
// @namespace https://github.com/Yorkian/Xbout
// @version 2.0
// @description Display a user's account location ๐, device type (๐ Apple / ๐ค Android), and registration year directly on X (Twitter) pages.
// @author Yorkian
// @license MIT
// @match https://x.com/*
// @match https://twitter.com/*
// @grant GM_addStyle
// @run-at document-idle
// @homepageURL https://github.com/Yorkian/Xbout
// @supportURL https://github.com/Yorkian/Xbout/issues
// ==/UserScript==
(function() {
'use strict';
if (window.__xboutLoaded) return;
window.__xboutLoaded = true;
console.log('[Xbout] Script loaded');
// ===== Inject Styles =====
GM_addStyle(`
.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;
}
.xbout-flag-wrapper {
display: inline;
cursor: pointer;
}
.xbout-flag-text {
display: inline;
}
.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;
}
.xbout-flag-wrapper:hover .xbout-flag-text {
display: none;
}
.xbout-flag-wrapper:hover .xbout-flag-label {
display: inline;
}
.xbout-flag-container {
display: inline;
}
.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;
}
.xbout-toast {
position: fixed;
bottom: 28px;
right: 24px;
z-index: 99999;
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 280px;
padding: 12px 14px;
background: #16181c;
color: #e7e9ea;
border: 1px solid #2f3640;
border-left: 3px solid #1d9bf0;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1.4;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.25s ease, transform 0.25s ease;
pointer-events: none;
}
.xbout-toast::before {
content: "Xbout";
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
color: #1d9bf0;
background: rgba(29, 155, 240, 0.12);
padding: 2px 7px;
border-radius: 4px;
margin-top: 1px;
white-space: nowrap;
}
.xbout-toast.xbout-toast-show {
opacity: 1;
transform: translateY(0);
}
`);
// ===== Config =====
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_BASE64: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij4KICA8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSIyMiIgZmlsbD0iIzQyODVGNCIvPgogIDxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Ik0yNCAyQzEzLjUgMiA0LjYgOC42IDIuMSAxNy44TDE0LjMgMjRsNS4yLTljMS4zLTIuMyAzLjgtNCA2LjUtNGgxOS44QzQyLjMgNS42IDMzLjggMiAyNCAyeiIvPgogIDxwYXRoIGZpbGw9IiNGQkJDMDUiIGQ9Ik0yLjEgMTcuOEMuNyAyMi4zLjcgMjcuMiAyLjEgMzEuN2wxMi4yLTYuMi01LjItOWMtMS4zLTIuMy0xLjgtNS0xLjMtNy41TDIuMSAxNy44eiIvPgogIDxwYXRoIGZpbGw9IiMzNEE4NTMiIGQ9Ik0yNCA0NmM5LjUgMCAxOC01LjYgMjEuOS0xNC4zbC0xMi4yLTYuMi01LjIgOWMtMS4zIDIuMy0zLjggNC02LjUgNC01LjUgMC0xMC00LjUtMTAtMTAgMC0xLjkuNS0zLjYgMS40LTUuMkwyLjEgMzEuN0M1LjcgNDAuNCAxNC4yIDQ2IDI0IDQ2eiIvPgogIDxjaXJjbGUgY3g9IjI0IiBjeT0iMjQiIHI9IjkiIGZpbGw9IiNmZmYiLz4KICA8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSI3IiBmaWxsPSIjNDI4NUY0Ii8+Cjwvc3ZnPgo=',
};
// ===== Country โ Flag =====
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': '๐ด๓ ง๓ ข๓ ณ๓ ฃ๓ ดU+E007F',
'united states': '๐บ๐ธ', 'usa': '๐บ๐ธ', 'us': '๐บ๐ธ',
'canada': '๐จ๐ฆ', 'mexico': '๐ฒ๐ฝ', 'brazil': '๐ง๐ท', 'argentina': '๐ฆ๐ท',
'chile': '๐จ๐ฑ', 'colombia': '๐จ๐ด', 'peru': '๐ต๐ช', 'venezuela': '๐ป๐ช',
'australia': '๐ฆ๐บ', 'new zealand': '๐ณ๐ฟ', 'south africa': '๐ฟ๐ฆ',
'egypt': '๐ช๐ฌ', 'nigeria': '๐ณ๐ฌ', 'kenya': '๐ฐ๐ช', 'morocco': '๐ฒ๐ฆ',
'ethiopia': '๐ช๐น', 'ghana': '๐ฌ๐ญ',
};
// ===== Cache Manager =====
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;
}
}
// ===== Rate Limiter =====
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('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 =====
function showToast(message, duration = 5000, type = 'warning') {
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);
requestAnimationFrame(() => {
toast.classList.add('xbout-toast-show');
});
setTimeout(() => {
toast.classList.remove('xbout-toast-show');
setTimeout(() => toast.remove(), 300);
}, duration);
}
// ===== Helpers =====
function getFlag(location) {
if (!location) return null;
const loc = location.toLowerCase().trim();
if (loc.includes('asia') || loc.includes('pacific') || loc.includes('oceania')) return '๐';
if (loc.includes('america')) return '๐';
if (loc.includes('europe') || loc.includes('africa')) return '๐';
if (countryToFlag[loc]) return countryToFlag[loc];
for (const [country, flag] of Object.entries(countryToFlag)) {
if (loc.includes(country) || country.includes(loc)) return flag;
}
return '๐';
}
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 `<img src="${CONFIG.CHROME_ICON_BASE64}" class="xbout-device-icon" alt="Web">`;
}
return '';
}
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;
}
// ===== Query ID =====
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) {}
}
// ===== API =====
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,
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)) {
callback(cache.get(username));
return;
}
if (cache.isErrorCached(username)) {
callback(null);
return;
}
if (pendingUsers.has(username)) return;
pendingUsers.add(username);
requestQueue.push({ username, callback });
processQueue();
}
// ===== DOM =====
function findDateElement(usernameLink) {
let container = usernameLink.parentElement;
for (let i = 0; i < 5 && container; i++) {
const timeElement = container.querySelector('time');
if (timeElement) {
return timeElement.closest('a') || timeElement.parentElement;
}
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;
// ๆฉๅคงๅฎนๅจๆฃๆต๏ผarticle๏ผๆจๆๆต๏ผใUserCell๏ผWho to Follow / ๆ็ดข็ปๆ๏ผใTypeaheadUser๏ผๆ็ดขไธๆ๏ผ
const container = element.closest('article') ||
element.closest('[data-testid="UserCell"]') ||
element.closest('[data-testid="TypeaheadUser"]');
if (container) {
const existingBadge = container.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 = [];
if (flag) {
const locationName = formatLocationName(info.location);
const escapedLocationName = locationName
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
if (info.locationAccurate === false) {
parts.push(`<span class="xbout-flag-wrapper"><span class="xbout-flag-text"><span class="xbout-flag-container">${flag}<span class="xbout-vpn-badge">VPN</span></span></span><span class="xbout-flag-label">${escapedLocationName}</span></span>`);
} else {
parts.push(`<span class="xbout-flag-wrapper"><span class="xbout-flag-text">${flag}</span><span class="xbout-flag-label">${escapedLocationName}</span></span>`);
}
}
if (deviceHtml) parts.push(deviceHtml);
if (year) parts.push(`<span class="xbout-year">${year}</span>`);
const content = parts.join('<span class="xbout-sep">๏ฝ</span>');
badge.innerHTML = dateElement
? '<span class="xbout-dot"> ยท </span>' + content
: 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);
});
}
function debouncedScan() {
if (scanTimeout) clearTimeout(scanTimeout);
scanTimeout = setTimeout(scan, CONFIG.SCAN_DEBOUNCE);
}
function setupMutationObserver() {
if (mutationObserver) mutationObserver.disconnect();
mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
debouncedScan();
break;
}
}
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
console.log('[Xbout] MutationObserver started');
}
// ===== Init =====
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();
setupMutationObserver();
scan();
console.log('[Xbout] Ready');
}
setTimeout(() => {
if (document.querySelector('main')) {
init();
} else {
setTimeout(init, 3000);
}
}, CONFIG.INIT_DELAY);
})();