From 26afc803a5147a1c05b3f070f81dadc0a1eb0c05 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Fri, 6 Mar 2026 12:32:34 -0800 Subject: [PATCH] Navigate directly to apply URL instead of finding/clicking Easy Apply button LinkedIn's apply URL pattern is {jobUrl}/apply/?openSDUIApplyFlow=true which opens the modal directly. This eliminates: - Button finding with waitForSelector (flaky on slow loads) - Click retry logic - "Continue" link fallback for draft applications - Shadow DOM piercing for button detection Tested: modal opens reliably, meta readable from background page, form + progress bar present, 3.4s total navigation time. Co-Authored-By: Claude Opus 4.6 --- lib/apply/easy_apply.mjs | 75 ++++++++-------------------------------- 1 file changed, 14 insertions(+), 61 deletions(-) diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index 7a99c67..9196b7a 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -18,7 +18,7 @@ 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_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_MAX_MODAL_STEPS } from '../constants.mjs'; @@ -131,38 +131,21 @@ async function findApplyModal(page) { export async function apply(page, job, formFiller) { const meta = { title: job.title, company: job.company }; - // Navigate to job page — networkidle ensures JS has rendered the Easy Apply button - await page.goto(job.url, { waitUntil: 'networkidle', timeout: NAVIGATION_TIMEOUT }); + // Navigate directly to the apply URL — opens the modal without needing to find/click the button + const applyUrl = job.url.replace(/\/$/, '') + '/apply/?openSDUIApplyFlow=true'; + await page.goto(applyUrl, { 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); + // Read meta from the job page (renders behind the modal) + 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); - // Fallback: LinkedIn shows plain "Continue" when a draft exists (span > span > a) - // The 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(() => {}); - } - } - } + // Wait for modal to appear + let modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 10000 }).catch(() => null); - if (!eaBtn) { + if (!modal) { // Check if the listing is closed const closed = await page.evaluate(() => { const text = (document.body.innerText || '').toLowerCase(); @@ -173,37 +156,7 @@ export async function apply(page, job, formFiller) { console.log(` ℹ️ Job closed — no longer accepting applications`); return { status: 'skipped_no_apply', meta }; } - 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 - // Click the actual found element — not a fresh selector query that might miss shadow DOM elements - await eaBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); - let modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null); - - // Retry: button may not have been interactable on first click (lazy-loaded, overlapping element, etc.) - if (!modal) { - console.log(` ℹ️ Modal didn't open — retrying click`); - await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}); - await page.waitForTimeout(1000); - // Re-find button in case DOM changed - const eaBtn2 = await page.$(LINKEDIN_APPLY_BUTTON_SELECTOR) || eaBtn; - await eaBtn2.click({ force: true, timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); - 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 (2 attempts)`); - console.log(` Action: LinkedIn may have changed the modal structure or login expired`); + console.log(` ❌ Modal did not open. Page URL: ${page.url()}`); return { status: 'no_modal', meta }; }