Greasy Fork

Greasy Fork is available in English.

claude-markdown-exporter

一个用于导出 Claude AI 对话内容的增强版脚本。支持完整的对话导出,包括文本附件和图片附件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         claude-markdown-exporter
// @namespace    https://claude.ai/
// @version      2.12
// @description  一个用于导出 Claude AI 对话内容的增强版脚本。支持完整的对话导出,包括文本附件和图片附件
// @author       sansan
// @match        https://claude.ai/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=claude.ai
// @grant        unsafeWindow
// @run-at       document-start
// @license      GPL-3.0 License
// ==/UserScript==

(function () {
  "use strict";

  function showNotification(textCount, imageCount) {
    if (textCount === 0 && imageCount === 0) return;

    const notification = document.createElement("div");
    notification.classList.add("custom-notification");
    notification.innerHTML = `
        <div class="custom-notification-content">
            <p class="custom-notification-title">附件统计:</p>
            <p class="custom-notification-stats">
                文本附件:${textCount} 个<br>
                图像附件:${imageCount} 个
            </p>
        </div>
    `;

    const notificationStyle = document.createElement("style");
    notificationStyle.textContent = `
        .custom-notification {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: rgba(0, 0, 0, 0.8);
            color: white;
            padding: 20px;
            border-radius: 10px;
            font-family: Arial, sans-serif;
            z-index: 100000;
            max-width: 90%;
            box-sizing: border-box;
        }
        .custom-notification-content {
            margin: 0;
        }
        .custom-notification-title {
            margin: 0;
            font-size: 16px;
        }
        .custom-notification-stats {
            margin: 10px 0;
            font-size: 14px;
        }
    `;

    document.head.appendChild(notificationStyle);
    document.body.appendChild(notification);

    setTimeout(() => {
      document.body.removeChild(notification);
      document.head.removeChild(notificationStyle);
    }, 5000);

    document.addEventListener("dblclick", function removeNotification(e) {
      if (!notification.contains(e.target)) {
        document.body.removeChild(notification);
        document.head.removeChild(notificationStyle);
        document.removeEventListener("click", removeNotification);
      }
    });
  }

  /**
   * 提取对话内容并转换为 Markdown 格式
   * @returns {Promise<string>} 转换后的 Markdown 内容
   */
  async function extractConversation() {
    let markdown = "# Conversation with Claude\n\n";
    const messages = document.querySelectorAll(
      ".font-claude-message, div.font-user-message"
    );
    let totalTextCount = 0;
    let totalImageCount = 0;
    for (const message of messages) {
      const isHuman = message.classList.contains("font-user-message");
      const role = isHuman ? "Human" : "Claude";

      const { attachments, textCount, imageCount } = await getAttachments(
        message
      );

      totalTextCount += textCount;
      totalImageCount += imageCount;
      if (attachments) {
        markdown += `### ${role}'s Attachments:\n\n${attachments}\n\n`;
      }

      markdown += `## ${role}:\n\n`;

      if (isHuman) {
        markdown += processContent(message);
      } else {
        const gridElements = message.querySelectorAll(".grid-cols-1");
        let claudeContent = "";

        gridElements.forEach((element) => {
          claudeContent += processContent(element);
        });

        markdown += claudeContent;
      }
      markdown += "\n";
    }

    showNotification(totalTextCount, totalImageCount);
    return markdown;
  }

  /**
   * 获取消息前的附件内容
   * @param {Element} message - 消息元素
   * @returns {Promise<{attachments: string|null, textCount: number, imageCount: number}>} 附件内容、文本附件数量和图像附件数量
   */
  async function getAttachments(message) {
    let parent = message;
    while (
      parent &&
      (!parent.classList.contains("mb-1") || !parent.classList.contains("mt-1"))
    ) {
      parent = parent.parentElement;
      if (!parent) break;
    }

    if (!parent) {
      return { attachments: null, textCount: 0, imageCount: 0 };
    }

    const childDivs = Array.from(parent.children).filter(
      (child) => child.tagName === "DIV"
    );
    if (childDivs.length < 2) {
      return { attachments: null, textCount: 0, imageCount: 0 };
    }

    const attachmentsDiv = childDivs[0];

    const attachmentDivs = Array.from(attachmentsDiv.children).filter(
      (child) => child.tagName === "DIV"
    );
    if (attachmentDivs.length === 0) {
      return { attachments: null, textCount: 0, imageCount: 0 };
    }

    const attachments = [];
    let textCount = 0;
    let imageCount = 0;

    for (const attachmentDiv of attachmentDivs) {
      const fileElement = attachmentDiv.querySelector(
        '[data-testid="file-thumbnail"]'
      );
      if (fileElement) {
        const textAttachment = await processTextAttachment(fileElement);
        if (textAttachment) {
          textCount++;
          attachments.push(`附件${textCount}(文本):\n\n${textAttachment}`);
        }
      } else {
        const imgElement = attachmentDiv.querySelector("img[src]");
        if (imgElement) {
          const imageAttachment = await processImageAttachment(imgElement);
          if (imageAttachment) {
            imageCount++;
            attachments.push(
              `附件${textCount + imageCount}(图片):\n\n${imageAttachment}`
            );
          }
        }
      }
    }

    return {
      attachments: attachments.length > 0 ? attachments.join("\n\n") : null,
      textCount,
      imageCount,
    };
  }

  async function processTextAttachment(fileElement) {
    fileElement.click();

    await new Promise((resolve) => setTimeout(resolve, 1000));

    const popupContent = document.querySelector(
      "div.overflow-y-auto.whitespace-pre-wrap"
    );

    if (popupContent) {
      const content = `\`\`\`\n${popupContent.textContent.trim()}\n\`\`\``;

      setTimeout(() => {
        const closeButton = document.querySelector(
          "div.flex.items-center > h2.font-styrene-display + button"
        );
        if (closeButton) {
          closeButton.click();
        }
      }, 1000);

      return content;
    }

    return null;
  }

  async function processImageAttachment(imgElement) {
    if (imgElement && imgElement.hasAttribute("src")) {
      const imgSrc = imgElement.getAttribute("src");
      const imgWidth = imgElement.getAttribute("width") || "100";
      const imgHeight = imgElement.getAttribute("height") || "100";

      try {
        const response = await fetch(imgSrc);
        const blob = await response.blob();
        const base64 = await blobToBase64(blob);
        return `![Image](${base64}){width=${imgWidth} height=${imgHeight}}`;
      } catch (error) {
        console.error("Error fetching image:", error);
      }
    }
    return null;
  }

  /**
   * 将 Blob 对象转换为 Base64 编码的字符串
   * @param {Blob} blob - Blob 对象
   * @returns {Promise<string>} Base64 编码的字符串
   */
  function blobToBase64(blob) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result);
      reader.onerror = reject;
      reader.readAsDataURL(blob);
    });
  }

  /**
   * 处理消息内容,将其转换为 Markdown 格式
   * @param {Element} element - 要处理的 HTML 元素
   * @param {number} depth - 当前处理的嵌套深度
   * @returns {string} 转换后的 Markdown 内容
   */
  function processContent(element, depth = 0) {
    let markdown = "";
    const children = element.childNodes;

    for (let child of children) {
      if (child.nodeType === Node.TEXT_NODE) {
        markdown += child.textContent;
      } else if (child.nodeType === Node.ELEMENT_NODE) {
        switch (child.tagName) {
          case "P":
            markdown += processInlineElements(child) + "\n\n";
            break;
          case "OL":
            markdown += processList(child, "ol", depth) + "\n";
            break;
          case "UL":
            markdown += processList(child, "ul", depth) + "\n";
            break;
          case "PRE":
            {
              const codeBlock = child.querySelector(".code-block__code");
              if (codeBlock) {
                const language =
                  codeBlock.className.match(/language-(\w+)/)?.[1] || "";
                markdown +=
                  "```" +
                  language +
                  "\n" +
                  codeBlock.textContent.trim() +
                  "\n```\n\n";
              }
            }
            break;
          default:
            markdown += processInlineElements(child) + "\n\n";
        }
      }
    }

    return markdown;
  }

  /**
   * 处理列表元素,将其转换为 Markdown 格式
   * @param {Element} listElement - 要处理的列表元素
   * @param {string} listType - 列表类型,'ol' 表示有序列表,'ul' 表示无序列表
   * @param {number} depth - 当前处理的嵌套深度
   * @returns {string} 转换后的 Markdown 内容
   */
  function processList(listElement, listType, depth = 0) {
    let markdown = "";
    const items = listElement.children;
    const indent = "  ".repeat(depth);

    for (let i = 0; i < items.length; i++) {
      const item = items[i];
      const prefix = listType === "ol" ? `${i + 1}. ` : "- ";
      markdown += `${indent}${prefix}${processInlineElements(item).trim()}\n`;

      const nestedLists = item.querySelectorAll(":scope > ol, :scope > ul");
      for (let nestedList of nestedLists) {
        markdown += processList(
          nestedList,
          nestedList.tagName.toLowerCase(),
          depth + 1
        );
      }
    }

    return markdown;
  }

  /**
   * 处理内联元素,将其转换为 Markdown 格式
   * @param {Element} element - 要处理的 HTML 元素
   * @returns {string} 转换后的 Markdown 内容
   */
  function processInlineElements(element) {
    let markdown = "";
    const children = element.childNodes;

    for (let child of children) {
      if (child.nodeType === Node.TEXT_NODE) {
        markdown += child.textContent;
      } else if (child.nodeType === Node.ELEMENT_NODE) {
        if (child.tagName === "CODE") {
          markdown += "`" + child.textContent + "`";
        } else if (child.tagName === "OL" || child.tagName === "UL") {
          continue;
        } else {
          markdown += processInlineElements(child);
        }
      }
    }

    return markdown;
  }

  /**
   * 下载 Markdown 文件
   * @param {string} content - 要下载的 Markdown 内容
   * @param {string} filename - 下载文件的名称
   */
  function downloadMarkdown(content, filename) {
    const blob = new Blob([content], { type: "text/markdown" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
  }

  /**
   * 添加下载对话内容的按钮
   */
  function addButton() {
    const button = document.createElement("button");
    updateButtonText(button);
    button.style.position = "fixed";
    button.style.bottom = "10px";
    button.style.right = "10px";
    button.style.zIndex = "1000";
    button.style.padding = "10px 20px";
    button.style.backgroundColor = "#007BFF";
    button.style.color = "#FFF";
    button.style.border = "none";
    button.style.borderRadius = "5px";
    button.style.cursor = "pointer";

    button.onclick = async function () {
      try {
        showNotification(0, 0);
        const conversationMarkdown = await extractConversation();
        const titleElement = document.querySelector(
          ".font-tiempos.truncate.font-normal.tracking-tight"
        );
        const versionElement = document.querySelector(
          ".whitespace-nowrap.tracking-tight"
        );

        let filename = "claude_conversation.md";

        if (titleElement || versionElement) {
          let namePart = "";

          if (typeof unsafeWindow.name !== "undefined") {
            namePart = unsafeWindow.name;
          } else if (titleElement) {
            namePart = titleElement.textContent.trim();
          }

          let versionPart = "";

          if (typeof unsafeWindow.model !== "undefined") {
            versionPart = unsafeWindow.model;
          } else if (versionElement) {
            versionPart = versionElement.textContent.trim();
          }

          let createdAtPart =
            typeof unsafeWindow.createdAt !== "undefined"
              ? `_${unsafeWindow.createdAt}`
              : "";
          let updatedAtPart =
            typeof unsafeWindow.updatedAt !== "undefined"
              ? `_${unsafeWindow.updatedAt}`
              : "";

          filename = `${versionPart}${
            namePart ? `_${namePart}` : ""
          }${createdAtPart}${updatedAtPart}.md`;
        }

        downloadMarkdown(conversationMarkdown, filename);
      } catch (error) {
        console.error("Error in button click handler:", error);
      }
    };

    let prevMessageCount =
      document.querySelectorAll(".font-user-message").length;

    setInterval(() => {
      const currentMessageCount =
        document.querySelectorAll(".font-user-message").length;
      if (currentMessageCount !== prevMessageCount) {
        prevMessageCount = currentMessageCount;
        updateButtonText(button);
      }
    }, 1000);

    document.body.appendChild(button);
  }

  function updateButtonText(button) {
    const messageThreads = document.querySelectorAll(
      "[data-test-render-count]"
    );
    const messageCount = messageThreads.length / 2;
    button.innerText = `${messageCount} Download Conversation`;
  }

  function setupRequestMonitoring() {
    const urlPattern =
      /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}\?tree=True&rendering_mode=messages&render_all_tools=true$/;

    // 创建日期格式化辅助函数
    function formatDateTime(dateString, includeUnderscore = true) {
      const options = {
        timeZone: "Asia/Shanghai",
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
      };

      let formatted = new Date(dateString)
        .toLocaleString("zh-CN", options)
        .replace(/\//g, "-");

      return includeUnderscore ? formatted.replace(",", "_") : formatted;
    }

    const originalFetch = unsafeWindow.fetch;
    unsafeWindow.fetch = async function (...args) {
      const [resource, config] = args;
      if (config && config.method === "GET" && urlPattern.test(resource)) {
        const response = await originalFetch.apply(this, args);
        const responseClone = response.clone();

        responseClone
          .json()
          .then((json) => {
            // 使用辅助函数处理日期
            unsafeWindow.createdAt = formatDateTime(json.created_at);
            unsafeWindow.updatedAt = formatDateTime(json.updated_at);
            unsafeWindow.model = json.model;
            unsafeWindow.name = json.name;

            if (json.chat_messages && Array.isArray(json.chat_messages)) {
              const attemptProcessMessages = (
                retryCount = 0,
                maxRetries = 10
              ) => {
                const messages = document.querySelectorAll(
                  ".font-claude-message, div.font-user-message"
                );

                if (messages.length === json.chat_messages.length) {
                  document
                    .querySelectorAll(".message-time")
                    .forEach((el) => el.remove());

                  messages.forEach((messageElement, index) => {
                    const messageTime = json.chat_messages[index].created_at;
                    if (!messageTime) {
                      console.error("No created_at time for message:", index);
                      return;
                    }

                    // 消息时间不需要下划线替换
                    const messageCreatedAt = formatDateTime(messageTime, false);

                    const timeElement = document.createElement("div");
                    timeElement.className = "message-time";
                    timeElement.textContent = messageCreatedAt;
                    timeElement.style.cssText = `
                                    font-size: 12px;
                                    color: #666;
                                    margin-top: 5px;
                                    text-align: right;
                                    padding-right: 10px;
                                    font-family: Arial, sans-serif;
                                `;

                    const isHuman =
                      messageElement.classList.contains("font-user-message");
                    if (isHuman) {
                      messageElement.insertBefore(
                        timeElement,
                        messageElement.firstChild
                      );
                    } else {
                      messageElement.appendChild(timeElement);
                    }
                  });
                } else if (retryCount < maxRetries) {
                  setTimeout(
                    () => attemptProcessMessages(retryCount + 1, maxRetries),
                    500
                  );
                } else {
                  console.error(
                    "Failed to add timestamps after maximum retries"
                  );
                }
              };

              attemptProcessMessages();
            }
          })
          .catch((error) => {
            console.error("Error processing response:", error);
          });

        return response;
      }

      return originalFetch.apply(this, args);
    };
  }

  setupRequestMonitoring();

  window.addEventListener("load", () => {
    addButton();
  });
})();