package browser import ( "context" "encoding/json" "fmt" "log" "math/rand" "net" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" ) // Config holds browser configuration options type Config struct { Headless bool Timeout time.Duration UserDataDir string DebugPort int UseRealChrome bool // Connect to real Chrome instead of launching automated one ConnectToExisting bool // Connect to already running Chrome (user must start it manually) } // DefaultConfig returns default browser configuration func DefaultConfig() Config { return Config{ Headless: false, Timeout: 10 * time.Minute, UserDataDir: filepath.Join(os.TempDir(), "hiringcafe-chrome-profile"), DebugPort: 9224, // Use different port to avoid conflicts UseRealChrome: true, ConnectToExisting: false, } } // Browser wraps chromedp browser context with anti-detection features type Browser struct { allocCtx context.Context allocCancel context.CancelFunc ctx context.Context cancel context.CancelFunc config Config chromeCmd *exec.Cmd } // chromeResponse holds the Chrome DevTools Protocol response type chromeResponse struct { WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` } // ErrCaptchaDetected is returned when bot detection/CAPTCHA is detected var ErrCaptchaDetected = fmt.Errorf("captcha or bot detection detected - terminating") // New creates a new browser instance with anti-detection measures func New(parentCtx context.Context, config Config) (*Browser, error) { // Always use the automated real Chrome approach now return newRealChromeAutomated(parentCtx, config) } // newRealChromeAutomated launches Chrome like a normal user would and connects to it func newRealChromeAutomated(parentCtx context.Context, config Config) (*Browser, error) { b := &Browser{config: config} // Check if Chrome is already running on the debug port conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", config.DebugPort), time.Second) if err == nil { conn.Close() log.Printf("Chrome already running on port %d, connecting to it...", config.DebugPort) } else { // Launch Chrome with remote debugging if err := b.launchChromeClean(); err != nil { return nil, fmt.Errorf("failed to launch Chrome: %w", err) } // Wait for Chrome to be ready if err := b.waitForChrome(); err != nil { b.Close() return nil, fmt.Errorf("chrome not ready: %w", err) } } // Get WebSocket URL wsURL, err := b.getWebSocketURL() if err != nil { b.Close() return nil, fmt.Errorf("failed to get WebSocket URL: %w", err) } log.Printf("Connected to Chrome at %s", wsURL) // Connect to Chrome via WebSocket allocCtx, allocCancel := chromedp.NewRemoteAllocator(parentCtx, wsURL) ctx, cancel := chromedp.NewContext(allocCtx) // Set timeout ctx, timeoutCancel := context.WithTimeout(ctx, config.Timeout) b.allocCtx = allocCtx b.allocCancel = allocCancel b.ctx = ctx b.cancel = func() { timeoutCancel() cancel() } return b, nil } // launchChromeClean launches Chrome as cleanly as possible (like a normal user) func (b *Browser) launchChromeClean() error { chromePath := findChromePath() if chromePath == "" { return fmt.Errorf("chrome not found") } // Ensure user data directory exists if err := os.MkdirAll(b.config.UserDataDir, 0755); err != nil { return fmt.Errorf("failed to create user data dir: %w", err) } // Minimal args - just what's needed for remote debugging // Avoid any automation-related flags that might be detected args := []string{ fmt.Sprintf("--remote-debugging-port=%d", b.config.DebugPort), fmt.Sprintf("--user-data-dir=%s", b.config.UserDataDir), "--remote-allow-origins=*", "--no-first-run", "--no-default-browser-check", "--start-maximized", } log.Printf("Launching Chrome: %s", chromePath) b.chromeCmd = exec.Command(chromePath, args...) b.chromeCmd.Stdout = nil b.chromeCmd.Stderr = nil if err := b.chromeCmd.Start(); err != nil { return fmt.Errorf("failed to start Chrome: %w", err) } log.Printf("Chrome launched with PID %d on port %d", b.chromeCmd.Process.Pid, b.config.DebugPort) return nil } // newRealChrome launches a real Chrome browser and connects via DevTools Protocol func newRealChrome(parentCtx context.Context, config Config) (*Browser, error) { b := &Browser{config: config} // Launch Chrome with remote debugging if err := b.launchChrome(); err != nil { return nil, fmt.Errorf("failed to launch Chrome: %w", err) } // Wait for Chrome to be ready if err := b.waitForChrome(); err != nil { b.Close() return nil, fmt.Errorf("Chrome not ready: %w", err) } // Get WebSocket URL wsURL, err := b.getWebSocketURL() if err != nil { b.Close() return nil, fmt.Errorf("failed to get WebSocket URL: %w", err) } log.Printf("Connecting to Chrome at %s", wsURL) // Connect to Chrome via WebSocket allocCtx, allocCancel := chromedp.NewRemoteAllocator(parentCtx, wsURL) ctx, cancel := chromedp.NewContext(allocCtx) // Set timeout ctx, timeoutCancel := context.WithTimeout(ctx, config.Timeout) b.allocCtx = allocCtx b.allocCancel = allocCancel b.ctx = ctx b.cancel = func() { timeoutCancel() cancel() } // Inject anti-detection scripts if err := b.injectAntiDetection(); err != nil { log.Printf("Warning: could not inject anti-detection: %v", err) } return b, nil } // launchChrome launches Chrome with remote debugging enabled func (b *Browser) launchChrome() error { chromePath := findChromePath() if chromePath == "" { return fmt.Errorf("Chrome not found") } // Ensure user data directory exists if err := os.MkdirAll(b.config.UserDataDir, 0755); err != nil { return fmt.Errorf("failed to create user data dir: %w", err) } args := []string{ fmt.Sprintf("--remote-debugging-port=%d", b.config.DebugPort), fmt.Sprintf("--user-data-dir=%s", b.config.UserDataDir), "--remote-allow-origins=*", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-client-side-phishing-detection", "--disable-default-apps", "--disable-hang-monitor", "--disable-popup-blocking", "--disable-prompt-on-repost", "--disable-sync", "--disable-translate", "--metrics-recording-only", "--safebrowsing-disable-auto-update", "--password-store=basic", "--use-mock-keychain", } log.Printf("Launching Chrome: %s", chromePath) b.chromeCmd = exec.Command(chromePath, args...) b.chromeCmd.Stdout = nil b.chromeCmd.Stderr = nil if err := b.chromeCmd.Start(); err != nil { return fmt.Errorf("failed to start Chrome: %w", err) } log.Printf("Chrome launched with PID %d on port %d", b.chromeCmd.Process.Pid, b.config.DebugPort) return nil } // waitForChrome waits for Chrome to be ready to accept connections func (b *Browser) waitForChrome() error { timeout := time.After(30 * time.Second) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-timeout: return fmt.Errorf("timeout waiting for Chrome to start") case <-ticker.C: conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", b.config.DebugPort), time.Second) if err == nil { conn.Close() time.Sleep(500 * time.Millisecond) // Give Chrome a moment to fully initialize return nil } } } } // getWebSocketURL gets the WebSocket debugger URL from Chrome func (b *Browser) getWebSocketURL() (string, error) { resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", b.config.DebugPort)) if err != nil { return "", err } defer resp.Body.Close() var result chromeResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } return result.WebSocketDebuggerURL, nil } // findChromePath finds the Chrome executable path func findChromePath() string { var paths []string switch runtime.GOOS { case "darwin": paths = []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/Applications/Chromium.app/Contents/MacOS/Chromium", } case "linux": paths = []string{ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", } case "windows": paths = []string{ os.Getenv("LOCALAPPDATA") + "\\Google\\Chrome\\Application\\chrome.exe", os.Getenv("PROGRAMFILES") + "\\Google\\Chrome\\Application\\chrome.exe", os.Getenv("PROGRAMFILES(X86)") + "\\Google\\Chrome\\Application\\chrome.exe", } } for _, p := range paths { if _, err := os.Stat(p); err == nil { return p } } return "" } // newAutomatedChrome creates a browser using chromedp's built-in launcher (fallback) func newAutomatedChrome(parentCtx context.Context, config Config) (*Browser, error) { opts := []chromedp.ExecAllocatorOption{ chromedp.NoFirstRun, chromedp.NoDefaultBrowserCheck, chromedp.DisableGPU, chromedp.NoSandbox, chromedp.Flag("disable-blink-features", "AutomationControlled"), chromedp.Flag("disable-infobars", true), chromedp.Flag("disable-dev-shm-usage", true), chromedp.Flag("window-size", "1920,1080"), chromedp.UserAgent(getRandomUserAgent()), } if config.Headless { opts = append(opts, chromedp.Flag("headless", "new")) } if config.UserDataDir != "" { opts = append(opts, chromedp.UserDataDir(config.UserDataDir)) } allocCtx, allocCancel := chromedp.NewExecAllocator(parentCtx, opts...) ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) ctx, timeoutCancel := context.WithTimeout(ctx, config.Timeout) b := &Browser{ allocCtx: allocCtx, allocCancel: allocCancel, ctx: ctx, cancel: func() { timeoutCancel() cancel() }, config: config, } if err := b.injectAntiDetection(); err != nil { b.Close() return nil, fmt.Errorf("failed to inject anti-detection: %w", err) } return b, nil } // Context returns the browser context func (b *Browser) Context() context.Context { return b.ctx } // Close closes the browser and kills Chrome process if we launched it func (b *Browser) Close() { if b.cancel != nil { b.cancel() } if b.allocCancel != nil { b.allocCancel() } // Kill Chrome process if we launched it if b.chromeCmd != nil && b.chromeCmd.Process != nil { log.Println("Killing Chrome process...") b.chromeCmd.Process.Kill() } } // injectAntiDetection injects JavaScript to bypass bot detection func (b *Browser) injectAntiDetection() error { // JavaScript to override navigator properties and hide automation antiDetectionScript := ` // Override webdriver property Object.defineProperty(navigator, 'webdriver', { get: () => undefined, }); // Override plugins Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5], }); // Override languages Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'], }); // Override platform Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel', }); // Override hardware concurrency Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8, }); // Override device memory Object.defineProperty(navigator, 'deviceMemory', { get: () => 8, }); // Override permissions query const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => ( parameters.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(parameters) ); // Override chrome runtime window.chrome = { runtime: {}, }; // Override console.debug to prevent detection window.console.debug = () => {}; // Add mouse movement simulation capability window.simulateMouseMovement = function() { const event = new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: Math.random() * window.innerWidth, clientY: Math.random() * window.innerHeight }); document.dispatchEvent(event); }; ` return chromedp.Run(b.ctx, chromedp.ActionFunc(func(ctx context.Context) error { _, err := page.AddScriptToEvaluateOnNewDocument(antiDetectionScript).Do(ctx) return err }), ) } // Navigate navigates to a URL and checks for CAPTCHA/bot detection func (b *Browser) Navigate(url string) error { if err := chromedp.Run(b.ctx, chromedp.Navigate(url), chromedp.WaitVisible("body", chromedp.ByQuery), chromedp.Sleep(2*time.Second), ); err != nil { return err } // Check for CAPTCHA/bot detection if detected, err := b.CheckForCaptcha(); err != nil { return err } else if detected { return ErrCaptchaDetected } return nil } // CheckForCaptcha checks if the page shows CAPTCHA or bot detection func (b *Browser) CheckForCaptcha() (bool, error) { var pageContent string var pageTitle string if err := chromedp.Run(b.ctx, chromedp.Title(&pageTitle), chromedp.Evaluate(`document.body.innerText`, &pageContent), ); err != nil { return false, err } // Check for common CAPTCHA/bot detection indicators captchaIndicators := []string{ "Failed to verify your browser", "Vercel Security Checkpoint", "Please verify you are a human", "Access denied", "Checking your browser", "Please complete the security check", "captcha", "CAPTCHA", "robot", "bot detection", "security challenge", "Code 10", "Code 21", "Code 22", } contentLower := strings.ToLower(pageContent) titleLower := strings.ToLower(pageTitle) for _, indicator := range captchaIndicators { indicatorLower := strings.ToLower(indicator) if strings.Contains(contentLower, indicatorLower) || strings.Contains(titleLower, indicatorLower) { log.Printf("CAPTCHA/Bot detection detected: found '%s'", indicator) return true, nil } } return false, nil } // WaitAndClick waits for an element and clicks it with human-like delay func (b *Browser) WaitAndClick(selector string) error { RandomDelay(500, 1500) return chromedp.Run(b.ctx, chromedp.WaitVisible(selector, chromedp.ByQuery), chromedp.Click(selector, chromedp.ByQuery), ) } // Type types text with human-like delays between keystrokes func (b *Browser) Type(selector, text string) error { RandomDelay(300, 800) return chromedp.Run(b.ctx, chromedp.WaitVisible(selector, chromedp.ByQuery), chromedp.Click(selector, chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond), chromedp.SendKeys(selector, text, chromedp.ByQuery), ) } // Evaluate evaluates JavaScript and returns the result func (b *Browser) Evaluate(script string, result interface{}) error { return chromedp.Run(b.ctx, chromedp.Evaluate(script, result), ) } // Sleep pauses execution for the specified duration func (b *Browser) Sleep(d time.Duration) error { return chromedp.Run(b.ctx, chromedp.Sleep(d)) } // Nodes returns nodes matching the selector func (b *Browser) Nodes(selector string) ([]*cdp.Node, error) { var nodes []*cdp.Node err := chromedp.Run(b.ctx, chromedp.Nodes(selector, &nodes, chromedp.ByQueryAll), ) return nodes, err } // SimulateHumanBehavior performs random mouse movements and scrolls func (b *Browser) SimulateHumanBehavior() error { return chromedp.Run(b.ctx, chromedp.Evaluate(`window.simulateMouseMovement()`, nil), chromedp.Sleep(time.Duration(rand.Intn(500)+200)*time.Millisecond), chromedp.Evaluate(`window.scrollBy(0, Math.random() * 100)`, nil), ) } // RandomDelay waits for a random duration between min and max milliseconds func RandomDelay(minMs, maxMs int) { delay := time.Duration(rand.Intn(maxMs-minMs)+minMs) * time.Millisecond time.Sleep(delay) } // getRandomUserAgent returns a random modern user agent func getRandomUserAgent() string { userAgents := []string{ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", } return userAgents[rand.Intn(len(userAgents))] }