From 04a52eb5c2e8d88ca7a0150f503c8bfcbf8ad394 Mon Sep 17 00:00:00 2001 From: ashashash001001-stack Date: Wed, 3 Jun 2026 02:27:47 +0800 Subject: [PATCH 1/2] feat(qwen-studio): add adapter for chat.qwen.ai - chat-list: list conversation history via same-origin API fetch - ask: send message and return assistant response via UI flow - image: generate images and save locally --- clis/qwen-studio/ask.js | 171 +++++++++++++++ clis/qwen-studio/chat-list.js | 61 ++++++ clis/qwen-studio/image.js | 398 ++++++++++++++++++++++++++++++++++ 3 files changed, 630 insertions(+) create mode 100644 clis/qwen-studio/ask.js create mode 100644 clis/qwen-studio/chat-list.js create mode 100644 clis/qwen-studio/image.js diff --git a/clis/qwen-studio/ask.js b/clis/qwen-studio/ask.js new file mode 100644 index 000000000..e133a8da2 --- /dev/null +++ b/clis/qwen-studio/ask.js @@ -0,0 +1,171 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +const SIDE_CHANNEL_ID = '__opencli_side_channel__'; + +cli({ + site: 'qwen-studio', + name: 'ask', + description: 'Send a message to Qwen AI and get the response (chat.qwen.ai)', + access: 'write', + example: 'opencli qwen-studio ask --message "What is 2+2?" --model qwen3.7-plus', + domain: 'chat.qwen.ai', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'message', type: 'string', required: true, help: 'The message to send to Qwen AI' }, + { name: 'model', type: 'string', default: 'qwen3.7-plus', help: 'Model to use: qwen3.7-plus (default), qwen3.7-max, qwen3.6-plus, qwen3.5-plus' }, + ], + columns: ['question', 'answer', 'model', 'chatId'], + func: async (page, args) => { + const message = String(args.message ?? '').trim(); + if (!message) throw new ArgumentError('message is required'); + if (message.length > 8000) throw new ArgumentError('message too long (max 8000 chars)'); + + const model = String(args.model ?? 'qwen3.7-plus'); + + await page.goto('https://chat.qwen.ai/'); + + // Helper: run main-world code, return result via DOM side-channel + const runMainWorld = async (code) => { + // Reset side channel + await page.evaluate((id) => { + let el = document.getElementById(id); + if (!el) { el = document.createElement('div'); el.id = id; el.style.display = 'none'; document.body.appendChild(el); } + el.textContent = ''; + el.dataset.value = ''; + }, SIDE_CHANNEL_ID); + + // Inject main-world script that writes to side channel + await page.evaluate((c, id) => { + const s = document.createElement('script'); + s.textContent = `try { var __r = (function(){${c}})(); document.getElementById('${id}').dataset.value = JSON.stringify(__r); } catch(e) { document.getElementById('${id}').dataset.value = JSON.stringify({ ok:false, reason:'scriptErr: ' + e.message }); }`; + document.head.appendChild(s); + s.remove(); + }, code, SIDE_CHANNEL_ID); + + // Read result from side channel + const raw = await page.evaluate((id) => document.getElementById(id)?.dataset?.value, SIDE_CHANNEL_ID); + try { return JSON.parse(raw); } catch { return { ok: false, reason: 'parseErr', raw }; } + }; + + // Wait for hydration + let hydrated = false; + for (let i = 0; i < 30; i++) { + hydrated = await page.evaluate(() => { + const ta = document.querySelector('textarea[placeholder*="幫助"]') || document.querySelector('textarea'); + return ta && ta.offsetParent !== null; + }).catch(() => false); + if (hydrated) break; + await page.wait(1); + } + if (!hydrated) { + const url = await page.evaluate(() => window.location.href).catch(() => 'unknown'); + throw new CommandExecutionError(`Qwen Studio SPA did not hydrate within 30s. URL: ${url}`); + } + + // STEP 1: Type message — set value + dispatch input + call React onChange directly + const typed = await runMainWorld(` + var t = document.querySelector('textarea[placeholder*="幫助"]') || document.querySelector('textarea'); + if (!t) return { ok: false, reason: 'no textarea' }; + t.focus(); + var propsKey = Object.keys(t).find(k => k.startsWith('__reactProps')); + if (!propsKey) return { ok: false, reason: 'no reactProps' }; + var props = t[propsKey]; + if (!props || !props.onChange) return { ok: false, reason: 'no onChange' }; + var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + nativeSetter.call(t, ${JSON.stringify(message)}); + t.dispatchEvent(new Event('input', { bubbles: true })); + try { + props.onChange({ target: t, currentTarget: t, type: 'change', bubbles: true }); + } catch (e) { + return { ok: false, reason: 'onChange err: ' + e.message, after: t.value }; + } + return { ok: true, val: t.value }; + `); + if (!typed?.ok) throw new CommandExecutionError(`Type failed: ${typed?.reason || 'unknown'} after=${typed?.after}`); + + // STEP 2: Wait for send button (replaces voice-input when React state has text) + let sendReady = false; + let btnInfo = null; + for (let i = 0; i < 10; i++) { + btnInfo = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + if (!ta) return null; + const btn = ta.parentElement?.querySelector('button.send-button') + || Array.from(document.querySelectorAll('button')).find(b => + b.className && b.className.includes && b.className.includes('send-button') && b.offsetParent !== null + ); + if (!btn) return null; + const propsKey = Object.keys(btn).find(k => k.startsWith('__reactProps')); + return { hasOnClick: !!(propsKey && btn[propsKey]?.onClick) }; + }).catch(() => null); + if (btnInfo) { sendReady = true; break; } + await page.wait(1); + } + if (!sendReady) throw new CommandExecutionError('Send button did not appear after typing (React state may not have updated)'); + + // STEP 3: Click send via main-world React onClick + const clicked = await runMainWorld(` + var ta = document.querySelector('textarea'); + var btn = ta && ta.parentElement && ta.parentElement.querySelector('button.send-button'); + if (!btn) { + btn = Array.from(document.querySelectorAll('button')).find(b => + b.className && b.className.includes && b.className.includes('send-button') && b.offsetParent !== null + ); + } + if (!btn) return { ok: false, reason: 'no send button' }; + var propsKey = Object.keys(btn).find(k => k.startsWith('__reactProps')); + var props = propsKey ? btn[propsKey] : null; + if (props && props.onClick) { + try { props.onClick({ target: btn, currentTarget: btn, type: 'click', preventDefault: function(){}, stopPropagation: function(){} }); } + catch (e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick-direct' }; + } + btn.click(); + return { ok: true, method: 'native-click' }; + `); + if (!clicked?.ok) throw new CommandExecutionError(`Click failed: ${clicked?.reason}`); + + // Poll URL for the real chat UUID; "/c/new-chat" is a brief draft placeholder + let chatId = null; + for (let i = 0; i < 30; i++) { + const url = await page.evaluate(() => window.location.href).catch(() => ''); + const match = url.match(/\/c\/([^/]+)/); + const candidate = match ? match[1] : null; + if (candidate && candidate !== 'new-chat') { chatId = candidate; break; } + await page.wait(1); + } + if (!chatId) throw new CommandExecutionError('URL did not change to /c/{UUID} after send (timeout)'); + + // STEP 5: Poll chat detail API for assistant response + for (let i = 0; i < 30; i++) { + await page.wait(2); + const result = await page.evaluate((cid) => { + return new Promise((resolve) => { + fetch('https://chat.qwen.ai/api/v2/chats/' + cid + '/') + .then(r => r.json()) + .then(j => { + const msgs = j?.data?.chat?.history?.messages; + if (!msgs) { resolve({ ok: false, code: j?.data?.code, details: j?.data?.details }); return; } + for (const id of Object.keys(msgs)) { + const m = msgs[id]; + if (m.role === 'assistant' && m.content_list) { + const parts = m.content_list.filter(c => c.phase === 'answer').map(c => c.content).join(''); + if (parts) { resolve({ ok: true, answer: parts }); return; } + } + } + resolve({ ok: false, code: 'NO_ANSWER_YET', count: Object.keys(msgs).length }); + }) + .catch(e => resolve({ ok: false, code: 'FETCH_ERR', error: e.message })); + }); + }, chatId); + if (result?.ok) return { answer: result.answer.trim(), chatId }; + if (i === 0 && result?.code === 'Not_Found') { + throw new CommandExecutionError(`Chat ${chatId} not found (auto-deleted). React state may not have updated.`); + } + } + throw new CommandExecutionError(`No assistant response received within 60s for chat ${chatId}`); + }, +}); diff --git a/clis/qwen-studio/chat-list.js b/clis/qwen-studio/chat-list.js new file mode 100644 index 000000000..fcd857038 --- /dev/null +++ b/clis/qwen-studio/chat-list.js @@ -0,0 +1,61 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +cli({ + site: 'qwen-studio', + name: 'chat-list', + description: '获取 Qwen Studio (chat.qwen.ai) 对话历史列表(最近对话默认 10 条)', + access: 'read', + example: 'opencli qwen-studio chat-list --limit 20', + domain: 'chat.qwen.ai', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: '返回条数 (max 50)' }, + ], + columns: ['rank', 'id', 'title', 'chatType', 'updatedAt', 'createdAt', 'pinned'], + func: async (page, args) => { + const limit = Number(args.limit ?? 10); + if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer'); + if (limit > 50) throw new ArgumentError('limit must be <= 50'); + + // Ensure we're on chat.qwen.ai before making same-origin fetch + const currentUrl = await page.evaluate(() => window.location.href).catch(() => ''); + if (!currentUrl.includes('chat.qwen.ai')) { + await page.goto('https://chat.qwen.ai/'); + await page.wait(2); + } + + let data; + try { + const body = await page.evaluate(async (maxRows) => { + const resp = await fetch('https://chat.qwen.ai/api/v2/chats/?page=1&exclude_project=true'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const j = await resp.json(); + return j?.data?.slice(0, maxRows) ?? []; + }, limit); + data = body; + } catch (error) { + const msg = error?.message || String(error); + if (msg.includes('HTTP 401') || msg.includes('HTTP 403') || msg.includes('401') || msg.includes('403')) { + throw new AuthRequiredError('chat.qwen.ai', 'Session expired. Please log into chat.qwen.ai in your browser.'); + } + throw new CommandExecutionError(`Qwen Studio chat list request failed: ${msg}`); + } + + if (!Array.isArray(data) || data.length === 0) { + throw new EmptyResultError('qwen-studio chat-list', 'No conversations found'); + } + + return data.map((item, i) => ({ + rank: i + 1, + id: item.id, + title: item.title, + chatType: item.chat_type, + updatedAt: item.updated_at, + createdAt: item.created_at, + pinned: item.pinned, + })); + }, +}); \ No newline at end of file diff --git a/clis/qwen-studio/image.js b/clis/qwen-studio/image.js new file mode 100644 index 000000000..129340073 --- /dev/null +++ b/clis/qwen-studio/image.js @@ -0,0 +1,398 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { saveBase64ToFile } from '@jackwener/opencli/utils'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors'; + +const SIDE_CHANNEL_ID = '__opencli_image_side_channel__'; + +function displayPath(filePath) { + const home = os.homedir(); + return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath; +} + +function extFromMime(mime) { + if (!mime) return '.png'; + if (mime.includes('png')) return '.png'; + if (mime.includes('webp')) return '.webp'; + if (mime.includes('gif')) return '.gif'; + if (mime.includes('jpeg') || mime.includes('jpg')) return '.jpg'; + return '.png'; +} + +// Run main-world JS and return result via DOM side channel +async function runMainWorld(page, code) { + await page.evaluate((id) => { + let el = document.getElementById(id); + if (!el) { el = document.createElement('div'); el.id = id; el.style.display = 'none'; document.body.appendChild(el); } + el.textContent = ''; + el.dataset.value = ''; + }, SIDE_CHANNEL_ID); + + await page.evaluate((c, id) => { + const s = document.createElement('script'); + s.textContent = `try { var __r = (function(){${c}})(); document.getElementById('${id}').dataset.value = JSON.stringify(__r); } catch(e) { document.getElementById('${id}').dataset.value = JSON.stringify({ ok:false, reason:'scriptErr: ' + e.message }); }`; + document.head.appendChild(s); + s.remove(); + }, code, SIDE_CHANNEL_ID); + + const raw = await page.evaluate((id) => document.getElementById(id)?.dataset?.value, SIDE_CHANNEL_ID); + try { return JSON.parse(raw); } catch { return { ok: false, reason: 'parseErr', raw }; } +} + +// Navigate to a URL and wait for a condition +async function navigateAndWait(page, url, condition, timeoutSecs) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); + const start = Date.now(); + while (Date.now() - start < timeoutSecs * 1000) { + const result = await condition().catch(() => null); + if (result) return result; + await page.wait(0.5); + } + return null; +} + +cli({ + site: 'qwen-studio', + name: 'image', + description: 'Generate images with Qwen Studio (chat.qwen.ai) and save them locally', + access: 'write', + domain: 'chat.qwen.ai', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + defaultFormat: 'plain', + args: [ + { name: 'prompt', required: true, positional: true, help: 'Image prompt to generate' }, + { name: 'op', default: '~/Pictures/qwen-studio', help: 'Output directory' }, + { name: 'ratio', default: '16:9', help: 'Aspect ratio: 16:9, 1:1, 4:3, 9:16' }, + { name: 'sd', type: 'boolean', default: false, help: 'Skip download; only return the Qianwen link' }, + { name: 'timeout', type: 'int', default: 300, help: 'Max seconds to wait for image generation' }, + ], + columns: ['Status', 'File', 'Link'], + func: async (page, kwargs) => { + const prompt = String(kwargs.prompt || '').trim(); + if (!prompt) throw new ArgumentError('prompt is required'); + const outputDir = String(kwargs.op || '~/Pictures/qwen-studio').replace(/^~\//, `${os.homedir()}/`); + const ratio = String(kwargs.ratio || '16:9'); + const skipDownload = Boolean(kwargs.sd); + const timeout = Number(kwargs.timeout ?? 300); + if (!Number.isInteger(timeout) || timeout <= 0) throw new ArgumentError('timeout must be a positive integer'); + + // ── Step 1: Open chat.qwen.ai and wait for sidebar ───────────────────────── + await page.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded' }); + + // Wait for sidebar with "新建對話" to appear (SPA hydration) + let hydrated = false; + for (let i = 0; i < 30; i++) { + hydrated = await page.evaluate(() => { + // Look for the new chat button text + const els = Array.from(document.querySelectorAll('div')); + return els.some(el => el.textContent.trim() === '新建對話' && el.offsetParent !== null); + }).catch(() => false); + if (hydrated) break; + await page.wait(1); + } + if (!hydrated) throw new CommandExecutionError('Qwen Studio sidebar did not load within 30s'); + + // ── Step 2: Click 新建對話 to open a new chat ─────────────────────────────── + const newChatClicked = await runMainWorld(page, ` + var divs = Array.from(document.querySelectorAll('div')); + var newChatDiv = divs.find(d => d.textContent.trim() === '新建對話' && d.offsetParent !== null); + if (!newChatDiv) return { ok: false, reason: 'no new chat div found' }; + // Find the closest parent that is a button or has onClick + var parent = newChatDiv.parentElement; + while (parent && !parent.onClick && parent.tagName !== 'BUTTON') { + parent = parent.parentElement; + } + var el = parent && (parent.onClick || parent.tagName === 'BUTTON') ? parent : newChatDiv; + var propsKey = Object.keys(el).find(k => k.startsWith('__reactProps')); + if (propsKey && el[propsKey]?.onClick) { + try { el[propsKey].onClick({ target: el, currentTarget: el, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick' }; + } + el.click(); + return { ok: true, method: 'native' }; + `); + if (!newChatClicked?.ok) throw new CommandExecutionError(`New chat click failed: ${newChatClicked?.reason}`); + + // Wait for chat area to load (textarea or mode toggle should appear) + let chatAreaLoaded = false; + for (let i = 0; i < 20; i++) { + chatAreaLoaded = await page.evaluate(() => { + // Look for the chat textarea or the mode toggle + const ta = document.querySelector('textarea'); + const modeToggle = Array.from(document.querySelectorAll('span')).find(s => s.textContent === '自動' || s.textContent === '創建圖像'); + return !!(ta || modeToggle); + }).catch(() => false); + if (chatAreaLoaded) break; + await page.wait(1); + } + + // ── Step 3: Check if we're in image creation mode already ───────────────── + // If textarea placeholder is "描述你想要生成的圖像。" we're already in image mode + const inImageMode = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta && ta.placeholder && ta.placeholder.includes('描述'); + }).catch(() => false); + + if (!inImageMode) { + // Need to switch to image creation mode via the mode toggle + // The mode toggle is a div/span containing the current mode text (通常"自動") + // We need to find and click it, then select "創建圖像" from the dropdown + + // First, try to find and click the mode toggle (look for the div containing "自動") + const modeToggleResult = await runMainWorld(page, ` + // Find all divs containing "自動" text that are visible + var divs = Array.from(document.querySelectorAll('div')); + var autoDiv = divs.find(d => d.textContent.trim() === '自動' && d.offsetParent !== null); + if (!autoDiv) return { ok: false, reason: 'no auto div' }; + // Walk up to find clickable element + var el = autoDiv; + for (var i = 0; i < 5 && el; i++) { + var pk = Object.keys(el).find(k => k.startsWith('__reactProps')); + if (el.onClick || (pk && el[pk]?.onClick)) break; + el = el.parentElement; + } + if (!el) return { ok: false, reason: 'no clickable parent found' }; + var propsKey = Object.keys(el).find(k => k.startsWith('__reactProps')); + if (propsKey && el[propsKey]?.onClick) { + try { el[propsKey].onClick({ target: el, currentTarget: el, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick' }; + } + el.click(); + return { ok: true, method: 'native' }; + `); + + if (!modeToggleResult?.ok) throw new CommandExecutionError(`Mode toggle click failed: ${modeToggleResult?.reason}`); + await page.wait(1); + + // Now look for "創建圖像" in the dropdown that should appear + // The dropdown options are divs with role=option or menuitem + const imageOptionResult = await runMainWorld(page, ` + // Wait a moment for dropdown to animate in + var options = Array.from(document.querySelectorAll('[role=\"option\"], [role=\"menuitem\"]')); + var imgOption = options.find(o => o.textContent.includes('創建圖像')); + if (imgOption) { + var propsKey = Object.keys(imgOption).find(k => k.startsWith('__reactProps')); + if (propsKey && imgOption[propsKey]?.onClick) { + try { imgOption[propsKey].onClick({ target: imgOption, currentTarget: imgOption, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick' }; + } + imgOption.click(); + return { ok: true, method: 'clicked' }; + } + return { ok: false, reason: 'no create image option found', optionCount: options.length, optionTexts: options.slice(0,5).map(o => o.textContent.substring(0,20)) }; + `); + + // If "創建圖像" not in dropdown, try finding it as a direct clickable element + if (!imageOptionResult?.ok) { + // Try clicking a div containing "創建圖像" directly + const directClick = await runMainWorld(page, ` + var divs = Array.from(document.querySelectorAll('div')); + var createImgDiv = divs.find(d => d.textContent.trim() === '創建圖像' && d.offsetParent !== null); + if (!createImgDiv) return { ok: false, reason: 'no create image div' }; + var propsKey = Object.keys(createImgDiv).find(k => k.startsWith('__reactProps')); + if (propsKey && createImgDiv[propsKey]?.onClick) { + try { createImgDiv[propsKey].onClick({ target: createImgDiv, currentTarget: createImgDiv, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick' }; + } + createImgDiv.click(); + return { ok: true, method: 'native' }; + `); + if (!directClick?.ok) throw new CommandExecutionError(`Could not find image creation mode: ${directClick?.reason || imageOptionResult?.reason}`); + } + } + + // Wait for the image creation textarea to appear + let taReady = false; + for (let i = 0; i < 15; i++) { + taReady = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta && ta.placeholder && ta.placeholder.includes('描述'); + }).catch(() => false); + if (taReady) break; + await page.wait(1); + } + if (!taReady) throw new CommandExecutionError('Image creation textarea did not appear'); + + // ── Step 4: Set ratio if needed ───────────────────────────────────────────── + // The ratio button is near the textarea - click to expand options + await runMainWorld(page, ` + // Try to find and click a ratio option (16:9, 1:1, etc.) + var ratio = '${ratio}'; + var divs = Array.from(document.querySelectorAll('div')); + var ratioDiv = divs.find(d => d.textContent.trim() === ratio && d.offsetParent !== null); + if (ratioDiv) { + var propsKey = Object.keys(ratioDiv).find(k => k.startsWith('__reactProps')); + if (propsKey && ratioDiv[propsKey]?.onClick) { + try { ratioDiv[propsKey].onClick({ target: ratioDiv, currentTarget: ratioDiv, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true }; + } + ratioDiv.click(); + return { ok: true }; + } + return { ok: false, reason: 'ratio not found: ' + ratio }; + `).catch(() => ({})); + await page.wait(0.5); + + // ── Step 5: Type prompt via React state ───────────────────────────────────── + const typed = await runMainWorld(page, ` + var t = document.querySelector('textarea'); + if (!t) return { ok: false, reason: 'no textarea' }; + t.focus(); + var propsKey = Object.keys(t).find(k => k.startsWith('__reactProps')); + if (!propsKey) return { ok: false, reason: 'no reactProps' }; + var props = t[propsKey]; + if (!props || !props.onChange) return { ok: false, reason: 'no onChange' }; + var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + nativeSetter.call(t, ${JSON.stringify(prompt)}); + t.dispatchEvent(new Event('input', { bubbles: true })); + try { + props.onChange({ target: t, currentTarget: t, type: 'change', bubbles: true }); + } catch (e) { + return { ok: false, reason: 'onChange err: ' + e.message, after: t.value }; + } + return { ok: true, val: t.value }; + `); + if (!typed?.ok) throw new CommandExecutionError(`Type failed: ${typed?.reason}`); + await page.wait(0.5); + + // ── Step 6: Click the create/generate button ──────────────────────────────── + // The button has "創建圖像" text and is near the textarea + const createClicked = await runMainWorld(page, ` + // Find the button with "創建圖像" text that's near the textarea + var btns = Array.from(document.querySelectorAll('button, div[role=\"button\"]')); + var createBtn = btns.find(b => b.offsetParent !== null && ( + b.textContent.includes('創建') || b.getAttribute('aria-label')?.includes('創建') + )); + if (createBtn) { + var propsKey = Object.keys(createBtn).find(k => k.startsWith('__reactProps')); + if (propsKey && createBtn[propsKey]?.onClick) { + try { createBtn[propsKey].onClick({ target: createBtn, currentTarget: createBtn, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick' }; + } + createBtn.click(); + return { ok: true, method: 'click' }; + } + // Fallback: find any visible button in the form area + var ta = document.querySelector('textarea'); + if (!ta) return { ok: false, reason: 'no textarea' }; + var formArea = ta.closest('div'); + if (formArea) { + var nearbyBtns = formArea.querySelectorAll('button'); + for (var i = 0; i < nearbyBtns.length; i++) { + var b = nearbyBtns[i]; + if (b.offsetParent !== null) { + var propsKey = Object.keys(b).find(k => k.startsWith('__reactProps')); + if (propsKey && b[propsKey]?.onClick) { + try { b[propsKey].onClick({ target: b, currentTarget: b, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } + catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick' }; + } + b.click(); + return { ok: true, method: 'click' }; + } + } + } + return { ok: false, reason: 'no create button found' }; + `); + if (!createClicked?.ok) throw new CommandExecutionError(`Create button click failed: ${createClicked?.reason}`); + + // ── Step 7: Wait for URL to change to /c/{uuid} ───────────────────────────── + let chatId = null; + for (let i = 0; i < 30; i++) { + const url = await page.evaluate(() => window.location.href).catch(() => ''); + const match = url.match(/\/c\/([^/?#]+)/); + const candidate = match ? match[1] : null; + if (candidate && candidate !== 'new-chat') { chatId = candidate; break; } + await page.wait(1); + } + if (!chatId) throw new CommandExecutionError('URL did not change to /c/{UUID} after sending image prompt'); + + // ── Step 8: Poll for image URLs in the page DOM ───────────────────────────── + const startTime = Date.now(); + let imageUrls = []; + let lastUrls = []; + + while (Date.now() - startTime < timeout * 1000) { + await page.wait(2); + const urls = await page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll('img')).filter(img => + img.src && img.src.includes('cdn.qwenlm.ai') && img.naturalWidth > 0 + ).map(img => img.src); + return [...new Set(imgs)]; + }); + + if (urls.length && urls.length === lastUrls.length && urls.every((u, i) => u === lastUrls[i])) { + imageUrls = urls; + break; + } + if (urls.length > 0) { + await page.wait(1); + const urls2 = await page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll('img')).filter(img => + img.src && img.src.includes('cdn.qwenlm.ai') && img.naturalWidth > 0 + ).map(img => img.src); + return [...new Set(imgs)]; + }); + if (urls2.length === urls.length) { + imageUrls = urls2; + break; + } + lastUrls = urls2; + } + lastUrls = urls; + } + + if (imageUrls.length === 0) { + throw new TimeoutError('qwen-studio image', timeout, `No generated images found within ${timeout}s`); + } + + if (skipDownload) { + return imageUrls.map(url => ({ Status: 'generated', File: null, Link: url })); + } + + // ── Step 9: Download images ───────────────────────────────────────────────── + const stamp = Date.now(); + const results = []; + for (let i = 0; i < imageUrls.length; i++) { + const url = imageUrls[i]; + const asset = await page.evaluate(async (imgUrl) => { + try { + const res = await fetch(imgUrl, { credentials: 'include' }); + if (!res.ok) return { ok: false, status: res.status }; + const mime = res.headers.get('content-type') || 'image/png'; + const buf = await res.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return { ok: true, mime, base64: btoa(binary) }; + } catch (error) { + return { ok: false, error: String(error?.message || error) }; + } + }, url); + + if (!asset?.ok) { + throw new CommandExecutionError(`Failed to fetch generated image ${i + 1}: ${asset?.error || 'status=' + asset?.status}`); + } + + const suffix = imageUrls.length > 1 ? `_${i + 1}` : ''; + const ext = extFromMime(asset.mime); + const filePath = path.join(outputDir, `qwen_studio_${stamp}${suffix}${ext}`); + await saveBase64ToFile(asset.base64, filePath); + results.push({ Status: 'saved', File: displayPath(filePath), Link: url }); + } + + return results; + }, +}); \ No newline at end of file From c099a155d7a46826a2f663b103c2a13e10c2a532 Mon Sep 17 00:00:00 2001 From: ashashash001001-stack Date: Wed, 3 Jun 2026 18:57:13 +0800 Subject: [PATCH 2/2] Update image.js --- clis/qwen-studio/image.js | 416 ++++++++++++++------------------------ 1 file changed, 151 insertions(+), 265 deletions(-) diff --git a/clis/qwen-studio/image.js b/clis/qwen-studio/image.js index 129340073..3e4bc75b6 100644 --- a/clis/qwen-studio/image.js +++ b/clis/qwen-studio/image.js @@ -1,8 +1,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as https from 'node:https'; +import * as http from 'node:http'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { saveBase64ToFile } from '@jackwener/opencli/utils'; -import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors'; +import { ArgumentError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; const SIDE_CHANNEL_ID = '__opencli_image_side_channel__'; @@ -20,36 +23,25 @@ function extFromMime(mime) { return '.png'; } -// Run main-world JS and return result via DOM side channel -async function runMainWorld(page, code) { - await page.evaluate((id) => { - let el = document.getElementById(id); - if (!el) { el = document.createElement('div'); el.id = id; el.style.display = 'none'; document.body.appendChild(el); } - el.textContent = ''; - el.dataset.value = ''; - }, SIDE_CHANNEL_ID); - - await page.evaluate((c, id) => { - const s = document.createElement('script'); - s.textContent = `try { var __r = (function(){${c}})(); document.getElementById('${id}').dataset.value = JSON.stringify(__r); } catch(e) { document.getElementById('${id}').dataset.value = JSON.stringify({ ok:false, reason:'scriptErr: ' + e.message }); }`; - document.head.appendChild(s); - s.remove(); - }, code, SIDE_CHANNEL_ID); - - const raw = await page.evaluate((id) => document.getElementById(id)?.dataset?.value, SIDE_CHANNEL_ID); - try { return JSON.parse(raw); } catch { return { ok: false, reason: 'parseErr', raw }; } -} - -// Navigate to a URL and wait for a condition -async function navigateAndWait(page, url, condition, timeoutSecs) { - await page.goto(url, { waitUntil: 'domcontentloaded' }); - const start = Date.now(); - while (Date.now() - start < timeoutSecs * 1000) { - const result = await condition().catch(() => null); - if (result) return result; - await page.wait(0.5); - } - return null; +function downloadToBuffer(imageUrl) { + return new Promise((resolve) => { + const protocol = imageUrl.startsWith('https') ? https : http; + const req = protocol.get(imageUrl, { headers: { Accept: 'image/*' } }, (res) => { + if (res.statusCode !== 200) { + resolve({ ok: false, status: res.statusCode }); + return; + } + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const mime = res.headers['content-type'] || 'image/png'; + resolve({ ok: true, mime, buffer: buf }); + }); + }); + req.on('error', (e) => resolve({ ok: false, error: e.message })); + req.setTimeout(30000, () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); }); + }); } cli({ @@ -63,10 +55,9 @@ cli({ navigateBefore: false, defaultFormat: 'plain', args: [ - { name: 'prompt', required: true, positional: true, help: 'Image prompt to generate' }, + { name: 'prompt', required: true, positional: true, help: 'Image prompt to generate (e.g. "一隻可愛的貓")' }, { name: 'op', default: '~/Pictures/qwen-studio', help: 'Output directory' }, - { name: 'ratio', default: '16:9', help: 'Aspect ratio: 16:9, 1:1, 4:3, 9:16' }, - { name: 'sd', type: 'boolean', default: false, help: 'Skip download; only return the Qianwen link' }, + { name: 'sd', type: 'boolean', default: false, help: 'Skip download; only return the image links' }, { name: 'timeout', type: 'int', default: 300, help: 'Max seconds to wait for image generation' }, ], columns: ['Status', 'File', 'Link'], @@ -74,175 +65,53 @@ cli({ const prompt = String(kwargs.prompt || '').trim(); if (!prompt) throw new ArgumentError('prompt is required'); const outputDir = String(kwargs.op || '~/Pictures/qwen-studio').replace(/^~\//, `${os.homedir()}/`); - const ratio = String(kwargs.ratio || '16:9'); const skipDownload = Boolean(kwargs.sd); const timeout = Number(kwargs.timeout ?? 300); if (!Number.isInteger(timeout) || timeout <= 0) throw new ArgumentError('timeout must be a positive integer'); - // ── Step 1: Open chat.qwen.ai and wait for sidebar ───────────────────────── + // Helper: run main-world code, return result via DOM side-channel + const runMainWorld = async (code) => { + await page.evaluate((id) => { + let el = document.getElementById(id); + if (!el) { el = document.createElement('div'); el.id = id; el.style.display = 'none'; document.body.appendChild(el); } + el.textContent = ''; + el.dataset.value = ''; + }, SIDE_CHANNEL_ID); + + await page.evaluate((c, id) => { + const s = document.createElement('script'); + s.textContent = `try { var __r = (function(){${c}})(); document.getElementById('${id}').dataset.value = JSON.stringify(__r); } catch(e) { document.getElementById('${id}').dataset.value = JSON.stringify({ ok:false, reason:'scriptErr: ' + e.message }); }`; + document.head.appendChild(s); + s.remove(); + }, code, SIDE_CHANNEL_ID); + + const raw = await page.evaluate((id) => document.getElementById(id)?.dataset?.value, SIDE_CHANNEL_ID); + try { return JSON.parse(raw); } catch { return { ok: false, reason: 'parseErr', raw }; } + }; + + // ── Step 1: Navigate to chat.qwen.ai ─────────────────────────────────────── await page.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded' }); - // Wait for sidebar with "新建對話" to appear (SPA hydration) + // Wait for hydration (textarea appears) let hydrated = false; for (let i = 0; i < 30; i++) { hydrated = await page.evaluate(() => { - // Look for the new chat button text - const els = Array.from(document.querySelectorAll('div')); - return els.some(el => el.textContent.trim() === '新建對話' && el.offsetParent !== null); - }).catch(() => false); - if (hydrated) break; - await page.wait(1); - } - if (!hydrated) throw new CommandExecutionError('Qwen Studio sidebar did not load within 30s'); - - // ── Step 2: Click 新建對話 to open a new chat ─────────────────────────────── - const newChatClicked = await runMainWorld(page, ` - var divs = Array.from(document.querySelectorAll('div')); - var newChatDiv = divs.find(d => d.textContent.trim() === '新建對話' && d.offsetParent !== null); - if (!newChatDiv) return { ok: false, reason: 'no new chat div found' }; - // Find the closest parent that is a button or has onClick - var parent = newChatDiv.parentElement; - while (parent && !parent.onClick && parent.tagName !== 'BUTTON') { - parent = parent.parentElement; - } - var el = parent && (parent.onClick || parent.tagName === 'BUTTON') ? parent : newChatDiv; - var propsKey = Object.keys(el).find(k => k.startsWith('__reactProps')); - if (propsKey && el[propsKey]?.onClick) { - try { el[propsKey].onClick({ target: el, currentTarget: el, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true, method: 'onClick' }; - } - el.click(); - return { ok: true, method: 'native' }; - `); - if (!newChatClicked?.ok) throw new CommandExecutionError(`New chat click failed: ${newChatClicked?.reason}`); - - // Wait for chat area to load (textarea or mode toggle should appear) - let chatAreaLoaded = false; - for (let i = 0; i < 20; i++) { - chatAreaLoaded = await page.evaluate(() => { - // Look for the chat textarea or the mode toggle const ta = document.querySelector('textarea'); - const modeToggle = Array.from(document.querySelectorAll('span')).find(s => s.textContent === '自動' || s.textContent === '創建圖像'); - return !!(ta || modeToggle); + return ta && ta.offsetParent !== null; }).catch(() => false); - if (chatAreaLoaded) break; - await page.wait(1); - } - - // ── Step 3: Check if we're in image creation mode already ───────────────── - // If textarea placeholder is "描述你想要生成的圖像。" we're already in image mode - const inImageMode = await page.evaluate(() => { - const ta = document.querySelector('textarea'); - return ta && ta.placeholder && ta.placeholder.includes('描述'); - }).catch(() => false); - - if (!inImageMode) { - // Need to switch to image creation mode via the mode toggle - // The mode toggle is a div/span containing the current mode text (通常"自動") - // We need to find and click it, then select "創建圖像" from the dropdown - - // First, try to find and click the mode toggle (look for the div containing "自動") - const modeToggleResult = await runMainWorld(page, ` - // Find all divs containing "自動" text that are visible - var divs = Array.from(document.querySelectorAll('div')); - var autoDiv = divs.find(d => d.textContent.trim() === '自動' && d.offsetParent !== null); - if (!autoDiv) return { ok: false, reason: 'no auto div' }; - // Walk up to find clickable element - var el = autoDiv; - for (var i = 0; i < 5 && el; i++) { - var pk = Object.keys(el).find(k => k.startsWith('__reactProps')); - if (el.onClick || (pk && el[pk]?.onClick)) break; - el = el.parentElement; - } - if (!el) return { ok: false, reason: 'no clickable parent found' }; - var propsKey = Object.keys(el).find(k => k.startsWith('__reactProps')); - if (propsKey && el[propsKey]?.onClick) { - try { el[propsKey].onClick({ target: el, currentTarget: el, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true, method: 'onClick' }; - } - el.click(); - return { ok: true, method: 'native' }; - `); - - if (!modeToggleResult?.ok) throw new CommandExecutionError(`Mode toggle click failed: ${modeToggleResult?.reason}`); + if (hydrated) break; await page.wait(1); - - // Now look for "創建圖像" in the dropdown that should appear - // The dropdown options are divs with role=option or menuitem - const imageOptionResult = await runMainWorld(page, ` - // Wait a moment for dropdown to animate in - var options = Array.from(document.querySelectorAll('[role=\"option\"], [role=\"menuitem\"]')); - var imgOption = options.find(o => o.textContent.includes('創建圖像')); - if (imgOption) { - var propsKey = Object.keys(imgOption).find(k => k.startsWith('__reactProps')); - if (propsKey && imgOption[propsKey]?.onClick) { - try { imgOption[propsKey].onClick({ target: imgOption, currentTarget: imgOption, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true, method: 'onClick' }; - } - imgOption.click(); - return { ok: true, method: 'clicked' }; - } - return { ok: false, reason: 'no create image option found', optionCount: options.length, optionTexts: options.slice(0,5).map(o => o.textContent.substring(0,20)) }; - `); - - // If "創建圖像" not in dropdown, try finding it as a direct clickable element - if (!imageOptionResult?.ok) { - // Try clicking a div containing "創建圖像" directly - const directClick = await runMainWorld(page, ` - var divs = Array.from(document.querySelectorAll('div')); - var createImgDiv = divs.find(d => d.textContent.trim() === '創建圖像' && d.offsetParent !== null); - if (!createImgDiv) return { ok: false, reason: 'no create image div' }; - var propsKey = Object.keys(createImgDiv).find(k => k.startsWith('__reactProps')); - if (propsKey && createImgDiv[propsKey]?.onClick) { - try { createImgDiv[propsKey].onClick({ target: createImgDiv, currentTarget: createImgDiv, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true, method: 'onClick' }; - } - createImgDiv.click(); - return { ok: true, method: 'native' }; - `); - if (!directClick?.ok) throw new CommandExecutionError(`Could not find image creation mode: ${directClick?.reason || imageOptionResult?.reason}`); - } } - - // Wait for the image creation textarea to appear - let taReady = false; - for (let i = 0; i < 15; i++) { - taReady = await page.evaluate(() => { - const ta = document.querySelector('textarea'); - return ta && ta.placeholder && ta.placeholder.includes('描述'); - }).catch(() => false); - if (taReady) break; - await page.wait(1); + if (!hydrated) { + const url = await page.evaluate(() => window.location.href).catch(() => 'unknown'); + throw new CommandExecutionError(`Qwen Studio SPA did not hydrate within 30s. URL: ${url}`); } - if (!taReady) throw new CommandExecutionError('Image creation textarea did not appear'); - // ── Step 4: Set ratio if needed ───────────────────────────────────────────── - // The ratio button is near the textarea - click to expand options - await runMainWorld(page, ` - // Try to find and click a ratio option (16:9, 1:1, etc.) - var ratio = '${ratio}'; - var divs = Array.from(document.querySelectorAll('div')); - var ratioDiv = divs.find(d => d.textContent.trim() === ratio && d.offsetParent !== null); - if (ratioDiv) { - var propsKey = Object.keys(ratioDiv).find(k => k.startsWith('__reactProps')); - if (propsKey && ratioDiv[propsKey]?.onClick) { - try { ratioDiv[propsKey].onClick({ target: ratioDiv, currentTarget: ratioDiv, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true }; - } - ratioDiv.click(); - return { ok: true }; - } - return { ok: false, reason: 'ratio not found: ' + ratio }; - `).catch(() => ({})); - await page.wait(0.5); + // ── Step 2: Type the image generation prompt ─────────────────────────────── + // Wrap prompt in image generation request + const imagePrompt = `請生成一張圖片:${prompt}`; - // ── Step 5: Type prompt via React state ───────────────────────────────────── - const typed = await runMainWorld(page, ` + const typed = await runMainWorld(` var t = document.querySelector('textarea'); if (!t) return { ok: false, reason: 'no textarea' }; t.focus(); @@ -251,7 +120,7 @@ cli({ var props = t[propsKey]; if (!props || !props.onChange) return { ok: false, reason: 'no onChange' }; var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; - nativeSetter.call(t, ${JSON.stringify(prompt)}); + nativeSetter.call(t, ${JSON.stringify(imagePrompt)}); t.dispatchEvent(new Event('input', { bubbles: true })); try { props.onChange({ target: t, currentTarget: t, type: 'change', bubbles: true }); @@ -260,136 +129,153 @@ cli({ } return { ok: true, val: t.value }; `); - if (!typed?.ok) throw new CommandExecutionError(`Type failed: ${typed?.reason}`); - await page.wait(0.5); + if (!typed?.ok) throw new CommandExecutionError(`Type failed: ${typed?.reason || 'unknown'}`); - // ── Step 6: Click the create/generate button ──────────────────────────────── - // The button has "創建圖像" text and is near the textarea - const createClicked = await runMainWorld(page, ` - // Find the button with "創建圖像" text that's near the textarea - var btns = Array.from(document.querySelectorAll('button, div[role=\"button\"]')); - var createBtn = btns.find(b => b.offsetParent !== null && ( - b.textContent.includes('創建') || b.getAttribute('aria-label')?.includes('創建') - )); - if (createBtn) { - var propsKey = Object.keys(createBtn).find(k => k.startsWith('__reactProps')); - if (propsKey && createBtn[propsKey]?.onClick) { - try { createBtn[propsKey].onClick({ target: createBtn, currentTarget: createBtn, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true, method: 'onClick' }; - } - createBtn.click(); - return { ok: true, method: 'click' }; - } - // Fallback: find any visible button in the form area + // ── Step 3: Wait for send button to appear ───────────────────────────────── + let sendReady = false; + for (let i = 0; i < 10; i++) { + sendReady = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + if (!ta) return false; + const btn = ta.parentElement?.querySelector('button.send-button') + || Array.from(document.querySelectorAll('button')).find(b => + b.className && b.className.includes && b.className.includes('send-button') && b.offsetParent !== null + ); + return !!btn; + }).catch(() => false); + if (sendReady) break; + await page.wait(1); + } + if (!sendReady) throw new CommandExecutionError('Send button did not appear after typing'); + + // ── Step 4: Click send ───────────────────────────────────────────────────── + const clicked = await runMainWorld(` var ta = document.querySelector('textarea'); - if (!ta) return { ok: false, reason: 'no textarea' }; - var formArea = ta.closest('div'); - if (formArea) { - var nearbyBtns = formArea.querySelectorAll('button'); - for (var i = 0; i < nearbyBtns.length; i++) { - var b = nearbyBtns[i]; - if (b.offsetParent !== null) { - var propsKey = Object.keys(b).find(k => k.startsWith('__reactProps')); - if (propsKey && b[propsKey]?.onClick) { - try { b[propsKey].onClick({ target: b, currentTarget: b, type: 'click', preventDefault: ()=>{}, stopPropagation: ()=>{} }); } - catch(e) { return { ok: false, reason: 'onClick err: ' + e.message }; } - return { ok: true, method: 'onClick' }; - } - b.click(); - return { ok: true, method: 'click' }; - } - } + var btn = ta && ta.parentElement && ta.parentElement.querySelector('button.send-button'); + if (!btn) { + btn = Array.from(document.querySelectorAll('button')).find(b => + b.className && b.className.includes && b.className.includes('send-button') && b.offsetParent !== null + ); } - return { ok: false, reason: 'no create button found' }; + if (!btn) return { ok: false, reason: 'no send button' }; + var propsKey = Object.keys(btn).find(k => k.startsWith('__reactProps')); + var props = propsKey ? btn[propsKey] : null; + if (props && props.onClick) { + try { props.onClick({ target: btn, currentTarget: btn, type: 'click', preventDefault: function(){}, stopPropagation: function(){} }); } + catch (e) { return { ok: false, reason: 'onClick err: ' + e.message }; } + return { ok: true, method: 'onClick-direct' }; + } + btn.click(); + return { ok: true, method: 'native-click' }; `); - if (!createClicked?.ok) throw new CommandExecutionError(`Create button click failed: ${createClicked?.reason}`); + if (!clicked?.ok) throw new CommandExecutionError(`Send click failed: ${clicked?.reason}`); - // ── Step 7: Wait for URL to change to /c/{uuid} ───────────────────────────── + // ── Step 5: Wait for URL to change to /c/{uuid} ─────────────────────────── let chatId = null; for (let i = 0; i < 30; i++) { const url = await page.evaluate(() => window.location.href).catch(() => ''); - const match = url.match(/\/c\/([^/?#]+)/); + const match = url.match(/\/c\/([^/]+)/); const candidate = match ? match[1] : null; if (candidate && candidate !== 'new-chat') { chatId = candidate; break; } await page.wait(1); } - if (!chatId) throw new CommandExecutionError('URL did not change to /c/{UUID} after sending image prompt'); + if (!chatId) throw new CommandExecutionError('URL did not change to /c/{UUID} after send (timeout)'); - // ── Step 8: Poll for image URLs in the page DOM ───────────────────────────── + // ── Step 6: Poll for generated images in DOM ─────────────────────────────── + // Images appear as tags with cdn.qwenlm.ai src after generation completes const startTime = Date.now(); let imageUrls = []; let lastUrls = []; + let stableCount = 0; while (Date.now() - startTime < timeout * 1000) { await page.wait(2); + const urls = await page.evaluate(() => { const imgs = Array.from(document.querySelectorAll('img')).filter(img => img.src && img.src.includes('cdn.qwenlm.ai') && img.naturalWidth > 0 ).map(img => img.src); return [...new Set(imgs)]; - }); + }).catch(() => []); - if (urls.length && urls.length === lastUrls.length && urls.every((u, i) => u === lastUrls[i])) { - imageUrls = urls; - break; - } - if (urls.length > 0) { - await page.wait(1); - const urls2 = await page.evaluate(() => { - const imgs = Array.from(document.querySelectorAll('img')).filter(img => - img.src && img.src.includes('cdn.qwenlm.ai') && img.naturalWidth > 0 - ).map(img => img.src); - return [...new Set(imgs)]; - }); - if (urls2.length === urls.length) { - imageUrls = urls2; + // Image is stable when we see the same URLs twice in a row + if (urls.length > 0 && urls.length === lastUrls.length && urls.every((u, i) => u === lastUrls[i])) { + stableCount++; + if (stableCount >= 2) { + imageUrls = urls; break; } - lastUrls = urls2; + } else { + stableCount = 0; } + lastUrls = urls; + + // Check for generation failure indicators + const hasError = await page.evaluate(() => { + const errorEls = Array.from(document.querySelectorAll('div')).filter(el => + el.textContent.includes('生成失敗') || el.textContent.includes('generation failed') || + el.textContent.includes('error') || el.textContent.includes('錯誤') + ); + return errorEls.length > 0; + }).catch(() => false); + + if (hasError) { + throw new CommandExecutionError('Image generation failed according to page content'); + } } if (imageUrls.length === 0) { - throw new TimeoutError('qwen-studio image', timeout, `No generated images found within ${timeout}s`); + throw new TimeoutError('qwen-studio image', timeout, `No generated images found within ${timeout}s. Chat ID: ${chatId}`); } if (skipDownload) { return imageUrls.map(url => ({ Status: 'generated', File: null, Link: url })); } - // ── Step 9: Download images ───────────────────────────────────────────────── + // ── Step 7: Download images ───────────────────────────────────────────────── const stamp = Date.now(); const results = []; + for (let i = 0; i < imageUrls.length; i++) { const url = imageUrls[i]; - const asset = await page.evaluate(async (imgUrl) => { + const suffix = imageUrls.length > 1 ? `_${i + 1}` : ''; + let mime = 'image/png'; + let data; + + const browserFetch = await page.evaluate(async (imgUrl) => { try { const res = await fetch(imgUrl, { credentials: 'include' }); if (!res.ok) return { ok: false, status: res.status }; - const mime = res.headers.get('content-type') || 'image/png'; + const ct = res.headers.get('content-type') || 'image/png'; const buf = await res.arrayBuffer(); const bytes = new Uint8Array(buf); let binary = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + for (let j = 0; j < bytes.length; j += 0x8000) { + binary += String.fromCharCode.apply(null, bytes.subarray(j, j + 0x8000)); } - return { ok: true, mime, base64: btoa(binary) }; - } catch (error) { - return { ok: false, error: String(error?.message || error) }; + return { ok: true, mime: ct, base64: btoa(binary) }; + } catch (e) { + return { ok: false, error: e.message }; } - }, url); - - if (!asset?.ok) { - throw new CommandExecutionError(`Failed to fetch generated image ${i + 1}: ${asset?.error || 'status=' + asset?.status}`); + }, url).catch((e) => ({ ok: false, error: e.message })); + + if (browserFetch?.ok) { + mime = browserFetch.mime; + data = Buffer.from(browserFetch.base64, 'base64'); + } else { + const nodeResult = await downloadToBuffer(url); + if (!nodeResult.ok) { + results.push({ Status: 'url', File: null, Link: url }); + continue; + } + mime = nodeResult.mime; + data = nodeResult.buffer; } - const suffix = imageUrls.length > 1 ? `_${i + 1}` : ''; - const ext = extFromMime(asset.mime); + const ext = extFromMime(mime); const filePath = path.join(outputDir, `qwen_studio_${stamp}${suffix}${ext}`); - await saveBase64ToFile(asset.base64, filePath); + await fs.promises.writeFile(filePath, data); results.push({ Status: 'saved', File: displayPath(filePath), Link: url }); }