diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index 7212578..28a38c9 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -21,33 +21,49 @@ import { export const SUPPORTED_TYPES = ['easy_apply']; +/** + * Find the frame that owns the Easy Apply modal. + * LinkedIn renders the modal inside a /preload/ frame, not the main document. + * page.$() searches cross-frame, but evaluate() only runs in one frame's context. + * We need the correct frame for evaluate/evaluateHandle calls. + */ +async function findModalFrame(page, modalSelector) { + for (const frame of page.frames()) { + const el = await frame.$(modalSelector).catch(() => null); + if (el) return frame; + } + return page; // fallback to main frame +} + /** * Find a non-disabled button inside the modal using multiple strategies. - * @param {Page} page - Playwright page + * @param {Page} page - Playwright page (for cross-frame $() calls) + * @param {Frame} frame - The frame containing the modal (for evaluate calls) * @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 = [] }) { +async function findModalButton(page, frame, modalSelector, { ariaLabels = [], exactTexts = [] }) { // Strategy 1: aria-label exact match inside modal (non-disabled only) + // Use frame.$() since modal is in a specific frame for (const label of ariaLabels) { - const btn = await page.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`); + const btn = await frame.$(`${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])`); + const btn = await frame.$(`${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 + // Uses evaluateHandle on the correct frame to return a live DOM reference if (exactTexts.length === 0) return null; - const handle = await page.evaluateHandle((sel, texts) => { + const handle = await frame.evaluateHandle((sel, texts) => { const modal = document.querySelector(sel); if (!modal) return null; const targets = texts.map(t => t.toLowerCase()); @@ -107,23 +123,32 @@ export async function apply(page, job, formFiller) { const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR; + // LinkedIn renders the modal inside a /preload/ frame, not the main document. + // page.$() works cross-frame, but evaluate() only runs in one frame's context. + // Find the frame that owns the modal so evaluate calls work correctly. + const modalFrame = await findModalFrame(page, MODAL); + const isPreloadFrame = modalFrame !== page; + if (isPreloadFrame) { + console.log(` ℹ️ Modal found in frame: ${modalFrame.url().slice(0, 80)}`); + } + // Step through modal let lastProgress = '-1'; for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) { - const modalStillOpen = await page.$(MODAL); + const modalStillOpen = await modalFrame.$(MODAL); if (!modalStillOpen) { console.log(` ✅ Modal closed — submitted`); return { status: 'submitted', meta }; } - const progress = await page.$eval(`${MODAL} [role="progressbar"]`, + const progress = await modalFrame.$eval(`${MODAL} [role="progressbar"]`, el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || '' ).catch(() => ''); - // Debug snapshot: heading, buttons in modal, iframes, and any validation errors - const debugInfo = await page.evaluate((sel) => { + // Debug snapshot: heading, buttons in modal, and any validation errors + const debugInfo = await modalFrame.evaluate((sel) => { const modal = document.querySelector(sel); - if (!modal) return { heading: '', buttons: [], errors: [], iframes: 0, childTags: '', htmlSnippet: '' }; + 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), @@ -132,29 +157,21 @@ export async function apply(page, job, formFiller) { })).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); - const iframes = modal.querySelectorAll('iframe').length; - // Top-level child tags for structure debugging - const childTags = Array.from(modal.children).map(c => c.tagName.toLowerCase() + (c.className ? '.' + c.className.split(' ')[0] : '')).join(', '); - const htmlSnippet = modal.innerHTML.slice(0, 500); - return { heading, buttons, errors, iframes, childTags, htmlSnippet }; - }, MODAL).catch(() => ({ heading: '', buttons: [], errors: [], iframes: 0, childTags: '', htmlSnippet: '' })); + 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) : ''}`); - if (step === 0) { - console.log(` [diag] iframes=${debugInfo.iframes} children=[${debugInfo.childTags}]`); - console.log(` [diag] html=${debugInfo.htmlSnippet}`); - } - // Fill form fields - const unknowns = await formFiller.fill(page, formFiller.profile.resume_path); + // Fill form fields — pass modalFrame so form_filler scopes to the correct frame + const unknowns = await formFiller.fill(modalFrame, 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); + await dismissModal(page, modalFrame, MODAL); return { status: 'skipped_honeypot', meta }; } if (unknowns.length > 0) { - await dismissModal(page, MODAL); + await dismissModal(page, modalFrame, MODAL); return { status: 'needs_answer', pending_question: unknowns[0], meta }; } @@ -162,7 +179,7 @@ export async function apply(page, job, formFiller) { // Check for validation errors after form fill — if LinkedIn shows errors, // the form won't advance. Re-check errors AFTER fill since fill may have resolved them. - const postFillErrors = await page.evaluate((sel) => { + const postFillErrors = await modalFrame.evaluate((sel) => { const modal = document.querySelector(sel); if (!modal) return []; return Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error')) @@ -172,7 +189,7 @@ export async function apply(page, job, formFiller) { 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); + await dismissModal(page, modalFrame, MODAL); return { status: 'incomplete', meta, validation_errors: postFillErrors }; } @@ -181,7 +198,7 @@ export async function apply(page, job, formFiller) { // This prevents accidentally clicking a Submit-like element on early modal steps. // Check for Next button - const nextBtn = await findModalButton(page, MODAL, { + const nextBtn = await findModalButton(page, modalFrame, MODAL, { ariaLabels: ['Continue to next step'], exactTexts: ['Next'], }); @@ -194,7 +211,7 @@ export async function apply(page, job, formFiller) { } // Check for Review button - const reviewBtn = await findModalButton(page, MODAL, { + const reviewBtn = await findModalButton(page, modalFrame, MODAL, { ariaLabels: ['Review your application'], exactTexts: ['Review'], }); @@ -207,7 +224,7 @@ export async function apply(page, job, formFiller) { } // Check for Submit button (only when no Next/Review exists) - const submitBtn = await findModalButton(page, MODAL, { + const submitBtn = await findModalButton(page, modalFrame, MODAL, { ariaLabels: ['Submit application'], exactTexts: ['Submit application'], }); @@ -217,9 +234,8 @@ export async function apply(page, job, formFiller) { await page.waitForTimeout(SUBMIT_WAIT); // Verify modal closed or success message appeared - const modalGone = !(await page.$(MODAL)); - const successVisible = await page.$('[class*="success"], [class*="confirmation"], [aria-label*="applied"]').catch(() => null); - if (modalGone || successVisible) { + const modalGone = !(await modalFrame.$(MODAL)); + if (modalGone) { console.log(` ✅ Submit confirmed — modal closed`); return { status: 'submitted', meta }; } @@ -227,14 +243,14 @@ export async function apply(page, job, formFiller) { // Modal still open — submit may have failed console.log(` [step ${step}] ⚠️ Modal still open after Submit click`); console.log(` Action: submit may have failed due to validation or network error`); - await dismissModal(page, MODAL); + await dismissModal(page, modalFrame, MODAL); return { status: 'incomplete', meta }; } // 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 dismissModal(page, MODAL); + await dismissModal(page, modalFrame, MODAL); return { status: 'stuck', meta }; } @@ -243,7 +259,7 @@ export async function apply(page, job, formFiller) { break; } - await dismissModal(page, MODAL); + await dismissModal(page, modalFrame, MODAL); return { status: 'incomplete', meta }; } @@ -251,27 +267,30 @@ export async function apply(page, job, formFiller) { * Dismiss the Easy Apply modal. * Tries multiple strategies: Dismiss button → Close/X → Escape key. * Handles the "Discard" confirmation dialog that appears after Escape. + * @param {Page} page - Playwright page (for keyboard and cross-frame fallbacks) + * @param {Frame} frame - The frame containing the modal + * @param {string} modalSelector - CSS selector for the modal container */ -async function dismissModal(page, modalSelector) { - // Try aria-label Dismiss - const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`); +async function dismissModal(page, frame, modalSelector) { + // Try aria-label Dismiss (search in the modal's frame) + const dismissBtn = await frame.$(`${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"]`); + const closeBtn = await frame.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`); if (closeBtn) { await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {}); return; } - // Fallback: Escape key + // Fallback: Escape key (works regardless of frame) await page.keyboard.press('Escape').catch(() => {}); - // Handle "Discard" confirmation dialog that may appear after Escape - const discardBtn = await page.waitForSelector( + // Handle "Discard" confirmation dialog — may appear in any frame + const discardBtn = await frame.waitForSelector( 'button[data-test-dialog-primary-btn]', { timeout: DISMISS_TIMEOUT, state: 'visible' } ).catch(() => null); @@ -280,8 +299,8 @@ async function dismissModal(page, modalSelector) { return; } - // Last resort: find Discard by exact text - const handle = await page.evaluateHandle(() => { + // Last resort: find Discard by exact text in the modal's frame + const handle = await frame.evaluateHandle(() => { for (const b of document.querySelectorAll('button')) { if ((b.innerText || '').trim().toLowerCase() === 'discard') return b; }