Bing Plus

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

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