Replace fixed 2.5s sleep + single check with event-driven waitForSelector (state: 'detached', timeout: 8s). If modal persists, check for success indicators (success text, Submit button gone) before marking incomplete. Fixes false 'incomplete' status when LinkedIn shows post-submit dialog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
398 lines
17 KiB
JavaScript
398 lines
17 KiB
JavaScript
/**
|
||
* easy_apply.mjs — LinkedIn Easy Apply handler
|
||
* Handles the LinkedIn Easy Apply modal flow
|
||
*
|
||
* IMPORTANT: LinkedIn renders the Easy Apply modal inside shadow DOM.
|
||
* This means document.querySelector() inside evaluate() CANNOT find it.
|
||
* Playwright's page.$() pierces shadow DOM, so we use ElementHandle-based
|
||
* operations throughout — never document.querySelector for the modal.
|
||
*
|
||
* Button detection strategy: LinkedIn frequently changes aria-labels and button
|
||
* structure. We use multiple fallback strategies:
|
||
* 1. CSS selector via page.$() (pierces shadow DOM)
|
||
* 2. ElementHandle.evaluate() for text matching (runs on already-found elements)
|
||
*
|
||
* 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, CLICK_WAIT, MODAL_STEP_WAIT,
|
||
SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT,
|
||
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_APPLY_BUTTON_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.
|
||
* All searches use page.$() which pierces shadow DOM, unlike document.querySelector().
|
||
*
|
||
* @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)
|
||
// page.$() pierces shadow DOM — safe to use compound selectors
|
||
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: find modal via page.$(), then scan buttons via ElementHandle.evaluate()
|
||
// This works because evaluate() on an ElementHandle runs in the element's context
|
||
if (exactTexts.length === 0) return null;
|
||
|
||
const modal = await page.$(modalSelector);
|
||
if (!modal) return null;
|
||
|
||
// Get all non-disabled buttons inside the modal
|
||
const buttons = await modal.$$('button:not([disabled])');
|
||
const targets = exactTexts.map(t => t.toLowerCase());
|
||
|
||
for (const btn of buttons) {
|
||
const text = await btn.evaluate(el =>
|
||
(el.innerText || el.textContent || '').trim().toLowerCase()
|
||
).catch(() => '');
|
||
if (targets.includes(text)) return btn;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Get debug info about the modal using ElementHandle operations.
|
||
* Does NOT use document.querySelector — uses page.$() which pierces shadow DOM.
|
||
*/
|
||
async function getModalDebugInfo(page, modalSelector) {
|
||
const modal = await page.$(modalSelector);
|
||
if (!modal) return { heading: '', buttons: [], errors: [] };
|
||
|
||
const heading = await modal.$eval(
|
||
'h1, h2, h3, [class*="title"], [class*="heading"]',
|
||
el => el.textContent?.trim()?.slice(0, 60) || ''
|
||
).catch(() => '');
|
||
|
||
const buttonEls = await modal.$$('button, [role="button"]');
|
||
const buttons = [];
|
||
for (const b of buttonEls) {
|
||
const info = await b.evaluate(el => ({
|
||
text: (el.innerText || el.textContent || '').trim().slice(0, 50),
|
||
aria: el.getAttribute('aria-label'),
|
||
disabled: el.disabled,
|
||
})).catch(() => null);
|
||
if (info && (info.text || info.aria)) buttons.push(info);
|
||
}
|
||
|
||
const errorEls = await modal.$$('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error');
|
||
const errors = [];
|
||
for (const e of errorEls) {
|
||
const text = await e.evaluate(el => el.textContent?.trim()?.slice(0, 60) || '').catch(() => '');
|
||
if (text) errors.push(text);
|
||
}
|
||
|
||
return { heading, buttons, errors };
|
||
}
|
||
|
||
export async function apply(page, job, formFiller) {
|
||
const meta = { title: job.title, company: job.company };
|
||
|
||
// Navigate to job page
|
||
await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
|
||
|
||
// Scroll slightly to trigger lazy-loaded content, then wait for Easy Apply button
|
||
await page.evaluate(() => window.scrollTo(0, 300)).catch(() => {});
|
||
let eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null);
|
||
|
||
// Fallback: LinkedIn shows plain "Continue" when a draft exists (span > span > a)
|
||
// The <a> has href containing /apply/ — find it via evaluateHandle in each frame
|
||
// (page.$() may not pierce LinkedIn's specific shadow DOM setup)
|
||
if (!eaBtn) {
|
||
for (const frame of page.frames()) {
|
||
const handle = await frame.evaluateHandle(() => {
|
||
const links = document.querySelectorAll('a[href*="/apply/"]');
|
||
for (const a of links) {
|
||
if (/continue/i.test((a.innerText || '').trim())) return a;
|
||
}
|
||
return null;
|
||
}).catch(() => null);
|
||
if (handle) {
|
||
const el = handle.asElement();
|
||
if (el) {
|
||
eaBtn = el;
|
||
console.log(` ℹ️ Found "Continue" link (draft application)`);
|
||
break;
|
||
}
|
||
await handle.dispose().catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!eaBtn) {
|
||
console.log(` ℹ️ No Easy Apply button found. Page URL: ${page.url()}`);
|
||
console.log(` Action: job may have been removed, filled, or changed to external apply`);
|
||
return { status: 'skipped_easy_apply_unsupported', meta };
|
||
}
|
||
|
||
// Re-read meta after page settled
|
||
const pageMeta = await page.evaluate(() => ({
|
||
title: document.querySelector('.job-details-jobs-unified-top-card__job-title, h1[class*="title"]')?.textContent?.trim(),
|
||
company: document.querySelector('.job-details-jobs-unified-top-card__company-name a, .jobs-unified-top-card__company-name a')?.textContent?.trim(),
|
||
})).catch(() => ({}));
|
||
Object.assign(meta, pageMeta);
|
||
|
||
// Click Easy Apply and wait for modal to appear
|
||
await page.click(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||
const modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null);
|
||
if (!modal) {
|
||
console.log(` ❌ Modal did not open after clicking Easy Apply`);
|
||
console.log(` Action: LinkedIn may have changed the modal structure or login expired`);
|
||
return { status: 'no_modal', meta };
|
||
}
|
||
|
||
const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR;
|
||
|
||
// Step through modal
|
||
let lastProgress = '-1';
|
||
let samePageCount = 0;
|
||
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
|
||
const modalStillOpen = await page.$(MODAL);
|
||
if (!modalStillOpen) {
|
||
console.log(` ✅ Modal closed — submitted`);
|
||
return { status: 'submitted', meta };
|
||
}
|
||
|
||
// Read progress bar — LinkedIn uses <progress> element (no explicit role="progressbar")
|
||
const progressEl = await page.$(`${MODAL} progress, ${MODAL} [role="progressbar"]`);
|
||
const progress = progressEl
|
||
? await progressEl.evaluate(el => el.getAttribute('aria-valuenow') || el.value?.toString() || el.getAttribute('value') || el.style?.width || '').catch(() => '')
|
||
: '';
|
||
|
||
// Debug snapshot using ElementHandle operations (shadow DOM safe)
|
||
const debugInfo = await getModalDebugInfo(page, MODAL);
|
||
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 — page.$() in form_filler pierces shadow DOM
|
||
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 || u))}`);
|
||
|
||
if (unknowns[0]?.honeypot) {
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'skipped_honeypot', meta };
|
||
}
|
||
|
||
if (unknowns.length > 0) {
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'needs_answer', pending_question: unknowns[0], meta };
|
||
}
|
||
|
||
await page.waitForTimeout(MODAL_STEP_WAIT);
|
||
|
||
// Check for validation errors after form fill (shadow DOM safe)
|
||
const postModal = await page.$(MODAL);
|
||
const postFillErrors = [];
|
||
if (postModal) {
|
||
const errorEls = await postModal.$$('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error');
|
||
for (const e of errorEls) {
|
||
const text = await e.evaluate(el => el.textContent?.trim()?.slice(0, 80) || '').catch(() => '');
|
||
if (text) postFillErrors.push(text);
|
||
}
|
||
}
|
||
|
||
if (postFillErrors.length > 0) {
|
||
console.log(` [step ${step}] ❌ Validation errors after fill: ${JSON.stringify(postFillErrors)}`);
|
||
console.log(` Action: check answers.json or profile.json for missing/wrong answers`);
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'incomplete', meta, validation_errors: postFillErrors };
|
||
}
|
||
|
||
// --- 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(() => {});
|
||
|
||
// Detect if we're stuck — wait for content to change after clicking Next
|
||
await page.waitForTimeout(CLICK_WAIT);
|
||
const newProgress = await (async () => {
|
||
const el = await page.$(`${MODAL} progress, ${MODAL} [role="progressbar"]`);
|
||
return el ? await el.evaluate(e => e.getAttribute('aria-valuenow') || e.value?.toString() || '').catch(() => '') : '';
|
||
})();
|
||
|
||
if (newProgress === progress) {
|
||
samePageCount++;
|
||
if (samePageCount >= 3) {
|
||
console.log(` [step ${step}] stuck — clicked Next ${samePageCount} times but progress unchanged at ${progress}`);
|
||
console.log(` Action: a required field may be unfilled. Check select dropdowns still at "Select an option"`);
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'stuck', meta };
|
||
}
|
||
} else {
|
||
samePageCount = 0;
|
||
}
|
||
lastProgress = newProgress;
|
||
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;
|
||
samePageCount = 0;
|
||
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 submitBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||
|
||
// Wait for modal to close — LinkedIn may take a few seconds after submit
|
||
const modalClosed = await page.waitForSelector(MODAL, { state: 'detached', timeout: 8000 }).then(() => true).catch(() => false);
|
||
if (modalClosed) {
|
||
console.log(` ✅ Submit confirmed — modal closed`);
|
||
return { status: 'submitted', meta };
|
||
}
|
||
|
||
// Modal still open — LinkedIn often shows a post-submit confirmation/success
|
||
// dialog that still matches [role="dialog"]. Check for success indicators.
|
||
const postSubmitModal = await page.$(MODAL);
|
||
if (postSubmitModal) {
|
||
const postSubmitInfo = await postSubmitModal.evaluate(el => {
|
||
const text = (el.innerText || el.textContent || '').toLowerCase();
|
||
return {
|
||
hasSuccess: text.includes('application was sent') || text.includes('applied') ||
|
||
text.includes('thank you') || text.includes('submitted') ||
|
||
text.includes('application has been') || text.includes('successfully'),
|
||
hasDone: text.includes('done') || text.includes('got it'),
|
||
snippet: (el.innerText || '').trim().slice(0, 200),
|
||
};
|
||
}).catch(() => ({ hasSuccess: false, hasDone: false, snippet: '' }));
|
||
|
||
console.log(` [step ${step}] post-submit modal: "${postSubmitInfo.snippet}"`);
|
||
|
||
if (postSubmitInfo.hasSuccess || postSubmitInfo.hasDone) {
|
||
console.log(` ✅ Submit confirmed — success dialog detected`);
|
||
// Try to dismiss the success dialog
|
||
const doneBtn = await findModalButton(page, MODAL, {
|
||
ariaLabels: ['Dismiss', 'Done', 'Close'],
|
||
exactTexts: ['Done', 'Got it', 'Close'],
|
||
});
|
||
if (doneBtn) await doneBtn.click().catch(() => {});
|
||
return { status: 'submitted', meta };
|
||
}
|
||
|
||
// Check for validation errors — real failure
|
||
const postErrors = await postSubmitModal.$$('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error');
|
||
const errorTexts = [];
|
||
for (const e of postErrors) {
|
||
const t = await e.evaluate(el => el.textContent?.trim()?.slice(0, 80) || '').catch(() => '');
|
||
if (t) errorTexts.push(t);
|
||
}
|
||
|
||
if (errorTexts.length > 0) {
|
||
console.log(` [step ${step}] ❌ Validation errors after Submit: ${JSON.stringify(errorTexts)}`);
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'incomplete', meta, validation_errors: errorTexts };
|
||
}
|
||
|
||
// No errors, no success text — but Submit button is gone, likely succeeded
|
||
// (LinkedIn sometimes shows a follow-up prompt like "Follow company?")
|
||
const submitStillThere = await findModalButton(page, MODAL, {
|
||
ariaLabels: ['Submit application'],
|
||
exactTexts: ['Submit application'],
|
||
});
|
||
if (!submitStillThere) {
|
||
console.log(` ✅ Submit likely succeeded — Submit button gone, no errors`);
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'submitted', meta };
|
||
}
|
||
|
||
console.log(` [step ${step}] ⚠️ Submit button still present — click may not have registered`);
|
||
await dismissModal(page, MODAL);
|
||
return { status: 'incomplete', meta };
|
||
}
|
||
}
|
||
|
||
// Stuck detection — no Next/Review/Submit found
|
||
// (stuck-after-click detection is handled above in the Next button section)
|
||
|
||
console.log(` [step ${step}] ❌ No Next/Review/Submit button found in modal`);
|
||
console.log(` Action: LinkedIn may have changed button text/structure. Check button snapshot above.`);
|
||
break;
|
||
}
|
||
|
||
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.
|
||
* All searches use page.$() which pierces shadow DOM.
|
||
*/
|
||
async function dismissModal(page, modalSelector) {
|
||
// Step 1: Close the modal — Dismiss button, Close/X, or Escape
|
||
const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`);
|
||
if (dismissBtn) {
|
||
await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||
} else {
|
||
const closeBtn = await page.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`);
|
||
if (closeBtn) {
|
||
await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||
} else {
|
||
await page.keyboard.press('Escape').catch(() => {});
|
||
}
|
||
}
|
||
|
||
// Step 2: LinkedIn shows a "Discard" confirmation — always wait for it and click
|
||
const discardBtn = await page.waitForSelector(
|
||
'button[data-test-dialog-primary-btn]',
|
||
{ timeout: DISMISS_TIMEOUT, state: 'visible' }
|
||
).catch(() => null);
|
||
if (discardBtn) {
|
||
await discardBtn.click().catch(() => {});
|
||
await page.waitForTimeout(500);
|
||
return;
|
||
}
|
||
|
||
// Fallback: find Discard by text — scan all buttons via page.$$()
|
||
const allBtns = await page.$$('button');
|
||
for (const btn of allBtns) {
|
||
const text = await btn.evaluate(el => (el.innerText || '').trim().toLowerCase()).catch(() => '');
|
||
if (text === 'discard') {
|
||
await btn.click().catch(() => {});
|
||
await page.waitForTimeout(500);
|
||
return;
|
||
}
|
||
}
|
||
}
|