🔍 搜索快切

快速切换搜索引擎的工具栏,支持自定义搜索引擎

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==

// @name         🔍 搜索快切

// @namespace    Aiccest

// @version      1.0.0

// @description  快速切换搜索引擎的工具栏,支持自定义搜索引擎

// @author       Aiccest

// @match        *://*/*

// @grant        GM_addStyle

// @grant        GM_setValue

// @grant        GM_getValue

// @grant        GM_registerMenuCommand

// @noframes

// @license      MIT

// ==/UserScript==

(function() {

    'use strict';

    const CONFIG = { DELAY: 500, DEBOUNCE: 150 };

    function debounce(fn, wait) {

        let t;

        return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };

    }

    function escapeHTML(str) {

        return str.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' })[c] || c);

    }

    function generateId(name) {

        let h = 0;

        for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;

        return 'engine_' + Math.abs(h);

    }

    const SEARCH_RULES = {

        preset: [

            { domains: ['www.baidu.com', 'baidu.com', 'm.baidu.com'], pathTest: /^\/(s|s\/|wappass\/bdstatic\/|from|mobile\/)/, paramKeys: ['wd', 'word', 'q'], exclude: [/^\/tieba/, /^\/zhidao/, /^\/question/, /^\/passport/], isBaidu: true },

            { domains: ['www.google.*', '*.google.*'], pathTest: /^\/search\b/, paramKeys: ['q'] },

            { domains: ['m.sm.cn'], pathTest: /^\/s\b/, paramKeys: ['q'] },

            { domains: ['*.so.com'], pathTest: /^\/s\b/, paramKeys: ['q'] },

            { domains: ['sogou.com', 'm.sogou.com'], pathTest: /^\/(web|web\/searchList\.jsp)\b/, paramKeys: ['q', 'keyword'] },

            { domains: ['*.bing.com'], pathTest: /^\/search\b/, paramKeys: ['q'] },

            { domains: ['zhihu.com'], pathTest: /^\/search\b/, paramKeys: ['q'] },

            { domains: ['metaso.cn'], pathTest: /^\/$/, paramKeys: ['q'] }

        ],

        custom(e) {

            try {

                const u = new URL(e.link.replace('%s', ''));

                let k = '';

                new URLSearchParams(u.search).forEach((v, key) => { if (v === '') k = key; });

                return { domains: [u.hostname], pathTest: new RegExp(`^${u.pathname}`), paramKeys: [k] };

            } catch {

                return null;

            }

        }

    };

    function isSearchEnginePage() {

        const u = new URL(location.href), p = new URLSearchParams(u.search);

        const e = GM_getValue('universal_search_engines', []);

        return [...SEARCH_RULES.preset, ...e.map(SEARCH_RULES.custom).filter(Boolean)].some(r => {

            if (!r.domains.some(d => u.hostname.includes(d.replace('*.', '')))) return false;

            if (r.exclude?.some(ex => ex.test(u.pathname))) return false;

            if (r.pathTest && !r.pathTest.test(u.pathname)) return false;

            return r.paramKeys.some(k => p.has(k));

        });

    }

    class BaiduHandler {

        constructor(render) {

            this.lastQuery = null;

            this.render = render;

            this.init();

        }

        init() {

            ['pushState', 'replaceState'].forEach(m => {

                const o = history[m];

                history[m] = (...args) => { o.apply(history, args); this.handleChange('history'); };

            });

            window.addEventListener('popstate', () => this.handleChange('popstate'));

            this.observer = new MutationObserver(m => {

                if (m.some(r => Array.from(r.addedNodes).some(n => n.id === 'content_left' || n.classList?.contains('c-container') || n.querySelector?.('[data-click]')))) {

                    this.handleChange('dom');

                }

            });

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

        }

        handleChange(src) {

            debounce(() => {

                const q = this.getQuery();

                if (q && (q !== this.lastQuery || src === 'popstate' || src === 'dom')) {

                    this.lastQuery = q;

                    document.querySelector('#search-toolbox')?.remove();

                    this.render();

                }

            }, 300)();

        }

        getQuery() {

            const p = new URLSearchParams(location.search);

            let q = p.get('wd') || p.get('word') || p.get('q');

            if (!q) {

                const i = document.querySelector('input#kw, input[name="wd"], input[type="search"]');

                q = i?.value?.trim();

            }

            return q || document.title.replace(/(百度搜索|_百度搜索|-百度搜索).*$/, '').trim();

        }

        destroy() {

            this.observer?.disconnect();

        }

    }

    class SearchBox {

        constructor() {

            this.engines = GM_getValue('universal_search_engines') || [

                { name: 'Google', link: 'https://www.google.com/search?q=%s' },

                { name: '百度', link: 'https://www.baidu.com/s?wd=%s' },

                { name: '神马', link: 'https://m.sm.cn/s?q=%s' },

                { name: '360搜索', link: 'https://www.so.com/s?q=%s' },

                { name: '搜狗', link: 'https://m.sogou.com/web/searchList.jsp?keyword=%s' },

                { name: '必应', link: 'https://cn.bing.com/search?q=%s' },

                { name: '知乎', link: 'https://www.zhihu.com/search?q=%s' },

                { name: '秘塔AI', link: 'https://metaso.cn/?q=%s' }

            ].map(e => ({ ...e, id: generateId(e.name) }));

            if (!GM_getValue('universal_search_engines')) GM_setValue('universal_search_engines', this.engines);

            if (!isSearchEnginePage()) return;

            this.injectStyles();

            if (SEARCH_RULES.preset[0].domains.some(d => location.hostname.includes(d.replace('*.', '')))) {

                this.baiduHandler = new BaiduHandler(() => this.renderToolbox());

            }

            this.renderToolbox();

            this.bindEvents();

            this.bindResizeHandler();

            GM_registerMenuCommand('⚙️ 设置', () => this.showSettings());

        }

        injectStyles() {

            GM_addStyle(`

                #search-toolbox {

                    position: fixed; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.98);

                    border-top: 1px solid #ddd; padding: 8px; display: flex; gap: 6px; z-index: 2147483647;

                    overflow-x: auto; scrollbar-width: none;

                }

                #search-toolbox::-webkit-scrollbar { display: none; }

                #search-toolbox.long-content { justify-content: flex-start; }

                #search-toolbox:not(.long-content) { justify-content: center; }

                .search-engine {

                    padding: 4px 10px; background: #007bff; color: #fff; border-radius: 4px; font-size: 12px;

                    white-space: nowrap; flex-shrink: 0; cursor: pointer;

                }

                #settings-btn { background: #6c757d; }

                @media (max-width: 480px) {

                    .search-engine { font-size: 11px; padding: 4px 8px; }

                }

            `);

        }

        renderToolbox() {

            let t = document.querySelector('#search-toolbox');

            if (!t) {

                t = document.createElement('div');

                t.id = 'search-toolbox';

                document.body.appendChild(t);

            }

            t.innerHTML = this.engines.map(e => `<div class="search-engine" data-id="${escapeHTML(e.id)}" data-link="${escapeHTML(e.link)}">${escapeHTML(e.name)}</div>`).join('') + '<div class="search-engine" id="settings-btn">⚙️</div>';

            this.updateToolbox();

        }

        bindEvents() {

            document.addEventListener('click', e => {

                const t = e.target;

                if (t.closest('#search-toolbox') && t.id === 'settings-btn') {

                    e.preventDefault();

                    const p = document.querySelector('#settings-panel-container');

                    if (p) {

                        p.remove();

                    } else {

                        this.showSettings();

                        setTimeout(() => {

                            document.addEventListener('click', function closePanel(ev) {

                                const path = ev.composedPath();

                                const isInPanel = path.some(el => el?.id === 'settings-panel-container' || el?.classList?.contains('settings-panel'));

                                const isSettingsBtn = ev.target.matches('#settings-btn');

                                if (!isInPanel && !isSettingsBtn) {

                                    document.querySelector('#settings-panel-container')?.remove();

                                    document.removeEventListener('click', closePanel);

                                }

                            }, { capture: true });

                        }, 50);

                    }

                } else if (t.classList.contains('search-engine') && t.id !== 'settings-btn') {

                    const q = this.getQuery();

                    if (q) window.open(t.dataset.link.replace('%s', encodeURIComponent(q)), '_blank');

                }

            });

        }

        getQuery() {

            if (this.baiduHandler) return this.baiduHandler.getQuery();

            const p = new URLSearchParams(location.search);

            for (const k of ['q', 'wd', 'query']) {

                const v = p.get(k);

                if (v?.trim()) return v.trim();

            }

            return document.querySelector('input[type="search"]')?.value?.trim() || '';

        }

        showSettings() {

            const p = document.createElement('div');

            p.id = 'settings-panel-container';

            document.body.appendChild(p);

            // 创建 Shadow DOM

            const shadow = p.attachShadow({ mode: 'open' });

            // 创建样式

            const style = document.createElement('style');

            style.textContent = `

                .settings-panel {

                    width: 60%; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white;

                    padding: 12px; border-radius: 10px; box-shadow: 0 0 20px rgba(0,0,0,0.15); z-index: 2147483647;

                    max-width: 600px; max-height: 80vh; overflow-y: auto; box-sizing: border-box;

                    font-family: Arial, sans-serif;

                }

                .settings-panel h3 {

                    margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; font-size: 12px;

                    display: flex; justify-content: space-between; align-items: center;

                }

                .engine-item {

                    margin-bottom: 12px; /* 保留条目间间距 */

                }

                .name-row {

                    display: flex; gap: 6px; align-items: center; margin-bottom: 0; /* 移除名称栏底部间距 */

                }

                .name-row input[type="text"] {

                    flex: 1; min-width: 60px; max-width: 120px; padding: 6px 8px; box-sizing: border-box;

                    border: 1px solid #ddd; border-radius: 4px;

                }

                .engine-actions {

                    display: flex; gap: 4px; margin-left: auto;

                }

                .url-input {

                    width: 100%; margin: 0; /* 移除URL输入框间距,使其紧贴名称栏 */

                    padding: 7px 10px; box-sizing: border-box;

                    border: 1px solid #ddd; border-radius: 4px;

                }

                .action-bar {

                    display: flex; gap: 6px; margin-top: 8px; padding-top: 12px; border-top: 1px solid #eee;

                }

                .action-btn {

                    flex: 1; padding: 5px; height: 24px; line-height: 14px; text-align: center; border-radius: 4px;

                    font-size: 16px; border: none; cursor: pointer;

                }

                .engine-actions button {

                    width: 18px; height: 24px; padding: 0; font-size: 14px; border-radius: 1px;

                    line-height: 22px; border: 1px solid #ddd; background: #f8f9fa;

                }

                .engine-actions button:hover { background: #e9ecef; }

                .engine-actions button[disabled] { opacity: 0.5; cursor: not-allowed; }

                #add-engine { background: #28a745; color: white; }

                #save-settings { background: #007bff; color: white; }

                #close-panel { background: #6c757d; color: white; }

                @media (prefers-color-scheme: dark) {

                    .settings-panel { background: #2d2d2d; color: #eee; }

                    .engine-actions button { background: #333; border-color: #555; }

                    .url-input { background: #333; color: #fff; border-color: #555; }

                    .name-row input[type="text"] { background: #333; color: #fff; border-color: #555; }

                    .action-bar { border-top: 1px solid #444; }

                    .settings-panel h3 { border-bottom: 1px solid #444; }

                }

            `;

            // 创建内容

            const content = document.createElement('div');

            content.className = 'settings-panel';

            content.innerHTML = `

                <h3>🔧 搜索引擎管理</h3>

                <div id="engine-list">

                    ${this.engines.map((e, i) => `

                        <div class="engine-item" data-id="${escapeHTML(e.id)}">

                            <div class="name-row">

                                <input type="text" value="${escapeHTML(e.name)}" required>

                                <div class="engine-actions">

                                    <button class="move-up" ${i === 0 ? 'disabled' : ''}>↑</button>

                                    <button class="move-down" ${i === this.engines.length - 1 ? 'disabled' : ''}>↓</button>

                                    <button class="delete">×</button>

                                </div>

                            </div>

                            <input class="url-input" type="url" value="${escapeHTML(e.link)}" required>

                        </div>

                    `).join('')}

                </div>

                <div class="action-bar">

                    <button class="action-btn" id="add-engine">添加</button>

                    <button class="action-btn" id="save-settings">保存</button>

                    <button class="action-btn" id="close-panel">关闭</button>

                </div>

            `;

            // 将样式和内容添加到 Shadow DOM

            shadow.appendChild(style);

            shadow.appendChild(content);

            // 绑定事件

            content.addEventListener('click', (e) => this.handleSettingsClick(e));

        }

        handleSettingsClick(e) {

            const t = e.target;

            const p = t.closest('.settings-panel');

            if (!p) return;

            if (t.id === 'add-engine') {

                const item = document.createElement('div');

                item.className = 'engine-item';

                item.dataset.id = generateId('新引擎');

                item.innerHTML = `

                    <div class="name-row">

                        <input type="text" placeholder="引擎名称" required>

                        <div class="engine-actions">

                            <button class="move-up">↑</button>

                            <button class="move-down">↓</button>

                            <button class="delete">×</button>

                        </div>

                    </div>

                    <input class="url-input" type="url" placeholder="https://www.google.com/search?q=%s" required>

                `;

                p.querySelector('#engine-list').appendChild(item);

                p.scrollTop = p.scrollHeight;

            } else if (t.id === 'save-settings') {

                const engines = [];

                let valid = true;

                p.querySelectorAll('.engine-item').forEach(item => {

                    const nameInput = item.querySelector('input[type="text"]');

                    const urlInput = item.querySelector('input[type="url"]');

                    const name = nameInput.value.trim();

                    const link = urlInput.value.trim();

                    if (!name) {

                        alert('引擎名称不能为空!');

                        nameInput.focus();

                        valid = false;

                        return;

                    }

                    if (!/%s/.test(link)) {

                        alert('URL必须包含%s占位符!');

                        urlInput.focus();

                        valid = false;

                        return;

                    }

                    try {

                        new URL(link.replace('%s', 'test'));

                    } catch {

                        alert('请输入有效的URL!');

                        urlInput.focus();

                        valid = false;

                        return;

                    }

                    engines.push({ id: item.dataset.id, name, link });

                });

                if (valid) {

                    this.engines = engines;

                    GM_setValue('universal_search_engines', engines);

                    document.querySelector('#settings-panel-container')?.remove();

                    document.querySelector('#search-toolbox')?.remove();

                    this.renderToolbox();

                }

            } else if (t.id === 'close-panel') {

                document.querySelector('#settings-panel-container')?.remove();

            } else if (t.classList.contains('move-up') || t.classList.contains('move-down')) {

                const item = t.closest('.engine-item');

                if (t.classList.contains('move-up')) {

                    item.previousElementSibling?.before(item);

                } else {

                    item.nextElementSibling?.after(item);

                }

                p.querySelectorAll('.engine-item').forEach((el, i) => {

                    el.querySelector('.move-up').disabled = i === 0;

                    el.querySelector('.move-down').disabled = i === p.querySelectorAll('.engine-item').length - 1;

                });

            } else if (t.classList.contains('delete')) {

                if (p.querySelectorAll('.engine-item').length <= 1) {

                    alert('至少保留一个搜索引擎!');

                    return;

                }

                t.closest('.engine-item').remove();

            }

        }

        updateToolbox() {

            const t = document.querySelector('#search-toolbox');

            if (!t) return;

            t.classList.remove('long-content');

            t.getBoundingClientRect();

            const sw = t.scrollWidth, cw = t.clientWidth;

            t.classList.toggle('long-content', sw > cw);

            if (sw > cw) t.scrollLeft = 0;

        }

        bindResizeHandler() {

            const h = debounce(() => {

                requestAnimationFrame(() => this.updateToolbox());

            }, CONFIG.DEBOUNCE);

            window.addEventListener('resize', h);

            window.addEventListener('orientationchange', h);

            requestAnimationFrame(() => this.updateToolbox());

        }

        destroy() {

            this.baiduHandler?.destroy();

            document.querySelectorAll('#search-toolbox, #settings-panel-container').forEach(e => e.remove());

        }

    }

    function init() {

        if (document.body && isSearchEnginePage()) new SearchBox();

        else setTimeout(init, 200);

    }

    init();

})();