Bing Plus

Link Bing search results directly to real URL, show Gemini search results on the right side (PC only), and highlight ad links in green. Gemini response is now cached across pages.

目前為 2025-04-06 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Bing Plus
// @version      2.0
// @description  Link Bing search results directly to real URL, show Gemini search results on the right side (PC only), and highlight ad links in green. Gemini response is now cached across pages.
// @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 MARKED_VERSION = '15.0.7';
    const GEMINI_MODEL = 'gemini-2.0-flash';
    const isDesktop = () => window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent);

    /*** 🌐 다국어 메시지 관리 ***/
    const getLocalizedMessage = (key, vars = {}) => {
        const lang = navigator.language;
        const messages = {
            'prompt': {
                'ko': `"${vars.query}"에 대한 정보를 마크다운 형식으로 작성해줘`,
                'zh': `请以标记格式填写有关"${vars.query}"的信息。`,
                'default': `Please write information about "${vars.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'
            },
        };

        const langKey = lang.includes('ko') ? 'ko' : lang.includes('zh') ? 'zh' : 'default';
        const template = messages[key]?.[langKey] || messages[key]?.default || '';
        return template.replace(/\$\{(.*?)\}/g, (_, k) => vars[k] || '');
    };

    /*** 💡 유틸 함수 모음 ***/
    const compareVersions = (v1, v2) => {
        const a = v1.split('.').map(Number), b = v2.split('.').map(Number);
        for (let i = 0; i < Math.max(a.length, b.length); i++) {
            if ((a[i] || 0) < (b[i] || 0)) return -1;
            if ((a[i] || 0) > (b[i] || 0)) return 1;
        }
        return 0;
    };

    const decodeRedirectUrl = (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;
        }
    };

    const 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 = decodeRedirectUrl(url, key);
                if (real && real !== url) return real;
            }
        }
        return url;
    };

    const convertLinksToReal = (root) => {
        root.querySelectorAll('a[href]').forEach(a => {
            const realUrl = resolveRealUrl(a.href);
            if (realUrl && realUrl !== a.href) a.href = realUrl;
        });
    };

    /*** 🎨 스타일 정의 ***/
    GM_addStyle(`#b_results > li.b_ad a { color: green !important; }`);
    GM_addStyle(`
        #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-header {
            display: flex; align-items: center; justify-content: space-between;
            margin-bottom: 8px;
        }
        #gemini-title-wrap {
            display: flex; align-items: center;
        }
        #gemini-logo {
            width: 24px; height: 24px; margin-right: 8px;
        }
        #gemini-box h3 {
            margin: 0; font-size: 18px; color: #202124;
        }
        #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-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;
        }
    `);

    /*** 🔑 Gemini API 키 관리 ***/
    const getApiKey = () => {
        if (!isDesktop()) return null;
        let key = localStorage.getItem('geminiApiKey');
        if (!key) {
            key = prompt(getLocalizedMessage('enterApiKey'));
            if (key) localStorage.setItem('geminiApiKey', key);
        }
        return key;
    };

    /*** ⚠️ marked.js 최신 버전 확인 알림 ***/
    const checkMarkedJsVersion = () => {
        if (localStorage.getItem('markedUpdateDismissed') === MARKED_VERSION) return;
        GM_xmlhttpRequest({
            method: 'GET',
            url: 'https://api.cdnjs.com/libraries/marked',
            onload({ responseText }) {
                try {
                    const latest = JSON.parse(responseText).version;
                    if (compareVersions(MARKED_VERSION, latest) < 0) {
                        const warning = document.createElement('div');
                        warning.innerHTML = `
                            <div style="position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:20px;z-index:9999;border:1px solid #ccc;">
                                <p><b>${getLocalizedMessage('updateTitle')}</b></p>
                                <p>현재: ${MARKED_VERSION}<br>최신: ${latest}</p>
                                <button>${getLocalizedMessage('updateNow')}</button>
                            </div>`;
                        warning.querySelector('button').onclick = () => {
                            localStorage.setItem('markedUpdateDismissed', MARKED_VERSION);
                            warning.remove();
                        };
                        document.body.appendChild(warning);
                    }
                } catch {}
            }
        });
    };

    /*** 📡 Gemini 응답 요청 함수 ***/
    const fetchGeminiResponse = (query, container, apiKey, force = false) => {
        const cacheKey = `gemini_cache_${query}`;
        if (!force) {
            const cached = sessionStorage.getItem(cacheKey);
            if (cached) {
                container.innerHTML = marked.parse(cached);
                return;
            }
        }

        container.textContent = getLocalizedMessage('loading');
        checkMarkedJsVersion();

        GM_xmlhttpRequest({
            method: 'POST',
            url: `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${apiKey}`,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify({ contents: [{ parts: [{ text: getLocalizedMessage('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 = getLocalizedMessage('geminiEmpty');
                    }
                } catch (e) {
                    container.textContent = `${getLocalizedMessage('parseError')} ${e.message}`;
                }
            },
            onerror(err) {
                container.textContent = `${getLocalizedMessage('networkError')} ${err.finalUrl}`;
            },
            ontimeout() {
                container.textContent = getLocalizedMessage('timeout');
            }
        });
    };

    /*** 🧠 Gemini 박스 DOM 생성 ***/
    const createGeminiBox = (query, apiKey) => {
        const wrapper = document.createElement('div');
        wrapper.id = 'gemini-box';
        wrapper.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">${getLocalizedMessage('loading')}</div>
        `;
        const refreshBtn = wrapper.querySelector('#gemini-refresh-btn');
        const content = wrapper.querySelector('#gemini-content');
        refreshBtn.onclick = () => fetchGeminiResponse(query, content, apiKey, true);
        return wrapper;
    };

    /*** 🔄 Gemini 박스 렌더링 ***/
    const renderGeminiOnSearch = () => {
        if (!isDesktop()) return;

        const query = new URLSearchParams(location.search).get('q');
        if (!query) return;

        const sidebar = document.getElementById('b_context');
        if (!sidebar || document.getElementById('gemini-box')) return;

        const apiKey = getApiKey();
        if (!apiKey) return;

        const geminiBox = createGeminiBox(query, apiKey);
        sidebar.prepend(geminiBox);

        const contentDiv = geminiBox.querySelector('#gemini-content');
        const cache = sessionStorage.getItem(`gemini_cache_${query}`);
        contentDiv.innerHTML = cache ? marked.parse(cache) : getLocalizedMessage('loading');
        if (!cache) fetchGeminiResponse(query, contentDiv, apiKey);
    };

    /*** 🚀 초기 실행 및 URL 변경 감지 ***/
    const init = () => {
        convertLinksToReal(document);
        renderGeminiOnSearch();

        let lastUrl = location.href;
        new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                renderGeminiOnSearch();
                convertLinksToReal(document);
            }
        }).observe(document.body, { childList: true, subtree: true });
    };

    init();

})();

QingJ © 2025

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