@@ -97,26 +97,47 @@ export async function submitViaBrowser({
9797 const page = ctx . pages ( ) [ 0 ] || await ctx . newPage ( ) ;
9898
9999 console . log ( `\n📋 Opening: ${ url } ` ) ;
100- await page . goto ( url , { waitUntil : 'domcontentloaded' , timeout : 20000 } ) ;
100+ // networkidle ensures React hydration for React-Select dropdowns
101+ try {
102+ await page . goto ( url , { waitUntil : 'networkidle' , timeout : 45000 } ) ;
103+ } catch {
104+ // Fallback: load + manual wait for React hydration
105+ await page . goto ( url , { waitUntil : 'load' , timeout : 30000 } ) ;
106+ await page . waitForTimeout ( 3000 ) ;
107+ }
101108 await page . waitForSelector ( '#application-form' , { timeout : 10000 } ) ;
102- await page . waitForTimeout ( 1500 ) ; // let React hydrate
103109
104110 // --- Fill standard fields ---
105111 console . log ( ' ✏️ Filling standard fields...' ) ;
106112 await safeType ( page , '#first_name' , firstName ) ;
107113 await safeType ( page , '#last_name' , lastName ) ;
108114 await safeType ( page , '#email' , cand . email || '' ) ;
109- await safeType ( page , '#phone' , cand . phone || '' ) ;
110115
111- // Set phone country to India
116+ // Strip country code from phone (form has separate country dropdown)
117+ const rawPhone = cand . phone || '' ;
118+ const phoneDigits = rawPhone . replace ( / ^ \+ \d { 1 , 3 } [ - \s ] ? / , '' ) ;
119+ await safeType ( page , '#phone' , phoneDigits ) ;
120+
121+ // Set phone country to India (+91)
112122 try {
113- const phoneCountry = page . locator ( '#country[role="combobox"]' ) . first ( ) ;
114- if ( await phoneCountry . count ( ) > 0 ) {
115- await phoneCountry . click ( ) ;
116- await phoneCountry . fill ( 'India' ) ;
123+ const phoneWrapper = page . locator ( '.field-wrapper:has(#phone) .select__control, .iti__selected-flag' ) . first ( ) ;
124+ if ( await phoneWrapper . count ( ) > 0 ) {
125+ await phoneWrapper . click ( ) ;
117126 await page . waitForTimeout ( 500 ) ;
118- const opt = page . locator ( '.select__option:has-text("India")' ) . first ( ) ;
119- if ( await opt . count ( ) > 0 ) await opt . click ( ) ;
127+ const indiaOpt = page . locator ( '.select__option' ) . filter ( { hasText : 'India' } ) . first ( ) ;
128+ if ( await indiaOpt . count ( ) > 0 ) {
129+ await indiaOpt . click ( ) ;
130+ } else {
131+ // Try typing to filter
132+ const phoneInput = page . locator ( '.field-wrapper:has(#phone) .select__input input' ) . first ( ) ;
133+ if ( await phoneInput . count ( ) > 0 ) {
134+ await phoneInput . fill ( 'India' ) ;
135+ await page . waitForTimeout ( 400 ) ;
136+ const filtered = page . locator ( '.select__option' ) . first ( ) ;
137+ if ( await filtered . count ( ) > 0 ) await filtered . click ( ) ;
138+ }
139+ await page . keyboard . press ( 'Escape' ) ;
140+ }
120141 }
121142 } catch { /* phone country not found */ }
122143
@@ -182,7 +203,28 @@ export async function submitViaBrowser({
182203 await submitBtn . scrollIntoViewIfNeeded ( ) ;
183204 }
184205
185- // --- Prompt user ---
206+ // --- Auto-submit: attempt CAPTCHA solve + form submit ---
207+ let autoSubmitted = false ;
208+ try {
209+ autoSubmitted = await attemptAutoSubmit ( page ) ;
210+ } catch ( e ) {
211+ console . log ( ` ⚠️ Auto-submit failed: ${ e . message } ` ) ;
212+ }
213+
214+ if ( autoSubmitted ) {
215+ // Wait for redirect/confirmation
216+ await page . waitForTimeout ( 3000 ) ;
217+ const successEl = await page . $ ( '.application--success, .thank-you, [data-test="confirmation"]' ) ;
218+ const curUrl = page . url ( ) ;
219+ if ( successEl || ( curUrl !== url && ! curUrl . includes ( '/error' ) ) ) {
220+ console . log ( ' 🎉 Application auto-submitted successfully!' ) ;
221+ try { await browser . close ( ) ; } catch { }
222+ if ( chromePid ) try { process . kill ( chromePid , 'SIGTERM' ) ; } catch { }
223+ return { success : true , message : 'Auto-submitted' , unanswered } ;
224+ }
225+ }
226+
227+ // --- Fallback: Prompt user ---
186228 // Bring Chrome to foreground on macOS
187229 try {
188230 const { execSync } = await import ( 'child_process' ) ;
@@ -199,30 +241,36 @@ export async function submitViaBrowser({
199241 // --- Wait for user to submit ---
200242 const startUrl = page . url ( ) ;
201243 const result = await Promise . race ( [
202- // Success: page navigates away or shows confirmation
203244 waitForSubmission ( page , startUrl , submitTimeout ) ,
204- // Timeout
205245 new Promise ( res => setTimeout ( ( ) => res ( {
206246 success : false ,
207247 message : 'Timed out waiting for user to submit' ,
208248 } ) , submitTimeout ) ) ,
209249 ] ) ;
210250
211- // Small delay before closing so user sees confirmation
212251 if ( result . success ) {
213252 console . log ( ' 🎉 Application submitted successfully!' ) ;
214- await page . waitForTimeout ( 2000 ) ;
253+ try { await page . waitForTimeout ( 2000 ) ; } catch { }
215254 } else {
216255 console . log ( ` ❌ ${ result . message } ` ) ;
217256 }
218257
219- await browser . close ( ) ;
258+ try { await browser . close ( ) ; } catch { }
220259 if ( chromePid ) try { process . kill ( chromePid , 'SIGTERM' ) ; } catch { }
221260 return { ...result , unanswered } ;
222261
223262 } catch ( err ) {
224- if ( browser ) await browser . close ( ) . catch ( ( ) => { } ) ;
263+ try { if ( browser ) await browser . close ( ) ; } catch { }
225264 if ( chromePid ) try { process . kill ( chromePid , 'SIGTERM' ) ; } catch { }
265+ // Browser/page closed = user likely submitted and closed Chrome
266+ if ( err . message ?. includes ( 'closed' ) || err . message ?. includes ( 'crashed' ) ) {
267+ console . log ( ' ℹ️ Browser was closed — application may have been submitted' ) ;
268+ return {
269+ success : true ,
270+ message : 'Browser closed by user (likely submitted)' ,
271+ unanswered : [ ] ,
272+ } ;
273+ }
226274 return {
227275 success : false ,
228276 message : `Browser error: ${ err . message } ` ,
@@ -251,27 +299,137 @@ export async function batchApply({ boardToken, jobIds, profile, pdfPath, submitT
251299
252300// --- Internal helpers ---
253301
302+ /**
303+ * Attempt to auto-solve reCAPTCHA and submit the form.
304+ * Strategy:
305+ * 1. Try clicking the reCAPTCHA checkbox (works in some non-headless setups)
306+ * 2. If CAPTCHA solver API key is available, use it
307+ * 3. Try submitting without CAPTCHA (some forms don't enforce it)
308+ * Returns true if form was submitted.
309+ */
310+ async function attemptAutoSubmit ( page ) {
311+ // Strategy 1: Try clicking reCAPTCHA checkbox directly
312+ try {
313+ const recaptchaFrame = page . frameLocator ( 'iframe[src*="recaptcha"]' ) . first ( ) ;
314+ const checkbox = recaptchaFrame . locator ( '.recaptcha-checkbox-border, #recaptcha-anchor' ) ;
315+ if ( await checkbox . count ( ) > 0 ) {
316+ await checkbox . click ( ) ;
317+ await page . waitForTimeout ( 3000 ) ;
318+ // Check if it was solved (checkmark appears)
319+ const checked = recaptchaFrame . locator ( '.recaptcha-checkbox-checked, .recaptcha-checkbox-checkmark[style*="opacity: 1"]' ) ;
320+ if ( await checked . count ( ) > 0 ) {
321+ console . log ( ' ✅ reCAPTCHA checkbox solved!' ) ;
322+ }
323+ }
324+ } catch { /* CAPTCHA frame not found or not clickable */ }
325+
326+ // Strategy 2: Use 2captcha/capsolver if API key is set
327+ const captchaKey = process . env . CAPTCHA_API_KEY || process . env . TWO_CAPTCHA_KEY || '' ;
328+ if ( captchaKey ) {
329+ try {
330+ const solved = await solveCaptchaViaAPI ( page , captchaKey ) ;
331+ if ( solved ) console . log ( ' ✅ reCAPTCHA solved via API!' ) ;
332+ } catch ( e ) {
333+ console . log ( ` ⚠️ CAPTCHA API solve failed: ${ e . message } ` ) ;
334+ }
335+ }
336+
337+ // Strategy 3: Click submit button
338+ const submitBtn = page . locator ( 'button.btn--pill, button[type="submit"], #submit_app' ) . first ( ) ;
339+ if ( await submitBtn . count ( ) > 0 ) {
340+ console . log ( ' 🚀 Clicking submit...' ) ;
341+ await submitBtn . click ( ) ;
342+ await page . waitForTimeout ( 2000 ) ;
343+
344+ // Check if submission went through
345+ const errorEl = await page . $ ( '.application--error, .error-message, .flash--danger' ) ;
346+ if ( ! errorEl ) {
347+ return true ; // No error → likely submitted
348+ }
349+ const errText = await errorEl . textContent ( ) . catch ( ( ) => '' ) ;
350+ if ( errText . toLowerCase ( ) . includes ( 'captcha' ) || errText . toLowerCase ( ) . includes ( 'robot' ) ) {
351+ console . log ( ' ⚠️ CAPTCHA required — auto-submit blocked' ) ;
352+ return false ;
353+ }
354+ // Other error — still return false to fall back to manual
355+ return false ;
356+ }
357+ return false ;
358+ }
359+
360+ /**
361+ * Solve reCAPTCHA Enterprise via 2captcha API.
362+ */
363+ async function solveCaptchaViaAPI ( page , apiKey ) {
364+ const pageUrl = page . url ( ) ;
365+ const siteKey = '6LfmcbcpAAAAAChNTbhUShzUOAMj_wY9LQIvLFX0' ;
366+
367+ // Submit solve request
368+ const submitUrl = `https://2captcha.com/in.php?key=${ apiKey } &method=userrecaptcha&googlekey=${ siteKey } &pageurl=${ encodeURIComponent ( pageUrl ) } &enterprise=1&json=1` ;
369+ const submitRes = await fetch ( submitUrl ) . then ( r => r . json ( ) ) ;
370+ if ( submitRes . status !== 1 ) throw new Error ( `2captcha submit: ${ submitRes . request } ` ) ;
371+
372+ const requestId = submitRes . request ;
373+ console . log ( ` ⏳ CAPTCHA solve request: ${ requestId } ` ) ;
374+
375+ // Poll for result (max 120s)
376+ for ( let i = 0 ; i < 24 ; i ++ ) {
377+ await new Promise ( r => setTimeout ( r , 5000 ) ) ;
378+ const resultUrl = `https://2captcha.com/res.php?key=${ apiKey } &action=get&id=${ requestId } &json=1` ;
379+ const res = await fetch ( resultUrl ) . then ( r => r . json ( ) ) ;
380+ if ( res . status === 1 ) {
381+ // Inject token
382+ await page . evaluate ( ( token ) => {
383+ const textarea = document . getElementById ( 'g-recaptcha-response' ) ;
384+ if ( textarea ) {
385+ textarea . style . display = 'block' ;
386+ textarea . value = token ;
387+ }
388+ // Also try enterprise callback
389+ if ( window . ___grecaptcha_cfg ?. clients ) {
390+ for ( const client of Object . values ( window . ___grecaptcha_cfg . clients ) ) {
391+ try {
392+ const cb = Object . values ( client ) . find ( v => v ?. callback ) ?. callback ;
393+ if ( typeof cb === 'function' ) cb ( token ) ;
394+ } catch { }
395+ }
396+ }
397+ } , res . request ) ;
398+ return true ;
399+ }
400+ if ( res . request !== 'CAPCHA_NOT_READY' ) {
401+ throw new Error ( `2captcha poll: ${ res . request } ` ) ;
402+ }
403+ }
404+ throw new Error ( 'CAPTCHA solve timed out (120s)' ) ;
405+ }
406+
254407/** Wait for the page to navigate or show a success element after form submit. */
255408async function waitForSubmission ( page , startUrl , timeout ) {
256409 const deadline = Date . now ( ) + timeout ;
257410 while ( Date . now ( ) < deadline ) {
258- // Check URL change (most Greenhouse boards redirect after submit)
259- const currentUrl = page . url ( ) ;
260- if ( currentUrl !== startUrl && ! currentUrl . includes ( '/error' ) ) {
261- return { success : true , message : 'Redirected to confirmation page' } ;
262- }
263- // Check for success element on same page
264- const success = await page . $ ( '.application--success, .thank-you, [data-test="confirmation"], .flash--success' ) ;
265- if ( success ) {
266- return { success : true , message : 'Confirmation element detected' } ;
267- }
268- // Check for error
269- const err = await page . $ ( '.application--error, .error-message' ) ;
411+ try {
412+ const currentUrl = page . url ( ) ;
413+ if ( currentUrl !== startUrl && ! currentUrl . includes ( '/error' ) ) {
414+ return { success : true , message : 'Redirected to confirmation page' } ;
415+ }
416+ const success = await page . $ ( '.application--success, .thank-you, [data-test="confirmation"], .flash--success' ) ;
417+ if ( success ) {
418+ return { success : true , message : 'Confirmation element detected' } ;
419+ }
420+ const err = await page . $ ( '.application--error, .error-message' ) ;
270421 if ( err ) {
271422 const text = await err . textContent ( ) . catch ( ( ) => 'Unknown error' ) ;
272423 return { success : false , message : `Form error: ${ text . substring ( 0 , 200 ) } ` } ;
273424 }
274- await page . waitForTimeout ( 1000 ) ;
425+ await page . waitForTimeout ( 1000 ) ;
426+ } catch ( e ) {
427+ // Page/browser was closed — user likely submitted and closed Chrome
428+ if ( e . message ?. includes ( 'closed' ) || e . message ?. includes ( 'crashed' ) ) {
429+ return { success : true , message : 'Browser closed by user (likely submitted)' } ;
430+ }
431+ throw e ;
432+ }
275433 }
276434 return { success : false , message : 'Submission wait timed out' } ;
277435}
@@ -301,30 +459,51 @@ async function selectReactOption(page, question, answerValue) {
301459 const qId = question . id || '' ;
302460
303461 try {
304- // The React-Select combobox input has id=qId and role="combobox"
305- const combobox = page . locator ( `#${ qId } [role="combobox"], input#${ qId } .select__input` ) . first ( ) ;
306- if ( await combobox . count ( ) > 0 ) {
307- await combobox . click ( ) ;
308- await combobox . fill ( labelText . substring ( 0 , 25 ) ) ;
462+ const wrapper = page . locator ( `.field-wrapper:has(#${ qId } )` ) . first ( ) ;
463+ if ( await wrapper . count ( ) === 0 ) return ;
464+
465+ // Primary method: click the .select__control to open dropdown
466+ const control = wrapper . locator ( '.select__control' ) . first ( ) ;
467+ if ( await control . count ( ) > 0 ) {
468+ await control . click ( ) ;
309469 await page . waitForTimeout ( 600 ) ;
310- const opt = page . locator ( `.select__option:has-text("${ labelText . substring ( 0 , 40 ) } ")` ) . first ( ) ;
470+
471+ // Try to find the option in the dropdown menu
472+ const opt = page . locator ( `.select__option` ) . filter ( { hasText : labelText } ) . first ( ) ;
311473 if ( await opt . count ( ) > 0 ) {
312474 await opt . click ( ) ;
475+ await page . waitForTimeout ( 200 ) ;
313476 return ;
314477 }
315- }
316478
317- // Fallback: find the .select__control near this question's field-wrapper
318- const wrapper = page . locator ( `.field-wrapper:has(#${ qId } )` ) . first ( ) ;
319- if ( await wrapper . count ( ) > 0 ) {
320- const control = wrapper . locator ( '.select__control' ) . first ( ) ;
321- if ( await control . count ( ) > 0 ) {
322- await control . click ( ) ;
323- await page . waitForTimeout ( 400 ) ;
324- const opt = page . locator ( `.select__option:has-text("${ labelText . substring ( 0 , 40 ) } ")` ) . first ( ) ;
325- if ( await opt . count ( ) > 0 ) await opt . click ( ) ;
479+ // If many options, type to filter
480+ const input = wrapper . locator ( '.select__input input, input.select__input' ) . first ( ) ;
481+ if ( await input . count ( ) > 0 ) {
482+ await input . fill ( labelText . substring ( 0 , 20 ) ) ;
483+ await page . waitForTimeout ( 500 ) ;
484+ const filtered = page . locator ( `.select__option` ) . first ( ) ;
485+ if ( await filtered . count ( ) > 0 ) {
486+ await filtered . click ( ) ;
487+ await page . waitForTimeout ( 200 ) ;
488+ return ;
489+ }
326490 }
491+
492+ // Close dropdown if we couldn't select
493+ await page . keyboard . press ( 'Escape' ) ;
327494 }
495+
496+ // Fallback: direct DOM manipulation via page.evaluate
497+ await page . evaluate ( ( { qId, value, label } ) => {
498+ const input = document . getElementById ( qId ) ;
499+ if ( ! input ) return ;
500+ // Trigger React-Select's onChange via React fiber
501+ const nativeSet = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set ;
502+ if ( nativeSet ) {
503+ nativeSet . call ( input , label ) ;
504+ input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
505+ }
506+ } , { qId, value : answerValue , label : labelText } ) ;
328507 } catch { /* dropdown interaction failed, skip */ }
329508}
330509
@@ -336,16 +515,20 @@ async function fillEEOCFields(page) {
336515 const eeocFields = [ 'gender' , 'hispanic_ethnicity' , 'veteran_status' , 'disability_status' ] ;
337516 for ( const fieldId of eeocFields ) {
338517 try {
339- const combobox = page . locator ( `#${ fieldId } [role="combobox"]` ) . first ( ) ;
340- if ( await combobox . count ( ) === 0 ) continue ;
341- await combobox . click ( ) ;
342- await page . waitForTimeout ( 300 ) ;
518+ const wrapper = page . locator ( `.field-wrapper:has(#${ fieldId } ), .field-wrapper:has([name="${ fieldId } "])` ) . first ( ) ;
519+ if ( await wrapper . count ( ) === 0 ) continue ;
520+
521+ const control = wrapper . locator ( '.select__control' ) . first ( ) ;
522+ if ( await control . count ( ) === 0 ) continue ;
523+
524+ await control . click ( ) ;
525+ await page . waitForTimeout ( 500 ) ;
526+
343527 // Prefer "Decline" option
344- const decline = page . locator ( '.select__option:has-text(" Decline")' ) . first ( ) ;
528+ const decline = page . locator ( '.select__option' ) . filter ( { hasText : ' Decline' } ) . first ( ) ;
345529 if ( await decline . count ( ) > 0 ) {
346530 await decline . click ( ) ;
347531 } else {
348- // Close dropdown if no decline option
349532 await page . keyboard . press ( 'Escape' ) ;
350533 }
351534 await page . waitForTimeout ( 200 ) ;
0 commit comments