import type { DomainProfile } from './profiles/domain-profile.js'; export type CrawlEngineName = | 'http' | 'browser' | 'browser-stealth' | 'stagehand' | 'vision' | 'recorded-session' | 'native'; export interface CrawlRouteDecisionInput { appUrl: string; appType?: string | null; hasCredentials: boolean; hasStorageState: boolean; hasAuthToken: boolean; hasSkyvern: boolean; hasProxy: boolean; profile: DomainProfile; previousFailureReason?: string; } export interface CrawlRouteDecision { engines: CrawlEngineName[]; reasons: string[]; confidence: number; } /** * Per-domain rendering predictor — learns whether a domain requires a full browser * or can be served by the fast HTTP engine. Implements adaptive crawlee-python pattern. * * Strategy: * 1. On first encounter of a domain, classify the HTTP response body. * 2. Store the decision in memory (Map). All future URLs on same domain skip re-testing. * 3. "Requires browser" signals: React/Vue/Angular root with no content, tiny body (<500 chars meaningful text). */ export class RenderingTypePredictor { private cache = new Map(); /** Return cached decision if available, undefined if unknown. */ getCached(url: string): 'http' | 'browser' | undefined { try { return this.cache.get(new URL(url).hostname); } catch { return undefined; } } /** * Analyze an HTTP response body to predict if JS rendering is required. * Returns 'browser' if JS-heavy signals detected, 'http' otherwise. */ predict(url: string, htmlBody: string, statusCode: number): 'http' | 'browser' { let decision: 'http' | 'browser' = 'http'; try { if (statusCode >= 400) { decision = 'browser'; // let browser handle error pages } else { const bodyLen = htmlBody.length; // Strip tags to get raw text const textOnly = htmlBody.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); const wordCount = textOnly.split(/\s+/).filter(w => w.length > 2).length; // SPA signals: very short text, presence of React/Vue/Angular root markers const hasSpaRoot = /]*\s+id=["'](?:root|app|__nuxt|__next|app-root)["'][^>]*>\s*<\/div>/i.test(htmlBody) || /]*>\s*<\/app-root>/i.test(htmlBody); const hasNoscriptFallback = /]*>[\s\S]{100,}/i.test(htmlBody); const tinyBody = bodyLen < 3000 && wordCount < 50; if ((hasSpaRoot && wordCount < 100) || (tinyBody && !hasNoscriptFallback)) { decision = 'browser'; } } } catch { /* default http */ } try { this.cache.set(new URL(url).hostname, decision); } catch { /* ignore */ } return decision; } /** Force a domain to use a specific engine (e.g. after observing crawl errors). */ set(url: string, engine: 'http' | 'browser'): void { try { this.cache.set(new URL(url).hostname, engine); } catch { /* ignore */ } } stats(): { cached: number; browserDomains: string[]; httpDomains: string[] } { const browserDomains: string[] = []; const httpDomains: string[] = []; for (const [domain, engine] of this.cache) { if (engine === 'browser') browserDomains.push(domain); else httpDomains.push(domain); } return { cached: this.cache.size, browserDomains, httpDomains }; } } /** Singleton predictor — shared across crawl jobs in the same process. */ export const renderingPredictor = new RenderingTypePredictor(); export function chooseCrawlEngines(input: CrawlRouteDecisionInput): CrawlRouteDecision { const engines: CrawlEngineName[] = []; const reasons: string[] = []; const appType = input.appType?.toLowerCase(); if (appType === 'android' || appType === 'ios' || appType === 'windows' || appType === 'macos') { return { engines: ['native'], reasons: [`appType=${input.appType}`], confidence: 0.95 }; } if (input.hasStorageState || input.hasAuthToken) { engines.push('recorded-session'); reasons.push('session/token auth available'); } engines.push(input.hasProxy ? 'browser-stealth' : 'browser'); reasons.push(input.hasProxy ? 'proxy configured; prefer stealth browser path' : 'default browser path'); if (input.hasCredentials || input.profile.industry !== 'unknown') { engines.push('stagehand'); reasons.push(input.hasCredentials ? 'credentials available for AI login/navigation' : `domain profile=${input.profile.industry}`); } const hardUi = input.profile.industry === 'legacy-enterprise' || /captcha|blocked|canvas|iframe|bot/i.test(input.previousFailureReason ?? ''); if (input.hasSkyvern && hardUi) { engines.push('vision'); reasons.push('hard UI or previous block detected; vision fallback enabled'); } if (engines.length === 1) { engines.unshift('http'); reasons.push('HTTP/static extraction can be attempted before browser escalation'); } return { engines: Array.from(new Set(engines)), reasons, confidence: input.profile.confidence, }; }