// ==UserScript== // @name Bing Plus // @version 3.0 // @description Display Gemini response results next to Bing search results and speed up searches by eliminating intermediate URLs. // @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/ // @downloadURL none // ==/UserScript== (function () { 'use strict'; // 설정 모듈 const Config = { GEMINI_MODEL: 'gemini-2.0-flash', MARKED_VERSION: '15.0.7', CACHE_PREFIX: 'gemini_cache_', STORAGE_KEYS: { CURRENT_VERSION: 'markedCurrentVersion', LATEST_VERSION: 'markedLatestVersion', LAST_NOTIFIED: 'markedLastNotifiedVersion' } }; // 지역화 모듈 const Localization = { MESSAGES: { prompt: { ko: `"${'${query}'}"에 대한 정보를 마크다운 형식으로 작성해줘`, zh: `请以标记格式填写有关\"${'${query}'}\"的信息。`, default: `Please write information about \"${'${query}'}\" in markdown format` }, enterApiKey: { ko: 'Gemini API 키를 입력하세요:', zh: '请输入 Gemini API 密钥:', default: 'Please enter your Gemini API key:' }, geminiEmpty: { ko: '⚠️ Gemini 응답이 비어있습니다.', zh: '⚠️ Gemini 返回为空。', default: '⚠️ Gemini response is empty.' }, parseError: { ko: '❌ 파싱 오류:', zh: '❌ 解析错误:', default: '❌ Parsing error:' }, networkError: { ko: '❌ 네트워크 오류:', zh: '❌ 网络错误:', default: '❌ Network error:' }, timeout: { ko: '❌ 요청 시간이 초과되었습니다.', zh: '❌ 请求超时。', default: '❌ Request timeout' }, loading: { ko: '불러오는 중...', zh: '加载中...', default: 'Loading...' }, updateTitle: { ko: 'marked.min.js 업데이트 필요', zh: '需要更新 marked.min.js', default: 'marked.min.js update required' }, updateNow: { ko: '확인', zh: '确认', default: 'OK' }, searchongoogle: { ko: 'Google 에서 검색하기', zh: '在 Google 上搜索', default: 'Search on Google' } }, /** * 사용자의 언어에 따라 지역화된 메시지를 반환합니다. * @param {string} key - 메시지 키 * @param {Object} vars - 메시지에 삽입할 변수 * @returns {string} 지역화된 메시지 */ 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 Styles = { /** * 페이지에 CSS 스타일을 삽입합니다. (GM_addStyle 사용) */ inject() { GM_addStyle(` /* 광고 링크 스타일 */ #b_results > li.b_ad a { color: green !important; } /* Gemini 박스 컨테이너 */ #gemini-box { max-width: 400px; background: #fff; border: 1px solid #e0e0e0; padding: 16px; margin-bottom: 20px; font-family: sans-serif; overflow-x: auto; position: relative; } /* Gemini 헤더 레이아웃 */ #gemini-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } /* Gemini 제목 래퍼 */ #gemini-title-wrap { display: flex; align-items: center; } /* Gemini 로고 스타일 */ #gemini-logo { width: 24px; height: 24px; margin-right: 8px; } /* Gemini 제목 */ #gemini-box h3 { margin: 0; font-size: 18px; color: #202124; font-weight: bold; } /* 새로고침 버튼 스타일 */ #gemini-refresh-btn { width: 20px; height: 20px; cursor: pointer; opacity: 0.6; transition: transform 0.5s ease; } /* 새로고침 버튼 호버 효과 */ #gemini-refresh-btn:hover { opacity: 1; transform: rotate(360deg); } /* 구분선 스타일 */ #gemini-divider { height: 1px; background: #e0e0e0; margin: 8px 0; } /* Gemini 콘텐츠 스타일 */ #gemini-content { font-size: 14px; line-height: 1.6; color: #333; white-space: pre-wrap; word-wrap: break-word; } /* 코드 블록 스타일 */ #gemini-content pre { background: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; } /* Google 검색 버튼 스타일 */ #google-search-btn { width: 100%; font-size: 14px; padding: 8px; margin-bottom: 10px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background-color: #f0f3ff; color: #202124; font-family: sans-serif; display: flex; align-items: center; justify-content: center; gap: 8px; } /* Google 버튼 이미지 */ #google-search-btn img { width: 16px; height: 16px; vertical-align: middle; } /* 버전 업데이트 팝업 스타일 */ #marked-update-popup { position: fixed; top: 30%; left: 50%; transform: translate(-50%, -50%); background: #fff; padding: 20px; z-index: 9999; border: 1px solid #ccc; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; } /* 팝업 버튼 스타일 */ #marked-update-popup button { margin-top: 10px; padding: 8px 16px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background-color: #f0f3ff; color: #202124; font-family: sans-serif; } `); } }; // 유틸리티 모듈 const Utils = { /** * 디바이스가 데스크톱인지 확인합니다. (화면 너비 > 768px, 모바일 아님) * @returns {boolean} */ isDesktop() { return window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent); }, /** * Gemini UI를 표시할 수 있는지 확인합니다. * @returns {boolean} */ isGeminiAvailable() { return this.isDesktop() && !!document.getElementById('b_context'); }, /** * URL 파라미터에서 검색 쿼리를 가져옵니다. * @returns {string|null} */ getQuery() { return new URLSearchParams(location.search).get('q'); }, /** * Gemini API 키를 가져오거나 사용자에게 입력받습니다. * @returns {string|null} */ getApiKey() { let key = localStorage.getItem('geminiApiKey'); if (!key) { key = prompt(Localization.getMessage('enterApiKey')); if (key) localStorage.setItem('geminiApiKey', key); } return key; } }; // UI 모듈 const UI = { /** * Google 검색 버튼을 생성합니다. * @param {string} query - 검색 쿼리 * @returns {HTMLElement} */ createGoogleButton(query) { const btn = document.createElement('button'); btn.id = 'google-search-btn'; btn.innerHTML = ` Google Logo ${Localization.getMessage('searchongoogle')} `; btn.onclick = () => window.open(`https://www.google.com/search?q=${encodeURIComponent(query)}`, '_blank'); return btn; }, /** * Gemini 결과 박스를 생성합니다. * @param {string} query - 검색 쿼리 * @param {string} apiKey - Gemini API 키 * @returns {HTMLElement} */ createGeminiBox(query, apiKey) { const box = document.createElement('div'); box.id = 'gemini-box'; box.innerHTML = `

Gemini Search Results


${Localization.getMessage('loading')}
`; box.querySelector('#gemini-refresh-btn').onclick = () => GeminiAPI.fetch(query, box.querySelector('#gemini-content'), apiKey, true); return box; }, /** * 전체 Gemini UI를 생성합니다. * @param {string} query - 검색 쿼리 * @param {string} apiKey - Gemini API 키 * @returns {HTMLElement} */ createGeminiUI(query, apiKey) { const wrapper = document.createElement('div'); wrapper.appendChild(this.createGoogleButton(query)); wrapper.appendChild(this.createGeminiBox(query, apiKey)); return wrapper; } }; // Gemini API 모듈 const GeminiAPI = { /** * Gemini 결과를 가져와 컨테이너를 업데이트합니다. * @param {string} query - 검색 쿼리 * @param {HTMLElement} container - 콘텐츠 컨테이너 * @param {string} apiKey - Gemini API 키 * @param {boolean} force - 강제 새로고침 여부 */ fetch(query, container, apiKey, force = false) { // marked.min.js 버전 확인 VersionChecker.checkMarkedJsVersion(); const cacheKey = `${Config.CACHE_PREFIX}${query}`; if (!force) { const cached = sessionStorage.getItem(cacheKey); if (cached) { container.innerHTML = marked.parse(cached); return; } } container.textContent = Localization.getMessage('loading'); GM_xmlhttpRequest({ method: 'POST', url: `https://generativelanguage.googleapis.com/v1beta/models/${Config.GEMINI_MODEL}:generateContent?key=${apiKey}`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ contents: [{ parts: [{ text: Localization.getMessage('prompt', { query }) }] }] }), onload({ responseText }) { try { const text = JSON.parse(responseText)?.candidates?.[0]?.content?.parts?.[0]?.text; if (text) { sessionStorage.setItem(cacheKey, text); container.innerHTML = marked.parse(text); } else { container.textContent = Localization.getMessage('geminiEmpty'); } } catch (e) { container.textContent = `${Localization.getMessage('parseError')} ${e.message}`; } }, onerror: err => container.textContent = `${Localization.getMessage('networkError')} ${err.finalUrl}`, ontimeout: () => container.textContent = Localization.getMessage('timeout') }); } }; // 링크 정리 모듈 const LinkCleaner = { /** * URL 파라미터를 실제 목적지로 디코딩합니다. * @param {string} url - 디코딩할 URL * @param {string} key - 파라미터 키 * @returns {string|null} */ 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을 실제 목적지 URL로 변환합니다. * @param {string} url - 변환할 URL * @returns {string} */ 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로 변환합니다. * @param {HTMLElement} root - 처리할 루트 요소 */ convertLinksToReal(root) { root.querySelectorAll('a[href]').forEach(a => { const realUrl = this.resolveRealUrl(a.href); if (realUrl && realUrl !== a.href) a.href = realUrl; }); } }; // 버전 확인 모듈 const VersionChecker = { /** * 두 버전 문자열을 비교합니다. * @param {string} current - 현재 버전 * @param {string} latest - 최신 버전 * @returns {number} -1 (current < latest), 0 (equal), 1 (current > latest) */ 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.min.js의 최신 버전을 확인하고 필요 시 팝업을 표시합니다. */ checkMarkedJsVersion() { // 현재 버전 저장 localStorage.setItem(Config.STORAGE_KEYS.CURRENT_VERSION, Config.MARKED_VERSION); GM_xmlhttpRequest({ method: 'GET', url: 'https://api.cdnjs.com/libraries/marked', onload({ responseText }) { try { const latest = JSON.parse(responseText).version; console.log(`현재 버전: ${Config.MARKED_VERSION}, 최신 버전: ${latest}`); // 최신 버전 저장 localStorage.setItem(Config.STORAGE_KEYS.LATEST_VERSION, latest); // 이전에 알림 받은 버전 const lastNotified = localStorage.getItem(Config.STORAGE_KEYS.LAST_NOTIFIED); console.log(`마지막 알림 버전: ${lastNotified || '없음'}`); // 팝업 표시 조건: 현재 버전 < 최신 버전 && (알림 받은 적 없거나 최신 버전 > 마지막 알림 버전) if (VersionChecker.compareVersions(Config.MARKED_VERSION, latest) < 0 && (!lastNotified || VersionChecker.compareVersions(lastNotified, latest) < 0)) { console.log('팝업 표시 조건 충족'); // 기존 팝업 제거 const existingPopup = document.getElementById('marked-update-popup'); if (existingPopup) { existingPopup.remove(); console.log('기존 팝업 제거'); } // 새 팝업 생성 const popup = document.createElement('div'); popup.id = 'marked-update-popup'; popup.innerHTML = `

${Localization.getMessage('updateTitle')}

현재 버전: ${Config.MARKED_VERSION}
최신 버전: ${latest}

`; popup.querySelector('button').onclick = () => { // 알림 받은 버전 기록 localStorage.setItem(Config.STORAGE_KEYS.LAST_NOTIFIED, latest); console.log(`알림 기록: ${latest}`); popup.remove(); }; document.body.appendChild(popup); console.log('새 팝업 표시'); } else { console.log('팝업 표시 조건 미충족'); } } catch (e) { console.warn('marked.min.js 버전 확인 중 오류:', e.message); } }, onerror: () => console.warn('marked.min.js 버전 확인 요청 실패') }); } }; // 메인 모듈 const Main = { /** * 조건이 충족되면 Gemini UI를 렌더링합니다. */ renderGemini() { if (!Utils.isGeminiAvailable()) return; const query = Utils.getQuery(); if (!query || document.getElementById('gemini-box')) return; const apiKey = Utils.getApiKey(); if (!apiKey) return; const ui = UI.createGeminiUI(query, apiKey); document.getElementById('b_context').prepend(ui); const content = ui.querySelector('#gemini-content'); const cache = sessionStorage.getItem(`${Config.CACHE_PREFIX}${query}`); content.innerHTML = cache ? marked.parse(cache) : Localization.getMessage('loading'); if (!cache) GeminiAPI.fetch(query, content, apiKey); }, /** * URL 변경을 감지하여 UI를 다시 렌더링하고 링크를 정리합니다. */ observeUrlChange() { let lastUrl = location.href; new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; this.renderGemini(); LinkCleaner.convertLinksToReal(document); } }).observe(document.body, { childList: true, subtree: true }); }, /** * 스크립트를 초기화합니다. */ init() { Styles.inject(); LinkCleaner.convertLinksToReal(document); this.renderGemini(); this.observeUrlChange(); } }; // 스크립트 시작 Main.init(); })();