Export ChatGPT conversations to Markdown, HTML, or text, with best-effort Deep Research capture and manual paste fallback.
// ==UserScript==
// @name ChatGPT Conversation Export
// @namespace https://github.com/Crimsab
// @version 0.1.6
// @description Export ChatGPT conversations to Markdown, HTML, or text, with best-effort Deep Research capture and manual paste fallback.
// @author Crimsab
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @match https://connector_openai_deep_research.web-sandbox.oaiusercontent.com/*
// @match https://*.web-sandbox.oaiusercontent.com/*
// @include about:blank
// @include about:srcdoc
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const buttonId = "personal-suite-chatgpt-export";
const menuId = "personal-suite-chatgpt-export-menu";
const styleId = "personal-suite-chatgpt-export-style";
const manualDialogId = "personal-suite-chatgpt-export-manual-deep-research";
const messageTypes = {
request: "personal-suite:chatgpt-export:deep-research-request",
snapshot: "personal-suite:chatgpt-export:deep-research-snapshot"
};
const deepResearchTimeoutMs = 900;
const deepResearchSnapshotCache = new Map();
let chatGptObserver;
let remountTimer;
const exportFormats = [
{ format: "markdown", label: "Markdown (.md)", extension: "md", mime: "text/markdown;charset=utf-8" },
{ format: "html", label: "HTML (.html)", extension: "html", mime: "text/html;charset=utf-8" },
{ format: "text", label: "Text (.txt)", extension: "txt", mime: "text/plain;charset=utf-8" }
];
window.addEventListener("message", handleUserscriptMessage);
if (isChatGptHost(location.hostname) && window.top === window) {
mountChatGptExport();
startChatGptObserver();
} else {
startDeepResearchFrameBridge();
}
function mountChatGptExport() {
if (!isChatGptHost(location.hostname)) return;
injectStyles();
const container = findHeaderActions();
if (!container) {
scheduleChatGptExportRemount();
return;
}
const existing = document.getElementById(buttonId);
const button = existing || createExportButton();
if (!document.contains(button)) {
container.insertBefore(button, container.firstElementChild);
}
}
function createExportButton() {
const button = document.createElement("button");
button.id = buttonId;
button.type = "button";
button.className = "no-draggable";
button.setAttribute("aria-label", "Choose chat export format");
button.title = "Export chat";
button.innerHTML = [
'<span class="ps-chatgpt-export-icon" aria-hidden="true">',
'<svg viewBox="0 0 20 20" focusable="false">',
'<path d="M10 2.5a.75.75 0 0 1 .75.75v8.2l2.72-2.72a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 1 1 1.06-1.06l2.72 2.72v-8.2A.75.75 0 0 1 10 2.5Z"></path>',
'<path d="M4.75 14.75a.75.75 0 0 1 .75.75v.75h9v-.75a.75.75 0 0 1 1.5 0V17a.75.75 0 0 1-.75.75H4.75A.75.75 0 0 1 4 17v-1.5a.75.75 0 0 1 .75-.75Z"></path>',
"</svg>",
"</span>",
'<span class="ps-chatgpt-export-label">Export</span>',
'<span class="ps-chatgpt-export-chevron" aria-hidden="true">',
'<svg viewBox="0 0 20 20" focusable="false"><path d="M5.72 7.47a.75.75 0 0 1 1.06 0L10 10.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-3.75 3.75a.75.75 0 0 1-1.06 0L5.72 8.53a.75.75 0 0 1 0-1.06Z"></path></svg>',
"</span>"
].join("");
button.addEventListener("click", () => openExportMenu(button));
return button;
}
function openExportMenu(anchor) {
const existing = document.getElementById(menuId);
if (existing) {
existing.remove();
return;
}
const menu = document.createElement("div");
menu.id = menuId;
menu.setAttribute("role", "menu");
menu.innerHTML = exportFormats.map((item) => [
`<button type="button" role="menuitem" data-format="${item.format}">`,
`<span>${item.label}</span>`,
`<small>${item.extension.toUpperCase()}</small>`,
"</button>"
].join("")).join("");
menu.querySelectorAll("button[data-format]").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
const format = button.dataset.format || "markdown";
menu.remove();
exportCurrentConversation(anchor, format).catch((error) => {
console.error("[ChatGPT Conversation Export] export failed", error);
});
});
});
document.body.append(menu);
positionExportMenu(menu, anchor);
let outsidePointerDown;
const close = () => {
if (outsidePointerDown) document.removeEventListener("pointerdown", outsidePointerDown, true);
menu.remove();
};
window.addEventListener("resize", close, { once: true });
window.addEventListener("scroll", close, { once: true, capture: true });
window.setTimeout(() => {
outsidePointerDown = (event) => {
if (event.target instanceof Node && (menu.contains(event.target) || anchor.contains(event.target))) return;
close();
};
document.addEventListener("pointerdown", outsidePointerDown, true);
});
}
function positionExportMenu(menu, anchor) {
const rect = anchor.getBoundingClientRect();
const width = 186;
const margin = 8;
const left = Math.min(Math.max(margin, rect.left), window.innerWidth - width - margin);
const top = Math.min(rect.bottom + 8, window.innerHeight - margin);
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
}
async function exportCurrentConversation(button, format) {
const originalLabel = button.querySelector(".ps-chatgpt-export-label")?.textContent || "Export";
setButtonState(button, "Exporting...");
try {
const exportFile = renderExport(await collectExportData({}), format);
downloadBlob(exportFile.content, exportFile.filename, exportFile.mime);
setButtonState(button, "Exported");
window.setTimeout(() => setButtonState(button, originalLabel), 1500);
} catch (error) {
setButtonState(button, "Error");
console.error("[ChatGPT Conversation Export] failed", error);
window.setTimeout(() => setButtonState(button, originalLabel), 2200);
}
}
function setButtonState(button, label) {
const labelNode = button.querySelector(".ps-chatgpt-export-label");
if (labelNode) labelNode.textContent = label;
}
async function collectExportData(options = {}) {
const title = cleanTitle(document.title) || "Conversation with ChatGPT";
const date = formatLocalDate();
const turns = await collectTurns(options);
const deepResearchCount = document.querySelectorAll("iframe").length
? findDeepResearchIframes(document).length
: 0;
return { title, date, url: location.href, deepResearchCount, turns };
}
async function collectTurns(options = {}) {
const sections = Array.from(document.querySelectorAll("section[data-turn]"));
if (!sections.length) return collectLegacyMessageTurns();
const turns = [];
for (const section of sections) {
const role = section.dataset.turn
|| section.querySelector("[data-message-author-role]")?.getAttribute("data-message-author-role")
|| "assistant";
const chunks = [];
const content = findTurnContent(section, role);
if (content) chunks.push(elementToMarkdown(content));
const deepResearch = await collectDeepResearch(section, options);
chunks.push(...deepResearch);
const markdown = chunks.map((chunk) => chunk.trim()).filter(Boolean).join("\n\n");
if (!markdown) continue;
turns.push({ role, sender: senderForRole(role), markdown });
}
return turns;
}
function collectLegacyMessageTurns() {
return Array.from(document.querySelectorAll("[data-message-author-role]")).map((node) => {
const role = node.getAttribute("data-message-author-role") || "assistant";
const content = findMessageContent(node, role);
return {
role,
sender: senderForRole(role),
markdown: content ? elementToMarkdown(content) : ""
};
}).filter((turn) => turn.markdown.trim());
}
function findTurnContent(section, role) {
if (role === "user") {
return section.querySelector("[data-testid='collapsible-user-message-content']")
|| section.querySelector("[data-message-author-role='user'] .whitespace-pre-wrap")
|| section.querySelector("[data-message-author-role='user'] .user-message-bubble-color")
|| section.querySelector("[data-message-author-role='user']");
}
const message = section.querySelector("[data-message-author-role='assistant']");
if (!message) return null;
return findMessageContent(message, role);
}
function findMessageContent(message, role) {
if (role === "assistant") {
return message.querySelector(".markdown")
|| message.querySelector("[data-message-content]")
|| message;
}
return message.querySelector("[data-testid='collapsible-user-message-content']")
|| message.querySelector(".whitespace-pre-wrap")
|| message.querySelector("[data-message-content]")
|| message;
}
async function collectDeepResearch(root, options = {}) {
const frames = findDeepResearchIframes(root);
if (!frames.length) return [];
const requestId = `ps-chatgpt-export-${Date.now()}-${Math.random().toString(36).slice(2)}`;
for (const frame of frames) {
try {
frame.contentWindow?.postMessage({ type: messageTypes.request, requestId }, "*");
} catch {
// Cross-origin frame messaging can fail if the frame is still navigating.
}
}
await delay(deepResearchTimeoutMs);
const snapshots = Array.from(deepResearchSnapshotCache.values())
.filter((entry) => Date.now() - entry.receivedAt < 60_000)
.map((entry) => entry.snapshot)
.filter((snapshot) => snapshot && snapshot.markdown && snapshot.markdown.trim());
const best = pickBestSnapshot(snapshots);
if (best) return [renderDeepResearch(best, frames[0], 1)];
if (!options.skipManualDeepResearchPrompt) {
const pasted = await requestDeepResearchPaste();
const pastedSnapshot = buildManualDeepResearchSnapshot(pasted);
options.skipManualDeepResearchPrompt = true;
if (pastedSnapshot) return [renderDeepResearch(pastedSnapshot, frames[0], 1)];
}
return frames.map((frame, index) => renderDeepResearch(null, frame, index + 1));
}
function buildManualDeepResearchSnapshot(value) {
const markdown = normalizeMarkdown(String(value || "")
.replace(/\r\n/g, "\n")
.replace(/\u00a0/g, " ")
.trim());
if (!markdown) return null;
return {
title: "Deep Research (pasted content)",
url: "",
capturedAt: new Date().toISOString(),
markdown,
text: markdownToText(markdown)
};
}
function requestDeepResearchPaste() {
const existing = document.getElementById(manualDialogId);
if (existing) existing.remove();
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.id = manualDialogId;
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "false");
overlay.innerHTML = `
<div class="ps-chatgpt-export-dialog-card">
<h2>Paste Deep Research content</h2>
<p>
Deep Research is inside a sandboxed frame that a userscript cannot always read.
This panel does not block the page: use <strong>Copy contents</strong> in the Deep Research panel, paste it here, then continue the export.
</p>
<textarea spellcheck="false" placeholder="Paste the copied Deep Research report here"></textarea>
<div class="ps-chatgpt-export-dialog-actions">
<button type="button" data-action="skip">Skip Deep Research</button>
<button type="button" data-action="continue">Continue export</button>
</div>
</div>
`;
const finish = (value) => {
overlay.remove();
resolve(value);
};
overlay.querySelector("[data-action='skip']").addEventListener("click", () => finish(""));
overlay.querySelector("[data-action='continue']").addEventListener("click", () => {
finish(overlay.querySelector("textarea").value);
});
overlay.addEventListener("keydown", (event) => {
if (event.key === "Escape") finish("");
});
document.body.append(overlay);
overlay.querySelector("textarea").focus();
});
}
function findDeepResearchIframes(root) {
return Array.from(root.querySelectorAll("iframe")).filter((frame) => {
const src = frame.src || frame.getAttribute("src") || "";
const title = frame.title || frame.getAttribute("title") || "";
return src.includes("deep_research")
|| src.includes("deep-research")
|| src.includes("web-sandbox.oaiusercontent.com")
|| title.includes("deep-research")
|| title.includes("Deep Research");
});
}
function renderDeepResearch(snapshot, frame, index) {
const title = snapshot?.title?.trim() || `Deep Research ${index}`;
if (snapshot?.markdown?.trim()) {
const body = snapshot.markdown.trim();
const alreadyTitled = new RegExp(`^#{1,6}\\s+${escapeRegExp(title)}(?:\\n|$)`).test(body);
const lines = alreadyTitled ? [body] : [`#### ${title}`, "", body];
if (snapshot.url && !isTechnicalFrameUrl(snapshot.url)) lines.push("", `Source frame: ${snapshot.url}`);
return lines.join("\n");
}
const src = frame?.src || frame?.getAttribute?.("src") || "";
const lines = [
`#### Deep Research ${index}`,
"",
"> Deep Research is embedded in a sandboxed iframe. The userscript detected it, but the iframe content was not readable from this userscript run.",
"> If ChatGPT shows a 'Copy contents' button for the report, click it and export again. The userscript will include that copied report as a fallback when clipboard access is allowed."
];
if (src) lines.push("", `Frame: ${src}`);
return lines.join("\n");
}
function handleUserscriptMessage(event) {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === messageTypes.snapshot && data.snapshot) {
if (window.top === window && isChatGptHost(location.hostname)) {
const key = data.frameKey || data.snapshot.url || `${event.origin}-${Date.now()}`;
deepResearchSnapshotCache.set(key, { snapshot: data.snapshot, receivedAt: Date.now() });
} else {
postToParent(data);
}
return;
}
if (data.type === messageTypes.request) {
publishLocalDeepResearchSnapshot(data.requestId || "");
forwardRequestToChildFrames(data);
}
}
function startDeepResearchFrameBridge() {
window.setTimeout(() => publishLocalDeepResearchSnapshot("startup"), 300);
let publishTimer;
const schedule = () => {
if (publishTimer) window.clearTimeout(publishTimer);
publishTimer = window.setTimeout(() => publishLocalDeepResearchSnapshot("mutation"), 500);
};
if (document.documentElement) {
const observer = new MutationObserver(schedule);
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
}
}
function publishLocalDeepResearchSnapshot(requestId) {
const snapshot = collectDeepResearchSnapshot();
if (!snapshot || !snapshot.markdown.trim()) return;
postToParent({
type: messageTypes.snapshot,
requestId,
frameKey: `${snapshot.url}:${snapshot.markdown.length}`,
snapshot
});
}
function collectDeepResearchSnapshot() {
const root = findDeepResearchContentRoot();
if (!root) return null;
const markdown = elementToMarkdown(root).trim();
const text = visibleText(root);
if (text.length < 40 && markdown.length < 40) return null;
if (looksLikeOnlyShell(text)) return null;
return {
title: cleanTitle(document.title) || firstHeading(root) || "Deep Research",
url: location.href,
capturedAt: new Date().toISOString(),
markdown,
text
};
}
function findDeepResearchContentRoot() {
const candidates = [
document.querySelector("article"),
document.querySelector("main article"),
document.querySelector("main"),
document.querySelector("[data-testid*='research' i]"),
document.querySelector("[class*='research' i]"),
document.body
].filter(Boolean);
return candidates
.map((node) => ({ node, score: visibleText(node).length }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)[0]?.node || null;
}
function looksLikeOnlyShell(text) {
const trimmed = text.replace(/\s+/g, " ").trim();
if (!trimmed) return true;
if (/^(Copy contents|Copia contenuti|Download|Open|Close|Apri|Chiudi|Loading)$/i.test(trimmed)) return true;
return trimmed.length < 40;
}
function forwardRequestToChildFrames(message) {
document.querySelectorAll("iframe").forEach((frame) => {
try {
frame.contentWindow?.postMessage(message, "*");
} catch {
// Ignore frames that are not ready.
}
});
}
function postToParent(message) {
try {
if (window.parent && window.parent !== window) window.parent.postMessage(message, "*");
} catch {
// Cross-origin parent may reject direct access in unusual sandbox states.
}
try {
if (window.top && window.top !== window && window.top !== window.parent) window.top.postMessage(message, "*");
} catch {
// Best effort only.
}
}
function pickBestSnapshot(snapshots) {
return snapshots
.slice()
.sort((a, b) => (b.markdown?.length || 0) - (a.markdown?.length || 0))[0] || null;
}
function renderExport(data, format) {
const meta = exportFormats.find((item) => item.format === format) || exportFormats[0];
const filename = buildFilename(meta.extension);
if (format === "html") return { content: renderHtml(data), filename, mime: meta.mime };
if (format === "text") return { content: renderText(data), filename, mime: meta.mime };
return { content: renderMarkdown(data), filename, mime: meta.mime };
}
function renderMarkdown(data) {
const lines = [
`# ${data.title}`,
"",
`**Date:** ${data.date}`,
`**Source:** [chatgpt.com](${data.url})`,
data.deepResearchCount ? `**Deep Research frames:** ${data.deepResearchCount}` : "",
"",
"---",
""
].filter((line) => line !== "");
if (!data.turns.length) lines.push("> No visible conversation turns were found.");
for (const turn of data.turns) {
lines.push(`### **${turn.sender}**`, "", turn.markdown.trim(), "", "---", "");
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}
function renderHtml(data) {
const body = data.turns.length
? data.turns.map((turn) => [
`<section class="turn turn-${escapeAttribute(turn.role)}">`,
`<h2>${escapeHtml(turn.sender)}</h2>`,
markdownToHtml(turn.markdown),
"</section>"
].join("\n")).join("\n")
: "<p>No visible conversation turns were found.</p>";
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(data.title)}</title>
<style>
:root { color-scheme: light dark; }
body { max-width: min(1180px, calc(100vw - 48px)); margin: 48px auto; padding: 0 24px; font: 16px/1.62 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
h1 { line-height: 1.1; }
h2 { margin-top: 36px; padding-top: 20px; border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent); font-size: 1.05rem; }
a { color: #2563eb; }
pre { overflow: auto; padding: 14px; border-radius: 8px; background: #111827; color: #f9fafb; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid color-mix(in srgb, currentColor 24%, transparent); color: color-mix(in srgb, currentColor 72%, transparent); }
.turn { overflow-x: auto; }
table { width: max-content; min-width: 100%; max-width: none; margin: 20px 0; border-collapse: collapse; overflow-wrap: normal; }
th, td { min-width: 9rem; padding: 8px 10px; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); vertical-align: top; overflow-wrap: break-word; word-break: normal; }
th { background: color-mix(in srgb, currentColor 8%, transparent); text-align: left; font-weight: 650; }
tr:nth-child(even) td { background: color-mix(in srgb, currentColor 3%, transparent); }
.meta { color: color-mix(in srgb, currentColor 64%, transparent); }
.turn-user h2 { text-align: right; }
</style>
</head>
<body>
<h1>${escapeHtml(data.title)}</h1>
<p class="meta">${escapeHtml(data.date)} · <a href="${escapeAttribute(data.url)}">${escapeHtml(data.url)}</a>${data.deepResearchCount ? ` · Deep Research frames: ${data.deepResearchCount}` : ""}</p>
${body}
</body>
</html>`;
}
function renderText(data) {
const lines = [
data.title,
"",
`Date: ${data.date}`,
`Source: ${data.url}`,
data.deepResearchCount ? `Deep Research frames: ${data.deepResearchCount}` : "",
"",
"----",
""
].filter((line) => line !== "");
if (!data.turns.length) lines.push("No visible conversation turns were found.");
for (const turn of data.turns) {
lines.push(turn.sender, "", markdownToText(turn.markdown), "", "----", "");
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}
function markdownToHtml(markdown) {
const lines = markdown.split(/\r?\n/);
const chunks = [];
let index = 0;
while (index < lines.length) {
const line = lines[index] || "";
if (!line.trim()) {
index += 1;
continue;
}
const fence = line.match(/^```([a-zA-Z0-9_-]*)\s*$/);
if (fence) {
const language = fence[1] || "";
const code = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index] || "")) {
code.push(lines[index] || "");
index += 1;
}
if (index < lines.length) index += 1;
chunks.push(`<pre><code${language ? ` class="language-${escapeAttribute(language)}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>`);
continue;
}
if (isMarkdownTableStart(lines, index)) {
const result = consumeMarkdownTable(lines, index);
chunks.push(result.html);
index = result.nextIndex;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const level = Math.min(6, heading[1].length);
chunks.push(`<h${level}>${inlineMarkdownToHtml(heading[2])}</h${level}>`);
index += 1;
continue;
}
if (/^---+$/.test(line.trim())) {
chunks.push("<hr>");
index += 1;
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index] || "")) {
items.push((lines[index] || "").replace(/^\s*[-*]\s+/, ""));
index += 1;
}
chunks.push(`<ul>${items.map((item) => `<li>${inlineMarkdownToHtml(item)}</li>`).join("")}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index] || "")) {
items.push((lines[index] || "").replace(/^\s*\d+\.\s+/, ""));
index += 1;
}
chunks.push(`<ol>${items.map((item) => `<li>${inlineMarkdownToHtml(item)}</li>`).join("")}</ol>`);
continue;
}
if (/^>\s?/.test(line)) {
const quotes = [];
while (index < lines.length && /^>\s?/.test(lines[index] || "")) {
quotes.push((lines[index] || "").replace(/^>\s?/, ""));
index += 1;
}
chunks.push(`<blockquote>${quotes.map(inlineMarkdownToHtml).join("<br>")}</blockquote>`);
continue;
}
const paragraph = [line.trim()];
index += 1;
while (index < lines.length && lines[index] && lines[index].trim() && !isBlockStart(lines, index)) {
paragraph.push(lines[index].trim());
index += 1;
}
chunks.push(`<p>${inlineMarkdownToHtml(paragraph.join(" "))}</p>`);
}
return chunks.join("\n");
}
function isBlockStart(lines, index) {
const line = lines[index] || "";
return /^```/.test(line)
|| /^#{1,6}\s+/.test(line)
|| /^---+$/.test(line.trim())
|| /^\s*[-*]\s+/.test(line)
|| /^\s*\d+\.\s+/.test(line)
|| /^>\s?/.test(line)
|| isMarkdownTableStart(lines, index);
}
function isMarkdownTableStart(lines, index) {
const header = lines[index] || "";
const separator = lines[index + 1] || "";
return /^\s*\|.*\|\s*$/.test(header)
&& /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(separator);
}
function consumeMarkdownTable(lines, index) {
const header = splitMarkdownTableRow(lines[index] || "");
index += 2;
const body = [];
while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index] || "")) {
body.push(splitMarkdownTableRow(lines[index] || ""));
index += 1;
}
const headerHtml = `<thead><tr>${header.map((cell) => `<th>${inlineMarkdownToHtml(cell)}</th>`).join("")}</tr></thead>`;
const bodyHtml = body.length
? `<tbody>${body.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdownToHtml(cell)}</td>`).join("")}</tr>`).join("")}</tbody>`
: "";
return { html: `<table>${headerHtml}${bodyHtml}</table>`, nextIndex: index };
}
function splitMarkdownTableRow(row) {
const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
const cells = [];
let current = "";
let escaped = false;
for (const char of trimmed) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "|") {
cells.push(current.trim());
current = "";
continue;
}
current += char;
}
cells.push(current.trim());
return cells;
}
function inlineMarkdownToHtml(value) {
let output = escapeHtml(value);
output = output.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, alt, src) => {
return `<img alt="${escapeAttribute(unescapeHtml(alt))}" src="${escapeAttribute(unescapeHtml(src))}">`;
});
output = output.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, text, href) => {
const url = safeHref(unescapeHtml(href));
return url ? `<a href="${escapeAttribute(url)}">${text}</a>` : text;
});
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
output = output.replace(/`([^`]+)`/g, "<code>$1</code>");
return output;
}
function markdownToText(markdown) {
return stripChatGptArtifacts(markdown)
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1 ($2)")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^>\s?/gm, "")
.replace(/```[a-zA-Z0-9_-]*\n?/g, "")
.replace(/```/g, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.trim();
}
const blockTags = new Set([
"ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DD", "DETAILS", "DIV", "DL", "DT",
"FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM", "H1", "H2", "H3", "H4",
"H5", "H6", "HEADER", "HR", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION",
"TABLE", "UL"
]);
const noisySelectors = [
"script",
"style",
"noscript",
"svg",
".sr-only",
"[aria-hidden='true']",
"[data-testid='copy-turn-action-button']",
"[data-testid='share-prompt-link-turn-action-button']",
"[aria-label='Copia']",
"[aria-label='Copia risposta']",
"[aria-label='Copia messaggio']",
"[aria-label='Condividi']",
"[aria-label='Condividi prompt']",
"[aria-label='Feedback su Pro']",
"[aria-label='Più azioni']",
"[aria-label='Cambia modello']",
"[aria-label='Modifica messaggio']",
`#${buttonId}`,
`#${menuId}`,
`#${styleId}`,
`#${manualDialogId}`
];
function elementToMarkdown(root) {
const clone = root.cloneNode(true);
pruneNoise(clone);
return normalizeMarkdown(serializeChildren(clone, { listDepth: 0 }));
}
function visibleText(root) {
return normalizeInlineText(root instanceof HTMLElement && root.innerText ? root.innerText : textWithBreaks(root));
}
function pruneNoise(root) {
root.querySelectorAll(noisySelectors.join(",")).forEach((node) => node.remove());
}
function serializeNode(node, context) {
if (node.nodeType === Node.TEXT_NODE) return normalizeTextNode(node.textContent || "");
if (!(node instanceof Element)) return "";
if (isHidden(node)) return "";
const tag = node.tagName.toUpperCase();
if (tag === "BR") return "\n";
if (tag === "HR") return "\n\n---\n\n";
if (tag === "PRE") return serializeCodeBlock(node);
if (/^H[1-6]$/.test(tag)) return block(`${"#".repeat(Number(tag.slice(1)))} ${serializeChildren(node, context).trim()}`);
if (tag === "P") return block(serializeChildren(node, context).trim());
if (tag === "STRONG" || tag === "B") return wrapInline("**", serializeChildren(node, context));
if (tag === "EM" || tag === "I") return wrapInline("*", serializeChildren(node, context));
if (tag === "CODE") return serializeInlineCode(node);
if (tag === "A") return serializeLink(node, context);
if (tag === "IMG") return serializeImage(node);
if (tag === "UL") return serializeList(node, false, context);
if (tag === "OL") return serializeList(node, true, context);
if (tag === "BLOCKQUOTE") return serializeBlockquote(node, context);
if (tag === "TABLE") return serializeTable(node);
if (tag === "IFRAME") return serializeIframe(node);
const serialized = serializeChildren(node, context);
return blockTags.has(tag) ? block(serialized.trim()) : serialized;
}
function serializeChildren(node, context) {
return Array.from(node.childNodes).map((child) => serializeNode(child, context)).join("");
}
function serializeCodeBlock(node) {
const code = node.querySelector("code") || node;
const className = code.getAttribute("class") || "";
const language = className.match(/language-([a-zA-Z0-9_-]+)/)?.[1] || "";
const value = textWithBreaks(code).replace(/\n+$/g, "");
if (!value.trim()) return "";
return `\n\n\`\`\`${language}\n${value}\n\`\`\`\n\n`;
}
function serializeInlineCode(node) {
if (node.closest("pre")) return "";
const value = textWithBreaks(node).trim();
if (!value) return "";
const fence = value.includes("`") ? "``" : "`";
return `${fence}${value}${fence}`;
}
function serializeLink(node, context) {
const href = safeHref(node.getAttribute("href") || "");
const text = normalizeInlineText(isCitationLink(node) ? visibleText(node) : serializeChildren(node, context)) || href;
if (!href) return text;
return `[${escapeLinkText(text)}](${href})`;
}
function serializeImage(node) {
const src = safeHref(node.getAttribute("src") || "");
const alt = normalizeInlineText(node.getAttribute("alt") || "");
if (!src) return alt ? `[Image: ${alt}]` : "[Image]";
return alt ? `` : ``;
}
function serializeList(node, ordered, context) {
const items = Array.from(node.children).filter((child) => child.tagName.toUpperCase() === "LI");
const indent = " ".repeat(context.listDepth);
const lines = items.map((item, index) => {
const marker = ordered ? `${index + 1}.` : "-";
const body = serializeChildren(item, { listDepth: context.listDepth + 1 }).trim();
return `${indent}${marker} ${body.replace(/\n/g, `\n${indent} `)}`;
});
return `\n\n${lines.join("\n")}\n\n`;
}
function serializeBlockquote(node, context) {
const body = serializeChildren(node, context).trim();
if (!body) return "";
return `\n\n${body.split("\n").map((line) => `> ${line}`).join("\n")}\n\n`;
}
function serializeTable(node) {
const rows = Array.from(node.querySelectorAll("tr")).map((row) => {
return Array.from(row.children)
.filter((cell) => ["TH", "TD"].includes(cell.tagName.toUpperCase()))
.map((cell) => normalizeInlineText(elementToMarkdown(cell)).replace(/\|/g, "\\|"));
}).filter((row) => row.length > 0);
if (!rows.length) return "";
const header = rows[0];
const separator = header.map(() => "---");
const body = rows.slice(1);
return `\n\n| ${header.join(" | ")} |\n| ${separator.join(" | ")} |\n${body.map((row) => `| ${row.join(" | ")} |`).join("\n")}\n\n`;
}
function serializeIframe(node) {
const src = node.getAttribute("src") || "";
const title = node.getAttribute("title") || "Embedded frame";
if (src.includes("deep_research")
|| src.includes("deep-research")
|| src.includes("web-sandbox.oaiusercontent.com")
|| title.includes("deep-research")
|| title.includes("Deep Research")
|| src === "about:blank"
|| src === "about:srcdoc") {
return "";
}
return src ? `\n\n[Embedded frame: ${title}](${src})\n\n` : "";
}
function textWithBreaks(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (!(node instanceof Element)) return "";
if (node.tagName.toUpperCase() === "BR") return "\n";
const body = Array.from(node.childNodes).map(textWithBreaks).join("");
return blockTags.has(node.tagName.toUpperCase()) && !["SPAN", "CODE"].includes(node.tagName.toUpperCase())
? `${body}\n`
: body;
}
function normalizeTextNode(value) {
return value.replace(/[ \t\r\n]+/g, " ");
}
function normalizeInlineText(value) {
return stripChatGptArtifacts(value).replace(/[ \t\r\n]+/g, " ").trim();
}
function normalizeMarkdown(value) {
return stripChatGptArtifacts(value)
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/^\s+|\s+$/g, "");
}
function stripChatGptArtifacts(value) {
return String(value || "")
.replace(/[\uE200\uE000]cite[\uE202\uE002][\s\S]*?[\uE201\uE001]/g, "")
.replace(/cite[\s\S]*?/g, "")
.replace(/【\s*\d+(?::\d+)?†[^】]*】/g, "")
.replace(/[ \t]+([.,;:!?])/g, "$1");
}
function block(value) {
return value ? `\n\n${value}\n\n` : "";
}
function wrapInline(marker, value) {
const trimmed = value.trim();
return trimmed ? `${marker}${trimmed}${marker}` : "";
}
function firstHeading(root) {
return root.querySelector("h1,h2,h3")?.textContent?.trim() || "";
}
function isHidden(node) {
if (node.hasAttribute("hidden")) return true;
if (node.getAttribute("aria-hidden") === "true") return true;
if (node instanceof HTMLElement && node.style.display === "none") return true;
return false;
}
function isCitationLink(node) {
return Boolean(node.closest("[data-testid='webpage-citation-pill']"));
}
function safeHref(value) {
const trimmed = String(value || "").trim();
if (!trimmed || /^(javascript|data):/i.test(trimmed)) return "";
try {
return new URL(trimmed, location.href).toString();
} catch {
return trimmed;
}
}
function escapeLinkText(value) {
return value.replace(/[[\]]/g, "\\$&");
}
function findHeaderActions() {
return document.querySelector("#conversation-header-actions")
|| document.querySelector("[data-testid='thread-header-right-actions']");
}
function startChatGptObserver() {
if (chatGptObserver || !document.documentElement) return;
chatGptObserver = new MutationObserver(() => scheduleChatGptExportRemount());
chatGptObserver.observe(document.documentElement, { childList: true, subtree: true });
}
function scheduleChatGptExportRemount() {
if (remountTimer) window.clearTimeout(remountTimer);
remountTimer = window.setTimeout(() => {
remountTimer = undefined;
mountChatGptExport();
}, 350);
}
function downloadBlob(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function buildFilename(extension) {
const title = cleanTitle(document.title) || "Conversation";
const slug = title
.normalize("NFKD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.slice(0, 72) || "Conversation";
return `ChatGPT_${slug}_${formatLocalDate()}.${extension}`;
}
function senderForRole(role) {
if (role === "user") return "You";
if (role === "assistant") return "ChatGPT";
return role || "Unknown";
}
function cleanTitle(value) {
return String(value || "").replace(/\s*[-|]\s*ChatGPT\s*$/i, "").trim();
}
function formatLocalDate(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function isChatGptHost(host) {
return host === "chatgpt.com" || host.endsWith(".chatgpt.com") || host === "chat.openai.com";
}
function isTechnicalFrameUrl(value) {
return value.startsWith("about:") || value.startsWith("blob:") || value.startsWith("data:");
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
"\"": """,
"'": "'"
})[char] || char);
}
function unescapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, "\"")
.replace(/'/g, "'");
}
function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, "`");
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function injectStyles() {
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
#${buttonId} {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 0 10px;
border: 0;
border-radius: 10px;
background: transparent;
color: currentColor;
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
#${buttonId}:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
#${buttonId} svg { width: 20px; height: 20px; fill: currentColor; display: block; }
#${buttonId} .ps-chatgpt-export-chevron svg { width: 14px; height: 14px; opacity: 0.72; }
#${menuId} {
position: fixed;
z-index: 2147483647;
width: 186px;
padding: 6px;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 12px;
background: color-mix(in srgb, Canvas 96%, currentColor 4%);
color: CanvasText;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.18);
}
#${menuId} button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font: 13px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-align: left;
cursor: pointer;
}
#${menuId} button:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
#${menuId} small { opacity: 0.58; font-size: 11px; font-weight: 700; }
#${manualDialogId} {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483647;
width: min(720px, calc(100vw - 36px));
max-height: calc(100vh - 36px);
pointer-events: none;
}
#${manualDialogId} .ps-chatgpt-export-dialog-card {
pointer-events: auto;
width: 100%;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 14px;
background: color-mix(in srgb, Canvas 98%, currentColor 2%);
color: CanvasText;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
padding: 18px;
font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#${manualDialogId} h2 {
margin: 0 0 8px;
font-size: 18px;
line-height: 1.2;
}
#${manualDialogId} p {
margin: 0 0 14px;
color: color-mix(in srgb, currentColor 72%, transparent);
}
#${manualDialogId} textarea {
box-sizing: border-box;
width: 100%;
min-height: min(280px, calc(100vh - 250px));
resize: vertical;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 10px;
background: color-mix(in srgb, Canvas 94%, currentColor 6%);
color: CanvasText;
padding: 12px;
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
#${manualDialogId} .ps-chatgpt-export-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
#${manualDialogId} button {
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 9px;
background: color-mix(in srgb, currentColor 8%, transparent);
color: inherit;
padding: 8px 12px;
font: inherit;
font-weight: 650;
cursor: pointer;
}
#${manualDialogId} button[data-action='continue'] {
background: #2563eb;
border-color: #2563eb;
color: white;
}
`;
document.documentElement.append(style);
}
})();