ChatGPT Project PDF Exporter

Export entire ChatGPT project folders with each conversation as a separate PDF including images

Size

16.7 KB

Version

1.0.1

Created

Oct 23, 2025

Updated

about 5 hours ago

1// ==UserScript==
2// @name		ChatGPT Project PDF Exporter
3// @description		Export entire ChatGPT project folders with each conversation as a separate PDF including images
4// @version		1.0.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// ==/UserScript==
10(function() {
11    'use strict';
12
13    console.log('ChatGPT Project PDF Exporter loaded');
14
15    // Initialize jsPDF from the global scope
16    const { jsPDF } = window.jspdf;
17
18    // Add custom styles for the export button
19    TM_addStyle(`
20        .pdf-export-button {
21            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
22            color: white;
23            border: none;
24            border-radius: 8px;
25            padding: 10px 20px;
26            font-size: 14px;
27            font-weight: 600;
28            cursor: pointer;
29            display: flex;
30            align-items: center;
31            gap: 8px;
32            transition: all 0.3s ease;
33            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
34            margin: 10px;
35        }
36        
37        .pdf-export-button:hover {
38            transform: translateY(-2px);
39            box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
40        }
41        
42        .pdf-export-button:disabled {
43            opacity: 0.6;
44            cursor: not-allowed;
45            transform: none;
46        }
47        
48        .pdf-export-progress {
49            position: fixed;
50            top: 50%;
51            left: 50%;
52            transform: translate(-50%, -50%);
53            background: white;
54            padding: 30px;
55            border-radius: 12px;
56            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
57            z-index: 10000;
58            min-width: 300px;
59            text-align: center;
60        }
61        
62        .pdf-export-progress h3 {
63            margin: 0 0 15px 0;
64            color: #333;
65        }
66        
67        .pdf-export-progress p {
68            margin: 10px 0;
69            color: #666;
70        }
71        
72        .pdf-progress-bar {
73            width: 100%;
74            height: 8px;
75            background: #e0e0e0;
76            border-radius: 4px;
77            overflow: hidden;
78            margin: 15px 0;
79        }
80        
81        .pdf-progress-fill {
82            height: 100%;
83            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
84            transition: width 0.3s ease;
85        }
86    `);
87
88    // Function to show progress dialog
89    function showProgress(message, current, total) {
90        let progressDialog = document.getElementById('pdf-export-progress');
91        
92        if (!progressDialog) {
93            progressDialog = document.createElement('div');
94            progressDialog.id = 'pdf-export-progress';
95            progressDialog.className = 'pdf-export-progress';
96            document.body.appendChild(progressDialog);
97        }
98        
99        const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
100        
101        progressDialog.innerHTML = `
102            <h3>📄 Exporting Project to PDFs</h3>
103            <p>${message}</p>
104            <div class="pdf-progress-bar">
105                <div class="pdf-progress-fill" style="width: ${percentage}%"></div>
106            </div>
107            <p><strong>${current} of ${total}</strong> conversations processed</p>
108        `;
109    }
110
111    // Function to hide progress dialog
112    function hideProgress() {
113        const progressDialog = document.getElementById('pdf-export-progress');
114        if (progressDialog) {
115            progressDialog.remove();
116        }
117    }
118
119    // Function to convert image to base64
120    async function imageToBase64(imgElement) {
121        try {
122            const canvas = document.createElement('canvas');
123            const ctx = canvas.getContext('2d');
124            canvas.width = imgElement.naturalWidth || imgElement.width;
125            canvas.height = imgElement.naturalHeight || imgElement.height;
126            ctx.drawImage(imgElement, 0, 0);
127            return canvas.toDataURL('image/png');
128        } catch (error) {
129            console.error('Error converting image to base64:', error);
130            return null;
131        }
132    }
133
134    // Function to extract conversation messages
135    async function extractConversationMessages() {
136        console.log('Extracting conversation messages...');
137        const messages = [];
138        
139        // Find all message groups in the conversation
140        const messageElements = document.querySelectorAll('article[data-testid^="conversation-turn-"]');
141        console.log(`Found ${messageElements.length} message elements`);
142        
143        for (const messageEl of messageElements) {
144            try {
145                // Determine if it's a user or assistant message
146                const isUser = messageEl.querySelector('[data-message-author-role="user"]') !== null;
147                const isAssistant = messageEl.querySelector('[data-message-author-role="assistant"]') !== null;
148                
149                const role = isUser ? 'user' : (isAssistant ? 'assistant' : 'unknown');
150                
151                // Get the message content container
152                const contentContainer = messageEl.querySelector('[data-message-author-role] > div > div');
153                
154                if (!contentContainer) continue;
155                
156                // Extract text content
157                let textContent = '';
158                const textElements = contentContainer.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, pre, code');
159                textElements.forEach(el => {
160                    textContent += el.textContent + '\n';
161                });
162                
163                // Extract images
164                const images = [];
165                const imgElements = contentContainer.querySelectorAll('img');
166                for (const img of imgElements) {
167                    if (img.src && !img.src.includes('data:image/svg')) {
168                        const base64 = await imageToBase64(img);
169                        if (base64) {
170                            images.push({
171                                src: base64,
172                                width: img.naturalWidth || img.width,
173                                height: img.naturalHeight || img.height
174                            });
175                        }
176                    }
177                }
178                
179                if (textContent.trim() || images.length > 0) {
180                    messages.push({
181                        role: role,
182                        content: textContent.trim(),
183                        images: images
184                    });
185                }
186            } catch (error) {
187                console.error('Error extracting message:', error);
188            }
189        }
190        
191        console.log(`Extracted ${messages.length} messages`);
192        return messages;
193    }
194
195    // Function to create PDF from conversation
196    async function createPDFFromConversation(conversationTitle, messages) {
197        console.log(`Creating PDF for: ${conversationTitle}`);
198        
199        const doc = new jsPDF({
200            orientation: 'portrait',
201            unit: 'mm',
202            format: 'a4'
203        });
204        
205        const pageWidth = doc.internal.pageSize.getWidth();
206        const pageHeight = doc.internal.pageSize.getHeight();
207        const margin = 15;
208        const maxWidth = pageWidth - (margin * 2);
209        let yPosition = margin;
210        
211        // Add title
212        doc.setFontSize(18);
213        doc.setFont(undefined, 'bold');
214        doc.text(conversationTitle, margin, yPosition);
215        yPosition += 10;
216        
217        // Add date
218        doc.setFontSize(10);
219        doc.setFont(undefined, 'normal');
220        doc.setTextColor(128, 128, 128);
221        doc.text(`Exported: ${new Date().toLocaleString()}`, margin, yPosition);
222        yPosition += 15;
223        
224        // Process each message
225        for (let i = 0; i < messages.length; i++) {
226            const message = messages[i];
227            
228            // Check if we need a new page
229            if (yPosition > pageHeight - 30) {
230                doc.addPage();
231                yPosition = margin;
232            }
233            
234            // Add role label
235            doc.setFontSize(12);
236            doc.setFont(undefined, 'bold');
237            if (message.role === 'user') {
238                doc.setTextColor(0, 102, 204);
239                doc.text('User:', margin, yPosition);
240            } else if (message.role === 'assistant') {
241                doc.setTextColor(16, 163, 127);
242                doc.text('Assistant:', margin, yPosition);
243            }
244            yPosition += 7;
245            
246            // Add message content
247            doc.setFontSize(10);
248            doc.setFont(undefined, 'normal');
249            doc.setTextColor(0, 0, 0);
250            
251            if (message.content) {
252                const lines = doc.splitTextToSize(message.content, maxWidth);
253                for (const line of lines) {
254                    if (yPosition > pageHeight - 20) {
255                        doc.addPage();
256                        yPosition = margin;
257                    }
258                    doc.text(line, margin, yPosition);
259                    yPosition += 5;
260                }
261            }
262            
263            // Add images
264            for (const image of message.images) {
265                const imgWidth = Math.min(maxWidth, 150);
266                const imgHeight = (image.height / image.width) * imgWidth;
267                
268                if (yPosition + imgHeight > pageHeight - 20) {
269                    doc.addPage();
270                    yPosition = margin;
271                }
272                
273                try {
274                    doc.addImage(image.src, 'PNG', margin, yPosition, imgWidth, imgHeight);
275                    yPosition += imgHeight + 5;
276                } catch (error) {
277                    console.error('Error adding image to PDF:', error);
278                }
279            }
280            
281            yPosition += 5;
282            
283            // Add page break after assistant messages
284            if (message.role === 'assistant' && i < messages.length - 1) {
285                doc.addPage();
286                yPosition = margin;
287            }
288        }
289        
290        return doc;
291    }
292
293    // Function to get all conversations in the project
294    async function getProjectConversations() {
295        console.log('Getting project conversations...');
296        const conversations = [];
297        
298        // Find all conversation links in the sidebar
299        const conversationLinks = document.querySelectorAll('a[href^="/c/"]');
300        console.log(`Found ${conversationLinks.length} conversation links`);
301        
302        for (const link of conversationLinks) {
303            const href = link.getAttribute('href');
304            const titleElement = link.querySelector('div[class*="truncate"]') || link;
305            const title = titleElement.textContent.trim() || 'Untitled Conversation';
306            
307            if (href && href.startsWith('/c/')) {
308                conversations.push({
309                    id: href.split('/c/')[1],
310                    title: title,
311                    url: `https://chatgpt.com${href}`
312                });
313            }
314        }
315        
316        console.log(`Found ${conversations.length} conversations`);
317        return conversations;
318    }
319
320    // Main export function
321    async function exportProjectToPDFs() {
322        try {
323            console.log('Starting project export...');
324            
325            // Get all conversations in the project
326            const conversations = await getProjectConversations();
327            
328            if (conversations.length === 0) {
329                alert('No conversations found in this project.');
330                return;
331            }
332            
333            showProgress('Preparing to export...', 0, conversations.length);
334            
335            // Store current URL to return to it later
336            const currentUrl = window.location.href;
337            
338            // Process each conversation
339            for (let i = 0; i < conversations.length; i++) {
340                const conversation = conversations[i];
341                showProgress(`Processing: ${conversation.title}`, i, conversations.length);
342                
343                // Navigate to the conversation
344                if (window.location.href !== conversation.url) {
345                    window.location.href = conversation.url;
346                    // Wait for page to load
347                    await new Promise(resolve => setTimeout(resolve, 3000));
348                }
349                
350                // Extract messages
351                const messages = await extractConversationMessages();
352                
353                if (messages.length === 0) {
354                    console.log(`No messages found in conversation: ${conversation.title}`);
355                    continue;
356                }
357                
358                // Create PDF
359                const pdf = await createPDFFromConversation(conversation.title, messages);
360                
361                // Save PDF
362                const filename = `${conversation.title.replace(/[^a-z0-9]/gi, '_')}.pdf`;
363                pdf.save(filename);
364                
365                // Wait a bit before processing next conversation
366                await new Promise(resolve => setTimeout(resolve, 2000));
367            }
368            
369            showProgress('Export complete!', conversations.length, conversations.length);
370            
371            // Return to original URL
372            if (window.location.href !== currentUrl) {
373                window.location.href = currentUrl;
374            }
375            
376            setTimeout(() => {
377                hideProgress();
378                alert(`Successfully exported ${conversations.length} conversations as PDFs!`);
379            }, 2000);
380            
381        } catch (error) {
382            console.error('Error exporting project:', error);
383            hideProgress();
384            alert('An error occurred while exporting. Please check the console for details.');
385        }
386    }
387
388    // Function to add export button to the page
389    function addExportButton() {
390        // Check if we're on a project page
391        if (!window.location.href.includes('/project')) {
392            console.log('Not on a project page, skipping button injection');
393            return;
394        }
395        
396        // Check if button already exists
397        if (document.getElementById('pdf-export-btn')) {
398            return;
399        }
400        
401        // Find the project header area to add the button
402        const headerArea = document.querySelector('header') || document.querySelector('nav');
403        
404        if (!headerArea) {
405            console.log('Header area not found, will retry...');
406            return;
407        }
408        
409        // Create export button
410        const exportButton = document.createElement('button');
411        exportButton.id = 'pdf-export-btn';
412        exportButton.className = 'pdf-export-button';
413        exportButton.innerHTML = `
414            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
415                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
416                <polyline points="7 10 12 15 17 10"></polyline>
417                <line x1="12" y1="15" x2="12" y2="3"></line>
418            </svg>
419            Export Project to PDFs
420        `;
421        
422        exportButton.addEventListener('click', async () => {
423            exportButton.disabled = true;
424            exportButton.textContent = 'Exporting...';
425            
426            await exportProjectToPDFs();
427            
428            exportButton.disabled = false;
429            exportButton.innerHTML = `
430                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
431                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
432                    <polyline points="7 10 12 15 17 10"></polyline>
433                    <line x1="12" y1="15" x2="12" y2="3"></line>
434                </svg>
435                Export Project to PDFs
436            `;
437        });
438        
439        // Insert button into the header
440        headerArea.appendChild(exportButton);
441        console.log('Export button added successfully');
442    }
443
444    // Initialize the extension
445    function init() {
446        console.log('Initializing ChatGPT Project PDF Exporter...');
447        
448        // Add button when page loads
449        TM_runBody(() => {
450            setTimeout(addExportButton, 2000);
451            
452            // Watch for navigation changes
453            const observer = new MutationObserver(() => {
454                addExportButton();
455            });
456            
457            observer.observe(document.body, {
458                childList: true,
459                subtree: true
460            });
461        });
462    }
463
464    // Start the extension
465    init();
466})();
ChatGPT Project PDF Exporter | Robomonkey