// ==UserScript== // @name 🌐 搜索中心增强 // @name:en 🌐 Search Hub Enhancer // @namespace https://greasyfork.org/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 // @downloadURL none // ==/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = `

🌐 搜索引擎设置

${this.searchHub.engines.map((e, i) => `
`).join('')}
`; 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 => ` `).join('')} `; 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 = `
`; 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); } })();