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.

// ==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);
  }
})();