fix: resilient modal button detection and form filler robustness

easy_apply.mjs:
- findModalButton() uses 3-strategy detection: aria-label exact/substring,
  then exact button text match — survives LinkedIn aria-label changes
- Check order fixed: Next → Review → Submit (submit only when no forward nav)
- All queries scoped to modal + :not([disabled])
- dismissModal() with fallback chain: Dismiss → Close/X → Escape → Discard
- Uses innerText for button text (ignores hidden children)

form_filler.mjs:
- All queries scoped to container (modal when present, page otherwise)
- Radio labels use $$('label') + textContent instead of broken :has-text()
- Autocomplete uses waitForSelector instead of blind 800ms sleep
- EEO selects iterate options directly (selectOption doesn't accept regex)
- Country code check ordered before country to prevent fragile match order

constants.mjs:
- Add AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT
- Remove unused button selectors (now handled inline by findModalButton)

ai_answer.mjs + keywords.mjs:
- Use ANTHROPIC_API_URL constant, claude-sonnet-4-6 model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:53:54 -08:00
parent 33c50178f4
commit a7ce119bde
5 changed files with 228 additions and 90 deletions

View File

@@ -3,8 +3,7 @@
* Called when form_filler hits a question it can't answer from profile/answers.json
*/
import { readFileSync, existsSync } from 'fs';
const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
import { ANTHROPIC_API_URL } from './constants.mjs';
/**
* Generate an answer to an unknown application question using Claude.
@@ -57,7 +56,7 @@ Application question:
Write the best answer for this question. Just the answer text -- no preamble, no explanation.`;
try {
const res = await fetch(ANTHROPIC_API, {
const res = await fetch(ANTHROPIC_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,18 +1,74 @@
/**
* easy_apply.mjs — LinkedIn Easy Apply handler
* Handles the LinkedIn Easy Apply modal flow
*
* Button detection strategy: LinkedIn frequently changes aria-labels and button
* structure. We use multiple fallback strategies:
* 1. aria-label exact match (fastest, but brittle)
* 2. aria-label substring match (handles minor text changes)
* 3. Exact button text match (most resilient — matches trimmed innerText)
* All searches are scoped to the modal dialog to avoid clicking page buttons.
*
* Modal flow: Easy Apply → [fill → Next] × N → Review → Submit application
* Check order per step: Next → Review → Submit (only submit when no forward nav exists)
*/
import {
NAVIGATION_TIMEOUT, PAGE_LOAD_WAIT, CLICK_WAIT, MODAL_STEP_WAIT,
NAVIGATION_TIMEOUT, CLICK_WAIT, MODAL_STEP_WAIT,
SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT,
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_APPLY_BUTTON_SELECTOR,
LINKEDIN_SUBMIT_SELECTOR, LINKEDIN_NEXT_SELECTOR,
LINKEDIN_REVIEW_SELECTOR, LINKEDIN_DISMISS_SELECTOR,
LINKEDIN_MAX_MODAL_STEPS
} from '../constants.mjs';
export const SUPPORTED_TYPES = ['easy_apply'];
/**
* Find a non-disabled button inside the modal using multiple strategies.
* @param {Page} page - Playwright page
* @param {string} modalSelector - CSS selector for the modal container
* @param {Object} opts
* @param {string[]} opts.ariaLabels - aria-label values to try (exact then substring)
* @param {string[]} opts.exactTexts - exact button text matches (case-insensitive, trimmed)
* @returns {ElementHandle|null}
*/
async function findModalButton(page, modalSelector, { ariaLabels = [], exactTexts = [] }) {
// Strategy 1: aria-label exact match inside modal (non-disabled only)
for (const label of ariaLabels) {
const btn = await page.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`);
if (btn) return btn;
}
// Strategy 2: aria-label substring match inside modal
for (const label of ariaLabels) {
const btn = await page.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`);
if (btn) return btn;
}
// Strategy 3: exact text match on button innerText (case-insensitive, trimmed)
// Uses evaluateHandle to return a live DOM reference
if (exactTexts.length === 0) return null;
const handle = await page.evaluateHandle((sel, texts) => {
const modal = document.querySelector(sel);
if (!modal) return null;
const targets = texts.map(t => t.toLowerCase());
const buttons = modal.querySelectorAll('button:not([disabled])');
for (const btn of buttons) {
// Use innerText (not textContent) to get rendered text without hidden children
const text = (btn.innerText || btn.textContent || '').trim().toLowerCase();
if (targets.includes(text)) return btn;
}
return null;
}, modalSelector, exactTexts).catch(() => null);
if (handle) {
const el = handle.asElement();
if (el) return el;
await handle.dispose().catch(() => {});
}
return null;
}
export async function apply(page, job, formFiller) {
const meta = { title: job.title, company: job.company };
@@ -23,7 +79,6 @@ export async function apply(page, job, formFiller) {
await page.evaluate(() => window.scrollTo(0, 300)).catch(() => {});
const eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null);
if (!eaBtn) {
// Debug: log what apply-related elements exist
const applyEls = await page.evaluate(() =>
Array.from(document.querySelectorAll('[aria-label*="Easy Apply"], [aria-label*="Apply"]'))
.map(el => ({ tag: el.tagName, aria: el.getAttribute('aria-label'), visible: el.offsetParent !== null }))
@@ -44,87 +99,151 @@ export async function apply(page, job, formFiller) {
const modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null);
if (!modal) return { status: 'no_modal', meta };
const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR;
// Step through modal
let lastProgress = '-1';
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
const modalStillOpen = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR);
const modalStillOpen = await page.$(MODAL);
if (!modalStillOpen) {
console.log(` ✅ Modal closed — submitted`);
return { status: 'submitted', meta };
}
const progress = await page.$eval('[role="progressbar"]',
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || String(el.style?.width || step)
).catch(() => String(step));
const progress = await page.$eval(`${MODAL} [role="progressbar"]`,
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || ''
).catch(() => '');
// Snapshot modal heading + all page-level buttons for debugging
// Debug snapshot: heading, buttons in modal, and any validation errors
const debugInfo = await page.evaluate((sel) => {
const modal = document.querySelector(sel);
const heading = modal?.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || '';
const realProgress = document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow') ||
document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuetext') || null;
const allBtns = Array.from(document.querySelectorAll('button')).map(b => ({
text: b.textContent?.trim().slice(0, 40),
if (!modal) return { heading: '', buttons: [], errors: [] };
const heading = modal.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || '';
const buttons = Array.from(modal.querySelectorAll('button, [role="button"]')).map(b => ({
text: (b.innerText || b.textContent || '').trim().slice(0, 50),
aria: b.getAttribute('aria-label'),
disabled: b.disabled,
})).filter(b => (b.text || b.aria) && !['Home', 'Jobs', 'Me', 'For Business', 'Save the job'].includes(b.aria));
const errors = Array.from(document.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
})).filter(b => b.text || b.aria);
const errors = Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
.map(e => e.textContent?.trim().slice(0, 60)).filter(Boolean);
return { heading, realProgress, allBtns, errors };
}, LINKEDIN_EASY_APPLY_MODAL_SELECTOR).catch(() => ({}));
console.log(` [step ${step}] heading="${debugInfo.heading}" realProgress=${debugInfo.realProgress} buttons=${JSON.stringify(debugInfo.allBtns)} errors=${JSON.stringify(debugInfo.errors)}`);
return { heading, buttons, errors };
}, MODAL).catch(() => ({ heading: '', buttons: [], errors: [] }));
console.log(` [step ${step}] progress=${progress} heading="${debugInfo.heading}" buttons=${JSON.stringify(debugInfo.buttons)}${debugInfo.errors.length ? ' errors=' + JSON.stringify(debugInfo.errors) : ''}`);
// Fill form fields
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label))}`);
if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label || u))}`);
if (unknowns[0]?.honeypot) {
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
await dismissModal(page, MODAL);
return { status: 'skipped_honeypot', meta };
}
if (unknowns.length > 0) {
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
await dismissModal(page, MODAL);
return { status: 'needs_answer', pending_question: unknowns[0], meta };
}
await page.waitForTimeout(MODAL_STEP_WAIT);
const hasSubmit = await page.$(LINKEDIN_SUBMIT_SELECTOR);
if (hasSubmit) {
// --- Button check order: Next → Review → Submit ---
// Check Next first — only fall through to Submit when there's no forward navigation.
// This prevents accidentally clicking a Submit-like element on early modal steps.
// Check for Next button
const nextBtn = await findModalButton(page, MODAL, {
ariaLabels: ['Continue to next step'],
exactTexts: ['Next'],
});
if (nextBtn) {
console.log(` [step ${step}] clicking Next`);
await nextBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
// Check for Review button
const reviewBtn = await findModalButton(page, MODAL, {
ariaLabels: ['Review your application'],
exactTexts: ['Review'],
});
if (reviewBtn) {
console.log(` [step ${step}] clicking Review`);
await reviewBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
// Check for Submit button (only when no Next/Review exists)
const submitBtn = await findModalButton(page, MODAL, {
ariaLabels: ['Submit application'],
exactTexts: ['Submit application'],
});
if (submitBtn) {
console.log(` [step ${step}] clicking Submit`);
await page.click(LINKEDIN_SUBMIT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT });
await submitBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(SUBMIT_WAIT);
return { status: 'submitted', meta };
}
if (progress === lastProgress && step > 2) {
// Stuck detection — progress hasn't changed and we've been through a few steps
if (progress && progress === lastProgress && step > 2) {
console.log(` [step ${step}] stuck — progress unchanged at ${progress}`);
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
await dismissModal(page, MODAL);
return { status: 'stuck', meta };
}
const hasNext = await page.$(LINKEDIN_NEXT_SELECTOR);
if (hasNext) {
console.log(` [step ${step}] clicking Next`);
await page.click(LINKEDIN_NEXT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
const hasReview = await page.$(LINKEDIN_REVIEW_SELECTOR);
if (hasReview) {
console.log(` [step ${step}] clicking Review`);
await page.click(LINKEDIN_REVIEW_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
console.log(` [step ${step}] no Next/Review/Submit found — breaking`);
break;
}
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
await dismissModal(page, MODAL);
return { status: 'incomplete', meta };
}
/**
* Dismiss the Easy Apply modal.
* Tries multiple strategies: Dismiss button → Close/X → Escape key.
* Handles the "Discard" confirmation dialog that appears after Escape.
*/
async function dismissModal(page, modalSelector) {
// Try aria-label Dismiss
const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`);
if (dismissBtn) {
await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
return;
}
// Try close/X button
const closeBtn = await page.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`);
if (closeBtn) {
await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
return;
}
// Fallback: Escape key
await page.keyboard.press('Escape').catch(() => {});
// Handle "Discard" confirmation dialog that may appear after Escape
const discardBtn = await page.waitForSelector(
'button[data-test-dialog-primary-btn]',
{ timeout: DISMISS_TIMEOUT, state: 'visible' }
).catch(() => null);
if (discardBtn) {
await discardBtn.click().catch(() => {});
return;
}
// Last resort: find Discard by exact text
const handle = await page.evaluateHandle(() => {
for (const b of document.querySelectorAll('button')) {
if ((b.innerText || '').trim().toLowerCase() === 'discard') return b;
}
return null;
}).catch(() => null);
const el = handle?.asElement();
if (el) await el.click().catch(() => {});
else await handle?.dispose().catch(() => {});
}

View File

@@ -25,10 +25,6 @@ export const APPLY_BETWEEN_DELAY_JITTER = 1000;
export const LINKEDIN_BASE = 'https://www.linkedin.com';
export const LINKEDIN_EASY_APPLY_MODAL_SELECTOR = '[role="dialog"]';
export const LINKEDIN_APPLY_BUTTON_SELECTOR = '[aria-label*="Easy Apply"]';
export const LINKEDIN_SUBMIT_SELECTOR = 'button[aria-label="Submit application"]';
export const LINKEDIN_NEXT_SELECTOR = 'button[aria-label="Continue to next step"]';
export const LINKEDIN_REVIEW_SELECTOR = 'button[aria-label="Review your application"]';
export const LINKEDIN_DISMISS_SELECTOR = 'button[aria-label="Dismiss"]';
export const LINKEDIN_MAX_MODAL_STEPS = 20;
// --- Wellfound ---
@@ -48,6 +44,10 @@ export const SESSION_REFRESH_POLL_TIMEOUT = 30000;
export const SESSION_REFRESH_POLL_WAIT = 2000;
export const SESSION_LOGIN_VERIFY_WAIT = 3000;
// --- Form Filler ---
export const AUTOCOMPLETE_WAIT = 800;
export const AUTOCOMPLETE_TIMEOUT = 2000;
// --- Form Filler Defaults ---
export const DEFAULT_YEARS_EXPERIENCE = 7;
export const DEFAULT_DESIRED_SALARY = 150000;

View File

@@ -6,7 +6,8 @@
import {
DEFAULT_YEARS_EXPERIENCE, DEFAULT_DESIRED_SALARY,
MINIMUM_SALARY_FACTOR, DEFAULT_SKILL_RATING,
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH,
AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT
} from './constants.mjs';
export class FormFiller {
@@ -45,10 +46,10 @@ export class FormFiller {
if (l.includes('email')) return p.email || null;
if (l.includes('phone') || l.includes('mobile')) return p.phone || null;
if (l.includes('city') && !l.includes('remote')) return p.location?.city || null;
if (l.includes('state') && !l.includes('statement')) return p.location?.state || null;
if (l.includes('zip') || l.includes('postal')) return p.location?.zip || null;
if (l.includes('country') && !l.includes('code')) return p.location?.country || null;
if (l.includes('country code') || l.includes('phone country')) return 'United States (+1)';
if (l.includes('country')) return p.location?.country || null;
if (l.includes('state') && !l.includes('statement')) return p.location?.state || null;
if (l.includes('linkedin')) return p.linkedin_url || null;
if (l.includes('website') || l.includes('portfolio')) return p.linkedin_url || null;
if (l.includes('currently located') || l.includes('current location') || l.includes('where are you')) {
@@ -122,21 +123,39 @@ export class FormFiller {
}).catch(() => '');
}
/**
* Select the first option from an autocomplete dropdown.
* Waits for the dropdown to appear, then clicks the first option.
* Scoped to the input's nearest container to avoid clicking wrong dropdowns.
*/
async selectAutocomplete(page, inp) {
// Wait for dropdown to appear near the input
const option = await page.waitForSelector(
'[role="option"], [role="listbox"] li, ul[class*="autocomplete"] li',
{ timeout: AUTOCOMPLETE_TIMEOUT, state: 'visible' }
).catch(() => null);
if (option) {
await option.click().catch(() => {});
await page.waitForTimeout(AUTOCOMPLETE_WAIT);
}
}
// Fill all fields in a container (page or modal element)
// Returns array of unknown required field labels
async fill(page, resumePath) {
const unknown = [];
const modal = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page;
// Scope to modal if present, otherwise use page
const container = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page;
// Resume upload — only if no existing resume selected
const hasResumeSelected = await page.$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]').catch(() => null);
const hasResumeSelected = await container.$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]').catch(() => null);
if (!hasResumeSelected && resumePath) {
const fileInput = await page.$('input[type="file"]');
const fileInput = await container.$('input[type="file"]');
if (fileInput) await fileInput.setInputFiles(resumePath).catch(() => {});
}
// Phone — always overwrite (LinkedIn pre-fills wrong number)
for (const inp of await page.$$('input[type="text"], input[type="tel"]')) {
for (const inp of await container.$$('input[type="text"], input[type="tel"]')) {
if (!await inp.isVisible().catch(() => false)) continue;
const lbl = await this.getLabel(inp);
if (lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) {
@@ -146,7 +165,7 @@ export class FormFiller {
}
// Text / number / url / email inputs
for (const inp of await page.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"]')) {
for (const inp of await container.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"]')) {
if (!await inp.isVisible().catch(() => false)) continue;
const lbl = await this.getLabel(inp);
if (!lbl || lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) continue;
@@ -157,12 +176,9 @@ export class FormFiller {
if (answer && answer !== this.profile.cover_letter) {
await inp.fill(String(answer)).catch(() => {});
// Handle city/location autocomplete dropdowns
if (lbl.toLowerCase().includes('city') || lbl.toLowerCase().includes('location') || lbl.toLowerCase().includes('located')) {
await page.waitForTimeout(800);
const dropdown = await page.$('[role="listbox"], [role="option"], ul[class*="autocomplete"] li').catch(() => null);
if (dropdown) {
await page.click('[role="option"]:first-child, [role="listbox"] li:first-child').catch(() => {});
}
const ll = lbl.toLowerCase();
if (ll.includes('city') || ll.includes('location') || ll.includes('located')) {
await this.selectAutocomplete(page, inp);
}
} else if (!answer) {
const required = await inp.getAttribute('required').catch(() => null);
@@ -171,7 +187,7 @@ export class FormFiller {
}
// Textareas
for (const ta of await page.$$('textarea')) {
for (const ta of await container.$$('textarea')) {
if (!await ta.isVisible().catch(() => false)) continue;
const lbl = await this.getLabel(ta);
const existing = await ta.inputValue().catch(() => '');
@@ -186,22 +202,29 @@ export class FormFiller {
}
// Fieldsets (Yes/No radios)
for (const fs of await page.$$('fieldset')) {
for (const fs of await container.$$('fieldset')) {
const leg = await fs.$eval('legend', el => el.textContent.trim()).catch(() => '');
if (!leg) continue;
const anyChecked = await fs.$('input:checked');
if (anyChecked) continue;
const answer = this.answerFor(leg);
if (answer) {
const lbl = fs.locator(`label:has-text("${answer}")`).first();
if (await lbl.count() > 0) await lbl.click().catch(() => {});
// Find label within this fieldset that matches the answer text
const labels = await fs.$$('label');
for (const lbl of labels) {
const text = await lbl.textContent().catch(() => '');
if (text.trim().toLowerCase() === answer.toLowerCase()) {
await lbl.click().catch(() => {});
break;
}
}
} else {
unknown.push(leg);
}
}
// Selects
for (const sel of await page.$$('select')) {
for (const sel of await container.$$('select')) {
if (!await sel.isVisible().catch(() => false)) continue;
const lbl = await this.getLabel(sel);
const existing = await sel.inputValue().catch(() => '');
@@ -213,34 +236,31 @@ export class FormFiller {
});
} else {
// EEO/voluntary fields — default to "Prefer not to disclose"
const l = lbl.toLowerCase();
if (l.includes('race') || l.includes('ethnicity') || l.includes('gender') ||
l.includes('veteran') || l.includes('disability') || l.includes('identification')) {
await sel.selectOption({ label: /prefer not|decline|do not wish|i don/i }).catch(async () => {
const opts = await sel.$$('option');
for (const opt of opts) {
const text = await opt.textContent();
if (/prefer not|decline|do not wish|i don/i.test(text || '')) {
await sel.selectOption({ label: text.trim() }).catch(() => {});
break;
}
const ll = lbl.toLowerCase();
if (ll.includes('race') || ll.includes('ethnicity') || ll.includes('gender') ||
ll.includes('veteran') || ll.includes('disability') || ll.includes('identification')) {
// Iterate options to find a "prefer not" variant — selectOption doesn't accept regex
const opts = await sel.$$('option');
for (const opt of opts) {
const text = await opt.textContent().catch(() => '');
if (/prefer not|decline|do not wish|i don/i.test(text || '')) {
await sel.selectOption({ label: text.trim() }).catch(() => {});
break;
}
});
}
}
}
}
// Checkboxes — "mark as top choice" and similar opt-ins
for (const cb of await page.$$('input[type="checkbox"]')) {
for (const cb of await container.$$('input[type="checkbox"]')) {
if (!await cb.isVisible().catch(() => false)) continue;
if (await cb.isChecked().catch(() => false)) continue;
const lbl = await this.getLabel(cb);
const l = lbl.toLowerCase();
// Check opt-in boxes (top choice, interested, confirm)
if (l.includes('top choice') || l.includes('interested') || l.includes('confirm') || l.includes('agree')) {
const ll = lbl.toLowerCase();
if (ll.includes('top choice') || ll.includes('interested') || ll.includes('confirm') || ll.includes('agree')) {
await cb.check().catch(() => {});
}
// Skip EEO/legal checkboxes — leave unchecked unless they are required confirms
}
return unknown;

View File

@@ -48,7 +48,7 @@ Example format: ["query one", "query two", "query three"]`;
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
model: 'claude-sonnet-4-6-20251101',
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }]
})