Greasy Fork

🔍 搜索快切

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

// ==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();

})();