Google Scholar: One-Click Copy BibTeX (GM_xmlhttpRequest fix)

Add a "Copy BibTeX" button to each Google Scholar result and copy BibTeX in one click. Uses GM_xmlhttpRequest to bypass CORS issues.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Google Scholar: One-Click Copy BibTeX (GM_xmlhttpRequest fix)
// @namespace    franz.tools.scholar.copybib
// @version      0.5.0
// @description  Add a "Copy BibTeX" button to each Google Scholar result and copy BibTeX in one click. Uses GM_xmlhttpRequest to bypass CORS issues.
// @author       Franz
// @match        *://scholar.google.com/scholar*
// @match        *://scholar.google.com.hk/scholar*
// @include      /^https?:\/\/scholar\.google\.[^\/]+\/scholar.*/
// @run-at       document-end
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @connect      scholar.googleusercontent.com
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const RESULT_CONTAINER_SELECTOR = '.gs_r.gs_or.gs_scl';
  const RESULT_INNER_SELECTOR     = '.gs_ri';
  const ACTION_BAR_SELECTOR       = '.gs_fl';
  const CITE_LINK_SELECTOR        = 'a.gs_or_cit, a[aria-controls="gs_cit"]';
  const BTN_CLASS                 = 'copy-bibtex-btn';

  injectStyle(`
    .${BTN_CLASS} {
      font: 13px/1.4 Arial, sans-serif;
      padding: 0 8px;
      margin-left: 8px;
      cursor: pointer;
      border: 1px solid rgba(0,0,0,0.25);
      background: #f8f9fa;
      border-radius: 3px;
      color: #202124;
      height: 24px;
    }
    .${BTN_CLASS}:disabled { opacity: 0.6; cursor: default; }
    .${BTN_CLASS}.ok { background: #e6f4ea; border-color: #34a853; }
    .${BTN_CLASS}.fail { background: #fce8e6; border-color: #d93025; }
    .copy-bibtex-toast {
      position: fixed; right: 16px; bottom: 16px;
      background: rgba(32,33,36,.95); color: #fff;
      padding: 8px 12px; border-radius: 6px;
      z-index: 999999; font: 13px/1.3 Arial, sans-serif;
      box-shadow: 0 4px 18px rgba(0,0,0,.25);
    }
  `);

  addButtonsToAll();
  observeForNewResults(() => addButtonsToAll());

  function addButtonsToAll() {
    const results = document.querySelectorAll(RESULT_CONTAINER_SELECTOR);
    for (const res of results) {
      if (res.dataset.copyBibInjected === '1') continue;
      const inner = res.querySelector(RESULT_INNER_SELECTOR);
      if (!inner) continue;

      const actionBar = inner.querySelector(ACTION_BAR_SELECTOR) || inner;
      const citeAnchor = inner.querySelector(CITE_LINK_SELECTOR);
      const cid = extractCitationId(res, citeAnchor);
      if (!cid) continue;

      const lang = document.documentElement.getAttribute('lang') || 'en';
      const citeUrl = new URL('/scholar', location.origin);
      citeUrl.searchParams.set('hl', lang);
      citeUrl.searchParams.set('q', `info:${cid}:scholar.google.com`);
      citeUrl.searchParams.set('output', 'cite');

      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = BTN_CLASS;
      btn.textContent = 'Copy BibTeX';
      btn.addEventListener('click', async (e) => {
        e.preventDefault();
        e.stopPropagation();
        await handleCopy(btn, citeUrl.toString());
      });

      actionBar.appendChild(btn);
      res.dataset.copyBibInjected = '1';
    }
  }

  function extractCitationId(resultEl, citeAnchor) {
    let cid = citeAnchor?.dataset?.cid || citeAnchor?.getAttribute?.('data-cid')
           || citeAnchor?.dataset?.id  || citeAnchor?.getAttribute?.('data-id')
           || resultEl.getAttribute('data-cid');
    const href = citeAnchor?.getAttribute?.('href') || '';
    if (!cid && href) {
      const m = href.match(/info:([^:]+):scholar\.google\.com/);
      if (m) cid = m[1];
    }
    return cid || null;
  }

  async function fetchBibTeX(citePageUrl) {
    // Step 1: fetch cite page normally
    const resp = await fetch(citePageUrl, { credentials: 'include' });
    if (!resp.ok) throw new Error(`Cite page HTTP ${resp.status}`);
    const html = await resp.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');

    // Step 2: find BibTeX link
    const bibLink = Array.from(doc.querySelectorAll('a')).find(a => /BibTeX/i.test(a.textContent || ''));
    if (!bibLink) throw new Error('BibTeX link not found');
    const bibUrl = new URL(bibLink.getAttribute('href'), location.origin).toString();

    // Step 3: fetch BibTeX with GM_xmlhttpRequest (bypass CORS)
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: bibUrl,
        onload: function (r) {
          if (r.status === 200) resolve(r.responseText);
          else reject(new Error(`BibTeX HTTP ${r.status}`));
        },
        onerror: function (err) {
          reject(new Error(`BibTeX request failed: ${err.error}`));
        }
      });
    });
  }

  async function writeClipboardRobust(text) {
    try {
      GM_setClipboard(text, { type: 'text', mimetype: 'text/plain' });
      return true;
    } catch (_) {}
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
    showManualCopy(text);
    return false;
  }

  async function handleCopy(btn, citeUrl) {
    const origText = btn.textContent;
    try {
      btn.disabled = true;
      btn.textContent = 'Fetching…';
      const bib = await fetchBibTeX(citeUrl);
      btn.textContent = 'Copying…';
      const ok = await writeClipboardRobust(bib);
      if (ok) {
        btn.textContent = 'Copied ✓';
        btn.classList.add('ok');
        showToast('BibTeX copied to clipboard.');
      } else {
        btn.textContent = 'Copied (manual)';
        btn.classList.add('ok');
        showToast('Clipboard blocked. Manual copy dialog opened.');
      }
    } catch (err) {
      console.error('[Copy BibTeX] Error:', err);
      btn.textContent = 'Failed';
      btn.classList.add('fail');
      showToast('Failed to copy BibTeX. See console for details.');
    } finally {
      setTimeout(() => {
        btn.disabled = false;
        btn.textContent = origText;
        btn.classList.remove('ok', 'fail');
      }, 1400);
    }
  }

  function observeForNewResults(onChange) {
    const mo = new MutationObserver((mutations) => {
      for (const m of mutations) {
        if (m.addedNodes && m.addedNodes.length) { onChange(); break; }
      }
    });
    mo.observe(document.body, { childList: true, subtree: true });
  }

  function showToast(msg) {
    const el = document.createElement('div');
    el.className = 'copy-bibtex-toast';
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 1600);
  }

  function showManualCopy(text) {
    const overlay = document.createElement('div');
    overlay.style.cssText = `
      position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 999999;
      display: flex; align-items: center; justify-content: center; padding: 24px;
    `;
    const panel = document.createElement('div');
    panel.style.cssText = `
      background: #fff; padding: 16px; border-radius: 8px; max-width: 800px; width: 90%;
      box-shadow: 0 8px 30px rgba(0,0,0,.25); font: 13px Arial, sans-serif;
    `;
    const info = document.createElement('div');
    info.textContent = 'Clipboard blocked. Press ⌘/Ctrl+A then ⌘/Ctrl+C to copy:';
    info.style.marginBottom = '8px';
    const ta = document.createElement('textarea');
    ta.value = text; ta.rows = 16; ta.style.width = '100%';
    const closeBtn = document.createElement('button');
    closeBtn.textContent = 'Close';
    closeBtn.style.cssText = 'margin-top: 8px; padding: 4px 10px;';
    closeBtn.onclick = () => overlay.remove();
    panel.append(info, ta, closeBtn);
    overlay.append(panel);
    document.body.appendChild(overlay);
    ta.focus(); ta.select();
  }

  function injectStyle(css) {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }
})();