- 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>
714 lines
28 KiB
JavaScript
714 lines
28 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) {
|
|
for (let len = Math.ceil(raw.length / 2); len >= 4; len--) {
|
|
const candidate = raw.slice(0, len);
|
|
if (raw.startsWith(candidate + candidate)) {
|
|
raw = candidate.trim();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
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) {
|
|
for (let len = Math.ceil(raw.length / 2); len >= 4; len--) {
|
|
const candidate = raw.slice(0, len);
|
|
if (raw.startsWith(candidate + candidate)) { raw = candidate.trim(); break; }
|
|
}
|
|
}
|
|
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('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 make up facts
|
|
- 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) 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) {
|
|
for (let len = Math.ceil(raw.length / 2); len >= 4; len--) {
|
|
const candidate = raw.slice(0, len);
|
|
if (raw.startsWith(candidate + candidate)) {
|
|
raw = candidate.trim();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
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) {
|
|
for (let len = Math.ceil(raw.length / 2); len >= 4; len--) {
|
|
const candidate = raw.slice(0, len);
|
|
if (raw.startsWith(candidate + candidate)) { raw = candidate.trim(); break; }
|
|
}
|
|
}
|
|
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 || '', 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 || '', 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 }];
|
|
|
|
let answer = this.answerFor(lbl);
|
|
if (!answer && field.required) {
|
|
answer = await this.aiAnswerFor(lbl);
|
|
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);
|
|
}
|
|
} else if (field.required) {
|
|
unknown.push(lbl);
|
|
}
|
|
}
|
|
|
|
// --- Textareas ---
|
|
for (const field of snap.textareas) {
|
|
if (field.value?.trim()) continue;
|
|
let answer = this.answerFor(field.label);
|
|
if (!answer && field.required) {
|
|
answer = await this.aiAnswerFor(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;
|
|
}
|
|
}
|