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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user