Bing Plus

Add Gemini response, improve speed to search results, add Google search buttons

当前为 2025-05-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Bing Plus
  3. // @version 6.1
  4. // @description Add Gemini response, improve speed to search results, add Google search buttons
  5. // @author lanpod
  6. // @match https://www.bing.com/search*
  7. // @grant GM_addStyle
  8. // @grant GM_xmlhttpRequest
  9. // @require https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.7/marked.min.js
  10. // @license MIT
  11. // @namespace http://tampermonkey.net/
  12. // ==/UserScript==
  13.  
  14.  
  15. (function () {
  16. 'use strict';
  17.  
  18. // 설정 모듈: 전역 설정값 관리 (API, 스타일, 메시지 등)
  19. const Config = {
  20. API: {
  21. GEMINI_MODEL: 'gemini-2.0-flash',
  22. GEMINI_URL: 'https://generativelanguage.googleapis.com/v1beta/models/',
  23. MARKED_CDN_URL: 'https://api.cdnjs.com/libraries/marked'
  24. },
  25. VERSIONS: {
  26. MARKED_VERSION: '15.0.7'
  27. },
  28. CACHE: {
  29. PREFIX: 'gemini_cache_'
  30. },
  31. STORAGE_KEYS: {
  32. CURRENT_VERSION: 'markedCurrentVersion',
  33. LATEST_VERSION: 'markedLatestVersion',
  34. LAST_NOTIFIED: 'markedLastNotifiedVersion'
  35. },
  36. UI: {
  37. DEFAULT_MARGIN: 8,
  38. DEFAULT_PADDING: 16,
  39. Z_INDEX: 9999
  40. },
  41. STYLES: {
  42. COLORS: {
  43. BACKGROUND: '#fff',
  44. BORDER: '#e0e0e0',
  45. TEXT: '#000',
  46. TITLE: '#000',
  47. BUTTON_BG: '#f0f3ff',
  48. BUTTON_BORDER: '#ccc',
  49. DARK_BACKGROUND: '#202124',
  50. DARK_BORDER: '#5f6368',
  51. DARK_TEXT: '#fff',
  52. CODE_BLOCK_BG: '#f0f0f0',
  53. DARK_CODE_BLOCK_BG: '#555'
  54. },
  55. BORDER: '1px solid #e0e0e0',
  56. BORDER_RADIUS: '4px',
  57. FONT_SIZE: {
  58. TEXT: '14px',
  59. TITLE: '18px'
  60. },
  61. ICON_SIZE: '20px',
  62. LOGO_SIZE: '24px',
  63. SMALL_ICON_SIZE: '16px'
  64. },
  65. ASSETS: {
  66. GOOGLE_LOGO: 'https://www.gstatic.com/marketing-cms/assets/images/bc/1a/a310779347afa1927672dc66a98d/g.png=s48-fcrop64=1,00000000ffffffff-rw',
  67. GEMINI_LOGO: 'https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg',
  68. REFRESH_ICON: 'https://www.svgrepo.com/show/533704/refresh-cw-alt-3.svg'
  69. },
  70. MESSAGE_KEYS: {
  71. PROMPT: 'prompt',
  72. ENTER_API_KEY: 'enterApiKey',
  73. GEMINI_EMPTY: 'geminiEmpty',
  74. PARSE_ERROR: 'parseError',
  75. NETWORK_ERROR: 'networkError',
  76. TIMEOUT: 'timeout',
  77. LOADING: 'loading',
  78. UPDATE_TITLE: 'updateTitle',
  79. UPDATE_NOW: 'updateNow',
  80. SEARCH_ON_GOOGLE: 'searchongoogle'
  81. }
  82. };
  83.  
  84. // 지역화 모듈: 다국어 메시지 처리
  85. const Localization = {
  86. // 다국어 메시지 객체
  87. MESSAGES: {
  88. [Config.MESSAGE_KEYS.PROMPT]: {
  89. ko: `"${'${query}'}" 대한 정보를 찾아줘`,
  90. zh: `请以标记格式填写有关\"${'${query}'}\"的信息。`,
  91. default: `Please write information about \"${'${query}'}\" in markdown format`
  92. },
  93. [Config.MESSAGE_KEYS.ENTER_API_KEY]: {
  94. ko: 'Gemini API 키를 입력하세요:',
  95. zh: '请输入 Gemini API 密钥:',
  96. default: 'Please enter your Gemini API key:'
  97. },
  98. [Config.MESSAGE_KEYS.GEMINI_EMPTY]: {
  99. ko: '⚠️ Gemini 응답이 비어있습니다.',
  100. zh: '⚠️ Gemini 返回为空。',
  101. default: '⚠️ Gemini response is empty.'
  102. },
  103. [Config.MESSAGE_KEYS.PARSE_ERROR]: {
  104. ko: '❌ 파싱 오류:',
  105. zh: '❌ 解析错误:',
  106. default: '❌ Parsing error:'
  107. },
  108. [Config.MESSAGE_KEYS.NETWORK_ERROR]: {
  109. ko: '❌ 네트워크 오류:',
  110. zh: '❌ 网络错误:',
  111. default: '❌ Network error:'
  112. },
  113. [Config.MESSAGE_KEYS.TIMEOUT]: {
  114. ko: '❌ 요청 시간이 초과되었습니다.',
  115. zh: '❌ 请求超时。',
  116. default: '❌ Request timeout'
  117. },
  118. [Config.MESSAGE_KEYS.LOADING]: {
  119. ko: '불러오는 중...',
  120. zh: '加载中...',
  121. default: 'Loading...'
  122. },
  123. [Config.MESSAGE_KEYS.UPDATE_TITLE]: {
  124. ko: 'marked.min.js 업데이트 필요',
  125. zh: '需要更新 marked.min.js',
  126. default: 'marked.min.js update required'
  127. },
  128. [Config.MESSAGE_KEYS.UPDATE_NOW]: {
  129. ko: '확인',
  130. zh: '确认',
  131. default: 'OK'
  132. },
  133. [Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE]: {
  134. ko: 'Google 에서 검색하기',
  135. zh: '在 Google 上搜索',
  136. default: 'Search on Google'
  137. }
  138. },
  139.  
  140. // 주어진 키와 변수를 사용하여 지역화된 메시지 반환
  141. getMessage(key, vars = {}) {
  142. const lang = navigator.language;
  143. const langKey = lang.includes('ko') ? 'ko' : lang.includes('zh') ? 'zh' : 'default';
  144. const template = this.MESSAGES[key]?.[langKey] || this.MESSAGES[key]?.default || '';
  145. return template.replace(/\$\{(.*?)\}/g, (_, k) => vars[k] || '');
  146. }
  147. };
  148.  
  149. // 디바이스 감지 모듈: 디바이스 타입 감지 (데스크톱, 모바일, 태블릿)
  150. const DeviceDetector = {
  151. // 캐싱 변수
  152. _cache: {
  153. isDesktop: null,
  154. isMobile: null,
  155. isTablet: null,
  156. isGeminiAvailable: null
  157. },
  158.  
  159. // 디바이스가 Android 또는 iPhone인지 확인
  160. isAndroidOrIPhone() {
  161. return /Android|iPhone/i.test(navigator.userAgent);
  162. },
  163.  
  164. // UserAgent에 "Mobile" 키워드가 있는지 확인
  165. hasMobileKeyword() {
  166. return /Mobile/i.test(navigator.userAgent);
  167. },
  168.  
  169. // 데스크톱 환경인지 확인 (Android 또는 iPhone이 아닌 경우)
  170. isDesktop() {
  171. if (this._cache.isDesktop === null) {
  172. this._cache.isDesktop = !this.isAndroidOrIPhone();
  173. }
  174. return this._cache.isDesktop;
  175. },
  176.  
  177. // 모바일 환경인지 확인 (Android 또는 iPhone이면서 "Mobile" 키워드 포함)
  178. isMobile() {
  179. if (this._cache.isMobile === null) {
  180. this._cache.isMobile = this.isAndroidOrIPhone() && this.hasMobileKeyword();
  181. }
  182. return this._cache.isMobile;
  183. },
  184.  
  185. // 태블릿 환경인지 확인 (Android이지만 "Mobile" 키워드 없음)
  186. isTablet() {
  187. if (this._cache.isTablet === null) {
  188. this._cache.isTablet = /Android/i.test(navigator.userAgent) && !this.hasMobileKeyword();
  189. }
  190. return this._cache.isTablet;
  191. },
  192.  
  193. // Gemini UI를 표시할 수 있는 환경인지 확인 (데스크톱 환경에서만 가능)
  194. isGeminiAvailable() {
  195. if (this._cache.isGeminiAvailable === null) {
  196. const hasBContext = !!document.getElementById('b_context');
  197. const hasBRight = !!document.querySelector('.b_right');
  198. this._cache.isGeminiAvailable = this.isDesktop() && (hasBContext || hasBRight);
  199. }
  200. return this._cache.isGeminiAvailable;
  201. },
  202.  
  203. // 캐시 초기화
  204. resetCache() {
  205. this._cache = {
  206. isDesktop: null,
  207. isMobile: null,
  208. isTablet: null,
  209. isGeminiAvailable: null
  210. };
  211. }
  212. };
  213.  
  214. // 스타일 생성 모듈: CSS 스타일 정의
  215. const StyleGenerator = {
  216. // 공통 스타일 정의
  217. commonStyles: {
  218. '#b_results > li.b_ad a': { 'color': 'green !important' },
  219. '#b_context, .b_context, .b_right': {
  220. 'color': 'initial !important',
  221. 'border': 'none !important',
  222. 'border-width': '0 !important',
  223. 'border-style': 'none !important',
  224. 'border-collapse': 'separate !important',
  225. 'background': 'transparent !important'
  226. }
  227. },
  228.  
  229. // Gemini 박스 스타일 정의
  230. geminiBoxStyles: {
  231. '#gemini-box': {
  232. 'width': '100%',
  233. 'max-width': '100%',
  234. 'background': `${Config.STYLES.COLORS.BACKGROUND} !important`,
  235. 'border': `${Config.STYLES.BORDER} !important`,
  236. 'border-style': 'solid !important',
  237. 'border-width': '1px !important',
  238. 'border-radius': Config.STYLES.BORDER_RADIUS,
  239. 'padding': `${Config.UI.DEFAULT_PADDING}px`,
  240. 'margin-bottom': `${Config.UI.DEFAULT_MARGIN * 2.5}px`,
  241. 'font-family': 'sans-serif',
  242. 'overflow-x': 'auto',
  243. 'position': 'relative',
  244. 'box-sizing': 'border-box',
  245. 'color': 'initial !important'
  246. }
  247. },
  248.  
  249. // 테마별 스타일 정의 (라이트/다크 모드)
  250. themeStyles: {
  251. '[data-theme="light"] #gemini-box, .light #gemini-box': {
  252. 'background': `${Config.STYLES.COLORS.BACKGROUND} !important`,
  253. 'border': `1px solid ${Config.STYLES.COLORS.BORDER} !important`
  254. },
  255. '[data-theme="light"] #gemini-box h3, .light #gemini-box h3': {
  256. 'color': `${Config.STYLES.COLORS.TITLE} !important`
  257. },
  258. '[data-theme="light"] #gemini-content, [data-theme="light"] #gemini-content *, .light #gemini-content, .light #gemini-content *': {
  259. 'color': `${Config.STYLES.COLORS.TEXT} !important`,
  260. 'background': 'transparent !important'
  261. },
  262. '[data-theme="light"] #gemini-divider, .light #gemini-divider': {
  263. 'background': `${Config.STYLES.COLORS.BORDER} !important`
  264. },
  265. '[data-theme="dark"] #gemini-box, .dark #gemini-box, .b_dark #gemini-box': {
  266. 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
  267. 'border': `1px solid ${Config.STYLES.COLORS.DARK_BORDER} !important`
  268. },
  269. '@media (prefers-color-scheme: dark)': {
  270. '#gemini-box': {
  271. 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
  272. 'border': `1px solid ${Config.STYLES.COLORS.DARK_BORDER} !important`
  273. },
  274. '#gemini-box h3': {
  275. 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`
  276. },
  277. '#gemini-content, #gemini-content *': {
  278. 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`,
  279. 'background': 'transparent !important'
  280. },
  281. '#gemini-content pre': {
  282. 'background': `${Config.STYLES.COLORS.DARK_CODE_BLOCK_BG} !important`
  283. },
  284. '#gemini-divider': {
  285. 'background': `${Config.STYLES.COLORS.DARK_BORDER} !important`
  286. },
  287. '#marked-update-popup': {
  288. 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
  289. 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`
  290. }
  291. }
  292. },
  293.  
  294. // Gemini 콘텐츠 스타일 정의
  295. contentStyles: {
  296. '#gemini-content': {
  297. 'font-size': Config.STYLES.FONT_SIZE.TEXT,
  298. 'line-height': '1.6',
  299. 'white-space': 'pre-wrap',
  300. 'word-wrap': 'break-word',
  301. 'background': 'transparent !important'
  302. },
  303. '#gemini-content pre': {
  304. 'background': `${Config.STYLES.COLORS.CODE_BLOCK_BG} !important`,
  305. 'padding': `${Config.UI.DEFAULT_MARGIN + 2}px`,
  306. 'border-radius': Config.STYLES.BORDER_RADIUS,
  307. 'overflow-x': 'auto'
  308. },
  309. '[data-theme="dark"] #gemini-content pre, .dark #gemini-content pre, .b_dark #gemini-content pre': {
  310. 'background': `${Config.STYLES.COLORS.DARK_CODE_BLOCK_BG} !important`
  311. }
  312. },
  313.  
  314. // Gemini 헤더 스타일 정의
  315. headerStyles: {
  316. '#gemini-header': {
  317. 'display': 'flex',
  318. 'align-items': 'center',
  319. 'justify-content': 'space-between',
  320. 'margin-bottom': `${Config.UI.DEFAULT_MARGIN}px`
  321. },
  322. '#gemini-title-wrap': {
  323. 'display': 'flex',
  324. 'align-items': 'center'
  325. },
  326. '#gemini-logo': {
  327. 'width': Config.STYLES.LOGO_SIZE,
  328. 'height': Config.STYLES.LOGO_SIZE,
  329. 'margin-right': `${Config.UI.DEFAULT_MARGIN}px`
  330. },
  331. '#gemini-box h3': {
  332. 'margin': '0',
  333. 'font-size': Config.STYLES.FONT_SIZE.TITLE,
  334. 'font-weight': 'bold'
  335. },
  336. '#gemini-refresh-btn': {
  337. 'width': Config.STYLES.ICON_SIZE,
  338. 'height': Config.STYLES.ICON_SIZE,
  339. 'cursor': 'pointer',
  340. 'opacity': '0.6',
  341. 'transition': 'transform 0.5s ease'
  342. },
  343. '#gemini-refresh-btn:hover': {
  344. 'opacity': '1',
  345. 'transform': 'rotate(360deg)'
  346. },
  347. '#gemini-divider': {
  348. 'height': '1px',
  349. 'margin': `${Config.UI.DEFAULT_MARGIN}px 0`
  350. }
  351. },
  352.  
  353. // Google 검색 버튼 스타일 정의
  354. googleButtonStyles: {
  355. '#google-search-btn': {
  356. 'width': '100%',
  357. 'max-width': '100%',
  358. 'font-size': Config.STYLES.FONT_SIZE.TEXT,
  359. 'padding': `${Config.UI.DEFAULT_MARGIN}px`,
  360. 'margin-bottom': `${Config.UI.DEFAULT_MARGIN * 1.25}px`,
  361. 'cursor': 'pointer',
  362. 'border': `1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}`,
  363. 'border-radius': Config.STYLES.BORDER_RADIUS,
  364. 'background-color': Config.STYLES.COLORS.BUTTON_BG,
  365. 'color': Config.STYLES.COLORS.TITLE,
  366. 'font-family': 'sans-serif',
  367. 'display': 'flex',
  368. 'align-items': 'center',
  369. 'justify-content': 'center',
  370. 'gap': `${Config.UI.DEFAULT_MARGIN}px`,
  371. 'transition': 'transform 0.2s ease'
  372. },
  373. '#google-search-btn img': {
  374. 'width': Config.STYLES.SMALL_ICON_SIZE,
  375. 'height': Config.STYLES.SMALL_ICON_SIZE,
  376. 'vertical-align': 'middle',
  377. 'transition': 'transform 0.2s ease'
  378. },
  379. '.desktop-useragent #google-search-btn:hover': {
  380. 'transform': 'scale(1.1)'
  381. },
  382. '.desktop-useragent #google-search-btn:hover img': {
  383. 'transform': 'scale(1.1)'
  384. }
  385. },
  386.  
  387. // 업데이트 팝업 스타일 정의
  388. popupStyles: {
  389. '#marked-update-popup': {
  390. 'position': 'fixed',
  391. 'top': '30%',
  392. 'left': '50%',
  393. 'transform': 'translate(-50%, -50%)',
  394. 'background': Config.STYLES.COLORS.BACKGROUND,
  395. 'padding': `${Config.UI.DEFAULT_PADDING * 1.25}px`,
  396. 'z-index': Config.UI.Z_INDEX,
  397. 'border': `1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}`,
  398. 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
  399. 'text-align': 'center'
  400. },
  401. '[data-theme="dark"] #marked-update-popup, .dark #marked-update-popup, .b_dark #marked-update-popup': {
  402. 'background': `${Config.STYLES.COLORS.DARK_BACKGROUND} !important`,
  403. 'color': `${Config.STYLES.COLORS.DARK_TEXT} !important`
  404. },
  405. '#marked-update-popup button': {
  406. 'margin-top': `${Config.UI.DEFAULT_MARGIN * 1.25}px`,
  407. 'padding': `${Config.UI.DEFAULT_MARGIN}px ${Config.UI.DEFAULT_PADDING}px`,
  408. 'cursor': 'pointer',
  409. 'border': `1px solid ${Config.STYLES.COLORS.BUTTON_BORDER}`,
  410. 'border-radius': Config.STYLES.BORDER_RADIUS,
  411. 'background-color': Config.STYLES.COLORS.BUTTON_BG,
  412. 'color': Config.STYLES.COLORS.TITLE,
  413. 'font-family': 'sans-serif'
  414. }
  415. },
  416.  
  417. // 모바일 환경 스타일 정의
  418. mobileStyles: {
  419. '.mobile-useragent #google-search-btn': {
  420. 'max-width': '96%',
  421. 'margin': `${Config.UI.DEFAULT_MARGIN}px auto`,
  422. 'padding': `${Config.UI.DEFAULT_PADDING * 0.75}px`,
  423. 'border-radius': '16px'
  424. },
  425. '.mobile-useragent #gemini-box': {
  426. 'padding': `${Config.UI.DEFAULT_PADDING * 0.75}px`,
  427. 'border-radius': '16px'
  428. }
  429. },
  430.  
  431. // 모든 스타일을 하나의 CSS 문자열로 변환
  432. generateStyles() {
  433. const styles = [
  434. this.commonStyles,
  435. this.geminiBoxStyles,
  436. this.themeStyles,
  437. this.contentStyles,
  438. this.headerStyles,
  439. this.googleButtonStyles,
  440. this.popupStyles,
  441. this.mobileStyles
  442. ];
  443.  
  444. return styles.reduce((css, styleObj) => {
  445. for (const [selector, props] of Object.entries(styleObj)) {
  446. css += `${selector} {`;
  447. for (const [prop, value] of Object.entries(props)) {
  448. css += `${prop}: ${value};`;
  449. }
  450. css += '}';
  451. }
  452. return css;
  453. }, '');
  454. }
  455. };
  456.  
  457. // 테마 관리 모듈: 테마 변경 감지 및 적용
  458. const ThemeManager = {
  459. // 테마 변경 감지 및 스타일 적용
  460. applyTheme() {
  461. const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark' ||
  462. document.documentElement.classList.contains('dark') ||
  463. document.documentElement.classList.contains('b_dark') ||
  464. window.matchMedia('(prefers-color-scheme: dark)').matches;
  465.  
  466. const geminiBox = document.querySelector('#gemini-box');
  467. if (geminiBox) {
  468. geminiBox.style.background = isDarkTheme
  469. ? Config.STYLES.COLORS.DARK_BACKGROUND
  470. : Config.STYLES.COLORS.BACKGROUND;
  471. geminiBox.style.borderColor = isDarkTheme
  472. ? Config.STYLES.COLORS.DARK_BORDER
  473. : Config.STYLES.COLORS.BORDER;
  474. }
  475. },
  476.  
  477. // 테마 변경 감지 설정
  478. observeThemeChange() {
  479. const observer = new MutationObserver(() => this.applyTheme());
  480. const targetElement = document.querySelector('#b_context') || document.querySelector('.b_right') || document.documentElement;
  481.  
  482. observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
  483. if (targetElement !== document.documentElement) {
  484. observer.observe(targetElement, { attributes: true, attributeFilter: ['style', 'class'] });
  485. }
  486.  
  487. window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.applyTheme());
  488. }
  489. };
  490.  
  491. // 스타일 관리 모듈: 스타일 초기화 및 적용
  492. const Styles = {
  493. // 스타일 초기화
  494. initStyles() {
  495. const styleElement = document.createElement('style');
  496. styleElement.id = 'bing-plus-styles';
  497. styleElement.textContent = StyleGenerator.generateStyles();
  498. document.head.appendChild(styleElement);
  499. this.applyMobileStyles();
  500. },
  501.  
  502. // 모바일 환경 스타일 적용
  503. applyMobileStyles() {
  504. if (DeviceDetector.isMobile()) {
  505. document.documentElement.classList.add('mobile-useragent');
  506. } else if (DeviceDetector.isDesktop()) {
  507. document.documentElement.classList.add('desktop-useragent');
  508. }
  509. }
  510. };
  511.  
  512. // 유틸리티 모듈: 공통 유틸리티 함수
  513. const Utils = {
  514. // 검색 쿼리 추출
  515. getQuery() {
  516. return new URLSearchParams(location.search).get('q');
  517. },
  518.  
  519. // Gemini API 키 가져오기 또는 입력받기
  520. getApiKey() {
  521. let key = localStorage.getItem('geminiApiKey');
  522. if (!key) {
  523. key = prompt(Localization.getMessage(Config.MESSAGE_KEYS.ENTER_API_KEY));
  524. if (key) localStorage.setItem('geminiApiKey', key);
  525. }
  526. return key;
  527. }
  528. };
  529.  
  530. // UI 생성 모듈: DOM 요소 생성
  531. const UI = {
  532. // Google 검색 버튼 생성
  533. createGoogleButton(query) {
  534. const btn = document.createElement('button');
  535. btn.id = 'google-search-btn';
  536. btn.innerHTML = `
  537. <img src="${Config.ASSETS.GOOGLE_LOGO}" alt="Google Logo">
  538. ${Localization.getMessage(Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE)}
  539. `;
  540. btn.onclick = () => window.open(`https://www.google.com/search?q=${encodeURIComponent(query)}`, '_blank');
  541. return btn;
  542. },
  543.  
  544. // Gemini 박스 생성
  545. createGeminiBox(query, apiKey) {
  546. const box = document.createElement('div');
  547. box.id = 'gemini-box';
  548. box.innerHTML = `
  549. <div id="gemini-header">
  550. <div id="gemini-title-wrap">
  551. <img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}" alt="Gemini Logo">
  552. <h3>Gemini Search Results</h3>
  553. </div>
  554. <img id="gemini-refresh-btn" title="Refresh" src="${Config.ASSETS.REFRESH_ICON}" />
  555. </div>
  556. <hr id="gemini-divider">
  557. <div id="gemini-content">${Localization.getMessage(Config.MESSAGE_KEYS.LOADING)}</div>
  558. `;
  559. box.querySelector('#gemini-refresh-btn').onclick = () => GeminiAPI.fetch(query, box.querySelector('#gemini-content'), apiKey, true);
  560.  
  561. if (DeviceDetector.isDesktop()) {
  562. VersionChecker.checkMarkedJsVersion();
  563. }
  564.  
  565. return box;
  566. },
  567.  
  568. // Gemini UI 전체 생성 (Google 버튼 + Gemini 박스)
  569. createGeminiUI(query, apiKey) {
  570. const wrapper = document.createElement('div');
  571. wrapper.id = 'gemini-wrapper';
  572. wrapper.appendChild(this.createGoogleButton(query));
  573. wrapper.appendChild(this.createGeminiBox(query, apiKey));
  574. return wrapper;
  575. },
  576.  
  577. // 기존 UI 요소 제거
  578. removeExistingElements() {
  579. document.querySelectorAll('#gemini-wrapper, #google-search-btn').forEach(el => el.remove());
  580. }
  581. };
  582.  
  583. // Gemini API 모듈: Gemini API 호출 및 응답 처리
  584. const GeminiAPI = {
  585. // Gemini API 호출
  586. fetch(query, container, apiKey, force = false) {
  587. const cacheKey = `${Config.CACHE.PREFIX}${query}`;
  588. const cached = force ? null : sessionStorage.getItem(cacheKey);
  589.  
  590. if (cached) {
  591. if (container) container.innerHTML = marked.parse(cached);
  592. return;
  593. }
  594.  
  595. if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.LOADING);
  596.  
  597. GM_xmlhttpRequest({
  598. method: 'POST',
  599. url: `${Config.API.GEMINI_URL}${Config.API.GEMINI_MODEL}:generateContent?key=${apiKey}`,
  600. headers: { 'Content-Type': 'application/json' },
  601. data: JSON.stringify({
  602. contents: [{ parts: [{ text: Localization.getMessage(Config.MESSAGE_KEYS.PROMPT, { query }) }] }]
  603. }),
  604. onload({ responseText }) {
  605. try {
  606. const text = JSON.parse(responseText)?.candidates?.[0]?.content?.parts?.[0]?.text;
  607. if (text) {
  608. sessionStorage.setItem(cacheKey, text);
  609. if (container) container.innerHTML = marked.parse(text);
  610. } else {
  611. if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.GEMINI_EMPTY);
  612. }
  613. } catch (e) {
  614. if (container) container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.PARSE_ERROR)} ${e.message}`;
  615. }
  616. },
  617. onerror(err) {
  618. if (container) container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.NETWORK_ERROR)} ${err.finalUrl}`;
  619. },
  620. ontimeout() {
  621. if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.TIMEOUT);
  622. }
  623. });
  624. }
  625. };
  626.  
  627. // 링크 정리 모듈: 중간 URL 제거
  628. const LinkCleaner = {
  629. // URL 디코딩
  630. decodeRealUrl(url, key) {
  631. const param = new URL(url).searchParams.get(key)?.replace(/^a1/, '');
  632. if (!param) return null;
  633. try {
  634. const decoded = decodeURIComponent(atob(param.replace(/_/g, '/').replace(/-/g, '+')));
  635. return decoded.startsWith('/') ? location.origin + decoded : decoded;
  636. } catch {
  637. return null;
  638. }
  639. },
  640.  
  641. // 실제 URL로 변환
  642. resolveRealUrl(url) {
  643. const rules = [
  644. { pattern: /bing\.com\/(ck\/a|aclick)/, key: 'u' },
  645. { pattern: /so\.com\/search\/eclk/, key: 'aurl' }
  646. ];
  647. for (const { pattern, key } of rules) {
  648. if (pattern.test(url)) {
  649. const real = this.decodeRealUrl(url, key);
  650. if (real && real !== url) return real;
  651. }
  652. }
  653. return url;
  654. },
  655.  
  656. // 모든 링크를 실제 URL로 변환
  657. convertLinksToReal(root) {
  658. root.querySelectorAll('a[href]').forEach(a => {
  659. const realUrl = this.resolveRealUrl(a.href);
  660. if (realUrl && realUrl !== a.href) a.href = realUrl;
  661. });
  662. }
  663. };
  664.  
  665. // 버전 확인 모듈: marked.js 버전 체크
  666. const VersionChecker = {
  667. // 버전 비교
  668. compareVersions(current, latest) {
  669. const currentParts = current.split('.').map(Number);
  670. const latestParts = latest.split('.').map(Number);
  671. for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
  672. const c = currentParts[i] || 0;
  673. const l = latestParts[i] || 0;
  674. if (c < l) return -1;
  675. if (c > l) return 1;
  676. }
  677. return 0;
  678. },
  679.  
  680. // marked.js 버전 체크 및 업데이트 알림 표시
  681. checkMarkedJsVersion() {
  682. localStorage.setItem(Config.STORAGE_KEYS.CURRENT_VERSION, Config.VERSIONS.MARKED_VERSION);
  683.  
  684. GM_xmlhttpRequest({
  685. method: 'GET',
  686. url: Config.API.MARKED_CDN_URL,
  687. onload: ({ responseText }) => {
  688. try {
  689. const latest = JSON.parse(responseText).version;
  690. localStorage.setItem(Config.STORAGE_KEYS.LATEST_VERSION, latest);
  691.  
  692. const lastNotified = localStorage.getItem(Config.STORAGE_KEYS.LAST_NOTIFIED);
  693. if (this.compareVersions(Config.VERSIONS.MARKED_VERSION, latest) < 0 &&
  694. (!lastNotified || this.compareVersions(lastNotified, latest) < 0)) {
  695. const existingPopup = document.getElementById('marked-update-popup');
  696. if (existingPopup) existingPopup.remove();
  697.  
  698. const popup = document.createElement('div');
  699. popup.id = 'marked-update-popup';
  700. popup.innerHTML = `
  701. <p><b>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_TITLE)}</b></p>
  702. <p>Current: ${Config.VERSIONS.MARKED_VERSION}<br>Latest: ${latest}</p>
  703. <button>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_NOW)}</button>
  704. `;
  705. popup.querySelector('button').onclick = () => {
  706. localStorage.setItem(Config.STORAGE_KEYS.LAST_NOTIFIED, latest);
  707. popup.remove();
  708. };
  709. document.body.appendChild(popup);
  710. }
  711. } catch (e) {
  712. console.warn('marked.min.js version check error:', e.message);
  713. }
  714. },
  715. onerror: () => console.warn('marked.min.js version check request failed')
  716. });
  717. }
  718. };
  719.  
  720. // 이벤트 핸들러 모듈: URL 및 DOM 변경 감지
  721. const EventHandler = {
  722. // URL 변경 감지 및 처리
  723. observeUrlChange(onChangeCallback) {
  724. let lastUrl = location.href;
  725.  
  726. const checkUrlChange = () => {
  727. if (location.href !== lastUrl) {
  728. lastUrl = location.href;
  729. onChangeCallback();
  730. }
  731. };
  732.  
  733. const originalPushState = history.pushState;
  734. history.pushState = function (...args) {
  735. originalPushState.apply(this, args);
  736. checkUrlChange();
  737. };
  738.  
  739. const originalReplaceState = history.replaceState;
  740. history.replaceState = function (...args) {
  741. originalReplaceState.apply(this, args);
  742. checkUrlChange();
  743. };
  744.  
  745. window.addEventListener('popstate', checkUrlChange);
  746.  
  747. const observer = new MutationObserver(checkUrlChange);
  748. const targetNode = document.querySelector('head > title') || document.body;
  749. observer.observe(targetNode, { childList: true, subtree: true });
  750. }
  751. };
  752.  
  753. // 렌더링 상태 관리 모듈: UI 렌더링 상태 관리
  754. const RenderState = {
  755. isRendering: false,
  756.  
  757. // 렌더링 시작
  758. startRendering() {
  759. if (this.isRendering) return false;
  760. this.isRendering = true;
  761. return true;
  762. },
  763.  
  764. // 렌더링 완료
  765. finishRendering() {
  766. this.isRendering = false;
  767. }
  768. };
  769.  
  770. // UI 렌더링 모듈: UI 렌더링 로직
  771. const UIRenderer = {
  772. // 데스크톱 환경 렌더링
  773. renderDesktop(query, apiKey) {
  774. // Gemini UI가 표시될 수 있는지 확인 (데스크톱에서 강제로 렌더링 보장)
  775. const contextTarget = document.getElementById('b_context') || document.querySelector('.b_right');
  776. if (!contextTarget) return false;
  777.  
  778. requestAnimationFrame(() => {
  779. const wrapper = UI.createGeminiUI(query, apiKey);
  780. contextTarget.prepend(wrapper);
  781.  
  782. window.requestIdleCallback(() => {
  783. const content = wrapper.querySelector('#gemini-content');
  784. if (content) {
  785. const cache = sessionStorage.getItem(`${Config.CACHE.PREFIX}${query}`);
  786. if (cache) {
  787. content.innerHTML = marked.parse(cache);
  788. } else {
  789. window.requestIdleCallback(() => GeminiAPI.fetch(query, content, apiKey));
  790. }
  791. }
  792. RenderState.finishRendering();
  793. });
  794. });
  795. return true;
  796. },
  797.  
  798. // 모바일 환경 렌더링
  799. renderMobile(query) {
  800. const contentTarget = document.getElementById('b_content');
  801. if (!contentTarget) return false;
  802.  
  803. requestAnimationFrame(() => {
  804. const googleBtn = UI.createGoogleButton(query);
  805. contentTarget.parentNode.insertBefore(googleBtn, contentTarget);
  806. RenderState.finishRendering();
  807. });
  808. return true;
  809. },
  810.  
  811. // 태블릿 환경 렌더링 (아무 UI도 표시하지 않음, Google 검색 버튼 포함)
  812. renderTablet() {
  813. // 태블릿에서는 Gemini UI와 Google 검색 버튼 모두 표시하지 않음
  814. RenderState.finishRendering();
  815. return true;
  816. },
  817.  
  818. // 메인 렌더링 함수
  819. render() {
  820. if (!RenderState.startRendering()) return;
  821.  
  822. const query = Utils.getQuery();
  823. if (!query) {
  824. RenderState.finishRendering();
  825. return;
  826. }
  827.  
  828. UI.removeExistingElements();
  829.  
  830. if (DeviceDetector.isDesktop()) {
  831. const apiKey = Utils.getApiKey();
  832. if (!apiKey) {
  833. RenderState.finishRendering();
  834. return;
  835. }
  836. this.renderDesktop(query, apiKey);
  837. } else if (DeviceDetector.isMobile()) {
  838. this.renderMobile(query);
  839. } else if (DeviceDetector.isTablet()) {
  840. this.renderTablet();
  841. } else {
  842. RenderState.finishRendering();
  843. }
  844. }
  845. };
  846.  
  847. // 초기화 모듈: 스크립트 초기화
  848. const Initializer = {
  849. // 초기화 실행
  850. init() {
  851. const initialize = () => {
  852. Styles.initStyles();
  853. ThemeManager.applyTheme();
  854. LinkCleaner.convertLinksToReal(document);
  855. // DOM 준비 완료 후 렌더링
  856. const checkAndRender = () => {
  857. if (document.getElementById('b_context') || document.querySelector('.b_right')) {
  858. UIRenderer.render();
  859. } else {
  860. setTimeout(checkAndRender, 100); // 100ms 후 재시도
  861. }
  862. };
  863. checkAndRender();
  864. EventHandler.observeUrlChange(() => {
  865. UIRenderer.render();
  866. LinkCleaner.convertLinksToReal(document);
  867. });
  868. ThemeManager.observeThemeChange();
  869. };
  870.  
  871. if (document.readyState === 'complete' || document.readyState === 'interactive') {
  872. setTimeout(initialize, 1);
  873. } else {
  874. document.addEventListener('DOMContentLoaded', initialize);
  875. }
  876. }
  877. };
  878.  
  879. // 스크립트 실행
  880. Initializer.init();
  881. })();