Initial commit: navegator - Chrome CDP automation for LLMs
Tests / Lint (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / E2E Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled

Add complete navegator system for stealthy browser automation:
- CDP client with WebSocket communication
- Browser API with navigation, storage, network, runtime
- Stealth flags and anti-detection scripts
- Persistent profile support
- Examples and comprehensive documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-24 23:33:07 +01:00
commit 3253828fef
36 changed files with 8116 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
package browser
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"navegator/pkg/cdp"
"navegator/pkg/stealth"
)
// Browser representa una instancia de Chrome/Chromium.
type Browser struct {
cmd *exec.Cmd
cdpClient *cdp.Client
config *Config
profilePath string
debugURL string
ctx context.Context
cancel context.CancelFunc
targetID string
recorder *Recorder
}
// Config contiene la configuración para lanzar el navegador.
type Config struct {
// ExecutablePath es la ruta al ejecutable de Chrome/Chromium
// Si está vacío, se buscará automáticamente
ExecutablePath string
// ProfileName es el nombre del perfil a usar/crear
ProfileName string
// ProfilesBaseDir es el directorio base donde se guardan los perfiles
// Por defecto: ~/.navegator/profiles
ProfilesBaseDir string
// StealthFlags son las configuraciones stealth
StealthFlags *stealth.StealthFlags
// Timeout para iniciar el navegador
StartTimeout time.Duration
// Env variables de entorno adicionales
Env []string
}
// DefaultConfig retorna una configuración por defecto.
func DefaultConfig() *Config {
homeDir, _ := os.UserHomeDir()
defaultProfilesDir := filepath.Join(homeDir, ".navegator", "profiles")
return &Config{
ProfilesBaseDir: defaultProfilesDir,
ProfileName: "default",
StealthFlags: stealth.DefaultStealthFlags(),
StartTimeout: 30 * time.Second,
}
}
// Launch inicia una nueva instancia del navegador.
func Launch(ctx context.Context, config *Config) (*Browser, error) {
if config == nil {
config = DefaultConfig()
}
// Buscar ejecutable de Chrome si no está especificado
if config.ExecutablePath == "" {
exe, err := findChrome()
if err != nil {
return nil, fmt.Errorf("failed to find Chrome executable: %w", err)
}
config.ExecutablePath = exe
}
// Crear directorio de perfil
profilePath := filepath.Join(config.ProfilesBaseDir, config.ProfileName)
if err := os.MkdirAll(profilePath, 0755); err != nil {
return nil, fmt.Errorf("failed to create profile directory: %w", err)
}
// Configurar flags stealth con el profilePath
config.StealthFlags.UserDataDir = profilePath
config.StealthFlags.ProfileName = "Default"
// Construir flags
flags := config.StealthFlags.Build()
// Crear comando
cmd := exec.CommandContext(ctx, config.ExecutablePath, flags...)
cmd.Env = append(os.Environ(), config.Env...)
// Iniciar Chrome
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start Chrome: %w", err)
}
browserCtx, cancel := context.WithCancel(ctx)
b := &Browser{
cmd: cmd,
config: config,
profilePath: profilePath,
ctx: browserCtx,
cancel: cancel,
}
// Esperar a que Chrome esté listo
if err := b.waitForChrome(config.StartTimeout); err != nil {
b.Close()
return nil, err
}
// Conectar CDP
if err := b.connectCDP(); err != nil {
b.Close()
return nil, err
}
// Inyectar script anti-detección
if err := b.injectAntiDetection(); err != nil {
// No es crítico, continuar
fmt.Fprintf(os.Stderr, "Warning: failed to inject anti-detection script: %v\n", err)
}
return b, nil
}
// waitForChrome espera a que Chrome esté listo y escuchando CDP.
func (b *Browser) waitForChrome(timeout time.Duration) error {
// Leer el archivo DevToolsActivePort para obtener el puerto
devToolsFile := filepath.Join(b.profilePath, "DevToolsActivePort")
ctx, cancel := context.WithTimeout(b.ctx, timeout)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return errors.New("timeout waiting for Chrome to start")
case <-ticker.C:
data, err := os.ReadFile(devToolsFile)
if err != nil {
continue
}
lines := strings.Split(string(data), "\n")
if len(lines) < 1 {
continue
}
port := strings.TrimSpace(lines[0])
if port == "" {
continue
}
b.debugURL = "http://127.0.0.1:" + port
return nil
}
}
}
// connectCDP conecta al cliente CDP.
func (b *Browser) connectCDP() error {
wsURL, err := cdp.GetWebSocketURL(b.ctx, b.debugURL)
if err != nil {
return fmt.Errorf("failed to get WebSocket URL: %w", err)
}
client, err := cdp.NewClient(b.ctx, wsURL)
if err != nil {
return fmt.Errorf("failed to create CDP client: %w", err)
}
b.cdpClient = client
// Habilitar dominios necesarios
if err := b.enableDomains(); err != nil {
return fmt.Errorf("failed to enable CDP domains: %w", err)
}
// Obtener target ID
if err := b.getTargetID(); err != nil {
return fmt.Errorf("failed to get target ID: %w", err)
}
return nil
}
// enableDomains habilita los dominios CDP necesarios.
func (b *Browser) enableDomains() error {
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
defer cancel()
// Solo algunos dominios tienen método .enable
domains := []string{
"Network",
"Runtime",
"DOM",
}
for _, domain := range domains {
if err := b.cdpClient.Execute(ctx, domain+".enable", nil, nil); err != nil {
return fmt.Errorf("failed to enable %s domain: %w", domain, err)
}
}
// Page.enable no existe, Page se activa automáticamente
// Storage.enable no existe, Storage funciona directamente
// Fetch.enable se llama manualmente cuando se necesita interceptación
return nil
}
// getTargetID obtiene el ID del target principal.
func (b *Browser) getTargetID() error {
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
defer cancel()
var result struct {
TargetInfos []struct {
TargetID string `json:"targetId"`
Type string `json:"type"`
} `json:"targetInfos"`
}
if err := b.cdpClient.Execute(ctx, "Target.getTargets", nil, &result); err != nil {
return err
}
for _, info := range result.TargetInfos {
if info.Type == "page" {
b.targetID = info.TargetID
return nil
}
}
return errors.New("no page target found")
}
// injectAntiDetection inyecta el script anti-detección en todas las páginas.
func (b *Browser) injectAntiDetection() error {
script := stealth.GetAntiDetectionScript()
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
defer cancel()
params := map[string]interface{}{
"source": script,
}
return b.cdpClient.Execute(ctx, "Page.addScriptToEvaluateOnNewDocument", params, nil)
}
// Client retorna el cliente CDP subyacente.
func (b *Browser) Client() *cdp.Client {
return b.cdpClient
}
// ProfilePath retorna la ruta del perfil usado.
func (b *Browser) ProfilePath() string {
return b.profilePath
}
// DebugURL retorna la URL de debugging de Chrome.
func (b *Browser) DebugURL() string {
return b.debugURL
}
// TargetID retorna el ID del target principal.
func (b *Browser) TargetID() string {
return b.targetID
}
// Close cierra el navegador y limpia recursos.
func (b *Browser) Close() error {
b.cancel()
if b.recorder != nil {
b.recorder.Close()
}
if b.cdpClient != nil {
b.cdpClient.Close()
}
if b.cmd != nil && b.cmd.Process != nil {
b.cmd.Process.Kill()
b.cmd.Wait()
}
return nil
}
// StartRecording inicia el registro de acciones en un archivo.
func (b *Browser) StartRecording(filepath string) error {
recorder, err := NewRecorder(filepath)
if err != nil {
return err
}
b.recorder = recorder
return nil
}
// StopRecording detiene el registro de acciones.
func (b *Browser) StopRecording() error {
if b.recorder != nil {
return b.recorder.Close()
}
return nil
}
// AddComment agrega un comentario al log de recording.
func (b *Browser) AddComment(comment string) {
if b.recorder != nil {
b.recorder.AddComment(comment)
}
}
// findChrome busca el ejecutable de Chrome en las ubicaciones comunes.
func findChrome() (string, error) {
var candidates []string
switch runtime.GOOS {
case "darwin":
candidates = []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
case "windows":
candidates = []string{
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Users\\" + os.Getenv("USERNAME") + "\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
}
default: // linux
candidates = []string{
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
}
}
// Verificar cada candidato
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
// Intentar buscar en PATH
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser"} {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
}
return "", errors.New("Chrome/Chromium executable not found")
}
+291
View File
@@ -0,0 +1,291 @@
package browser
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestLaunchBrowser(t *testing.T) {
ctx := context.Background()
// Crear directorio temporal para perfiles
tempDir := t.TempDir()
config := DefaultConfig()
config.ProfilesBaseDir = tempDir
config.ProfileName = "test-launch"
config.StealthFlags.Headless = true
config.StartTimeout = 15 * time.Second
// Lanzar navegador
b, err := Launch(ctx, config)
if err != nil {
t.Fatalf("Failed to launch browser: %v", err)
}
defer b.Close()
// Verificar que el perfil se creó
profilePath := filepath.Join(tempDir, "test-launch")
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
t.Errorf("Profile directory not created: %s", profilePath)
}
// Verificar que tenemos debug URL
if b.DebugURL() == "" {
t.Error("Debug URL is empty")
}
// Verificar que tenemos target ID
if b.TargetID() == "" {
t.Error("Target ID is empty")
}
}
func TestNavigate(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
config := DefaultConfig()
config.ProfilesBaseDir = tempDir
config.ProfileName = "test-navigate"
config.StealthFlags.Headless = true
b, err := Launch(ctx, config)
if err != nil {
t.Fatalf("Failed to launch browser: %v", err)
}
defer b.Close()
// Navegar a example.com
opts := DefaultNavigateOptions()
opts.Timeout = 15 * time.Second
err = b.Navigate(ctx, "https://example.com", opts)
if err != nil {
t.Fatalf("Failed to navigate: %v", err)
}
// Verificar que estamos en la página correcta
result, err := b.Evaluate(ctx, "window.location.href")
if err != nil {
t.Fatalf("Failed to evaluate location: %v", err)
}
url, ok := result.Value.(string)
if !ok || url != "https://example.com/" {
t.Errorf("Expected URL https://example.com/, got %v", result.Value)
}
}
func TestScreenshot(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
config := DefaultConfig()
config.ProfilesBaseDir = tempDir
config.ProfileName = "test-screenshot"
config.StealthFlags.Headless = true
b, err := Launch(ctx, config)
if err != nil {
t.Fatalf("Failed to launch browser: %v", err)
}
defer b.Close()
// Navegar
opts := DefaultNavigateOptions()
opts.Timeout = 15 * time.Second
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
t.Logf("Navigation warning: %v", err)
}
// Tomar screenshot
screenshot, err := b.Screenshot(ctx, false)
if err != nil {
t.Fatalf("Failed to take screenshot: %v", err)
}
// Verificar que tiene contenido
if len(screenshot) == 0 {
t.Error("Screenshot is empty")
}
// Verificar que es PNG válido (empieza con magic bytes)
if len(screenshot) < 8 || screenshot[0] != 0x89 || screenshot[1] != 0x50 {
t.Error("Screenshot is not a valid PNG")
}
}
func TestEvaluate(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
config := DefaultConfig()
config.ProfilesBaseDir = tempDir
config.ProfileName = "test-evaluate"
config.StealthFlags.Headless = true
b, err := Launch(ctx, config)
if err != nil {
t.Fatalf("Failed to launch browser: %v", err)
}
defer b.Close()
// Navegar
opts := DefaultNavigateOptions()
opts.Timeout = 15 * time.Second
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
t.Logf("Navigation warning: %v", err)
}
// Evaluar JavaScript
result, err := b.Evaluate(ctx, "2 + 2")
if err != nil {
t.Fatalf("Failed to evaluate: %v", err)
}
// Verificar resultado
val, ok := result.Value.(float64)
if !ok || val != 4 {
t.Errorf("Expected 4, got %v", result.Value)
}
}
func TestStealthFlags(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
config := DefaultConfig()
config.ProfilesBaseDir = tempDir
config.ProfileName = "test-stealth"
config.StealthFlags.Headless = true
b, err := Launch(ctx, config)
if err != nil {
t.Fatalf("Failed to launch browser: %v", err)
}
defer b.Close()
// Navegar
opts := DefaultNavigateOptions()
opts.Timeout = 15 * time.Second
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
t.Logf("Navigation warning: %v", err)
}
// Verificar navigator.webdriver = false
result, err := b.Evaluate(ctx, "navigator.webdriver")
if err != nil {
t.Fatalf("Failed to evaluate webdriver: %v", err)
}
// Debe ser false o undefined
if result.Value != false && result.Value != nil {
t.Errorf("navigator.webdriver should be false, got %v", result.Value)
}
// Verificar window.chrome existe
chromeExists, err := b.Evaluate(ctx, "typeof window.chrome !== 'undefined'")
if err != nil {
t.Fatalf("Failed to check chrome object: %v", err)
}
if chromeExists.Value != true {
t.Error("window.chrome should exist")
}
}
func TestRecorder(t *testing.T) {
tempDir := t.TempDir()
recordFile := filepath.Join(tempDir, "test-recording.log")
recorder, err := NewRecorder(recordFile)
if err != nil {
t.Fatalf("Failed to create recorder: %v", err)
}
defer recorder.Close()
// Registrar acción
recorder.Record("TestAction", map[string]interface{}{
"param1": "value1",
"param2": 123,
}, "test result", nil)
recorder.Close()
// Verificar que el archivo existe y tiene contenido
content, err := os.ReadFile(recordFile)
if err != nil {
t.Fatalf("Failed to read recording file: %v", err)
}
if len(content) == 0 {
t.Error("Recording file is empty")
}
// Verificar que contiene JSON
contentStr := string(content)
if !contains(contentStr, "TestAction") {
t.Error("Recording doesn't contain action name")
}
}
func TestProfilePersistence(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
profileName := "test-persistence"
// Primera sesión: crear perfil
config1 := DefaultConfig()
config1.ProfilesBaseDir = tempDir
config1.ProfileName = profileName
config1.StealthFlags.Headless = true
b1, err := Launch(ctx, config1)
if err != nil {
t.Fatalf("Failed to launch browser (session 1): %v", err)
}
// Verificar perfil creado
profilePath := filepath.Join(tempDir, profileName)
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
t.Fatalf("Profile not created: %s", profilePath)
}
b1.Close()
// Segunda sesión: reutilizar perfil
config2 := DefaultConfig()
config2.ProfilesBaseDir = tempDir
config2.ProfileName = profileName
config2.StealthFlags.Headless = true
b2, err := Launch(ctx, config2)
if err != nil {
t.Fatalf("Failed to launch browser (session 2): %v", err)
}
defer b2.Close()
// Perfil debe seguir existiendo
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
t.Error("Profile was deleted between sessions")
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+494
View File
@@ -0,0 +1,494 @@
package browser
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
)
// NavigateOptions opciones para la navegación.
type NavigateOptions struct {
// WaitUntil define cuándo se considera completada la navegación
// "load" = evento load, "domcontentloaded" = DOM listo, "networkidle" = red inactiva
WaitUntil string
// Timeout para la navegación
Timeout time.Duration
// Referer personalizado
Referer string
}
// DefaultNavigateOptions retorna opciones por defecto.
func DefaultNavigateOptions() *NavigateOptions {
return &NavigateOptions{
WaitUntil: "load",
Timeout: 30 * time.Second,
}
}
// Navigate navega a una URL.
func (b *Browser) Navigate(ctx context.Context, url string, opts *NavigateOptions) error {
if opts == nil {
opts = DefaultNavigateOptions()
}
navCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
// Preparar parámetros
params := map[string]interface{}{
"url": url,
}
if opts.Referer != "" {
params["referrer"] = opts.Referer
}
// Canal para eventos de navegación
loadedCh := make(chan struct{})
var loadErr error
// Registrar eventos según WaitUntil
switch opts.WaitUntil {
case "domcontentloaded":
b.cdpClient.On("Page.domContentEventFired", func(params json.RawMessage) {
close(loadedCh)
})
case "networkidle":
// Implementación simple: esperar a que no haya requests por 500ms
idleTimer := time.NewTimer(500 * time.Millisecond)
activeRequests := 0
b.cdpClient.On("Network.requestWillBeSent", func(params json.RawMessage) {
activeRequests++
idleTimer.Reset(500 * time.Millisecond)
})
b.cdpClient.On("Network.loadingFinished", func(params json.RawMessage) {
activeRequests--
if activeRequests <= 0 {
idleTimer.Reset(500 * time.Millisecond)
}
})
b.cdpClient.On("Network.loadingFailed", func(params json.RawMessage) {
activeRequests--
if activeRequests <= 0 {
idleTimer.Reset(500 * time.Millisecond)
}
})
go func() {
<-idleTimer.C
close(loadedCh)
}()
default: // "load"
b.cdpClient.On("Page.loadEventFired", func(params json.RawMessage) {
close(loadedCh)
})
}
// Navegar
if err := b.cdpClient.Execute(navCtx, "Page.navigate", params, nil); err != nil {
return fmt.Errorf("failed to navigate: %w", err)
}
// Esperar a que se complete la navegación
var err error
select {
case <-loadedCh:
err = loadErr
case <-navCtx.Done():
err = fmt.Errorf("navigation timeout: %w", navCtx.Err())
}
// Registrar acción
if b.recorder != nil {
b.recorder.Record("Navigate", map[string]interface{}{
"url": url,
"waitUntil": opts.WaitUntil,
}, nil, err)
}
return err
}
// Click hace clic en un elemento usando un selector CSS.
func (b *Browser) Click(ctx context.Context, selector string) error {
// Obtener el NodeID del elemento
nodeID, err := b.querySelector(ctx, selector)
if err != nil {
return err
}
// Obtener las coordenadas del elemento
box, err := b.getElementBox(ctx, nodeID)
if err != nil {
return err
}
// Calcular centro del elemento
x := box.X + box.Width/2
y := box.Y + box.Height/2
// Simular click (mousePressed + mouseReleased)
if err := b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", map[string]interface{}{
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}, nil); err != nil {
return fmt.Errorf("failed to press mouse: %w", err)
}
// Pequeño delay entre pressed y released (más natural)
time.Sleep(50 * time.Millisecond)
err = b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", map[string]interface{}{
"type": "mouseReleased",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}, nil)
if err != nil {
if b.recorder != nil {
b.recorder.Record("Click", map[string]interface{}{"selector": selector}, nil, err)
}
return fmt.Errorf("failed to release mouse: %w", err)
}
// Registrar acción
if b.recorder != nil {
b.recorder.Record("Click", map[string]interface{}{"selector": selector}, nil, nil)
}
return nil
}
// Type escribe texto en un elemento.
func (b *Browser) Type(ctx context.Context, selector string, text string, opts *TypeOptions) error {
if opts == nil {
opts = DefaultTypeOptions()
}
// Focus en el elemento primero
if err := b.Focus(ctx, selector); err != nil {
if b.recorder != nil {
b.recorder.Record("Type", map[string]interface{}{
"selector": selector,
"text": text,
}, nil, err)
}
return err
}
// Escribir cada carácter con delay
for _, char := range text {
if err := b.typeChar(ctx, string(char)); err != nil {
if b.recorder != nil {
b.recorder.Record("Type", map[string]interface{}{
"selector": selector,
"text": text,
}, nil, err)
}
return err
}
if opts.Delay > 0 {
time.Sleep(opts.Delay)
}
}
// Registrar acción exitosa
if b.recorder != nil {
b.recorder.Record("Type", map[string]interface{}{
"selector": selector,
"text": text,
"delay": opts.Delay.String(),
}, nil, nil)
}
return nil
}
// TypeOptions opciones para escribir texto.
type TypeOptions struct {
// Delay entre caracteres (más natural)
Delay time.Duration
}
// DefaultTypeOptions retorna opciones por defecto.
func DefaultTypeOptions() *TypeOptions {
return &TypeOptions{
Delay: 50 * time.Millisecond,
}
}
// typeChar escribe un solo carácter.
func (b *Browser) typeChar(ctx context.Context, char string) error {
params := map[string]interface{}{
"type": "char",
"text": char,
}
return b.cdpClient.Execute(ctx, "Input.dispatchKeyEvent", params, nil)
}
// Focus hace foco en un elemento.
func (b *Browser) Focus(ctx context.Context, selector string) error {
nodeID, err := b.querySelector(ctx, selector)
if err != nil {
return err
}
params := map[string]interface{}{
"nodeId": nodeID,
}
return b.cdpClient.Execute(ctx, "DOM.focus", params, nil)
}
// Screenshot toma una captura de pantalla.
func (b *Browser) Screenshot(ctx context.Context, fullPage bool) ([]byte, error) {
params := map[string]interface{}{
"format": "png",
"captureBeyondViewport": fullPage,
}
var result struct {
Data string `json:"data"`
}
if err := b.cdpClient.Execute(ctx, "Page.captureScreenshot", params, &result); err != nil {
return nil, fmt.Errorf("failed to capture screenshot: %w", err)
}
data, err := base64.StdEncoding.DecodeString(result.Data)
if err != nil {
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
}
return data, nil
}
// GetHTML obtiene el HTML de la página o de un elemento específico.
func (b *Browser) GetHTML(ctx context.Context, selector string) (string, error) {
if selector == "" {
// Obtener HTML completo
var result struct {
Result struct {
Value string `json:"value"`
} `json:"result"`
}
params := map[string]interface{}{
"expression": "document.documentElement.outerHTML",
}
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
return "", fmt.Errorf("failed to get HTML: %w", err)
}
return result.Result.Value, nil
}
// Obtener HTML de un elemento específico
nodeID, err := b.querySelector(ctx, selector)
if err != nil {
return "", err
}
var result struct {
OuterHTML string `json:"outerHTML"`
}
params := map[string]interface{}{
"nodeId": nodeID,
}
if err := b.cdpClient.Execute(ctx, "DOM.getOuterHTML", params, &result); err != nil {
return "", fmt.Errorf("failed to get HTML: %w", err)
}
return result.OuterHTML, nil
}
// GetText obtiene el texto visible de un elemento.
func (b *Browser) GetText(ctx context.Context, selector string) (string, error) {
script := fmt.Sprintf(`document.querySelector('%s')?.textContent`, selector)
var result struct {
Result struct {
Value string `json:"value"`
} `json:"result"`
}
params := map[string]interface{}{
"expression": script,
}
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
return "", fmt.Errorf("failed to get text: %w", err)
}
return result.Result.Value, nil
}
// WaitForSelector espera a que un selector esté disponible.
func (b *Browser) WaitForSelector(ctx context.Context, selector string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for selector: %s", selector)
case <-ticker.C:
_, err := b.querySelector(ctx, selector)
if err == nil {
return nil
}
}
}
}
// querySelector helper para obtener NodeID de un selector.
func (b *Browser) querySelector(ctx context.Context, selector string) (int64, error) {
// Primero obtener el documento root
var docResult struct {
Root struct {
NodeID int64 `json:"nodeId"`
} `json:"root"`
}
if err := b.cdpClient.Execute(ctx, "DOM.getDocument", nil, &docResult); err != nil {
return 0, fmt.Errorf("failed to get document: %w", err)
}
// Buscar el elemento
var queryResult struct {
NodeID int64 `json:"nodeId"`
}
params := map[string]interface{}{
"nodeId": docResult.Root.NodeID,
"selector": selector,
}
if err := b.cdpClient.Execute(ctx, "DOM.querySelector", params, &queryResult); err != nil {
return 0, fmt.Errorf("failed to query selector: %w", err)
}
if queryResult.NodeID == 0 {
return 0, fmt.Errorf("element not found: %s", selector)
}
return queryResult.NodeID, nil
}
// Box representa las coordenadas de un elemento.
type Box struct {
X float64
Y float64
Width float64
Height float64
}
// getElementBox obtiene las coordenadas de un elemento.
func (b *Browser) getElementBox(ctx context.Context, nodeID int64) (*Box, error) {
var result struct {
Model struct {
Content []float64 `json:"content"`
} `json:"model"`
}
params := map[string]interface{}{
"nodeId": nodeID,
}
if err := b.cdpClient.Execute(ctx, "DOM.getBoxModel", params, &result); err != nil {
return nil, fmt.Errorf("failed to get box model: %w", err)
}
if len(result.Model.Content) < 8 {
return nil, errors.New("invalid box model")
}
// Content es un array [x1, y1, x2, y2, x3, y3, x4, y4] (cuatro esquinas)
x := result.Model.Content[0]
y := result.Model.Content[1]
width := result.Model.Content[2] - x
height := result.Model.Content[5] - y
return &Box{
X: x,
Y: y,
Width: width,
Height: height,
}, nil
}
// Reload recarga la página actual.
func (b *Browser) Reload(ctx context.Context) error {
return b.cdpClient.Execute(ctx, "Page.reload", nil, nil)
}
// GoBack navega hacia atrás en el historial.
func (b *Browser) GoBack(ctx context.Context) error {
// Obtener historial
var history struct {
CurrentIndex int `json:"currentIndex"`
Entries []struct {
ID int `json:"id"`
} `json:"entries"`
}
if err := b.cdpClient.Execute(ctx, "Page.getNavigationHistory", nil, &history); err != nil {
return fmt.Errorf("failed to get history: %w", err)
}
if history.CurrentIndex <= 0 {
return errors.New("no history to go back")
}
// Navegar a la entrada anterior
params := map[string]interface{}{
"entryId": history.Entries[history.CurrentIndex-1].ID,
}
return b.cdpClient.Execute(ctx, "Page.navigateToHistoryEntry", params, nil)
}
// GoForward navega hacia adelante en el historial.
func (b *Browser) GoForward(ctx context.Context) error {
var history struct {
CurrentIndex int `json:"currentIndex"`
Entries []struct {
ID int `json:"id"`
} `json:"entries"`
}
if err := b.cdpClient.Execute(ctx, "Page.getNavigationHistory", nil, &history); err != nil {
return fmt.Errorf("failed to get history: %w", err)
}
if history.CurrentIndex >= len(history.Entries)-1 {
return errors.New("no history to go forward")
}
params := map[string]interface{}{
"entryId": history.Entries[history.CurrentIndex+1].ID,
}
return b.cdpClient.Execute(ctx, "Page.navigateToHistoryEntry", params, nil)
}
+385
View File
@@ -0,0 +1,385 @@
package browser
import (
"context"
"encoding/json"
"fmt"
"sync"
)
// RequestInterceptor intercepta y puede modificar requests.
type RequestInterceptor struct {
URLPattern string
Handler RequestHandler
}
// RequestHandler maneja un request interceptado.
type RequestHandler func(req *InterceptedRequest) *RequestAction
// InterceptedRequest representa un request interceptado.
type InterceptedRequest struct {
InterceptionID string
RequestID string
URL string
Method string
Headers map[string]interface{}
PostData string
ResourceType string
}
// RequestAction es la acción a tomar sobre un request.
type RequestAction struct {
// Continue continúa el request sin modificar
Continue bool
// Abort aborta el request
Abort bool
// Mock responde con datos mockeados
Mock *MockResponse
// ModifiedHeaders headers modificados
ModifiedHeaders map[string]string
// ModifiedURL URL modificada
ModifiedURL string
}
// MockResponse es una respuesta mockeada.
type MockResponse struct {
StatusCode int
Headers map[string]string
Body string
}
// NetworkInterceptor gestiona interceptación de red.
type NetworkInterceptor struct {
browser *Browser
interceptors []*RequestInterceptor
mu sync.RWMutex
enabled bool
}
// EnableNetworkInterception habilita la interceptación de red.
func (b *Browser) EnableNetworkInterception(ctx context.Context) (*NetworkInterceptor, error) {
ni := &NetworkInterceptor{
browser: b,
interceptors: make([]*RequestInterceptor, 0),
enabled: false,
}
// Habilitar Fetch domain
if err := b.cdpClient.Execute(ctx, "Fetch.enable", map[string]interface{}{
"patterns": []map[string]interface{}{
{
"urlPattern": "*",
"resourceType": "*",
},
},
}, nil); err != nil {
return nil, fmt.Errorf("failed to enable Fetch domain: %w", err)
}
// Registrar handler de eventos
b.cdpClient.On("Fetch.requestPaused", func(params json.RawMessage) {
ni.handleRequestPaused(params)
})
ni.enabled = true
return ni, nil
}
// AddInterceptor agrega un interceptor.
func (ni *NetworkInterceptor) AddInterceptor(urlPattern string, handler RequestHandler) {
ni.mu.Lock()
defer ni.mu.Unlock()
ni.interceptors = append(ni.interceptors, &RequestInterceptor{
URLPattern: urlPattern,
Handler: handler,
})
}
// handleRequestPaused maneja eventos de request pausado.
func (ni *NetworkInterceptor) handleRequestPaused(params json.RawMessage) {
var event struct {
RequestID string `json:"requestId"`
Request struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]interface{} `json:"headers"`
PostData string `json:"postData,omitempty"`
} `json:"request"`
ResourceType string `json:"resourceType"`
FrameID string `json:"frameId"`
InterceptionID string `json:"interceptionId"`
}
if err := json.Unmarshal(params, &event); err != nil {
ni.continueRequest(event.InterceptionID)
return
}
req := &InterceptedRequest{
InterceptionID: event.InterceptionID,
RequestID: event.RequestID,
URL: event.Request.URL,
Method: event.Request.Method,
Headers: event.Request.Headers,
PostData: event.Request.PostData,
ResourceType: event.ResourceType,
}
// Buscar interceptor que coincida
ni.mu.RLock()
var matchedHandler RequestHandler
for _, interceptor := range ni.interceptors {
if matchesPattern(req.URL, interceptor.URLPattern) {
matchedHandler = interceptor.Handler
break
}
}
ni.mu.RUnlock()
if matchedHandler == nil {
// No hay interceptor, continuar normalmente
ni.continueRequest(event.InterceptionID)
return
}
// Ejecutar handler
action := matchedHandler(req)
ni.handleAction(req, action)
}
// handleAction ejecuta la acción sobre el request.
func (ni *NetworkInterceptor) handleAction(req *InterceptedRequest, action *RequestAction) {
ctx := context.Background()
if action.Abort {
// Abortar request
params := map[string]interface{}{
"requestId": req.InterceptionID,
"errorReason": "Aborted",
}
ni.browser.cdpClient.Execute(ctx, "Fetch.failRequest", params, nil)
return
}
if action.Mock != nil {
// Responder con mock
params := map[string]interface{}{
"requestId": req.InterceptionID,
"responseCode": action.Mock.StatusCode,
"responseHeaders": convertHeaders(action.Mock.Headers),
"body": base64Encode(action.Mock.Body),
}
ni.browser.cdpClient.Execute(ctx, "Fetch.fulfillRequest", params, nil)
return
}
// Continuar (posiblemente modificado)
params := map[string]interface{}{
"requestId": req.InterceptionID,
}
if action.ModifiedURL != "" {
params["url"] = action.ModifiedURL
}
if len(action.ModifiedHeaders) > 0 {
params["headers"] = convertHeaders(action.ModifiedHeaders)
}
ni.browser.cdpClient.Execute(ctx, "Fetch.continueRequest", params, nil)
}
// continueRequest continúa un request sin modificaciones.
func (ni *NetworkInterceptor) continueRequest(interceptionID string) {
ctx := context.Background()
params := map[string]interface{}{
"requestId": interceptionID,
}
ni.browser.cdpClient.Execute(ctx, "Fetch.continueRequest", params, nil)
}
// Disable deshabilita la interceptación de red.
func (ni *NetworkInterceptor) Disable(ctx context.Context) error {
if !ni.enabled {
return nil
}
if err := ni.browser.cdpClient.Execute(ctx, "Fetch.disable", nil, nil); err != nil {
return fmt.Errorf("failed to disable Fetch domain: %w", err)
}
ni.enabled = false
return nil
}
// BlockURLs bloquea requests a URLs que coincidan con los patrones.
func (b *Browser) BlockURLs(ctx context.Context, patterns ...string) (*NetworkInterceptor, error) {
ni, err := b.EnableNetworkInterception(ctx)
if err != nil {
return nil, err
}
for _, pattern := range patterns {
ni.AddInterceptor(pattern, func(req *InterceptedRequest) *RequestAction {
return &RequestAction{Abort: true}
})
}
return ni, nil
}
// BlockResourceTypes bloquea tipos de recursos específicos.
func (b *Browser) BlockResourceTypes(ctx context.Context, resourceTypes ...string) (*NetworkInterceptor, error) {
ni, err := b.EnableNetworkInterception(ctx)
if err != nil {
return nil, err
}
typeMap := make(map[string]bool)
for _, rt := range resourceTypes {
typeMap[rt] = true
}
ni.AddInterceptor("*", func(req *InterceptedRequest) *RequestAction {
if typeMap[req.ResourceType] {
return &RequestAction{Abort: true}
}
return &RequestAction{Continue: true}
})
return ni, nil
}
// ModifyHeaders crea un interceptor que modifica headers.
func (b *Browser) ModifyHeaders(ctx context.Context, headers map[string]string) (*NetworkInterceptor, error) {
ni, err := b.EnableNetworkInterception(ctx)
if err != nil {
return nil, err
}
ni.AddInterceptor("*", func(req *InterceptedRequest) *RequestAction {
return &RequestAction{
Continue: true,
ModifiedHeaders: headers,
}
})
return ni, nil
}
// SetExtraHTTPHeaders establece headers HTTP extra para todos los requests.
// Más eficiente que usar interceptación.
func (b *Browser) SetExtraHTTPHeaders(ctx context.Context, headers map[string]string) error {
params := map[string]interface{}{
"headers": headers,
}
return b.cdpClient.Execute(ctx, "Network.setExtraHTTPHeaders", params, nil)
}
// SetUserAgent establece el user agent.
func (b *Browser) SetUserAgent(ctx context.Context, userAgent string) error {
params := map[string]interface{}{
"userAgent": userAgent,
}
return b.cdpClient.Execute(ctx, "Network.setUserAgentOverride", params, nil)
}
// EmulateNetworkConditions emula condiciones de red (throttling).
func (b *Browser) EmulateNetworkConditions(ctx context.Context, offline bool, latency, downloadThroughput, uploadThroughput float64) error {
params := map[string]interface{}{
"offline": offline,
"latency": latency,
"downloadThroughput": downloadThroughput,
"uploadThroughput": uploadThroughput,
}
return b.cdpClient.Execute(ctx, "Network.emulateNetworkConditions", params, nil)
}
// DisableCache deshabilita el caché HTTP.
func (b *Browser) DisableCache(ctx context.Context) error {
params := map[string]interface{}{
"cacheDisabled": true,
}
return b.cdpClient.Execute(ctx, "Network.setCacheDisabled", params, nil)
}
// EnableCache habilita el caché HTTP.
func (b *Browser) EnableCache(ctx context.Context) error {
params := map[string]interface{}{
"cacheDisabled": false,
}
return b.cdpClient.Execute(ctx, "Network.setCacheDisabled", params, nil)
}
// Helpers
func matchesPattern(url, pattern string) bool {
// Implementación simple de pattern matching
// * = wildcard
if pattern == "*" {
return true
}
// TODO: Implementar matching más sofisticado con wildcards
// Por ahora, solo match exacto o wildcard completo
return url == pattern
}
func convertHeaders(headers map[string]string) []map[string]string {
result := make([]map[string]string, 0, len(headers))
for name, value := range headers {
result = append(result, map[string]string{
"name": name,
"value": value,
})
}
return result
}
func base64Encode(s string) string {
// Simple base64 encode usando tabla estándar
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
data := []byte(s)
result := make([]byte, ((len(data)+2)/3)*4)
j := 0
for i := 0; i < len(data); i += 3 {
b := (uint(data[i]) << 16)
if i+1 < len(data) {
b |= (uint(data[i+1]) << 8)
}
if i+2 < len(data) {
b |= uint(data[i+2])
}
result[j] = base64Table[(b>>18)&0x3F]
result[j+1] = base64Table[(b>>12)&0x3F]
if i+1 < len(data) {
result[j+2] = base64Table[(b>>6)&0x3F]
} else {
result[j+2] = '='
}
if i+2 < len(data) {
result[j+3] = base64Table[b&0x3F]
} else {
result[j+3] = '='
}
j += 4
}
return string(result)
}
+117
View File
@@ -0,0 +1,117 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// Action representa una acción realizada en el navegador.
type Action struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"`
Params map[string]interface{} `json:"params"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// Recorder registra todas las acciones del navegador.
type Recorder struct {
file *os.File
encoder *json.Encoder
mu sync.Mutex
enabled bool
}
// NewRecorder crea un nuevo recorder que escribe en un archivo.
func NewRecorder(filepath string) (*Recorder, error) {
file, err := os.Create(filepath)
if err != nil {
return nil, fmt.Errorf("failed to create recorder file: %w", err)
}
r := &Recorder{
file: file,
encoder: json.NewEncoder(file),
enabled: true,
}
// Escribir header
fmt.Fprintf(file, "# Navegator Recording - %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(file, "# Este archivo puede ser usado para reproducir las acciones\n\n")
return r, nil
}
// Record registra una acción.
func (r *Recorder) Record(actionType string, params map[string]interface{}, result interface{}, err error) {
if !r.enabled {
return
}
r.mu.Lock()
defer r.mu.Unlock()
action := Action{
Timestamp: time.Now(),
Type: actionType,
Params: params,
Result: result,
}
if err != nil {
action.Error = err.Error()
}
// Escribir JSON
r.encoder.Encode(action)
// También escribir comentario legible
if params["url"] != nil {
fmt.Fprintf(r.file, "# %s - %s: %v\n", action.Timestamp.Format("15:04:05"), actionType, params["url"])
} else if params["selector"] != nil {
fmt.Fprintf(r.file, "# %s - %s: %v\n", action.Timestamp.Format("15:04:05"), actionType, params["selector"])
} else {
fmt.Fprintf(r.file, "# %s - %s\n", action.Timestamp.Format("15:04:05"), actionType)
}
}
// Close cierra el recorder.
func (r *Recorder) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.file != nil {
fmt.Fprintf(r.file, "\n# Recording ended at %s\n", time.Now().Format(time.RFC3339))
return r.file.Close()
}
return nil
}
// Enable activa el recording.
func (r *Recorder) Enable() {
r.mu.Lock()
defer r.mu.Unlock()
r.enabled = true
}
// Disable desactiva el recording.
func (r *Recorder) Disable() {
r.mu.Lock()
defer r.mu.Unlock()
r.enabled = false
}
// AddComment agrega un comentario al log.
func (r *Recorder) AddComment(comment string) {
if !r.enabled {
return
}
r.mu.Lock()
defer r.mu.Unlock()
fmt.Fprintf(r.file, "\n# %s\n", comment)
}
+396
View File
@@ -0,0 +1,396 @@
package browser
import (
"context"
"encoding/json"
"fmt"
)
// EvaluateResult representa el resultado de una evaluación de JavaScript.
type EvaluateResult struct {
Type string `json:"type"`
Value interface{} `json:"value"`
Description string `json:"description"`
ObjectID string `json:"objectId,omitempty"`
SubType string `json:"subtype,omitempty"`
RawResult json.RawMessage `json:"-"`
}
// Evaluate ejecuta código JavaScript en el contexto de la página.
func (b *Browser) Evaluate(ctx context.Context, expression string) (*EvaluateResult, error) {
params := map[string]interface{}{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
}
var response struct {
Result struct {
Type string `json:"type"`
Value interface{} `json:"value"`
Description string `json:"description"`
ObjectID string `json:"objectId"`
SubType string `json:"subtype"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
Exception struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &response); err != nil {
return nil, fmt.Errorf("failed to evaluate: %w", err)
}
if response.ExceptionDetails != nil {
return nil, fmt.Errorf("JavaScript exception: %s - %s",
response.ExceptionDetails.Text,
response.ExceptionDetails.Exception.Description)
}
result := &EvaluateResult{
Type: response.Result.Type,
Value: response.Result.Value,
Description: response.Result.Description,
ObjectID: response.Result.ObjectID,
SubType: response.Result.SubType,
}
return result, nil
}
// EvaluateOnNode ejecuta JavaScript en el contexto de un nodo específico.
func (b *Browser) EvaluateOnNode(ctx context.Context, nodeID int64, expression string) (*EvaluateResult, error) {
// Primero obtener el objectId del nodo
var objResult struct {
Object struct {
ObjectID string `json:"objectId"`
} `json:"object"`
}
params := map[string]interface{}{
"nodeId": nodeID,
}
if err := b.cdpClient.Execute(ctx, "DOM.resolveNode", params, &objResult); err != nil {
return nil, fmt.Errorf("failed to resolve node: %w", err)
}
// Ejecutar función en el objeto
callParams := map[string]interface{}{
"functionDeclaration": fmt.Sprintf("function() { return (%s); }", expression),
"objectId": objResult.Object.ObjectID,
"returnByValue": true,
"awaitPromise": true,
}
var response struct {
Result struct {
Type string `json:"type"`
Value interface{} `json:"value"`
Description string `json:"description"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
} `json:"exceptionDetails"`
}
if err := b.cdpClient.Execute(ctx, "Runtime.callFunctionOn", callParams, &response); err != nil {
return nil, fmt.Errorf("failed to call function: %w", err)
}
if response.ExceptionDetails != nil {
return nil, fmt.Errorf("JavaScript exception: %s", response.ExceptionDetails.Text)
}
return &EvaluateResult{
Type: response.Result.Type,
Value: response.Result.Value,
Description: response.Result.Description,
}, nil
}
// EvaluateAsync ejecuta JavaScript de forma asíncrona (retorna Promise).
func (b *Browser) EvaluateAsync(ctx context.Context, expression string) (*EvaluateResult, error) {
params := map[string]interface{}{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
}
var response struct {
Result struct {
Type string `json:"type"`
Value interface{} `json:"value"`
Description string `json:"description"`
ObjectID string `json:"objectId"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
Exception struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &response); err != nil {
return nil, fmt.Errorf("failed to evaluate async: %w", err)
}
if response.ExceptionDetails != nil {
return nil, fmt.Errorf("JavaScript exception: %s - %s",
response.ExceptionDetails.Text,
response.ExceptionDetails.Exception.Description)
}
return &EvaluateResult{
Type: response.Result.Type,
Value: response.Result.Value,
Description: response.Result.Description,
ObjectID: response.Result.ObjectID,
}, nil
}
// CallFunction ejecuta una función JavaScript con argumentos.
func (b *Browser) CallFunction(ctx context.Context, functionDeclaration string, args ...interface{}) (*EvaluateResult, error) {
// Convertir args a formato CDP
cdpArgs := make([]map[string]interface{}, len(args))
for i, arg := range args {
cdpArgs[i] = map[string]interface{}{
"value": arg,
}
}
params := map[string]interface{}{
"functionDeclaration": functionDeclaration,
"arguments": cdpArgs,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
}
var response struct {
Result struct {
Type string `json:"type"`
Value interface{} `json:"value"`
Description string `json:"description"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
} `json:"exceptionDetails"`
}
if err := b.cdpClient.Execute(ctx, "Runtime.callFunctionOn", params, &response); err != nil {
return nil, fmt.Errorf("failed to call function: %w", err)
}
if response.ExceptionDetails != nil {
return nil, fmt.Errorf("JavaScript exception: %s", response.ExceptionDetails.Text)
}
return &EvaluateResult{
Type: response.Result.Type,
Value: response.Result.Value,
Description: response.Result.Description,
}, nil
}
// GetProperty obtiene una propiedad de un objeto.
func (b *Browser) GetProperty(ctx context.Context, objectID string, propertyName string) (*EvaluateResult, error) {
params := map[string]interface{}{
"objectId": objectID,
}
var response struct {
Result []struct {
Name string `json:"name"`
Value struct {
Type string `json:"type"`
Value interface{} `json:"value"`
Description string `json:"description"`
} `json:"value"`
} `json:"result"`
}
if err := b.cdpClient.Execute(ctx, "Runtime.getProperties", params, &response); err != nil {
return nil, fmt.Errorf("failed to get properties: %w", err)
}
for _, prop := range response.Result {
if prop.Name == propertyName {
return &EvaluateResult{
Type: prop.Value.Type,
Value: prop.Value.Value,
Description: prop.Value.Description,
}, nil
}
}
return nil, fmt.Errorf("property not found: %s", propertyName)
}
// AddBinding agrega un binding (función JS que llama a Go).
type BindingCallback func(args []interface{}) interface{}
// AddBinding expone una función Go al contexto JavaScript.
func (b *Browser) AddBinding(ctx context.Context, name string, callback BindingCallback) error {
// Agregar binding en Runtime
params := map[string]interface{}{
"name": name,
}
if err := b.cdpClient.Execute(ctx, "Runtime.addBinding", params, nil); err != nil {
return fmt.Errorf("failed to add binding: %w", err)
}
// Registrar evento para manejar llamadas
b.cdpClient.On("Runtime.bindingCalled", func(eventParams json.RawMessage) {
var event struct {
Name string `json:"name"`
Payload string `json:"payload"`
ExecutionContextID int64 `json:"executionContextId"`
}
if err := json.Unmarshal(eventParams, &event); err != nil {
return
}
if event.Name != name {
return
}
// Parsear args
var args []interface{}
if err := json.Unmarshal([]byte(event.Payload), &args); err != nil {
return
}
// Ejecutar callback
result := callback(args)
// Devolver resultado (evaluando código que lo retorna)
returnScript := fmt.Sprintf("window.%s_result = %v", name, result)
b.Evaluate(ctx, returnScript)
})
// Inyectar wrapper en JavaScript
wrapperScript := fmt.Sprintf(`
window.%s = async (...args) => {
const payload = JSON.stringify(args);
window.%s_result = undefined;
await window.chrome.runtime.sendMessage({
type: 'binding',
name: '%s',
payload: payload
});
// Esperar resultado (polling simple)
while (window.%s_result === undefined) {
await new Promise(r => setTimeout(r, 10));
}
return window.%s_result;
};
`, name, name, name, name, name)
_, err := b.Evaluate(ctx, wrapperScript)
return err
}
// ConsoleMessage representa un mensaje de consola.
type ConsoleMessage struct {
Type string `json:"type"`
Args []interface{} `json:"args"`
Text string `json:"text"`
URL string `json:"url"`
Line int `json:"lineNumber"`
Column int `json:"columnNumber"`
}
// OnConsole registra un handler para mensajes de consola.
func (b *Browser) OnConsole(handler func(msg *ConsoleMessage)) {
b.cdpClient.On("Runtime.consoleAPICalled", func(params json.RawMessage) {
var event struct {
Type string `json:"type"`
Args []struct {
Type string `json:"type"`
Value interface{} `json:"value"`
} `json:"args"`
StackTrace struct {
CallFrames []struct {
URL string `json:"url"`
LineNumber int `json:"lineNumber"`
ColumnNumber int `json:"columnNumber"`
} `json:"callFrames"`
} `json:"stackTrace"`
}
if err := json.Unmarshal(params, &event); err != nil {
return
}
msg := &ConsoleMessage{
Type: event.Type,
Args: make([]interface{}, len(event.Args)),
}
// Construir texto del mensaje
text := ""
for i, arg := range event.Args {
msg.Args[i] = arg.Value
if i > 0 {
text += " "
}
text += fmt.Sprintf("%v", arg.Value)
}
msg.Text = text
// Agregar info de stack trace si existe
if len(event.StackTrace.CallFrames) > 0 {
frame := event.StackTrace.CallFrames[0]
msg.URL = frame.URL
msg.Line = frame.LineNumber
msg.Column = frame.ColumnNumber
}
handler(msg)
})
}
// EnableConsole habilita eventos de consola.
func (b *Browser) EnableConsole(ctx context.Context) error {
return b.cdpClient.Execute(ctx, "Runtime.enable", nil, nil)
}
// QuerySelector helper para ejecutar querySelector desde JavaScript.
func (b *Browser) QuerySelector(ctx context.Context, selector string) (*EvaluateResult, error) {
script := fmt.Sprintf(`document.querySelector('%s')`, selector)
return b.Evaluate(ctx, script)
}
// QuerySelectorAll ejecuta querySelectorAll y retorna array de elementos.
func (b *Browser) QuerySelectorAll(ctx context.Context, selector string) (*EvaluateResult, error) {
script := fmt.Sprintf(`Array.from(document.querySelectorAll('%s'))`, selector)
return b.Evaluate(ctx, script)
}
// WaitForFunction espera a que una función JavaScript retorne true.
func (b *Browser) WaitForFunction(ctx context.Context, function string, pollInterval int) error {
script := fmt.Sprintf(`
new Promise((resolve) => {
const check = () => {
if (%s) {
resolve(true);
} else {
setTimeout(check, %d);
}
};
check();
})
`, function, pollInterval)
_, err := b.EvaluateAsync(ctx, script)
return err
}
+347
View File
@@ -0,0 +1,347 @@
package browser
import (
"context"
"fmt"
"time"
)
// Cookie representa una cookie.
type Cookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain,omitempty"`
Path string `json:"path,omitempty"`
Expires float64 `json:"expires,omitempty"` // Unix timestamp
HTTPOnly bool `json:"httpOnly,omitempty"`
Secure bool `json:"secure,omitempty"`
SameSite string `json:"sameSite,omitempty"` // "Strict", "Lax", "None"
}
// GetCookies obtiene todas las cookies o las de un dominio específico.
func (b *Browser) GetCookies(ctx context.Context, urls ...string) ([]*Cookie, error) {
params := make(map[string]interface{})
if len(urls) > 0 {
params["urls"] = urls
}
var result struct {
Cookies []*Cookie `json:"cookies"`
}
if err := b.cdpClient.Execute(ctx, "Network.getCookies", params, &result); err != nil {
return nil, fmt.Errorf("failed to get cookies: %w", err)
}
return result.Cookies, nil
}
// SetCookie establece una cookie.
func (b *Browser) SetCookie(ctx context.Context, cookie *Cookie) error {
params := map[string]interface{}{
"name": cookie.Name,
"value": cookie.Value,
}
if cookie.Domain != "" {
params["domain"] = cookie.Domain
}
if cookie.Path != "" {
params["path"] = cookie.Path
}
if cookie.Expires > 0 {
params["expires"] = cookie.Expires
}
if cookie.HTTPOnly {
params["httpOnly"] = true
}
if cookie.Secure {
params["secure"] = true
}
if cookie.SameSite != "" {
params["sameSite"] = cookie.SameSite
}
var result struct {
Success bool `json:"success"`
}
if err := b.cdpClient.Execute(ctx, "Network.setCookie", params, &result); err != nil {
return fmt.Errorf("failed to set cookie: %w", err)
}
if !result.Success {
return fmt.Errorf("failed to set cookie: %s", cookie.Name)
}
return nil
}
// SetCookies establece múltiples cookies.
func (b *Browser) SetCookies(ctx context.Context, cookies []*Cookie) error {
for _, cookie := range cookies {
if err := b.SetCookie(ctx, cookie); err != nil {
return err
}
}
return nil
}
// DeleteCookie elimina una cookie específica.
func (b *Browser) DeleteCookie(ctx context.Context, name string, domain string) error {
params := map[string]interface{}{
"name": name,
}
if domain != "" {
params["domain"] = domain
}
return b.cdpClient.Execute(ctx, "Network.deleteCookies", params, nil)
}
// ClearCookies elimina todas las cookies.
func (b *Browser) ClearCookies(ctx context.Context) error {
return b.cdpClient.Execute(ctx, "Network.clearBrowserCookies", nil, nil)
}
// LocalStorageItem representa un item de localStorage.
type LocalStorageItem struct {
Key string `json:"key"`
Value string `json:"value"`
}
// GetLocalStorage obtiene todos los items del localStorage.
func (b *Browser) GetLocalStorage(ctx context.Context) ([]*LocalStorageItem, error) {
script := `
(() => {
const items = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
items.push({ key: key, value: localStorage.getItem(key) });
}
return items;
})()
`
var result struct {
Result struct {
Value []map[string]interface{} `json:"value"`
} `json:"result"`
}
params := map[string]interface{}{
"expression": script,
"returnByValue": true,
}
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
return nil, fmt.Errorf("failed to get localStorage: %w", err)
}
items := make([]*LocalStorageItem, 0, len(result.Result.Value))
for _, item := range result.Result.Value {
items = append(items, &LocalStorageItem{
Key: item["key"].(string),
Value: item["value"].(string),
})
}
return items, nil
}
// SetLocalStorage establece un item en localStorage.
func (b *Browser) SetLocalStorage(ctx context.Context, key, value string) error {
script := fmt.Sprintf(`localStorage.setItem(%q, %q)`, key, value)
params := map[string]interface{}{
"expression": script,
}
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
}
// RemoveLocalStorage elimina un item de localStorage.
func (b *Browser) RemoveLocalStorage(ctx context.Context, key string) error {
script := fmt.Sprintf(`localStorage.removeItem(%q)`, key)
params := map[string]interface{}{
"expression": script,
}
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
}
// ClearLocalStorage limpia todo el localStorage.
func (b *Browser) ClearLocalStorage(ctx context.Context) error {
params := map[string]interface{}{
"expression": "localStorage.clear()",
}
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
}
// GetSessionStorage obtiene todos los items del sessionStorage.
func (b *Browser) GetSessionStorage(ctx context.Context) ([]*LocalStorageItem, error) {
script := `
(() => {
const items = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
items.push({ key: key, value: sessionStorage.getItem(key) });
}
return items;
})()
`
var result struct {
Result struct {
Value []map[string]interface{} `json:"value"`
} `json:"result"`
}
params := map[string]interface{}{
"expression": script,
"returnByValue": true,
}
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
return nil, fmt.Errorf("failed to get sessionStorage: %w", err)
}
items := make([]*LocalStorageItem, 0, len(result.Result.Value))
for _, item := range result.Result.Value {
items = append(items, &LocalStorageItem{
Key: item["key"].(string),
Value: item["value"].(string),
})
}
return items, nil
}
// SetSessionStorage establece un item en sessionStorage.
func (b *Browser) SetSessionStorage(ctx context.Context, key, value string) error {
script := fmt.Sprintf(`sessionStorage.setItem(%q, %q)`, key, value)
params := map[string]interface{}{
"expression": script,
}
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
}
// RemoveSessionStorage elimina un item de sessionStorage.
func (b *Browser) RemoveSessionStorage(ctx context.Context, key string) error {
script := fmt.Sprintf(`sessionStorage.removeItem(%q)`, key)
params := map[string]interface{}{
"expression": script,
}
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
}
// ClearSessionStorage limpia todo el sessionStorage.
func (b *Browser) ClearSessionStorage(ctx context.Context) error {
params := map[string]interface{}{
"expression": "sessionStorage.clear()",
}
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
}
// StorageType tipo de storage.
type StorageType string
const (
StorageTypeCookies StorageType = "cookies"
StorageTypeLocalStorage StorageType = "local_storage"
StorageTypeSessionStorage StorageType = "session_storage"
StorageTypeIndexedDB StorageType = "indexeddb"
StorageTypeWebSQL StorageType = "websql"
StorageTypeCacheStorage StorageType = "cache_storage"
)
// ClearDataForOrigin limpia datos de un origen específico.
func (b *Browser) ClearDataForOrigin(ctx context.Context, origin string, storageTypes ...StorageType) error {
if len(storageTypes) == 0 {
// Por defecto, limpiar todo
storageTypes = []StorageType{
StorageTypeCookies,
StorageTypeLocalStorage,
StorageTypeSessionStorage,
StorageTypeIndexedDB,
StorageTypeWebSQL,
StorageTypeCacheStorage,
}
}
// Convertir a string separado por comas
types := ""
for i, st := range storageTypes {
if i > 0 {
types += ","
}
types += string(st)
}
params := map[string]interface{}{
"origin": origin,
"storageTypes": types,
}
return b.cdpClient.Execute(ctx, "Storage.clearDataForOrigin", params, nil)
}
// ExportCookies exporta las cookies del perfil a un archivo JSON.
func (b *Browser) ExportCookies(ctx context.Context) ([]*Cookie, error) {
return b.GetCookies(ctx)
}
// ImportCookies importa cookies desde un slice.
func (b *Browser) ImportCookies(ctx context.Context, cookies []*Cookie) error {
return b.SetCookies(ctx, cookies)
}
// CreateCookie crea una cookie helper.
func CreateCookie(name, value, domain string) *Cookie {
return &Cookie{
Name: name,
Value: value,
Domain: domain,
Path: "/",
// Expira en 1 año
Expires: float64(time.Now().Add(365 * 24 * time.Hour).Unix()),
HTTPOnly: false,
Secure: false,
SameSite: "Lax",
}
}
// CreateSessionCookie crea una cookie de sesión (sin expiración).
func CreateSessionCookie(name, value, domain string) *Cookie {
return &Cookie{
Name: name,
Value: value,
Domain: domain,
Path: "/",
HTTPOnly: false,
Secure: false,
SameSite: "Lax",
}
}
// CreateSecureCookie crea una cookie segura (HTTPS only).
func CreateSecureCookie(name, value, domain string) *Cookie {
return &Cookie{
Name: name,
Value: value,
Domain: domain,
Path: "/",
Expires: float64(time.Now().Add(365 * 24 * time.Hour).Unix()),
HTTPOnly: true,
Secure: true,
SameSite: "Strict",
}
}
+274
View File
@@ -0,0 +1,274 @@
package cdp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"github.com/gorilla/websocket"
)
// Client representa un cliente del Chrome DevTools Protocol.
type Client struct {
wsURL string
conn *websocket.Conn
mu sync.Mutex
nextID atomic.Int64
callbacks map[int64]chan *Response
events map[string][]EventHandler
eventMu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
closeCh chan struct{}
closeOnce sync.Once
}
// EventHandler es una función que maneja eventos CDP.
type EventHandler func(params json.RawMessage)
// Request representa una solicitud CDP.
type Request struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
// Response representa una respuesta CDP.
type Response struct {
ID int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *ErrorResponse `json:"error,omitempty"`
}
// Event representa un evento CDP.
type Event struct {
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
// ErrorResponse representa un error en una respuesta CDP.
type ErrorResponse struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data string `json:"data,omitempty"`
}
// NewClient crea un nuevo cliente CDP conectado al WebSocket URL especificado.
func NewClient(ctx context.Context, wsURL string) (*Client, error) {
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to connect to CDP websocket: %w", err)
}
clientCtx, cancel := context.WithCancel(ctx)
c := &Client{
wsURL: wsURL,
conn: conn,
callbacks: make(map[int64]chan *Response),
events: make(map[string][]EventHandler),
ctx: clientCtx,
cancel: cancel,
closeCh: make(chan struct{}),
}
// Iniciar goroutine para recibir mensajes
go c.receiveLoop()
return c, nil
}
// receiveLoop procesa mensajes entrantes del WebSocket.
func (c *Client) receiveLoop() {
defer close(c.closeCh)
for {
select {
case <-c.ctx.Done():
return
default:
}
_, data, err := c.conn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
// Log error pero no hacer panic
}
return
}
// Intentar parsear como Response primero
var resp Response
if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 {
c.handleResponse(&resp)
continue
}
// Sino, es un evento
var event Event
if err := json.Unmarshal(data, &event); err == nil && event.Method != "" {
c.handleEvent(&event)
}
}
}
// handleResponse procesa una respuesta CDP.
func (c *Client) handleResponse(resp *Response) {
c.mu.Lock()
ch, ok := c.callbacks[resp.ID]
if ok {
delete(c.callbacks, resp.ID)
}
c.mu.Unlock()
if ok {
select {
case ch <- resp:
default:
}
}
}
// handleEvent procesa un evento CDP.
func (c *Client) handleEvent(event *Event) {
c.eventMu.RLock()
handlers := c.events[event.Method]
c.eventMu.RUnlock()
for _, handler := range handlers {
go handler(event.Params)
}
}
// Execute envía un comando CDP y espera la respuesta.
func (c *Client) Execute(ctx context.Context, method string, params interface{}, result interface{}) error {
id := c.nextID.Add(1)
req := Request{
ID: id,
Method: method,
Params: params,
}
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
// Crear canal para respuesta
respCh := make(chan *Response, 1)
c.mu.Lock()
c.callbacks[id] = respCh
c.mu.Unlock()
// Enviar request
c.mu.Lock()
err = c.conn.WriteMessage(websocket.TextMessage, data)
c.mu.Unlock()
if err != nil {
c.mu.Lock()
delete(c.callbacks, id)
c.mu.Unlock()
return fmt.Errorf("failed to send request: %w", err)
}
// Esperar respuesta
select {
case resp := <-respCh:
if resp.Error != nil {
return fmt.Errorf("CDP error %d: %s - %s", resp.Error.Code, resp.Error.Message, resp.Error.Data)
}
if result != nil && len(resp.Result) > 0 {
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("failed to unmarshal result: %w", err)
}
}
return nil
case <-ctx.Done():
c.mu.Lock()
delete(c.callbacks, id)
c.mu.Unlock()
return ctx.Err()
case <-c.closeCh:
return errors.New("client closed")
}
}
// On registra un handler para un evento específico.
func (c *Client) On(event string, handler EventHandler) {
c.eventMu.Lock()
defer c.eventMu.Unlock()
c.events[event] = append(c.events[event], handler)
}
// Close cierra la conexión CDP.
func (c *Client) Close() error {
var err error
c.closeOnce.Do(func() {
c.cancel()
c.mu.Lock()
if c.conn != nil {
err = c.conn.Close()
}
c.mu.Unlock()
})
return err
}
// GetWebSocketURL obtiene la URL del WebSocket CDP desde el endpoint HTTP.
func GetWebSocketURL(ctx context.Context, debugURL string) (string, error) {
// Primero intentar con /json para obtener lista de targets
req, err := http.NewRequestWithContext(ctx, "GET", debugURL+"/json", nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get CDP info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
// /json retorna un array de targets
var targets []struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
Type string `json:"type"`
}
if err := json.Unmarshal(body, &targets); err != nil {
return "", fmt.Errorf("failed to unmarshal response: %w", err)
}
// Buscar el primer target de tipo "page"
for _, target := range targets {
if target.Type == "page" && target.WebSocketDebuggerURL != "" {
return target.WebSocketDebuggerURL, nil
}
}
// Si no hay page, usar el primero disponible
if len(targets) > 0 && targets[0].WebSocketDebuggerURL != "" {
return targets[0].WebSocketDebuggerURL, nil
}
return "", errors.New("no webSocketDebuggerUrl found in targets")
}
+467
View File
@@ -0,0 +1,467 @@
package stealth
// StealthFlags contiene todas las flags de Chrome para evasión de detección.
// Ver docs/STEALTH_FLAGS.md para documentación completa.
type StealthFlags struct {
// UserDataDir es la ruta al perfil persistente de Chrome
UserDataDir string
// ProfileName es el nombre del perfil dentro de UserDataDir
ProfileName string
// WindowSize define el tamaño de la ventana (ancho,alto)
WindowSize [2]int
// UserAgent personalizado (vacío = usar default de Chrome)
UserAgent string
// Headless activa modo headless si es true
Headless bool
// NoSandbox desactiva sandbox (PELIGROSO - solo Docker/VMs)
NoSandbox bool
// DisableWebSecurity desactiva CORS (solo testing)
DisableWebSecurity bool
// EnableLogging activa logs de Chrome
EnableLogging bool
// LogLevel define nivel de logs (0=INFO, 1=WARNING, 2=ERROR)
LogLevel int
// RemoteDebuggingPort puerto para CDP (0 = aleatorio)
RemoteDebuggingPort int
// CustomFlags flags adicionales personalizadas
CustomFlags []string
}
// DefaultStealthFlags retorna configuración stealth por defecto.
func DefaultStealthFlags() *StealthFlags {
return &StealthFlags{
ProfileName: "Default",
WindowSize: [2]int{1920, 1080},
Headless: true,
NoSandbox: false,
DisableWebSecurity: false,
EnableLogging: false,
LogLevel: 2, // ERROR
RemoteDebuggingPort: 0, // aleatorio
}
}
// Build convierte StealthFlags a slice de argumentos para Chrome.
func (sf *StealthFlags) Build() []string {
flags := []string{
// ============================================
// CRÍTICAS - SIEMPRE ACTIVADAS
// ============================================
// Elimina navigator.webdriver = true
"--disable-blink-features=AutomationControlled",
// Evita flag --enable-automation
"--exclude-switches=enable-automation",
// ============================================
// CONFIGURACIÓN DE PERFIL
// ============================================
}
// User data dir (perfil persistente)
if sf.UserDataDir != "" {
flags = append(flags, "--user-data-dir="+sf.UserDataDir)
if sf.ProfileName != "" {
flags = append(flags, "--profile-directory="+sf.ProfileName)
}
}
// ============================================
// HEADLESS Y GPU
// ============================================
if sf.Headless {
// Nuevo modo headless estable
flags = append(flags, "--headless=new")
// Desactivar GPU en headless
flags = append(flags, "--disable-gpu")
// Ocultar scrollbars
flags = append(flags, "--hide-scrollbars")
// Silenciar audio
flags = append(flags, "--mute-audio")
}
// ============================================
// CONFIGURACIÓN DE VENTANA
// ============================================
if sf.WindowSize[0] > 0 && sf.WindowSize[1] > 0 {
flags = append(flags,
"--window-size="+intToString(sf.WindowSize[0])+","+intToString(sf.WindowSize[1]),
)
}
if !sf.Headless {
flags = append(flags, "--start-maximized")
}
// ============================================
// DESACTIVAR PROMPTS Y POPUPS DE CHROME
// ============================================
// No mostrar "¿Hacer Chrome tu navegador predeterminado?"
flags = append(flags, "--no-default-browser-check")
// No mostrar prompt de "restaurar sesión"
flags = append(flags, "--disable-session-crashed-bubble")
// No mostrar infobars (barras de información)
flags = append(flags, "--disable-infobars")
// No mostrar "Chrome está siendo controlado por software automatizado"
// (ya cubierto por --exclude-switches=enable-automation)
// Desactivar prompts de guardar contraseñas
flags = append(flags, "--disable-save-password-bubble")
// No mostrar primera experiencia de usuario
flags = append(flags, "--no-first-run")
// Desactivar componentes de sync
flags = append(flags, "--disable-sync")
// Desactivar ofertas de instalación de Chrome
flags = append(flags, "--disable-component-update")
// ============================================
// USER AGENT
// ============================================
if sf.UserAgent != "" {
flags = append(flags, "--user-agent="+sf.UserAgent)
}
// ============================================
// OPTIMIZACIÓN Y ESTABILIDAD
// ============================================
// Evita problemas en Docker/containers
flags = append(flags, "--disable-dev-shm-usage")
// Reduce superficie de detección
flags = append(flags, "--disable-extensions")
// Mejora rendimiento
flags = append(flags, "--disable-plugins")
// Evita throttling de timers
flags = append(flags, "--disable-background-timer-throttling")
// Mantiene ventanas ocultas activas
flags = append(flags, "--disable-backgrounding-occluded-windows")
// Mantiene renderer activo
flags = append(flags, "--disable-renderer-backgrounding")
// Permite muchos comandos CDP rápidos
flags = append(flags, "--disable-ipc-flooding-protection")
// ============================================
// PRIVACIDAD Y UI
// ============================================
// Bloquea notificaciones
flags = append(flags, "--disable-notifications")
// Permite popups
flags = append(flags, "--disable-popup-blocking")
// Desactiva UI de traducción
flags = append(flags, "--disable-features=TranslateUI")
// Desactiva Privacy Sandbox
flags = append(flags, "--disable-features=PrivacySandboxSettings4")
// ============================================
// FLAGS OPCIONALES (PELIGROSAS/DEBUG)
// ============================================
// SANDBOX - Solo Docker/VMs confiables
if sf.NoSandbox {
flags = append(flags, "--no-sandbox", "--disable-setuid-sandbox")
}
// WEB SECURITY - Solo testing
if sf.DisableWebSecurity {
flags = append(flags, "--disable-web-security")
flags = append(flags, "--disable-features=IsolateOrigins,site-per-process")
flags = append(flags, "--disable-site-isolation-trials")
}
// LOGGING - Solo debugging
if sf.EnableLogging {
flags = append(flags, "--enable-logging")
flags = append(flags, "--v=1")
flags = append(flags, "--log-level="+intToString(sf.LogLevel))
}
// ============================================
// REMOTE DEBUGGING (CDP)
// ============================================
if sf.RemoteDebuggingPort > 0 {
flags = append(flags, "--remote-debugging-port="+intToString(sf.RemoteDebuggingPort))
} else {
// Puerto aleatorio, CDP asignará uno
flags = append(flags, "--remote-debugging-port=0")
}
// Escuchar en todas las interfaces
flags = append(flags, "--remote-debugging-address=0.0.0.0")
// ============================================
// CUSTOM FLAGS
// ============================================
if len(sf.CustomFlags) > 0 {
flags = append(flags, sf.CustomFlags...)
}
return flags
}
// GetAntiDetectionScript retorna el código JavaScript para inyectar
// en cada página y sobrescribir propiedades que delatan automatización.
func GetAntiDetectionScript() string {
return `
// ============================================
// ANTI-DETECTION SCRIPT
// ============================================
// Sobrescribir navigator.webdriver
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
// Eliminar propiedades de Selenium/WebDriver
delete window.navigator.__proto__.webdriver;
delete window.navigator.webdriver;
delete window._selenium;
delete window._webdriver;
delete window.callSelenium;
delete window.callPhantom;
delete window._phantom;
// Mock chrome runtime
window.chrome = {
app: {
isInstalled: false,
InstallState: {
DISABLED: 'disabled',
INSTALLED: 'installed',
NOT_INSTALLED: 'not_installed'
},
RunningState: {
CANNOT_RUN: 'cannot_run',
READY_TO_RUN: 'ready_to_run',
RUNNING: 'running'
}
},
runtime: {
OnInstalledReason: {
CHROME_UPDATE: 'chrome_update',
INSTALL: 'install',
SHARED_MODULE_UPDATE: 'shared_module_update',
UPDATE: 'update'
},
OnRestartRequiredReason: {
APP_UPDATE: 'app_update',
OS_UPDATE: 'os_update',
PERIODIC: 'periodic'
},
PlatformArch: {
ARM: 'arm',
ARM64: 'arm64',
MIPS: 'mips',
MIPS64: 'mips64',
X86_32: 'x86-32',
X86_64: 'x86-64'
},
PlatformNaclArch: {
ARM: 'arm',
MIPS: 'mips',
MIPS64: 'mips64',
X86_32: 'x86-32',
X86_64: 'x86-64'
},
PlatformOs: {
ANDROID: 'android',
CROS: 'cros',
LINUX: 'linux',
MAC: 'mac',
OPENBSD: 'openbsd',
WIN: 'win'
},
RequestUpdateCheckStatus: {
NO_UPDATE: 'no_update',
THROTTLED: 'throttled',
UPDATE_AVAILABLE: 'update_available'
}
},
csi: function() {},
loadTimes: function() {}
};
// Mock permissions.query
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
// Plugins array mock (navegadores reales tienen plugins)
Object.defineProperty(navigator, 'plugins', {
get: () => [
{
0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format", enabledPlugin: Plugin},
description: "Portable Document Format",
filename: "internal-pdf-viewer",
length: 1,
name: "Chrome PDF Plugin"
},
{
0: {type: "application/pdf", suffixes: "pdf", description: "", enabledPlugin: Plugin},
description: "",
filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai",
length: 1,
name: "Chrome PDF Viewer"
},
{
0: {type: "application/x-nacl", suffixes: "", description: "Native Client Executable", enabledPlugin: Plugin},
1: {type: "application/x-pnacl", suffixes: "", description: "Portable Native Client Executable", enabledPlugin: Plugin},
description: "",
filename: "internal-nacl-plugin",
length: 2,
name: "Native Client"
}
]
});
// Languages array (debe tener contenido realista)
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en', 'es']
});
// Platform fix
Object.defineProperty(navigator, 'platform', {
get: () => 'Win32'
});
// Vendor fix
Object.defineProperty(navigator, 'vendor', {
get: () => 'Google Inc.'
});
// Connection mock
Object.defineProperty(navigator, 'connection', {
get: () => ({
effectiveType: '4g',
rtt: 100,
downlink: 10,
saveData: false
})
});
// Hardware concurrency (núcleos de CPU)
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8
});
// Device memory (GB RAM)
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8
});
// Battery mock (solo si la API existe)
if (navigator.getBattery) {
const originalGetBattery = navigator.getBattery;
navigator.getBattery = () => originalGetBattery().then(battery => {
Object.defineProperty(battery, 'charging', { get: () => true });
Object.defineProperty(battery, 'chargingTime', { get: () => 0 });
Object.defineProperty(battery, 'dischargingTime', { get: () => Infinity });
Object.defineProperty(battery, 'level', { get: () => 1 });
return battery;
});
}
// Console debug signature removal
const originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
debug: console.debug
};
console.log = (...args) => originalConsole.log.apply(console, args);
console.warn = (...args) => originalConsole.warn.apply(console, args);
console.error = (...args) => originalConsole.error.apply(console, args);
console.debug = (...args) => originalConsole.debug.apply(console, args);
// Performance timing fix
if (window.performance && window.performance.timing) {
Object.defineProperty(window.performance.timing, 'navigationStart', {
get: () => Date.now() - Math.floor(Math.random() * 10000)
});
}
// Screen dimensions mock (deben coincidir con window size)
Object.defineProperty(window.screen, 'width', {
get: () => 1920
});
Object.defineProperty(window.screen, 'height', {
get: () => 1080
});
Object.defineProperty(window.screen, 'availWidth', {
get: () => 1920
});
Object.defineProperty(window.screen, 'availHeight', {
get: () => 1040 // Menos barra de tareas
});
// ============================================
// FIN ANTI-DETECTION SCRIPT
// ============================================
`
}
// intToString convierte int a string (helper simple)
func intToString(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf) - 1
neg := n < 0
if neg {
n = -n
}
for n > 0 {
buf[i] = byte('0' + n%10)
n /= 10
i--
}
if neg {
buf[i] = '-'
i--
}
return string(buf[i+1:])
}