Files
gemini-extension/scripts/content.js
Kunthawat Greethong f490c63632 feat: add extension implementation and docs
Add manifest.json, sidepanel components, and scripts.
Include project assets and documentation files.
Remove placeholder blank file.
2026-01-06 08:49:28 +07:00

663 lines
23 KiB
JavaScript

// Content script for Gemini integration
console.log('Auto Cover Generator content script loaded on Gemini');
// Selectors for Gemini UI elements (may need adjustment based on Gemini's actual DOM)
const SELECTORS = {
inputArea: 'rich-textarea[placeholder*="Enter"], textarea[placeholder*="Enter"], div[contenteditable="true"]',
sendButton: 'button[aria-label*="Send"], button[type="submit"]',
imageUploadButton: 'button[aria-label*="Add"], input[type="file"]',
generatedImage: 'img[src*="googleusercontent"], img[src*="generated"], img.image.loaded, img[alt="รูปภาพ"]',
responseContainer: 'div[data-message-author-role="model"], .model-response'
};
let isGenerating = false;
let currentPrompt = null;
let currentImage = null;
let lastGenerationRequestTime = 0; // Timestamp of last extension-triggered generation
// Listen for messages from side panel
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'generateCover' || message.action === 'removeObjectFromImage') {
// Reuse the generation handler but adapt inputs
const isRemover = message.action === 'removeObjectFromImage';
// For remover, the 'image' field is actually the 'imageData' (base64)
// and we wrap it in an array to match logic.
// For cover, 'images' or 'image' is used.
const msgAdapter = isRemover ? {
prompt: message.prompt,
images: [message.imageData] // Specific for remover
} : message;
handleGenerateCover(msgAdapter)
.then(() => sendResponse({ success: true }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true; // Keep channel open for async response
}
});
// Main generation handler
async function handleGenerateCover(message) {
if (isGenerating) {
throw new Error('กำลังสร้างอยู่แล้ว กรุณารอสักครู่');
}
isGenerating = true;
lastGenerationRequestTime = Date.now(); // Start the "Auto Download" window
currentPrompt = message.prompt;
currentImage = message.image;
try {
// Step 1: Find input area
const inputElement = await waitForElement(SELECTORS.inputArea, 5000);
if (!inputElement) {
throw new Error('ไม่พบช่องกรอกข้อความของ Gemini');
}
// Count existing images to differentiate new generation
const existingImages = document.querySelectorAll(SELECTORS.generatedImage);
const initialImageCount = existingImages.length;
console.log(`Initial image count: ${initialImageCount}`);
// Step 2: Upload images if provided
const images = message.images || (message.image ? [message.image] : []);
if (images.length > 0) {
await uploadImagesToGemini(images, inputElement);
// Wait for image upload processing
// User requested delay consistent with number of attached images
// Optimized: Reduced from 3000ms to 1000ms per image for speed
const uploadDelay = Math.min(images.length * 1000, 12000); // Max 12s
console.log(`Waiting ${uploadDelay}ms for ${images.length} images to upload/process...`);
await sleep(uploadDelay);
}
// Step 3: Insert prompt
await insertPrompt(inputElement, currentPrompt);
await sleep(100); // Reduced from 500
// Step 3.5: Press Enter key
await pressEnter(inputElement);
await sleep(200); // Reduced from 500
// Step 4: Click send button with retry
const sent = await clickSendButton();
if (sent) {
// Wait a bit for Gemini to process the prompt
await sleep(1000); // Reduced from 2000
// Step 4.5: Auto-click generate/create button if it appears
await autoClickGenerateButton();
// Step 5: Wait for and capture generated image
// Network Listener in background.js now handles detection and download!
// await waitForGeneratedImage(initialImageCount);
console.log('Content: Waiting for background network detection...');
} else {
throw new Error('ไม่สามารถกดปุ่มส่งข้อความได้');
}
} catch (error) {
console.error('Generation error:', error);
chrome.runtime.sendMessage({
action: 'generationError',
error: error.message
});
} finally {
isGenerating = false;
}
}
// Click send button with retry
async function clickSendButton() {
const maxRetries = 10;
for (let i = 0; i < maxRetries; i++) {
const sendButton = document.querySelector(SELECTORS.sendButton);
if (sendButton && !sendButton.disabled && sendButton.getAttribute('aria-disabled') !== 'true') {
console.log('Found enabled send button, clicking...');
sendButton.click();
return true;
}
console.log('Waiting for send button to be enabled...');
await sleep(500);
}
return false;
}
// Simulate Enter key press
async function pressEnter(element) {
console.log('Simulating Enter key...');
const events = ['keydown', 'keypress', 'keyup'];
for (const type of events) {
const event = new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
char: '\r',
view: window
});
element.dispatchEvent(event);
await sleep(50);
}
}
// Insert prompt into Gemini input
async function insertPrompt(element, prompt) {
element.focus();
// Clear existing content if possible
if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') {
element.value = '';
element.value = prompt;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// ContentEditable div
element.textContent = prompt;
// Dispatch complex event sequence for rich text editors
const events = ['keydown', 'keypress', 'textInput', 'input', 'keyup'];
events.forEach(eventType => {
const event = new Event(eventType, { bubbles: true, cancelable: true });
element.dispatchEvent(event);
});
// Trigger specific InputEvent for modern frameworks
try {
const inputEvent = new InputEvent('input', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: prompt
});
element.dispatchEvent(inputEvent);
} catch (e) {
// Fallback for older browsers
}
}
// Small delay to let framework validation run
await sleep(200);
element.blur();
await sleep(100);
element.focus();
}
// Super Robust Upload Function: Tries Paste -> DragDrop -> Classical Input
async function uploadImagesToGemini(imagesDatBase64, inputElement) {
console.log('Starting hybrid upload sequence...');
// Helper: Base64 to Blob
const base64ToBlob = (base64) => {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([new Uint8Array(u8arr)], 'image.png', { type: mime });
};
// Prepare files
const dataTransfer = new DataTransfer();
for (let i = 0; i < imagesDatBase64.length; i++) {
const file = base64ToBlob(imagesDatBase64[i]);
dataTransfer.items.add(file);
}
const fileList = dataTransfer.files;
// STRATEGY 1: DISPATCH PASTE EVENT
try {
console.log('Strategy 1: Simulating Paste Event...');
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer
});
// Target the specific input (textarea/div) first
inputElement.dispatchEvent(pasteEvent);
await sleep(500);
} catch (e) {
console.warn('Strategy 1 failed', e);
}
// STRATEGY 2: CLICK ADD BUTTON & ATTACH TO INPUT (Fall back to native interaction)
console.log('Strategy 2: UI Interaction (Find & Click Add Button)...');
// 2.1 Find the Add Button
// Scoped search near input
let addBtn = null;
let inputContainer = document.body;
if (inputElement) {
inputContainer = inputElement.closest('.textarea-wrapper, .input-area, .text-input-field, footer, .main-footer') ||
inputElement.parentElement.parentElement;
}
if (inputContainer) {
// Updated selectors based on common Gemini UI and Material Design
const selectors = [
'button[aria-label*="Upload"]',
'button[aria-label*="Add"]',
'button[aria-label*="เลือกรูป"]',
'button[aria-label*="เพิ่ม"]',
'.mat-mdc-button-touch-target', // Material touch target often overlays the icon
'button:has(mat-icon[data-mat-icon-name="add"])',
'button:has(svg)', // Risky but constrained to inputContainer
];
for (const sel of selectors) {
const found = inputContainer.querySelectorAll(sel);
for (const btn of found) {
// Check if it looks like the plus button (usually first button on left)
const rect = btn.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
addBtn = btn;
break;
}
}
if (addBtn) break;
}
}
if (addBtn) {
console.log('Found Add Button, clicking...', addBtn);
addBtn.click();
await sleep(800); // Wait for menu animation
} else {
console.log('Could not pinpoint Add Button, skipping click step.');
}
// 2.2 Find File Input (It should exist now if click worked, or be always present)
const fileInput = document.querySelector('input[type="file"]');
if (fileInput) {
console.log('Found File Input, attaching files...');
fileInput.files = fileList;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
console.log('Files attached to input.');
return true;
} else {
console.warn('Strategy 2 failed: No file input found even after clicking.');
}
// STRATEGY 3: DRAG & DROP (Last Resort)
console.log('Strategy 3: Drag & Drop Simulation...');
const dropEventInit = {
bubbles: true,
cancelable: true,
view: window,
dataTransfer: dataTransfer
};
// Try to find specific dropzone if inputElement is just a text area
const dropzone = document.querySelector('xapfileselectordropzone') ||
document.querySelector('.drop-zone') ||
document.body;
let dropTarget = (dropzone && dropzone !== document.body) ? dropzone : inputElement;
dropTarget.dispatchEvent(new DragEvent('dragenter', dropEventInit));
await sleep(50);
dropTarget.dispatchEvent(new DragEvent('dragover', dropEventInit));
await sleep(50);
dropTarget.dispatchEvent(new DragEvent('drop', dropEventInit));
console.log('Hybrid upload sequence completed.');
// We return true blindly because we tried everything.
return true;
}
// Auto-click generate/create button
async function autoClickGenerateButton() {
// Common selectors for Gemini's generate/create button
const generateButtonSelectors = [
'button[aria-label*="Generate"]',
'button[aria-label*="Create"]',
'button:has-text("Generate")',
'button:has-text("Create")',
'button[data-test-id*="generate"]',
'button[data-test-id*="create"]',
'.generate-button',
'.create-button'
];
// Try to find and click the generate button in a loop for a short period
const maxAttempts = 5;
for (let i = 0; i < maxAttempts; i++) {
for (const selector of generateButtonSelectors) {
try {
const button = document.querySelector(selector);
if (button && button.offsetParent !== null) { // Check if visible
console.log('Found generate button, clicking...', selector);
button.click();
await sleep(500);
return true;
}
} catch (e) {
// Continue to next selector
}
}
await sleep(500);
}
// Alternative: Look for buttons with specific text content
const allButtons = document.querySelectorAll('button');
for (const button of allButtons) {
const text = button.textContent.toLowerCase();
if ((text.includes('generate') || text.includes('create') || text.includes('สร้าง')) &&
button.offsetParent !== null) {
console.log('Found generate button by text, clicking...', button.textContent);
button.click();
await sleep(500);
return true;
}
}
console.log('Generate button not found, image may need manual generation');
return false;
}
// Wait for generated image to appear
async function waitForGeneratedImage(initialCount = 0) {
const maxWaitTime = 60000; // 60 seconds
const checkInterval = 1000; // Check every second
let elapsed = 0;
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
elapsed += checkInterval;
// Look for generated images
const images = document.querySelectorAll(SELECTORS.generatedImage);
if (images.length > initialCount) {
// Get the most recent image
const latestImage = images[images.length - 1];
const imageUrl = latestImage.src;
if (imageUrl && !imageUrl.includes('data:image')) {
clearInterval(interval);
// Send image URL back to side panel
chrome.runtime.sendMessage({
action: 'imageGenerated',
imageUrl: imageUrl
});
resolve(imageUrl);
}
}
if (elapsed >= maxWaitTime) {
clearInterval(interval);
reject(new Error('หมดเวลารอรูปภาพจาก Gemini'));
}
}, checkInterval);
});
}
// Utility: Wait for element to appear
function waitForElement(selector, timeout = 5000) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
// Utility: Sleep
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Monitor for manual image generation (if user generates without extension)
// -----------------------------------------------------------
// -----------------------------------------------------------
// Auto Download Logic (Persistent)
// -----------------------------------------------------------
let isAutoDownloadEnabled = false; // Default off, will sync with storage
const downloadedImages = new Set(); // Local cache of what we've seen/downloaded
// Initialize state from storage
chrome.storage.local.get(['autoDownload', 'downloadedHistory'], (result) => {
isAutoDownloadEnabled = result.autoDownload || false;
// 1. Load history into Set
if (result.downloadedHistory && Array.isArray(result.downloadedHistory)) {
result.downloadedHistory.forEach(url => downloadedImages.add(url));
}
// 2. Scan Existing DOM for images (The "Reload Extension" case)
// Any image currently visible is "old" and shouldn't be auto-downloaded again this session
// We add EVERYTHING currently on screen to the history to be safe.
const existingImages = document.querySelectorAll('img');
let addedCount = 0;
existingImages.forEach(img => {
if (img.src && img.src.startsWith('http')) {
// Just add it blindly to history so we don't process it again
if (!downloadedImages.has(img.src)) {
downloadedImages.add(img.src);
addedCount++;
}
}
});
if (addedCount > 0) {
console.log(`Marked ${addedCount} existing images as seen (skipped download).`);
saveDownloadHistory();
}
console.log('Auto Download status:', isAutoDownloadEnabled);
console.log(`Loaded ${downloadedImages.size} total images in history.`);
// Start observing ONLY if enabled
if (isAutoDownloadEnabled) {
startObserving();
}
});
// Listen for storage changes
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.autoDownload) {
isAutoDownloadEnabled = changes.autoDownload.newValue;
console.log('Auto Download status changed to:', isAutoDownloadEnabled);
if (isAutoDownloadEnabled) {
startObserving();
} else {
stopObserving();
}
}
});
// ... (previous code)
let observerActive = false;
let isInitialLoad = true;
// Set a grace period for the initial load
// This prevents the extension from downloading existing chat history as "new" images
// when the user reloads the page.
setTimeout(() => {
isInitialLoad = false;
console.log('Initial load grace period ended. Ready to capture NEW images.');
}, 5000);
const autoDownloadObserver = new MutationObserver((mutations) => {
// Only run if we are generally interested, though checking inside loop is safer for dynamic toggling
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
// Specific validaiton for IMG tag
if (node.tagName === 'IMG') {
checkAndDownloadImage(node);
}
// Sometimes images are nested in added containers
if (node.querySelectorAll) {
const images = node.querySelectorAll('img');
images.forEach(checkAndDownloadImage);
}
}
}
});
let saveTimeout;
function debouncedSaveHistory() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveDownloadHistory, 1000);
}
function checkAndDownloadImage(img) {
// 1. Check if it's already processed (in local Set)
// Safety: Duplicate Check using src as key
if (downloadedImages.has(img.src)) return;
// 2. Wait for image to load to check dimensions
if (!img.complete) {
img.onload = () => checkAndDownloadImage(img);
return;
}
if (!img.src) return;
// --- STRICT FILTERING LOGIC ---
// Condition A (Size): Width OR Height > 300px
// Prevents icons, avatars, stickers
const isBigEnough = img.naturalWidth > 300 || img.naturalHeight > 300;
if (!isBigEnough) {
return;
}
// Condition B (Source):
// - URL must not be small Data URI (handled partly by size check, but user wants strict source check)
// - Must not be Blob from user upload
// - If possible, check if in model-response container
// Check ancestors for container type
const messageContainer = img.closest('[data-message-author-role], .model-response, .user-message, .query-container');
let isModelResponse = false;
let isUserUpload = false;
if (messageContainer) {
const role = messageContainer.getAttribute('data-message-author-role');
if (role === 'user') {
isUserUpload = true;
} else if (role === 'model') {
isModelResponse = true; // High confidence
} else if (messageContainer.classList.contains('user-message') || messageContainer.classList.contains('query-container')) {
isUserUpload = true;
} else if (messageContainer.classList.contains('model-response')) {
isModelResponse = true;
}
}
// B.1: Reject Explicit User Uploads
if (isUserUpload) {
console.log('Ignored: Image is in User message', img.src);
downloadedImages.add(img.src);
return;
}
// B.2: Source URL Check
const src = img.src;
const isBlob = src.startsWith('blob:');
const isData = src.startsWith('data:');
if ((isBlob || isData) && !isModelResponse) {
console.log('Ignored: Blob/Data URI without Model context (Likely user upload)', src);
return;
}
// B.3: Input Area Check (Composing phrase)
if (img.closest('div[contenteditable="true"], .input-area, textarea, .text-input-field')) {
return;
}
// B.4: Avatar Check (googleusercontent specific)
if (src.includes('googleusercontent.com/a/')) {
return;
}
// If we passed all filters:
// console.log('Pass: Valid Generated Image detected:', src);
// Add to handled set immediately
downloadedImages.add(src);
debouncedSaveHistory(); // Use debounced save
// CRITICAL: Check if we are in the initial load phase (Reload protection)
if (isInitialLoad) {
console.log('Skipped Download (Initial Load):', src);
return;
}
if (isAutoDownloadEnabled) {
console.log('Auto-downloading image...');
chrome.runtime.sendMessage({
action: 'downloadImage',
url: src
});
}
}
function saveDownloadHistory() {
// Convert Set to Array
const historyArray = Array.from(downloadedImages);
// Limit history size to prevent storage bloat (e.g., last 500 images)
if (historyArray.length > 500) {
historyArray.splice(0, historyArray.length - 500);
}
chrome.storage.local.set({ downloadedHistory: historyArray });
}
function startObserving() {
if (observerActive) return; // Prevent double attach
// Start observing specifically for images appearing in the chat
autoDownloadObserver.observe(document.body, {
childList: true,
subtree: true
});
observerActive = true;
console.log('Auto Download Observer STARTED');
}
function stopObserving() {
if (!observerActive) return;
autoDownloadObserver.disconnect();
observerActive = false;
console.log('Auto Download Observer STOPPED (Resource Saved)');
}