โ˜ฐ

Xbout

Display a user's account location ๐ŸŒ, device type (๐ŸŽ Apple / ๐Ÿค– Android), and registration year directly on X (Twitter) pages.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

        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);

})();