Add Gemini response, improve speed to search results(Bing), add Google/Bing search buttons, Gemini On/Off toggle
// ==UserScript==
// @name Google Plus & Bing Plus
// @version 8.3
// @description Add Gemini response, improve speed to search results(Bing), add Google/Bing search buttons, Gemini On/Off toggle
// @author monit8280
// @match https://www.bing.com/search*
// @match https://www.google.com/search*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect generativelanguage.googleapis.com
// @connect api.cdnjs.com
// @require https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.js
// @license MIT
// @namespace http://tampermonkey.net/
// ==/UserScript==
(function () {
'use strict';
/**
* ==========================================================================================
* 1. CONFIGURATION & CONSTANTS
* ==========================================================================================
*/
const Config = {
API: {
DEFAULT_MODEL: 'gemini-2.5-flash',
BASE_URL: 'https://generativelanguage.googleapis.com/v1beta/models/',
MARKED_CDN: 'https://api.cdnjs.com/libraries/marked'
},
MODELS: [
{ id: 'gemini-2.5-flash', name: '2.5 Flash' },
{ id: 'gemini-2.5-flash-lite', name: '2.5 Flash Lite' },
{ id: 'gemini-2.5-pro', name: '2.5 Pro' },
{ id: 'gemini-3-flash-preview', name: '3.0 Flash' },
{ id: 'gemini-3-pro-preview', name: '3.0 Pro' }
],
STORAGE: {
PREFIX: 'gemini_',
API_KEY: 'geminiApiKey',
ENABLED: 'geminiEnabled',
THEME: 'themeMode',
MODEL: 'selectedModel',
LAST_NOTIFIED: 'markedLastNotifiedVersion',
CURRENT_VERSION: 'markedCurrentVersion',
LATEST_VERSION: 'markedLatestVersion'
},
UI: {
MARGIN: 8,
PADDING: 16,
BORDER_RADIUS: '12px', // Rounded aesthetics
ICON_SIZE: '20px',
LOGO_SIZE: '24px',
SMALL_ICON_SIZE: '16px',
ANIMATION_SPEED: '0.2s'
},
ASSETS: {
GOOGLE_LOGO: 'https://www.gstatic.com/images/branding/searchlogo/ico/favicon.ico',
BING_LOGO: 'https://account.microsoft.com/favicon.ico',
GEMINI_LOGO: 'https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg',
REFRESH_ICON: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPg0KPHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik00LjA2MTg5IDEzQzQuMDIxMDQgMTIuNjcyNCA0IDEyLjMzODcgNCAxMkM0IDcuNTgxNzIgNy41ODE3MiA0IDEyIDRDMTQuNTAwNiA0IDE2LjczMzIgNS4xNDcyNyAxOC4yMDAyIDYuOTQ0MTZNMTkuOTM4MSAxMUMxOS45NzkgMTEuMzI3NiAyMCAxMS42NjEzIDIwIDEyQzIwIDE2LjQxODMgMTYuNDE4MyAyMCAxMiAyMEM5LjYxMDYxIDIwIDcuNDY1ODkgMTguOTUyNSA2IDE3LjI5MTZNOSAxN0g2VjE3LjI5MTZNMTguMjAwMiA0VjYuOTQ0MTZNMTguMjAwMiA2Ljk0NDE2VjYuOTk5OTNMMTUuMjAwMiA3TTYgMjBWMTcuMjkxNiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPg0KPC9zdmc+',
LIGHT_ICON: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gVXBsb2FkZWQgdG86IFNWRyBSZXBvLCB3d3cuc3ZncmVwby5jb20sIEdlbmVyYXRvcjogU1ZHIFJlcG8gTWl4ZXIgVG9vbHMgLS0+DQo8c3ZnIHdpZHRoPSI4MDBweCIgaGVpZ2h0PSI4MDBweCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgY2xhc3M9Imljb24iICB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg2MSA2NTYuN2wxNDQuNi0xNDQuNkw4NjEgMzY3LjZWMTYzLjFINjU2LjZMNTEyIDE4LjYgMzY3LjQgMTYzLjFIMTYzdjIwNC41TDE4LjQgNTEyLjEgMTYzIDY1Ni43djIwNC40aDIwNC40TDUxMiAxMDA1LjdsMTQ0LjYtMTQ0LjZIODYxeiIgZmlsbD0iI0ZDRDE3MCIgLz48cGF0aCBkPSJNNTEyIDEwMTUuN2MtMi42IDAtNS4xLTEtNy4xLTIuOUwzNjMuMyA4NzEuMUgxNjNjLTUuNSAwLTEwLTQuNS0xMC0xMFY2NjAuOEwxMS40IDUxOS4yYy0xLjktMS45LTIuOS00LjQtMi45LTcuMSAwLTIuNyAxLjEtNS4yIDIuOS03LjFMMTUzIDM2My40VjE2My4xYzAtNS41IDQuNS0xMCAxMC0xMGgyMDAuM0w1MDQuOSAxMS41YzEuOS0xLjkgNC40LTIuOSA3LjEtMi45czUuMiAxLjEgNy4xIDIuOWwxNDEuNiAxNDEuNkg4NjFjNS41IDAgMTAgNC41IDEwIDEwdjIwMC4zTDEwMTIuNiA1MDVjMS45IDEuOSAyLjkgNC40IDIuOSA3LjEgMCAyLjctMS4xIDUuMi0yLjkgNy4xTDg3MSA2NjAuOHYyMDAuM2MwIDUuNS00LjUgMTAtMTAgMTBINjYwLjdsLTE0MS42IDE0MS42Yy0yIDItNC41IDMtNy4xIDN6TTE3MyA4NTEuMWgxOTQuNGMyLjcgMCA1LjIgMS4xIDcuMSAyLjlMNTEyIDk5MS42bDEzNy41LTEzNy41YzEuOS0xLjkgNC40LTIuOSA3LjEtMi45SDg1MVY2NTYuN2MwLTIuNyAxLjEtNS4yIDIuOS03LjFsMTM3LjUtMTM3LjUtMTM3LjUtMTM3LjVjLTEuOS0xLjktMi45LTQuNC0yLjktNy4xVjE3My4xSDY1Ni42Yy0yLjcgMC01LjItMS4xLTcuMS0yLjlMNTEyIDMyLjcgMzc0LjUgMTcwLjJjLTEuOSAxLjktNC40IDIuOS03LjEgMi45SDE3M3YxOTQuNGMwIDIuNy0xLjEgNS4yLTIuOSA3LjFMMzIuNiA1MTIuMWwxMzcuNSAxMzcuNWMxLjkgMS45IDIuOSA0LjQgMi45IDcuMXYxOTQuNHoiIGZpbGw9IiIgLz48cGF0aCBkPSJNNTEyIDUxMi4xbS0yNTcuOCAwYTI1Ny44IDI1Ny44IDAgMSAwIDUxNS42IDAgMjU3LjggMjU3LjggMCAxIDAtNTE1LjYgMFoiIGZpbGw9IiNGN0REQUQiIC8+PHBhdGggZD0iTTUxMiA3NzkuOWMtNzEuNSAwLTEzOC44LTI3LjktMTg5LjQtNzguNC01MC42LTUwLjYtNzguNC0xMTcuOC03OC40LTE4OS40czI3LjktMTM4LjggNzguNC0xODkuNGM1MC42LTUwLjYgMTE3LjgtNzguNCAxODkuNC03OC40IDcxLjUgMCAxMzguOCAyNy45IDE4OS40IDc4LjQgNTAuNiA1MC42IDc4LjQgMTE3LjggNzguNCAxODkuNFM3NTIgNjUwLjkgNzAxLjQgNzAxLjUgNTgzLjUgNzc5LjkgNTEyIDc3OS45eiBtMC01MTUuNmMtNjYuMiAwLTEyOC40IDI1LjgtMTc1LjIgNzIuNi00Ni44IDQ2LjgtNzIuNiAxMDktNzIuNiAxNzUuMnMyNS44IDEyOC40IDcyLjYgMTc1LjJjNDYuOCA0Ni44IDEwOSA3Mi42IDE3NS4yIDcyLjYgNjYuMiAwIDEyOC40LTI1LjggMTc1LjItNzIuNiA0Ni44LTQ2LjggNzIuNiAxMDkgNzIuNi0xNzUuMlM3MzQgMzgzLjcgNjg3LjIgMzM2LjljLTQ2LjgtNDYuOC0xMDktNzIuNi0xNzUuMi03Mi42eiIgZmlsbD0iIiAvPjwvc3ZnPg==',
DARK_ICON: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPg0KPHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik0xOS45MDAxIDIuMzA3MTlDMTkuNzM5MiAxLjg5NzYgMTkuMTYxNiAxLjg5NzYgMTkuMDAwNyAyLjMwNzE5TDE4LjU3MDMgMy40MDI0N0MxOC41MjEyIDMuNTI3NTIgMTguNDIyNiAzLjYyNjUxIDE4LjI5OCAzLjY3NTgzTDE3LjIwNjcgNC4xMDc4QzE2Ljc5ODYgNC4yNjkzNCAxNi43OTg2IDQuODQ5IDE3LjIwNjcgNS4wMTA1NEwxOC4yOTggNS40NDI1MkMxOC40MjI2IDUuNDkxODQgMTguNTIxMiA1LjU5MDgyIDE4LjU3MDMgNS43MTU4N0wxOS4wMDA3IDYuODExMTVDMTkuMTYxNiA3LjIyMDc0IDE5LjczOTIgNy4yMjA3NCAxOS45MDAxIDYuODExMTZMMjAuMzMwNSA1LjcxNTg3QzIwLjM3OTYgNS41OTA4MiAyMC40NzgyIDUuNDkxODQgMjAuNjAyOCA1LjQ0MjUyTDIxLjY5NDEgNS4wMTA1NEMyMi4xMDIyIDQuODQ5IDIyLjEwMjIgNC4yNjkzNCAyMS42OTQxIDQuMTA3OEwyMC42MDI4IDMuNjc1ODNDMjAuNDc4MiAzLjYyNjUxIDIwLjM3OTYgMy41Mjc1MiAyMC4zMzA1IDMuNDAyNDdMMTkuOTAwMSAyLjMwNzE5WiIgZmlsbD0iIzFDMjc0QyIvPg0KPHBhdGggZD0iTTE2LjAzMjggOC4xMjk2N0MxNS44NzE4IDcuNzIwMDkgMTUuMjk0MyA3LjcyMDA5IDE1LjEzMzMgOC4xMjk2N0wxNC45NzY0IDguNTI5MDJDMTQuOTI3MyA4LjY1NDA3IDE0LjgyODcgOC43NTMwNSAxNC43MDQxIDguODAyMzdMMTQuMzA2MiA4Ljk1OTg3QzEzLjg5ODEgOS4xMjE0MSAxMy44OTgxIDkuNzAxMDcgMTQuMzA2MiA5Ljg2MjYxTDE0LjcwNDEgMTAuMDIwMUMxNC44Mjg3IDEwLjA2OTQgMTQuOTI3MyAxMC4xNjg0IDE0Ljk3NjQgMTAuMjkzNUwxNS4xMzMzIDEwLjY5MjhDMTUuMjk0MyAxMS4xMDI0IDE1Ljg3MTggMTEuMTAyNCAxNi4wMzI4IDEwLjY5MjhMMTYuMTg5NyAxMC4yOTM1QzE2LjIzODggMTAuMTY4NCAxNi4zMzc0IDEwLjA2OTQgMTYuNDYyIDEwLjAyMDFMMTYuODU5OSA5Ljg2MjYxQzE3LjI2OCA5LjcwMTA3IDE3LjI2OCA5LjEyMTQxIDE2Ljg1OTkgOC45NTk4N0wxNi40NjIgOC44MDIzN0MxNi4zMzc0IDguNzUzMDUgMTYuMjM4OCA4LjY1NDA3IDE2LjE4OTcgOC41MjkwMkwxNi4wMzI4IDguMTI5NjdaIiBmaWxsPSIjMUMyNzRDIi8+DQo8cGF0aCBkPSJNMTIgMjJDMTcuNTIyOCAyMiAyMiAxNy41MjI4IDIyIDEyQzIyIDExLjUzNzMgMjEuMzA2NSAxMS40NjA4IDIxLjA2NzIgMTEuODU2OEMxOS45Mjg5IDEzLjc0MDYgMTcuODYxNSAxNSAxNS41IDE1QzExLjkxMDEgMTUgOSAxMi4wODk5IDkgOC41QzkgNi4xMzg0NSAxMC4yNTk0IDQuMDcxMDUgMTIuMTQzMiAyLjkzMjc2QzEyLjUzOTIgMi42OTM0NyAxMi40NjI3IDIgMTIgMkM2LjQ3NzE1IDIgMiA2LjQ3NzE1IDIgMTJDMiAxNy41MjI4IDYuNDc3MTUgMjIgMTIgMjJaIiBmaWxsPSIjMUMyNzRDIi8+DQo8L3N2Zz4='
}
};
/**
* ==========================================================================================
* 2. CORE UTILITIES
* ==========================================================================================
*/
const Utils = {
Storage: {
get: (key) => localStorage.getItem(key),
set: (key, val) => localStorage.setItem(key, val),
getBool: (key, def = true) => {
const val = localStorage.getItem(key);
return val === null ? def : val === 'true';
}
},
Env: {
isGoogle: () => window.location.hostname.includes('google.com'),
isBing: () => window.location.hostname.includes('bing.com'),
getQuery: () => new URLSearchParams(window.location.search).get('q'),
isMobile: () => {
const ua = navigator.userAgent;
const topWidth = window.innerWidth;
const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
return (/Android|iPhone|Mobile/i.test(ua)) || (isTouch && topWidth <= 1024);
}
},
I18n: {
MESSAGES: {
prompt: { ko: `"${'${query}'}"에 대한 정보를 찾아줘`, default: `Please write information about "${'${query}'}" in markdown format` },
enterApiKey: { ko: 'Gemini API 키 입력:', default: 'Enter Gemini API Key:' },
geminiEmpty: { ko: '⚠️ 응답이 비어있습니다.', default: '⚠️ Response is empty.' },
loading: { ko: '불러오는 중...', default: 'Loading...' },
error: { ko: '오류 발생:', default: 'Error:' },
geminiOff: { ko: 'Gemini가 꺼져있습니다.', default: 'Gemini is OFF' },
searchGoogle: { ko: 'Google 검색', default: 'Search on Google' },
searchBing: { ko: 'Bing 검색', default: 'Search on Bing' }
},
get(key, vars = {}) {
const lang = navigator.language.includes('ko') ? 'ko' : 'default';
const tmpl = this.MESSAGES[key]?.[lang] || this.MESSAGES[key]?.default || '';
return tmpl.replace(/\$\{(.*?)\}/g, (_, k) => vars[k] || '');
}
}
};
/**
* ==========================================================================================
* 3. STATE MANAGEMENT (Theme & Settings)
* ==========================================================================================
*/
const State = {
theme: 'light',
init() {
const saved = Utils.Storage.get(Config.STORAGE.THEME);
this.theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
this.applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
this.theme = e.matches ? 'dark' : 'light';
this.applyTheme();
});
},
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
Utils.Storage.set(Config.STORAGE.THEME, this.theme);
this.applyTheme();
return this.theme;
},
applyTheme() {
document.documentElement.classList.toggle('gemini-dark-mode', this.theme === 'dark');
const btn = document.getElementById('gemini-theme-toggle-btn');
if (btn) btn.src = this.getThemeIcon();
},
getThemeIcon() {
return this.theme === 'light' ? Config.ASSETS.DARK_ICON : Config.ASSETS.LIGHT_ICON;
},
// Model & API Key
getApiKey: () => Utils.Storage.get(Config.STORAGE.API_KEY),
setApiKey: (key) => Utils.Storage.set(Config.STORAGE.API_KEY, key),
getModel: () => Utils.Storage.get(Config.STORAGE.MODEL) || Config.API.DEFAULT_MODEL,
setModel: (model) => Utils.Storage.set(Config.STORAGE.MODEL, model),
isEnabled: () => Utils.Storage.getBool(Config.STORAGE.ENABLED),
setEnabled: (v) => Utils.Storage.set(Config.STORAGE.ENABLED, v ? 'true' : 'false')
};
/**
* ==========================================================================================
* 4. STYLES
* ==========================================================================================
*/
const Styles = {
inject() {
const css = `
:root {
--g-bg: #fff; --g-border: #e0e0e0; --g-text: #000; --g-title: #000;
--g-btn-bg: #f0f3ff; --g-btn-border: #ccc; --g-code-bg: #f0f0f0;
--g-icon-filter: none;
}
.gemini-dark-mode {
--g-bg: #282c34; --g-border: #444; --g-text: #e0e0e0; --g-title: #fff;
--g-btn-bg: #3a3f4b; --g-btn-border: #555; --g-code-bg: #3b3b3b;
--g-icon-filter: invert(1);
}
/* Layout */
#gemini-wrapper { margin-bottom: 20px; font-family: sans-serif; }
#gemini-top-row { display: flex; gap: 8px; margin-bottom: 12px; width: 100%; }
/* Buttons & Selects */
.gemini-btn, .gemini-select {
height: 40px; border-radius: ${Config.UI.BORDER_RADIUS}; font-size: 13px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all ${Config.UI.ANIMATION_SPEED};
border: 1px solid var(--g-btn-border);
background: var(--g-btn-bg) !important; /* Bing 스타일 오버라이드 */
color: var(--g-title) !important;
box-sizing: border-box; overflow: hidden; /* 오버플로우 방지 */
}
.gemini-btn img {
width: 16px !important; height: 16px !important;
object-fit: contain; /* 이미지 비율 유지 */
}
.gemini-btn:hover, .gemini-select:hover { transform: translateY(-2px); box-shadow: 0 2px 5px rgba(0,0,0,0.15); }
#google-search-btn, #bing-search-btn { flex: 8; gap: 8px; } /* gap으로 간격 조정 */
#gemini-model-select-top {
flex: 2; appearance: none; text-align-last: center; text-align: center;
outline: none; padding: 0 !important; margin: 0;
}
#gemini-model-select-top option { text-align: center; }
/* Main Box */
#gemini-box {
width: 100%; padding: ${Config.UI.PADDING}px;
border: 1px solid var(--g-border); border-radius: ${Config.UI.BORDER_RADIUS};
background: var(--g-bg); color: var(--g-text);
box-sizing: border-box; overflow: hidden;
position: relative; /* 자식 요소 위치 기준 */
}
#gemini-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
#gemini-title-wrap { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; }
#gemini-title-wrap h3 { font-weight: bold; margin: 0; font-size: 16px; color: var(--g-title); }
#gemini-time-info { font-size: 12px; color: #888; font-weight: normal; margin-left: 6px; }
#gemini-logo { width: 24px; height: 24px; cursor: pointer; transition: transform 0.2s; }
#gemini-logo:hover { transform: scale(1.1); }
#gemini-actions { display: flex; align-items: center; gap: 10px; }
/* Content & Markdown */
#gemini-content {
font-size: 14px; line-height: 1.6; color: var(--g-text);
padding-top: 4px; padding-right: 8px; /* 스크롤바 공간 확보 */
max-height: 650px; overflow-y: auto; scrollbar-width: thin;
}
#gemini-content p { margin: 0 0 12px 0; } /* 문단 간격 */
#gemini-content::-webkit-scrollbar { width: 6px; }
#gemini-content::-webkit-scrollbar-thumb { background-color: #ccc; border-radius: 3px; }
.gemini-dark-mode #gemini-content::-webkit-scrollbar-thumb { background-color: #555; }
#gemini-content h1, #gemini-content h2, #gemini-content h3 { margin-top: 20px; margin-bottom: 10px; }
#gemini-content ul, #gemini-content ol { padding-left: 20px; margin: 10px 0; }
#gemini-content li { margin-bottom: 5px; }
#gemini-content pre { background: var(--g-code-bg); padding: 12px; border-radius: 6px; overflow-x: auto; margin: 10px 0; position: relative; }
#gemini-divider { height: 1px; border: 0; background: var(--g-border); margin: 8px 0 16px 0; }
/* Code Copy Button */
.code-copy-btn {
position: absolute; top: 8px; right: 8px;
background: rgba(128, 128, 128, 0.2); color: var(--g-text);
border: 1px solid var(--g-border); border-radius: 4px;
font-size: 11px; padding: 4px 8px; cursor: pointer;
opacity: 0; transition: opacity 0.2s;
}
#gemini-content pre:hover .code-copy-btn { opacity: 1; }
.code-copy-btn:hover { background: rgba(128, 128, 128, 0.4); }
/* Icons - Specific Animations */
.gemini-icon-btn {
width: 20px; height: 20px; cursor: pointer; opacity: 0.6; transition: 0.3s ease;
vertical-align: middle;
}
.gemini-icon-btn:hover { opacity: 1; }
#gemini-theme-toggle-btn:hover { transform: scale(1.2); }
#gemini-refresh-btn { transition: transform 0.6s ease-in-out; filter: var(--g-icon-filter); }
#gemini-refresh-btn:hover { transform: rotate(360deg); }
/* Toggle Switch */
.g-toggle { width: 44px; height: 24px; border-radius: 12px; position: relative; cursor: pointer; transition: background 0.2s; }
.g-knob { width: 22px; height: 22px; background: #fff; border-radius: 50%; position: absolute; top: 1px; left: 1px; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
.g-toggle.on { background: #d1d5db; } .g-toggle.off { background: #3c4043; }
.g-toggle.on .g-knob { left: 21px; }
/* API Key Input */
#gemini-api-container { display: flex; flex-direction: column; gap: 10px; padding: 10px 0; }
#gemini-api-input { width: 95%; padding: 10px; border-radius: 8px; border: 1px solid var(--g-border); background: var(--g-bg); color: var(--g-text); }
#gemini-api-save-btn { align-self: flex-end; padding: 8px 16px; background: #4285f4; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
/* Device Specific */
.mobile-ua #gemini-box { border-radius: 16px; }
#b_context, .b_right { width: 432px !important; }
/* Skeleton Loading Animation */
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-loader { display: flex; flex-direction: column; gap: 12px; padding: 4px 0; }
.skeleton-line {
height: 14px; width: 100%; border-radius: 4px;
background: linear-gradient(90deg, var(--g-bg) 25%, var(--g-border) 50%, var(--g-bg) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite;
}
.skeleton-line.short { width: 60%; }
.skeleton-line.medium { width: 85%; }
`;
GM_addStyle(css);
document.documentElement.classList.add(Utils.Env.isMobile() ? 'mobile-ua' : 'desktop-ua');
}
};
/**
* ==========================================================================================
* 5. API & SERVICES
* ==========================================================================================
*/
const GeminiAPI = {
fetch(query, container, apiKey) {
container.innerHTML = `
<div class="skeleton-loader">
<div class="skeleton-line medium"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
`;
// 시간 표시 초기화
const timeSpan = document.getElementById('gemini-time-info');
if (timeSpan) timeSpan.textContent = '(0.00s)';
const model = State.getModel();
const prompt = Utils.I18n.get('prompt', { query });
const startTime = performance.now();
let timerId = null;
// 스톱워치 시작
if (timeSpan) {
timerId = setInterval(() => {
const current = ((performance.now() - startTime) / 1000).toFixed(2);
timeSpan.textContent = `(${current}s)`;
}, 10);
}
GM_xmlhttpRequest({
method: 'POST',
url: `${Config.API.BASE_URL}${model}:generateContent?key=${apiKey}`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
}),
onload: (res) => {
if (timerId) clearInterval(timerId); // 타이머 정지
try {
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
if (timeSpan) timeSpan.textContent = `(${duration}s)`;
const data = JSON.parse(res.responseText);
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (text) {
sessionStorage.setItem(`${Config.STORAGE.PREFIX}${query}`, text);
sessionStorage.setItem(`${Config.STORAGE.PREFIX}${query}_time`, duration); // 시간 저장
container.innerHTML = marked.parse(text);
UI.addCodeCopyButtons(container);
} else {
throw new Error(data.error?.message || 'Empty response');
}
} catch (e) {
container.textContent = `${Utils.I18n.get('error')} ${e.message}`;
}
},
onerror: () => {
if (timerId) clearInterval(timerId);
container.textContent = Utils.I18n.get('geminiEmpty');
}
});
}
};
const LinkCleaner = {
clean(root = document) {
root.querySelectorAll('a[href]').forEach(a => {
try {
const url = new URL(a.href);
// Google
if (url.hostname.includes('google.com') && url.pathname === '/url') {
const q = url.searchParams.get('q') || url.searchParams.get('url');
if (q) a.href = decodeURIComponent(q);
}
// Bing
else if (url.hostname.includes('bing.com') && url.pathname.includes('/ck')) {
const u = url.searchParams.get('u');
if (u) {
// Bing uses 'a1' prefix followed by base64 encoded URL
const target = u.replace(/^a1/, '');
// Base64 decoding might fail if padding is missing or characters are invalid
// Also need to handle URL safe base64 if necessary (Bing usually uses standard or simple replacement)
// Usually just atob working is fine for standard links.
// Some characters might be replaced: - -> +, _ -> /
const b64 = target.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const pad = b64.length % 4;
const padded = pad ? b64 + '='.repeat(4 - pad) : b64;
try {
a.href = decodeURIComponent(atob(padded));
} catch (e) {
// If base64 fails, fallback to simple request or leave it
}
}
}
} catch (e) { }
});
}
};
/**
* ==========================================================================================
* 6. UI COMPONENTS
* ==========================================================================================
*/
const UI = {
create(tag, { id, className, style, html, attributes, events } = {}) {
const el = document.createElement(tag);
if (id) el.id = id;
if (className) el.className = className;
if (style) Object.assign(el.style, style);
if (html) el.innerHTML = html;
if (attributes) Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v));
if (events) Object.entries(events).forEach(([k, v]) => el.addEventListener(k, v));
return el;
},
Controls: {
createToggle() {
const enabled = State.isEnabled();
const wrap = UI.create('div', { style: { display: 'flex', alignItems: 'center', cursor: 'pointer', gap: '8px' } });
// Text Label
const label = UI.create('span', {
html: enabled ? 'ON' : 'OFF',
style: { fontSize: '13px', fontWeight: 'bold', color: enabled ? 'var(--g-title)' : '#888' }
});
// Switch
const switchEl = UI.create('div', { className: `g-toggle ${enabled ? 'on' : 'off'}` });
switchEl.appendChild(UI.create('div', { className: 'g-knob' }));
wrap.onclick = () => {
const next = !State.isEnabled();
State.setEnabled(next);
// Update UI
switchEl.className = `g-toggle ${next ? 'on' : 'off'}`;
label.textContent = next ? 'ON' : 'OFF';
label.style.color = next ? 'var(--g-title)' : '#888';
App.refresh();
};
wrap.append(label, switchEl);
return wrap;
}
},
createTopRow(query) {
const row = UI.create('div', { id: 'gemini-top-row' });
// 1. Search Switch Button
const isGoogle = Utils.Env.isGoogle();
const btn = UI.create('div', {
id: isGoogle ? 'bing-search-btn' : 'google-search-btn',
className: 'gemini-btn',
html: `<img src="${isGoogle ? Config.ASSETS.BING_LOGO : Config.ASSETS.GOOGLE_LOGO}"> <span>${Utils.I18n.get(isGoogle ? 'searchBing' : 'searchGoogle')}</span>`,
events: { click: () => window.open(`https://${isGoogle ? 'bing' : 'google'}.com/search?q=${encodeURIComponent(query)}`) }
});
// 2. Model Selector
const select = UI.create('select', {
id: 'gemini-model-select-top',
className: 'gemini-select',
events: { change: (e) => { State.setModel(e.target.value); App.refresh(true); } }
});
Config.MODELS.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id; opt.textContent = m.name;
opt.selected = m.id === State.getModel();
select.appendChild(opt);
});
row.appendChild(btn);
row.appendChild(select);
return row;
},
createApiKeyInput() {
const container = UI.create('div', { id: 'gemini-api-container' });
container.innerHTML = `<p style="margin:0; font-size:13px; font-weight:bold;">${Utils.I18n.get('enterApiKey')}</p>`;
const input = UI.create('input', { id: 'gemini-api-input', type: 'password', attributes: { placeholder: 'AIza...' } });
const btn = UI.create('button', {
id: 'gemini-api-save-btn', html: 'Save & Refresh', events: {
click: () => {
const val = input.value.trim();
if (val) { State.setApiKey(val); App.refresh(true); }
}
}
});
container.appendChild(input);
container.appendChild(btn);
return container;
},
createMainBox(query) {
const box = UI.create('div', { id: 'gemini-box' });
// Header
const header = UI.create('div', { id: 'gemini-header' });
// Title Container (Wrapper)
const titleContainer = UI.create('div', { style: { display: 'flex', alignItems: 'center', gap: '0px' } });
// Link (Clickable)
const titleLink = UI.create('a', {
id: 'gemini-title-link',
attributes: { href: 'https://gemini.google.com/', target: '_blank' },
style: { display: 'flex', alignItems: 'center', gap: '8px', textDecoration: 'none', color: 'inherit' }
});
titleLink.innerHTML = `<img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}"> <h3 style="margin:0; font-size:16px; font-weight:bold; color:var(--g-title);">Gemini Results</h3>`;
// Time Info (Non-clickable)
const timeSpan = UI.create('span', { id: 'gemini-time-info' });
titleContainer.append(titleLink, timeSpan);
const actions = UI.create('div', { id: 'gemini-actions' });
const toggle = this.Controls.createToggle();
const themeBtn = UI.create('img', {
id: 'gemini-theme-toggle-btn', className: 'gemini-icon-btn',
attributes: { src: State.getThemeIcon(), title: 'Toggle Theme' },
events: { click: () => { State.toggleTheme(); themeBtn.src = State.getThemeIcon(); } }
});
const refreshBtn = UI.create('img', {
id: 'gemini-refresh-btn', className: 'gemini-icon-btn',
attributes: { src: Config.ASSETS.REFRESH_ICON, title: 'Refresh' },
events: { click: () => App.refresh(true) }
});
actions.append(toggle, themeBtn, refreshBtn);
header.append(titleContainer, actions);
// Content
const content = UI.create('div', { id: 'gemini-content' });
const divider = UI.create('hr', { id: 'gemini-divider' });
box.append(header, divider, content);
return { box, content, refreshBtn };
},
addCodeCopyButtons(container) {
container.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.code-copy-btn')) return;
const btn = UI.create('button', {
className: 'code-copy-btn',
html: 'Copy',
events: {
click: () => {
const code = pre.querySelector('code')?.innerText || pre.innerText;
navigator.clipboard.writeText(code).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 2000);
});
}
}
});
pre.appendChild(btn);
});
}
};
/**
* ==========================================================================================
* 7. MAIN APP CONTROLLER
* ==========================================================================================
*/
const App = {
elements: null,
init() {
State.init();
Styles.inject();
try { marked.setOptions({ breaks: true, gfm: true }); } catch (e) { } // 마크다운 옵션 설정
this.handleNavigation();
LinkCleaner.clean();
// Delayed init to wait for page load
const onReady = () => {
const target = Utils.Env.isGoogle() ? document.getElementById('rhs') : (document.getElementById('b_context') || document.querySelector('.b_right'));
// Mobile check first
if (Utils.Env.isMobile()) {
this.render(null); // Container not needed for mobile logic
} else if (target) {
this.render(target);
} else if (Utils.Env.isGoogle()) {
// Force rhs ...
// Force RHS creation if missing on Google
const rcnt = document.getElementById('rcnt');
if (rcnt) {
const rhs = UI.create('div', { id: 'rhs', style: { marginLeft: '16px', width: '432px' } });
rcnt.appendChild(rhs);
this.render(rhs);
}
}
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', onReady);
else onReady();
},
render(container) {
const query = Utils.Env.getQuery();
if (!query) return;
// 1. Mobile/Tablet Mode (Search Button Only)
if (Utils.Env.isMobile()) {
document.getElementById('mobile-search-btn-wrapper')?.remove();
const btn = UI.create('div', {
id: 'mobile-search-btn-wrapper',
style: { margin: '0', padding: '15px 16px', background: 'var(--g-bg)' }
});
// 모바일용 버튼 생성 (UI.createTopRow에서 버튼만 추출하여 사용하거나 새로 생성)
const isGoogle = Utils.Env.isGoogle();
const searchBtn = UI.create('div', {
id: isGoogle ? 'bing-search-btn' : 'google-search-btn',
className: 'gemini-btn',
html: `<img src="${isGoogle ? Config.ASSETS.BING_LOGO : Config.ASSETS.GOOGLE_LOGO}"> <span>${Utils.I18n.get(isGoogle ? 'searchBing' : 'searchGoogle')}</span>`,
events: { click: () => window.open(`https://${isGoogle ? 'bing' : 'google'}.com/search?q=${encodeURIComponent(query)}`) },
style: { width: '100%' } // 모바일은 꽉 차게
});
btn.appendChild(searchBtn);
// Insert Position Logic
if (isGoogle) {
const target = document.getElementById('main') || document.getElementById('rcnt');
if (target) target.parentNode.insertBefore(btn, target);
} else {
const target = document.getElementById('b_content');
if (target) target.prepend(btn);
}
return; // Gemini 로직 중단
}
// 2. Desktop Mode (Existing Logic)
if (!container) return;
// Google Width Enforcement
if (Utils.Env.isGoogle() && container.id === 'rhs') {
container.style.width = '432px';
container.style.minWidth = '432px';
}
// Remove existing
document.getElementById('gemini-wrapper')?.remove();
// Structure
const wrapper = UI.create('div', { id: 'gemini-wrapper' });
wrapper.appendChild(UI.createTopRow(query));
const { box, content, refreshBtn } = UI.createMainBox(query);
this.elements = { content, refreshBtn }; // Cache for updates
wrapper.appendChild(box);
container.prepend(wrapper);
this.loadContent(query);
},
loadContent(query, forceRefresh = false) {
if (!this.elements) return;
const { content, refreshBtn } = this.elements;
const apiKey = State.getApiKey();
if (!apiKey) {
content.innerHTML = '';
content.appendChild(UI.createApiKeyInput());
refreshBtn.style.display = 'none';
return;
}
refreshBtn.style.display = 'block';
if (!State.isEnabled()) {
content.textContent = Utils.I18n.get('geminiOff');
return;
}
const cached = !forceRefresh && sessionStorage.getItem(`${Config.STORAGE.PREFIX}${query}`);
if (cached) {
const cachedTime = sessionStorage.getItem(`${Config.STORAGE.PREFIX}${query}_time`);
const timeSpan = document.getElementById('gemini-time-info');
if (timeSpan && cachedTime) timeSpan.textContent = `(${cachedTime}s)`;
content.innerHTML = marked.parse(cached);
UI.addCodeCopyButtons(content);
} else {
GeminiAPI.fetch(query, content, apiKey);
}
},
refresh(force = false) {
if (force) {
// 모든 Gemini 관련 캐시(sessionStorage) 삭제
Object.keys(sessionStorage).forEach(key => {
if (key.startsWith(Config.STORAGE.PREFIX)) {
sessionStorage.removeItem(key);
}
});
}
const query = Utils.Env.getQuery();
if (query) this.loadContent(query, force);
},
handleNavigation() {
// SPA Navigation Watcher
let lastUrl = location.href;
const check = () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(() => {
const target = document.getElementById('rhs') || document.querySelector('#b_context') || document.querySelector('.b_right');
this.render(target);
LinkCleaner.clean();
}, 500);
}
};
const obs = new MutationObserver(check);
obs.observe(document.body, { childList: true, subtree: true });
}
};
// Run
App.init();
})();