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})();