diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec6675..f0ef3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,47 @@ All notable changes to AutoApplyMax will be documented in this file. +## [v1.6.0] - 2026-01-26 + +### ✨ New Features + +- **AI-Powered Answers (Groq Integration)** - Uses Groq AI to intelligently answer unknown/custom application questions +- **Smart Answer Caching** - All answers are saved to storage and reused for similar questions (no repeated API calls) +- **Similarity Matching** - Finds cached answers for similar questions using Jaccard similarity algorithm +- **Professional Profile** - Configure your professional summary and skills for better AI responses +- **Textarea Support** - Now handles long-form text fields like cover letter snippets +- **Learning System** - Saves all submitted answers to improve future applications + +### 🔧 Settings Additions + +- Groq API Key field (free at console.groq.com) +- Enable/Disable AI toggle +- Professional Summary textarea +- Skills input field +- Cached answers counter with clear button + +### 📝 How AI Works + +1. When a question is detected, first checks the cache for exact or similar matches +2. If no cache hit (>70% similarity), calls Groq API with your profile context +3. Answer is saved to cache for future use +4. All answers from successful applications are automatically cached + ## [v1.5.0] - 2026-01-15 ### ✨ New Features + - **LinkedIn Collections Support** - Now works on `/jobs/collections/` pages (saved jobs, recommended jobs, etc.) with infinite scroll - **Smart Resume Selection** - Now selects existing/previously uploaded CV instead of re-uploading for each application. Only uploads once (first application), then reuses the CV. ### 🐛 Bug Fixes + - Removed redundant "Welcome aboard!" toast message after onboarding - Fixed fadeOut animation for onboarding overlay closure - Fixed resume being uploaded for every single application (now uploads once, then selects existing) ### 🔧 Technical Improvements + - Collections support uses conditional selectors (only on collections pages, doesn't affect search pages) - Infinite scroll pagination for collections pages - Standard pagination preserved for search pages @@ -23,6 +52,7 @@ All notable changes to AutoApplyMax will be documented in this file. ### 🎉 Initial Release #### ✨ Features + - **Automated LinkedIn Easy Apply** - One-click job applications - **Smart Form Filling** - Automatically fills application forms - **Human-like Behavior** - Random delays and natural interactions @@ -34,6 +64,7 @@ All notable changes to AutoApplyMax will be documented in this file. - **Applied Jobs Tab** - View application history in extension popup #### 🎨 UI/UX + - Clean, modern interface with LinkedIn color scheme - Discord community card with direct link - Three tabs: Personal Info, Settings, Applied Jobs @@ -46,6 +77,7 @@ All notable changes to AutoApplyMax will be documented in this file. - Empty state for no applications #### 🤖 Automation + - Multi-selector element detection (XPath + CSS) - Retry mechanism (up to 3 attempts) - Automatic stuck detection (2-minute timeout) @@ -53,18 +85,21 @@ All notable changes to AutoApplyMax will be documented in this file. - Error recovery and continuation #### 🔒 Security & Privacy + - 100% local data storage - No external servers - LinkedIn-only permissions - Open source and transparent #### 💬 Community -- Discord integration (https://discord.gg/xWaCXBZbws) + +- Discord integration () - Twitter presence (@Azo92i) - Community-driven development - Feature voting and feedback #### 📚 Documentation + - Professional README with roadmap - Installation guide - Usage guide @@ -75,6 +110,7 @@ All notable changes to AutoApplyMax will be documented in this file. - Google Sheets integration guide (future) ### 🐛 Bug Fixes + - Fixed daily limit detection with multiple message patterns - Improved button state synchronization - Better error handling for failed applications @@ -82,21 +118,25 @@ All notable changes to AutoApplyMax will be documented in this file. ### 🚀 Coming Soon (Roadmap) #### v1.1.0 - AI Integration + - AI-powered job matching - Google Sheets auto-export - Success rate tracking #### v1.2.0 - CV & Cover Letters + - Dynamic CV adaptation for each job - AI-generated cover letters - Multi-format resume support #### v1.3.0 - Multi-Platform + - Indeed support - Glassdoor support - Company career page auto-apply #### v1.4.0 - Advanced Analytics + - Application success rates - Response time tracking - Industry insights @@ -113,6 +153,7 @@ All notable changes to AutoApplyMax will be documented in this file. ## Upgrade Guide ### From Nothing to v1.0.0 + Fresh install - follow [Installation Guide](README.md#installation) --- diff --git a/content-simple.js b/content-simple.js index 9846f4c..411c099 100644 --- a/content-simple.js +++ b/content-simple.js @@ -16,6 +16,133 @@ let resumeFile = null; // Base64 data let resumeFileName = null; let resumeFileType = null; +// AI Integration variables +let groqApiKey = null; +let enableAI = false; +let professionalSummary = ''; +let skills = ''; +let aiAnswersCache = {}; // Cache for AI-generated answers + +// Current language for AI responses +let currentLanguage = 'en'; + +// ============================================ +// MULTILINGUAL PATTERNS - Button/Field Detection +// Supports: EN, PT-BR, ES, FR, DE, IT +// ============================================ +const PATTERNS = { + // Easy Apply button patterns (for aria-label detection) + easyApply: [ + 'Easy Apply', 'Candidatura simplificada', 'Candidatura Simplificada', + 'Solicitud sencilla', 'Solicitud Sencilla', 'Candidature simplifiée', + 'Candidature Simplifiée', 'Einfach bewerben', 'Candidatura semplice' + ], + + // Done/Submit application button text + doneButtons: [ + 'Done', 'Submit application', 'Submit', 'Dismiss', 'Close', 'Continue', + 'Concluído', 'Enviar candidatura', 'Enviar', 'Fechar', 'Dispensar', 'Continuar', + 'Listo', 'Enviar solicitud', 'Cerrar', 'Descartar', + 'Terminé', 'Soumettre la candidature', 'Soumettre', 'Fermer', + 'Fertig', 'Bewerbung absenden', 'Absenden', 'Schließen', + 'Fatto', 'Invia candidatura', 'Invia', 'Chiudi' + ], + + // Discard/Cancel button text + discardButtons: [ + 'discard', 'cancel', 'close', 'dismiss', + 'descartar', 'cancelar', 'fechar', 'dispensar', + 'cerrar', 'anular', 'annuler', 'abandonner', 'fermer', + 'verwerfen', 'abbrechen', 'schließen', 'scartare', 'annullare', 'chiudere' + ], + + // Close/Dismiss button aria-labels (for modal close buttons) + closeButtonLabels: [ + 'Dismiss', 'Close', 'Fechar', 'Dispensar', 'Cerrar', 'Descartar', + 'Fermer', 'Ignorer', 'Schließen', 'Chiudi', 'Annulla' + ], + + // Pagination next button aria-labels + paginationNext: [ + 'Next', 'Próximo', 'Próxima', 'Siguiente', 'Suivant', 'Weiter', 'Avanti', + 'View next page', 'Ver próxima página', 'Ver siguiente página', + 'Page suivante', 'Nächste Seite', 'Pagina successiva' + ], + + // Next/Review/Continue button text + nextButtons: [ + 'next', 'review', 'continue', + 'próximo', 'avançar', 'continuar', 'revisar', + 'siguiente', 'suivant', 'réviser', 'continuer', + 'weiter', 'überprüfen', 'fortsetzen', 'avanti', 'rivedi', 'continua' + ], + + // Submit button text + submitButtons: [ + 'submit', 'send', 'apply', + 'enviar', 'candidatar', 'aplicar', 'postular', + 'soumettre', 'envoyer', 'postuler', 'absenden', 'bewerben', 'invia', 'candidati' + ], + + // Daily limit messages + dailyLimit: [ + "You've reached today's Easy Apply limit", "reached today's Easy Apply limit", + "Great effort applying today", "we limit daily submissions", "continue applying tomorrow", + "Save this job and continue applying tomorrow", "exceeded the daily application limit", + "daily Easy Apply limit", "limit daily submissions", + "Você atingiu o limite de Candidatura Simplificada de hoje", "limite diário de candidaturas", + "continue se candidatando amanhã", "Bom trabalho se candidatando hoje", + "Has alcanzado el límite de Solicitud Sencilla de hoy", "límite diario de solicitudes", + "continúa aplicando mañana", "Gran esfuerzo aplicando hoy", + "Vous avez atteint la limite de Candidature Simplifiée", "limite quotidienne de candidatures", + "Tägliches Bewerbungslimit erreicht" + ], + + // Field detection patterns (regex) + fields: { + yearsExperience: /experience|years|expérience|années|experiência|anos|años|jahre|anni|esperienza/i, + salary: /salary|compensation|remuneration|salaire|rémunération|salário|sueldo|salario|gehalt|stipendio/i, + email: /email|e-mail|courriel|correo/i, + firstName: /first|prénom|prenom|nombre|vorname|nome/i, + lastName: /last|nom|apellido|nachname|cognome/i, + phone: /phone|téléphone|telefono|telefon|mobile|portable|cell|móvil|cellulare|celular/i, + city: /city|ville|ciudad|stadt|città|location|localisation|ubicación|standort/i, + currentCompany: /current.*company|current.*employer|empresa.*atual|employeur.*actuel|empleador.*actual|most recent.*company|última.*empresa|recent.*employer/i, + linkedinUrl: /linkedin.*url|linkedin.*profile|perfil.*linkedin|profil.*linkedin|url.*linkedin/i, + portfolioUrl: /portfolio.*url|portfolio|website|personal.*site|site.*personnel|sitio.*personal|site.*pessoal|github|behance/i + }, + + // Yes/No patterns for radio buttons + yesPatterns: /^(yes|oui|sí|si|ja|y|sim)$/i, + noPatterns: /^(no|non|nein|n|não)$/i, + + // Language proficiency patterns + proficiency: { + native: /native|bilingual|bilingue|bilíngue|langue maternelle|lengua materna/i, + fluent: /fluent|courant|fluide|fluente|fluido/i, + professional: /professional|professionnel|profissional|profesional|advanced|avançado/i, + intermediate: /intermediate|intermediário|intermedio|moyen/i, + basic: /basic|básico|basique|elementary|elementar/i + }, + + // English level detection pattern + englishLevelQuestion: /english.*level|level.*english|english.*proficiency|proficiency.*english|inglês|inglés|anglais/i, + + // Ethnicity/Race detection pattern + ethnicityQuestion: /race|ethnicity|ethnic|racial|cor|raça|raza|etnia|origem étnica/i, + + // Ethnicity option patterns (to match dropdown options) + ethnicityOptions: { + white: /white|branco|blanco|caucasian|caucasiano/i, + black: /black|negro|preto|african|africano/i, + hispanic: /hispanic|latino|latina|hispânico|hispano/i, + asian: /asian|asiático|asiatic|amarelo/i, + indigenous: /indigenous|indígena|native american|índio|aboriginal/i, + mixed: /mixed|multiracial|pardo|mestizo|two or more|duas ou mais/i, + preferNotToSay: /prefer not|prefiro não|prefiero no|decline|não informar|no responder/i + } +}; + // Logs simples function log(msg) { console.log('[LinkedIn Bot]', msg); @@ -59,20 +186,8 @@ function isStuck() { // Check for LinkedIn's daily Easy Apply limit function checkDailyLimit() { try { - // List of limit message patterns (case-insensitive) - const limitPatterns = [ - "You've reached today's Easy Apply limit", - "You've reached today's easy apply limit", - "reached today's Easy Apply limit", - "Great effort applying today", - "we limit daily submissions", - "continue applying tomorrow", - "Save this job and continue applying tomorrow", - "exceeded the daily application limit", - "reached today\\'s easy apply limit", - "daily Easy Apply limit", - "limit daily submissions" - ]; + // Use multilingual patterns from PATTERNS constant + const limitPatterns = PATTERNS.dailyLimit; // Search in entire page text const bodyText = document.body.innerText || ''; @@ -126,7 +241,8 @@ function checkDailyLimit() { async function findAndClickDoneButton(contextElement = document, contextName = 'page', maxAttempts = 15) { log(`🔍 [${contextName}] Starting exhaustive search for Done button...`); - const doneTexts = ['Done', 'Terminé', 'Submit application', 'Soumettre la candidature', 'Dismiss', 'Close', 'Fermer']; + // Use multilingual patterns + const doneTexts = PATTERNS.doneButtons; let doneBtn = null; for (let attempt = 0; attempt < maxAttempts && !doneBtn; attempt++) { @@ -298,7 +414,8 @@ async function refreshAndReturnToSearch() { async function discardApplication() { log('🚀 DISCARD: Starting SAFE discard sequence...'); - const discardTexts = ['discard', 'annuler', 'cancel', 'abandonner', 'descarter']; + // Use multilingual patterns + const discardTexts = PATTERNS.discardButtons; try { // 🆕 DETECTION CRITIQUE: Vérifier si popup de chargement est bloqué (Python ligne 1547-1558) @@ -317,7 +434,9 @@ async function discardApplication() { // STEP 1: Force close with X button (MOST RELIABLE METHOD - moved to first) log('🔍 STEP 1: Looking for X/Close button...'); - const closeButtons = document.querySelectorAll('button[aria-label*="Dismiss"], button[aria-label*="Close"], button.artdeco-modal__dismiss'); + // Build multilingual selector for close buttons + const closeSelectors = PATTERNS.closeButtonLabels.map(label => `button[aria-label*="${label}"]`).join(', '); + const closeButtons = document.querySelectorAll(`${closeSelectors}, button.artdeco-modal__dismiss`); for (let btn of closeButtons) { if (btn.offsetParent) { @@ -428,6 +547,392 @@ function fill(input, value) { input.dispatchEvent(new Event('change', { bubbles: true })); } +// ============================================ +// AI INTEGRATION - Groq API + Answer Caching +// ============================================ + +// Normalize question text for comparison (remove extra spaces, lowercase, etc.) +function normalizeQuestion(question) { + return question + .toLowerCase() + .replace(/[^\w\s]/g, '') // Remove punctuation + .replace(/\s+/g, ' ') // Normalize spaces + .trim(); +} + +// Calculate similarity between two strings (Jaccard similarity on words) +function calculateSimilarity(str1, str2) { + const words1 = new Set(normalizeQuestion(str1).split(' ').filter(w => w.length > 2)); + const words2 = new Set(normalizeQuestion(str2).split(' ').filter(w => w.length > 2)); + + if (words1.size === 0 || words2.size === 0) return 0; + + const intersection = new Set([...words1].filter(x => words2.has(x))); + const union = new Set([...words1, ...words2]); + + return intersection.size / union.size; +} + +// Find cached answer with similarity threshold +function findCachedAnswer(question, threshold = 0.7) { + const normalizedQuestion = normalizeQuestion(question); + + // First, try exact match + if (aiAnswersCache[normalizedQuestion]) { + log(`🎯 AI Cache HIT (exact): "${question.substring(0, 40)}..."`); + return aiAnswersCache[normalizedQuestion]; + } + + // Then, try similarity matching + let bestMatch = null; + let bestScore = 0; + + for (const [cachedQuestion, answer] of Object.entries(aiAnswersCache)) { + const similarity = calculateSimilarity(question, cachedQuestion); + if (similarity > bestScore && similarity >= threshold) { + bestScore = similarity; + bestMatch = { question: cachedQuestion, answer, score: similarity }; + } + } + + if (bestMatch) { + log(`🎯 AI Cache HIT (${Math.round(bestMatch.score * 100)}% similar): "${question.substring(0, 40)}..."`); + return bestMatch.answer; + } + + return null; +} + +// Save answer to cache +async function saveAnswerToCache(question, answer) { + const normalizedQuestion = normalizeQuestion(question); + aiAnswersCache[normalizedQuestion] = answer; + + // Also save to Chrome storage for persistence + try { + await chrome.storage.local.set({ aiAnswersCache }); + log(`💾 Cached answer for: "${question.substring(0, 40)}..."`); + } catch (e) { + log(`⚠️ Failed to save answer to cache: ${e.message}`); + } +} + +// Get language name for AI prompts +function getLanguageName(langCode) { + const languages = { + 'en': 'English', + 'pt-br': 'Portuguese (Brazilian)', + 'es': 'Spanish', + 'fr': 'French' + }; + return languages[langCode] || 'English'; +} + +// Call Groq API to generate answer +async function callGroqAPI(question, context = {}) { + if (!groqApiKey) { + log('⚠️ Groq API key not configured'); + return null; + } + + const responseLanguage = getLanguageName(currentLanguage); + + const systemPrompt = `You are an AI assistant helping to fill out job application forms. +You must provide SHORT, CONCISE answers suitable for form fields (usually 1-3 sentences max). +Do NOT use markdown formatting. Just plain text. + +CANDIDATE PROFILE: +- Name: ${config.firstName || ''} ${config.lastName || ''} +- Email: ${config.email || ''} +- Location: ${config.city || ''} +- Years of Experience: ${config.yearsOfExperience || ''} +- Skills: ${skills || 'Not specified'} +- Professional Summary: ${professionalSummary || 'Not provided'} + +PREVIOUS ANSWERS (use for consistency): +${Object.entries(aiAnswersCache).slice(-10).map(([q, a]) => `Q: ${q}\nA: ${a}`).join('\n\n')} + +RULES: +1. Be concise - form fields have character limits +2. Be professional and confident +3. Match the tone of the question +4. If asking about years of experience with specific tech, give a realistic number based on total experience +5. If you don't have enough info, make reasonable assumptions based on the profile +6. NEVER say "I don't know" - always provide a helpful answer +7. Always reply in ${responseLanguage} +8. IMPORTANT: For salary, compensation, or any monetary value questions, respond with ONLY numbers (e.g., "50000" not "R$ 50.000" or "$50,000"). No currency symbols, no dots, no commas, no spaces - just the raw number. +9. For numeric fields (years, quantity, percentage), respond with ONLY the number without any text or symbols`; + + const userPrompt = `Application Question: "${question}" +${context.fieldType ? `Field Type: ${context.fieldType}` : ''} +${context.maxLength ? `Max Length: ${context.maxLength} characters` : ''} + +Provide a direct answer suitable for this form field:`; + + try { + log(`🤖 Calling Groq API for: "${question.substring(0, 50)}..."`); + + const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${groqApiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.3, + max_tokens: 200 + }) + }); + + if (!response.ok) { + const error = await response.text(); + log(`❌ Groq API error: ${response.status} - ${error}`); + return null; + } + + const data = await response.json(); + let answer = data.choices?.[0]?.message?.content?.trim(); + + if (answer) { + // Clean numeric answers (salary, years, etc.) - remove currency symbols, dots, commas, spaces + const isNumericQuestion = context.fieldType === 'number' || + /salary|salário|sueldo|compensation|remuneration|rémunération|years|anos|años|quantity|quantidade|percentage|porcentagem/i.test(question); + + if (isNumericQuestion) { + // Extract only digits from the answer + const numericValue = answer.replace(/[^\d]/g, ''); + if (numericValue) { + answer = numericValue; + log(`🔢 Cleaned numeric answer: "${answer}"`); + } + } + + log(`✅ Groq response: "${answer.substring(0, 50)}..."`); + // Save to cache for future use + await saveAnswerToCache(question, answer); + return answer; + } + + return null; + } catch (error) { + log(`❌ Groq API call failed: ${error.message}`); + return null; + } +} + +// Main function to get AI answer (cache first, then API) +async function getAIAnswer(question, context = {}) { + if (!enableAI) { + return null; + } + + // Try cache first + const cachedAnswer = findCachedAnswer(question); + if (cachedAnswer) { + return cachedAnswer; + } + + // If not in cache, call API + return await callGroqAPI(question, context); +} + +// AI function to select the best option from a dropdown +async function getAIDropdownSelection(question, options) { + if (!enableAI || !groqApiKey) { + return null; + } + + // Build options list for AI + const optionsList = options.map((opt, i) => `${i + 1}. ${opt}`).join('\n'); + + const systemPrompt = `You are an AI assistant helping to fill out job application forms. +You must select the BEST option from a dropdown menu for the given question. + +CANDIDATE PROFILE: +- Name: ${config.firstName || ''} ${config.lastName || ''} +- Email: ${config.email || ''} +- Location: ${config.city || ''} +- Years of Experience: ${config.yearsOfExperience || ''} +- Skills: ${skills || 'Not specified'} +- Professional Summary: ${professionalSummary || 'Not provided'} +- English Level: ${config.englishLevel || 'advanced'} +- Visa Sponsorship Needed: ${config.visaSponsorship || 'no'} +- Legally Authorized to Work: ${config.legallyAuthorized || 'yes'} +- Willing to Relocate: ${config.willingToRelocate || 'yes'} +- Has Driver's License: ${config.driversLicense || 'yes'} +- Disability Status: ${config.disabilityStatus || 'no'} + +RULES: +1. Respond with ONLY the number of the best option (e.g., "1" or "3") +2. Choose the most appropriate option based on the candidate profile +3. If the question is about experience level, choose based on years of experience +4. If unsure, prefer positive/affirmative options +5. Never respond with text, only the option number +6. If options include "Select...", "Choose...", or similar placeholders, skip those`; + + const userPrompt = `Question: "${question}" + +Available options: +${optionsList} + +Which option number should I select? (respond with only the number)`; + + try { + log(`🤖 AI selecting dropdown for: "${question.substring(0, 50)}..."`); + + const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${groqApiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.1, + max_tokens: 10 + }) + }); + + if (!response.ok) { + const error = await response.text(); + log(`❌ Groq API error: ${response.status} - ${error}`); + return null; + } + + const data = await response.json(); + const answer = data.choices?.[0]?.message?.content?.trim(); + + if (answer) { + // Parse the number from the response + const optionNumber = parseInt(answer.replace(/\D/g, '')); + if (optionNumber > 0 && optionNumber <= options.length) { + log(`✅ AI selected option ${optionNumber}: "${options[optionNumber - 1].substring(0, 30)}..."`); + // Cache this selection for future similar questions + await saveAnswerToCache(question, options[optionNumber - 1]); + return optionNumber - 1; // Return 0-based index + } + } + + return null; + } catch (error) { + log(`❌ AI dropdown selection failed: ${error.message}`); + return null; + } +} + +// Save any filled answer to cache (for learning from user's manual inputs) +async function learnFromFilledField(question, answer) { + if (!question || !answer || answer.length < 2) return; + + // Don't cache common fields like name, email, phone + const skipPatterns = /^(email|phone|name|first.*name|last.*name|city|location|salary|experience|years)/i; + if (skipPatterns.test(normalizeQuestion(question))) return; + + await saveAnswerToCache(question, answer); +} + +// Save all answers from a modal to cache (called after successful submission) +async function saveAllAnswersFromModal(modal) { + if (!modal) return; + + log('💾 Saving all answers from application...'); + let savedCount = 0; + + // Common fields to skip (already handled by config) + const skipPatterns = /^(email|e-mail|phone|téléphone|name|nom|prénom|first|last|city|ville|location|salary|salaire|experience|années|years)/i; + + // 1. Save text input answers + const textInputs = modal.querySelectorAll('input[type="text"], input[type="number"], textarea'); + for (let input of textInputs) { + if (!input.value || input.value.length < 3) continue; + + let labelText = getFieldLabel(input, modal); + if (!labelText || labelText.length < 5) continue; + if (skipPatterns.test(labelText)) continue; + + await saveAnswerToCache(labelText, input.value); + savedCount++; + } + + // 2. Save radio button answers + const radioFieldsets = modal.querySelectorAll('fieldset[data-test-form-builder-radio-button-form-component]'); + for (let fieldset of radioFieldsets) { + const questionLabel = fieldset.querySelector('legend, span[class*="title"]'); + const questionText = questionLabel ? questionLabel.textContent.trim() : ''; + if (!questionText || questionText.length < 5) continue; + if (skipPatterns.test(questionText)) continue; + + const checkedRadio = fieldset.querySelector('input[type="radio"]:checked'); + if (checkedRadio) { + const radioLabel = fieldset.querySelector(`label[for="${checkedRadio.id}"]`); + const answer = radioLabel ? radioLabel.textContent.trim() : checkedRadio.value; + await saveAnswerToCache(questionText, answer); + savedCount++; + } + } + + // 3. Save select dropdown answers + const selects = modal.querySelectorAll('select'); + for (let select of selects) { + if (select.selectedIndex <= 0) continue; + + let labelText = getFieldLabel(select, modal); + if (!labelText || labelText.length < 5) continue; + if (skipPatterns.test(labelText)) continue; + + const selectedOption = select.options[select.selectedIndex]; + if (selectedOption && selectedOption.text) { + await saveAnswerToCache(labelText, selectedOption.text); + savedCount++; + } + } + + log(`💾 Saved ${savedCount} answers to cache (total: ${Object.keys(aiAnswersCache).length})`); +} + +// Helper function to get label text for a field +function getFieldLabel(field, modal) { + let labelText = ''; + + // aria-label + labelText += ' ' + (field.getAttribute('aria-label') || ''); + + // name attribute + labelText += ' ' + (field.getAttribute('name') || ''); + + // placeholder + labelText += ' ' + (field.getAttribute('placeholder') || ''); + + // Associated