Greasy Fork

Greasy Fork is available in English.

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'; // 사용 중인 marked.js 버전
    const GEMINI_MODEL = 'gemini-2.0-flash'; // Gemini API 모델 이름

    // 현재 디바이스가 데스크톱인지 판단
    const isDesktop = () => window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent);

    /*** 🧰 유틸리티 함수 모음 ***/

    // 버전 비교 함수 (현재 vs 최신 marked.js)
    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;
    };

    // 브라우저 언어에 따라 Gemini에 보낼 프롬프트 생성
    const getLocalizedPrompt = (query) => {
        const lang = navigator.language;
        if (lang.includes('ko')) return `"${query}"에 대한 정보를 마크다운 형식으로 작성해줘`;
        if (lang.includes('zh')) return `请以标记格式填写有关"${query}"的信息。`;
        return `Please write information about "${query}" in markdown format`;
    };

    // 추적 URL의 base64 파라미터를 디코딩하여 실제 URL 반환
    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;
        }
    };

    // 특정 redirect URL 패턴에 따라 실제 URL 반환
    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;
    };

    // 검색결과 내의 모든 링크를 실제 URL로 변환
    const convertLinksToReal = (root) => {
        root.querySelectorAll('a[href]').forEach(a => {
            const realUrl = resolveRealUrl(a.href);
            if (realUrl && realUrl !== a.href) a.href = realUrl;
        });
    };

    /*** 🎨 CSS 스타일 적용 ***/
    GM_addStyle(`#b_results > li.b_ad a { color: green !important; }`); // 광고 링크 강조

    // Gemini 박스 스타일
    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('Gemini API 키를 입력하세요:');
            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>marked.min.js 업데이트 필요</b></p>
                                <p>현재: ${MARKED_VERSION}<br>최신: ${latest}</p>
                                <button>확인</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 = '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: getLocalizedPrompt(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 = '⚠️ Gemini response is empty.';
                    }
                } catch (e) {
                    container.textContent = `❌ Parsing error: ${e.message}`;
                }
            },
            onerror(err) {
                container.textContent = `❌ Network error: ${err.finalUrl}`;
            },
            ontimeout() {
                container.textContent = '❌ Request timeout';
            }
        });
    };

    /*** 🧱 Gemini UI 요소 생성 및 초기화 ***/
    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">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) : 'Loading...';
        if (!cache) fetchGeminiResponse(query, contentDiv, apiKey);
    };

    /*** 🚀 초기 실행 및 URL 변경 감지 핸들링 ***/
    const init = () => {
        convertLinksToReal(document); // 링크 실주소 변환
        renderGeminiOnSearch();       // Gemini 박스 렌더링

        // SPA 대응: URL 변경 감지하여 다시 처리
        let lastUrl = location.href;
        new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                renderGeminiOnSearch();
                convertLinksToReal(document);
            }
        }).observe(document.body, { childList: true, subtree: true });
    };

    init(); // 스크립트 시작

})();