Greasy Fork

Greasy Fork is available in English.

Xbout

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

当前为 2025-11-27 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
// ==/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 `<img src="${CONFIG.CHROME_ICON_URL}" class="xbout-device-icon" alt="Web">`;
    }

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

        if (info.locationAccurate === false) {
          // Location is not accurate - add VPN badge
          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>');

      // Only add the · separator when the date element is present.
      if (dateElement) {
        badge.innerHTML = '<span class="xbout-dot"> · </span>' + 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);

})();