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