image.js
1 import * as os from 'node:os'; 2 import * as path from 'node:path'; 3 import { cli, Strategy } from '@jackwener/opencli/registry'; 4 import { saveBase64ToFile } from '@jackwener/opencli/utils'; 5 import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js'; 6 7 const CHATGPT_DOMAIN = 'chatgpt.com'; 8 9 function extFromMime(mime) { 10 if (mime.includes('png')) return '.png'; 11 if (mime.includes('webp')) return '.webp'; 12 if (mime.includes('gif')) return '.gif'; 13 return '.jpg'; 14 } 15 16 function normalizeBooleanFlag(value) { 17 if (typeof value === 'boolean') return value; 18 const normalized = String(value ?? '').trim().toLowerCase(); 19 return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; 20 } 21 22 function displayPath(filePath) { 23 const home = os.homedir(); 24 return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath; 25 } 26 27 async function currentChatGPTLink(page) { 28 const url = await page.evaluate('window.location.href').catch(() => ''); 29 return typeof url === 'string' && url ? url : 'https://chatgpt.com'; 30 } 31 32 export const imageCommand = cli({ 33 site: 'chatgpt', 34 name: 'image', 35 description: 'Generate images with ChatGPT web and save them locally', 36 domain: CHATGPT_DOMAIN, 37 strategy: Strategy.COOKIE, 38 browser: true, 39 navigateBefore: false, 40 defaultFormat: 'plain', 41 timeoutSeconds: 240, 42 args: [ 43 { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' }, 44 { name: 'op', default: '~/Pictures/chatgpt', help: 'Output directory' }, 45 { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' }, 46 ], 47 columns: ['status', 'file', 'link'], 48 func: async (page, kwargs) => { 49 const prompt = kwargs.prompt; 50 const outputDir = kwargs.op || path.join(os.homedir(), 'Pictures', 'chatgpt'); 51 const skipDownloadRaw = kwargs.sd; 52 const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw); 53 const timeout = 120; 54 55 // Navigate to chatgpt.com/new with full reload to clear React sidebar state 56 await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 }); 57 58 const beforeUrls = await getChatGPTVisibleImageUrls(page); 59 60 // Send the image generation prompt - must be explicit 61 const sent = await sendChatGPTMessage(page, `Generate an image of: ${prompt}`); 62 if (!sent) { 63 return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }]; 64 } 65 66 // Wait for response and images 67 const urls = await waitForChatGPTImages(page, beforeUrls, timeout); 68 const link = await currentChatGPTLink(page); 69 70 if (!urls.length) { 71 return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }]; 72 } 73 74 if (skipDownload) { 75 return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }]; 76 } 77 78 // Export and save images 79 const assets = await getChatGPTImageAssets(page, urls); 80 if (!assets.length) { 81 return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }]; 82 } 83 84 const stamp = Date.now(); 85 const results = []; 86 for (let index = 0; index < assets.length; index += 1) { 87 const asset = assets[index]; 88 const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, ''); 89 const suffix = assets.length > 1 ? `_${index + 1}` : ''; 90 const ext = extFromMime(asset.mimeType); 91 const filePath = path.join(outputDir, `chatgpt_${stamp}${suffix}${ext}`); 92 await saveBase64ToFile(base64, filePath); 93 results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` }); 94 } 95 return results; 96 }, 97 });