mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
25e86532a3
* 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>
331 lines
12 KiB
JavaScript
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);
|
|
}
|