From 2c04ecd8274e765367e4e2e516dd519e032f8f64 Mon Sep 17 00:00:00 2001 From: leini8891 Date: Sun, 28 Jun 2026 17:07:55 +0800 Subject: [PATCH] Harden extension form mapping --- apps/extension/src/background/index.ts | 466 +++++---- apps/extension/src/content/form-mapping.ts | 642 +++++++++++++ apps/extension/src/content/greenhouse.ts | 882 ++++++++++++++++++ apps/extension/src/content/linkedin.ts | 843 ++++++++++------- apps/extension/src/content/mycareersfuture.ts | 655 +++++++------ apps/extension/src/manifest.ts | 10 + apps/web/src/app/api/runs/start/route.ts | 2 +- apps/web/src/components/manual-job-form.tsx | 32 +- apps/web/src/server/services/app-service.ts | 1 + packages/domain/src/schemas.ts | 6 +- tests/e2e/greenhouse-fixture.spec.ts | 157 ++++ tests/fixtures/greenhouse-application.html | 136 +++ tests/form-mapping.test.ts | 200 ++++ 13 files changed, 3229 insertions(+), 803 deletions(-) create mode 100644 apps/extension/src/content/form-mapping.ts create mode 100644 apps/extension/src/content/greenhouse.ts create mode 100644 tests/e2e/greenhouse-fixture.spec.ts create mode 100644 tests/fixtures/greenhouse-application.html create mode 100644 tests/form-mapping.test.ts diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index 3d79d27..dc7a82c 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -1,6 +1,15 @@ -import type { ExtensionMessage, PopupState, WorkerJobPlan } from '../shared/messages'; +import type { + ExtensionMessage, + PopupState, + WorkerJobPlan, +} from '../shared/messages'; import { extensionEnv } from '../shared/env'; -const SUPPORTED_JOB_HOSTS = ['linkedin.com/jobs', 'mycareersfuture.gov.sg'] as const; +const SUPPORTED_JOB_HOSTS = [ + 'linkedin.com/jobs', + 'mycareersfuture.gov.sg', + 'boards.greenhouse.io', + 'job-boards.greenhouse.io', +] as const; const PENDING_WORKER_RUNS_KEY = 'applypilot-pending-worker-runs'; const defaultState: PopupState = { @@ -24,10 +33,16 @@ type PendingWorkerRun = { const getPendingWorkerRuns = async () => { const result = await chrome.storage.local.get([PENDING_WORKER_RUNS_KEY]); - return (result[PENDING_WORKER_RUNS_KEY] as Record | undefined) ?? {}; + return ( + (result[PENDING_WORKER_RUNS_KEY] as + | Record + | undefined) ?? {} + ); }; -const savePendingWorkerRuns = async (runs: Record) => { +const savePendingWorkerRuns = async ( + runs: Record, +) => { await chrome.storage.local.set({ [PENDING_WORKER_RUNS_KEY]: runs, }); @@ -56,7 +71,10 @@ const saveState = async (patch: Partial) => { }); await chrome.action.setBadgeText({ - text: nextState.pendingReviewCount > 0 ? String(nextState.pendingReviewCount) : '', + text: + nextState.pendingReviewCount > 0 + ? String(nextState.pendingReviewCount) + : '', }); await chrome.action.setBadgeBackgroundColor({ color: nextState.pendingReviewCount > 0 ? '#aa6a17' : '#1b6b5c', @@ -76,7 +94,8 @@ const proxyApiRequest = async ({ }) => { const response = await fetch(`${extensionEnv.VITE_API_BASE_URL}${path}`, { method, - headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + headers: + body === undefined ? undefined : { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); @@ -88,7 +107,9 @@ const proxyApiRequest = async ({ return { ok: false, error: - typeof payload?.error === 'string' ? payload.error : `Request failed: ${response.status}`, + typeof payload?.error === 'string' + ? payload.error + : `Request failed: ${response.status}`, }; } @@ -137,16 +158,20 @@ const startRunOnCurrentPage = async ({ activeTabId: number; targetCount: number; }) => { - const response = await sendMessageToTab<{ ok?: boolean; error?: string }>(activeTabId, { - type: 'applypilot:start-run-on-page', - targetCount, - apiBaseUrl: extensionEnv.VITE_API_BASE_URL, - sourceTabId: activeTabId, - } satisfies ExtensionMessage); + const response = await sendMessageToTab<{ ok?: boolean; error?: string }>( + activeTabId, + { + type: 'applypilot:start-run-on-page', + targetCount, + apiBaseUrl: extensionEnv.VITE_API_BASE_URL, + sourceTabId: activeTabId, + } satisfies ExtensionMessage, + ); if (!response?.ok) { throw new Error( - response?.error ?? 'ApplyPilot did not receive a start confirmation from the page.', + response?.error ?? + 'ApplyPilot did not receive a start confirmation from the page.', ); } }; @@ -195,6 +220,14 @@ const matchesContentScriptPattern = (url: string, pattern: string) => { return url.startsWith('https://www.mycareersfuture.gov.sg/'); } + if (pattern === 'https://boards.greenhouse.io/*') { + return url.startsWith('https://boards.greenhouse.io/'); + } + + if (pattern === 'https://job-boards.greenhouse.io/*') { + return url.startsWith('https://job-boards.greenhouse.io/'); + } + return false; }; @@ -202,10 +235,14 @@ const getContentScriptFilesForUrl = (url: string) => chrome.runtime .getManifest() .content_scripts?.filter((contentScript) => - (contentScript.matches ?? []).some((pattern) => matchesContentScriptPattern(url, pattern)), + (contentScript.matches ?? []).some((pattern) => + matchesContentScriptPattern(url, pattern), + ), ) .flatMap((contentScript) => contentScript.js ?? []) - .filter((file): file is string => typeof file === 'string' && file.length > 0) ?? []; + .filter( + (file): file is string => typeof file === 'string' && file.length > 0, + ) ?? []; const ensureContentScriptInjected = async (tabId: number) => { const tab = await chrome.tabs.get(tabId); @@ -223,7 +260,9 @@ const ensureContentScriptInjected = async (tabId: number) => { const canRetryByInjecting = (error: unknown) => { const message = error instanceof Error ? error.message : String(error ?? ''); - return /receiving end does not exist|could not establish connection/i.test(message); + return /receiving end does not exist|could not establish connection/i.test( + message, + ); }; const shouldReloadJobSiteTab = (error: unknown) => { @@ -268,7 +307,10 @@ const sendMessageToTab = async ( try { return (await chrome.tabs.sendMessage(tabId, message)) as TResponse; } catch (error) { - lastError = error instanceof Error ? error : new Error('Unknown tab messaging error'); + lastError = + error instanceof Error + ? error + : new Error('Unknown tab messaging error'); if (!injectionAttempted && canRetryByInjecting(error)) { injectionAttempted = true; @@ -277,7 +319,9 @@ const sendMessageToTab = async ( continue; } catch (injectError) { lastError = - injectError instanceof Error ? injectError : new Error('Could not inject ApplyPilot into this tab.'); + injectError instanceof Error + ? injectError + : new Error('Could not inject ApplyPilot into this tab.'); } } @@ -285,11 +329,14 @@ const sendMessageToTab = async ( } } - throw lastError ?? new Error('ApplyPilot could not attach to this job site page.'); + throw ( + lastError ?? new Error('ApplyPilot could not attach to this job site page.') + ); }; const buildWorkerJobUrl = (plan: WorkerJobPlan, sourceTabUrl?: string) => { - return plan.job.url?.startsWith('https://www.linkedin.com/jobs/') + return plan.job.url?.startsWith('http://') || + plan.job.url?.startsWith('https://') ? plan.job.url : sourceTabUrl && sourceTabUrl.includes('mycareersfuture.gov.sg') ? plan.job.url @@ -318,7 +365,8 @@ const runPlanInWorkerTab = async ({ url: buildWorkerJobUrl(plan, sourceTab.url), active: true, windowId: sourceTab.windowId, - index: typeof sourceTab.index === 'number' ? sourceTab.index + 1 : undefined, + index: + typeof sourceTab.index === 'number' ? sourceTab.index + 1 : undefined, }); if (!workerTab?.id) { @@ -389,209 +437,249 @@ chrome.runtime.onInstalled.addListener(async () => { }); }); -chrome.runtime.onMessage.addListener((message: ExtensionMessage, _sender, sendResponse) => { - void (async () => { - try { - if (message.type === 'applypilot:get-state') { - sendResponse(await getState()); - return; - } - - if (message.type === 'applypilot:ping') { - sendResponse({ ok: true }); - return; - } - - if (message.type === 'applypilot:api-request') { - sendResponse( - await proxyApiRequest({ - path: message.path, - method: message.method, - body: message.body, - }), - ); - return; - } +chrome.runtime.onMessage.addListener( + (message: ExtensionMessage, _sender, sendResponse) => { + void (async () => { + try { + if (message.type === 'applypilot:get-state') { + sendResponse(await getState()); + return; + } - if (message.type === 'applypilot:content-update') { - const current = await getState(); - sendResponse( - await saveState({ - ...message.payload, - dailySubmitted: - typeof message.payload.dailySubmitted === 'number' - ? current.dailySubmitted + message.payload.dailySubmitted - : current.dailySubmitted, - pendingReviewCount: - typeof message.payload.pendingReviewCount === 'number' - ? current.pendingReviewCount + message.payload.pendingReviewCount - : current.pendingReviewCount, - }), - ); - return; - } + if (message.type === 'applypilot:ping') { + sendResponse({ ok: true }); + return; + } - if (message.type === 'applypilot:request-screenshot') { - const dataUrl = await chrome.tabs.captureVisibleTab(chrome.windows.WINDOW_ID_CURRENT, { - format: 'png', - }); - sendResponse({ dataUrl }); - return; - } + if (message.type === 'applypilot:api-request') { + sendResponse( + await proxyApiRequest({ + path: message.path, + method: message.method, + body: message.body, + }), + ); + return; + } - if (message.type === 'applypilot:start-run') { - const activeTab = await getActiveSupportedTab(); - if (!isSupportedJobTab(activeTab)) { - sendResponse({ ok: false, error: 'Open a LinkedIn or MyCareersFuture job page first.' }); + if (message.type === 'applypilot:content-update') { + const current = await getState(); + sendResponse( + await saveState({ + ...message.payload, + dailySubmitted: + typeof message.payload.dailySubmitted === 'number' + ? current.dailySubmitted + message.payload.dailySubmitted + : current.dailySubmitted, + pendingReviewCount: + typeof message.payload.pendingReviewCount === 'number' + ? current.pendingReviewCount + + message.payload.pendingReviewCount + : current.pendingReviewCount, + }), + ); return; } - const activeTabId = activeTab?.id; - if (activeTabId === undefined) { - sendResponse({ ok: false, error: 'Could not resolve the active job-site tab.' }); + if (message.type === 'applypilot:request-screenshot') { + const dataUrl = await chrome.tabs.captureVisibleTab( + chrome.windows.WINDOW_ID_CURRENT, + { + format: 'png', + }, + ); + sendResponse({ dataUrl }); return; } - const targetCount = Math.max(1, Math.min(50, Math.round(message.targetCount || 1))); + if (message.type === 'applypilot:start-run') { + const activeTab = await getActiveSupportedTab(); + if (!isSupportedJobTab(activeTab)) { + sendResponse({ + ok: false, + error: + 'Open a LinkedIn, MyCareersFuture, or Greenhouse job page first.', + }); + return; + } + const activeTabId = activeTab?.id; + + if (activeTabId === undefined) { + sendResponse({ + ok: false, + error: 'Could not resolve the active job-site tab.', + }); + return; + } - await saveState({ - runStatus: 'running', - pendingReviewCount: 0, - recentResult: - targetCount > 1 ? `Preparing batch run for up to ${targetCount} jobs` : 'Preparing application run', - }); + const targetCount = Math.max( + 1, + Math.min(50, Math.round(message.targetCount || 1)), + ); - try { - await startLocalRunWithRetry({ - activeTabId, - targetCount, - }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'ApplyPilot could not start this run.'; await saveState({ - runStatus: 'failed', - recentResult: messageText, + runStatus: 'running', + pendingReviewCount: 0, + recentResult: + targetCount > 1 + ? `Preparing batch run for up to ${targetCount} jobs` + : 'Preparing application run', }); - sendResponse({ - ok: false, - error: messageText, - }); - return; - } - sendResponse({ ok: true }); - return; - } + try { + await startLocalRunWithRetry({ + activeTabId, + targetCount, + }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'ApplyPilot could not start this run.'; + await saveState({ + runStatus: 'failed', + recentResult: messageText, + }); + sendResponse({ + ok: false, + error: messageText, + }); + return; + } - if (message.type === 'applypilot:run-plan-in-worker-tab') { - if (message.sourceTabId === undefined) { - sendResponse({ ok: false, error: 'Could not determine the source job-site tab.' }); + sendResponse({ ok: true }); return; } - try { - await runPlanInWorkerTab({ - apiBaseUrl: message.apiBaseUrl, - sourceTabId: message.sourceTabId, - plan: message.plan, - }); - sendResponse({ ok: true }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'ApplyPilot could not run this job in a worker tab.'; - sendResponse({ ok: false, error: messageText }); - } - return; - } + if (message.type === 'applypilot:run-plan-in-worker-tab') { + if (message.sourceTabId === undefined) { + sendResponse({ + ok: false, + error: 'Could not determine the source job-site tab.', + }); + return; + } - if (message.type === 'applypilot:get-pending-worker-plan') { - const senderTabId = _sender.tab?.id; - if (senderTabId === undefined) { - sendResponse({ ok: false }); + try { + await runPlanInWorkerTab({ + apiBaseUrl: message.apiBaseUrl, + sourceTabId: message.sourceTabId, + plan: message.plan, + }); + sendResponse({ ok: true }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'ApplyPilot could not run this job in a worker tab.'; + sendResponse({ ok: false, error: messageText }); + } return; } - const runs = await getPendingWorkerRuns(); - const run = runs[String(senderTabId)]; - if (!run) { - sendResponse({ ok: false }); - return; - } + if (message.type === 'applypilot:get-pending-worker-plan') { + const senderTabId = _sender.tab?.id; + if (senderTabId === undefined) { + sendResponse({ ok: false }); + return; + } - sendResponse({ - ok: true, - apiBaseUrl: run.apiBaseUrl, - plan: run.plan, - sourceTabId: run.sourceTabId, - }); - return; - } + const runs = await getPendingWorkerRuns(); + const run = runs[String(senderTabId)]; + if (!run) { + sendResponse({ ok: false }); + return; + } - if (message.type === 'applypilot:worker-plan-finished') { - const senderTabId = _sender.tab?.id; - if (senderTabId === undefined) { - sendResponse({ ok: false, error: 'Could not resolve the worker tab.' }); + sendResponse({ + ok: true, + apiBaseUrl: run.apiBaseUrl, + plan: run.plan, + sourceTabId: run.sourceTabId, + }); return; } - const completion = pendingWorkerCompletions.get(senderTabId); - if (!completion) { - sendResponse({ ok: false, error: 'No pending worker run was registered for this tab.' }); - return; - } + if (message.type === 'applypilot:worker-plan-finished') { + const senderTabId = _sender.tab?.id; + if (senderTabId === undefined) { + sendResponse({ + ok: false, + error: 'Could not resolve the worker tab.', + }); + return; + } - if (message.status === 'completed') { - completion.resolve({ ok: true }); + const completion = pendingWorkerCompletions.get(senderTabId); + if (!completion) { + sendResponse({ + ok: false, + error: 'No pending worker run was registered for this tab.', + }); + return; + } + + if (message.status === 'completed') { + completion.resolve({ ok: true }); + sendResponse({ ok: true }); + return; + } + + completion.reject( + new Error(message.error ?? 'ApplyPilot worker run failed.'), + ); sendResponse({ ok: true }); return; } - completion.reject(new Error(message.error ?? 'ApplyPilot worker run failed.')); - sendResponse({ ok: true }); - return; - } - - if (message.type === 'applypilot:pause-run') { - const tabs = await chrome.tabs.query({ - currentWindow: true, - url: ['https://www.linkedin.com/jobs/*', 'https://www.mycareersfuture.gov.sg/*'], - }); + if (message.type === 'applypilot:pause-run') { + const tabs = await chrome.tabs.query({ + currentWindow: true, + url: [ + 'https://www.linkedin.com/jobs/*', + 'https://www.mycareersfuture.gov.sg/*', + 'https://boards.greenhouse.io/*', + 'https://job-boards.greenhouse.io/*', + ], + }); - for (const tab of tabs) { - if (!tab.id) { - continue; + for (const tab of tabs) { + if (!tab.id) { + continue; + } + + try { + await chrome.tabs.sendMessage(tab.id, { + type: 'applypilot:pause-run-on-page', + } satisfies ExtensionMessage); + } catch { + // Ignore missing content script when a run has already ended or the page re-rendered. + } } - try { - await chrome.tabs.sendMessage(tab.id, { - type: 'applypilot:pause-run-on-page', - } satisfies ExtensionMessage); - } catch { - // Ignore missing content script when a run has already ended or the page re-rendered. - } + sendResponse( + await saveState({ + runStatus: 'paused', + recentResult: 'Run paused from popup', + }), + ); + return; } - sendResponse( - await saveState({ - runStatus: 'paused', - recentResult: 'Run paused from popup', - }), - ); - return; - } - - sendResponse({ ok: false, error: 'Unsupported ApplyPilot message.' }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'ApplyPilot background handling failed unexpectedly.'; - try { - sendResponse({ ok: false, error: messageText }); - } catch { - // Ignore send failures after the channel has already closed. + sendResponse({ ok: false, error: 'Unsupported ApplyPilot message.' }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'ApplyPilot background handling failed unexpectedly.'; + try { + sendResponse({ ok: false, error: messageText }); + } catch { + // Ignore send failures after the channel has already closed. + } } - } - })(); + })(); - return true; -}); + return true; + }, +); diff --git a/apps/extension/src/content/form-mapping.ts b/apps/extension/src/content/form-mapping.ts new file mode 100644 index 0000000..1bd549d --- /dev/null +++ b/apps/extension/src/content/form-mapping.ts @@ -0,0 +1,642 @@ +export type YesNoUnknown = 'yes' | 'no' | 'unknown'; + +export type FormMappingPreference = { + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: YesNoUnknown; + requiresVisaSponsorship?: YesNoUnknown; + willingToRelocate?: YesNoUnknown; +}; + +export type SalaryFallbackPeriod = 'annual' | 'monthly'; + +export type QuestionAnswerSource = + | 'applicationSalaryAmount' + | 'minSalary' + | 'noticePeriodWeeks' + | 'requiresVisaSponsorship' + | 'salaryCurrency' + | 'willingToRelocate' + | 'workAuthorization' + | 'yearsExperienceOverride'; + +export type QuestionCategory = + | 'relocation' + | 'salaryAmount' + | 'salaryCurrency' + | 'startAvailability' + | 'visaSponsorship' + | 'noticePeriod' + | 'workAuthorization' + | 'yearsExperience'; + +export type TextQuestionAnswer = + | { + outcome: 'answer'; + category: Exclude; + source: QuestionAnswerSource; + value: string; + } + | { + outcome: 'review'; + category: QuestionCategory | 'ambiguous'; + reason: string; + sources?: QuestionAnswerSource[]; + } + | { + outcome: 'ignore'; + }; + +export type ChoiceQuestionAnswer = + | { + outcome: 'answer'; + category: Exclude; + optionIndex: number; + source: QuestionAnswerSource; + value: string; + } + | { + outcome: 'review'; + category: QuestionCategory | 'ambiguous'; + reason: string; + sources?: QuestionAnswerSource[]; + } + | { + outcome: 'ignore'; + }; + +type CategoryMatch = { + category: QuestionCategory; + source: QuestionAnswerSource; +}; + +type ResolveOptions = { + salaryFallbackPeriod?: SalaryFallbackPeriod; +}; + +export const normalizeQuestionText = (value: string) => + value + .toLowerCase() + .replace(/[’']/g, "'") + .replace(/[^a-z0-9+$.'-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +const hasAny = (value: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(value)); + +const uniqueMatches = (matches: CategoryMatch[]) => { + const seen = new Set(); + return matches.filter((match) => { + if (seen.has(match.category)) { + return false; + } + seen.add(match.category); + return true; + }); +}; + +export const detectQuestionCategories = ( + questionText: string, +): CategoryMatch[] => { + const normalized = normalizeQuestionText(questionText); + if (!normalized) { + return []; + } + + const matches: CategoryMatch[] = []; + const asksAboutSalary = hasAny(normalized, [ + /\bsalary\b/, + /\bcompensation\b/, + /\bexpected pay\b/, + /\bpay expectation\b/, + /\bexpected package\b/, + /\bbase pay\b/, + /\bbase salary\b/, + /\bremuneration\b/, + /\bctc\b/, + ]); + + if (asksAboutSalary && /\bcurrency\b/.test(normalized)) { + matches.push({ category: 'salaryCurrency', source: 'salaryCurrency' }); + } else if (asksAboutSalary) { + matches.push({ + category: 'salaryAmount', + source: 'applicationSalaryAmount', + }); + } + + if ( + hasAny(normalized, [ + /\bnotice period\b/, + /\bcurrent notice\b/, + /\bweeks? notice\b/, + ]) + ) { + matches.push({ category: 'noticePeriod', source: 'noticePeriodWeeks' }); + } else if ( + hasAny(normalized, [ + /\bavailable to (start|join)\b/, + /\bavailability to (start|join)\b/, + /\bwhen can you (start|join)\b/, + /\bearliest (start|join)(ing)? date\b/, + /\b(start|join)(ing)? date\b/, + ]) + ) { + matches.push({ + category: 'startAvailability', + source: 'noticePeriodWeeks', + }); + } + + if ( + !/\bage\b/.test(normalized) && + hasAny(normalized, [ + /\bhow many years\b/, + /\bminimum years\b/, + /\byrs?\b.*\bexperience\b/, + /\byears?\b.*\bexperience\b/, + /\bexperience\b.*\byrs?\b/, + /\bexperience\b.*\byears?\b/, + ]) + ) { + matches.push({ + category: 'yearsExperience', + source: 'yearsExperienceOverride', + }); + } + + const asksAboutSponsorship = hasAny(normalized, [ + /\bsponsor(ship)?\b/, + /\brequire\b.*\bvisa\b/, + /\bneed\b.*\bvisa\b/, + /\bvisa\b.*\bsupport\b/, + /\bwork visa\b.*\brequire\b/, + /\bemployment pass\b.*\brequire\b/, + /\bh-?1b\b/, + ]); + if (asksAboutSponsorship) { + matches.push({ + category: 'visaSponsorship', + source: 'requiresVisaSponsorship', + }); + } + + const asksAboutWorkAuthorization = hasAny(normalized, [ + /\bright to work\b/, + /\blegally\b.*\b(authori[sz]ed|eligible)\b.*\b(work|employ)/, + /\b(authori[sz]ed|authori[sz]ation|authori[sz]e|eligible)\b.*\b(work|employ)/, + /\b(work|employment)\b.*\b(authori[sz]ed|authori[sz]ation|eligible)\b/, + /\bvalid\b.*\b(work permit|work pass|work visa)\b/, + ]); + if (asksAboutWorkAuthorization) { + matches.push({ + category: 'workAuthorization', + source: 'workAuthorization', + }); + } + + if ( + hasAny(normalized, [ + /\brelocat(e|ion|ing)?\b/, + /\bwilling to move\b/, + /\bopen to moving\b/, + /\bmove to\b.*\b(role|job|position)\b/, + ]) + ) { + matches.push({ category: 'relocation', source: 'willingToRelocate' }); + } + + return uniqueMatches(matches); +}; + +const getSingleCategory = (questionText: string) => { + const matches = detectQuestionCategories(questionText); + if (matches.length === 0) { + return { kind: 'none' as const }; + } + + if (matches.length > 1) { + return { kind: 'ambiguous' as const, matches }; + } + + return { kind: 'match' as const, match: matches[0]! }; +}; + +const getYesNoPreference = ( + preference: FormMappingPreference | null | undefined, + source: 'requiresVisaSponsorship' | 'willingToRelocate' | 'workAuthorization', +) => { + const value = preference?.[source]; + return value === 'yes' || value === 'no' ? value : null; +}; + +const getFiniteNumber = ( + value: number | null | undefined, + options?: { allowZero?: boolean }, +) => + typeof value === 'number' && + Number.isFinite(value) && + (options?.allowZero ? value >= 0 : value > 0) + ? value + : null; + +const formatNumberAnswer = (value: number) => + String(Math.max(0, Math.round(value))); + +const resolveSalaryAmount = ( + questionText: string, + preference: FormMappingPreference | null | undefined, + options?: ResolveOptions, +): { + source: 'applicationSalaryAmount' | 'minSalary'; + value: string; +} | null => { + const normalized = normalizeQuestionText(questionText); + if ( + hasAny(normalized, [ + /\bcurrent salary\b/, + /\blast drawn\b/, + /\bprevious salary\b/, + /\bpresent salary\b/, + ]) + ) { + return null; + } + + if (hasAny(normalized, [/\bhourly\b/, /\bper hour\b/, /\bhour rate\b/])) { + return null; + } + + const applicationAmount = getFiniteNumber( + preference?.applicationSalaryAmount, + ); + if (applicationAmount !== null) { + return { + source: 'applicationSalaryAmount', + value: formatNumberAnswer(applicationAmount), + }; + } + + const minSalary = getFiniteNumber(preference?.minSalary); + if (minSalary === null) { + return null; + } + + const period = /\b(monthly|per month|month)\b/.test(normalized) + ? 'monthly' + : /\b(annual|annually|yearly|per year|year)\b/.test(normalized) + ? 'annual' + : (options?.salaryFallbackPeriod ?? 'annual'); + + return { + source: 'minSalary', + value: formatNumberAnswer( + period === 'monthly' ? minSalary / 12 : minSalary, + ), + }; +}; + +export const resolveTextQuestionAnswer = ( + questionText: string, + preference: FormMappingPreference | null | undefined, + options?: ResolveOptions, +): TextQuestionAnswer => { + const categoryResult = getSingleCategory(questionText); + + if (categoryResult.kind === 'none') { + return { outcome: 'ignore' }; + } + + if (categoryResult.kind === 'ambiguous') { + return { + outcome: 'review', + category: 'ambiguous', + reason: 'Question matches multiple saved preference fields.', + sources: categoryResult.matches.map((match) => match.source), + }; + } + + const { category, source } = categoryResult.match; + + if (category === 'startAvailability') { + return { + outcome: 'review', + category, + reason: + 'Question asks for an exact start date, but saved preference only stores notice period.', + sources: [source], + }; + } + + if ( + source === 'workAuthorization' || + source === 'requiresVisaSponsorship' || + source === 'willingToRelocate' + ) { + const value = getYesNoPreference(preference, source); + return value + ? { + outcome: 'answer', + category, + source, + value, + } + : { + outcome: 'review', + category, + reason: `Saved preference does not include ${source}.`, + sources: [source], + }; + } + + if (source === 'noticePeriodWeeks') { + const value = getFiniteNumber(preference?.noticePeriodWeeks, { + allowZero: true, + }); + return value !== null + ? { + outcome: 'answer', + category, + source, + value: formatNumberAnswer(value), + } + : { + outcome: 'review', + category, + reason: 'Saved preference does not include notice period.', + sources: [source], + }; + } + + if (source === 'yearsExperienceOverride') { + const value = getFiniteNumber(preference?.yearsExperienceOverride); + return value !== null + ? { + outcome: 'answer', + category, + source, + value: formatNumberAnswer(value), + } + : { + outcome: 'review', + category, + reason: + 'Saved preference does not include years of experience override.', + sources: [source], + }; + } + + if (source === 'salaryCurrency') { + const value = preference?.salaryCurrency?.trim().toUpperCase() ?? ''; + return value + ? { + outcome: 'answer', + category, + source, + value, + } + : { + outcome: 'review', + category, + reason: 'Saved preference does not include salary currency.', + sources: [source], + }; + } + + const salary = resolveSalaryAmount(questionText, preference, options); + return salary + ? { + outcome: 'answer', + category, + source: salary.source, + value: salary.value, + } + : { + outcome: 'review', + category, + reason: + 'Saved preference does not include a safe salary answer for this question.', + sources: ['applicationSalaryAmount', 'minSalary'], + }; +}; + +const normalizeChoiceLabel = (value: string) => + normalizeQuestionText(value).replace(/^\W+|\W+$/g, ''); + +const isYesChoice = (label: string) => + /\byes\b/.test(label) || + /\bi am\b/.test(label) || + /\bi do\b/.test(label) || + /\bauthorized\b/.test(label) || + /\bauthorised\b/.test(label) || + /\beligible\b/.test(label) || + /\bwilling\b/.test(label) || + /\bopen to\b/.test(label); + +const isNoChoice = (label: string) => + /\bno\b/.test(label) || + /\bi am not\b/.test(label) || + /\bi do not\b/.test(label) || + /\bdon't\b/.test(label) || + /\bnot willing\b/.test(label) || + /\bunable\b/.test(label); + +const findYesNoChoiceIndex = ( + choiceLabels: string[], + expected: 'yes' | 'no', +) => { + const candidates = choiceLabels.map((label, index) => ({ + index, + label: normalizeChoiceLabel(label), + })); + + return ( + candidates.find((candidate) => { + if (!candidate.label) { + return false; + } + + const yes = isYesChoice(candidate.label); + const no = isNoChoice(candidate.label); + return expected === 'yes' ? yes && !no : no; + })?.index ?? null + ); +}; + +type NumericChoice = { + index: number; + min: number; + max: number; +}; + +const parseNumericChoice = ( + label: string, + index: number, +): NumericChoice | null => { + const normalized = normalizeChoiceLabel(label); + const rawNumbers = normalized.match(/\d+(?:\.\d+)?/g) ?? []; + const numbers = rawNumbers + .map(Number) + .filter((value) => Number.isFinite(value)); + + if (numbers.length === 0) { + return null; + } + + const first = numbers[0]!; + + if (/\b(less than|under|below)\b/.test(normalized)) { + return { index, min: 0, max: first }; + } + + if (/\+|\b(or more|and above|or above|over|more than)\b/.test(normalized)) { + return { index, min: first, max: Number.POSITIVE_INFINITY }; + } + + if (numbers.length >= 2) { + return { + index, + min: Math.min(numbers[0]!, numbers[1]!), + max: Math.max(numbers[0]!, numbers[1]!), + }; + } + + return { index, min: first, max: first }; +}; + +const findNumericChoiceIndex = (choiceLabels: string[], desired: number) => { + const choices = choiceLabels + .map((label, index) => parseNumericChoice(label, index)) + .filter((choice): choice is NumericChoice => choice !== null); + + const containing = choices + .filter((choice) => desired >= choice.min && desired <= choice.max) + .sort((left, right) => { + const leftWidth = left.max - left.min; + const rightWidth = right.max - right.min; + if (leftWidth !== rightWidth) { + return leftWidth - rightWidth; + } + return right.min - left.min; + }); + + if (containing[0]) { + return containing[0].index; + } + + const closestFloor = choices + .filter((choice) => choice.min <= desired) + .sort((left, right) => right.min - left.min); + + return closestFloor[0]?.index ?? null; +}; + +const findCurrencyChoiceIndex = (choiceLabels: string[], currency: string) => { + const normalizedCurrency = normalizeQuestionText(currency); + return choiceLabels.findIndex((label) => { + const normalized = normalizeChoiceLabel(label); + return ( + normalized === normalizedCurrency || + normalized.includes(normalizedCurrency) + ); + }); +}; + +export const resolveChoiceQuestionAnswer = ({ + questionText, + choiceLabels, + preference, + salaryFallbackPeriod, +}: { + questionText: string; + choiceLabels: string[]; + preference?: FormMappingPreference | null; + salaryFallbackPeriod?: SalaryFallbackPeriod; +}): ChoiceQuestionAnswer => { + const textAnswer = resolveTextQuestionAnswer(questionText, preference, { + salaryFallbackPeriod, + }); + + if (textAnswer.outcome !== 'answer') { + return textAnswer; + } + + if ( + textAnswer.source === 'workAuthorization' || + textAnswer.source === 'requiresVisaSponsorship' || + textAnswer.source === 'willingToRelocate' + ) { + const optionIndex = findYesNoChoiceIndex( + choiceLabels, + textAnswer.value as 'yes' | 'no', + ); + return optionIndex !== null + ? { + outcome: 'answer', + category: textAnswer.category, + optionIndex, + source: textAnswer.source, + value: textAnswer.value, + } + : { + outcome: 'review', + category: textAnswer.category, + reason: + 'Could not match saved yes/no preference to the available choices.', + sources: [textAnswer.source], + }; + } + + if ( + textAnswer.source === 'yearsExperienceOverride' || + textAnswer.source === 'noticePeriodWeeks' + ) { + const desired = Number(textAnswer.value); + const optionIndex = findNumericChoiceIndex(choiceLabels, desired); + return optionIndex !== null + ? { + outcome: 'answer', + category: textAnswer.category, + optionIndex, + source: textAnswer.source, + value: textAnswer.value, + } + : { + outcome: 'review', + category: textAnswer.category, + reason: + 'Could not match saved numeric preference to the available choices.', + sources: [textAnswer.source], + }; + } + + if (textAnswer.source === 'salaryCurrency') { + const optionIndex = findCurrencyChoiceIndex(choiceLabels, textAnswer.value); + return optionIndex >= 0 + ? { + outcome: 'answer', + category: textAnswer.category, + optionIndex, + source: textAnswer.source, + value: textAnswer.value, + } + : { + outcome: 'review', + category: textAnswer.category, + reason: + 'Could not match saved salary currency to the available choices.', + sources: [textAnswer.source], + }; + } + + return { + outcome: 'review', + category: textAnswer.category, + reason: 'This mapped question is not safe to answer from a fixed choice.', + sources: [textAnswer.source], + }; +}; diff --git a/apps/extension/src/content/greenhouse.ts b/apps/extension/src/content/greenhouse.ts new file mode 100644 index 0000000..c495dca --- /dev/null +++ b/apps/extension/src/content/greenhouse.ts @@ -0,0 +1,882 @@ +import type { + ExtensionMessage, + PopupState, + WorkerJobPlan, +} from '../shared/messages'; +import { + resolveChoiceQuestionAnswer, + resolveTextQuestionAnswer, +} from './form-mapping'; + +type JobPlan = WorkerJobPlan; + +type BootstrapPayload = { + summary: { + dailyTarget: number; + }; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const runState = { + active: false, + paused: false, +}; + +const contentScriptWindow = window as Window & { + __applypilotGreenhouseListenerRegistered?: boolean; + __applypilotGreenhouseAutoResumeStarted?: boolean; +}; + +const textOf = (element: Element | null | undefined) => + element?.textContent?.trim() ?? ''; +const firstNonEmpty = (...values: Array) => + values + .find((value) => typeof value === 'string' && value.trim().length > 0) + ?.trim() ?? ''; +const normalizeToken = (value: string) => + value.toLowerCase().replace(/\s+/g, ' ').trim(); +const normalizeDigits = (value: string) => value.replace(/[^\d]/g, ''); + +const isTransientRuntimeMessageError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error ?? ''); + return /message channel closed|receiving end does not exist|could not establish connection|extension context invalidated/i.test( + message, + ); +}; + +const sendRuntimeMessage = async ( + message: ExtensionMessage, + retries = 2, +) => { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + return (await chrome.runtime.sendMessage(message)) as TResponse; + } catch (error) { + lastError = + error instanceof Error + ? error + : new Error('ApplyPilot runtime messaging failed.'); + if (!isTransientRuntimeMessageError(error) || attempt === retries) { + throw lastError; + } + + await sleep(250); + } + } + + throw lastError ?? new Error('ApplyPilot runtime messaging failed.'); +}; + +const reportState = async (payload: Partial) => { + try { + await sendRuntimeMessage({ + type: 'applypilot:content-update', + payload, + } satisfies ExtensionMessage); + } catch { + // Keep the application flow moving even if popup state sync briefly drops. + } +}; + +const fetchJson = async ( + path: string, + init?: { method?: 'GET' | 'POST' | 'PUT' | 'PATCH'; body?: unknown }, +) => { + const response = (await sendRuntimeMessage({ + type: 'applypilot:api-request', + path, + method: init?.method, + body: init?.body, + } satisfies ExtensionMessage)) as + | { ok?: true; data?: T } + | { ok?: false; error?: string } + | undefined; + + if (!response || response.ok !== true) { + const errorMessage = + response && 'error' in response ? response.error : undefined; + throw new Error(errorMessage ?? 'ApplyPilot API request failed.'); + } + + return response.data as T; +}; + +const isVisible = (element: Element) => { + const style = window.getComputedStyle(element); + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' + ); +}; + +const getApplicationForm = () => + document.querySelector( + [ + 'form#application-form', + 'form[action*="/jobs/"]', + 'form[action*="/job_applications"]', + 'form', + ].join(', '), + ); + +const getFormControls = () => + Array.from( + (getApplicationForm() ?? document).querySelectorAll< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input, select, textarea'), + ).filter((element) => isVisible(element) && !element.disabled); + +const getAssociatedLabelText = ( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, +) => + element.id + ? textOf(document.querySelector(`label[for="${CSS.escape(element.id)}"]`)) + : ''; + +const getFieldContextText = ( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, +) => + normalizeToken( + [ + getAssociatedLabelText(element), + element.getAttribute('aria-label'), + element.getAttribute('placeholder'), + textOf(element.closest('label')), + textOf(element.closest('fieldset')?.querySelector('legend')), + textOf( + element.closest( + '.field, .form-field, .application-question, .custom-question, .question, [data-qa*="question"]', + ), + ), + element.name, + element.id, + ] + .filter(Boolean) + .join(' '), + ); + +const setFieldValue = ( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(element), + 'value', + )?.set; + descriptor?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + element.dispatchEvent(new Event('blur', { bubbles: true })); +}; + +const triggerElementClick = (element: HTMLElement) => { + element.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ); + element.dispatchEvent( + new MouseEvent('mouseup', { bubbles: true, cancelable: true }), + ); + element.click(); +}; + +const isRequiredControl = ( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, +) => + element.required || + element.getAttribute('aria-required') === 'true' || + Boolean(element.closest('.required')) || + /\*/.test( + firstNonEmpty( + getAssociatedLabelText(element), + textOf(element.closest('.field, .form-field')), + ), + ); + +const resolveGreenhouseTextAnswer = ( + context: string, + preference?: BootstrapPayload['preference'] | null, +) => + resolveTextQuestionAnswer(context, preference, { + salaryFallbackPeriod: 'annual', + }); + +const getPreferredLocationValue = ({ + profile, + preference, +}: { + profile: BootstrapPayload['profile']; + preference?: BootstrapPayload['preference'] | null; +}) => firstNonEmpty(preference?.regions?.[0], profile?.location); + +const fillStandardFields = (profile: BootstrapPayload['profile']) => { + if (!profile) { + return; + } + + const fullName = profile.fullName?.trim() ?? ''; + const [firstName = '', ...restNames] = fullName.split(/\s+/); + const lastName = restNames.join(' '); + + for (const control of getFormControls()) { + if ( + !( + control instanceof HTMLInputElement || + control instanceof HTMLTextAreaElement + ) || + control.value + ) { + continue; + } + + const context = getFieldContextText(control); + + if (/email/.test(context) && profile.email) { + setFieldValue(control, profile.email); + continue; + } + + if (/phone|mobile|telephone/.test(context) && profile.phone) { + setFieldValue(control, profile.phone); + continue; + } + + if (/first name|given name/.test(context) && firstName) { + setFieldValue(control, firstName); + continue; + } + + if ( + /last name|family name|surname/.test(context) && + (lastName || firstName) + ) { + setFieldValue(control, lastName || firstName); + continue; + } + + if (/full name|name/.test(context) && fullName) { + setFieldValue(control, fullName); + } + } +}; + +const fillMappedTextFields = ({ + profile, + preference, +}: { + profile: BootstrapPayload['profile']; + preference?: BootstrapPayload['preference'] | null; +}) => { + const preferredLocation = getPreferredLocationValue({ profile, preference }); + + for (const control of getFormControls()) { + if ( + !( + control instanceof HTMLInputElement || + control instanceof HTMLTextAreaElement + ) || + control.value + ) { + continue; + } + + if (control.type === 'file') { + continue; + } + + const context = getFieldContextText(control); + + if (/location|city|area/.test(context) && preferredLocation) { + setFieldValue(control, preferredLocation); + continue; + } + + const inferred = resolveGreenhouseTextAnswer(context, preference); + if ( + inferred.outcome === 'answer' && + /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text') + ) { + setFieldValue(control, inferred.value); + } + } +}; + +const getRadioGroups = () => { + const radios = getFormControls().filter( + (element): element is HTMLInputElement => + element instanceof HTMLInputElement && element.type === 'radio', + ); + const groups = new Map(); + + for (const radio of radios) { + const groupKey = + firstNonEmpty( + radio.name, + textOf(radio.closest('fieldset')?.querySelector('legend')), + textOf( + radio.closest( + '.field, .form-field, .application-question, .custom-question', + ), + ), + radio.id, + ) || `radio-group-${groups.size}`; + + const current = groups.get(groupKey) ?? []; + current.push(radio); + groups.set(groupKey, current); + } + + return Array.from(groups.values()); +}; + +const getRadioLabelText = (radio: HTMLInputElement) => + normalizeToken( + firstNonEmpty( + textOf(radio.closest('label')), + radio.id + ? textOf(document.querySelector(`label[for="${CSS.escape(radio.id)}"]`)) + : '', + radio.getAttribute('aria-label'), + radio.value, + ), + ); + +const getRadioQuestionText = (radios: HTMLInputElement[]) => + normalizeToken( + firstNonEmpty( + textOf(radios[0]?.closest('fieldset')?.querySelector('legend')), + textOf( + radios[0]?.closest( + '.field, .form-field, .application-question, .custom-question', + ), + ), + radios[0]?.name, + ), + ); + +const fillMappedChoiceFields = ( + preference?: BootstrapPayload['preference'] | null, +) => { + for (const control of getFormControls()) { + if (!(control instanceof HTMLSelectElement) || control.value) { + continue; + } + + const answer = resolveChoiceQuestionAnswer({ + questionText: getFieldContextText(control), + choiceLabels: Array.from(control.options).map((option) => + firstNonEmpty(option.label, option.text, option.value), + ), + preference, + salaryFallbackPeriod: 'annual', + }); + + if (answer.outcome === 'answer' && control.options[answer.optionIndex]) { + control.selectedIndex = answer.optionIndex; + control.dispatchEvent(new Event('change', { bubbles: true })); + } + } + + for (const radios of getRadioGroups()) { + if (radios.some((radio) => radio.checked)) { + continue; + } + + const answer = resolveChoiceQuestionAnswer({ + questionText: getRadioQuestionText(radios), + choiceLabels: radios.map(getRadioLabelText), + preference, + salaryFallbackPeriod: 'annual', + }); + + const radio = + answer.outcome === 'answer' ? radios[answer.optionIndex] : null; + if (radio) { + triggerElementClick(radio); + } + } +}; + +const fillCurrentForm = ({ + profile, + preference, +}: { + profile: BootstrapPayload['profile']; + preference?: BootstrapPayload['preference'] | null; +}) => { + fillStandardFields(profile); + fillMappedTextFields({ profile, preference }); + fillMappedChoiceFields(preference); + + const unresolvedRequiredControls = getFormControls().filter((element) => { + if (!isRequiredControl(element)) { + return false; + } + + if (element instanceof HTMLInputElement && element.type === 'radio') { + return false; + } + + if (element instanceof HTMLInputElement && element.type === 'file') { + return !element.files?.length; + } + + return ( + normalizeDigits(element.value).length === 0 && + element.value.trim().length === 0 + ); + }); + + const unresolvedRequiredRadioGroups = getRadioGroups().filter( + (radios) => + radios.some((radio) => isRequiredControl(radio)) && + !radios.some((radio) => radio.checked), + ); + + return ( + unresolvedRequiredControls.length === 0 && + unresolvedRequiredRadioGroups.length === 0 + ); +}; + +const downloadResumeFile = async (url: string, preferredFileName: string) => { + const response = await fetch(url); + const blob = await response.blob(); + const fallbackName = `${preferredFileName.replace(/[^a-z0-9.]+/gi, '-').toLowerCase() || 'resume'}`; + const fileName = + fallbackName.endsWith('.pdf') || fallbackName.endsWith('.docx') + ? fallbackName + : `${fallbackName}.pdf`; + + return new File([blob], fileName, { + type: blob.type || 'application/pdf', + }); +}; + +const uploadResumeIfNeeded = async ({ + resumeUrl, + resumeFileName, + jobTitle, +}: { + resumeUrl: string | null; + resumeFileName: string | null; + jobTitle: string; +}) => { + if (!resumeUrl) { + return; + } + + const fileInput = + getApplicationForm()?.querySelector('input[type="file"]'); + if (!fileInput || fileInput.files?.length) { + return; + } + + const file = await downloadResumeFile(resumeUrl, resumeFileName ?? jobTitle); + const transfer = new DataTransfer(); + transfer.items.add(file); + fileInput.files = transfer.files; + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + await sleep(800); +}; + +const findSubmitButton = () => + Array.from( + (getApplicationForm() ?? document).querySelectorAll( + 'button[type="submit"], input[type="submit"], #submit_app', + ), + ).find( + (element) => isVisible(element) && !element.hasAttribute('disabled'), + ) ?? null; + +const hasSuccessState = () => + /thank you for applying|application submitted|we have received your application|application received/i.test( + textOf(document.body), + ); + +const fetchBootstrap = () => + fetchJson('/api/dashboard/summary'); + +const postReview = async (attemptId: string, reason: string) => { + await fetchJson(`/api/applications/${attemptId}/review`, { + method: 'POST', + body: { reason }, + }); +}; + +const postStatus = async (attemptId: string, status: string) => { + await fetchJson(`/api/applications/${attemptId}/status`, { + method: 'PATCH', + body: { status }, + }); +}; + +const postReceipt = async (attemptId: string) => { + const screenshot = (await sendRuntimeMessage<{ + dataUrl?: string; + }>({ + type: 'applypilot:request-screenshot', + } satisfies ExtensionMessage)) as { dataUrl?: string } | undefined; + + if (!screenshot?.dataUrl) { + return; + } + + await fetchJson(`/api/applications/${attemptId}/receipt`, { + method: 'POST', + body: { + dataUrl: screenshot.dataUrl, + }, + }); +}; + +const processPlan = async ({ + plan, + profile, + preference, +}: { + plan: JobPlan; + profile: BootstrapPayload['profile']; + preference?: BootstrapPayload['preference'] | null; +}) => { + const form = getApplicationForm(); + if (!form) { + const reason = 'Greenhouse application form was not found.'; + await postReview(plan.attempt.id, reason); + await reportState({ + pendingReviewCount: 1, + recentResult: reason, + }); + return { ok: false as const, reason }; + } + + fillCurrentForm({ profile, preference }); + await uploadResumeIfNeeded({ + resumeUrl: plan.resumeUploadUrl ?? plan.tailoredResumeUrl, + resumeFileName: plan.resumeFileName, + jobTitle: plan.job.title, + }); + + const resolved = fillCurrentForm({ profile, preference }); + if (!resolved) { + const reason = `Paused on Greenhouse required questions for ${plan.job.company}`; + await postReview(plan.attempt.id, 'Unresolved required questions'); + await reportState({ + pendingReviewCount: 1, + recentResult: reason, + }); + return { ok: false as const, reason }; + } + + const submitButton = findSubmitButton(); + if (!submitButton) { + const reason = 'Greenhouse submit action was not found.'; + await postReview(plan.attempt.id, reason); + await reportState({ + pendingReviewCount: 1, + recentResult: reason, + }); + return { ok: false as const, reason }; + } + + triggerElementClick(submitButton); + await sleep(2500); + + if (!hasSuccessState()) { + const reason = `Manual Greenhouse finish needed for ${plan.job.company}`; + await postReview(plan.attempt.id, reason); + await reportState({ + pendingReviewCount: 1, + recentResult: reason, + }); + return { ok: false as const, reason }; + } + + await postStatus(plan.attempt.id, 'submitted'); + await postReceipt(plan.attempt.id); + await reportState({ + dailySubmitted: 1, + recentResult: `Submitted ${plan.job.title} at ${plan.job.company}`, + }); + return { ok: true as const }; +}; + +const slugify = (value: string) => + normalizeToken(value) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'greenhouse-role'; + +const extractCurrentJob = () => { + const title = firstNonEmpty( + textOf(document.querySelector('h1')), + textOf(document.querySelector('[data-qa="job-title"]')), + document.title.split('|')[0], + ); + const company = firstNonEmpty( + document.querySelector('meta[property="og:site_name"]') + ?.content, + textOf( + document.querySelector( + '[data-qa="company-name"], .company-name, .app-title', + ), + ), + document.title.split('|')[1], + 'Greenhouse company', + ); + const location = firstNonEmpty( + textOf( + document.querySelector( + '[data-qa="job-location"], .location, .job__location', + ), + ), + textOf(document.querySelector('[class*="location"]')), + ); + const description = firstNonEmpty( + textOf( + document.querySelector( + '[data-qa="job-description"], .job__description, #content, main', + ), + ), + textOf(document.body), + ); + const externalJobId = + window.location.pathname.match(/\/jobs\/([A-Za-z0-9-]+)/i)?.[1] ?? + new URLSearchParams(window.location.search).get('gh_jid') ?? + slugify(title); + + return { + source: 'greenhouse' as const, + externalJobId, + title: title || 'Greenhouse role', + company, + location, + url: window.location.href, + description, + easyApply: true, + detectedQuestions: [], + }; +}; + +const startServerRun = async ({ + jobs, + targetCount, +}: { + jobs: Array>; + targetCount: number; +}) => + fetchJson<{ + run: { id: string }; + plans: JobPlan[]; + }>('/api/runs/start', { + method: 'POST', + body: { + source: 'greenhouse', + targetCount, + jobs, + }, + }); + +const runOnPage = async ({ targetCount }: { targetCount: number }) => { + runState.active = true; + runState.paused = false; + + await reportState({ + runStatus: 'running', + recentResult: 'Scanning Greenhouse application page...', + }); + + const job = extractCurrentJob(); + const { run, plans } = await startServerRun({ + jobs: [job], + targetCount: 1, + }); + const plan = plans[0] ?? null; + + if (!plan) { + throw new Error('No Greenhouse job could be queued from this page.'); + } + + await reportState({ + activeRunId: run.id, + runStatus: 'running', + recentResult: `Queued ${plan.job.title} on Greenhouse`, + }); + + const bootstrap = await fetchBootstrap(); + const result = await processPlan({ + plan, + profile: bootstrap.profile, + preference: bootstrap.preference, + }); + + await reportState({ + activeRunId: null, + runStatus: result.ok ? 'completed' : 'failed', + recentResult: result.ok ? 'Run completed' : result.reason, + }); +}; + +const executePlanOnCurrentPage = async ({ + plan, +}: { + apiBaseUrl: string; + plan: JobPlan; +}) => { + const bootstrap = await fetchBootstrap(); + return processPlan({ + plan, + profile: bootstrap.profile, + preference: bootstrap.preference, + }); +}; + +const maybeResumePendingWorkerPlan = async () => { + if (contentScriptWindow.__applypilotGreenhouseAutoResumeStarted) { + return; + } + contentScriptWindow.__applypilotGreenhouseAutoResumeStarted = true; + + try { + const pending = (await sendRuntimeMessage<{ + ok?: boolean; + apiBaseUrl?: string; + plan?: JobPlan; + }>({ + type: 'applypilot:get-pending-worker-plan', + } satisfies ExtensionMessage)) as + | { ok?: boolean; apiBaseUrl?: string; plan?: JobPlan } + | undefined; + + if (!pending?.ok || !pending.apiBaseUrl || !pending.plan) { + return; + } + + await reportState({ + runStatus: 'running', + recentResult: `Continuing ${pending.plan.job.title} on Greenhouse`, + }); + + const result = await executePlanOnCurrentPage({ + apiBaseUrl: pending.apiBaseUrl, + plan: pending.plan, + }); + + if (!result.ok) { + throw new Error(result.reason); + } + + await sendRuntimeMessage({ + type: 'applypilot:worker-plan-finished', + status: 'completed', + } satisfies ExtensionMessage); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'Greenhouse worker run failed unexpectedly.'; + + try { + await sendRuntimeMessage({ + type: 'applypilot:worker-plan-finished', + status: 'failed', + error: messageText, + } satisfies ExtensionMessage); + } catch { + // Keep the worker tab visible for manual recovery if the background channel tears down. + } + } +}; + +if (!contentScriptWindow.__applypilotGreenhouseListenerRegistered) { + contentScriptWindow.__applypilotGreenhouseListenerRegistered = true; + + chrome.runtime.onMessage.addListener( + (message: ExtensionMessage, _sender, sendResponse) => { + void (async () => { + if (message.type === 'applypilot:ping') { + sendResponse({ ok: true }); + return; + } + + if (message.type === 'applypilot:collect-current-job-on-page') { + sendResponse({ ok: true, job: extractCurrentJob() }); + return; + } + + if (message.type === 'applypilot:start-run-on-page') { + try { + await runOnPage({ + targetCount: message.targetCount, + }); + sendResponse({ ok: true }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'ApplyPilot could not run on this Greenhouse page.'; + await reportState({ + runStatus: 'failed', + recentResult: messageText, + }); + sendResponse({ ok: false, error: messageText }); + } + return; + } + + if (message.type === 'applypilot:execute-plan-on-page') { + try { + const result = await executePlanOnCurrentPage({ + apiBaseUrl: message.apiBaseUrl, + plan: message.plan, + }); + sendResponse( + result.ok ? { ok: true } : { ok: false, error: result.reason }, + ); + } catch (error) { + sendResponse({ + ok: false, + error: + error instanceof Error + ? error.message + : 'Greenhouse execution failed.', + }); + } + return; + } + + if (message.type === 'applypilot:pause-run-on-page') { + runState.paused = true; + runState.active = false; + sendResponse({ ok: true }); + return; + } + })(); + + return true; + }, + ); +} + +void maybeResumePendingWorkerPlan(); diff --git a/apps/extension/src/content/linkedin.ts b/apps/extension/src/content/linkedin.ts index 426106d..a748a2e 100644 --- a/apps/extension/src/content/linkedin.ts +++ b/apps/extension/src/content/linkedin.ts @@ -1,4 +1,12 @@ -import type { ExtensionMessage, PopupState, WorkerJobPlan } from '../shared/messages'; +import type { + ExtensionMessage, + PopupState, + WorkerJobPlan, +} from '../shared/messages'; +import { + resolveChoiceQuestionAnswer, + resolveTextQuestionAnswer, +} from './form-mapping'; type JobPlan = WorkerJobPlan; @@ -6,26 +14,26 @@ type BootstrapPayload = { summary: { dailyTarget: number; }; - profile: { - fullName: string; - phone: string; - email: string; - location: string; - yearsExperience?: number; - } | null; - preference?: { - targetRoles?: string[]; - regions?: string[]; - minSalary?: number; - salaryCurrency?: string; - applicationSalaryAmount?: number; - yearsExperienceOverride?: number | null; - noticePeriodWeeks?: number | null; - workAuthorization?: 'yes' | 'no' | 'unknown'; - requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; - willingToRelocate?: 'yes' | 'no' | 'unknown'; - } | null; - }; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; +}; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -55,14 +63,20 @@ const isTransientRuntimeMessageError = (error: unknown) => { ); }; -const sendRuntimeMessage = async (message: ExtensionMessage, retries = 2) => { +const sendRuntimeMessage = async ( + message: ExtensionMessage, + retries = 2, +) => { let lastError: Error | null = null; for (let attempt = 0; attempt <= retries; attempt += 1) { try { return (await chrome.runtime.sendMessage(message)) as TResponse; } catch (error) { - lastError = error instanceof Error ? error : new Error('ApplyPilot runtime messaging failed.'); + lastError = + error instanceof Error + ? error + : new Error('ApplyPilot runtime messaging failed.'); if (!isTransientRuntimeMessageError(error) || attempt === retries) { throw lastError; } @@ -100,17 +114,22 @@ const fetchJson = async ( | undefined; if (!response || response.ok !== true) { - const errorMessage = response && 'error' in response ? response.error : undefined; + const errorMessage = + response && 'error' in response ? response.error : undefined; throw new Error(errorMessage ?? 'ApplyPilot API request failed.'); } return response.data as T; }; -const textOf = (element: Element | null | undefined) => element?.textContent?.trim() ?? ''; +const textOf = (element: Element | null | undefined) => + element?.textContent?.trim() ?? ''; const firstNonEmpty = (...values: Array) => - values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() ?? ''; -const normalizeToken = (value: string) => value.toLowerCase().replace(/\s+/g, ' ').trim(); + values + .find((value) => typeof value === 'string' && value.trim().length > 0) + ?.trim() ?? ''; +const normalizeToken = (value: string) => + value.toLowerCase().replace(/\s+/g, ' ').trim(); const normalizeDigits = (value: string) => value.replace(/[^\d]/g, ''); const GENERIC_CARD_LINE_PATTERN = /^(easy apply|快速申请|抢先申请|保存|save|已查看|viewed|积极审核申请者|actively reviewing applicants|由招聘者推广|promoted|超过.*位申请者|over \d+ applicants|申请已提交|已申请|apply|混合办公|现场办公|远程办公|全职|兼职|contract|full-time|part-time|hybrid|remote|on-site)$/i; @@ -119,15 +138,23 @@ const isVisible = (element: Element) => { const rect = element.getBoundingClientRect(); const htmlElement = element as HTMLElement; const style = window.getComputedStyle(htmlElement); - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + return ( + rect.width > 0 && + rect.height > 0 && + style.visibility !== 'hidden' && + style.display !== 'none' + ); }; const getHeuristicDialogContainers = () => { const candidates = new Set(); - const actionPattern = /next|review|continue|preview|submit|done|下一|继续|审核|预览|查看|提交|完成/i; + const actionPattern = + /next|review|continue|preview|submit|done|下一|继续|审核|预览|查看|提交|完成/i; const fieldPattern = /email|phone|mobile|联系电话|联系方式/i; - Array.from(document.querySelectorAll('button, a, [role="button"]')) + Array.from( + document.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button)) .forEach((button) => { const label = firstNonEmpty( @@ -153,8 +180,7 @@ const getHeuristicDialogContainers = () => { 'section', 'main', ].join(', '), - ) ?? - button.parentElement; + ) ?? button.parentElement; if ( container && @@ -166,7 +192,11 @@ const getHeuristicDialogContainers = () => { } }); - Array.from(document.querySelectorAll('input, textarea')) + Array.from( + document.querySelectorAll( + 'input, textarea', + ), + ) .filter((field) => isVisible(field)) .forEach((field) => { const context = firstNonEmpty( @@ -174,7 +204,11 @@ const getHeuristicDialogContainers = () => { field.getAttribute('name'), field.getAttribute('placeholder'), textOf(field.closest('label')), - field.id ? textOf(document.querySelector(`label[for="${CSS.escape(field.id)}"]`)) : '', + field.id + ? textOf( + document.querySelector(`label[for="${CSS.escape(field.id)}"]`), + ) + : '', ); if (!fieldPattern.test(context)) { return; @@ -194,8 +228,7 @@ const getHeuristicDialogContainers = () => { 'section', 'main', ].join(', '), - ) ?? - field.parentElement; + ) ?? field.parentElement; if ( container && @@ -239,9 +272,9 @@ const getVisibleDialogCandidates = () => { const getDialogFormControlsFor = (dialog: ParentNode) => Array.from( - dialog.querySelectorAll( - 'input, select, textarea', - ), + dialog.querySelectorAll< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input, select, textarea'), ).filter((element) => isVisible(element) && !element.disabled); const getDialogTitle = (dialog: ParentNode) => @@ -260,7 +293,9 @@ const getPrimaryApplicationDialog = () => { dialogs.sort((left, right) => { const leftText = normalizeToken(textOf(left)); const rightText = normalizeToken(textOf(right)); - const leftActionCount = Array.from(left.querySelectorAll('button, a, [role="button"]')).filter( + const leftActionCount = Array.from( + left.querySelectorAll('button, a, [role="button"]'), + ).filter( (button) => isVisible(button) && /next|review|continue|preview|submit|done|下一|继续|审核|预览|查看|提交|完成/.test( @@ -285,7 +320,8 @@ const getPrimaryApplicationDialog = () => { getDialogFormControlsFor(left).length * 1000 + leftActionCount * 250 + left.querySelectorAll('button, a, [role="button"]').length * 10 + - left.getBoundingClientRect().width * left.getBoundingClientRect().height; + left.getBoundingClientRect().width * + left.getBoundingClientRect().height; const rightScore = (/easy apply|快速申请|contact info|联系方式|resume|additional questions|review|审核|提交申请|application submitted|已发送申请/.test( rightText, @@ -295,7 +331,8 @@ const getPrimaryApplicationDialog = () => { getDialogFormControlsFor(right).length * 1000 + rightActionCount * 250 + right.querySelectorAll('button, a, [role="button"]').length * 10 + - right.getBoundingClientRect().width * right.getBoundingClientRect().height; + right.getBoundingClientRect().width * + right.getBoundingClientRect().height; return rightScore - leftScore; })[0] ?? null @@ -343,7 +380,10 @@ const isLikelySearchResultsCard = (card: HTMLElement) => { return false; } - if (card.hasAttribute('data-job-id') || card.hasAttribute('data-occludable-job-id')) { + if ( + card.hasAttribute('data-job-id') || + card.hasAttribute('data-occludable-job-id') + ) { return true; } @@ -440,7 +480,8 @@ const getPrimaryJobLink = (card: HTMLElement) => const label = normalizeToken(textOf(anchor)); return label.length > 0 && !GENERIC_CARD_LINE_PATTERN.test(label); }) - .sort((left, right) => textOf(right).length - textOf(left).length)[0] ?? null; + .sort((left, right) => textOf(right).length - textOf(left).length)[0] ?? + null; const getJobCards = (limit: number) => { const primaryCardSelectors = [ @@ -457,17 +498,34 @@ const getJobCards = (limit: number) => { 'article[data-occludable-job-id]', ].join(', '); - const primaryCandidates = Array.from(document.querySelectorAll(primaryCardSelectors)).filter( - (card) => isVisible(card) && isLikelySearchResultsCard(card) && Boolean(getPrimaryJobLink(card)), + const primaryCandidates = Array.from( + document.querySelectorAll(primaryCardSelectors), + ).filter( + (card) => + isVisible(card) && + isLikelySearchResultsCard(card) && + Boolean(getPrimaryJobLink(card)), ); const secondaryCandidates = Array.from( document.querySelectorAll(secondaryCardSelectors), - ).filter((card) => isVisible(card) && isLikelySearchResultsCard(card) && Boolean(getPrimaryJobLink(card))); - const anchorCandidates = Array.from(document.querySelectorAll('a[href*="/jobs/view/"]')) + ).filter( + (card) => + isVisible(card) && + isLikelySearchResultsCard(card) && + Boolean(getPrimaryJobLink(card)), + ); + const anchorCandidates = Array.from( + document.querySelectorAll('a[href*="/jobs/view/"]'), + ) .filter((anchor) => isLikelySearchResultsLink(anchor)) .map((anchor) => findCardContainerFromLink(anchor)) .filter((card): card is HTMLElement => card instanceof HTMLElement) - .filter((card) => isVisible(card) && isLikelySearchResultsCard(card) && Boolean(getPrimaryJobLink(card))); + .filter( + (card) => + isVisible(card) && + isLikelySearchResultsCard(card) && + Boolean(getPrimaryJobLink(card)), + ); const candidates = [ ...(primaryCandidates.length > 0 ? primaryCandidates : secondaryCandidates), @@ -479,7 +537,8 @@ const getJobCards = (limit: number) => { const occludableJobId = card.getAttribute('data-occludable-job-id'); const titleLink = getPrimaryJobLink(card); - const uniqueKey = jobId ?? occludableJobId ?? titleLink?.href ?? textOf(card); + const uniqueKey = + jobId ?? occludableJobId ?? titleLink?.href ?? textOf(card); if (!uniqueKey) { return all.indexOf(card) === index; @@ -490,7 +549,12 @@ const getJobCards = (limit: number) => { const itemOccludableJobId = item.getAttribute('data-occludable-job-id'); const itemTitleLink = getPrimaryJobLink(item); - return (itemJobId ?? itemOccludableJobId ?? itemTitleLink?.href ?? textOf(item)) === uniqueKey; + return ( + (itemJobId ?? + itemOccludableJobId ?? + itemTitleLink?.href ?? + textOf(item)) === uniqueKey + ); }); return firstIndex === index; @@ -543,8 +607,14 @@ const describeJobCardSurface = () => { const getDetailPaneFingerprint = () => firstNonEmpty( - textOf(document.querySelector('.job-details-jobs-unified-top-card__job-title')), - textOf(document.querySelector('.job-details-jobs-unified-top-card__company-name')), + textOf( + document.querySelector('.job-details-jobs-unified-top-card__job-title'), + ), + textOf( + document.querySelector( + '.job-details-jobs-unified-top-card__company-name', + ), + ), textOf(document.querySelector('.jobs-unified-top-card__job-title')), new URL(window.location.href).searchParams.get('currentJobId') ?? '', window.location.pathname, @@ -615,8 +685,7 @@ const clickIntoJobCard = async (card: HTMLElement) => { '.job-card-list', '.artdeco-entity-lockup', ].join(', '), - ) ?? - card; + ) ?? card; triggerElementClick(clickable); await waitForDetailPaneUpdate({ @@ -692,16 +761,26 @@ const ensureJobSelectedOnPage = async ({ return false; }; -const isSearchResultsRoute = () => /\/jobs\/search(?:-results)?/.test(window.location.pathname); +const isSearchResultsRoute = () => + /\/jobs\/search(?:-results)?/.test(window.location.pathname); -const restoreSearchResultsView = async (searchResultsUrl: string, targetCount: number) => { - if (!isSearchResultsRoute() || getJobCards(Math.max(1, targetCount)).length === 0) { +const restoreSearchResultsView = async ( + searchResultsUrl: string, + targetCount: number, +) => { + if ( + !isSearchResultsRoute() || + getJobCards(Math.max(1, targetCount)).length === 0 + ) { window.location.href = searchResultsUrl; } const startedAt = Date.now(); while (Date.now() - startedAt < 15000) { - if (isSearchResultsRoute() && getJobCards(Math.max(1, targetCount)).length > 0) { + if ( + isSearchResultsRoute() && + getJobCards(Math.max(1, targetCount)).length > 0 + ) { return true; } @@ -721,15 +800,18 @@ const extractJobFromCard = async (card: HTMLElement) => { ); const link = - getPrimaryJobLink(card) ?? card.querySelector('a.job-card-container__link'); + getPrimaryJobLink(card) ?? + card.querySelector('a.job-card-container__link'); const externalJobId = link?.href.match(/\/jobs\/view\/(\d+)/)?.[1] ?? card.getAttribute('data-job-id') ?? card.getAttribute('data-occludable-job-id') ?? - card.closest('[data-job-id], [data-occludable-job-id]')?.getAttribute('data-job-id') ?? - card.closest('[data-job-id], [data-occludable-job-id]')?.getAttribute( - 'data-occludable-job-id', - ) ?? + card + .closest('[data-job-id], [data-occludable-job-id]') + ?.getAttribute('data-job-id') ?? + card + .closest('[data-job-id], [data-occludable-job-id]') + ?.getAttribute('data-occludable-job-id') ?? String(Date.now()); const title = firstNonEmpty( textOf( @@ -756,7 +838,9 @@ const extractJobFromCard = async (card: HTMLElement) => { '.job-card-container__metadata-item, .job-card-container__metadata-wrapper li, .artdeco-entity-lockup__caption', ), ), - meaningfulCardTextLines.find((line) => line !== title && line !== company) ?? '', + meaningfulCardTextLines.find( + (line) => line !== title && line !== company, + ) ?? '', ); const cardText = normalizeToken(cardTextLines.join(' ')); const detailDescription = textOf( @@ -778,7 +862,10 @@ const extractJobFromCard = async (card: HTMLElement) => { company, location, url: link?.href ?? `https://www.linkedin.com/jobs/view/${externalJobId}/`, - description: detailDescription || meaningfulCardTextLines.join(' | ') || 'Description unavailable', + description: + detailDescription || + meaningfulCardTextLines.join(' | ') || + 'Description unavailable', easyApply: /easy apply|快速申请|抢先申请/.test(cardText), detectedQuestions: [], }; @@ -818,13 +905,13 @@ const extractSelectedJobFromDetails = () => { detailTopCardLines[0], 'Untitled role', ); - const companyLine = detailTopCardLines.find((line) => line !== title && !GENERIC_CARD_LINE_PATTERN.test(normalizeToken(line))) ?? ''; + const companyLine = + detailTopCardLines.find( + (line) => + line !== title && !GENERIC_CARD_LINE_PATTERN.test(normalizeToken(line)), + ) ?? ''; const companyFromLine = companyLine.split(/[•·]/)[0]?.trim() ?? ''; - const locationFromLine = companyLine - .split(/[•·]/) - .slice(1) - .join(' ') - .trim(); + const locationFromLine = companyLine.split(/[•·]/).slice(1).join(' ').trim(); const company = firstNonEmpty( textOf( document.querySelector( @@ -951,7 +1038,9 @@ const isUsableExtractedJob = ( } if (!normalizedCompany || normalizedCompany === 'unknown company') { - return Boolean(options?.allowUnknownCompany && job.externalJobId && job.easyApply); + return Boolean( + options?.allowUnknownCompany && job.externalJobId && job.easyApply, + ); } return true; @@ -985,9 +1074,10 @@ const downloadResumeFile = async (url: string, preferredFileName: string) => { const response = await fetch(url); const blob = await response.blob(); const fallbackName = `${preferredFileName.replace(/[^a-z0-9.]+/gi, '-').toLowerCase() || 'resume'}`; - const fileName = fallbackName.endsWith('.pdf') || fallbackName.endsWith('.docx') - ? fallbackName - : `${fallbackName}.pdf`; + const fileName = + fallbackName.endsWith('.pdf') || fallbackName.endsWith('.docx') + ? fallbackName + : `${fallbackName}.pdf`; return new File([blob], fileName, { type: 'application/pdf', @@ -1007,7 +1097,10 @@ const getDialogFormControls = () => { return getDialogFormControlsFor(dialog); }; -const setFieldValue = (element: HTMLInputElement | HTMLTextAreaElement, value: string) => { +const setFieldValue = ( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) => { const nextValue = value ?? ''; if (element instanceof HTMLInputElement) { if (nativeInputValueSetter) { @@ -1032,7 +1125,13 @@ const setFieldValue = (element: HTMLInputElement | HTMLTextAreaElement, value: s } } - element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' })); + element.dispatchEvent( + new InputEvent('input', { + bubbles: true, + data: nextValue, + inputType: 'insertText', + }), + ); element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new Event('blur', { bubbles: true })); }; @@ -1059,7 +1158,10 @@ const scrollDialogByViewport = async (direction: 'down' | 'up') => { const delta = Math.max(240, Math.floor(container.clientHeight * 0.8)); const targetTop = direction === 'down' - ? Math.min(container.scrollHeight - container.clientHeight, container.scrollTop + delta) + ? Math.min( + container.scrollHeight - container.clientHeight, + container.scrollTop + delta, + ) : Math.max(0, container.scrollTop - delta); if (targetTop === container.scrollTop) { @@ -1121,10 +1223,19 @@ const fillStandardFields = (profile: BootstrapPayload['profile']) => { } }; -const getFieldContextText = (element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => { +const getFieldContextText = ( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, +) => { const labelFromFor = - element.id && document.querySelector(`label[for="${CSS.escape(element.id)}"]`) - ? textOf(document.querySelector(`label[for="${CSS.escape(element.id)}"]`)) + element.id && + document.querySelector( + `label[for="${CSS.escape(element.id)}"]`, + ) + ? textOf( + document.querySelector( + `label[for="${CSS.escape(element.id)}"]`, + ), + ) : ''; return normalizeToken( @@ -1159,46 +1270,13 @@ const getPreferredLocationValue = ({ jobLocation.split(' ')[0], ); -const getPreferredSalaryValue = (preference?: BootstrapPayload['preference'] | null) => { - if (preference?.applicationSalaryAmount && preference.applicationSalaryAmount > 0) { - return String(preference.applicationSalaryAmount); - } - - if (preference?.minSalary && preference.minSalary > 0) { - return String(Math.max(1, Math.round(preference.minSalary / 12))); - } - - return ''; -}; - -const getPreferredYearsValue = ( - preference?: BootstrapPayload['preference'] | null, - profile?: BootstrapPayload['profile'], -) => { - const years = preference?.yearsExperienceOverride ?? profile?.yearsExperience ?? null; - - return typeof years === 'number' && Number.isFinite(years) && years > 0 ? String(years) : ''; -}; - -const getNoticePeriodValue = (preference?: BootstrapPayload['preference'] | null) => { - const weeks = preference?.noticePeriodWeeks; - - return typeof weeks === 'number' && Number.isFinite(weeks) && weeks >= 0 ? String(weeks) : ''; -}; - -const inferYearsAnswer = ( +const resolveLinkedInTextAnswer = ( context: string, preference?: BootstrapPayload['preference'] | null, - profile?: BootstrapPayload['profile'], -) => { - const normalized = normalizeToken(context); - - if (!/(years|experience|经验|多久)/.test(normalized)) { - return ''; - } - - return getPreferredYearsValue(preference, profile); -}; +) => + resolveTextQuestionAnswer(context, preference, { + salaryFallbackPeriod: 'monthly', + }); const looksLikeSuspiciousFullName = (value: string) => value.trim().split(/\s+/).length > 5 || @@ -1219,11 +1297,19 @@ const fillKnownTextFields = ({ const fullName = looksLikeSuspiciousFullName(rawFullName) ? '' : rawFullName; const [firstName = '', ...restNames] = fullName.split(/\s+/); const lastName = restNames.join(' '); - const preferredLocation = getPreferredLocationValue({ profile, preference, jobLocation }); - const preferredSalary = getPreferredSalaryValue(preference); + const preferredLocation = getPreferredLocationValue({ + profile, + preference, + jobLocation, + }); for (const control of getDialogFormControls()) { - if (!(control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) { + if ( + !( + control instanceof HTMLInputElement || + control instanceof HTMLTextAreaElement + ) + ) { continue; } @@ -1238,7 +1324,10 @@ const fillKnownTextFields = ({ continue; } - if (/last name|family name|surname|姓氏/.test(context) && (lastName || firstName)) { + if ( + /last name|family name|surname|姓氏/.test(context) && + (lastName || firstName) + ) { setFieldValue(control, lastName || firstName); continue; } @@ -1248,14 +1337,12 @@ const fillKnownTextFields = ({ continue; } - if (/salary|compensation|pay|薪资|薪酬/.test(context) && preferredSalary) { - setFieldValue(control, preferredSalary); - continue; - } - - const inferredYears = inferYearsAnswer(context, preference, profile); - if (inferredYears && /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text')) { - setFieldValue(control, inferredYears); + const inferred = resolveLinkedInTextAnswer(context, preference); + if ( + inferred.outcome === 'answer' && + /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text') + ) { + setFieldValue(control, inferred.value); } } }; @@ -1273,7 +1360,8 @@ const uploadResumeIfNeeded = async ({ return; } - const fileInput = document.querySelector('input[type="file"]'); + const fileInput = + document.querySelector('input[type="file"]'); if (!fileInput) { return; } @@ -1289,7 +1377,8 @@ const uploadResumeIfNeeded = async ({ const getRadioGroups = () => { const controls = getDialogFormControls(); const radios = controls.filter( - (element): element is HTMLInputElement => element instanceof HTMLInputElement && element.type === 'radio', + (element): element is HTMLInputElement => + element instanceof HTMLInputElement && element.type === 'radio', ); const groups = new Map(); @@ -1314,15 +1403,14 @@ const getRadioLabelText = (radio: HTMLInputElement) => normalizeToken( firstNonEmpty( textOf(radio.closest('label')), - radio.id ? textOf(document.querySelector(`label[for="${CSS.escape(radio.id)}"]`)) : '', + radio.id + ? textOf(document.querySelector(`label[for="${CSS.escape(radio.id)}"]`)) + : '', radio.getAttribute('aria-label'), radio.value, ), ); -const choiceTokens = (value?: 'yes' | 'no' | 'unknown') => - value === 'yes' || value === 'no' ? [value] : []; - const chooseRadioCandidate = ( radios: HTMLInputElement[], preference?: BootstrapPayload['preference'] | null, @@ -1334,29 +1422,16 @@ const chooseRadioCandidate = ( radios[0]?.name, ), ); + const answer = resolveChoiceQuestionAnswer({ + questionText, + choiceLabels: radios.map(getRadioLabelText), + preference, + salaryFallbackPeriod: 'monthly', + }); - const preferredTokens = /sponsor|sponsorship|visa/.test(questionText) - ? choiceTokens(preference?.requiresVisaSponsorship) - : /work authorization|work authorisation|authorized to work|authorised to work/.test(questionText) - ? choiceTokens(preference?.workAuthorization) - : /relocat|willing to move/.test(questionText) - ? choiceTokens(preference?.willingToRelocate) - : /degree|education|bachelor|master|phd|completed|complete the following level/.test(questionText) - ? ['yes'] - : ['yes', 'no']; - - if (preferredTokens.length === 0) { - return null; - } - - for (const token of preferredTokens) { - const candidate = radios.find((radio) => new RegExp(`\\b${token}\\b`).test(getRadioLabelText(radio))); - if (candidate) { - return candidate; - } - } - - return radios[0] ?? null; + return answer.outcome === 'answer' + ? (radios[answer.optionIndex] ?? null) + : null; }; const isRequiredRadioGroup = (radios: HTMLInputElement[]) => @@ -1381,9 +1456,11 @@ const getFallbackFieldValue = ({ jobLocation: string; }) => { const context = getFieldContextText(element); - const preferredLocation = getPreferredLocationValue({ profile, preference, jobLocation }); - const preferredSalary = getPreferredSalaryValue(preference); - const noticePeriod = getNoticePeriodValue(preference); + const preferredLocation = getPreferredLocationValue({ + profile, + preference, + jobLocation, + }); if (/email/.test(context) && profile?.email) { return profile.email; @@ -1397,16 +1474,9 @@ const getFallbackFieldValue = ({ return preferredLocation; } - if (/salary|compensation|pay|薪资|薪酬/.test(context)) { - return preferredSalary; - } - - if (/notice/.test(context)) { - return noticePeriod; - } - - if (/(years|experience|经验|多久)/.test(context)) { - return inferYearsAnswer(context, preference, profile); + const inferred = resolveLinkedInTextAnswer(context, preference); + if (inferred.outcome === 'answer') { + return inferred.value; } if (element instanceof HTMLInputElement && element.type === 'number') { @@ -1428,39 +1498,29 @@ const answerKnockoutQuestions = ({ const controls = getDialogFormControls(); const unansweredSelect = controls.find( (element): element is HTMLSelectElement => - element instanceof HTMLSelectElement && isRequiredControl(element) && !element.value, + element instanceof HTMLSelectElement && + isRequiredControl(element) && + !element.value, ); if (unansweredSelect) { - unansweredSelect.selectedIndex = unansweredSelect.options.length > 1 ? 1 : 0; - unansweredSelect.dispatchEvent(new Event('change', { bubbles: true })); - } - - const dialog = getEasyApplyDialog() ?? document; - const preferredYears = Number(getPreferredYearsValue(preference, profile)); - const requiredGroups = Array.from(dialog.querySelectorAll('fieldset')).filter((fieldset) => - isVisible(fieldset) && - fieldset.textContent?.toLowerCase().includes('years') && - Number.isFinite(preferredYears) && - preferredYears > 0, - ); + const answer = resolveChoiceQuestionAnswer({ + questionText: getFieldContextText(unansweredSelect), + choiceLabels: Array.from(unansweredSelect.options).map((option) => + firstNonEmpty(option.label, option.text, option.value), + ), + preference, + salaryFallbackPeriod: 'monthly', + }); - requiredGroups.forEach((group) => { - const preferred = - Array.from(group.querySelectorAll('input[type="radio"]')) - .map((radio) => { - const label = getRadioLabelText(radio); - const numericValue = Number(label.match(/\d+/)?.[0] ?? radio.value.match(/\d+/)?.[0] ?? NaN); - return { - radio, - numericValue, - }; - }) - .filter((candidate) => Number.isFinite(candidate.numericValue) && candidate.numericValue <= preferredYears) - .sort((left, right) => right.numericValue - left.numericValue)[0]?.radio ?? null; - - preferred?.click(); - }); + if ( + answer.outcome === 'answer' && + unansweredSelect.options[answer.optionIndex] + ) { + unansweredSelect.selectedIndex = answer.optionIndex; + unansweredSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + } getRadioGroups().forEach((radios) => { if (radios.some((radio) => radio.checked)) { @@ -1472,7 +1532,12 @@ const answerKnockoutQuestions = ({ }); controls.forEach((element) => { - if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + if ( + !( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ) + ) { return; } @@ -1480,9 +1545,12 @@ const answerKnockoutQuestions = ({ return; } - const inferredYears = inferYearsAnswer(getFieldContextText(element), preference, profile); - if (inferredYears) { - setFieldValue(element, inferredYears); + const inferred = resolveLinkedInTextAnswer( + getFieldContextText(element), + preference, + ); + if (inferred.outcome === 'answer') { + setFieldValue(element, inferred.value); } }); @@ -1491,15 +1559,26 @@ const answerKnockoutQuestions = ({ return false; } - if (element instanceof HTMLInputElement && ['radio', 'checkbox', 'file'].includes(element.type)) { + if ( + element instanceof HTMLInputElement && + ['radio', 'checkbox', 'file'].includes(element.type) + ) { return false; } - return normalizeDigits(element.value).length === 0 && element.value.trim().length === 0; + return ( + normalizeDigits(element.value).length === 0 && + element.value.trim().length === 0 + ); }); unresolvedRequiredInputs.forEach((element) => { - if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + if ( + !( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ) + ) { return; } @@ -1517,7 +1596,8 @@ const answerKnockoutQuestions = ({ }); const unresolvedRequiredRadioGroups = getRadioGroups().filter( - (radios) => isRequiredRadioGroup(radios) && !radios.some((radio) => radio.checked), + (radios) => + isRequiredRadioGroup(radios) && !radios.some((radio) => radio.checked), ); const stillUnresolvedInputs = controls.filter((element) => { @@ -1525,14 +1605,23 @@ const answerKnockoutQuestions = ({ return false; } - if (element instanceof HTMLInputElement && ['radio', 'checkbox', 'file'].includes(element.type)) { + if ( + element instanceof HTMLInputElement && + ['radio', 'checkbox', 'file'].includes(element.type) + ) { return false; } - return normalizeDigits(element.value).length === 0 && element.value.trim().length === 0; + return ( + normalizeDigits(element.value).length === 0 && + element.value.trim().length === 0 + ); }); - return stillUnresolvedInputs.length === 0 && unresolvedRequiredRadioGroups.length === 0; + return ( + stillUnresolvedInputs.length === 0 && + unresolvedRequiredRadioGroups.length === 0 + ); }; const fillCurrentStep = ({ @@ -1608,8 +1697,11 @@ const isButtonDisabled = (button: HTMLElement) => button.getAttribute('aria-disabled') === 'true'; const buttonLabel = (button: HTMLElement) => - firstNonEmpty(textOf(button), button.getAttribute('aria-label'), button.getAttribute('data-control-name')) - .toLowerCase(); + firstNonEmpty( + textOf(button), + button.getAttribute('aria-label'), + button.getAttribute('data-control-name'), + ).toLowerCase(); const triggerButtonClick = (button: HTMLElement) => { button.scrollIntoView({ @@ -1620,9 +1712,9 @@ const triggerButtonClick = (button: HTMLElement) => { const clientX = Math.round(rect.left + rect.width / 2); const clientY = Math.round(rect.top + rect.height / 2); const hitTarget = - (document.elementFromPoint(clientX, clientY) as HTMLElement | null)?.closest( - 'button, a, [role="button"]', - ) ?? button; + ( + document.elementFromPoint(clientX, clientY) as HTMLElement | null + )?.closest('button, a, [role="button"]') ?? button; hitTarget.focus(); @@ -1698,18 +1790,29 @@ const getDialogFingerprint = () => { textOf(dialog.querySelector('header')), ); const progress = getDialogProgressValue(); - const buttons = Array.from(dialog.querySelectorAll('button, a, [role="button"]')) + const buttons = Array.from( + dialog.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button)) .map((button) => buttonLabel(button)) .join('|'); const fields = getDialogFormControls() - .map((field) => firstNonEmpty(field.getAttribute('name'), field.getAttribute('id'), field.getAttribute('aria-label'))) + .map((field) => + firstNonEmpty( + field.getAttribute('name'), + field.getAttribute('id'), + field.getAttribute('aria-label'), + ), + ) .join('|'); return `${title}::${progress}::${buttons}::${fields}`; }; -const waitForDialogChange = async (previousFingerprint: string, timeoutMs = 3500) => { +const waitForDialogChange = async ( + previousFingerprint: string, + timeoutMs = 3500, +) => { const startedAt = Date.now(); await sleep(400); @@ -1727,9 +1830,7 @@ const waitForDialogChange = async (previousFingerprint: string, timeoutMs = 3500 const classifyDialogAction = (button: HTMLElement) => { const label = buttonLabel(button); - if ( - /submit application|submit|发送申请|提交申请|提交/.test(label) - ) { + if (/submit application|submit|发送申请|提交申请|提交/.test(label)) { return 'submit'; } @@ -1773,12 +1874,13 @@ const findPrimaryEasyApplyAction = () => { ].join(', '), ), ).filter((button) => isVisible(button) && !isButtonDisabled(button)); - const buttons = (footerButtons.length > 0 - ? footerButtons - : Array.from(dialog.querySelectorAll('button, a, [role="button"]')) - ).filter( - (button) => isVisible(button) && !isButtonDisabled(button), - ); + const buttons = ( + footerButtons.length > 0 + ? footerButtons + : Array.from( + dialog.querySelectorAll('button, a, [role="button"]'), + ) + ).filter((button) => isVisible(button) && !isButtonDisabled(button)); const ranked = buttons .map((button) => ({ button, @@ -1790,7 +1892,8 @@ const findPrimaryEasyApplyAction = () => { .sort((left, right) => { const priority = { submit: 0, advance: 1, finish: 2 } as const; const priorityDelta = - priority[left.kind as keyof typeof priority] - priority[right.kind as keyof typeof priority]; + priority[left.kind as keyof typeof priority] - + priority[right.kind as keyof typeof priority]; if (priorityDelta !== 0) { return priorityDelta; } @@ -1829,7 +1932,9 @@ const findPreferredFlowAction = () => { /^(done|完成|close|关闭)$/i, ]; - const sequenceIndex = preferredSequence.findIndex((pattern) => pattern.test(label)); + const sequenceIndex = preferredSequence.findIndex((pattern) => + pattern.test(label), + ); return { ...candidate, sequenceIndex: sequenceIndex === -1 ? 99 : sequenceIndex, @@ -1908,7 +2013,9 @@ const getVisibleEasyApplyButton = () => { }; const describeEasyApplySurface = () => { - const buttonLabels = Array.from(document.querySelectorAll('button, a, [role="button"]')) + const buttonLabels = Array.from( + document.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button)) .map((button) => buttonLabel(button)) .filter(Boolean) @@ -1966,12 +2073,18 @@ const hasSubmissionSuccessState = () => { const dialogText = textOf(dialog).toLowerCase(); if ( - /application submitted|your application was sent|已提交申请|申请已发送|申请已提交/.test(dialogText) + /application submitted|your application was sent|已提交申请|申请已发送|申请已提交/.test( + dialogText, + ) ) { return true; } - return Array.from((dialog ?? document).querySelectorAll('button, a, [role="button"]')) + return Array.from( + (dialog ?? document).querySelectorAll( + 'button, a, [role="button"]', + ), + ) .filter((button) => isVisible(button)) .some((button) => /done|完成/.test(buttonLabel(button))); }; @@ -1982,9 +2095,9 @@ const dismissSuccessfulApplicationDialog = async () => { return; } - const closeButton = Array.from(dialog.querySelectorAll('button, a, [role="button"]')).find((button) => - /done|close|dismiss|完成|关闭/.test(buttonLabel(button)), - ); + const closeButton = Array.from( + dialog.querySelectorAll('button, a, [role="button"]'), + ).find((button) => /done|close|dismiss|完成|关闭/.test(buttonLabel(button))); if (closeButton) { triggerButtonClick(closeButton); @@ -1998,8 +2111,12 @@ const dismissCurrentApplicationDialog = async () => { return; } - const dismissButton = Array.from(dialog.querySelectorAll('button, a, [role="button"]')).find((button) => - /close|dismiss|done|not now|cancel|完成|关闭|取消/.test(buttonLabel(button)), + const dismissButton = Array.from( + dialog.querySelectorAll('button, a, [role="button"]'), + ).find((button) => + /close|dismiss|done|not now|cancel|完成|关闭|取消/.test( + buttonLabel(button), + ), ); if (dismissButton) { @@ -2009,7 +2126,9 @@ const dismissCurrentApplicationDialog = async () => { }; const findDismissButton = (container: ParentNode) => - Array.from(container.querySelectorAll('button, a, [role="button"]')).find((button) => { + Array.from( + container.querySelectorAll('button, a, [role="button"]'), + ).find((button) => { const label = buttonLabel(button); return ( /close|dismiss|cancel|done|关闭|取消|完成/.test(label) || @@ -2035,14 +2154,21 @@ const findDismissScopeFromHeading = (heading: HTMLElement) => { const dismissInterferingDialogs = async () => { const overlayCandidates = new Set(getAuxiliaryDialogs()); const preferenceOverlayHeadings = Array.from( - document.querySelectorAll('h1, h2, h3, [aria-level="1"], [aria-level="2"]'), - ).filter((heading) => isVisible(heading) && looksLikePreferenceOverlay(textOf(heading))); + document.querySelectorAll( + 'h1, h2, h3, [aria-level="1"], [aria-level="2"]', + ), + ).filter( + (heading) => + isVisible(heading) && looksLikePreferenceOverlay(textOf(heading)), + ); preferenceOverlayHeadings.forEach((heading) => { const overlay = findDismissScopeFromHeading(heading) ?? heading.closest('[role="dialog"]') ?? - heading.closest('.artdeco-modal, .artdeco-modal-overlay, .jobs-easy-apply-content') ?? + heading.closest( + '.artdeco-modal, .artdeco-modal-overlay, .jobs-easy-apply-content', + ) ?? heading.parentElement ?? heading; @@ -2099,14 +2225,22 @@ const dismissInterferingDialogs = async () => { return dismissedCount; }; -const postReview = async (apiBaseUrl: string, attemptId: string, reason: string) => { +const postReview = async ( + apiBaseUrl: string, + attemptId: string, + reason: string, +) => { await fetchJson(`/api/applications/${attemptId}/review`, { method: 'POST', body: { reason }, }); }; -const postStatus = async (apiBaseUrl: string, attemptId: string, status: string) => { +const postStatus = async ( + apiBaseUrl: string, + attemptId: string, + status: string, +) => { await fetchJson(`/api/applications/${attemptId}/status`, { method: 'PATCH', body: { status }, @@ -2145,11 +2279,14 @@ const processPlan = async ({ preference?: BootstrapPayload['preference'] | null; allowAttemptDespiteReview?: boolean; }): Promise => { - const hasVipReview = plan.reviewReasons.some((reason) => reason.toLowerCase().includes('vip')); + const hasVipReview = plan.reviewReasons.some((reason) => + reason.toLowerCase().includes('vip'), + ); if ( hasVipReview || - (!allowAttemptDespiteReview && (plan.reviewReasons.length > 0 || plan.attempt.status === 'needs_review')) + (!allowAttemptDespiteReview && + (plan.reviewReasons.length > 0 || plan.attempt.status === 'needs_review')) ) { await reportState({ pendingReviewCount: 1, @@ -2229,10 +2366,16 @@ const processPlan = async ({ }); const actionCandidate = findPreferredFlowAction(); const canAdvanceDespiteHeuristics = - actionCandidate !== null && actionCandidate.kind !== 'submit' && actionCandidate.kind !== 'finish'; + actionCandidate !== null && + actionCandidate.kind !== 'submit' && + actionCandidate.kind !== 'finish'; if (!resolved && !canAdvanceDespiteHeuristics) { - await postReview(apiBaseUrl, plan.attempt.id, 'Unresolved required questions'); + await postReview( + apiBaseUrl, + plan.attempt.id, + 'Unresolved required questions', + ); await reportState({ pendingReviewCount: 1, recentResult: `Paused on questions for ${plan.job.company}`, @@ -2281,17 +2424,19 @@ const processPlan = async ({ const currentActionLabel = normalizeToken(action.label || ''); await reportState({ runStatus: 'running', - recentResult: - /submit|提交/.test(currentActionLabel) - ? `Clicking ${action.label || '提交申请'} for ${plan.job.title}` - : /review|查看|预览/.test(currentActionLabel) - ? `Clicking ${action.label || '查看'} for ${plan.job.title}` - : /done|完成|close|关闭/.test(currentActionLabel) - ? `Clicking ${action.label || '完成'} for ${plan.job.title}` - : `Clicking ${action.label || '下一页'} for ${plan.job.title}`, + recentResult: /submit|提交/.test(currentActionLabel) + ? `Clicking ${action.label || '提交申请'} for ${plan.job.title}` + : /review|查看|预览/.test(currentActionLabel) + ? `Clicking ${action.label || '查看'} for ${plan.job.title}` + : /done|完成|close|关闭/.test(currentActionLabel) + ? `Clicking ${action.label || '完成'} for ${plan.job.title}` + : `Clicking ${action.label || '下一页'} for ${plan.job.title}`, }); - if (action.kind === 'submit' && (hasSubmissionSuccessState() || !getEasyApplyDialog())) { + if ( + action.kind === 'submit' && + (hasSubmissionSuccessState() || !getEasyApplyDialog()) + ) { await postStatus(apiBaseUrl, plan.attempt.id, 'submitted'); await postReceipt(apiBaseUrl, plan.attempt.id); await dismissSuccessfulApplicationDialog(); @@ -2328,7 +2473,10 @@ const processPlan = async ({ const retryFingerprint = getDialogFingerprint(); const retryAction = clickPrimaryEasyApplyAction(); const retryNextFingerprint = retryAction - ? await waitForDialogChange(retryFingerprint, retryAction.kind === 'submit' ? 6500 : 5000) + ? await waitForDialogChange( + retryFingerprint, + retryAction.kind === 'submit' ? 6500 : 5000, + ) : retryFingerprint; if (retryAction && retryNextFingerprint !== retryFingerprint) { @@ -2359,7 +2507,11 @@ const processPlan = async ({ } } - await postReview(apiBaseUrl, plan.attempt.id, 'Could not complete Easy Apply flow'); + await postReview( + apiBaseUrl, + plan.attempt.id, + 'Could not complete Easy Apply flow', + ); await reportState({ pendingReviewCount: 1, recentResult: `Manual finish needed for ${plan.job.company}`, @@ -2522,7 +2674,8 @@ const runBatchOnSearchResults = async ({ await clickIntoJobCard(card); const job = - (await waitForCurrentSelectedLinkedInJob(6000)) ?? (await extractJobFromCard(card)); + (await waitForCurrentSelectedLinkedInJob(6000)) ?? + (await extractJobFromCard(card)); if (!job || !isUsableExtractedJob(job, { allowUnknownCompany: true })) { continue; } @@ -2678,10 +2831,14 @@ const runOnPage = async ({ apiBaseUrl, plan, sourceTabId, - } satisfies ExtensionMessage)) as { ok?: boolean; error?: string } | undefined; + } satisfies ExtensionMessage)) as + | { ok?: boolean; error?: string } + | undefined; if (!response?.ok) { - throw new Error(response?.error ?? `Could not open ${plan.job.title} in a worker tab.`); + throw new Error( + response?.error ?? `Could not open ${plan.job.title} in a worker tab.`, + ); } await reportState({ @@ -2753,7 +2910,9 @@ const maybeResumePendingWorkerPlan = async () => { } satisfies ExtensionMessage); } catch (error) { const messageText = - error instanceof Error ? error.message : 'LinkedIn worker run failed unexpectedly.'; + error instanceof Error + ? error.message + : 'LinkedIn worker run failed unexpectedly.'; try { await sendRuntimeMessage({ @@ -2770,93 +2929,101 @@ const maybeResumePendingWorkerPlan = async () => { if (!contentScriptWindow.__applypilotLinkedInListenerRegistered) { contentScriptWindow.__applypilotLinkedInListenerRegistered = true; - chrome.runtime.onMessage.addListener((message: ExtensionMessage, _sender, sendResponse) => { - void (async () => { - if (message.type === 'applypilot:ping') { - sendResponse({ ok: true }); - return; - } - - if (message.type === 'applypilot:start-run-on-page') { - try { - await runOnPage({ - apiBaseUrl: message.apiBaseUrl, - targetCount: message.targetCount, - sourceTabId: message.sourceTabId, - }); + chrome.runtime.onMessage.addListener( + (message: ExtensionMessage, _sender, sendResponse) => { + void (async () => { + if (message.type === 'applypilot:ping') { sendResponse({ ok: true }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'LinkedIn run failed unexpectedly.'; - await reportState({ - activeRunId: null, - runStatus: 'failed', - recentResult: messageText, - }); - sendResponse({ ok: false, error: messageText }); + return; } - return; - } - if (message.type === 'applypilot:collect-current-job-on-page') { - try { - const selectedJob = await waitForCurrentSelectedLinkedInJob(); - if (!selectedJob) { + if (message.type === 'applypilot:start-run-on-page') { + try { + await runOnPage({ + apiBaseUrl: message.apiBaseUrl, + targetCount: message.targetCount, + sourceTabId: message.sourceTabId, + }); + sendResponse({ ok: true }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'LinkedIn run failed unexpectedly.'; + await reportState({ + activeRunId: null, + runStatus: 'failed', + recentResult: messageText, + }); + sendResponse({ ok: false, error: messageText }); + } + return; + } + + if (message.type === 'applypilot:collect-current-job-on-page') { + try { + const selectedJob = await waitForCurrentSelectedLinkedInJob(); + if (!selectedJob) { + sendResponse({ + ok: false, + error: `Could not resolve the selected LinkedIn job on this page. ${describeJobCardSurface()}`, + }); + return; + } + + sendResponse({ + ok: true, + job: selectedJob, + }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'Could not read the selected LinkedIn job.'; sendResponse({ ok: false, - error: `Could not resolve the selected LinkedIn job on this page. ${describeJobCardSurface()}`, + error: messageText, }); - return; } + return; + } - sendResponse({ - ok: true, - job: selectedJob, - }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'Could not read the selected LinkedIn job.'; - sendResponse({ - ok: false, - error: messageText, - }); + if (message.type === 'applypilot:execute-plan-on-page') { + try { + await executePlanOnCurrentPage({ + apiBaseUrl: message.apiBaseUrl, + plan: message.plan, + }); + sendResponse({ ok: true }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'LinkedIn worker execution failed unexpectedly.'; + await reportState({ + runStatus: 'failed', + recentResult: messageText, + }); + sendResponse({ ok: false, error: messageText }); + } + return; } - return; - } - if (message.type === 'applypilot:execute-plan-on-page') { - try { - await executePlanOnCurrentPage({ - apiBaseUrl: message.apiBaseUrl, - plan: message.plan, + if (message.type === 'applypilot:pause-run-on-page') { + runState.paused = true; + runState.active = false; + void reportState({ + activeRunId: null, + runStatus: 'paused', + recentResult: 'Run paused', }); sendResponse({ ok: true }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'LinkedIn worker execution failed unexpectedly.'; - await reportState({ - runStatus: 'failed', - recentResult: messageText, - }); - sendResponse({ ok: false, error: messageText }); } - return; - } - - if (message.type === 'applypilot:pause-run-on-page') { - runState.paused = true; - runState.active = false; - void reportState({ - activeRunId: null, - runStatus: 'paused', - recentResult: 'Run paused', - }); - sendResponse({ ok: true }); - } - })(); + })(); - return true; - }); + return true; + }, + ); } void maybeResumePendingWorkerPlan(); diff --git a/apps/extension/src/content/mycareersfuture.ts b/apps/extension/src/content/mycareersfuture.ts index 2146907..059f47b 100644 --- a/apps/extension/src/content/mycareersfuture.ts +++ b/apps/extension/src/content/mycareersfuture.ts @@ -1,4 +1,8 @@ -import type { ExtensionMessage, PopupState, WorkerJobPlan } from '../shared/messages'; +import type { + ExtensionMessage, + PopupState, + WorkerJobPlan, +} from '../shared/messages'; import { detectMcfFlowStage, elementLabel, @@ -6,6 +10,10 @@ import { isDisabledElement, isVisibleElement, } from './mycareersfuture-flow'; +import { + resolveChoiceQuestionAnswer, + resolveTextQuestionAnswer, +} from './form-mapping'; type JobPlan = WorkerJobPlan; @@ -13,26 +21,26 @@ type BootstrapPayload = { summary: { dailyTarget: number; }; - profile: { - fullName: string; - phone: string; - email: string; - location: string; - yearsExperience?: number; - } | null; - preference?: { - targetRoles?: string[]; - regions?: string[]; - minSalary?: number; - salaryCurrency?: string; - applicationSalaryAmount?: number; - yearsExperienceOverride?: number | null; - noticePeriodWeeks?: number | null; - workAuthorization?: 'yes' | 'no' | 'unknown'; - requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; - willingToRelocate?: 'yes' | 'no' | 'unknown'; - } | null; - }; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; +}; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -46,10 +54,14 @@ const contentScriptWindow = window as Window & { __applypilotMyCareersFutureAutoResumeStarted?: boolean; }; -const textOf = (element: Element | null | undefined) => element?.textContent?.trim() ?? ''; +const textOf = (element: Element | null | undefined) => + element?.textContent?.trim() ?? ''; const firstNonEmpty = (...values: Array) => - values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() ?? ''; -const normalizeToken = (value: string) => value.toLowerCase().replace(/\s+/g, ' ').trim(); + values + .find((value) => typeof value === 'string' && value.trim().length > 0) + ?.trim() ?? ''; +const normalizeToken = (value: string) => + value.toLowerCase().replace(/\s+/g, ' ').trim(); const normalizeDigits = (value: string) => value.replace(/[^\d]/g, ''); const toAbsoluteUrl = (value: string) => { try { @@ -69,14 +81,20 @@ const isTransientRuntimeMessageError = (error: unknown) => { ); }; -const sendRuntimeMessage = async (message: ExtensionMessage, retries = 2) => { +const sendRuntimeMessage = async ( + message: ExtensionMessage, + retries = 2, +) => { let lastError: Error | null = null; for (let attempt = 0; attempt <= retries; attempt += 1) { try { return (await chrome.runtime.sendMessage(message)) as TResponse; } catch (error) { - lastError = error instanceof Error ? error : new Error('ApplyPilot runtime messaging failed.'); + lastError = + error instanceof Error + ? error + : new Error('ApplyPilot runtime messaging failed.'); if (!isTransientRuntimeMessageError(error) || attempt === retries) { throw lastError; } @@ -114,7 +132,8 @@ const fetchJson = async ( | undefined; if (!response || response.ok !== true) { - const errorMessage = response && 'error' in response ? response.error : undefined; + const errorMessage = + response && 'error' in response ? response.error : undefined; throw new Error(errorMessage ?? 'ApplyPilot API request failed.'); } @@ -127,25 +146,40 @@ const triggerElementClick = (element: HTMLElement) => { inline: 'center', }); element.focus?.(); - element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); - element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); - element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true })); - element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })); + element.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, cancelable: true }), + ); + element.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ); + element.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true, cancelable: true }), + ); + element.dispatchEvent( + new MouseEvent('mouseup', { bubbles: true, cancelable: true }), + ); element.click(); }; const getVisibleDialogs = () => - Array.from(document.querySelectorAll('[role="dialog"], .modal, .drawer, .chakra-modal__content')) - .filter((dialog) => isVisible(dialog)); + Array.from( + document.querySelectorAll( + '[role="dialog"], .modal, .drawer, .chakra-modal__content', + ), + ).filter((dialog) => isVisible(dialog)); const getApplicationContainer = () => { const dialogs = getVisibleDialogs(); if (dialogs.length > 0) { - return dialogs.sort((left, right) => { - const leftRect = left.getBoundingClientRect(); - const rightRect = right.getBoundingClientRect(); - return rightRect.width * rightRect.height - leftRect.width * leftRect.height; - })[0] ?? null; + return ( + dialogs.sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return ( + rightRect.width * rightRect.height - leftRect.width * leftRect.height + ); + })[0] ?? null + ); } return ( @@ -156,12 +190,13 @@ const getApplicationContainer = () => { const getFormControlsFor = (container: ParentNode) => Array.from( - container.querySelectorAll( - 'input, select, textarea', - ), + container.querySelectorAll< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input, select, textarea'), ).filter((element) => isVisible(element) && !element.disabled); -const getFormControls = () => getFormControlsFor(getApplicationContainer() ?? document); +const getFormControls = () => + getFormControlsFor(getApplicationContainer() ?? document); const getFieldContextText = ( element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, @@ -170,16 +205,30 @@ const getFieldContextText = ( firstNonEmpty( element.getAttribute('aria-label'), element.getAttribute('placeholder'), - element.id ? textOf(document.querySelector(`label[for="${CSS.escape(element.id)}"]`)) : '', + element.id + ? textOf( + document.querySelector(`label[for="${CSS.escape(element.id)}"]`), + ) + : '', textOf(element.closest('label')), textOf(element.closest('fieldset')?.querySelector('legend')), - textOf(element.closest('[data-testid], [class*="field"], [class*="input"], [class*="question"]')), + textOf( + element.closest( + '[data-testid], [class*="field"], [class*="input"], [class*="question"]', + ), + ), element.name, ), ); -const setFieldValue = (element: HTMLInputElement | HTMLTextAreaElement, value: string) => { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')?.set; +const setFieldValue = ( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(element), + 'value', + )?.set; descriptor?.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); @@ -201,7 +250,12 @@ const fillStandardFields = (profile: BootstrapPayload['profile']) => { const lastName = restNames.join(' '); for (const control of controls) { - if (!(control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) { + if ( + !( + control instanceof HTMLInputElement || + control instanceof HTMLTextAreaElement + ) + ) { continue; } @@ -231,7 +285,10 @@ const fillStandardFields = (profile: BootstrapPayload['profile']) => { continue; } - if (/last name|family name|surname/.test(context) && (lastName || firstName)) { + if ( + /last name|family name|surname/.test(context) && + (lastName || firstName) + ) { setFieldValue(control, lastName || firstName); continue; } @@ -246,64 +303,15 @@ const getPreferredLocationValue = ({ profile: BootstrapPayload['profile']; preference?: BootstrapPayload['preference'] | null; jobLocation: string; - }) => - firstNonEmpty( - preference?.regions?.[0], - jobLocation, - profile?.location, - ); - -const getPreferredSalaryValue = (preference?: BootstrapPayload['preference'] | null) => { - if (preference?.applicationSalaryAmount && preference.applicationSalaryAmount > 0) { - return String(preference.applicationSalaryAmount); - } - - if (preference?.minSalary && preference.minSalary > 0) { - return String(preference.minSalary); - } +}) => firstNonEmpty(preference?.regions?.[0], jobLocation, profile?.location); - return ''; -}; - -const getPreferredYearsValue = ( - preference?: BootstrapPayload['preference'] | null, - profile?: BootstrapPayload['profile'], -) => { - const years = preference?.yearsExperienceOverride ?? profile?.yearsExperience ?? null; - - return typeof years === 'number' && Number.isFinite(years) && years > 0 ? String(years) : ''; -}; - -const getNoticePeriodValue = (preference?: BootstrapPayload['preference'] | null) => { - const weeks = preference?.noticePeriodWeeks; - - return typeof weeks === 'number' && Number.isFinite(weeks) && weeks >= 0 ? String(weeks) : ''; -}; - -const inferYearsAnswer = ( +const resolveMcfTextAnswer = ( context: string, preference?: BootstrapPayload['preference'] | null, - profile?: BootstrapPayload['profile'], -) => { - const normalized = normalizeToken(context); - if (!normalized) { - return ''; - } - - if (/notice period/.test(normalized)) { - return getNoticePeriodValue(preference); - } - - if (/salary|pay|compensation/.test(normalized)) { - return getPreferredSalaryValue(preference); - } - - if (/year|experience/.test(normalized)) { - return getPreferredYearsValue(preference, profile); - } - - return ''; -}; +) => + resolveTextQuestionAnswer(context, preference, { + salaryFallbackPeriod: 'annual', + }); const fillKnownTextFields = ({ profile, @@ -314,11 +322,19 @@ const fillKnownTextFields = ({ preference?: BootstrapPayload['preference'] | null; jobLocation: string; }) => { - const preferredLocation = getPreferredLocationValue({ profile, preference, jobLocation }); - const preferredSalary = getPreferredSalaryValue(preference); + const preferredLocation = getPreferredLocationValue({ + profile, + preference, + jobLocation, + }); for (const control of getFormControls()) { - if (!(control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) { + if ( + !( + control instanceof HTMLInputElement || + control instanceof HTMLTextAreaElement + ) + ) { continue; } @@ -333,24 +349,20 @@ const fillKnownTextFields = ({ continue; } - if (/salary|pay|compensation|notice period/.test(context)) { - setFieldValue( - control, - /notice period/.test(context) ? '2' : preferredSalary, - ); - continue; - } - - const inferred = inferYearsAnswer(context); - if (inferred && /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text')) { - setFieldValue(control, inferred); + const inferred = resolveMcfTextAnswer(context, preference); + if ( + inferred.outcome === 'answer' && + /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text') + ) { + setFieldValue(control, inferred.value); } } }; const getRadioGroups = () => { const radios = getFormControls().filter( - (element): element is HTMLInputElement => element instanceof HTMLInputElement && element.type === 'radio', + (element): element is HTMLInputElement => + element instanceof HTMLInputElement && element.type === 'radio', ); const groups = new Map(); @@ -359,7 +371,9 @@ const getRadioGroups = () => { firstNonEmpty( radio.name, textOf(radio.closest('fieldset')?.querySelector('legend')), - textOf(radio.closest('[data-testid], [class*="question"], [class*="field"]')), + textOf( + radio.closest('[data-testid], [class*="question"], [class*="field"]'), + ), radio.id, ) || `radio-group-${groups.size}`; @@ -375,15 +389,14 @@ const getRadioLabelText = (radio: HTMLInputElement) => normalizeToken( firstNonEmpty( textOf(radio.closest('label')), - radio.id ? textOf(document.querySelector(`label[for="${CSS.escape(radio.id)}"]`)) : '', + radio.id + ? textOf(document.querySelector(`label[for="${CSS.escape(radio.id)}"]`)) + : '', radio.getAttribute('aria-label'), radio.value, ), ); -const choiceTokens = (value?: 'yes' | 'no' | 'unknown') => - value === 'yes' || value === 'no' ? [value] : []; - const chooseRadioCandidate = ( radios: HTMLInputElement[], preference?: BootstrapPayload['preference'] | null, @@ -391,33 +404,24 @@ const chooseRadioCandidate = ( const questionText = normalizeToken( firstNonEmpty( textOf(radios[0]?.closest('fieldset')), - textOf(radios[0]?.closest('[data-testid], [class*="question"], [class*="field"]')), + textOf( + radios[0]?.closest( + '[data-testid], [class*="question"], [class*="field"]', + ), + ), radios[0]?.name, ), ); + const answer = resolveChoiceQuestionAnswer({ + questionText, + choiceLabels: radios.map(getRadioLabelText), + preference, + salaryFallbackPeriod: 'annual', + }); - const preferredTokens = /sponsor|sponsorship|visa/.test(questionText) - ? choiceTokens(preference?.requiresVisaSponsorship) - : /work authorization|work authorisation|authorized to work|authorised to work/.test(questionText) - ? choiceTokens(preference?.workAuthorization) - : /relocat|willing to move/.test(questionText) - ? choiceTokens(preference?.willingToRelocate) - : /comfortable|onsite|on-site|willing|degree|education|completed/.test(questionText) - ? ['yes', 'no'] - : ['yes', 'no']; - - if (preferredTokens.length === 0) { - return null; - } - - for (const token of preferredTokens) { - const candidate = radios.find((radio) => new RegExp(`\\b${token}\\b`).test(getRadioLabelText(radio))); - if (candidate) { - return candidate; - } - } - - return radios[0] ?? null; + return answer.outcome === 'answer' + ? (radios[answer.optionIndex] ?? null) + : null; }; const answerQuestions = ({ @@ -430,9 +434,24 @@ const answerQuestions = ({ const controls = getFormControls(); controls.forEach((element) => { - if (element instanceof HTMLSelectElement && isRequiredControl(element) && !element.value) { - element.selectedIndex = element.options.length > 1 ? 1 : 0; - element.dispatchEvent(new Event('change', { bubbles: true })); + if ( + element instanceof HTMLSelectElement && + isRequiredControl(element) && + !element.value + ) { + const answer = resolveChoiceQuestionAnswer({ + questionText: getFieldContextText(element), + choiceLabels: Array.from(element.options).map((option) => + firstNonEmpty(option.label, option.text, option.value), + ), + preference, + salaryFallbackPeriod: 'annual', + }); + + if (answer.outcome === 'answer' && element.options[answer.optionIndex]) { + element.selectedIndex = answer.optionIndex; + element.dispatchEvent(new Event('change', { bubbles: true })); + } } }); @@ -446,7 +465,12 @@ const answerQuestions = ({ }); controls.forEach((element) => { - if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + if ( + !( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ) + ) { return; } @@ -454,27 +478,38 @@ const answerQuestions = ({ return; } - const inferred = inferYearsAnswer(getFieldContextText(element), preference, profile); - if (inferred) { - setFieldValue(element, inferred); + const inferred = resolveMcfTextAnswer( + getFieldContextText(element), + preference, + ); + if (inferred.outcome === 'answer') { + setFieldValue(element, inferred.value); } }); const unresolvedRequiredRadioGroups = getRadioGroups().filter( - (radios) => radios.some((radio) => isRequiredControl(radio)) && !radios.some((radio) => radio.checked), + (radios) => + radios.some((radio) => isRequiredControl(radio)) && + !radios.some((radio) => radio.checked), ); - return unresolvedRequiredRadioGroups.length === 0 && controls.every((element) => { - if (!isRequiredControl(element)) { - return true; - } + return ( + unresolvedRequiredRadioGroups.length === 0 && + controls.every((element) => { + if (!isRequiredControl(element)) { + return true; + } - if (element instanceof HTMLInputElement && ['radio', 'checkbox', 'file'].includes(element.type)) { - return true; - } + if ( + element instanceof HTMLInputElement && + ['radio', 'checkbox', 'file'].includes(element.type) + ) { + return true; + } - return element.value.trim().length > 0; - }); + return element.value.trim().length > 0; + }) + ); }; const fillCurrentStep = ({ @@ -516,16 +551,20 @@ const uploadResumeIfNeeded = async ({ const hasExistingResumeSelected = /select an existing resume/.test(containerText) && (/\.pdf|\.docx?|uploaded|last used/.test(containerText) || - /next, review application|review application|next/.test(visibleActionLabels)); - const hasDuplicateResumeWarning = /file with the same name already exists|rename or upload a different file/.test( - containerText, - ); + /next, review application|review application|next/.test( + visibleActionLabels, + )); + const hasDuplicateResumeWarning = + /file with the same name already exists|rename or upload a different file/.test( + containerText, + ); if (hasExistingResumeSelected || hasDuplicateResumeWarning) { return; } - const fileInput = document.querySelector('input[type="file"]'); + const fileInput = + document.querySelector('input[type="file"]'); if (!fileInput || fileInput.files?.length) { return; } @@ -542,8 +581,12 @@ const uploadResumeIfNeeded = async ({ await sleep(800); }; -const getVisibleActionButtons = (container: ParentNode = getApplicationContainer() ?? document.body) => - Array.from(container.querySelectorAll('button, a, [role="button"]')) +const getVisibleActionButtons = ( + container: ParentNode = getApplicationContainer() ?? document.body, +) => + Array.from( + container.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button) && !isDisabled(button)) .filter((button) => !isNavigationLikeContainer(button)); @@ -553,10 +596,14 @@ const getResumeSelectionCandidate = ({ resumeFileName: string | null; }) => { const container = getApplicationContainer() ?? document.body; - const baseName = normalizeToken((resumeFileName ?? '').replace(/\.[a-z0-9]+$/i, '')); + const baseName = normalizeToken( + (resumeFileName ?? '').replace(/\.[a-z0-9]+$/i, ''), + ); const candidates = Array.from( - container.querySelectorAll('label, button, [role="button"], li, div, article'), + container.querySelectorAll( + 'label, button, [role="button"], li, div, article', + ), ) .filter((element) => isVisible(element)) .filter((element) => !isNavigationLikeContainer(element)) @@ -579,7 +626,9 @@ const getResumeSelectionCandidate = ({ area: rect.width * rect.height, }; }) - .filter((candidate): candidate is NonNullable => Boolean(candidate)) + .filter((candidate): candidate is NonNullable => + Boolean(candidate), + ) .sort((left, right) => right.area - left.area); return candidates[0]?.element ?? null; @@ -619,10 +668,7 @@ const ensureExistingResumeSelected = async ({ const getExternalJobIdFromUrl = (value: string) => { try { const url = new URL(value, window.location.origin); - const pathPart = url.pathname - .split('/') - .filter(Boolean) - .slice(-1)[0]; + const pathPart = url.pathname.split('/').filter(Boolean).slice(-1)[0]; return firstNonEmpty( url.searchParams.get('jobId'), @@ -698,20 +744,32 @@ const extractJobsFromSearchResults = (targetCount: number) => { }) .filter((job): job is NonNullable => Boolean(job)); - const deduped = Array.from(new Map(cards.map((job) => [job.url, job])).values()); + const deduped = Array.from( + new Map(cards.map((job) => [job.url, job])).values(), + ); return deduped.slice(0, Math.max(targetCount, 1)); }; const getVisibleHeadings = () => - Array.from(document.querySelectorAll('h1, h2, h3')).filter((heading) => { - const label = normalizeToken(textOf(heading)); - return isVisible(heading) && label.length > 3 && !/mycareersfuture|search|jobs/.test(label); - }); + Array.from(document.querySelectorAll('h1, h2, h3')).filter( + (heading) => { + const label = normalizeToken(textOf(heading)); + return ( + isVisible(heading) && + label.length > 3 && + !/mycareersfuture|search|jobs/.test(label) + ); + }, + ); const extractCurrentJob = () => { const headings = getVisibleHeadings(); const titleHeading = headings[0] ?? null; - const title = firstNonEmpty(textOf(titleHeading), document.title.replace(' | MyCareersFuture Singapore', ''), 'Untitled role'); + const title = firstNonEmpty( + textOf(titleHeading), + document.title.replace(' | MyCareersFuture Singapore', ''), + 'Untitled role', + ); const company = firstNonEmpty( textOf(document.querySelector('a[href*="/companies/"]')), textOf(titleHeading?.parentElement?.querySelector('a, span, div')), @@ -719,8 +777,10 @@ const extractCurrentJob = () => { ); const location = firstNonEmpty( textOf( - Array.from(document.querySelectorAll('span, div, p')).find((node) => - isVisible(node) && /singapore|remote|hybrid|on-site|onsite/i.test(textOf(node)), + Array.from(document.querySelectorAll('span, div, p')).find( + (node) => + isVisible(node) && + /singapore|remote|hybrid|on-site|onsite/i.test(textOf(node)), ), ), '', @@ -747,7 +807,8 @@ const extractCurrentJob = () => { }; }; -const fetchBootstrap = () => fetchJson('/api/dashboard/summary'); +const fetchBootstrap = () => + fetchJson('/api/dashboard/summary'); const startServerRun = async ({ jobs, @@ -769,7 +830,11 @@ const startServerRun = async ({ }); const getPageFingerprint = () => - [window.location.href, textOf(document.querySelector('h1')), textOf(document.querySelector('[role="dialog"]'))].join('::'); + [ + window.location.href, + textOf(document.querySelector('h1')), + textOf(document.querySelector('[role="dialog"]')), + ].join('::'); const getFlowFingerprint = () => { const container = getApplicationContainer() ?? document.body; @@ -777,10 +842,13 @@ const getFlowFingerprint = () => { textOf(container.querySelector('h1')), textOf(container.querySelector('h2')), ); - const progress = Array.from(container.querySelectorAll('span, div')) - .map((node) => textOf(node)) - .find((value) => /\d+\s*%/.test(value)) ?? ''; - const buttons = Array.from(container.querySelectorAll('button, a, [role="button"]')) + const progress = + Array.from(container.querySelectorAll('span, div')) + .map((node) => textOf(node)) + .find((value) => /\d+\s*%/.test(value)) ?? ''; + const buttons = Array.from( + container.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button)) .map((button) => elementLabel(button)) .join('|'); @@ -788,7 +856,10 @@ const getFlowFingerprint = () => { return `${window.location.href}::${title}::${progress}::${buttons}`; }; -const waitForFlowChange = async (previousFingerprint: string, timeoutMs = 3500) => { +const waitForFlowChange = async ( + previousFingerprint: string, + timeoutMs = 3500, +) => { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -803,7 +874,9 @@ const waitForFlowChange = async (previousFingerprint: string, timeoutMs = 3500) }; const isJobDetailApplyLabel = (label: string) => - /^(apply|apply now|register interest|apply on company site)$/.test(normalizeToken(label)); + /^(apply|apply now|register interest|apply on company site)$/.test( + normalizeToken(label), + ); const isNavigationLikeContainer = (element: HTMLElement) => Boolean( @@ -830,7 +903,9 @@ const getApplyEntryButton = () => { ].filter((scope): scope is HTMLElement => scope instanceof HTMLElement); for (const scope of scopes) { - const candidates = Array.from(scope.querySelectorAll('button, a, [role="button"]')) + const candidates = Array.from( + scope.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button) && !isDisabled(button)) .filter((button) => !isNavigationLikeContainer(button)) .filter((button) => isJobDetailApplyLabel(elementLabel(button))) @@ -867,7 +942,9 @@ const isAlreadyInApplicationFlow = () => { return true; } - return Array.from(container.querySelectorAll('button, a, [role="button"]')) + return Array.from( + container.querySelectorAll('button, a, [role="button"]'), + ) .filter((button) => isVisible(button)) .some((button) => /next, review application|review application|submit|change|upload resume|next/i.test( @@ -906,7 +983,11 @@ const ensureApplicationStarted = async () => { for (let attempt = 0; attempt < 12; attempt += 1) { await sleep(400); - if (getVisibleDialogs().length > 0 || getFormControls().length > 0 || getPageFingerprint() !== previousFingerprint) { + if ( + getVisibleDialogs().length > 0 || + getFormControls().length > 0 || + getPageFingerprint() !== previousFingerprint + ) { return true; } } @@ -931,7 +1012,9 @@ const rankActionButtons = (buttons: HTMLElement[], patterns: RegExp[]) => rect, }; }) - .filter((candidate): candidate is NonNullable => Boolean(candidate)) + .filter((candidate): candidate is NonNullable => + Boolean(candidate), + ) .sort((left, right) => { if (left.patternIndex !== right.patternIndex) { return left.patternIndex - right.patternIndex; @@ -996,12 +1079,18 @@ const waitForPrimaryFlowAction = async ({ return null; }; -const hasSuccessState = () => detectMcfFlowStage(getApplicationContainer() ?? document.body) === 'success'; +const hasSuccessState = () => + detectMcfFlowStage(getApplicationContainer() ?? document.body) === 'success'; const dismissSuccessState = async () => { const container = getApplicationContainer() ?? document.body; - const button = Array.from(container.querySelectorAll('button, a, [role="button"]')) - .find((candidate) => isVisible(candidate) && /done|finish|close|完成|关闭/.test(elementLabel(candidate))); + const button = Array.from( + container.querySelectorAll('button, a, [role="button"]'), + ).find( + (candidate) => + isVisible(candidate) && + /done|finish|close|完成|关闭/.test(elementLabel(candidate)), + ); if (button) { triggerElementClick(button); @@ -1009,14 +1098,22 @@ const dismissSuccessState = async () => { } }; -const postStatus = async (apiBaseUrl: string, attemptId: string, status: string) => { +const postStatus = async ( + apiBaseUrl: string, + attemptId: string, + status: string, +) => { await fetchJson(`/api/applications/${attemptId}/status`, { method: 'PATCH', body: { status }, }); }; -const postReview = async (apiBaseUrl: string, attemptId: string, reason: string) => { +const postReview = async ( + apiBaseUrl: string, + attemptId: string, + reason: string, +) => { await fetchJson(`/api/applications/${attemptId}/review`, { method: 'POST', body: { reason }, @@ -1116,7 +1213,10 @@ const processPlan = async ({ : `Advancing ${plan.job.title} on MyCareersFuture`, }); - const nextFingerprint = await waitForFlowChange(previousFingerprint, action.kind === 'submit' ? 5000 : 3500); + const nextFingerprint = await waitForFlowChange( + previousFingerprint, + action.kind === 'submit' ? 5000 : 3500, + ); if (nextFingerprint === previousFingerprint && action.kind !== 'submit') { fillCurrentStep({ profile, @@ -1130,7 +1230,10 @@ const processPlan = async ({ }); if (retryAction) { triggerElementClick(retryAction.button); - await waitForFlowChange(previousFingerprint, retryAction.kind === 'submit' ? 5000 : 3500); + await waitForFlowChange( + previousFingerprint, + retryAction.kind === 'submit' ? 5000 : 3500, + ); } } } @@ -1179,15 +1282,19 @@ const executePlansInWorkerTabs = async ({ recentResult: `Opening ${plan.job.title} on MyCareersFuture`, }); - const response = await sendRuntimeMessage<{ ok?: boolean; error?: string }>({ - type: 'applypilot:run-plan-in-worker-tab', - apiBaseUrl, - plan, - sourceTabId, - } satisfies ExtensionMessage); + const response = await sendRuntimeMessage<{ ok?: boolean; error?: string }>( + { + type: 'applypilot:run-plan-in-worker-tab', + apiBaseUrl, + plan, + sourceTabId, + } satisfies ExtensionMessage, + ); if (!response?.ok) { - throw new Error(response?.error ?? `ApplyPilot could not open ${plan.job.title}.`); + throw new Error( + response?.error ?? `ApplyPilot could not open ${plan.job.title}.`, + ); } } }; @@ -1249,7 +1356,9 @@ const maybeResumePendingWorkerPlan = async () => { } satisfies ExtensionMessage); } catch (error) { const messageText = - error instanceof Error ? error.message : 'MyCareersFuture worker run failed unexpectedly.'; + error instanceof Error + ? error.message + : 'MyCareersFuture worker run failed unexpectedly.'; try { await sendRuntimeMessage({ @@ -1283,7 +1392,9 @@ const runOnPage = async ({ const jobs = extractJobsFromSearchResults(targetCount); if (jobs.length === 0) { - throw new Error('No MyCareersFuture job cards could be extracted from this search page.'); + throw new Error( + 'No MyCareersFuture job cards could be extracted from this search page.', + ); } const { run, plans } = await startServerRun({ @@ -1292,7 +1403,9 @@ const runOnPage = async ({ }); if (plans.length === 0) { - throw new Error('No MyCareersFuture jobs could be queued from this search page.'); + throw new Error( + 'No MyCareersFuture jobs could be queued from this search page.', + ); } await reportState({ @@ -1343,68 +1456,74 @@ const runOnPage = async ({ if (!contentScriptWindow.__applypilotMyCareersFutureListenerRegistered) { contentScriptWindow.__applypilotMyCareersFutureListenerRegistered = true; - chrome.runtime.onMessage.addListener((message: ExtensionMessage, _sender, sendResponse) => { - void (async () => { - if (message.type === 'applypilot:ping') { - sendResponse({ ok: true }); - return; - } - - if (message.type === 'applypilot:start-run-on-page') { - try { - await runOnPage({ - apiBaseUrl: message.apiBaseUrl, - targetCount: message.targetCount, - sourceTabId: message.sourceTabId, - }); + chrome.runtime.onMessage.addListener( + (message: ExtensionMessage, _sender, sendResponse) => { + void (async () => { + if (message.type === 'applypilot:ping') { sendResponse({ ok: true }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'MyCareersFuture run failed unexpectedly.'; - await reportState({ - activeRunId: null, - runStatus: 'failed', - recentResult: messageText, - }); - sendResponse({ ok: false, error: messageText }); + return; } - return; - } - if (message.type === 'applypilot:execute-plan-on-page') { - try { - await executePlanOnCurrentPage({ - apiBaseUrl: message.apiBaseUrl, - plan: message.plan, - }); - sendResponse({ ok: true }); - } catch (error) { - const messageText = - error instanceof Error ? error.message : 'MyCareersFuture execution failed unexpectedly.'; + if (message.type === 'applypilot:start-run-on-page') { + try { + await runOnPage({ + apiBaseUrl: message.apiBaseUrl, + targetCount: message.targetCount, + sourceTabId: message.sourceTabId, + }); + sendResponse({ ok: true }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'MyCareersFuture run failed unexpectedly.'; + await reportState({ + activeRunId: null, + runStatus: 'failed', + recentResult: messageText, + }); + sendResponse({ ok: false, error: messageText }); + } + return; + } + + if (message.type === 'applypilot:execute-plan-on-page') { + try { + await executePlanOnCurrentPage({ + apiBaseUrl: message.apiBaseUrl, + plan: message.plan, + }); + sendResponse({ ok: true }); + } catch (error) { + const messageText = + error instanceof Error + ? error.message + : 'MyCareersFuture execution failed unexpectedly.'; + await reportState({ + activeRunId: null, + runStatus: 'failed', + recentResult: messageText, + }); + sendResponse({ ok: false, error: messageText }); + } + return; + } + + if (message.type === 'applypilot:pause-run-on-page') { + runState.paused = true; + runState.active = false; await reportState({ activeRunId: null, - runStatus: 'failed', - recentResult: messageText, + runStatus: 'paused', + recentResult: 'Run paused', }); - sendResponse({ ok: false, error: messageText }); + sendResponse({ ok: true }); } - return; - } + })(); - if (message.type === 'applypilot:pause-run-on-page') { - runState.paused = true; - runState.active = false; - await reportState({ - activeRunId: null, - runStatus: 'paused', - recentResult: 'Run paused', - }); - sendResponse({ ok: true }); - } - })(); - - return true; - }); + return true; + }, + ); } void maybeResumePendingWorkerPlan(); diff --git a/apps/extension/src/manifest.ts b/apps/extension/src/manifest.ts index 4c5c291..bf1575d 100644 --- a/apps/extension/src/manifest.ts +++ b/apps/extension/src/manifest.ts @@ -9,6 +9,8 @@ export default defineManifest({ host_permissions: [ 'https://www.linkedin.com/*', 'https://www.mycareersfuture.gov.sg/*', + 'https://boards.greenhouse.io/*', + 'https://job-boards.greenhouse.io/*', 'http://localhost:3000/*', ], background: { @@ -30,5 +32,13 @@ export default defineManifest({ js: ['src/content/mycareersfuture.ts'], run_at: 'document_idle', }, + { + matches: [ + 'https://boards.greenhouse.io/*', + 'https://job-boards.greenhouse.io/*', + ], + js: ['src/content/greenhouse.ts'], + run_at: 'document_idle', + }, ], }); diff --git a/apps/web/src/app/api/runs/start/route.ts b/apps/web/src/app/api/runs/start/route.ts index 5775d93..acf0176 100644 --- a/apps/web/src/app/api/runs/start/route.ts +++ b/apps/web/src/app/api/runs/start/route.ts @@ -6,7 +6,7 @@ import { startRun } from '@/server/services/app-service'; export async function POST(request: Request) { return withAuthenticatedRoute(request, async ({ candidateId }) => { const body = (await request.json()) as { - source?: 'linkedin' | 'mycareersfuture'; + source?: 'linkedin' | 'mycareersfuture' | 'greenhouse'; targetCount?: number; jobs?: Array>; }; diff --git a/apps/web/src/components/manual-job-form.tsx b/apps/web/src/components/manual-job-form.tsx index 4d608f6..53c6a98 100644 --- a/apps/web/src/components/manual-job-form.tsx +++ b/apps/web/src/components/manual-job-form.tsx @@ -39,7 +39,9 @@ export function ManualJobForm() { }); if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { error?: string } | null; + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; setStatus(''); setError(payload?.error ?? 'Could not save this job.'); return; @@ -65,25 +67,40 @@ export function ManualJobForm() {
@@ -101,7 +118,10 @@ export function ManualJobForm() {