Bing Plus

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

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

  1. // ==UserScript==
  2. // @name Bing Plus
  3. // @version 5.3
  4. // @description Display Gemini response results next to Bing search results and speed up searches by eliminating intermediate URLs.
  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. // 설정 모듈
  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 styleElement = document.createElement('style');
  85. styleElement.id = 'bing-plus-styles';
  86. styleElement.textContent = `
  87. #b_results > li.b_ad a { color: green !important; }
  88.  
  89. /* 상위 요소 스타일 초기화 */
  90. #b_context, .b_context, .b_right {
  91. color: initial !important;
  92. border: none !important;
  93. border-width: 0 !important;
  94. border-style: none !important;
  95. border-collapse: separate !important;
  96. background: transparent !important;
  97. }
  98.  
  99. #b_context #gemini-box,
  100. .b_right #gemini-box {
  101. width: 100%;
  102. max-width: 100%;
  103. background: ${Config.STYLES.COLORS.BACKGROUND} !important;
  104. border: ${Config.STYLES.BORDER} !important;
  105. border-style: solid !important;
  106. border-width: 1px !important;
  107. border-radius: ${Config.STYLES.BORDER_RADIUS};
  108. padding: ${Config.UI.DEFAULT_PADDING}px;
  109. margin-bottom: ${Config.UI.DEFAULT_MARGIN * 2.5}px;
  110. font-family: sans-serif;
  111. overflow-x: auto;
  112. position: relative;
  113. box-sizing: border-box;
  114. color: initial !important;
  115. }
  116.  
  117. [data-theme="light"] #b_context #gemini-box,
  118. [data-theme="light"] .b_right #gemini-box,
  119. .light #b_context #gemini-box,
  120. .light .b_right #gemini-box {
  121. background: ${Config.STYLES.COLORS.BACKGROUND} !important;
  122. border: 1px solid ${Config.STYLES.COLORS.BORDER} !important;
  123. border-style: solid !important;
  124. border-width: 1px !important;
  125. }
  126.  
  127. [data-theme="light"] #b_context #gemini-box h3,
  128. [data-theme="light"] .b_right #gemini-box h3,
  129. .light #b_context #gemini-box h3,
  130. .light .b_right #gemini-box h3 {
  131. color: ${Config.STYLES.COLORS.TITLE} !important;
  132. }
  133.  
  134. [data-theme="light"] #b_context #gemini-content,
  135. [data-theme="light"] #b_context #gemini-content *,
  136. [data-theme="light"] .b_right #gemini-content,
  137. [data-theme="light"] .b_right #gemini-content *,
  138. .light #b_context #gemini-content,
  139. .light #b_context #gemini-content *,
  140. .light .b_right #gemini-content,
  141. .light .b_right #gemini-content * {
  142. color: ${Config.STYLES.COLORS.TEXT} !important;
  143. background: transparent !important;
  144. }
  145.  
  146. [data-theme="light"] #b_context #gemini-divider,
  147. [data-theme="light"] .b_right #gemini-divider,
  148. .light #b_context #gemini-divider,
  149. .light .b_right #gemini-divider {
  150. background: ${Config.STYLES.COLORS.BORDER} !important;
  151. }
  152.  
  153. [data-theme="dark"] #b_context #gemini-box,
  154. [data-theme="dark"] .b_right #gemini-box,
  155. .dark #b_context #gemini-box,
  156. .dark .b_right #gemini-box,
  157. .b_dark #b_context #gemini-box,
  158. .b_dark .b_right #gemini-box {
  159. background: ${Config.STYLES.COLORS.DARK_BACKGROUND} !important;
  160. border: 1px solid ${Config.STYLES.COLORS.DARK_BORDER} !important;
  161. border-style: solid !important;
  162. border-width: 1px !important;
  163. }
  164.  
  165. @media (prefers-color-scheme: dark) {
  166. #b_context #gemini-box,
  167. .b_right #gemini-box {
  168. background: ${Config.STYLES.COLORS.DARK_BACKGROUND} !important;
  169. border: 1px solid ${Config.STYLES.COLORS.DARK_BORDER} !important;
  170. border-style: solid !important;
  171. border-width: 1px !important;
  172. }
  173. }
  174.  
  175. [data-theme="dark"] #b_context #gemini-box h3,
  176. [data-theme="dark"] .b_right #gemini-box h3,
  177. .dark #b_context #gemini-box h3,
  178. .dark .b_right #gemini-box h3,
  179. .b_dark #b_context #gemini-box h3,
  180. .b_dark .b_right #gemini-box h3 {
  181. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  182. }
  183.  
  184. @media (prefers-color-scheme: dark) {
  185. #b_context #gemini-box h3,
  186. .b_right #gemini-box h3 {
  187. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  188. }
  189. }
  190.  
  191. [data-theme="dark"] #b_context #gemini-content,
  192. [data-theme="dark"] #b_context #gemini-content *,
  193. [data-theme="dark"] .b_right #gemini-content,
  194. [data-theme="dark"] .b_right #gemini-content *,
  195. .dark #b_context #gemini-content,
  196. .dark #b_context #gemini-content *,
  197. .dark .b_right #gemini-content,
  198. .dark .b_right #gemini-content *,
  199. .b_dark #b_context #gemini-content,
  200. .b_dark #b_context #gemini-content *,
  201. .b_dark .b_right #gemini-content,
  202. .b_dark .b_right #gemini-content * {
  203. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  204. background: transparent !important;
  205. }
  206.  
  207. @media (prefers-color-scheme: dark) {
  208. #b_context #gemini-content,
  209. #b_context #gemini-content *,
  210. .b_right #gemini-content,
  211. .b_right #gemini-content * {
  212. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  213. background: transparent !important;
  214. }
  215. }
  216.  
  217. /* 코드 블록 스타일 */
  218. #gemini-content pre {
  219. background: ${Config.STYLES.COLORS.CODE_BLOCK_BG} !important;
  220. padding: ${Config.UI.DEFAULT_MARGIN + 2}px;
  221. border-radius: ${Config.STYLES.BORDER_RADIUS};
  222. overflow-x: auto;
  223. }
  224.  
  225. /* 다크 모드에서 코드 블록 배경색 */
  226. [data-theme="dark"] #gemini-content pre,
  227. .dark #gemini-content pre,
  228. .b_dark #gemini-content pre,
  229. [data-theme="dark"] #b_context #gemini-content pre,
  230. [data-theme="dark"] .b_right #gemini-content pre,
  231. .dark #b_context #gemini-content pre,
  232. .dark .b_right #gemini-content pre,
  233. .b_dark #b_context #gemini-content pre,
  234. .b_dark .b_right #gemini-content pre {
  235. background: ${Config.STYLES.COLORS.DARK_CODE_BLOCK_BG} !important;
  236. }
  237.  
  238. @media (prefers-color-scheme: dark) {
  239. #gemini-content pre,
  240. #b_context #gemini-content pre,
  241. .b_right #gemini-content pre {
  242. background: ${Config.STYLES.COLORS.DARK_CODE_BLOCK_BG} !important;
  243. }
  244. }
  245.  
  246. [data-theme="dark"] #b_context #gemini-divider,
  247. [data-theme="dark"] .b_right #gemini-divider,
  248. .dark #b_context #gemini-divider,
  249. .dark .b_right #gemini-divider,
  250. .b_dark #b_context #gemini-divider,
  251. .b_dark .b_right #gemini-divider {
  252. background: ${Config.STYLES.COLORS.DARK_BORDER} !important;
  253. }
  254.  
  255. @media (prefers-color-scheme: dark) {
  256. #b_context #gemini-divider,
  257. .b_right #gemini-divider {
  258. background: ${Config.STYLES.COLORS.DARK_BORDER} !important;
  259. }
  260. }
  261.  
  262. #gemini-header {
  263. display: flex;
  264. align-items: center;
  265. justify-content: space-between;
  266. margin-bottom: ${Config.UI.DEFAULT_MARGIN}px;
  267. }
  268.  
  269. #gemini-title-wrap {
  270. display: flex;
  271. align-items: center;
  272. }
  273.  
  274. #gemini-logo {
  275. width: ${Config.STYLES.LOGO_SIZE};
  276. height: ${Config.STYLES.LOGO_SIZE};
  277. margin-right: ${Config.UI.DEFAULT_MARGIN}px;
  278. }
  279.  
  280. #gemini-box h3 {
  281. margin: 0;
  282. font-size: ${Config.STYLES.FONT_SIZE.TITLE};
  283. font-weight: bold;
  284. }
  285.  
  286. #gemini-refresh-btn {
  287. width: ${Config.STYLES.ICON_SIZE};
  288. height: ${Config.STYLES.ICON_SIZE};
  289. cursor: pointer;
  290. opacity: 0.6;
  291. transition: transform 0.5s ease;
  292. }
  293.  
  294. #gemini-refresh-btn:hover {
  295. opacity: 1;
  296. transform: rotate(360deg);
  297. }
  298.  
  299. #gemini-divider {
  300. height: 1px;
  301. margin: ${Config.UI.DEFAULT_MARGIN}px 0;
  302. }
  303.  
  304. #gemini-content {
  305. font-size: ${Config.STYLES.FONT_SIZE.TEXT};
  306. line-height: 1.6;
  307. white-space: pre-wrap;
  308. word-wrap: break-word;
  309. background: transparent !important;
  310. }
  311.  
  312. #google-search-btn {
  313. width: 100%;
  314. max-width: 100%;
  315. font-size: ${Config.STYLES.FONT_SIZE.TEXT};
  316. padding: ${Config.UI.DEFAULT_MARGIN}px;
  317. margin-bottom: ${Config.UI.DEFAULT_MARGIN * 1.25}px;
  318. cursor: pointer;
  319. border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER};
  320. border-radius: ${Config.STYLES.BORDER_RADIUS};
  321. background-color: ${Config.STYLES.COLORS.BUTTON_BG};
  322. color: ${Config.STYLES.COLORS.TITLE};
  323. font-family: sans-serif;
  324. display: flex;
  325. align-items: center;
  326. justify-content: center;
  327. gap: ${Config.UI.DEFAULT_MARGIN}px;
  328. transition: transform 0.2s ease;
  329. }
  330.  
  331. #google-search-btn img {
  332. width: ${Config.STYLES.SMALL_ICON_SIZE};
  333. height: ${Config.STYLES.SMALL_ICON_SIZE};
  334. vertical-align: middle;
  335. transition: transform 0.2s ease;
  336. }
  337.  
  338. @media (min-width: 769px) {
  339. #google-search-btn:hover {
  340. transform: scale(1.1);
  341. }
  342. #google-search-btn:hover img {
  343. transform: scale(1.1);
  344. }
  345. }
  346.  
  347. #marked-update-popup {
  348. position: fixed;
  349. top: 30%;
  350. left: 50%;
  351. transform: translate(-50%, -50%);
  352. background: ${Config.STYLES.COLORS.BACKGROUND};
  353. padding: ${Config.UI.DEFAULT_PADDING * 1.25}px;
  354. z-index: ${Config.UI.Z_INDEX};
  355. border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER};
  356. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  357. text-align: center;
  358. }
  359.  
  360. [data-theme="dark"] #marked-update-popup,
  361. .dark #marked-update-popup,
  362. .b_dark #marked-update-popup {
  363. background: ${Config.STYLES.COLORS.DARK_BACKGROUND} !important;
  364. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  365. }
  366.  
  367. @media (prefers-color-scheme: dark) {
  368. #marked-update-popup {
  369. background: ${Config.STYLES.COLORS.DARK_BACKGROUND} !important;
  370. color: ${Config.STYLES.COLORS.DARK_TEXT} !important;
  371. }
  372. }
  373.  
  374. #marked-update-popup button {
  375. margin-top: ${Config.UI.DEFAULT_MARGIN * 1.25}px;
  376. padding: ${Config.UI.DEFAULT_MARGIN}px ${Config.UI.DEFAULT_PADDING}px;
  377. cursor: pointer;
  378. border: 1px solid ${Config.STYLES.COLORS.BUTTON_BORDER};
  379. border-radius: ${Config.STYLES.BORDER_RADIUS};
  380. background-color: ${Config.STYLES.COLORS.BUTTON_BG};
  381. color: ${Config.STYLES.COLORS.TITLE};
  382. font-family: sans-serif;
  383. }
  384.  
  385. @media (max-width: 768px) {
  386. #google-search-btn {
  387. max-width: 96%;
  388. margin: ${Config.UI.DEFAULT_MARGIN}px auto;
  389. padding: ${Config.UI.DEFAULT_PADDING * 0.75}px;
  390. border-radius: 16px;
  391. }
  392. #gemini-box {
  393. padding: ${Config.UI.DEFAULT_PADDING * 0.75}px;
  394. border-radius: 16px;
  395. }
  396. }
  397. `;
  398. document.head.appendChild(styleElement);
  399.  
  400. // 지역화 모듈
  401. const Localization = {
  402. MESSAGES: {
  403. [Config.MESSAGE_KEYS.PROMPT]: {
  404. ko: `"${'${query}'}" 대한 정보를 찾아줘`,
  405. zh: `请以标记格式填写有关\"${'${query}'}\"的信息。`,
  406. default: `Please write information about \"${'${query}'}\" in markdown format`
  407. },
  408. [Config.MESSAGE_KEYS.ENTER_API_KEY]: {
  409. ko: 'Gemini API 키를 입력하세요:',
  410. zh: '请输入 Gemini API 密钥:',
  411. default: 'Please enter your Gemini API key:'
  412. },
  413. [Config.MESSAGE_KEYS.GEMINI_EMPTY]: {
  414. ko: '⚠️ Gemini 응답이 비어있습니다.',
  415. zh: '⚠️ Gemini 返回为空。',
  416. default: '⚠️ Gemini response is empty.'
  417. },
  418. [Config.MESSAGE_KEYS.PARSE_ERROR]: {
  419. ko: '❌ 파싱 오류:',
  420. zh: '❌ 解析错误:',
  421. default: '❌ Parsing error:'
  422. },
  423. [Config.MESSAGE_KEYS.NETWORK_ERROR]: {
  424. ko: '❌ 네트워크 오류:',
  425. zh: '❌ 网络错误:',
  426. default: '❌ Network error:'
  427. },
  428. [Config.MESSAGE_KEYS.TIMEOUT]: {
  429. ko: '❌ 요청 시간이 초과되었습니다.',
  430. zh: '❌ 请求超时。',
  431. default: '❌ Request timeout'
  432. },
  433. [Config.MESSAGE_KEYS.LOADING]: {
  434. ko: '불러오는 중...',
  435. zh: '加载中...',
  436. default: 'Loading...'
  437. },
  438. [Config.MESSAGE_KEYS.UPDATE_TITLE]: {
  439. ko: 'marked.min.js 업데이트 필요',
  440. zh: '需要更新 marked.min.js',
  441. default: 'marked.min.js update required'
  442. },
  443. [Config.MESSAGE_KEYS.UPDATE_NOW]: {
  444. ko: '확인',
  445. zh: '确认',
  446. default: 'OK'
  447. },
  448. [Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE]: {
  449. ko: 'Google 에서 검색하기',
  450. zh: '在 Google 上搜索',
  451. default: 'Search on Google'
  452. }
  453. },
  454. getMessage(key, vars = {}) {
  455. const lang = navigator.language;
  456. const langKey = lang.includes('ko') ? 'ko' : lang.includes('zh') ? 'zh' : 'default';
  457. const template = this.MESSAGES[key]?.[langKey] || this.MESSAGES[key]?.default || '';
  458. return template.replace(/\$\{(.*?)\}/g, (_, k) => vars[k] || '');
  459. }
  460. };
  461.  
  462. // 스타일 모듈
  463. const Styles = {
  464. applyTheme() {
  465. const currentTheme = document.documentElement.getAttribute('data-theme') ||
  466. (document.documentElement.classList.contains('dark') ||
  467. document.documentElement.classList.contains('b_dark')) ? 'dark' :
  468. (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  469. console.log(`Theme applied: ${currentTheme}`);
  470. }
  471. };
  472.  
  473. // 유틸리티 모듈
  474. const Utils = {
  475. isDesktop() {
  476. return window.innerWidth > 768 && !/Mobi|Android/i.test(navigator.userAgent);
  477. },
  478. isGeminiAvailable() {
  479. const hasBContext = !!document.getElementById('b_context');
  480. const hasBRight = !!document.querySelector('.b_right');
  481. return this.isDesktop() && (hasBContext || hasBRight);
  482. },
  483. getQuery() {
  484. return new URLSearchParams(location.search).get('q');
  485. },
  486. getApiKey() {
  487. let key = localStorage.getItem('geminiApiKey');
  488. if (!key) {
  489. key = prompt(Localization.getMessage(Config.MESSAGE_KEYS.ENTER_API_KEY));
  490. if (key) localStorage.setItem('geminiApiKey', key);
  491. }
  492. return key;
  493. }
  494. };
  495.  
  496. // UI 모듈
  497. const UI = {
  498. createGoogleButton(query) {
  499. const btn = document.createElement('button');
  500. btn.id = 'google-search-btn';
  501. btn.innerHTML = `
  502. <img src="${Config.ASSETS.GOOGLE_LOGO}" alt="Google Logo">
  503. ${Localization.getMessage(Config.MESSAGE_KEYS.SEARCH_ON_GOOGLE)}
  504. `;
  505. btn.onclick = () => window.open(`https://www.google.com/search?q=${encodeURIComponent(query)}`, '_blank');
  506. return btn;
  507. },
  508. createGeminiBox(query, apiKey) {
  509. const box = document.createElement('div');
  510. box.id = 'gemini-box';
  511. box.innerHTML = `
  512. <div id="gemini-header">
  513. <div id="gemini-title-wrap">
  514. <img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}" alt="Gemini Logo">
  515. <h3>Gemini Search Results</h3>
  516. </div>
  517. <img id="gemini-refresh-btn" title="Refresh" src="${Config.ASSETS.REFRESH_ICON}" />
  518. </div>
  519. <hr id="gemini-divider">
  520. <div id="gemini-content">${Localization.getMessage(Config.MESSAGE_KEYS.LOADING)}</div>
  521. `;
  522. box.querySelector('#gemini-refresh-btn').onclick = () => GeminiAPI.fetch(query, box.querySelector('#gemini-content'), apiKey, true);
  523. return box;
  524. },
  525. createGeminiUI(query, apiKey) {
  526. const wrapper = document.createElement('div');
  527. wrapper.id = 'gemini-wrapper';
  528. wrapper.appendChild(this.createGoogleButton(query));
  529. wrapper.appendChild(this.createGeminiBox(query, apiKey));
  530. return wrapper;
  531. }
  532. };
  533.  
  534. // Gemini API 모듈
  535. const GeminiAPI = {
  536. fetch(query, container, apiKey, force = false) {
  537. VersionChecker.checkMarkedJsVersion();
  538.  
  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: [{
  558. parts: [{ text: Localization.getMessage(Config.MESSAGE_KEYS.PROMPT, { query }) }]
  559. }]
  560. }),
  561. onload({ responseText }) {
  562. try {
  563. const text = JSON.parse(responseText)?.candidates?.[0]?.content?.parts?.[0]?.text;
  564. if (text) {
  565. sessionStorage.setItem(cacheKey, text);
  566. if (container) {
  567. container.innerHTML = marked.parse(text);
  568. }
  569. } else {
  570. if (container) {
  571. container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.GEMINI_EMPTY);
  572. }
  573. }
  574. } catch (e) {
  575. if (container) {
  576. container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.PARSE_ERROR)} ${e.message}`;
  577. }
  578. }
  579. },
  580. onerror: err => {
  581. if (container) {
  582. container.textContent = `${Localization.getMessage(Config.MESSAGE_KEYS.NETWORK_ERROR)} ${err.finalUrl}`;
  583. }
  584. },
  585. ontimeout: () => {
  586. if (container) {
  587. container.textContent = Localization.getMessage(Config.MESSAGE_KEYS.TIMEOUT);
  588. }
  589. }
  590. });
  591. }
  592. };
  593.  
  594. // 링크 정리 모듈
  595. const LinkCleaner = {
  596. decodeRealUrl(url, key) {
  597. const param = new URL(url).searchParams.get(key)?.replace(/^a1/, '');
  598. if (!param) return null;
  599. try {
  600. const decoded = decodeURIComponent(atob(param.replace(/_/g, '/').replace(/-/g, '+')));
  601. return decoded.startsWith('/') ? location.origin + decoded : decoded;
  602. } catch {
  603. return null;
  604. }
  605. },
  606. resolveRealUrl(url) {
  607. const rules = [
  608. { pattern: /bing\.com\/(ck\/a|aclick)/, key: 'u' },
  609. { pattern: /so\.com\/search\/eclk/, key: 'aurl' }
  610. ];
  611. for (const { pattern, key } of rules) {
  612. if (pattern.test(url)) {
  613. const real = this.decodeRealUrl(url, key);
  614. if (real && real !== url) return real;
  615. }
  616. }
  617. return url;
  618. },
  619. convertLinksToReal(root) {
  620. root.querySelectorAll('a[href]').forEach(a => {
  621. const realUrl = this.resolveRealUrl(a.href);
  622. if (realUrl && realUrl !== a.href) a.href = realUrl;
  623. });
  624. }
  625. };
  626.  
  627. // 버전 확인 모듈
  628. const VersionChecker = {
  629. compareVersions(current, latest) {
  630. const currentParts = current.split('.').map(Number);
  631. const latestParts = latest.split('.').map(Number);
  632. for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
  633. const c = currentParts[i] || 0;
  634. const l = latestParts[i] || 0;
  635. if (c < l) return -1;
  636. if (c > l) return 1;
  637. }
  638. return 0;
  639. },
  640. checkMarkedJsVersion() {
  641. localStorage.setItem(Config.STORAGE_KEYS.CURRENT_VERSION, Config.VERSIONS.MARKED_VERSION);
  642.  
  643. GM_xmlhttpRequest({
  644. method: 'GET',
  645. url: Config.API.MARKED_CDN_URL,
  646. onload({ responseText }) {
  647. try {
  648. const latest = JSON.parse(responseText).version;
  649. localStorage.setItem(Config.STORAGE_KEYS.LATEST_VERSION, latest);
  650.  
  651. const lastNotified = localStorage.getItem(Config.STORAGE_KEYS.LAST_NOTIFIED);
  652. if (this.compareVersions(Config.VERSIONS.MARKED_VERSION, latest) < 0 &&
  653. (!lastNotified || this.compareVersions(lastNotified, latest) < 0)) {
  654. const existingPopup = document.getElementById('marked-update-popup');
  655. if (existingPopup) existingPopup.remove();
  656.  
  657. const popup = document.createElement('div');
  658. popup.id = 'marked-update-popup';
  659. popup.innerHTML = `
  660. <p><b>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_TITLE)}</b></p>
  661. <p>Current: ${Config.VERSIONS.MARKED_VERSION}<br>Latest: ${latest}</p>
  662. <button>${Localization.getMessage(Config.MESSAGE_KEYS.UPDATE_NOW)}</button>
  663. `;
  664. popup.querySelector('button').onclick = () => {
  665. localStorage.setItem(Config.STORAGE_KEYS.LAST_NOTIFIED, latest);
  666. popup.remove();
  667. };
  668. document.body.appendChild(popup);
  669. }
  670. } catch (e) {
  671. console.warn('marked.min.js version check error:', e.message);
  672. }
  673. },
  674. onerror: () => console.warn('marked.min.js version check request failed')
  675. });
  676. }
  677. };
  678.  
  679. // 메인 모듈
  680. const Main = {
  681. isRendering: false, // 렌더링 중복 방지 플래그
  682. renderGemini() {
  683. if (this.isRendering) return; // 이미 렌더링 중이면 중단
  684. this.isRendering = true;
  685.  
  686. const query = Utils.getQuery();
  687. if (!query) {
  688. this.isRendering = false;
  689. return;
  690. }
  691.  
  692. // 기존 요소 제거
  693. const existingWrapper = document.getElementById('gemini-wrapper');
  694. if (existingWrapper) {
  695. existingWrapper.remove();
  696. }
  697. const existingGoogleBtn = document.getElementById('google-search-btn');
  698. if (existingGoogleBtn) {
  699. existingGoogleBtn.remove();
  700. }
  701.  
  702. if (Utils.isDesktop()) {
  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. // Google 버튼 먼저 렌더링
  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. const contentTarget = document.getElementById('b_content');
  741. if (!contentTarget) {
  742. this.isRendering = false;
  743. return;
  744. }
  745.  
  746. requestAnimationFrame(() => {
  747. const googleBtn = UI.createGoogleButton(query);
  748. contentTarget.parentNode.insertBefore(googleBtn, contentTarget);
  749. this.isRendering = false;
  750. });
  751. }
  752. },
  753. observeUrlChange() {
  754. let lastUrl = location.href;
  755.  
  756. const checkUrlChange = () => {
  757. if (location.href !== lastUrl) {
  758. lastUrl = location.href;
  759. this.renderGemini();
  760. LinkCleaner.convertLinksToReal(document);
  761. }
  762. };
  763.  
  764. // History API 이벤트 감지
  765. const originalPushState = history.pushState;
  766. history.pushState = function (...args) {
  767. originalPushState.apply(this, args);
  768. checkUrlChange();
  769. };
  770.  
  771. const originalReplaceState = history.replaceState;
  772. history.replaceState = function (...args) {
  773. originalReplaceState.apply(this, args);
  774. checkUrlChange();
  775. };
  776.  
  777. window.addEventListener('popstate', () => {
  778. checkUrlChange();
  779. });
  780.  
  781. // Fallback: DOM 변경 감지
  782. const observer = new MutationObserver(() => {
  783. checkUrlChange();
  784. });
  785. const targetNode = document.querySelector('head > title') || document.body;
  786. observer.observe(targetNode, { childList: true, subtree: true });
  787. },
  788. observeThemeChange() {
  789. const themeObserver = new MutationObserver(() => {
  790. Styles.applyTheme();
  791. });
  792. themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
  793.  
  794. window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
  795. Styles.applyTheme();
  796. });
  797.  
  798. const contextObserver = new MutationObserver(() => {
  799. Styles.applyTheme();
  800. });
  801. const targetElement = document.querySelector('#b_context') || document.querySelector('.b_right');
  802. if (targetElement) {
  803. contextObserver.observe(targetElement, { attributes: true, attributeFilter: ['style', 'class'] });
  804. }
  805. },
  806. init() {
  807. const initialize = () => {
  808. Styles.applyTheme();
  809. LinkCleaner.convertLinksToReal(document);
  810. this.renderGemini();
  811. this.observeUrlChange();
  812. this.observeThemeChange();
  813. };
  814.  
  815. if (document.readyState === 'complete' || document.readyState === 'interactive') {
  816. setTimeout(initialize, 1);
  817. } else {
  818. document.addEventListener('DOMContentLoaded', initialize);
  819. }
  820. }
  821. };
  822.  
  823. Main.init();
  824. })();