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-04 提交的版本,查看 最新版本

// ==UserScript==
// @name         Bing Plus
// @version      1.5
// @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 CURRENT_MARKED_VERSION = '15.0.7';

    const compareVersions = (v1, v2) => {
        const a = v1.split('.').map(Number);
        const b = v2.split('.').map(Number);
        for (let i = 0; i < Math.max(a.length, b.length); i++) {
            const n1 = a[i] || 0, n2 = b[i] || 0;
            if (n1 < n2) return -1;
            if (n1 > n2) return 1;
        }
        return 0;
    };

    const checkMarkedVersion = () => {
        if (localStorage.getItem('markedUpdateDismissed') === CURRENT_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(CURRENT_MARKED_VERSION, latest) < 0) {
                        const box = document.createElement('div');
                        box.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>marked.min.js 업데이트 필요</b></p>
                                <p>현재: ${CURRENT_MARKED_VERSION}<br>최신: ${latest}</p>
                                <button>확인</button>
                            </div>`;
                        box.querySelector('button').onclick = () => {
                            localStorage.setItem('markedUpdateDismissed', CURRENT_MARKED_VERSION);
                            box.remove();
                        };
                        document.body.appendChild(box);
                    }
                } catch (e) {}
            }
        });
    };

    const isPC = () => window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent);

    const getPromptQuery = q => {
        const lang = navigator.language;
        if (lang.includes('ko')) return `"${q}"에 대한 정보를 마크다운 형식으로 작성해줘`;
        if (lang.includes('zh')) return `请以标记格式填写有关"${q}"的信息。`;
        return `Please write information about "${q}" in markdown format`;
    };

    const decodeRedirectUrl = (url, key) => {
        const encoded = new URL(url).searchParams.get(key)?.replace(/^a1/, '');
        if (!encoded) return null;
        try {
            const decoded = decodeURIComponent(atob(encoded.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 rule of rules) {
            if (rule.pattern.test(url)) {
                const real = decodeRedirectUrl(url, rule.key);
                if (real && real !== url) return real;
            }
        }
        return url;
    };

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

    GM_addStyle(`#b_results > li.b_ad a { color: green !important; }`);

    // 💡 Gemini 박스 UI 생성
    const createGeminiBox = () => {
        const box = document.createElement('div');
        box.id = 'gemini-box';
        box.innerHTML = `
            <div id="gemini-header">
                <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>
            <hr id="gemini-divider">
            <div id="gemini-content">Loading...</div>
        `;
        return box;
    };

    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; }
        #gemini-header { display:flex; align-items:center; margin-bottom:8px; }
        #gemini-logo { width:24px; height:24px; margin-right:8px; }
        #gemini-box h3 { margin:0; font-size:18px; color:#202124; }
        #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; }
    `);

    const apiKey = isPC() ? (localStorage.getItem('geminiApiKey') || prompt('Gemini API 키를 입력하세요:')) : null;
    if (apiKey) localStorage.setItem('geminiApiKey', apiKey);

    const fetchGeminiResult = (query, box) => {
        const cached = sessionStorage.getItem(`gemini_cache_${query}`);
        if (cached) {
            box.innerHTML = marked.parse(cached);
            return;
        }

        checkMarkedVersion();

        GM_xmlhttpRequest({
            method: 'POST',
            url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify({ contents: [{ parts: [{ text: getPromptQuery(query) }] }] }),
            onload({ responseText }) {
                try {
                    const data = JSON.parse(responseText);
                    const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
                    if (text) {
                        sessionStorage.setItem(`gemini_cache_${query}`, text);
                        box.innerHTML = marked.parse(text);
                    } else {
                        box.textContent = '⚠️ No valid content in response.';
                    }
                } catch (e) {
                    box.textContent = `❌ Error parsing response: ${e.message}`;
                }
            },
            onerror(err) {
                box.textContent = `❌ API request failed due to a network error.\n\n🔗 ${err.finalUrl}`;
            },
            ontimeout() {
                box.textContent = '❌ API request failed: Timeout';
            }
        });
    };

    const ensureGeminiBox = () => {
        if (!isPC()) return;

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

        const context = document.getElementById('b_context');
        if (!context) return;

        let box = document.getElementById('gemini-box');
        if (!box) {
            box = createGeminiBox();
            context.prepend(box);
        }

        const existing = sessionStorage.getItem(`gemini_cache_${query}`);
        if (existing) {
            box.querySelector('#gemini-content').innerHTML = marked.parse(existing);
        } else {
            box.querySelector('#gemini-content').innerText = 'Loading...';
            fetchGeminiResult(query, box.querySelector('#gemini-content'));
        }
    };

    // 📌 감지: 검색 페이지 이동 시 반응
    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            ensureGeminiBox();
            convertLinks(document);
        }
    }).observe(document.body, { childList: true, subtree: true });

    // 초기 실행
    convertLinks(document);
    ensureGeminiBox();

})();