Files
jackwener__opencli/clis/chatgpt/utils.js
E2ern1ty b262d8ffd5 feat(chatgpt): support local image uploads (#1476)
* feat(chatgpt): support local image uploads

* chore: refresh cli manifest

* fix(chatgpt): harden image upload flow

* fix(chatgpt): validate image uploads before navigation

* fix(chatgpt): keep send fallback click in sync

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 17:52:16 +08:00

855 lines
34 KiB
JavaScript

/**
* ChatGPT web browser automation helpers.
* Cross-platform: works on Linux/macOS/Windows via OpenCLI's CDP browser automation.
*/
import { htmlToMarkdown } from '@jackwener/opencli/utils';
import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
export const CHATGPT_DOMAIN = 'chatgpt.com';
export const CHATGPT_URL = 'https://chatgpt.com';
// Selectors
const COMPOSER_SELECTORS = [
'[aria-label="Chat with ChatGPT"]',
'[aria-label="与 ChatGPT 聊天"]',
'[placeholder="Ask anything"]',
'[placeholder="有问题,尽管问"]',
'#prompt-textarea',
'[data-testid="prompt-textarea"]',
'[contenteditable="true"][role="textbox"]',
];
const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
const SEND_BUTTON_FALLBACK_SELECTORS = [
'#composer-submit-button:not([disabled])',
];
const SEND_BUTTON_LABELS = [
'Send prompt',
'Send message',
'Send',
'发送提示',
];
const CLOSE_SIDEBAR_LABELS = [
'Close sidebar',
'关闭边栏',
];
function isSameChatGPTConversation(currentUrl, expectedUrl) {
if (!currentUrl || !expectedUrl) return false;
return currentUrl === expectedUrl
|| currentUrl.startsWith(`${expectedUrl}?`)
|| currentUrl.startsWith(`${expectedUrl}#`);
}
function buildComposerLocatorScript() {
const markerAttr = 'data-opencli-chatgpt-composer';
return `
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const markerAttr = ${JSON.stringify(markerAttr)};
const clearMarkers = (active) => {
document.querySelectorAll('[' + markerAttr + ']').forEach(node => {
if (node !== active) node.removeAttribute(markerAttr);
});
};
const findComposer = () => {
const marked = document.querySelector('[' + markerAttr + '="1"]');
if (marked instanceof HTMLElement && isVisible(marked)) return marked;
for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) {
const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c));
if (node instanceof HTMLElement) {
node.setAttribute(markerAttr, '1');
return node;
}
}
return null;
};
findComposer.toString = () => 'findComposer';
return { findComposer, markerAttr };
`;
}
export function normalizeBooleanFlag(value, fallback = false) {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
export function requireNonEmptyPrompt(prompt, commandName) {
const text = String(prompt ?? '').trim();
if (!text) {
throw new ArgumentError(
`${commandName} prompt cannot be empty`,
`Example: opencli ${commandName} "hello"`,
);
}
return text;
}
export function requirePositiveInt(value, flagLabel, hint) {
if (!Number.isInteger(value) || value < 1) {
throw new ArgumentError(`${flagLabel} must be a positive integer`, hint);
}
return value;
}
export function parseChatGPTConversationId(value) {
const raw = String(value ?? '').trim();
const match = raw.match(/(?:^|\/c\/)([A-Za-z0-9_-]{8,})(?:[/?#]|$)/);
if (match) return match[1];
if (/^[A-Za-z0-9_-]{8,}$/.test(raw)) return raw;
throw new ArgumentError(
'chatgpt detail requires a conversation id or /c/<id> URL',
'Example: opencli chatgpt detail 123e4567-e89b-12d3-a456-426614174000',
);
}
export async function currentChatGPTUrl(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
return typeof url === 'string' ? url : '';
}
export async function isOnChatGPT(page) {
const url = await currentChatGPTUrl(page);
if (!url) return false;
try {
const host = new URL(url).hostname;
return host === CHATGPT_DOMAIN || host.endsWith(`.${CHATGPT_DOMAIN}`);
} catch {
return false;
}
}
// Comma-joined CSS selector list passed to page.wait({ selector }) so the
// wait succeeds as soon as any composer flavour mounts (querySelectorAll
// matches all of them). Tracks the most stable subset of COMPOSER_SELECTORS;
// we only need to know "the composer is ready", not which variant rendered.
const COMPOSER_WAIT_SELECTOR = '#prompt-textarea, [data-testid="prompt-textarea"]';
const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]';
// Selector used by detail.js to wait for at least one rendered message bubble
// after navigating to /c/<id>; mirrors the markup queried by getVisibleMessages.
export const CONVERSATION_MESSAGE_SELECTOR = '[data-message-author-role], article[data-testid*="conversation-turn"]';
export async function ensureOnChatGPT(page) {
if (await isOnChatGPT(page)) return false;
await page.goto(CHATGPT_URL, { settleMs: 2000 });
try {
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
} catch {
// Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
}
return true;
}
export async function startNewChat(page) {
await page.goto(`${CHATGPT_URL}/new`, { settleMs: 2000 });
try {
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
} catch {
// Composer didn't mount; downstream ensureChatGPTComposer surfaces a typed error.
}
}
export async function getPageState(page) {
return await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const composerSelectors = ${JSON.stringify(COMPOSER_SELECTORS)};
const hasComposer = composerSelectors.some((selector) =>
Array.from(document.querySelectorAll(selector)).some((node) => isVisible(node))
);
const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim();
const loginLink = Array.from(document.querySelectorAll('a, button')).find((node) => {
const label = ((node.innerText || node.textContent || '') + ' ' + (node.getAttribute('aria-label') || '')).trim().toLowerCase();
return isVisible(node) && /^(log in|login|sign up|sign in)$/.test(label);
});
const userMenu = document.querySelector('[data-testid="profile-button"], [aria-label*="Profile"], [aria-label*="Account"], button[id*="headlessui-menu-button"]');
const hasLoginGate = !!loginLink || /log in to chatgpt|sign up to chatgpt|welcome to chatgpt/i.test(text);
return {
url: window.location.href,
title: document.title,
hasComposer,
isLoggedIn: hasComposer || !!userMenu || !hasLoginGate,
hasLoginGate,
};
})()`);
}
export async function ensureChatGPTLogin(page, message = 'ChatGPT requires a logged-in browser session.') {
const state = await getPageState(page);
if (!state.isLoggedIn || state.hasLoginGate) {
throw new AuthRequiredError(CHATGPT_DOMAIN, message);
}
return state;
}
export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is not available on the current page.') {
const state = await ensureChatGPTLogin(page, message);
if (!state.hasComposer) {
throw new CommandExecutionError(message);
}
return state;
}
export async function clearChatGPTDraft(page) {
await page.evaluate(`
(() => {
const removeLabels = [/^remove file/i, /^移除文件/];
for (let pass = 0; pass < 10; pass += 1) {
const button = Array.from(document.querySelectorAll('button')).find((node) => {
const label = node.getAttribute('aria-label') || '';
return removeLabels.some((pattern) => pattern.test(label));
});
if (!button) break;
button.click();
}
const selectors = ${JSON.stringify(COMPOSER_SELECTORS)};
for (const selector of selectors) {
for (const node of document.querySelectorAll(selector)) {
if (!(node instanceof HTMLElement)) continue;
if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
node.value = '';
} else if (node.isContentEditable) {
node.textContent = '';
node.innerHTML = '<p><br></p>';
} else {
node.textContent = '';
}
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}
}
})()
`);
await page.wait(0.5);
}
/**
* Send a message to the ChatGPT composer and submit it.
* Returns true if the message was sent successfully.
*/
export async function sendChatGPTMessage(page, text) {
// Close sidebar if open (it can cover the chat composer)
await page.evaluate(`
(() => {
const labels = ${JSON.stringify(CLOSE_SIDEBAR_LABELS)};
const closeBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || ''));
if (closeBtn) closeBtn.click();
})()
`);
// The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next
// page.evaluate roundtrip flushes the close-sidebar React update and
// findComposer() retries inside a single CDP call, so no fixed sleep is
// needed before reading the composer.
const typeResult = await page.evaluate(`
(() => {
${buildComposerLocatorScript()}
const composer = findComposer();
if (!composer) return false;
composer.focus();
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
composer.value = '';
} else if (composer.isContentEditable) {
composer.textContent = '';
composer.innerHTML = '<p><br></p>';
} else {
composer.textContent = '';
}
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
`);
if (!typeResult) return false;
// Use page.type() which is Playwright's native method
try {
if (page.nativeType) {
await page.nativeType(text);
} else {
throw new Error('nativeType unavailable');
}
} catch (e) {
// Fallback: use execCommand
await page.evaluate(`
(() => {
var composer = null;
var sels = ${JSON.stringify(COMPOSER_SELECTORS)};
for (var si = 0; si < sels.length; si++) { composer = document.querySelector(sels[si]); if (composer) break; }
if (!composer) return;
composer.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
})()
`);
}
let sent = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.wait(0.5);
sent = await page.evaluate(`
(() => {
const isUsable = (button) => button
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true';
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
const btns = Array.from(document.querySelectorAll('button'));
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
const sendBtn = isUsable(primary)
? primary
: btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b));
return { sendBtnFound: !!sendBtn };
})()
`);
if (sent?.sendBtnFound) break;
}
if (!sent?.sendBtnFound) {
return false;
}
await page.evaluate(`
(() => {
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
if (sendBtn) sendBtn.click();
})()
`);
return true;
}
export async function getVisibleMessages(page) {
const result = await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\u00a0/g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
const roleOf = (node) => {
const attr = node.getAttribute('data-message-author-role') || node.getAttribute('data-author') || '';
if (/assistant/i.test(attr)) return 'Assistant';
if (/user/i.test(attr)) return 'User';
const testid = node.getAttribute('data-testid') || '';
if (/assistant/i.test(testid)) return 'Assistant';
if (/user/i.test(testid)) return 'User';
const label = node.getAttribute('aria-label') || '';
if (/assistant|chatgpt/i.test(label)) return 'Assistant';
if (/you|user/i.test(label)) return 'User';
return '';
};
let nodes = Array.from(document.querySelectorAll('[data-message-author-role], article[data-testid*="conversation-turn"]'));
nodes = nodes.filter((node) => node instanceof HTMLElement && isVisible(node));
const rows = [];
const seen = new Set();
for (const node of nodes) {
let role = roleOf(node);
const roleNode = node.querySelector('[data-message-author-role], [data-author]');
if (!role && roleNode) role = roleOf(roleNode);
if (!role) continue;
const contentNode = node.querySelector('[data-message-author-role] .markdown')
|| node.querySelector('.markdown')
|| node.querySelector('[data-message-author-role]')
|| node;
const html = contentNode instanceof HTMLElement ? (contentNode.innerHTML || '') : '';
const text = normalize(contentNode instanceof HTMLElement ? (contentNode.innerText || contentNode.textContent || '') : '');
if (!text) continue;
const key = role + '\\n' + text;
if (seen.has(key)) continue;
seen.add(key);
rows.push({ role, text, html });
}
return rows;
})()`);
if (!Array.isArray(result)) return [];
return result.map((item, index) => ({
Index: index + 1,
Role: item?.role === 'Assistant' ? 'Assistant' : 'User',
Text: String(item?.text || '').trim(),
Html: String(item?.html || ''),
})).filter((item) => item.Text);
}
export function messageHtmlToMarkdown(html) {
try {
return htmlToMarkdown(html).trim();
} catch {
return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
}
export async function getBubbleCount(page) {
const messages = await getVisibleMessages(page);
return messages.length;
}
export async function waitForChatGPTResponse(page, baselineCount, prompt, timeoutSeconds) {
const startTime = Date.now();
let lastText = '';
let stableCount = 0;
while (Date.now() - startTime < timeoutSeconds * 1000) {
await page.wait(3);
if (await isGenerating(page)) {
stableCount = 0;
continue;
}
const messages = await getVisibleMessages(page);
const newMessages = messages.slice(Math.max(0, baselineCount));
const assistant = [...newMessages].reverse().find((m) => m.Role === 'Assistant')
|| [...messages].reverse().find((m) => m.Role === 'Assistant');
const candidate = String(assistant?.Text || '').trim();
if (!candidate || candidate === String(prompt || '').trim()) continue;
if (candidate === lastText) {
stableCount += 1;
if (stableCount >= 2) return candidate;
} else {
lastText = candidate;
stableCount = 0;
}
}
throw new TimeoutError(
'chatgpt ask',
timeoutSeconds,
'No ChatGPT response appeared before timeout. Re-run with a higher --timeout if it is still generating.',
);
}
export async function getConversationList(page) {
// ensureOnChatGPT already waits for the composer selector after navigation,
// so the previous standalone 2 s settle is redundant.
await ensureOnChatGPT(page);
const openSidebar = await page.evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button'))
.find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || ''));
if (button instanceof HTMLElement) {
button.click();
return true;
}
return false;
})()`);
if (openSidebar) {
try {
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
} catch {
// Sidebar slide-in didn't surface conversation links; extractConversationLinks below tolerates empty and falls back to home goto.
}
}
let items = await extractConversationLinks(page);
if (!items.length) {
await page.goto(CHATGPT_URL, { settleMs: 2000 });
try {
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 8 });
} catch {
// No conversation links visible after fallback goto; extractConversationLinks returns empty.
}
items = await extractConversationLinks(page);
}
return items;
}
async function extractConversationLinks(page) {
const items = await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const links = Array.from(document.querySelectorAll('a[href*="/c/"]'))
.filter((link) => link instanceof HTMLAnchorElement && isVisible(link));
const seen = new Set();
const rows = [];
for (const link of links) {
const href = link.getAttribute('href') || '';
const match = href.match(/\\/c\\/([^/?#]+)/);
if (!match || seen.has(match[1])) continue;
seen.add(match[1]);
const title = (link.innerText || link.textContent || '').replace(/\\s+/g, ' ').trim() || '(untitled)';
rows.push({
Id: match[1],
Title: title,
Url: href.startsWith('http') ? href : ('${CHATGPT_URL}' + href),
});
}
return rows;
})()`);
return Array.isArray(items)
? items.map((item, index) => ({
Index: index + 1,
Id: String(item?.Id || ''),
Title: String(item?.Title || '(untitled)').trim() || '(untitled)',
Url: String(item?.Url || ''),
})).filter((item) => item.Id)
: [];
}
function imageMimeFromPath(filePath) {
const lower = String(filePath || '').toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.heic')) return 'image/heic';
if (lower.endsWith('.heif')) return 'image/heif';
return 'image/jpeg';
}
export async function prepareChatGPTImagePaths(imagePaths) {
const fs = await import('node:fs');
const path = await import('node:path');
const absPaths = imagePaths.map(filePath => path.default.resolve(filePath));
const allowedExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
for (const absPath of absPaths) {
if (!fs.default.existsSync(absPath)) {
return { ok: false, reason: `Image not found: ${absPath}` };
}
const stat = fs.default.statSync(absPath);
if (!stat.isFile()) {
return { ok: false, reason: `Not a file: ${absPath}` };
}
if (stat.size > 25 * 1024 * 1024) {
return { ok: false, reason: `Image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 25 MB` };
}
const ext = path.default.extname(absPath).toLowerCase();
if (!allowedExts.has(ext)) {
return { ok: false, reason: `Unsupported image type: ${absPath}` };
}
}
return { ok: true, paths: absPaths };
}
async function waitForChatGPTUploadPreview(page, fileNames) {
const namesJson = JSON.stringify(fileNames);
for (let attempt = 0; attempt < 10; attempt += 1) {
await page.wait(1);
const ready = await page.evaluate(`
(() => {
const names = ${namesJson};
const text = document.body ? (document.body.innerText || '') : '';
const matchedNames = names.filter(name => text.includes(name)).length;
if (matchedNames >= names.length) return true;
const composer = document.querySelector('[aria-label="Chat with ChatGPT"], [placeholder="Ask anything"], #prompt-textarea');
let root = composer;
for (let i = 0; i < 6 && root && root.parentElement; i += 1) root = root.parentElement;
const scope = root || document.body;
if (!scope) return false;
const previewNodes = scope.querySelectorAll('img[src], canvas, video, [style*="background-image"], [data-testid*="attachment"], [data-testid*="upload"], [class*="attachment"], [class*="upload"]');
return previewNodes.length >= names.length;
})()
`);
if (ready) return true;
}
return false;
}
export async function uploadChatGPTImages(page, imagePaths) {
const fs = await import('node:fs');
const path = await import('node:path');
const prepared = await prepareChatGPTImagePaths(imagePaths);
if (!prepared.ok) return prepared;
const absPaths = prepared.paths;
const fileNames = absPaths.map(filePath => path.default.basename(filePath));
let uploaded = false;
if (page.setFileInput) {
try {
await page.setFileInput(absPaths, 'input[type="file"]');
uploaded = true;
} catch (err) {
const msg = String(err?.message || err);
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) {
throw err;
}
}
}
if (!uploaded) {
const files = absPaths.map(absPath => ({
name: path.default.basename(absPath),
mime: imageMimeFromPath(absPath),
base64: fs.default.readFileSync(absPath).toString('base64'),
}));
const fallbackResult = await page.evaluate(`
(() => {
const files = ${JSON.stringify(files)};
const input = document.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) {
return { ok: false, reason: 'file input not found' };
}
const dt = new DataTransfer();
for (const item of files) {
const binary = atob(item.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.mime }));
}
input.files = dt.files;
const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$'));
if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') {
const nativeEvent = new Event('change', { bubbles: true });
input[propsKey].onChange({
target: input,
currentTarget: input,
nativeEvent,
preventDefault() {},
stopPropagation() {},
isDefaultPrevented() { return false; },
isPropagationStopped() { return false; },
persist() {},
});
} else {
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
return { ok: true };
})()
`);
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
}
const ready = await waitForChatGPTUploadPreview(page, fileNames);
if (!ready) return { ok: false, reason: 'image upload preview did not appear' };
return { ok: true, files: absPaths };
}
/**
* Check if ChatGPT is still generating a response.
*/
export async function isGenerating(page) {
return await page.evaluate(`
(() => {
return Array.from(document.querySelectorAll('button')).some(b => {
const label = b.getAttribute('aria-label') || '';
return label === 'Stop generating' || label.includes('Thinking');
});
})()
`);
}
/**
* Get visible image URLs from the ChatGPT page (excluding profile/avatar images).
*/
export async function getChatGPTVisibleImageUrls(page) {
return await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 32 && rect.height > 32;
};
const imgs = Array.from(document.querySelectorAll('img')).filter(img =>
img instanceof HTMLImageElement && isVisible(img)
);
const urls = [];
const seen = new Set();
for (const img of imgs) {
const src = img.currentSrc || img.src || '';
const alt = (img.getAttribute('alt') || '').toLowerCase();
const cls = (img.className || '').toLowerCase();
const width = img.naturalWidth || img.width || 0;
const height = img.naturalHeight || img.height || 0;
if (!src) continue;
if (alt.includes('avatar') || alt.includes('profile') || alt.includes('logo') || alt.includes('icon')) continue;
if (cls.includes('avatar') || cls.includes('profile') || cls.includes('icon')) continue;
if (width < 128 && height < 128) continue;
if (seen.has(src)) continue;
seen.add(src);
urls.push(src);
}
return urls;
})()
`);
}
/**
* Wait for new images to appear after sending a prompt.
*/
export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, convUrl) {
const beforeSet = new Set(beforeUrls);
const pollIntervalSeconds = 3;
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
let lastUrls = [];
let stableCount = 0;
for (let i = 0; i < maxPolls; i++) {
await page.wait(i === 0 ? 3 : pollIntervalSeconds);
let currentUrl = '';
if (convUrl && convUrl.includes('/c/')) {
currentUrl = await page.evaluate('window.location.href').catch(() => '');
if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
await page.goto(convUrl);
await page.wait(3);
}
}
const generating = await isGenerating(page);
if (generating) continue;
if (convUrl && convUrl.includes('/c/') && i > 0 && i % 5 === 0) {
const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl);
if (onConversation) {
await page.goto(convUrl);
await page.wait(3);
}
}
const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url));
if (urls.length === 0) continue;
const key = urls.join('\n');
const prevKey = lastUrls.join('\n');
if (key === prevKey) {
stableCount += 1;
} else {
lastUrls = urls;
stableCount = 1;
}
if (stableCount >= 2 || i === maxPolls - 1) {
return lastUrls;
}
}
return lastUrls;
}
export const __test__ = {
COMPOSER_SELECTORS,
SEND_BUTTON_SELECTOR,
SEND_BUTTON_FALLBACK_SELECTORS,
SEND_BUTTON_LABELS,
CLOSE_SIDEBAR_LABELS,
isSameChatGPTConversation,
parseChatGPTConversationId,
imageMimeFromPath,
};
/**
* Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs.
*/
export async function getChatGPTImageAssets(page, urls) {
const urlsJson = JSON.stringify(urls);
return await page.evaluate(`
(async (targetUrls) => {
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read blob'));
reader.readAsDataURL(blob);
});
const inferMime = (value, fallbackUrl) => {
if (value) return value;
const lower = String(fallbackUrl || '').toLowerCase();
if (lower.includes('.png')) return 'image/png';
if (lower.includes('.webp')) return 'image/webp';
if (lower.includes('.gif')) return 'image/gif';
return 'image/jpeg';
};
const results = [];
for (const targetUrl of targetUrls) {
let dataUrl = '';
let mimeType = 'image/jpeg';
let width = 0;
let height = 0;
// Try to find the img element for size info
const img = Array.from(document.querySelectorAll('img')).find(el =>
(el.currentSrc || el.src || '') === targetUrl
);
if (img) {
width = img.naturalWidth || img.width || 0;
height = img.naturalHeight || img.height || 0;
}
try {
if (String(targetUrl).startsWith('data:')) {
dataUrl = String(targetUrl);
mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png';
} else {
// Try to fetch via CORS from the page's origin
const res = await fetch(targetUrl, { credentials: 'include' });
if (res.ok) {
const blob = await res.blob();
mimeType = inferMime(blob.type, targetUrl);
dataUrl = await blobToDataUrl(blob);
}
}
} catch (e) {
// If fetch fails (CORS), try canvas approach via img element
}
// Fallback: draw img to canvas
if (!dataUrl && img && img instanceof HTMLImageElement) {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width || 512;
canvas.height = img.naturalHeight || img.height || 512;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0);
dataUrl = canvas.toDataURL('image/png');
mimeType = 'image/png';
}
} catch (e) { }
}
if (dataUrl) {
results.push({ url: String(targetUrl), dataUrl, mimeType, width, height });
}
}
return results;
})(${urlsJson})
`, urls);
}