您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add Gemini response, improve speed to search results, add Google search buttons
- // ==UserScript==
- // @name Bing Plus
- // @version 6.2
- // @description Add Gemini response, improve speed to search results, add Google search buttons
- // @author lanpod
- // @match https://www.bing.com/search*
- // @grant GM_addStyle
- // @grant GM_xmlhttpRequest
- // @require https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.7/marked.min.js
- // @license MIT
- // @namespace http://tampermonkey.net/
- // ==/UserScript==
- (function () {
- 'use strict';
- // 설정 모듈: 전역 설정값 관리 (API, 스타일, 메시지 등)
- const Config = {
- API: {
- GEMINI_MODEL: 'gemini-2.0-flash',
- GEMINI_URL: 'https://generativelanguage.googleapis.com/v1beta/models/',
- MARKED_CDN_URL: 'https://api.cdnjs.com/libraries/marked'
- },
- VERSIONS: {
- MARKED_VERSION: '15.0.7'
- },
- CACHE: {
- PREFIX: 'gemini_cache_'
- },
- STORAGE_KEYS: {
- CURRENT_VERSION: 'markedCurrentVersion',
- LATEST_VERSION: 'markedLatestVersion',
- LAST_NOTIFIED: 'markedLastNotifiedVersion'
- },
- UI: {
- DEFAULT_MARGIN: 8,
- DEFAULT_PADDING: 16,
- Z_INDEX: 9999
- },
- STYLES: {
- COLORS: {
- BACKGROUND: '#fff',
- BORDER: '#e0e0e0',
- TEXT: '#000',
- TITLE: '#000',
- BUTTON_BG: '#f0f3ff',
- BUTTON_BORDER: '#ccc',
- DARK_BACKGROUND: '#202124',
- DARK_BORDER: '#5f6368',
- DARK_TEXT: '#fff',
- CODE_BLOCK_BG: '#f0f0f0',
- DARK_CODE_BLOCK_BG: '#555'
- },
- BORDER: '1px solid #e0e0e0',
- BORDER_RADIUS: '4px',
- FONT_SIZE: {
- TEXT: '14px',
- TITLE: '18px'
- },
- ICON_SIZE: '20px',
- LOGO_SIZE: '24px',
- SMALL_ICON_SIZE: '16px'
- },
- ASSETS: {
- GOOGLE_LOGO: 'https://www.gstatic.com/marketing-cms/assets/images/bc/1a/a310779347afa1927672dc66a98d/g.png=s48-fcrop64=1,00000000ffffffff-rw',
- GEMINI_LOGO: 'https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg',
- REFRESH_ICON: 'https://www.svgrepo.com/show/533704/refresh-cw-alt-3.svg'
- },
- MESSAGE_KEYS: {
- PROMPT: 'prompt',
- ENTER_API_KEY: 'enterApiKey',
- GEMINI_EMPTY: 'geminiEmpty',
- PARSE_ERROR: 'parseError',
- NETWORK_ERROR: 'networkError',
- TIMEOUT: 'timeout',
- LOADING: 'loading',
- UPDATE_TITLE: 'updateTitle',
- UPDATE_NOW: 'updateNow',
- SEARCH_ON_GOOGLE: 'searchongoogle'
- }
- };
- // 지역화 모듈: 다국어 메시지 처리
- const Localization = {
- // 다국어 메시지 객체
- MESSAGES: {
- [Config.MESSAGE_KEYS.PROMPT]: {
- ko: `"${'${query}'}"에 대한 정보를 찾아줘`,
- zh: `请以标记格式填写有关\"${'${query}'}\"的信息。`,
- default: `Please write information about \"${'${query}'}\" in markdown format`
- },
- [Config.MESSAGE_KEYS.ENTER_API_KEY]: {
- ko: 'Gemini API 키를 입력하세요:',
- zh: '请输入 Gemini API 密钥:',
- default: 'Please enter your Gemini API key:'
- },
- [Config.MESSAGE_KEYS.GEMINI_EMPTY]: {
- ko: '⚠️ Gemini 응답이 비어있습니다.',
- zh: '⚠️ Gemini 返回为空。',
- default: '⚠️ Gemini response is empty.'
- },
- [Config.MESSAGE_KEYS.PARSE_ERROR]: {
- ko: '❌ 파싱 오류:',
- zh: '❌ 解析错误:',
- default: '❌ Parsing error:'
- },
- [Config.MESSAGE_KEYS.NETWORK_ERROR]: {
- ko: '❌ 네트워크 오류:',
- zh: '❌ 网络错误:',
- default: '❌ Network error:'
- },
- [Config.MESSAGE_KEYS.TIMEOUT]: {
- ko: '❌ 요청 시간이 초과되었습니다.',
- zh: '❌ 请求超时。',
- default: '❌ Request timeout'
- },
- [Config.MESSAGE_KEYS.LOADING]: {
- ko: '불러오는 중...',
- zh: '加载中...',
- default: 'Loading...'
- },
- [Config.MESSAGE_KEYS.UPDATE_TITLE]: {
- ko: 'marked.min.js 업데이트 필요',
- zh: '需要更新 marked.min.js',
- default: 'marked.min.js update required'
- },
- [Config.MESSAGE_KEYS.UPDATE_NOW]: {
- ko: '확인',
- zh: '确认',
- default: 'OK'
- },
- [Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE]: {
- ko: 'Google 에서 검색하기',
- zh: '在 Google 上搜索',
- default: 'Search on Google'
- }
- },
- // 주어진 키와 변수를 사용하여 지역화된 메시지 반환
- getMessage(key, vars = {}) {
- const lang = navigator.language;
- const langKey = lang.includes('ko') ? 'ko' : lang.includes('zh') ? 'zh' : 'default';
- const template = this.MESSAGES[key]?.[langKey] || this.MESSAGES[key]?.default || '';
- return template.replace(/\$\{(.*?)\}/g, (_, k) => vars[k] || '');
- }
- };
- // 디바이스 감지 모듈: 디바이스 타입 감지 (데스크톱, 모바일, 태블릿)
- const DeviceDetector = {
- // 캐싱 변수
- _cache: {
- deviceType: null,
- isGeminiAvailable: null
- },
- // 디바이스 타입 판별 (한 번에 처리)
- getDeviceType() {
- if (this._cache.deviceType !== null) {
- return this._cache.deviceType;
- }
- const userAgent = navigator.userAgent;
- const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
- const width = window.innerWidth;
- let deviceType;
- // 1. UserAgent 기반으로 기본 판별
- const isAndroid = /Android/i.test(userAgent);
- const isIPhone = /iPhone/i.test(userAgent);
- const hasMobileKeyword = /Mobile/i.test(userAgent);
- const isWindows = /Windows NT/i.test(userAgent);
- // 2. 디바이스 타입 판별 로직
- if (isWindows && !isTouchDevice && width > 1024) {
- // Windows 기반이고 터치 디바이스가 아니며 화면이 넓으면 데스크톱으로 판별
- deviceType = 'desktop';
- } else if ((isAndroid || isIPhone) && hasMobileKeyword) {
- // Android 또는 iPhone이고 "Mobile" 키워드가 있으면 모바일
- deviceType = 'mobile';
- } else if (isAndroid && !hasMobileKeyword && width >= 768) {
- // Android이지만 "Mobile" 키워드가 없고 화면 크기가 태블릿 수준이면 태블릿
- deviceType = 'tablet';
- } else if (isTouchDevice && width <= 1024) {
- // 터치 디바이스이고 화면 크기가 작으면 모바일
- deviceType = 'mobile';
- } else {
- // 기본적으로 데스크톱으로 간주
- deviceType = 'desktop';
- }
- // 캐싱 및 로그 출력
- this._cache.deviceType = deviceType;
- console.log(`Device Type: ${deviceType.charAt(0).toUpperCase() + deviceType.slice(1)}`);
- return deviceType;
- },
- // 데스크톱 환경인지 확인
- isDesktop() {
- return this.getDeviceType() === 'desktop';
- },
- // 모바일 환경인지 확인
- isMobile() {
- return this.getDeviceType() === 'mobile';
- },
- // 태블릿 환경인지 확인
- isTablet() {
- return this.getDeviceType() === 'tablet';
- },
- // Gemini UI를 표시할 수 있는 환경인지 확인 (데스크톱 환경에서만 가능)
- isGeminiAvailable() {
- if (this._cache.isGeminiAvailable === null) {
- const hasBContext = !!document.getElementById('b_context');
- const hasBRight = !!document.querySelector('.b_right');
- this._cache.isGeminiAvailable = this.isDesktop() && (hasBContext || hasBRight);
- }
- return this._cache.isGeminiAvailable;
- },
- // 캐시 초기화
- resetCache() {
- this._cache = {
- deviceType: null,
- isGeminiAvailable: null
- };
- }
- };
- // 스타일 생성 모듈: CSS 스타일 정의
- const StyleGenerator = {
- // 공통 스타일 정의
- commonStyles: {
- '#b_results > li.b_ad a': { 'color': 'green !important' },
- '#b_context, .b_context, .b_right': {
- 'color': 'initial !important',
- 'border': 'none !important',
- 'border-width': '0 !important',
- 'border-style': 'none !important',
- 'border-collapse': 'separate !important',
- 'background': 'transparent !important'
- }
- },
- // Gemini 박스 스타일 정의
- geminiBoxStyles: {
- '#gemini-box': {
- 'width': '100%',
- 'max-width': '100%',
- 'background': `${Config.STYLES.COLORS.BACKGROUND} !important`,
- 'border': `${Config.STYLES.BORDER} !important`,
- 'border-style': 'solid !important',
- 'border-width': '1px !important',
- 'border-radius': Config.STYLES.BORDER_RADIUS,
- 'padding': `${Config.UI.DEFAULT_PADDING}px`,
- 'margin-bottom': `${Config.UI.DEFAULT_MARGIN * 2.5}px`,
- 'font-family': 'sans-serif',
- 'overflow-x': 'auto',
- 'position': 'relative',
- 'box-sizing': 'border-box',
- 'color': 'initial !important'
- }
- },
- // 테마별 스타일 정의 (라이트/다크 모드)
- themeStyles: {
- '[data-theme="light"] #gemini-box, .light #gemini-box': {
- 'background': `${Config.STYLES.COLORS.BACKGROUND} !important`,
- 'border': `1px solid ${Config.STYLES.COLORS.BORDER} !important`
- },
- '[data-theme="light"] #gemini-box h3, .light #gemini-box h3': {
- 'color': `${Config.STYLES.COLORS.TITLE} !important`
- },
- '[data-theme="light"] #gemini-content, [data-theme="light"] #gemini-content *, .light #gemini-content, .light #gemini-content *': {
- 'color': `${Config.STYLES.COLORS.TEXT} !important`,
- 'background': 'transparent !important'
- },
- '[data-theme="light"] #gemini-divider, .light #gemini-divider': {
- 'background': `${Config.STYLES.COLORS.BORDER} !important`
- },
- '[data-theme="dark"] #gemini-box, .dark #gemini-box, .b_dark #gemini-box': {
- 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
- 'border': `1px solid ${Config.STYLES.COLORS.DARK_BORDER} !important`
- },
- '@media (prefers-color-scheme: dark)': {
- '#gemini-box': {
- 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
- 'border': `1px solid ${Config.STYLES.COLORS.DARK_BORDER} !important`
- },
- '#gemini-box h3': {
- 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`
- },
- '#gemini-content, #gemini-content *': {
- 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`,
- 'background': 'transparent !important'
- },
- '#gemini-content pre': {
- 'background': `${Config.STYLES.COLORS.DARK_CODE_BLOCK_BG} !important`
- },
- '#gemini-divider': {
- 'background': `${Config.STYLES.COLORS.DARK_BORDER} !important`
- },
- '#marked-update-popup': {
- 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
- 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`
- }
- }
- },
- // Gemini 콘텐츠 스타일 정의
- contentStyles: {
- '#gemini-content': {
- 'font-size': Config.STYLES.FONT_SIZE.TEXT,
- 'line-height': '1.6',
- 'white-space': 'pre-wrap',
- 'word-wrap': 'break-word',
- 'background': 'transparent !important'
- },
- '#gemini-content pre': {
- 'background': `${Config.STYLES.COLORS.CODE_BLOCK_BG} !important`,
- 'padding': `${Config.UI.DEFAULT_MARGIN + 2}px`,
- 'border-radius': Config.STYLES.BORDER_RADIUS,
- 'overflow-x': 'auto'
- },
- '[data-theme="dark"] #gemini-content pre, .dark #gemini-content pre, .b_dark #gemini-content pre': {
- 'background': `${Config.STYLES.COLORS.DARK_CODE_BLOCK_BG} !important`
- }
- },
- // Gemini 헤더 스타일 정의
- headerStyles: {
- '#gemini-header': {
- 'display': 'flex',
- 'align-items': 'center',
- 'justify-content': 'space-between',
- 'margin-bottom': `${Config.UI.DEFAULT_MARGIN}px`
- },
- '#gemini-title-wrap': {
- 'display': 'flex',
- 'align-items': 'center'
- },
- '#gemini-logo': {
- 'width': Config.STYLES.LOGO_SIZE,
- 'height': Config.STYLES.LOGO_SIZE,
- 'margin-right': `${Config.UI.DEFAULT_MARGIN}px`
- },
- '#gemini-box h3': {
- 'margin': '0',
- 'font-size': Config.STYLES.FONT_SIZE.TITLE,
- 'font-weight': 'bold'
- },
- '#gemini-refresh-btn': {
- 'width': Config.STYLES.ICON_SIZE,
- 'height': Config.STYLES.ICON_SIZE,
- 'cursor': 'pointer',
- 'opacity': '0.6',
- 'transition': 'transform 0.5s ease'
- },
- '#gemini-refresh-btn:hover': {
- 'opacity': '1',
- 'transform': 'rotate(360deg)'
- },
- '#gemini-divider': {
- 'height': '1px',
- 'margin': `${Config.UI.DEFAULT_MARGIN}px 0`
- }
- },
- // Google 검색 버튼 스타일 정의
- googleButtonStyles: {
- '#google-search-btn': {
- 'width': '100%',
- 'max-width': '100%',
- 'font-size': Config.STYLES.FONT_SIZE.TEXT,
- 'padding': `${Config.UI.DEFAULT_MARGIN}px`,
- 'margin-bottom': `${Config.UI.DEFAULT_MARGIN * 1.25}px`,
- 'cursor': 'pointer',
- 'border': `1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}`,
- 'border-radius': Config.STYLES.BORDER_RADIUS,
- 'background-color': Config.STYLES.COLORS.BUTTON_BG,
- 'color': Config.STYLES.COLORS.TITLE,
- 'font-family': 'sans-serif',
- 'display': 'flex',
- 'align-items': 'center',
- 'justify-content': 'center',
- 'gap': `${Config.UI.DEFAULT_MARGIN}px`,
- 'transition': 'transform 0.2s ease'
- },
- '#google-search-btn img': {
- 'width': Config.STYLES.SMALL_ICON_SIZE,
- 'height': Config.STYLES.SMALL_ICON_SIZE,
- 'vertical-align': 'middle',
- 'transition': 'transform 0.2s ease'
- },
- '.desktop-useragent #google-search-btn:hover': {
- 'transform': 'scale(1.1)'
- },
- '.desktop-useragent #google-search-btn:hover img': {
- 'transform': 'scale(1.1)'
- }
- },
- // 업데이트 팝업 스타일 정의
- popupStyles: {
- '#marked-update-popup': {
- 'position': 'fixed',
- 'top': '30%',
- 'left': '50%',
- 'transform': 'translate(-50%, -50%)',
- 'background': Config.STYLES.COLORS.BACKGROUND,
- 'padding': `${Config.UI.DEFAULT_PADDING * 1.25}px`,
- 'z-index': Config.UI.Z_INDEX,
- 'border': `1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}`,
- 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
- 'text-align': 'center'
- },
- '[data-theme="dark"] #marked-update-popup, .dark #marked-update-popup, .b_dark #marked-update-popup': {
- 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
- 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`
- },
- '#marked-update-popup button': {
- 'margin-top': `${Config.UI.DEFAULT_MARGIN * 1.25}px`,
- 'padding': `${Config.UI.DEFAULT_MARGIN}px ${Config.UI.DEFAULT_PADDING}px`,
- 'cursor': 'pointer',
- 'border': `1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}`,
- 'border-radius': Config.STYLES.BORDER_RADIUS,
- 'background-color': Config.STYLES.COLORS.BUTTON_BG,
- 'color': Config.STYLES.COLORS.TITLE,
- 'font-family': 'sans-serif'
- }
- },
- // 모바일 환경 스타일 정의
- mobileStyles: {
- '.mobile-useragent #google-search-btn': {
- 'max-width': '100%',
- 'width': 'calc(100% - 16px)', // 좌우 마진 8px씩을 고려한 너비 계산
- 'margin-left': `${Config.UI.DEFAULT_MARGIN}px !important`, // 좌측 마진 8px
- 'margin-right': `${Config.UI.DEFAULT_MARGIN}px !important`, // 우측 마진 8px
- 'margin-top': `${Config.UI.DEFAULT_MARGIN}px`,
- 'margin-bottom': `${Config.UI.DEFAULT_MARGIN}px`,
- 'padding': `${Config.UI.DEFAULT_PADDING * 0.75}px`,
- 'border-radius': '16px',
- 'box-sizing': 'border-box'
- },
- '.mobile-useragent #gemini-box': {
- 'padding': `${Config.UI.DEFAULT_PADDING * 0.75}px`,
- 'border-radius': '16px'
- },
- '.mobile-useragent #b_content': {
- 'overflow': 'visible !important', // 부모 요소의 오버플로우로 인해 마진이 잘리지 않도록 설정
- 'position': 'relative'
- }
- },
- // 모든 스타일을 하나의 CSS 문자열로 변환
- generateStyles() {
- const styles = [
- this.commonStyles,
- this.geminiBoxStyles,
- this.themeStyles,
- this.contentStyles,
- this.headerStyles,
- this.googleButtonStyles,
- this.popupStyles,
- this.mobileStyles
- ];
- return styles.reduce((css, styleObj) => {
- for (const [selector, props] of Object.entries(styleObj)) {
- css += `${selector} {`;
- for (const [prop, value] of Object.entries(props)) {
- css += `${prop}: ${value};`;
- }
- css += '}';
- }
- return css;
- }, '');
- }
- };
- // 테마 관리 모듈: 테마 변경 감지 및 적용
- const ThemeManager = {
- // 테마 변경 감지 및 스타일 적용
- applyTheme() {
- const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark' ||
- document.documentElement.classList.contains('dark') ||
- document.documentElement.classList.contains('b_dark') ||
- window.matchMedia('(prefers-color-scheme: dark)').matches;
- const geminiBox = document.querySelector('#gemini-box');
- if (geminiBox) {
- geminiBox.style.background = isDarkTheme
- ? Config.STYLES.COLORS.DARK_BACKGROUND
- : Config.STYLES.COLORS.BACKGROUND;
- geminiBox.style.borderColor = isDarkTheme
- ? Config.STYLES.COLORS.DARK_BORDER
- : Config.STYLES.COLORS.BORDER;
- }
- },
- // 테마 변경 감지 설정
- observeThemeChange() {
- const observer = new MutationObserver(() => this.applyTheme());
- const targetElement = document.querySelector('#b_context') || document.querySelector('.b_right') || document.documentElement;
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
- if (targetElement !== document.documentElement) {
- observer.observe(targetElement, { attributes: true, attributeFilter: ['style', 'class'] });
- }
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.applyTheme());
- }
- };
- // 스타일 관리 모듈: 스타일 초기화 및 적용
- const Styles = {
- // 스타일 초기화
- initStyles() {
- const styleElement = document.createElement('style');
- styleElement.id = 'bing-plus-styles';
- styleElement.textContent = StyleGenerator.generateStyles();
- document.head.appendChild(styleElement);
- this.applyMobileStyles();
- },
- // 모바일 환경 스타일 적용
- applyMobileStyles() {
- if (DeviceDetector.isMobile()) {
- document.documentElement.classList.add('mobile-useragent');
- } else if (DeviceDetector.isDesktop()) {
- document.documentElement.classList.add('desktop-useragent');
- }
- }
- };
- // 유틸리티 모듈: 공통 유틸리티 함수
- const Utils = {
- // 검색 쿼리 추출
- getQuery() {
- return new URLSearchParams(location.search).get('q');
- },
- // Gemini API 키 가져오기 또는 입력받기
- getApiKey() {
- let key = localStorage.getItem('geminiApiKey');
- if (!key) {
- key = prompt(Localization.getMessage(Config.MESSAGE_KEYS.ENTER_API_KEY));
- if (key) localStorage.setItem('geminiApiKey', key);
- }
- return key;
- }
- };
- // UI 생성 모듈: DOM 요소 생성
- const UI = {
- // Google 검색 버튼 생성
- createGoogleButton(query) {
- const btn = document.createElement('button');
- btn.id = 'google-search-btn';
- btn.innerHTML = `
- <img src="${Config.ASSETS.GOOGLE_LOGO}" alt="Google Logo">
- ${Localization.getMessage(Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE)}
- `;
- btn.onclick = () => window.open(`https://www.google.com/search?q=${encodeURIComponent(query)}`, '_blank');
- return btn;
- },
- // Gemini 박스 생성
- createGeminiBox(query, apiKey) {
- const box = document.createElement('div');
- box.id = 'gemini-box';
- box.innerHTML = `
- <div id="gemini-header">
- <div id="gemini-title-wrap">
- <img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}" alt="Gemini Logo">
- <h3>Gemini Search Results</h3>
- </div>
- <img id="gemini-refresh-btn" title="Refresh" src="${Config.ASSETS.REFRESH_ICON}" />
- </div>
- <hr id="gemini-divider">
- <div id="gemini-content">${Localization.getMessage(Config.MESSAGE_KEYS.LOADING)}</div>
- `;
- box.querySelector('#gemini-refresh-btn').onclick = () => GeminiAPI.fetch(query, box.querySelector('#gemini-content'), apiKey, true);
- if (DeviceDetector.isDesktop()) {
- VersionChecker.checkMarkedJsVersion();
- }
- return box;
- },
- // Gemini UI 전체 생성 (Google 버튼 + Gemini 박스)
- createGeminiUI(query, apiKey) {
- const wrapper = document.createElement('div');
- wrapper.id = 'gemini-wrapper';
- wrapper.appendChild(this.createGoogleButton(query));
- wrapper.appendChild(this.createGeminiBox(query, apiKey));
- return wrapper;
- },
- // 기존 UI 요소 제거
- removeExistingElements() {
- document.querySelectorAll('#gemini-wrapper, #google-search-btn').forEach(el => el.remove());
- }
- };
- // Gemini API 모듈: Gemini API 호출 및 응답 처리
- const GeminiAPI = {
- // Gemini API 호출
- fetch(query, container, apiKey, force = false) {
- const cacheKey = `${Config.CACHE.PREFIX}${query}`;
- const cached = force ? null : sessionStorage.getItem(cacheKey);
- if (cached) {
- if (container) container.innerHTML = marked.parse(cached);
- return;
- }
- if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.LOADING);
- GM_xmlhttpRequest({
- method: 'POST',
- url: `${Config.API.GEMINI_URL}${Config.API.GEMINI_MODEL}:generateContent?key=${apiKey}`,
- headers: { 'Content-Type': 'application/json' },
- data: JSON.stringify({
- contents: [{ parts: [{ text: Localization.getMessage(Config.MESSAGE_KEYS.PROMPT, { query }) }] }]
- }),
- onload({ responseText }) {
- try {
- const text = JSON.parse(responseText)?.candidates?.[0]?.content?.parts?.[0]?.text;
- if (text) {
- sessionStorage.setItem(cacheKey, text);
- if (container) container.innerHTML = marked.parse(text);
- } else {
- if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.GEMINI_EMPTY);
- }
- } catch (e) {
- if (container) container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.PARSE_ERROR)} ${e.message}`;
- }
- },
- onerror(err) {
- if (container) container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.NETWORK_ERROR)} ${err.finalUrl}`;
- },
- ontimeout() {
- if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.TIMEOUT);
- }
- });
- }
- };
- // 링크 정리 모듈: 중간 URL 제거
- const LinkCleaner = {
- // URL 디코딩
- decodeRealUrl(url, key) {
- const param = new URL(url).searchParams.get(key)?.replace(/^a1/, '');
- if (!param) return null;
- try {
- const decoded = decodeURIComponent(atob(param.replace(/_/g, '/').replace(/-/g, '+')));
- return decoded.startsWith('/') ? location.origin + decoded : decoded;
- } catch {
- return null;
- }
- },
- // 실제 URL로 변환
- resolveRealUrl(url) {
- const rules = [
- { pattern: /bing\.com\/(ck\/a|aclick)/, key: 'u' },
- { pattern: /so\.com\/search\/eclk/, key: 'aurl' }
- ];
- for (const { pattern, key } of rules) {
- if (pattern.test(url)) {
- const real = this.decodeRealUrl(url, key);
- if (real && real !== url) return real;
- }
- }
- return url;
- },
- // 모든 링크를 실제 URL로 변환
- convertLinksToReal(root) {
- root.querySelectorAll('a[href]').forEach(a => {
- const realUrl = this.resolveRealUrl(a.href);
- if (realUrl && realUrl !== a.href) a.href = realUrl;
- });
- }
- };
- // 버전 확인 모듈: marked.js 버전 체크
- const VersionChecker = {
- // 버전 비교
- compareVersions(current, latest) {
- const currentParts = current.split('.').map(Number);
- const latestParts = latest.split('.').map(Number);
- for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
- const c = currentParts[i] || 0;
- const l = latestParts[i] || 0;
- if (c < l) return -1;
- if (c > l) return 1;
- }
- return 0;
- },
- // marked.js 버전 체크 및 업데이트 알림 표시
- checkMarkedJsVersion() {
- localStorage.setItem(Config.STORAGE_KEYS.CURRENT_VERSION, Config.VERSIONS.MARKED_VERSION);
- GM_xmlhttpRequest({
- method: 'GET',
- url: Config.API.MARKED_CDN_URL,
- onload: ({ responseText }) => {
- try {
- const latest = JSON.parse(responseText).version;
- localStorage.setItem(Config.STORAGE_KEYS.LATEST_VERSION, latest);
- const lastNotified = localStorage.getItem(Config.STORAGE_KEYS.LAST_NOTIFIED);
- if (this.compareVersions(Config.VERSIONS.MARKED_VERSION, latest) < 0 &&
- (!lastNotified || this.compareVersions(lastNotified, latest) < 0)) {
- const existingPopup = document.getElementById('marked-update-popup');
- if (existingPopup) existingPopup.remove();
- const popup = document.createElement('div');
- popup.id = 'marked-update-popup';
- popup.innerHTML = `
- <p><b>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_TITLE)}</b></p>
- <p>Current: ${Config.VERSIONS.MARKED_VERSION}<br>Latest: ${latest}</p>
- <button>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_NOW)}</button>
- `;
- popup.querySelector('button').onclick = () => {
- localStorage.setItem(Config.STORAGE_KEYS.LAST_NOTIFIED, latest);
- popup.remove();
- };
- document.body.appendChild(popup);
- }
- } catch (e) {
- console.warn('marked.min.js version check error:', e.message);
- }
- },
- onerror: () => console.warn('marked.min.js version check request failed')
- });
- }
- };
- // 이벤트 핸들러 모듈: URL 및 DOM 변경 감지
- const EventHandler = {
- // URL 변경 감지 및 처리
- observeUrlChange(onChangeCallback) {
- let lastUrl = location.href;
- const checkUrlChange = () => {
- if (location.href !== lastUrl) {
- lastUrl = location.href;
- onChangeCallback();
- }
- };
- const originalPushState = history.pushState;
- history.pushState = function (...args) {
- originalPushState.apply(this, args);
- checkUrlChange();
- };
- const originalReplaceState = history.replaceState;
- history.replaceState = function (...args) {
- originalReplaceState.apply(this, args);
- checkUrlChange();
- };
- window.addEventListener('popstate', checkUrlChange);
- const observer = new MutationObserver(checkUrlChange);
- const targetNode = document.querySelector('head > title') || document.body;
- observer.observe(targetNode, { childList: true, subtree: true });
- }
- };
- // 렌더링 상태 관리 모듈: UI 렌더링 상태 관리
- const RenderState = {
- isRendering: false,
- // 렌더링 시작
- startRendering() {
- if (this.isRendering) return false;
- this.isRendering = true;
- return true;
- },
- // 렌더링 완료
- finishRendering() {
- this.isRendering = false;
- }
- };
- // UI 렌더링 모듈: UI 렌더링 로직
- const UIRenderer = {
- // 데스크톱 환경 렌더링
- renderDesktop(query, apiKey) {
- // Gemini UI가 표시될 수 있는지 확인 (데스크톱에서 강제로 렌더링 보장)
- const contextTarget = document.getElementById('b_context') || document.querySelector('.b_right');
- if (!contextTarget) return false;
- requestAnimationFrame(() => {
- const wrapper = UI.createGeminiUI(query, apiKey);
- contextTarget.prepend(wrapper);
- window.requestIdleCallback(() => {
- const content = wrapper.querySelector('#gemini-content');
- if (content) {
- const cache = sessionStorage.getItem(`${Config.CACHE.PREFIX}${query}`);
- if (cache) {
- content.innerHTML = marked.parse(cache);
- } else {
- window.requestIdleCallback(() => GeminiAPI.fetch(query, content, apiKey));
- }
- }
- RenderState.finishRendering();
- });
- });
- return true;
- },
- // 모바일 환경 렌더링
- renderMobile(query) {
- const contentTarget = document.getElementById('b_content');
- if (!contentTarget) return false;
- requestAnimationFrame(() => {
- const googleBtn = UI.createGoogleButton(query);
- // 부모 요소에 스타일 적용 (필요 시)
- contentTarget.parentNode.style.overflow = 'visible';
- contentTarget.parentNode.style.position = 'relative';
- contentTarget.parentNode.insertBefore(googleBtn, contentTarget);
- RenderState.finishRendering();
- });
- return true;
- },
- // 태블릿 환경 렌더링 (아무 UI도 표시하지 않음, Google 검색 버튼 포함)
- renderTablet() {
- // 태블릿에서는 Gemini UI와 Google 검색 버튼 모두 표시하지 않음
- RenderState.finishRendering();
- return true;
- },
- // 메인 렌더링 함수
- render() {
- if (!RenderState.startRendering()) return;
- const query = Utils.getQuery();
- if (!query) {
- RenderState.finishRendering();
- return;
- }
- UI.removeExistingElements();
- const deviceType = DeviceDetector.getDeviceType();
- if (deviceType === 'desktop') {
- const apiKey = Utils.getApiKey();
- if (!apiKey) {
- RenderState.finishRendering();
- return;
- }
- this.renderDesktop(query, apiKey);
- } else if (deviceType === 'mobile') {
- this.renderMobile(query);
- } else if (deviceType === 'tablet') {
- this.renderTablet();
- } else {
- RenderState.finishRendering();
- }
- }
- };
- // 초기화 모듈: 스크립트 초기화
- const Initializer = {
- // 초기화 실행
- init() {
- const initialize = () => {
- Styles.initStyles();
- ThemeManager.applyTheme();
- LinkCleaner.convertLinksToReal(document);
- // DOM 준비 완료 후 렌더링
- const checkAndRender = () => {
- if (document.getElementById('b_context') || document.querySelector('.b_right')) {
- UIRenderer.render();
- } else {
- setTimeout(checkAndRender, 100); // 100ms 후 재시도
- }
- };
- checkAndRender();
- EventHandler.observeUrlChange(() => {
- UIRenderer.render();
- LinkCleaner.convertLinksToReal(document);
- });
- ThemeManager.observeThemeChange();
- };
- if (document.readyState === 'complete' || document.readyState === 'interactive') {
- setTimeout(initialize, 1);
- } else {
- document.addEventListener('DOMContentLoaded', initialize);
- }
- }
- };
- // 스크립트 실행
- Initializer.init();
- })();