ChatGPT Project PDF Exporter

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

Size

18.9 KB

Version

1.1.1

Created

Oct 23, 2025

Updated

about 2 months 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.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// ==/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    // Function to fetch conversation data via API
321    async function fetchConversationData(conversationId) {
322        try {
323            console.log(`Fetching conversation data for: ${conversationId}`);
324            
325            const response = await GM.xmlhttpRequest({
326                method: 'GET',
327                url: `https://chatgpt.com/backend-api/conversation/${conversationId}`,
328                headers: {
329                    'Accept': 'application/json',
330                    'Content-Type': 'application/json'
331                },
332                responseType: 'json'
333            });
334            
335            if (response.status === 200) {
336                return response.response;
337            } else {
338                console.error(`Failed to fetch conversation ${conversationId}: ${response.status}`);
339                return null;
340            }
341        } catch (error) {
342            console.error(`Error fetching conversation ${conversationId}:`, error);
343            return null;
344        }
345    }
346
347    // Function to extract messages from conversation data
348    function extractMessagesFromData(conversationData) {
349        const messages = [];
350        
351        if (!conversationData || !conversationData.mapping) {
352            return messages;
353        }
354        
355        // Build message tree from mapping
356        const mapping = conversationData.mapping;
357        const rootId = Object.keys(mapping).find(id => !mapping[id].parent);
358        
359        function traverseMessages(nodeId) {
360            const node = mapping[nodeId];
361            if (!node) return;
362            
363            const message = node.message;
364            if (message && message.content && message.content.parts) {
365                const role = message.author.role;
366                const content = message.content.parts.join('\n');
367                
368                if (content.trim()) {
369                    messages.push({
370                        role: role,
371                        content: content.trim(),
372                        images: [] // API doesn't include images in the same way
373                    });
374                }
375            }
376            
377            // Process children
378            if (node.children && node.children.length > 0) {
379                // Follow the first child (main conversation path)
380                traverseMessages(node.children[0]);
381            }
382        }
383        
384        if (rootId) {
385            traverseMessages(rootId);
386        }
387        
388        return messages;
389    }
390
391    // Main export function
392    async function exportProjectToPDFs() {
393        try {
394            console.log('Starting project export...');
395            
396            // Get all conversations in the project
397            const conversations = await getProjectConversations();
398            
399            if (conversations.length === 0) {
400                alert('No conversations found in this project.');
401                return;
402            }
403            
404            showProgress('Preparing to export...', 0, conversations.length);
405            
406            // Process each conversation without navigating
407            for (let i = 0; i < conversations.length; i++) {
408                const conversation = conversations[i];
409                showProgress(`Processing: ${conversation.title}`, i, conversations.length);
410                
411                // Fetch conversation data via API
412                const conversationData = await fetchConversationData(conversation.id);
413                
414                if (!conversationData) {
415                    console.log(`Failed to fetch data for conversation: ${conversation.title}`);
416                    continue;
417                }
418                
419                // Extract messages from the data
420                const messages = extractMessagesFromData(conversationData);
421                
422                if (messages.length === 0) {
423                    console.log(`No messages found in conversation: ${conversation.title}`);
424                    continue;
425                }
426                
427                // Create PDF
428                const pdf = await createPDFFromConversation(conversation.title, messages);
429                
430                // Save PDF
431                const filename = `${conversation.title.replace(/[^a-z0-9]/gi, '_')}.pdf`;
432                pdf.save(filename);
433                
434                // Small delay between downloads
435                await new Promise(resolve => setTimeout(resolve, 500));
436            }
437            
438            showProgress('Export complete!', conversations.length, conversations.length);
439            
440            setTimeout(() => {
441                hideProgress();
442                alert(`Successfully exported ${conversations.length} conversations as PDFs!`);
443            }, 2000);
444            
445        } catch (error) {
446            console.error('Error exporting project:', error);
447            hideProgress();
448            alert('An error occurred while exporting. Please check the console for details.');
449        }
450    }
451
452    // Function to add export button to the page
453    function addExportButton() {
454        // Check if we're on a project page
455        if (!window.location.href.includes('/project')) {
456            console.log('Not on a project page, skipping button injection');
457            return;
458        }
459        
460        // Check if button already exists
461        if (document.getElementById('pdf-export-btn')) {
462            return;
463        }
464        
465        // Find the project header area to add the button
466        const headerArea = document.querySelector('header') || document.querySelector('nav');
467        
468        if (!headerArea) {
469            console.log('Header area not found, will retry...');
470            return;
471        }
472        
473        // Create export button
474        const exportButton = document.createElement('button');
475        exportButton.id = 'pdf-export-btn';
476        exportButton.className = 'pdf-export-button';
477        exportButton.innerHTML = `
478            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
479                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
480                <polyline points="7 10 12 15 17 10"></polyline>
481                <line x1="12" y1="15" x2="12" y2="3"></line>
482            </svg>
483            Export Project to PDFs
484        `;
485        
486        exportButton.addEventListener('click', async () => {
487            exportButton.disabled = true;
488            exportButton.textContent = 'Exporting...';
489            
490            await exportProjectToPDFs();
491            
492            exportButton.disabled = false;
493            exportButton.innerHTML = `
494                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
495                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
496                    <polyline points="7 10 12 15 17 10"></polyline>
497                    <line x1="12" y1="15" x2="12" y2="3"></line>
498                </svg>
499                Export Project to PDFs
500            `;
501        });
502        
503        // Insert button into the header
504        headerArea.appendChild(exportButton);
505        console.log('Export button added successfully');
506    }
507
508    // Initialize the extension
509    function init() {
510        console.log('Initializing ChatGPT Project PDF Exporter...');
511        
512        // Add button when page loads
513        TM_runBody(() => {
514            setTimeout(addExportButton, 2000);
515            
516            // Watch for navigation changes
517            const observer = new MutationObserver(() => {
518                addExportButton();
519            });
520            
521            observer.observe(document.body, {
522                childList: true,
523                subtree: true
524            });
525        });
526    }
527
528    // Start the extension
529    init();
530})();
ChatGPT Project PDF Exporter | Robomonkey