Files
claw-apply/lib/form_filler.mjs
Matthew Jackson 84a98e7839 Treat number fields as required even if DOM doesn't mark them
LinkedIn validates number fields even when not marked required in the
DOM. Previously these were skipped (no AI call, no answer). Now number
fields always trigger AI fallback and are reported as unknown if empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:43:39 -08:00

770 lines
31 KiB
JavaScript

/**
* form_filler.mjs — Generic form filling
* Config-driven: answers loaded from answers.json
* Returns list of unknown required fields
*
* Performance: uses a single evaluate() to snapshot all form state from the DOM,
* does answer matching locally in Node, then only makes CDP calls to fill/click.
*/
import { writeFileSync, renameSync } from 'fs';
import {
DEFAULT_YEARS_EXPERIENCE, DEFAULT_DESIRED_SALARY,
MINIMUM_SALARY_FACTOR, DEFAULT_SKILL_RATING,
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH,
AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT, ANTHROPIC_API_URL
} from './constants.mjs';
/**
* Normalize answers from either format:
* Object: { "question": "answer" } -> [{ pattern: "question", answer: "answer" }]
* Array: [{ pattern, answer }] -> as-is
*/
function normalizeAnswers(answers) {
if (!answers) return [];
if (Array.isArray(answers)) return answers;
if (typeof answers === 'object') {
return Object.entries(answers).map(([pattern, answer]) => ({ pattern, answer: String(answer) }));
}
return [];
}
/**
* Extract label text from a DOM node. Runs inside evaluate().
* Checks: label[for], aria-label, aria-labelledby, ancestor label, placeholder, name.
*/
function extractLabel(node) {
const id = node.id;
const forLabel = id ? document.querySelector(`label[for="${id}"]`)?.textContent?.trim() : '';
const ariaLabel = node.getAttribute('aria-label') || '';
const ariaLabelledBy = node.getAttribute('aria-labelledby');
const linked = ariaLabelledBy ? document.getElementById(ariaLabelledBy)?.textContent?.trim() : '';
let ancestorLabel = '';
if (!forLabel && !ariaLabel && !linked) {
let parent = node.parentElement;
for (let i = 0; i < 5 && parent; i++) {
const lbl = parent.querySelector('label');
if (lbl) {
ancestorLabel = lbl.textContent?.trim() || '';
break;
}
parent = parent.parentElement;
}
}
let raw = forLabel || ariaLabel || linked || ancestorLabel || node.placeholder || node.name || '';
raw = raw.replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').replace(/\s*Required\s*$/i, '').trim();
// Deduplicate repeated label text (LinkedIn renders label text twice)
if (raw.length > 8) {
const half = Math.ceil(raw.length / 2);
const firstHalf = raw.slice(0, half).trim();
const secondHalf = raw.slice(half).trim();
if (firstHalf === secondHalf) {
raw = firstHalf;
} else {
// Also check with space separator: "ABC ABC" -> "ABC"
const spaceIdx = raw.indexOf(' ', Math.floor(raw.length / 2) - 2);
if (spaceIdx > 0) {
const left = raw.slice(0, spaceIdx).trim();
const right = raw.slice(spaceIdx).trim();
if (left === right) raw = left;
}
}
}
return raw;
}
/**
* Check if a node is required. Runs inside evaluate().
*/
function checkRequired(node) {
if (node.required || node.getAttribute('required') !== null) return true;
if (node.getAttribute('aria-required') === 'true') return true;
const id = node.id;
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label && label.textContent.includes('*')) return true;
}
let parent = node.parentElement;
for (let i = 0; i < 5 && parent; i++) {
const lbl = parent.querySelector('label');
if (lbl && lbl.textContent.includes('*')) return true;
const reqSpan = parent.querySelector('[class*="required"], .artdeco-text-input--required');
if (reqSpan) return true;
parent = parent.parentElement;
}
return false;
}
/**
* Normalize a fieldset legend, same logic as extractLabel dedup.
*/
function normalizeLegend(el) {
let raw = (el.textContent || '').replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').replace(/\s*Required\s*$/i, '').trim();
if (raw.length > 8) {
const half = Math.ceil(raw.length / 2);
const firstHalf = raw.slice(0, half).trim();
const secondHalf = raw.slice(half).trim();
if (firstHalf === secondHalf) {
raw = firstHalf;
} else {
const spaceIdx = raw.indexOf(' ', Math.floor(raw.length / 2) - 2);
if (spaceIdx > 0) {
const left = raw.slice(0, spaceIdx).trim();
const right = raw.slice(spaceIdx).trim();
if (left === right) raw = left;
}
}
}
return raw;
}
export class FormFiller {
constructor(profile, answers, opts = {}) {
this.profile = profile;
this.answers = normalizeAnswers(answers); // [{ pattern, answer }]
this.apiKey = opts.apiKey || null;
this.answersPath = opts.answersPath || null;
this.jobContext = opts.jobContext || {};
}
saveAnswer(pattern, answer) {
if (!pattern || !answer) return;
const existing = this.answers.findIndex(a => a.pattern === pattern);
if (existing >= 0) return;
this.answers.push({ pattern, answer });
if (this.answersPath) {
try {
const tmp = this.answersPath + '.tmp';
writeFileSync(tmp, JSON.stringify(this.answers, null, 2));
renameSync(tmp, this.answersPath);
} catch { /* best effort */ }
}
}
answerFor(label) {
if (!label) return null;
const l = label.toLowerCase();
// Check custom answers first
for (const entry of this.answers) {
try {
if (entry.pattern.length > FORM_PATTERN_MAX_LENGTH) throw new Error('pattern too long');
const re = new RegExp(entry.pattern, 'i');
if (re.test(l)) return String(entry.answer);
} catch {
if (l.includes(entry.pattern.toLowerCase())) return String(entry.answer);
}
}
const p = this.profile;
// Contact
if (l.includes('first name') && !l.includes('last')) return p.name?.first || null;
if (l.includes('last name')) return p.name?.last || null;
if (l.includes('full name') || l === 'name') {
const first = p.name?.first;
const last = p.name?.last;
return (first && last) ? `${first} ${last}` : null;
}
if (l.includes('email')) return p.email || null;
if (l.includes('phone') || l.includes('mobile')) return p.phone || null;
if (l.includes('street') || (l.includes('address') && !l.includes('email'))) return p.location?.address || '';
if (l.includes('city') && !l.includes('remote')) return p.location?.city || null;
if (l.includes('zip') || l.includes('postal')) return p.location?.zip || 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')) {
return `${p.location?.city || ''}, ${p.location?.state || ''}`.trim().replace(/^,\s*|,\s*$/, '');
}
if (l.includes('hear about') || l.includes('how did you find') || l.includes('how did you hear')) return 'LinkedIn';
// Work auth
if (l.includes('sponsor') || l.includes('visa')) return p.work_authorization?.requires_sponsorship ? 'Yes' : 'No';
if (l.includes('relocat')) return p.willing_to_relocate ? 'Yes' : 'No';
if (l.includes('authoriz') || l.includes('eligible') || l.includes('legally') || l.includes('work in the u') || l.includes('right to work')) {
return p.work_authorization?.authorized ? 'Yes' : 'No';
}
if (l.includes('remote') && (l.includes('willing') || l.includes('comfortable') || l.includes('able to'))) return 'Yes';
// Experience
if (l.includes('year') && (l.includes('experienc') || l.includes('exp') || l.includes('work'))) {
if (l.includes('enterprise') || l.includes('b2b')) return '5';
if (l.includes('crm') || l.includes('salesforce') || l.includes('hubspot') || l.includes('database')) return '7';
if (l.includes('cold') || l.includes('outbound') || l.includes('prospecting')) return '5';
if (l.includes('sales') || l.includes('revenue') || l.includes('quota') || l.includes('account')) return '7';
if (l.includes('saas') || l.includes('software') || l.includes('tech')) return '7';
if (l.includes('manag') || l.includes('leadership')) return '3';
return String(p.years_experience || DEFAULT_YEARS_EXPERIENCE);
}
// 1-10 scale
if (l.includes('1 - 10') || l.includes('1-10') || l.includes('scale of 1') || l.includes('rate your')) {
if (l.includes('cold') || l.includes('outbound') || l.includes('prospecting')) return '9';
if (l.includes('sales') || l.includes('selling') || l.includes('revenue') || l.includes('gtm')) return '9';
if (l.includes('enterprise') || l.includes('b2b')) return '9';
if (l.includes('technical') || l.includes('engineering')) return '7';
if (l.includes('crm') || l.includes('salesforce')) return '8';
return DEFAULT_SKILL_RATING;
}
// Compensation
if (l.includes('salary') || l.includes('compensation') || l.includes('expected pay')) return String(p.desired_salary || '');
if (l.includes('minimum') && l.includes('salary')) return String(Math.round((p.desired_salary || DEFAULT_DESIRED_SALARY) * MINIMUM_SALARY_FACTOR));
// Dates
if (l.includes('start date') || l.includes('when can you start') || l.includes('available to start')) return 'Immediately';
if (l.includes('notice period')) return '2 weeks';
// Education
if (l.includes('degree') || l.includes('bachelor')) return 'No';
// Cover letter
if (l.includes('cover letter') || l.includes('additional info') || l.includes('tell us') ||
l.includes('why do you') || l.includes('about yourself') || l.includes('message to')) {
return p.cover_letter || '';
}
return null;
}
isHoneypot(label) {
const l = (label || '').toLowerCase();
return l.includes('digit code') || l.includes('secret word') || l.includes('not apply on linkedin') ||
l.includes('best way to apply') || l.includes('hidden code') || l.includes('passcode');
}
// Keep these for external callers (test scripts etc)
async getLabel(el) {
return await el.evaluate(extractLabel).catch(() => '');
}
async isRequired(el) {
return await el.evaluate(checkRequired).catch(() => false);
}
async aiAnswerFor(label, opts = {}) {
if (!this.apiKey) return null;
const savedAnswers = this.answers.map(a => `Q: "${a.pattern}" -> A: "${a.answer}"`).join('\n');
const optionsHint = opts.options?.length ? `\nAvailable options: ${opts.options.join(', ')}` : '';
const systemPrompt = `You are helping a job candidate fill out application forms. You have access to their profile and previously answered questions.
Rules:
- If this question is a variation of a previously answered question, return the SAME answer
- For yes/no or multiple choice, return ONLY the exact option text
- For short-answer fields, be brief and direct (1 line)
- Use first person
- NEVER guess or fabricate factual information (addresses, numbers, dates, credentials, etc.) — if you don't know, return exactly: UNKNOWN
- Just the answer text — no preamble, no explanation, no quotes`;
const userPrompt = `Candidate: ${this.profile.name?.first} ${this.profile.name?.last}
Location: ${this.profile.location?.city}, ${this.profile.location?.state}
Years experience: ${this.profile.years_experience || 7}
Applying for: ${this.jobContext.title || 'a role'} at ${this.jobContext.company || 'a company'}
Previously answered questions:
${savedAnswers || '(none yet)'}
New question: "${label}"${optionsHint}
Answer:`;
try {
const res = await fetch(ANTHROPIC_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 256,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!res.ok) return null;
const data = await res.json();
const answer = data.content?.[0]?.text?.trim() || null;
if (answer === 'UNKNOWN') {
console.log(` [AI] "${label}" -> UNKNOWN (skipping)`);
return null;
}
if (answer) console.log(` [AI] "${label}" -> "${answer}"`);
return answer;
} catch {
return null;
}
}
async selectOptionFuzzy(sel, answer) {
const exactWorked = await sel.selectOption({ label: answer }).then(() => true).catch(() => false);
if (exactWorked) return;
// Batch-read all option texts in one evaluate instead of per-element textContent() calls
const target = answer.trim().toLowerCase();
const optTexts = await sel.evaluate(el =>
Array.from(el.options).map(o => o.textContent?.trim() || '')
).catch(() => []);
const exact = optTexts.find(t => t.toLowerCase() === target);
if (exact) { await sel.selectOption({ label: exact }).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(() => {});
}
async selectAutocomplete(page, container) {
const selectors = '[role="option"], [role="listbox"] li, ul[class*="autocomplete"] li';
const option = await container.waitForSelector(selectors, {
timeout: AUTOCOMPLETE_TIMEOUT, state: 'visible',
}).catch(() => {
return page.waitForSelector(selectors, {
timeout: AUTOCOMPLETE_TIMEOUT, state: 'visible',
}).catch(() => null);
});
if (option) {
await option.click().catch(() => {});
await page.waitForTimeout(AUTOCOMPLETE_WAIT);
}
}
/**
* Snapshot all form fields from the DOM in a single evaluate() call.
* Returns a plain JSON object describing every field, avoiding per-element CDP round-trips.
*/
async _snapshotFields(container) {
return await container.evaluate((rootOrUndefined) => {
// When container is an ElementHandle, root is the element.
// When container is a Page, root is undefined — use document.
const root = rootOrUndefined || document;
function _extractLabel(node) {
const id = node.id;
const forLabel = id ? document.querySelector('label[for="' + id + '"]')?.textContent?.trim() : '';
const ariaLabel = node.getAttribute('aria-label') || '';
const ariaLabelledBy = node.getAttribute('aria-labelledby');
const linked = ariaLabelledBy ? document.getElementById(ariaLabelledBy)?.textContent?.trim() : '';
let ancestorLabel = '';
if (!forLabel && !ariaLabel && !linked) {
let parent = node.parentElement;
for (let i = 0; i < 5 && parent; i++) {
const lbl = parent.querySelector('label');
if (lbl) {
ancestorLabel = lbl.textContent?.trim() || '';
break;
}
parent = parent.parentElement;
}
}
let raw = forLabel || ariaLabel || linked || ancestorLabel || node.placeholder || node.name || '';
raw = raw.replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').replace(/\s*Required\s*$/i, '').trim();
if (raw.length > 8) {
const half = Math.ceil(raw.length / 2);
const firstHalf = raw.slice(0, half).trim();
const secondHalf = raw.slice(half).trim();
if (firstHalf === secondHalf) {
raw = firstHalf;
} else {
const spaceIdx = raw.indexOf(' ', Math.floor(raw.length / 2) - 2);
if (spaceIdx > 0) {
const left = raw.slice(0, spaceIdx).trim();
const right = raw.slice(spaceIdx).trim();
if (left === right) raw = left;
}
}
}
return raw;
}
function _checkRequired(node) {
if (node.required || node.getAttribute('required') !== null) return true;
if (node.getAttribute('aria-required') === 'true') return true;
const id = node.id;
if (id) {
const label = document.querySelector('label[for="' + id + '"]');
if (label && label.textContent.includes('*')) return true;
}
let parent = node.parentElement;
for (let i = 0; i < 5 && parent; i++) {
const lbl = parent.querySelector('label');
if (lbl && lbl.textContent.includes('*')) return true;
const reqSpan = parent.querySelector('[class*="required"], .artdeco-text-input--required');
if (reqSpan) return true;
parent = parent.parentElement;
}
return false;
}
function _normalizeLegend(el) {
let raw = (el.textContent || '').replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').replace(/\s*Required\s*$/i, '').trim();
if (raw.length > 8) {
const half = Math.ceil(raw.length / 2);
const firstHalf = raw.slice(0, half).trim();
const secondHalf = raw.slice(half).trim();
if (firstHalf === secondHalf) {
raw = firstHalf;
} else {
const spaceIdx = raw.indexOf(' ', Math.floor(raw.length / 2) - 2);
if (spaceIdx > 0) {
const left = raw.slice(0, spaceIdx).trim();
const right = raw.slice(spaceIdx).trim();
if (left === right) raw = left;
}
}
}
return raw;
}
function isVisible(el) {
if (!el.offsetParent && el.style.position !== 'fixed') return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
const result = {
resumeRadios: [],
hasFileInput: false,
inputs: [],
textareas: [],
fieldsets: [],
selects: [],
checkboxes: [],
};
// Resume radios
const resumeInputs = root.querySelectorAll('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]');
let resumeChecked = false;
resumeInputs.forEach((r, i) => {
if (r.checked) resumeChecked = true;
result.resumeRadios.push({ index: i, checked: r.checked });
});
result.resumeChecked = resumeChecked;
// 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) => {
if (!isVisible(inp)) return;
const tag = 'inp-' + (idx++);
inp.setAttribute('data-claw-idx', tag);
result.inputs.push({ tag, label: _extractLabel(inp), value: inp.value || '', placeholder: inp.placeholder || '', required: _checkRequired(inp), type: inp.type });
});
// Textareas
const taEls = root.querySelectorAll('textarea');
taEls.forEach((ta) => {
if (!isVisible(ta)) return;
const tag = 'ta-' + (idx++);
ta.setAttribute('data-claw-idx', tag);
result.textareas.push({ tag, label: _extractLabel(ta), value: ta.value || '', placeholder: ta.placeholder || '', required: _checkRequired(ta) });
});
// Fieldsets
const fsEls = root.querySelectorAll('fieldset');
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"]');
let anyChecked = false;
radios.forEach(r => { if (r.checked) anyChecked = true; });
checkboxes.forEach(c => { if (c.checked) anyChecked = true; });
const options = [];
fs.querySelectorAll('label').forEach(lbl => {
const t = (lbl.textContent || '').trim();
if (t) options.push(t);
});
const selectInFs = fs.querySelector('select');
const selectOptions = [];
if (selectInFs) {
selectInFs.querySelectorAll('option').forEach(opt => {
const t = (opt.textContent || '').trim();
if (t && !/^select/i.test(t)) selectOptions.push(t);
});
}
result.fieldsets.push({
tag, legend: leg, isCheckboxGroup,
anyChecked, options, hasSelect: !!selectInFs, selectOptions,
});
});
// Selects (standalone)
const selEls = root.querySelectorAll('select');
selEls.forEach((sel) => {
if (!isVisible(sel)) return;
const inFieldset = !!sel.closest('fieldset')?.querySelector('legend');
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) => {
if (!isVisible(cb)) return;
if (cb.closest('fieldset')?.querySelector('legend')) return;
const tag = 'cb-' + (idx++);
cb.setAttribute('data-claw-idx', tag);
result.checkboxes.push({ tag, label: _extractLabel(cb), checked: cb.checked });
});
return result;
}).catch(() => null);
}
/**
* Fill all fields in a container (page or modal element).
* Uses _snapshotFields() to batch-read all DOM state in one call,
* then only makes individual CDP calls for elements that need action.
* Returns array of unknown required field labels.
*/
async fill(page, resumePath) {
const unknown = [];
const container = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page;
// Single DOM snapshot — all labels, values, visibility, required status
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"]');
if (radios[0]) await radios[0].click().catch(() => {});
} else if (snap.resumeRadios.length === 0 && snap.hasFileInput && resumePath) {
const fileInput = await container.$('input[type="file"]');
if (fileInput) await fileInput.setInputFiles(resumePath).catch(() => {});
}
// --- Inputs (text/number/url/email/tel) ---
for (const field of snap.inputs) {
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;
if (this.isHoneypot(lbl)) return [{ label: lbl, honeypot: true }];
// Date fields — detect by placeholder format (e.g. "MM/DD/YYYY")
const ph = (field.placeholder || '').toUpperCase();
if (ph.includes('MM') && ph.includes('DD') && ph.includes('YYYY')) {
const now = new Date();
const dateStr = `${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}/${now.getFullYear()}`;
const el = await byTag(field.tag);
if (el) await el.fill(dateStr).catch(() => {});
continue;
}
let formatHint = field.placeholder ? `(format: ${field.placeholder})` : '';
if (field.type === 'number') formatHint = '(must be a number, no text or units)';
let answer = this.answerFor(lbl);
// For number fields, always try AI if no answer — LinkedIn validates them even if not marked required
const needsAnswer = field.required || field.type === 'number';
if (!answer && needsAnswer) {
answer = await this.aiAnswerFor(formatHint ? `${lbl} ${formatHint}` : lbl);
if (answer) this.saveAnswer(lbl, answer);
}
// For number fields, extract just the numeric value
if (answer && field.type === 'number') {
const num = String(answer).replace(/[^\d.]/g, '');
if (num) answer = num;
}
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);
}
} else if (needsAnswer) {
unknown.push(lbl);
}
}
// --- Textareas ---
for (const field of snap.textareas) {
if (field.value?.trim()) continue;
const taFormatHint = field.placeholder ? `(format: ${field.placeholder})` : '';
let answer = this.answerFor(field.label);
if (!answer && field.required) {
answer = await this.aiAnswerFor(taFormatHint ? `${field.label} ${taFormatHint}` : field.label);
if (answer) this.saveAnswer(field.label, answer);
}
if (answer) {
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) ---
for (const field of snap.fieldsets) {
if (!field.isCheckboxGroup && field.anyChecked) continue;
let answer = this.answerFor(field.legend);
if (answer && field.options.length > 0) {
const ansLower = answer.toLowerCase();
const matches = field.options.some(o =>
o.toLowerCase() === ansLower || o.toLowerCase().includes(ansLower) || ansLower.includes(o.toLowerCase())
);
if (!matches) answer = null;
}
if (!answer) {
answer = await this.aiAnswerFor(field.legend, { options: field.options });
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());
for (const lbl of labels) {
const text = (await lbl.textContent().catch(() => '') || '').trim();
if (selections.some(s => text.toLowerCase() === s || text.toLowerCase().includes(s))) {
await lbl.click().catch(() => {});
}
}
} else {
let clicked = false;
for (const lbl of labels) {
const text = (await lbl.textContent().catch(() => '') || '').trim();
if (text.toLowerCase() === answer.toLowerCase() ||
text.toLowerCase().startsWith(answer.toLowerCase())) {
await lbl.click().catch(() => {});
clicked = true;
break;
}
}
if (clicked) {
const nowChecked = await fs.$('input:checked');
if (!nowChecked) {
const radios = await fs.$$('input[type="radio"]');
for (const radio of radios) {
const val = await radio.evaluate(el => el.value || el.nextSibling?.textContent?.trim() || '').catch(() => '');
if (val.toLowerCase() === answer.toLowerCase() ||
val.toLowerCase().startsWith(answer.toLowerCase())) {
await radio.click({ force: true }).catch(() => {});
break;
}
}
}
}
if (!clicked || !(await fs.$('input:checked'))) {
if (field.hasSelect) {
const sel = await fs.$('select');
if (sel) await this.selectOptionFuzzy(sel, answer);
}
}
}
} else {
unknown.push(field.legend);
}
}
// --- Selects (standalone) ---
for (const field of snap.selects) {
if (field.inFieldset) continue;
const existing = field.selectedText || field.value || '';
if (existing && !/^select an? /i.test(existing)) continue;
let answer = this.answerFor(field.label);
if (answer && field.options.length > 0) {
const ansLower = answer.toLowerCase();
const matches = field.options.some(o =>
o.toLowerCase() === ansLower || o.toLowerCase().includes(ansLower) || ansLower.includes(o.toLowerCase())
);
if (!matches) answer = null;
}
if (!answer) {
const ll = field.label.toLowerCase();
if (ll.includes('race') || ll.includes('ethnicity') || ll.includes('gender') ||
ll.includes('veteran') || ll.includes('disability') || ll.includes('identification')) {
const declineOpt = field.options.find(t => /prefer not|decline|do not wish|i don/i.test(t));
if (declineOpt) {
const sel = await byTag(field.tag);
if (sel) await sel.selectOption({ label: declineOpt }).catch(() => {});
}
continue;
}
if (field.required) {
answer = await this.aiAnswerFor(field.label, { options: field.options });
if (answer) {
this.saveAnswer(field.label, answer);
} else {
unknown.push({ label: field.label, type: 'select', options: field.options });
continue;
}
}
}
if (answer) {
const sel = await byTag(field.tag);
if (sel) await this.selectOptionFuzzy(sel, answer);
}
}
// --- Checkboxes (standalone) ---
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 = await byTag(field.tag);
if (el) await el.check().catch(() => {});
}
}
return unknown;
}
}