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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:50:47 -08:00
parent a956b98941
commit d0a40e4654
2 changed files with 81 additions and 104 deletions

View File

@@ -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: [] }));
}
/**

View File

@@ -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(() => {});
}
}