Xbout

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();