// ==UserScript==
// @name 🌐 搜索中心增强
// @name:en 🌐 Search Hub Enhancer
// @namespace http://greasyfork.icu/zh-CN/users/1454800
// @version 1.0.3
// @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 CONFIG = {
STORAGE_KEY: 'search_hub_engines',
DEBOUNCE_MS: 600,
ANIMATION_MS: 300,
TOOLBAR_POSITION: 'bottom-center',
};
// 语言包
const i18n = {
'zh-CN': {
scriptName: '🌐 搜索中心增强',
scriptDescription: '快速切换搜索引擎的工具栏,可自定义引擎',
settingsTitle: '🌐 搜索引擎设置',
addButton: '添加',
saveButton: '保存',
closeButton: '关闭',
namePlaceholder: '名称',
urlPlaceholder: '包含 %s 的URL',
alertRequired: '名称和URL为必填项!',
alertUrlFormat: 'URL必须包含%s占位符!',
alertInvalidUrl: '无效的URL!',
alertMinEngines: '至少需要一个搜索引擎!',
alertNotSearchPage: '当前页面不是搜索页面,无法添加为搜索引擎!',
alertNoEngineConfig: '无法检测当前页面的搜索引擎配置!',
menuAddEngine: '🌐 添加当前页面为搜索引擎'
},
'en-US': {
scriptName: '🌐 Search Hub Enhancer',
scriptDescription: 'A toolbar for quickly switching search engines, with customizable engines',
settingsTitle: '🌐 Search Engine Settings',
addButton: 'Add',
saveButton: 'Save',
closeButton: 'Close',
namePlaceholder: 'Name',
urlPlaceholder: 'URL containing %s',
alertRequired: 'Name and URL are required!',
alertUrlFormat: 'URL must contain %s placeholder!',
alertInvalidUrl: 'Invalid URL!',
alertMinEngines: 'At least one search engine is required!',
alertNotSearchPage: 'This page is not a search page and cannot be added as a search engine!',
alertNoEngineConfig: 'Cannot detect the search engine configuration for this page!',
menuAddEngine: '🌐 Add Current Page as Search Engine'
}
};
// 获取系统语言
const getLanguage = () => {
const lang = navigator.language || navigator.userLanguage;
return lang.startsWith('zh') ? 'zh-CN' : 'en-US';
};
const lang = getLanguage();
// 全局 CSS
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)}`;
// Baidu 特殊处理
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>${i18n[lang].settingsTitle}</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="${i18n[lang].namePlaceholder}" 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="${i18n[lang].urlPlaceholder}" required>
</div>
`).join('')}
</div>
<div class="panel-actions">
<button class="panel-btn add-btn">${i18n[lang].addButton}</button>
<button class="panel-btn save-btn">${i18n[lang].saveButton}</button>
<button class="panel-btn close-btn">${i18n[lang].closeButton}</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 = lang === 'zh-CN' ? '新搜索引擎' : 'New Search Engine';
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(i18n[lang].alertRequired);
valid = false;
return;
}
if (!/%s/.test(url)) {
alert(i18n[lang].alertUrlFormat);
valid = false;
return;
}
try {
new URL(url.replace('%s', 'test'));
engines.push({ id: item.dataset.id, name, url });
} catch {
alert(i18n[lang].alertInvalidUrl);
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(i18n[lang].alertMinEngines);
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(i18n[lang].menuAddEngine, () => 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 = lang === 'zh-CN' ? '新搜索引擎' : 'New Search Engine', 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="${i18n[lang].namePlaceholder}" 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="${i18n[lang].urlPlaceholder}" required>
`;
list.appendChild(item);
return item;
}
addCurrentPageAsEngine() {
if (!SearchDetector.isSearchPage()) {
alert(i18n[lang].alertNotSearchPage);
return;
}
const engineConfig = SearchDetector.detectEngineConfig();
if (!engineConfig) {
alert(i18n[lang].alertNoEngineConfig);
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);
}
})();