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:
2026-03-06 12:32:34 -08:00
parent 7a730f689e
commit 26afc803a5

View File

@@ -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 };
}