Files
jackwener__opencli/clis/chatgpt/utils.js
Benjamin Liu 25e86532a3 fix(chatgpt): fix image generation detection and output path (#1218)
* fix(chatgpt): fix image generation detection and output path

Three fixes for chatgpt image command:

1. Page navigation: ChatGPT redirects away from the conversation
   after sending. Poll for the /c/ URL after send, then periodically
   reload the conversation page during image wait to pick up
   asynchronously rendered images.

2. Composer selector: add fallback selectors for the chat input
   since ChatGPT uses different aria-labels across UI versions.

3. Output path: the default '~/Pictures/chatgpt' was passed as a
   literal string without tilde expansion, creating a directory
   named '~' in the working directory. Removed the string default
   and use os.homedir() fallback instead.

Fixes #1206

* fix(chatgpt): fail fast on image export failures

* fix(chatgpt): avoid reloads during image generation

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:01:34 +08:00

331 lines
12 KiB
JavaScript

/**
* ChatGPT web browser automation helpers for image generation.
* Cross-platform: works on Linux/macOS/Windows via OpenCLI's CDP browser automation.
*/
export const CHATGPT_DOMAIN = 'chatgpt.com';
export const CHATGPT_URL = 'https://chatgpt.com';
// Selectors
const COMPOSER_SELECTORS = [
'[aria-label="Chat with ChatGPT"]',
'[placeholder="Ask anything"]',
'#prompt-textarea',
];
const SEND_BTN_SELECTOR = 'button[aria-label="Send prompt"]';
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 };
`;
}
/**
* 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 closeBtn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Close sidebar');
if (closeBtn) closeBtn.click();
})()
`);
await page.wait(0.5);
// Wait for composer to be ready and use Playwright's type()
await page.wait(1.5);
const typeResult = await page.evaluate(`
(() => {
${buildComposerLocatorScript()}
const composer = findComposer();
if (!composer) return false;
composer.focus();
composer.textContent = '';
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)});
})()
`);
}
// Wait for send button to appear (it only shows when there's text)
await page.wait(1.5);
// Click send button
const sent = await page.evaluate(`
(() => {
const btns = Array.from(document.querySelectorAll('button'));
const sendBtn = btns.find(b => b.getAttribute('aria-label') === 'Send prompt');
return { sendBtnFound: !!sendBtn };
})()
`);
if (!sent || !sent.sendBtnFound) {
return false;
}
await page.evaluate(`
(() => {
const sendBtn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Send prompt');
if (sendBtn) sendBtn.click();
})()
`);
return true;
}
/**
* 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,
isSameChatGPTConversation,
};
/**
* 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);
}