快捷搜索引擎切换器:支持新增、删除、排序、位置自定义
// ==UserScript==
// @name Search Engine Switcher
// @namespace https://github.com/EchoRan6319/Search-Engine-Switcher
// @version 2.5.1
// @description 快捷搜索引擎切换器:支持新增、删除、排序、位置自定义
// @author EchoRan6319
// @license MIT
// @homepageURL https://github.com/EchoRan6319/Search-Engine-Switcher
// @supportURL https://github.com/EchoRan6319/Search-Engine-Switcher/issues
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
if (window !== window.top) return;
const STORAGE_KEY = 'search_engine_switcher_config_v1';
const VERSION_KEY = 'search_engine_switcher_version';
const CURRENT_VERSION = '2.5.1';
const STYLE_ID = 'search-engine-switcher-style';
const ROOT_ID = 'search-engine-switcher-root';
const PANEL_ID = 'search-engine-switcher-panel';
const DEFAULT_CONFIG = {
engines: [
// ========== 国外传统搜索引擎 ==========
{
id: 'google',
name: 'Google',
searchUrl: 'https://www.google.com/search?q={q}',
hosts: ['google.', 'www.google.'],
hidden: false
},
{
id: 'bing',
name: 'Bing',
searchUrl: 'https://www.bing.com/search?q={q}',
hosts: ['bing.com', 'www.bing.com', 'cn.bing.com'],
hidden: false
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
searchUrl: 'https://duckduckgo.com/?q={q}',
hosts: ['duckduckgo.com'],
hidden: false
},
{
id: 'brave',
name: 'Brave',
searchUrl: 'https://search.brave.com/search?q={q}',
hosts: ['search.brave.com'],
hidden: false
},
{
id: 'yandex',
name: 'Yandex',
searchUrl: 'https://yandex.com/search/?text={q}&from=browser',
hosts: ['yandex.'],
hidden: false
},
// ========== 国内传统搜索引擎 ==========
{
id: 'baidu',
name: '百度',
searchUrl: 'https://www.baidu.com/s?wd={q}',
hosts: ['baidu.com', 'www.baidu.com'],
hidden: false
},
{
id: 'sogou',
name: '搜狗',
searchUrl: 'https://www.sogou.com/web?query={q}',
hosts: ['sogou.com'],
hidden: true
},
{
id: '360',
name: '360搜索',
searchUrl: 'https://www.so.com/s?q={q}',
hosts: ['so.com'],
hidden: true
},
// ========== 国外AI大模型 ==========
{
id: 'chatgpt',
name: 'ChatGPT',
searchUrl: 'https://chatgpt.com/?hints=search&q={q}',
hosts: ['chatgpt.com'],
hidden: true
},
{
id: 'gemini',
name: 'Gemini',
searchUrl: 'https://gemini.google.com/app?q={q}',
hosts: ['gemini.google.com'],
hidden: true
},
{
id: 'perplexity',
name: 'Perplexity',
searchUrl: 'https://www.perplexity.ai/?q={q}',
hosts: ['perplexity.ai'],
hidden: true
},
// ========== 国内AI大模型 ==========
{
id: 'qianwen',
name: '千问',
searchUrl: 'https://www.qianwen.com/?q={q}',
hosts: ['qianwen.com', 'www.qianwen.com'],
hidden: true
},
{
id: 'doubao',
name: '豆包',
searchUrl: 'https://www.doubao.com/chat/?q={q}',
hosts: ['doubao.com'],
hidden: true
},
{
id: 'deepseek',
name: 'DeepSeek',
searchUrl: 'https://chat.deepseek.com/?q={q}',
hosts: ['chat.deepseek.com'],
hidden: true
},
{
id: 'kimi',
name: 'Kimi',
searchUrl: 'https://kimi.moonshot.cn/?q={q}',
hosts: ['kimi.moonshot.cn'],
hidden: true
},
{
id: 'metaso',
name: '秘塔AI',
searchUrl: 'https://metaso.cn/?q={q}',
hosts: ['metaso.cn'],
hidden: true
},
// ========== 国外社交/社区 ==========
{
id: 'youtube',
name: 'YouTube',
searchUrl: 'https://www.youtube.com/results?search_query={q}',
hosts: ['youtube.com', 'm.youtube.com'],
hidden: true,
disableWidget: true
},
{
id: 'github',
name: 'GitHub',
searchUrl: 'https://github.com/search?q={q}',
hosts: ['github.com'],
hidden: true,
disableWidget: true
},
// ========== 国内社交/社区 ==========
{
id: 'bilibili',
name: '哔哩哔哩',
searchUrl: 'https://search.bilibili.com/all?keyword={q}',
hosts: ['search.bilibili.com', 'bilibili.com', 'www.bilibili.com'],
hidden: true,
disableWidget: true
},
{
id: 'zhihu',
name: '知乎',
searchUrl: 'https://www.zhihu.com/search?q={q}',
hosts: ['zhihu.com', 'www.zhihu.com'],
hidden: true,
disableWidget: true
},
{
id: 'xiaohongshu',
name: '小红书',
searchUrl: 'https://www.xiaohongshu.com/search_result?keyword={q}',
hosts: ['xiaohongshu.com'],
hidden: true,
disableWidget: true
},
{
id: 'douyin',
name: '抖音',
searchUrl: 'https://www.douyin.com/search/{q}',
hosts: ['douyin.com', 'www.douyin.com'],
hidden: true,
disableWidget: true
},
{
id: 'weixin',
name: '微信',
searchUrl: 'https://weixin.sogou.com/weixin?type=2&s_from=input&query={q}',
hosts: ['weixin.sogou.com'],
hidden: true,
disableWidget: true
}
],
ui: {
vertical: 'bottom',
align: 'center',
offsetX: 16,
offsetY: 16,
useCustomXY: false,
customX: 16,
customY: 16,
showWhenNoQuery: false,
openInNewTab: false,
theme: 'auto'
}
};
const safeGMGet = (key, fallback) => {
try {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
const v = localStorage.getItem(key);
return v == null ? fallback : v;
} catch (_) {
return fallback;
}
};
const safeGMSet = (key, value) => {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
} else {
localStorage.setItem(key, value);
}
} catch (_) {
// ignore
}
};
const uid = () => `se_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const deepClone = (obj) => JSON.parse(JSON.stringify(obj));
function mergeConfig(raw) {
const cfg = deepClone(DEFAULT_CONFIG);
if (!raw || typeof raw !== 'object') return cfg;
if (Array.isArray(raw.engines) && raw.engines.length > 0) {
cfg.engines = raw.engines
.map((e) => ({
id: String(e.id || uid()),
name: String(e.name || '').trim(),
searchUrl: String(e.searchUrl || '').trim(),
hosts: Array.isArray(e.hosts) ? e.hosts.map((h) => String(h).trim()).filter(Boolean) : [],
hidden: !!e.hidden,
disableWidget: !!e.disableWidget
}))
.filter((e) => e.name && e.searchUrl.includes('{q}'));
if (cfg.engines.length === 0) cfg.engines = deepClone(DEFAULT_CONFIG.engines);
}
if (raw.ui && typeof raw.ui === 'object') {
cfg.ui.vertical = raw.ui.vertical === 'top' ? 'top' : 'bottom';
cfg.ui.align = ['left', 'center', 'right'].includes(raw.ui.align) ? raw.ui.align : 'center';
cfg.ui.offsetX = Number.isFinite(raw.ui.offsetX) ? raw.ui.offsetX : cfg.ui.offsetX;
cfg.ui.offsetY = Number.isFinite(raw.ui.offsetY) ? raw.ui.offsetY : cfg.ui.offsetY;
cfg.ui.useCustomXY = !!raw.ui.useCustomXY;
cfg.ui.customX = Number.isFinite(raw.ui.customX) ? raw.ui.customX : cfg.ui.customX;
cfg.ui.customY = Number.isFinite(raw.ui.customY) ? raw.ui.customY : cfg.ui.customY;
cfg.ui.showWhenNoQuery = raw.ui.showWhenNoQuery !== false;
cfg.ui.openInNewTab = !!raw.ui.openInNewTab;
cfg.ui.theme = ['light', 'dark'].includes(raw.ui.theme) ? raw.ui.theme : 'auto';
}
return cfg;
}
function loadConfig() {
const raw = safeGMGet(STORAGE_KEY, '');
if (!raw) return deepClone(DEFAULT_CONFIG);
try {
return mergeConfig(JSON.parse(raw));
} catch (_) {
return deepClone(DEFAULT_CONFIG);
}
}
function saveConfig(cfg) {
safeGMSet(STORAGE_KEY, JSON.stringify(cfg));
}
const config = loadConfig();
function isLightTheme() {
const theme = config.ui.theme || 'auto';
if (theme === 'light') return true;
if (theme === 'dark') return false;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
}
function applyTheme() {
const theme = config.ui.theme || 'auto';
const root = document.documentElement;
if (theme === 'dark') {
root.removeAttribute('data-theme');
} else if (isLightTheme()) {
root.setAttribute('data-theme', 'light');
} else {
root.removeAttribute('data-theme');
}
}
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
:root {
--ses-bg-primary: rgba(16, 16, 16, 0.92);
--ses-bg-secondary: #101114;
--ses-bg-input: #151820;
--ses-bg-button: #4a4a4a;
--ses-text-primary: #fff;
--ses-text-secondary: #f3f3f3;
--ses-text-muted: #9fa6b2;
--ses-text-label: #aeb6c2;
--ses-border-color: rgba(255, 255, 255, 0.12);
--ses-border-light: rgba(255, 255, 255, 0.14);
--ses-border-medium: rgba(255, 255, 255, 0.16);
--ses-border-pill: rgba(255, 255, 255, 0.2);
--ses-shadow: rgba(0, 0, 0, 0.32);
--ses-shadow-panel: rgba(0, 0, 0, 0.35);
--ses-overlay: rgba(0, 0, 0, 0.45);
--ses-active-border: #7ea1ff;
--ses-active-shadow: rgba(126, 161, 255, 0.35);
--ses-primary-bg: #2e5fff;
--ses-danger-bg: #6b2026;
--ses-danger-border: #9f3540;
}
[data-theme="light"] {
color-scheme: light;
--ses-bg-primary: rgba(255, 255, 255, 0.95);
--ses-bg-secondary: #f5f5f7;
--ses-bg-input: #ffffff;
--ses-bg-button: #e8e8ed;
--ses-text-primary: #1c1c1e;
--ses-text-secondary: #2c2c2e;
--ses-text-muted: #6c6c70;
--ses-text-label: #3a3a3c;
--ses-border-color: rgba(0, 0, 0, 0.1);
--ses-border-light: rgba(0, 0, 0, 0.12);
--ses-border-medium: rgba(0, 0, 0, 0.15);
--ses-border-pill: rgba(0, 0, 0, 0.15);
--ses-shadow: rgba(0, 0, 0, 0.15);
--ses-shadow-panel: rgba(0, 0, 0, 0.2);
--ses-overlay: rgba(0, 0, 0, 0.35);
--ses-active-border: #007aff;
--ses-active-shadow: rgba(0, 122, 255, 0.3);
--ses-primary-bg: #007aff;
--ses-danger-bg: #ff3b30;
--ses-danger-border: #ff3b30;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
color-scheme: light;
--ses-bg-primary: rgba(255, 255, 255, 0.95);
--ses-bg-secondary: #f5f5f7;
--ses-bg-input: #ffffff;
--ses-bg-button: #e8e8ed;
--ses-text-primary: #1c1c1e;
--ses-text-secondary: #2c2c2e;
--ses-text-muted: #6c6c70;
--ses-text-label: #3a3a3c;
--ses-border-color: rgba(0, 0, 0, 0.1);
--ses-border-light: rgba(0, 0, 0, 0.12);
--ses-border-medium: rgba(0, 0, 0, 0.15);
--ses-border-pill: rgba(0, 0, 0, 0.15);
--ses-shadow: rgba(0, 0, 0, 0.15);
--ses-shadow-panel: rgba(0, 0, 0, 0.2);
--ses-overlay: rgba(0, 0, 0, 0.35);
--ses-active-border: #007aff;
--ses-active-shadow: rgba(0, 122, 255, 0.3);
--ses-primary-bg: #007aff;
--ses-danger-bg: #ff3b30;
--ses-danger-border: #ff3b30;
}
}
#${ROOT_ID} {
position: fixed;
z-index: 2147483646;
max-width: min(96vw, 860px);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
user-select: none;
box-sizing: border-box;
forced-color-adjust: none;
}
#${ROOT_ID}.hidden {
display: none;
}
#${ROOT_ID} .ses-wrap {
display: flex;
align-items: center;
gap: 8px;
background: var(--ses-bg-primary);
border: 1px solid var(--ses-border-color);
border-radius: 16px;
padding: 8px;
box-shadow: 0 8px 26px var(--ses-shadow);
backdrop-filter: blur(6px);
width: 100%;
box-sizing: border-box;
}
#${ROOT_ID} .ses-list {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex: 1;
mask-image: linear-gradient(to right, transparent 0%, #000 10px, #000 calc(100% - 10px), transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, #000 10px, #000 calc(100% - 10px), transparent 100%);
padding-right: 10px;
padding-left: 10px;
}
#${ROOT_ID} .ses-list::-webkit-scrollbar {
display: none;
}
#${ROOT_ID} .ses-pill,
#${ROOT_ID} .ses-btn {
border: 1px solid var(--ses-border-pill);
background: var(--ses-bg-button);
color: var(--ses-text-primary);
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
#${ROOT_ID} .ses-pill.active {
/* 激活描边由 JS inline style 控制,以防止 Dark Reader 篡改 */
}
#${ROOT_ID} .ses-btn {
width: 34px;
min-width: 34px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
#${PANEL_ID} {
position: fixed;
inset: 0;
z-index: 2147483647;
background: var(--ses-overlay);
display: none;
align-items: center;
justify-content: center;
}
#${PANEL_ID}.show {
display: flex;
}
#${PANEL_ID} .panel {
width: min(96vw, 720px);
max-height: 90vh;
overflow: auto;
overscroll-behavior: contain;
background: var(--ses-bg-secondary);
color: var(--ses-text-secondary);
border-radius: 14px;
border: 1px solid var(--ses-border-light);
box-shadow: 0 18px 48px var(--ses-shadow-panel);
padding: 14px;
font-size: 14px;
}
#${PANEL_ID} .panel h3 {
margin: 0 0 10px 0;
font-size: 16px;
}
#${PANEL_ID} .sub {
margin: 12px 0 8px;
font-weight: 600;
}
#${PANEL_ID} .engine-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
padding: 8px;
border: 1px solid var(--ses-border-color);
border-radius: 10px;
margin-bottom: 8px;
}
#${PANEL_ID} .engine-row.hidden-engine {
opacity: 0.6;
background: var(--ses-bg-button);
}
#${PANEL_ID} .muted {
color: var(--ses-text-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${PANEL_ID} .ops {
display: flex;
gap: 6px;
}
#${PANEL_ID} button,
#${PANEL_ID} input,
#${PANEL_ID} select,
#${PANEL_ID} textarea {
font: inherit;
}
#${PANEL_ID} .op,
#${PANEL_ID} .primary,
#${PANEL_ID} .danger,
#${PANEL_ID} .ghost {
border-radius: 8px;
border: 1px solid var(--ses-border-medium);
background: var(--ses-bg-button);
color: var(--ses-text-primary);
padding: 6px 10px;
cursor: pointer;
}
#${PANEL_ID} .primary {
background: var(--ses-primary-bg);
border-color: var(--ses-primary-bg);
color: #fff;
}
#${PANEL_ID} .danger {
background: var(--ses-danger-bg);
border-color: var(--ses-danger-border);
color: #fff;
}
#${PANEL_ID} .ghost {
background: transparent;
}
#${PANEL_ID} .grid2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
#${PANEL_ID} .form {
border: 1px solid var(--ses-border-color);
border-radius: 10px;
padding: 10px;
margin-top: 8px;
}
#${PANEL_ID} label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: var(--ses-text-label);
}
#${PANEL_ID} input,
#${PANEL_ID} select {
width: 100%;
box-sizing: border-box;
background: var(--ses-bg-input);
border: 1px solid var(--ses-border-light);
color: var(--ses-text-primary);
border-radius: 8px;
padding: 6px 8px;
}
#${PANEL_ID} .panel-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
@media (max-width: 560px) {
#${ROOT_ID} {
max-width: 98vw;
}
#${ROOT_ID} .ses-pill {
font-size: 12px;
padding: 6px 10px;
}
#${PANEL_ID} .engine-row {
display: flex;
flex-direction: column;
}
#${PANEL_ID} .ops {
justify-content: flex-start;
}
#${PANEL_ID} .op,
#${PANEL_ID} .danger {
padding: 7px 10px;
min-width: 52px;
text-align: center;
}
#${PANEL_ID} .grid2 {
grid-template-columns: 1fr;
}
}
`;
document.documentElement.appendChild(style);
}
function getCurrentQuery() {
const url = new URL(location.href);
const keys = ['q', 'wd', 'word', 'query', 'text', 'keyword', 'search', 'p', 'k'];
for (const key of keys) {
const v = url.searchParams.get(key);
if (v && v.trim()) return v.trim();
}
if (url.hash.includes('=')) {
const hashText = url.hash.replace(/^#/, '');
const hashParams = new URLSearchParams(hashText);
for (const key of keys) {
const v = hashParams.get(key);
if (v && v.trim()) return v.trim();
}
}
const sel = String(window.getSelection && window.getSelection()).trim();
if (sel) return sel;
const focused = document.activeElement;
if (focused && focused.tagName === 'INPUT') {
const input = focused;
const v = typeof input.value === 'string' ? input.value.trim() : '';
if (v) return v;
}
return '';
}
function isMatchHost(host, h) {
if (h.endsWith('.')) {
return host === h.slice(0, -1) || host.startsWith(h) || host.includes('.' + h);
}
return host === h || host.endsWith('.' + h);
}
function activeEngineIdByHost() {
const host = location.hostname;
const exact = config.engines.find((e) => (e.hosts || []).some((h) => isMatchHost(host, h)));
return exact ? exact.id : '';
}
function buildSearchUrl(engine, query) {
return engine.searchUrl.replace('{q}', encodeURIComponent(query));
}
async function resolveQuery() {
let q = getCurrentQuery();
if (q) return q.trim();
q = prompt('输入搜索关键词');
return q ? q.trim() : '';
}
function applyRootPosition(root) {
const ui = config.ui;
root.style.left = '';
root.style.right = '';
root.style.top = '';
root.style.bottom = '';
root.style.transform = '';
if (ui.useCustomXY) {
root.style.left = `${Math.max(0, ui.customX)}px`;
root.style.top = `${Math.max(0, ui.customY)}px`;
return;
}
// 垂直位置
if (ui.vertical === 'top') {
root.style.top = `${Math.max(0, ui.offsetY)}px`;
} else {
root.style.bottom = `${Math.max(0, ui.offsetY)}px`;
}
// 水平位置:默认居中
if (ui.align === 'left') {
root.style.left = `${Math.max(0, ui.offsetX)}px`;
} else if (ui.align === 'right') {
root.style.right = `${Math.max(0, ui.offsetX)}px`;
} else {
// 居中(默认)
root.style.left = '50%';
root.style.transform = 'translateX(-50%)';
}
}
function createRoot() {
let root = document.getElementById(ROOT_ID);
if (root) return root;
root = document.createElement('div');
root.id = ROOT_ID;
root.innerHTML = `
<div class="ses-wrap">
<div class="ses-list" id="${ROOT_ID}-list"></div>
<button class="ses-btn" id="${ROOT_ID}-settings" title="设置">⚙</button>
</div>
`;
document.documentElement.appendChild(root);
applyRootPosition(root);
const list = root.querySelector(`#${ROOT_ID}-list`);
// 支持鼠标滚轮横向滚动(针对桌面端)
list.addEventListener('wheel', (e) => {
if (e.deltaY !== 0) {
e.preventDefault();
list.scrollLeft += e.deltaY;
}
}, { passive: false });
const settingsBtn = root.querySelector(`#${ROOT_ID}-settings`);
settingsBtn.addEventListener('click', () => openPanel());
let holdTimer = null;
root.addEventListener('pointerdown', (e) => {
if (e.target && e.target.closest('.ses-pill')) return;
holdTimer = setTimeout(() => openPanel(), 500);
});
root.addEventListener('pointerup', () => {
if (holdTimer) clearTimeout(holdTimer);
holdTimer = null;
});
root.addEventListener('pointerleave', () => {
if (holdTimer) clearTimeout(holdTimer);
holdTimer = null;
});
return root;
}
function renderEngineButtons() {
const root = createRoot();
const list = root.querySelector(`#${ROOT_ID}-list`);
list.innerHTML = '';
// 只在未禁用的搜索引擎页面上显示
const host = location.hostname;
const currentEngine = config.engines.find((e) => (e.hosts || []).some((h) => isMatchHost(host, h)));
if (!currentEngine || currentEngine.disableWidget) {
root.classList.add('hidden');
return;
}
const q = getCurrentQuery();
// 无关键词时根据设置决定是否显示
if (!q && !config.ui.showWhenNoQuery) {
root.classList.add('hidden');
return;
}
const activeId = activeEngineIdByHost();
root.classList.remove('hidden');
for (const engine of config.engines) {
if (engine.hidden) continue;
const btn = document.createElement('button');
btn.className = 'ses-pill';
if (engine.id === activeId) {
btn.classList.add('active');
// 用 inline style 强制设置描边,绕过 Dark Reader 对 CSS 规则的修改
const light = isLightTheme();
const borderColor = light ? '#007aff' : '#7ea1ff';
const shadowColor = light ? 'rgba(0, 122, 255, 0.3)' : 'rgba(126, 161, 255, 0.35)';
btn.style.setProperty('border-color', borderColor, 'important');
btn.style.setProperty('box-shadow', `0 0 0 1px ${shadowColor} inset`, 'important');
}
btn.textContent = engine.name;
btn.title = `${engine.name}\n${engine.searchUrl}`;
const handleAction = async (openInNewTabOverride) => {
const query = await resolveQuery();
if (!query) return;
const url = buildSearchUrl(engine, query);
const shouldOpenNewTab = openInNewTabOverride !== undefined ? openInNewTabOverride : config.ui.openInNewTab;
if (shouldOpenNewTab) {
window.open(url, '_blank');
} else {
location.href = url;
}
};
btn.addEventListener('click', (e) => {
if (e.button === 0) { // 左键
handleAction();
}
});
// 阻止中键默认的自动滚动行为
btn.addEventListener('mousedown', (e) => {
if (e.button === 1) {
e.preventDefault();
}
});
btn.addEventListener('mouseup', (e) => {
if (e.button === 1) { // 中键
e.preventDefault();
handleAction(true); // 强制新标签页打开
}
});
list.appendChild(btn);
}
}
function createPanel() {
let overlay = document.getElementById(PANEL_ID);
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.id = PANEL_ID;
overlay.innerHTML = `<div class="panel"></div>`;
document.documentElement.appendChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closePanel();
});
return overlay;
}
function editEngine(existing) {
const name = prompt('搜索引擎名称', existing ? existing.name : '');
if (name == null) return;
const searchUrl = prompt('搜索 URL(必须包含 {q})', existing ? existing.searchUrl : 'https://example.com/search?q={q}');
if (searchUrl == null) return;
if (!searchUrl.includes('{q}')) {
alert('URL 必须包含 {q} 占位符');
return;
}
const hostsRaw = prompt('识别当前引擎的域名(可选,逗号分隔)', existing ? (existing.hosts || []).join(',') : '');
if (hostsRaw == null) return;
const hosts = hostsRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (existing) {
existing.name = name.trim() || existing.name;
existing.searchUrl = searchUrl.trim();
existing.hosts = hosts;
} else {
config.engines.push({
id: uid(),
name: name.trim() || '未命名',
searchUrl: searchUrl.trim(),
hosts
});
}
saveConfig(config);
renderPanel();
renderEngineButtons();
}
function moveEngine(index, delta) {
const target = index + delta;
if (target < 0 || target >= config.engines.length) return;
const tmp = config.engines[index];
config.engines[index] = config.engines[target];
config.engines[target] = tmp;
saveConfig(config);
renderPanel();
renderEngineButtons();
}
function deleteEngine(index) {
if (config.engines.length <= 1) {
alert('至少保留一个搜索引擎');
return;
}
const item = config.engines[index];
const ok = confirm(`确定删除 ${item.name} 吗?`);
if (!ok) return;
config.engines.splice(index, 1);
saveConfig(config);
renderPanel();
renderEngineButtons();
}
function toggleEngineVisibility(index) {
const engine = config.engines[index];
if (!engine) return;
engine.hidden = !engine.hidden;
saveConfig(config);
renderPanel();
renderEngineButtons();
}
function toggleWidgetVisibility(index) {
const engine = config.engines[index];
if (!engine) return;
engine.disableWidget = !engine.disableWidget;
saveConfig(config);
renderPanel();
renderEngineButtons();
}
function exportConfig() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(config, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "search-engine-switcher-config.json");
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
function importConfig() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = event => {
try {
const importedData = JSON.parse(event.target.result);
const mergedConfig = mergeConfig(importedData);
if (!confirm('确定要覆盖当前配置吗?此操作不可逆!')) return;
Object.assign(config, mergedConfig);
saveConfig(config);
applyRootPosition(createRoot());
applyTheme();
renderEngineButtons();
renderPanel();
alert('配置导入成功!');
} catch (error) {
alert('配置导入失败:无效的 JSON 格式或数据结构。');
console.error('Import Config Error:', error);
}
};
reader.readAsText(file);
};
input.click();
}
function startDragPositioning() {
const root = createRoot();
config.ui.useCustomXY = true;
saveConfig(config);
applyRootPosition(root);
alert('拖动切换器到你想要的位置,松开后自动保存');
root.style.cursor = 'move';
root.style.touchAction = 'none';
let dragging = false;
let startX = 0;
let startY = 0;
let originX = config.ui.customX;
let originY = config.ui.customY;
function onDown(e) {
if (e.target && e.target.closest('.ses-pill')) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
originX = config.ui.customX;
originY = config.ui.customY;
if (e.target.setPointerCapture) {
e.target.setPointerCapture(e.pointerId);
}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
config.ui.customX = Math.max(0, originX + (e.clientX - startX));
config.ui.customY = Math.max(0, originY + (e.clientY - startY));
applyRootPosition(root);
}
function onUp(e) {
dragging = false;
saveConfig(config);
renderPanel();
root.style.cursor = '';
root.style.touchAction = '';
if (e && e.target && e.target.releasePointerCapture) {
try { e.target.releasePointerCapture(e.pointerId); } catch (_) { }
}
root.removeEventListener('pointerdown', onDown);
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
}
root.addEventListener('pointerdown', onDown);
}
function renderPanel() {
const overlay = createPanel();
const panel = overlay.querySelector('.panel');
const engineRows = config.engines
.map(
(e, i) => `
<div class="engine-row ${e.hidden ? 'hidden-engine' : ''}" data-index="${i}">
<div>
<div><strong>${escapeHtml(e.name)}${e.hidden ? ' <span class="muted">(按钮已隐藏)</span>' : ''}${e.disableWidget ? ' <span class="muted" style="color:var(--ses-danger-border);">(本站禁用悬浮)</span>' : ''}</strong></div>
<div class="muted">${escapeHtml(e.searchUrl)}</div>
<div class="muted">识别域名: ${escapeHtml((e.hosts || []).join(', ') || '(空)')}</div>
</div>
<div class="ops" style="flex-wrap: wrap;">
<button class="op" data-act="up">↑</button>
<button class="op" data-act="down">↓</button>
<button class="op" data-act="edit">编辑</button>
<button class="op" data-act="${e.hidden ? 'show' : 'hide'}" title="在切换器面板中显示或隐藏该按钮">${e.hidden ? '显示按钮' : '隐藏按钮'}</button>
<button class="op" data-act="toggle-widget" title="是否在该搜索引擎的网页中注入并显示悬浮控件">${e.disableWidget ? '启用悬浮' : '禁用悬浮'}</button>
<button class="danger" data-act="del">删除</button>
</div>
</div>
`
)
.join('');
panel.innerHTML = `
<h3>搜索引擎切换器设置</h3>
<div class="sub" style="display:flex; justify-content:space-between; align-items:center;">
<span>引擎列表(支持新增 / 删除 / 排序 / 隐藏)</span>
<div style="display:flex; gap:8px;">
<button class="primary" id="ses-show-guide" style="padding:4px 8px; font-size:12px;">使用指南</button>
<button class="ghost" id="ses-reset-engines" style="padding:4px 8px; font-size:12px;">恢复默认</button>
</div>
</div>
<div>${engineRows}</div>
<div style="margin-top:8px; display:flex; gap:8px;">
<button class="primary" id="ses-add">新增搜索引擎</button>
<button class="ghost" id="ses-export">导出配置</button>
<button class="ghost" id="ses-import">导入配置</button>
</div>
<div class="sub" style="display:flex; justify-content:space-between; align-items:center;">
<span>显示位置</span>
<button class="ghost" id="ses-reset-position" style="padding:4px 8px; font-size:12px;">恢复默认</button>
</div>
<div class="grid2">
<div>
<label>定位模式</label>
<select id="ses-pos-mode">
<option value="preset" ${!config.ui.useCustomXY ? 'selected' : ''}>预设位置</option>
<option value="custom" ${config.ui.useCustomXY ? 'selected' : ''}>自定义坐标</option>
</select>
</div>
<div>
<label>垂直位置</label>
<select id="ses-vertical">
<option value="bottom" ${config.ui.vertical === 'bottom' ? 'selected' : ''}>底部</option>
<option value="top" ${config.ui.vertical === 'top' ? 'selected' : ''}>顶部</option>
</select>
</div>
<div>
<label>水平对齐</label>
<select id="ses-align">
<option value="left" ${config.ui.align === 'left' ? 'selected' : ''}>左</option>
<option value="center" ${config.ui.align === 'center' ? 'selected' : ''}>中</option>
<option value="right" ${config.ui.align === 'right' ? 'selected' : ''}>右</option>
</select>
</div>
<div>
<label>水平偏移(px)</label>
<input type="number" id="ses-offset-x" value="${Number(config.ui.offsetX) || 0}" min="0" step="1" />
</div>
<div>
<label>垂直偏移(px)</label>
<input type="number" id="ses-offset-y" value="${Number(config.ui.offsetY) || 0}" min="0" step="1" />
</div>
<div>
<label>自定义 X(px)</label>
<input type="number" id="ses-custom-x" value="${Number(config.ui.customX) || 0}" min="0" step="1" />
</div>
<div>
<label>自定义 Y(px)</label>
<input type="number" id="ses-custom-y" value="${Number(config.ui.customY) || 0}" min="0" step="1" />
</div>
</div>
<div style="margin-top:8px; display:flex; gap:8px; flex-wrap:wrap;">
<button class="op" id="ses-drag">拖拽定位</button>
</div>
<div class="sub" style="display:flex; justify-content:space-between; align-items:center;">
<span>行为选项</span>
<button class="ghost" id="ses-reset-behavior" style="padding:4px 8px; font-size:12px;">恢复默认</button>
</div>
<div class="grid2">
<div>
<label>无关键词时</label>
<select id="ses-show-no-query">
<option value="1" ${config.ui.showWhenNoQuery ? 'selected' : ''}>仍然显示切换器</option>
<option value="0" ${!config.ui.showWhenNoQuery ? 'selected' : ''}>隐藏切换器</option>
</select>
</div>
<div>
<label>打开方式</label>
<select id="ses-open-way">
<option value="0" ${!config.ui.openInNewTab ? 'selected' : ''}>当前标签页</option>
<option value="1" ${config.ui.openInNewTab ? 'selected' : ''}>新标签页</option>
</select>
</div>
<div>
<label>主题模式</label>
<select id="ses-theme">
<option value="auto" ${config.ui.theme === 'auto' ? 'selected' : ''}>跟随系统</option>
<option value="light" ${config.ui.theme === 'light' ? 'selected' : ''}>浅色</option>
<option value="dark" ${config.ui.theme === 'dark' ? 'selected' : ''}>深色</option>
</select>
</div>
</div>
<div class="panel-footer">
<button class="ghost" id="ses-close">关闭</button>
<button class="primary" id="ses-save">保存设置</button>
</div>
`;
panel.querySelectorAll('.engine-row').forEach((row) => {
row.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const actionEl = target.closest('[data-act]');
if (!actionEl) return;
const index = Number(row.getAttribute('data-index'));
const act = actionEl.getAttribute('data-act');
if (act === 'up') moveEngine(index, -1);
if (act === 'down') moveEngine(index, 1);
if (act === 'edit') editEngine(config.engines[index]);
if (act === 'del') deleteEngine(index);
if (act === 'hide' || act === 'show') toggleEngineVisibility(index);
if (act === 'toggle-widget') toggleWidgetVisibility(index);
});
});
panel.querySelector('#ses-add').addEventListener('click', () => editEngine(null));
panel.querySelector('#ses-export').addEventListener('click', exportConfig);
panel.querySelector('#ses-import').addEventListener('click', importConfig);
panel.querySelector('#ses-show-guide').addEventListener('click', () => showOfflineGuide());
// 恢复默认 - 引擎列表
panel.querySelector('#ses-reset-engines').addEventListener('click', () => {
if (!confirm('确定恢复默认搜索引擎列表吗?')) return;
const reset = deepClone(DEFAULT_CONFIG);
config.engines = reset.engines;
saveConfig(config);
renderPanel();
renderEngineButtons();
});
// 恢复默认 - 显示位置
panel.querySelector('#ses-reset-position').addEventListener('click', () => {
if (!confirm('确定恢复默认显示位置吗?')) return;
const reset = deepClone(DEFAULT_CONFIG);
config.ui.vertical = reset.ui.vertical;
config.ui.align = reset.ui.align;
config.ui.offsetX = reset.ui.offsetX;
config.ui.offsetY = reset.ui.offsetY;
config.ui.useCustomXY = reset.ui.useCustomXY;
config.ui.customX = reset.ui.customX;
config.ui.customY = reset.ui.customY;
saveConfig(config);
applyRootPosition(createRoot());
renderPanel();
});
// 恢复默认 - 行为选项
panel.querySelector('#ses-reset-behavior').addEventListener('click', () => {
if (!confirm('确定恢复默认行为选项吗?')) return;
const reset = deepClone(DEFAULT_CONFIG);
config.ui.showWhenNoQuery = reset.ui.showWhenNoQuery;
config.ui.openInNewTab = reset.ui.openInNewTab;
config.ui.theme = reset.ui.theme;
saveConfig(config);
applyTheme();
renderPanel();
});
panel.querySelector('#ses-drag').addEventListener('click', () => {
closePanel();
startDragPositioning();
});
panel.querySelector('#ses-close').addEventListener('click', closePanel);
panel.querySelector('#ses-save').addEventListener('click', () => {
const posMode = panel.querySelector('#ses-pos-mode').value;
const vertical = panel.querySelector('#ses-vertical').value;
const align = panel.querySelector('#ses-align').value;
const offsetX = Number(panel.querySelector('#ses-offset-x').value) || 0;
const offsetY = Number(panel.querySelector('#ses-offset-y').value) || 0;
const customX = Number(panel.querySelector('#ses-custom-x').value) || 0;
const customY = Number(panel.querySelector('#ses-custom-y').value) || 0;
const showNoQuery = panel.querySelector('#ses-show-no-query').value === '1';
const openInNewTab = panel.querySelector('#ses-open-way').value === '1';
const theme = panel.querySelector('#ses-theme').value;
config.ui.useCustomXY = posMode === 'custom';
config.ui.vertical = vertical === 'top' ? 'top' : 'bottom';
config.ui.theme = ['light', 'dark'].includes(theme) ? theme : 'auto';
config.ui.align = ['left', 'center', 'right'].includes(align) ? align : 'center';
config.ui.offsetX = Math.max(0, offsetX);
config.ui.offsetY = Math.max(0, offsetY);
config.ui.customX = Math.max(0, customX);
config.ui.customY = Math.max(0, customY);
config.ui.showWhenNoQuery = showNoQuery;
config.ui.openInNewTab = openInNewTab;
saveConfig(config);
applyRootPosition(createRoot());
applyTheme();
renderEngineButtons();
closePanel();
});
}
function openPanel() {
renderPanel();
const overlay = createPanel();
overlay.classList.add('show');
// 阻止面板滚动影响下方网页
const panel = overlay.querySelector('.panel');
if (panel) {
panel.addEventListener('wheel', (e) => {
const isScrollingUp = e.deltaY < 0;
const isScrollingDown = e.deltaY > 0;
const isAtTop = panel.scrollTop === 0;
const isAtBottom = panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 1;
// 如果在顶部继续向上滚动,或在底部继续向下滚动,阻止事件
if ((isScrollingUp && isAtTop) || (isScrollingDown && isAtBottom)) {
e.preventDefault();
}
}, { passive: false });
// 阻止 touchmove 事件冒泡
panel.addEventListener('touchmove', (e) => {
e.stopPropagation();
}, { passive: true });
}
}
function closePanel() {
const overlay = createPanel();
overlay.classList.remove('show');
}
function showOfflineGuide() {
const guideHtml = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search Engine Switcher 使用指南</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #fff;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
h1 { color: #2e5fff; border-bottom: 2px solid #eee; padding-bottom: 10px; }
h2 { color: #444; margin-top: 30px; }
ul, ol { padding-left: 20px; }
li { margin-bottom: 10px; }
.highlight { background-color: #ffeeba; padding: 2px 6px; border-radius: 4px; font-weight: bold; }
.btn {
display: inline-block;
background-color: #2e5fff;
color: #fff;
padding: 10px 20px;
text-decoration: none;
border-radius: 8px;
margin-top: 20px;
text-align: center;
}
.btn:hover { background-color: #1c45d8; }
</style>
</head>
<body>
<div class="container">
<h1>🎉 欢迎使用 Search Engine Switcher (v${CURRENT_VERSION})</h1>
<p>感谢您安装本脚本!为了让您更好地使用,请花 1 分钟阅读以下指南:</p>
<h2>👀 悬浮窗去哪了?为什么不显示?</h2>
<p>为了给您提供纯净的排版体验并避免遮挡内容,本脚本的默认机制是:<span class="highlight" style="color:#d9534f;">在未检测到“搜索需求”时,自动隐藏自身。</span></p>
<p><strong>具体在以下几种情况下,悬浮窗默认会“消失”:</strong></p>
<ul>
<li><strong>浏览非引擎的普通网页</strong>:如新闻、博客、百度百科等不在您列表范围内的页面,悬浮窗绝不会多余出现。</li>
<li><strong>停留在搜索引擎的首页大厅</strong>:当您刚打开 <strong>GitHub、百度、Google 的主页</strong>时,由于您还没有真正输入回车发起搜索,网页网址里并没有“搜索参数”,插件认为此时任务未开始,所以选择保持隐藏。</li>
</ul>
<p><strong>什么情况下它会自动“呼出”?</strong></p>
<ul>
<li><strong>进入了实际的搜索结果页</strong>:只要您提交了搜索(网址栏能看到带有 <code>q=</code> 或 <code>wd=</code> 等请求参数),它就会马上出现在页面底部供您随时切换。</li>
<li><strong>用鼠标选中了想搜索的文本</strong>:此时插件检测到你在“划词”,悬浮窗立刻现身让您一键带词去别的引擎查找。</li>
<li><strong>您的光标在输入框里并敲了字</strong>:此时也等同于有了即将搜索的意图,它也会随时待命弹起。</li>
</ul>
<div style="background-color: #f0f7ff; border-left: 4px solid #2e5fff; padding: 12px; margin-top: 16px; border-radius: 4px;">
<strong style="color: #2e5fff; display: block; margin-bottom: 6px;">💡 觉得这样太麻烦?想要让它在 GitHub 主页也一直保持可见?</strong>
如果你希望在主页发呆、或者未搜任何词的情况下也<strong>始终能看到这排按钮</strong>,请按以下步骤操作:
<ol style="margin-bottom: 0;">
<li>长按一次现有的悬浮窗(或点击小齿轮 ⚙ 图标)唤出设置面板</li>
<li>向下划动,找到底部的<strong>【行为选项】</strong>分类</li>
<li>将<strong>【无关键词时】</strong>选项的设定从“隐藏切换器”改为<strong>“仍然显示切换器”</strong></li>
<li>点击右下角保存设置,它就不会再随意消失了!</li>
</ol>
</div>
<h2>🖱️ 基础操作</h2>
<ul>
<li><strong>左键点击:</strong> 直接在当前页面(或根据设置在新标签页)切换到目标搜索引擎。</li>
<li><strong>鼠标中键点击:</strong> 强制在<span class="highlight">新标签页</span>中打开搜索结果。</li>
<li><strong>长按悬浮窗 (或点击 ⚙ 按钮):</strong> 打开设置面板。</li>
</ul>
<h2>🚫 为什么 B站/YouTube 也会有悬浮窗?怎么关掉?</h2>
<p>脚本预设了一些社交/视频类搜索(如 B站、YouTube、知乎)。如果在刷视频时悬浮窗影响了您的体验,您可以:</p>
<ol>
<li>打开脚本的<strong>设置面板</strong>。</li>
<li>在引擎列表中找到对应的网站(例如“哔哩哔哩”)。</li>
<li>点击列表右侧的 <strong>“禁用悬浮”</strong> 按钮(如果已经是禁用状态则会显示“本站禁用悬浮”红字)。</li>
</ol>
<p>这样,该网站就不会再出现悬浮窗了!</p>
</div>
</body>
</html>
`;
const blob = new Blob([guideHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}
function checkFirstInstallOrUpdate() {
const lastVersion = safeGMGet(VERSION_KEY, null);
if (lastVersion !== CURRENT_VERSION) {
safeGMSet(VERSION_KEY, CURRENT_VERSION);
// 只在安装/更新后的第一次自动弹出
showOfflineGuide();
}
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function init() {
checkFirstInstallOrUpdate();
injectStyle();
applyTheme();
createRoot();
renderEngineButtons();
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('搜索引擎切换器设置', openPanel);
GM_registerMenuCommand('搜索引擎切换器开关', () => {
const root = createRoot();
root.classList.toggle('hidden');
});
}
window.addEventListener('popstate', renderEngineButtons);
window.addEventListener('hashchange', renderEngineButtons);
// Listen for system theme changes when in auto mode
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
mediaQuery.addEventListener('change', () => {
if (config.ui.theme === 'auto') {
applyTheme();
}
});
}
const observer = new MutationObserver(() => {
if (!document.getElementById(ROOT_ID)) {
createRoot();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
setInterval(renderEngineButtons, 1600);
}
init();
})();