// ==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/
// ==/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 = `
<img src="https://www.gstatic.com/marketing-cms/assets/images/bc/1a/a310779347afa1927672dc66a98d/g.png=s48-fcrop64=1,00000000ffffffff-rw" alt="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 = `
<div id="gemini-header">
<div id="gemini-title-wrap">
<img id="gemini-logo" src="https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg" alt="Gemini Logo">
<h3>Gemini Search Results</h3>
</div>
<img id="gemini-refresh-btn" title="Refresh" src="https://www.svgrepo.com/show/533704/refresh-cw-alt-3.svg" />
</div>
<hr id="gemini-divider">
<div id="gemini-content">${Localization.getMessage('loading')}</div>
`;
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 = `
<p><b>${Localization.getMessage('updateTitle')}</b></p>
<p>현재 버전: ${Config.MARKED_VERSION}<br>최신 버전: ${latest}</p>
<button>${Localization.getMessage('updateNow')}</button>
`;
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();
})();