Initial commit: navegator - Chrome CDP automation for LLMs
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:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user