Bing Plus

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

// ==UserScript==
// @name         Bing Plus
// @version      4.1
// @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 = {
        // API 관련 설정: Gemini API와 외부 리소스에 접근하기 위한 URL 및 모델 정보
        API: {
            GEMINI_MODEL: 'gemini-2.0-flash', // 사용할 Gemini 모델 이름
            GEMINI_URL: 'https://generativelanguage.googleapis.com/v1beta/models/', // Gemini API 기본 URL
            MARKED_CDN_URL: 'https://api.cdnjs.com/libraries/marked' // marked.js 라이브러리 버전 확인용 CDN URL
        },

        // 버전 및 캐시 설정: 외부 라이브러리 버전과 캐시 접두사 관리
        VERSIONS: {
            MARKED_VERSION: '15.0.7' // 현재 사용하는 marked.js 버전
        },
        CACHE: {
            PREFIX: 'gemini_cache_' // Gemini API 응답 캐싱 시 사용할 키 접두사
        },
        STORAGE_KEYS: {
            CURRENT_VERSION: 'markedCurrentVersion', // 현재 marked.js 버전 저장 키
            LATEST_VERSION: 'markedLatestVersion', // 최신 marked.js 버전 저장 키
            LAST_NOTIFIED: 'markedLastNotifiedVersion' // 마지막으로 사용자에게 알림을 표시한 버전 저장 키
        },

        // UI 관련 설정: Gemini UI 요소의 크기와 여백 등 관리
        UI: {
            DEFAULT_MARGIN: 8, // 기본 여백 크기 (단위: px)
            DEFAULT_PADDING: 16, // 기본 패딩 크기 (단위: px)
            Z_INDEX: 9999 // 팝업 및 UI 요소의 z-index 값
        },

        // 스타일 관련 설정: 색상, 폰트 크기, 테두리 등 스타일 속성 관리
        STYLES: {
            COLORS: {
                BACKGROUND: '#fff', // Gemini 박스 배경색
                BORDER: '#e0e0e0', // 테두리 색상
                TEXT: '#333', // 기본 텍스트 색상
                TITLE: '#202124', // 제목 텍스트 색상
                BUTTON_BG: '#f0f3ff', // 버튼 배경색
                CODE_BG: '#f5f5f5', // 코드 블록 배경색
                BUTTON_BORDER: '#ccc' // 버튼 테두리 색상
            },
            BORDER: '1px solid #e0e0e0', // 기본 테두리 스타일
            BORDER_RADIUS: '4px', // 테두리 둥글기 반경
            FONT_SIZE: {
                TEXT: '14px', // 기본 텍스트 크기
                TITLE: '18px' // 제목 텍스트 크기
            },
            ICON_SIZE: '20px', // 새로고침 아이콘 크기
            LOGO_SIZE: '24px', // 로고 이미지 크기
            SMALL_ICON_SIZE: '16px' // Google 버튼 내 작은 아이콘 크기
        },

        // 아이콘 및 이미지 URL: UI에 사용할 이미지 리소스 경로
        ASSETS: {
            GOOGLE_LOGO: 'https://www.gstatic.com/marketing-cms/assets/images/bc/1a/a310779347afa1927672dc66a98d/g.png=s48-fcrop64=1,00000000ffffffff-rw', // Google 로고 이미지 URL
            GEMINI_LOGO: 'https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg', // Gemini 로고 이미지 URL
            REFRESH_ICON: 'https://www.svgrepo.com/show/533704/refresh-cw-alt-3.svg' // 새로고침 아이콘 이미지 URL
        },

        // 로컬라이제이션 메시지 키: 지역화 메시지 접근 시 사용할 키
        MESSAGE_KEYS: {
            PROMPT: 'prompt', // Gemini API 요청 프롬프트 메시지 키
            ENTER_API_KEY: 'enterApiKey', // API 키 입력 요청 메시지 키
            GEMINI_EMPTY: 'geminiEmpty', // Gemini 응답이 비었을 때 메시지 키
            PARSE_ERROR: 'parseError', // JSON 파싱 오류 메시지 키
            NETWORK_ERROR: 'networkError', // 네트워크 오류 메시지 키
            TIMEOUT: 'timeout', // 요청 시간 초과 메시지 키
            LOADING: 'loading', // 로딩 중 메시지 키
            UPDATE_TITLE: 'updateTitle', // marked.js 업데이트 알림 제목 메시지 키
            UPDATE_NOW: 'updateNow', // 업데이트 확인 버튼 메시지 키
            SEARCH_ON_GOOGLE: 'searchongoogle' // Google 검색 버튼 메시지 키
        }
    };

    // 지역화 모듈: 다국어 지원을 위한 메시지 관리 및 반환
    const Localization = {
        // 지역화 메시지 데이터: 각 키에 대해 한국어, 중국어, 기본(영어) 메시지 제공
        MESSAGES: {
            [Config.MESSAGE_KEYS.PROMPT]: {
                ko: `"${'${query}'}"에 대한 정보를 찾아줘`,
                zh: `请以标记格式填写有关\"${'${query}'}\"的信息。`,
                default: `Please write information about \"${'${query}'}\" in markdown format`
            },
            [Config.MESSAGE_KEYS.ENTER_API_KEY]: {
                ko: 'Gemini API 키를 입력하세요:',
                zh: '请输入 Gemini API 密钥:',
                default: 'Please enter your Gemini API key:'
            },
            [Config.MESSAGE_KEYS.GEMINI_EMPTY]: {
                ko: '⚠️ Gemini 응답이 비어있습니다.',
                zh: '⚠️ Gemini 返回为空。',
                default: '⚠️ Gemini response is empty.'
            },
            [Config.MESSAGE_KEYS.PARSE_ERROR]: {
                ko: '❌ 파싱 오류:',
                zh: '❌ 解析错误:',
                default: '❌ Parsing error:'
            },
            [Config.MESSAGE_KEYS.NETWORK_ERROR]: {
                ko: '❌ 네트워크 오류:',
                zh: '❌ 网络错误:',
                default: '❌ Network error:'
            },
            [Config.MESSAGE_KEYS.TIMEOUT]: {
                ko: '❌ 요청 시간이 초과되었습니다.',
                zh: '❌ 请求超时。',
                default: '❌ Request timeout'
            },
            [Config.MESSAGE_KEYS.LOADING]: {
                ko: '불러오는 중...',
                zh: '加载中...',
                default: 'Loading...'
            },
            [Config.MESSAGE_KEYS.UPDATE_TITLE]: {
                ko: 'marked.min.js 업데이트 필요',
                zh: '需要更新 marked.min.js',
                default: 'marked.min.js update required'
            },
            [Config.MESSAGE_KEYS.UPDATE_NOW]: {
                ko: '확인',
                zh: '确认',
                default: 'OK'
            },
            [Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE]: {
                ko: 'Google 에서 검색하기',
                zh: '在 Google 上搜索',
                default: 'Search on Google'
            }
        },

        // 사용자의 언어에 따라 적절한 메시지를 반환
        // @param {string} key - 메시지 키 (Config.MESSAGE_KEYS에서 참조)
        // @param {Object} vars - 메시지 내 변수 치환용 객체 (예: { query: '검색어' })
        // @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] || ''); // 변수 치환
        }
    };

    // 스타일 모듈: 페이지에 CSS 스타일을 동적으로 삽입
    const Styles = {
        // CSS 스타일을 정의하고 페이지에 삽입 (GM_addStyle 사용)
        // Gemini UI, Google 검색 버튼, 버전 업데이트 팝업 등의 스타일 포함
        inject() {
            console.log('Injecting styles...'); // 스타일 삽입 시작 로그
            GM_addStyle(`
                #b_results > li.b_ad a { color: green !important; } /* Bing 광고 링크를 초록색으로 표시 */

                #gemini-box { /* Gemini 결과 박스 스타일 */
                    width: 100%; /* 너비를 100%로 설정 */
                    max-width: 100%; /* 최대 너비도 100%로 설정 */
                    background: ${Config.STYLES.COLORS.BACKGROUND}; /* 배경색 */
                    border: ${Config.STYLES.BORDER}; /* 테두리 스타일 */
                    padding: ${Config.UI.DEFAULT_PADDING}px; /* 내부 여백 */
                    margin-bottom: ${Config.UI.DEFAULT_MARGIN * 2.5}px; /* 하단 여백 */
                    font-family: sans-serif; /* 폰트 설정 */
                    overflow-x: auto; /* 가로 스크롤 허용 */
                    position: relative; /* 위치 조정용 */
                    box-sizing: border-box; /* 테두리와 패딩 포함한 크기 계산 */
                }

                #gemini-header { /* Gemini 박스 헤더 스타일 */
                    display: flex; /* 플렉스 레이아웃 */
                    align-items: center; /* 세로 중앙 정렬 */
                    justify-content: space-between; /* 양쪽 끝 정렬 */
                    margin-bottom: ${Config.UI.DEFAULT_MARGIN}px; /* 하단 여백 */
                }

                #gemini-title-wrap { /* Gemini 제목과 로고를 감싸는 컨테이너 */
                    display: flex; /* 플렉스 레이아웃 */
                    align-items: center; /* 세로 중앙 정렬 */
                }

                #gemini-logo { /* Gemini 로고 스타일 */
                    width: ${Config.STYLES.LOGO_SIZE}; /* 로고 너비 */
                    height: ${Config.STYLES.LOGO_SIZE}; /* 로고 높이 */
                    margin-right: ${Config.UI.DEFAULT_MARGIN}px; /* 오른쪽 여백 */
                }

                #gemini-box h3 { /* Gemini 박스 제목 스타일 */
                    margin: 0; /* 기본 여백 제거 */
                    font-size: ${Config.STYLES.FONT_SIZE.TITLE}; /* 폰트 크기 */
                    color: ${Config.STYLES.COLORS.TITLE}; /* 텍스트 색상 */
                    font-weight: bold; /* 굵은 글씨 */
                }

                #gemini-refresh-btn { /* 새로고침 버튼 스타일 */
                    width: ${Config.STYLES.ICON_SIZE}; /* 버튼 너비 */
                    height: ${Config.STYLES.ICON_SIZE}; /* 버튼 높이 */
                    cursor: pointer; /* 커서 포인터로 변경 */
                    opacity: 0.6; /* 기본 투명도 */
                    transition: transform 0.5s ease; /* 회전 애니메이션 효과 */
                }

                #gemini-refresh-btn:hover { /* 새로고침 버튼 호버 스타일 */
                    opacity: 1; /* 투명도 제거 */
                    transform Faulk: rotate(360deg); /* 360도 회전 */
                }

                #gemini-divider { /* 구분선 스타일 */
                    height: 1px; /* 높이 */
                    background: ${Config.STYLES.COLORS.BORDER}; /* 배경색 (테두리 색상과 동일) */
                    margin: ${Config.UI.DEFAULT_MARGIN}px 0; /* 상하 여백 */
                }

                #gemini-content { /* Gemini 콘텐츠 스타일 */
                    font-size: ${Config.STYLES.FONT_SIZE.TEXT}; /* 폰트 크기 */
                    line-height: 1.6 envolve; /* 줄 간격 */
                    color: ${Config.STYLES.COLORS.TEXT}; /* 텍스트 색상 */
                    white-space: pre-wrap; /* 줄바꿈 유지 */
                    word-wrap: break-word; /* 긴 단어 줄바꿈 */
                }

                #gemini-content pre { /* 코드 블록 스타일 */
                    background: ${Config.STYLES.COLORS.CODE_BG}; /* 배경색 */
                    padding: ${Config.UI.DEFAULT_MARGIN + 2}px; /* 내부 여백 */
                    border-radius: ${Config.STYLES.BORDER_RADIUS}; /* 테두리 둥글기 */
                    overflow-x: auto; /* 가로 스크롤 허용 */
                }

                #google-search-btn { /* Google 검색 버튼 스타일 */
                    width: 100%; /* 버튼 너비를 100%로 설정 */
                    max-width: 100%; /* 최대 너비도 100% */
                    font-size: ${Config.STYLES.FONT_SIZE.TEXT}; /* 폰트 크기 */
                    padding: ${Config.UI.DEFAULT_MARGIN}px; /* 내부 여백 */
                    margin-bottom: ${Config.UI.DEFAULT_MARGIN * 1.25}px; /* 하단 여백 */
                    cursor: pointer; /* 커서 포인터로 변경 */
                    border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}; /* 테두리 스타일 */
                    border-radius: ${Config.STYLES.BORDER_RADIUS}; /* 테두리 둥글기 */
                    background-color: ${Config.STYLES.COLORS.BUTTON_BG}; /* 배경색 */
                    color: ${Config.STYLES.COLORS.TITLE}; /* 텍스트 색상 */
                    font-family: sans-serif; /* 폰트 설정 */
                    display: flex; /* 플렉스 레이아웃 */
                    align-items: center; /* 세로 중앙 정렬 */
                    justify-content: center; /* 가로 중앙 정렬 */
                    gap: ${Config.UI.DEFAULT_MARGIN}px; /* 아이콘과 텍스트 간격 */
                }

                #google-search-btn img { /* Google 버튼 내 이미지 스타일 */
                    width: ${Config.STYLES.SMALL_ICON_SIZE}; /* 이미지 너비 */
                    height: ${Config.STYLES.SMALL_ICON_SIZE}; /* 이미지 높이 */
                    vertical-align: middle; /* 세로 중앙 정렬 */
                }

                #marked-update-popup { /* 버전 업데이트 팝업 스타일 */
                    position: fixed; /* 고정 위치 */
                    top: 30%; /* 상단에서 30% 위치 */
                    left: 50%; /* 좌측에서 50% 위치 */
                    transform: translate(-50%, -50%); /* 중앙 정렬 */
                    background: ${Config.STYLES.COLORS.BACKGROUND}; /* 배경색 */
                    padding: ${Config.UI.DEFAULT_PADDING * 1.25}px; /* 내부 여백 */
                    z-index: ${Config.UI.Z_INDEX}; /* z-index 값 */
                    border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}; /* 테두리 스타일 */
                    box-shadow: 0 2px 10px rgba(0,0,0,0.1); /* 그림자 효과 */
                    text-align: center; /* 텍스트 중앙 정렬 */
                }

                #marked-update-popup button { /* 팝업 버튼 스타일 */
                    margin-top: ${Config.UI.DEFAULT_MARGIN * 1.25}px; /* 상단 여백 */
                    padding: ${Config.UI.DEFAULT_MARGIN}px ${Config.UI.DEFAULT_PADDING}px; /* 내부 여백 */
                    cursor: pointer; /* 커서 포인터로 변경 */
                    border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}; /* 테두리 스타일 */
                    border-radius: ${Config.STYLES.BORDER_RADIUS}; /* 테두리 둥글기 */
                    background-color: ${Config.STYLES.COLORS.BUTTON_BG}; /* 배경색 */
                    color: ${Config.STYLES.COLORS.TITLE}; /* 텍스트 색상 */
                    font-family: sans-serif; /* 폰트 설정 */
                }

                /* 반응형 스타일 */
                @media (max-width: 768px) {
                    #gemini-box, #google-search-btn {
                        padding: ${Config.UI.DEFAULT_PADDING * 0.75}px; /* 모바일에서 패딩 축소 */
                    }
                }
            `);
            console.log('Styles injected'); // 스타일 삽입 완료 로그
        }
    };

    // 유틸리티 모듈: 자주 사용하는 도구 함수 모음
    const Utils = {
        // 디바이스가 데스크톱인지 확인
        // 화면 너비가 768px 이상이고, 모바일 디바이스가 아닌 경우 true 반환
        // @returns {boolean} - 데스크톱 여부
        isDesktop() {
            const isDesktop = window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent); // 너비 및 유저 에이전트 확인
            console.log('isDesktop:', { width: window.innerWidth, userAgent: navigator.userAgent, result: isDesktop }); // 디버깅 로그
            return isDesktop;
        },

        // Gemini UI를 표시할 수 있는 환경인지 확인
        // 데스크톱 환경이고 Bing 페이지의 b_context 요소가 존재하는 경우 true 반환
        // @returns {boolean} - Gemini UI 표시 가능 여부
        isGeminiAvailable() {
            const hasBContext = !!document.getElementById('b_context'); // b_context 요소 존재 여부
            console.log('Bing isGeminiAvailable:', { isDesktop: this.isDesktop(), hasBContext }); // 디버깅 로그
            return this.isDesktop() && hasBContext;
        },

        // URL 파라미터에서 검색 쿼리를 추출
        // @returns {string|null} - 검색 쿼리 문자열 또는 null
        getQuery() {
            const query = new URLSearchParams(location.search).get('q'); // URL에서 'q' 파라미터 추출
            console.log('getQuery:', { query, search: location.search }); // 디버깅 로그
            return query;
        },

        // Gemini API 키를 로컬 스토리지에서 가져오거나 사용자 입력으로 획득
        // @returns {string|null} - API 키 문자열 또는 null
        getApiKey() {
            let key = localStorage.getItem('geminiApiKey'); // 로컬 스토리지에서 API 키 확인
            if (!key) { // API 키가 없으면 사용자 입력 요청
                key = prompt(Localization.getMessage(Config.MESSAGE_KEYS.ENTER_API_KEY)); // 사용자 입력 프롬프트
                if (key) localStorage.setItem('geminiApiKey', key); // 입력된 키 저장
                console.log('API key:', key ? 'stored' : 'prompt failed'); // 디버깅 로그
            } else {
                console.log('API key retrieved'); // API 키가 이미 존재하는 경우 로그
            }
            return key;
        }
    };

    // UI 모듈: Gemini UI와 Google 검색 버튼 생성 및 관리
    const UI = {
        // Google 검색 버튼 생성
        // 클릭 시 동일한 검색 쿼리로 Google 검색 페이지로 이동
        // @param {string} query - 검색 쿼리 문자열
        // @returns {HTMLElement} - 생성된 버튼 요소
        createGoogleButton(query) {
            const btn = document.createElement('button'); // 버튼 요소 생성
            btn.id = 'google-search-btn'; // 버튼 ID 설정
            btn.innerHTML = `
                <img src="${Config.ASSETS.GOOGLE_LOGO}" alt="Google Logo">
                ${Localization.getMessage(Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE)}
            `; // 버튼 내용 설정 (아이콘 + 텍스트)
            btn.onclick = () => window.open(`https://www.google.com/search?q=${encodeURIComponent(query)}`, '_blank'); // 클릭 이벤트: Google 검색 페이지로 이동
            return btn;
        },

        // Gemini 결과 박스 생성
        // 헤더, 로고, 새로고침 버튼, 콘텐츠 영역 포함
        // @param {string} query - 검색 쿼리 문자열
        // @param {string} apiKey - Gemini API 키
        // @returns {HTMLElement} - 생성된 Gemini 박스 요소
        createGeminiBox(query, apiKey) {
            const box = document.createElement('div'); // 박스 요소 생성
            box.id = 'gemini-box'; // 박스 ID 설정
            box.innerHTML = `
                <div id="gemini-header">
                    <div id="gemini-title-wrap">
                        <img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}" alt="Gemini Logo">
                        <h3>Gemini Search Results</h3>
                    </div>
                    <img id="gemini-refresh-btn" title="Refresh" src="${Config.ASSETS.REFRESH_ICON}" />
                </div>
                <hr id="gemini-divider">
                <div id="gemini-content">${Localization.getMessage(Config.MESSAGE_KEYS.LOADING)}</div>
            `; // 박스 내부 구조 생성
            box.querySelector('#gemini-refresh-btn').onclick = () => GeminiAPI.fetch(query, box.querySelector('#gemini-content'), apiKey, true); // 새로고침 버튼 클릭 이벤트
            return box;
        },

        // 전체 Gemini UI 생성 (Google 버튼 + Gemini 박스)
        // @param {string} query - 검색 쿼리 문자열
        // @param {string} apiKey - Gemini API 키
        // @returns {HTMLElement} - 생성된 UI 래퍼 요소
        createGeminiUI(query, apiKey) {
            const wrapper = document.createElement('div'); // UI 래퍼 요소 생성
            wrapper.appendChild(this.createGoogleButton(query)); // Google 버튼 추가
            wrapper.appendChild(this.createGeminiBox(query, apiKey)); // Gemini 박스 추가
            console.log('Gemini UI created:', { query, hasApiKey: !!apiKey }); // 디버깅 로그
            return wrapper;
        }
    };

    // Gemini API 모듈: Gemini API 요청 및 응답 처리
    const GeminiAPI = {
        // Gemini API를 호출하여 검색 결과를 가져오고 콘텐츠를 업데이트
        // @param {string} query - 검색 쿼리 문자열
        // @param {HTMLElement} container - 콘텐츠를 표시할 요소
        // @param {string} apiKey - Gemini API 키
        // @param {boolean} force - 캐시 무시하고 새로 요청할지 여부 (기본값: false)
        fetch(query, container, apiKey, force = false) {
            console.log('Fetching Gemini API:', { query, force }); // API 요청 시작 로그
            VersionChecker.checkMarkedJsVersion(); // marked.js 버전 확인 호출

            const cacheKey = `${Config.CACHE.PREFIX}${query}`; // 캐시 키 생성
            if (!force) { // 강제 새로고침이 아닌 경우 캐시 확인
                const cached = sessionStorage.getItem(cacheKey); // 캐시에서 데이터 조회
                if (cached) {
                    container.innerHTML = marked.parse(cached); // 캐시 데이터로 콘텐츠 업데이트
                    console.log('Loaded from cache:', { query }); // 캐시 로드 로그
                    return;
                }
            }

            container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.LOADING); // 로딩 메시지 표시

            // Gemini API 요청
            GM_xmlhttpRequest({
                method: 'POST', // POST 요청
                url: `${Config.API.GEMINI_URL}${Config.API.GEMINI_MODEL}:generateContent?key=${apiKey}`, // API 엔드포인트
                headers: { 'Content-Type': 'application/json' }, // 요청 헤더
                data: JSON.stringify({
                    contents: [{
                        parts: [{ text: Localization.getMessage(Config.MESSAGE_KEYS.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); // 마크다운 파싱 후 콘텐츠 업데이트
                            console.log('Gemini API success:', { query }); // 성공 로그
                        } else {
                            container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.GEMINI_EMPTY); // 응답이 비었을 경우 메시지 표시
                            console.log('Gemini API empty response'); // 비어있는 응답 로그
                        }
                    } catch (e) {
                        container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.PARSE_ERROR)} ${e.message}`; // 파싱 오류 메시지 표시
                        console.error('Gemini API parse error:', e.message); // 오류 로그
                    }
                },
                onerror: err => { // 네트워크 오류 시
                    container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.NETWORK_ERROR)} ${err.finalUrl}`; // 오류 메시지 표시
                    console.error('Gemini API network error:', err); // 오류 로그
                },
                ontimeout: () => { // 타임아웃 시
                    container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.TIMEOUT); // 타임아웃 메시지 표시
                    console.error('Gemini API timeout'); // 타임아웃 로그
                }
            });
        }
    };

    // 링크 정리 모듈: Bing 페이지의 추적 링크를 실제 URL로 변환
    const LinkCleaner = {
        // URL 파라미터를 디코딩하여 실제 목적지 URL 추출
        // @param {string} url - 디코딩할 URL
        // @param {string} key - 파라미터 키 (예: 'u', 'aurl')
        // @returns {string|null} - 디코딩된 URL 또는 null
        decodeRealUrl(url, key) {
            const param = new URL(url).searchParams.get(key)?.replace(/^a1/, ''); // 파라미터 추출 및 'a1' 접두사 제거
            if (!param) return null;
            try {
                const decoded = decodeURIComponent(atob(param.replace(/_/g, '/').replace(/-/g, '+'))); // Base64 디코딩 후 URL 디코딩
                return decoded.startsWith('/') ? location.origin + decoded : decoded; // 상대 경로일 경우 도메인 추가
            } catch {
                return null; // 디코딩 실패 시 null 반환
            }
        },

        // 추적 URL을 실제 목적지 URL로 변환
        // @param {string} url - 변환할 URL
        // @returns {string} - 실제 URL 또는 원본 URL
        resolveRealUrl(url) {
            const rules = [
                { pattern: /bing\.com\/(ck\/a|aclick)/, key: 'u' }, // Bing 추적 링크 규칙
                { pattern: /so\.com\/search\/eclk/, key: 'aurl' } // So.com 추적 링크 규칙
            ];
            for (const { pattern, key } of rules) {
                if (pattern.test(url)) { // URL이 규칙에 맞는지 확인
                    const real = this.decodeRealUrl(url, key); // 실제 URL 디코딩
                    if (real && real !== url) return real; // 디코딩 성공 시 실제 URL 반환
                }
            }
            return url; // 변환 실패 시 원본 URL 반환
        },

        // 페이지 내 모든 추적 링크를 실제 URL로 변환
        // @param {HTMLElement} root - 링크를 검색할 루트 요소
        convertLinksToReal(root) {
            root.querySelectorAll('a[href]').forEach(a => { // 모든 링크 요소 순회
                const realUrl = this.resolveRealUrl(a.href); // 실제 URL로 변환
                if (realUrl && realUrl !== a.href) a.href = realUrl; // 변환된 URL로 업데이트
            });
            console.log('Links converted'); // 링크 변환 완료 로그
        }
    };

    // 버전 확인 모듈: marked.js 라이브러리 버전 확인 및 업데이트 알림
    const VersionChecker = {
        // 두 버전 문자열을 비교
        // @param {string} current - 현재 버전 (예: '15.0.7')
        // @param {string} latest - 최신 버전 (예: '15.1.0')
        // @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; // 없는 부분은 0으로 처리
                const l = latestParts[i] || 0;
                if (c < l) return -1; // 현재 버전이 더 낮음
                if (c > l) return 1; // 현재 버전이 더 높음
            }
            return 0; // 버전 동일
        },

        // marked.js의 최신 버전을 확인하고 업데이트가 필요하면 팝업 표시
        checkMarkedJsVersion() {
            localStorage.setItem(Config.STORAGE_KEYS.CURRENT_VERSION, Config.VERSIONS.MARKED_VERSION); // 현재 버전 저장

            GM_xmlhttpRequest({
                method: 'GET', // GET 요청
                url: Config.API.MARKED_CDN_URL, // marked.js CDN URL
                onload({ responseText }) { // 요청 성공 시
                    try {
                        const latest = JSON.parse(responseText).version; // 최신 버전 추출
                        console.log(`marked.js version: current=${Config.VERSIONS.MARKED_VERSION}, latest=${latest}`); // 버전 비교 로그

                        localStorage.setItem(Config.STORAGE_KEYS.LATEST_VERSION, latest); // 최신 버전 저장

                        const lastNotified = localStorage.getItem(Config.STORAGE_KEYS.LAST_NOTIFIED); // 마지막 알림 버전 확인
                        console.log(`Last notified version: ${lastNotified || 'none'}`); // 알림 로그

                        // 팝업 표시 조건: 현재 버전이 최신 버전보다 낮고, 이전에 알림을 받지 않았거나 최신 버전이 더 높은 경우
                        if (this.compareVersions(Config.VERSIONS.MARKED_VERSION, latest) < 0 &&
                            (!lastNotified || this.compareVersions(lastNotified, latest) < 0)) {
                            console.log('Popup display condition met'); // 팝업 조건 충족 로그

                            const existingPopup = document.getElementById('marked-update-popup'); // 기존 팝업 확인
                            if (existingPopup) {
                                existingPopup.remove(); // 기존 팝업 제거
                                console.log('Existing popup removed'); // 제거 로그
                            }

                            const popup = document.createElement('div'); // 새 팝업 생성
                            popup.id = 'marked-update-popup';
                            popup.innerHTML = `
                                <p><b>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_TITLE)}</b></p>
                                <p>Current: ${Config.VERSIONS.MARKED_VERSION}<br>Latest: ${latest}</p>
                                <button>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_NOW)}</button>
                            `; // 팝업 내용 설정
                            popup.querySelector('button').onclick = () => { // 확인 버튼 클릭 이벤트
                                localStorage.setItem(Config.STORAGE_KEYS.LAST_NOTIFIED, latest); // 알림 버전 기록
                                console.log(`Notified version recorded: ${latest}`); // 기록 로그
                                popup.remove(); // 팝업 제거
                            };
                            document.body.appendChild(popup); // 팝업 표시
                            console.log('New popup displayed'); // 표시 로그
                        } else {
                            console.log('Popup display condition not met'); // 팝업 조건 미충족 로그
                        }
                    } catch (e) {
                        console.warn('marked.min.js version check error:', e.message); // 오류 로그
                    }
                },
                onerror: () => console.warn('marked.min.js version check request failed') // 요청 실패 로그
            });
        }
    };

    // 메인 모듈: 스크립트의 주요 기능 실행 및 관리
    const Main = {
        // Gemini UI를 조건에 따라 렌더링
        renderGemini() {
            console.log('renderGemini called'); // 렌더링 시작 로그
            if (!Utils.isGeminiAvailable()) { // Gemini UI 표시 조건 확인
                console.log('Skipped: isGeminiAvailable false'); // 조건 불충족 로그
                return;
            }

            const query = Utils.getQuery(); // 검색 쿼리 가져오기
            if (!query || document.getElementById('gemini-box')) { // 쿼리 없거나 이미 UI 존재 시 스킵
                console.log('Skipped:', { queryExists: !!query, geminiBoxExists: !!document.getElementById('gemini-box') }); // 스킵 로그
                return;
            }

            const apiKey = Utils.getApiKey(); // API 키 가져오기
            if (!apiKey) { // API 키 없으면 스킵
                console.log('Skipped: No API key'); // 스킵 로그
                return;
            }

            const ui = UI.createGeminiUI(query, apiKey); // Gemini UI 생성
            const target = document.getElementById('b_context'); // 삽입 대상 요소
            if (target) {
                target.prepend(ui); // UI 삽입
                console.log('Gemini UI inserted into b_context'); // 삽입 성공 로그
            } else {
                console.error('b_context not found for insertion'); // 삽입 실패 로그
                return;
            }

            const content = ui.querySelector('#gemini-content'); // 콘텐츠 영역 선택
            const cache = sessionStorage.getItem(`${Config.CACHE.PREFIX}${query}`); // 캐시 확인
            content.innerHTML = cache ? marked.parse(cache) : Localization.getMessage(Config.MESSAGE_KEYS.LOADING); // 캐시 있으면 표시, 없으면 로딩 메시지
            if (!cache) GeminiAPI.fetch(query, content, apiKey); // 캐시 없으면 API 호출
        },

        // URL 변경 감지 및 UI 갱신
        observeUrlChange() {
            let lastUrl = location.href; // 현재 URL 저장
            const observer = new MutationObserver(() => { // DOM 변경 감지
                if (location.href !== lastUrl) { // URL 변경 시
                    lastUrl = location.href; // 새로운 URL 저장
                    console.log('MutationObserver triggered: URL changed'); // URL 변경 로그
                    this.renderGemini(); // UI 갱신
                    LinkCleaner.convertLinksToReal(document); // 링크 정리
                }
            });
            observer.observe(document.body, { childList: true, subtree: true }); // DOM 변경 감시 설정
            console.log('Observing URL changes on document.body'); // 감시 시작 로그
        },

        // 스크립트 초기화
        init() {
            console.log('Bing Plus init:', { hostname: location.hostname, url: location.href }); // 초기화 시작 로그
            try {
                Styles.inject(); // 스타일 삽입
                LinkCleaner.convertLinksToReal(document); // 링크 정리
                this.renderGemini(); // Gemini UI 렌더링
                this.observeUrlChange(); // URL 변경 감시 시작
            } catch (e) {
                console.error('Init error:', e.message); // 초기화 오류 로그
            }
        }
    };

    // 스크립트 실행 시작
    console.log('Bing Plus script loaded'); // 스크립트 로드 완료 로그
    Main.init(); // 초기화 함수 호출
})();

QingJ © 2025

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