diff --git a/chrome/content.js b/chrome/content.js index 9b0232e..3517ab1 100644 --- a/chrome/content.js +++ b/chrome/content.js @@ -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'], diff --git a/chrome/popup.html b/chrome/popup.html index 2792369..afe8e5b 100644 --- a/chrome/popup.html +++ b/chrome/popup.html @@ -55,6 +55,12 @@
+ +
📥 Bulk Media Downloader
diff --git a/chrome/popup.js b/chrome/popup.js index 19adf8d..ce1d450 100644 --- a/chrome/popup.js +++ b/chrome/popup.js @@ -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'); diff --git a/firefox/content.js b/firefox/content.js index 8480064..c546ec2 100644 --- a/firefox/content.js +++ b/firefox/content.js @@ -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'], diff --git a/firefox/popup.html b/firefox/popup.html index 2792369..afe8e5b 100644 --- a/firefox/popup.html +++ b/firefox/popup.html @@ -55,6 +55,12 @@
+ +
📥 Bulk Media Downloader
diff --git a/firefox/popup.js b/firefox/popup.js index d99ee5f..be63ed0 100644 --- a/firefox/popup.js +++ b/firefox/popup.js @@ -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');