chrome-ai-bridge 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/download-manager.js +192 -0
- package/build/src/selectors/gemini.json +13 -2
- package/build/src/tools/gemini-image.js +170 -151
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
export class DownloadManager extends EventEmitter {
|
|
8
|
+
cdpSession = null;
|
|
9
|
+
downloadDir;
|
|
10
|
+
page;
|
|
11
|
+
pendingDownloads = new Map();
|
|
12
|
+
downloadPromiseResolvers = new Map();
|
|
13
|
+
isMonitoring = false;
|
|
14
|
+
constructor(page, downloadDir) {
|
|
15
|
+
super();
|
|
16
|
+
this.page = page;
|
|
17
|
+
this.downloadDir = downloadDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Handle downloadWillBegin event
|
|
21
|
+
*/
|
|
22
|
+
handleDownloadWillBegin = (event) => {
|
|
23
|
+
const info = {
|
|
24
|
+
guid: event.guid,
|
|
25
|
+
url: event.url,
|
|
26
|
+
suggestedFilename: event.suggestedFilename,
|
|
27
|
+
state: 'inProgress',
|
|
28
|
+
receivedBytes: 0,
|
|
29
|
+
totalBytes: 0,
|
|
30
|
+
};
|
|
31
|
+
this.pendingDownloads.set(event.guid, info);
|
|
32
|
+
this.emit('started', event.suggestedFilename);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Handle downloadProgress event
|
|
36
|
+
*/
|
|
37
|
+
handleDownloadProgress = (event) => {
|
|
38
|
+
const info = this.pendingDownloads.get(event.guid);
|
|
39
|
+
if (!info)
|
|
40
|
+
return;
|
|
41
|
+
info.receivedBytes = event.receivedBytes;
|
|
42
|
+
info.totalBytes = event.totalBytes;
|
|
43
|
+
info.state = event.state;
|
|
44
|
+
// Emit progress
|
|
45
|
+
if (event.totalBytes > 0) {
|
|
46
|
+
const percent = Math.round((event.receivedBytes / event.totalBytes) * 100);
|
|
47
|
+
this.emit('progress', percent, info.suggestedFilename);
|
|
48
|
+
}
|
|
49
|
+
// Handle completion
|
|
50
|
+
if (event.state === 'completed') {
|
|
51
|
+
// Construct the final path
|
|
52
|
+
const finalPath = `${this.downloadDir}/${info.suggestedFilename}`;
|
|
53
|
+
info.resolvedPath = finalPath;
|
|
54
|
+
this.emit('completed', finalPath);
|
|
55
|
+
// Resolve any waiting promises
|
|
56
|
+
const resolver = this.downloadPromiseResolvers.get(event.guid);
|
|
57
|
+
if (resolver) {
|
|
58
|
+
resolver.resolve(finalPath);
|
|
59
|
+
this.downloadPromiseResolvers.delete(event.guid);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (event.state === 'canceled') {
|
|
63
|
+
this.emit('canceled', info.suggestedFilename);
|
|
64
|
+
const resolver = this.downloadPromiseResolvers.get(event.guid);
|
|
65
|
+
if (resolver) {
|
|
66
|
+
resolver.reject(new Error('Download canceled'));
|
|
67
|
+
this.downloadPromiseResolvers.delete(event.guid);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Start monitoring downloads using CDP events
|
|
73
|
+
* Event handlers are registered immediately after CDP session creation
|
|
74
|
+
* to prevent race conditions
|
|
75
|
+
*/
|
|
76
|
+
async startMonitoring() {
|
|
77
|
+
if (this.isMonitoring) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Create CDP session
|
|
81
|
+
this.cdpSession = await this.page.createCDPSession();
|
|
82
|
+
// Register event handlers IMMEDIATELY after CDP session creation
|
|
83
|
+
// This prevents race condition where events could be missed
|
|
84
|
+
this.cdpSession.on('Browser.downloadWillBegin', this.handleDownloadWillBegin);
|
|
85
|
+
this.cdpSession.on('Browser.downloadProgress', this.handleDownloadProgress);
|
|
86
|
+
// Enable download events
|
|
87
|
+
await this.cdpSession.send('Browser.setDownloadBehavior', {
|
|
88
|
+
behavior: 'allowAndName',
|
|
89
|
+
downloadPath: this.downloadDir,
|
|
90
|
+
eventsEnabled: true,
|
|
91
|
+
});
|
|
92
|
+
this.isMonitoring = true;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Wait for any download to complete
|
|
96
|
+
* @param timeoutMs Timeout in milliseconds
|
|
97
|
+
* @returns Path to the downloaded file
|
|
98
|
+
*/
|
|
99
|
+
async waitForDownload(timeoutMs = 60000) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timeoutId = setTimeout(() => {
|
|
102
|
+
reject(new Error(`Download timeout after ${timeoutMs}ms`));
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
// Check if there's already a pending download
|
|
105
|
+
const existingDownload = Array.from(this.pendingDownloads.values()).find(d => d.state === 'inProgress');
|
|
106
|
+
const handleCompletion = (filepath) => {
|
|
107
|
+
clearTimeout(timeoutId);
|
|
108
|
+
this.off('completed', handleCompletion);
|
|
109
|
+
this.off('canceled', handleCancellation);
|
|
110
|
+
resolve(filepath);
|
|
111
|
+
};
|
|
112
|
+
const handleCancellation = (filename) => {
|
|
113
|
+
clearTimeout(timeoutId);
|
|
114
|
+
this.off('completed', handleCompletion);
|
|
115
|
+
this.off('canceled', handleCancellation);
|
|
116
|
+
reject(new Error(`Download canceled: ${filename}`));
|
|
117
|
+
};
|
|
118
|
+
this.on('completed', handleCompletion);
|
|
119
|
+
this.on('canceled', handleCancellation);
|
|
120
|
+
// If a download is already completed, resolve immediately
|
|
121
|
+
const completedDownload = Array.from(this.pendingDownloads.values()).find(d => d.state === 'completed' && d.resolvedPath);
|
|
122
|
+
if (completedDownload?.resolvedPath) {
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
this.off('completed', handleCompletion);
|
|
125
|
+
this.off('canceled', handleCancellation);
|
|
126
|
+
resolve(completedDownload.resolvedPath);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Wait for a specific download by GUID
|
|
132
|
+
*/
|
|
133
|
+
async waitForSpecificDownload(guid, timeoutMs = 60000) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const timeoutId = setTimeout(() => {
|
|
136
|
+
this.downloadPromiseResolvers.delete(guid);
|
|
137
|
+
reject(new Error(`Download timeout after ${timeoutMs}ms`));
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
// Check if already completed
|
|
140
|
+
const info = this.pendingDownloads.get(guid);
|
|
141
|
+
if (info?.state === 'completed' && info.resolvedPath) {
|
|
142
|
+
clearTimeout(timeoutId);
|
|
143
|
+
resolve(info.resolvedPath);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.downloadPromiseResolvers.set(guid, {
|
|
147
|
+
resolve: (path) => {
|
|
148
|
+
clearTimeout(timeoutId);
|
|
149
|
+
resolve(path);
|
|
150
|
+
},
|
|
151
|
+
reject: (err) => {
|
|
152
|
+
clearTimeout(timeoutId);
|
|
153
|
+
reject(err);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Get current pending downloads
|
|
160
|
+
*/
|
|
161
|
+
getPendingDownloads() {
|
|
162
|
+
return Array.from(this.pendingDownloads.values()).filter(d => d.state === 'inProgress');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Clear download history
|
|
166
|
+
*/
|
|
167
|
+
clearHistory() {
|
|
168
|
+
this.pendingDownloads.clear();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Release resources
|
|
172
|
+
*/
|
|
173
|
+
async dispose() {
|
|
174
|
+
if (this.cdpSession) {
|
|
175
|
+
try {
|
|
176
|
+
// Reset download behavior to default
|
|
177
|
+
await this.cdpSession.send('Browser.setDownloadBehavior', {
|
|
178
|
+
behavior: 'default',
|
|
179
|
+
});
|
|
180
|
+
await this.cdpSession.detach();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Ignore errors during cleanup
|
|
184
|
+
}
|
|
185
|
+
this.cdpSession = null;
|
|
186
|
+
}
|
|
187
|
+
this.isMonitoring = false;
|
|
188
|
+
this.pendingDownloads.clear();
|
|
189
|
+
this.downloadPromiseResolvers.clear();
|
|
190
|
+
this.removeAllListeners();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"lastUpdated": "2025-
|
|
2
|
+
"version": "1.1.0",
|
|
3
|
+
"lastUpdated": "2025-01-24",
|
|
4
4
|
"description": "Selectors for Gemini web interface",
|
|
5
5
|
"elements": {
|
|
6
6
|
"editor": {
|
|
@@ -16,6 +16,17 @@
|
|
|
16
16
|
"response": {
|
|
17
17
|
"css": "model-response",
|
|
18
18
|
"description": "Response container"
|
|
19
|
+
},
|
|
20
|
+
"downloadButton": {
|
|
21
|
+
"ax": {
|
|
22
|
+
"ariaDescribedByContains": ["フルサイズ", "ダウンロード", "Download"]
|
|
23
|
+
},
|
|
24
|
+
"text": ["フルサイズの画像をダウンロード", "Download full-size image"],
|
|
25
|
+
"description": "Download button for generated images"
|
|
26
|
+
},
|
|
27
|
+
"generatedImage": {
|
|
28
|
+
"css": "img[src*='blob:'], img[src*='generated']",
|
|
29
|
+
"description": "Generated image element"
|
|
19
30
|
}
|
|
20
31
|
},
|
|
21
32
|
"placeholders": {
|
|
@@ -9,6 +9,7 @@ import os from 'node:os';
|
|
|
9
9
|
import { Jimp } from 'jimp';
|
|
10
10
|
import z from 'zod';
|
|
11
11
|
import { GEMINI_CONFIG } from '../config.js';
|
|
12
|
+
import { DownloadManager } from '../download-manager.js';
|
|
12
13
|
import { getLoginStatus, waitForLoginStatus, LoginStatus, } from '../login-helper.js';
|
|
13
14
|
import { ToolCategories } from './categories.js';
|
|
14
15
|
import { defineTool } from './ToolDefinition.js';
|
|
@@ -84,59 +85,6 @@ async function cropWatermark(inputPath, outputPath, margin = DEFAULT_CROP_MARGIN
|
|
|
84
85
|
await image.write(outputPath);
|
|
85
86
|
return { width: newWidth, height: newHeight };
|
|
86
87
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Wait for download to complete and return the file path
|
|
89
|
-
* Looks for new image files (png, jpg, jpeg) in the download directory
|
|
90
|
-
*/
|
|
91
|
-
async function waitForDownload(downloadDir, timeoutMs = 60000) {
|
|
92
|
-
const startTime = Date.now();
|
|
93
|
-
const checkInterval = 1000; // Check every second
|
|
94
|
-
// Get initial files with their mtimes
|
|
95
|
-
const initialFiles = new Map();
|
|
96
|
-
try {
|
|
97
|
-
const files = await fs.promises.readdir(downloadDir);
|
|
98
|
-
for (const f of files) {
|
|
99
|
-
if (/\.(png|jpg|jpeg)$/i.test(f)) {
|
|
100
|
-
const stat = await fs.promises.stat(path.join(downloadDir, f));
|
|
101
|
-
initialFiles.set(f, stat.mtime.getTime());
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// Directory might not exist, continue
|
|
107
|
-
}
|
|
108
|
-
while (Date.now() - startTime < timeoutMs) {
|
|
109
|
-
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
110
|
-
try {
|
|
111
|
-
const currentFiles = await fs.promises.readdir(downloadDir);
|
|
112
|
-
for (const f of currentFiles) {
|
|
113
|
-
// Only check image files
|
|
114
|
-
if (!/\.(png|jpg|jpeg)$/i.test(f))
|
|
115
|
-
continue;
|
|
116
|
-
// Skip incomplete downloads
|
|
117
|
-
if (f.endsWith('.crdownload') || f.endsWith('.tmp'))
|
|
118
|
-
continue;
|
|
119
|
-
const filePath = path.join(downloadDir, f);
|
|
120
|
-
const stat = await fs.promises.stat(filePath);
|
|
121
|
-
const mtime = stat.mtime.getTime();
|
|
122
|
-
// Check if this is a new file or modified after we started
|
|
123
|
-
const initialMtime = initialFiles.get(f);
|
|
124
|
-
if (!initialMtime || mtime > initialMtime) {
|
|
125
|
-
// Verify file is complete (size > 0 and not growing)
|
|
126
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
127
|
-
const stat2 = await fs.promises.stat(filePath);
|
|
128
|
-
if (stat2.size > 0 && stat2.size === stat.size) {
|
|
129
|
-
return filePath;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
// Continue on error
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
throw new Error(`Download timeout after ${timeoutMs}ms`);
|
|
139
|
-
}
|
|
140
88
|
export const askGeminiImage = defineTool({
|
|
141
89
|
name: 'ask_gemini_image',
|
|
142
90
|
description: 'Generate image using Gemini (Nano Banana / 3 Preview) via browser. ' +
|
|
@@ -240,121 +188,192 @@ export const askGeminiImage = defineTool({
|
|
|
240
188
|
response.appendResponseLine('⚠️ 送信ボタンが見つかりません (Enterキーを試行)');
|
|
241
189
|
}
|
|
242
190
|
response.appendResponseLine('🎨 画像生成中... (1-2分かかることがあります)');
|
|
243
|
-
// Wait for image generation
|
|
244
|
-
// Look for generated image or download button
|
|
191
|
+
// Wait for image generation using MutationObserver for instant detection
|
|
245
192
|
const startTime = Date.now();
|
|
246
193
|
const maxWaitTime = 180000; // 3 minutes
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
194
|
+
const imageFound = await page.evaluate((maxWait) => {
|
|
195
|
+
return new Promise(resolve => {
|
|
196
|
+
// Check if image already exists (immediate return)
|
|
197
|
+
const checkCompletion = () => {
|
|
198
|
+
// Check for generated image
|
|
199
|
+
const images = document.querySelectorAll('img[src*="blob:"], img[src*="generated"]');
|
|
200
|
+
// Check for download button with various detection methods
|
|
201
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="menuitem"]'));
|
|
202
|
+
const hasDownload = buttons.some(b => {
|
|
203
|
+
const text = b.textContent || '';
|
|
204
|
+
const ariaLabel = b.getAttribute('aria-label') || '';
|
|
205
|
+
const describedBy = b.getAttribute('aria-describedby');
|
|
206
|
+
let desc = '';
|
|
207
|
+
if (describedBy) {
|
|
208
|
+
const descEl = document.getElementById(describedBy);
|
|
209
|
+
desc = descEl?.textContent || '';
|
|
210
|
+
}
|
|
211
|
+
return (text.includes('ダウンロード') ||
|
|
212
|
+
text.includes('Download') ||
|
|
213
|
+
text.includes('フルサイズ') ||
|
|
214
|
+
ariaLabel.toLowerCase().includes('download') ||
|
|
215
|
+
desc.includes('フルサイズ') ||
|
|
216
|
+
desc.includes('ダウンロード'));
|
|
217
|
+
});
|
|
218
|
+
if (images.length > 0 || hasDownload) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
267
222
|
};
|
|
223
|
+
// Initial check
|
|
224
|
+
if (checkCompletion()) {
|
|
225
|
+
resolve(true);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Set up MutationObserver for instant detection
|
|
229
|
+
const observer = new MutationObserver(() => {
|
|
230
|
+
if (checkCompletion()) {
|
|
231
|
+
observer.disconnect();
|
|
232
|
+
clearTimeout(timeoutId);
|
|
233
|
+
resolve(true);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
observer.observe(document.body, {
|
|
237
|
+
childList: true,
|
|
238
|
+
subtree: true,
|
|
239
|
+
attributes: true,
|
|
240
|
+
attributeFilter: ['src', 'aria-label', 'aria-describedby'],
|
|
241
|
+
});
|
|
242
|
+
// Timeout fallback
|
|
243
|
+
const timeoutId = setTimeout(() => {
|
|
244
|
+
observer.disconnect();
|
|
245
|
+
resolve(false);
|
|
246
|
+
}, maxWait);
|
|
268
247
|
});
|
|
269
|
-
|
|
270
|
-
imageFound = true;
|
|
271
|
-
response.appendResponseLine(`✅ 画像生成完了 (${Math.floor((Date.now() - startTime) / 1000)}秒)`);
|
|
272
|
-
break;
|
|
273
|
-
}
|
|
274
|
-
if (!status.isGenerating && Date.now() - startTime > 30000) {
|
|
275
|
-
// Not generating and no image after 30s - might have failed
|
|
276
|
-
response.appendResponseLine('⚠️ 生成中インジケータが消えました...');
|
|
277
|
-
}
|
|
278
|
-
}
|
|
248
|
+
}, maxWaitTime);
|
|
279
249
|
if (!imageFound) {
|
|
280
250
|
response.appendResponseLine('❌ 画像生成タイムアウト (3分)');
|
|
281
251
|
return;
|
|
282
252
|
}
|
|
283
|
-
|
|
253
|
+
response.appendResponseLine(`✅ 画像生成完了 (${Math.floor((Date.now() - startTime) / 1000)}秒)`);
|
|
254
|
+
// Try to download the image using CDP-based download manager
|
|
284
255
|
response.appendResponseLine('📥 画像をダウンロード中...');
|
|
285
|
-
//
|
|
286
|
-
const downloadClicked = await page.evaluate(() => {
|
|
287
|
-
const buttons = Array.from(document.querySelectorAll('button'));
|
|
288
|
-
// Look for "フルサイズの画像をダウンロード" or "フルサイズでダウンロード" button
|
|
289
|
-
const downloadBtn = buttons.find(b => {
|
|
290
|
-
const text = b.textContent || '';
|
|
291
|
-
const ariaLabel = b.getAttribute('aria-label') || '';
|
|
292
|
-
const description = b.getAttribute('aria-describedby')
|
|
293
|
-
? document.getElementById(b.getAttribute('aria-describedby'))?.textContent || ''
|
|
294
|
-
: '';
|
|
295
|
-
return (text.includes('フルサイズ') ||
|
|
296
|
-
text.includes('ダウンロード') ||
|
|
297
|
-
ariaLabel.includes('ダウンロード') ||
|
|
298
|
-
ariaLabel.includes('download') ||
|
|
299
|
-
description.includes('フルサイズ') ||
|
|
300
|
-
description.includes('ダウンロード'));
|
|
301
|
-
});
|
|
302
|
-
if (downloadBtn) {
|
|
303
|
-
downloadBtn.click();
|
|
304
|
-
return true;
|
|
305
|
-
}
|
|
306
|
-
return false;
|
|
307
|
-
});
|
|
308
|
-
if (!downloadClicked) {
|
|
309
|
-
response.appendResponseLine('⚠️ ダウンロードボタンが見つかりません');
|
|
310
|
-
response.appendResponseLine('ヒント: ブラウザで画像を右クリックして保存してください');
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
// Wait for download to start (Gemini shows progress bar)
|
|
314
|
-
response.appendResponseLine('⏳ ダウンロード処理を待機中...');
|
|
315
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
316
|
-
// Wait for download to complete - check user's Downloads folder
|
|
256
|
+
// Set up download manager with CDP events
|
|
317
257
|
const userDownloadsDir = path.join(os.homedir(), 'Downloads');
|
|
318
|
-
|
|
258
|
+
const downloadManager = new DownloadManager(page, userDownloadsDir);
|
|
319
259
|
try {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
260
|
+
await downloadManager.startMonitoring();
|
|
261
|
+
// Track reported progress to ensure threshold-based reporting
|
|
262
|
+
let lastReportedThreshold = 0;
|
|
263
|
+
// Listen for progress updates with threshold-based reporting
|
|
264
|
+
// This ensures 25%, 50%, 75%, 100% are always reported even if progress jumps
|
|
265
|
+
downloadManager.on('progress', (percent, filename) => {
|
|
266
|
+
// Calculate the next threshold to report (25, 50, 75, 100)
|
|
267
|
+
const thresholds = [25, 50, 75, 100];
|
|
268
|
+
for (const threshold of thresholds) {
|
|
269
|
+
if (percent >= threshold && lastReportedThreshold < threshold) {
|
|
270
|
+
response.appendResponseLine(`📥 ダウンロード中... ${threshold}% (${filename})`);
|
|
271
|
+
lastReportedThreshold = threshold;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
downloadManager.on('started', (filename) => {
|
|
276
|
+
response.appendResponseLine(`📥 ダウンロード開始: ${filename}`);
|
|
277
|
+
});
|
|
278
|
+
// Click download button - Gemini uses "フルサイズの画像をダウンロード" button
|
|
279
|
+
// Improved selector: prioritize aria-describedby for more reliable detection
|
|
280
|
+
const downloadClicked = await page.evaluate(() => {
|
|
281
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
282
|
+
// First, try to find button with aria-describedby pointing to "フルサイズ"
|
|
283
|
+
let downloadBtn = buttons.find(b => {
|
|
284
|
+
const describedBy = b.getAttribute('aria-describedby');
|
|
285
|
+
if (describedBy) {
|
|
286
|
+
const descEl = document.getElementById(describedBy);
|
|
287
|
+
const desc = descEl?.textContent || '';
|
|
288
|
+
return desc.includes('フルサイズ') || desc.includes('ダウンロード');
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
});
|
|
292
|
+
// Fallback: look for text/aria-label containing download keywords
|
|
293
|
+
if (!downloadBtn) {
|
|
294
|
+
downloadBtn = buttons.find(b => {
|
|
295
|
+
const text = b.textContent || '';
|
|
296
|
+
const ariaLabel = b.getAttribute('aria-label') || '';
|
|
297
|
+
// Avoid "Cancel download" buttons
|
|
298
|
+
const lowerText = text.toLowerCase();
|
|
299
|
+
const lowerLabel = ariaLabel.toLowerCase();
|
|
300
|
+
if (lowerText.includes('cancel') || lowerLabel.includes('cancel') ||
|
|
301
|
+
lowerText.includes('キャンセル') || lowerLabel.includes('キャンセル')) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
return (text.includes('フルサイズ') ||
|
|
305
|
+
text.includes('ダウンロード') ||
|
|
306
|
+
ariaLabel.includes('ダウンロード') ||
|
|
307
|
+
ariaLabel.includes('download'));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (downloadBtn) {
|
|
311
|
+
downloadBtn.click();
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
});
|
|
316
|
+
if (!downloadClicked) {
|
|
317
|
+
response.appendResponseLine('⚠️ ダウンロードボタンが見つかりません');
|
|
318
|
+
response.appendResponseLine('ヒント: ブラウザで画像を右クリックして保存してください');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Wait for download to complete using CDP events (more reliable than file polling)
|
|
322
|
+
response.appendResponseLine('⏳ CDP経由でダウンロード完了を待機中...');
|
|
323
|
+
let downloadedPath;
|
|
338
324
|
try {
|
|
339
|
-
|
|
340
|
-
response.appendResponseLine(`✅
|
|
325
|
+
downloadedPath = await downloadManager.waitForDownload(60000); // 60 seconds
|
|
326
|
+
response.appendResponseLine(`✅ ダウンロード完了: ${path.basename(downloadedPath)}`);
|
|
327
|
+
}
|
|
328
|
+
catch (downloadError) {
|
|
329
|
+
const errMsg = downloadError instanceof Error ? downloadError.message : String(downloadError);
|
|
330
|
+
if (errMsg.includes('timeout')) {
|
|
331
|
+
response.appendResponseLine('❌ ダウンロードタイムアウト (60秒)');
|
|
332
|
+
response.appendResponseLine('💡 ヒント: ブラウザで画像を右クリックして「画像を保存」してください');
|
|
333
|
+
}
|
|
334
|
+
else if (errMsg.includes('canceled')) {
|
|
335
|
+
response.appendResponseLine('❌ ダウンロードがキャンセルされました');
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
response.appendResponseLine(`❌ ダウンロードエラー: ${errMsg}`);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
341
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
342
|
+
// Ensure output directory exists
|
|
343
|
+
const outputDir = path.dirname(outputPath);
|
|
344
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
345
|
+
// Crop watermark or copy directly
|
|
346
|
+
if (skipCrop) {
|
|
346
347
|
await fs.promises.copyFile(downloadedPath, outputPath);
|
|
348
|
+
response.appendResponseLine(`📄 画像保存(クロップなし): ${outputPath}`);
|
|
347
349
|
}
|
|
350
|
+
else {
|
|
351
|
+
response.appendResponseLine(`✂️ ウォーターマークをクロップ中 (margin: ${cropMargin}px)...`);
|
|
352
|
+
try {
|
|
353
|
+
const { width, height } = await cropWatermark(downloadedPath, outputPath, cropMargin);
|
|
354
|
+
response.appendResponseLine(`✅ クロップ完了: ${width}x${height}px → ${outputPath}`);
|
|
355
|
+
}
|
|
356
|
+
catch (cropError) {
|
|
357
|
+
const msg = cropError instanceof Error ? cropError.message : String(cropError);
|
|
358
|
+
response.appendResponseLine(`⚠️ クロップ失敗: ${msg}`);
|
|
359
|
+
response.appendResponseLine('元の画像をそのまま保存します...');
|
|
360
|
+
await fs.promises.copyFile(downloadedPath, outputPath);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Cleanup temp file
|
|
364
|
+
try {
|
|
365
|
+
await fs.promises.unlink(downloadedPath);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// Ignore cleanup errors
|
|
369
|
+
}
|
|
370
|
+
response.appendResponseLine('\n🎉 画像生成完了!');
|
|
371
|
+
response.appendResponseLine(`📁 出力: ${outputPath}`);
|
|
348
372
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await
|
|
352
|
-
}
|
|
353
|
-
catch {
|
|
354
|
-
// Ignore cleanup errors
|
|
373
|
+
finally {
|
|
374
|
+
// Ensure download manager is always disposed
|
|
375
|
+
await downloadManager.dispose();
|
|
355
376
|
}
|
|
356
|
-
response.appendResponseLine('\n🎉 画像生成完了!');
|
|
357
|
-
response.appendResponseLine(`📁 出力: ${outputPath}`);
|
|
358
377
|
}
|
|
359
378
|
catch (error) {
|
|
360
379
|
const msg = error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED