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