X Account Location Tagger & Video Downloader

在 X 推文时间戳旁显示按钮,鼠标悬停自动查询"账号所在地 / App Store 区域",红色标注可能使用 VPN 的账号;同时在含视频的推文操作栏添加下载按钮,一键下载最高画质 MP4。

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         X Account Location Tagger & Video Downloader
// @namespace    http://tampermonkey.net/
// @version      0.4
// @description  在 X 推文时间戳旁显示按钮,鼠标悬停自动查询"账号所在地 / App Store 区域",红色标注可能使用 VPN 的账号;同时在含视频的推文操作栏添加下载按钮,一键下载最高画质 MP4。
// @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';

    // 视频下载:TweetResultByRestId 端点(queryId 可能会变化,需定期更新)
    const TWEET_DETAIL_ENDPOINT =
        'https://x.com/i/api/graphql/4PdbzTmQ5PTjz9RiureISQ/TweetResultByRestId';

    // 可能包含视频数据的 GraphQL 操作名
    const VIDEO_GRAPHQL_ENDPOINTS = [
        'TweetDetail',
        'TweetResultByRestId',
        'HomeTimeline',
        'HomeLatestTimeline',
        'SearchTimeline',
        'UserTweets',
        'UserTweetsAndReplies',
        'ListLatestTweetsTimeline',
        'Likes',
        'Bookmarks',
    ];

    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 响应,提取视频 MP4 变体。
     * 通过 rest_id + legacy 形状识别推文对象,
     * 从 legacy.extended_entities.media[].video_info.variants 取出 MP4 链接。
     * @returns {Map<string, {variants: Array, bestUrl: string}>}
     */
    function extractVideoVariants(obj, results) {
        if (!results) results = new Map();
        if (!obj || typeof obj !== 'object') return results;

        // 识别推文对象:有 rest_id 和 legacy 字段
        if (obj.rest_id && obj.legacy) {
            const tweetId = obj.rest_id;
            const media = obj.legacy?.extended_entities?.media;
            if (Array.isArray(media)) {
                for (const m of media) {
                    if (m.type === 'video' || m.type === 'animated_gif') {
                        const variants = m.video_info?.variants;
                        if (Array.isArray(variants)) {
                            const mp4s = variants.filter(
                                v => v.content_type === 'video/mp4' && v.bitrate != null
                            );
                            if (mp4s.length > 0) {
                                mp4s.sort((a, b) => b.bitrate - a.bitrate);
                                results.set(tweetId, {
                                    variants: mp4s,
                                    bestUrl: mp4s[0].url,
                                });
                            }
                        }
                    }
                }
            }
        }

        // 递归子属性
        if (Array.isArray(obj)) {
            for (const item of obj) {
                extractVideoVariants(item, results);
            }
        } else {
            for (const key of Object.keys(obj)) {
                if (key === 'url' || key === 'expanded_url' || key === 'display_url') continue;
                extractVideoVariants(obj[key], results);
            }
        }

        return results;
    }

    /*****************************************************************
     * 调用 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();
    // tweetId -> { variants: Array<{bitrate, url, content_type}>, bestUrl: string } | null
    const videoCache = 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 = async (e) => {
            button.style.opacity = '1';

            // 防止重复请求
            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;
            }
        };

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

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

    /*****************************************************************
     * Fetch 拦截:被动捕获视频 URL
     *****************************************************************/

    function installFetchInterceptor() {
        const originalFetch = window.fetch;

        window.fetch = function (...args) {
            const request = args[0];
            const url = (typeof request === 'string') ? request : request?.url;

            const result = originalFetch.apply(this, args);

            // 仅拦截可能含视频数据的 GraphQL 响应
            if (url && typeof url === 'string' && url.includes('/i/api/graphql/')) {
                const isRelevant = VIDEO_GRAPHQL_ENDPOINTS.some(ep => url.includes(ep));
                if (isRelevant) {
                    result
                        .then(response => response.clone().json())
                        .then(data => {
                            const videos = extractVideoVariants(data);
                            if (videos.size > 0) {
                                log('[VideoDownload] 拦截到', videos.size, '个视频:',
                                    [...videos.keys()]);
                                for (const [tweetId, info] of videos) {
                                    videoCache.set(tweetId, info);
                                }
                                scanAndAddVideoButtons();
                            }
                        })
                        .catch(() => {
                            // 静默忽略解析失败(非 JSON 响应、rate limit 等)
                        });
                }
            }

            return result;
        };

        log('[VideoDownload] Fetch 拦截器已安装');
    }

    /*****************************************************************
     * 按需查询:TweetResultByRestId(视频 fallback)
     *****************************************************************/

    async function fetchVideoForTweet(tweetId) {
        if (videoCache.has(tweetId)) {
            return videoCache.get(tweetId);
        }

        const variables = {
            tweetId: tweetId,
            withCommunity: false,
            includePromotedContent: false,
            withVoice: false,
        };
        const features = {
            creator_subscriptions_tweet_preview_api_enabled: true,
            premium_content_api_read_enabled: false,
            communities_web_enable_tweet_community_results_fetch: true,
            c9s_tweet_anatomy_moderator_badge_enabled: true,
            articles_preview_enabled: true,
            responsive_web_edit_tweet_api_enabled: true,
            graphql_is_translatable_rweb_tweet_is_translatable: true,
            view_counts_everywhere_api_enabled: true,
            longform_notetweets_consumption_enabled: true,
            responsive_web_twitter_article_tweet_consumption_enabled: true,
            tweet_awards_web_tipping_enabled: false,
            creator_subscriptions_quote_tweet_preview_enabled: false,
            freedom_of_speech_not_reach_fetch_enabled: true,
            standardized_nudges_misinfo: true,
            tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
            rweb_video_timestamps_enabled: true,
            longform_notetweets_rich_text_read_enabled: true,
            longform_notetweets_inline_media_enabled: true,
            responsive_web_enhance_cards_enabled: false,
            responsive_web_graphql_exclude_directive_enabled: true,
            verified_phone_label_enabled: false,
            responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
            responsive_web_graphql_timeline_navigation_enabled: true,
            responsive_web_media_download_video_enabled: false,
        };

        const url =
            TWEET_DETAIL_ENDPOINT +
            '?variables=' + encodeURIComponent(JSON.stringify(variables)) +
            '&features=' + encodeURIComponent(JSON.stringify(features));

        const csrf = getCsrfToken();
        log('[VideoDownload] 请求 TweetResultByRestId:', tweetId);

        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('[VideoDownload] 请求失败:', tweetId, 'HTTP', res.status);
            throw new Error(`HTTP ${res.status}`);
        }

        const data = await res.json();
        const videos = extractVideoVariants(data);

        if (videos.has(tweetId)) {
            const info = videos.get(tweetId);
            videoCache.set(tweetId, info);
            return info;
        }

        // 无视频,缓存 null 避免重复查询
        videoCache.set(tweetId, null);
        return null;
    }

    /*****************************************************************
     * 视频下载辅助
     *****************************************************************/

    function downloadVideo(url, tweetId) {
        log('[VideoDownload] 开始下载:', url);

        // 优先 fetch + blob(可控制文件名),失败则 window.open
        fetch(url)
            .then(res => {
                if (!res.ok) throw new Error('Fetch failed');
                return res.blob();
            })
            .then(blob => {
                const blobUrl = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = blobUrl;
                a.download = `tweet_${tweetId}.mp4`;
                document.body.appendChild(a);
                a.click();
                setTimeout(() => {
                    URL.revokeObjectURL(blobUrl);
                    a.remove();
                }, 1000);
            })
            .catch(err => {
                log('[VideoDownload] Blob 下载失败,使用 window.open:', err.message);
                window.open(url, '_blank');
            });
    }

    /*****************************************************************
     * DOM:视频下载按钮
     *****************************************************************/

    // 下载图标 SVG path
    const DOWNLOAD_ICON_PATH =
        'M12 2.59L12 16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z';
    // 翻转分享图标得到下载图标(上传箭头 → 下载箭头)
    const DOWNLOAD_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:currentColor;transform:rotate(180deg)"><g><path d="${DOWNLOAD_ICON_PATH}"></path></g></svg>`;

    // 加载中 SVG(旋转的圆圈)
    const LOADING_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:currentColor"><g><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" opacity="0.3"></path><path d="M12 2v4c4.42 0 8 3.58 8 8h4c0-6.62-5.38-12-12-12z"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></path></g></svg>`;

    // 成功 SVG(打勾)
    const SUCCESS_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:#00ba7c"><g><path d="M9 20l-5.447-5.46 1.42-1.42L9 17.17 19.03 7.14l1.42 1.42L9 20z"></path></g></svg>`;

    // 失败 SVG(叉号)
    const FAIL_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:#f4212e"><g><path d="M10.59 12L4.54 5.96l1.42-1.42L12 10.59l6.04-6.05 1.42 1.42L13.41 12l6.05 6.04-1.42 1.42L12 13.41l-6.04 6.05-1.42-1.42L10.59 12z"></path></g></svg>`;

    function addVideoDownloadButton(articleElement, tweetId) {
        if (articleElement.dataset.xVideoTagged) return;
        articleElement.dataset.xVideoTagged = '1';

        // 外层 wrapper:模拟 X 原生按钮的 inline-grid 容器
        const wrapper = document.createElement('div');
        wrapper.style.cssText =
            'display:inline-grid;justify-content:inherit;' +
            'transform:rotate(0deg) scale(1) translate3d(0px,0px,0px);';

        // 按钮本体
        const button = document.createElement('button');
        button.setAttribute('aria-label', '下载视频');
        button.setAttribute('role', 'button');
        button.setAttribute('type', 'button');
        button.style.cssText =
            'display:flex;align-items:center;justify-content:center;' +
            'border:0;background:transparent;padding:0;cursor:pointer;' +
            'color:#536471;transition:color 0.2s;outline:none;' +
            'min-width:18.75px;min-height:20px;';

        // 内部结构:图标容器 + 圆形 hover 背景
        const iconContainer = document.createElement('div');
        iconContainer.style.cssText =
            'display:flex;align-items:center;justify-content:center;' +
            'position:relative;width:18.75px;height:18.75px;';

        // 圆形背景层(hover 时显示)
        const hoverCircle = document.createElement('div');
        hoverCircle.style.cssText =
            'position:absolute;border-radius:9999px;' +
            'width:34.75px;height:34.75px;' +
            'transition:background-color 0.2s;';

        // SVG 图标层
        const iconSpan = document.createElement('div');
        iconSpan.style.cssText =
            'position:relative;display:flex;align-items:center;justify-content:center;';
        iconSpan.innerHTML = DOWNLOAD_SVG;

        iconContainer.appendChild(hoverCircle);
        iconContainer.appendChild(iconSpan);
        button.appendChild(iconContainer);
        wrapper.appendChild(button);

        // Hover 效果:与 X 原生一致的蓝色高亮
        button.onmouseenter = () => {
            button.style.color = '#1d9bf0';
            hoverCircle.style.backgroundColor = 'rgba(29,155,240,0.1)';
        };
        button.onmouseleave = () => {
            button.style.color = '#536471';
            hoverCircle.style.backgroundColor = 'transparent';
        };

        // 按下效果
        button.onmousedown = () => {
            hoverCircle.style.backgroundColor = 'rgba(29,155,240,0.2)';
        };
        button.onmouseup = () => {
            if (button.matches(':hover')) {
                hoverCircle.style.backgroundColor = 'rgba(29,155,240,0.1)';
            }
        };

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

            iconSpan.innerHTML = LOADING_SVG;
            button.disabled = true;
            button.style.color = '#536471';

            try {
                let info = videoCache.get(tweetId);

                if (!info) {
                    info = await fetchVideoForTweet(tweetId);
                }

                if (info && info.bestUrl) {
                    downloadVideo(info.bestUrl, tweetId);
                    iconSpan.innerHTML = SUCCESS_SVG;
                    setTimeout(() => {
                        iconSpan.innerHTML = DOWNLOAD_SVG;
                        button.disabled = false;
                    }, 2000);
                } else {
                    iconSpan.innerHTML = FAIL_SVG;
                    button.setAttribute('aria-label', '未找到视频');
                    setTimeout(() => {
                        iconSpan.innerHTML = DOWNLOAD_SVG;
                        button.setAttribute('aria-label', '下载视频');
                        button.disabled = false;
                    }, 2000);
                }
            } catch (err) {
                log('[VideoDownload] 下载失败:', err);
                iconSpan.innerHTML = FAIL_SVG;
                button.setAttribute('aria-label', '下载失败');
                setTimeout(() => {
                    iconSpan.innerHTML = DOWNLOAD_SVG;
                    button.setAttribute('aria-label', '下载视频');
                    button.disabled = false;
                }, 3000);
            }
        };

        // 放置在推文操作栏末尾
        const actionBar = articleElement.querySelector('div[role="group"]');
        if (actionBar) {
            actionBar.appendChild(wrapper);
        } else {
            const timeEl = articleElement.querySelector('a[href*="/status/"] time');
            if (timeEl) {
                timeEl.insertAdjacentElement('afterend', wrapper);
            }
        }
    }

    function scanAndAddVideoButtons() {
        const articles = document.querySelectorAll('article[data-testid="tweet"]');

        articles.forEach(article => {
            if (article.dataset.xVideoTagged) return;

            // 检测方式 1:DOM 中有 <video> 元素
            const hasVideo = article.querySelector('video') !== null;

            // 提取 tweetId
            const statusLink = article.querySelector('a[href*="/status/"]');
            if (!statusLink) return;
            const href = statusLink.getAttribute('href');
            const match = href?.match(/\/status\/(\d+)/);
            if (!match) return;
            const tweetId = match[1];

            // 检测方式 2:videoCache 中有对应数据
            const hasCachedVideo = videoCache.has(tweetId) && videoCache.get(tweetId) !== null;

            if (hasVideo || hasCachedVideo) {
                addVideoDownloadButton(article, tweetId);
            }
        });
    }

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

    function init() {
        log('脚本启动 - 鼠标悬停按钮自动查询归属地 + 视频下载');

        // 安装 fetch 拦截器(在任何 GraphQL 请求之前)
        installFetchInterceptor();

        // 初始扫描
        scanAndAddButtons();
        scanAndAddVideoButtons();

        // 监听 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();
                scanAndAddVideoButtons();
            }
        });

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

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