From 90e36162245afb88d762ccb9271380364b32aeb1 Mon Sep 17 00:00:00 2001 From: knowen <1369727119@qq.com> Date: Wed, 18 Mar 2026 19:59:10 +0800 Subject: [PATCH] feat(ops): add image upload function --- src/demo.js | 34 +++++++++++++++-------- src/gemini-ops.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/demo.js b/src/demo.js index c101937..3bf2442 100644 --- a/src/demo.js +++ b/src/demo.js @@ -127,8 +127,19 @@ async function main() { } } - // 4. 发送一句话 - console.log('\n[4] 发送提示词...'); + // 4. 上传图片 + console.log('\n[4] 上传图片...'); + + const uploadResult = await ops.uploadImage('./gemini-image/tianyi.jpg'); + if (uploadResult.ok) { + console.log(`[4] ✅ 图片上传完成 (${uploadResult.elapsed}ms)`); + if (uploadResult.warning) console.warn(`[4] ⚠ ${uploadResult.warning}`); + } else { + console.warn(`[4] ⚠ 图片上传失败: ${uploadResult.error} — ${uploadResult.detail}`); + } + + // 5. 发送一句话 + console.log('\n[5] 发送提示词...'); const result = await ops.sendAndWait(prompt, { timeout: 120_000, onPoll(poll) { @@ -137,19 +148,18 @@ async function main() { }); console.log('result:', JSON.stringify(result, null, 2)); - // 5. 获取最新图片并保存到本地 + // 6. 获取最新图片并保存到本地 if (result.ok) { - console.log('\n[5] 查找最新生成的图片...'); - await sleep(2000); // 等待图片渲染完毕 + console.log('\n[6] 查找最新生成的图片...'); const imgInfo = await ops.getLatestImage(); console.log('imgInfo:', JSON.stringify(imgInfo, null, 2)); if (imgInfo.ok && imgInfo.src) { - console.log(`[5] 找到图片 (${imgInfo.width}x${imgInfo.height}, isNew=${imgInfo.isNew})`); + console.log(`[6] 找到图片 (${imgInfo.width}x${imgInfo.height}, isNew=${imgInfo.isNew})`); // 提取 base64 数据 - console.log(`[5] 提取图片数据 (src=${imgInfo.src})...`); + console.log(`[6] 提取图片数据 (src=${imgInfo.src})...`); const b64Result = await ops.extractImageBase64(imgInfo.src); if (b64Result.ok && b64Result.dataUrl) { @@ -160,7 +170,7 @@ async function main() { const base64Data = matches[2]; const buffer = Buffer.from(base64Data, 'base64'); - // 保存到 ~/gemini-skill-output/ + // 保存到 ./gemini-image/ const outputDir = './gemini-image'; if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); @@ -169,15 +179,15 @@ async function main() { const filepath = join(outputDir, filename); writeFileSync(filepath, buffer); - console.log(`[5] ✅ 图片已保存: ${filepath} (${(buffer.length / 1024).toFixed(1)} KB, method=${b64Result.method})`); + console.log(`[6] ✅ 图片已保存: ${filepath} (${(buffer.length / 1024).toFixed(1)} KB, method=${b64Result.method})`); } else { - console.warn('[5] ⚠ dataUrl 格式无法解析'); + console.warn('[6] ⚠ dataUrl 格式无法解析'); } } else { - console.warn(`[5] ⚠ 提取图片数据失败: ${b64Result.error || 'unknown'}`); + console.warn(`[6] ⚠ 提取图片数据失败: ${b64Result.error || 'unknown'}`); } } else { - console.log('[5] 未找到图片(可能本次回答不含图片)'); + console.log('[6] 未找到图片(可能本次回答不含图片)'); } } diff --git a/src/gemini-ops.js b/src/gemini-ops.js index 68af5cc..6b03f22 100644 --- a/src/gemini-ops.js +++ b/src/gemini-ops.js @@ -75,6 +75,17 @@ const SELECTORS = { '[data-test-id="overflow-container"]', // 测试专属属性 'div.overflow-container', // class 兜底 ], + /** 加号面板按钮(点击后弹出上传菜单) */ + uploadPanelBtn: [ + 'button.upload-card-button[aria-haspopup="menu"]', // class + aria 组合 + 'button[aria-controls="upload-file-u"]', // aria-controls 兜底 + 'button.upload-card-button', // class 兜底 + ], + /** 上传文件选项(加号面板展开后的"上传文件"按钮) */ + uploadFileBtn: [ + '[data-test-id="uploader-images-files-button-advanced"]', // 测试专属属性 + 'images-files-uploader', // 标签名兜底 + ], }; /** @@ -687,6 +698,65 @@ export function createOps(page) { // ─── 高层组合操作 ─── + /** + * 上传图片到 Gemini 输入框 + * + * 流程: + * 1. 点击加号面板按钮,展开上传菜单 + * 2. 等待 300ms 让菜单动画稳定 + * 3. 拦截文件选择器 + 点击"上传文件"按钮(Promise.all 并发) + * 4. 向文件选择器塞入指定图片路径 + * 5. 轮询等待图片加载完成(.image-preview.loading 消失) + * + * @param {string} filePath - 本地图片的绝对路径 + * @returns {Promise<{ok: boolean, elapsed?: number, warning?: string, error?: string, detail?: string}>} + */ + async uploadImage(filePath) { + try { + // 1. 点击加号面板按钮,展开上传菜单 + const panelClick = await this.click('uploadPanelBtn'); + if (!panelClick.ok) { + return { ok: false, error: 'upload_panel_click_failed', detail: panelClick.error }; + } + + // 2. 等待菜单动画稳定 + await sleep(250); + + // 3. Promise.all 是精髓:一边开始监听文件选择器弹窗,一边点击"上传文件"按钮 + const [fileChooser] = await Promise.all([ + page.waitForFileChooser({ timeout: 3_000 }), + this.click('uploadFileBtn'), + ]); + + // 4. 弹窗被拦截,塞入文件 + await fileChooser.accept([filePath]); + console.log(`[ops] 文件已塞入,等待 Gemini 加载图片...`); + + // 5. 等待图片加载完成(.image-preview.loading 消失) + const loadTimeout = 10_000; + const loadInterval = 250; + const loadStart = Date.now(); + + while (Date.now() - loadStart < loadTimeout) { + const loading = await op.query(() => { + const el = document.querySelector('.image-preview.loading'); + return !!el; + }); + if (!loading) { + console.log(`[ops] 图片加载完成 (${Date.now() - loadStart}ms): ${filePath}`); + return { ok: true, elapsed: Date.now() - loadStart }; + } + await sleep(loadInterval); + } + + // 超时了但文件已经塞进去了,不算完全失败 + console.warn(`[ops] 图片加载超时 (${loadTimeout}ms),但文件已提交`); + return { ok: true, warning: 'load_timeout', elapsed: Date.now() - loadStart }; + } catch (e) { + return { ok: false, error: 'upload_image_failed', detail: e.message }; + } + }, + /** * 发送提示词并等待生成完成 * @param {string} prompt