Perplexity.ai Chat Exporter

Export Perplexity.ai conversations as markdown with configurable citation styles

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Perplexity.ai Chat Exporter
// @namespace    https://github.com/ckep1/pplxport
// @version      2.3.0
// @description  Export Perplexity.ai conversations as markdown with configurable citation styles
// @author       Chris Kephart
// @match        https://www.perplexity.ai/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

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

  const DEBUG = false;
  const console = DEBUG ? window.console : { log() {}, warn() {}, error() {} };

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

  const CITATION_STYLE_LABELS = {
    [CITATION_STYLES.ENDNOTES]: "Endnotes",
    [CITATION_STYLES.FOOTNOTES]: "Footnotes",
    [CITATION_STYLES.INLINE]: "Inline",
    [CITATION_STYLES.PARENTHESIZED]: "Parenthesized",
    [CITATION_STYLES.NAMED]: "Named",
    [CITATION_STYLES.NONE]: "No Citations",
  };

  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) - Uses domain names",
    [CITATION_STYLES.NONE]: "Remove all citations from the text",
  };

  const FORMAT_STYLES = {
    FULL: "full", // Include User/Assistant tags and all dividers
    CONCISE: "concise", // Just content, minimal dividers
  };

  const FORMAT_STYLE_LABELS = {
    [FORMAT_STYLES.FULL]: "Full",
    [FORMAT_STYLES.CONCISE]: "Concise",
  };

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

  const EXPORT_METHOD_LABELS = {
    [EXPORT_METHODS.DOWNLOAD]: "Download File",
    [EXPORT_METHODS.CLIPBOARD]: "Copy to Clipboard",
  };


  // Global citation tracking for consistent numbering across all responses
  const globalCitations = {
    urlToNumber: new Map(), // normalized URL -> citation number
    citationRefs: new Map(), // citation number -> {href, sourceName, normalizedUrl}
    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);
    },

    getCitationNumber(url) {
      const normalizedUrl = normalizeUrl(url);
      return this.urlToNumber.get(normalizedUrl);
    },
  };

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

  // Get user preferences
  function getPreferences() {
    return {
      citationStyle: GM_getValue("citationStyle", CITATION_STYLES.PARENTHESIZED),
      formatStyle: GM_getValue("formatStyle", FORMAT_STYLES.FULL),
      addExtraNewlines: GM_getValue("addExtraNewlines", false),
      exportMethod: GM_getValue("exportMethod", EXPORT_METHODS.DOWNLOAD),
      includeFrontmatter: GM_getValue("includeFrontmatter", true),
      titleAsH1: GM_getValue("titleAsH1", false),
    };
  }

  // Extract source name from text, handling various formats
  function extractSourceName(text) {
    if (!text) return null;

    // Clean the text
    text = text.trim();

    // If it's a pattern like "rabbit+2", "reddit+1", extract the source name
    const plusMatch = text.match(/^([a-zA-Z]+)\+\d+$/);
    if (plusMatch) {
      return plusMatch[1];
    }

    // If it's just text without numbers, use it as is (but clean it up)
    const cleanName = text.replace(/[^a-zA-Z0-9-_]/g, "").toLowerCase();
    if (cleanName && cleanName.length > 0) {
      return cleanName;
    }

    return null;
  }

  // Normalize URL by removing fragments (#) to group same page citations
  function normalizeUrl(url) {
    if (!url) return null;

    try {
      const urlObj = new URL(url);
      // Remove the fragment (hash) portion
      urlObj.hash = "";
      return urlObj.toString();
    } catch (e) {
      // If URL parsing fails, just remove # manually
      return url.split("#")[0];
    }
  }

  // Extract domain name from URL for named citations
  function extractDomainName(url) {
    if (!url) return null;

    try {
      const urlObj = new URL(url);
      let domain = urlObj.hostname.toLowerCase();

      // Remove www. prefix
      domain = domain.replace(/^www\./, "");

      // Get the main domain part (before first dot for common cases)
      const parts = domain.split(".");
      if (parts.length >= 2) {
        // Handle special cases like co.uk, github.io, etc.
        if (parts[parts.length - 2].length <= 3 && parts.length > 2) {
          return parts[parts.length - 3];
        } else {
          return parts[parts.length - 2];
        }
      }

      return parts[0];
    } catch (e) {
      return null;
    }
  }

  // ============================================================================
  // DOM HELPER FUNCTIONS
  // ============================================================================

  function getThreadContainer() {
    return document.querySelector('.max-w-threadContentWidth, [class*="threadContentWidth"]') || document.querySelector("main") || document.body;
  }

  function getScrollRoot() {
    const thread = getThreadContainer();
    const candidates = [];
    let node = thread;
    while (node && node !== document.body) {
      candidates.push(node);
      node = node.parentElement;
    }
    const scrollingElement = document.scrollingElement || document.documentElement;
    candidates.push(scrollingElement);

    let best = null;
    for (const el of candidates) {
      try {
        const style = getComputedStyle(el);
        const overflowY = (style.overflowY || style.overflow || "").toLowerCase();
        const canScroll = el.scrollHeight - el.clientHeight > 50;
        const isScrollable = /auto|scroll|overlay/.test(overflowY) || el === scrollingElement;
        if (canScroll && isScrollable) {
          if (!best || el.scrollHeight > best.scrollHeight) {
            best = el;
          }
        }
      } catch (e) {
        // ignore
      }
    }
    return best || scrollingElement;
  }

  function isInViewport(el, margin = 8) {
    const rect = el.getBoundingClientRect();
    const vh = window.innerHeight || document.documentElement.clientHeight;
    const vw = window.innerWidth || document.documentElement.clientWidth;
    return rect.bottom > -margin && rect.top < vh + margin && rect.right > -margin && rect.left < vw + margin;
  }

  function isCodeCopyButton(btn) {
    const testId = btn.getAttribute("data-testid");
    const ariaLower = (btn.getAttribute("aria-label") || "").toLowerCase();
    if (testId === "copy-code-button" || testId === "copy-code" || (testId && testId.includes("copy-code"))) return true;
    if (ariaLower.includes("copy code")) return true;
    if (btn.closest("pre") || btn.closest("code")) return true;
    return false;
  }

  function findUserMessageRootFromElement(el) {
    let node = el;
    let depth = 0;
    while (node && node !== document.body && depth < 10) {
      if (node.querySelector && (node.querySelector("button[data-testid='copy-query-button']") || node.querySelector("button[aria-label='Copy Query']") || node.querySelector("span[data-lexical-text='true']"))) {
        return node;
      }
      node = node.parentElement;
      depth++;
    }
    return el.parentElement || el;
  }

  function findUserMessageRootFrom(button) {
    let node = button;
    let depth = 0;
    while (node && node !== document.body && depth < 10) {
      // A user message root should contain lexical text from the input/query
      if (node.querySelector && (node.querySelector(".whitespace-pre-line.text-pretty.break-words") || node.querySelector("span[data-lexical-text='true']"))) {
        return node;
      }
      node = node.parentElement;
      depth++;
    }
    return button.parentElement || button;
  }

  function findAssistantMessageRootFrom(button) {
    let node = button;
    let depth = 0;
    while (node && node !== document.body && depth < 10) {
      // An assistant message root should contain the prose answer block
      if (node.querySelector && node.querySelector(".prose.text-pretty.dark\\:prose-invert, [class*='prose'][class*='prose-invert'], [data-testid='answer'], [data-testid='assistant']")) {
        return node;
      }
      node = node.parentElement;
      depth++;
    }
    return button.parentElement || button;
  }

  // ============================================================================
  // SCROLL & NAVIGATION HELPERS
  // ============================================================================

  async function pageDownOnce(scroller, delayMs = 90, factor = 0.9) {
    if (!scroller) scroller = getScrollRoot();
    const delta = Math.max(200, Math.floor(scroller.clientHeight * factor));
    scroller.scrollTop = Math.min(scroller.scrollTop + delta, scroller.scrollHeight);
    await new Promise((r) => setTimeout(r, delayMs));
  }

  async function preloadPageFully() {
    try {
      const scroller = getScrollRoot();
      window.focus();
      scroller.scrollTop = 0;
      await new Promise((resolve) => setTimeout(resolve, 80));

      let lastHeight = scroller.scrollHeight;
      let stableCount = 0;
      const maxTries = 25; // shorter preload with faster intervals

      for (let i = 0; i < maxTries && stableCount < 2; i++) {
        scroller.scrollTop = scroller.scrollHeight;
        await new Promise((resolve) => setTimeout(resolve, 120));
        const newHeight = scroller.scrollHeight;
        if (newHeight > lastHeight + 10) {
          lastHeight = newHeight;
          stableCount = 0;
        } else {
          stableCount++;
        }
      }
      // Return to top so processing starts from the beginning
      scroller.scrollTop = 0;
      await new Promise((resolve) => setTimeout(resolve, 120));
    } catch (e) {
      // Non-fatal; we'll just proceed
      console.warn("Preload scroll encountered an issue:", e);
    }
  }

  function simulateHover(element) {
    try {
      const rect = element.getBoundingClientRect();
      const x = rect.left + Math.min(20, Math.max(2, rect.width / 3));
      const y = rect.top + Math.min(20, Math.max(2, rect.height / 3));
      const opts = { bubbles: true, clientX: x, clientY: y };
      element.dispatchEvent(new MouseEvent("mouseenter", opts));
      element.dispatchEvent(new MouseEvent("mouseover", opts));
      element.dispatchEvent(new MouseEvent("mousemove", opts));
    } catch (e) {
      // best effort
    }
  }

  async function readClipboardWithRetries(maxRetries = 3, delayMs = 60) {
    let last = "";
    for (let i = 0; i < maxRetries; i++) {
      try {
        const text = await navigator.clipboard.readText();
        if (text && text.trim() && text !== last) {
          return text;
        }
        last = text;
      } catch (e) {
        // keep retrying
      }
      await new Promise((r) => setTimeout(r, delayMs));
    }
    try {
      return await navigator.clipboard.readText();
    } catch {
      return "";
    }
  }

  // Click expanders like "Show more", "Read more", etc. Best-effort
  const clickedExpanders = new WeakSet();

  function findExpanders(limit = 8) {
    const candidates = [];
    const patterns = /(show more|read more|view more|see more|expand|load more|view full|show all|continue reading)/i;
    const els = document.querySelectorAll('button, a, [role="button"]');
    for (const el of els) {
      if (candidates.length >= limit) break;
      if (clickedExpanders.has(el)) continue;
      const label = (el.getAttribute("aria-label") || "").trim();
      const text = (el.textContent || "").trim();
      if (patterns.test(label) || patterns.test(text)) {
        // avoid code-block related buttons
        if (el.closest("pre, code")) continue;
        // avoid external anchors that might navigate
        if (el.tagName && el.tagName.toLowerCase() === "a") {
          const href = (el.getAttribute("href") || "").trim();
          const target = (el.getAttribute("target") || "").trim().toLowerCase();
          const isExternal = /^https?:\/\//i.test(href);
          if (isExternal || target === "_blank") continue;
        }
        candidates.push(el);
      }
    }
    return candidates;
  }

  async function clickExpandersOnce(limit = 6) {
    const expanders = findExpanders(limit);
    if (expanders.length === 0) return false;
    for (const el of expanders) {
      try {
        clickedExpanders.add(el);
        el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
        await new Promise((r) => setTimeout(r, 50));
        el.click();
        await new Promise((r) => setTimeout(r, 150));
      } catch {}
    }
    // allow expanded content to render
    await new Promise((r) => setTimeout(r, 250));
    return true;
  }

  // ============================================================================
  // BUTTON HELPER FUNCTIONS
  // ============================================================================

  function getViewportQueryButtons() {
    const buttons = Array.from(document.querySelectorAll('button[data-testid="copy-query-button"], button[aria-label="Copy Query"]'));
    return buttons.filter((btn) => isInViewport(btn) && !btn.closest("pre,code"));
  }

  function getViewportResponseButtons() {
    // Just check for any SVG element, or multiple possible SVG classes
    const buttons = Array.from(document.querySelectorAll('button[aria-label="Copy"]')).filter((btn) => {
      return btn.querySelector("svg.tabler-icon") || btn.querySelector("svg.tabler-icon-copy") || btn.querySelector("svg");
    });
    return buttons.filter((btn) => isInViewport(btn) && !btn.closest("pre,code"));
  }

  async function clickVisibleButtonAndGetClipboard(button) {
    try {
      window.focus();
      simulateHover(button);
      await new Promise((r) => setTimeout(r, 40));
      button.focus();
      button.click();
      await new Promise((r) => setTimeout(r, 60));
      return await readClipboardWithRetries(3, 60);
    } catch (e) {
      return "";
    }
  }

  async function clickButtonAndGetClipboard(button) {
    window.focus();
    button.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
    await new Promise((r) => setTimeout(r, 60));
    simulateHover(button);
    await new Promise((r) => setTimeout(r, 40));
    button.focus();
    button.click();
    await new Promise((r) => setTimeout(r, 120));
    window.focus();
    return await readClipboardWithRetries(3, 60);
  }

  function collectAnchoredMessageRootsOnce() {
    const roots = new Map(); // rootEl -> { rootEl, top, queryButton, responseButton }

    const queryButtons = Array.from(document.querySelectorAll('button[data-testid="copy-query-button"], button[aria-label="Copy Query"]'));
    for (const btn of queryButtons) {
      if (isCodeCopyButton(btn)) continue;
      const root = findUserMessageRootFrom(btn);
      const top = root.getBoundingClientRect().top + window.scrollY || btn.getBoundingClientRect().top + window.scrollY;
      const obj = roots.get(root) || { rootEl: root, top, queryButton: null, responseButton: null };
      obj.queryButton = obj.queryButton || btn;
      obj.top = Math.min(obj.top, top);
      roots.set(root, obj);
    }

    const responseButtons = Array.from(document.querySelectorAll('button[aria-label="Copy"]')).filter((btn) => {
      return btn.querySelector("svg.tabler-icon") || btn.querySelector("svg.tabler-icon-copy") || btn.querySelector("svg");
    });
    for (const btn of responseButtons) {
      if (isCodeCopyButton(btn)) continue;
      const root = findAssistantMessageRootFrom(btn);
      // Ensure the root actually holds an assistant answer, not some header copy control
      const hasAnswer = !!root.querySelector(".prose.text-pretty.dark\\:prose-invert, [class*='prose'][class*='prose-invert']");
      if (!hasAnswer) continue;
      const top = root.getBoundingClientRect().top + window.scrollY || btn.getBoundingClientRect().top + window.scrollY;
      const obj = roots.get(root) || { rootEl: root, top, queryButton: null, responseButton: null };
      obj.responseButton = obj.responseButton || btn;
      obj.top = Math.min(obj.top, top);
      roots.set(root, obj);
    }

    return Array.from(roots.values()).sort((a, b) => a.top - b.top);
  }

  // ============================================================================
  // EXTRACTION METHODS - ALL GROUPED TOGETHER
  // ============================================================================

  // Method 1: Page-down with button clicking (most reliable)
  async function extractByPageDownClickButtons(citationStyle) {
    const conversation = [];
    const processedContent = new Set();
    const processedQueryButtons = new WeakSet();
    const processedAnswerButtons = new WeakSet();

    const scroller = getScrollRoot();
    scroller.scrollTop = 0;
    await new Promise((r) => setTimeout(r, 80));

    let stableBottomCount = 0;
    let scrollAttempt = 0;
    const maxScrollAttempts = 200;
    const scrollDelay = 90;

    while (scrollAttempt < maxScrollAttempts && stableBottomCount < 5) {
      scrollAttempt++;
      let processedSomething = false;

      // Collect visible query/response copy buttons and process in top-to-bottom order
      const qButtons = getViewportQueryButtons().map((btn) => ({ btn, role: "User" }));
      const rButtons = getViewportResponseButtons().map((btn) => ({ btn, role: "Assistant" }));
      const allButtons = [...qButtons, ...rButtons].sort((a, b) => {
        const at = a.btn.getBoundingClientRect().top;
        const bt = b.btn.getBoundingClientRect().top;
        return at - bt;
      });

      for (const item of allButtons) {
        const { btn, role } = item;
        if (role === "User") {
          if (processedQueryButtons.has(btn)) continue;
          processedQueryButtons.add(btn);
          const text = (await clickVisibleButtonAndGetClipboard(btn))?.trim();
          if (text) {
            const hash = text.substring(0, 200) + text.substring(Math.max(0, text.length - 50)) + text.length + "|U";
            if (!processedContent.has(hash)) {
              processedContent.add(hash);
              conversation.push({ role: "User", content: text });
              processedSomething = true;
            }
          }
        } else {
          if (processedAnswerButtons.has(btn)) continue;
          processedAnswerButtons.add(btn);
          const raw = (await clickVisibleButtonAndGetClipboard(btn))?.trim();
          if (raw) {
            const hash = raw.substring(0, 200) + raw.substring(Math.max(0, raw.length - 50)) + raw.length;
            if (!processedContent.has(hash)) {
              processedContent.add(hash);
              const processedMarkdown = processCopiedMarkdown(raw, citationStyle);
              conversation.push({ role: "Assistant", content: processedMarkdown });
              processedSomething = true;
            }
          }
        }
      }

      // Expand any collapsed content every few steps if nothing was processed
      if (!processedSomething) {
        await clickExpandersOnce(6);
      }

      const beforeBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
      await pageDownOnce(scroller, scrollDelay, 0.9);
      const afterBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
      if (beforeBottom && afterBottom && !processedSomething) {
        stableBottomCount++;
      } else {
        stableBottomCount = 0;
      }
    }

    return conversation;
  }

  // Method 2: Single-pass DOM scan (no button clicking)
  async function extractByDomScanSinglePass(citationStyle) {
    const processedContent = new Set();
    const collected = [];

    const scroller = getScrollRoot();
    scroller.scrollTop = 0;
    await new Promise((r) => setTimeout(r, 80));

    let stableBottomCount = 0;
    let scrollAttempt = 0;
    const maxScrollAttempts = 200;
    const scrollDelay = 90;

    while (scrollAttempt < maxScrollAttempts && stableBottomCount < 5) {
      scrollAttempt++;
      const beforeCount = collected.length;

      // Collect in DOM order for this viewport/state
      const batch = collectDomMessagesInOrderOnce(citationStyle, processedContent);
      if (batch.length > 0) {
        for (const item of batch) {
          collected.push(item);
        }
      } else {
        // Try expanding collapsed sections and collect again
        const expanded = await clickExpandersOnce(8);
        if (expanded) {
          const batch2 = collectDomMessagesInOrderOnce(citationStyle, processedContent);
          if (batch2.length > 0) {
            for (const item of batch2) collected.push(item);
          }
        }
      }

      // Detect bottom
      const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
      await pageDownOnce(scroller, scrollDelay, 0.9);
      const atBottomAfter = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;

      if (atBottom && atBottomAfter && collected.length === beforeCount) {
        stableBottomCount++;
      } else {
        stableBottomCount = 0;
      }
    }

    // Do not return to top; keep scroller where it ended
    return collected;
  }

  // Helper for Method 2: collect messages in DOM order within a pass
  function collectDomMessagesInOrderOnce(citationStyle, processedContent) {
    const results = [];
    const container = getThreadContainer();

    const assistantSelector = ".prose.text-pretty.dark\\:prose-invert, [class*='prose'][class*='prose-invert']";
    const userSelectors = [".whitespace-pre-line.text-pretty.break-words", ".group\\/query span[data-lexical-text='true']", "h1.group\\/query span[data-lexical-text='true']", "span[data-lexical-text='true']"];
    const combined = `${assistantSelector}, ${userSelectors.join(", ")}`;

    const nodes = container.querySelectorAll(combined);
    nodes.forEach((node) => {
      if (node.matches(assistantSelector)) {
        const cloned = node.cloneNode(true);
        const md = htmlToMarkdown(cloned.innerHTML, citationStyle).trim();
        if (!md) return;
        const hash = md.substring(0, 200) + md.substring(Math.max(0, md.length - 50)) + md.length;
        if (processedContent.has(hash)) return;
        processedContent.add(hash);
        results.push({ role: "Assistant", content: md });
      } else {
        // User
        const root = findUserMessageRootFromElement(node);
        if (root.closest && (root.closest(".prose.text-pretty.dark\\:prose-invert") || root.closest("[class*='prose'][class*='prose-invert']"))) return;
        // Aggregate query text from all lexical spans within the same root for stability
        const spans = root.querySelectorAll("span[data-lexical-text='true']");
        let text = "";
        if (spans.length > 0) {
          text = Array.from(spans)
            .map((s) => (s.textContent || "").trim())
            .join(" ")
            .trim();
        } else {
          text = (node.textContent || "").trim();
        }
        if (!text || text.length < 2) return;
        // Prefer nodes within a container that also has a copy-query button, but don't require it
        const hasCopyQueryButton = !!(root.querySelector && (root.querySelector("button[data-testid='copy-query-button']") || root.querySelector("button[aria-label='Copy Query']")));
        if (!hasCopyQueryButton && text.length < 10) return;
        const hash = text.substring(0, 200) + text.substring(Math.max(0, text.length - 50)) + text.length + "|U";
        if (processedContent.has(hash)) return;
        processedContent.add(hash);
        results.push({ role: "User", content: text });
      }
    });

    return results;
  }

  // Method 3: Anchored copy button approach (more complex, uses scrollIntoView)
  async function extractUsingCopyButtons(citationStyle) {
    // Reset global citation tracking for this export
    globalCitations.reset();

    try {
      // First try anchored, container-aware approach with preload + progressive scroll
      const anchored = await processAnchoredButtonsWithProgressiveScroll(citationStyle);
      if (anchored.length > 0) {
        return anchored;
      }

      // Fallback: robust scroll-and-process (legacy)
      return await scrollAndProcessButtons(citationStyle);
    } catch (e) {
      console.error("Copy button extraction failed:", e);
      return [];
    }
  }

  async function processAnchoredButtonsWithProgressiveScroll(citationStyle) {
    const conversation = [];
    const processedContent = new Set();
    const processedButtons = new WeakSet();

    await preloadPageFully();

    // Start at top and progressively page down to handle virtualized lists
    const scroller = getScrollRoot();
    scroller.scrollTop = 0;
    await new Promise((r) => setTimeout(r, 80));

    let stableCount = 0;
    let scrollAttempt = 0;
    const maxScrollAttempts = 80;
    const scrollDelay = 120;

    while (scrollAttempt < maxScrollAttempts && stableCount < 5) {
      scrollAttempt++;

      const roots = collectAnchoredMessageRootsOnce();
      let processedSomethingThisPass = false;

      for (const item of roots) {
        const { queryButton, responseButton } = item;

        // Process query first
        if (queryButton && !processedButtons.has(queryButton)) {
          try {
            const text = (await clickButtonAndGetClipboard(queryButton))?.trim();
            if (text) {
              const contentHash = text.substring(0, 200) + text.substring(Math.max(0, text.length - 50)) + text.length;
              if (!processedContent.has(contentHash)) {
                processedContent.add(contentHash);
                conversation.push({ role: "User", content: text });
                processedSomethingThisPass = true;
              }
            }
          } catch (e) {
            console.warn("Query copy failed:", e);
          } finally {
            processedButtons.add(queryButton);
          }
        }

        // Then process response
        if (responseButton && !processedButtons.has(responseButton)) {
          try {
            const raw = (await clickButtonAndGetClipboard(responseButton))?.trim();
            if (raw) {
              const contentHash = raw.substring(0, 200) + raw.substring(Math.max(0, raw.length - 50)) + raw.length;
              if (!processedContent.has(contentHash)) {
                processedContent.add(contentHash);
                const processedMarkdown = processCopiedMarkdown(raw, citationStyle);
                conversation.push({ role: "Assistant", content: processedMarkdown });
                processedSomethingThisPass = true;
              }
            }
          } catch (e) {
            console.warn("Response copy failed:", e);
          } finally {
            processedButtons.add(responseButton);
          }
        }
      }

      if (!processedSomethingThisPass) {
        stableCount++;
      } else {
        stableCount = 0;
      }

      // Page down and allow DOM to settle
      await pageDownOnce(scroller, scrollDelay, 0.9);
    }

    // Try to catch any remaining at the end with a final full scan without scrolling
    const finalRoots = collectAnchoredMessageRootsOnce();
    for (const { queryButton, responseButton } of finalRoots) {
      if (queryButton && !processedButtons.has(queryButton)) {
        try {
          const text = (await clickButtonAndGetClipboard(queryButton))?.trim();
          if (text) {
            const contentHash = text.substring(0, 200) + text.substring(Math.max(0, text.length - 50)) + text.length;
            if (!processedContent.has(contentHash)) {
              processedContent.add(contentHash);
              conversation.push({ role: "User", content: text });
            }
          }
        } catch {}
      }
      if (responseButton && !processedButtons.has(responseButton)) {
        try {
          const raw = (await clickButtonAndGetClipboard(responseButton))?.trim();
          if (raw) {
            const contentHash = raw.substring(0, 200) + raw.substring(Math.max(0, raw.length - 50)) + raw.length;
            if (!processedContent.has(contentHash)) {
              processedContent.add(contentHash);
              const processedMarkdown = processCopiedMarkdown(raw, citationStyle);
              conversation.push({ role: "Assistant", content: processedMarkdown });
            }
          }
        } catch {}
      }
    }

    // Return to top
    scroller.scrollTop = 0;
    await new Promise((r) => setTimeout(r, 300));

    return conversation;
  }

  // Robustly scroll through page and process copy buttons as we find them
  async function scrollAndProcessButtons(citationStyle) {
    console.log("Starting robust scroll and process...");

    const conversation = [];
    const processedContent = new Set();
    const processedButtons = new Set();

    // Ensure document stays focused
    window.focus();

      // Start from top
  const scroller = getScrollRoot();
  scroller.scrollTop = 0;
  await new Promise((resolve) => setTimeout(resolve, 120));

    let stableCount = 0;
    let scrollAttempt = 0;
    let lastButtonCount = 0;
    const maxScrollAttempts = 80; // faster loop
    const scrollDelay = 120; // shorter delay between page downs

    while (scrollAttempt < maxScrollAttempts && stableCount < 5) {
      scrollAttempt++;

      // Count current buttons before processing
      const currentButtonCount = document.querySelectorAll('button[data-testid="copy-query-button"], button[aria-label="Copy Query"], button[aria-label="Copy"]').length;

      console.log(`Page Down attempt ${scrollAttempt}: buttons=${currentButtonCount}`);

      // Find and process visible copy buttons at current position
      await processVisibleButtons();

      // Track button count changes
      if (currentButtonCount > lastButtonCount) {
        console.log(`Button count increased from ${lastButtonCount} to ${currentButtonCount}`);
        lastButtonCount = currentButtonCount;
        stableCount = 0; // Reset stability when new buttons found
      } else {
        stableCount++;
        console.log(`Button count stable at ${currentButtonCount} (stability: ${stableCount}/5)`);
      }

      // Page down the actual scroller
      await pageDownOnce(scroller, scrollDelay, 0.9);
    }

    console.log(`Scroll complete after ${scrollAttempt} attempts. Found ${conversation.length} conversation items`);

      // Return to top
  scroller.scrollTop = 0;
  await new Promise((resolve) => setTimeout(resolve, 120));

    return conversation;

    // Helper function to process visible buttons at current scroll position
    async function processVisibleButtons() {
      const allButtons = document.querySelectorAll("button");
      const copyButtons = [];

      allButtons.forEach((btn) => {
        if (processedButtons.has(btn)) return;

        // Exclude code block copy buttons
        const testId = btn.getAttribute("data-testid");
        const ariaLower = (btn.getAttribute("aria-label") || "").toLowerCase();
        if (testId === "copy-code-button" || testId === "copy-code" || testId?.includes("copy-code") || ariaLower.includes("copy code") || btn.closest("pre") || btn.closest("code")) {
          return;
        }

        // Only include conversation copy buttons
        const isQueryCopyButton = testId === "copy-query-button" || btn.getAttribute("aria-label") === "Copy Query";
        const isResponseCopyButton = btn.getAttribute("aria-label") === "Copy" && (btn.querySelector("svg.tabler-icon") || btn.querySelector("svg.tabler-icon-copy") || btn.querySelector("svg"));

        if (isQueryCopyButton) {
          copyButtons.push({ el: btn, role: "User" });
        } else if (isResponseCopyButton) {
          copyButtons.push({ el: btn, role: "Assistant" });
        }
      });

      // Sort by vertical position
      copyButtons.sort((a, b) => {
        const aTop = a.el.getBoundingClientRect().top + window.scrollY;
        const bTop = b.el.getBoundingClientRect().top + window.scrollY;
        return aTop - bTop;
      });

      console.log(`Found ${copyButtons.length} copy buttons in DOM`);

      // Process each copy button (don't filter by viewport visibility)
      for (const { el: button, role } of copyButtons) {
        if (processedButtons.has(button)) continue;

        try {
          processedButtons.add(button);

          // Ensure window stays focused
          window.focus();

          // Scroll button into view and center it
          button.scrollIntoView({
            behavior: "instant",
            block: "center",
            inline: "center",
          });
          await new Promise((resolve) => setTimeout(resolve, 80));

          // Click button
          button.focus();
          button.click();

          // Wait for clipboard
          await new Promise((resolve) => setTimeout(resolve, 120));
          window.focus();

          const clipboardText = await navigator.clipboard.readText();

          if (clipboardText && clipboardText.trim().length > 0) {
            // Check for duplicates
            const trimmedContent = clipboardText.trim();
            const contentHash = trimmedContent.substring(0, 200) + trimmedContent.substring(Math.max(0, trimmedContent.length - 50)) + trimmedContent.length;

            if (processedContent.has(contentHash)) {
              console.log(`Skipping duplicate content (${clipboardText.length} chars)`);
              continue;
            }

            processedContent.add(contentHash);

            if (role === "User") {
              conversation.push({
                role: "User",
                content: trimmedContent,
              });
            } else {
              const processedMarkdown = processCopiedMarkdown(clipboardText, citationStyle);
              conversation.push({
                role: "Assistant",
                content: processedMarkdown,
              });
            }
          }
        } catch (e) {
          console.error(`Failed to copy from button:`, e);
        }
      }
    }
  }


  // MAIN EXTRACTION ORCHESTRATOR
  async function extractConversation(citationStyle) {
    // Reset global citation tracking
    globalCitations.reset();

    // Method 1: Page-down with button clicking (most reliable)
    // Uses Perplexity's native copy buttons to extract exact content
    console.log("Trying Method 1: Page-down with button clicking...");
    const viaButtons = await extractByPageDownClickButtons(citationStyle);
    console.log(`Method 1 found ${viaButtons.length} items`);
    if (viaButtons.length >= 2) {
      // At least 1 complete turn (User + Assistant)
      console.log("✅ Using Method 1: Button clicking extraction");
      return viaButtons;
    }

    // Method 2: Single-pass DOM scan (no button clicking)
    // Directly reads DOM content while scrolling
    console.log("Trying Method 2: Single-pass DOM scan...");
    const domSingle = await extractByDomScanSinglePass(citationStyle);
    console.log(`Method 2 found ${domSingle.length} items`);
    if (domSingle.length >= 2) {
      // At least 1 complete turn (User + Assistant)
      console.log("✅ Using Method 2: DOM scan extraction");
      return domSingle;
    }

    // Method 3: Anchored copy button approach (legacy)
    // Falls back to older button-based extraction
    console.log("Trying Method 3: Anchored copy button approach...");
    const copyButtonApproach = await extractUsingCopyButtons(citationStyle);
    console.log(`Method 3 found ${copyButtonApproach.length} items`);
    if (copyButtonApproach.length >= 2) {
      // At least 1 complete turn (User + Assistant)
      console.log("✅ Using Method 3: Anchored button extraction");
      return copyButtonApproach;
    }

    console.log("❌ No content found with any method");
    return [];
  }

  // ============================================================================
  // MARKDOWN PROCESSING FUNCTIONS
  // ============================================================================

  // Process copied markdown and convert citations to desired style with global consolidation
  function processCopiedMarkdown(markdown, citationStyle) {
    // The copied format already has [N] citations and numbered URL references at bottom

    // Extract the numbered references section (at bottom of each response)
    const referenceMatches = markdown.match(/\[(\d+)\]\(([^)]+)\)/g) || [];
    const localReferences = new Map(); // local number -> URL

    // Also extract plain numbered references like "1 https://example.com"
    const plainRefs = markdown.match(/^\s*(\d+)\s+(https?:\/\/[^\s\n]+)/gm) || [];
    plainRefs.forEach((ref) => {
      const match = ref.match(/(\d+)\s+(https?:\/\/[^\s\n]+)/);
      if (match) {
        localReferences.set(match[1], match[2]);
      }
    });

    // Extract from [N](url) format citations
    referenceMatches.forEach((ref) => {
      const match = ref.match(/\[(\d+)\]\(([^)]+)\)/);
      if (match) {
        localReferences.set(match[1], match[2]);
      }
    });

    // Remove the plain numbered references section and [N](url) citation blocks from the main content
    let content = markdown
      .replace(/^\s*\d+\s+https?:\/\/[^\s\n]+$/gm, "") // Remove "1 https://example.com" lines
      .replace(/^\s*\[(\d+)\]\([^)]+\)$/gm, "") // Remove "[1](https://example.com)" lines
      .replace(/\n{3,}/g, "\n\n"); // Clean up extra newlines left behind

    // Create mapping from local citation numbers to global numbers
    const localToGlobalMap = new Map();

    // Build the mapping by processing all found references
    localReferences.forEach((url, localNum) => {
      const globalNum = globalCitations.addCitation(url);
      localToGlobalMap.set(localNum, globalNum);
    });

    // Normalize any inline [N](url) occurrences inside the content into [N] tokens, while capturing URLs
    content = content.replace(/\[(\d+)\]\(([^)]+)\)/g, (m, localNum, url) => {
      if (!localReferences.has(localNum)) {
        localReferences.set(localNum, url);
        if (!localToGlobalMap.has(localNum)) {
          const globalNum = globalCitations.addCitation(url);
          localToGlobalMap.set(localNum, globalNum);
        }
      }
      return `[${localNum}]`;
    });

    // Helper builders per style for a run of local numbers
    function buildEndnotesRun(localNums) {
      return localNums
        .map((n) => {
          const g = localToGlobalMap.get(n) || n;
          return `[${g}]`;
        })
        .join("");
    }

    function buildFootnotesRun(localNums) {
      return localNums
        .map((n) => {
          const g = localToGlobalMap.get(n) || n;
          return `[^${g}]`;
        })
        .join("");
    }

    function buildInlineRun(localNums) {
      return localNums
        .map((n) => {
          const url = localReferences.get(n) || "";
          const g = localToGlobalMap.get(n) || n;
          return url ? `[${g}](${url})` : `[${g}]`;
        })
        .join("");
    }

    function buildParenthesizedRun(localNums) {
      // Render each citation in its own parentheses: ([g1](u1)) ([g2](u2)) ...
      return localNums
        .map((n) => {
          const url = localReferences.get(n) || "";
          const g = localToGlobalMap.get(n) || n;
          const core = url ? `[${g}](${url})` : `[${g}]`;
          return `(${core})`;
        })
        .join(" ");
    }

    function buildNamedRun(localNums) {
      // Render each citation in its own parentheses with domain name: ([domain1](u1)) ([domain2](u2)) ...
      return localNums
        .map((n) => {
          const url = localReferences.get(n) || "";
          const domain = extractDomainName(url) || "source";
          const core = url ? `[${domain}](${url})` : `[${domain}]`;
          return `(${core})`;
        })
        .join(" ");
    }

    // Replace runs of citation tokens like [2][4][5] or with spaces between with style-specific output
    // Don't consume trailing whitespace/newlines so we preserve layout
    content = content.replace(/(?:\s*\[\d+\])+/g, (run) => {
      const nums = Array.from(run.matchAll(/\[(\d+)\]/g)).map((m) => m[1]);
      if (nums.length === 0) return run;
      if (citationStyle === CITATION_STYLES.NONE) return ""; // Remove citations completely
      if (citationStyle === CITATION_STYLES.ENDNOTES) return buildEndnotesRun(nums);
      if (citationStyle === CITATION_STYLES.FOOTNOTES) return buildFootnotesRun(nums);
      if (citationStyle === CITATION_STYLES.INLINE) return buildInlineRun(nums);
      if (citationStyle === CITATION_STYLES.PARENTHESIZED) return buildParenthesizedRun(nums);
      if (citationStyle === CITATION_STYLES.NAMED) return buildNamedRun(nums);
      return run;
    });

    // Citations processed and remapped to global numbers

    // Clean up any excessive parentheses sequences that might have been created
    // Collapse 3+ to 2 and remove spaces before punctuation
    content = content.replace(/\){3,}/g, "))");
    content = content.replace(/\(\({2,}/g, "((");

    // Handle newline spacing based on user preference
    const prefs = getPreferences();
    if (prefs.addExtraNewlines) {
      // Keep some extra newlines (max two consecutive)
      content = content.replace(/\n{3,}/g, "\n\n");
    } else {
      // Strip ALL extra newlines by default - single newlines only everywhere
      content = content
        .replace(/\n+/g, "\n") // Replace any multiple newlines with single newline
        .replace(/\n\s*\n/g, "\n"); // Remove any newlines with just whitespace between them
    }

    // Ensure content ends with single newline and clean up extra whitespace
    content = content.trim();

    return content;
  }

  // Convert HTML content to markdown
  function htmlToMarkdown(html, citationStyle = CITATION_STYLES.PARENTHESIZED) {
    const tempDiv = document.createElement("div");
    tempDiv.innerHTML = html;

    tempDiv.querySelectorAll("code").forEach((codeElem) => {
      if (codeElem.style.whiteSpace && codeElem.style.whiteSpace.includes("pre-wrap")) {
        if (codeElem.parentElement.tagName.toLowerCase() !== "pre") {
          const pre = document.createElement("pre");
          let language = "";
          const prevDiv = codeElem.closest("div.pr-lg")?.previousElementSibling;
          if (prevDiv) {
            const langDiv = prevDiv.querySelector(".text-text-200");
            if (langDiv) {
              language = langDiv.textContent.trim().toLowerCase();
              langDiv.remove();
            }
          }
          pre.dataset.language = language;
          pre.innerHTML = "<code>" + codeElem.innerHTML + "</code>";
          codeElem.parentNode.replaceChild(pre, codeElem);
        }
      }
    });

    // Process citations - updated for new structure with proper URL-based tracking
    const citations = [
      ...tempDiv.querySelectorAll("a.citation"), // Old structure
      ...tempDiv.querySelectorAll(".citation.inline"), // New structure
    ];

    // Track unique sources by normalized URL
    const urlToNumber = new Map(); // normalized URL -> citation number
    const citationRefs = new Map(); // citation number -> {href, sourceName, normalizedUrl, multipleUrls}
    let nextCitationNumber = 1;

    // Process citations synchronously first, then handle multi-citations
    citations.forEach((citation) => {
      let href = null;
      let sourceName = null;
      let isMultiCitation = false;

      // Handle old structure (a.citation)
      if (citation.tagName === "A" && citation.classList.contains("citation")) {
        href = citation.getAttribute("href");
      }
      // Handle new structure (.citation.inline)
      else if (citation.classList.contains("citation") && citation.classList.contains("inline")) {
        // Get source name from aria-label or nested text
        const ariaLabel = citation.getAttribute("aria-label");
        if (ariaLabel) {
          sourceName = extractSourceName(ariaLabel);
        }

        // If no source name from aria-label, try to find it in nested elements
        if (!sourceName) {
          const numberSpan = citation.querySelector('.text-3xs, [class*="text-3xs"]');
          if (numberSpan) {
            const spanText = numberSpan.textContent;
            sourceName = extractSourceName(spanText);

            // Check if this is a multi-citation (has +N format)
            isMultiCitation = /\+\d+$/.test(spanText.trim());
          }
        }

        // Get href from nested anchor
        const nestedAnchor = citation.querySelector("a[href]");
        href = nestedAnchor ? nestedAnchor.getAttribute("href") : null;

        // For multi-citations, we'll process them later to avoid blocking
        if (isMultiCitation) {
          citation.setAttribute("data-is-multi-citation", "true");
        }
      }

      if (href) {
        const normalizedUrl = normalizeUrl(href);

        // Check if we've seen this URL before
        if (!urlToNumber.has(normalizedUrl)) {
          // New URL - assign next available number
          urlToNumber.set(normalizedUrl, nextCitationNumber);
          citationRefs.set(nextCitationNumber, {
            href,
            sourceName,
            normalizedUrl,
            isMultiCitation,
          });
          nextCitationNumber++;
        }
        // If we've seen this URL before, we'll reuse the existing number
      }
    });

    // Clean up citations based on style using URL-based numbering
    tempDiv.querySelectorAll(".citation").forEach((el) => {
      let href = null;
      let sourceName = null;
      let isMultiCitation = false;

      // Handle old structure (a.citation)
      if (el.tagName === "A" && el.classList.contains("citation")) {
        href = el.getAttribute("href");
      }
      // Handle new structure (.citation.inline)
      else if (el.classList.contains("citation") && el.classList.contains("inline")) {
        // Get source name from aria-label or nested text
        const ariaLabel = el.getAttribute("aria-label");
        if (ariaLabel) {
          sourceName = extractSourceName(ariaLabel);
        }

        if (!sourceName) {
          const numberSpan = el.querySelector('.text-3xs, [class*="text-3xs"]');
          if (numberSpan) {
            const spanText = numberSpan.textContent;
            sourceName = extractSourceName(spanText);
            isMultiCitation = /\+\d+$/.test(spanText.trim());
          }
        }

        // Get href from nested anchor
        const nestedAnchor = el.querySelector("a[href]");
        href = nestedAnchor ? nestedAnchor.getAttribute("href") : null;
      }

      if (href) {
        const normalizedUrl = normalizeUrl(href);
        const number = urlToNumber.get(normalizedUrl);

        if (number) {
          // For multi-citations, we'll show a note about multiple sources
          let citationText = "";
          let citationUrl = href;

          if (isMultiCitation) {
            // Extract the count from the +N format
            const numberSpan = el.querySelector('.text-3xs, [class*="text-3xs"]');
            const countMatch = numberSpan ? numberSpan.textContent.match(/\+(\d+)$/) : null;
            const count = countMatch ? parseInt(countMatch[1]) : 2;

            if (citationStyle === CITATION_STYLES.NONE) {
              citationText = ""; // Remove citation completely
            } else if (citationStyle === CITATION_STYLES.NAMED && sourceName) {
              citationText = ` [${sourceName} +${count} more](${citationUrl}) `;
            } else {
              citationText = ` [${number} +${count} more](${citationUrl}) `;
            }
          } else {
            // Single citation - use normal format
            if (citationStyle === CITATION_STYLES.NONE) {
              citationText = ""; // Remove citation completely
            } else if (citationStyle === CITATION_STYLES.INLINE) {
              citationText = ` [${number}](${citationUrl}) `;
            } else if (citationStyle === CITATION_STYLES.PARENTHESIZED) {
              citationText = ` ([${number}](${citationUrl})) `;
            } else if (citationStyle === CITATION_STYLES.NAMED && sourceName) {
              citationText = ` [${sourceName}](${citationUrl}) `;
            } else if (citationStyle === CITATION_STYLES.FOOTNOTES) {
              citationText = ` [^${number}] `;
            } else {
              citationText = ` [${number}] `;
            }
          }

          el.replaceWith(citationText);
        } else {
          // Fallback if we can't find the number
        }
      } else {
        // Fallback if we can't parse properly
      }
    });

    // Convert strong sections to headers and clean up content
    let text = tempDiv.innerHTML;

    //  Basic HTML conversion
    text = text
      .replace(/<h1[^>]*>([\s\S]*?)<\/h1>/g, "# $1")
      .replace(/<h2[^>]*>([\s\S]*?)<\/h2>/g, "## $1")
      .replace(/<h3[^>]*>([\s\S]*?)<\/h3>/g, "### $1")
      .replace(/<h4[^>]*>([\s\S]*?)<\/h4>/g, "#### $1")
      .replace(/<h5[^>]*>([\s\S]*?)<\/h5>/g, "##### $1")
      .replace(/<h6[^>]*>([\s\S]*?)<\/h6>/g, "###### $1")
      .replace(/<p[^>]*>([\s\S]*?)<\/p>/g, (_, content) => {
        const prefs = getPreferences();
        return prefs.addExtraNewlines ? `${content}\n\n` : `${content}\n`;
      })
      .replace(/<br\s*\/?>(?!\n)/g, () => {
        const prefs = getPreferences();
        return prefs.addExtraNewlines ? "\n\n" : "\n";
      })
      .replace(/<strong>([\s\S]*?)<\/strong>/g, "**$1**")
      .replace(/<em>([\s\S]*?)<\/em>/g, "*$1*")
      .replace(/<ul[^>]*>([\s\S]*?)<\/ul>/g, (_, content) => {
        const prefs = getPreferences();
        return prefs.addExtraNewlines ? `${content}\n\n` : `${content}\n`;
      })
      .replace(/<li[^>]*>([\s\S]*?)<\/li>/g, (_, content) => {
        const prefs = getPreferences();
        return prefs.addExtraNewlines ? ` - ${content}\n\n` : ` - ${content}\n`;
      });

    // Handle tables before removing remaining HTML
    text = text.replace(/<table[^>]*>([\s\S]*?)<\/table>/g, (match) => {
      const tableDiv = document.createElement("div");
      tableDiv.innerHTML = match;
      const rows = [];

      // Process header rows
      const headerRows = tableDiv.querySelectorAll("thead tr");
      if (headerRows.length > 0) {
        headerRows.forEach((row) => {
          const cells = [...row.querySelectorAll("th, td")].map((cell) => cell.textContent.trim() || " ");
          if (cells.length > 0) {
            rows.push(`| ${cells.join(" | ")} |`);
            // Add separator row after headers
            rows.push(`| ${cells.map(() => "---").join(" | ")} |`);
          }
        });
      }

      // Process body rows
      const bodyRows = tableDiv.querySelectorAll("tbody tr");
      bodyRows.forEach((row) => {
        const cells = [...row.querySelectorAll("td")].map((cell) => cell.textContent.trim() || " ");
        if (cells.length > 0) {
          rows.push(`| ${cells.join(" | ")} |`);
        }
      });

      // Return markdown table with proper spacing
      return rows.length > 0 ? `\n\n${rows.join("\n")}\n\n` : "";
    });

    // Continue with remaining HTML conversion
    text = text
      .replace(/<pre[^>]*data-language="([^"]*)"[^>]*><code>([\s\S]*?)<\/code><\/pre>/g, "```$1\n$2\n```")
      .replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, "```\n$1\n```")
      .replace(/<code>(.*?)<\/code>/g, "`$1`")
      .replace(/<a\s+(?:[^>]*?\s+)?href="([^"]*)"[^>]*>(.*?)<\/a>/g, "[$2]($1)")
      .replace(/<[^>]+>/g, ""); // Remove any remaining HTML tags

    // Clean up whitespace
    // Convert bold text at start of line to h3 headers, but not if inside a list item
    text = text.replace(/^(\s*)\*\*([^*\n]+)\*\*(?!.*\n\s*-)/gm, "$1### $2");

    // This fixes list items where the entire text was incorrectly converted to headers
    // We need to preserve both partial bold items and fully bold items
    text = text.replace(/^(\s*-\s+)###\s+([^\n]+)/gm, function (_, listPrefix, content) {
      // Check if the content contains bold markers
      if (content.includes("**")) {
        // If it already has bold markers, just remove the ### and keep the rest intact
        return `${listPrefix}${content}`;
      } else {
        // If it doesn't have bold markers (because it was fully bold before),
        // add them back (this was incorrectly converted to a header)
        return `${listPrefix}**${content}**`;
      }
    });

    // Fix list spacing (no extra newlines between items)
    text = text.replace(/\n\s*-\s+/g, "\n- ");

    // Ensure headers have proper spacing
    text = text.replace(/([^\n])(\n#{1,3} )/g, "$1\n\n$2");

    // Fix unbalanced or misplaced bold markers in list items
    text = text.replace(/^(\s*-\s+.*?)(\s\*\*\s*)$/gm, "$1"); // Remove trailing ** with space before

    // Fix citation and bold issues - make sure citations aren't wrapped in bold
    text = text.replace(/\*\*([^*]+)(\[[0-9]+\]\([^)]+\))\s*\*\*/g, "**$1**$2");
    text = text.replace(/\*\*([^*]+)(\(\[[0-9]+\]\([^)]+\)\))\s*\*\*/g, "**$1**$2");

    // Fix cases where a line ends with an extra bold marker after a citation
    text = text.replace(/(\[[0-9]+\]\([^)]+\))\s*\*\*/g, "$1");
    text = text.replace(/(\(\[[0-9]+\]\([^)]+\)\))\s*\*\*/g, "$1");

    // Clean up whitespace
    text = text
      .replace(/^[\s\n]+|[\s\n]+$/g, "") // Trim start and end
      .replace(/^\s+/gm, "") // Remove leading spaces on each line
      .replace(/[ \t]+$/gm, "") // Remove trailing spaces
      .trim();

    // Handle newline spacing based on user preference
    const prefs = getPreferences();
    if (prefs.addExtraNewlines) {
      // Keep extra newlines (max two consecutive)
      text = text.replace(/\n{3,}/g, "\n\n");
    } else {
      // Strip ALL extra newlines by default - single newlines only everywhere
      text = text
        .replace(/\n+/g, "\n") // Replace any multiple newlines with single newline
        .replace(/\n\s*\n/g, "\n"); // Remove any newlines with just whitespace between them
    }

    if (citationStyle === CITATION_STYLES.INLINE || citationStyle === CITATION_STYLES.PARENTHESIZED) {
      // Remove extraneous space before a period, but preserve newlines
      text = text.replace(/ (?=\.)/g, "");
    }

    // Add citations at the bottom for endnotes style
    if (citationStyle === CITATION_STYLES.ENDNOTES && citationRefs.size > 0) {
      text += "\n\n### Sources\n";
      for (const [number, { href }] of citationRefs) {
        text += `[${number}] ${href}\n`;
      }
    }

    // Add footnote definitions at the bottom for footnotes style
    if (citationStyle === CITATION_STYLES.FOOTNOTES && citationRefs.size > 0) {
      text += "\n\n";
      for (const [number, { href }] of citationRefs) {
        text += `[^${number}]: ${href}\n`;
      }
    }

    return text;
  }

  // Format the complete markdown document
  function formatMarkdown(conversations) {
    const title = document.title.replace(" | Perplexity", "").trim();
    const timestamp = new Date().toISOString().split("T")[0];
    const prefs = getPreferences();

    let markdown = "";

    // Only add frontmatter if enabled
    if (prefs.includeFrontmatter) {
      markdown += "---\n";
      markdown += `title: ${title}\n`;
      markdown += `date: ${timestamp}\n`;
      markdown += `source: ${window.location.href}\n`;
      markdown += "---\n\n"; // Add newline after properties
    }

    // Add title as H1 if enabled
    if (prefs.titleAsH1) {
      markdown += `# ${title}\n\n`;
    }

    conversations.forEach((conv, index) => {
      if (conv.role === "Assistant") {
        // Ensure assistant content ends with single newline
        let cleanContent = conv.content.trim();

        // Check if content starts with a header and fix formatting
        if (cleanContent.match(/^#+ /)) {
          // Content starts with a header - ensure role is on separate line
          if (prefs.formatStyle === FORMAT_STYLES.FULL) {
            markdown += `**${conv.role}:**\n\n${cleanContent}\n\n`;
          } else {
            markdown += `${cleanContent}\n\n`;
          }
        } else {
          // Normal content formatting
          if (prefs.formatStyle === FORMAT_STYLES.FULL) {
            markdown += `**${conv.role}:** ${cleanContent}\n\n`;
          } else {
            markdown += `${cleanContent}\n\n`;
          }
        }

        // Add divider only between assistant responses, not after the last one
        const nextAssistant = conversations.slice(index + 1).find((c) => c.role === "Assistant");
        if (nextAssistant) {
          markdown += "---\n\n";
        }
      } else if (conv.role === "User" && prefs.formatStyle === FORMAT_STYLES.FULL) {
        markdown += `**${conv.role}:** ${conv.content.trim()}\n\n`;
        markdown += "---\n\n";
      }
    });

    // Add global citations at the end for endnotes style
    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"; // Add final newline
    }

    // Add global footnote definitions at the end for footnotes style
    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.trim(); // Trim any trailing whitespace at the very end
  }

  // ============================================================================
  // UI FUNCTIONS
  // ============================================================================

  // Download markdown file
  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);
  }

  // Copy to clipboard
  async function copyToClipboard(content) {
    try {
      await navigator.clipboard.writeText(content);
      return true;
    } catch (err) {
      console.error('Failed to copy to clipboard:', err);
      return false;
    }
  }

  // Temporarily prevent navigation (external anchors and window.open) during export
  function installNavBlocker() {
    const clickBlocker = (e) => {
      try {
        const anchor = e.target && e.target.closest && e.target.closest('a[href], area[href]');
        if (!anchor) return;
        const href = (anchor.getAttribute('href') || '').trim();
        const target = (anchor.getAttribute('target') || '').trim().toLowerCase();
        const isExternal = /^https?:\/\//i.test(href);
        if (isExternal || target === '_blank') {
          e.preventDefault();
          e.stopImmediatePropagation();
        }
      } catch {}
    };
    document.addEventListener('click', clickBlocker, true);

    const originalOpen = window.open;
    window.open = function () { return null; };

    return function removeNavBlocker() {
      try { document.removeEventListener('click', clickBlocker, true); } catch {}
      try { window.open = originalOpen; } catch {}
    };
  }

  // Create and add export button
  function addExportButton() {
    const existingControls = document.getElementById("perplexity-export-controls");
    if (existingControls) {
      existingControls.remove();
    }

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

    const exportButton = document.createElement("button");
    exportButton.id = "perplexity-export-btn";
    exportButton.type = "button";
    exportButton.textContent = "Save as Markdown"; // Default, will be updated
    exportButton.style.cssText = `
            padding: 4px 8px;
            background-color: #30b8c6;
            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);
        `;

    const optionsWrapper = document.createElement("div");
    optionsWrapper.style.cssText = `
            position: relative;
            display: flex;
        `;

    const optionsButton = document.createElement("button");
    optionsButton.id = "perplexity-export-options-btn";
    optionsButton.type = "button";
    optionsButton.setAttribute("aria-haspopup", "true");
    optionsButton.setAttribute("aria-expanded", "false");
    optionsButton.style.cssText = `
            padding: 4px 8px;
            background-color: #30b8c6;
            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;
        `;

    const menu = document.createElement("div");
    menu.id = "perplexity-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);
        `;

    optionsWrapper.appendChild(optionsButton);

    container.appendChild(exportButton);
    container.appendChild(optionsWrapper);
    container.appendChild(menu);

    function updateOptionsButtonLabel() {
      const label = `Options`;
      optionsButton.textContent = label;
      optionsButton.setAttribute("aria-label", `Export options. ${label}`);
    }

    function updateExportButtonLabel() {
      const prefs = getPreferences();
      const label = prefs.exportMethod === EXPORT_METHODS.CLIPBOARD ? "Copy as Markdown" : "Save as Markdown";
      exportButton.textContent = label;
    }

    function createOptionButton(label, value, currentValue, onSelect, tooltip) {
      const optionBtn = document.createElement("button");
      optionBtn.type = "button";
      optionBtn.textContent = label;
      if (tooltip) {
        optionBtn.setAttribute("title", tooltip);
      }
      optionBtn.style.cssText = `
                padding: 6px 8px;
                border-radius: 6px;
                border: 1px solid ${value === currentValue ? "#30b8c6" : "#4a5568"};
                background-color: ${value === currentValue ? "#30b8c6" : "#2d3748"};
                color: ${value === currentValue ? "#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;
            `;
      optionBtn.addEventListener("mouseenter", () => {
        if (value !== currentValue) {
          optionBtn.style.borderColor = "#30b8c6";
          optionBtn.style.backgroundColor = "#4a5568";
        }
      });
      optionBtn.addEventListener("mouseleave", () => {
        if (value !== currentValue) {
          optionBtn.style.borderColor = "#4a5568";
          optionBtn.style.backgroundColor = "#2d3748";
        }
      });
      optionBtn.addEventListener("click", () => {
        onSelect(value);
        renderOptionsMenu();
        updateOptionsButtonLabel();
        updateExportButtonLabel();
      });
      return optionBtn;
    }

    function appendOptionGroup(sectionEl, label, options, currentValue, onSelect, labelTooltip) {
      const group = document.createElement("div");
      group.style.display = "flex";
      group.style.flexDirection = "column";
      group.style.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.display = "grid";
      list.style.gridTemplateColumns = "1fr 1fr";
      list.style.gap = "4px";

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

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

    function renderOptionsMenu() {
      const prefs = getPreferences();
      menu.innerHTML = "";

      const citationSection = document.createElement("div");
      citationSection.style.display = "flex";
      citationSection.style.flexDirection = "column";
      citationSection.style.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)
      );

      menu.appendChild(citationSection);

      const outputSection = document.createElement("div");
      outputSection.style.display = "flex";
      outputSection.style.flexDirection = "column";
      outputSection.style.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,
        "Layout",
        [
          { label: "Full (User & Assistant)", value: FORMAT_STYLES.FULL },
          { label: "Concise (content only)", value: FORMAT_STYLES.CONCISE },
        ],
        prefs.formatStyle,
        (next) => GM_setValue("formatStyle", next)
      );

      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 conversation 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 conversation 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 conversation title as a # heading at the top"
      );

      menu.appendChild(outputSection);

      const exportSection = document.createElement("div");
      exportSection.style.display = "flex";
      exportSection.style.flexDirection = "column";
      exportSection.style.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)
      );

      menu.appendChild(exportSection);
    }

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

    function closeMenu() {
      menu.style.display = "none";
      optionsButton.setAttribute("aria-expanded", "false");
      optionsButton.style.backgroundColor = "#30b8c6";
      document.removeEventListener("mousedown", handleOutsideClick, true);
      document.removeEventListener("keydown", handleEscapeKey, true);
    }

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

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

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

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

    optionsButton.addEventListener("mouseenter", () => {
      optionsButton.style.backgroundColor = "#30b8c6";
    });

    optionsButton.addEventListener("mouseleave", () => {
      optionsButton.style.backgroundColor = "#30b8c6";
    });

    updateOptionsButtonLabel();
    updateExportButtonLabel();

    const positionContainer = () => {
      let mainContainer = document.querySelector(".max-w-threadContentWidth") || document.querySelector('[class*="threadContentWidth"]');

      if (!mainContainer) {
        const inputArea = document.querySelector("textarea[placeholder]") || document.querySelector('[role="textbox"]') || document.querySelector("form");
        if (inputArea) {
          let parent = inputArea.parentElement;
          while (parent && parent !== document.body) {
            const width = parent.getBoundingClientRect().width;
            if (width > 400 && width < window.innerWidth * 0.8) {
              mainContainer = parent;
              break;
            }
            parent = parent.parentElement;
          }
        }
      }

      if (!mainContainer) {
        mainContainer = document.querySelector("main") || document.querySelector('[role="main"]') || document.querySelector('[class*="main-content"]');
      }

      if (mainContainer) {
        const rect = mainContainer.getBoundingClientRect();
        const centerX = rect.left + rect.width / 2;
        container.style.transition = "none";
        container.style.left = `${centerX}px`;
        container.style.transform = "translateX(-50%)";
        requestAnimationFrame(() => {
          container.style.transition = "left 0.2s";
        });
        console.log("Controls positioned at:", centerX, "Container width:", rect.width, "Container left:", rect.left);
      } else {
        container.style.transition = "none";
        container.style.left = "50%";
        container.style.transform = "translateX(-50%)";
        requestAnimationFrame(() => {
          container.style.transition = "left 0.2s";
        });
      }
    };

    positionContainer();

    window.addEventListener("resize", () => {
      console.log("Window resize detected");
      positionContainer();
    });

    window.addEventListener("orientationchange", () => {
      console.log("Orientation change detected");
      setTimeout(positionContainer, 100);
    });

    const observer = new MutationObserver((mutations) => {
      console.log("DOM mutation detected:", mutations.length, "mutations");
      positionContainer();
    });

    observer.observe(document.body, {
      attributes: true,
      attributeFilter: ["class", "style"],
      subtree: false,
    });

    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style"],
      subtree: false,
    });

    if (typeof ResizeObserver !== "undefined") {
      const resizeObserver = new ResizeObserver((entries) => {
        console.log("ResizeObserver triggered for", entries.length, "elements");
        positionContainer();
      });

      resizeObserver.observe(document.body);
      resizeObserver.observe(document.documentElement);

      const containers = [
        document.querySelector(".max-w-threadContentWidth"),
        document.querySelector('[class*="threadContentWidth"]'),
        document.querySelector("main"),
        document.querySelector('[role="main"]'),
      ].filter(Boolean);

      containers.forEach((candidate) => {
        console.log("Observing container:", candidate);
        resizeObserver.observe(candidate);
        if (candidate.parentElement) {
          resizeObserver.observe(candidate.parentElement);
        }
      });
    }

    setInterval(() => {
      const currentLeft = parseFloat(container.style.left) || 0;
      const rect = (document.querySelector(".max-w-threadContentWidth") || document.querySelector('[class*="threadContentWidth"]') || document.querySelector("main"))?.getBoundingClientRect();

      if (rect) {
        const expectedX = rect.left + rect.width / 2;
        if (Math.abs(currentLeft - expectedX) > 20) {
          console.log("Periodic check: repositioning controls from", currentLeft, "to", expectedX);
          positionContainer();
        }
      }
    }, 2000);

    exportButton.addEventListener("mouseenter", () => {
      exportButton.style.backgroundColor = "#30b8c6";
    });

    exportButton.addEventListener("mouseleave", () => {
      exportButton.style.backgroundColor = "#30b8c6";
    });

    exportButton.addEventListener("click", async () => {
      const originalText = exportButton.textContent;
      exportButton.textContent = "Exporting...";
      exportButton.disabled = true;

      const removeNavBlocker = installNavBlocker();
      try {
        window.focus();
        await new Promise((resolve) => setTimeout(resolve, 500));

        const prefs = getPreferences();
        const conversation = await extractConversation(prefs.citationStyle);
        if (conversation.length === 0) {
          alert("No conversation content found to export.");
          return;
        }

        const markdown = formatMarkdown(conversation);

        if (prefs.exportMethod === EXPORT_METHODS.CLIPBOARD) {
          const success = await copyToClipboard(markdown);
          if (success) {
            exportButton.textContent = "Copied!";
            setTimeout(() => {
              exportButton.textContent = originalText;
            }, 2000);
          } else {
            alert("Failed to copy to clipboard. Please try again.");
          }
        } else {
          const title = document.title.replace(" | Perplexity", "").trim();
          const safeTitle = title
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, " ")
            .replace(/^-+|-+$/g, "");
          const filename = `${safeTitle}.md`;
          downloadMarkdown(markdown, filename);
        }
      } catch (error) {
        console.error("Export failed:", error);
        alert("Export failed. Please try again.");
      } finally {
        try {
          removeNavBlocker();
        } catch {}
        if (exportButton.textContent !== "Copied!") {
          exportButton.textContent = originalText;
        }
        exportButton.disabled = false;
        closeMenu();
      }
    });

    document.body.appendChild(container);
  }

  // Initialize the script
  function init() {
    const observer = new MutationObserver(() => {
      if ((document.querySelector(".prose.text-pretty.dark\\:prose-invert") || document.querySelector("[class*='prose'][class*='prose-invert']") || document.querySelector("span[data-lexical-text='true']")) && !document.getElementById("perplexity-export-btn")) {
        addExportButton();
      }
    });

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

    if (document.querySelector(".prose.text-pretty.dark\\:prose-invert") || document.querySelector("[class*='prose'][class*='prose-invert']") || document.querySelector("span[data-lexical-text='true']")) {
      addExportButton();
    }
  }

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址