ChatGPT Project Bulk PDF Exporter

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})();
ChatGPT Project Bulk PDF Exporter | Robomonkey