From d0a40e46544c6690994e2b1f8091525f47bcc72f Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Fri, 6 Mar 2026 14:50:47 -0800 Subject: [PATCH] Batch CDP calls: single-evaluate debug info, data-claw-idx tagging, field count logging - getModalDebugInfo: one evaluate() for heading, buttons, errors (was N+2 calls) - selectOptionFuzzy: batch-read option texts in one evaluate (was N calls) - Tag elements with data-claw-idx during snapshot, query by attribute in fill() (fixes fragile positional index matching for checkboxes/inputs) - Log field counts per fill step for debugging Co-Authored-By: Claude Opus 4.6 --- lib/apply/easy_apply.mjs | 39 +++++------ lib/form_filler.mjs | 146 +++++++++++++++++---------------------- 2 files changed, 81 insertions(+), 104 deletions(-) diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index 7fe429d..9fbc990 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -78,30 +78,27 @@ async function getModalDebugInfo(page, modalSelector) { const modal = await page.$(modalSelector); if (!modal) return { heading: '', buttons: [], errors: [] }; - const heading = await modal.$eval( - 'h1, h2, h3, [class*="title"], [class*="heading"]', - el => el.textContent?.trim()?.slice(0, 60) || '' - ).catch(() => ''); + // Single evaluate to extract all debug info at once + return await modal.evaluate((el) => { + const headingEl = el.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]'); + const heading = headingEl?.textContent?.trim()?.slice(0, 60) || ''; - const buttonEls = await modal.$$('button, [role="button"]'); - const buttons = []; - for (const b of buttonEls) { - const info = await b.evaluate(el => ({ - text: (el.innerText || el.textContent || '').trim().slice(0, 50), - aria: el.getAttribute('aria-label'), - disabled: el.disabled, - })).catch(() => null); - if (info && (info.text || info.aria)) buttons.push(info); - } + const buttons = []; + for (const b of el.querySelectorAll('button, [role="button"]')) { + const text = (b.innerText || b.textContent || '').trim().slice(0, 50); + const aria = b.getAttribute('aria-label'); + const disabled = b.disabled; + if (text || aria) buttons.push({ text, aria, disabled }); + } - const errorEls = await modal.$$('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'); - const errors = []; - for (const e of errorEls) { - const text = await e.evaluate(el => el.textContent?.trim()?.slice(0, 60) || '').catch(() => ''); - if (text) errors.push(text); - } + const errors = []; + for (const e of el.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error')) { + const text = e.textContent?.trim()?.slice(0, 60) || ''; + if (text) errors.push(text); + } - return { heading, buttons, errors }; + return { heading, buttons, errors }; + }).catch(() => ({ heading: '', buttons: [], errors: [] })); } /** diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs index 362cff9..9b33952 100644 --- a/lib/form_filler.mjs +++ b/lib/form_filler.mjs @@ -286,25 +286,17 @@ Answer:`; const exactWorked = await sel.selectOption({ label: answer }).then(() => true).catch(() => false); if (exactWorked) return; - const opts = await sel.$$('option'); + // Batch-read all option texts in one evaluate instead of per-element textContent() calls const target = answer.trim().toLowerCase(); - let substringMatch = null; + const optTexts = await sel.evaluate(el => + Array.from(el.options).map(o => o.textContent?.trim() || '') + ).catch(() => []); - for (const opt of opts) { - const text = (await opt.textContent().catch(() => '') || '').trim(); - if (text.toLowerCase() === target) { - await sel.selectOption({ label: text }).catch(() => {}); - return; - } - if (!substringMatch && text.toLowerCase().includes(target)) { - substringMatch = text; - } - } + const exact = optTexts.find(t => t.toLowerCase() === target); + if (exact) { await sel.selectOption({ label: exact }).catch(() => {}); return; } - if (substringMatch) { - await sel.selectOption({ label: substringMatch }).catch(() => {}); - return; - } + const substring = optTexts.find(t => t.toLowerCase().includes(target)); + if (substring) { await sel.selectOption({ label: substring }).catch(() => {}); return; } await sel.selectOption({ value: answer }).catch(() => {}); } @@ -426,35 +418,39 @@ Answer:`; // File input result.hasFileInput = !!root.querySelector('input[type="file"]'); + // Tag each element with data-claw-idx so we can reliably find it later + // (avoids fragile positional index matching between snapshot and querySelectorAll) + let idx = 0; + // Text / number / url / email / tel inputs const inputEls = root.querySelectorAll('input[type="text"], input[type="number"], input[type="url"], input[type="email"], input[type="tel"]'); - inputEls.forEach((inp, i) => { + inputEls.forEach((inp) => { if (!isVisible(inp)) return; - const label = _extractLabel(inp); - const value = inp.value || ''; - const required = _checkRequired(inp); - const type = inp.type; - result.inputs.push({ index: i, label, value, required, type }); + const tag = 'inp-' + (idx++); + inp.setAttribute('data-claw-idx', tag); + result.inputs.push({ tag, label: _extractLabel(inp), value: inp.value || '', required: _checkRequired(inp), type: inp.type }); }); // Textareas const taEls = root.querySelectorAll('textarea'); - taEls.forEach((ta, i) => { + taEls.forEach((ta) => { if (!isVisible(ta)) return; - const label = _extractLabel(ta); - const value = ta.value || ''; - const required = _checkRequired(ta); - result.textareas.push({ index: i, label, value, required }); + const tag = 'ta-' + (idx++); + ta.setAttribute('data-claw-idx', tag); + result.textareas.push({ tag, label: _extractLabel(ta), value: ta.value || '', required: _checkRequired(ta) }); }); // Fieldsets const fsEls = root.querySelectorAll('fieldset'); - fsEls.forEach((fs, i) => { + fsEls.forEach((fs) => { const legend = fs.querySelector('legend'); if (!legend) return; const leg = _normalizeLegend(legend); if (!leg) return; + const tag = 'fs-' + (idx++); + fs.setAttribute('data-claw-idx', tag); + const checkboxes = fs.querySelectorAll('input[type="checkbox"]'); const isCheckboxGroup = checkboxes.length > 0; const radios = fs.querySelectorAll('input[type="radio"]'); @@ -468,7 +464,6 @@ Answer:`; if (t) options.push(t); }); - // Check for a select inside the fieldset const selectInFs = fs.querySelector('select'); const selectOptions = []; if (selectInFs) { @@ -479,38 +474,36 @@ Answer:`; } result.fieldsets.push({ - index: i, legend: leg, isCheckboxGroup, + tag, legend: leg, isCheckboxGroup, anyChecked, options, hasSelect: !!selectInFs, selectOptions, }); }); - // Selects (standalone, not inside fieldsets we already handle) + // Selects (standalone) const selEls = root.querySelectorAll('select'); - selEls.forEach((sel, i) => { + selEls.forEach((sel) => { if (!isVisible(sel)) return; - const label = _extractLabel(sel); - const value = sel.value || ''; - const selectedText = sel.options[sel.selectedIndex]?.textContent?.trim() || ''; - const required = _checkRequired(sel); - const options = []; - sel.querySelectorAll('option').forEach(opt => { - const t = (opt.textContent || '').trim(); - if (t && !/^select/i.test(t)) options.push(t); - }); - // Check if inside a fieldset we're already handling const inFieldset = !!sel.closest('fieldset')?.querySelector('legend'); - result.selects.push({ index: i, label, value, selectedText, required, options, inFieldset }); + const tag = 'sel-' + (idx++); + sel.setAttribute('data-claw-idx', tag); + result.selects.push({ + tag, label: _extractLabel(sel), value: sel.value || '', + selectedText: sel.options[sel.selectedIndex]?.textContent?.trim() || '', + required: _checkRequired(sel), inFieldset, + options: Array.from(sel.querySelectorAll('option')) + .map(o => (o.textContent || '').trim()) + .filter(t => t && !/^select/i.test(t)), + }); }); // Checkboxes (standalone) const cbEls = root.querySelectorAll('input[type="checkbox"]'); - cbEls.forEach((cb, i) => { + cbEls.forEach((cb) => { if (!isVisible(cb)) return; - // Skip if inside a fieldset with a legend (handled in fieldsets section) if (cb.closest('fieldset')?.querySelector('legend')) return; - const label = _extractLabel(cb); - const checked = cb.checked; - result.checkboxes.push({ index: i, label, checked }); + const tag = 'cb-' + (idx++); + cb.setAttribute('data-claw-idx', tag); + result.checkboxes.push({ tag, label: _extractLabel(cb), checked: cb.checked }); }); return result; @@ -531,6 +524,13 @@ Answer:`; const snap = await this._snapshotFields(container); if (!snap) return unknown; + // Log field counts for debugging + const counts = [snap.inputs.length && `${snap.inputs.length} inputs`, snap.textareas.length && `${snap.textareas.length} textareas`, snap.fieldsets.length && `${snap.fieldsets.length} fieldsets`, snap.selects.length && `${snap.selects.length} selects`, snap.checkboxes.length && `${snap.checkboxes.length} checkboxes`].filter(Boolean); + if (counts.length > 0) console.log(` [fill] ${counts.join(', ')}`); + + // Helper: find element by data-claw-idx tag + const byTag = (tag) => container.$(`[data-claw-idx="${tag}"]`); + // --- Resume --- if (snap.resumeRadios.length > 0 && !snap.resumeChecked) { const radios = await container.$$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]'); @@ -541,28 +541,21 @@ Answer:`; } // --- Inputs (text/number/url/email/tel) --- - // We need element handles for filling — get them once - const inputEls = snap.inputs.length > 0 - ? await container.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"], input[type="tel"]') - : []; - - // Build a map from snapshot index to element handle (only visible ones are in snap) - // snap.inputs[i].index is the original DOM index for (const field of snap.inputs) { - const el = inputEls[field.index]; - if (!el) continue; const lbl = field.label; const ll = lbl.toLowerCase(); // Phone — always overwrite if (ll.includes('phone') || ll.includes('mobile')) { + const el = await byTag(field.tag); + if (!el) continue; await el.click({ clickCount: 3 }).catch(() => {}); await el.fill(this.profile.phone || '').catch(() => {}); continue; } if (!lbl) continue; - if (field.value?.trim()) continue; // already filled + if (field.value?.trim()) continue; if (this.isHoneypot(lbl)) return [{ label: lbl, honeypot: true }]; let answer = this.answerFor(lbl); @@ -571,6 +564,8 @@ Answer:`; if (answer) this.saveAnswer(lbl, answer); } if (answer && answer !== this.profile.cover_letter) { + const el = await byTag(field.tag); + if (!el) continue; await el.fill(String(answer)).catch(() => {}); if (ll.includes('city') || ll.includes('location') || ll.includes('located')) { await this.selectAutocomplete(page, container); @@ -581,10 +576,7 @@ Answer:`; } // --- Textareas --- - const taEls = snap.textareas.length > 0 ? await container.$$('textarea') : []; for (const field of snap.textareas) { - const el = taEls[field.index]; - if (!el) continue; if (field.value?.trim()) continue; let answer = this.answerFor(field.label); if (!answer && field.required) { @@ -592,23 +584,18 @@ Answer:`; if (answer) this.saveAnswer(field.label, answer); } if (answer) { - await el.fill(answer).catch(() => {}); + const el = await byTag(field.tag); + if (el) await el.fill(answer).catch(() => {}); } else if (field.required) { unknown.push(field.label); } } // --- Fieldsets (radios and checkbox groups) --- - const fsEls = snap.fieldsets.length > 0 ? await container.$$('fieldset') : []; for (const field of snap.fieldsets) { - const fs = fsEls[field.index]; - if (!fs) continue; - - // For radios: skip if any already checked. For checkboxes: never skip if (!field.isCheckboxGroup && field.anyChecked) continue; let answer = this.answerFor(field.legend); - // Validate answer against available options if (answer && field.options.length > 0) { const ansLower = answer.toLowerCase(); const matches = field.options.some(o => @@ -621,6 +608,8 @@ Answer:`; if (answer) this.saveAnswer(field.legend, answer); } if (answer) { + const fs = await byTag(field.tag); + if (!fs) continue; const labels = await fs.$$('label'); if (field.isCheckboxGroup) { const selections = answer.split(',').map(s => s.trim().toLowerCase()); @@ -631,7 +620,6 @@ Answer:`; } } } else { - // Single-select radio let clicked = false; for (const lbl of labels) { const text = (await lbl.textContent().catch(() => '') || '').trim(); @@ -642,7 +630,6 @@ Answer:`; break; } } - // Verify radio got checked if (clicked) { const nowChecked = await fs.$('input:checked'); if (!nowChecked) { @@ -657,7 +644,6 @@ Answer:`; } } } - // Last resort: select within fieldset if (!clicked || !(await fs.$('input:checked'))) { if (field.hasSelect) { const sel = await fs.$('select'); @@ -671,17 +657,13 @@ Answer:`; } // --- Selects (standalone) --- - const selEls = snap.selects.length > 0 ? await container.$$('select') : []; for (const field of snap.selects) { - if (field.inFieldset) continue; // handled above - const sel = selEls[field.index]; - if (!sel) continue; + if (field.inFieldset) continue; const existing = field.selectedText || field.value || ''; if (existing && !/^select an? /i.test(existing)) continue; let answer = this.answerFor(field.label); - // Validate answer against available options if (answer && field.options.length > 0) { const ansLower = answer.toLowerCase(); const matches = field.options.some(o => @@ -690,18 +672,16 @@ Answer:`; if (!matches) answer = null; } if (!answer) { - // EEO/voluntary fields const ll = field.label.toLowerCase(); if (ll.includes('race') || ll.includes('ethnicity') || ll.includes('gender') || ll.includes('veteran') || ll.includes('disability') || ll.includes('identification')) { - // Find "prefer not to disclose" option from snapshot const declineOpt = field.options.find(t => /prefer not|decline|do not wish|i don/i.test(t)); if (declineOpt) { - await sel.selectOption({ label: declineOpt }).catch(() => {}); + const sel = await byTag(field.tag); + if (sel) await sel.selectOption({ label: declineOpt }).catch(() => {}); } continue; } - // AI fallback for required selects if (field.required) { answer = await this.aiAnswerFor(field.label, { options: field.options }); if (answer) { @@ -713,17 +693,17 @@ Answer:`; } } if (answer) { - await this.selectOptionFuzzy(sel, answer); + const sel = await byTag(field.tag); + if (sel) await this.selectOptionFuzzy(sel, answer); } } // --- Checkboxes (standalone) --- - const cbEls = snap.checkboxes.length > 0 ? await container.$$('input[type="checkbox"]') : []; for (const field of snap.checkboxes) { if (field.checked) continue; const ll = field.label.toLowerCase(); if (ll.includes('confirm') || ll.includes('agree') || ll.includes('consent')) { - const el = cbEls[field.index]; + const el = await byTag(field.tag); if (el) await el.check().catch(() => {}); } }