Greasy Fork

来自缓存

Greasy Fork is available in English.

X/Twitter 年龄限制解除

[UI重构] 悬浮球控制 | 独立接口监听开关 | 暗黑模式 | 自动解析 X/Twitter 高清原图/视频 | 自动多级目录 | 发送至 Motrix | 🚫 彻底去除敏感内容遮罩

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X/Twitter 年龄限制解除
// @namespace    http://tampermonkey.net/
// @version      2.4.0
// @description  [UI重构] 悬浮球控制 | 独立接口监听开关 | 暗黑模式 | 自动解析 X/Twitter 高清原图/视频 | 自动多级目录 | 发送至 Motrix | 🚫 彻底去除敏感内容遮罩
// @author       Gemini & JHC_Style
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      localhost
// @connect      127.0.0.1
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // ================= 状态与配置管理 =================
    const STATE_COLORS = {
        IDLE: 'rgb(108, 92, 231)',      // 空闲/就绪 (紫色)
        SUCCESS: 'rgb(46, 204, 113)',   // 成功 (绿色)
        ERROR: 'rgb(231, 76, 60)',      // 错误 (红色)
        PROCESSING: 'rgb(241, 196, 15)' // 处理中 (黄色)
    };

    const DEFAULTS = {
        enabled: true,
        rpcUrl: 'http://localhost:16800/jsonrpc',
        rpcToken: '',
        debugMode: false,
        darkMode: false,           // 暗黑模式
        listenUserTweets: true,    // 监听用户主页/时间线
        listenTweetDetail: true,   // 监听推文详情
        listenSearch: true,        // 监听搜索结果
        unlockSensitive: true      // 🟢 新增:解除敏感内容限制
    };

    function getSettings() {
        return {
            enabled: GM_getValue('x_enabled', DEFAULTS.enabled),
            rpcUrl: GM_getValue('x_rpcUrl', DEFAULTS.rpcUrl),
            rpcToken: GM_getValue('x_rpcToken', DEFAULTS.rpcToken),
            debugMode: GM_getValue('x_debugMode', DEFAULTS.debugMode),
            darkMode: GM_getValue('x_darkMode', DEFAULTS.darkMode),
            listenUserTweets: GM_getValue('x_listenUserTweets', DEFAULTS.listenUserTweets),
            listenTweetDetail: GM_getValue('x_listenTweetDetail', DEFAULTS.listenTweetDetail),
            listenSearch: GM_getValue('x_listenSearch', DEFAULTS.listenSearch),
            unlockSensitive: GM_getValue('x_unlockSensitive', DEFAULTS.unlockSensitive)
        };
    }

    let isUiReady = false;

    // ================= 工具函数 =================
    function log(msg, data) {
        const s = getSettings();
        if (s.debugMode) {
            console.log(`%c[X-Motrix] ${msg}`, 'color: #0984e3; font-weight: bold;', data || '');
        }
    }

    function prettyLog(title, sub, msg, color) {
        if (isUiReady) {
            showToast(title, sub, msg, color);
        } else {
            console.log(`[${title}] ${sub}: ${msg}`);
        }
    }

    function showToast(t, s, m, c) {
        const container = document.getElementById('x-toast-container');
        if (container) {
            const d = document.createElement('div');
            d.className = 'x-toast-msg';

            // 实时同步暗黑模式:优先读取 UI 复选框的实时状态,如果 UI 未加载则读取存储配置
            const uiCheckbox = document.getElementById('x-ui-dark-mode');
            const isDark = uiCheckbox ? uiCheckbox.checked : getSettings().darkMode;

            if (isDark) {
                d.classList.add('x-dark-mode');
            }

            d.style.borderColor = c;
            d.innerHTML = `<b>${t} ${s}</b><br><span class="x-toast-desc">${m}</span>`;
            container.appendChild(d);
            setTimeout(() => {
                d.style.opacity = '0';
                d.style.transform = 'translateX(100%)';
                setTimeout(() => d.remove(), 300);
            }, 3000);
        }
    }

    function updateBallColor(stateKey) {
        const btn = document.getElementById('x-helper-btn');
        if (btn && STATE_COLORS[stateKey]) {
            btn.style.background = STATE_COLORS[stateKey];
            // 1.5秒后恢复为空闲色
            if (stateKey !== 'IDLE') {
                setTimeout(() => {
                    btn.style.background = STATE_COLORS.IDLE;
                }, 1500);
            }
        }
    }

    /**
     * 格式化当前时间 YYYYMMDD_HHmmss
     */
    function getFormattedTime() {
        const now = new Date();
        return now.getFullYear().toString() +
            (now.getMonth() + 1).toString().padStart(2, '0') +
            now.getDate().toString().padStart(2, '0') + "_" +
            now.getHours().toString().padStart(2, '0') +
            now.getMinutes().toString().padStart(2, '0') +
            now.getSeconds().toString().padStart(2, '0');
    }

    /**
     * 文件名非法字符清理
     */
    function sanitizeFileName(str) {
        return (str || "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim();
    }

    /**
     * 获取高清原图链接
     */
    function getHighResUrl(url) {
        if (!url) return null;
        if (url.includes('name=orig')) return url;
        const match = url.match(/^(https?:\/\/.*)\.([a-zA-Z0-9]+)(\?.*)?$/);
        if (match) {
            return `${match[1]}?format=${match[2]}&name=orig`;
        }
        return url;
    }

    // ================= 核心逻辑:推送到 Motrix =================
    function sendToMotrix(mediaUrl, context, index, type) {
        const s = getSettings();
        if (!s.enabled) return;

        if (!mediaUrl) return;

        // 1. 构造路径: 域名/作者名/描述_时间.后缀
        const domain = location.hostname;
        const author = sanitizeFileName(context.userName || "Unknown_User");
        // 截取描述前50个字符作为文件名一部分
        let desc = sanitizeFileName(context.text || context.tweetId);
        if (desc.length > 50) desc = desc.substring(0, 50);

        const timeStr = getFormattedTime();

        // 提取扩展名
        let ext = 'jpg';
        if (type === 'video') {
            ext = 'mp4';
        } else {
            const extMatch = mediaUrl.match(/[?&]format=([a-zA-Z0-9]+)/);
            if (extMatch) {
                ext = extMatch[1];
            } else {
                const dotMatch = mediaUrl.match(/\.([a-zA-Z0-9]+)$/);
                if (dotMatch) ext = dotMatch[1];
            }
        }

        const filename = `${domain}/${author}/${desc}_${timeStr}_p${index}.${ext}`;

        // 2. 构造 RPC 请求
        const payload = {
            jsonrpc: '2.0',
            id: 'X-Motrix-' + Date.now(),
            method: 'aria2.addUri',
            params: [
                [mediaUrl],
                {
                    "out": filename,
                    "header": [
                        "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
                        "Referer: https://x.com/",
                        "Origin: https://x.com"
                    ],
                    "check-certificate": "false",
                    "connect-timeout": "10",
                    "timeout": "20",
                    "max-tries": "0",
                    "retry-wait": "3",
                    "split": "8", // 连接数建议 4-8,不要过大
                    "min-split-size": "1M"
                }
            ]
        };

        if (s.rpcToken) {
            payload.params.unshift(`token:${s.rpcToken}`);
        }

        updateBallColor('PROCESSING');

        GM_xmlhttpRequest({
            method: 'POST',
            url: s.rpcUrl,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(payload),
            onload: function(response) {
                if (response.status === 200) {
                    log(`✅ 推送成功: ${filename}`);
                    prettyLog("✅", "推送成功", `文件: ${filename.split('/').pop()}`, STATE_COLORS.SUCCESS);
                    updateBallColor('SUCCESS');
                } else {
                    log(`❌ 推送失败: ${response.responseText}`);
                    prettyLog("❌", "推送失败", "RPC 返回错误,请检查配置", STATE_COLORS.ERROR);
                    updateBallColor('ERROR');
                }
            },
            onerror: function(err) {
                log(`❌ RPC 连接错误:`, err);
                prettyLog("❌", "连接错误", "无法连接 Motrix,请检查运行状态", STATE_COLORS.ERROR);
                updateBallColor('ERROR');
            }
        });
    }

    // ================= 解析逻辑 =================
    function extractMediaFromTweet(tweet) {
        if (!tweet || !tweet.legacy) return 0;

        const legacy = tweet.legacy;
        const extendedEntities = legacy.extended_entities;

        if (!extendedEntities || !extendedEntities.media || !Array.isArray(extendedEntities.media)) return 0;

        // 提取推文上下文信息
        const userName = tweet.core?.user_results?.result?.core?.name || legacy.user_id_str || 'unknown';
        const fullText = legacy.full_text || "";
        const tweetId = legacy.rest_id;

        const context = {
            userName: userName,
            text: fullText,
            tweetId: tweetId
        };

        let extractedCount = 0;

        extendedEntities.media.forEach((media, index) => {
            // 图片
            if (media.type === 'photo' && media.media_url_https) {
                const highResUrl = getHighResUrl(media.media_url_https);
                sendToMotrix(highResUrl, context, index, 'photo');
                extractedCount++;
                log(`📷 发现图片: ${highResUrl}`);
            }
            // 视频
            else if (media.type === 'video' && media.video_info) {
                const variants = media.video_info.variants;
                if (variants) {
                    const mp4Variants = variants.filter(v => v.content_type === 'video/mp4');
                    // 按码率排序取最大
                    mp4Variants.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
                    if (mp4Variants.length > 0) {
                        const videoUrl = mp4Variants[0].url;
                        sendToMotrix(videoUrl, context, index, 'video');
                        extractedCount++;
                        log(`🎥 发现视频: ${videoUrl}`);
                    }
                }
            }
        });

        return extractedCount;
    }

    function handleApiResponse(jsonStr) {
        const s = getSettings();
        if (!s.enabled) return;

        try {
            const data = JSON.parse(jsonStr);
            let instructions = null;
            let totalExtracted = 0;

            // 路径匹配与独立开关判断

            // 1. UserTweets (主页/时间线)
            if (data?.data?.user?.result?.timeline?.timeline?.instructions) {
                if (!s.listenUserTweets) {
                    log('🚫 UserTweets 接口被禁用,跳过解析');
                    return;
                }
                instructions = data.data.user.result.timeline.timeline.instructions;
                log('检测到 UserTweets 数据结构');
            }
            // 2. TweetDetail (详情页)
            else if (data?.data?.threaded_conversation_with_injections_v2?.instructions) {
                if (!s.listenTweetDetail) {
                      log('🚫 TweetDetail 接口被禁用,跳过解析');
                      return;
                }
                instructions = data.data.threaded_conversation_with_injections_v2.instructions;
                log('检测到 TweetDetail 数据结构');
            }
            // 3. Search (搜索结果)
            else if (data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions) {
                if (!s.listenSearch) {
                    log('🚫 Search 接口被禁用,跳过解析');
                    return;
                }
                instructions = data.data.search_by_raw_query.search_timeline.timeline.instructions;
                log('检测到 Search 数据结构');
            }

            if (!instructions || !Array.isArray(instructions)) return;

            const processTweetResult = (result) => {
                if (!result) return 0;
                const tweet = result.tweet || result;
                // 排除广告
                if (tweet.legacy && tweet.legacy.extended_entities) {
                    return extractMediaFromTweet(tweet);
                }
                return 0;
            };

            instructions.forEach(instruction => {
                if (instruction.type === 'TimelineAddEntries' && Array.isArray(instruction.entries)) {
                    instruction.entries.forEach(entry => {
                        // 标准推文
                        const tweetResult = entry?.content?.itemContent?.tweet_results?.result;
                        totalExtracted += processTweetResult(tweetResult);

                        // 评论/对话
                        const items = entry?.content?.items;
                        if (items && Array.isArray(items)) {
                            items.forEach(item => {
                                const itemTweetResult = item?.item?.itemContent?.tweet_results?.result;
                                totalExtracted += processTweetResult(itemTweetResult);
                            });
                        }
                    });
                } else if (instruction.type === 'TimelineAddToModule' && Array.isArray(instruction.moduleItems)) {
                      instruction.moduleItems.forEach(item => {
                        const tweetResult = item?.item?.itemContent?.tweet_results?.result;
                        totalExtracted += processTweetResult(tweetResult);
                    });
                }
            });

            if (totalExtracted > 0) {
                log(`📊 本批次共解析 ${totalExtracted} 个媒体文件`);
            }

        } catch (e) {
            console.error('[X-Motrix] JSON 解析异常:', e);
        }
    }

    // ================= Hook XHR (核心修改) =================
    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSend = XMLHttpRequest.prototype.send;
    // 获取原始的 responseText 描述符,用于后续在 getter 中调用
    const originalResponseTextDesc = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');

    XMLHttpRequest.prototype.open = function(method, url) {
        this._url = url;
        return originalOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function() {
        // 1. 监听加载完成 (被动解析用于下载)
        this.addEventListener('load', function() {
            if (this._url && (
                this._url.includes('UserTweets') ||
                this._url.includes('TweetDetail') ||
                this._url.includes('SearchTimeline')
            ) && this.responseText) {
                handleApiResponse(this.responseText);
            }
        });

        // 2. 劫持响应数据 (主动修改用于前端渲染) - 🟢 新增功能
        // 只有涉及推文数据的接口才需要处理
        if (this._url && (
            this._url.includes('UserTweets') ||
            this._url.includes('TweetDetail') ||
            this._url.includes('SearchTimeline')
        )) {
            // 定义 getter 覆盖原有属性
            Object.defineProperty(this, 'responseText', {
                get: function() {
                    // 调用原始 getter 获取真实数据
                    let rawText = originalResponseTextDesc.get.call(this);
                    const s = getSettings();

                    // 如果开启了解锁敏感内容,且数据是字符串,则进行替换
                    if (s.unlockSensitive && typeof rawText === 'string') {
                        try {
                            // 暴力替换:
                            // 1. possibly_sensitive: true -> false (推文级开关)
                            // 2. profile_interstitial_type: "sensitive_media" -> "" (主页级警告)
                            // 3. mediaVisibilityResults -> mediaVisibilityResults_bypass (直接破坏遮罩层数据结构,防止前端读取)
                            return rawText.replace(/"possibly_sensitive":\s*true/g, '"possibly_sensitive":false')
                                          .replace(/"profile_interstitial_type":\s*"sensitive_media"/g, '"profile_interstitial_type":""')
                                          .replace(/"mediaVisibilityResults":/g, '"mediaVisibilityResults_bypass":');
                        } catch (e) {
                            console.error('[X-Motrix] 敏感内容解锁失败:', e);
                            return rawText;
                        }
                    }
                    return rawText;
                },
                configurable: true
            });
        }

        return originalSend.apply(this, arguments);
    };

    // ================= UI 构建 =================
    function createUI() {
        // 复刻抖音脚本样式,增加暗黑模式适配 CSS
        GM_addStyle(`
            #x-helper-btn { position: fixed; z-index: 2147483647; width: 50px; height: 50px; background: ${STATE_COLORS.IDLE}; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: grab; box-shadow: 0 8px 25px rgba(108, 92, 231, 0.4); font-weight: bold; font-family: sans-serif; transition: transform 0.1s, box-shadow 0.2s; border: 2px solid rgba(255,255,255,0.2); user-select: none; }
            #x-helper-btn:active { cursor: grabbing; transform: scale(0.95); }
            #x-helper-btn:hover { box-shadow: 0 10px 30px rgba(108, 92, 231, 0.6); }

            #x-settings-panel { position: fixed; z-index: 2147483647; width: 320px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); padding: 20px; display: none; border-radius: 16px; box-shadow: 0 20px 50px rgba(0,0,0,0.15); border: 1px solid rgba(255,255,255,0.6); font-family: 'Segoe UI', Roboto, sans-serif; color: #2d3436; flex-direction: column; transition: opacity 0.2s; }
            #x-settings-panel h3 { margin: 0 0 15px 0; font-size: 18px; color: #2d3436; border-bottom: 1px solid #eee; padding-bottom: 10px; }

            /* 暗黑模式适配 - 面板 */
            #x-settings-panel.x-dark-mode { background: rgba(45, 52, 54, 0.95); color: #dfe6e9; border: 1px solid rgba(255,255,255,0.1); }
            #x-settings-panel.x-dark-mode h3 { color: #dfe6e9; border-bottom-color: rgba(255,255,255,0.1); }
            #x-settings-panel.x-dark-mode .x-label { color: #dfe6e9; }
            #x-settings-panel.x-dark-mode .x-toggle-label { color: #b2bec3; }
            #x-settings-panel.x-dark-mode .x-input { background: #1e272e; color: #ecf0f1; border: 1px solid #636e72; }
            #x-settings-panel.x-dark-mode .x-input:focus { border-color: #a29bfe; }
            #x-settings-panel.x-dark-mode .x-btn-secondary { background: #636e72; color: #dfe6e9; }
            #x-settings-panel.x-dark-mode .x-slider { background-color: #636e72; }
            #x-settings-panel.x-dark-mode .x-info-box { background: #2d3436; color: #b2bec3; border: 1px solid #636e72; }
            #x-settings-panel.x-dark-mode .x-section-box { background: rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.2); }

            .x-group { margin-bottom: 15px; }
            .x-label { font-size: 12px; color: #636e72; margin-bottom: 6px; font-weight: 700; display: block; }
            .x-input { all: initial; display: block; width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #b2bec3; border-radius: 8px; background: #fff; color: #2d3436; outline: none; transition: border 0.2s; font-size: 13px !important; font-family: sans-serif !important; margin-top: 5px; }
            .x-input:focus { border-color: #6c5ce7; box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.1); }

            .x-toggle-wrapper { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
            .x-toggle-label { font-size: 14px; color: #2d3436; font-weight: 500; }
            .x-toggle { position: relative; display: inline-block; width: 44px; height: 24px; }
            .x-toggle input { opacity: 0; width: 0; height: 0; }
            .x-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #b2bec3; transition: .4s; border-radius: 34px; }
            .x-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
            input:checked + .x-slider { background-color: #00b894 !important; }
            input:checked + .x-slider:before { transform: translateX(20px); }

            .x-actions { display: flex; gap: 10px; margin-top: 15px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); }
            .x-btn { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; transition: opacity 0.2s; }
            .x-btn-primary { background: #6c5ce7; color: white; box-shadow: 0 4px 15px rgba(108, 92, 231, 0.3); }
            .x-btn-secondary { background: #dfe6e9; color: #636e72; }
            .x-btn:hover { opacity: 0.9; }

            .x-info-box { background:#f1f2f6; padding:10px; border-radius:8px; font-size:11px; color:#636e72; }
            .x-section-box { border:1px dashed #6c5ce7; padding:10px; border-radius:8px; margin-bottom:15px; background:rgba(108, 92, 231, 0.05); }

            #x-toast-container { position: fixed; top: 80px; right: 20px; z-index: 2147483600; pointer-events: none; }
            .x-toast-msg { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); margin-bottom: 10px; padding: 8px 12px; border-left: 4px solid; border-radius: 6px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); width: 220px; pointer-events: auto; animation: slideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); border: 1px solid rgba(255,255,255,0.4); color: #2d3436; font-family: sans-serif; font-size: 12px; line-height: 1.4; }

            /* 暗黑模式适配 - Toast */
            .x-toast-msg.x-dark-mode { background: rgba(40, 40, 40, 0.95); color: #dfe6e9; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 5px 20px rgba(0,0,0,0.3); }
            .x-toast-msg.x-dark-mode .x-toast-desc { color: #b2bec3 !important; }

            .x-toast-desc { font-size:12px; color:#636e72; }

            @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
        `);

        // 等待页面加载
        const initInterval = setInterval(() => {
            if (document.body) {
                clearInterval(initInterval);
                renderElements();
            }
        }, 100);
    }

    function renderElements() {
        if (document.getElementById('x-helper-btn')) return;

        // 1. 悬浮球 (替换为 X Logo SVG)
        const btn = document.createElement('div');
        btn.id = 'x-helper-btn';
        // 使用 X (Twitter) 官方 SVG 路径
        btn.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width: 24px; height: 24px; fill: white;"><g><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"></path></g></svg>`;
        btn.title = "X-Motrix 下载助手";
        const savedTop = GM_getValue('x_btn_top', '20%');
        const savedLeft = GM_getValue('x_btn_left', 'calc(100% - 70px)');
        btn.style.top = savedTop;
        btn.style.left = savedLeft;

        // 2. 拖拽逻辑
        let isDragging = false;
        let dragStartTime = 0;
        let dragRaf = null;

        btn.onmousedown = function (event) {
            isDragging = false;
            dragStartTime = Date.now();
            let shiftX = event.clientX - btn.getBoundingClientRect().left;
            let shiftY = event.clientY - btn.getBoundingClientRect().top;

            function moveAt(pageX, pageY) {
                isDragging = true;
                if (dragRaf) cancelAnimationFrame(dragRaf);
                dragRaf = requestAnimationFrame(() => {
                    btn.style.left = pageX - shiftX + 'px';
                    btn.style.top = pageY - shiftY + 'px';
                    const panel = document.getElementById('x-settings-panel');
                    if (panel && panel.style.display !== 'none') {
                        repositionPanel(btn, panel);
                    }
                });
            }

            function onMouseMove(event) {
                moveAt(event.pageX, event.pageY);
            }

            document.addEventListener('mousemove', onMouseMove);
            document.onmouseup = function () {
                document.removeEventListener('mousemove', onMouseMove);
                document.onmouseup = null;
                if (dragRaf) cancelAnimationFrame(dragRaf);
                if (isDragging) {
                    GM_setValue('x_btn_top', btn.style.top);
                    GM_setValue('x_btn_left', btn.style.left);
                }
            };
        };

        btn.ondragstart = () => false;

        // 点击打开/关闭面板
        btn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (isDragging || (Date.now() - dragStartTime > 200)) return;
            const p = document.getElementById('x-settings-panel');
            if (p) {
                if (p.style.display === 'none') {
                    p.style.display = 'flex';
                    syncUI(); // 打开时同步数据
                    repositionPanel(btn, p);
                } else {
                    p.style.display = 'none';
                }
            }
        });

        document.body.appendChild(btn);

        // 3. 配置面板
        const panel = document.createElement('div');
        panel.id = 'x-settings-panel';
        // 初始应用暗黑模式
        if (getSettings().darkMode) panel.classList.add('x-dark-mode');

        panel.innerHTML = `
            <h3>X/Twitter Motrix 助手</h3>

            <div class="x-toggle-wrapper">
                <span class="x-toggle-label">🟢 启用总开关</span>
                <label class="x-toggle"><input type="checkbox" id="x-ui-enable"><span class="x-slider"></span></label>
            </div>

             <div class="x-toggle-wrapper" style="margin-bottom:15px; background:rgba(231, 76, 60, 0.1); padding:8px; border-radius:6px; border:1px dashed rgba(231, 76, 60, 0.4);">
                <span class="x-toggle-label" style="color:#e74c3c;">🔞 解除敏感内容限制</span>
                <label class="x-toggle"><input type="checkbox" id="x-ui-unlock-sensitive"><span class="x-slider"></span></label>
            </div>

            <div class="x-section-box">
                <div class="x-label" style="color:#6c5ce7; margin-bottom:8px;">📡 接口监听设置</div>
                <div class="x-toggle-wrapper">
                    <span class="x-toggle-label" style="font-size:12px">👤 主页/时间线 (UserTweets)</span>
                    <label class="x-toggle" style="transform:scale(0.8);"><input type="checkbox" id="x-ui-listen-user"><span class="x-slider"></span></label>
                </div>
                <div class="x-toggle-wrapper">
                    <span class="x-toggle-label" style="font-size:12px">📄 推文详情页 (TweetDetail)</span>
                    <label class="x-toggle" style="transform:scale(0.8);"><input type="checkbox" id="x-ui-listen-detail"><span class="x-slider"></span></label>
                </div>
                <div class="x-toggle-wrapper">
                    <span class="x-toggle-label" style="font-size:12px">🔍 搜索结果页 (Search)</span>
                    <label class="x-toggle" style="transform:scale(0.8);"><input type="checkbox" id="x-ui-listen-search"><span class="x-slider"></span></label>
                </div>
            </div>

            <div class="x-group">
                <span class="x-label">📡 Motrix RPC 地址</span>
                <input class="x-input" id="x-ui-rpc" placeholder="http://127.0.0.1:16800/jsonrpc">
            </div>

            <div class="x-group">
                <span class="x-label">🔑 RPC 密钥 (Token)</span>
                <input class="x-input" id="x-ui-token" type="password" placeholder="留空则无密钥">
            </div>

            <div class="x-toggle-wrapper">
                <span class="x-toggle-label">🌙 暗黑模式</span>
                <label class="x-toggle"><input type="checkbox" id="x-ui-dark-mode"><span class="x-slider"></span></label>
            </div>

            <div class="x-toggle-wrapper">
                <span class="x-toggle-label">🐞 调试日志 (Console)</span>
                <label class="x-toggle"><input type="checkbox" id="x-ui-debug"><span class="x-slider"></span></label>
            </div>

            <div class="x-actions">
                <button class="x-btn x-btn-secondary" id="x-ui-close">关闭</button>
                <button class="x-btn x-btn-primary" id="x-ui-save">💾 保存配置</button>
            </div>
        `;

        // 防止点击面板触发页面其他事件
        panel.addEventListener('click', (e) => e.stopPropagation());
        // 防止输入框触发页面快捷键
        panel.querySelectorAll('input').forEach(el => {
            el.addEventListener('keydown', (e) => e.stopPropagation());
        });

        document.body.appendChild(panel);

        // Toast 容器
        const toastContainer = document.createElement('div');
        toastContainer.id = 'x-toast-container';
        document.body.appendChild(toastContainer);

        // 绑定事件
        document.getElementById('x-ui-close').onclick = () => {
            document.getElementById('x-settings-panel').style.display = 'none';
        };
        document.getElementById('x-ui-save').onclick = saveUI;

        // 绑定暗黑模式即时预览
        document.getElementById('x-ui-dark-mode').addEventListener('change', (e) => {
            const p = document.getElementById('x-settings-panel');
            if (e.target.checked) p.classList.add('x-dark-mode'); else p.classList.remove('x-dark-mode');
        });

        isUiReady = true;
        log('UI 初始化完成');
    }

    // 智能定位面板
    function repositionPanel(btn, panel) {
        const btnRect = btn.getBoundingClientRect();
        const panelWidth = 320;
        const margin = 15;

        // 默认显示在左侧
        let left = btnRect.left - panelWidth - margin;
        let top = btnRect.top;

        // 如果左边空间不足,显示在右边
        if (left < 10) {
            left = btnRect.right + margin;
        }

        // 垂直防溢出
        if (top + panel.offsetHeight > window.innerHeight) {
            top = window.innerHeight - panel.offsetHeight - 10;
        }
        if (top < 10) top = 10;

        panel.style.top = top + 'px';
        panel.style.left = left + 'px';
    }

    // 从存储同步到 UI
    function syncUI() {
        const s = getSettings();
        document.getElementById('x-ui-enable').checked = s.enabled;
        document.getElementById('x-ui-listen-user').checked = s.listenUserTweets;
        document.getElementById('x-ui-listen-detail').checked = s.listenTweetDetail;
        document.getElementById('x-ui-listen-search').checked = s.listenSearch;
        document.getElementById('x-ui-rpc').value = s.rpcUrl;
        document.getElementById('x-ui-token').value = s.rpcToken;
        document.getElementById('x-ui-dark-mode').checked = s.darkMode;
        document.getElementById('x-ui-debug').checked = s.debugMode;
        document.getElementById('x-ui-unlock-sensitive').checked = s.unlockSensitive;

        // 同步面板暗黑样式
        const panel = document.getElementById('x-settings-panel');
        if (s.darkMode) panel.classList.add('x-dark-mode'); else panel.classList.remove('x-dark-mode');
    }

    // 从 UI 保存到存储
    function saveUI() {
        const enabled = document.getElementById('x-ui-enable').checked;
        const listenUser = document.getElementById('x-ui-listen-user').checked;
        const listenDetail = document.getElementById('x-ui-listen-detail').checked;
        const listenSearch = document.getElementById('x-ui-listen-search').checked;
        const rpcUrl = document.getElementById('x-ui-rpc').value;
        const rpcToken = document.getElementById('x-ui-token').value;
        const darkMode = document.getElementById('x-ui-dark-mode').checked;
        const debugMode = document.getElementById('x-ui-debug').checked;
        const unlockSensitive = document.getElementById('x-ui-unlock-sensitive').checked;

        GM_setValue('x_enabled', enabled);
        GM_setValue('x_listenUserTweets', listenUser);
        GM_setValue('x_listenTweetDetail', listenDetail);
        GM_setValue('x_listenSearch', listenSearch);
        GM_setValue('x_rpcUrl', rpcUrl);
        GM_setValue('x_rpcToken', rpcToken);
        GM_setValue('x_darkMode', darkMode);
        GM_setValue('x_debugMode', debugMode);
        GM_setValue('x_unlockSensitive', unlockSensitive);

        document.getElementById('x-settings-panel').style.display = 'none';
        prettyLog("💾", "配置已保存", "新的设置即刻生效 (敏感内容需刷新页面)", STATE_COLORS.SUCCESS);
    }

    // 启动
    createUI();

})();