[mobile-optimized] ChatGPT Conversation Exporter

Mobile-first script which exports the full contents of a chat-gpt conversation as JSON, plaintext, or a zip file containing both.

// ==UserScript==
// @name         [mobile-optimized] ChatGPT Conversation Exporter
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Mobile-first script which exports the full contents of a chat-gpt conversation as JSON, plaintext, or a zip file containing both.
// @author       rASTROco Labs, with input/assistance from OpenAI via ChatGPT
// @match        https://*.chatgpt.com/*
// @grant        download
// @license      MIT
// ==/UserScript==

(function() {
	'use strict';

	// -----------------------------
	// Wait for a selector
	// -----------------------------
	function waitForSelector(selector, timeout = 10000) {
		return new Promise((resolve, reject) => {
			const start = Date.now();
			const interval = setInterval(() => {
				const el = document.querySelector(selector);
				if (el) {
					clearInterval(interval);
					resolve(el);
				} else if (Date.now() - start > timeout) {
					clearInterval(interval);
					reject(`Selector ${selector} not found`);
				}
			},
				300);
		});
	}

	// -----------------------------
	// Create mobile export button
	// -----------------------------
	function createExportButton() {
		const exportBtn = document.createElement("button");
		exportBtn.id = "chat-export-btn";
		exportBtn.innerHTML = `
		<div style="
		width: 20px;
		height: 16px;
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		margin: auto;
		">
		<span style="display:block;height:2px;width:100%;background:white;border-radius:1px;"></span>
		<span style="display:block;height:2px;width:100%;background:white;border-radius:1px;"></span>
		<span style="display:block;height:2px;width:100%;background:white;border-radius:1px;"></span>
		</div>
		`;

		exportBtn.style.cssText = `
		background-color: #2b2b2b; /* dark grey */
		border: none;
		border-radius: 50%;
		width: 50px;
		height: 50px;
		z-index: 9999;
		position: fixed;
		bottom: 20px;
		right: 20px;
		display: flex;
		align-items: center;
		justify-content: center;
		padding: 0;
		box-shadow: 0 2px 8px rgba(0,0,0,0.4);
		transition: opacity 0.5s ease, transform 0.2s ease;
		opacity: 1;
		touch-action: none;
		`;

		document.body.appendChild(exportBtn);

		makeDraggable(exportBtn);
		addFadeEffect(exportBtn);
		exportBtn.addEventListener('click', exportChatSequence, {
			passive: true
		});
	}

	// -----------------------------
	// Mobile drag with corner snapping, viewport clamping, haptics
	// -----------------------------

	function makeDraggable(el) {
		let dragging = false;
		let touchOffsetX = 0;
		let touchOffsetY = 0;

		// Initialize at bottom-right corner
		el.style.position = "fixed";
		el.style.left = (window.innerWidth - el.offsetWidth - 20) + "px";
		el.style.top = (window.innerHeight - el.offsetHeight - 20) + "px";

		el.addEventListener('touchstart', function(e) {
			const touch = e.touches[0];
			const rect = el.getBoundingClientRect();
			touchOffsetX = touch.clientX - rect.left;
			touchOffsetY = touch.clientY - rect.top;
			dragging = false;
			el.style.transition = "none";
		}, {
			passive: false
		});

		el.addEventListener('touchmove', function(e) {
			e.preventDefault();
			const touch = e.touches[0];
			let newX = touch.clientX - touchOffsetX;
			let newY = touch.clientY - touchOffsetY;

			// Clamp to viewport
			const maxX = window.innerWidth - el.offsetWidth - 5;
			const maxY = window.innerHeight - el.offsetHeight - 5;
			newX = Math.min(Math.max(5, newX),
				maxX);
			newY = Math.min(Math.max(5, newY),
				maxY);

			el.style.left = newX + "px";
			el.style.top = newY + "px";
			dragging = true;
			el.style.opacity = "1";
		}, {
			passive: false
		});

		el.addEventListener('touchend', function(e) {
			if (!dragging) return;

			const rect = el.getBoundingClientRect();
			const screenWidth = window.innerWidth;
			const screenHeight = window.innerHeight;
			const centerX = rect.left + rect.width / 2;
			const centerY = rect.top + rect.height / 2;

			// Decide nearest corner
			const snapLeft = (centerX < screenWidth / 2);
			const snapTop = (centerY < screenHeight / 2);

			const targetX = snapLeft ? 10: (screenWidth - rect.width - 10);
			const targetY = snapTop ? 10: (screenHeight - rect.height - 10);

			el.style.transition = "left 0.2s ease, top 0.2s ease";
			el.style.left = targetX + "px";
			el.style.top = targetY + "px";

			// Haptic feedback
			if (navigator.vibrate) {
				navigator.vibrate(20);
			}

			setTimeout(() => {
				el.style.transition = "opacity 0.5s ease, left 0.2s ease, top 0.2s ease";
			}, 250);
		},
			{
				passive: false
			});
	}

	// -----------------------------
	// Fade effect for idle
	// -----------------------------
	function addFadeEffect(el,
		idleTime = 3000) {
		let fadeTimer;

		const setFade = () => {
			el.style.opacity = "0.3";
		};
		const resetFade = () => {
			el.style.opacity = "1";
			clearTimeout(fadeTimer);
			fadeTimer = setTimeout(setFade,
				idleTime);
		};

		['touchstart', 'touchend', 'touchmove'].forEach(evt => {
			el.addEventListener(evt, resetFade, {
				passive: true
			});
		});

		resetFade();
	}

	// -----------------------------
	// Toolbar observer
	// -----------------------------
	async function observeToolbar() {
		const container = await waitForSelector("div[class*='flex'][class*='gap']");
		const observer = new MutationObserver(() => {
			if (!document.querySelector("#chat-export-btn")) {
				createExportButton();
			}
		});
		observer.observe(container,
			{
				childList: true,
				subtree: true
			});
	}

	// -----------------------------
	// Scroll and extract messages
	// -----------------------------
	async function scrollToTopDynamic(delay = 500) {
		const scrollable = document.querySelector("main");
		if (!scrollable) return;
		let previousHeight = -1,
		stableCount = 0;
		while (stableCount < 3) {
			const currentHeight = scrollable.scrollHeight;
			scrollable.scrollTop = 0;
			await new Promise(r => setTimeout(r, delay));
			stableCount = (currentHeight === previousHeight) ? stableCount+1: 0;
			previousHeight = currentHeight;
		}
	}

	function extractMessages() {
		const messages = [];
		const messageDivs = document.querySelectorAll("div[class*='group']");
		messageDivs.forEach((div, idx) => {
			let content = Array.from(div.childNodes).map(n => {
				if (n.nodeType === Node.TEXT_NODE) return n.nodeValue;
				if (n.nodeType === Node.ELEMENT_NODE) {
					if (n.tagName === 'PRE') {
						const codeLang = n.getAttribute('data-lang') || '';
						return `\`\`\`${codeLang}\n${n.innerText}\n\`\`\``;
					}
					return n.innerText;
				}
				return '';
			}).join('\n').trim();
			if (!content) return;
			let role = /^(you|user):/i.test(content)?'user': (/^(chatgpt|assistant):/i.test(content)?'assistant': idx%2 === 0?'user': 'assistant');
			const timeEl = div.querySelector("time");
			const timestamp = timeEl ? (timeEl.getAttribute("datetime") || timeEl.innerText): null;
			content = content.replace(/([^\n])\n{3,}/g, '$1\n\n');
			messages.push({
				role, content, ...(timestamp && {
					timestamp
				})
			});
		});
		return messages;
	}

	// -----------------------------
	// Download helpers
	// -----------------------------
	function downloadJSON(data,
		filename = "chat_export.json") {
		const blob = new Blob([JSON.stringify(data, null, 2)],
			{
				type: "application/json"
			});
		const a = document.createElement("a");
		a.href = URL.createObjectURL(blob);
		a.download = filename;
		a.click();
		URL.revokeObjectURL(a.href);
	}

	function downloadPlaintext(data,
		filename = "chat_export.txt") {
		let text = '';
		data.forEach(msg => {
			const ts = msg.timestamp ? ` [${msg.timestamp}]`: '';
			text += `${msg.role.toUpperCase()}${ts}:\n${msg.content}\n\n`;
		});
		const blob = new Blob([text],
			{
				type: "text/plain"
			});
		const a = document.createElement("a");
		a.href = URL.createObjectURL(blob);
		a.download = filename;
		a.click();
		URL.revokeObjectURL(a.href);
	}

	async function downloadZip(data,
		filename = "chat_export.zip") {
		if (typeof JSZip === 'undefined') {
			await new Promise(resolve => {
				const s = document.createElement("script");
				s.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
				s.onload = resolve;
				document.head.appendChild(s);
			});
		}
		const zip = new JSZip();
		zip.file("chat_export.json", JSON.stringify(data, null, 2));
		let text = '';
		data.forEach(msg => {
			const ts = msg.timestamp?` [${msg.timestamp}]`: '';
			text += `${msg.role.toUpperCase()}${ts}:\n${msg.content}\n\n`;
		});
		zip.file("chat_export.txt", text);
		const blob = await zip.generateAsync({
			type: "blob"
		});
		const a = document.createElement("a");
		a.href = URL.createObjectURL(blob);
		a.download = filename;
		a.click();
		URL.revokeObjectURL(a.href);
	}

	// -----------------------------
	// Export sequence
	// -----------------------------
	async function exportChatSequence() {
		try {
			if (!confirm("This will load the entire conversation for export. Continue?")) return;
			await scrollToTopDynamic();
			const messages = extractMessages();
			const choice = prompt("Export options:\n1=JSON (default)\n2=Plaintext\n3=ZIP (JSON+Plaintext)\n\nEnter choice number:", "1");
			switch (choice) {
				case "2": downloadPlaintext(messages); alert(`Plaintext export complete: ${messages.length} messages saved.`); break;
				case "3": await downloadZip(messages); alert(`ZIP export complete: ${messages.length} messages saved.`); break;
				default: downloadJSON(messages); alert(`JSON export complete: ${messages.length} messages saved.`);
				}
			} catch(err) {
				console.error("Error during export:", err);
				alert("Failed to export chat. See console for details.");
			}
		}

		// -----------------------------
		// Initialize
		// -----------------------------
		createExportButton();
		observeToolbar();
	})();