fix: resilient modal button detection and form filler robustness
easy_apply.mjs:
- findModalButton() uses 3-strategy detection: aria-label exact/substring,
then exact button text match — survives LinkedIn aria-label changes
- Check order fixed: Next → Review → Submit (submit only when no forward nav)
- All queries scoped to modal + :not([disabled])
- dismissModal() with fallback chain: Dismiss → Close/X → Escape → Discard
- Uses innerText for button text (ignores hidden children)
form_filler.mjs:
- All queries scoped to container (modal when present, page otherwise)
- Radio labels use $$('label') + textContent instead of broken :has-text()
- Autocomplete uses waitForSelector instead of blind 800ms sleep
- EEO selects iterate options directly (selectOption doesn't accept regex)
- Country code check ordered before country to prevent fragile match order
constants.mjs:
- Add AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT
- Remove unused button selectors (now handled inline by findModalButton)
ai_answer.mjs + keywords.mjs:
- Use ANTHROPIC_API_URL constant, claude-sonnet-4-6 model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,74 @@
|
||||
/**
|
||||
* easy_apply.mjs — LinkedIn Easy Apply handler
|
||||
* Handles the LinkedIn Easy Apply modal flow
|
||||
*
|
||||
* Button detection strategy: LinkedIn frequently changes aria-labels and button
|
||||
* structure. We use multiple fallback strategies:
|
||||
* 1. aria-label exact match (fastest, but brittle)
|
||||
* 2. aria-label substring match (handles minor text changes)
|
||||
* 3. Exact button text match (most resilient — matches trimmed innerText)
|
||||
* All searches are scoped to the modal dialog to avoid clicking page buttons.
|
||||
*
|
||||
* Modal flow: Easy Apply → [fill → Next] × N → Review → Submit application
|
||||
* Check order per step: Next → Review → Submit (only submit when no forward nav exists)
|
||||
*/
|
||||
import {
|
||||
NAVIGATION_TIMEOUT, PAGE_LOAD_WAIT, CLICK_WAIT, MODAL_STEP_WAIT,
|
||||
NAVIGATION_TIMEOUT, CLICK_WAIT, MODAL_STEP_WAIT,
|
||||
SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT,
|
||||
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_APPLY_BUTTON_SELECTOR,
|
||||
LINKEDIN_SUBMIT_SELECTOR, LINKEDIN_NEXT_SELECTOR,
|
||||
LINKEDIN_REVIEW_SELECTOR, LINKEDIN_DISMISS_SELECTOR,
|
||||
LINKEDIN_MAX_MODAL_STEPS
|
||||
} from '../constants.mjs';
|
||||
|
||||
export const SUPPORTED_TYPES = ['easy_apply'];
|
||||
|
||||
/**
|
||||
* Find a non-disabled button inside the modal using multiple strategies.
|
||||
* @param {Page} page - Playwright page
|
||||
* @param {string} modalSelector - CSS selector for the modal container
|
||||
* @param {Object} opts
|
||||
* @param {string[]} opts.ariaLabels - aria-label values to try (exact then substring)
|
||||
* @param {string[]} opts.exactTexts - exact button text matches (case-insensitive, trimmed)
|
||||
* @returns {ElementHandle|null}
|
||||
*/
|
||||
async function findModalButton(page, modalSelector, { ariaLabels = [], exactTexts = [] }) {
|
||||
// Strategy 1: aria-label exact match inside modal (non-disabled only)
|
||||
for (const label of ariaLabels) {
|
||||
const btn = await page.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`);
|
||||
if (btn) return btn;
|
||||
}
|
||||
|
||||
// Strategy 2: aria-label substring match inside modal
|
||||
for (const label of ariaLabels) {
|
||||
const btn = await page.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`);
|
||||
if (btn) return btn;
|
||||
}
|
||||
|
||||
// Strategy 3: exact text match on button innerText (case-insensitive, trimmed)
|
||||
// Uses evaluateHandle to return a live DOM reference
|
||||
if (exactTexts.length === 0) return null;
|
||||
|
||||
const handle = await page.evaluateHandle((sel, texts) => {
|
||||
const modal = document.querySelector(sel);
|
||||
if (!modal) return null;
|
||||
const targets = texts.map(t => t.toLowerCase());
|
||||
const buttons = modal.querySelectorAll('button:not([disabled])');
|
||||
for (const btn of buttons) {
|
||||
// Use innerText (not textContent) to get rendered text without hidden children
|
||||
const text = (btn.innerText || btn.textContent || '').trim().toLowerCase();
|
||||
if (targets.includes(text)) return btn;
|
||||
}
|
||||
return null;
|
||||
}, modalSelector, exactTexts).catch(() => null);
|
||||
|
||||
if (handle) {
|
||||
const el = handle.asElement();
|
||||
if (el) return el;
|
||||
await handle.dispose().catch(() => {});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function apply(page, job, formFiller) {
|
||||
const meta = { title: job.title, company: job.company };
|
||||
|
||||
@@ -23,7 +79,6 @@ export async function apply(page, job, formFiller) {
|
||||
await page.evaluate(() => window.scrollTo(0, 300)).catch(() => {});
|
||||
const eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null);
|
||||
if (!eaBtn) {
|
||||
// Debug: log what apply-related elements exist
|
||||
const applyEls = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('[aria-label*="Easy Apply"], [aria-label*="Apply"]'))
|
||||
.map(el => ({ tag: el.tagName, aria: el.getAttribute('aria-label'), visible: el.offsetParent !== null }))
|
||||
@@ -44,87 +99,151 @@ export async function apply(page, job, formFiller) {
|
||||
const modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null);
|
||||
if (!modal) return { status: 'no_modal', meta };
|
||||
|
||||
const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR;
|
||||
|
||||
// Step through modal
|
||||
let lastProgress = '-1';
|
||||
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
|
||||
const modalStillOpen = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR);
|
||||
const modalStillOpen = await page.$(MODAL);
|
||||
if (!modalStillOpen) {
|
||||
console.log(` ✅ Modal closed — submitted`);
|
||||
return { status: 'submitted', meta };
|
||||
}
|
||||
|
||||
const progress = await page.$eval('[role="progressbar"]',
|
||||
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || String(el.style?.width || step)
|
||||
).catch(() => String(step));
|
||||
const progress = await page.$eval(`${MODAL} [role="progressbar"]`,
|
||||
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || ''
|
||||
).catch(() => '');
|
||||
|
||||
// Snapshot modal heading + all page-level buttons for debugging
|
||||
// Debug snapshot: heading, buttons in modal, and any validation errors
|
||||
const debugInfo = await page.evaluate((sel) => {
|
||||
const modal = document.querySelector(sel);
|
||||
const heading = modal?.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || '';
|
||||
const realProgress = document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow') ||
|
||||
document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuetext') || null;
|
||||
const allBtns = Array.from(document.querySelectorAll('button')).map(b => ({
|
||||
text: b.textContent?.trim().slice(0, 40),
|
||||
if (!modal) return { heading: '', buttons: [], errors: [] };
|
||||
const heading = modal.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || '';
|
||||
const buttons = Array.from(modal.querySelectorAll('button, [role="button"]')).map(b => ({
|
||||
text: (b.innerText || b.textContent || '').trim().slice(0, 50),
|
||||
aria: b.getAttribute('aria-label'),
|
||||
disabled: b.disabled,
|
||||
})).filter(b => (b.text || b.aria) && !['Home', 'Jobs', 'Me', 'For Business', 'Save the job'].includes(b.aria));
|
||||
const errors = Array.from(document.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
|
||||
})).filter(b => b.text || b.aria);
|
||||
const errors = Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
|
||||
.map(e => e.textContent?.trim().slice(0, 60)).filter(Boolean);
|
||||
return { heading, realProgress, allBtns, errors };
|
||||
}, LINKEDIN_EASY_APPLY_MODAL_SELECTOR).catch(() => ({}));
|
||||
console.log(` [step ${step}] heading="${debugInfo.heading}" realProgress=${debugInfo.realProgress} buttons=${JSON.stringify(debugInfo.allBtns)} errors=${JSON.stringify(debugInfo.errors)}`);
|
||||
return { heading, buttons, errors };
|
||||
}, MODAL).catch(() => ({ heading: '', buttons: [], errors: [] }));
|
||||
console.log(` [step ${step}] progress=${progress} heading="${debugInfo.heading}" buttons=${JSON.stringify(debugInfo.buttons)}${debugInfo.errors.length ? ' errors=' + JSON.stringify(debugInfo.errors) : ''}`);
|
||||
|
||||
// Fill form fields
|
||||
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
|
||||
if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label))}`);
|
||||
if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label || u))}`);
|
||||
|
||||
if (unknowns[0]?.honeypot) {
|
||||
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||
await dismissModal(page, MODAL);
|
||||
return { status: 'skipped_honeypot', meta };
|
||||
}
|
||||
|
||||
if (unknowns.length > 0) {
|
||||
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||
await dismissModal(page, MODAL);
|
||||
return { status: 'needs_answer', pending_question: unknowns[0], meta };
|
||||
}
|
||||
|
||||
await page.waitForTimeout(MODAL_STEP_WAIT);
|
||||
|
||||
const hasSubmit = await page.$(LINKEDIN_SUBMIT_SELECTOR);
|
||||
if (hasSubmit) {
|
||||
// --- Button check order: Next → Review → Submit ---
|
||||
// Check Next first — only fall through to Submit when there's no forward navigation.
|
||||
// This prevents accidentally clicking a Submit-like element on early modal steps.
|
||||
|
||||
// Check for Next button
|
||||
const nextBtn = await findModalButton(page, MODAL, {
|
||||
ariaLabels: ['Continue to next step'],
|
||||
exactTexts: ['Next'],
|
||||
});
|
||||
if (nextBtn) {
|
||||
console.log(` [step ${step}] clicking Next`);
|
||||
await nextBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(CLICK_WAIT);
|
||||
lastProgress = progress;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for Review button
|
||||
const reviewBtn = await findModalButton(page, MODAL, {
|
||||
ariaLabels: ['Review your application'],
|
||||
exactTexts: ['Review'],
|
||||
});
|
||||
if (reviewBtn) {
|
||||
console.log(` [step ${step}] clicking Review`);
|
||||
await reviewBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(CLICK_WAIT);
|
||||
lastProgress = progress;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for Submit button (only when no Next/Review exists)
|
||||
const submitBtn = await findModalButton(page, MODAL, {
|
||||
ariaLabels: ['Submit application'],
|
||||
exactTexts: ['Submit application'],
|
||||
});
|
||||
if (submitBtn) {
|
||||
console.log(` [step ${step}] clicking Submit`);
|
||||
await page.click(LINKEDIN_SUBMIT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT });
|
||||
await submitBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(SUBMIT_WAIT);
|
||||
return { status: 'submitted', meta };
|
||||
}
|
||||
|
||||
if (progress === lastProgress && step > 2) {
|
||||
// Stuck detection — progress hasn't changed and we've been through a few steps
|
||||
if (progress && progress === lastProgress && step > 2) {
|
||||
console.log(` [step ${step}] stuck — progress unchanged at ${progress}`);
|
||||
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||
await dismissModal(page, MODAL);
|
||||
return { status: 'stuck', meta };
|
||||
}
|
||||
|
||||
const hasNext = await page.$(LINKEDIN_NEXT_SELECTOR);
|
||||
if (hasNext) {
|
||||
console.log(` [step ${step}] clicking Next`);
|
||||
await page.click(LINKEDIN_NEXT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(CLICK_WAIT);
|
||||
lastProgress = progress;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasReview = await page.$(LINKEDIN_REVIEW_SELECTOR);
|
||||
if (hasReview) {
|
||||
console.log(` [step ${step}] clicking Review`);
|
||||
await page.click(LINKEDIN_REVIEW_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(CLICK_WAIT);
|
||||
lastProgress = progress;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` [step ${step}] no Next/Review/Submit found — breaking`);
|
||||
break;
|
||||
}
|
||||
|
||||
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||
await dismissModal(page, MODAL);
|
||||
return { status: 'incomplete', meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the Easy Apply modal.
|
||||
* Tries multiple strategies: Dismiss button → Close/X → Escape key.
|
||||
* Handles the "Discard" confirmation dialog that appears after Escape.
|
||||
*/
|
||||
async function dismissModal(page, modalSelector) {
|
||||
// Try aria-label Dismiss
|
||||
const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`);
|
||||
if (dismissBtn) {
|
||||
await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try close/X button
|
||||
const closeBtn = await page.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`);
|
||||
if (closeBtn) {
|
||||
await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Escape key
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
|
||||
// Handle "Discard" confirmation dialog that may appear after Escape
|
||||
const discardBtn = await page.waitForSelector(
|
||||
'button[data-test-dialog-primary-btn]',
|
||||
{ timeout: DISMISS_TIMEOUT, state: 'visible' }
|
||||
).catch(() => null);
|
||||
if (discardBtn) {
|
||||
await discardBtn.click().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: find Discard by exact text
|
||||
const handle = await page.evaluateHandle(() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if ((b.innerText || '').trim().toLowerCase() === 'discard') return b;
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
const el = handle?.asElement();
|
||||
if (el) await el.click().catch(() => {});
|
||||
else await handle?.dispose().catch(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user