/** * form_filler.mjs — Generic form filling * Config-driven: answers loaded from answers.json * Returns list of unknown required fields */ 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'; export class FormFiller { constructor(profile, answers, opts = {}) { this.profile = profile; this.answers = 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 } } /** * 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 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 */ } } } // 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) 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); } } // Built-in answers 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('authorized') || l.includes('eligible') || l.includes('legally') || l.includes('work in the u')) { 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'); } 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 — remove trailing * from required field labels // Also deduplicate labels like "Phone country codePhone country code" let raw = forLabel || ariaLabel || linked || ancestorLabel || node.placeholder || node.name || ''; // Normalize whitespace and remove trailing * from required field labels raw = raw.replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').trim(); // Deduplicate repeated label text (LinkedIn renders label text twice sometimes) // e.g. "Phone country codePhone country code" → "Phone country code" if (raw.length > 4) { const half = Math.ceil(raw.length / 2); if (raw.slice(0, half) === raw.slice(half, half * 2)) raw = raw.slice(0, half).trim(); } return raw; }).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); } /** * 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 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; } } /** * Select an option from a