583 lines
16 KiB
Go
583 lines
16 KiB
Go
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))]
|
|
}
|