Bing Plus

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

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址