Perplexity.ai Chat Exporter

Export Perplexity.ai conversations as markdown with configurable citation styles

目前為 2025-09-11 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Perplexity.ai Chat Exporter
// @namespace    https://github.com/ckep1/pplxport
// @version      2.0.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
// @grant        GM_registerMenuCommand
// @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",
    INLINE: "inline",
    PARENTHESIZED: "parenthesized",
    NAMED: "named",
  };

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

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

  // ============================================================================
  // MENU COMMANDS
  // ============================================================================

  GM_registerMenuCommand("Use Endnotes Citation Style", () => {
    GM_setValue("citationStyle", CITATION_STYLES.ENDNOTES);
    alert("Citation style set to endnotes. Format: [1] with sources listed at end.");
  });

  GM_registerMenuCommand("Use Inline Citation Style", () => {
    GM_setValue("citationStyle", CITATION_STYLES.INLINE);
    alert("Citation style set to inline. Format: [1](url)");
  });

  GM_registerMenuCommand("Use Parenthesized Citation Style", () => {
    GM_setValue("citationStyle", CITATION_STYLES.PARENTHESIZED);
    alert("Citation style set to parenthesized. Format: ([1](url))");
  });

  GM_registerMenuCommand("Use Named Citation Style", () => {
    GM_setValue("citationStyle", CITATION_STYLES.NAMED);
    alert("Citation style set to named. Format: [source](url) using source names like 'wikipedia', 'reddit', etc.");
  });

  GM_registerMenuCommand("Full Format (with User/Assistant)", () => {
    GM_setValue("formatStyle", FORMAT_STYLES.FULL);
    alert("Format set to full with User/Assistant tags.");
  });

  GM_registerMenuCommand("Concise Format (content only)", () => {
    GM_setValue("formatStyle", FORMAT_STYLES.CONCISE);
    alert("Format set to concise content only.");
  });

  GM_registerMenuCommand("Enable Extra Newlines", () => {
    GM_setValue("addExtraNewlines", true);
    alert("Extra newlines enabled. Adds blank lines after paragraphs and list items.");
  });

  GM_registerMenuCommand("Disable Extra Newlines", () => {
    GM_setValue("addExtraNewlines", false);
    alert("Extra newlines disabled. Standard markdown spacing.");
  });

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

  // 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 = 500, 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, 300));

      let lastHeight = scroller.scrollHeight;
      let stableCount = 0;
      const maxTries = 80; // ~40s at 500ms intervals

      for (let i = 0; i < maxTries && stableCount < 3; i++) {
        scroller.scrollTop = scroller.scrollHeight;
        await new Promise((resolve) => setTimeout(resolve, 500));
        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, 500));
    } 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 = 200) {
    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;
        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() {
    const buttons = Array.from(document.querySelectorAll('button[aria-label="Copy"]')).filter((btn) => btn.querySelector("svg.tabler-icon-copy"));
    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, 100));
      button.focus();
      button.click();
      await new Promise((r) => setTimeout(r, 200));
      return await readClipboardWithRetries(3, 150);
    } catch (e) {
      return "";
    }
  }

  async function clickButtonAndGetClipboard(button) {
    window.focus();
    button.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
    await new Promise((r) => setTimeout(r, 200));
    simulateHover(button);
    await new Promise((r) => setTimeout(r, 150));
    button.focus();
    button.click();
    await new Promise((r) => setTimeout(r, 500));
    window.focus();
    return await readClipboardWithRetries(4, 250);
  }

  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) => btn.querySelector("svg.tabler-icon-copy"));
    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, 200));

    let stableBottomCount = 0;
    let scrollAttempt = 0;
    const maxScrollAttempts = 300;
    const scrollDelay = 500;

    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, 300));

    let stableBottomCount = 0;
    let scrollAttempt = 0;
    const maxScrollAttempts = 300;
    const scrollDelay = 500;

    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, 400));

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

    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, 500));

    let stableCount = 0;
    let scrollAttempt = 0;
    let lastButtonCount = 0;
    const maxScrollAttempts = 100; // Increase max attempts
    const scrollDelay = 600; // 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, 500));

    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-copy");

        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: "smooth",
            block: "center",
            inline: "center",
          });
          await new Promise((resolve) => setTimeout(resolve, 300));

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

          // Wait for clipboard
          await new Promise((resolve) => setTimeout(resolve, 500));
          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;
    }

    // Method 4: Direct DOM parsing (final fallback)
    // Parses visible DOM elements without any scrolling
    console.log("Trying Method 4: Direct DOM parsing fallback...");
    const conversation = [];
    const seenUserQueries = new Set();

    // Find the main thread container
    const threadContainer = document.querySelector('.max-w-threadContentWidth, [class*="threadContentWidth"]');

    if (threadContainer) {
      // Get ALL divs in the thread container, process them in document order
      const allDivs = threadContainer.querySelectorAll("div");

      allDivs.forEach((div) => {
        // Check if this div contains a user query (multiple possible structures)
        const userQuerySelectors = [
          ".whitespace-pre-line.text-pretty.break-words", // Original working selector
          "span[data-lexical-text='true']", // New structure selector
          ".group\\/query span[data-lexical-text='true']", // Grouped queries
          "h1.group\\/query span[data-lexical-text='true']", // H1 queries
        ];

        let foundUserQuery = false;
        for (const selector of userQuerySelectors) {
          const userQuery = div.querySelector(selector);
          if (userQuery) {
            const content = userQuery.textContent.trim();
            if (content && content.length > 10 && !seenUserQueries.has(content)) {
              seenUserQueries.add(content);
              conversation.push({
                role: "User",
                content: content,
              });
              foundUserQuery = true;
              break; // Stop looking once we find a query in this div
            }
          }
        }

        // If no user query found in this div, check for assistant response
        if (!foundUserQuery) {
          const assistantResponse = div.querySelector(".prose.text-pretty.dark\\:prose-invert");
          if (assistantResponse) {
            const answerContent = assistantResponse.cloneNode(true);
            conversation.push({
              role: "Assistant",
              content: htmlToMarkdown(answerContent.innerHTML, citationStyle),
            });
          }
        }
      });
    }

    // Fallback: if we didn't find conversations, try the simple original method
    if (conversation.length === 0) {
      // Just get user queries and assistant responses in document order
      document.querySelectorAll(".whitespace-pre-line.text-pretty.break-words, span[data-lexical-text='true'], .prose.text-pretty.dark\\:prose-invert").forEach((element) => {
        if (element.matches(".prose.text-pretty.dark\\:prose-invert")) {
          // Assistant response
          const answerContent = element.cloneNode(true);
          conversation.push({
            role: "Assistant",
            content: htmlToMarkdown(answerContent.innerHTML, citationStyle),
          });
        } else {
          // User query
          const content = element.textContent.trim();
          if (content && content.length > 10 && !seenUserQueries.has(content)) {
            seenUserQueries.add(content);
            conversation.push({
              role: "User",
              content: content,
            });
          }
        }
      });
    }

    console.log(`Method 4 found ${conversation.length} items`);
    if (conversation.length > 0) {
      console.log("✅ Using Method 4: Direct DOM parsing");
    } else {
      console.log("❌ No content found with any method");
    }
    return conversation;
  }

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

    // Replace citations in the content based on the desired style
    // Sort by localNum in descending order to avoid replacement interference (e.g., [10] before [1])
    const sortedEntries = Array.from(localToGlobalMap.entries()).sort((a, b) => {
      const numA = parseInt(a[0]);
      const numB = parseInt(b[0]);
      return numB - numA; // Sort in descending order
    });

    sortedEntries.forEach(([localNum, globalNum]) => {
      if (citationStyle === CITATION_STYLES.ENDNOTES) {
        // Replace [N](url) with [globalN] and [N] with [globalN]
        const linkRegex = new RegExp(`\\[${localNum}\\]\\([^)]+\\)`, "g");
        const plainRegex = new RegExp(`\\[${localNum}\\](?!\\()`, "g");
        content = content.replace(linkRegex, `[${globalNum}]`);
        content = content.replace(plainRegex, `[${globalNum}]`);
      } else if (citationStyle === CITATION_STYLES.INLINE) {
        // Replace [N] with [globalN](url) and keep [N](url) as [globalN](url)
        const url = localReferences.get(localNum);
        const linkRegex = new RegExp(`\\[${localNum}\\]\\([^)]+\\)`, "g");
        const plainRegex = new RegExp(`\\[${localNum}\\](?!\\()`, "g");
        content = content.replace(linkRegex, `[${globalNum}](${url})`);
        content = content.replace(plainRegex, `[${globalNum}](${url})`);
      } else if (citationStyle === CITATION_STYLES.PARENTHESIZED) {
        // Replace with ([globalN](url))
        const url = localReferences.get(localNum);
        const linkRegex = new RegExp(`\\[${localNum}\\]\\([^)]+\\)`, "g");
        const plainRegex = new RegExp(`\\[${localNum}\\](?!\\()`, "g");
        content = content.replace(linkRegex, `([${globalNum}](${url}))`);
        content = content.replace(plainRegex, `([${globalNum}](${url}))`);
      } else if (citationStyle === CITATION_STYLES.NAMED) {
        // Replace with ([domain](url))
        const url = localReferences.get(localNum);
        const domain = extractDomainName(url) || "source";
        const linkRegex = new RegExp(`\\[${localNum}\\]\\([^)]+\\)`, "g");
        const plainRegex = new RegExp(`\\[${localNum}\\](?!\\()`, "g");
        content = content.replace(linkRegex, `([${domain}](${url}))`);
        content = content.replace(plainRegex, `([${domain}](${url}))`);
      }
    });

    // Citations processed and remapped to global numbers

    // Clean up any extra parentheses that might have been created
    content = content.replace(/\)\)\)/g, "))"); // Fix triple parentheses
    content = content.replace(/\(\(\(/g, "(("); // Fix triple opening parentheses

    // 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.NAMED && sourceName) {
              citationText = ` [${sourceName} +${count} more](${citationUrl}) `;
            } else {
              citationText = ` [${number} +${count} more](${citationUrl}) `;
            }
          } else {
            // Single citation - use normal format
            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 {
              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(/\n{3,}/g, "\n\n") // Max two consecutive newlines
      .replace(/^\s+/gm, "") // Remove leading spaces on each line
      .replace(/[ \t]+$/gm, "") // Remove trailing spaces
      .trim();

    if (citationStyle === CITATION_STYLES.INLINE || citationStyle === CITATION_STYLES.PARENTHESIZED) {
      // Remove extraneous space before a period: e.g. " [1](url) ." -> " [1](url)."
      text = text.replace(/\s+\./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`;
      }
    }

    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 = "---\n";
    markdown += `title: ${title}\n`;
    markdown += `date: ${timestamp}\n`;
    markdown += `source: ${window.location.href}\n`;
    markdown += "---\n\n"; // Add newline after properties

    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
    }

    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);
  }

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

    const button = document.createElement("button");
    button.id = "perplexity-export-btn";
    button.textContent = "Save as Markdown";

    // Position button relative to main content area
    const positionButton = () => {
      // Multiple strategies to find the main content area

      // Strategy 1: Find the thread content container
      let mainContainer = document.querySelector(".max-w-threadContentWidth") || document.querySelector('[class*="threadContentWidth"]');

      // Strategy 2: Find the input area's parent container
      if (!mainContainer) {
        const inputArea = document.querySelector("textarea[placeholder]") || document.querySelector('[role="textbox"]') || document.querySelector("form");
        if (inputArea) {
          // Walk up to find a container with reasonable width
          let parent = inputArea.parentElement;
          while (parent && parent !== document.body) {
            const width = parent.getBoundingClientRect().width;
            // Look for a container that's likely the main content (not full width, not too narrow)
            if (width > 400 && width < window.innerWidth * 0.8) {
              mainContainer = parent;
              break;
            }
            parent = parent.parentElement;
          }
        }
      }

      // Strategy 3: Find main element
      if (!mainContainer) {
        mainContainer = document.querySelector("main") || document.querySelector('[role="main"]') || document.querySelector('[class*="main-content"]');
      }

      if (mainContainer) {
        const rect = mainContainer.getBoundingClientRect();

        // Calculate center and apply directly without transition
        const centerX = rect.left + rect.width / 2;

        // Force immediate positioning update by removing and re-adding styles
        button.style.transition = "none";
        button.style.left = `${centerX}px`;
        button.style.transform = "translateX(-50%)";

        // Re-enable transition after a moment
        requestAnimationFrame(() => {
          button.style.transition = "background-color 0.2s, left 0.2s";
        });

        // Debug positioning
        console.log("Button positioned at:", centerX, "Container width:", rect.width, "Container left:", rect.left);
      } else {
        // Fallback to viewport center
        button.style.transition = "none";
        button.style.left = "50%";
        button.style.transform = "translateX(-50%)";
        requestAnimationFrame(() => {
          button.style.transition = "background-color 0.2s, left 0.2s";
        });
      }
    };

    button.style.cssText = `
            position: fixed;
            bottom: 40px;
            left: 50%;
            transform: translateX(-50%);
            padding: 4px 8px;
            background-color: #30b8c6;
            color: black;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 12px;
            z-index: 99999;
            font-weight: 600;
            transition: background-color 0.2s, left 0.2s;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        `;

    // Position initially and on resize - use immediate execution
    positionButton();

    // Multiple event listeners for maximum responsiveness
    window.addEventListener("resize", () => {
      console.log("Window resize detected");
      positionButton();
    });

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

    // Watch for sidebar changes and layout updates with more aggressive observation
    const observer = new MutationObserver((mutations) => {
      console.log("DOM mutation detected:", mutations.length, "mutations");
      positionButton();
    });

    // Observe body and html for any class/style changes
    observer.observe(document.body, {
      attributes: true,
      attributeFilter: ["class", "style"],
      subtree: false,
    });

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

    // Use ResizeObserver on everything that could affect layout
    if (typeof ResizeObserver !== "undefined") {
      const resizeObserver = new ResizeObserver((entries) => {
        console.log("ResizeObserver triggered for", entries.length, "elements");
        positionButton();
      });

      // Observe multiple elements immediately
      resizeObserver.observe(document.body);
      resizeObserver.observe(document.documentElement);

      // Also observe any main containers we can find right now
      const containers = [document.querySelector(".max-w-threadContentWidth"), document.querySelector('[class*="threadContentWidth"]'), document.querySelector("main"), document.querySelector('[role="main"]')].filter(Boolean);

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

    // Also set up a periodic check as ultimate fallback
    setInterval(() => {
      const currentLeft = parseFloat(button.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 button position is significantly off, reposition
        if (Math.abs(currentLeft - expectedX) > 20) {
          console.log("Periodic check: repositioning button from", currentLeft, "to", expectedX);
          positionButton();
        }
      }
    }, 2000); // Check every 2 seconds

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

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

    button.addEventListener("click", async () => {
      // Show loading state
      const originalText = button.textContent;
      button.textContent = "Exporting...";
      button.disabled = true;

      try {
        // Ensure window is focused before starting
        window.focus();

        // Give user a moment to ensure 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 title = document.title.replace(" | Perplexity", "").trim();
        const safeTitle = title
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, " ")
          .replace(/^-+|-+$/g, "");
        const filename = `${safeTitle}.md`;

        const markdown = formatMarkdown(conversation);
        downloadMarkdown(markdown, filename);
      } catch (error) {
        console.error("Export failed:", error);
        alert("Export failed. Please try again.");
      } finally {
        // Restore button state
        button.textContent = originalText;
        button.disabled = false;
      }
    });

    document.body.appendChild(button);
  }

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