Export all conversations from a ChatGPT project folder as separate PDF files
Size
17.6 KB
Version
1.1.1
Created
Oct 16, 2025
Updated
7 days ago
1// ==UserScript==
2// @name ChatGPT Project Bulk PDF Exporter
3// @description Export all conversations from a ChatGPT project folder as separate PDF files
4// @version 1.1.1
5// @match https://*.chatgpt.com/*
6// @icon https://cdn.oaistatic.com/assets/favicon-l4nq08hd.svg
7// @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
8// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
9// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
10// ==/UserScript==
11(function() {
12 'use strict';
13
14 console.log('ChatGPT Project Bulk PDF Exporter loaded');
15
16 // Utility function to sanitize filename
17 function sanitizeFilename(filename) {
18 return filename.replace(/[^a-z0-9]/gi, '_').replace(/_+/g, '_').substring(0, 100);
19 }
20
21 // Utility function to wait for element
22 function waitForElement(selector, timeout = 10000) {
23 return new Promise((resolve, reject) => {
24 const startTime = Date.now();
25 const checkInterval = setInterval(() => {
26 const element = document.querySelector(selector);
27 if (element) {
28 clearInterval(checkInterval);
29 resolve(element);
30 } else if (Date.now() - startTime > timeout) {
31 clearInterval(checkInterval);
32 reject(new Error(`Element ${selector} not found within ${timeout}ms`));
33 }
34 }, 100);
35 });
36 }
37
38 // Function to scroll the chat list to load all conversations
39 async function scrollChatList() {
40 console.log('Scrolling chat list to load all conversations...');
41 const historyContainer = document.querySelector('#history');
42
43 if (!historyContainer) {
44 throw new Error('Chat history container not found');
45 }
46
47 let previousHeight = 0;
48 let currentHeight = historyContainer.scrollHeight;
49 let attempts = 0;
50 const maxAttempts = 50;
51
52 while (previousHeight !== currentHeight && attempts < maxAttempts) {
53 previousHeight = currentHeight;
54
55 // Scroll to bottom of the chat list
56 const lastChat = historyContainer.querySelector('a.chat-item:last-child');
57 if (lastChat) {
58 lastChat.scrollIntoView({ behavior: 'smooth', block: 'end' });
59 }
60
61 // Wait for new content to load
62 await new Promise(resolve => setTimeout(resolve, 1000));
63
64 currentHeight = historyContainer.scrollHeight;
65 attempts++;
66 console.log(`Scroll attempt ${attempts}: height ${currentHeight}`);
67 }
68
69 console.log('Finished scrolling chat list');
70 }
71
72 // Function to convert blob URLs to base64 data URLs
73 async function convertBlobToBase64(blobUrl) {
74 try {
75 const response = await fetch(blobUrl);
76 const blob = await response.blob();
77 return new Promise((resolve, reject) => {
78 const reader = new FileReader();
79 reader.onloadend = () => resolve(reader.result);
80 reader.onerror = reject;
81 reader.readAsDataURL(blob);
82 });
83 } catch (error) {
84 console.error('Error converting blob to base64:', error);
85 return null;
86 }
87 }
88
89 // Function to convert all blob images in the current conversation to base64
90 async function convertAllBlobImages() {
91 console.log('Converting blob images to base64...');
92 const images = document.querySelectorAll('img[src^="blob:"]');
93 console.log(`Found ${images.length} blob images to convert`);
94
95 for (const img of images) {
96 const blobUrl = img.src;
97 const base64Data = await convertBlobToBase64(blobUrl);
98 if (base64Data) {
99 img.src = base64Data;
100 console.log('Converted blob image to base64');
101 }
102 }
103
104 // Also check for background images with blob URLs
105 const elementsWithBgImage = document.querySelectorAll('[style*="blob:"]');
106 for (const element of elementsWithBgImage) {
107 const style = element.style.backgroundImage;
108 if (style && style.includes('blob:')) {
109 const blobUrl = style.match(/url\(["']?(blob:[^"')]+)["']?\)/)?.[1];
110 if (blobUrl) {
111 const base64Data = await convertBlobToBase64(blobUrl);
112 if (base64Data) {
113 element.style.backgroundImage = `url("${base64Data}")`;
114 console.log('Converted blob background image to base64');
115 }
116 }
117 }
118 }
119
120 console.log('Finished converting blob images');
121 }
122
123 // Function to wait for conversation to fully load
124 async function waitForConversationLoad() {
125 console.log('Waiting for conversation to load...');
126
127 // Wait for the main thread container
128 await waitForElement('#thread', 15000);
129
130 // Wait a bit more for content to render
131 await new Promise(resolve => setTimeout(resolve, 3000));
132
133 // Scroll to load all messages
134 const mainContainer = document.querySelector('main#main');
135 if (mainContainer) {
136 let previousHeight = 0;
137 let currentHeight = mainContainer.scrollHeight;
138 let scrollAttempts = 0;
139
140 while (previousHeight !== currentHeight && scrollAttempts < 10) {
141 previousHeight = currentHeight;
142 mainContainer.scrollTo(0, mainContainer.scrollHeight);
143 await new Promise(resolve => setTimeout(resolve, 1000));
144 currentHeight = mainContainer.scrollHeight;
145 scrollAttempts++;
146 }
147
148 // Scroll back to top for better PDF rendering
149 mainContainer.scrollTo(0, 0);
150 await new Promise(resolve => setTimeout(resolve, 500));
151 }
152
153 console.log('Conversation loaded');
154 }
155
156 // Function to export current conversation as PDF using Chrome's Print to PDF
157 async function exportConversationAsPDF(title) {
158 console.log(`Exporting conversation: ${title}`);
159
160 try {
161 // Convert blob images to base64 first
162 await convertAllBlobImages();
163
164 // Wait a moment for images to update
165 await new Promise(resolve => setTimeout(resolve, 1000));
166
167 // Trigger Chrome's print dialog
168 const filename = sanitizeFilename(title) + '.pdf';
169
170 // Store the filename for the print dialog
171 await GM.setValue('current_pdf_filename', filename);
172
173 console.log(`Ready to print: ${filename}`);
174 console.log('Please use Ctrl+P (or Cmd+P on Mac) and select "Save as PDF"');
175
176 // We can't programmatically trigger the actual PDF save due to browser security,
177 // but we can open the print dialog
178 window.print();
179
180 // Wait for user to complete the print/save
181 await new Promise(resolve => setTimeout(resolve, 5000));
182
183 return filename;
184 } catch (error) {
185 console.error(`Error exporting ${title}:`, error);
186 throw error;
187 }
188 }
189
190 // Function to get all conversation links
191 function getAllConversationLinks() {
192 const historyContainer = document.querySelector('#history');
193 if (!historyContainer) {
194 return [];
195 }
196
197 const links = Array.from(historyContainer.querySelectorAll('a.chat-item[href*="/c/"]'));
198 return links.map(link => ({
199 title: link.querySelector('.truncate span')?.textContent?.trim() ||
200 link.querySelector('[title]')?.getAttribute('title') ||
201 'Untitled',
202 url: link.href,
203 element: link
204 }));
205 }
206
207 // Main export function
208 async function exportAllConversations() {
209 try {
210 console.log('Starting bulk export process...');
211
212 const exportBtn = document.getElementById('export-project-btn');
213 if (exportBtn) {
214 exportBtn.disabled = true;
215 exportBtn.textContent = 'Scrolling chats...';
216 }
217
218 // First, scroll through all chats to load them
219 await scrollChatList();
220
221 // Get all conversation links
222 const conversations = getAllConversationLinks();
223
224 if (conversations.length === 0) {
225 alert('No conversations found in this project.');
226 if (exportBtn) {
227 exportBtn.disabled = false;
228 exportBtn.textContent = 'Export All Chats to PDF';
229 }
230 return;
231 }
232
233 console.log(`Found ${conversations.length} conversations to export`);
234
235 // Store the list of conversations for processing
236 await GM.setValue('conversations_to_export', JSON.stringify(conversations.map(c => ({
237 title: c.title,
238 url: c.url
239 }))));
240 await GM.setValue('current_export_index', 0);
241 await GM.setValue('export_in_progress', true);
242
243 if (exportBtn) {
244 exportBtn.textContent = `Exporting 1/${conversations.length}...`;
245 }
246
247 // Navigate to the first conversation
248 window.location.href = conversations[0].url;
249
250 } catch (error) {
251 console.error('Export failed:', error);
252 alert('Export failed: ' + error.message);
253
254 const exportBtn = document.getElementById('export-project-btn');
255 if (exportBtn) {
256 exportBtn.disabled = false;
257 exportBtn.textContent = 'Export All Chats to PDF';
258 }
259
260 // Clean up
261 await GM.deleteValue('export_in_progress');
262 }
263 }
264
265 // Function to continue export process when on a conversation page
266 async function continueExportProcess() {
267 const exportInProgress = await GM.getValue('export_in_progress', false);
268
269 if (!exportInProgress) {
270 return;
271 }
272
273 try {
274 const conversationsJson = await GM.getValue('conversations_to_export', '[]');
275 const conversations = JSON.parse(conversationsJson);
276 const currentIndex = await GM.getValue('current_export_index', 0);
277
278 if (currentIndex >= conversations.length) {
279 // Export complete
280 console.log('All conversations exported!');
281 alert(`Export complete! ${conversations.length} conversations have been saved.\n\nPlease manually zip the PDF files from your Downloads folder.`);
282
283 // Clean up
284 await GM.deleteValue('export_in_progress');
285 await GM.deleteValue('conversations_to_export');
286 await GM.deleteValue('current_export_index');
287
288 // Navigate back to project page
289 const projectUrl = window.location.pathname.split('/c/')[0] + '/project';
290 window.location.href = projectUrl;
291 return;
292 }
293
294 const currentConversation = conversations[currentIndex];
295 console.log(`Processing conversation ${currentIndex + 1}/${conversations.length}: ${currentConversation.title}`);
296
297 // Wait for conversation to load
298 await waitForConversationLoad();
299
300 // Export as PDF
301 await exportConversationAsPDF(currentConversation.title);
302
303 // Update progress
304 await GM.setValue('current_export_index', currentIndex + 1);
305
306 // Show instructions to user
307 const nextIndex = currentIndex + 1;
308 if (nextIndex < conversations.length) {
309 const continueExport = confirm(
310 `Conversation "${currentConversation.title}" is ready to save.\n\n` +
311 `Please:\n` +
312 `1. Use Ctrl+P (or Cmd+P on Mac)\n` +
313 `2. Select "Save as PDF" as the destination\n` +
314 `3. Save the file\n\n` +
315 `Progress: ${nextIndex}/${conversations.length}\n\n` +
316 `Click OK to continue to the next conversation.`
317 );
318
319 if (continueExport) {
320 // Navigate to next conversation
321 window.location.href = conversations[nextIndex].url;
322 } else {
323 // User cancelled
324 await GM.deleteValue('export_in_progress');
325 await GM.deleteValue('conversations_to_export');
326 await GM.deleteValue('current_export_index');
327 alert('Export cancelled.');
328 }
329 } else {
330 alert(
331 `Last conversation "${currentConversation.title}" is ready to save.\n\n` +
332 `Please:\n` +
333 `1. Use Ctrl+P (or Cmd+P on Mac)\n` +
334 `2. Select "Save as PDF" as the destination\n` +
335 `3. Save the file\n\n` +
336 `This was the last conversation. Click OK to finish.`
337 );
338
339 // Clean up and return to project
340 await GM.deleteValue('export_in_progress');
341 await GM.deleteValue('conversations_to_export');
342 await GM.deleteValue('current_export_index');
343
344 const projectUrl = window.location.pathname.split('/c/')[0] + '/project';
345 window.location.href = projectUrl;
346 }
347
348 } catch (error) {
349 console.error('Error during export process:', error);
350 alert('Error during export: ' + error.message);
351
352 // Clean up
353 await GM.deleteValue('export_in_progress');
354 await GM.deleteValue('conversations_to_export');
355 await GM.deleteValue('current_export_index');
356 }
357 }
358
359 // Function to add export button to project page
360 function addExportButton() {
361 // Check if we're on a project page
362 if (!window.location.pathname.includes('/project')) {
363 console.log('Not on a project page, skipping button injection');
364 return;
365 }
366
367 // Check if button already exists
368 if (document.getElementById('export-project-btn')) {
369 console.log('Export button already exists');
370 return;
371 }
372
373 console.log('Adding export button to project page');
374
375 // Create button container
376 const buttonContainer = document.createElement('div');
377 buttonContainer.id = 'export-project-container';
378 buttonContainer.style.cssText = `
379 position: fixed;
380 top: 20px;
381 right: 20px;
382 z-index: 9999;
383 background: white;
384 padding: 10px;
385 border-radius: 8px;
386 box-shadow: 0 2px 8px rgba(0,0,0,0.15);
387 `;
388
389 // Create export button
390 const exportButton = document.createElement('button');
391 exportButton.id = 'export-project-btn';
392 exportButton.textContent = 'Export All Chats to PDF';
393 exportButton.style.cssText = `
394 background: #10a37f;
395 color: white;
396 border: none;
397 padding: 10px 20px;
398 border-radius: 6px;
399 font-size: 14px;
400 font-weight: 600;
401 cursor: pointer;
402 transition: background 0.2s;
403 `;
404
405 exportButton.onmouseover = () => {
406 exportButton.style.background = '#0d8c6d';
407 };
408
409 exportButton.onmouseout = () => {
410 exportButton.style.background = '#10a37f';
411 };
412
413 exportButton.onclick = exportAllConversations;
414
415 buttonContainer.appendChild(exportButton);
416 document.body.appendChild(buttonContainer);
417
418 console.log('Export button added successfully');
419 }
420
421 // Initialize the extension
422 async function init() {
423 console.log('Initializing ChatGPT Project Bulk PDF Exporter');
424
425 // Check if we're in the middle of an export process
426 const exportInProgress = await GM.getValue('export_in_progress', false);
427
428 if (exportInProgress && window.location.pathname.includes('/c/')) {
429 // We're on a conversation page during export
430 console.log('Continuing export process...');
431 setTimeout(continueExportProcess, 2000);
432 } else if (window.location.pathname.includes('/project')) {
433 // We're on a project page
434 if (document.readyState === 'loading') {
435 document.addEventListener('DOMContentLoaded', () => {
436 setTimeout(addExportButton, 2000);
437 });
438 } else {
439 setTimeout(addExportButton, 2000);
440 }
441
442 // Watch for navigation changes (SPA)
443 let lastUrl = location.href;
444 new MutationObserver(() => {
445 const currentUrl = location.href;
446 if (currentUrl !== lastUrl) {
447 lastUrl = currentUrl;
448 console.log('URL changed to:', currentUrl);
449 if (currentUrl.includes('/project')) {
450 setTimeout(addExportButton, 2000);
451 }
452 }
453 }).observe(document.body, { childList: true, subtree: true });
454 }
455 }
456
457 // Start the extension
458 init();
459})();