fix: shadow DOM support — LinkedIn modal is inside shadow root

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:25:51 -08:00
parent df31019fd0
commit 3c46de1358
2 changed files with 145 additions and 118 deletions

View File

@@ -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(() => {});
}
}