Xbout

Display a user's account location 🌍, device type (🍎 Apple / 🤖 Android), and registration year directly on X (Twitter) pages.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

})();