Greasy Fork

Greasy Fork is available in English.

🌐 搜索中心增强

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

目前为 2025-05-04 提交的版本。查看 最新版本

// ==UserScript==

// @name         🌐 搜索中心增强
// @name:en     🌐 Search Hub Enhancer

// @namespace    http://greasyfork.icu/zh-CN/users/1454800

// @version      1.0.2

// @description  快速切换搜索引擎的工具栏,可自定义引擎
// @description:en A toolbar for quick switching between search engines with custom engine support

// @author       Aiccest

// @match        *://*/*

// @grant        GM_setValue

// @grant        GM_getValue

// @grant        GM_registerMenuCommand

// @noframes

// @license      MIT

// ==/UserScript==

(function () {

    'use strict';

const i18n = {
      zh: {
        settings: t("settings"),
        searchWith: t("searchWith"),
        defaultEngine: t("defaultEngine"),
        enableIcons: t("enableIcons"),
        save: t("save"),
        cancel: t("cancel"),
        addEngine: t("addEngine"),
        reset: t("reset"),
        toolbarPosition: t("toolbarPosition")
      },
      en: {
        settings: "Settings",
        searchWith: "Search with the following engines",
        defaultEngine: "Default search engine",
        enableIcons: "Enable icons",
        save: "Save",
        cancel: "Cancel",
        addEngine: "Add Engine",
        reset: "Reset",
        toolbarPosition: "Toolbar Position"
      }
    };
    const getLang = () => navigator.language.startsWith("zh") ? "zh" : "en";
    const t = (key) => i18n[getLang()][key] || key;

    const CONFIG = {

        STORAGE_KEY: 'search_hub_engines',

        DEBOUNCE_MS: 600,

        ANIMATION_MS: 300,

        TOOLBAR_POSITION: 'bottom-center',

    };

    const GLOBAL_CSS = `

        :host {

            --bg-color: rgba(255, 255, 255, 0.95);

            --text-color: #1f2937;

            --border-color: #e5e7eb;

            --hover-bg: #f9fafb;

            --panel-bg: white;

            --btn-bg: #f9fafb;

            --btn-active-bg: #e5e7eb;

            --btn-save-bg: #4f46e5;

            --btn-add-bg: #22c55e;

            --btn-close-bg: #6b7280;

        }

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

            :host {

                --bg-color: rgba(31, 41, 55, 0.95);

                --text-color: #e5e7eb;

                --border-color: #4b5563;

                --hover-bg: #374151;

                --panel-bg: #1f2937;

                --btn-bg: #374151;

                --btn-active-bg: #4b5563;

            }

        }

        #search-hub-toolbar {

            position: fixed;

            bottom: 0;

            left: 50%;

            transform: translateX(-50%);

            background: var(--bg-color);

            backdrop-filter: blur(8px);

            border-radius: 12px 12px 0 0;

            padding: 8px;

            display: flex;

            gap: 8px;

            z-index: 2147483647;

            max-width: 90vw;

            overflow-x: auto;

            scrollbar-width: none;

            box-shadow: 0 -2px 8px rgba(0,0,0,0.1);

            touch-action: pan-x;

            user-select: none;

            -webkit-user-select: none;

            pointer-events: auto;

        }

        #search-hub-toolbar::-webkit-scrollbar { display: none; }

        .engine-btn {

            padding: 6px 12px;

            background: var(--btn-bg);

            color: var(--text-color);

            border: 0.8px solid var(--border-color);

            border-radius: 8px;

            font-size: 14px;

            cursor: pointer;

            transition: background 0.2s ease;

            white-space: nowrap;

        }

        .engine-btn:hover { background: var(--hover-bg); }

        .settings-btn {

            background: var(--btn-bg);

            color: var(--text-color);

            border: 0.8px solid var(--border-color);

        }

        @media (max-width: 640px) {

            #search-hub-toolbar { max-width: 95vw; }

            .engine-btn { font-size: 12px; padding: 4px 8px; }

        }

        @keyframes fadeIn {

            from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }

            to { opacity: 1; transform: translate(-50%, -50%) scale(1); }

        }

        .settings-panel {

            position: fixed;

            top: 50%;

            left: 50%;

            transform: translate(-50%, -50%);

            background: var(--panel-bg);

            border-radius: 12px;

            padding: 16px;

            box-shadow: 0 4px 16px rgba(0,0,0,0.2);

            z-index: 2147483647;

            max-width: 600px;

            max-height: 80vh;

            overflow-y: auto;

            font-family: system-ui, sans-serif;

            box-sizing: border-box;

            animation: fadeIn ${CONFIG.ANIMATION_MS}ms ease forwards;

            pointer-events: auto;

            color: var(--text-color);

        }

        h3 {

            font-size: 16px;

            margin: 0 0 12px;

            padding-bottom: 8px;

            border-bottom: 1px solid var(--border-color);

        }

        .engine-item {

            margin-bottom: 12px;

            border: 1px solid var(--border-color);

            border-radius: 6px;

            padding: 0;

        }

        .name-row {

            display: flex;

            gap: 8px;

            align-items: center;

            margin: 8px;

        }

        .name-row input {

            flex: 1;

            padding: 6px 8px;

            border: 1px solid var(--border-color);

            border-radius: 4px;

            font-size: 14px;

            box-sizing: border-box;

            background: var(--panel-bg);

            color: var(--text-color);

        }

        .url-input {

            width: calc(100% - 16px);

            margin: 0 8px 8px;

            padding: 6px 8px;

            border: 1px solid var(--border-color);

            border-radius: 4px;

            font-size: 14px;

            box-sizing: border-box;

            background: var(--panel-bg);

            color: var(--text-color);

        }

        .actions {

            display: flex;

            gap: 4px;

        }

        .action-btn {

            width: 24px;

            height: 24px;

            padding: 0;

            font-size: 14px;

            border: 1px solid var(--border-color);

            border-radius: 4px;

            background: var(--btn-bg);

            cursor: pointer;

            display: flex;

            align-items: center;

            justify-content: center;

            box-sizing: border-box;

            color: var(--text-color);

        }

        .action-btn:hover {

            background: var(--btn-active-bg);

        }

        .action-btn:disabled {

            opacity: 0.5;

            cursor: not-allowed;

        }

        .panel-actions {

            display: flex;

            gap: 8px;

            margin-top: 12px;

            border-top: 1px solid var(--border-color);

            padding-top: 12px;

            justify-content: flex-end;

        }

        .panel-btn {

            padding: 8px 16px;

            font-size: 14px;

            border-radius: 6px;

            border: none;

            cursor: pointer;

            box-sizing: border-box;

            color: white;

        }

        .add-btn { background: var(--btn-add-bg); }

        .save-btn { background: var(--btn-save-bg); }

        .close-btn { background: var(--btn-close-bg); }

        @media (max-width: 640px) {

            .settings-panel { width: 90vw; }

            .name-row input { max-width: calc(100% - 94px); }

            .url-input { width: calc(100% - 16px); }

            .panel-btn { padding: 6px 12px; font-size: 12px; }

        }

    `;

    const debounce = (fn, ms) => {

        let timeout;

        return (...args) => {

            clearTimeout(timeout);

            timeout = setTimeout(() => fn(...args), ms);

        };

    };

    const sanitize = str => str.replace(/[&<>"']/g, c => ({

        '&': '&amp;',

        '<': '&lt;',

        '>': '&gt;',

        '"': '&quot;',

        "'": '&#39;'

    })[c]);

    const generateId = () => `se_${Math.random().toString(36).slice(2, 10)}`;

    class BaiduHandler {

        static isBaidu() {

            return /baidu\.com$/.test(location.hostname);

        }

        static getQuery() {

            if (!this.isBaidu()) return null;

            const input = SearchDetector.getSearchInput();

            return input?.value?.trim() || new URLSearchParams(location.search).get('wd')?.trim() || '';

        }

    }

    class SearchDetector {

        static cachedInput = null;

        static config = {

            domains: {

                'metaso.cn': { basePath: '/', queryParam: 'q', displayName: 'Metaso' },

                'www.baidu.com': { basePath: '/s', queryParam: 'wd', displayName: 'Baidu' },

                'www.yandex.com': { basePath: '/search', queryParam: 'text', displayName: 'Yandex' },

                'search.yahoo.com': { basePath: '/search', queryParam: 'p', displayName: 'Yahoo' },

                'www.startpage.com': { basePath: '/search', queryParam: 'q', displayName: 'Startpage' },

                'search.aol.com': { basePath: '/aol/search', queryParam: 'q', displayName: 'AOL' },

            },

            exclude: [

                { domain: /baidu\.com$/, paths: [/^\/(tieba|zhidao|question|passport)/] },

            ],

            commonQueryParams: ['q', 'wd', 'word', 'keyword', 'search', 'query', 'text', 'p'],

        };

        static getSearchInput() {

            if (!this.cachedInput) {

                this.cachedInput = document.querySelector(

                    'input[type="search"], input#kw, input[name="wd"], input[name="q"], input[name="search"], input[name="query"], input[name="text"], input[name="p"], input.search-input'

                );

            }

            return this.cachedInput;

        }

        static isSearchPage() {

            try {

                const url = new URL(location.href);

                const params = new URLSearchParams(url.search);

                for (const rule of this.config.exclude) {

                    if (rule.domain.test(url.hostname) && rule.paths.some(ex => ex.test(url.pathname))) {

                        return false;

                    }

                }

                const domainConfig = this.config.domains[url.hostname];

                if (domainConfig && domainConfig.basePath === url.pathname.split('?')[0]) {

                    return true;

                }

                const hasQueryParam = this.config.commonQueryParams.some(param => params.has(param) && params.get(param).trim());

                const hasSearchInput = !!this.getSearchInput()?.value?.trim();

                const hasSearchTitle = document.title.toLowerCase().includes('search') || document.title.includes('搜索');

                return hasQueryParam || hasSearchInput || hasSearchTitle;

            } catch (e) {

                console.error('SearchDetector.isSearchPage error:', e);

                return false;

            }

        }

        static getQuery() {

            try {

                const params = new URLSearchParams(location.search);

                for (const param of this.config.commonQueryParams) {

                    const value = params.get(param)?.trim();

                    if (value) return value;

                }

                const inputValue = this.getSearchInput()?.value?.trim();

                if (inputValue) return inputValue;

                return document.title.replace(/\s*[-_|](搜索|Search|Query|Results).*$/, '').trim();

            } catch (e) {

                console.error('SearchDetector.getQuery error:', e);

                return '';

            }

        }

        static custom(e) {

            try {

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

                let k = '';

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

                const pathTest = new RegExp(`^${u.pathname}(/.*)?$`);

                return { domains: [u.hostname], pathTest, paramKeys: [k || 'q'] };

            } catch {

                return null;

            }

        }

        static detectEngineConfig() {

            try {

                const url = new URL(location.href);

                const domainConfig = this.config.domains[url.hostname];

                if (domainConfig) {

                    const searchUrl = `${url.protocol}//${url.hostname}${domainConfig.basePath}${domainConfig.basePath === '/' ? '?' : domainConfig.basePath.includes('?') ? '&' : '?'}${domainConfig.queryParam}=%s`;

                    return { name: domainConfig.displayName, url: searchUrl };

                }

                let queryParam = null;

                let basePath = '/';

                let detectionSource = 'none';

                const forms = document.querySelectorAll('form[action]');

                const searchForm = Array.from(forms).find(form =>

                    form.querySelector('input[type="search"], input[name="q"], input[name="wd"], input[name="search"], input[name="query"], input[name="text"], input[name="p"], input.search-input')

                );

                if (searchForm) {

                    try {

                        const actionUrl = new URL(searchForm.action, url.origin);

                        basePath = actionUrl.pathname || '/';

                        const searchInput = searchForm.querySelector('input[type="search"], input[name="q"], input[name="wd"], input[name="search"], input[name="query"], input[name="text"], input[name="p"], input.search-input');

                        if (searchInput && searchInput.name) {

                            queryParam = searchInput.name;

                            detectionSource = 'form-input';

                        } else {

                            const actionParams = new URLSearchParams(actionUrl.search);

                            for (const param of this.config.commonQueryParams) {

                                if (actionParams.has(param)) {

                                    queryParam = param;

                                    detectionSource = 'form-action';

                                    break;

                                }

                            }

                        }

                    } catch (e) {

                        console.warn('Dynamic form detection failed:', e);

                    }

                }

                if (detectionSource === 'none') {

                    const params = new URLSearchParams(url.search);

                    for (const param of this.config.commonQueryParams) {

                        if (params.has(param) && params.get(param).trim()) {

                            queryParam = param;

                            break;

                        }

                    }

                    if (!queryParam) {

                        for (const [key, value] of params.entries()) {

                            if (value && value.trim().length > 0) {

                                queryParam = key;

                                break;

                            }

                        }

                    }

                    if (!queryParam && this.getSearchInput()?.value?.trim()) {

                        queryParam = 'q';

                    }

                    if (!queryParam) return null;

                    const pathSegments = url.pathname.split('/').filter(segment => segment);

                    const staticSegments = pathSegments.filter(segment =>

                        !/^[0-9]+$/.test(segment) &&

                        !/^ssid=/.test(segment) &&

                        !/^from=/.test(segment) &&

                        !/^[a-f0-9]{8}-/.test(segment) &&

                        segment.length < 20

                    );

                    basePath = staticSegments.length > 0 ? `/${staticSegments.join('/')}` : '/';

                    detectionSource = 'fallback';

                }

                if (!queryParam) return null;

                const hostnameParts = url.hostname.split('.');

                const commonSubdomains = ['www', 'm', 'mobile', 'search'];

                const tlds = ['com', 'cn', 'org', 'net', 'co', 'io', 'ai'];

                const significantParts = hostnameParts.filter(part =>

                    !commonSubdomains.includes(part) && !tlds.includes(part)

                );

                const engineName = significantParts.length > 0 ? significantParts[significantParts.length - 1] : hostnameParts[0];

                const displayName = engineName.charAt(0).toUpperCase() + engineName.slice(1);

                const baseUrl = `${url.protocol}//${url.hostname}${basePath}`;

                const searchUrl = `${baseUrl}${basePath === '/' ? '?' : basePath.includes('?') ? '&' : '?'}${queryParam}=%s`;

                return { name: displayName, url: searchUrl };

            } catch (e) {

                console.error('SearchDetector.detectEngineConfig error:', e);

                return null;

            }

        }

    }

    class SettingsPanel {

        constructor(searchHub) {

            this.searchHub = searchHub;

            this.panel = null;

        }

        render() {

            this.panel = document.createElement('div');

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

            this.panel.setAttribute('translate', 'no');

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

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

            style.textContent = GLOBAL_CSS;

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

            content.className = 'settings-panel';

            content.innerHTML = `

                <h3>🌐 搜索引擎设置</h3>

                <div id="engine-list">

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

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

                            <div class="name-row">

                                <input type="text" value="${sanitize(e.name)}" placeholder="名称" required>

                                <div class="actions">

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

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

                                    <button class="action-btn delete">×</button>

                                </div>

                            </div>

                            <input class="url-input" type="url" value="${sanitize(e.url)}" placeholder="包含 %s 的URL" required>

                        </div>

                    `).join('')}

                </div>

                <div class="panel-actions">

                    <button class="panel-btn add-btn">添加</button>

                    <button class="panel-btn save-btn">保存</button>

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

                </div>

            `;

            shadow.appendChild(style);

            shadow.appendChild(content);

            document.body.appendChild(this.panel);

            shadow.addEventListener('click', e => this.handleClick(e), { capture: true, passive: false });

        }

        handleClick(e) {

            e.stopPropagation();

            const target = e.target;

            const list = this.panel.shadowRoot.querySelector('#engine-list');

            if (target.classList.contains('add-btn')) {

                let name = '新搜索引擎';

                let url = 'https://example.com/search?q=%s';

                if (SearchDetector.isSearchPage()) {

                    const engineConfig = SearchDetector.detectEngineConfig();

                    if (engineConfig) {

                        name = engineConfig.name;

                        url = engineConfig.url;

                    }

                }

                this.searchHub.addEngineItem(list, name, url);

                this.panel.scrollTop = this.panel.scrollHeight;

            } else if (target.classList.contains('save-btn')) {

                const engines = [];

                let valid = true;

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

                    const name = item.querySelector('input[type="text"]')?.value.trim();

                    const url = item.querySelector('input[type="url"]')?.value.trim();

                    if (!name || !url) {

                        alert('名称和URL为必填项!');

                        valid = false;

                        return;

                    }

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

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

                        valid = false;

                        return;

                    }

                    try {

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

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

                    } catch {

                        alert('无效的URL!');

                        valid = false;

                    }

                });

                if (valid) {

                    this.searchHub.engines = engines;

                    GM_setValue(CONFIG.STORAGE_KEY, engines);

                    this.close();

                    this.searchHub.renderToolbar();

                }

            } else if (target.classList.contains('close-btn')) {

                this.close();

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

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

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

                    item.previousElementSibling?.before(item);

                } else {

                    item.nextElementSibling?.after(item);

                }

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

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

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

                });

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

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

                    alert('至少需要一个搜索引擎!');

                    return;

                }

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

            }

        }

        close() {

            this.panel?.remove();

            this.panel = null;

        }

    }

    class SearchHub {

        constructor() {

            this.engines = GM_getValue(CONFIG.STORAGE_KEY) || [

                { id: generateId(), name: 'Google', url: 'https://www.google.com/search?q=%s' },

                { id: generateId(), name: 'Bing', url: 'https://www.bing.com/search?q=%s' },

            ];

            if (!GM_getValue(CONFIG.STORAGE_KEY)) GM_setValue(CONFIG.STORAGE_KEY, this.engines);

            this.init();

        }

        init() {

            if (SearchDetector.isSearchPage()) {

                this.renderToolbar();

            }

            this.bindEvents();

            this.bindKeyboard();

            this.observePageChanges();

            GM_registerMenuCommand('🌐 添加当前页面为搜索引擎', () => this.addCurrentPageAsEngine());

        }

        renderToolbar() {

            let toolbarContainer = document.querySelector('#search-hub-toolbar-container');

            if (toolbarContainer) {

                toolbarContainer.remove();

            }

            if (!SearchDetector.isSearchPage()) return;

            toolbarContainer = document.createElement('div');

            toolbarContainer.id = 'search-hub-toolbar-container';

            toolbarContainer.setAttribute('translate', 'no');

            document.body.appendChild(toolbarContainer);

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

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

            style.textContent = GLOBAL_CSS;

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

            toolbar.id = 'search-hub-toolbar';

            toolbar.innerHTML = `

                ${this.engines.map(e => `

                    <button class="engine-btn" data-id="${sanitize(e.id)}" data-url="${sanitize(e.url)}">

                        ${sanitize(e.name)}

                    </button>

                `).join('')}

                <button class="engine-btn settings-btn">⚙️</button>

            `;

            toolbar.addEventListener('click', (e) => {

                e.preventDefault();

                e.stopPropagation();

                const target = e.target;

                if (target.classList.contains('settings-btn')) {

                    this.toggleSettings();

                } else if (target.classList.contains('engine-btn')) {

                    const query = BaiduHandler.isBaidu() ? BaiduHandler.getQuery() : SearchDetector.getQuery();

                    if (query) window.open(target.dataset.url.replace('%s', encodeURIComponent(query)), '_blank');

                }

            }, { capture: true, passive: false });

            shadow.appendChild(style);

            shadow.appendChild(toolbar);

        }

        bindEvents() {

            this.globalClickHandler = (e) => {

                if (!e.composedPath().some(el => el.id === 'settings-panel-container') && document.querySelector('#settings-panel-container')) {

                    this.closeSettings();

                }

            };

            document.addEventListener('click', this.globalClickHandler, { capture: true });

        }

        bindKeyboard() {

            this.keyboardHandler = (e) => {

                if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 's') {

                    e.preventDefault();

                    this.toggleSettings();

                }

            };

            document.addEventListener('keydown', this.keyboardHandler);

        }

        observePageChanges() {

            const updateToolbar = debounce(() => {

                const isSearchPage = SearchDetector.isSearchPage();

                const toolbarExists = !!document.querySelector('#search-hub-toolbar-container');

                if (isSearchPage && !toolbarExists) {

                    this.renderToolbar();

                } else if (!isSearchPage && toolbarExists) {

                    document.querySelector('#search-hub-toolbar-container')?.remove();

                }

            }, CONFIG.DEBOUNCE_MS);

            const observerTarget = document.querySelector('form, header, main') || document.body;

            const observer = new MutationObserver(mutations => {

                if (mutations.some(m => m.addedNodes.length || m.removedNodes.length)) {

                    updateToolbar();

                }

            });

            observer.observe(observerTarget, { childList: true, subtree: true });

            window.addEventListener('popstate', () => setTimeout(updateToolbar, 100));

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

                const original = history[method];

                history[method] = (...args) => {

                    original.apply(history, args);

                    setTimeout(updateToolbar, 100);

                };

            });

        }

        toggleSettings() {

            if (document.querySelector('#settings-panel-container')) {

                this.closeSettings();

            } else {

                new SettingsPanel(this).render();

            }

        }

        closeSettings() {

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

        }

        addEngineItem(list, name = '新搜索引擎', url = 'https://example.com/search?q=%s', id = generateId()) {

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

            item.className = 'engine-item';

            item.dataset.id = id;

            item.innerHTML = `

                <div class="name-row">

                    <input type="text" value="${sanitize(name)}" placeholder="名称" required>

                    <div class="actions">

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

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

                        <button class="action-btn delete">×</button>

                    </div>

                </div>

                <input class="url-input" type="url" value="${sanitize(url)}" placeholder="包含 %s 的URL" required>

            `;

            list.appendChild(item);

            return item;

        }

        addCurrentPageAsEngine() {

            if (!SearchDetector.isSearchPage()) {

                alert('当前页面不是搜索页面,无法添加为搜索引擎!');

                return;

            }

            const engineConfig = SearchDetector.detectEngineConfig();

            if (!engineConfig) {

                alert('无法检测当前页面的搜索引擎配置!');

                return;

            }

            this.toggleSettings();

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

            if (panel) {

                const list = panel.shadowRoot.querySelector('#engine-list');

                this.addEngineItem(list, engineConfig.name, engineConfig.url);

                panel.scrollTop = panel.scrollHeight;

            }

        }

        destroy() {

            document.querySelector('#search-hub-toolbar-container')?.remove();

            this.closeSettings();

            if (this.globalClickHandler) {

                document.removeEventListener('click', this.globalClickHandler, { capture: true });

            }

            if (this.keyboardHandler) {

                document.removeEventListener('keydown', this.keyboardHandler);

            }

        }

    }

    function init() {

        new SearchHub();

    }

    if (document.readyState === 'complete') {

        init();

    } else {

        window.addEventListener('load', init);

    }

})();