// ==UserScript==
// @name 谷歌换搜
// @namespace https://t.me/aibiancheng
// @author 星宿老魔
// @license MIT
// @version 0.1
// @description 在 Google 搜索页面优化搜索栏,添加多站点搜索切换功能,导航栏悬浮且紧凑、可拖动,可自定义站点,支持与GitHub Gist同步,站点编辑框可调整大小并记忆。
// @match https://www.google.com/search?q=*site*
// @match https://www.google.com.hk/search?q=*site*
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @connect api.github.com
// ==/UserScript==
(function() {
'use strict';
// --- 常量与配置 ---
const DEBUG_MODE = false; // 调试模式开关,true为开启,false为关闭
const GM_GITHUB_TOKEN_KEY = 'googleSearchMultiSite_github_token'; // 油猴存储:GitHub个人访问令牌的键名
const GM_GIST_ID_KEY = 'googleSearchMultiSite_gist_id'; // 油猴存储:GitHub Gist ID的键名
const GIST_FILENAME = 'googleSearchMultiSite_sites-config.txt'; // Gist云端备份时使用的文件名
const LOCALSTORAGE_SITES_KEY = 'customSiteRowsText'; // 浏览器本地存储:自定义站点配置文本的键名
const LOCALSTORAGE_NAVBAR_POSITION_KEY = "navBarPosition"; // 浏览器本地存储:悬浮导航栏位置的键名
const LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY = 'googleSearchMultiSite_siteConfigTextareaWidth'; // 浏览器本地存储:站点配置编辑框宽度
const LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY = 'googleSearchMultiSite_siteConfigTextareaHeight'; // 浏览器本地存储:站点配置编辑框高度
// --- Gist凭据获取辅助函数 ---
// 从油猴存储中获取GitHub个人访问令牌
function getGitHubToken() {
return GM_getValue(GM_GITHUB_TOKEN_KEY, '');
}
// 从油猴存储中获取Gist ID
function getGistId() {
return GM_getValue(GM_GIST_ID_KEY, '');
}
// --- GM_addStyle 设置对话框样式 ---
// 为脚本中使用的对话框(如Gist参数设置、站点编辑)注入CSS样式
GM_addStyle(`
#googleSearchMultiSite-settings-dialog {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.6); z-index: 10000; /* 确保在顶层显示 */
display: flex; justify-content: center; align-items: center; font-family: sans-serif;
}
#googleSearchMultiSite-settings-dialog-content {
background: white; padding: 25px; border-radius: 8px;
box-shadow: 0 5px 20px rgba(0,0,0,0.3); width: 400px; max-width: 90%;
position: relative; /* 用于关闭按钮的绝对定位 */
}
#googleSearchMultiSite-settings-dialog-content h3 { margin-top: 0; margin-bottom: 20px; text-align: center; color: #333; font-size: 1.3em; }
#googleSearchMultiSite-settings-dialog-content label { display: block; margin-bottom: 5px; color: #555; font-size: 0.95em; }
#googleSearchMultiSite-settings-dialog-content input[type="text"], #googleSearchMultiSite-settings-dialog-content input[type="password"] {
width: 100%; padding: 10px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 0; /* <small>标签边距微调 */ font-size: 1em;
}
#googleSearchMultiSite-settings-dialog-content small { font-size:0.8em; color:#777; display:block; margin-top:4px; margin-bottom:12px; }
#googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons { text-align: right; margin-top: 15px; }
#googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons button { padding: 10px 18px; border-radius: 4px; border: none; cursor: pointer; font-size: 0.95em; transition: background-color 0.2s ease; }
#googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-cancel-btn { margin-right: 10px; background-color: #f0f0f0; color: #333; }
#googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-cancel-btn:hover { background-color: #e0e0e0; }
#googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-save-btn { background-color: #4CAF50; color: white; }
#googleSearchMultiSite-settings-dialog-content .rw-dialog-buttons .rw-save-btn:hover { background-color: #45a049; }
#googleSearchMultiSite-settings-close-btn { position: absolute; top: 10px; right: 10px; font-size: 1.5em; color: #aaa; cursor: pointer; background: none; border: none; padding: 5px; line-height: 1; }
#googleSearchMultiSite-settings-close-btn:hover { color: #777; }
`);
// --- UI 通知 ---
// 在页面底部显示一个短暂的通知消息(例如成功、错误、警告等)
function showNotification(message, type = 'info', duration = 3000) {
const notificationId = 'userscript-notification-' + Date.now();
const notificationDiv = document.createElement('div');
notificationDiv.id = notificationId;
notificationDiv.textContent = message;
Object.assign(notificationDiv.style, {
position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)',
padding: '10px 20px', borderRadius: '5px', color: 'white', zIndex: '10001', // 确保在设置对话框之上
boxShadow: '0 2px 10px rgba(0,0,0,0.2)', opacity: '0', transition: 'opacity 0.5s ease-in-out'
});
switch (type) {
case 'success': notificationDiv.style.backgroundColor = '#4CAF50'; break;
case 'error': notificationDiv.style.backgroundColor = '#F44336'; break;
case 'warning': notificationDiv.style.backgroundColor = '#FF9800'; break;
default: notificationDiv.style.backgroundColor = '#2196F3'; break;
}
document.body.appendChild(notificationDiv);
setTimeout(() => { notificationDiv.style.opacity = '1'; }, 10); // 延迟以确保元素已渲染再触发过渡动画
if (duration > 0) { // 如果持续时间大于0,则自动隐藏
setTimeout(() => {
notificationDiv.style.opacity = '0';
setTimeout(() => { notificationDiv.remove(); }, 500); // 淡出动画后移除
}, duration);
}
}
// 默认多行站点数据 (如果用户没有自定义配置,则使用此数据)
const defaultSiteRowsText = [
'暗香,anxiangge.cc 2nt,mm2211.blog.2nt.com 2048,hjd2048.com',
'Intporn,forum.intporn.com EC,eroticity.net SPS,sexpicturespass.com planetsuzy,planetsuzy.org'
].join('\n');
// 解析文本为站点数组
// 将存储的站点配置文本(格式:"名称1,域名1 名称2,域名2\n名称3,域名3...")
// 解析为一个包含多个行数组的二维数组,每个行数组又包含多个站点对象{name: "名称", url: "域名"}
function parseSiteRows(text) {
return text.split('\n').map(line =>
line.trim().split(/\s+/).filter(Boolean).map(btn => {
const [name, url] = btn.split(',');
return { name: name || '', url: url || '' };
}).filter(btn => btn.name && btn.url) // 过滤掉无效的按钮数据
).filter(row => row.length > 0); // 过滤掉空行
}
// 读取站点配置(优先浏览器本地存储)
// 从浏览器本地存储中加载用户保存的站点配置文本,如果不存在则使用上面定义的默认配置
function getSiteRows() {
const saved = localStorage.getItem(LOCALSTORAGE_SITES_KEY);
return parseSiteRows(saved || defaultSiteRowsText);
}
// --- Gist 同步核心函数 (异步) ---
// 异步从Gist获取指定文件的内容
// gistId: Gist的唯一标识符
async function getGistFileContentAsync(gistId) {
const GITHUB_TOKEN = getGitHubToken();
// Gist ID 和 Token 由调用函数 (uploadToGist/downloadFromGist) 检查
if (DEBUG_MODE) console.log('[GSMS] 正在获取Gist文件内容, Gist ID:', gistId);
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.github.com/gists/${gistId}`,
headers: {
'Authorization': `token ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json' // GitHub API推荐的Accept头
},
onload: res => resolve(res), // 请求成功
onerror: err => reject(err) // 请求失败
});
});
if (response.status === 200) { // HTTP 200 成功
const gistData = JSON.parse(response.responseText);
if (gistData.files && gistData.files[GIST_FILENAME]) {
if (DEBUG_MODE) console.log('[GSMS] Gist文件内容已获取:', gistData.files[GIST_FILENAME].content);
return gistData.files[GIST_FILENAME].content; // 返回文件内容
} else {
if (DEBUG_MODE) console.warn(`[GSMS] Gist (${gistId.substring(0,7)}...) 已找到, 但文件 ${GIST_FILENAME} 不存在.`);
return null; // Gist中未找到指定文件
}
} else if (response.status === 404) { // HTTP 404 未找到
if (DEBUG_MODE) console.warn('[GSMS] Gist 未找到 (404) for ID:', gistId);
showNotification('Gist 未找到 (404)。请检查Gist ID配置。', 'warning', 5000);
return null; // Gist本身未找到
} else {
if (DEBUG_MODE) console.error(`[GSMS] 获取Gist文件失败 ${response.status}:`, response);
showNotification(`获取Gist文件失败: ${response.statusText} (${response.status})`, 'error');
return null;
}
} catch (error) {
if (DEBUG_MODE) console.error('[GSMS] 请求Gist文件出错:', error);
showNotification('请求Gist文件出错,详情请查看控制台。', 'error');
return null;
}
}
// 异步更新Gist中指定文件的内容
// gistId: Gist的唯一标识符
// content: 新的文件内容
// 如果文件不存在于Gist中,GitHub API的PATCH操作通常会自动创建该文件。
async function updateGistFileContentAsync(gistId, content) {
const GITHUB_TOKEN = getGitHubToken();
// Token和Gist ID由调用者检查
if (DEBUG_MODE) console.log('[GSMS] 正在更新Gist文件内容, Gist ID:', gistId);
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PATCH', // 使用PATCH方法更新Gist
url: `https://api.github.com/gists/${gistId}`,
headers: {
'Authorization': `token ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json' // 发送JSON数据
},
data: JSON.stringify({
files: {
[GIST_FILENAME]: { content: content } // 指定要更新的文件名和内容
}
}),
onload: res => resolve(res),
onerror: err => reject(err)
});
});
if (response.status === 200) { // HTTP 200 成功, 更新成功
if (DEBUG_MODE) console.log('[GSMS] Gist文件已成功更新。');
return true;
} else {
if (DEBUG_MODE) console.error(`[GSMS] 更新Gist文件失败 ${response.status}:`, response);
showNotification(`更新Gist文件失败: ${response.statusText} (${response.status})`, 'error');
return false;
}
} catch (error) {
if (DEBUG_MODE) console.error('[GSMS] 请求更新Gist文件出错:', error);
showNotification('请求更新Gist文件出错,详情请查看控制台。', 'error');
return false;
}
}
// 异步创建新的Gist (私有),包含一个指定内容的文件,并返回新Gist的ID
// content: 要在新Gist中创建的文件的内容
// description: Gist的描述文本
async function createGistWithFileAsync(content, description = '谷歌搜图多站点配置') {
const GITHUB_TOKEN = getGitHubToken();
// Token由调用者检查
if (DEBUG_MODE) console.log('[GSMS] 正在创建新的Gist并包含文件。');
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST', // 使用POST方法创建Gist
url: 'https://api.github.com/gists',
headers: {
'Authorization': `token ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
data: JSON.stringify({
description: description, // Gist描述
public: false, // 设置为私有Gist
files: {
[GIST_FILENAME]: { content: content } // Gist中的文件名和内容
}
}),
onload: res => resolve(res),
onerror: err => reject(err)
});
});
if (response.status === 201) { // HTTP 201 已创建, 创建成功
const newGist = JSON.parse(response.responseText);
if (DEBUG_MODE) console.log('[GSMS] 新Gist已成功创建. ID:', newGist.id);
return newGist.id; // 返回新创建的Gist的ID
} else {
if (DEBUG_MODE) console.error(`[GSMS] 创建Gist失败 ${response.status}:`, response);
showNotification(`创建Gist失败: ${response.statusText} (${response.status})`, 'error');
return null;
}
} catch (error) {
if (DEBUG_MODE) console.error('[GSMS] 请求创建Gist出错:', error);
showNotification('请求创建Gist出错,详情请查看控制台。', 'error');
return null;
}
}
// 上传配置到GitHub Gist
// 将浏览器本地存储中的站点配置上传到用户的GitHub Gist。
// 1. 检查GitHub Token是否配置。
// 2. 如果已存在Gist ID,则尝试更新该Gist中的配置文件。
// 3. 如果不存在Gist ID,则创建一个新的Gist来存储配置,并保存新Gist的ID。
// 4. 操作过程中会显示通知反馈。
async function uploadToGist() {
const GITHUB_TOKEN = getGitHubToken();
if (!GITHUB_TOKEN) {
showNotification('GitHub Token 未配置。请通过油猴菜单「⚙️ 配置Gist同步参数」进行设置。', 'error');
console.error('[GSMS] 上传中止: Token缺失。'); return;
}
const localContent = localStorage.getItem(LOCALSTORAGE_SITES_KEY) || defaultSiteRowsText;
let currentGistId = getGistId();
let success = false;
let newGistCreated = false;
showNotification('上传配置到Gist中...', 'info', 0); // 显示持续的"上传中"通知
try {
if (currentGistId) { // 如果已有Gist ID
if (DEBUG_MODE) console.log(`[GSMS] 尝试更新已存在的Gist ID: ${currentGistId}`);
// 尝试更新。如果Gist存在但文件不存在,此操作也会创建文件。
success = await updateGistFileContentAsync(currentGistId, localContent);
if (!success) {
// 如果更新失败,可能是Gist ID无效(例如404),或文件因其他原因无法创建/更新。
// 目前,如果使用现有ID更新失败,不会自动创建新的Gist。
// 用户可能需要在设置中清除有问题的Gist ID。
if (DEBUG_MODE) console.log('[GSMS] 使用现有Gist ID更新失败。用户可能需要检查或清除ID。');
}
} else { // 如果没有Gist ID
if (DEBUG_MODE) console.log('[GSMS] 未找到Gist ID。尝试创建新的Gist。');
const newGistId = await createGistWithFileAsync(localContent);
if (newGistId) {
GM_setValue(GM_GIST_ID_KEY, newGistId); // 保存新Gist的ID到油猴存储
currentGistId = newGistId; // 更新currentGistId用于通知显示
success = true;
newGistCreated = true;
}
}
} catch (error) {
// 异步函数内部的错误应该已经显示了各自的通知
console.error('[GSMS] uploadToGist执行过程中发生错误:', error);
success = false; // 确保success为false
} finally {
// 清除"上传中..."的通知 (通过查找特定文本内容来移除)
const uploadingNotifications = Array.from(document.body.children).filter(el => el.textContent === '上传配置到Gist中...');
uploadingNotifications.forEach(n => n.remove());
}
if (success) {
if (newGistCreated) {
showNotification(`新Gist已创建并自动保存!ID: ${currentGistId.substring(0,7)}...`, 'success', 7000);
} else {
showNotification('配置已成功同步到Gist!', 'success');
}
if (DEBUG_MODE) console.log('[GSMS] 同步到Gist成功。');
} else {
if (!newGistCreated) { // 避免在创建失败时重复显示错误 (创建函数会自行显示错误)
// 也避免在更新失败时重复显示错误 (更新函数会自行显示错误)
const GistIDForError = currentGistId ? ` (ID: ${currentGistId.substring(0,7)}...)` : '';
// 检查是否已有错误通知,避免重复
if(!document.querySelector('div[style*="background-color: rgb(244, 67, 54)"]')) {
showNotification(`同步到Gist失败${GistIDForError}。请检查Token/ID及控制台。`, 'error');
}
}
if (DEBUG_MODE) console.log('[GSMS] 同步到Gist失败。');
}
}
// 从GitHub Gist下载配置
// 从用户的GitHub Gist下载站点配置,并用其覆盖浏览器本地存储中的配置。
// 1. 检查GitHub Token和Gist ID是否都已配置。
// 2. 获取Gist中的配置文件内容。
// 3. 如果Gist内容与本地内容不同,则向用户确认是否覆盖。
// 4. 如果用户确认,则用Gist内容更新本地存储,并提示刷新页面以应用更改。
// 5. 操作过程中会显示通知反馈。
async function downloadFromGist() {
const GITHUB_TOKEN = getGitHubToken();
if (!GITHUB_TOKEN) {
showNotification('GitHub Token 未配置。请通过油猴菜单「⚙️ 配置Gist同步参数」进行设置。', 'error');
console.error('[GSMS] 下载中止: Token缺失。'); return;
}
const currentGistId = getGistId();
if (!currentGistId) {
showNotification('Gist ID 未配置。请通过油猴菜单「⚙️ 配置Gist同步参数」进行设置,或先上传一次。', 'warning', 5000);
console.error('[GSMS] 下载中止: Gist ID缺失。'); return;
}
showNotification('从Gist下载配置中...', 'info', 0); // 显示持续的"下载中"通知
try {
const remoteContent = await getGistFileContentAsync(currentGistId);
// 清除"下载中..."的通知
const downloadingNotifications = Array.from(document.body.children).filter(el => el.textContent === '从Gist下载配置中...');
downloadingNotifications.forEach(n => n.remove());
if (remoteContent !== null) { // 确保获取到了内容
const localContent = localStorage.getItem(LOCALSTORAGE_SITES_KEY) || defaultSiteRowsText;
if (remoteContent === localContent) {
showNotification('本地配置与Gist中的一致,无需下载。', 'info');
if (DEBUG_MODE) console.log('[GSMS] Gist内容与本地相同,跳过下载。');
return;
}
// 为确认信息估算配置行数,提供更友好的提示
const remoteLines = remoteContent.split('\n').length;
const localLines = localContent.split('\n').length;
if (confirm(`Gist含约 ${remoteLines} 行配置,本地含约 ${localLines} 行。\n确定用Gist记录覆盖本地吗?(建议先上传备份本地配置)`)) {
localStorage.setItem(LOCALSTORAGE_SITES_KEY, remoteContent); // 更新本地存储
showNotification('已从Gist下载并覆盖本地配置!将刷新页面应用。', 'success', 3000);
if (DEBUG_MODE) console.log('[GSMS] 下载并覆盖成功。');
setTimeout(() => window.location.reload(), 2000); // 延迟刷新页面以应用更改
} else {
showNotification('已取消从Gist下载。', 'info');
if (DEBUG_MODE) console.log('[GSMS] 用户取消了Gist下载。');
}
} else {
// getGistFileContentAsync 函数在获取失败时应已显示具体错误 (如404, 文件缺失等)
// 如果它返回null但没有特定用户通知,则显示一个通用错误。
// 检查是否已有错误或警告通知,避免重复
if(!document.querySelector('div[style*="background-color: rgb(244, 67, 54)"]') && !document.querySelector('div[style*="background-color: rgb(255, 152, 0)"]')) {
showNotification('从Gist下载配置失败,未找到有效内容。', 'error');
}
if (DEBUG_MODE) console.log('[GSMS] 从Gist下载失败 - 未获取到内容。');
}
} catch (error) {
// 清除可能残留的"下载中..."通知
const downloadingNotifications = Array.from(document.body.children).filter(el => el.textContent === '从Gist下载配置中...');
downloadingNotifications.forEach(n => n.remove());
console.error('[GSMS] 从Gist下载时发生错误:', error);
showNotification('从Gist下载时发生严重错误,详情请查看控制台。', 'error');
}
}
// --- Gist参数设置对话框 ---
// 显示一个对话框,允许用户配置GitHub个人访问令牌 (Token) 和 Gist ID。
// 配置会保存到油猴的存储中。
function showGistSettingsDialog() {
const existingDialog = document.getElementById('googleSearchMultiSite-settings-dialog');
if (existingDialog) existingDialog.remove(); // 如果已存在对话框,先移除
const dialogOverlay = document.createElement('div'); // 遮罩层
dialogOverlay.id = 'googleSearchMultiSite-settings-dialog';
const dialogContent = document.createElement('div'); // 对话框内容区域
dialogContent.id = 'googleSearchMultiSite-settings-dialog-content';
// 对话框HTML结构
dialogContent.innerHTML = `
<button id="googleSearchMultiSite-settings-close-btn" title="关闭">×</button>
<h3>Gist 同步参数配置</h3>
<div>
<label for="gist_token_input_gsms">GitHub 个人访问令牌 (Token):</label>
<input type="password" id="gist_token_input_gsms" value="${getGitHubToken()}" placeholder="例如 ghp_xxxxxxxxxxxxxxxxx">
<small>Token 用于授权访问您的Gist。需要 Gist 读写权限。</small>
</div>
<div>
<label for="gist_id_input_gsms">Gist ID:</label>
<input type="text" id="gist_id_input_gsms" value="${getGistId()}" placeholder="例如 123abc456def7890">
<small>Gist ID 是备份用Gist的标识。若为空,首次上传时将自动创建并保存。</small>
</div>
<div class="rw-dialog-buttons">
<button id="settings_cancel_btn_gsms" class="rw-cancel-btn">取消</button>
<button id="settings_save_btn_gsms" class="rw-save-btn">保存配置</button>
</div>
`;
dialogOverlay.appendChild(dialogContent);
document.body.appendChild(dialogOverlay);
// 关闭对话框的函数
const closeDialog = () => { dialogOverlay.remove(); };
// 保存配置并关闭对话框的函数
const saveAndClose = () => {
const newToken = document.getElementById('gist_token_input_gsms').value.trim();
const newGistId = document.getElementById('gist_id_input_gsms').value.trim();
GM_setValue(GM_GITHUB_TOKEN_KEY, newToken); // 保存Token到油猴存储
GM_setValue(GM_GIST_ID_KEY, newGistId); // 保存Gist ID到油猴存储
let msg = "Gist参数已保存!";
if (!newToken && !newGistId) msg = "Gist参数已清空。";
else if (!newToken) msg = "Token已清空, Gist ID已保存。";
else if (!newGistId) msg = "Gist ID已清空, Token已保存。";
showNotification(msg, 'success');
closeDialog();
};
// 取消并关闭对话框的函数
const cancelAndClose = () => {
closeDialog();
showNotification('参数设置已取消。', 'info');
};
// 绑定事件到按钮
document.getElementById('settings_save_btn_gsms').addEventListener('click', saveAndClose);
document.getElementById('settings_cancel_btn_gsms').addEventListener('click', cancelAndClose);
document.getElementById('googleSearchMultiSite-settings-close-btn').addEventListener('click', cancelAndClose);
// 点击遮罩层本身也会关闭对话框 (如果不是点击在内容区域上)
dialogOverlay.addEventListener('click', e => { if (e.target === dialogOverlay) cancelAndClose(); });
}
// 弹窗编辑站点配置
// 显示一个对话框,允许用户编辑多行站点配置文本。
// 用户可以调整文本框的大小,脚本会记住调整后的尺寸。
// 配置会保存到浏览器的本地存储中。
function showSiteConfigDialog() {
const currentText = localStorage.getItem(LOCALSTORAGE_SITES_KEY) || defaultSiteRowsText;
// 创建遮罩层和对话框
const mask = document.createElement('div');
mask.style.position = 'fixed';
mask.style.left = '0';
mask.style.top = '0';
mask.style.width = '100vw';
mask.style.height = '100vh';
mask.style.background = 'rgba(0,0,0,0.25)';
mask.style.zIndex = '99999'; // 确保在顶层
mask.style.display = 'flex';
mask.style.alignItems = 'center';
mask.style.justifyContent = 'center';
const dialog = document.createElement('div');
dialog.style.background = '#fff';
dialog.style.borderRadius = '10px';
dialog.style.boxShadow = '0 8px 32px rgba(0,0,0,0.18)';
dialog.style.padding = '24px 24px 16px 24px';
dialog.style.minWidth = '480px'; // 对话框最小宽度
dialog.style.maxWidth = '90vw';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.alignItems = 'stretch';
// 对话框标题
const title = document.createElement('div');
title.textContent = '自定义站点配置';
title.style.fontWeight = 'bold';
title.style.fontSize = '18px';
title.style.marginBottom = '10px';
dialog.appendChild(title);
// 对话框说明文字
const desc = document.createElement('div');
desc.textContent = '每一行代表一行按钮,每个按钮之间用空格分隔,每个按钮是"名字,网址"的格式:';
desc.style.fontSize = '13px';
desc.style.color = '#666';
desc.style.marginBottom = '8px';
dialog.appendChild(desc);
// 大号文本编辑框
const textarea = document.createElement('textarea');
textarea.value = currentText; // 加载当前或默认配置
// 加载或设置文本框的保存尺寸
const savedWidth = localStorage.getItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY);
const savedHeight = localStorage.getItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY);
textarea.style.width = savedWidth || '100%'; // 应用保存的宽度,或默认100%
textarea.style.height = savedHeight || '240px'; // 应用保存的高度,或默认240px
textarea.style.fontSize = '14px';
textarea.style.padding = '10px';
textarea.style.border = '1px solid #bbb';
textarea.style.borderRadius = '6px';
textarea.style.resize = 'both'; // 允许用户调整文本框大小 (宽度和高度)
textarea.style.overflow = 'auto'; // 内容超出时显示滚动条
textarea.style.marginBottom = '16px';
textarea.style.fontFamily = 'monospace,Consolas,Menlo'; // 等宽字体,便于编辑
dialog.appendChild(textarea);
// 按钮区域
const btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.justifyContent = 'flex-end'; // 按钮右对齐
btnRow.style.gap = '12px'; // 按钮间距
// 保存按钮
const saveBtn = document.createElement('button');
saveBtn.textContent = '保存';
saveBtn.style.padding = '6px 18px';
saveBtn.style.background = '#4caf50'; // 绿色背景
saveBtn.style.color = '#fff';
saveBtn.style.border = 'none';
saveBtn.style.borderRadius = '5px';
saveBtn.style.fontSize = '15px';
saveBtn.style.cursor = 'pointer';
saveBtn.onclick = function() {
localStorage.setItem(LOCALSTORAGE_SITES_KEY, textarea.value); // 保存站点配置文本
// 保存文本框当前的宽高,以便下次打开时恢复
localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY, textarea.style.width);
localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY, textarea.style.height);
document.body.removeChild(mask); // 移除对话框
showNotification('站点配置已保存,刷新页面后生效!', 'success');
};
btnRow.appendChild(saveBtn);
// 取消按钮
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '取消';
cancelBtn.style.padding = '6px 18px';
cancelBtn.style.background = '#eee'; // 灰色背景
cancelBtn.style.color = '#333';
cancelBtn.style.border = 'none';
cancelBtn.style.borderRadius = '5px';
cancelBtn.style.fontSize = '15px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.onclick = function() {
// 即使用户取消,也保存文本框的尺寸,因为用户可能只是想调整大小而不修改内容
localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_WIDTH_KEY, textarea.style.width);
localStorage.setItem(LOCALSTORAGE_SITE_CONFIG_TEXTAREA_HEIGHT_KEY, textarea.style.height);
document.body.removeChild(mask); // 移除对话框
};
btnRow.appendChild(cancelBtn);
dialog.appendChild(btnRow);
mask.appendChild(dialog);
document.body.appendChild(mask); // 将对话框添加到页面
}
// 注册菜单命令
// 向油猴脚本管理器(如Tampermonkey)的菜单中添加命令选项,方便用户操作。
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('⚙️ 配置Gist同步参数', showGistSettingsDialog); // 打开Gist参数配置对话框
GM_registerMenuCommand('⬆️ 上传配置到 Gist', uploadToGist); // 执行上传配置到Gist的操作
GM_registerMenuCommand('⬇️ 从 Gist 下载配置', downloadFromGist); // 执行从Gist下载配置的操作
GM_registerMenuCommand('⚙️ 设置站点', showSiteConfigDialog); // 打开站点列表编辑对话框
}
// 获取Google搜索页面中的主搜索框元素
function getSearchBox() {
return document.querySelector("textarea[name='q']"); // Google搜索框通常是一个textarea
}
// 获取当前地址栏中的搜索关键词,并移除其中可能存在的 "site:xxx.com" 部分
function getQuery() {
const query = new URLSearchParams(window.location.search).get('q') || ''; // 从URL参数中获取q值
return query.replace(/site:[^\s]+/g, '').trim(); // 移除site限定并去除首尾空格
}
// 创建并显示悬浮导航栏
// 导航栏包含根据用户配置生成的多个站点搜索按钮。
// 导航栏可以被用户拖动,并且其位置会被保存和恢复。
function createFloatingNavBar() {
const searchBox = getSearchBox();
if (!searchBox) return; // 如果找不到搜索框,则不创建导航栏
const navBar = document.createElement('div'); // 导航栏容器
navBar.style.position = 'fixed'; // 固定定位,使其悬浮
navBar.style.top = '100px'; // 默认初始位置
navBar.style.left = '10px';
navBar.style.zIndex = '10000'; // 确保在页面上层
navBar.style.background = '#f8f8f8';
navBar.style.border = '1px solid #ccc';
navBar.style.padding = '3px 10px';
navBar.style.borderRadius = '8px';
navBar.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.2)';
navBar.style.display = 'flex';
navBar.style.flexDirection = 'column'; // 允许多行按钮
navBar.style.gap = '5px'; // 行间距或按钮间距
navBar.style.cursor = 'move'; // 鼠标样式提示可拖动
navBar.style.transition = 'all 0.3s ease'; // 位置变化时的平滑过渡动画
// 读取并应用上次保存的导航栏位置
const lastPosition = JSON.parse(localStorage.getItem(LOCALSTORAGE_NAVBAR_POSITION_KEY) || '{"top": "100px", "left": "10px"}');
navBar.style.top = lastPosition.top;
navBar.style.left = lastPosition.left;
// 读取并解析站点配置,然后为每一行站点创建按钮组
const siteRows = getSiteRows();
siteRows.forEach(row => {
const rowDiv = document.createElement('div'); // 每行按钮的容器
rowDiv.style.display = 'flex';
rowDiv.style.gap = '5px'; // 同行按钮间距
row.forEach(({ name, url }) => {
const btn = document.createElement('button');
btn.textContent = name; // 按钮文字
btn.style.padding = '2px 6px';
btn.style.cursor = 'pointer';
btn.style.border = '1px solid #ccc';
btn.style.background = '#f8f8f8';
btn.style.borderRadius = '5px';
btn.style.fontSize = '10px';
// 按钮点击事件:构建新的搜索查询 (原查询词 + "site:目标域名") 并提交
btn.addEventListener('click', () => {
searchBox.value = `${getQuery()} site:${url}`; // 设置搜索框内容
document.querySelector("button[type='submit']").click(); // 模拟点击Google的搜索按钮
});
rowDiv.appendChild(btn);
});
navBar.appendChild(rowDiv);
});
document.body.appendChild(navBar); // 将导航栏添加到页面
// 实现导航栏拖动功能
let isDragging = false; // 标记是否正在拖动
let offsetX, offsetY; // 记录鼠标按下时在导航栏内部的偏移量
navBar.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - navBar.offsetLeft;
offsetY = e.clientY - navBar.offsetTop;
navBar.style.transition = 'none'; // 拖动时移除动画,避免延迟感
});
window.addEventListener('mousemove', (e) => {
if (isDragging) {
navBar.style.left = `${e.clientX - offsetX}px`;
navBar.style.top = `${e.clientY - offsetY}px`;
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
// 拖动结束后,保存新的位置到浏览器本地存储
localStorage.setItem(LOCALSTORAGE_NAVBAR_POSITION_KEY, JSON.stringify({ top: navBar.style.top, left: navBar.style.left }));
navBar.style.transition = 'all 0.3s ease'; // 恢复位置变化动画
}
});
}
// 初始化函数
// 脚本的主入口点。此处使用setTimeout延迟执行,以尝试确保Google页面元素已完全加载。
function init() {
createFloatingNavBar(); // 创建悬浮导航栏
}
setTimeout(init, 1000); // 延迟1秒执行初始化函数
})();