decant-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ai/chatgpt.js ADDED
@@ -0,0 +1,973 @@
1
+ import { ChatParser } from './base.js';
2
+ import { convertToMarkdown } from '../utils/html-to-markdown.js';
3
+ import {
4
+ collectMountedTurnMessages,
5
+ findChatGPTScrollRoot,
6
+ getConversationTurns,
7
+ } from './chatgpt_scroll_collector.js';
8
+
9
+ function getAccessToken() {
10
+ try {
11
+ const el = typeof document !== 'undefined' && document.getElementById('client-bootstrap');
12
+ if (!el) return null;
13
+ return JSON.parse(el.textContent).session.accessToken;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function getConversationId() {
20
+ try {
21
+ if (typeof window === 'undefined' || !window.location) return null;
22
+ const path = window.location.pathname || window.location.href || '';
23
+ const match = path.match(/\/(?:c|share|g\/[^/]+\/c)\/([^/?#]+)/);
24
+ return match ? match[1] : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function fetchConversation(convId, token, includeImages) {
31
+ return new Promise((resolve, reject) => {
32
+ const requestId =
33
+ typeof crypto !== 'undefined' && crypto.randomUUID
34
+ ? crypto.randomUUID()
35
+ : Math.random().toString(36).substring(2) + Date.now().toString(36);
36
+
37
+ const handler = (event) => {
38
+ if (event.data?.source !== 'chatgpt-exporter-page') return;
39
+ if (event.data?.requestId !== requestId) return;
40
+ window.removeEventListener('message', handler);
41
+ clearTimeout(timer);
42
+ if (event.data.error) {
43
+ reject(new Error(event.data.error));
44
+ } else {
45
+ resolve({ data: event.data.data, images: event.data.images ?? {} });
46
+ }
47
+ };
48
+
49
+ const timer = setTimeout(() => {
50
+ window.removeEventListener('message', handler);
51
+ reject(new Error('Request timed out'));
52
+ }, 45000);
53
+
54
+ window.addEventListener('message', handler);
55
+ window.postMessage(
56
+ {
57
+ source: 'chatgpt-exporter-ext',
58
+ type: 'fetch_conversation',
59
+ convId,
60
+ token,
61
+ requestId,
62
+ includeImages,
63
+ },
64
+ 'https://chatgpt.com',
65
+ );
66
+ });
67
+ }
68
+
69
+ function findLeafFromNode(mapping, startNodeId) {
70
+ let nodeId = startNodeId;
71
+ if (!nodeId || !mapping[nodeId]) return null;
72
+ let node = mapping[nodeId];
73
+ const visited = new Set();
74
+ while (node?.children?.length) {
75
+ if (visited.has(nodeId)) break;
76
+ visited.add(nodeId);
77
+ const lastChildId = node.children[node.children.length - 1];
78
+ if (!mapping[lastChildId]) break;
79
+ node = mapping[lastChildId];
80
+ nodeId = lastChildId;
81
+ }
82
+ return nodeId;
83
+ }
84
+
85
+ function resolveActiveLeafNode(mapping, currentNodeId) {
86
+ // 1. Try DOM elements first (captures branch if user switched turns in UI)
87
+ if (typeof document !== 'undefined' && document.querySelectorAll) {
88
+ const msgEls = Array.from(
89
+ document.querySelectorAll(
90
+ 'div[data-message-id], [data-message-author-role][data-message-id], section[data-turn-id], article[data-turn-id]',
91
+ ),
92
+ );
93
+ for (let i = msgEls.length - 1; i >= 0; i--) {
94
+ const el = msgEls[i];
95
+ const id =
96
+ el.dataset?.messageId || el.dataset?.turnId || el.getAttribute?.('data-message-id');
97
+ if (id && mapping[id]) {
98
+ const leaf = findLeafFromNode(mapping, id);
99
+ if (leaf) return leaf;
100
+ }
101
+ }
102
+ }
103
+
104
+ // 2. Fall back to currentNodeId from API
105
+ if (currentNodeId && mapping[currentNodeId]) {
106
+ return findLeafFromNode(mapping, currentNodeId);
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ export function extractSharedConversationFromDom(
113
+ doc = typeof document !== 'undefined' ? document : null,
114
+ ) {
115
+ if (!doc || typeof doc.querySelectorAll !== 'function') return null;
116
+
117
+ function findMappingInObj(obj, seen = new Set()) {
118
+ if (!obj || typeof obj !== 'object' || seen.has(obj)) return null;
119
+ seen.add(obj);
120
+
121
+ if (obj.mapping && typeof obj.mapping === 'object' && Object.keys(obj.mapping).length > 0) {
122
+ return obj;
123
+ }
124
+ if (
125
+ obj.data &&
126
+ obj.data.mapping &&
127
+ typeof obj.data.mapping === 'object' &&
128
+ Object.keys(obj.data.mapping).length > 0
129
+ ) {
130
+ return obj.data;
131
+ }
132
+
133
+ if (Array.isArray(obj)) {
134
+ for (const item of obj) {
135
+ const found = findMappingInObj(item, seen);
136
+ if (found) return found;
137
+ }
138
+ } else {
139
+ for (const val of Object.values(obj)) {
140
+ const found = findMappingInObj(val, seen);
141
+ if (found) return found;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ const scripts = Array.from(doc.querySelectorAll('script'));
148
+ for (const script of scripts) {
149
+ const text = script.textContent || '';
150
+ if (!text || (!text.includes('"mapping"') && !text.includes('current_node'))) continue;
151
+
152
+ try {
153
+ const parsed = JSON.parse(text);
154
+ const convo = findMappingInObj(parsed);
155
+ if (convo) return convo;
156
+ } catch {
157
+ const matches = text.match(/\{[\s\S]*"mapping"[\s\S]*\}/g);
158
+ if (matches) {
159
+ for (const match of matches) {
160
+ try {
161
+ const parsed = JSON.parse(match);
162
+ const convo = findMappingInObj(parsed);
163
+ if (convo) return convo;
164
+ } catch {
165
+ // Ignore JSON parse errors in script segments
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ const bootstrapEl = doc.getElementById?.('client-bootstrap');
173
+ if (bootstrapEl) {
174
+ try {
175
+ const parsed = JSON.parse(bootstrapEl.textContent);
176
+ const convo = findMappingInObj(parsed);
177
+ if (convo) return convo;
178
+ } catch {
179
+ // Ignore client-bootstrap JSON parse errors
180
+ }
181
+ }
182
+
183
+ return null;
184
+ }
185
+
186
+ export function linearize(mapping, includeImages, currentNodeId) {
187
+ let path = [];
188
+ const leafId = resolveActiveLeafNode(mapping, currentNodeId);
189
+
190
+ if (leafId) {
191
+ let id = leafId;
192
+ const visited = new Set();
193
+ while (id && mapping[id] && !visited.has(id)) {
194
+ visited.add(id);
195
+ path.push(mapping[id]);
196
+ id = mapping[id].parent;
197
+ }
198
+ path.reverse();
199
+ } else {
200
+ const root = Object.values(mapping).find((n) => !n.parent || !mapping[n.parent]);
201
+ if (!root) return [];
202
+
203
+ const subtreeSize = {};
204
+ function size(id) {
205
+ if (id in subtreeSize) return subtreeSize[id];
206
+ const node = mapping[id];
207
+ if (!node) return (subtreeSize[id] = 0);
208
+ const childSizes = (node.children ?? []).map((cid) => size(cid));
209
+ return (subtreeSize[id] = 1 + (childSizes.length ? Math.max(...childSizes) : 0));
210
+ }
211
+ for (const id of Object.keys(mapping)) size(id);
212
+
213
+ let node = root;
214
+ const visited = new Set();
215
+ while (node && !visited.has(node.id)) {
216
+ visited.add(node.id);
217
+ path.push(node);
218
+ const validChildren = (node.children ?? []).filter((cid) => cid in mapping);
219
+ node = validChildren.length
220
+ ? mapping[
221
+ validChildren.reduce((best, cid) => (subtreeSize[cid] > subtreeSize[best] ? cid : best))
222
+ ]
223
+ : null;
224
+ }
225
+ }
226
+
227
+ const messages = [];
228
+
229
+ for (const node of path) {
230
+ const msg = node.message;
231
+ if (!msg) continue;
232
+ if (msg.metadata?.is_visually_hidden_from_conversation === true) continue;
233
+
234
+ const role = msg?.author?.role;
235
+ const authorName = msg?.author?.name;
236
+ const isThoughtMsg =
237
+ authorName === 'thought' ||
238
+ msg?.recipient === 'thought' ||
239
+ msg?.content?.content_type === 'thought' ||
240
+ msg?.content?.content_type === 'thoughts' ||
241
+ msg?.metadata?.reasoning_status === 'is_reasoning';
242
+
243
+ if (role === 'user' || role === 'assistant' || role === 'tool' || isThoughtMsg) {
244
+ const segments = [];
245
+ const parts = msg?.content?.parts ?? [];
246
+
247
+ for (const part of parts) {
248
+ let partText = '';
249
+ let isThoughtPart = isThoughtMsg;
250
+
251
+ if (typeof part === 'string') {
252
+ partText = part;
253
+ } else if (part && typeof part === 'object') {
254
+ if (part.content_type === 'text' && typeof part.text === 'string') {
255
+ partText = part.text;
256
+ } else if (part.content_type === 'thought' && typeof part.text === 'string') {
257
+ partText = part.text;
258
+ isThoughtPart = true;
259
+ } else if (part.content_type === 'audio_transcription' && typeof part.text === 'string') {
260
+ partText = part.text;
261
+ }
262
+ }
263
+
264
+ if (partText && role !== 'tool') {
265
+ const text = partText
266
+ .replace(/\u{E0000}[\u{E0000}-\u{E007F}]*/gu, '')
267
+ .replace(/citeturn\d+\w*/g, '')
268
+ .trim();
269
+ if (text) {
270
+ if (isThoughtPart) {
271
+ segments.push({ type: 'thought', content: text });
272
+ } else {
273
+ segments.push({ type: 'text', content: text });
274
+ }
275
+ }
276
+ } else if (
277
+ includeImages &&
278
+ part?.content_type === 'image_asset_pointer' &&
279
+ part?.asset_pointer
280
+ ) {
281
+ segments.push({ type: 'image', fileId: part.asset_pointer.split('://')[1] });
282
+ }
283
+ }
284
+
285
+ // Handle standalone content.text (e.g. execution_output or plain text)
286
+ if (typeof msg.content?.text === 'string' && msg.content.text.trim() && parts.length === 0) {
287
+ segments.push({ type: 'text', content: msg.content.text.trim() });
288
+ }
289
+
290
+ // Handle o1/o3/o4 reasoning thoughts array: content.thoughts = [{ summary, content }]
291
+ if (Array.isArray(msg.content?.thoughts) && msg.content.thoughts.length > 0) {
292
+ const thoughtParts = msg.content.thoughts
293
+ .map((t) => (t.summary ? `**${t.summary}**\n${t.content || ''}` : t.content || ''))
294
+ .filter(Boolean);
295
+ if (thoughtParts.length > 0) {
296
+ segments.push({ type: 'thought', content: thoughtParts.join('\n\n') });
297
+ }
298
+ }
299
+
300
+ // Handle reasoning recap
301
+ if (
302
+ (msg.content?.content_type === 'reasoning_recap' || msg.content?.content) &&
303
+ typeof msg.content.content === 'string' &&
304
+ msg.content.content.trim()
305
+ ) {
306
+ segments.push({ type: 'thought', content: msg.content.content.trim() });
307
+ }
308
+
309
+ // Handle Deep Research reports (widget_state)
310
+ const widgetRaw =
311
+ msg.metadata?.chatgpt_sdk?.widget_state ||
312
+ msg.metadata?.tool_response_metadata?.venus_widget_state;
313
+ if (widgetRaw) {
314
+ try {
315
+ const widget = typeof widgetRaw === 'string' ? JSON.parse(widgetRaw) : widgetRaw;
316
+ const reportText = widget.report_message?.content?.parts?.[0] || widget.markdown;
317
+ const steering = widget.steering_acknowledgement;
318
+ let researchContent = '';
319
+ if (steering) researchContent += `${steering}\n\n`;
320
+ if (reportText) researchContent += reportText;
321
+ if (researchContent.trim()) {
322
+ segments.push({ type: 'text', content: researchContent.trim() });
323
+ }
324
+ } catch {
325
+ // Ignore widget state JSON parse errors
326
+ }
327
+ }
328
+
329
+ // Handle attachments
330
+ if (Array.isArray(msg.metadata?.attachments) && msg.metadata.attachments.length > 0) {
331
+ const fileNames = msg.metadata.attachments.map((att) => att.name).filter(Boolean);
332
+ if (fileNames.length > 0) {
333
+ segments.push({ type: 'text', content: `[Attached: ${fileNames.join(', ')}]` });
334
+ }
335
+ }
336
+
337
+ // Handle Canvas documents
338
+ if (msg.metadata?.canvas?.title) {
339
+ segments.push({ type: 'text', content: `[Canvas: ${msg.metadata.canvas.title}]` });
340
+ }
341
+
342
+ const displayRole = role === 'user' ? 'User' : 'ChatGPT';
343
+ const timestamp = msg?.create_time ? new Date(msg.create_time * 1000).toLocaleString() : null;
344
+
345
+ if (segments.length) {
346
+ const citeMap = {};
347
+ const imageGroupMap = {};
348
+ for (const ref of msg?.metadata?.content_references ?? []) {
349
+ if (ref.matched_text) {
350
+ if (ref.items?.length) citeMap[ref.matched_text] = ref.items;
351
+ if (ref.type === 'image_group' || ref.matched_text.includes('image_group')) {
352
+ imageGroupMap[ref.matched_text] = ref;
353
+ }
354
+ }
355
+ }
356
+
357
+ // If previous message is also ChatGPT, merge segments (thoughts in front, content in back)
358
+ if (
359
+ displayRole === 'ChatGPT' &&
360
+ messages.length > 0 &&
361
+ messages[messages.length - 1].role === 'ChatGPT'
362
+ ) {
363
+ const prevMsg = messages[messages.length - 1];
364
+ if (isThoughtMsg) {
365
+ prevMsg.segments.unshift(...segments);
366
+ } else {
367
+ prevMsg.segments.push(...segments);
368
+ }
369
+ Object.assign(prevMsg.citeMap, citeMap);
370
+ Object.assign(prevMsg.imageGroupMap, imageGroupMap);
371
+ if (timestamp && !prevMsg.timestamp) {
372
+ prevMsg.timestamp = timestamp;
373
+ }
374
+ } else {
375
+ messages.push({ role: displayRole, segments, citeMap, imageGroupMap, timestamp });
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ return messages;
382
+ }
383
+
384
+ function cleanMarkdownFromApi(text, citeMap, imageGroupMap) {
385
+ if (!text) return '';
386
+
387
+ // 1. Remove specific character ranges (like some PUA ranges)
388
+ text = text
389
+ .replace(/\u{E0000}[\u{E0000}-\u{E007F}]*/gu, '')
390
+ .replace(/citeturn\d+\w*/g, '')
391
+ .trim();
392
+
393
+ // 2. Replace ChatGPT PUA URL annotations: url{label}{href}
394
+ text = text.replace(
395
+ /\uE200url\uE202([^\uE202\uE201]+)\uE202([^\uE201]+)\uE201/g,
396
+ (_, label, href) => `[${label.trim()}](${href.trim()})`,
397
+ );
398
+
399
+ // 3. Replace ChatGPT PUA entity annotations: entity[\"type\",\"name\",...]
400
+ text = text.replace(/\uE200entity\uE202([^\uE201]+)\uE201/g, (_, json) => {
401
+ try {
402
+ const arr = JSON.parse(json.replace(/\\"/g, '"'));
403
+ const name = Array.isArray(arr) && arr[1] ? arr[1] : json;
404
+ return name;
405
+ } catch {
406
+ return json;
407
+ }
408
+ });
409
+
410
+ // 3.5. Clean/replace ChatGPT PUA image_group annotations: image_group{json}
411
+ text = text.replace(/\uE200image_group\uE202[^\uE201]+\uE201/g, (match) => {
412
+ const ref = imageGroupMap?.[match];
413
+ if (ref) {
414
+ if (Array.isArray(ref.images) && ref.images.length > 0) {
415
+ const markdownImgs = ref.images
416
+ .map((imgObj) => {
417
+ const res = imgObj.image_result || {};
418
+ const title = res.title || imgObj.image_search_query || 'Image';
419
+ const src = res.content_url || res.thumbnail_url || res.original_content_url;
420
+ if (src) {
421
+ return `![${title}](${src})`;
422
+ }
423
+ return '';
424
+ })
425
+ .filter(Boolean);
426
+ if (markdownImgs.length > 0) {
427
+ return '\n\n' + markdownImgs.join('\n\n') + '\n\n';
428
+ }
429
+ }
430
+ if (ref.safe_urls && Array.isArray(ref.safe_urls) && ref.safe_urls.length > 0) {
431
+ return (
432
+ '\n\n' + ref.safe_urls.map((url, i) => `![Image ${i + 1}](${url})`).join('\n\n') + '\n\n'
433
+ );
434
+ }
435
+ if (ref.alt) {
436
+ return '\n\n' + ref.alt + '\n\n';
437
+ }
438
+ }
439
+ return '';
440
+ });
441
+
442
+ // 4. Replace ChatGPT PUA cite annotations
443
+ text = text.replace(/\uE200cite(?:\uE202[^\uE202\uE201]+)+\uE201/g, (match) => {
444
+ const items = citeMap?.[match] ?? [];
445
+ if (!items.length) return '';
446
+ const formatted = items.map((item) => {
447
+ const label = item.attribution || item.title || 'Source';
448
+ return `[${label}](${item.url})`;
449
+ });
450
+ return ` (${formatted.join(', ')})`;
451
+ });
452
+
453
+ return text;
454
+ }
455
+
456
+ export class ChatGPTParser extends ChatParser {
457
+ name = 'ChatGPT';
458
+ constructor() {
459
+ super();
460
+ this.lastFetch = null;
461
+ }
462
+
463
+ isAvailable(url) {
464
+ return url.includes('chatgpt.com');
465
+ }
466
+
467
+ getRoleElement(container) {
468
+ if (container.matches?.('[data-message-author-role]')) return container;
469
+ return container.querySelector?.('[data-message-author-role]') || null;
470
+ }
471
+
472
+ getRoleElements(container) {
473
+ if (container.matches?.('[data-message-author-role]')) return [container];
474
+ return Array.from(container.querySelectorAll?.('[data-message-author-role]') || []);
475
+ }
476
+
477
+ getMessageRole(container, roleElement) {
478
+ const roleAttr = roleElement?.getAttribute('data-message-author-role');
479
+ if (roleAttr) return roleAttr === 'user' ? 'User' : 'ChatGPT';
480
+
481
+ const text = container.innerText || '';
482
+ if (text.startsWith('You\n') || text.includes('\nYou\n')) return 'User';
483
+
484
+ return 'ChatGPT';
485
+ }
486
+
487
+ getContentElement(container, roleElement) {
488
+ if (roleElement?.getAttribute('data-message-author-role') === 'user') {
489
+ return roleElement;
490
+ }
491
+
492
+ const selectors = ['.markdown', '.prose', '.whitespace-pre-wrap'];
493
+ for (const selector of selectors) {
494
+ const contentElement = container.querySelector?.(selector);
495
+ if (contentElement) return contentElement;
496
+ }
497
+
498
+ return roleElement || (container.matches?.('article') ? container : null);
499
+ }
500
+
501
+ getContentElements(container, roleElements) {
502
+ const contentElements = [];
503
+
504
+ roleElements.forEach((roleElement) => {
505
+ const contentElement = this.getContentElement(roleElement, roleElement);
506
+ if (contentElement) contentElements.push(contentElement);
507
+ });
508
+
509
+ if (contentElements.length > 0) return contentElements;
510
+
511
+ const fallback = this.getContentElement(container, roleElements[0]);
512
+ return fallback ? [fallback] : [];
513
+ }
514
+
515
+ cleanContent(content) {
516
+ return content
517
+ .replace(/^Show moreShow less$/gm, '')
518
+ .replace(/\n{3,}/g, '\n\n')
519
+ .trim();
520
+ }
521
+
522
+ getMessageKey(container, roleElement, role, content) {
523
+ const idElement =
524
+ roleElement?.closest?.('[data-message-id]') || container.querySelector?.('[data-message-id]');
525
+ const messageId = idElement?.getAttribute('data-message-id');
526
+ if (messageId) return messageId;
527
+
528
+ const turnId = container.getAttribute?.('data-testid');
529
+ if (turnId) return `${turnId}:${role}`;
530
+
531
+ return `${role}:${content.replace(/\s+/g, ' ').trim()}`;
532
+ }
533
+
534
+ extractAttachments(container) {
535
+ const attachments = [];
536
+ const rawContent = container.textContent || container.innerText || '';
537
+ const filePatterns = [
538
+ /([a-zA-Z0-9_-]+\.tex)/g,
539
+ /([a-zA-Z0-9_-]+\.txt)/g,
540
+ /([a-zA-Z0-9_-]+\.md)/g,
541
+ /([a-zA-Z0-9_-]+\.pdf)/g,
542
+ /([a-zA-Z0-9_-]+\.doc)/g,
543
+ ];
544
+ const foundFiles = new Set();
545
+
546
+ filePatterns.forEach((pattern) => {
547
+ const matches = rawContent.match(pattern);
548
+ if (matches) matches.forEach((match) => foundFiles.add(match));
549
+ });
550
+
551
+ foundFiles.forEach((fileName) => {
552
+ const fileExt = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
553
+ const typeMap = {
554
+ tex: 'LaTeX',
555
+ txt: 'Text',
556
+ md: 'Markdown',
557
+ pdf: 'PDF',
558
+ doc: 'Document',
559
+ docx: 'Document',
560
+ };
561
+ attachments.push({ name: fileName, type: typeMap[fileExt] || 'File' });
562
+ });
563
+
564
+ return attachments;
565
+ }
566
+
567
+ extractImages(container) {
568
+ const seenSrcs = new Set();
569
+ const capturedImages = [];
570
+
571
+ container.querySelectorAll?.('img').forEach((img) => {
572
+ const src = img.getAttribute('src');
573
+ const alt = img.getAttribute('alt') || 'Image';
574
+ const isContentImage =
575
+ src?.includes('backend-api') ||
576
+ src?.includes('files') ||
577
+ src?.startsWith('blob:') ||
578
+ alt.includes('Uploaded') ||
579
+ alt.includes('Generated');
580
+
581
+ if (src && !seenSrcs.has(src) && isContentImage) {
582
+ seenSrcs.add(src);
583
+ capturedImages.push(`![${alt}](${src})`);
584
+ }
585
+ });
586
+
587
+ return capturedImages;
588
+ }
589
+
590
+ appendAttachments(content, attachments, capturedImages) {
591
+ const attachmentLines = [];
592
+ const groupedAttachments = {};
593
+
594
+ attachments.forEach((attachment) => {
595
+ groupedAttachments[attachment.type] ||= [];
596
+ groupedAttachments[attachment.type].push(attachment.name);
597
+ });
598
+
599
+ Object.entries(groupedAttachments).forEach(([type, files]) => {
600
+ attachmentLines.push(`**${type} Files:**`);
601
+ files.forEach((file) => attachmentLines.push(`- ${file}`));
602
+ attachmentLines.push('');
603
+ });
604
+
605
+ if (capturedImages.length > 0) {
606
+ if (attachmentLines.length > 0) attachmentLines.push('');
607
+ attachmentLines.push('**Images:**');
608
+ capturedImages.forEach((image) => attachmentLines.push(`- ${image}`));
609
+ }
610
+
611
+ if (attachmentLines.length === 0) return content;
612
+ return `${content}\n\n**Attachments & Images:**\n${attachmentLines.join('\n')}`;
613
+ }
614
+
615
+ convertContentElement(contentElement) {
616
+ return convertToMarkdown(contentElement);
617
+ }
618
+
619
+ extractMessage(container) {
620
+ const roleElements = this.getRoleElements(container);
621
+ const roleElement = roleElements[0] || this.getRoleElement(container);
622
+ const contentElements = this.getContentElements(container, roleElements);
623
+ if (contentElements.length === 0) return null;
624
+
625
+ const role = this.getMessageRole(container, roleElement);
626
+ const noiseSelectors = ['.flex.gap-2', 'button', '.sr-only', '[role="button"]'];
627
+ const contentParts = contentElements
628
+ .map((contentElement) => {
629
+ const clone = contentElement.cloneNode(true);
630
+ clone.querySelectorAll('button').forEach((button) => {
631
+ const img = button.querySelector('img');
632
+ if (!img) return;
633
+
634
+ let caption = 'Image';
635
+ const ariaLabel = button.getAttribute('aria-label') || '';
636
+ if (ariaLabel.toLowerCase().includes('open image details for')) {
637
+ caption = ariaLabel.replace(/^Open image details for\s*/i, '').trim();
638
+ } else if (img.getAttribute('alt') && !img.getAttribute('alt').startsWith('http')) {
639
+ caption = img.getAttribute('alt').trim();
640
+ }
641
+
642
+ const alt = img.getAttribute('alt') || '';
643
+ const src = img.getAttribute('src') || '';
644
+ const imageUrl = alt.startsWith('http://') || alt.startsWith('https://') ? alt : src;
645
+
646
+ if (imageUrl) {
647
+ const newImg = clone.ownerDocument.createElement('img');
648
+ newImg.setAttribute('src', imageUrl);
649
+ newImg.setAttribute('alt', caption);
650
+ button.parentNode.replaceChild(newImg, button);
651
+ }
652
+ });
653
+
654
+ noiseSelectors.forEach((selector) => {
655
+ clone.querySelectorAll(selector).forEach((node) => node.remove());
656
+ });
657
+ return this.cleanContent(this.convertContentElement(clone));
658
+ })
659
+ .filter(Boolean);
660
+
661
+ let content = contentParts.join('\n\n');
662
+ content = this.appendAttachments(
663
+ content,
664
+ this.extractAttachments(container),
665
+ this.extractImages(container),
666
+ );
667
+
668
+ if (!content) return null;
669
+
670
+ return {
671
+ role,
672
+ content,
673
+ key: this.getMessageKey(container, roleElement, role, content),
674
+ };
675
+ }
676
+
677
+ extractMountedMessages() {
678
+ const articles = Array.from(document.querySelectorAll('article'));
679
+ const containers =
680
+ articles.length > 0
681
+ ? articles
682
+ : Array.from(document.querySelectorAll('[data-message-author-role]'));
683
+
684
+ return containers
685
+ .map((container) => this.extractMessage(container))
686
+ .filter(Boolean)
687
+ .map(({ role, content }) => ({ role, content }));
688
+ }
689
+
690
+ async extractAllConversationTurns() {
691
+ const turns = getConversationTurns(document);
692
+ if (turns.length === 0) return [];
693
+
694
+ return collectMountedTurnMessages({
695
+ turns,
696
+ scrollRoot: findChatGPTScrollRoot(turns, document),
697
+ extractMessage: (turn) => this.extractMessage(turn),
698
+ });
699
+ }
700
+
701
+ formatApiResult(convoData, apiMessages, fallbackTitle, images = {}) {
702
+ const messages = [];
703
+ for (const msg of apiMessages) {
704
+ let content = '';
705
+ for (const seg of msg.segments) {
706
+ if (seg.type === 'text') {
707
+ content += cleanMarkdownFromApi(seg.content, msg.citeMap, msg.imageGroupMap) + '\n\n';
708
+ } else if (seg.type === 'thought') {
709
+ const thoughtText = cleanMarkdownFromApi(seg.content, msg.citeMap, msg.imageGroupMap);
710
+ if (thoughtText) {
711
+ content += `<details><summary>Thought Process</summary>\n\n${thoughtText}\n\n</details>\n\n`;
712
+ }
713
+ } else if (seg.type === 'image') {
714
+ const src = images[seg.fileId];
715
+ if (src) {
716
+ content += `![Image](${src})\n\n`;
717
+ }
718
+ }
719
+ }
720
+ content = content.trim();
721
+ if (content) {
722
+ const msgObj = {
723
+ role: msg.role,
724
+ content: content,
725
+ };
726
+ if (msg.timestamp) {
727
+ msgObj.timestamp = msg.timestamp;
728
+ }
729
+ messages.push(msgObj);
730
+ }
731
+ }
732
+
733
+ const currentUrl =
734
+ typeof window !== 'undefined' && window.location ? window.location.href || '' : '';
735
+ const convTitle = convoData?.title || fallbackTitle;
736
+ const metadata = {
737
+ Source: 'ChatGPT',
738
+ Date: new Date().toLocaleString(),
739
+ Link: currentUrl,
740
+ Model:
741
+ convoData?.model_slug ||
742
+ document.querySelector('[data-testid="model-selector-dropdown"]')?.innerText ||
743
+ 'ChatGPT',
744
+ };
745
+
746
+ return { title: convTitle, messages, url: currentUrl, metadata };
747
+ }
748
+
749
+ async parse(options = {}) {
750
+ const title = document.title || 'ChatGPT Session';
751
+ const messages = [];
752
+
753
+ const token = getAccessToken();
754
+ const convId = getConversationId();
755
+ const parserMode = options.parserMode || 'auto';
756
+ const includeImages = options.includeImages !== false;
757
+
758
+ // 1. If on shared chat URL (/share/...) or SSR conversation data exists in DOM, try SSR data first
759
+ const isShareUrl =
760
+ typeof window !== 'undefined' &&
761
+ window.location &&
762
+ (window.location.pathname || '').startsWith('/share/');
763
+ const sharedData = extractSharedConversationFromDom(
764
+ typeof document !== 'undefined' ? document : null,
765
+ );
766
+
767
+ if (isShareUrl && sharedData?.mapping && parserMode !== 'prefer_dom') {
768
+ const apiMessages = linearize(sharedData.mapping, includeImages, sharedData.current_node);
769
+ if (apiMessages.length > 0) {
770
+ return this.formatApiResult(sharedData, apiMessages, title);
771
+ }
772
+ }
773
+
774
+ // 2. Try fetching from ChatGPT backend API
775
+ if (token && convId && parserMode !== 'prefer_dom') {
776
+ try {
777
+ const now = Date.now();
778
+ let result;
779
+
780
+ if (
781
+ this.lastFetch &&
782
+ this.lastFetch.convId === convId &&
783
+ this.lastFetch.includeImages === includeImages &&
784
+ now - this.lastFetch.timestamp < 20000
785
+ ) {
786
+ result = this.lastFetch.result;
787
+ } else {
788
+ if (!document.getElementById('ai-export-chatgpt-helper')) {
789
+ const script = document.createElement('script');
790
+ script.src = chrome.runtime.getURL('content/chatgpt_helper.js');
791
+ script.id = 'ai-export-chatgpt-helper';
792
+ script.onload = function () {
793
+ this.remove();
794
+ };
795
+ (document.head || document.documentElement).appendChild(script);
796
+ await new Promise((r) => setTimeout(r, 100));
797
+ }
798
+
799
+ result = await fetchConversation(convId, token, includeImages);
800
+ this.lastFetch = {
801
+ convId,
802
+ includeImages,
803
+ timestamp: now,
804
+ result,
805
+ };
806
+ }
807
+
808
+ const apiMessages = linearize(result.data.mapping, includeImages, result.data.current_node);
809
+ if (apiMessages.length > 0) {
810
+ return this.formatApiResult(result.data, apiMessages, title, result.images);
811
+ }
812
+ } catch (e) {
813
+ console.error('[AI Exporter] API parse failed, falling back to SSR/DOM:', e);
814
+ }
815
+ }
816
+
817
+ // 3. If API failed or was not available, check if SSR shared/embedded conversation data exists
818
+ if (sharedData?.mapping && parserMode !== 'prefer_dom') {
819
+ const apiMessages = linearize(sharedData.mapping, includeImages, sharedData.current_node);
820
+ if (apiMessages.length > 0) {
821
+ return this.formatApiResult(sharedData, apiMessages, title);
822
+ }
823
+ }
824
+
825
+ // Check if we have iframe-based content (deep research feature)
826
+ const iframes = document.querySelectorAll('iframe[src*="oaiusercontent.com"]');
827
+ if (iframes.length > 0) {
828
+ console.log('Detected iframe-based content, attempting extraction...');
829
+
830
+ // Try multiple strategies to extract content
831
+ let extractedContent = '';
832
+
833
+ // Strategy 1: Look for data in script tags or window objects
834
+ try {
835
+ // Check if any conversation data is exposed globally
836
+ if (window.conversationData || window.chatData) {
837
+ extractedContent = JSON.stringify(window.conversationData || window.chatData);
838
+ }
839
+ } catch (e) {
840
+ console.log('Global data access failed:', e);
841
+ }
842
+
843
+ // Strategy 2: Look for preloaded content in hidden elements
844
+ if (!extractedContent) {
845
+ const hiddenSelectors = [
846
+ '[data-conversation]',
847
+ '[data-messages]',
848
+ '.conversation-data',
849
+ '.chat-transcript',
850
+ 'pre[data-conversation]',
851
+ ];
852
+
853
+ for (const selector of hiddenSelectors) {
854
+ const element = document.querySelector(selector);
855
+ if (element && element.textContent) {
856
+ extractedContent = element.textContent;
857
+ break;
858
+ }
859
+ }
860
+ }
861
+
862
+ // Strategy 3: Enhanced text extraction from main content
863
+ if (!extractedContent) {
864
+ const mainContent =
865
+ document.querySelector('main') ||
866
+ document.querySelector('[role="main"]') ||
867
+ document.querySelector('.conversation') ||
868
+ document.body;
869
+
870
+ if (mainContent) {
871
+ const textContent = mainContent.textContent || mainContent.innerText;
872
+ if (textContent && textContent.trim()) {
873
+ const lines = textContent.split('\n').filter((line) => line.trim());
874
+
875
+ // Look for conversation patterns
876
+ const conversationLines = lines.filter(
877
+ (line) =>
878
+ line.length > 20 && // Substantial content
879
+ !line.includes('ChatGPT') &&
880
+ !line.includes('Regenerate') &&
881
+ !line.includes('Copy code') &&
882
+ !line.includes('Continue') &&
883
+ !line.includes('Share') &&
884
+ !line.includes('Thumb') &&
885
+ !line.includes('New chat') &&
886
+ !line.includes('Menu') &&
887
+ !line.includes('Settings') &&
888
+ !line.includes('History'),
889
+ );
890
+
891
+ if (conversationLines.length > 0) {
892
+ extractedContent = conversationLines.join('\n\n');
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ // Strategy 4: Last resort - check for any meaningful content
899
+ if (!extractedContent) {
900
+ const allText = document.body.textContent || document.body.innerText;
901
+ if (allText && allText.trim().length > 100) {
902
+ extractedContent = allText.trim();
903
+ }
904
+ }
905
+
906
+ // If we found content, try to structure it
907
+ if (extractedContent) {
908
+ // Try to identify user vs assistant messages
909
+ const lines = extractedContent.split('\n').filter((line) => line.trim());
910
+
911
+ lines.forEach((line) => {
912
+ if (line.length > 10) {
913
+ // Simple heuristic: shorter lines are often user prompts
914
+ if (
915
+ line.length < 200 ||
916
+ line.includes('?') ||
917
+ line.includes('write') ||
918
+ line.includes('tell')
919
+ ) {
920
+ messages.push({
921
+ role: 'User',
922
+ content: line.trim(),
923
+ });
924
+ } else {
925
+ messages.push({
926
+ role: 'ChatGPT',
927
+ content: line.trim(),
928
+ });
929
+ }
930
+ }
931
+ });
932
+ }
933
+
934
+ // Add note about extraction method
935
+ if (messages.length > 0) {
936
+ messages.push({
937
+ role: 'ChatGPT',
938
+ content:
939
+ '*Note: Content extracted from iframe-based ChatGPT interface. Some formatting may be lost.*',
940
+ });
941
+ } else {
942
+ // Last resort - add a message explaining the limitation
943
+ messages.push({
944
+ role: 'ChatGPT',
945
+ content:
946
+ '*Note: ChatGPT is using iframe-based content that cannot be accessed by browser extensions. Please try exporting from a standard ChatGPT conversation.*',
947
+ });
948
+ }
949
+
950
+ return { title, messages };
951
+ }
952
+
953
+ const fullExport = options.full !== false;
954
+ const extractedMessages = fullExport
955
+ ? await this.extractAllConversationTurns()
956
+ : this.extractMountedMessages();
957
+ messages.push(
958
+ ...(extractedMessages.length > 0 ? extractedMessages : this.extractMountedMessages()),
959
+ );
960
+
961
+ const currentUrl =
962
+ typeof window !== 'undefined' && window.location ? window.location.href || '' : '';
963
+ const metadata = {
964
+ Source: 'ChatGPT',
965
+ Date: new Date().toLocaleString(),
966
+ Link: currentUrl,
967
+ Model:
968
+ document.querySelector('[data-testid="model-selector-dropdown"]')?.innerText || 'ChatGPT',
969
+ };
970
+
971
+ return { title, messages, url: currentUrl, metadata };
972
+ }
973
+ }