X Account Location Tagger (AboutAccountQuery)

在 X 推文时间戳旁显示按钮,点击后查询"账号所在地 / App Store 区域",红色标注可能使用 VPN 的账号。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X Account Location Tagger (AboutAccountQuery)
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  在 X 推文时间戳旁显示按钮,点击后查询"账号所在地 / App Store 区域",红色标注可能使用 VPN 的账号。
// @author       海空蒼
// @homepage     https://github.com/SkyBlue997/X-Account-Location-Tagger
// @source       https://github.com/SkyBlue997/X-Account-Location-Tagger
// @match        https://x.com/*
// @match        https://twitter.com/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    /*****************************************************************
     * 配置区
     *****************************************************************/

    const ABOUT_ENDPOINT =
        'https://x.com/i/api/graphql/zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery';

    const AUTH_BEARER =
        'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';

    const DEBUG = true;

    /*****************************************************************
     * 工具函数
     *****************************************************************/

    function log(...args) {
        if (DEBUG) {
            console.log('[X-AccountLocation]', ...args);
        }
    }

    function getCsrfToken() {
        const m = document.cookie.match(/(?:^|;\s*)ct0=([^;]+)/);
        return m ? decodeURIComponent(m[1]) : '';
    }

    function extractLocationFromResponse(data) {
        if (!data || !data.data) return null;

        let result = null;

        // 结构 1:data.user_result.result
        if (data.data.user_result && data.data.user_result.result) {
            result = data.data.user_result.result;
        }
        // 结构 2:data.user.result
        else if (data.data.user && data.data.user.result) {
            result = data.data.user.result;
        }
        // 结构 3:data.user_result_by_screen_name.result(当前 AboutAccountQuery 返回)
        else if (data.data.user_result_by_screen_name && data.data.user_result_by_screen_name.result) {
            result = data.data.user_result_by_screen_name.result;
        }

        if (!result) {
            log('未知 GraphQL 顶层结构,data =', JSON.stringify(data, null, 2));
            return null;
        }

        // 新结构:result.about_profile
        const aboutProfile =
            result.about_profile ||
            result.aboutProfile ||
            (result.aboutModule && (result.aboutModule.about_profile || result.aboutModule.aboutProfile)) ||
            null;

        if (!aboutProfile) {
            log('未找到 about_profile 字段,打印 result 以供检查:', result);
            return null;
        }

        const country = aboutProfile.account_based_in || aboutProfile.accountBasedIn || null;
        // 目前返回里没有明显的 countryCode,可以留空,将来若有字段再补
        const countryCode = null;
        // about_profile.source 是类似 "Japan App Store" / "Turkey App Store" / "Canada Android App" 的字符串
        const appStoreRegion = aboutProfile.source || null;
        // location_accurate 为 false 表示可能使用了 VPN
        const locationAccurate = aboutProfile.location_accurate !== false; // 默认为 true

        if (!country && !appStoreRegion) {
            return null;
        }

        return {
            country,
            countryCode,
            appStoreRegion,
            locationAccurate,
        };
    }

    /*****************************************************************
     * 调用 GraphQL:AboutAccountQuery
     *****************************************************************/

    async function fetchAccountLocation(screenName) {
        const variables = { screenName };
        const url =
            ABOUT_ENDPOINT +
            '?variables=' + encodeURIComponent(JSON.stringify(variables));

        const csrf = getCsrfToken();

        log('请求 AboutAccountQuery:', screenName);

        const res = await fetch(url, {
            method: 'GET',
            credentials: 'include',
            headers: {
                'authorization': AUTH_BEARER,
                'x-csrf-token': csrf,
                'x-twitter-active-user': 'yes',
                'x-twitter-auth-type': 'OAuth2Session',
                'x-twitter-client-language': document.documentElement.lang || 'zh-cn',
                'accept': '*/*',
                'content-type': 'application/json',
            },
        });

        if (!res.ok) {
            log('请求失败:', screenName, 'HTTP', res.status);
            if (res.status === 429) {
                log('遭遇 rate limit,请稍后再试');
            }
            throw new Error(`HTTP ${res.status}`);
        }

        const data = await res.json();
        const loc = extractLocationFromResponse(data);
        log('获得位置:', screenName, loc);
        return loc;
    }

    /*****************************************************************
     * DOM:按需查询归属地
     *****************************************************************/

    const locationCache = new Map();

    function addLocationButton(timeElement, screenName) {
        // 检查是否已经添加过按钮或标签
        const link = timeElement.closest('a');
        if (!link || link.dataset.xLocationTagged) return;

        // 如果已经有缓存,直接显示标签
        if (locationCache.has(screenName)) {
            const info = locationCache.get(screenName);
            if (info && (info.country || info.appStoreRegion)) {
                showLocationLabel(timeElement, info, screenName);
                return;
            }
        }

        // 创建查询按钮
        const button = document.createElement('button');
        button.textContent = '📍';
        button.title = '查看归属地';
        button.style.marginLeft = '4px';
        button.style.fontSize = '12px';
        button.style.border = 'none';
        button.style.background = 'none';
        button.style.cursor = 'pointer';
        button.style.opacity = '0.6';
        button.style.padding = '0 2px';
        button.style.transition = 'opacity 0.2s';

        button.onmouseenter = () => {
            button.style.opacity = '1';
        };
        button.onmouseleave = () => {
            button.style.opacity = '0.6';
        };

        button.onclick = async (e) => {
            e.preventDefault();
            e.stopPropagation();

            // 防止重复请求
            if (button.dataset.loading) return;
            button.dataset.loading = 'true';
            button.textContent = '⏳';
            button.disabled = true;

            try {
                const info = await fetchAccountLocation(screenName);
                locationCache.set(screenName, info || {});

                if (info && (info.country || info.appStoreRegion)) {
                    // 移除按钮,显示标签
                    button.remove();
                    showLocationLabel(timeElement, info, screenName);
                } else {
                    button.textContent = '❌';
                    button.title = '无归属地信息';
                    setTimeout(() => {
                        button.remove();
                    }, 2000);
                }
            } catch (e) {
                log('查询归属地失败:', screenName, e);
                button.textContent = '⚠️';
                button.title = '查询失败';
                button.disabled = false;
                delete button.dataset.loading;
            }
        };

        link.dataset.xLocationTagged = '1';
        timeElement.insertAdjacentElement('afterend', button);
    }

    function showLocationLabel(timeElement, info, screenName) {
        const link = timeElement.closest('a');
        if (!link) return;

        link.dataset.xLocationTagged = '1';

        const parts = [];
        if (info.country) parts.push(info.country);
        if (info.appStoreRegion) parts.push(info.appStoreRegion);
        const label = parts.join(' / ');
        if (!label) return;

        const tag = document.createElement('span');
        tag.textContent = ` [${label}]`;
        tag.style.marginLeft = '4px';
        tag.style.fontSize = '12px';
        tag.style.opacity = '0.7';

        // 根据 location_accurate 设置颜色
        // false 表示可能使用了 VPN,标记为红色
        if (info.locationAccurate === false) {
            tag.style.color = '#f91880'; // 红色 (Twitter 警告红)
            tag.title = '可能使用了 VPN';
        } else {
            tag.style.color = '#536471'; // Twitter 灰色
        }

        timeElement.insertAdjacentElement('afterend', tag);
    }

    function scanAndAddButtons() {
        const timeLinks = document.querySelectorAll('a[href*="/status/"]');

        timeLinks.forEach((link) => {
            // 跳过已处理的
            if (link.dataset.xLocationTagged) return;

            const href = link.getAttribute('href');
            const match = href?.match(/^\/([^\/]+)\/status\/\d+/);
            if (!match) return;

            const screenName = match[1];
            const timeElement = link.querySelector('time');
            if (!timeElement) return;

            addLocationButton(timeElement, screenName);
        });
    }

    /*****************************************************************
     * 启动
     *****************************************************************/

    function init() {
        log('脚本启动 - 点击按钮查询归属地');

        // 初始扫描
        scanAndAddButtons();

        // 监听 DOM 变化
        const mo = new MutationObserver((mutations) => {
            let needRescan = false;
            for (const m of mutations) {
                if (m.addedNodes && m.addedNodes.length > 0) {
                    needRescan = true;
                    break;
                }
            }
            if (needRescan) {
                scanAndAddButtons();
            }
        });

        mo.observe(document.body, {
            childList: true,
            subtree: true,
        });
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        init();
    } else {
        window.addEventListener('DOMContentLoaded', init, { once: true });
    }
})();