谷歌换搜

在 Google 搜索页面优化搜索栏,添加多站点搜索切换功能,导航栏悬浮且紧凑、可拖动,可自定义站点,支持与GitHub Gist同步,站点编辑框可调整大小并记忆。

// ==UserScript==
// @name         谷歌换搜
// @namespace    https://t.me/aibiancheng
// @author       星宿老魔
// @license      MIT
// @version      0.1
// @description  在 Google 搜索页面优化搜索栏,添加多站点搜索切换功能,导航栏悬浮且紧凑、可拖动,可自定义站点,支持与GitHub Gist同步,站点编辑框可调整大小并记忆。
// @match        https://www.google.com/search?q=*site*
// @match        https://www.google.com.hk/search?q=*site*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @connect      api.github.com
// ==/UserScript==

(function() {
    'use strict';

    // --- 常量与配置 ---
    const DEBUG_MODE = false; // 调试模式开关,true为开启,false为关闭
    const GM_GITHUB_TOKEN_KEY = 'googleSearchMultiSite_github_token';  // 油猴存储:GitHub个人访问令牌的键名
    const GM_GIST_ID_KEY = 'googleSearchMultiSite_gist_id';        // 油猴存储:GitHub Gist ID的键名
    const GIST_FILENAME = 'googleSearchMultiSite_sites-config.txt';  // Gist云端备份时使用的文件名
    const LOCALSTORAGE_SITES_KEY = 'customSiteRowsText'; // 浏览器本地存储:自定义站点配置文本的键名
    const LOCALSTORAGE_NAVBAR_POSITION_KEY = "navBarPosition"; // 浏览器本地存储:悬浮导航栏位置的键名
    const LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY = 'googleSearchMultiSite_siteConfigTextareaWidth'; // 浏览器本地存储:站点配置编辑框宽度
    const LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY = 'googleSearchMultiSite_siteConfigTextareaHeight'; // 浏览器本地存储:站点配置编辑框高度

    // --- Gist凭据获取辅助函数 ---
    // 从油猴存储中获取GitHub个人访问令牌
    function getGitHubToken() {
        return GM_getValue(GM_GITHUB_TOKEN_KEY, '');
    }
    // 从油猴存储中获取Gist ID
    function getGistId() {
        return GM_getValue(GM_GIST_ID_KEY, '');
    }

    // --- GM_addStyle 设置对话框样式 ---
    // 为脚本中使用的对话框(如Gist参数设置、站点编辑)注入CSS样式
    GM_addStyle(`
        #googleSearchMultiSite-settings-dialog {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background-color: rgba(0,0,0,0.6); z-index: 10000; /* 确保在顶层显示 */
            display: flex; justify-content: center; align-items: center; font-family: sans-serif;
        }
        #googleSearchMultiSite-settings-dialog-content {
            background: white; padding: 25px; border-radius: 8px;
            box-shadow: 0 5px 20px rgba(0,0,0,0.3); width: 400px; max-width: 90%;
            position: relative; /* 用于关闭按钮的绝对定位 */
        }
        #googleSearchMultiSite-settings-dialog-content h3 { margin-top: 0; margin-bottom: 20px; text-align: center; color: #333; font-size: 1.3em; }
        #googleSearchMultiSite-settings-dialog-content label { display: block; margin-bottom: 5px; color: #555; font-size: 0.95em; }
        #googleSearchMultiSite-settings-dialog-content input[type="text"], #googleSearchMultiSite-settings-dialog-content input[type="password"] {
            width: 100%; padding: 10px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 0; /* <small>标签边距微调 */ font-size: 1em;
        }
        #googleSearchMultiSite-settings-dialog-content small { font-size:0.8em; color:#777; display:block; margin-top:4px; margin-bottom:12px; }
        #googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons { text-align: right; margin-top: 15px; }
        #googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons button { padding: 10px 18px; border-radius: 4px; border: none; cursor: pointer; font-size: 0.95em; transition: background-color 0.2s ease; }
        #googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-cancel-btn { margin-right: 10px; background-color: #f0f0f0; color: #333; }
        #googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-cancel-btn:hover { background-color: #e0e0e0; }
        #googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-save-btn { background-color: #4CAF50; color: white; }
        #googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-save-btn:hover { background-color: #45a049; }
        #googleSearchMultiSite-settings-close-btn { position: absolute; top: 10px; right: 10px; font-size: 1.5em; color: #aaa; cursor: pointer; background: none; border: none; padding: 5px; line-height: 1; }
        #googleSearchMultiSite-settings-close-btn:hover { color: #777; }
    `);

    // --- UI 通知 ---
    // 在页面底部显示一个短暂的通知消息(例如成功、错误、警告等)
    function showNotification(message, type = 'info', duration = 3000) {
        const notificationId = 'userscript-notification-' + Date.now();
        const notificationDiv = document.createElement('div');
        notificationDiv.id = notificationId;
        notificationDiv.textContent = message;
        Object.assign(notificationDiv.style, {
            position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)',
            padding: '10px 20px', borderRadius: '5px', color: 'white', zIndex: '10001', // 确保在设置对话框之上
            boxShadow: '0 2px 10px rgba(0,0,0,0.2)', opacity: '0', transition: 'opacity 0.5s ease-in-out'
        });
        switch (type) {
            case 'success': notificationDiv.style.backgroundColor = '#4CAF50'; break;
            case 'error': notificationDiv.style.backgroundColor = '#F44336'; break;
            case 'warning': notificationDiv.style.backgroundColor = '#FF9800'; break;
            default: notificationDiv.style.backgroundColor = '#2196F3'; break;
        }
        document.body.appendChild(notificationDiv);

        setTimeout(() => { notificationDiv.style.opacity = '1'; }, 10); // 延迟以确保元素已渲染再触发过渡动画
        if (duration > 0) { // 如果持续时间大于0,则自动隐藏
            setTimeout(() => {
                notificationDiv.style.opacity = '0';
                setTimeout(() => { notificationDiv.remove(); }, 500); // 淡出动画后移除
            }, duration);
        }
    }

    // 默认多行站点数据 (如果用户没有自定义配置,则使用此数据)
    const defaultSiteRowsText = [
        '暗香,anxiangge.cc 2nt,mm2211.blog.2nt.com 2048,hjd2048.com',
        'Intporn,forum.intporn.com EC,eroticity.net SPS,sexpicturespass.com planetsuzy,planetsuzy.org'
    ].join('\n');

    // 解析文本为站点数组
    // 将存储的站点配置文本(格式:"名称1,域名1 名称2,域名2\n名称3,域名3...")
    // 解析为一个包含多个行数组的二维数组,每个行数组又包含多个站点对象{name: "名称", url: "域名"}
    function parseSiteRows(text) {
        return text.split('\n').map(line =>
            line.trim().split(/\s+/).filter(Boolean).map(btn => {
                const [name, url] = btn.split(',');
                return { name: name || '', url: url || '' };
            }).filter(btn => btn.name && btn.url) // 过滤掉无效的按钮数据
        ).filter(row => row.length > 0); // 过滤掉空行
    }

    // 读取站点配置(优先浏览器本地存储)
    // 从浏览器本地存储中加载用户保存的站点配置文本,如果不存在则使用上面定义的默认配置
    function getSiteRows() {
        const saved = localStorage.getItem(LOCALSTORAGE_SITES_KEY);
        return parseSiteRows(saved || defaultSiteRowsText);
    }

    // --- Gist 同步核心函数 (异步) ---
    // 异步从Gist获取指定文件的内容
    // gistId: Gist的唯一标识符
    async function getGistFileContentAsync(gistId) {
        const GITHUB_TOKEN = getGitHubToken();
        // Gist ID 和 Token 由调用函数 (uploadToGist/downloadFromGist) 检查

        if (DEBUG_MODE) console.log('[GSMS] 正在获取Gist文件内容, Gist ID:', gistId);

        try {
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://api.github.com/gists/${gistId}`,
                    headers: {
                        'Authorization': `token ${GITHUB_TOKEN}`,
                        'Accept': 'application/vnd.github.v3+json' // GitHub API推荐的Accept头
                    },
                    onload: res => resolve(res), // 请求成功
                    onerror: err => reject(err)  // 请求失败
                });
            });

            if (response.status === 200) { // HTTP 200 成功
                const gistData = JSON.parse(response.responseText);
                if (gistData.files && gistData.files[GIST_FILENAME]) {
                    if (DEBUG_MODE) console.log('[GSMS] Gist文件内容已获取:', gistData.files[GIST_FILENAME].content);
                    return gistData.files[GIST_FILENAME].content; // 返回文件内容
                } else {
                    if (DEBUG_MODE) console.warn(`[GSMS] Gist (${gistId.substring(0,7)}...) 已找到, 但文件 ${GIST_FILENAME} 不存在.`);
                    return null; // Gist中未找到指定文件
                }
            } else if (response.status === 404) { // HTTP 404 未找到
                if (DEBUG_MODE) console.warn('[GSMS] Gist 未找到 (404) for ID:', gistId);
                showNotification('Gist 未找到 (404)。请检查Gist ID配置。', 'warning', 5000);
                return null; // Gist本身未找到
            } else {
                if (DEBUG_MODE) console.error(`[GSMS] 获取Gist文件失败 ${response.status}:`, response);
                showNotification(`获取Gist文件失败: ${response.statusText} (${response.status})`, 'error');
                return null;
            }
        } catch (error) {
            if (DEBUG_MODE) console.error('[GSMS] 请求Gist文件出错:', error);
            showNotification('请求Gist文件出错,详情请查看控制台。', 'error');
            return null;
        }
    }

    // 异步更新Gist中指定文件的内容
    // gistId: Gist的唯一标识符
    // content: 新的文件内容
    // 如果文件不存在于Gist中,GitHub API的PATCH操作通常会自动创建该文件。
    async function updateGistFileContentAsync(gistId, content) {
        const GITHUB_TOKEN = getGitHubToken();
        // Token和Gist ID由调用者检查

        if (DEBUG_MODE) console.log('[GSMS] 正在更新Gist文件内容, Gist ID:', gistId);

        try {
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'PATCH', // 使用PATCH方法更新Gist
                    url: `https://api.github.com/gists/${gistId}`,
                    headers: {
                        'Authorization': `token ${GITHUB_TOKEN}`,
                        'Accept': 'application/vnd.github.v3+json',
                        'Content-Type': 'application/json' // 发送JSON数据
                    },
                    data: JSON.stringify({
                        files: {
                            [GIST_FILENAME]: { content: content } // 指定要更新的文件名和内容
                        }
                    }),
                    onload: res => resolve(res),
                    onerror: err => reject(err)
                });
            });

            if (response.status === 200) { // HTTP 200 成功, 更新成功
                if (DEBUG_MODE) console.log('[GSMS] Gist文件已成功更新。');
                return true;
            } else {
                if (DEBUG_MODE) console.error(`[GSMS] 更新Gist文件失败 ${response.status}:`, response);
                showNotification(`更新Gist文件失败: ${response.statusText} (${response.status})`, 'error');
                return false;
            }
        } catch (error) {
            if (DEBUG_MODE) console.error('[GSMS] 请求更新Gist文件出错:', error);
            showNotification('请求更新Gist文件出错,详情请查看控制台。', 'error');
            return false;
        }
    }

    // 异步创建新的Gist (私有),包含一个指定内容的文件,并返回新Gist的ID
    // content: 要在新Gist中创建的文件的内容
    // description: Gist的描述文本
    async function createGistWithFileAsync(content, description = '谷歌搜图多站点配置') {
        const GITHUB_TOKEN = getGitHubToken();
        // Token由调用者检查

        if (DEBUG_MODE) console.log('[GSMS] 正在创建新的Gist并包含文件。');

        try {
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST', // 使用POST方法创建Gist
                    url: 'https://api.github.com/gists',
                    headers: {
                        'Authorization': `token ${GITHUB_TOKEN}`,
                        'Accept': 'application/vnd.github.v3+json',
                        'Content-Type': 'application/json'
                    },
                    data: JSON.stringify({
                        description: description, // Gist描述
                        public: false, // 设置为私有Gist
                        files: {
                            [GIST_FILENAME]: { content: content } // Gist中的文件名和内容
                        }
                    }),
                    onload: res => resolve(res),
                    onerror: err => reject(err)
                });
            });

            if (response.status === 201) { // HTTP 201 已创建, 创建成功
                const newGist = JSON.parse(response.responseText);
                if (DEBUG_MODE) console.log('[GSMS] 新Gist已成功创建. ID:', newGist.id);
                return newGist.id; // 返回新创建的Gist的ID
            } else {
                if (DEBUG_MODE) console.error(`[GSMS] 创建Gist失败 ${response.status}:`, response);
                showNotification(`创建Gist失败: ${response.statusText} (${response.status})`, 'error');
                return null;
            }
        } catch (error) {
            if (DEBUG_MODE) console.error('[GSMS] 请求创建Gist出错:', error);
            showNotification('请求创建Gist出错,详情请查看控制台。', 'error');
            return null;
        }
    }

    // 上传配置到GitHub Gist
    // 将浏览器本地存储中的站点配置上传到用户的GitHub Gist。
    // 1. 检查GitHub Token是否配置。
    // 2. 如果已存在Gist ID,则尝试更新该Gist中的配置文件。
    // 3. 如果不存在Gist ID,则创建一个新的Gist来存储配置,并保存新Gist的ID。
    // 4. 操作过程中会显示通知反馈。
    async function uploadToGist() {
        const GITHUB_TOKEN = getGitHubToken();
        if (!GITHUB_TOKEN) {
            showNotification('GitHub Token 未配置。请通过油猴菜单「⚙️ 配置Gist同步参数」进行设置。', 'error');
            console.error('[GSMS] 上传中止: Token缺失。'); return;
        }

        const localContent = localStorage.getItem(LOCALSTORAGE_SITES_KEY) || defaultSiteRowsText;
        let currentGistId = getGistId();
        let success = false;
        let newGistCreated = false;

        showNotification('上传配置到Gist中...', 'info', 0); // 显示持续的"上传中"通知

        try {
            if (currentGistId) { // 如果已有Gist ID
                if (DEBUG_MODE) console.log(`[GSMS] 尝试更新已存在的Gist ID: ${currentGistId}`);
                // 尝试更新。如果Gist存在但文件不存在,此操作也会创建文件。
                success = await updateGistFileContentAsync(currentGistId, localContent);
                if (!success) {
                    // 如果更新失败,可能是Gist ID无效(例如404),或文件因其他原因无法创建/更新。
                    // 目前,如果使用现有ID更新失败,不会自动创建新的Gist。
                    // 用户可能需要在设置中清除有问题的Gist ID。
                     if (DEBUG_MODE) console.log('[GSMS] 使用现有Gist ID更新失败。用户可能需要检查或清除ID。');
                }
            } else { // 如果没有Gist ID
                if (DEBUG_MODE) console.log('[GSMS] 未找到Gist ID。尝试创建新的Gist。');
                const newGistId = await createGistWithFileAsync(localContent);
                if (newGistId) {
                    GM_setValue(GM_GIST_ID_KEY, newGistId); // 保存新Gist的ID到油猴存储
                    currentGistId = newGistId; // 更新currentGistId用于通知显示
                    success = true;
                    newGistCreated = true;
                }
            }
        } catch (error) {
            // 异步函数内部的错误应该已经显示了各自的通知
            console.error('[GSMS] uploadToGist执行过程中发生错误:', error);
            success = false; // 确保success为false
        } finally {
            // 清除"上传中..."的通知 (通过查找特定文本内容来移除)
            const uploadingNotifications = Array.from(document.body.children).filter(el => el.textContent === '上传配置到Gist中...');
            uploadingNotifications.forEach(n => n.remove());
        }

        if (success) {
            if (newGistCreated) {
                showNotification(`新Gist已创建并自动保存!ID: ${currentGistId.substring(0,7)}...`, 'success', 7000);
            } else {
                showNotification('配置已成功同步到Gist!', 'success');
            }
            if (DEBUG_MODE) console.log('[GSMS] 同步到Gist成功。');
        } else {
            if (!newGistCreated) { // 避免在创建失败时重复显示错误 (创建函数会自行显示错误)
                                   // 也避免在更新失败时重复显示错误 (更新函数会自行显示错误)
                const GistIDForError = currentGistId ? ` (ID: ${currentGistId.substring(0,7)}...)` : '';
                // 检查是否已有错误通知,避免重复
                if(!document.querySelector('div[style*="background-color: rgb(244, 67, 54)"]')) {
                    showNotification(`同步到Gist失败${GistIDForError}。请检查Token/ID及控制台。`, 'error');
                }
            }
            if (DEBUG_MODE) console.log('[GSMS] 同步到Gist失败。');
        }
    }

    // 从GitHub Gist下载配置
    // 从用户的GitHub Gist下载站点配置,并用其覆盖浏览器本地存储中的配置。
    // 1. 检查GitHub Token和Gist ID是否都已配置。
    // 2. 获取Gist中的配置文件内容。
    // 3. 如果Gist内容与本地内容不同,则向用户确认是否覆盖。
    // 4. 如果用户确认,则用Gist内容更新本地存储,并提示刷新页面以应用更改。
    // 5. 操作过程中会显示通知反馈。
    async function downloadFromGist() {
        const GITHUB_TOKEN = getGitHubToken();
        if (!GITHUB_TOKEN) {
            showNotification('GitHub Token 未配置。请通过油猴菜单「⚙️ 配置Gist同步参数」进行设置。', 'error');
            console.error('[GSMS] 下载中止: Token缺失。'); return;
        }

        const currentGistId = getGistId();
        if (!currentGistId) {
            showNotification('Gist ID 未配置。请通过油猴菜单「⚙️ 配置Gist同步参数」进行设置,或先上传一次。', 'warning', 5000);
            console.error('[GSMS] 下载中止: Gist ID缺失。'); return;
        }

        showNotification('从Gist下载配置中...', 'info', 0); // 显示持续的"下载中"通知

        try {
            const remoteContent = await getGistFileContentAsync(currentGistId);

            // 清除"下载中..."的通知
            const downloadingNotifications = Array.from(document.body.children).filter(el => el.textContent === '从Gist下载配置中...');
            downloadingNotifications.forEach(n => n.remove());

            if (remoteContent !== null) { // 确保获取到了内容
                const localContent = localStorage.getItem(LOCALSTORAGE_SITES_KEY) || defaultSiteRowsText;
                if (remoteContent === localContent) {
                    showNotification('本地配置与Gist中的一致,无需下载。', 'info');
                    if (DEBUG_MODE) console.log('[GSMS] Gist内容与本地相同,跳过下载。');
                    return;
                }

                // 为确认信息估算配置行数,提供更友好的提示
                const remoteLines = remoteContent.split('\n').length;
                const localLines = localContent.split('\n').length;

                if (confirm(`Gist含约 ${remoteLines} 行配置,本地含约 ${localLines} 行。\n确定用Gist记录覆盖本地吗?(建议先上传备份本地配置)`)) {
                    localStorage.setItem(LOCALSTORAGE_SITES_KEY, remoteContent); // 更新本地存储
                    showNotification('已从Gist下载并覆盖本地配置!将刷新页面应用。', 'success', 3000);
                    if (DEBUG_MODE) console.log('[GSMS] 下载并覆盖成功。');
                    setTimeout(() => window.location.reload(), 2000); // 延迟刷新页面以应用更改
                } else {
                    showNotification('已取消从Gist下载。', 'info');
                    if (DEBUG_MODE) console.log('[GSMS] 用户取消了Gist下载。');
                }
            } else {
                // getGistFileContentAsync 函数在获取失败时应已显示具体错误 (如404, 文件缺失等)
                // 如果它返回null但没有特定用户通知,则显示一个通用错误。
                // 检查是否已有错误或警告通知,避免重复
                if(!document.querySelector('div[style*="background-color: rgb(244, 67, 54)"]') && !document.querySelector('div[style*="background-color: rgb(255, 152, 0)"]')) {
                     showNotification('从Gist下载配置失败,未找到有效内容。', 'error');
                }
                if (DEBUG_MODE) console.log('[GSMS] 从Gist下载失败 - 未获取到内容。');
            }
        } catch (error) {
            // 清除可能残留的"下载中..."通知
            const downloadingNotifications = Array.from(document.body.children).filter(el => el.textContent === '从Gist下载配置中...');
            downloadingNotifications.forEach(n => n.remove());
            console.error('[GSMS] 从Gist下载时发生错误:', error);
            showNotification('从Gist下载时发生严重错误,详情请查看控制台。', 'error');
        }
    }

    // --- Gist参数设置对话框 ---
    // 显示一个对话框,允许用户配置GitHub个人访问令牌 (Token) 和 Gist ID。
    // 配置会保存到油猴的存储中。
    function showGistSettingsDialog() {
        const existingDialog = document.getElementById('googleSearchMultiSite-settings-dialog');
        if (existingDialog) existingDialog.remove(); // 如果已存在对话框,先移除

        const dialogOverlay = document.createElement('div'); // 遮罩层
        dialogOverlay.id = 'googleSearchMultiSite-settings-dialog';
        const dialogContent = document.createElement('div'); // 对话框内容区域
        dialogContent.id = 'googleSearchMultiSite-settings-dialog-content';

        // 对话框HTML结构
        dialogContent.innerHTML = `
            <button id="googleSearchMultiSite-settings-close-btn" title="关闭">&times;</button>
            <h3>Gist 同步参数配置</h3>
            <div>
                <label for="gist_token_input_gsms">GitHub 个人访问令牌 (Token):</label>
                <input type="password" id="gist_token_input_gsms" value="${getGitHubToken()}" placeholder="例如 ghp_xxxxxxxxxxxxxxxxx">
                <small>Token 用于授权访问您的Gist。需要 Gist 读写权限。</small>
            </div>
            <div>
                <label for="gist_id_input_gsms">Gist ID:</label>
                <input type="text" id="gist_id_input_gsms" value="${getGistId()}" placeholder="例如 123abc456def7890">
                <small>Gist ID 是备份用Gist的标识。若为空,首次上传时将自动创建并保存。</small>
            </div>
            <div class="rw-dialog-buttons">
                <button id="settings_cancel_btn_gsms" class="rw-cancel-btn">取消</button>
                <button id="settings_save_btn_gsms" class="rw-save-btn">保存配置</button>
            </div>
        `;
        dialogOverlay.appendChild(dialogContent);
        document.body.appendChild(dialogOverlay);

        // 关闭对话框的函数
        const closeDialog = () => { dialogOverlay.remove(); };

        // 保存配置并关闭对话框的函数
        const saveAndClose = () => {
            const newToken = document.getElementById('gist_token_input_gsms').value.trim();
            const newGistId = document.getElementById('gist_id_input_gsms').value.trim();
            GM_setValue(GM_GITHUB_TOKEN_KEY, newToken); // 保存Token到油猴存储
            GM_setValue(GM_GIST_ID_KEY, newGistId);   // 保存Gist ID到油猴存储
            let msg = "Gist参数已保存!";
            if (!newToken && !newGistId) msg = "Gist参数已清空。";
            else if (!newToken) msg = "Token已清空, Gist ID已保存。";
            else if (!newGistId) msg = "Gist ID已清空, Token已保存。";
            showNotification(msg, 'success');
            closeDialog();
        };
        // 取消并关闭对话框的函数
        const cancelAndClose = () => {
            closeDialog();
            showNotification('参数设置已取消。', 'info');
        };

        // 绑定事件到按钮
        document.getElementById('settings_save_btn_gsms').addEventListener('click', saveAndClose);
        document.getElementById('settings_cancel_btn_gsms').addEventListener('click', cancelAndClose);
        document.getElementById('googleSearchMultiSite-settings-close-btn').addEventListener('click', cancelAndClose);
        // 点击遮罩层本身也会关闭对话框 (如果不是点击在内容区域上)
        dialogOverlay.addEventListener('click', e => { if (e.target === dialogOverlay) cancelAndClose(); });
    }

    // 弹窗编辑站点配置
    // 显示一个对话框,允许用户编辑多行站点配置文本。
    // 用户可以调整文本框的大小,脚本会记住调整后的尺寸。
    // 配置会保存到浏览器的本地存储中。
    function showSiteConfigDialog() {
        const currentText = localStorage.getItem(LOCALSTORAGE_SITES_KEY) || defaultSiteRowsText;
        // 创建遮罩层和对话框
        const mask = document.createElement('div');
        mask.style.position = 'fixed';
        mask.style.left = '0';
        mask.style.top = '0';
        mask.style.width = '100vw';
        mask.style.height = '100vh';
        mask.style.background = 'rgba(0,0,0,0.25)';
        mask.style.zIndex = '99999'; // 确保在顶层
        mask.style.display = 'flex';
        mask.style.alignItems = 'center';
        mask.style.justifyContent = 'center';

        const dialog = document.createElement('div');
        dialog.style.background = '#fff';
        dialog.style.borderRadius = '10px';
        dialog.style.boxShadow = '0 8px 32px rgba(0,0,0,0.18)';
        dialog.style.padding = '24px 24px 16px 24px';
        dialog.style.minWidth = '480px'; // 对话框最小宽度
        dialog.style.maxWidth = '90vw';
        dialog.style.display = 'flex';
        dialog.style.flexDirection = 'column';
        dialog.style.alignItems = 'stretch';

        // 对话框标题
        const title = document.createElement('div');
        title.textContent = '自定义站点配置';
        title.style.fontWeight = 'bold';
        title.style.fontSize = '18px';
        title.style.marginBottom = '10px';
        dialog.appendChild(title);

        // 对话框说明文字
        const desc = document.createElement('div');
        desc.textContent = '每一行代表一行按钮,每个按钮之间用空格分隔,每个按钮是"名字,网址"的格式:';
        desc.style.fontSize = '13px';
        desc.style.color = '#666';
        desc.style.marginBottom = '8px';
        dialog.appendChild(desc);

        // 大号文本编辑框
        const textarea = document.createElement('textarea');
        textarea.value = currentText; // 加载当前或默认配置

        // 加载或设置文本框的保存尺寸
        const savedWidth = localStorage.getItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY);
        const savedHeight = localStorage.getItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY);

        textarea.style.width = savedWidth || '100%'; // 应用保存的宽度,或默认100%
        textarea.style.height = savedHeight || '240px'; // 应用保存的高度,或默认240px
        textarea.style.fontSize = '14px';
        textarea.style.padding = '10px';
        textarea.style.border = '1px solid #bbb';
        textarea.style.borderRadius = '6px';
        textarea.style.resize = 'both'; // 允许用户调整文本框大小 (宽度和高度)
        textarea.style.overflow = 'auto'; // 内容超出时显示滚动条
        textarea.style.marginBottom = '16px';
        textarea.style.fontFamily = 'monospace,Consolas,Menlo'; // 等宽字体,便于编辑
        dialog.appendChild(textarea);

        // 按钮区域
        const btnRow = document.createElement('div');
        btnRow.style.display = 'flex';
        btnRow.style.justifyContent = 'flex-end'; // 按钮右对齐
        btnRow.style.gap = '12px'; // 按钮间距

        // 保存按钮
        const saveBtn = document.createElement('button');
        saveBtn.textContent = '保存';
        saveBtn.style.padding = '6px 18px';
        saveBtn.style.background = '#4caf50'; // 绿色背景
        saveBtn.style.color = '#fff';
        saveBtn.style.border = 'none';
        saveBtn.style.borderRadius = '5px';
        saveBtn.style.fontSize = '15px';
        saveBtn.style.cursor = 'pointer';
        saveBtn.onclick = function() {
            localStorage.setItem(LOCALSTORAGE_SITES_KEY, textarea.value); // 保存站点配置文本
            // 保存文本框当前的宽高,以便下次打开时恢复
            localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY, textarea.style.width);
            localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY, textarea.style.height);
            document.body.removeChild(mask); // 移除对话框
            showNotification('站点配置已保存,刷新页面后生效!', 'success');
        };
        btnRow.appendChild(saveBtn);

        // 取消按钮
        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = '取消';
        cancelBtn.style.padding = '6px 18px';
        cancelBtn.style.background = '#eee'; // 灰色背景
        cancelBtn.style.color = '#333';
        cancelBtn.style.border = 'none';
        cancelBtn.style.borderRadius = '5px';
        cancelBtn.style.fontSize = '15px';
        cancelBtn.style.cursor = 'pointer';
        cancelBtn.onclick = function() {
            // 即使用户取消,也保存文本框的尺寸,因为用户可能只是想调整大小而不修改内容
            localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY, textarea.style.width);
            localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY, textarea.style.height);
            document.body.removeChild(mask); // 移除对话框
        };
        btnRow.appendChild(cancelBtn);

        dialog.appendChild(btnRow);
        mask.appendChild(dialog);
        document.body.appendChild(mask); // 将对话框添加到页面
    }

    // 注册菜单命令
    // 向油猴脚本管理器(如Tampermonkey)的菜单中添加命令选项,方便用户操作。
    if (typeof GM_registerMenuCommand === 'function') {
        GM_registerMenuCommand('⚙️ 配置Gist同步参数', showGistSettingsDialog); // 打开Gist参数配置对话框
        GM_registerMenuCommand('⬆️ 上传配置到 Gist', uploadToGist);      // 执行上传配置到Gist的操作
        GM_registerMenuCommand('⬇️ 从 Gist 下载配置', downloadFromGist);    // 执行从Gist下载配置的操作
        GM_registerMenuCommand('⚙️ 设置站点', showSiteConfigDialog);         // 打开站点列表编辑对话框
    }

    // 获取Google搜索页面中的主搜索框元素
    function getSearchBox() {
        return document.querySelector("textarea[name='q']"); // Google搜索框通常是一个textarea
    }

    // 获取当前地址栏中的搜索关键词,并移除其中可能存在的 "site:xxx.com" 部分
    function getQuery() {
        const query = new URLSearchParams(window.location.search).get('q') || ''; // 从URL参数中获取q值
        return query.replace(/site:[^\s]+/g, '').trim(); // 移除site限定并去除首尾空格
    }

    // 创建并显示悬浮导航栏
    // 导航栏包含根据用户配置生成的多个站点搜索按钮。
    // 导航栏可以被用户拖动,并且其位置会被保存和恢复。
    function createFloatingNavBar() {
        const searchBox = getSearchBox();
        if (!searchBox) return; // 如果找不到搜索框,则不创建导航栏

        const navBar = document.createElement('div'); // 导航栏容器
        navBar.style.position = 'fixed'; // 固定定位,使其悬浮
        navBar.style.top = '100px'; // 默认初始位置
        navBar.style.left = '10px';
        navBar.style.zIndex = '10000'; // 确保在页面上层
        navBar.style.background = '#f8f8f8';
        navBar.style.border = '1px solid #ccc';
        navBar.style.padding = '3px 10px';
        navBar.style.borderRadius = '8px';
        navBar.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.2)';
        navBar.style.display = 'flex';
        navBar.style.flexDirection = 'column'; // 允许多行按钮
        navBar.style.gap = '5px'; // 行间距或按钮间距
        navBar.style.cursor = 'move'; // 鼠标样式提示可拖动
        navBar.style.transition = 'all 0.3s ease'; // 位置变化时的平滑过渡动画

        // 读取并应用上次保存的导航栏位置
        const lastPosition = JSON.parse(localStorage.getItem(LOCALSTORAGE_NAVBAR_POSITION_KEY) || '{"top": "100px", "left": "10px"}');
        navBar.style.top = lastPosition.top;
        navBar.style.left = lastPosition.left;

        // 读取并解析站点配置,然后为每一行站点创建按钮组
        const siteRows = getSiteRows();
        siteRows.forEach(row => {
            const rowDiv = document.createElement('div'); // 每行按钮的容器
            rowDiv.style.display = 'flex';
            rowDiv.style.gap = '5px'; // 同行按钮间距
            row.forEach(({ name, url }) => {
                const btn = document.createElement('button');
                btn.textContent = name; // 按钮文字
                btn.style.padding = '2px 6px';
                btn.style.cursor = 'pointer';
                btn.style.border = '1px solid #ccc';
                btn.style.background = '#f8f8f8';
                btn.style.borderRadius = '5px';
                btn.style.fontSize = '10px';
                // 按钮点击事件:构建新的搜索查询 (原查询词 + "site:目标域名") 并提交
                btn.addEventListener('click', () => {
                    searchBox.value = `${getQuery()} site:${url}`; // 设置搜索框内容
                    document.querySelector("button[type='submit']").click(); // 模拟点击Google的搜索按钮
                });
                rowDiv.appendChild(btn);
            });
            navBar.appendChild(rowDiv);
        });

        document.body.appendChild(navBar); // 将导航栏添加到页面

        // 实现导航栏拖动功能
        let isDragging = false; // 标记是否正在拖动
        let offsetX, offsetY; // 记录鼠标按下时在导航栏内部的偏移量

        navBar.addEventListener('mousedown', (e) => {
            isDragging = true;
            offsetX = e.clientX - navBar.offsetLeft;
            offsetY = e.clientY - navBar.offsetTop;
            navBar.style.transition = 'none'; // 拖动时移除动画,避免延迟感
        });

        window.addEventListener('mousemove', (e) => {
            if (isDragging) {
                navBar.style.left = `${e.clientX - offsetX}px`;
                navBar.style.top = `${e.clientY - offsetY}px`;
            }
        });

        window.addEventListener('mouseup', () => {
            if (isDragging) {
                isDragging = false;
                // 拖动结束后,保存新的位置到浏览器本地存储
                localStorage.setItem(LOCALSTORAGE_NAVBAR_POSITION_KEY, JSON.stringify({ top: navBar.style.top, left: navBar.style.left }));
                navBar.style.transition = 'all 0.3s ease'; // 恢复位置变化动画
            }
        });
    }

    // 初始化函数
    // 脚本的主入口点。此处使用setTimeout延迟执行,以尝试确保Google页面元素已完全加载。
    function init() {
        createFloatingNavBar(); // 创建悬浮导航栏
    }

    setTimeout(init, 1000); // 延迟1秒执行初始化函数
})();