ChatGPT Deep Research Markdown Exporter

Export ChatGPT conversations and deep research content as markdown with configurable citation styles

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ChatGPT Deep Research Markdown Exporter
// @namespace    https://github.com/ckep1/chatgpt-research-export
// @version      2.1.1
// @description  Export ChatGPT conversations and deep research content as markdown with configurable citation styles
// @author       Chris Kephart
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @match        https://*.web-sandbox.oaiusercontent.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // ============================================================================
  // CONTEXT DETECTION
  // ============================================================================

  const IS_IFRAME_CONTEXT = window.location.hostname.includes("web-sandbox.oaiusercontent.com");

  // ============================================================================
  // CONFIGURATION & CONSTANTS
  // ============================================================================

  const CITATION_STYLES = {
    ENDNOTES: "endnotes",
    FOOTNOTES: "footnotes",
    INLINE: "inline",
    PARENTHESIZED: "parenthesized",
    NAMED: "named",
    NONE: "none",
  };

  const CITATION_STYLE_DESCRIPTIONS = {
    [CITATION_STYLES.ENDNOTES]: "[1] in text with sources listed at the end",
    [CITATION_STYLES.FOOTNOTES]: "[^1] in text with footnote definitions at the end",
    [CITATION_STYLES.INLINE]: "[1](url) - Clean inline citations",
    [CITATION_STYLES.PARENTHESIZED]: "([1](url)) - Inline citations in parentheses",
    [CITATION_STYLES.NAMED]: "([wikipedia](url)) - Parenthesized domain names",
    [CITATION_STYLES.NONE]: "Remove all citations from the text",
  };

  const EXPORT_METHODS = {
    DOWNLOAD: "download",
    CLIPBOARD: "clipboard",
  };

  const ACCENT_COLOR = "#78c6f0";

  const globalCitations = {
    urlToNumber: new Map(),
    citationRefs: new Map(),
    nextCitationNumber: 1,

    reset() {
      this.urlToNumber.clear();
      this.citationRefs.clear();
      this.nextCitationNumber = 1;
    },

    addCitation(url, sourceName = null) {
      const normalizedUrl = normalizeUrl(url);
      if (!this.urlToNumber.has(normalizedUrl)) {
        this.urlToNumber.set(normalizedUrl, this.nextCitationNumber);
        this.citationRefs.set(this.nextCitationNumber, {
          href: url,
          sourceName,
          normalizedUrl,
        });
        this.nextCitationNumber++;
      }
      return this.urlToNumber.get(normalizedUrl);
    },
  };

  // ============================================================================
  // UTILITY FUNCTIONS
  // ============================================================================

  function getPreferences() {
    // GM_getValue may not be available in cross-origin iframe contexts
    const getter = typeof GM_getValue === "function" ? GM_getValue : (_, def) => def;
    return {
      citationStyle: getter("citationStyle", CITATION_STYLES.PARENTHESIZED),
      addExtraNewlines: getter("addExtraNewlines", false),
      exportMethod: getter("exportMethod", EXPORT_METHODS.DOWNLOAD),
      includeFrontmatter: getter("includeFrontmatter", true),
      titleAsH1: getter("titleAsH1", false),
    };
  }

  function normalizeUrl(url) {
    if (!url) return null;
    try {
      const urlObj = new URL(url);
      urlObj.hash = "";
      return urlObj.toString();
    } catch (e) {
      return url.split("#")[0];
    }
  }

  function extractDomainName(url) {
    if (!url) return null;
    try {
      const urlObj = new URL(url);
      let domain = urlObj.hostname.toLowerCase().replace(/^www\./, "");
      const parts = domain.split(".");
      if (parts.length >= 2) {
        if (parts[parts.length - 2].length <= 3 && parts.length > 2) {
          return parts[parts.length - 3];
        }
        return parts[parts.length - 2];
      }
      return parts[0];
    } catch (e) {
      return null;
    }
  }

  function cleanTitle() {
    return document.title
      .replace(/ \| ChatGPT$/, "")
      .replace(/ - ChatGPT$/, "")
      .trim() || "ChatGPT";
  }

  function makeSafeFilename(title) {
    return title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, " ")
      .trim()
      .replace(/\s+/g, "-")
      .replace(/^-+|-+$/g, "");
  }

  // ============================================================================
  // TITLE & CONTENT EXTRACTION
  // ============================================================================

  function extractResearchTitle(overrideTitle) {
    if (overrideTitle) return overrideTitle;
    const container = document.querySelector(".deep-research-result");
    if (container) {
      const h1 = container.querySelector("h1");
      if (h1) return h1.textContent.trim();
    }
    return cleanTitle() || "ChatGPT Research";
  }

  function hasDeepResearch() {
    return !!document.querySelector(".deep-research-result") ||
      !!document.querySelector('iframe[title="internal://deep-research"]');
  }

  function extractResearchContent() {
    // Try legacy .deep-research-result first
    const container = document.querySelector(".deep-research-result");
    if (container) {
      const prefs = getPreferences();
      globalCitations.reset();

      const markdown = htmlToMarkdown(container, prefs.citationStyle);
      if (!markdown || !markdown.trim()) return null;

      return formatResearchDocument(markdown.trim());
    }

    // If no legacy container, check for cross-origin iframe
    const iframe = document.querySelector('iframe[title="internal://deep-research"]');
    if (iframe) {
      // This path is async - handled by extractResearchFromIframe()
      return null;
    }

    return null;
  }

  function extractResearchFromIframe() {
    return new Promise((resolve) => {
      const iframe = document.querySelector('iframe[title="internal://deep-research"]');
      if (!iframe || !iframe.contentWindow) {
        resolve(null);
        return;
      }

      const prefs = getPreferences();
      let responded = false;

      function handleResponse(event) {
        if (responded) return;
        if (!event.data || event.data.type !== "chatgpt-export-response") return;
        responded = true;
        window.removeEventListener("message", handleResponse);

        const { markdown, title, citations } = event.data;

        if (!markdown || !markdown.trim()) {
          resolve(null);
          return;
        }

        // Restore citation state from iframe data
        globalCitations.reset();
        if (citations) {
          for (const [numStr, data] of Object.entries(citations)) {
            const num = parseInt(numStr, 10);
            globalCitations.urlToNumber.set(data.normalizedUrl, num);
            globalCitations.citationRefs.set(num, data);
            if (num >= globalCitations.nextCitationNumber) {
              globalCitations.nextCitationNumber = num + 1;
            }
          }
        }

        resolve(formatResearchDocument(markdown.trim(), title));
      }

      window.addEventListener("message", handleResponse);

      iframe.contentWindow.postMessage({
        type: "chatgpt-export-request",
        citationStyle: prefs.citationStyle,
      }, "*");

      // Timeout after 10 seconds
      setTimeout(() => {
        if (!responded) {
          responded = true;
          window.removeEventListener("message", handleResponse);
          resolve(null);
        }
      }, 10000);
    });
  }

  function extractConversationContent() {
    const turns = document.querySelectorAll('article[data-testid^="conversation-turn"]');
    if (!turns.length) return null;

    const prefs = getPreferences();
    globalCitations.reset();

    const parts = [];

    turns.forEach((article) => {
      const userEl = article.querySelector('[data-message-author-role="user"]');
      const assistantEl = article.querySelector('[data-message-author-role="assistant"]');

      if (userEl) {
        const textEl = userEl.querySelector("div.whitespace-pre-wrap");
        if (textEl) {
          const text = textEl.textContent.trim();
          if (text) {
            parts.push(`**User:** ${text}`);
          }
        }
      } else if (assistantEl) {
        const markdownEls = assistantEl.querySelectorAll("div.markdown");
        if (markdownEls.length > 0) {
          const sectionParts = [];
          markdownEls.forEach((el) => {
            const md = htmlToMarkdown(el, prefs.citationStyle);
            if (md && md.trim()) {
              sectionParts.push(md.trim());
            }
          });
          if (sectionParts.length > 0) {
            parts.push(`**Assistant:** ${sectionParts.join("\n\n")}`);
          }
        }
      }
    });

    if (!parts.length) return null;

    const content = parts.join("\n\n---\n\n");
    return formatConversationDocument(content);
  }

  // ============================================================================
  // HTML TO MARKDOWN CONVERSION
  // ============================================================================

  function htmlToMarkdown(rootElement, citationStyle, citationUrlMap) {
    const prefs = getPreferences();

    function processNode(node) {
      if (node.nodeType === Node.TEXT_NODE) {
        return node.textContent.replace(/\$/g, "\\$");
      }
      if (node.nodeType !== Node.ELEMENT_NODE) {
        return "";
      }

      const tag = node.tagName.toLowerCase();

      if (tag === "span" && node.getAttribute("data-state") === "closed") {
        return processCitationSpan(node, citationStyle);
      }

      if (tag === "a" && node.getAttribute("href")) {
        if (node.closest('span[data-state="closed"]')) {
          return "";
        }
        const href = node.getAttribute("href");
        const text = processChildren(node);

        // Detect citation links: numeric text inside <sup> (iframe deep research format)
        const inSup = node.parentElement && node.parentElement.tagName.toLowerCase() === "sup";
        if (inSup && /^\d+$/.test(text.trim())) {
          const num = globalCitations.addCitation(href);
          const domain = extractDomainName(href) || "source";
          if (citationStyle === CITATION_STYLES.NONE) return "";
          if (citationStyle === CITATION_STYLES.ENDNOTES) return `[${num}]`;
          if (citationStyle === CITATION_STYLES.FOOTNOTES) return `[^${num}]`;
          if (citationStyle === CITATION_STYLES.INLINE) return `[${num}](${href})`;
          if (citationStyle === CITATION_STYLES.PARENTHESIZED) return `([${num}](${href}))`;
          if (citationStyle === CITATION_STYLES.NAMED) return `([${domain}](${href}))`;
          return `[${num}]`;
        }

        if (citationStyle === CITATION_STYLES.NONE) {
          return text;
        }
        return `[${text}](${href})`;
      }

      const children = processChildren(node);

      switch (tag) {
        case "h1":
          return `# ${children.trim()}\n\n`;
        case "h2":
          return `## ${children.trim()}\n\n`;
        case "h3":
          return `### ${children.trim()}\n\n`;
        case "h4":
          return `#### ${children.trim()}\n\n`;
        case "h5":
          return `##### ${children.trim()}\n\n`;
        case "h6":
          return `###### ${children.trim()}\n\n`;
        case "p":
          return `${children.trim()}\n\n`;
        case "strong":
        case "b":
          return `**${children}**`;
        case "em":
        case "i":
          return `*${children}*`;
        case "ul":
          return `${children}\n`;
        case "ol":
          return processOrderedList(node);
        case "li":
          return processListItem(node);
        case "blockquote":
          return processBlockquote(children);
        case "code":
          if (node.parentElement && node.parentElement.tagName.toLowerCase() === "pre") {
            return children;
          }
          return `\`${children}\``;
        case "pre":
          return processPreBlock(node);
        case "br":
          return "\n";
        case "table":
          return processTable(node);
        case "thead":
        case "tbody":
        case "tr":
        case "th":
        case "td":
          return children;
        case "hr":
          return "\n---\n\n";
        case "svg":
        case "path":
          return "";
        case "sup": {
          const supText = children.trim();
          if (/^\d+$/.test(supText) && citationUrlMap) {
            const urls = citationUrlMap.get(node);
            if (urls && urls.length > 0) {
              if (citationStyle === CITATION_STYLES.NONE) return "";
              const parts = urls.map((url) => {
                const num = globalCitations.addCitation(url);
                const domain = extractDomainName(url) || "source";
                if (citationStyle === CITATION_STYLES.ENDNOTES) return `[${num}]`;
                if (citationStyle === CITATION_STYLES.FOOTNOTES) return `[^${num}]`;
                if (citationStyle === CITATION_STYLES.INLINE) return `[${num}](${url})`;
                if (citationStyle === CITATION_STYLES.PARENTHESIZED) return `([${num}](${url}))`;
                if (citationStyle === CITATION_STYLES.NAMED) return `([${domain}](${url}))`;
                return `[${num}]`;
              });
              return parts.join("");
            }
            return `[${supText}]`;
          }
          return children;
        }
        case "div":
        case "section":
        case "article":
        case "span":
          return children;
        default:
          return children;
      }
    }

    function processChildren(node) {
      let result = "";
      for (const child of node.childNodes) {
        result += processNode(child);
      }
      return result;
    }

    function processCitationSpan(span, style) {
      const link = span.querySelector("a[href]");
      if (!link) return "";

      const href = link.getAttribute("href");
      if (!href) return "";

      const num = globalCitations.addCitation(href);
      const domain = extractDomainName(href) || "source";

      if (style === CITATION_STYLES.NONE) return "";
      if (style === CITATION_STYLES.ENDNOTES) return `[${num}]`;
      if (style === CITATION_STYLES.FOOTNOTES) return `[^${num}]`;
      if (style === CITATION_STYLES.INLINE) return `[${num}](${href})`;
      if (style === CITATION_STYLES.PARENTHESIZED) return `([${num}](${href}))`;
      if (style === CITATION_STYLES.NAMED) return `([${domain}](${href}))`;
      return `[${num}]`;
    }

    function processListItem(node) {
      const parent = node.parentElement;
      if (parent && parent.tagName.toLowerCase() === "ol") {
        const items = Array.from(parent.children).filter((c) => c.tagName.toLowerCase() === "li");
        const index = items.indexOf(node) + 1;
        return `${index}. ${processChildren(node).trim()}\n`;
      }
      return `- ${processChildren(node).trim()}\n`;
    }

    function processOrderedList(node) {
      return processChildren(node) + "\n";
    }

    function processBlockquote(content) {
      const lines = content.trim().split("\n");
      return lines.map((line) => `> ${line}`).join("\n") + "\n\n";
    }

    function processPreBlock(node) {
      const codeEl = node.querySelector("code");
      let language = "";
      if (codeEl) {
        const classList = Array.from(codeEl.classList);
        const langClass = classList.find((c) => c.startsWith("language-") || c.startsWith("lang-"));
        if (langClass) {
          language = langClass.replace(/^(language-|lang-)/, "");
        }
      }
      const text = codeEl ? codeEl.textContent : node.textContent;
      return `\`\`\`${language}\n${text}\n\`\`\`\n\n`;
    }

    function processTable(tableNode) {
      const rows = [];
      const headerRows = tableNode.querySelectorAll("thead tr");
      if (headerRows.length > 0) {
        headerRows.forEach((row) => {
          const cells = Array.from(row.querySelectorAll("th, td")).map((cell) => processChildren(cell).replace(/\n/g, " ").trim() || " ");
          if (cells.length > 0) {
            rows.push(`| ${cells.join(" | ")} |`);
            rows.push(`| ${cells.map(() => "---").join(" | ")} |`);
          }
        });
      }
      const bodyRows = tableNode.querySelectorAll("tbody tr");
      bodyRows.forEach((row) => {
        const cells = Array.from(row.querySelectorAll("td")).map((cell) => processChildren(cell).replace(/\n/g, " ").trim() || " ");
        if (cells.length > 0) {
          rows.push(`| ${cells.join(" | ")} |`);
        }
      });
      return rows.length > 0 ? `\n${rows.join("\n")}\n\n` : "";
    }

    let text = processNode(rootElement);

    text = text.replace(/\n{3,}/g, "\n\n");
    if (!prefs.addExtraNewlines) {
      text = text.replace(/\n\n/g, "\n");
    }

    text = text.replace(/[ \t]+$/gm, "").trim();

    return text;
  }

  // ============================================================================
  // DOCUMENT FORMATTING
  // ============================================================================

  function buildFrontmatter(title) {
    const timestamp = new Date().toISOString().split("T")[0];
    let fm = "---\n";
    fm += `title: ${title}\n`;
    fm += `date: ${timestamp}\n`;
    fm += `source: ${window.location.href}\n`;
    fm += "---\n\n";
    return fm;
  }

  function appendCitationEndnotes(markdown) {
    const prefs = getPreferences();

    if (prefs.citationStyle === CITATION_STYLES.ENDNOTES && globalCitations.citationRefs.size > 0) {
      markdown += "\n\n### Sources\n";
      for (const [number, { href }] of globalCitations.citationRefs) {
        markdown += `\n[${number}] ${href}`;
      }
      markdown += "\n";
    }

    if (prefs.citationStyle === CITATION_STYLES.FOOTNOTES && globalCitations.citationRefs.size > 0) {
      markdown += "\n\n";
      for (const [number, { href }] of globalCitations.citationRefs) {
        markdown += `[^${number}]: ${href}\n`;
      }
    }

    return markdown;
  }

  function formatResearchDocument(content, overrideTitle) {
    const title = extractResearchTitle(overrideTitle);
    const prefs = getPreferences();

    let markdown = "";

    if (prefs.includeFrontmatter) {
      markdown += buildFrontmatter(title);
    }

    if (prefs.titleAsH1) {
      markdown += `# ${title}\n\n`;
    }

    markdown += content;
    markdown = appendCitationEndnotes(markdown);

    return markdown.trim();
  }

  function formatConversationDocument(content) {
    const title = cleanTitle();
    const prefs = getPreferences();

    let markdown = "";

    if (prefs.includeFrontmatter) {
      markdown += buildFrontmatter(title);
    }

    if (prefs.titleAsH1) {
      markdown += `# ${title}\n\n`;
    }

    markdown += content;
    markdown = appendCitationEndnotes(markdown);

    return markdown.trim();
  }

  // ============================================================================
  // EXPORT FUNCTIONS
  // ============================================================================

  function downloadMarkdown(content, filename) {
    const blob = new Blob([content], { type: "text/markdown" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  async function copyToClipboard(content) {
    try {
      await navigator.clipboard.writeText(content);
      return true;
    } catch (err) {
      return false;
    }
  }

  async function doExportAsync(extractFn, filenameSuffix, button) {
    const originalText = button.textContent;
    button.textContent = "Exporting...";
    button.disabled = true;

    try {
      const markdown = await extractFn();
      if (!markdown) {
        alert("No content found to export. The iframe may not have responded.");
        return;
      }

      const prefs = getPreferences();

      if (prefs.exportMethod === EXPORT_METHODS.CLIPBOARD) {
        const success = await copyToClipboard(markdown);
        if (success) {
          button.textContent = "Copied!";
          setTimeout(() => {
            button.textContent = originalText;
          }, 2000);
          return;
        } else {
          alert("Failed to copy to clipboard. Please try again.");
        }
      } else {
        const title = cleanTitle();
        const safeTitle = makeSafeFilename(title);
        const filename = `${safeTitle || "chatgpt"}-${filenameSuffix}.md`;
        downloadMarkdown(markdown, filename);
      }
    } catch (error) {
      alert("Export failed. Please try again.");
    } finally {
      if (button.textContent !== "Copied!") {
        button.textContent = originalText;
      }
      button.disabled = false;
    }
  }

  async function doExport(extractFn, filenameSuffix, button) {
    const originalText = button.textContent;
    button.textContent = "Exporting...";
    button.disabled = true;

    try {
      const markdown = extractFn();
      if (!markdown) {
        alert("No content found to export.");
        return;
      }

      const prefs = getPreferences();

      if (prefs.exportMethod === EXPORT_METHODS.CLIPBOARD) {
        const success = await copyToClipboard(markdown);
        if (success) {
          button.textContent = "Copied!";
          setTimeout(() => {
            button.textContent = originalText;
          }, 2000);
          return;
        } else {
          alert("Failed to copy to clipboard. Please try again.");
        }
      } else {
        const title = cleanTitle();
        const safeTitle = makeSafeFilename(title);
        const filename = `${safeTitle || "chatgpt"}-${filenameSuffix}.md`;
        downloadMarkdown(markdown, filename);
      }
    } catch (error) {
      alert("Export failed. Please try again.");
    } finally {
      if (button.textContent !== "Copied!") {
        button.textContent = originalText;
      }
      button.disabled = false;
    }
  }

  // ============================================================================
  // UI
  // ============================================================================

  let conversationBtn = null;
  let researchBtn = null;
  let optionsBtn = null;
  let optionsMenu = null;
  let controlsContainer = null;
  let closeMenuFn = null;

  function makeButton(text) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = text;
    btn.style.cssText = `
      padding: 4px 8px;
      background-color: ${ACCENT_COLOR};
      color: black;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-size: 12px;
      font-weight: 600;
      transition: background-color 0.2s;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      white-space: nowrap;
    `;
    return btn;
  }

  function buildUI() {
    const existing = document.getElementById("chatgpt-export-controls");
    if (existing) existing.remove();

    const container = document.createElement("div");
    container.id = "chatgpt-export-controls";
    container.style.cssText = `
      position: fixed;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      gap: 8px;
      align-items: stretch;
      z-index: 99999;
      font-family: inherit;
    `;
    controlsContainer = container;

    // Conversation button
    conversationBtn = makeButton("");
    conversationBtn.id = "chatgpt-export-conversation-btn";
    conversationBtn.style.display = "none";
    conversationBtn.addEventListener("click", () => {
      doExport(extractConversationContent, "conversation", conversationBtn);
      if (closeMenuFn) closeMenuFn();
    });

    // Research button
    researchBtn = makeButton("");
    researchBtn.id = "chatgpt-export-research-btn";
    researchBtn.style.display = "none";
    researchBtn.addEventListener("click", () => {
      // Check if research is in a cross-origin iframe
      const iframe = document.querySelector('iframe[title="internal://deep-research"]');
      if (iframe && !document.querySelector(".deep-research-result")) {
        doExportAsync(extractResearchFromIframe, "research", researchBtn);
      } else {
        doExport(extractResearchContent, "research", researchBtn);
      }
      if (closeMenuFn) closeMenuFn();
    });

    // Options button + menu wrapper
    const optionsWrapper = document.createElement("div");
    optionsWrapper.style.cssText = "position: relative; display: flex;";

    optionsBtn = makeButton("Options");
    optionsBtn.id = "chatgpt-export-options-btn";
    optionsBtn.setAttribute("aria-haspopup", "true");
    optionsBtn.setAttribute("aria-expanded", "false");
    optionsBtn.style.display = "none";

    const menu = document.createElement("div");
    menu.id = "chatgpt-export-options-menu";
    menu.style.cssText = `
      position: absolute;
      bottom: calc(100% + 8px);
      left: 50%;
      transform: translateX(-50%);
      display: none;
      flex-direction: column;
      gap: 10px;
      min-width: 280px;
      background: #1F2121;
      color: white;
      border-radius: 12px;
      padding: 12px;
      box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
    `;
    optionsMenu = menu;

    function openMenu() {
      renderOptionsMenu();
      menu.style.display = "flex";
      optionsBtn.setAttribute("aria-expanded", "true");
      document.addEventListener("mousedown", handleOutsideClick, true);
      document.addEventListener("keydown", handleEscapeKey, true);
    }

    function closeMenu() {
      menu.style.display = "none";
      optionsBtn.setAttribute("aria-expanded", "false");
      document.removeEventListener("mousedown", handleOutsideClick, true);
      document.removeEventListener("keydown", handleEscapeKey, true);
    }
    closeMenuFn = closeMenu;

    function toggleMenu() {
      if (menu.style.display === "none" || menu.style.display === "") {
        openMenu();
      } else {
        closeMenu();
      }
    }

    function handleOutsideClick(event) {
      if (!menu.contains(event.target) && !optionsBtn.contains(event.target)) {
        closeMenu();
      }
    }

    function handleEscapeKey(event) {
      if (event.key === "Escape") {
        closeMenu();
      }
    }

    optionsBtn.addEventListener("click", (event) => {
      event.stopPropagation();
      toggleMenu();
    });

    optionsWrapper.appendChild(optionsBtn);

    container.appendChild(conversationBtn);
    container.appendChild(researchBtn);
    container.appendChild(optionsWrapper);
    container.appendChild(menu);

    document.body.appendChild(container);

    positionAboveComposer();
    window.addEventListener("resize", positionAboveComposer);
    if (typeof ResizeObserver !== "undefined") {
      const form = document.querySelector('form[data-type="unified-composer"]');
      if (form) new ResizeObserver(positionAboveComposer).observe(form);
    }
    setInterval(positionAboveComposer, 2000);

    updateButtonLabels();
    refreshButtons();
  }

  function positionAboveComposer() {
    if (!controlsContainer) return;
    const form = document.querySelector('form[data-type="unified-composer"]');
    if (form) {
      const rect = form.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      controlsContainer.style.bottom = `${window.innerHeight - rect.top + 8}px`;
      controlsContainer.style.left = `${centerX}px`;
    } else {
      controlsContainer.style.bottom = "40px";
      controlsContainer.style.left = "50%";
    }
  }

  function updateButtonLabels() {
    const prefs = getPreferences();
    const isClipboard = prefs.exportMethod === EXPORT_METHODS.CLIPBOARD;
    if (conversationBtn) {
      conversationBtn.textContent = isClipboard ? "Copy Conversation as Markdown" : "Save Conversation as Markdown";
    }
    if (researchBtn) {
      researchBtn.textContent = isClipboard ? "Copy Research as Markdown" : "Save Research as Markdown";
    }
  }

  function refreshButtons() {
    if (!controlsContainer) return;

    const hasTurns = document.querySelectorAll('article[data-testid^="conversation-turn"]').length > 0;
    const hasResearch = hasDeepResearch();
    const showAny = hasTurns || hasResearch;

    conversationBtn.style.display = hasTurns ? "" : "none";
    researchBtn.style.display = hasResearch ? "" : "none";
    optionsBtn.style.display = showAny ? "" : "none";
    controlsContainer.style.display = showAny ? "flex" : "none";
  }

  function createOptionButton(label, value, currentValue, onSelect, tooltip) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = label;
    if (tooltip) btn.setAttribute("title", tooltip);
    const isActive = value === currentValue;
    btn.style.cssText = `
      padding: 6px 8px;
      border-radius: 6px;
      border: 1px solid ${isActive ? ACCENT_COLOR : "#4a5568"};
      background-color: ${isActive ? ACCENT_COLOR : "#2d3748"};
      color: ${isActive ? "#0a0e13" : "#f7fafc"};
      font-size: 11px;
      text-align: center;
      cursor: pointer;
      transition: background-color 0.2s, border-color 0.2s, color 0.2s;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    `;
    btn.addEventListener("mouseenter", () => {
      if (value !== currentValue) {
        btn.style.borderColor = ACCENT_COLOR;
        btn.style.backgroundColor = "#4a5568";
      }
    });
    btn.addEventListener("mouseleave", () => {
      if (value !== currentValue) {
        btn.style.borderColor = "#4a5568";
        btn.style.backgroundColor = "#2d3748";
      }
    });
    btn.addEventListener("click", () => {
      onSelect(value);
      renderOptionsMenu();
      updateButtonLabels();
    });
    return btn;
  }

  function appendOptionGroup(sectionEl, label, options, currentValue, onSelect, labelTooltip) {
    const group = document.createElement("div");
    group.style.cssText = "display: flex; flex-direction: column; gap: 6px;";

    if (label) {
      const groupLabel = document.createElement("div");
      groupLabel.textContent = label;
      groupLabel.style.cssText = "font-size: 12px; font-weight: 600; color: #d1d5db;";
      if (labelTooltip) {
        groupLabel.setAttribute("title", labelTooltip);
        groupLabel.style.cursor = "help";
      }
      group.appendChild(groupLabel);
    }

    const list = document.createElement("div");
    list.style.cssText = "display: grid; grid-template-columns: 1fr 1fr; gap: 4px;";

    options.forEach((opt) => {
      list.appendChild(createOptionButton(opt.label, opt.value, currentValue, onSelect, opt.tooltip));
    });

    group.appendChild(list);
    sectionEl.appendChild(group);
  }

  function renderOptionsMenu() {
    if (!optionsMenu) return;
    const prefs = getPreferences();
    optionsMenu.innerHTML = "";

    const citationSection = document.createElement("div");
    citationSection.style.cssText = "display: flex; flex-direction: column; gap: 6px;";
    const citationHeading = document.createElement("div");
    citationHeading.textContent = "Citation Style";
    citationHeading.style.cssText = "font-size: 13px; font-weight: 700; color: #f9fafb;";
    citationSection.appendChild(citationHeading);

    appendOptionGroup(
      citationSection,
      "Format",
      [
        { label: "Endnotes", value: CITATION_STYLES.ENDNOTES, tooltip: CITATION_STYLE_DESCRIPTIONS[CITATION_STYLES.ENDNOTES] },
        { label: "Footnotes", value: CITATION_STYLES.FOOTNOTES, tooltip: CITATION_STYLE_DESCRIPTIONS[CITATION_STYLES.FOOTNOTES] },
        { label: "Inline", value: CITATION_STYLES.INLINE, tooltip: CITATION_STYLE_DESCRIPTIONS[CITATION_STYLES.INLINE] },
        { label: "Parenthesized", value: CITATION_STYLES.PARENTHESIZED, tooltip: CITATION_STYLE_DESCRIPTIONS[CITATION_STYLES.PARENTHESIZED] },
        { label: "Named", value: CITATION_STYLES.NAMED, tooltip: CITATION_STYLE_DESCRIPTIONS[CITATION_STYLES.NAMED] },
        { label: "No Citations", value: CITATION_STYLES.NONE, tooltip: CITATION_STYLE_DESCRIPTIONS[CITATION_STYLES.NONE] },
      ],
      prefs.citationStyle,
      (next) => GM_setValue("citationStyle", next)
    );
    optionsMenu.appendChild(citationSection);

    const outputSection = document.createElement("div");
    outputSection.style.cssText = "display: flex; flex-direction: column; gap: 6px;";
    const outputHeading = document.createElement("div");
    outputHeading.textContent = "Output Style";
    outputHeading.style.cssText = "font-size: 13px; font-weight: 700; color: #f9fafb;";
    outputSection.appendChild(outputHeading);

    appendOptionGroup(
      outputSection,
      "Spacing",
      [
        { label: "Standard", value: false },
        { label: "Extra newlines", value: true },
      ],
      prefs.addExtraNewlines,
      (next) => GM_setValue("addExtraNewlines", next)
    );

    appendOptionGroup(
      outputSection,
      "Frontmatter",
      [
        { label: "Include", value: true, tooltip: "Include YAML metadata (title, date, source URL) at the top" },
        { label: "Exclude", value: false, tooltip: "Export just the content without metadata" },
      ],
      prefs.includeFrontmatter,
      (next) => GM_setValue("includeFrontmatter", next),
      "YAML metadata section at the top with title, date, and source URL"
    );

    appendOptionGroup(
      outputSection,
      "Title as H1",
      [
        { label: "Include", value: true, tooltip: "Add the research title as a level 1 heading" },
        { label: "Exclude", value: false, tooltip: "Don't add title as heading (use frontmatter only)" },
      ],
      prefs.titleAsH1,
      (next) => GM_setValue("titleAsH1", next),
      "Add the research title as a # heading at the top"
    );

    optionsMenu.appendChild(outputSection);

    const exportSection = document.createElement("div");
    exportSection.style.cssText = "display: flex; flex-direction: column; gap: 6px;";
    const exportHeading = document.createElement("div");
    exportHeading.textContent = "Export Options";
    exportHeading.style.cssText = "font-size: 13px; font-weight: 700; color: #f9fafb;";
    exportSection.appendChild(exportHeading);

    appendOptionGroup(
      exportSection,
      "Output Method",
      [
        { label: "Download File", value: EXPORT_METHODS.DOWNLOAD },
        { label: "Copy to Clipboard", value: EXPORT_METHODS.CLIPBOARD },
      ],
      prefs.exportMethod,
      (next) => GM_setValue("exportMethod", next)
    );

    optionsMenu.appendChild(exportSection);
  }

  // ============================================================================
  // CITATION URL EXTRACTION (React fiber traversal for iframe content)
  // ============================================================================

  function extractCitationUrls(doc) {
    const map = new Map();
    const sups = doc.querySelectorAll("sup");
    const citationSups = [];

    for (const sup of sups) {
      const text = sup.textContent.trim();
      if (/^\d+$/.test(text)) citationSups.push(sup);
    }

    if (citationSups.length === 0) return map;

    // For each citation sup, resolve its URL(s) from React fiber item prop
    // Citations can reference multiple sources (nested/grouped citations)
    for (const sup of citationSups) {
      // Try the item prop from the citation's own fiber (level 2, sXn component)
      const fk = Object.keys(sup).find((k) => k.startsWith("__reactFiber"));
      if (fk) {
        let node = sup[fk];
        for (let i = 0; i < 5 && node; i++) {
          const props = node.memoizedProps || node.pendingProps;
          if (props && props.item) {
            const item = props.item;
            const urls = [];

            // Primary: item.reference.safe_urls (array of grouped URLs)
            if (item.reference && Array.isArray(item.reference.safe_urls)) {
              for (const u of item.reference.safe_urls) {
                if (typeof u === "string" && /^https?:\/\//.test(u)) {
                  urls.push(u.replace(/[?&]utm_source=chatgpt\.com/, ""));
                }
              }
            }

            // Fallback: item.url (single URL)
            if (urls.length === 0 && item.url) {
              urls.push(item.url.replace(/[?&]utm_source=chatgpt\.com/, ""));
            }

            const unique = [...new Set(urls)];
            if (unique.length > 0) { map.set(sup, unique); break; }
          }
          node = node.return;
        }
      }
    }

    return map;
  }

  // ============================================================================
  // IFRAME BRIDGE (runs inside the deep research sandbox iframe)
  // ============================================================================

  function initIframeBridge() {
    // The sandbox iframe contains a nested iframe with the actual content.
    // This nested iframe is same-origin and directly accessible.
    function getContentDocument() {
      const nested = document.querySelector("iframe");
      if (nested) {
        try {
          const doc = nested.contentDocument;
          if (doc && doc.body && doc.body.textContent.trim().length > 100) return doc;
        } catch (e) { /* cross-origin, fall through */ }
      }
      return document;
    }

    function findReportContainer() {
      const doc = getContentDocument();
      const candidates = ["main", "article", ".report", ".content"];
      for (const sel of candidates) {
        const el = doc.querySelector(sel);
        if (el && el.textContent.trim().length > 200) return el;
      }
      // Fallback: find the container with the most text content that has an h1
      const divs = doc.querySelectorAll("div");
      let best = null;
      let bestLen = 0;
      for (const div of divs) {
        const h1 = div.querySelector("h1");
        if (h1 && div.textContent.trim().length > bestLen) {
          bestLen = div.textContent.trim().length;
          best = div;
        }
      }
      return best || doc.body;
    }

    function extractTitle() {
      const doc = getContentDocument();
      const h1 = doc.querySelector("h1");
      return h1 ? h1.textContent.trim() : "ChatGPT Research";
    }

    function waitForContent() {
      return new Promise((resolve) => {
        function hasContent() {
          const doc = getContentDocument();
          return doc.querySelector("h1") && doc.body.textContent.trim().length > 200;
        }
        if (hasContent()) { resolve(); return; }
        // Poll for content (MutationObserver can't watch across iframe boundaries)
        const interval = setInterval(() => {
          if (hasContent()) { clearInterval(interval); resolve(); }
        }, 500);
        setTimeout(() => { clearInterval(interval); resolve(); }, 15000);
      });
    }

    waitForContent().then(() => {
      // Clean up temp element if present
      const tempLink = document.getElementById("__iframe_link");
      if (tempLink) tempLink.remove();

      window.addEventListener("message", (event) => {
        if (!event.data || event.data.type !== "chatgpt-export-request") return;

        const citationStyle = event.data.citationStyle || CITATION_STYLES.PARENTHESIZED;
        globalCitations.reset();

        const container = findReportContainer();
        const doc = getContentDocument();

        // Extract citation URLs from React fiber before converting to markdown
        const citationUrlMap = extractCitationUrls(doc);

        let markdown = htmlToMarkdown(container, citationStyle, citationUrlMap);

        // Strip pre-header metadata (SVG counter text, "Research completed..." line)
        const headingMatch = markdown.match(/^(#{1,6}\s)/m);
        if (headingMatch) {
          markdown = markdown.substring(markdown.indexOf(headingMatch[0]));
        }

        const title = extractTitle();

        // Collect citation data to send back to parent
        const citations = {};
        for (const [number, data] of globalCitations.citationRefs) {
          citations[number] = data;
        }

        window.parent.postMessage({
          type: "chatgpt-export-response",
          markdown: markdown || "",
          title,
          citations,
        }, "*");
      });
    });
  }

  // ============================================================================
  // INITIALIZATION
  // ============================================================================

  function init() {
    // If running inside the deep research iframe, set up the bridge and exit
    if (IS_IFRAME_CONTEXT) {
      initIframeBridge();
      return;
    }

    let lastUrl = location.href;

    buildUI();

    // MutationObserver for content changes within the current page
    const observer = new MutationObserver(() => {
      refreshButtons();
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    // URL change detection: popstate for back/forward
    window.addEventListener("popstate", () => {
      handleNavigation();
    });

    // Periodic URL check for client-side navigation that doesn't fire popstate
    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        handleNavigation();
      }
    }, 500);

    function handleNavigation() {
      lastUrl = location.href;
      // Remove existing UI and rebuild after content loads
      const existing = document.getElementById("chatgpt-export-controls");
      if (existing) existing.remove();
      controlsContainer = null;

      setTimeout(() => {
        buildUI();
      }, 800);
    }
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();