Bing Plus

Display Gemini response results next to Bing search results and speed up searches by eliminating intermediate URLs.

当前为 2025-04-16 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();