From 3c46de13584809bdcfd18548ad1e65076f254f67 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Fri, 6 Mar 2026 10:25:51 -0800 Subject: [PATCH] =?UTF-8?q?fix:=20shadow=20DOM=20support=20=E2=80=94=20Lin?= =?UTF-8?q?kedIn=20modal=20is=20inside=20shadow=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkedIn renders Easy Apply modal inside shadow DOM. document.querySelector() in evaluate() cannot pierce shadow DOM, but Playwright's page.$() can. easy_apply.mjs: - Replaced all frame.evaluate(document.querySelector) with ElementHandle ops - findModalButton uses modal.$$() + btn.evaluate() instead of evaluateHandle - getModalDebugInfo uses modal.$eval and modal.$$() for all queries - dismissModal scans buttons via page.$$() instead of evaluateHandle - Removed findModalFrame (no longer needed) form_filler.mjs: - getLabel() walks up ancestor DOM to find labels (LinkedIn doesn't use label[for]) - Deduplicates repeated label text ("Phone country codePhone country code") - isRequired() walks ancestors to find labels with * or required indicators Co-Authored-By: Claude Opus 4.6 --- lib/apply/easy_apply.mjs | 224 +++++++++++++++++++-------------------- lib/form_filler.mjs | 39 ++++++- 2 files changed, 145 insertions(+), 118 deletions(-) diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index 28a38c9..e3d8018 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -2,12 +2,15 @@ * 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. aria-label exact match (fastest, but brittle) - * 2. aria-label substring match (handles minor text changes) - * 3. Exact button text match (most resilient — matches trimmed innerText) - * All searches are scoped to the modal dialog to avoid clicking page buttons. + * 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) @@ -21,70 +24,86 @@ 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 (for cross-frame $() calls) - * @param {Frame} frame - The frame containing the modal (for evaluate calls) + * 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, frame, modalSelector, { ariaLabels = [], exactTexts = [] }) { +async function findModalButton(page, modalSelector, { ariaLabels = [], exactTexts = [] }) { // Strategy 1: aria-label exact match inside modal (non-disabled only) - // Use frame.$() since modal is in a specific frame + // page.$() pierces shadow DOM — safe to use compound selectors for (const label of ariaLabels) { - const btn = await frame.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`); + 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 frame.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`); + const btn = await page.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`); if (btn) return btn; } - // Strategy 3: exact text match on button innerText (case-insensitive, trimmed) - // Uses evaluateHandle on the correct frame to return a live DOM reference + // 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 handle = await frame.evaluateHandle((sel, texts) => { - const modal = document.querySelector(sel); - if (!modal) return null; - const targets = texts.map(t => t.toLowerCase()); - const buttons = modal.querySelectorAll('button:not([disabled])'); - for (const btn of buttons) { - // Use innerText (not textContent) to get rendered text without hidden children - const text = (btn.innerText || btn.textContent || '').trim().toLowerCase(); - if (targets.includes(text)) return btn; - } - return null; - }, modalSelector, exactTexts).catch(() => null); + const modal = await page.$(modalSelector); + if (!modal) return null; - if (handle) { - const el = handle.asElement(); - if (el) return el; - await handle.dispose().catch(() => {}); + // 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 }; @@ -95,12 +114,7 @@ export async function apply(page, job, formFiller) { await page.evaluate(() => window.scrollTo(0, 300)).catch(() => {}); const eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null); if (!eaBtn) { - const applyEls = await page.evaluate(() => - Array.from(document.querySelectorAll('[aria-label*="Easy Apply"], [aria-label*="Apply"]')) - .map(el => ({ tag: el.tagName, aria: el.getAttribute('aria-label'), visible: el.offsetParent !== null })) - ).catch(() => []); console.log(` ℹ️ No Easy Apply button found. Page URL: ${page.url()}`); - console.log(` ℹ️ Apply-related elements on page: ${JSON.stringify(applyEls)}`); console.log(` Action: job may have been removed, filled, or changed to external apply`); return { status: 'skipped_easy_apply_unsupported', meta }; } @@ -109,7 +123,7 @@ export async function apply(page, job, formFiller) { 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 @@ -123,73 +137,56 @@ 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 modalFrame.$(MODAL); + const modalStillOpen = await page.$(MODAL); if (!modalStillOpen) { console.log(` ✅ Modal closed — submitted`); return { status: 'submitted', meta }; } - const progress = await modalFrame.$eval(`${MODAL} [role="progressbar"]`, - el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || '' - ).catch(() => ''); + // Read progress bar — use page.$() + evaluate on the handle + const progressEl = await page.$(`${MODAL} [role="progressbar"]`); + const progress = progressEl + ? await progressEl.evaluate(el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || '').catch(() => '') + : ''; - // 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: [] }; - 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), - aria: b.getAttribute('aria-label'), - disabled: b.disabled, - })).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); - return { heading, buttons, errors }; - }, MODAL).catch(() => ({ heading: '', buttons: [], errors: [] })); + // 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 — pass modalFrame so form_filler scopes to the correct frame - const unknowns = await formFiller.fill(modalFrame, formFiller.profile.resume_path); + // 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, modalFrame, MODAL); + await dismissModal(page, MODAL); return { status: 'skipped_honeypot', meta }; } if (unknowns.length > 0) { - await dismissModal(page, modalFrame, MODAL); + 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 — if LinkedIn shows errors, - // the form won't advance. Re-check errors AFTER fill since fill may have resolved them. - 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')) - .map(e => e.textContent?.trim().slice(0, 80)).filter(Boolean); - }, MODAL).catch(() => []); + // 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, modalFrame, MODAL); + await dismissModal(page, MODAL); return { status: 'incomplete', meta, validation_errors: postFillErrors }; } @@ -198,7 +195,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, modalFrame, MODAL, { + const nextBtn = await findModalButton(page, MODAL, { ariaLabels: ['Continue to next step'], exactTexts: ['Next'], }); @@ -211,7 +208,7 @@ export async function apply(page, job, formFiller) { } // Check for Review button - const reviewBtn = await findModalButton(page, modalFrame, MODAL, { + const reviewBtn = await findModalButton(page, MODAL, { ariaLabels: ['Review your application'], exactTexts: ['Review'], }); @@ -224,7 +221,7 @@ export async function apply(page, job, formFiller) { } // Check for Submit button (only when no Next/Review exists) - const submitBtn = await findModalButton(page, modalFrame, MODAL, { + const submitBtn = await findModalButton(page, MODAL, { ariaLabels: ['Submit application'], exactTexts: ['Submit application'], }); @@ -233,8 +230,8 @@ export async function apply(page, job, formFiller) { await submitBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); await page.waitForTimeout(SUBMIT_WAIT); - // Verify modal closed or success message appeared - const modalGone = !(await modalFrame.$(MODAL)); + // Verify modal closed + const modalGone = !(await page.$(MODAL)); if (modalGone) { console.log(` ✅ Submit confirmed — modal closed`); return { status: 'submitted', meta }; @@ -243,14 +240,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, modalFrame, MODAL); + await dismissModal(page, 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, modalFrame, MODAL); + await dismissModal(page, MODAL); return { status: 'stuck', meta }; } @@ -259,7 +256,7 @@ export async function apply(page, job, formFiller) { break; } - await dismissModal(page, modalFrame, MODAL); + await dismissModal(page, MODAL); return { status: 'incomplete', meta }; } @@ -267,30 +264,28 @@ 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 + * All searches use page.$() which pierces shadow DOM. */ -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"]`); +async function dismissModal(page, modalSelector) { + // Try aria-label Dismiss + const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`); if (dismissBtn) { await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {}); return; } // Try close/X button - const closeBtn = await frame.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`); + const closeBtn = await page.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`); if (closeBtn) { await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {}); return; } - // Fallback: Escape key (works regardless of frame) + // Fallback: Escape key await page.keyboard.press('Escape').catch(() => {}); - // Handle "Discard" confirmation dialog — may appear in any frame - const discardBtn = await frame.waitForSelector( + // Handle "Discard" confirmation dialog that may appear after Escape + const discardBtn = await page.waitForSelector( 'button[data-test-dialog-primary-btn]', { timeout: DISMISS_TIMEOUT, state: 'visible' } ).catch(() => null); @@ -299,14 +294,13 @@ async function dismissModal(page, frame, modalSelector) { return; } - // 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; + // Last resort: 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(() => {}); + return; } - return null; - }).catch(() => null); - const el = handle?.asElement(); - if (el) await el.click().catch(() => {}); - else await handle?.dispose().catch(() => {}); + } } diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs index a3614cf..364618a 100644 --- a/lib/form_filler.mjs +++ b/lib/form_filler.mjs @@ -119,9 +119,32 @@ export class FormFiller { const ariaLabel = node.getAttribute('aria-label') || ''; const ariaLabelledBy = node.getAttribute('aria-labelledby'); const linked = ariaLabelledBy ? document.getElementById(ariaLabelledBy)?.textContent?.trim() : ''; + + // LinkedIn doesn't use label[for] — labels are ancestor elements. + // Walk up the DOM to find the nearest label in a parent container. + let ancestorLabel = ''; + if (!forLabel && !ariaLabel && !linked) { + let parent = node.parentElement; + for (let i = 0; i < 5 && parent; i++) { + const lbl = parent.querySelector('label'); + if (lbl) { + ancestorLabel = lbl.textContent?.trim() || ''; + break; + } + parent = parent.parentElement; + } + } + // Clean up — remove trailing * from required field labels - const raw = forLabel || ariaLabel || linked || node.placeholder || node.name || ''; - return raw.replace(/\s*\*\s*$/, '').trim(); + // Also deduplicate labels like "Phone country codePhone country code" + let raw = forLabel || ariaLabel || linked || ancestorLabel || node.placeholder || node.name || ''; + raw = raw.replace(/\s*\*\s*$/, '').trim(); + // Deduplicate repeated label text (LinkedIn renders label text twice sometimes) + if (raw.length > 4) { + const half = Math.floor(raw.length / 2); + if (raw.slice(0, half) === raw.slice(half)) raw = raw.slice(0, half); + } + return raw; }).catch(() => ''); } @@ -133,12 +156,22 @@ export class FormFiller { return await el.evaluate(node => { if (node.required || node.getAttribute('required') !== null) return true; if (node.getAttribute('aria-required') === 'true') return true; - // Check if the associated label contains * + // Check if any associated label contains * — try label[for], then ancestor labels const id = node.id; if (id) { const label = document.querySelector(`label[for="${id}"]`); if (label && label.textContent.includes('*')) return true; } + // Walk up ancestors to find a label with * + let parent = node.parentElement; + for (let i = 0; i < 5 && parent; i++) { + const lbl = parent.querySelector('label'); + if (lbl && lbl.textContent.includes('*')) return true; + // Also check for "Required" text in parent + const reqSpan = parent.querySelector('[class*="required"], .artdeco-text-input--required'); + if (reqSpan) return true; + parent = parent.parentElement; + } return false; }).catch(() => false); }