import { z } from 'zod'; import type { Stagehand } from '@browserbasehq/stagehand'; import { AppBrain } from '@detiq/app-brain'; import { solveCaptcha } from './captcha-solver.js'; import { waitForEmailContent } from './email-otp.js'; import { logger } from './logger.js'; import type { Credentials } from './auth.js'; import { simulateMouseMovement } from './stealth/human-behavior.js'; const CLICK_INTENT = /\b(click|tap|press|submit|select|choose|toggle)\b/i; /** Stagehand V3 enforces elementId as /^\d+-\d+$/ — LLM sometimes returns bare integers. * Catch the resulting AI_NoObjectGeneratedError so callers can fall back to DOM. */ function isNoObjectGeneratedError(err: any): boolean { const name = String(err?.name ?? ''); const msg = String(err?.message ?? ''); return name.includes('NoObjectGenerated') || msg.includes('NoObjectGenerated') || msg.includes('No object generated'); } /** Simulate human mouse movement before click-type actions, then call stagehand.act(). */ async function actWithMouseSim(stagehand: Stagehand, instruction: string): Promise { if (CLICK_INTENT.test(instruction)) { try { const pg = await (stagehand as any).resolvePage(); const viewport = pg.viewportSize() ?? { width: 1280, height: 720 }; // Move mouse toward center of page with some randomness before Stagehand clicks const targetX = viewport.width * 0.3 + Math.random() * viewport.width * 0.4; const targetY = viewport.height * 0.2 + Math.random() * viewport.height * 0.5; await simulateMouseMovement(pg, targetX, targetY); } catch { /* non-fatal — don't block the action */ } } return stagehand.act(instruction); } /** * Recorded login step from the Chrome extension's step recorder. * {username} and {password} in `value` are substituted at replay time. */ export interface LoginStep { action: 'fill' | 'click' | 'wait'; selector: string; value?: string; ms?: number; } const TwoFASchema = z.object({ has2FA: z.boolean(), fieldLabel: z.string().optional(), }); const CAPTCHA_SELECTORS = [ 'iframe[src*="recaptcha"]', 'iframe[src*="hcaptcha"]', 'iframe[src*="challenges.cloudflare.com"]', '.cf-turnstile', '[data-sitekey]', '#captcha', '.captcha', '[id*="captcha"]', '[class*="captcha"]', ]; /** * AI-guided login using Stagehand act/extract. * * @param loginInstructions - Optional natural language description of the login flow. * When provided, an initial stagehand.act() is called with these instructions to * navigate the UI (e.g. "click Sign In in the nav, fill email, click Continue"). * The standard 3-step credential fill follows immediately after. * @param opts - Optional tenant/project/job IDs for writing AUTH_STEP CrawlEvidence. */ export async function loginWithStagehand( stagehand: Stagehand, creds: Credentials, startUrl: string, captchaOpts?: { apiKey?: string; provider?: '2captcha' | 'capsolver' }, emailOtpOpts?: { apiKey?: string; inboxId?: string }, loginInstructions?: string, opts?: { tenantId?: string; projectId?: string; jobId?: string }, ): Promise { async function logStep(step: string, fn: () => Promise): Promise<{ success: boolean; detail: string }> { const t0 = Date.now(); try { const r = await fn(); const result = { success: true, detail: r?.description ?? r?.message ?? step }; if (opts?.tenantId && opts?.projectId) { AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { jobId: opts?.jobId, type: 'AUTH_STEP', payload: { step, ...result, ms: Date.now() - t0 }, }).catch(() => {}); } return result; } catch (e: any) { const result = { success: false, detail: e.message ?? 'failed' }; if (opts?.tenantId && opts?.projectId) { AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { jobId: opts?.jobId, type: 'AUTH_STEP', payload: { step, ...result, ms: Date.now() - t0 }, }).catch(() => {}); } throw e; } } try { const pg = await (stagehand as any).resolvePage(); // CAPTCHA detection for (const sel of CAPTCHA_SELECTORS) { const count = await pg.locator(sel).count().catch(() => 0); if (count > 0) { logger.warn({ selector: sel }, '[crawler] CAPTCHA detected on login page'); if (captchaOpts?.apiKey) { logger.info({ provider: captchaOpts.provider ?? '2captcha' }, '[crawler] Attempting auto-solve'); try { const result = await solveCaptcha(pg, startUrl, captchaOpts.apiKey, captchaOpts.provider ?? '2captcha'); if (result) { logger.info({ provider: result.provider }, '[crawler] CAPTCHA solved — proceeding with login'); break; } } catch (solveErr) { logger.warn({ err: solveErr }, '[crawler] CAPTCHA solve failed — falling back to manual error'); throw new Error(`CAPTCHA_DETECTED: Auto-solve failed (${String(solveErr)}). Use session capture instead.`); } } else { throw new Error( 'CAPTCHA_DETECTED: Login page has a CAPTCHA. Use "Session Capture → Launch Browser" to log in manually, then capture the session. Alternatively, disable CAPTCHA for the ZeTa crawler IP.' ); } break; } } // If caller provided natural language login instructions, use them to navigate to the login form const MAX_LOGIN_INSTRUCTIONS = 2000; if (loginInstructions && loginInstructions.length > MAX_LOGIN_INSTRUCTIONS) { throw new Error(`loginInstructions exceeds ${MAX_LOGIN_INSTRUCTIONS} character limit`); } if (loginInstructions) { await logStep('custom_recipe', async () => { try { return await actWithMouseSim(stagehand, loginInstructions); } catch (err: any) { if (!isNoObjectGeneratedError(err)) throw err; logger.warn('[crawler] custom_recipe act() failed — Stagehand V3 elementId schema error, skipping to standard fill'); return { description: 'custom_recipe skipped (schema error)' }; } }); await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(1500)); } await logStep('fill_username', async () => { try { return await actWithMouseSim(stagehand, `Type "${creds.username}" into the username or email input field`); } catch (err: any) { if (!isNoObjectGeneratedError(err)) throw err; logger.warn('[crawler] fill_username act() failed — falling back to DOM'); const sel = 'input[type="email"], input[autocomplete="email"], input[autocomplete="username"], input[name*="email" i], input[name*="user" i], input[placeholder*="email" i], input[placeholder*="user" i], input[id*="email" i], input[id*="user" i]'; await pg.locator(sel).first().fill(creds.username); return { description: 'fill_username via DOM fallback' }; } }); // Don't send password to LLM — use direct Playwright fill to avoid // leaking credentials into the Stagehand/LLM provider's request logs. await logStep('fill_password', async () => { const observations = await stagehand.observe('Find the password input field'); const selector = observations[0]?.selector || 'input[type="password"]'; try { await pg.locator(selector).fill(creds.password); } catch { // Stagehand locator failed (e.g. form re-rendered, proxy can't locate element). // Fall back to direct DOM fill via evaluate so the password never touches the LLM. await pg.evaluate((pwd: string) => { const input = document.querySelector('input[type="password"]') as HTMLInputElement | null; if (!input) throw new Error('No password field found on page'); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; setter?.call(input, pwd); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }, creds.password); } }); await logStep('click_submit', async () => { try { return await actWithMouseSim(stagehand, 'Click the sign in, log in, login, or continue button to submit credentials'); } catch (err: any) { if (!isNoObjectGeneratedError(err)) throw err; logger.warn('[crawler] click_submit act() failed — falling back to DOM'); const sel = 'button[type="submit"], input[type="submit"], button:has-text("Sign in"), button:has-text("Log in"), button:has-text("Login"), button:has-text("Continue"), button:has-text("Next"), button:has-text("Sign In"), [role="button"]:has-text("Sign in"), [role="button"]:has-text("Login")'; await pg.locator(sel).first().click(); await pg.waitForTimeout(500); return { description: 'click_submit via DOM fallback' }; } }); await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(2000)); const twoFA = await stagehand.extract( 'Is there a two-factor authentication, OTP, or verification code input visible right now?', TwoFASchema as any ); if (twoFA.has2FA) { if (opts?.tenantId && opts?.projectId) { AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { jobId: opts?.jobId, type: 'AUTH_STEP', payload: { step: '2fa_detect', success: true, detail: twoFA.fieldLabel ?? '2FA detected', ms: 0 }, }).catch(() => {}); } let code = creds.oneTimeCode ?? await creds.getOneTimeCode?.(); if (!code && emailOtpOpts?.apiKey && emailOtpOpts?.inboxId) { const since = new Date().toISOString(); logger.info('[crawler] 2FA detected — waiting for email OTP or magic link via MailSlurp'); const emailContent = await waitForEmailContent( { provider: 'mailslurp', apiKey: emailOtpOpts.apiKey, inboxId: emailOtpOpts.inboxId }, since ); if (emailContent?.magicLink) { logger.info({ url: emailContent.magicLink }, '[crawler] Magic link received — navigating'); await pg.goto(emailContent.magicLink, { waitUntil: 'domcontentloaded' }); await pg.waitForLoadState('networkidle').catch(() => {}); return true; // magic link navigation = login complete } code = emailContent?.otp ?? undefined; if (code) { logger.info('[crawler] Email OTP received'); } else { logger.warn('[crawler] Email OTP timed out after 60s'); } } if (!code) { console.warn('[crawler] 2FA detected but no oneTimeCode provided'); return false; } await logStep('2fa_inject', async () => { try { return await actWithMouseSim(stagehand, `Enter "${code}" into the ${twoFA.fieldLabel ?? 'verification code'} field`); } catch (err: any) { if (!isNoObjectGeneratedError(err)) throw err; logger.warn('[crawler] 2fa_inject act() failed — falling back to DOM'); const sel = 'input[autocomplete="one-time-code"], input[name*="otp" i], input[name*="code" i], input[name*="token" i], input[inputmode="numeric"], input[type="number"]'; await pg.locator(sel).first().fill(code as string); return { description: '2fa_inject via DOM fallback' }; } }); await logStep('2fa_submit', async () => { try { return await actWithMouseSim(stagehand, 'Click verify or confirm to submit the verification code'); } catch (err: any) { if (!isNoObjectGeneratedError(err)) throw err; logger.warn('[crawler] 2fa_submit act() failed — falling back to DOM'); const sel = 'button[type="submit"], button:has-text("Verify"), button:has-text("Confirm"), button:has-text("Submit"), button:has-text("Continue")'; await pg.locator(sel).first().click(); return { description: '2fa_submit via DOM fallback' }; } }); await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(2000)); } // URL-based success check const currentUrl = pg.url(); const loginPathPattern = /\/(login|signin|sign-in|auth|authenticate|log-in|account\/login)(\?|$|\/)/i; try { const { pathname } = new URL(currentUrl); const wasRedirected = currentUrl !== startUrl; const stillOnLoginPath = loginPathPattern.test(pathname); if (wasRedirected && !stillOnLoginPath) { logger.info({ url: currentUrl }, 'Login confirmed via URL change'); return true; } } catch { /* invalid URL — fall through */ } const passwordCount = await pg.locator('input[type="password"]').count().catch(() => 1); const bodyText = await pg.evaluate(() => (document as any).body?.innerText?.trim()?.length ?? 0).catch(() => 0); if (passwordCount === 0 && bodyText > 30) { logger.info('Login confirmed: password field no longer visible'); return true; } logger.warn({ url: currentUrl }, 'Login appears failed'); try { const screenshotPath = `/tmp/zeta-auth-failure-${Date.now()}.jpg`; await pg.screenshot({ path: screenshotPath, type: 'jpeg', quality: 70, fullPage: false }); logger.warn({ screenshotPath }, '[crawler] Auth failure screenshot saved'); } catch { /* non-fatal */ } return false; } catch (err) { const msg = String((err as Error).message ?? err); if (msg.includes('CAPTCHA_DETECTED')) throw err; logger.error({ err }, 'Login error'); return false; } }