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

// ==UserScript==
// @name         Bing Plus
// @version      1.8
// @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_Name = 'gemini-2.0-flash'
    const isDesktop = () => window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent); // 데스크탑 환경 여부 판별 함수

    /*** ─────────────────────────────────────────────
     *  📦 유틸리티 함수 모음
     * ───────────────────────────────────────────── */

    // 버전 비교 함수 (v1이 더 낮으면 -1, 같으면 0, 높으면 1)
    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의 파라미터 값을 디코딩하여 실제 링크를 추출하는 함수
    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;
        }
    };

    // 특정 규칙에 따라 실제 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;
        });
    };

    /*** ─────────────────────────────────────────────
     *  🎨 스타일 추가 (광고 강조, Gemini 박스 디자인)
     * ───────────────────────────────────────────── */
    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; }
        #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; }
    `);

    /*** ─────────────────────────────────────────────
     *  🔐 API 키 처리 및 marked.js 버전 체크
     * ───────────────────────────────────────────── */

    // 사용자가 입력한 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) => {
        const cacheKey = `gemini_cache_${query}`;
        const cached = sessionStorage.getItem(cacheKey);

        // 세션 캐시가 있으면 바로 표시
        if (cached) {
            container.innerHTML = marked.parse(cached);
            return;
        }

        checkMarkedJsVersion(); // 버전 체크

        // Gemini API 호출 (Flash 모델 사용)
        GM_xmlhttpRequest({
            method: 'POST',
            url: `https://generativelanguage.googleapis.com/v1beta/models/${Gemini_Model_Name}: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 = '⚠️ 응답에 유효한 내용이 없습니다.';
                    }
                } catch (e) {
                    container.textContent = `❌ 응답 파싱 중 오류 발생: ${e.message}`;
                }
            },
            onerror(err) {
                container.textContent = `❌ Gemini 요청 중 네트워크 오류 발생\n\n🔗 ${err.finalUrl}`;
            },
            ontimeout() {
                container.textContent = '❌ 요청 시간이 초과되었습니다.';
            }
        });
    };

    /*** ─────────────────────────────────────────────
     *  🧠 Gemini 박스 UI 생성 및 삽입
     * ───────────────────────────────────────────── */

    // Gemini 박스를 DOM 요소로 생성
    const createGeminiBox = () => {
        const wrapper = document.createElement('div');
        wrapper.id = 'gemini-box';
        wrapper.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 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) return;

        let geminiBox = document.getElementById('gemini-box');
        if (!geminiBox) {
            geminiBox = createGeminiBox();
            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) {
            const apiKey = getApiKey();
            if (apiKey) fetchGeminiResponse(query, contentDiv, apiKey);
        }
    };

    /*** ─────────────────────────────────────────────
     *  🚀 초기화 및 URL 변경 감지
     * ───────────────────────────────────────────── */

    const init = () => {
        convertLinksToReal(document);     // 모든 링크를 실제 URL로 변환
        renderGeminiOnSearch();           // 초기 페이지 로드 시 Gemini 박스 표시

        // 주소(URL)가 변경될 경우에도 Gemini 박스를 다시 렌더링
        let lastUrl = location.href;
        new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                renderGeminiOnSearch();
                convertLinksToReal(document);
            }
        }).observe(document.body, { childList: true, subtree: true });
    };

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

})();