diff --git a/manifest.dev.json b/manifest.dev.json index 756aa503d..4db394272 100755 --- a/manifest.dev.json +++ b/manifest.dev.json @@ -55,7 +55,7 @@ "https://lh3.google.com/*", "https://*.ggpht.com/*" ], - "optional_host_permissions": [""], + "optional_host_permissions": ["https://chatgpt.com/*", ""], "background": { "service_worker": "src/pages/background/index.ts", "type": "module" diff --git a/manifest.json b/manifest.json index 90b76114f..6fc48c4e8 100755 --- a/manifest.json +++ b/manifest.json @@ -41,7 +41,7 @@ "https://lh3.google.com/*", "https://*.ggpht.com/*" ], - "optional_host_permissions": [""], + "optional_host_permissions": ["https://chatgpt.com/*", ""], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; worker-src 'self'" }, diff --git a/public/contentStyle.css b/public/contentStyle.css index dcf2aecfd..e72de09cd 100644 --- a/public/contentStyle.css +++ b/public/contentStyle.css @@ -1580,6 +1580,11 @@ body.dark-theme .gv-export-dropdown-btn:hover, pointer-events: auto; } +.gv-persistent-export-toolbar[data-gv-platform='chatgpt'] { + top: 50px; + right: calc(var(--gv-persistent-export-right, 84px) - 50px); +} + .gv-persistent-export-btn { display: inline-flex; align-items: center; diff --git a/src/core/utils/__tests__/manifestPermissions.test.ts b/src/core/utils/__tests__/manifestPermissions.test.ts index 435c82413..b8ec00c6f 100644 --- a/src/core/utils/__tests__/manifestPermissions.test.ts +++ b/src/core/utils/__tests__/manifestPermissions.test.ts @@ -74,7 +74,21 @@ afterAll(() => { describe('manifest permissions', () => { it('keeps all-site access optional', () => { expect(manifestChrome.host_permissions).not.toContain(''); - expect(manifestChrome.optional_host_permissions).toEqual(['']); + expect(manifestChrome.optional_host_permissions).toEqual( + expect.arrayContaining(['']), + ); + }); + + it('keeps ChatGPT host access opt-in', () => { + expect(manifestChrome.host_permissions).not.toContain('https://chatgpt.com/*'); + expect(manifestChrome.optional_host_permissions).toEqual( + expect.arrayContaining(['https://chatgpt.com/*']), + ); + expect( + manifestChrome.content_scripts.some((entry) => + entry.matches.includes('https://chatgpt.com/*'), + ), + ).toBe(false); }); it('keeps unlimitedStorage out of the shared manifest', () => { diff --git a/src/features/export/services/ConversationExportService.ts b/src/features/export/services/ConversationExportService.ts index 360327cd0..0b3c490c5 100644 --- a/src/features/export/services/ConversationExportService.ts +++ b/src/features/export/services/ConversationExportService.ts @@ -3,7 +3,7 @@ * Unified service for exporting conversations in multiple formats * Uses Strategy pattern for format-specific implementations */ -import { fetchImageViaExtensionRuntime } from '@/core/utils/runtimeImageFetch'; +import type { ExportPlatformAdapter } from '@pages/content/export/adapter/platformAdapters'; import { IMAGE_RENDER_EVENT_ERROR_CODE, isEventLikeImageRenderError } from '../types/errors'; import { DEFAULT_EXPORT_SPEAKER_LABELS } from '../types/export'; @@ -20,6 +20,13 @@ import { DeepResearchPDFPrintService } from './DeepResearchPDFPrintService'; import { ImageExportService } from './ImageExportService'; import { MarkdownFormatter } from './MarkdownFormatter'; import { PDFPrintService } from './PDFPrintService'; +import { + EXPORT_IMAGE_FETCH_CONCURRENCY, + MAX_EXPORT_IMAGE_COUNT, + MAX_EXPORT_IMAGE_TOTAL_BYTES, + fetchBoundedExportImage, + mapWithConcurrency, +} from './boundedImageFetch'; /** * Main export service @@ -30,6 +37,18 @@ export class ConversationExportService { private static readonly CHAT_JSON_FORMAT = 'gemini-voyager.chat.v1' as const; + private static assertNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); + } + + /** + * Set the export adapter. + * @param adapter - The export adapter. + */ + static setExportAdapter(adapter: ExportPlatformAdapter) { + DOMContentExtractor.setExportAdapter(adapter); + } + /** * Export conversation in specified format */ @@ -39,6 +58,7 @@ export class ConversationExportService { options: ExportOptions, ): Promise { try { + this.assertNotAborted(options.signal); const layout: ExportLayout = options.layout ?? 'conversation'; if (layout === 'document') { return await this.exportDocument(turns, metadata, options); @@ -79,6 +99,9 @@ export class ConversationExportService { } private static normalizeError(error: unknown): string { + if (error instanceof DOMException) { + return error.message; + } if (error instanceof Error) { return error.message; } @@ -135,8 +158,12 @@ export class ConversationExportService { let assistantContent = turn.assistant; let attachments = turn.attachments ?? []; - // Extract rich content with Markdown formatting from DOM elements if available - if (turn.userElement) { + // ChatGPT snapshots rich content while its virtual-list item is mounted. + // Prefer that stable snapshot over a later DOM read, which may be empty. + if (turn.userContent) { + userContent = turn.userContent.text || userContent; + attachments = turn.userContent.attachments; + } else if (turn.userElement) { const extracted = DOMContentExtractor.extractUserContent(turn.userElement); if (extracted.text) { userContent = extracted.text; @@ -144,7 +171,9 @@ export class ConversationExportService { attachments = extracted.attachments; } - if (turn.assistantElement) { + if (turn.assistantContent) { + assistantContent = turn.assistantContent.text || assistantContent; + } else if (turn.assistantElement) { const extracted = DOMContentExtractor.extractAssistantContent(turn.assistantElement); if (extracted.text) { assistantContent = extracted.text; @@ -168,7 +197,9 @@ export class ConversationExportService { items: processedItems, }; - const filename = options.filename || this.generateFilename('json', metadata.title); + const filename = + options.filename || this.generateFilename('json', metadata.title, metadata.platform); + this.assertNotAborted(options.signal); this.downloadJSON(payload, filename); return { @@ -197,8 +228,14 @@ export class ConversationExportService { markdown = markdown.replace(/\n\*Source: \[[^\]]*\]\([^)]*\)\*\n/g, '\n'); } - const filename = options.filename || this.generateFilename('md', metadata.title); - const finalFilename = await this.downloadMarkdownOrZip(markdown, filename, 'chat.md'); + const filename = + options.filename || this.generateFilename('md', metadata.title, metadata.platform); + const finalFilename = await this.downloadMarkdownOrZip( + markdown, + filename, + 'chat.md', + options.signal, + ); return { success: true, format: 'markdown' as ExportFormat, filename: finalFilename }; } @@ -213,6 +250,7 @@ export class ConversationExportService { await PDFPrintService.export(turns, metadata, { fontSize: options.fontSize, speakerLabels: options.speakerLabels, + signal: options.signal, }); // Note: We can't get the actual filename from print dialog @@ -220,7 +258,7 @@ export class ConversationExportService { return { success: true, format: 'pdf' as ExportFormat, - filename: options.filename || this.generateFilename('pdf', metadata.title), + filename: options.filename || this.generateFilename('pdf', metadata.title, metadata.platform), }; } @@ -232,12 +270,14 @@ export class ConversationExportService { metadata: ConversationMetadata, options: ExportOptions, ): Promise { - const filename = options.filename || this.generateFilename('png', metadata.title); + const filename = + options.filename || this.generateFilename('png', metadata.title, metadata.platform); await ImageExportService.export(turns, metadata, { filename, fontSize: options.fontSize, imageWidth: options.imageWidth, speakerLabels: options.speakerLabels, + signal: options.signal, }); return { success: true, format: 'image' as ExportFormat, filename }; } @@ -258,7 +298,9 @@ export class ConversationExportService { }, }; - const filename = options.filename || this.generateFilename('json', metadata.title); + const filename = + options.filename || this.generateFilename('json', metadata.title, metadata.platform); + this.assertNotAborted(options.signal); this.downloadJSON(payload, filename); return { success: true, @@ -273,12 +315,18 @@ export class ConversationExportService { options: ExportOptions, ): Promise { const markdown = this.composeDocumentMarkdown(content.markdown, metadata); - const filename = options.filename || this.generateFilename('md', metadata.title); + const filename = + options.filename || this.generateFilename('md', metadata.title, metadata.platform); const mdEntryName = filename.toLowerCase().endsWith('.md') ? filename.split('/').pop() || 'report.md' : 'report.md'; - const finalFilename = await this.downloadMarkdownOrZip(markdown, filename, mdEntryName); + const finalFilename = await this.downloadMarkdownOrZip( + markdown, + filename, + mdEntryName, + options.signal, + ); return { success: true, format: 'markdown' as ExportFormat, @@ -305,7 +353,7 @@ export class ConversationExportService { return { success: true, format: 'pdf' as ExportFormat, - filename: options.filename || this.generateFilename('pdf', metadata.title), + filename: options.filename || this.generateFilename('pdf', metadata.title, metadata.platform), }; } @@ -314,7 +362,8 @@ export class ConversationExportService { metadata: ConversationMetadata, options: ExportOptions, ): Promise { - const filename = options.filename || this.generateFilename('png', metadata.title); + const filename = + options.filename || this.generateFilename('png', metadata.title, metadata.platform); await ImageExportService.exportDocument( { title: metadata.title || 'Deep Research Report', @@ -327,6 +376,7 @@ export class ConversationExportService { filename, fontSize: options.fontSize, imageWidth: options.imageWidth, + signal: options.signal, }, ); @@ -406,7 +456,9 @@ export class ConversationExportService { markdown: string, filename: string, markdownEntryName: string, + signal?: AbortSignal, ): Promise { + this.assertNotAborted(signal); const normalizedFilename = filename.toLowerCase().endsWith('.md') ? filename : `${filename}.md`; const imageUrls = MarkdownFormatter.extractImageUrls(markdown); @@ -423,21 +475,27 @@ export class ConversationExportService { const assetsFolder = zip.folder('assets'); const mapping = new Map(); - const fetchedByOrder = await Promise.all( - imageUrls.map(async (url) => { + const budget = { remainingBytes: MAX_EXPORT_IMAGE_TOTAL_BYTES }; + const fetchedByOrder = await mapWithConcurrency( + imageUrls.slice(0, MAX_EXPORT_IMAGE_COUNT), + EXPORT_IMAGE_FETCH_CONCURRENCY, + async (url) => { // For Google images, request original size (=s0) instead of the display thumbnail const fetchUrl = this.toOriginalSizeUrl(url); - const fetched = await this.fetchImageForMarkdownPackaging(fetchUrl); + const fetched = signal + ? await this.fetchImageForMarkdownPackaging(fetchUrl, budget, signal) + : await this.fetchImageForMarkdownPackaging(fetchUrl, budget); if (!fetched) return null; return { url, blob: fetched.blob, contentType: fetched.contentType, }; - }), + }, ); let index = 1; + this.assertNotAborted(signal); for (const item of fetchedByOrder) { if (!item) continue; const extension = this.pickImageExtension(item.contentType, item.url); @@ -452,6 +510,7 @@ export class ConversationExportService { zip.file(markdownEntryName, packagedMarkdown); const zipBlob = await zip.generateAsync({ type: 'blob' }); + this.assertNotAborted(signal); const zipFilename = normalizedFilename.replace(/\.md$/i, '.zip'); const url = URL.createObjectURL(zipBlob); const anchor = document.createElement('a'); @@ -522,84 +581,12 @@ export class ConversationExportService { }); } - private static decodeDataImageUrl(url: string): { blob: Blob; contentType: string } | null { - const commaIndex = url.indexOf(','); - if (!url.startsWith('data:image/') || commaIndex < 0) return null; - - const metadata = url.slice('data:'.length, commaIndex); - const contentType = metadata.split(';')[0] || 'image/png'; - const payload = url.slice(commaIndex + 1); - - try { - let bytes: Uint8Array; - if (metadata.includes(';base64')) { - const binary = atob(payload); - bytes = new Uint8Array(binary.length); - for (let idx = 0; idx < binary.length; idx++) { - bytes[idx] = binary.charCodeAt(idx); - } - } else { - bytes = new TextEncoder().encode(decodeURIComponent(payload)); - } - - const buffer = new ArrayBuffer(bytes.byteLength); - new Uint8Array(buffer).set(bytes); - - return { - blob: new Blob([buffer], { type: contentType }), - contentType, - }; - } catch { - return null; - } - } - private static async fetchImageForMarkdownPackaging( url: string, + budget = { remainingBytes: MAX_EXPORT_IMAGE_TOTAL_BYTES }, + signal?: AbortSignal, ): Promise<{ blob: Blob; contentType: string | null } | null> { - if (url.startsWith('data:image/')) { - return this.decodeDataImageUrl(url); - } - - try { - const response = await fetch(url, { credentials: 'include', mode: 'cors' as RequestMode }); - if (response.ok) { - return { - blob: await response.blob(), - contentType: response.headers.get('Content-Type'), - }; - } - } catch { - /* ignore */ - } - - // Retry without credentials for servers with wildcard CORS - // (Access-Control-Allow-Origin: * is incompatible with credentials: 'include') - try { - const response = await fetch(url, { credentials: 'omit', mode: 'cors' as RequestMode }); - if (response.ok) { - return { - blob: await response.blob(), - contentType: response.headers.get('Content-Type'), - }; - } - } catch { - /* ignore */ - } - - const runtimeImage = await fetchImageViaExtensionRuntime(url); - if (runtimeImage) { - const binary = atob(runtimeImage.base64); - const length = binary.length; - const bytes = new Uint8Array(length); - for (let idx = 0; idx < length; idx++) bytes[idx] = binary.charCodeAt(idx); - return { - blob: new Blob([bytes], { type: runtimeImage.contentType }), - contentType: runtimeImage.contentType, - }; - } - - return null; + return await fetchBoundedExportImage(url, budget, signal); } /** @@ -628,12 +615,13 @@ export class ConversationExportService { /** * Generate filename with timestamp */ - private static generateFilename(extension: string, title?: string): string { + private static generateFilename(extension: string, title?: string, platform?: string): string { const titlePart = this.sanitizeFilenamePart(title); if (titlePart) { return `${titlePart}.${extension}`; } + const slug = (platform || 'gemini').toLowerCase().replace(/\s+/g, '-'); const pad = (n: number) => String(n).padStart(2, '0'); const d = new Date(); const y = d.getFullYear(); @@ -642,7 +630,7 @@ export class ConversationExportService { const hh = pad(d.getHours()); const mm = pad(d.getMinutes()); const ss = pad(d.getSeconds()); - return `gemini-chat-${y}${m}${day}-${hh}${mm}${ss}.${extension}`; + return `${slug}-chat-${y}${m}${day}-${hh}${mm}${ss}.${extension}`; } private static sanitizeFilenamePart(title?: string): string { diff --git a/src/features/export/services/DOMContentExtractor.ts b/src/features/export/services/DOMContentExtractor.ts index 8677ea675..7bea94c88 100644 --- a/src/features/export/services/DOMContentExtractor.ts +++ b/src/features/export/services/DOMContentExtractor.ts @@ -2,6 +2,7 @@ * DOM Content Extractor * Extracts rich content from Gemini's DOM structure preserving formatting */ +import type { ExportPlatformAdapter } from '../../../pages/content/export/adapter/platformAdapters'; import type { ExportAttachment } from '../types/export'; export interface ExtractedContent { @@ -56,8 +57,20 @@ type ExportCodeBlock = export class DOMContentExtractor { private static DEBUG = false; + private static exportAdapter: ExportPlatformAdapter; + /** - * Extract user query content + * Set the export adapter. + * @param adapter - The export adapter. + */ + static setExportAdapter(adapter: ExportPlatformAdapter) { + this.exportAdapter = adapter; + } + + /** + * Extract user query content. + * @param imageSelectors - Platform-specific selectors for finding images. + * Empty/omitted = use Gemini's built-in selectors only. */ static extractUserContent(element: HTMLElement): ExtractedContent { const result: ExtractedContent = { @@ -70,22 +83,19 @@ export class DOMContentExtractor { hasCode: false, }; - // Check for images - const images = element.querySelectorAll('user-query-file-preview img, .preview-image'); + const images = this.exportAdapter.extractUserImage(element) ?? []; result.hasImages = images.length > 0; const attachments = this.extractUserAttachments(element); result.attachments = attachments; - // Extract text from query-text-line paragraphs - const textLines = element.querySelectorAll('.query-text-line'); + // Extract user message text. Each platform exposes its own DOM shape + // (Gemini's .query-text-line paragraphs vs. ChatGPT's plain text node), + // so the actual extraction strategy is delegated to the platform adapter. + const textLines = element.querySelectorAll('.query-text-line'); const textParts: string[] = []; - textLines.forEach((line) => { - const el = line as HTMLElement; - const raw = el.dataset?.userLatexOriginal ?? line.textContent ?? ''; - const text = this.normalizeText(raw); - if (text) textParts.push(text); - }); + this.exportAdapter.extractUserText(textLines, textParts, element); + result.text = textParts.join('\n'); // Build HTML representation @@ -96,7 +106,9 @@ export class DOMContentExtractor { images.forEach((img, index) => { const src = (img as HTMLImageElement).src; const alt = (img as HTMLImageElement).alt || `Uploaded image ${index + 1}`; - htmlParts.push(`${alt}`); + htmlParts.push( + `${this.escapeHtmlAttribute(alt)}`, + ); imageMarkdown.push(`![${alt}](${src})`); }); @@ -186,6 +198,7 @@ export class DOMContentExtractor { // Instead, skip model-thoughts during processNodes const htmlParts: string[] = []; const textParts: string[] = []; + const processedImageSrcs = new Set(); // STRATEGY CHANGE: Instead of recursing through DOM (which misses Angular-rendered elements), // process the .markdown div directly and then search for response-elements @@ -208,13 +221,13 @@ export class DOMContentExtractor { } // First, process all direct children of markdown that are NOT response-element - this.processNodes(markdownDiv, htmlParts, textParts, result); + this.processNodes(markdownDiv, htmlParts, textParts, result, processedImageSrcs); // Note: response-element contents are processed by processNodes recursion above } else { // Fallback to old method if (this.DEBUG) console.log('[DOMContentExtractor] No markdown div found, using fallback'); - this.processNodes(messageContent, htmlParts, textParts, result); + this.processNodes(messageContent, htmlParts, textParts, result, processedImageSrcs); } // Additionally, look for code blocks and tables at the element level @@ -312,25 +325,15 @@ export class DOMContentExtractor { } /** - * Extract non-image uploads from Gemini's user-query-file-preview elements. + * Extract non-image uploads from platform-specific user message file cards. * Image previews are already exported as images above, so they are not duplicated. */ private static extractUserAttachments(element: HTMLElement): ExportAttachment[] { - const uploadedFiles = Array.from( - element.querySelectorAll( - 'user-query-file-preview [data-test-id="uploaded-file"]', - ), - ); - const candidates = - uploadedFiles.length > 0 - ? uploadedFiles - : Array.from( - element.querySelectorAll('user-query-file-preview .new-file-preview-file'), - ); + const candidates = this.exportAdapter.getUserAttachmentCandidates(element); const attachments: ExportAttachment[] = []; const seen = new Set(); - candidates.forEach((candidate) => { + candidates?.forEach((candidate) => { const labelledElement = candidate.matches('[aria-label]') ? candidate : candidate.querySelector('[aria-label]'); @@ -364,25 +367,26 @@ export class DOMContentExtractor { * Process DOM nodes recursively */ private static processNodes( - container: Element, + container: Element | ShadowRoot, htmlParts: string[], textParts: string[], flags: Pick, + processedImageSrcs: Set = new Set(), ): void { const children = Array.from(container.children); if (this.DEBUG) console.log( `[DOMContentExtractor] processNodes: ${children.length} children in`, - container.tagName, - container.className, + container instanceof Element ? container.tagName : '#shadow-root', + container instanceof Element ? container.className : '', ); // Check for Shadow DOM - const shadowRoot = container.shadowRoot; + const shadowRoot = container instanceof Element ? container.shadowRoot : null; if (shadowRoot) { if (this.DEBUG) console.log('[DOMContentExtractor] Found Shadow DOM! Processing shadow children'); - this.processNodes(shadowRoot as unknown as Element, htmlParts, textParts, flags); + this.processNodes(shadowRoot, htmlParts, textParts, flags, processedImageSrcs); } for (const child of children) { @@ -396,6 +400,11 @@ export class DOMContentExtractor { continue; } + if (child.shadowRoot && child.children.length === 0) { + this.processNodes(child.shadowRoot, htmlParts, textParts, flags, processedImageSrcs); + continue; + } + // Canvas Export Section (Injected Canvas document content) if (child.classList.contains('gv-canvas-export-section')) { const headingEl = child.querySelector('h3'); @@ -410,43 +419,11 @@ export class DOMContentExtractor { continue; } - // Images - if (tagName === 'img') { - const img = child as HTMLImageElement; - const src = img.getAttribute('src') || img.src || ''; - if (src && src !== 'about:blank') { - flags.hasImages = true; - const altRaw = img.getAttribute('alt') || ''; - const alt = altRaw.trim() || 'Image'; - htmlParts.push( - `${this.escapeHtmlAttribute(alt)}`, - ); - const mdAlt = alt.replace(/\]/g, '\\]'); - textParts.push(`\n![${mdAlt}](${src})\n`); - } + // Extract formula + if (this.exportAdapter.extractFormula(child, flags, htmlParts, textParts, this.DEBUG)) { continue; } - // Math block (display formula) - check both class and data-math attribute - if (child.classList.contains('math-block') || child.hasAttribute('data-math')) { - const latex = child.getAttribute('data-math') || ''; - if (latex) { - if (this.DEBUG) console.log('[DOMContentExtractor] Found math-block, latex:', latex); - flags.hasFormulas = true; - // For HTML output: preserve the rendered formula HTML for PDF export - // Clone the element to preserve its rendered content - const clonedFormula = (child as HTMLElement).cloneNode(true) as HTMLElement; - // Ensure data-math attribute is preserved for potential re-rendering - if (!clonedFormula.hasAttribute('data-math')) { - clonedFormula.setAttribute('data-math', latex); - } - htmlParts.push(clonedFormula.outerHTML); - // For text output: use Markdown format - textParts.push(`\n$$\n${latex}\n$$\n`); - continue; - } - } - const exportCodeBlocks = this.findExportCodeBlocks(child); const directExportCodeBlock = exportCodeBlocks.find(({ element }) => element === child); if (directExportCodeBlock) { @@ -464,6 +441,13 @@ export class DOMContentExtractor { continue; } + // Extract code block via the per-platform adapter + if ( + this.exportAdapter.extractCodeBlock(child, htmlParts, textParts, flags, tagName, this.DEBUG) + ) { + continue; + } + // Traverse containers that own export blocks instead of consuming only their first // descendant. This keeps prose, code, and Mermaid output in DOM order. if (tagName !== 'ul' && tagName !== 'ol' && exportCodeBlocks.length > 0) { @@ -487,96 +471,19 @@ export class DOMContentExtractor { continue; } - // Search result images (web images found by Gemini) - // Structure: > > - // > > ... > - { - const searchImageContainers = child.querySelectorAll( - '.attachment-container.search-images .image-container[data-full-size-image-uri]', - ); - if (searchImageContainers.length > 0) { - for (const container of Array.from(searchImageContainers)) { - const fullSizeUri = container.getAttribute('data-full-size-image-uri') || ''; - const imgEl = container.querySelector('img.image') as HTMLImageElement | null; - if (!imgEl) continue; - // Use the Google-cached thumbnail (gstatic.com) as the downloadable src. - // The full-size URI points to arbitrary third-party domains that are blocked - // by both CORS and Gemini's CSP, so it's only usable as an attribution link. - const src = imgEl.src || ''; - if (!src || src === 'about:blank') continue; - const alt = imgEl.alt || 'Search result image'; - const sourceLink = container.querySelector('a.source') as HTMLAnchorElement | null; - const sourceUrl = sourceLink?.href || ''; - const sourceLabel = - container.querySelector('.source .label')?.textContent?.trim() || ''; - - flags.hasImages = true; - htmlParts.push( - `${this.escapeHtmlAttribute(alt)}`, - ); - const mdAlt = alt.replace(/\]/g, '\\]'); - // Link to the full-size image or source when available - const linkUrl = fullSizeUri || sourceUrl; - const linkLabel = sourceLabel || (sourceUrl ? sourceUrl : ''); - if (linkUrl) { - textParts.push( - `\n![${mdAlt}](${src})\n*Source: [${linkLabel || linkUrl}](${linkUrl})*\n`, - ); - } else { - textParts.push(`\n![${mdAlt}](${src})\n`); - } - } - if (this.DEBUG) - console.log( - '[DOMContentExtractor] Extracted', - searchImageContainers.length, - 'search result images', - ); - continue; - } - } - - // Generated images (model-generated images in assistant responses) - // These are typically wrapped in:

> > - // > > > ... > - // Also handle standalone generated-image / single-image custom elements - { - const generatedImgs = child.querySelectorAll( - 'generated-image img, single-image img, .attachment-container.generated-images img', - ); - if (generatedImgs.length > 0) { - for (const img of Array.from(generatedImgs)) { - const imgEl = img as HTMLImageElement; - const src = imgEl.src || imgEl.getAttribute('src') || ''; - if (!src || src === 'about:blank') continue; - const alt = imgEl.alt || 'Generated image'; - flags.hasImages = true; - htmlParts.push( - `${this.escapeHtmlAttribute(alt)}`, - ); - const mdAlt = alt.replace(/\]/g, '\\]'); - textParts.push(`\n![${mdAlt}](${src})\n`); - } - if (this.DEBUG) - console.log( - '[DOMContentExtractor] Extracted', - generatedImgs.length, - 'generated images', - ); - continue; - } - } - - // YouTube video cards — export the cover thumbnail (linked to the video). - // The