Bing Plus

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

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

  1. // ==UserScript==
  2. // @name Bing Plus
  3. // @version 5.5
  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. @media (min-width: 769px) {
  364. #google-search-btn:hover {
  365. transform: scale(1.1);
  366. }
  367. #google-search-btn:hover img {
  368. transform: scale(1.1);
  369. }
  370. }
  371.  
  372. /* 업데이트 팝업 스타일 */
  373. #marked-update-popup {
  374. position: fixed;
  375. top: 30%;
  376. left: 50%;
  377. transform: translate(-50%, -50%);
  378. background: ${Config.STYLES.COLORS.BACKGROUND};
  379. padding: ${Config.UI.DEFAULT_PADDING * 1.25}px;
  380. z-index: ${Config.UI.Z_INDEX};
  381. border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER};
  382. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  383. text-align: center;
  384. }
  385.  
  386. [data-theme="dark"] #marked-update-popup,
  387. .dark #marked-update-popup,
  388. .b_dark #marked-update-popup {
  389. background: ${Config.STYLES.COLORS.DARK_BACKGROUND} !important;
  390. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  391. }
  392.  
  393. @media (prefers-color-scheme: dark) {
  394. #marked-update-popup {
  395. background: ${Config.STYLES.COLORS.DARK_BACKGROUND} !important;
  396. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  397. }
  398. }
  399.  
  400. #marked-update-popup button {
  401. margin-top: ${Config.UI.DEFAULT_MARGIN * 1.25}px;
  402. padding: ${Config.UI.DEFAULT_MARGIN}px ${Config.UI.DEFAULT_PADDING}px;
  403. cursor: pointer;
  404. border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER};
  405. border-radius: ${Config.STYLES.BORDER_RADIUS};
  406. background-color: ${Config.STYLES.COLORS.BUTTON_BG};
  407. color: ${Config.STYLES.COLORS.TITLE};
  408. font-family: sans-serif;
  409. }
  410.  
  411. /* 모바일 반응형 스타일 */
  412. @media (max-width: 768px) {
  413. #google-search-btn {
  414. max-width: 96%;
  415. margin: ${Config.UI.DEFAULT_MARGIN}px auto;
  416. padding: ${Config.UI.DEFAULT_PADDING * 0.75}px;
  417. border-radius: 16px;
  418. }
  419. #gemini-box {
  420. padding: ${Config.UI.DEFAULT_PADDING * 0.75}px;
  421. border-radius: 16px;
  422. }
  423. }
  424. `;
  425. },
  426. // 스타일 초기화 및 적용
  427. initStyles() {
  428. const styleElement = document.createElement('style');
  429. styleElement.id = 'bing-plus-styles';
  430. styleElement.textContent = this.generateStyles();
  431. document.head.appendChild(styleElement);
  432. },
  433. // 현재 테마 감지 및 적용
  434. applyTheme() {
  435. const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark' ||
  436. document.documentElement.classList.contains('dark') ||
  437. document.documentElement.classList.contains('b_dark') ||
  438. window.matchMedia('(prefers-color-scheme: dark)').matches;
  439.  
  440. const geminiBox = document.querySelector('#gemini-box');
  441. if (geminiBox) {
  442. geminiBox.style.background = isDarkTheme
  443. ? Config.STYLES.COLORS.DARK_BACKGROUND
  444. : Config.STYLES.COLORS.BACKGROUND;
  445. geminiBox.style.borderColor = isDarkTheme
  446. ? Config.STYLES.COLORS.DARK_BORDER
  447. : Config.STYLES.COLORS.BORDER;
  448. }
  449. }
  450. };
  451.  
  452. // 유틸리티 모듈: 공통 유틸리티 함수
  453. const Utils = {
  454. // 캐싱 변수
  455. _isDesktop: null,
  456. _isGeminiAvailable: null,
  457. // 데스크톱 환경인지 확인
  458. isDesktop() {
  459. if (this._isDesktop === null) {
  460. this._isDesktop = window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent);
  461. }
  462. return this._isDesktop;
  463. },
  464. // Gemini UI를 표시할 수 있는 환경인지 확인
  465. isGeminiAvailable() {
  466. if (this._isGeminiAvailable === null) {
  467. const hasBContext = !!document.getElementById('b_context');
  468. const hasBRight = !!document.querySelector('.b_right');
  469. this._isGeminiAvailable = this.isDesktop() && (hasBContext || hasBRight);
  470. }
  471. return this._isGeminiAvailable;
  472. },
  473. // 검색 쿼리 추출
  474. getQuery() {
  475. return new URLSearchParams(location.search).get('q');
  476. },
  477. // Gemini API 키 가져오기 또는 입력받기
  478. getApiKey() {
  479. let key = localStorage.getItem('geminiApiKey');
  480. if (!key) {
  481. key = prompt(Localization.getMessage(Config.MESSAGE_KEYS.ENTER_API_KEY));
  482. if (key) localStorage.setItem('geminiApiKey', key);
  483. }
  484. return key;
  485. }
  486. };
  487.  
  488. // UI 모듈: UI 요소 생성
  489. const UI = {
  490. // Google 검색 버튼 생성
  491. createGoogleButton(query) {
  492. const btn = document.createElement('button');
  493. btn.id = 'google-search-btn';
  494. btn.innerHTML = `
  495. <img src="${Config.ASSETS.GOOGLE_LOGO}" alt="Google Logo">
  496. ${Localization.getMessage(Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE)}
  497. `;
  498. btn.onclick = () => window.open(`https://www.google.com/search?q=${encodeURIComponent(query)}`, '_blank');
  499. return btn;
  500. },
  501. // Gemini 응답 박스 생성
  502. createGeminiBox(query, apiKey) {
  503. const box = document.createElement('div');
  504. box.id = 'gemini-box';
  505. box.innerHTML = `
  506. <div id="gemini-header">
  507. <div id="gemini-title-wrap">
  508. <img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}" alt="Gemini Logo">
  509. <h3>Gemini Search Results</h3>
  510. </div>
  511. <img id="gemini-refresh-btn" title="Refresh" src="${Config.ASSETS.REFRESH_ICON}" />
  512. </div>
  513. <hr id="gemini-divider">
  514. <div id="gemini-content">${Localization.getMessage(Config.MESSAGE_KEYS.LOADING)}</div>
  515. `;
  516. box.querySelector('#gemini-refresh-btn').onclick = () => GeminiAPI.fetch(query, box.querySelector('#gemini-content'), apiKey, true);
  517.  
  518. // 데스크톱 환경에서만 marked.js 버전 체크
  519. if (Utils.isDesktop()) {
  520. VersionChecker.checkMarkedJsVersion();
  521. }
  522.  
  523. return box;
  524. },
  525. // Gemini UI 전체 생성 (버튼 + 박스)
  526. createGeminiUI(query, apiKey) {
  527. const wrapper = document.createElement('div');
  528. wrapper.id = 'gemini-wrapper';
  529. wrapper.appendChild(this.createGoogleButton(query));
  530. wrapper.appendChild(this.createGeminiBox(query, apiKey));
  531. return wrapper;
  532. }
  533. };
  534.  
  535. // Gemini API 모듈: Gemini API 호출
  536. const GeminiAPI = {
  537. // Gemini API 호출 및 응답 처리
  538. fetch(query, container, apiKey, force = false) {
  539. const cacheKey = `${Config.CACHE.PREFIX}${query}`;
  540. const cached = force ? null : sessionStorage.getItem(cacheKey);
  541. if (cached) {
  542. if (container) {
  543. container.innerHTML = marked.parse(cached);
  544. }
  545. return;
  546. }
  547.  
  548. if (container) {
  549. container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.LOADING);
  550. }
  551.  
  552. GM_xmlhttpRequest({
  553. method: 'POST',
  554. url: `${Config.API.GEMINI_URL}${Config.API.GEMINI_MODEL}:generateContent?key=${apiKey}`,
  555. headers: { 'Content-Type': 'application/json' },
  556. data: JSON.stringify({
  557. contents: [{ parts: [{ text: Localization.getMessage(Config.MESSAGE_KEYS.PROMPT, { query }) }] }]
  558. }),
  559. onload({ responseText }) {
  560. try {
  561. const text = JSON.parse(responseText)?.candidates?.[0]?.content?.parts?.[0]?.text;
  562. if (text) {
  563. sessionStorage.setItem(cacheKey, text);
  564. if (container) container.innerHTML = marked.parse(text);
  565. } else {
  566. if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.GEMINI_EMPTY);
  567. }
  568. } catch (e) {
  569. if (container) container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.PARSE_ERROR)} ${e.message}`;
  570. }
  571. },
  572. onerror(err) {
  573. if (container) container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.NETWORK_ERROR)} ${err.finalUrl}`;
  574. },
  575. ontimeout() {
  576. if (container) container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.TIMEOUT);
  577. }
  578. });
  579. }
  580. };
  581.  
  582. // 링크 정리 모듈: 중간 URL 제거
  583. const LinkCleaner = {
  584. // URL 디코딩
  585. decodeRealUrl(url, key) {
  586. const param = new URL(url).searchParams.get(key)?.replace(/^a1/, '');
  587. if (!param) return null;
  588. try {
  589. const decoded = decodeURIComponent(atob(param.replace(/_/g, '/').replace(/-/g, '+')));
  590. return decoded.startsWith('/') ? location.origin + decoded : decoded;
  591. } catch {
  592. return null;
  593. }
  594. },
  595. // 실제 URL로 변환
  596. resolveRealUrl(url) {
  597. const rules = [
  598. { pattern: /bing\.com\/(ck\/a|aclick)/, key: 'u' },
  599. { pattern: /so\.com\/search\/eclk/, key: 'aurl' }
  600. ];
  601. for (const { pattern, key } of rules) {
  602. if (pattern.test(url)) {
  603. const real = this.decodeRealUrl(url, key);
  604. if (real && real !== url) return real;
  605. }
  606. }
  607. return url;
  608. },
  609. // 모든 링크를 실제 URL로 변환
  610. convertLinksToReal(root) {
  611. root.querySelectorAll('a[href]').forEach(a => {
  612. const realUrl = this.resolveRealUrl(a.href);
  613. if (realUrl && realUrl !== a.href) a.href = realUrl;
  614. });
  615. }
  616. };
  617.  
  618. // 버전 확인 모듈: marked.js 버전 체크
  619. const VersionChecker = {
  620. // 버전 비교
  621. compareVersions(current, latest) {
  622. const currentParts = current.split('.').map(Number);
  623. const latestParts = latest.split('.').map(Number);
  624. for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
  625. const c = currentParts[i] || 0;
  626. const l = latestParts[i] || 0;
  627. if (c < l) return -1;
  628. if (c > l) return 1;
  629. }
  630. return 0;
  631. },
  632. // marked.js 최신 버전 확인 및 업데이트 알림
  633. checkMarkedJsVersion() {
  634. localStorage.setItem(Config.STORAGE_KEYS.CURRENT_VERSION, Config.VERSIONS.MARKED_VERSION);
  635.  
  636. // VersionChecker를 변수로 저장하여 콜백 내에서 사용
  637. const self = this;
  638.  
  639. GM_xmlhttpRequest({
  640. method: 'GET',
  641. url: Config.API.MARKED_CDN_URL,
  642. onload({ responseText }) {
  643. try {
  644. const latest = JSON.parse(responseText).version;
  645. localStorage.setItem(Config.STORAGE_KEYS.LATEST_VERSION, latest);
  646. console.log(`Current version: ${Config.VERSIONS.MARKED_VERSION}, Latest version: ${latest}`); // 디버깅 로그
  647. console.log(`Compare result: ${self.compareVersions(Config.VERSIONS.MARKED_VERSION, latest)}`); // 비교 결과 로그
  648.  
  649. const lastNotified = localStorage.getItem(Config.STORAGE_KEYS.LAST_NOTIFIED);
  650. console.log(`Last notified: ${lastNotified}`); // lastNotified 값 확인
  651.  
  652. if (self.compareVersions(Config.VERSIONS.MARKED_VERSION, latest) < 0 &&
  653. (!lastNotified || self.compareVersions(lastNotified, latest) < 0)) {
  654. console.log('Popup should be displayed'); // 팝업 표시 조건 충족 여부 확인
  655. const existingPopup = document.getElementById('marked-update-popup');
  656. if (existingPopup) existingPopup.remove();
  657.  
  658. const popup = document.createElement('div');
  659. popup.id = 'marked-update-popup';
  660. popup.innerHTML = `
  661. <p><b>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_TITLE)}</b></p>
  662. <p>Current: ${Config.VERSIONS.MARKED_VERSION}<br>Latest: ${latest}</p>
  663. <button>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_NOW)}</button>
  664. `;
  665. popup.querySelector('button').onclick = () => {
  666. localStorage.setItem(Config.STORAGE_KEYS.LAST_NOTIFIED, latest);
  667. popup.remove();
  668. };
  669. document.body.appendChild(popup);
  670. } else {
  671. console.log('Popup not displayed due to version or lastNotified condition');
  672. }
  673. } catch (e) {
  674. console.warn('marked.min.js version check error:', e.message);
  675. }
  676. },
  677. onerror: () => console.warn('marked.min.js version check request failed')
  678. });
  679. }
  680. };
  681.  
  682. // 메인 모듈: 전체 기능 초기화 및 관리
  683. const Main = {
  684. isRendering: false, // 렌더링 중복 방지 플래그
  685.  
  686. // Gemini UI 렌더링
  687. renderGemini() {
  688. if (this.isRendering) return; // 중복 렌더링 방지
  689. this.isRendering = true;
  690.  
  691. const query = Utils.getQuery();
  692. if (!query) {
  693. this.isRendering = false;
  694. return;
  695. }
  696.  
  697. // 기존 UI 요소 제거
  698. const existingElements = document.querySelectorAll('#gemini-wrapper, #google-search-btn');
  699. existingElements.forEach(el => el.remove());
  700.  
  701. if (Utils.isDesktop()) {
  702. // 데스크톱 환경: Gemini UI 표시
  703. if (!Utils.isGeminiAvailable()) {
  704. this.isRendering = false;
  705. return;
  706. }
  707.  
  708. const apiKey = Utils.getApiKey();
  709. if (!apiKey) {
  710. this.isRendering = false;
  711. return;
  712. }
  713.  
  714. const contextTarget = document.getElementById('b_context') || document.querySelector('.b_right');
  715. if (!contextTarget) {
  716. this.isRendering = false;
  717. return;
  718. }
  719.  
  720. // UI 렌더링
  721. requestAnimationFrame(() => {
  722. const wrapper = UI.createGeminiUI(query, apiKey);
  723. contextTarget.prepend(wrapper);
  724.  
  725. // Gemini 응답 비동기 로드
  726. window.requestIdleCallback(() => {
  727. const content = wrapper.querySelector('#gemini-content');
  728. if (content) {
  729. const cache = sessionStorage.getItem(`${Config.CACHE.PREFIX}${query}`);
  730. if (cache) {
  731. content.innerHTML = marked.parse(cache);
  732. } else {
  733. window.requestIdleCallback(() => GeminiAPI.fetch(query, content, apiKey));
  734. }
  735. }
  736. this.isRendering = false;
  737. });
  738. });
  739. } else {
  740. // 모바일 환경: Google 검색 버튼만 표시
  741. const contentTarget = document.getElementById('b_content');
  742. if (!contentTarget) {
  743. this.isRendering = false;
  744. return;
  745. }
  746.  
  747. requestAnimationFrame(() => {
  748. const googleBtn = UI.createGoogleButton(query);
  749. contentTarget.parentNode.insertBefore(googleBtn, contentTarget);
  750. this.isRendering = false;
  751. });
  752. }
  753. },
  754.  
  755. // URL 변경 감지
  756. observeUrlChange() {
  757. let lastUrl = location.href;
  758.  
  759. const checkUrlChange = () => {
  760. if (location.href !== lastUrl) {
  761. lastUrl = location.href;
  762. this.renderGemini();
  763. LinkCleaner.convertLinksToReal(document);
  764. }
  765. };
  766.  
  767. // History API 이벤트 감지
  768. const originalPushState = history.pushState;
  769. history.pushState = function (...args) {
  770. originalPushState.apply(this, args);
  771. checkUrlChange();
  772. };
  773.  
  774. const originalReplaceState = history.replaceState;
  775. history.replaceState = function (...args) {
  776. originalReplaceState.apply(this, args);
  777. checkUrlChange();
  778. };
  779.  
  780. window.addEventListener('popstate', checkUrlChange);
  781.  
  782. // DOM 변경 감지 (title 태그만 감시)
  783. const observer = new MutationObserver(checkUrlChange);
  784. const targetNode = document.querySelector('head > title') || document.body;
  785. observer.observe(targetNode, { childList: true, subtree: true });
  786. },
  787.  
  788. // 테마 변경 감지
  789. observeThemeChange() {
  790. const observer = new MutationObserver(() => {
  791. Styles.applyTheme();
  792. });
  793.  
  794. // 문서와 컨텍스트 요소의 테마 변경 감지
  795. const targetElement = document.querySelector('#b_context') || document.querySelector('.b_right') || document.documentElement;
  796. observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
  797. if (targetElement !== document.documentElement) {
  798. observer.observe(targetElement, { attributes: true, attributeFilter: ['style', 'class'] });
  799. }
  800.  
  801. window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
  802. Styles.applyTheme();
  803. });
  804. },
  805.  
  806. // 초기화 함수
  807. init() {
  808. const initialize = () => {
  809. Styles.initStyles(); // 스타일 초기화
  810. Styles.applyTheme(); // 테마 적용
  811. LinkCleaner.convertLinksToReal(document); // 링크 정리
  812. this.renderGemini(); // Gemini UI 렌더링
  813. this.observeUrlChange(); // URL 변경 감지
  814. this.observeThemeChange(); // 테마 변경 감지
  815. };
  816.  
  817. if (document.readyState === 'complete' || document.readyState === 'interactive') {
  818. setTimeout(initialize, 1);
  819. } else {
  820. document.addEventListener('DOMContentLoaded', initialize);
  821. }
  822. }
  823. };
  824.  
  825. // 스크립트 실행
  826. Main.init();
  827. })();