X Account Location Tagger

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X Account Location Tagger
// @namespace    http://tampermonkey.net/
// @version      0.2
// @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 });
    }
})();