Bing Plus

Bing 검색 결과 옆에 Gemini 응답을 표시하고 중간 URL을 제거하여 검색 속도를 향상시킵니다.

当前为 2025-04-24 提交的版本,查看 最新版本

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