Optimize form_filler: batch DOM reads into single evaluate() call
Instead of 60+ sequential CDP round-trips per step (isVisible, getLabel, inputValue, isRequired for each element), snapshot all form state in one evaluate() call, do answer matching locally, then only make CDP calls to fill/click elements that need action. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
* 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 {
|
||||
@@ -13,8 +16,8 @@ import {
|
||||
|
||||
/**
|
||||
* Normalize answers from either format:
|
||||
* Object: { "question": "answer" } → [{ pattern: "question", answer: "answer" }]
|
||||
* Array: [{ pattern, answer }] → as-is
|
||||
* Object: { "question": "answer" } -> [{ pattern: "question", answer: "answer" }]
|
||||
* Array: [{ pattern, answer }] -> as-is
|
||||
*/
|
||||
function normalizeAnswers(answers) {
|
||||
if (!answers) return [];
|
||||
@@ -25,23 +28,94 @@ function normalizeAnswers(answers) {
|
||||
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; // path to answers.json for saving
|
||||
this.jobContext = opts.jobContext || {}; // { title, company }
|
||||
this.answersPath = opts.answersPath || null;
|
||||
this.jobContext = opts.jobContext || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new answer to answers.json and in-memory cache.
|
||||
* Skips if pattern already exists.
|
||||
*/
|
||||
saveAnswer(pattern, answer) {
|
||||
if (!pattern || !answer) return;
|
||||
const existing = this.answers.findIndex(a => a.pattern === pattern);
|
||||
if (existing >= 0) return; // already saved
|
||||
if (existing >= 0) return;
|
||||
this.answers.push({ pattern, answer });
|
||||
if (this.answersPath) {
|
||||
try {
|
||||
@@ -52,12 +126,11 @@ export class FormFiller {
|
||||
}
|
||||
}
|
||||
|
||||
// Find answer for a label — checks custom answers first, then built-ins
|
||||
answerFor(label) {
|
||||
if (!label) return null;
|
||||
const l = label.toLowerCase();
|
||||
|
||||
// Check custom answers first (user-defined, pattern is substring or regex)
|
||||
// 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');
|
||||
@@ -68,7 +141,6 @@ export class FormFiller {
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in answers
|
||||
const p = this.profile;
|
||||
|
||||
// Contact
|
||||
@@ -148,85 +220,19 @@ export class FormFiller {
|
||||
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(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() : '';
|
||||
|
||||
// LinkedIn doesn't use label[for] — labels are ancestor elements.
|
||||
// Walk up the DOM to find the nearest label in a parent container.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up label text
|
||||
let raw = forLabel || ariaLabel || linked || ancestorLabel || node.placeholder || node.name || '';
|
||||
// Normalize whitespace, strip trailing *, strip "Required" suffix
|
||||
raw = raw.replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').replace(/\s*Required\s*$/i, '').trim();
|
||||
// Deduplicate repeated label text (LinkedIn renders label text twice)
|
||||
// e.g. "First sales hire?First sales hire?" → "First sales hire?"
|
||||
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;
|
||||
}).catch(() => '');
|
||||
return await el.evaluate(extractLabel).catch(() => '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form element is required.
|
||||
* LinkedIn uses multiple patterns: required attribute, aria-required, or * in label.
|
||||
*/
|
||||
async isRequired(el) {
|
||||
return await el.evaluate(node => {
|
||||
if (node.required || node.getAttribute('required') !== null) return true;
|
||||
if (node.getAttribute('aria-required') === 'true') return true;
|
||||
// Check if any associated label contains * — try label[for], then ancestor labels
|
||||
const id = node.id;
|
||||
if (id) {
|
||||
const label = document.querySelector(`label[for="${id}"]`);
|
||||
if (label && label.textContent.includes('*')) return true;
|
||||
}
|
||||
// Walk up ancestors to find a label with *
|
||||
let parent = node.parentElement;
|
||||
for (let i = 0; i < 5 && parent; i++) {
|
||||
const lbl = parent.querySelector('label');
|
||||
if (lbl && lbl.textContent.includes('*')) return true;
|
||||
// Also check for "Required" text in parent
|
||||
const reqSpan = parent.querySelector('[class*="required"], .artdeco-text-input--required');
|
||||
if (reqSpan) return true;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}).catch(() => false);
|
||||
return await el.evaluate(checkRequired).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask AI to answer an unknown question. Passes all saved answers so AI can
|
||||
* recognize variations of previously answered questions.
|
||||
* Returns the answer string, or null if AI can't help.
|
||||
*/
|
||||
async aiAnswerFor(label, opts = {}) {
|
||||
if (!this.apiKey) return null;
|
||||
|
||||
const savedAnswers = this.answers.map(a => `Q: "${a.pattern}" → A: "${a.answer}"`).join('\n');
|
||||
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.
|
||||
@@ -269,23 +275,17 @@ Answer:`;
|
||||
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}"`);
|
||||
if (answer) console.log(` [AI] "${label}" -> "${answer}"`);
|
||||
return answer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a <select> with case-insensitive, trimmed matching.
|
||||
* Tries: exact label → case-insensitive label → substring match → value match.
|
||||
*/
|
||||
async selectOptionFuzzy(sel, answer) {
|
||||
// Try exact match first (fastest path)
|
||||
const exactWorked = await sel.selectOption({ label: answer }).then(() => true).catch(() => false);
|
||||
if (exactWorked) return;
|
||||
|
||||
// Scan options for case-insensitive / trimmed match
|
||||
const opts = await sel.$$('option');
|
||||
const target = answer.trim().toLowerCase();
|
||||
let substringMatch = null;
|
||||
@@ -296,7 +296,6 @@ Answer:`;
|
||||
await sel.selectOption({ label: text }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Track first substring match as fallback (e.g. answer "Yes" matches "Yes, I am authorized")
|
||||
if (!substringMatch && text.toLowerCase().includes(target)) {
|
||||
substringMatch = text;
|
||||
}
|
||||
@@ -307,22 +306,14 @@ Answer:`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: try by value
|
||||
await sel.selectOption({ value: answer }).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the first option from an autocomplete dropdown.
|
||||
* Waits for the dropdown to appear, then clicks the first option.
|
||||
* Scoped to the input's nearest container to avoid clicking wrong dropdowns.
|
||||
*/
|
||||
async selectAutocomplete(page, container) {
|
||||
// Wait for dropdown to appear — scope to container (modal) to avoid clicking wrong dropdowns
|
||||
const selectors = '[role="option"], [role="listbox"] li, ul[class*="autocomplete"] li';
|
||||
const option = await container.waitForSelector(selectors, {
|
||||
timeout: AUTOCOMPLETE_TIMEOUT, state: 'visible',
|
||||
}).catch(() => {
|
||||
// Fallback to page-level if container doesn't support waitForSelector (e.g. ElementHandle)
|
||||
return page.waitForSelector(selectors, {
|
||||
timeout: AUTOCOMPLETE_TIMEOUT, state: 'visible',
|
||||
}).catch(() => null);
|
||||
@@ -333,85 +324,74 @@ Answer:`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill all fields in a container (page or modal element)
|
||||
// Returns array of unknown required field labels
|
||||
async fill(page, resumePath) {
|
||||
const unknown = [];
|
||||
// Scope to modal if present, otherwise use page
|
||||
const container = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page;
|
||||
/**
|
||||
* 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((extractLabelSrc, checkRequiredSrc, normalizeLegendSrc) => {
|
||||
// Reconstruct functions inside browser context
|
||||
const extractLabel = new Function('node', `
|
||||
${extractLabelSrc}
|
||||
return extractLabel(node);
|
||||
`);
|
||||
// Can't pass functions directly — inline the logic instead
|
||||
// We'll inline extractLabel and checkRequired logic directly
|
||||
|
||||
// Resume selection — LinkedIn shows radio buttons for previously uploaded resumes
|
||||
// Select the first resume radio if none is already checked
|
||||
const resumeRadios = await container.$$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]');
|
||||
if (resumeRadios.length > 0) {
|
||||
const anyChecked = await container.$('input[type="radio"][aria-label*="resume"]:checked, input[type="radio"][aria-label*="Resume"]:checked').catch(() => null);
|
||||
if (!anyChecked) {
|
||||
await resumeRadios[0].click().catch(() => {});
|
||||
}
|
||||
} else if (resumePath) {
|
||||
// No resume radios — try file upload
|
||||
const fileInput = await container.$('input[type="file"]');
|
||||
if (fileInput) await fileInput.setInputFiles(resumePath).catch(() => {});
|
||||
}
|
||||
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() : '';
|
||||
|
||||
// Phone — always overwrite (LinkedIn pre-fills wrong number)
|
||||
for (const inp of await container.$$('input[type="text"], input[type="tel"]')) {
|
||||
if (!await inp.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(inp);
|
||||
if (lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) {
|
||||
await inp.click({ clickCount: 3 }).catch(() => {});
|
||||
await inp.fill(this.profile.phone || '').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Text / number / url / email inputs
|
||||
for (const inp of await container.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"]')) {
|
||||
if (!await inp.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(inp);
|
||||
if (!lbl || lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) continue;
|
||||
const existing = await inp.inputValue().catch(() => '');
|
||||
if (existing?.trim()) continue;
|
||||
if (this.isHoneypot(lbl)) return [{ label: lbl, honeypot: true }];
|
||||
let answer = this.answerFor(lbl);
|
||||
// AI fallback for unknown required fields
|
||||
if (!answer && await this.isRequired(inp)) {
|
||||
answer = await this.aiAnswerFor(lbl);
|
||||
if (answer) this.saveAnswer(lbl, answer);
|
||||
}
|
||||
if (answer && answer !== this.profile.cover_letter) {
|
||||
await inp.fill(String(answer)).catch(() => {});
|
||||
// Handle city/location autocomplete dropdowns
|
||||
const ll = lbl.toLowerCase();
|
||||
if (ll.includes('city') || ll.includes('location') || ll.includes('located')) {
|
||||
await this.selectAutocomplete(page, container);
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No answer from profile, custom answers, or AI — check if required
|
||||
if (await this.isRequired(inp)) unknown.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
// Textareas
|
||||
for (const ta of await container.$$('textarea')) {
|
||||
if (!await ta.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(ta);
|
||||
const existing = await ta.inputValue().catch(() => '');
|
||||
if (existing?.trim()) continue;
|
||||
let answer = this.answerFor(lbl);
|
||||
if (!answer && await this.isRequired(ta)) {
|
||||
answer = await this.aiAnswerFor(lbl);
|
||||
if (answer) this.saveAnswer(lbl, answer);
|
||||
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;
|
||||
}
|
||||
if (answer) {
|
||||
await ta.fill(answer).catch(() => {});
|
||||
} else {
|
||||
if (await this.isRequired(ta)) unknown.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
// Fieldsets (radios and checkbox groups)
|
||||
for (const fs of await container.$$('fieldset')) {
|
||||
const leg = await fs.$eval('legend', el => {
|
||||
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--) {
|
||||
@@ -420,36 +400,233 @@ Answer:`;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}).catch(() => '');
|
||||
if (!leg) continue;
|
||||
// Detect if this is a checkbox group (multi-select) vs radio group
|
||||
const isCheckboxGroup = (await fs.$$('input[type="checkbox"]')).length > 0;
|
||||
// For radios: skip if any already checked. For checkboxes: never skip (multiple can be selected)
|
||||
if (!isCheckboxGroup) {
|
||||
const anyChecked = await fs.$('input:checked');
|
||||
if (anyChecked) continue;
|
||||
}
|
||||
// Collect option labels for AI context
|
||||
const labels = await fs.$$('label');
|
||||
const optionTexts = [];
|
||||
for (const lbl of labels) {
|
||||
const t = (await lbl.textContent().catch(() => '') || '').trim();
|
||||
if (t) optionTexts.push(t);
|
||||
|
||||
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';
|
||||
}
|
||||
let answer = this.answerFor(leg);
|
||||
// Validate answer against available options (e.g. got "7" but options are Yes/No)
|
||||
if (answer && optionTexts.length > 0) {
|
||||
|
||||
const result = {
|
||||
resumeRadios: [],
|
||||
hasFileInput: false,
|
||||
inputs: [],
|
||||
textareas: [],
|
||||
fieldsets: [],
|
||||
selects: [],
|
||||
checkboxes: [],
|
||||
};
|
||||
|
||||
// Resume radios
|
||||
const resumeInputs = document.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 = !!document.querySelector('input[type="file"]');
|
||||
|
||||
// Text / number / url / email / tel inputs
|
||||
const inputEls = document.querySelectorAll('input[type="text"], input[type="number"], input[type="url"], input[type="email"], input[type="tel"]');
|
||||
inputEls.forEach((inp, i) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
// Textareas
|
||||
const taEls = document.querySelectorAll('textarea');
|
||||
taEls.forEach((ta, i) => {
|
||||
if (!isVisible(ta)) return;
|
||||
const label = _extractLabel(ta);
|
||||
const value = ta.value || '';
|
||||
const required = _checkRequired(ta);
|
||||
result.textareas.push({ index: i, label, value, required });
|
||||
});
|
||||
|
||||
// Fieldsets
|
||||
const fsEls = document.querySelectorAll('fieldset');
|
||||
fsEls.forEach((fs, i) => {
|
||||
const legend = fs.querySelector('legend');
|
||||
if (!legend) return;
|
||||
const leg = _normalizeLegend(legend);
|
||||
if (!leg) return;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Check for a select inside the fieldset
|
||||
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({
|
||||
index: i, legend: leg, isCheckboxGroup,
|
||||
anyChecked, options, hasSelect: !!selectInFs, selectOptions,
|
||||
});
|
||||
});
|
||||
|
||||
// Selects (standalone, not inside fieldsets we already handle)
|
||||
const selEls = document.querySelectorAll('select');
|
||||
selEls.forEach((sel, i) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
// Checkboxes (standalone)
|
||||
const cbEls = document.querySelectorAll('input[type="checkbox"]');
|
||||
cbEls.forEach((cb, i) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// --- 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) ---
|
||||
// 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')) {
|
||||
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 (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) {
|
||||
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 ---
|
||||
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) {
|
||||
answer = await this.aiAnswerFor(field.label);
|
||||
if (answer) this.saveAnswer(field.label, answer);
|
||||
}
|
||||
if (answer) {
|
||||
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 = optionTexts.some(o => o.toLowerCase() === ansLower || o.toLowerCase().includes(ansLower) || ansLower.includes(o.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(leg, { options: optionTexts });
|
||||
if (answer) this.saveAnswer(leg, answer);
|
||||
answer = await this.aiAnswerFor(field.legend, { options: field.options });
|
||||
if (answer) this.saveAnswer(field.legend, answer);
|
||||
}
|
||||
if (answer) {
|
||||
if (isCheckboxGroup) {
|
||||
// Multi-select: split comma-separated answers and click each matching label
|
||||
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();
|
||||
@@ -458,7 +635,7 @@ Answer:`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single-select radio: click the matching label, then verify
|
||||
// Single-select radio
|
||||
let clicked = false;
|
||||
for (const lbl of labels) {
|
||||
const text = (await lbl.textContent().catch(() => '') || '').trim();
|
||||
@@ -469,7 +646,7 @@ Answer:`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Verify a radio got checked — if not, try clicking the input directly
|
||||
// Verify radio got checked
|
||||
if (clicked) {
|
||||
const nowChecked = await fs.$('input:checked');
|
||||
if (!nowChecked) {
|
||||
@@ -484,63 +661,57 @@ Answer:`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Last resort: try as a <select> within the fieldset
|
||||
// Last resort: select within fieldset
|
||||
if (!clicked || !(await fs.$('input:checked'))) {
|
||||
const sel = await fs.$('select');
|
||||
if (sel) await this.selectOptionFuzzy(sel, answer);
|
||||
if (field.hasSelect) {
|
||||
const sel = await fs.$('select');
|
||||
if (sel) await this.selectOptionFuzzy(sel, answer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unknown.push(leg);
|
||||
unknown.push(field.legend);
|
||||
}
|
||||
}
|
||||
|
||||
// Selects
|
||||
for (const sel of await container.$$('select')) {
|
||||
if (!await sel.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(sel);
|
||||
const existing = await sel.inputValue().catch(() => '');
|
||||
// "Select an option" is LinkedIn's placeholder — treat as unfilled
|
||||
// --- 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;
|
||||
|
||||
const existing = field.selectedText || field.value || '';
|
||||
if (existing && !/^select an? /i.test(existing)) continue;
|
||||
// Get available options for validation
|
||||
const availOpts = await sel.$$eval('option', els =>
|
||||
els.map(el => el.textContent?.trim()).filter(t => t && !/^select/i.test(t))
|
||||
).catch(() => []);
|
||||
let answer = this.answerFor(lbl);
|
||||
// If built-in answer doesn't match any option (e.g. got "7" but options are Yes/No), discard it
|
||||
if (answer && availOpts.length > 0) {
|
||||
|
||||
let answer = this.answerFor(field.label);
|
||||
// Validate answer against available options
|
||||
if (answer && field.options.length > 0) {
|
||||
const ansLower = answer.toLowerCase();
|
||||
const matches = availOpts.some(o => o.toLowerCase() === ansLower || o.toLowerCase().includes(ansLower) || ansLower.includes(o.toLowerCase()));
|
||||
const matches = field.options.some(o =>
|
||||
o.toLowerCase() === ansLower || o.toLowerCase().includes(ansLower) || ansLower.includes(o.toLowerCase())
|
||||
);
|
||||
if (!matches) answer = null;
|
||||
}
|
||||
if (!answer) {
|
||||
// EEO/voluntary fields — default to "Prefer not to disclose"
|
||||
const ll = lbl.toLowerCase();
|
||||
// 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')) {
|
||||
const opts = await sel.$$('option');
|
||||
for (const opt of opts) {
|
||||
const text = await opt.textContent().catch(() => '');
|
||||
if (/prefer not|decline|do not wish|i don/i.test(text || '')) {
|
||||
await sel.selectOption({ label: text.trim() }).catch(() => {});
|
||||
break;
|
||||
}
|
||||
// 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(() => {});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// AI fallback for required selects
|
||||
if (await this.isRequired(sel)) {
|
||||
const opts = await sel.$$('option');
|
||||
const options = [];
|
||||
for (const opt of opts) {
|
||||
const text = (await opt.textContent().catch(() => '') || '').trim();
|
||||
if (text && !/^select/i.test(text)) options.push(text);
|
||||
}
|
||||
answer = await this.aiAnswerFor(lbl, { options });
|
||||
if (field.required) {
|
||||
answer = await this.aiAnswerFor(field.label, { options: field.options });
|
||||
if (answer) {
|
||||
this.saveAnswer(lbl, answer);
|
||||
this.saveAnswer(field.label, answer);
|
||||
} else {
|
||||
unknown.push({ label: lbl, type: 'select', options });
|
||||
unknown.push({ label: field.label, type: 'select', options: field.options });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -550,14 +721,14 @@ Answer:`;
|
||||
}
|
||||
}
|
||||
|
||||
// Checkboxes — "mark as top choice" and similar opt-ins
|
||||
for (const cb of await container.$$('input[type="checkbox"]')) {
|
||||
if (!await cb.isVisible().catch(() => false)) continue;
|
||||
if (await cb.isChecked().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(cb);
|
||||
const ll = lbl.toLowerCase();
|
||||
// --- 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('top choice') || ll.includes('interested') || ll.includes('confirm') || ll.includes('agree') || ll.includes('consent')) {
|
||||
await cb.check().catch(() => {});
|
||||
const el = cbEls[field.index];
|
||||
if (el) await el.check().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user