Add ChatGPT conversation Markdown export

This commit is contained in:
Andy Dixon
2026-09-17 11:39:56 +01:00
parent 51e0982138
commit f51b1a1a07
6 changed files with 464 additions and 0 deletions
+123
View File
@@ -201,9 +201,132 @@
sendResponse({ items: harvestLinks(request.containerSelector) });
} else if (request.action === 'scrapeData') {
sendResponse({ items: scrapeData(request.selector, request.attribute) });
} else if (request.action === 'exportConversationMarkdown') {
exportConversationMarkdown().then(sendResponse).catch((error) => {
sendResponse({ error: error.message || 'Could not export the conversation.' });
});
return true;
}
});
async function exportConversationMarkdown() {
// ponytail: read the message nodes already loaded by ChatGPT; add virtual-scroll
// pagination only if ChatGPT starts unloading older messages from the DOM.
const messageElements = [...document.querySelectorAll('[data-message-author-role]')]
.filter((el) => ['user', 'assistant', 'system'].includes(el.dataset.messageAuthorRole));
if (!messageElements.length) throw new Error('No ChatGPT messages were found on this page.');
const imagesByElement = new Map();
const imagesByUrl = new Map();
let imageNumber = 0;
for (const message of messageElements) {
for (const image of message.querySelectorAll('img')) {
const url = image.currentSrc || image.src;
if (!url) continue;
let imageFile = imagesByUrl.get(url);
if (!imageFile) {
imageFile = await readConversationImage(url, ++imageNumber);
imagesByUrl.set(url, imageFile);
}
imagesByElement.set(image, imageFile);
}
}
const title = (document.querySelector('main h1')?.textContent || document.title || 'ChatGPT Conversation')
.replace(/\s*\|\s*ChatGPT.*$/i, '').trim() || 'ChatGPT Conversation';
const markdown = [`# ${title.replace(/^#+\s*/, '').replace(/\n/g, ' ')}`, ''];
messageElements.forEach((message) => {
const role = message.dataset.messageAuthorRole === 'assistant' ? 'ChatGPT' :
message.dataset.messageAuthorRole[0].toUpperCase() + message.dataset.messageAuthorRole.slice(1);
const body = cleanConversationMarkdown(nodeToConversationMarkdown(message, imagesByElement));
if (body) markdown.push(`## ${role}`, '', body, '');
});
return { markdown: `${markdown.join('\n').trim()}\n`, images: [...imagesByUrl.values()] };
}
async function readConversationImage(url, number) {
const fallbackName = `image-${String(number).padStart(3, '0')}.${imageExtension(url)}`;
try {
let bytes;
let mimeType = '';
if (url.startsWith('data:')) {
const match = url.match(/^data:([^,]*),(.*)$/s);
if (!match) throw new Error('Invalid data URL');
mimeType = (match[1].split(';')[0] || '').toLowerCase();
bytes = match[1].includes(';base64')
? base64ToBytes(match[2])
: new TextEncoder().encode(decodeURIComponent(match[2]));
} else {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
mimeType = response.headers.get('content-type') || '';
bytes = new Uint8Array(await response.arrayBuffer());
}
const name = `image-${String(number).padStart(3, '0')}.${imageExtension(url, mimeType)}`;
return { name, url, mimeType, data: bytesToBase64(bytes) };
} catch (error) {
return { name: fallbackName, url, error: error.message || 'download failed' };
}
}
function nodeToConversationMarkdown(node, imagesByElement) {
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue.replace(/\s+/g, ' ');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
if (['button', 'svg', 'script', 'style', 'textarea'].includes(tag)) return '';
if (tag === 'img') {
const image = imagesByElement.get(node);
return image ? `![${node.alt || 'Image'}](images/${image.name})` : '';
}
if (tag === 'pre') return `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`;
if (tag === 'br') return '\n';
if (tag === 'a') {
const text = cleanConversationMarkdown([...node.childNodes]
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join(''));
return text ? `[${text}](${node.href})` : node.href;
}
if (tag === 'code') return `\`${node.textContent.trim()}\``;
const children = [...node.childNodes]
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join('');
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${children.trim()}\n\n`;
if (tag === 'li') return `\n- ${children.trim()}\n`;
if (['p', 'div', 'section', 'article', 'blockquote', 'ul', 'ol'].includes(tag)) {
return `\n${children}\n`;
}
return children;
}
function cleanConversationMarkdown(text) {
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function imageExtension(url, mimeType = '') {
const mimeExtensions = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/avif': 'avif' };
if (mimeExtensions[mimeType.split(';')[0].toLowerCase()]) return mimeExtensions[mimeType.split(';')[0].toLowerCase()];
const match = url.match(/\.([a-z0-9]+)(?:[?#]|$)/i);
return match && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(match[1].toLowerCase())
? match[1].toLowerCase() : 'bin';
}
function base64ToBytes(value) {
const binary = atob(value.replace(/\s/g, ''));
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
function bytesToBase64(bytes) {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function scanMedia(mediaType, customExtensions) {
const extMap = {
images: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'],
+6
View File
@@ -55,6 +55,12 @@
</div>
<div class="tab-pane fade" id="toolsPane" role="tabpanel">
<div class="tool-card mb-3" id="conversationMarkdownCard" style="display: none;">
<h6>💬 ChatGPT Conversation</h6>
<button class="btn btn-sm btn-primary" id="downloadConversationMarkdownBtn">Download Conversation Markdown</button>
<div id="conversationMarkdownStatus" class="tool-results small text-muted mt-2"></div>
</div>
<div class="tool-card mb-3">
<h6>📥 Bulk Media Downloader</h6>
<div class="d-flex gap-2 mb-2">
+103
View File
@@ -609,11 +609,114 @@ function importRules() {
// ---------------------------------------------------------------------
function setupToolsTab() {
setupConversationMarkdown();
setupMediaDownloader();
setupLinkHarvester();
setupDataScraper();
}
async function setupConversationMarkdown() {
const card = document.getElementById('conversationMarkdownCard');
const button = document.getElementById('downloadConversationMarkdownBtn');
const status = document.getElementById('conversationMarkdownStatus');
const tab = await getActiveTab();
if (!tab || !/^https:\/\/(chatgpt\.com|chat\.openai\.com)(?:\/|$)/i.test(tab.url || '')) return;
card.style.display = 'block';
button.addEventListener('click', async () => {
button.disabled = true;
status.textContent = 'Reading conversation…';
try {
const result = await chrome.tabs.sendMessage(tab.id, { action: 'exportConversationMarkdown' });
if (!result || result.error) throw new Error(result && result.error || 'Could not read this conversation.');
if (!result.images || result.images.length === 0) {
downloadBlob(result.markdown, 'chatgpt-conversation.md', 'text/markdown');
status.textContent = 'Markdown downloaded.';
return;
}
status.textContent = `Preparing ${result.images.length} image(s)…`;
const files = [{ name: 'conversation.md', bytes: new TextEncoder().encode(result.markdown) }];
for (const image of result.images) {
const bytes = image.data ? base64ToBytes(image.data) : await fetchConversationImage(image.url);
files.push({ name: `images/${image.name}`, bytes });
}
downloadBlob(createStoredZip(files), 'chatgpt-conversation.zip', 'application/zip');
status.textContent = 'ZIP downloaded.';
} catch (error) {
status.textContent = error.message || 'Could not download the conversation.';
} finally {
button.disabled = false;
}
});
}
async function fetchConversationImage(url) {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error(`Could not download image (${response.status}).`);
return new Uint8Array(await response.arrayBuffer());
}
function base64ToBytes(value) {
const binary = atob(value);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
function createStoredZip(files) {
// ponytail: stored entries keep this dependency-free; add deflate only if archive size matters.
const parts = [];
const centralDirectory = [];
let offset = 0;
files.forEach(({ name, bytes }) => {
const nameBytes = new TextEncoder().encode(name);
const checksum = crc32(bytes);
const localHeader = new Uint8Array(30 + nameBytes.length);
const localView = new DataView(localHeader.buffer);
localView.setUint32(0, 0x04034b50, true);
localView.setUint16(4, 20, true);
localView.setUint32(14, checksum, true);
localView.setUint32(18, bytes.length, true);
localView.setUint32(22, bytes.length, true);
localView.setUint16(26, nameBytes.length, true);
localHeader.set(nameBytes, 30);
parts.push(localHeader, bytes);
const centralHeader = new Uint8Array(46 + nameBytes.length);
const centralView = new DataView(centralHeader.buffer);
centralView.setUint32(0, 0x02014b50, true);
centralView.setUint16(4, 20, true);
centralView.setUint16(6, 20, true);
centralView.setUint32(16, checksum, true);
centralView.setUint32(20, bytes.length, true);
centralView.setUint32(24, bytes.length, true);
centralView.setUint16(28, nameBytes.length, true);
centralView.setUint32(42, offset, true);
centralHeader.set(nameBytes, 46);
centralDirectory.push(centralHeader);
offset += localHeader.length + bytes.length;
});
const centralSize = centralDirectory.reduce((size, part) => size + part.length, 0);
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, files.length, true);
endView.setUint16(10, files.length, true);
endView.setUint32(12, centralSize, true);
endView.setUint32(16, offset, true);
return new Blob([...parts, ...centralDirectory, end], { type: 'application/zip' });
}
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function setupMediaDownloader() {
const mediaType = document.getElementById('mediaType');
const mediaExtensions = document.getElementById('mediaExtensions');
+123
View File
@@ -223,9 +223,132 @@
sendResponse({ items: harvestLinks(request.containerSelector) });
} else if (request.action === 'scrapeData') {
sendResponse({ items: scrapeData(request.selector, request.attribute) });
} else if (request.action === 'exportConversationMarkdown') {
exportConversationMarkdown().then(sendResponse).catch((error) => {
sendResponse({ error: error.message || 'Could not export the conversation.' });
});
return true;
}
});
async function exportConversationMarkdown() {
// ponytail: read the message nodes already loaded by ChatGPT; add virtual-scroll
// pagination only if ChatGPT starts unloading older messages from the DOM.
const messageElements = [...document.querySelectorAll('[data-message-author-role]')]
.filter((el) => ['user', 'assistant', 'system'].includes(el.dataset.messageAuthorRole));
if (!messageElements.length) throw new Error('No ChatGPT messages were found on this page.');
const imagesByElement = new Map();
const imagesByUrl = new Map();
let imageNumber = 0;
for (const message of messageElements) {
for (const image of message.querySelectorAll('img')) {
const url = image.currentSrc || image.src;
if (!url) continue;
let imageFile = imagesByUrl.get(url);
if (!imageFile) {
imageFile = await readConversationImage(url, ++imageNumber);
imagesByUrl.set(url, imageFile);
}
imagesByElement.set(image, imageFile);
}
}
const title = (document.querySelector('main h1')?.textContent || document.title || 'ChatGPT Conversation')
.replace(/\s*\|\s*ChatGPT.*$/i, '').trim() || 'ChatGPT Conversation';
const markdown = [`# ${title.replace(/^#+\s*/, '').replace(/\n/g, ' ')}`, ''];
messageElements.forEach((message) => {
const role = message.dataset.messageAuthorRole === 'assistant' ? 'ChatGPT' :
message.dataset.messageAuthorRole[0].toUpperCase() + message.dataset.messageAuthorRole.slice(1);
const body = cleanConversationMarkdown(nodeToConversationMarkdown(message, imagesByElement));
if (body) markdown.push(`## ${role}`, '', body, '');
});
return { markdown: `${markdown.join('\n').trim()}\n`, images: [...imagesByUrl.values()] };
}
async function readConversationImage(url, number) {
const fallbackName = `image-${String(number).padStart(3, '0')}.${imageExtension(url)}`;
try {
let bytes;
let mimeType = '';
if (url.startsWith('data:')) {
const match = url.match(/^data:([^,]*),(.*)$/s);
if (!match) throw new Error('Invalid data URL');
mimeType = (match[1].split(';')[0] || '').toLowerCase();
bytes = match[1].includes(';base64')
? base64ToBytes(match[2])
: new TextEncoder().encode(decodeURIComponent(match[2]));
} else {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
mimeType = response.headers.get('content-type') || '';
bytes = new Uint8Array(await response.arrayBuffer());
}
const name = `image-${String(number).padStart(3, '0')}.${imageExtension(url, mimeType)}`;
return { name, url, mimeType, data: bytesToBase64(bytes) };
} catch (error) {
return { name: fallbackName, url, error: error.message || 'download failed' };
}
}
function nodeToConversationMarkdown(node, imagesByElement) {
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue.replace(/\s+/g, ' ');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
if (['button', 'svg', 'script', 'style', 'textarea'].includes(tag)) return '';
if (tag === 'img') {
const image = imagesByElement.get(node);
return image ? `![${node.alt || 'Image'}](images/${image.name})` : '';
}
if (tag === 'pre') return `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`;
if (tag === 'br') return '\n';
if (tag === 'a') {
const text = cleanConversationMarkdown([...node.childNodes]
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join(''));
return text ? `[${text}](${node.href})` : node.href;
}
if (tag === 'code') return `\`${node.textContent.trim()}\``;
const children = [...node.childNodes]
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join('');
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${children.trim()}\n\n`;
if (tag === 'li') return `\n- ${children.trim()}\n`;
if (['p', 'div', 'section', 'article', 'blockquote', 'ul', 'ol'].includes(tag)) {
return `\n${children}\n`;
}
return children;
}
function cleanConversationMarkdown(text) {
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function imageExtension(url, mimeType = '') {
const mimeExtensions = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/avif': 'avif' };
if (mimeExtensions[mimeType.split(';')[0].toLowerCase()]) return mimeExtensions[mimeType.split(';')[0].toLowerCase()];
const match = url.match(/\.([a-z0-9]+)(?:[?#]|$)/i);
return match && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(match[1].toLowerCase())
? match[1].toLowerCase() : 'bin';
}
function base64ToBytes(value) {
const binary = atob(value.replace(/\s/g, ''));
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
function bytesToBase64(bytes) {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function scanMedia(mediaType, customExtensions) {
const extMap = {
images: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'],
+6
View File
@@ -55,6 +55,12 @@
</div>
<div class="tab-pane fade" id="toolsPane" role="tabpanel">
<div class="tool-card mb-3" id="conversationMarkdownCard" style="display: none;">
<h6>💬 ChatGPT Conversation</h6>
<button class="btn btn-sm btn-primary" id="downloadConversationMarkdownBtn">Download Conversation Markdown</button>
<div id="conversationMarkdownStatus" class="tool-results small text-muted mt-2"></div>
</div>
<div class="tool-card mb-3">
<h6>📥 Bulk Media Downloader</h6>
<div class="d-flex gap-2 mb-2">
+103
View File
@@ -609,11 +609,114 @@ function importRules() {
// ---------------------------------------------------------------------
function setupToolsTab() {
setupConversationMarkdown();
setupMediaDownloader();
setupLinkHarvester();
setupDataScraper();
}
async function setupConversationMarkdown() {
const card = document.getElementById('conversationMarkdownCard');
const button = document.getElementById('downloadConversationMarkdownBtn');
const status = document.getElementById('conversationMarkdownStatus');
const tab = await getActiveTab();
if (!tab || !/^https:\/\/(chatgpt\.com|chat\.openai\.com)(?:\/|$)/i.test(tab.url || '')) return;
card.style.display = 'block';
button.addEventListener('click', async () => {
button.disabled = true;
status.textContent = 'Reading conversation…';
try {
const result = await browser.tabs.sendMessage(tab.id, { action: 'exportConversationMarkdown' });
if (!result || result.error) throw new Error(result && result.error || 'Could not read this conversation.');
if (!result.images || result.images.length === 0) {
downloadBlob(result.markdown, 'chatgpt-conversation.md', 'text/markdown');
status.textContent = 'Markdown downloaded.';
return;
}
status.textContent = `Preparing ${result.images.length} image(s)…`;
const files = [{ name: 'conversation.md', bytes: new TextEncoder().encode(result.markdown) }];
for (const image of result.images) {
const bytes = image.data ? base64ToBytes(image.data) : await fetchConversationImage(image.url);
files.push({ name: `images/${image.name}`, bytes });
}
downloadBlob(createStoredZip(files), 'chatgpt-conversation.zip', 'application/zip');
status.textContent = 'ZIP downloaded.';
} catch (error) {
status.textContent = error.message || 'Could not download the conversation.';
} finally {
button.disabled = false;
}
});
}
async function fetchConversationImage(url) {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error(`Could not download image (${response.status}).`);
return new Uint8Array(await response.arrayBuffer());
}
function base64ToBytes(value) {
const binary = atob(value);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
function createStoredZip(files) {
// ponytail: stored entries keep this dependency-free; add deflate only if archive size matters.
const parts = [];
const centralDirectory = [];
let offset = 0;
files.forEach(({ name, bytes }) => {
const nameBytes = new TextEncoder().encode(name);
const checksum = crc32(bytes);
const localHeader = new Uint8Array(30 + nameBytes.length);
const localView = new DataView(localHeader.buffer);
localView.setUint32(0, 0x04034b50, true);
localView.setUint16(4, 20, true);
localView.setUint32(14, checksum, true);
localView.setUint32(18, bytes.length, true);
localView.setUint32(22, bytes.length, true);
localView.setUint16(26, nameBytes.length, true);
localHeader.set(nameBytes, 30);
parts.push(localHeader, bytes);
const centralHeader = new Uint8Array(46 + nameBytes.length);
const centralView = new DataView(centralHeader.buffer);
centralView.setUint32(0, 0x02014b50, true);
centralView.setUint16(4, 20, true);
centralView.setUint16(6, 20, true);
centralView.setUint32(16, checksum, true);
centralView.setUint32(20, bytes.length, true);
centralView.setUint32(24, bytes.length, true);
centralView.setUint16(28, nameBytes.length, true);
centralView.setUint32(42, offset, true);
centralHeader.set(nameBytes, 46);
centralDirectory.push(centralHeader);
offset += localHeader.length + bytes.length;
});
const centralSize = centralDirectory.reduce((size, part) => size + part.length, 0);
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, files.length, true);
endView.setUint16(10, files.length, true);
endView.setUint32(12, centralSize, true);
endView.setUint32(16, offset, true);
return new Blob([...parts, ...centralDirectory, end], { type: 'application/zip' });
}
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function setupMediaDownloader() {
const mediaType = document.getElementById('mediaType');
const mediaExtensions = document.getElementById('mediaExtensions');