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:
@@ -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: [] }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user