From a7ce119bde375958b27d349e03521d3d7bde8935 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Fri, 6 Mar 2026 09:53:54 -0800 Subject: [PATCH] fix: resilient modal button detection and form filler robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit easy_apply.mjs: - findModalButton() uses 3-strategy detection: aria-label exact/substring, then exact button text match — survives LinkedIn aria-label changes - Check order fixed: Next → Review → Submit (submit only when no forward nav) - All queries scoped to modal + :not([disabled]) - dismissModal() with fallback chain: Dismiss → Close/X → Escape → Discard - Uses innerText for button text (ignores hidden children) form_filler.mjs: - All queries scoped to container (modal when present, page otherwise) - Radio labels use $$('label') + textContent instead of broken :has-text() - Autocomplete uses waitForSelector instead of blind 800ms sleep - EEO selects iterate options directly (selectOption doesn't accept regex) - Country code check ordered before country to prevent fragile match order constants.mjs: - Add AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT - Remove unused button selectors (now handled inline by findModalButton) ai_answer.mjs + keywords.mjs: - Use ANTHROPIC_API_URL constant, claude-sonnet-4-6 model Co-Authored-By: Claude Opus 4.6 --- lib/ai_answer.mjs | 5 +- lib/apply/easy_apply.mjs | 211 ++++++++++++++++++++++++++++++--------- lib/constants.mjs | 8 +- lib/form_filler.mjs | 92 ++++++++++------- lib/keywords.mjs | 2 +- 5 files changed, 228 insertions(+), 90 deletions(-) diff --git a/lib/ai_answer.mjs b/lib/ai_answer.mjs index 84cd580..c195098 100644 --- a/lib/ai_answer.mjs +++ b/lib/ai_answer.mjs @@ -3,8 +3,7 @@ * Called when form_filler hits a question it can't answer from profile/answers.json */ import { readFileSync, existsSync } from 'fs'; - -const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages'; +import { ANTHROPIC_API_URL } from './constants.mjs'; /** * Generate an answer to an unknown application question using Claude. @@ -57,7 +56,7 @@ Application question: Write the best answer for this question. Just the answer text -- no preamble, no explanation.`; try { - const res = await fetch(ANTHROPIC_API, { + const res = await fetch(ANTHROPIC_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index a0a395e..0cfa677 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -1,18 +1,74 @@ /** * easy_apply.mjs — LinkedIn Easy Apply handler * Handles the LinkedIn Easy Apply modal flow + * + * Button detection strategy: LinkedIn frequently changes aria-labels and button + * structure. We use multiple fallback strategies: + * 1. aria-label exact match (fastest, but brittle) + * 2. aria-label substring match (handles minor text changes) + * 3. Exact button text match (most resilient — matches trimmed innerText) + * All searches are scoped to the modal dialog to avoid clicking page buttons. + * + * Modal flow: Easy Apply → [fill → Next] × N → Review → Submit application + * Check order per step: Next → Review → Submit (only submit when no forward nav exists) */ import { - NAVIGATION_TIMEOUT, PAGE_LOAD_WAIT, CLICK_WAIT, MODAL_STEP_WAIT, + NAVIGATION_TIMEOUT, CLICK_WAIT, MODAL_STEP_WAIT, SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT, LINKEDIN_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_APPLY_BUTTON_SELECTOR, - LINKEDIN_SUBMIT_SELECTOR, LINKEDIN_NEXT_SELECTOR, - LINKEDIN_REVIEW_SELECTOR, LINKEDIN_DISMISS_SELECTOR, LINKEDIN_MAX_MODAL_STEPS } from '../constants.mjs'; export const SUPPORTED_TYPES = ['easy_apply']; +/** + * Find a non-disabled button inside the modal using multiple strategies. + * @param {Page} page - Playwright page + * @param {string} modalSelector - CSS selector for the modal container + * @param {Object} opts + * @param {string[]} opts.ariaLabels - aria-label values to try (exact then substring) + * @param {string[]} opts.exactTexts - exact button text matches (case-insensitive, trimmed) + * @returns {ElementHandle|null} + */ +async function findModalButton(page, modalSelector, { ariaLabels = [], exactTexts = [] }) { + // Strategy 1: aria-label exact match inside modal (non-disabled only) + for (const label of ariaLabels) { + const btn = await page.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`); + if (btn) return btn; + } + + // Strategy 2: aria-label substring match inside modal + for (const label of ariaLabels) { + const btn = await page.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`); + if (btn) return btn; + } + + // Strategy 3: exact text match on button innerText (case-insensitive, trimmed) + // Uses evaluateHandle to return a live DOM reference + if (exactTexts.length === 0) return null; + + const handle = await page.evaluateHandle((sel, texts) => { + const modal = document.querySelector(sel); + if (!modal) return null; + const targets = texts.map(t => t.toLowerCase()); + const buttons = modal.querySelectorAll('button:not([disabled])'); + for (const btn of buttons) { + // Use innerText (not textContent) to get rendered text without hidden children + const text = (btn.innerText || btn.textContent || '').trim().toLowerCase(); + if (targets.includes(text)) return btn; + } + return null; + }, modalSelector, exactTexts).catch(() => null); + + if (handle) { + const el = handle.asElement(); + if (el) return el; + await handle.dispose().catch(() => {}); + } + + return null; +} + export async function apply(page, job, formFiller) { const meta = { title: job.title, company: job.company }; @@ -23,7 +79,6 @@ export async function apply(page, job, formFiller) { await page.evaluate(() => window.scrollTo(0, 300)).catch(() => {}); const eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null); if (!eaBtn) { - // Debug: log what apply-related elements exist const applyEls = await page.evaluate(() => Array.from(document.querySelectorAll('[aria-label*="Easy Apply"], [aria-label*="Apply"]')) .map(el => ({ tag: el.tagName, aria: el.getAttribute('aria-label'), visible: el.offsetParent !== null })) @@ -44,87 +99,151 @@ export async function apply(page, job, formFiller) { const modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null); if (!modal) return { status: 'no_modal', meta }; + const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR; + // Step through modal let lastProgress = '-1'; for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) { - const modalStillOpen = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR); + const modalStillOpen = await page.$(MODAL); if (!modalStillOpen) { console.log(` ✅ Modal closed — submitted`); return { status: 'submitted', meta }; } - const progress = await page.$eval('[role="progressbar"]', - el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || String(el.style?.width || step) - ).catch(() => String(step)); + const progress = await page.$eval(`${MODAL} [role="progressbar"]`, + el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || '' + ).catch(() => ''); - // Snapshot modal heading + all page-level buttons for debugging + // Debug snapshot: heading, buttons in modal, and any validation errors const debugInfo = await page.evaluate((sel) => { const modal = document.querySelector(sel); - const heading = modal?.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || ''; - const realProgress = document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow') || - document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuetext') || null; - const allBtns = Array.from(document.querySelectorAll('button')).map(b => ({ - text: b.textContent?.trim().slice(0, 40), + if (!modal) return { heading: '', buttons: [], errors: [] }; + const heading = modal.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || ''; + const buttons = Array.from(modal.querySelectorAll('button, [role="button"]')).map(b => ({ + text: (b.innerText || b.textContent || '').trim().slice(0, 50), aria: b.getAttribute('aria-label'), disabled: b.disabled, - })).filter(b => (b.text || b.aria) && !['Home', 'Jobs', 'Me', 'For Business', 'Save the job'].includes(b.aria)); - const errors = Array.from(document.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error')) + })).filter(b => b.text || b.aria); + const errors = Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error')) .map(e => e.textContent?.trim().slice(0, 60)).filter(Boolean); - return { heading, realProgress, allBtns, errors }; - }, LINKEDIN_EASY_APPLY_MODAL_SELECTOR).catch(() => ({})); - console.log(` [step ${step}] heading="${debugInfo.heading}" realProgress=${debugInfo.realProgress} buttons=${JSON.stringify(debugInfo.allBtns)} errors=${JSON.stringify(debugInfo.errors)}`); + return { heading, buttons, errors }; + }, MODAL).catch(() => ({ heading: '', buttons: [], errors: [] })); + console.log(` [step ${step}] progress=${progress} heading="${debugInfo.heading}" buttons=${JSON.stringify(debugInfo.buttons)}${debugInfo.errors.length ? ' errors=' + JSON.stringify(debugInfo.errors) : ''}`); + // Fill form fields const unknowns = await formFiller.fill(page, formFiller.profile.resume_path); - if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label))}`); + if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label || u))}`); if (unknowns[0]?.honeypot) { - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); + await dismissModal(page, MODAL); return { status: 'skipped_honeypot', meta }; } if (unknowns.length > 0) { - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); + await dismissModal(page, MODAL); return { status: 'needs_answer', pending_question: unknowns[0], meta }; } await page.waitForTimeout(MODAL_STEP_WAIT); - const hasSubmit = await page.$(LINKEDIN_SUBMIT_SELECTOR); - if (hasSubmit) { + // --- Button check order: Next → Review → Submit --- + // Check Next first — only fall through to Submit when there's no forward navigation. + // This prevents accidentally clicking a Submit-like element on early modal steps. + + // Check for Next button + const nextBtn = await findModalButton(page, MODAL, { + ariaLabels: ['Continue to next step'], + exactTexts: ['Next'], + }); + if (nextBtn) { + console.log(` [step ${step}] clicking Next`); + await nextBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); + await page.waitForTimeout(CLICK_WAIT); + lastProgress = progress; + continue; + } + + // Check for Review button + const reviewBtn = await findModalButton(page, MODAL, { + ariaLabels: ['Review your application'], + exactTexts: ['Review'], + }); + if (reviewBtn) { + console.log(` [step ${step}] clicking Review`); + await reviewBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); + await page.waitForTimeout(CLICK_WAIT); + lastProgress = progress; + continue; + } + + // Check for Submit button (only when no Next/Review exists) + const submitBtn = await findModalButton(page, MODAL, { + ariaLabels: ['Submit application'], + exactTexts: ['Submit application'], + }); + if (submitBtn) { console.log(` [step ${step}] clicking Submit`); - await page.click(LINKEDIN_SUBMIT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }); + await submitBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); await page.waitForTimeout(SUBMIT_WAIT); return { status: 'submitted', meta }; } - if (progress === lastProgress && step > 2) { + // Stuck detection — progress hasn't changed and we've been through a few steps + if (progress && progress === lastProgress && step > 2) { console.log(` [step ${step}] stuck — progress unchanged at ${progress}`); - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); + await dismissModal(page, MODAL); return { status: 'stuck', meta }; } - const hasNext = await page.$(LINKEDIN_NEXT_SELECTOR); - if (hasNext) { - console.log(` [step ${step}] clicking Next`); - await page.click(LINKEDIN_NEXT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); - await page.waitForTimeout(CLICK_WAIT); - lastProgress = progress; - continue; - } - - const hasReview = await page.$(LINKEDIN_REVIEW_SELECTOR); - if (hasReview) { - console.log(` [step ${step}] clicking Review`); - await page.click(LINKEDIN_REVIEW_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); - await page.waitForTimeout(CLICK_WAIT); - lastProgress = progress; - continue; - } - console.log(` [step ${step}] no Next/Review/Submit found — breaking`); break; } - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); + await dismissModal(page, MODAL); return { status: 'incomplete', meta }; } + +/** + * Dismiss the Easy Apply modal. + * Tries multiple strategies: Dismiss button → Close/X → Escape key. + * Handles the "Discard" confirmation dialog that appears after Escape. + */ +async function dismissModal(page, modalSelector) { + // Try aria-label Dismiss + const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`); + if (dismissBtn) { + await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {}); + return; + } + + // Try close/X button + const closeBtn = await page.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`); + if (closeBtn) { + await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {}); + return; + } + + // Fallback: Escape key + await page.keyboard.press('Escape').catch(() => {}); + + // Handle "Discard" confirmation dialog that may appear after Escape + const discardBtn = await page.waitForSelector( + 'button[data-test-dialog-primary-btn]', + { timeout: DISMISS_TIMEOUT, state: 'visible' } + ).catch(() => null); + if (discardBtn) { + await discardBtn.click().catch(() => {}); + return; + } + + // Last resort: find Discard by exact text + const handle = await page.evaluateHandle(() => { + for (const b of document.querySelectorAll('button')) { + if ((b.innerText || '').trim().toLowerCase() === 'discard') return b; + } + return null; + }).catch(() => null); + const el = handle?.asElement(); + if (el) await el.click().catch(() => {}); + else await handle?.dispose().catch(() => {}); +} diff --git a/lib/constants.mjs b/lib/constants.mjs index 4b66010..2156000 100644 --- a/lib/constants.mjs +++ b/lib/constants.mjs @@ -25,10 +25,6 @@ export const APPLY_BETWEEN_DELAY_JITTER = 1000; export const LINKEDIN_BASE = 'https://www.linkedin.com'; export const LINKEDIN_EASY_APPLY_MODAL_SELECTOR = '[role="dialog"]'; export const LINKEDIN_APPLY_BUTTON_SELECTOR = '[aria-label*="Easy Apply"]'; -export const LINKEDIN_SUBMIT_SELECTOR = 'button[aria-label="Submit application"]'; -export const LINKEDIN_NEXT_SELECTOR = 'button[aria-label="Continue to next step"]'; -export const LINKEDIN_REVIEW_SELECTOR = 'button[aria-label="Review your application"]'; -export const LINKEDIN_DISMISS_SELECTOR = 'button[aria-label="Dismiss"]'; export const LINKEDIN_MAX_MODAL_STEPS = 20; // --- Wellfound --- @@ -48,6 +44,10 @@ export const SESSION_REFRESH_POLL_TIMEOUT = 30000; export const SESSION_REFRESH_POLL_WAIT = 2000; export const SESSION_LOGIN_VERIFY_WAIT = 3000; +// --- Form Filler --- +export const AUTOCOMPLETE_WAIT = 800; +export const AUTOCOMPLETE_TIMEOUT = 2000; + // --- Form Filler Defaults --- export const DEFAULT_YEARS_EXPERIENCE = 7; export const DEFAULT_DESIRED_SALARY = 150000; diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs index 669b32c..c82ff77 100644 --- a/lib/form_filler.mjs +++ b/lib/form_filler.mjs @@ -6,7 +6,8 @@ import { DEFAULT_YEARS_EXPERIENCE, DEFAULT_DESIRED_SALARY, MINIMUM_SALARY_FACTOR, DEFAULT_SKILL_RATING, - LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH + LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH, + AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT } from './constants.mjs'; export class FormFiller { @@ -45,10 +46,10 @@ export class FormFiller { if (l.includes('email')) return p.email || null; if (l.includes('phone') || l.includes('mobile')) return p.phone || null; if (l.includes('city') && !l.includes('remote')) return p.location?.city || null; - if (l.includes('state') && !l.includes('statement')) return p.location?.state || null; if (l.includes('zip') || l.includes('postal')) return p.location?.zip || null; - if (l.includes('country') && !l.includes('code')) return p.location?.country || null; if (l.includes('country code') || l.includes('phone country')) return 'United States (+1)'; + if (l.includes('country')) return p.location?.country || null; + if (l.includes('state') && !l.includes('statement')) return p.location?.state || null; if (l.includes('linkedin')) return p.linkedin_url || null; if (l.includes('website') || l.includes('portfolio')) return p.linkedin_url || null; if (l.includes('currently located') || l.includes('current location') || l.includes('where are you')) { @@ -122,21 +123,39 @@ export class FormFiller { }).catch(() => ''); } + /** + * Select the first option from an autocomplete dropdown. + * Waits for the dropdown to appear, then clicks the first option. + * Scoped to the input's nearest container to avoid clicking wrong dropdowns. + */ + async selectAutocomplete(page, inp) { + // Wait for dropdown to appear near the input + const option = await page.waitForSelector( + '[role="option"], [role="listbox"] li, ul[class*="autocomplete"] li', + { timeout: AUTOCOMPLETE_TIMEOUT, state: 'visible' } + ).catch(() => null); + if (option) { + await option.click().catch(() => {}); + await page.waitForTimeout(AUTOCOMPLETE_WAIT); + } + } + // Fill all fields in a container (page or modal element) // Returns array of unknown required field labels async fill(page, resumePath) { const unknown = []; - const modal = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page; + // Scope to modal if present, otherwise use page + const container = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page; // Resume upload — only if no existing resume selected - const hasResumeSelected = await page.$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]').catch(() => null); + const hasResumeSelected = await container.$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]').catch(() => null); if (!hasResumeSelected && resumePath) { - const fileInput = await page.$('input[type="file"]'); + const fileInput = await container.$('input[type="file"]'); if (fileInput) await fileInput.setInputFiles(resumePath).catch(() => {}); } // Phone — always overwrite (LinkedIn pre-fills wrong number) - for (const inp of await page.$$('input[type="text"], input[type="tel"]')) { + for (const inp of await container.$$('input[type="text"], input[type="tel"]')) { if (!await inp.isVisible().catch(() => false)) continue; const lbl = await this.getLabel(inp); if (lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) { @@ -146,7 +165,7 @@ export class FormFiller { } // Text / number / url / email inputs - for (const inp of await page.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"]')) { + for (const inp of await container.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"]')) { if (!await inp.isVisible().catch(() => false)) continue; const lbl = await this.getLabel(inp); if (!lbl || lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) continue; @@ -157,12 +176,9 @@ export class FormFiller { if (answer && answer !== this.profile.cover_letter) { await inp.fill(String(answer)).catch(() => {}); // Handle city/location autocomplete dropdowns - if (lbl.toLowerCase().includes('city') || lbl.toLowerCase().includes('location') || lbl.toLowerCase().includes('located')) { - await page.waitForTimeout(800); - const dropdown = await page.$('[role="listbox"], [role="option"], ul[class*="autocomplete"] li').catch(() => null); - if (dropdown) { - await page.click('[role="option"]:first-child, [role="listbox"] li:first-child').catch(() => {}); - } + const ll = lbl.toLowerCase(); + if (ll.includes('city') || ll.includes('location') || ll.includes('located')) { + await this.selectAutocomplete(page, inp); } } else if (!answer) { const required = await inp.getAttribute('required').catch(() => null); @@ -171,7 +187,7 @@ export class FormFiller { } // Textareas - for (const ta of await page.$$('textarea')) { + for (const ta of await container.$$('textarea')) { if (!await ta.isVisible().catch(() => false)) continue; const lbl = await this.getLabel(ta); const existing = await ta.inputValue().catch(() => ''); @@ -186,22 +202,29 @@ export class FormFiller { } // Fieldsets (Yes/No radios) - for (const fs of await page.$$('fieldset')) { + for (const fs of await container.$$('fieldset')) { const leg = await fs.$eval('legend', el => el.textContent.trim()).catch(() => ''); if (!leg) continue; const anyChecked = await fs.$('input:checked'); if (anyChecked) continue; const answer = this.answerFor(leg); if (answer) { - const lbl = fs.locator(`label:has-text("${answer}")`).first(); - if (await lbl.count() > 0) await lbl.click().catch(() => {}); + // Find label within this fieldset that matches the answer text + const labels = await fs.$$('label'); + for (const lbl of labels) { + const text = await lbl.textContent().catch(() => ''); + if (text.trim().toLowerCase() === answer.toLowerCase()) { + await lbl.click().catch(() => {}); + break; + } + } } else { unknown.push(leg); } } // Selects - for (const sel of await page.$$('select')) { + for (const sel of await container.$$('select')) { if (!await sel.isVisible().catch(() => false)) continue; const lbl = await this.getLabel(sel); const existing = await sel.inputValue().catch(() => ''); @@ -213,34 +236,31 @@ export class FormFiller { }); } else { // EEO/voluntary fields — default to "Prefer not to disclose" - const l = lbl.toLowerCase(); - if (l.includes('race') || l.includes('ethnicity') || l.includes('gender') || - l.includes('veteran') || l.includes('disability') || l.includes('identification')) { - await sel.selectOption({ label: /prefer not|decline|do not wish|i don/i }).catch(async () => { - const opts = await sel.$$('option'); - for (const opt of opts) { - const text = await opt.textContent(); - if (/prefer not|decline|do not wish|i don/i.test(text || '')) { - await sel.selectOption({ label: text.trim() }).catch(() => {}); - break; - } + const ll = lbl.toLowerCase(); + if (ll.includes('race') || ll.includes('ethnicity') || ll.includes('gender') || + ll.includes('veteran') || ll.includes('disability') || ll.includes('identification')) { + // Iterate options to find a "prefer not" variant — selectOption doesn't accept regex + const opts = await sel.$$('option'); + for (const opt of opts) { + const text = await opt.textContent().catch(() => ''); + if (/prefer not|decline|do not wish|i don/i.test(text || '')) { + await sel.selectOption({ label: text.trim() }).catch(() => {}); + break; } - }); + } } } } // Checkboxes — "mark as top choice" and similar opt-ins - for (const cb of await page.$$('input[type="checkbox"]')) { + for (const cb of await container.$$('input[type="checkbox"]')) { if (!await cb.isVisible().catch(() => false)) continue; if (await cb.isChecked().catch(() => false)) continue; const lbl = await this.getLabel(cb); - const l = lbl.toLowerCase(); - // Check opt-in boxes (top choice, interested, confirm) - if (l.includes('top choice') || l.includes('interested') || l.includes('confirm') || l.includes('agree')) { + const ll = lbl.toLowerCase(); + if (ll.includes('top choice') || ll.includes('interested') || ll.includes('confirm') || ll.includes('agree')) { await cb.check().catch(() => {}); } - // Skip EEO/legal checkboxes — leave unchecked unless they are required confirms } return unknown; diff --git a/lib/keywords.mjs b/lib/keywords.mjs index c186d1e..4a95068 100644 --- a/lib/keywords.mjs +++ b/lib/keywords.mjs @@ -48,7 +48,7 @@ Example format: ["query one", "query two", "query three"]`; 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ - model: 'claude-haiku-4-5-20251001', + model: 'claude-sonnet-4-6-20251101', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] })