Greasy Fork

来自缓存

Greasy Fork is available in English.

Enhanced Claude Chat & Code Exporter 4.2

Export Claude chat conversations with code artifacts into individual files with timestamp prefixes

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Enhanced Claude Chat & Code Exporter 4.2
// @namespace    https://fsfarimani.dev/
// @version      4.2
// @description  Export Claude chat conversations with code artifacts into individual files with timestamp prefixes
// @author       Foad S. Farimani (fsfarimani) <[email protected]>
// @match        https://claude.ai/chat/*
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// @homepageURL  http://greasyfork.icu/en/scripts/534219-enhanced-claude-chat-code-exporter-4-1
// @source       http://greasyfork.icu/en/scripts/534219-enhanced-claude-chat-code-exporter-4-1/code
// ==/UserScript==

(function () {
  "use strict";

  // Add export buttons to the UI
  // Add export buttons to the UI (mounted outside the React tree via Shadow DOM)
  function addExportButtons() {
    // If our host already exists, bail
    if (document.getElementById("claude-export-host")) return;

    // Create a host attached to <body>
    const host = document.createElement("div");
    host.id = "claude-export-host";
    host.style.all = "initial"; // defensive reset in case of inherited styles
    host.style.position = "fixed";
    host.style.zIndex = "2147483647"; // max-ish
    host.style.bottom = "16px";
    host.style.right = "16px";
    host.style.pointerEvents = "auto";
    document.body.appendChild(host);

    // Shadow root for isolation
    const shadow = host.attachShadow({ mode: "open" });

    // Styles scoped to shadow
    const style = document.createElement("style");
    style.textContent = `
      * { box-sizing: border-box; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
      .wrap {
        display: flex;
        gap: 8px;
        background: rgba(17,17,17,0.85);
        border: 1px solid rgba(255,255,255,0.08);
        backdrop-filter: blur(6px);
        padding: 8px;
        border-radius: 10px;
        align-items: center;
        box-shadow: 0 8px 24px rgba(0,0,0,0.25);
      }
      button {
        appearance: none;
        border: 0;
        border-radius: 8px;
        height: 32px;
        padding: 0 10px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 6px;
        color: #fff;
        font-size: 13px;
        font-weight: 600;
        cursor: pointer;
        transition: transform 0.06s ease, opacity 0.2s ease, filter 0.2s ease;
        will-change: transform;
      }
      button:active { transform: translateY(1px) scale(0.98); }
      .btn-all { background: #4a6ee0; }
      .btn-md  { background: #9e6ee0; }
      .icon { display: inline-flex; }
    `;
    shadow.appendChild(style);

    // Container
    const container = document.createElement("div");
    container.className = "wrap";

    // Export All button
    const btnAll = document.createElement("button");
    btnAll.className = "btn-all";
    btnAll.setAttribute("aria-label", "Export All");
    btnAll.innerHTML = `
      <span class="icon" aria-hidden="true">
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256">
          <path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112a8,8,0,0,1,16,0v96H200V112a8,8,0,0,1,16,0ZM80,80a8,8,0,0,1,8-8h32V36a4,4,0,0,1,4-4h8a4,4,0,0,1,4,4V72h32a8,8,0,0,1,5.66,13.66l-40,40a8,8,0,0,1-11.32,0l-40-40A8,8,0,0,1,80,80Z"></path>
        </svg>
      </span>
      <span>Export All</span>
    `;
    btnAll.addEventListener("click", exportConversation);

    // Markdown-only button
    const btnMd = document.createElement("button");
    btnMd.className = "btn-md";
    btnMd.setAttribute("aria-label", "Export Markdown Only");
    btnMd.innerHTML = `
      <span class="icon" aria-hidden="true">
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256">
          <path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112a8,8,0,0,1,16,0v96H200V112a8,8,0,0,1,16,0ZM80,80a8,8,0,0,1,8-8h32V36a4,4,0,0,1,4-4h8a4,4,0,0,1,4,4V72h32a8,8,0,0,1,5.66,13.66l-40,40a8,8,0,0,1-11.32,0l-40-40A8,8,0,0,1,80,80Z"></path>
        </svg>
      </span>
      <span>Md Only</span>
    `;
    btnMd.addEventListener("click", exportMarkdownOnly);

    // Mount
    container.appendChild(btnAll);
    container.appendChild(btnMd);
    shadow.appendChild(container);
  }

  // Helper function to download a file
  function downloadFile(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();

    // Cleanup
    setTimeout(() => {
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }, 100);
  }

  // Generate a timestamp in the format yyyyMMddHHmmss
  function generateTimestamp() {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, "0");
    const day = String(now.getDate()).padStart(2, "0");
    const hours = String(now.getHours()).padStart(2, "0");
    const minutes = String(now.getMinutes()).padStart(2, "0");
    const seconds = String(now.getSeconds()).padStart(2, "0");

    return `${year}${month}${day}${hours}${minutes}${seconds}`;
  }

  // Function to export only the markdown content
  async function exportMarkdownOnly() {
    try {
      logDebug("Starting markdown-only export process...");

      // Show loading indicator
      showLoadingIndicator("Extracting conversation...");

      // Generate timestamp prefix for this export
      const timestampPrefix = generateTimestamp();
      logDebug(`Generated timestamp prefix: ${timestampPrefix}`);

      // Get the chat title
      const chatTitle = getChatTitle();
      const safeChatTitle = sanitizeFileName(
        chatTitle || "Claude Conversation"
      );
      logDebug(`Chat title: ${chatTitle} (sanitized: ${safeChatTitle})`);

      // Extract the conversation as markdown
      logDebug("Extracting conversation as markdown");
      const markdown = extractConversationMarkdown();

      // Download the markdown file with timestamp prefix
      const markdownFilename = `${timestampPrefix}_${safeChatTitle}.md`;
      const markdownBlob = new Blob([markdown], { type: "text/markdown" });
      downloadFile(markdownBlob, markdownFilename);
      logDebug(`Downloaded markdown file: ${markdownFilename}`);

      // Show success message
      hideLoadingIndicator();
      showNotification(
        `Exported Claude conversation as markdown successfully!`,
        "success"
      );
    } catch (error) {
      logDebug(`Error in exportMarkdownOnly: ${error.message}`);
      console.error("Error exporting markdown:", error);
      hideLoadingIndicator();
      showNotification(
        "Error exporting markdown. Check console for details.",
        "error"
      );
    }
  }

  // Main function to export the conversation with artifacts
  async function exportConversation() {
    try {
      logDebug("Starting export process...");

      // Initialize a variable to store clipboard content for artifact extraction
      let savedClipboardContent = "";

      // Try to save current clipboard content so we can restore it later
      try {
        savedClipboardContent = await navigator.clipboard.readText();
        logDebug("Saved original clipboard content");
      } catch (error) {
        logDebug("Could not read original clipboard content: " + error.message);
      }

      // Show loading indicator
      showLoadingIndicator("Processing chat and artifacts...");

      // Generate timestamp prefix for this export session
      const timestampPrefix = generateTimestamp();
      logDebug(`Generated timestamp prefix: ${timestampPrefix}`);

      // Get the chat title
      const chatTitle = getChatTitle();
      const safeChatTitle = sanitizeFileName(
        chatTitle || "Claude Conversation"
      );
      logDebug(`Chat title: ${chatTitle} (sanitized: ${safeChatTitle})`);

      // Extract the conversation as markdown
      logDebug("Extracting conversation as markdown");
      const markdown = extractConversationMarkdown();

      // Download the markdown file with timestamp prefix
      const markdownFilename = `${timestampPrefix}_00_${safeChatTitle}.md`;
      const markdownBlob = new Blob([markdown], { type: "text/markdown" });
      downloadFile(markdownBlob, markdownFilename);
      logDebug(`Downloaded markdown file: ${markdownFilename}`);

      // Small delay before processing artifacts
      await new Promise((resolve) => setTimeout(resolve, 300));

      // Find all artifact containers in the DOM
      const artifactButtons = document.querySelectorAll(
        'button[aria-label="Preview contents"]'
      );
      logDebug(`Found ${artifactButtons.length} artifact buttons`);

      // Process all artifacts sequentially
      showLoadingIndicator(
        `Found ${artifactButtons.length} artifacts, processing...`
      );

      for (let i = 0; i < artifactButtons.length; i++) {
        const artifactButton = artifactButtons[i];

        try {
          // Update loading indicator with progress
          showLoadingIndicator(
            `Processing artifact ${i + 1} of ${artifactButtons.length}...`
          );

          // First, extract metadata without opening the artifact
          const initialArtifact = extractArtifactMetadataFromPreview(
            artifactButton,
            i
          );

          if (!initialArtifact) {
            logDebug(`Failed to extract metadata for artifact ${i + 1}`);
            continue;
          }

          // Click the artifact button to open the code panel
          logDebug(`Clicking artifact button ${i + 1} to open code panel`);
          artifactButton.click();

          // Wait for the code panel to load
          await new Promise((resolve) => setTimeout(resolve, 1000));

          // Now extract the full content using keyboard shortcut method
          const fullArtifact = await extractArtifactUsingKeyboardCopy(
            initialArtifact
          );

          if (fullArtifact && fullArtifact.content) {
            const artifactNumber = String(i + 1).padStart(2, "0");
            const fileName = `${timestampPrefix}_${artifactNumber}_${sanitizeFileName(
              fullArtifact.title
            )}${getFileExtension(fullArtifact.language)}`;

            // Download the artifact
            const blob = new Blob([fullArtifact.content], {
              type: "text/plain",
            });
            downloadFile(blob, fileName);

            logDebug(
              `Downloaded artifact ${i + 1}: ${fileName} (${
                fullArtifact.content.length
              } chars)`
            );

            // Close the code panel by clicking outside or on close button
            const closeButton = document.querySelector(
              'button svg[width="18"][height="18"] path[d*="205.66,194.34"]'
            );
            if (
              closeButton &&
              closeButton.parentElement &&
              closeButton.parentElement.parentElement
            ) {
              closeButton.parentElement.parentElement.click();
            } else {
              // If can't find the close button, try clicking elsewhere
              const header = document.querySelector("header");
              if (header) header.click();
            }

            // Small delay between artifacts to prevent browser throttling
            await new Promise((resolve) => setTimeout(resolve, 800));
          } else {
            logDebug(`Failed to extract content for artifact ${i + 1}`);
          }
        } catch (error) {
          logDebug(`Error processing artifact ${i + 1}: ${error.message}`);
          console.error(`Error processing artifact ${i + 1}:`, error);

          // Try to close any open panels before continuing
          const closeButton = document.querySelector(
            'button svg[width="18"][height="18"] path[d*="205.66,194.34"]'
          );
          if (
            closeButton &&
            closeButton.parentElement &&
            closeButton.parentElement.parentElement
          ) {
            closeButton.parentElement.parentElement.click();
          }
        }
      }

      // Try to restore original clipboard content
      if (savedClipboardContent) {
        try {
          await navigator.clipboard.writeText(savedClipboardContent);
          logDebug("Restored original clipboard content");
        } catch (error) {
          logDebug("Could not restore clipboard: " + error.message);
        }
      }

      // Show success message
      hideLoadingIndicator();
      showNotification(
        `Exported Claude conversation and ${artifactButtons.length} artifacts successfully!`,
        "success"
      );
    } catch (error) {
      logDebug(`Error in exportConversation: ${error.message}`);
      console.error("Error exporting conversation:", error);
      hideLoadingIndicator();
      showNotification(
        "Error exporting conversation. Check console for details.",
        "error"
      );
    }
  }

  // Extract only metadata from an artifact preview without opening it
  function extractArtifactMetadataFromPreview(button, index) {
    try {
      // Extract metadata from the preview
      const titleElement = button.querySelector(".leading-tight.text-sm");
      const typeElement = button.querySelector(".text-sm.text-text-300");

      let title = `artifact_${index + 1}`;
      let type = "Code";

      if (titleElement) {
        title = titleElement.textContent.trim();
      }

      if (typeElement) {
        type = typeElement.textContent.trim();
      }

      // Return metadata without content
      return {
        title: title,
        type: type,
        language: determineLanguage(type, title, ""),
        content: null, // We'll get the content later
      };
    } catch (err) {
      logDebug(`Error in extractArtifactMetadataFromPreview: ${err.message}`);
      console.error("Error extracting artifact metadata from preview:", err);
      return null;
    }
  }

  // Extract artifact content by reading text directly (no clipboard dependency)
  async function extractArtifactUsingKeyboardCopy(artifactMetadata) {
    try {
      // Be liberal about code containers/selectors
      const codeBlock =
        document.querySelector(".code-block__code") ||
        document.querySelector('[data-testid="code-block"] code') ||
        document.querySelector("pre code") ||
        document.querySelector("pre") ||
        document.querySelector("code");

      if (!codeBlock) {
        logDebug("No code block found in panel");
        return null;
      }

      // Determine language from classnames or data attributes
      let language = artifactMetadata.language || "plaintext";
      const langGuessers = [
        (el) => (el.getAttribute && el.getAttribute("data-language")) || null,
        (el) => {
          const cls = (el.className || "").toString();
          const m = cls.match(/\blanguage-([\w+-]+)\b/i);
          return m ? m[1] : null;
        },
        (el) => {
          // Some UIs put language on the parent container
          const parent = el.parentElement;
          if (!parent) return null;
          const cls = (parent.className || "").toString();
          const m = cls.match(/\blanguage-([\w+-]+)\b/i);
          return m ? m[1] : null;
        },
      ];
      for (const g of langGuessers) {
        const v = g(codeBlock);
        if (v) {
          language = v;
          break;
        }
      }

      // Extract plain text reliably; innerText preserves visual line breaks
      let extractedText = (
        codeBlock.innerText ||
        codeBlock.textContent ||
        ""
      ).replace(/\r\n/g, "\n");

      // If extraction is unexpectedly empty, try a broader read
      if (!extractedText.trim()) {
        const fallback =
          document.querySelector("pre") || document.querySelector("code");
        if (fallback && fallback !== codeBlock) {
          extractedText = (
            fallback.innerText ||
            fallback.textContent ||
            ""
          ).replace(/\r\n/g, "\n");
        }
      }

      // Final safety net
      if (!extractedText.trim()) {
        extractedText = "// Unable to extract code from the artifact panel";
      }

      return {
        title: artifactMetadata.title,
        type: artifactMetadata.type,
        language: language,
        content: extractedText,
      };
    } catch (err) {
      logDebug(`Error in extractArtifactUsingKeyboardCopy: ${err.message}`);
      console.error("Error extracting artifact without clipboard:", err);

      // Last resort: try reading any visible pre/code text
      const anyCode = document.querySelector("pre, code");
      const txt = anyCode ? anyCode.innerText || anyCode.textContent || "" : "";
      return {
        title: artifactMetadata.title,
        type: artifactMetadata.type,
        language: artifactMetadata.language || "plaintext",
        content: txt || "// Error extracting content",
      };
    }
  }

  // Extract all text from an element including all child nodes, maintaining line breaks
  function extractAllTextFromElement(element) {
    if (!element) return "";

    let text = "";
    const childNodes = element.childNodes;

    for (let i = 0; i < childNodes.length; i++) {
      const node = childNodes[i];

      if (node.nodeType === Node.TEXT_NODE) {
        text += node.textContent;
      } else if (node.nodeType === Node.ELEMENT_NODE) {
        // Process element nodes
        if (
          node.tagName === "BR" ||
          node.tagName === "DIV" ||
          node.tagName === "P"
        ) {
          text += "\n"; // Add newline for line break elements
        }

        // Recursively process child elements
        text += extractAllTextFromElement(node);

        // Add newline after certain block elements
        if (
          node.tagName === "DIV" ||
          node.tagName === "P" ||
          node.tagName === "LI" ||
          node.tagName === "TR"
        ) {
          text += "\n";
        }
      }
    }

    return text;
  }

  // Extract the conversation as markdown (more tolerant selectors)
  function extractConversationMarkdown() {
    let markdown = "";

    // Title (from robust getter)
    const chatTitle = getChatTitle();
    if (chatTitle) {
      markdown += `# ${chatTitle}\n\n`;
    }

    // Export timestamp
    const now = new Date();
    markdown += `*Exported on: ${now.toLocaleString()}*\n\n`;

    // Try to find a message list container (best effort)
    const root =
      document.querySelector('[data-testid="chat"]') ||
      document.querySelector('[role="main"]') ||
      document.body;

    // Collect likely message nodes
    const messageNodes = root.querySelectorAll(
      [
        '[data-testid="chat-message"]',
        '[data-testid="message"]',
        "[data-message]",
        'article[role="article"]',
        'div[role="listitem"]',
        // fallback for generic message groups (Claude UI often nests blocks)
        ".group, .prose, .markdown",
      ].join(", ")
    );

    // Helper to convert a block to basic markdown
    const toMarkdown = (el) => {
      if (!el) return "";
      // Prefer innerText to keep line breaks; trim trailing spaces
      return (el.innerText || el.textContent || "")
        .replace(/\r\n/g, "\n")
        .replace(/\n{3,}/g, "\n\n")
        .trim();
    };

    // Identify role (user/assistant) per node
    messageNodes.forEach((node) => {
      // Heuristics/markers seen across Claude revisions
      const isUser =
        node.querySelector('[data-testid="user-message"]') ||
        node.getAttribute("data-message") === "user" ||
        node.querySelector('[data-role="user"]') ||
        node.querySelector('[aria-label="User"]');

      const isAssistant =
        node.querySelector(".font-claude-message") ||
        node.querySelector('[data-testid="assistant-message"]') ||
        node.getAttribute("data-message") === "assistant" ||
        node.querySelector('[data-role="assistant"]') ||
        node.querySelector('[aria-label="Claude"]');

      // Skip containers that are clearly empty
      const contentText = toMarkdown(node);
      if (!contentText) return;

      if (isUser) {
        markdown += `## User\n\n${contentText}\n\n`;
        return;
      }
      if (isAssistant) {
        markdown += `## Claude\n\n${contentText}\n\n`;

        // Attach artifact references if present
        const artifactButtons = node.querySelectorAll(
          'button[aria-label="Preview contents"]'
        );
        artifactButtons.forEach((button, index) => {
          const titleElement = button.querySelector(".leading-tight.text-sm");
          const typeElement = button.querySelector(".text-sm.text-text-300");
          const title = titleElement
            ? titleElement.textContent.trim()
            : `artifact_${index + 1}`;
          const type = typeElement ? typeElement.textContent.trim() : "Code";
          const artifactNumber = String(index + 1).padStart(2, "0");

          markdown += `\n**Code Artifact:** \`${artifactNumber}_${title}\` (${type})\n`;
          markdown += `*See separate file with corresponding timestamp prefix*\n\n`;
        });

        return;
      }

      // If we cannot confidently tell, include as a generic assistant message,
      // because most content blocks belong to assistant in Claude’s UI.
      markdown += `## Claude\n\n${contentText}\n\n`;
    });

    return markdown;
  }

  // Determine the language of a code artifact based on context clues
  function determineLanguage(type, title, content) {
    // If it's not code, return as document
    if (type.toLowerCase() !== "code") {
      return "markdown";
    }

    // Check title for language hints
    const titleLower = title.toLowerCase();
    if (titleLower.includes("java")) return "java";
    if (titleLower.includes("python") || titleLower.includes(".py"))
      return "python";
    if (titleLower.includes("javascript") || titleLower.includes("js"))
      return "javascript";
    if (titleLower.includes("html")) return "html";
    if (titleLower.includes("css")) return "css";
    if (
      titleLower.includes("bash") ||
      titleLower.includes("shell") ||
      titleLower.includes(".sh")
    )
      return "bash";
    if (titleLower.includes("powershell") || titleLower.includes(".ps1"))
      return "powershell";
    if (titleLower.includes("sql")) return "sql";
    if (titleLower.includes("c#")) return "csharp";
    if (titleLower.includes("c++")) return "cpp";
    if (titleLower.includes("go")) return "go";
    if (titleLower.includes("rust")) return "rust";

    // Check content for language clues if content is provided
    if (content) {
      if (content.includes("public class") || content.includes("import java."))
        return "java";
      if (content.includes("def ") && content.includes(":")) return "python";
      if (content.includes("function") && content.includes("{"))
        return "javascript";
      if (content.includes("<html") || content.includes("<!DOCTYPE html"))
        return "html";
      if (content.includes("#!/bin/bash")) return "bash";
      if (content.includes("#!/bin/sh")) return "bash";
      if (content.includes("#!powershell")) return "powershell";
    }

    // Default to plaintext if we can't determine
    return "plaintext";
  }

  // Get the appropriate file extension for a language
  function getFileExtension(language) {
    const extensions = {
      java: ".java",
      python: ".py",
      javascript: ".js",
      html: ".html",
      css: ".css",
      bash: ".sh",
      powershell: ".ps1",
      sql: ".sql",
      csharp: ".cs",
      cpp: ".cpp",
      go: ".go",
      rust: ".rs",
      markdown: ".md",
      plaintext: ".txt",
    };

    return extensions[language.toLowerCase()] || ".txt";
  }

  // Get the chat title (robust + derives from first user message if generic)
  function getChatTitle() {
    // 1) Try common title locations
    const selectors = [
      '[data-testid="conversation-title"]',
      '[data-testid="chat-title"]',
      "header h1",
      'h1[role="heading"]',
      "h1.truncate",
      "h1",
    ];
    for (const sel of selectors) {
      const el = document.querySelector(sel);
      if (el && el.textContent && el.textContent.trim()) {
        const t = el.textContent.trim();
        if (!isGenericTitle(t)) return t;
      }
    }

    // 2) Fallback: document.title (strip common decorations)
    const dt = (document.title || "")
      .trim()
      .replace(/^\s*Claude\s*[–-]\s*/i, "")
      .replace(/\s*[–-]\s*Claude\s*$/i, "")
      .trim();
    if (dt && !isGenericTitle(dt)) return dt;

    // 3) Last-resort: derive from the first user message snippet
    const root =
      document.querySelector('[data-testid="chat"]') ||
      document.querySelector('[role="main"]') ||
      document.body;

    // Prefer explicit user markers; fall back to plausible user blocks
    const userCandidates = root.querySelectorAll(
      [
        '[data-testid="user-message"]',
        '[data-role="user"]',
        '[aria-label="User"]',
        '[data-message="user"]',
        // plausible fallbacks (Claude often renders user text in simple blocks)
        'article[role="article"]',
        'div[role="listitem"]',
        ".prose, .markdown",
      ].join(", ")
    );

    for (const node of userCandidates) {
      const txt = (node.innerText || node.textContent || "")
        .replace(/\s+/g, " ")
        .trim();
      if (txt && !isLikelyNonContent(txt)) {
        const snippet = txt.slice(0, 60);
        const clean = snippet.replace(/[\\/:*?"<>|]+/g, " ").trim();
        if (clean) return clean;
      }
    }

    // If all else fails, let caller use its own fallback
    return null;

    // Helpers
    function isGenericTitle(s) {
      // Titles like "Claude" or empty are not helpful
      const v = (s || "").trim().toLowerCase();
      return !v || v === "claude" || v === "chat" || v === "conversation";
    }
    function isLikelyNonContent(s) {
      // Ignore very short or purely decorative strings
      return s.length < 4;
    }
  }

  // Sanitize a string to be used as a filename
  function sanitizeFileName(name) {
    return name
      .replace(/[\\/:*?"<>|]/g, "_") // Replace invalid filename chars
      .replace(/\s+/g, "_") // Replace spaces with underscores
      .replace(/__+/g, "_") // Replace multiple underscores with a single one
      .replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
      .slice(0, 100); // Limit length to 100 chars
  }

  // Log debug information to console with prefix
  function logDebug(message) {
    console.log(`[Claude Exporter] ${message}`);
  }

  // Show loading indicator with message
  function showLoadingIndicator(message) {
    // Remove existing indicator if any
    hideLoadingIndicator();

    const indicator = document.createElement("div");
    indicator.id = "claude-export-loading";
    indicator.style.position = "fixed";
    indicator.style.top = "50%";
    indicator.style.left = "50%";
    indicator.style.transform = "translate(-50%, -50%)";
    indicator.style.padding = "20px";
    indicator.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
    indicator.style.color = "white";
    indicator.style.borderRadius = "8px";
    indicator.style.zIndex = "10000";
    indicator.style.fontSize = "16px";
    indicator.style.fontFamily = "system-ui, -apple-system, sans-serif";

    // Add a spinner and message for better visual feedback
    indicator.innerHTML = `
            <div style="display: flex; align-items: center; gap: 10px;">
                <div class="spinner" style="border: 3px solid rgba(255,255,255,.3); border-radius: 50%; border-top: 3px solid white; width: 20px; height: 20px; animation: spin 1s linear infinite;"></div>
                <div>${message || "Processing..."}</div>
            </div>
        `;

    // Add animation style
    const style = document.createElement("style");
    style.id = "claude-export-style";
    style.textContent = `
            @keyframes spin {
                0% { transform: rotate(0deg); }
                100% { transform: rotate(360deg); }
            }
        `;

    if (!document.getElementById("claude-export-style")) {
      document.head.appendChild(style);
    }

    document.body.appendChild(indicator);
  }

  // Hide loading indicator
  function hideLoadingIndicator() {
    const indicator = document.getElementById("claude-export-loading");
    if (indicator) {
      document.body.removeChild(indicator);
    }
  }

  // Show a notification
  function showNotification(message, type = "info") {
    // Remove any existing notification
    const existingNotification = document.getElementById(
      "claude-export-notification"
    );
    if (existingNotification) {
      document.body.removeChild(existingNotification);
    }

    const notification = document.createElement("div");
    notification.id = "claude-export-notification";
    notification.style.position = "fixed";
    notification.style.bottom = "20px";
    notification.style.left = "50%";
    notification.style.transform = "translateX(-50%)";
    notification.style.padding = "10px 20px";
    notification.style.borderRadius = "4px";
    notification.style.zIndex = "10000";
    notification.style.fontSize = "14px";
    notification.style.fontFamily = "system-ui, -apple-system, sans-serif";
    notification.style.textAlign = "center";
    notification.style.maxWidth = "80%";
    notification.style.boxShadow = "0 2px 10px rgba(0, 0, 0, 0.2)";

    if (type === "error") {
      notification.style.backgroundColor = "#f44336";
      notification.style.color = "white";
    } else if (type === "success") {
      notification.style.backgroundColor = "#4CAF50";
      notification.style.color = "white";
    } else {
      notification.style.backgroundColor = "#2196F3";
      notification.style.color = "white";
    }

    notification.textContent = message;

    document.body.appendChild(notification);

    // Remove after 5 seconds
    setTimeout(() => {
      if (document.getElementById("claude-export-notification")) {
        document.body.removeChild(notification);
      }
    }, 5000);
  }

  // Initialize the script
  function init() {
    logDebug("Initializing Enhanced Claude Exporter 4.1");

    // Add export buttons when the page loads
    addExportButtons();

    // Create a MutationObserver to watch for DOM changes
    const observer = new MutationObserver(() => {
      // Check if we need to add the export buttons after DOM changes
      addExportButtons();
    });

    // Start observing the document body for changes
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    // Also register menu commands
    GM_registerMenuCommand(
      "Export Claude Conversation with Artifacts",
      exportConversation
    );
    GM_registerMenuCommand(
      "Export Claude Conversation as Markdown Only",
      exportMarkdownOnly
    );

    logDebug("Initialization complete");
  }

  // Run the initialization
  init();
})();