commit 3253828fefb42e88e9b4f1e1c904ab1ba036b5ef Author: Developer Date: Tue Mar 24 23:33:07 2026 +0100 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 diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..6d4f1d1 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,384 @@ +# Instrucciones para Claude - Navegator + +## Descripción del Proyecto + +**Navegator** es un sistema en Go que permite controlar Chrome/Chromium mediante Chrome DevTools Protocol (CDP) directo, diseñado específicamente para automatización sigilosa con LLMs. + +### Propósito +Proporcionar a los agentes LLM capacidades completas de control de navegador web con: +- Mínima detección de automatización (stealth flags optimizadas) +- Perfiles persistentes y reutilizables +- API completa de CDP (navegación, cookies, storage, network, JavaScript) +- Comunicación WebSocket directa sin abstracciones pesadas + +### Casos de Uso +- Web scraping sigiloso +- Automatización de formularios y flujos web +- Testing end-to-end +- Recopilación de datos +- Interacción con aplicaciones web complejas + +## Stack Tecnológico + +### Lenguajes y Runtime +- **Go 1.21+**: Lenguaje principal +- **JavaScript**: Para evaluación en contexto de páginas web + +### Dependencias Externas +- **gorilla/websocket**: Comunicación WebSocket con CDP +- **Chrome/Chromium**: Navegador requerido en el sistema + +### Protocolos +- **Chrome DevTools Protocol (CDP)**: Comunicación con Chrome via WebSocket +- **WebSocket**: Transporte para CDP + +## Estructura del Proyecto + +``` +navegator/ +├── pkg/ +│ ├── cdp/ +│ │ └── client.go # Cliente CDP de bajo nivel (WebSocket) +│ ├── browser/ +│ │ ├── browser.go # Gestión de instancia de Chrome +│ │ ├── navigation.go # Navigate, Click, Type, Screenshot +│ │ ├── storage.go # Cookies, LocalStorage, SessionStorage +│ │ ├── network.go # Interceptación, headers, cache +│ │ └── runtime.go # Evaluación JavaScript, bindings +│ └── stealth/ +│ └── flags.go # Configuración de flags anti-detección +├── examples/ +│ ├── basic.go # Ejemplo básico de uso +│ └── advanced.go # Ejemplos de capacidades avanzadas +├── docs/ +│ └── STEALTH_FLAGS.md # Documentación completa de flags +├── .claude/ +│ └── CLAUDE.md # Este archivo +├── go.mod # Módulo Go +├── go.sum # Checksums de dependencias +└── README.md # Documentación principal +``` + +## Convenciones + +### Código Go + +#### Estilo +- **gofmt**: Todo el código debe estar formateado con gofmt +- **Nombres**: CamelCase para exports, camelCase para privados +- **Comentarios**: Todos los exports deben tener comentarios godoc + +#### Patrones +- **Context**: Todos los métodos que hacen I/O reciben `context.Context` +- **Errores**: Usar `fmt.Errorf` con `%w` para wrap de errores +- **Configuración**: Structs de configuración con funciones `Default*()` +- **Recursos**: Siempre proveer método `Close()` y usar `defer` + +#### Ejemplo +```go +// Navigate navega a una URL. +func (b *Browser) Navigate(ctx context.Context, url string, opts *NavigateOptions) error { + if opts == nil { + opts = DefaultNavigateOptions() + } + // ... +} +``` + +### Estructura de Código + +#### Organización +- **pkg/**: Código de biblioteca reutilizable +- **examples/**: Código ejecutable de ejemplo +- **docs/**: Documentación técnica + +#### Capas +1. **CDP**: Bajo nivel, solo manejo de protocolo WebSocket +2. **Browser**: Alto nivel, API amigable para usuarios +3. **Stealth**: Configuración específica para evasión + +### Git + +#### Branches +- `master`: Rama principal estable +- Feature branches: `feature/` +- Bugfix branches: `bugfix/` + +#### Commits +- Mensajes en imperativo: "Add feature" no "Added feature" +- Scope claro: "pkg/browser: add screenshot support" +- Commits pequeños y atómicos + +#### Ejemplo +``` +pkg/browser: add full-page screenshot support + +- Modify Screenshot() to accept fullPage parameter +- Update captureScreenshot CDP params +- Add example to advanced.go +``` + +## Comandos Importantes + +| Comando | Descripción | +|---------|-------------| +| `go mod download` | Descargar dependencias | +| `go build -o nav examples/basic.go` | Compilar ejemplo básico | +| `go run examples/basic.go` | Ejecutar ejemplo básico | +| `go run examples/advanced.go` | Ejecutar ejemplos avanzados | +| `go test ./...` | Ejecutar tests (cuando existan) | +| `gofmt -w .` | Formatear todo el código | +| `go mod tidy` | Limpiar go.mod | + +## Arquitectura CDP + +### Flujo de Comunicación + +``` +Go App → WebSocket → Chrome CDP Endpoint + ↓ +Browser API (navegator/pkg/browser) + ↓ +CDP Client (navegator/pkg/cdp) + ↓ +WebSocket Connection + ↓ +Chrome/Chromium +``` + +### Patrón Request/Response + +```go +// Enviar comando CDP +req := Request{ + ID: 1, + Method: "Page.navigate", + Params: map[string]interface{}{"url": "https://example.com"}, +} + +// Respuesta CDP +resp := Response{ + ID: 1, + Result: {...}, +} +``` + +### Eventos CDP + +```go +// Chrome envía eventos asíncronos +event := Event{ + Method: "Page.loadEventFired", + Params: {...}, +} + +// Se manejan via callbacks registrados +client.On("Page.loadEventFired", handler) +``` + +## Stealth - Configuración Anti-Detección + +### Flags Críticas (SIEMPRE) + +```go +"--disable-blink-features=AutomationControlled" // Elimina navigator.webdriver +"--exclude-switches=enable-automation" // Evita flag automation +``` + +### Flags Contextuales + +```go +"--headless=new" // Modo headless (si se necesita) +"--no-sandbox" // SOLO Docker/VMs (peligroso) +"--disable-web-security" // SOLO testing (peligroso) +``` + +### JavaScript Anti-Detección + +Se inyecta automáticamente via `Page.addScriptToEvaluateOnNewDocument`: +- Sobrescribe `navigator.webdriver` +- Mock de `window.chrome` +- Ajusta `navigator.plugins`, `languages`, etc. + +Ver `pkg/stealth/flags.go` → `GetAntiDetectionScript()` + +## Perfiles Persistentes + +### Ubicación +`~/.navegator/profiles//` + +### Contenido +- **Cookies**: Persistentes entre ejecuciones +- **LocalStorage/SessionStorage**: Datos de aplicaciones +- **Historial**: Navegación previa +- **Extensiones**: Si se agregan +- **DevToolsActivePort**: Puerto CDP activo + +### Uso +```go +config := browser.DefaultConfig() +config.ProfileName = "agent-session-1" // Reutilizable +b, _ := browser.Launch(ctx, config) +``` + +## API Principal - Browser + +### Navegación +```go +Navigate(ctx, url, opts) // Navega a URL +Reload(ctx) // Recarga página +GoBack(ctx) // Historial atrás +GoForward(ctx) // Historial adelante +Click(ctx, selector) // Click en elemento +Type(ctx, selector, text, opts) // Escribe texto +WaitForSelector(ctx, sel, timeout) // Espera elemento +Screenshot(ctx, fullPage) // Captura pantalla +GetHTML(ctx, selector) // Obtiene HTML +GetText(ctx, selector) // Obtiene texto +``` + +### Cookies & Storage +```go +GetCookies(ctx, urls...) // Lee cookies +SetCookie(ctx, cookie) // Establece cookie +ClearCookies(ctx) // Limpia cookies +GetLocalStorage(ctx) // Lee localStorage +SetLocalStorage(ctx, key, value) // Escribe localStorage +ClearLocalStorage(ctx) // Limpia localStorage +// Similar para SessionStorage +``` + +### Network +```go +EnableNetworkInterception(ctx) // Habilita interceptación +BlockURLs(ctx, patterns...) // Bloquea URLs +BlockResourceTypes(ctx, types...) // Bloquea recursos +ModifyHeaders(ctx, headers) // Modifica headers +SetExtraHTTPHeaders(ctx, headers) // Headers extra +SetUserAgent(ctx, ua) // User-Agent +EmulateNetworkConditions(ctx, ...) // Throttling +``` + +### JavaScript +```go +Evaluate(ctx, script) // Ejecuta JS sync +EvaluateAsync(ctx, script) // Ejecuta JS async +CallFunction(ctx, fn, args...) // Llama función con args +AddBinding(ctx, name, callback) // Expone Go a JS +OnConsole(handler) // Monitor console.log +WaitForFunction(ctx, fn, interval) // Espera condición JS +``` + +## Restricciones y Mejores Prácticas + +### NO HACER + +❌ **NO usar `--no-sandbox` en producción** +- Solo en Docker/containers confiables +- Riesgo de seguridad crítico + +❌ **NO usar `--disable-web-security` fuera de testing** +- Desactiva CORS y otras protecciones +- Solo para desarrollo local + +❌ **NO ejecutar JavaScript no confiable** +- Usar `Evaluate()` solo con código controlado +- Validar inputs de usuarios + +❌ **NO olvidar cerrar el navegador** +```go +b, _ := browser.Launch(ctx, config) +defer b.Close() // SIEMPRE +``` + +❌ **NO usar selectores frágiles** +- Preferir IDs, data-attributes +- Evitar nth-child, posiciones absolutas + +### SÍ HACER + +✅ **Usar perfiles persistentes para sesiones** +```go +config.ProfileName = "user-session-123" +``` + +✅ **Manejar timeouts adecuadamente** +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() +``` + +✅ **Verificar errores siempre** +```go +if err := b.Navigate(ctx, url, nil); err != nil { + log.Printf("Error: %v", err) +} +``` + +✅ **Usar flags stealth apropiadas al contexto** +```go +config.StealthFlags.Headless = true // Servidor +config.StealthFlags.Headless = false // Desktop/visible +``` + +✅ **Documentar configuración específica** +```go +// UserAgent debe coincidir con versión de Chrome instalada +config.StealthFlags.UserAgent = "Mozilla/5.0 ..." +``` + +## Para LLMs - Instrucciones Específicas + +### Al Crear Nuevas Capacidades + +1. **Buscar en CDP docs primero**: https://chromedevtools.github.io/devtools-protocol/ +2. **Agregar método en browser package**: `pkg/browser/*.go` +3. **Documentar con godoc**: Comentario claro del propósito +4. **Manejar errores**: Wrap con `fmt.Errorf(..., %w, err)` +5. **Agregar ejemplo**: En `examples/advanced.go` + +### Al Modificar Flags Stealth + +1. **Consultar STEALTH_FLAGS.md**: `docs/STEALTH_FLAGS.md` +2. **Comentar flags peligrosas**: `--no-sandbox`, `--disable-web-security` +3. **Actualizar script anti-detección**: Si es necesario en `pkg/stealth/flags.go` +4. **Testear detección**: Usar sitios como https://bot.sannysoft.com/ + +### Al Extender CDP Client + +1. **Mantener bajo nivel**: Solo protocolo WebSocket +2. **No agregar lógica de negocio**: Eso va en `pkg/browser` +3. **Manejar eventos correctamente**: Usar goroutines con `go handler()` +4. **Thread-safety**: Usar `sync.Mutex` donde sea necesario + +### Debugging + +```go +// Activar logging +config.StealthFlags.EnableLogging = true + +// Ver puerto CDP +log.Println(b.DebugURL()) // http://127.0.0.1: + +// Abrir en navegador regular para inspeccionar +``` + +## Referencias Clave + +- **CDP Protocol**: https://chromedevtools.github.io/devtools-protocol/ +- **Chrome Flags**: https://peter.sh/experiments/chromium-command-line-switches/ +- **Puppeteer Stealth**: https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth +- **Bot Detection**: https://bot.sannysoft.com/ + +## Próximos Pasos (TODOs) + +- [ ] Agregar tests unitarios +- [ ] Implementar pool de navegadores +- [ ] Soporte para múltiples targets/tabs +- [ ] Gestión de extensiones de Chrome +- [ ] Proxy support +- [ ] Screenshots de elementos específicos +- [ ] PDF generation +- [ ] Geolocation mocking +- [ ] Permissions API +- [ ] Service Workers interception diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..483e26e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,134 @@ +name: Tests + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master ] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Run unit tests + run: make test-unit + + - name: Generate coverage + run: make coverage + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage.out + + e2e-tests: + name: E2E Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install Chrome + run: | + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' + sudo apt-get update + sudo apt-get install -y google-chrome-stable + + - name: Install dependencies + run: go mod download + + - name: Build binaries + run: make build + + - name: Run E2E tests + run: make test-e2e + env: + DISPLAY: :99 + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + name: test-artifacts + path: | + *.png + *.log + test-profiles/ + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install Chrome + run: | + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' + sudo apt-get update + sudo apt-get install -y google-chrome-stable + + - name: Install dependencies + run: go mod download + + - name: Build binaries + run: make build + + - name: Run integration tests + run: make test-integration + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Run go vet + run: make lint + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..037ee4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Binarios +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binaries +*.test + +# Coverage +*.out +coverage.html + +# Go workspace +go.work + +# Perfiles de navegador (contienen cookies/datos sensibles) +perfiles/ +.navegator/ + +# Screenshots y outputs de ejemplos +*.png +*.jpg +*.jpeg + +# JSON de resultados +*.json + +# Logs +*.log +session.log +recording_*.log + +# Temporal +test-profiles/ +test-integration/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..757ba02 --- /dev/null +++ b/Makefile @@ -0,0 +1,103 @@ +# Makefile para Navegator + +.PHONY: all build test test-unit test-e2e test-integration clean help + +# Variables +BINARIES := screenshot buscar navegar +TEST_TIMEOUT := 300s + +all: build test + +## build: Compilar todos los binarios +build: + @echo "🔨 Compilando binarios..." + @mkdir -p bin + @go build -o bin/screenshot cmd/screenshot.go + @go build -o bin/buscar cmd/buscar.go + @go build -o bin/navegar cmd/navegar.go + @echo "✅ Binarios compilados en bin/" + +## test: Ejecutar todos los tests +test: test-unit test-e2e test-integration + @echo "" + @echo "==========================================" + @echo "✅ Todos los tests completados" + @echo "==========================================" + +## test-unit: Ejecutar tests unitarios de Go +test-unit: + @echo "🧪 Ejecutando tests unitarios..." + @go test -v -timeout $(TEST_TIMEOUT) ./pkg/browser/... ./pkg/cdp/... ./pkg/stealth/... + +## test-e2e: Ejecutar tests E2E de binarios +test-e2e: build + @echo "🎯 Ejecutando tests E2E..." + @chmod +x e2e/e2e_test.sh + @./e2e/e2e_test.sh + +## test-integration: Ejecutar tests de integración +test-integration: build + @echo "🔗 Ejecutando tests de integración..." + @chmod +x e2e/integration_test.sh + @./e2e/integration_test.sh + +## test-quick: Tests rápidos (solo unitarios) +test-quick: + @echo "⚡ Tests rápidos..." + @go test -short ./pkg/... + +## clean: Limpiar archivos generados +clean: + @echo "🧹 Limpiando..." + @rm -rf bin/ buscar-v2 + @rm -f *.png *.json *.log + @rm -rf test-profiles/ + @rm -rf perfiles/test-* + @rm -rf perfiles/*-clone-* + @echo "✅ Limpieza completada" + +## fmt: Formatear código Go +fmt: + @echo "💅 Formateando código..." + @go fmt ./... + +## lint: Ejecutar linter +lint: + @echo "🔍 Ejecutando linter..." + @go vet ./... + +## coverage: Generar reporte de cobertura +coverage: + @echo "📊 Generando reporte de cobertura..." + @go test -coverprofile=coverage.out ./pkg/... + @go tool cover -html=coverage.out -o coverage.html + @echo "✅ Reporte generado: coverage.html" + +## bench: Ejecutar benchmarks +bench: + @echo "⚡ Ejecutando benchmarks..." + @go test -bench=. -benchmem ./pkg/... + +## install: Instalar binarios en $GOPATH/bin +install: build + @echo "📦 Instalando binarios..." + @cp screenshot $(GOPATH)/bin/navegator-screenshot + @cp buscar $(GOPATH)/bin/navegator-buscar + @cp navegar $(GOPATH)/bin/navegator-navegar + @echo "✅ Binarios instalados en $(GOPATH)/bin/" + +## demo: Ejecutar demo de perfiles en paralelo +demo: build + @chmod +x scripts/demo_paralelo.sh + @./scripts/demo_paralelo.sh + +## help: Mostrar ayuda +help: + @echo "Navegator - Makefile Commands" + @echo "" + @echo "Uso: make [target]" + @echo "" + @echo "Targets disponibles:" + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..179bcd3 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Navegator + +Sistema en Go para control de Chrome/Chromium via Chrome DevTools Protocol (CDP) diseñado para automatización sigilosa con LLMs. + +## Características + +- **Control CDP Directo**: Comunicación WebSocket con Chrome sin dependencias de librerías de alto nivel +- **Stealth Completo**: Flags optimizadas para evitar detección de automatización +- **Perfiles Persistentes**: Gestión de perfiles de usuario con cookies y sesiones reutilizables +- **API Completa**: Navegación, interacción, cookies, storage, interceptación de red, evaluación de JavaScript + +## Instalación + +```bash +# Clonar repositorio +git clone +cd navegator + +# Descargar dependencias +go mod download + +# Compilar ejemplo básico +go build -o navegator-basic examples/basic.go + +# Ejecutar +./navegator-basic +``` + +## Requisitos + +- Go 1.21+ +- Chrome o Chromium instalado en el sistema +- WebSocket support (gorilla/websocket) + +## Uso Básico + +```go +package main + +import ( + "context" + "log" + "navegator/pkg/browser" +) + +func main() { + ctx := context.Background() + + // Configuración por defecto + config := browser.DefaultConfig() + config.ProfileName = "mi-perfil" + + // Lanzar navegador + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatal(err) + } + defer b.Close() + + // Navegar + b.Navigate(ctx, "https://example.com", nil) + + // Obtener HTML + html, _ := b.GetHTML(ctx, "") + log.Println(html) +} +``` + +## Estructura del Proyecto + +``` +navegator/ +├── bin/ # Binarios compilados +│ ├── screenshot # Capturador de pantalla +│ ├── buscar # Motor de búsqueda +│ └── navegar # Navegador interactivo +├── cmd/ # Código fuente de binarios +│ ├── screenshot.go +│ ├── buscar.go +│ └── navegar.go +├── pkg/ # Librerías reutilizables +│ ├── cdp/ # Cliente CDP de bajo nivel +│ ├── browser/ # API de alto nivel del navegador +│ └── stealth/ # Configuración de flags stealth +├── scripts/ # Scripts de utilidad +│ ├── clonar_perfil.sh # Clonar perfiles para uso paralelo +│ ├── demo_paralelo.sh # Demo de múltiples usuarios +│ └── ejemplos_perfiles.sh +├── docs/ # Documentación +│ ├── STEALTH_FLAGS.md # Flags anti-detección +│ ├── BINARIOS.md # Guía de binarios +│ ├── PERFILES_AVANZADO.md # Gestión de perfiles +│ └── TESTING.md # Guía de testing +├── e2e/ # Tests E2E +│ ├── e2e_test.sh # Tests de binarios +│ └── integration_test.sh # Tests de integración +├── examples/ # Ejemplos de código +│ ├── basic.go +│ └── advanced.go +├── Makefile # Comandos de build y test +└── README.md +``` + +## Capacidades + +### Navegación +- Navigate, Reload, GoBack, GoForward +- WaitForSelector con timeout +- Click, Type, Focus +- Screenshot (viewport o página completa) +- GetHTML, GetText + +### Cookies & Storage +- GetCookies, SetCookie, DeleteCookie, ClearCookies +- LocalStorage: Get, Set, Remove, Clear +- SessionStorage: Get, Set, Remove, Clear +- ClearDataForOrigin + +### Network +- EnableNetworkInterception +- BlockURLs, BlockResourceTypes +- ModifyHeaders +- SetExtraHTTPHeaders +- SetUserAgent +- EmulateNetworkConditions +- DisableCache + +### JavaScript +- Evaluate (sync) +- EvaluateAsync (promises) +- CallFunction con argumentos +- AddBinding (exponer funciones Go a JS) +- OnConsole (monitorear console.log) +- WaitForFunction + +## Perfiles Persistentes + +Los perfiles se guardan en `~/.navegator/profiles//`: + +```go +config := browser.DefaultConfig() +config.ProfileName = "session-1" // Reutilizable entre ejecuciones +``` + +Cada perfil mantiene: +- Cookies +- LocalStorage/SessionStorage +- Historial +- Extensiones +- Preferencias + +## Stealth Flags + +Ver `docs/STEALTH_FLAGS.md` para documentación completa de todas las flags. + +Configuración básica: +```go +config.StealthFlags.Headless = true +config.StealthFlags.NoSandbox = false // Solo en Docker +config.StealthFlags.UserAgent = "Mozilla/5.0 ..." +config.StealthFlags.WindowSize = [2]int{1920, 1080} +``` + +## Ejemplos + +### Ejemplo 1: Navegación Básica +```bash +go run examples/basic.go +``` + +### Ejemplo 2: Capacidades Avanzadas +```bash +go run examples/advanced.go +``` + +## Para LLMs + +Este sistema está diseñado para ser controlado por LLMs. La API es: + +1. **Declarativa**: Métodos claros como `Navigate()`, `Click()`, `Type()` +2. **Contextual**: Usa selectores CSS estándar +3. **Asíncrona**: Manejo de timeouts y esperas automáticas +4. **Completa**: Todas las capacidades CDP disponibles + +Ejemplo de prompt para LLM: +``` +Usando el sistema navegator: +1. Lanza un navegador con perfil "agent-123" +2. Navega a https://example.com +3. Obtén el texto del h1 +4. Toma un screenshot +5. Cierra el navegador +``` + +## Flags Stealth Críticas + +**SIEMPRE ACTIVADAS**: +- `--disable-blink-features=AutomationControlled` +- `--exclude-switches=enable-automation` +- `--user-data-dir=` (perfiles persistentes) + +**CONTEXTUALES**: +- `--headless=new` (modo headless moderno) +- `--no-sandbox` (SOLO en Docker/containers) +- `--disable-web-security` (SOLO en testing) + +## Debugging + +```go +config.StealthFlags.EnableLogging = true +config.StealthFlags.LogLevel = 0 // INFO +``` + +URL de debugging disponible en `b.DebugURL()`: +``` +http://127.0.0.1: +``` + +## Seguridad + +⚠️ **IMPORTANTE**: +- `--no-sandbox` es PELIGROSO - solo usar en entornos confiables +- `--disable-web-security` desactiva CORS - solo para testing +- Los perfiles pueden contener datos sensibles - proteger adecuadamente + +## Licencia + +MIT + +## Referencias + +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) +- [Chrome Command Line Switches](https://peter.sh/experiments/chromium-command-line-switches/) +- [Puppeteer Stealth Plugin](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth) diff --git a/cmd/buscar.go b/cmd/buscar.go new file mode 100644 index 0000000..1387efc --- /dev/null +++ b/cmd/buscar.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "navegator/pkg/browser" +) + +// Resultado representa un resultado de búsqueda +type Resultado struct { + Titulo string `json:"titulo"` + URL string `json:"url"` + Descripcion string `json:"descripcion"` +} + +func main() { + // Definir flags/parámetros + query := flag.String("q", "", "Consulta de búsqueda (requerido)") + maxResults := flag.Int("n", 10, "Número máximo de resultados (default: 10)") + headless := flag.Bool("headless", true, "Modo headless (default: true)") + outputJSON := flag.String("output", "", "Guardar resultados en archivo JSON") + profileName := flag.String("profile", "search-bot", "Nombre del perfil a usar") + + flag.Parse() + + // Validar que se proporcionó la consulta + if *query == "" { + fmt.Println("Error: debes proporcionar una consulta con -q") + fmt.Println("\nEjemplo:") + fmt.Println(" ./buscar -q \"golang tutorial\" -n 20") + fmt.Println("\nOpciones:") + flag.PrintDefaults() + os.Exit(1) + } + + ctx := context.Background() + + // Configurar navegador + currentDir, _ := os.Getwd() + profilesDir := filepath.Join(currentDir, "perfiles") + + config := browser.DefaultConfig() + config.ProfilesBaseDir = profilesDir + config.ProfileName = *profileName + config.StealthFlags.Headless = *headless + config.StealthFlags.WindowSize = [2]int{1280, 720} + + log.Printf("🔍 Buscando: %s", *query) + log.Printf("📊 Máximo de resultados: %d", *maxResults) + log.Printf("👤 Usando perfil: %s", *profileName) + + // Lanzar navegador + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("❌ Error al lanzar navegador: %v", err) + } + defer b.Close() + + // Navegar a DuckDuckGo (más amigable para bots que Google) + searchURL := fmt.Sprintf("https://duckduckgo.com/?q=%s", *query) + log.Println("🌐 Navegando a DuckDuckGo...") + + if err := b.Navigate(ctx, searchURL, nil); err != nil { + log.Fatalf("❌ Error al navegar: %v", err) + } + + // Esperar a que carguen los resultados + time.Sleep(3 * time.Second) + + log.Println("📥 Extrayendo resultados...") + + // Script para extraer resultados + extractScript := fmt.Sprintf(` + (() => { + const results = []; + const maxResults = %d; + + // DuckDuckGo usa article con data-testid="result" + const items = document.querySelectorAll('article[data-testid="result"]'); + + for (let i = 0; i < Math.min(items.length, maxResults); i++) { + const item = items[i]; + + // Título + const titleEl = item.querySelector('h2 a'); + const titulo = titleEl ? titleEl.textContent : ''; + const url = titleEl ? titleEl.href : ''; + + // Descripción + const descEl = item.querySelector('[data-result="snippet"]'); + const descripcion = descEl ? descEl.textContent : ''; + + if (titulo && url) { + results.push({ + titulo: titulo.trim(), + url: url, + descripcion: descripcion.trim() + }); + } + } + + return results; + })() + `, *maxResults) + + result, err := b.Evaluate(ctx, extractScript) + if err != nil { + log.Fatalf("❌ Error al extraer resultados: %v", err) + } + + // Parsear resultados + resultadosJSON, err := json.Marshal(result.Value) + if err != nil { + log.Fatalf("❌ Error al parsear resultados: %v", err) + } + + var resultados []Resultado + if err := json.Unmarshal(resultadosJSON, &resultados); err != nil { + log.Fatalf("❌ Error al deserializar: %v", err) + } + + // Mostrar resultados + log.Printf("\n✅ Encontrados %d resultados:\n", len(resultados)) + + for i, r := range resultados { + fmt.Printf("\n%d. %s\n", i+1, r.Titulo) + fmt.Printf(" 🔗 %s\n", r.URL) + if r.Descripcion != "" { + fmt.Printf(" 📝 %s\n", r.Descripcion) + } + } + + // Guardar en JSON si se especificó + if *outputJSON != "" { + data, _ := json.MarshalIndent(resultados, "", " ") + if err := os.WriteFile(*outputJSON, data, 0644); err != nil { + log.Printf("⚠️ Error al guardar JSON: %v", err) + } else { + log.Printf("\n💾 Resultados guardados en: %s", *outputJSON) + } + } + + log.Println("\n✨ Búsqueda completada!") +} diff --git a/cmd/buscar_v2.go b/cmd/buscar_v2.go new file mode 100644 index 0000000..00db135 --- /dev/null +++ b/cmd/buscar_v2.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "navegator/pkg/browser" +) + +// Resultado representa un resultado de búsqueda +type Resultado struct { + Titulo string `json:"titulo"` + URL string `json:"url"` + Descripcion string `json:"descripcion"` +} + +func main() { + // Definir flags/parámetros + query := flag.String("q", "", "Consulta de búsqueda (requerido)") + maxResults := flag.Int("n", 10, "Número máximo de resultados (default: 10)") + headless := flag.Bool("headless", true, "Modo headless (default: true)") + outputJSON := flag.String("output", "", "Guardar resultados en archivo JSON") + profileName := flag.String("profile", "search-bot", "Nombre del perfil a usar") + + // NUEVO: Directorio de perfiles compartido + profilesDir := flag.String("profiles-dir", "", "Directorio de perfiles (default: ~/.navegator/profiles)") + + flag.Parse() + + // Validar que se proporcionó la consulta + if *query == "" { + fmt.Println("Error: debes proporcionar una consulta con -q") + fmt.Println("\nEjemplo:") + fmt.Println(" ./buscar -q \"golang tutorial\" -n 20") + fmt.Println("\nOpciones:") + flag.PrintDefaults() + os.Exit(1) + } + + ctx := context.Background() + + // Determinar directorio de perfiles + var perfilesPath string + if *profilesDir != "" { + perfilesPath = *profilesDir + } else { + // Default: ~/.navegator/profiles + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatalf("❌ Error al obtener home dir: %v", err) + } + perfilesPath = filepath.Join(homeDir, ".navegator", "profiles") + } + + // Crear directorio si no existe + if err := os.MkdirAll(perfilesPath, 0755); err != nil { + log.Fatalf("❌ Error al crear directorio de perfiles: %v", err) + } + + config := browser.DefaultConfig() + config.ProfilesBaseDir = perfilesPath + config.ProfileName = *profileName + config.StealthFlags.Headless = *headless + config.StealthFlags.WindowSize = [2]int{1280, 720} + + log.Printf("🔍 Buscando: %s", *query) + log.Printf("📊 Máximo de resultados: %d", *maxResults) + log.Printf("👤 Usando perfil: %s", *profileName) + log.Printf("📂 Perfiles en: %s", perfilesPath) + + // Lanzar navegador + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("❌ Error al lanzar navegador: %v", err) + } + defer b.Close() + + // Navegar a DuckDuckGo + searchURL := fmt.Sprintf("https://duckduckgo.com/?q=%s", *query) + log.Println("🌐 Navegando a DuckDuckGo...") + + if err := b.Navigate(ctx, searchURL, nil); err != nil { + log.Fatalf("❌ Error al navegar: %v", err) + } + + // Esperar a que carguen los resultados + time.Sleep(3 * time.Second) + + log.Println("📥 Extrayendo resultados...") + + // Script para extraer resultados + extractScript := fmt.Sprintf(` + (() => { + const results = []; + const maxResults = %d; + + const items = document.querySelectorAll('article[data-testid="result"]'); + + for (let i = 0; i < Math.min(items.length, maxResults); i++) { + const item = items[i]; + + const titleEl = item.querySelector('h2 a'); + const titulo = titleEl ? titleEl.textContent : ''; + const url = titleEl ? titleEl.href : ''; + + const descEl = item.querySelector('[data-result="snippet"]'); + const descripcion = descEl ? descEl.textContent : ''; + + if (titulo && url) { + results.push({ + titulo: titulo.trim(), + url: url, + descripcion: descripcion.trim() + }); + } + } + + return results; + })() + `, *maxResults) + + result, err := b.Evaluate(ctx, extractScript) + if err != nil { + log.Fatalf("❌ Error al extraer resultados: %v", err) + } + + // Parsear resultados + resultadosJSON, err := json.Marshal(result.Value) + if err != nil { + log.Fatalf("❌ Error al parsear resultados: %v", err) + } + + var resultados []Resultado + if err := json.Unmarshal(resultadosJSON, &resultados); err != nil { + log.Fatalf("❌ Error al deserializar: %v", err) + } + + // Mostrar resultados + log.Printf("\n✅ Encontrados %d resultados:\n", len(resultados)) + + for i, r := range resultados { + fmt.Printf("\n%d. %s\n", i+1, r.Titulo) + fmt.Printf(" 🔗 %s\n", r.URL) + if r.Descripcion != "" { + fmt.Printf(" 📝 %s\n", r.Descripcion) + } + } + + // Guardar en JSON si se especificó + if *outputJSON != "" { + data, _ := json.MarshalIndent(resultados, "", " ") + if err := os.WriteFile(*outputJSON, data, 0644); err != nil { + log.Printf("⚠️ Error al guardar JSON: %v", err) + } else { + log.Printf("\n💾 Resultados guardados en: %s", *outputJSON) + } + } + + log.Println("\n✨ Búsqueda completada!") +} diff --git a/cmd/navegar.go b/cmd/navegar.go new file mode 100644 index 0000000..76017ec --- /dev/null +++ b/cmd/navegar.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "navegator/pkg/browser" +) + +func main() { + // Parámetros + url := flag.String("url", "", "URL a visitar (requerido)") + profile := flag.String("profile", "user-default", "Perfil de navegador a usar") + headless := flag.Bool("headless", false, "Modo headless (default: false para ver la navegación)") + duration := flag.Int("duration", 10, "Segundos que mantener abierto el navegador") + click := flag.String("click", "", "Selector CSS para hacer click (opcional)") + type_ := flag.String("type", "", "Selector CSS donde escribir (opcional)") + text := flag.String("text", "", "Texto a escribir (requiere -type)") + + flag.Parse() + + if *url == "" { + fmt.Println("Error: debes proporcionar una URL con -url") + fmt.Println("\nEjemplo básico:") + fmt.Println(" ./navegar -url https://example.com -profile usuario1") + fmt.Println("\nEjemplo con interacción:") + fmt.Println(" ./navegar -url https://example.com -click 'a[href]' -duration 30") + fmt.Println("\nEjemplo con formulario:") + fmt.Println(" ./navegar -url https://httpbin.org/forms/post -type 'input[name=\"custname\"]' -text 'Juan Pérez'") + fmt.Println("\nOpciones:") + flag.PrintDefaults() + os.Exit(1) + } + + ctx := context.Background() + + // Configurar navegador + currentDir, _ := os.Getwd() + profilesDir := filepath.Join(currentDir, "perfiles") + + config := browser.DefaultConfig() + config.ProfilesBaseDir = profilesDir + config.ProfileName = *profile + config.StealthFlags.Headless = *headless + config.StealthFlags.WindowSize = [2]int{1280, 720} + + log.Printf("🌐 Navegando: %s", *url) + log.Printf("👤 Perfil: %s", *profile) + log.Printf("⏱️ Duración: %d segundos", *duration) + + // Lanzar navegador + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("❌ Error: %v", err) + } + defer b.Close() + + // Iniciar recording + recordingFile := filepath.Join(currentDir, fmt.Sprintf("recording_%s.log", *profile)) + if err := b.StartRecording(recordingFile); err != nil { + log.Printf("⚠️ Recording desactivado: %v", err) + } else { + log.Printf("📝 Recording: %s", recordingFile) + } + + b.AddComment(fmt.Sprintf("=== Sesión de %s ===", *profile)) + + // Navegar + opts := browser.DefaultNavigateOptions() + opts.Timeout = 30 * time.Second + + if err := b.Navigate(ctx, *url, opts); err != nil { + log.Printf("⚠️ Advertencia: %v", err) + } else { + log.Println("✅ Página cargada") + } + + time.Sleep(2 * time.Second) + + // Click si se especificó + if *click != "" { + b.AddComment(fmt.Sprintf("Click en: %s", *click)) + log.Printf("🖱️ Haciendo click en: %s", *click) + if err := b.Click(ctx, *click); err != nil { + log.Printf("⚠️ Error al hacer click: %v", err) + } else { + log.Println("✅ Click realizado") + time.Sleep(2 * time.Second) + } + } + + // Type si se especificó + if *type_ != "" && *text != "" { + b.AddComment(fmt.Sprintf("Escribiendo en: %s", *type_)) + log.Printf("⌨️ Escribiendo '%s' en: %s", *text, *type_) + if err := b.Type(ctx, *type_, *text, nil); err != nil { + log.Printf("⚠️ Error al escribir: %v", err) + } else { + log.Println("✅ Texto escrito") + time.Sleep(2 * time.Second) + } + } + + // Obtener información de la página + title, _ := b.Evaluate(ctx, "document.title") + log.Printf("📄 Título: %v", title.Value) + + currentURL, _ := b.Evaluate(ctx, "window.location.href") + log.Printf("🔗 URL actual: %v", currentURL.Value) + + // Mantener navegador abierto + log.Printf("\n⏳ Manteniendo navegador abierto por %d segundos...", *duration) + time.Sleep(time.Duration(*duration) * time.Second) + + b.AddComment("Sesión finalizada") + log.Println("✨ Completado!") +} diff --git a/cmd/screenshot.go b/cmd/screenshot.go new file mode 100644 index 0000000..5b25aee --- /dev/null +++ b/cmd/screenshot.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "navegator/pkg/browser" +) + +func main() { + // Parámetros + url := flag.String("url", "", "URL a capturar (requerido)") + output := flag.String("o", "screenshot.png", "Archivo de salida") + profile := flag.String("profile", "screenshot-bot", "Perfil de navegador a usar") + headless := flag.Bool("headless", true, "Modo headless") + fullPage := flag.Bool("full", false, "Captura página completa") + width := flag.Int("width", 1280, "Ancho de ventana") + height := flag.Int("height", 720, "Alto de ventana") + + flag.Parse() + + if *url == "" { + fmt.Println("Error: debes proporcionar una URL con -url") + fmt.Println("\nEjemplo:") + fmt.Println(" ./screenshot -url https://example.com -o captura.png") + fmt.Println("\nOpciones:") + flag.PrintDefaults() + os.Exit(1) + } + + ctx := context.Background() + + // Configurar navegador + currentDir, _ := os.Getwd() + profilesDir := filepath.Join(currentDir, "perfiles") + + config := browser.DefaultConfig() + config.ProfilesBaseDir = profilesDir + config.ProfileName = *profile + config.StealthFlags.Headless = *headless + config.StealthFlags.WindowSize = [2]int{*width, *height} + + log.Printf("📸 Capturando: %s", *url) + log.Printf("👤 Usando perfil: %s", *profile) + + // Lanzar navegador + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("❌ Error: %v", err) + } + defer b.Close() + + // Navegar + opts := browser.DefaultNavigateOptions() + opts.Timeout = 15 * 1000000000 // 15 segundos + + if err := b.Navigate(ctx, *url, opts); err != nil { + log.Printf("⚠️ Timeout en navegación, pero continuando...") + } + + // Tomar screenshot + log.Println("📷 Capturando pantalla...") + screenshot, err := b.Screenshot(ctx, *fullPage) + if err != nil { + log.Fatalf("❌ Error al capturar: %v", err) + } + + // Guardar + if err := os.WriteFile(*output, screenshot, 0644); err != nil { + log.Fatalf("❌ Error al guardar: %v", err) + } + + log.Printf("✅ Screenshot guardado: %s (%d bytes)", *output, len(screenshot)) +} diff --git a/docs/BINARIOS.md b/docs/BINARIOS.md new file mode 100644 index 0000000..e924093 --- /dev/null +++ b/docs/BINARIOS.md @@ -0,0 +1,396 @@ +# Binarios de Automatización - Navegator + +Herramientas CLI standalone para automatizar navegación web. + +## 🎯 Características Principales + +✅ **Perfiles Personalizables**: Cada binario puede usar cualquier perfil +✅ **Cookies Separadas**: Simula usuarios diferentes sin conflictos +✅ **Sin Dependencias**: Solo el binario ejecutable +✅ **Output Estructurado**: JSON, PNG, logs +✅ **Stealth Completo**: Flags anti-detección incluidas + +--- + +## 📦 Binarios Disponibles + +### 1. `screenshot` - Captura de Pantalla + +Captura screenshots de cualquier página web. + +```bash +# Compilar +go build -o screenshot cmd/screenshot.go + +# Uso básico +./screenshot -url https://example.com + +# Con perfil específico +./screenshot -url https://github.com -profile mi-usuario -o github.png + +# Página completa, modo visible +./screenshot -url https://news.ycombinator.com -full=true -headless=false + +# Resolución personalizada +./screenshot -url https://google.com -width=1920 -height=1080 -o google_hd.png +``` + +**Parámetros:** +- `-url` (requerido): URL a capturar +- `-profile` (default: screenshot-bot): Perfil de navegador +- `-o` (default: screenshot.png): Archivo de salida +- `-headless` (default: true): Modo headless +- `-full` (default: false): Captura página completa +- `-width` (default: 1280): Ancho de ventana +- `-height` (default: 720): Alto de ventana + +--- + +### 2. `buscar` - Motor de Búsqueda + +Busca en DuckDuckGo y extrae resultados estructurados. + +```bash +# Compilar +go build -o buscar cmd/buscar.go + +# Uso básico +./buscar -q "golang tutorial" + +# Con perfil y más resultados +./buscar -q "python web scraping" -n 20 -profile researcher-bot + +# Guardar en JSON +./buscar -q "nodejs frameworks" -output resultados.json + +# Modo visible para debugging +./buscar -q "react hooks" -headless=false -profile dev-session +``` + +**Parámetros:** +- `-q` (requerido): Consulta de búsqueda +- `-profile` (default: search-bot): Perfil de navegador +- `-n` (default: 10): Número máximo de resultados +- `-output` (opcional): Guardar resultados en JSON +- `-headless` (default: true): Modo headless + +**Output JSON:** +```json +[ + { + "titulo": "Tutorial de Golang", + "url": "https://...", + "descripcion": "Aprende Go desde cero..." + } +] +``` + +--- + +### 3. `navegar` - Navegación Interactiva + +Navega a URLs, interactúa con elementos, y registra acciones. + +```bash +# Compilar +go build -o navegar cmd/navegar.go + +# Navegación simple +./navegar -url https://example.com -profile usuario1 + +# Con click en elemento +./navegar -url https://github.com -click "a[href='/explore']" -profile dev1 + +# Llenar formulario +./navegar -url https://httpbin.org/forms/post \ + -type "input[name='custname']" \ + -text "Juan Pérez" \ + -profile test-user + +# Mantener abierto más tiempo +./navegar -url https://reddit.com -duration 30 -headless=false -profile lurker + +# Sesión completa con recording +./navegar -url https://example.com \ + -profile session-abc \ + -click "button.primary" \ + -duration 15 +``` + +**Parámetros:** +- `-url` (requerido): URL a visitar +- `-profile` (default: user-default): Perfil de navegador +- `-click` (opcional): Selector CSS para hacer click +- `-type` (opcional): Selector CSS donde escribir +- `-text` (opcional): Texto a escribir (requiere -type) +- `-headless` (default: false): Modo headless +- `-duration` (default: 10): Segundos que mantener abierto + +**Genera:** `recording_.log` con todas las acciones + +--- + +## 🎭 Simulación de Usuarios Orgánicos + +### Concepto de Perfiles + +Cada perfil es un **usuario virtual independiente**: + +``` +perfiles/ +├── usuario-juan/ # Juan - desarrollador +├── usuario-maria/ # Maria - diseñadora +├── bot-research-1/ # Bot de investigación #1 +├── bot-research-2/ # Bot de investigación #2 +└── session-temp/ # Sesión temporal +``` + +Cada perfil mantiene: +- ✅ Cookies propias +- ✅ LocalStorage separado +- ✅ Historial independiente +- ✅ Cache aislado +- ✅ User-Agent persistente + +### Ejemplo: Múltiples Usuarios + +```bash +# Usuario 1: Busca tutoriales de Go +./buscar -q "golang tutorial" -profile dev-juan -n 10 + +# Usuario 2: Busca Python +./buscar -q "python basics" -profile student-maria -n 15 + +# Usuario 3: Captura diseños +./screenshot -url https://dribbble.com -profile designer-pedro + +# Reutilizar perfil de Juan (tiene sus cookies) +./navegar -url https://github.com -profile dev-juan +``` + +### Script de Demostración + +```bash +./ejemplos_perfiles.sh +``` + +Simula 3 usuarios diferentes navegando automáticamente. + +--- + +## 🔄 Casos de Uso + +### 1. Monitoreo Multi-Cuenta +```bash +# Revisar 5 cuentas diferentes +for i in {1..5}; do + ./navegar -url https://miapp.com/dashboard \ + -profile account-$i \ + -duration 5 +done +``` + +### 2. A/B Testing +```bash +# Probar con diferentes perfiles (cookies diferentes) +./screenshot -url https://miapp.com -profile user-a -o version-a.png +./screenshot -url https://miapp.com -profile user-b -o version-b.png +``` + +### 3. Scraping Distribuido +```bash +# Buscar desde múltiples "usuarios" +./buscar -q "keyword1" -profile bot-1 -output bot1.json & +./buscar -q "keyword2" -profile bot-2 -output bot2.json & +./buscar -q "keyword3" -profile bot-3 -output bot3.json & +wait +``` + +### 4. Testing de Sesiones +```bash +# Login con usuario A +./navegar -url https://app.com/login \ + -type "#username" -text "userA" \ + -profile session-a + +# Verificar que usuario B no tiene acceso +./navegar -url https://app.com/dashboard \ + -profile session-b +``` + +--- + +## 🐍 Integración con Python + +```python +import subprocess +import json + +# Buscar desde Python con perfil específico +result = subprocess.run([ + './buscar', + '-q', 'python tutorial', + '-n', '10', + '-profile', 'python-bot', + '-output', 'temp.json' +], capture_output=True, text=True) + +# Parsear resultados +with open('temp.json') as f: + results = json.load(f) + for r in results: + print(f"{r['titulo']}: {r['url']}") + +# Screenshot con perfil rotativo +profiles = ['user1', 'user2', 'user3'] +for i, profile in enumerate(profiles): + subprocess.run([ + './screenshot', + '-url', 'https://example.com', + '-profile', profile, + '-o', f'capture_{i}.png' + ]) +``` + +--- + +## 🛡️ Stealth y Anti-Detección + +Todos los binarios incluyen automáticamente: + +✅ `navigator.webdriver = false` +✅ Sin banners de "controlado por automatización" +✅ Headers realistas +✅ Timing humano en Type +✅ User-Agent personalizable +✅ Sin extensiones sospechosas + +Para máximo stealth: +```bash +# Usar modo visible (menos detectable) +./navegar -url https://sitio-estricto.com -headless=false -profile real-user + +# Mantener sesión larga (más orgánico) +./navegar -url https://ejemplo.com -duration 60 -profile organic-session +``` + +--- + +## 📝 Logs y Debugging + +Cada binario genera logs: + +```bash +# buscar y navegar generan logs automáticos +./navegar -url https://example.com -profile test1 +# Crea: recording_test1.log + +# Ver log +cat recording_test1.log +``` + +Formato del log: +```json +{"timestamp":"...","type":"Navigate","params":{"url":"..."}} +# 22:49:11 - Navigate: https://example.com + +{"timestamp":"...","type":"Click","params":{"selector":"button"}} +# 22:49:12 - Click: button +``` + +--- + +## 🚀 Performance + +**Headless vs Visible:** +- Headless: Más rápido, menos memoria +- Visible: Más sigiloso, debugging más fácil + +**Perfiles:** +- Primer uso: ~2-3 segundos (crea perfil) +- Usos siguientes: ~1 segundo (reutiliza) + +**Limitar perfiles:** +```bash +# Limpiar perfiles viejos +rm -rf perfiles/temp-* +rm -rf perfiles/bot-old-* +``` + +--- + +## 💡 Tips + +1. **Nombres descriptivos de perfiles:** + ```bash + ./buscar -q "query" -profile "research-$(date +%Y%m%d)" + ``` + +2. **Rotación automática:** + ```bash + PROFILE="user-$RANDOM" + ./screenshot -url https://example.com -profile "$PROFILE" + ``` + +3. **Perfiles temporales:** + ```bash + ./navegar -url https://test.com -profile "temp-$$" + rm -rf perfiles/temp-* # Limpiar después + ``` + +4. **Compartir perfil entre binarios:** + ```bash + # Misma sesión, diferentes tools + ./navegar -url https://github.com -profile dev-session + ./screenshot -url https://github.com/trending -profile dev-session + # Ambos comparten las mismas cookies! + ``` + +--- + +## 🔧 Compilar Todos + +```bash +# Compilar todos los binarios +go build -o screenshot cmd/screenshot.go +go build -o buscar cmd/buscar.go +go build -o navegar cmd/navegar.go + +# O con un script +for cmd in cmd/*.go; do + name=$(basename "$cmd" .go) + go build -o "$name" "$cmd" + echo "✅ $name compilado" +done +``` + +--- + +## 📚 Crear Tus Propios Binarios + +Usa el patrón de `cmd/*.go`: + +```go +package main + +import ( + "flag" + "navegator/pkg/browser" +) + +func main() { + url := flag.String("url", "", "URL") + profile := flag.String("profile", "mi-bot", "Perfil") + flag.Parse() + + config := browser.DefaultConfig() + config.ProfileName = *profile + // ... tu lógica +} +``` + +Ventajas: +- ✅ Cada binario es independiente +- ✅ Fácil de distribuir +- ✅ Parámetros CLI estándar +- ✅ Perfiles automáticos diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..c5e82ae --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,199 @@ +# Navegator - Índice de Documentación + +Guía completa de toda la documentación disponible. + +--- + +## 📚 Documentación Principal + +### [README.md](../README.md) +Introducción al proyecto, instalación rápida y uso básico. + +**Cuándo leer:** Siempre al iniciar con el proyecto. + +--- + +## 🎯 Guías de Uso + +### [BINARIOS.md](BINARIOS.md) +Guía completa de los binarios CLI (screenshot, buscar, navegar). + +**Temas:** +- Compilar binarios +- Parámetros de cada binario +- Ejemplos de uso +- Integración con Python +- Casos de uso reales + +**Cuándo leer:** Para usar los binarios standalone. + +### [PERFILES_AVANZADO.md](PERFILES_AVANZADO.md) +Gestión avanzada de perfiles de navegador. + +**Temas:** +- Compartir perfiles entre proyectos +- Usar mismo perfil en paralelo (clonación) +- Perfiles persistentes vs temporales +- Sincronización entre máquinas +- Casos de uso: scraping multi-cuenta, A/B testing + +**Cuándo leer:** Cuando necesites: +- Mover binarios a otro repo +- Ejecutar múltiples instancias simultáneas +- Simular usuarios diferentes + +--- + +## 🔧 Guías Técnicas + +### [STEALTH_FLAGS.md](STEALTH_FLAGS.md) +Documentación completa de flags de Chrome para anti-detección. + +**Temas:** +- Flags críticas (SIEMPRE activadas) +- Flags opcionales por contexto +- JavaScript anti-detección +- Configuración recomendada +- Referencias y recursos + +**Cuándo leer:** Para entender o personalizar las flags stealth. + +### [TESTING.md](TESTING.md) +Sistema completo de testing E2E y unitario. + +**Temas:** +- Tests unitarios (Go) +- Tests E2E (binarios) +- Tests de integración +- CI/CD automático +- Debugging tests fallidos +- Escribir nuevos tests + +**Cuándo leer:** Para verificar que los binarios funcionan correctamente. + +--- + +## 🚀 Quick Start por Caso de Uso + +### Quiero automatizar capturas de pantalla +1. Leer: [BINARIOS.md](BINARIOS.md) → Sección "screenshot" +2. Compilar: `make build` +3. Usar: `./bin/screenshot -url https://example.com -o captura.png` + +### Quiero hacer web scraping con múltiples cuentas +1. Leer: [PERFILES_AVANZADO.md](PERFILES_AVANZADO.md) → "Scraping Multi-Cuenta" +2. Usar: `scripts/clonar_perfil.sh` para duplicar perfiles +3. Ejecutar en paralelo con perfiles diferentes + +### Quiero integrar con Python/otros lenguajes +1. Leer: [BINARIOS.md](BINARIOS.md) → "Integración con Python" +2. Compilar binarios: `make build` +3. Llamar desde subprocess + +### Quiero evitar detección de bots +1. Leer: [STEALTH_FLAGS.md](STEALTH_FLAGS.md) +2. Revisar flags activas por defecto +3. Personalizar según necesidad en `pkg/stealth/flags.go` + +### Quiero testear cambios antes de deploy +1. Leer: [TESTING.md](TESTING.md) +2. Ejecutar: `make test` +3. Verificar que pasa antes de commit + +--- + +## 📖 Orden de Lectura Recomendado + +### Principiante +1. README.md (introducción) +2. BINARIOS.md (usar binarios) +3. PERFILES_AVANZADO.md (entender perfiles) + +### Intermedio +4. STEALTH_FLAGS.md (personalizar detección) +5. TESTING.md (verificar funcionamiento) + +### Avanzado +6. Código fuente en `pkg/` (extender funcionalidad) +7. Scripts en `scripts/` (automatizar tareas) + +--- + +## 🔍 Buscar por Tema + +### Perfiles +- Crear perfil: [BINARIOS.md](BINARIOS.md#perfiles-personalizados) +- Compartir entre proyectos: [PERFILES_AVANZADO.md](PERFILES_AVANZADO.md#problema-1-mover-binarios) +- Usar en paralelo: [PERFILES_AVANZADO.md](PERFILES_AVANZADO.md#problema-2-mismo-perfil-en-paralelo) + +### Testing +- Ejecutar tests: [TESTING.md](TESTING.md#quick-start) +- Tests unitarios: [TESTING.md](TESTING.md#tests-unitarios-detallados) +- Tests E2E: [TESTING.md](TESTING.md#tests-e2e-detallados) +- CI/CD: [TESTING.md](TESTING.md#cicd-automático) + +### Stealth +- Flags básicas: [STEALTH_FLAGS.md](STEALTH_FLAGS.md#flags-críticas) +- JavaScript anti-detección: [STEALTH_FLAGS.md](STEALTH_FLAGS.md#javascript-injection-anti-detección) +- Configuración por contexto: [STEALTH_FLAGS.md](STEALTH_FLAGS.md#flags-para-contextos-específicos) + +### Binarios +- Compilar: [BINARIOS.md](BINARIOS.md#compilar-todos) +- screenshot: [BINARIOS.md](BINARIOS.md#1-screenshot) +- buscar: [BINARIOS.md](BINARIOS.md#2-buscar) +- navegar: [BINARIOS.md](BINARIOS.md#3-navegar) + +--- + +## 🆘 Troubleshooting + +### Chrome no se inicia +Ver: [TESTING.md](TESTING.md#chrome-crashed-o-cant-find-chrome) + +### Tests fallan +Ver: [TESTING.md](TESTING.md#debugging-tests-fallidos) + +### Perfil bloqueado +Ver: [PERFILES_AVANZADO.md](PERFILES_AVANZADO.md#-no-funciona-directamente) + +### Binario no encuentra perfiles +Ver: [PERFILES_AVANZADO.md](PERFILES_AVANZADO.md#-problema) + +--- + +## 📝 Contribuir + +Si encuentras errores o quieres mejorar la documentación: + +1. Documentación está en `docs/` +2. Ejemplos están en `examples/` +3. Tests están en `e2e/` + +--- + +## 🔗 Enlaces Útiles + +- **Chrome DevTools Protocol**: https://chromedevtools.github.io/devtools-protocol/ +- **Chrome Flags**: https://peter.sh/experiments/chromium-command-line-switches/ +- **Go Testing**: https://go.dev/doc/tutorial/add-a-test + +--- + +## ✅ Checklist Rápido + +Antes de usar Navegator: +- [ ] Leer README.md +- [ ] Instalar Chrome/Chromium +- [ ] Compilar binarios: `make build` +- [ ] Ejecutar tests: `make test-quick` + +Antes de usar en producción: +- [ ] Leer STEALTH_FLAGS.md +- [ ] Configurar perfiles persistentes +- [ ] Ejecutar `make test` +- [ ] Verificar en bot detection sites + +Antes de hacer commit: +- [ ] `make fmt` +- [ ] `make lint` +- [ ] `make test` diff --git a/docs/PERFILES_AVANZADO.md b/docs/PERFILES_AVANZADO.md new file mode 100644 index 0000000..ce0cbe6 --- /dev/null +++ b/docs/PERFILES_AVANZADO.md @@ -0,0 +1,498 @@ +# Gestión Avanzada de Perfiles + +Guía completa para mover binarios entre proyectos y usar perfiles en paralelo. + +--- + +## 🗂️ Problema 1: Mover Binarios a Otro Repo + +### ❌ Problema + +```bash +proyecto1/ +├── buscar +└── perfiles/ + └── mi-usuario/ + └── cookies.db + +# Copias binario a proyecto2 +proyecto2/ +├── buscar # ❌ Busca perfiles en ./perfiles/ (no existe) +``` + +### ✅ Soluciones + +#### **Opción A: Usar carpeta compartida en HOME** (RECOMENDADA) + +**Ventajas:** +- ✅ Perfiles accesibles desde cualquier proyecto +- ✅ No duplicar datos +- ✅ Mantiene cookies entre proyectos + +**Ubicación por defecto:** +``` +~/.navegator/profiles/ +├── usuario1/ +├── bot-research/ +└── dev-session/ +``` + +**Uso:** +```bash +# En cualquier proyecto, funciona automáticamente +cd ~/proyecto1 +./buscar -q "golang" -profile usuario1 + +cd ~/proyecto2 +./buscar -q "python" -profile usuario1 +# ↑ Usa el MISMO perfil con cookies compartidas +``` + +**Si compilaste con la nueva versión (`buscar_v2.go`):** +```bash +go build -o buscar cmd/buscar_v2.go + +# Default: ~/.navegator/profiles +./buscar -q "golang" -profile mi-usuario + +# Custom: especificar ruta +./buscar -q "golang" -profile mi-usuario -profiles-dir /ruta/custom +``` + +#### **Opción B: Variable de entorno** + +Agregar a `.bashrc` o `.zshrc`: + +```bash +export NAVEGATOR_PROFILES="$HOME/shared-profiles" +``` + +Modificar binarios para leer: +```go +profilesDir := os.Getenv("NAVEGATOR_PROFILES") +if profilesDir == "" { + profilesDir = filepath.Join(homeDir, ".navegator", "profiles") +} +``` + +#### **Opción C: Copiar carpeta de perfiles** + +```bash +# Copiar perfiles entre proyectos +cp -r ~/proyecto1/perfiles ~/proyecto2/ + +# Sincronizar cambios +rsync -av ~/proyecto1/perfiles/ ~/proyecto2/perfiles/ +``` + +#### **Opción D: Symlink** + +```bash +# Crear carpeta compartida +mkdir -p ~/shared-navegator-profiles + +# En cada proyecto +cd ~/proyecto1 +ln -s ~/shared-navegator-profiles perfiles + +cd ~/proyecto2 +ln -s ~/shared-navegator-profiles perfiles + +# Ambos apuntan al mismo directorio físico +``` + +--- + +## 🔀 Problema 2: Mismo Perfil en Paralelo + +### ❌ No Funciona Directamente + +Chrome bloquea múltiples instancias del mismo perfil: + +```bash +# Terminal 1 +./buscar -q "golang" -profile usuario1 # ✅ OK + +# Terminal 2 (simultáneamente) +./buscar -q "python" -profile usuario1 # ❌ ERROR +# Chrome error: Profile is already in use +``` + +**Archivos de lock:** +``` +perfiles/usuario1/ +├── SingletonLock # Bloquea acceso múltiple +├── SingletonSocket # Socket de comunicación +└── SingletonCookie # Cookie de instancia +``` + +### ✅ Soluciones + +#### **Solución 1: Clonar perfiles antes de usar** (RECOMENDADA) + +**Script automático:** +```bash +./scripts/clonar_perfil.sh usuario-base usuario-clon-1 +./scripts/clonar_perfil.sh usuario-base usuario-clon-2 +./scripts/clonar_perfil.sh usuario-base usuario-clon-3 + +# Ahora usar en paralelo +./buscar -q "query1" -profile usuario-clon-1 & +./buscar -q "query2" -profile usuario-clon-2 & +./buscar -q "query3" -profile usuario-clon-3 & +wait +``` + +**¿Qué hace el script?** +1. Copia el perfil completo (cookies, cache, historial) +2. Elimina archivos de lock +3. Crea perfil independiente listo para usar + +**Ejemplo completo:** +```bash +# 1. Crear perfil base con login/cookies +./navegar -url https://github.com -profile github-base +# ... hacer login manualmente ... + +# 2. Clonar para uso paralelo +for i in {1..5}; do + ./scripts/clonar_perfil.sh github-base github-worker-$i +done + +# 3. Usar en paralelo (todos con la misma sesión) +for i in {1..5}; do + ./buscar -q "topic-$i" -profile github-worker-$i & +done +wait +``` + +#### **Solución 2: Usar perfiles diferentes** + +Diseñar desde el inicio con múltiples perfiles: + +```bash +# Crear perfiles específicos +./navegar -url https://app.com -profile worker-1 +./navegar -url https://app.com -profile worker-2 +./navegar -url https://app.com -profile worker-3 + +# Usar en paralelo +./buscar -q "query1" -profile worker-1 & +./buscar -q "query2" -profile worker-2 & +./buscar -q "query3" -profile worker-3 & +``` + +#### **Solución 3: Pool de perfiles rotativos** + +```bash +#!/bin/bash +# pool_buscar.sh + +PROFILES=("bot-1" "bot-2" "bot-3" "bot-4" "bot-5") +QUERIES=("golang" "python" "rust" "javascript" "java") + +for i in "${!QUERIES[@]}"; do + PROFILE="${PROFILES[$i]}" + QUERY="${QUERIES[$i]}" + + echo "🔍 Buscando '$QUERY' con perfil $PROFILE" + ./buscar -q "$QUERY" -profile "$PROFILE" -output "result_$i.json" & +done + +wait +echo "✅ Todas las búsquedas completadas" +``` + +--- + +## 📊 Casos de Uso Reales + +### **Caso 1: Scraping Multi-Cuenta** + +Necesitas hacer scraping con 10 cuentas diferentes simultáneamente. + +```bash +# Preparación (una vez) +for i in {1..10}; do + ./navegar -url https://sitio.com/login \ + -profile account-$i \ + -type "#username" -text "user$i" \ + -duration 5 + # Hacer login manualmente si es necesario +done + +# Uso (todas a la vez) +for i in {1..10}; do + ./navegar -url https://sitio.com/dashboard \ + -profile account-$i \ + -duration 10 & +done +wait +``` + +### **Caso 2: A/B Testing con Misma Sesión** + +Probar variantes con cookies idénticas: + +```bash +# Crear perfil base +./navegar -url https://app.com -profile base-session +# ... configurar cookies/preferencias ... + +# Clonar para cada variante +./scripts/clonar_perfil.sh base-session variant-a +./scripts/clonar_perfil.sh base-session variant-b + +# Probar en paralelo +./screenshot -url https://app.com?variant=a -profile variant-a -o test-a.png & +./screenshot -url https://app.com?variant=b -profile variant-b -o test-b.png & +wait + +# Comparar resultados +compare test-a.png test-b.png diff.png +``` + +### **Caso 3: Búsqueda Distribuida** + +Buscar múltiples keywords sin rate limiting: + +```bash +# Crear pool de 20 perfiles +for i in {1..20}; do + ./scripts/clonar_perfil.sh base-search search-worker-$i +done + +# Buscar 100 keywords en paralelo (lotes de 20) +keywords=("keyword1" "keyword2" ... "keyword100") + +for i in "${!keywords[@]}"; do + profile_idx=$((i % 20 + 1)) + ./buscar -q "${keywords[$i]}" \ + -profile "search-worker-$profile_idx" \ + -output "result_$i.json" & + + # Cada 20 búsquedas, esperar + if [ $((i % 20)) -eq 19 ]; then + wait + fi +done +``` + +### **Caso 4: Proyectos Diferentes, Mismo Perfil** + +```bash +# Proyecto 1: Análisis de datos +cd ~/proyecto-scraper +./buscar -q "data science" -profile researcher-123 + +# Proyecto 2: Generación de reportes +cd ~/proyecto-reportes +./screenshot -url https://dashboard.com -profile researcher-123 +# ↑ Usa las mismas cookies del perfil! + +# Proyecto 3: Monitoreo +cd ~/proyecto-monitor +./navegar -url https://app.com/status -profile researcher-123 +``` + +**Resultado:** El perfil `researcher-123` se comparte entre los 3 proyectos sin duplicar datos. + +--- + +## 🛠️ Herramientas Útiles + +### **Ver perfiles activos** + +```bash +#!/bin/bash +# ver_perfiles_activos.sh + +echo "🔍 Perfiles actualmente en uso:" +echo "" + +PROFILES_DIR="$HOME/.navegator/profiles" + +for profile in "$PROFILES_DIR"/*; do + if [ -d "$profile" ]; then + name=$(basename "$profile") + + if [ -f "$profile/SingletonLock" ]; then + pid=$(cat "$profile/SingletonLock" 2>/dev/null | grep -oP '\d+') + echo "🟢 $name (PID: $pid)" + else + echo "⚪ $name (libre)" + fi + fi +done +``` + +### **Limpiar perfiles no usados** + +```bash +#!/bin/bash +# limpiar_perfiles_viejos.sh + +PROFILES_DIR="$HOME/.navegator/profiles" +DAYS=30 + +echo "🗑️ Eliminando perfiles sin usar en $DAYS días..." + +find "$PROFILES_DIR" -maxdepth 1 -type d -mtime +$DAYS | while read profile; do + if [ "$profile" != "$PROFILES_DIR" ]; then + name=$(basename "$profile") + echo " Eliminando: $name" + rm -rf "$profile" + fi +done + +echo "✅ Limpieza completada" +``` + +### **Sincronizar perfiles entre máquinas** + +```bash +#!/bin/bash +# sync_perfiles.sh + +REMOTE_HOST="servidor.com" +REMOTE_USER="usuario" +LOCAL_DIR="$HOME/.navegator/profiles" +REMOTE_DIR="/home/$REMOTE_USER/.navegator/profiles" + +echo "📤 Subiendo perfiles al servidor..." +rsync -avz --progress "$LOCAL_DIR/" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" + +echo "✅ Sincronización completada" +``` + +--- + +## 📝 Mejores Prácticas + +### **1. Nomenclatura de Perfiles** + +```bash +# ❌ Mal +perfil1, perfil2, test, abc + +# ✅ Bien +github-devuser-1 +scraper-bot-2024-01 +researcher-project-alpha +``` + +### **2. Perfiles Base + Clones** + +```bash +# Crear perfil base con configuración +base-authenticated/ + +# Clonar para uso paralelo +base-authenticated-clone-1/ +base-authenticated-clone-2/ +base-authenticated-clone-3/ +``` + +### **3. Limpieza Regular** + +```bash +# Eliminar clones temporales después de usar +rm -rf perfiles/*-clone-* +rm -rf perfiles/temp-* + +# Mantener solo perfiles base +``` + +### **4. Backup de Perfiles Importantes** + +```bash +# Backup +tar -czf perfiles-backup-$(date +%Y%m%d).tar.gz perfiles/ + +# Restaurar +tar -xzf perfiles-backup-20260324.tar.gz +``` + +--- + +## ⚡ Performance + +### **Perfiles ligeros vs pesados** + +```bash +# Perfil nuevo (primera vez): 2-3 segundos +./buscar -q "query" -profile nuevo-perfil + +# Perfil existente: 1 segundo +./buscar -q "query" -profile perfil-usado + +# Perfil con mucho cache: 1-2 segundos +``` + +### **Límites prácticos** + +- ✅ **5-10 perfiles en paralelo**: Funciona bien +- ⚠️ **20-30 perfiles en paralelo**: Depende del CPU/RAM +- ❌ **50+ perfiles en paralelo**: Puede saturar sistema + +### **Optimización** + +```bash +# Ejecutar en lotes +for i in {1..100}; do + ./buscar -q "query$i" -profile "bot-$((i % 10))" & + + # Cada 10, esperar + if [ $((i % 10)) -eq 9 ]; then + wait + fi +done +``` + +--- + +## 🔒 Seguridad + +### **Perfiles sensibles** + +Si tus perfiles tienen sesiones autenticadas: + +```bash +# Proteger perfiles +chmod 700 ~/.navegator/profiles/ +chmod 600 ~/.navegator/profiles/*/cookies* + +# Encriptar backup +tar -czf - perfiles/ | gpg -c > perfiles-encrypted.tar.gz.gpg +``` + +### **No compartir perfiles con credenciales** + +```bash +# ❌ NO hacer +git add perfiles/ +git commit -m "Added profiles" # Expondrías cookies/tokens + +# ✅ Hacer +echo "perfiles/" >> .gitignore +``` + +--- + +## 📚 Resumen + +| Escenario | Solución | +|-----------|----------| +| Mover binario a otro repo | Usar `~/.navegator/profiles` (compartido) | +| Mismo perfil en paralelo | Clonar con `clonar_perfil.sh` | +| Múltiples proyectos | Flag `-profiles-dir` o symlink | +| Scraping masivo | Pool de perfiles + rotación | +| A/B testing | Clonar perfil base para cada variante | +| Sincronizar entre máquinas | `rsync` de `~/.navegator/profiles` | + +**Comando más útil:** +```bash +./scripts/clonar_perfil.sh base-perfil clon-1 +``` + +Esto te permite usar el mismo perfil (cookies, sesión) en múltiples instancias simultáneas. diff --git a/docs/STEALTH_FLAGS.md b/docs/STEALTH_FLAGS.md new file mode 100644 index 0000000..e17e48e --- /dev/null +++ b/docs/STEALTH_FLAGS.md @@ -0,0 +1,324 @@ +# Chrome Stealth Flags - Documentación Completa + +Esta documentación lista todas las flags necesarias para ejecutar Chrome/Chromium con la menor detección posible de automatización. + +## Flags Críticas (Siempre Activadas) + +### 1. Desactivar Detección de Automatización + +```go +"--disable-blink-features=AutomationControlled" +``` +**Propósito**: Elimina `navigator.webdriver = true` que es el indicador más obvio de automatización. +**Impacto**: CRÍTICO - Sin esto, casi cualquier sitio detectará la automatización. + +```go +"--exclude-switches=enable-automation" +``` +**Propósito**: Evita que Chrome agregue el flag `--enable-automation` automáticamente. +**Impacto**: ALTO - Complementa la desactivación de AutomationControlled. + +### 2. Gestión de Perfiles y User Data + +```go +"--user-data-dir=/path/to/profile" +``` +**Propósito**: Especifica dónde Chrome almacena cookies, historial, extensiones, etc. +**Impacto**: CRÍTICO - Permite persistencia de sesión y reutilización de perfiles. +**Nota**: Debe ser ruta absoluta única por instancia. + +```go +"--profile-directory=Default" +``` +**Propósito**: Nombre del perfil dentro de user-data-dir. +**Impacto**: MEDIO - Permite múltiples perfiles en el mismo user-data-dir. + +### 3. Modo Sin Interfaz Gráfica + +```go +"--headless=new" +``` +**Propósito**: Ejecuta Chrome sin ventana visible (nuevo modo headless estable). +**Impacto**: ALTO - Mejor rendimiento, pero puede ser detectado. +**Alternativa**: Omitir para modo con interfaz visible (más sigiloso pero usa más recursos). + +```go +"--disable-gpu" +``` +**Propósito**: Desactiva aceleración por GPU (necesario en algunos entornos headless). +**Impacto**: MEDIO - Evita crashes en servidores sin GPU. + +### 4. Configuración de Ventana + +```go +"--window-size=1920,1080" +``` +**Propósito**: Define tamaño de viewport. +**Impacto**: MEDIO - Sitios pueden detectar tamaños anormales. +**Recomendación**: Usar resoluciones comunes (1920x1080, 1366x768, 1440x900). + +```go +"--start-maximized" +``` +**Propósito**: Inicia ventana maximizada (solo modo no-headless). +**Impacto**: BAJO - Apariencia más natural en modo visible. + +## Flags de Evasión Avanzada + +### 5. User Agent y Detección de Plataforma + +```go +"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +``` +**Propósito**: Sobrescribe el user agent del navegador. +**Impacto**: ALTO - Debe coincidir con la plataforma y versión de Chrome real. +**Nota**: Actualizar según versión de Chrome instalada. + +### 6. Permisos y Notificaciones + +```go +"--disable-notifications" +``` +**Propósito**: Bloquea solicitudes de notificaciones del navegador. +**Impacto**: BAJO - Evita interrupciones molestas. + +```go +"--disable-popup-blocking" +``` +**Propósito**: Permite abrir popups sin bloqueo. +**Impacto**: BAJO - Útil para algunos flujos de autenticación. + +### 7. Seguridad y Privacidad + +```go +"--disable-web-security" +``` +**Propósito**: Desactiva CORS y otras políticas de seguridad. +**Impacto**: MEDIO - Útil para testing, pero inseguro. +**⚠️ COMENTAR POR DEFECTO** - Solo activar si es necesario. + +```go +"--disable-features=IsolateOrigins,site-per-process" +``` +**Propósito**: Desactiva aislamiento de procesos por sitio. +**Impacto**: BAJO - Reduce consumo de memoria. +**⚠️ COMENTAR POR DEFECTO** - Puede afectar estabilidad. + +```go +"--disable-site-isolation-trials" +``` +**Propósito**: Desactiva experimentos de aislamiento de sitios. +**Impacto**: BAJO - Complementa flags anteriores. + +### 8. Optimización de Rendimiento + +```go +"--disable-dev-shm-usage" +``` +**Propósito**: Evita uso de /dev/shm en Docker/containers. +**Impacto**: MEDIO - Crítico en entornos containerizados. + +```go +"--no-sandbox" +``` +**Propósito**: Desactiva sandbox de Chrome. +**Impacto**: ALTO - **PELIGROSO** - Solo usar en entornos confiables (Docker, VMs). +**⚠️ COMENTAR POR DEFECTO** - Riesgo de seguridad. + +```go +"--disable-setuid-sandbox" +``` +**Propósito**: Desactiva sandbox SUID. +**Impacto**: MEDIO - Similar a --no-sandbox. +**⚠️ COMENTAR POR DEFECTO** - Usar solo si --no-sandbox está activo. + +### 9. Extensions y Plugins + +```go +"--disable-extensions" +``` +**Propósito**: Desactiva todas las extensiones de Chrome. +**Impacto**: BAJO - Reduce superficie de detección. + +```go +"--disable-plugins" +``` +**Propósito**: Desactiva plugins (Flash, PDF viewer, etc). +**Impacto**: BAJO - Mejora rendimiento. + +### 10. Logs y Debugging + +```go +"--enable-logging" +``` +**Propósito**: Activa logs de Chrome. +**Impacto**: BAJO - Útil para debugging. +**⚠️ COMENTAR EN PRODUCCIÓN** + +```go +"--v=1" +``` +**Propósito**: Nivel de verbosidad de logs (0-3). +**Impacto**: BAJO - Combinar con --enable-logging. + +```go +"--log-level=0" +``` +**Propósito**: Nivel de log (0=INFO, 1=WARNING, 2=ERROR). +**Impacto**: BAJO - Control fino de logs. + +### 11. Características Especiales + +```go +"--disable-background-timer-throttling" +``` +**Propósito**: Evita throttling de timers en background. +**Impacto**: BAJO - Útil para scrapers que esperan en background. + +```go +"--disable-backgrounding-occluded-windows" +``` +**Propósito**: Evita suspensión de ventanas ocultas. +**Impacto**: BAJO - Mantiene páginas activas aunque no sean visibles. + +```go +"--disable-renderer-backgrounding" +``` +**Propósito**: Evita que el renderer entre en modo background. +**Impacto**: BAJO - Mejora consistencia en ejecución. + +```go +"--disable-ipc-flooding-protection" +``` +**Propósito**: Desactiva protección contra flooding de IPC. +**Impacto**: BAJO - Útil cuando se envían muchos comandos CDP rápidamente. + +### 12. Features de Chrome a Desactivar + +```go +"--disable-features=TranslateUI" +``` +**Propósito**: Desactiva ofertas de traducción automática. +**Impacto**: BAJO - Menos interrupciones. + +```go +"--disable-features=PrivacySandboxSettings4" +``` +**Propósito**: Desactiva configuración de Privacy Sandbox. +**Impacto**: BAJO - Reduce telemetría. + +## Flags para Contextos Específicos + +### Docker/Containers +```go +"--no-sandbox" +"--disable-setuid-sandbox" +"--disable-dev-shm-usage" +``` + +### Headless Máximo Sigilo +```go +"--headless=new" +"--disable-gpu" +"--hide-scrollbars" +"--mute-audio" +``` + +### Debugging +```go +"--enable-logging" +"--v=1" +"--remote-debugging-port=0" // Puerto aleatorio, CDP asignará uno +``` + +## Configuración Recomendada por Defecto + +```go +var DefaultStealthFlags = []string{ + // CRÍTICAS - Siempre activadas + "--disable-blink-features=AutomationControlled", + "--exclude-switches=enable-automation", + + // Headless moderno + "--headless=new", + "--disable-gpu", + + // Ventana + "--window-size=1920,1080", + + // Optimización + "--disable-dev-shm-usage", + "--disable-extensions", + + // Estabilidad + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + + // Menos ruido + "--disable-notifications", + "--disable-features=TranslateUI", + + // COMENTADAS - Activar según necesidad: + // "--no-sandbox", // Solo Docker/confiable + // "--disable-web-security", // Solo para testing + // "--enable-logging", // Solo debugging +} +``` + +## JavaScript Injection Anti-Detección + +Además de las flags, inyectar este script en cada página: + +```javascript +// Sobrescribir propiedades que delatan automatización +Object.defineProperty(navigator, 'webdriver', { + get: () => undefined +}); + +// Eliminar _selenium, _webdriver, callSelenium +delete window.navigator.__proto__.webdriver; + +// Chrome runtime mock +window.chrome = { + runtime: {}, + loadTimes: function() {}, + csi: function() {}, + app: {} +}; + +// Permisos mock +const originalQuery = window.navigator.permissions.query; +window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: Notification.permission }) : + originalQuery(parameters) +); + +// Plugin array fix +Object.defineProperty(navigator, 'plugins', { + get: () => [1, 2, 3, 4, 5] +}); + +// Languages fix +Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'] +}); +``` + +## Orden de Prioridad + +1. **CRÍTICO**: `--disable-blink-features=AutomationControlled` +2. **CRÍTICO**: `--exclude-switches=enable-automation` +3. **CRÍTICO**: `--user-data-dir` (perfiles persistentes) +4. **ALTO**: `--headless=new` (o omitir para modo visible) +5. **ALTO**: User-Agent correcto +6. **MEDIO**: Window size realista +7. **MEDIO**: JavaScript injection anti-detección +8. **BAJO**: Resto de flags según contexto + +## Referencias + +- [Chrome Command Line Switches](https://peter.sh/experiments/chromium-command-line-switches/) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) +- [Puppeteer Extra Stealth Plugin](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth) diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..ada4f6f --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,557 @@ +# Testing Guide - Navegator + +Sistema completo de testing E2E para validar que los binarios funcionan correctamente. + +--- + +## 🎯 Tipos de Tests + +### 1. **Tests Unitarios** (Go) +Tests de funciones individuales en Go. + +```bash +# Ejecutar todos los tests unitarios +make test-unit + +# O directamente con go test +go test -v ./pkg/browser/... +go test -v ./pkg/cdp/... +go test -v ./pkg/stealth/... +``` + +**Cobertura:** +- ✅ Launch browser +- ✅ Navigate +- ✅ Screenshot +- ✅ JavaScript evaluation +- ✅ Stealth flags +- ✅ Recorder +- ✅ Profile persistence + +### 2. **Tests E2E** (Bash) +Tests de binarios compilados end-to-end. + +```bash +# Ejecutar tests E2E +make test-e2e + +# O directamente +./test/e2e_test.sh +``` + +**Cobertura:** +- ✅ Screenshot básico y con opciones +- ✅ Búsqueda (con timeout esperado) +- ✅ Navegación y recording +- ✅ Perfiles personalizados +- ✅ Persistencia de perfiles +- ✅ Error handling + +### 3. **Tests de Integración** (Bash) +Tests de integración entre componentes. + +```bash +# Ejecutar tests de integración +make test-integration + +# O directamente +./test/integration_test.sh +``` + +**Cobertura:** +- ✅ Compartir perfiles entre binarios +- ✅ Recording de múltiples acciones +- ✅ Perfiles en paralelo (clonados) +- ✅ Output JSON válido + +--- + +## 🚀 Quick Start + +### Ejecutar todos los tests +```bash +make test +``` + +### Tests rápidos (solo unitarios) +```bash +make test-quick +``` + +### Solo E2E +```bash +make build +make test-e2e +``` + +--- + +## 📊 Estructura de Tests + +``` +navegator/ +├── pkg/ +│ └── browser/ +│ └── browser_test.go # Tests unitarios +├── test/ +│ ├── e2e_test.sh # Tests E2E de binarios +│ └── integration_test.sh # Tests de integración +├── Makefile # Comandos de testing +└── .github/ + └── workflows/ + └── test.yml # CI/CD automático +``` + +--- + +## 🧪 Tests Unitarios Detallados + +### TestLaunchBrowser +Verifica que el navegador se lance correctamente. + +```go +func TestLaunchBrowser(t *testing.T) { + // Lanza Chrome con perfil temporal + // Verifica: perfil creado, debug URL, target ID +} +``` + +### TestNavigate +Verifica navegación a URLs. + +```go +func TestNavigate(t *testing.T) { + // Navega a example.com + // Verifica URL correcta via JavaScript +} +``` + +### TestScreenshot +Verifica capturas de pantalla. + +```go +func TestScreenshot(t *testing.T) { + // Toma screenshot + // Verifica: PNG válido, tamaño > 0 +} +``` + +### TestStealthFlags +Verifica flags anti-detección. + +```go +func TestStealthFlags(t *testing.T) { + // Evalúa navigator.webdriver + // Verifica: false o undefined + // Verifica: window.chrome existe +} +``` + +--- + +## 🎯 Tests E2E Detallados + +### Suite: screenshot + +**Test 1: Captura básica** +```bash +./screenshot -url https://example.com -o test.png +# Verifica: PNG válido, archivo existe +``` + +**Test 2: Perfil personalizado** +```bash +./screenshot -profile custom-123 -url https://example.com +# Verifica: perfil creado en disco +``` + +**Test 3: Dimensiones custom** +```bash +./screenshot -width=800 -height=600 -url https://example.com +# Verifica: screenshot generado +``` + +### Suite: buscar + +**Test 4: Búsqueda básica** +```bash +./buscar -q "test" -n 3 -output results.json +# Verifica: no crash (puede tener timeout de red) +``` + +**Test 5: Perfil personalizado** +```bash +./buscar -profile test-search -q "query" +# Verifica: perfil creado +``` + +### Suite: navegar + +**Test 6: Navegación básica** +```bash +./navegar -url https://example.com -duration 2 +# Verifica: recording creado +``` + +**Test 7: Recording funciona** +```bash +# Verifica contenido del recording log +grep "Navigate" recording_*.log +``` + +### Suite: Perfiles + +**Test 8: Persistencia** +```bash +# Primera sesión: crea perfil +./screenshot -profile persist-test -url https://example.com + +# Segunda sesión: reutiliza +./screenshot -profile persist-test -url https://example.com + +# Verifica: perfil existe después de ambas +``` + +**Test 9: Múltiples perfiles** +```bash +# Crear múltiples perfiles en paralelo +./screenshot -profile multi-1 & +./screenshot -profile multi-2 & +wait + +# Verifica: ambos existen +``` + +--- + +## 🔗 Tests de Integración Detallados + +### Test 1: Compartir perfil entre binarios +```bash +# navegar crea sesión +./navegar -url https://example.com -profile shared + +# screenshot usa misma sesión +./screenshot -url https://example.com -profile shared + +# Verifica: mismo perfil usado +``` + +### Test 2: Recording de múltiples acciones +```bash +./navegar -url https://example.com -duration 3 + +# Verifica: recording contiene JSON válido con acciones +``` + +### Test 3: Perfiles clonados en paralelo +```bash +# Clonar perfil base +cp -r base clone1 +cp -r base clone2 + +# Ejecutar en paralelo +./screenshot -profile clone1 & +./screenshot -profile clone2 & + +# Verifica: ambos completan sin error +``` + +--- + +## 📈 Cobertura de Código + +### Generar reporte +```bash +make coverage +``` + +Genera `coverage.html` con visualización de cobertura. + +### Ver cobertura en terminal +```bash +go test -cover ./pkg/... +``` + +### Cobertura por paquete +```bash +go test -coverprofile=coverage.out ./pkg/browser +go tool cover -func=coverage.out +``` + +--- + +## 🔄 CI/CD Automático + +### GitHub Actions + +El workflow `.github/workflows/test.yml` ejecuta automáticamente: + +1. **Unit Tests** - En cada push/PR +2. **E2E Tests** - Con Chrome instalado +3. **Integration Tests** - Verificación completa +4. **Lint** - Análisis de código + +### Configuración + +```yaml +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master ] +``` + +### Ver resultados + +En GitHub: **Actions** tab → Ver runs → Detalles de cada job + +--- + +## 🐛 Debugging Tests Fallidos + +### Test unitario falla + +```bash +# Ejecutar con verbose +go test -v ./pkg/browser/ -run TestName + +# Ver logs completos +go test -v ./pkg/browser/ 2>&1 | tee test.log +``` + +### Test E2E falla + +```bash +# Ejecutar directamente sin make +./test/e2e_test.sh + +# Ver archivos generados +ls -la *.png *.json test-profiles/ +``` + +### Chrome no se inicia + +```bash +# Verificar Chrome instalado +which google-chrome + +# Probar manualmente +google-chrome --version + +# Ver si hay procesos colgados +ps aux | grep chrome +pkill -9 chrome +``` + +### Timeout en búsquedas + +Las búsquedas en DuckDuckGo pueden tardar. Esto es **esperado** y el test lo marca como `SKIP`. + +```bash +# Test marcado como SKIP por timeout de red +# Esto NO es un fallo del binario +``` + +--- + +## 🎭 Tests de Regresión + +### Crear baseline + +```bash +# Capturar estado actual +make test > baseline.txt +``` + +### Comparar con baseline + +```bash +# Ejecutar tests nuevamente +make test > current.txt + +# Comparar +diff baseline.txt current.txt +``` + +--- + +## 📝 Escribir Nuevos Tests + +### Test unitario + +1. Crear archivo `*_test.go` en el paquete +2. Función con prefijo `Test` +3. Usar `t.TempDir()` para archivos temporales + +```go +func TestMyFeature(t *testing.T) { + tempDir := t.TempDir() + + // Tu código aquí + + if resultado != esperado { + t.Errorf("Expected %v, got %v", esperado, resultado) + } +} +``` + +### Test E2E + +Agregar a `test/e2e_test.sh`: + +```bash +TEST_NAME="mi-feature: descripción" +if ./mi-binario -arg valor; then + if [ condición ]; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Razón" + fi +else + report_test "$TEST_NAME" "FAIL" "Error ejecución" +fi +``` + +--- + +## 🚨 Troubleshooting + +### "Chrome crashed" o "Can't find Chrome" + +```bash +# Ubuntu/Debian +sudo apt-get install google-chrome-stable + +# Verificar instalación +google-chrome --version +``` + +### "Permission denied" en scripts + +```bash +chmod +x test/*.sh +chmod +x scripts/*.sh +``` + +### Tests pasan localmente pero fallan en CI + +```bash +# Puede ser dependencia de Chrome +# Verificar que Chrome se instala en CI (ver .github/workflows/test.yml) + +# O diferencias de timezone/locale +export TZ=UTC +export LANG=en_US.UTF-8 +``` + +### Perfiles de test llenan disco + +```bash +# Limpiar automáticamente +make clean + +# O manualmente +rm -rf test-profiles/ +rm -rf ~/.navegator/profiles/test-* +``` + +--- + +## 📊 Métricas de Calidad + +### Objetivos + +- ✅ **Cobertura de código**: >70% +- ✅ **Tests E2E**: >10 tests +- ✅ **Tiempo de ejecución**: <5 minutos +- ✅ **Pass rate**: >90% + +### Monitorear + +```bash +# Tiempo de ejecución +time make test + +# Cobertura +make coverage +# Ver coverage.html + +# Pass rate +make test | grep "Pass rate" +``` + +--- + +## 🔧 Comandos Útiles + +```bash +# Ejecutar todo +make test + +# Solo tests rápidos +make test-quick + +# Solo E2E +make test-e2e + +# Solo integración +make test-integration + +# Con cobertura +make coverage + +# Limpiar todo +make clean + +# Ver ayuda +make help +``` + +--- + +## 📚 Recursos + +- **Go Testing**: https://go.dev/doc/tutorial/add-a-test +- **Table-driven tests**: https://go.dev/wiki/TableDrivenTests +- **CI/CD con GitHub Actions**: https://docs.github.com/actions + +--- + +## ✅ Checklist Pre-Commit + +Antes de hacer commit, ejecutar: + +```bash +□ make fmt # Formatear código +□ make lint # Verificar código +□ make test-quick # Tests rápidos +□ make test # Tests completos (si hay tiempo) +``` + +Antes de hacer PR: + +```bash +□ make test # Todos los tests +□ make coverage # Verificar cobertura +□ Revisar CI/CD # Ver que pase en GitHub Actions +``` + +--- + +## 🎯 Conclusión + +Con este sistema de testing puedes: + +✅ Verificar que los binarios funcionan correctamente +✅ Detectar regresiones automáticamente +✅ Validar cambios antes de deploy +✅ Mantener calidad de código alta +✅ CI/CD automático en cada push + +**Comando más importante:** +```bash +make test +``` + +Ejecuta todo: unitarios, E2E, integración. Si pasa, el código está listo. diff --git a/e2e/e2e_test.sh b/e2e/e2e_test.sh new file mode 100755 index 0000000..4cbe0b7 --- /dev/null +++ b/e2e/e2e_test.sh @@ -0,0 +1,291 @@ +#!/bin/bash + +# E2E Testing Suite para Navegator Binarios +# Ejecuta tests completos de los binarios compilados + +set -e # Exit on error + +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$TEST_DIR")" +cd "$ROOT_DIR" + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Contadores +PASSED=0 +FAILED=0 +SKIPPED=0 + +# Función para reportar test +report_test() { + local test_name="$1" + local result="$2" + local message="$3" + + if [ "$result" = "PASS" ]; then + echo -e "${GREEN}✓${NC} $test_name" + ((PASSED++)) + elif [ "$result" = "FAIL" ]; then + echo -e "${RED}✗${NC} $test_name" + echo -e " ${RED}Error:${NC} $message" + ((FAILED++)) + elif [ "$result" = "SKIP" ]; then + echo -e "${YELLOW}⊘${NC} $test_name (skipped)" + ((SKIPPED++)) + fi +} + +# Función para limpiar después de tests +cleanup() { + rm -rf test-profiles/ + rm -f test-*.png test-*.json + rm -f recording_test-*.log +} + +echo "==========================================" +echo "🧪 Navegator E2E Test Suite" +echo "==========================================" +echo "" + +# Setup +echo "📦 Setup" +echo "---" + +# Verificar que los binarios existen +if [ ! -f "./bin/screenshot" ]; then + echo "Compilando screenshot..." + go build -o screenshot cm./bin/screenshot.go +fi + +if [ ! -f "./bin/buscar" ]; then + echo "Compilando buscar..." + go build -o buscar cm./bin/buscar.go +fi + +if [ ! -f "./bin/navegar" ]; then + echo "Compilando navegar..." + go build -o navegar cm./bin/navegar.go +fi + +echo "✅ Binarios listos" +echo "" + +# Crear directorio temporal para tests +TEST_PROFILES_DIR="$ROOT_DIR/test-profiles" +mkdir -p "$TEST_PROFILES_DIR" +export NAVEGATOR_PROFILES="$TEST_PROFILES_DIR" + +echo "==========================================" +echo "🔍 Test Suite: screenshot" +echo "==========================================" +echo "" + +# Test 1: Screenshot básico +TEST_NAME="screenshot: captura básica" +if timeout 30 ./bin/screenshot -url https://example.com -profile test-screenshot -o test-screenshot.png -headless=true &>/dev/null; then + if [ -f "test-screenshot.png" ] && [ -s "test-screenshot.png" ]; then + # Verificar magic bytes PNG + if xxd -l 8 test-screenshot.png | grep -q "8950 4e47"; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "No es un PNG válido" + fi + else + report_test "$TEST_NAME" "FAIL" "Archivo no creado o vacío" + fi +else + report_test "$TEST_NAME" "FAIL" "Timeout o error en ejecución" +fi + +# Test 2: Screenshot con perfil custom +TEST_NAME="screenshot: perfil personalizado" +if timeout 30 ./bin/screenshot -url https://example.com -profile custom-profile-123 -o test-custom.png -headless=true &>/dev/null; then + if [ -d "$TEST_PROFILES_DIR/custom-profile-123" ]; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Perfil no creado" + fi +else + report_test "$TEST_NAME" "FAIL" "Error en ejecución" +fi + +# Test 3: Screenshot con dimensiones custom +TEST_NAME="screenshot: dimensiones personalizadas" +if timeout 30 ./bin/screenshot -url https://example.com -profile test-dimensions -width=800 -height=600 -o test-800x600.png -headless=true &>/dev/null; then + if [ -f "test-800x600.png" ]; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Screenshot no creado" + fi +else + report_test "$TEST_NAME" "FAIL" "Error en ejecución" +fi + +echo "" +echo "==========================================" +echo "🔍 Test Suite: buscar" +echo "==========================================" +echo "" + +# Test 4: Búsqueda básica (puede tardar, usar timeout largo) +TEST_NAME="buscar: búsqueda básica" +if timeout 45 ./bin/buscar -q "test query" -n 3 -profile test-search -output test-search.json -headless=true &>/dev/null; then + # Puede fallar por timeout de DuckDuckGo, pero verificar que no crasheó + if [ $? -le 1 ]; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Error en búsqueda" + fi +else + # Si timeout, marcar como skip (problema de red, no del binario) + report_test "$TEST_NAME" "SKIP" "Timeout de red esperado" +fi + +# Test 5: Búsqueda con perfil +TEST_NAME="buscar: perfil personalizado" +if [ -d "$TEST_PROFILES_DIR/test-search" ]; then + report_test "$TEST_NAME" "PASS" +else + report_test "$TEST_NAME" "FAIL" "Perfil no creado" +fi + +echo "" +echo "==========================================" +echo "🔍 Test Suite: navegar" +echo "==========================================" +echo "" + +# Test 6: Navegación básica +TEST_NAME="navegar: navegación básica" +if timeout 30 ./bin/navegar -url https://example.com -profile test-navigate -duration 2 -headless=true &>/dev/null; then + if [ -f "recording_test-navigate.log" ]; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Recording no creado" + fi +else + report_test "$TEST_NAME" "FAIL" "Error en navegación" +fi + +# Test 7: Recording funciona +TEST_NAME="navegar: recording de acciones" +if [ -f "recording_test-navigate.log" ]; then + if grep -q "Navigate" recording_test-navigate.log; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Recording no contiene acciones" + fi +else + report_test "$TEST_NAME" "FAIL" "Archivo de recording no existe" +fi + +echo "" +echo "==========================================" +echo "🔍 Test Suite: Perfiles" +echo "==========================================" +echo "" + +# Test 8: Persistencia de perfiles +TEST_NAME="perfiles: persistencia entre sesiones" +PROFILE_PATH="$TEST_PROFILES_DIR/persist-test" + +# Primera sesión +timeout 30 ./bin/screenshot -url https://example.com -profile persist-test -o /dev/null -headless=true &>/dev/null + +if [ -d "$PROFILE_PATH" ]; then + # Segunda sesión (debe reutilizar) + timeout 30 ./bin/screenshot -url https://example.com -profile persist-test -o /dev/null -headless=true &>/dev/null + + if [ -d "$PROFILE_PATH" ]; then + report_test "$TEST_NAME" "PASS" + else + report_test "$TEST_NAME" "FAIL" "Perfil eliminado entre sesiones" + fi +else + report_test "$TEST_NAME" "FAIL" "Perfil no creado inicialmente" +fi + +# Test 9: Múltiples perfiles +TEST_NAME="perfiles: múltiples perfiles independientes" +timeout 30 ./bin/screenshot -url https://example.com -profile multi-1 -o /dev/null -headless=true &>/dev/null & +timeout 30 ./bin/screenshot -url https://example.com -profile multi-2 -o /dev/null -headless=true &>/dev/null & +wait + +if [ -d "$TEST_PROFILES_DIR/multi-1" ] && [ -d "$TEST_PROFILES_DIR/multi-2" ]; then + report_test "$TEST_NAME" "PASS" +else + report_test "$TEST_NAME" "FAIL" "No se crearon múltiples perfiles" +fi + +echo "" +echo "==========================================" +echo "🔍 Test Suite: Stealth" +echo "==========================================" +echo "" + +# Test 10: Verificar stealth flags (requiere navegar y evaluar JS) +# Este test es más complejo, lo marcamos como manual +TEST_NAME="stealth: flags anti-detección" +report_test "$TEST_NAME" "SKIP" "Requiere verificación manual" + +echo "" +echo "==========================================" +echo "🔍 Test Suite: Error Handling" +echo "==========================================" +echo "" + +# Test 11: URL inválida +TEST_NAME="error-handling: URL inválida" +if ./bin/screenshot -url "invalid-url" -profile test-error -o /dev/null -headless=true 2>&1 | grep -q -i "error"; then + report_test "$TEST_NAME" "PASS" +else + report_test "$TEST_NAME" "FAIL" "No reportó error con URL inválida" +fi + +# Test 12: Sin parámetros requeridos +TEST_NAME="error-handling: parámetros faltantes" +if ./bin/screenshot 2>&1 | grep -q -i "error"; then + report_test "$TEST_NAME" "PASS" +else + report_test "$TEST_NAME" "FAIL" "No reportó error sin parámetros" +fi + +echo "" +echo "==========================================" +echo "📊 Resultados" +echo "==========================================" +echo "" + +TOTAL=$((PASSED + FAILED + SKIPPED)) +echo "Total tests: $TOTAL" +echo -e "${GREEN}Passed:${NC} $PASSED" +echo -e "${RED}Failed:${NC} $FAILED" +echo -e "${YELLOW}Skipped:${NC} $SKIPPED" +echo "" + +# Calcular porcentaje +if [ $TOTAL -gt 0 ]; then + PASS_RATE=$((PASSED * 100 / TOTAL)) + echo "Pass rate: $PASS_RATE%" +fi + +echo "" + +# Cleanup +echo "🧹 Limpiando archivos de test..." +cleanup + +echo "" + +# Exit code +if [ $FAILED -gt 0 ]; then + echo -e "${RED}❌ Tests FAILED${NC}" + exit 1 +else + echo -e "${GREEN}✅ All tests PASSED${NC}" + exit 0 +fi diff --git a/e2e/integration_test.sh b/e2e/integration_test.sh new file mode 100755 index 0000000..2603f16 --- /dev/null +++ b/e2e/integration_test.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Integration Tests - Verifica integración entre componentes + +set -e + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +echo "==========================================" +echo "🔗 Integration Tests" +echo "==========================================" +echo "" + +TEST_DIR="./test-integration" +mkdir -p "$TEST_DIR" + +PASSED=0 +FAILED=0 + +test_result() { + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓${NC} $1" + ((PASSED++)) + else + echo -e "${RED}✗${NC} $1" + ((FAILED++)) + fi +} + +# Test 1: Compartir perfil entre binarios +echo "Test: Compartir perfil entre screenshot y navegar" +SHARED_PROFILE="shared-test-$$" + +# Crear sesión con navegar +timeout 30 ./navegar -url https://example.com \ + -profile "$SHARED_PROFILE" \ + -duration 2 \ + -headless=true &>/dev/null + +# Usar mismo perfil con screenshot +timeout 30 ./screenshot -url https://example.com \ + -profile "$SHARED_PROFILE" \ + -o "$TEST_DIR/shared.png" \ + -headless=true &>/dev/null + +# Verificar que ambos usaron mismo perfil +PROFILE_PATH="$HOME/.navegator/profiles/$SHARED_PROFILE" +[ -d "$PROFILE_PATH" ] +test_result "Perfil compartido entre binarios" + +# Test 2: Recording captura múltiples acciones +echo "Test: Recording de múltiples acciones" +RECORD_PROFILE="record-test-$$" + +timeout 30 ./navegar -url https://example.com \ + -profile "$RECORD_PROFILE" \ + -duration 3 \ + -headless=true &>/dev/null + +RECORD_FILE="recording_$RECORD_PROFILE.log" +if [ -f "$RECORD_FILE" ]; then + # Verificar que tiene contenido JSON + grep -q "Navigate" "$RECORD_FILE" && \ + grep -q "timestamp" "$RECORD_FILE" + test_result "Recording JSON válido" + rm -f "$RECORD_FILE" +else + false + test_result "Recording JSON válido" +fi + +# Test 3: Screenshot después de navegación +echo "Test: Screenshot refleja navegación" +NAV_PROFILE="nav-screenshot-$$" + +timeout 30 ./navegar -url https://example.com \ + -profile "$NAV_PROFILE" \ + -duration 2 \ + -headless=true &>/dev/null + +timeout 30 ./screenshot -url https://example.com \ + -profile "$NAV_PROFILE" \ + -o "$TEST_DIR/after-nav.png" \ + -headless=true &>/dev/null + +[ -f "$TEST_DIR/after-nav.png" ] && [ -s "$TEST_DIR/after-nav.png" ] +test_result "Screenshot después de navegación" + +# Test 4: Perfiles en paralelo (clonados) +echo "Test: Perfiles clonados en paralelo" +BASE_PROFILE="parallel-base-$$" + +# Crear perfil base +mkdir -p "$HOME/.navegator/profiles/$BASE_PROFILE" + +# Clonar +CLONE1="$BASE_PROFILE-clone1" +CLONE2="$BASE_PROFILE-clone2" + +cp -r "$HOME/.navegator/profiles/$BASE_PROFILE" "$HOME/.navegator/profiles/$CLONE1" +cp -r "$HOME/.navegator/profiles/$BASE_PROFILE" "$HOME/.navegator/profiles/$CLONE2" + +# Ejecutar en paralelo +timeout 30 ./screenshot -url https://example.com \ + -profile "$CLONE1" \ + -o "$TEST_DIR/clone1.png" \ + -headless=true &>/dev/null & +PID1=$! + +timeout 30 ./screenshot -url https://example.com \ + -profile "$CLONE2" \ + -o "$TEST_DIR/clone2.png" \ + -headless=true &>/dev/null & +PID2=$! + +wait $PID1 $PID2 + +[ -f "$TEST_DIR/clone1.png" ] && [ -f "$TEST_DIR/clone2.png" ] +test_result "Perfiles clonados en paralelo" + +# Test 5: Output JSON válido +echo "Test: Búsqueda genera JSON válido" +# Este puede fallar por timeout, pero verificar estructura +timeout 45 ./buscar -q "test" -n 1 \ + -profile "json-test-$$" \ + -output "$TEST_DIR/search.json" \ + -headless=true &>/dev/null || true + +if [ -f "$TEST_DIR/search.json" ]; then + # Verificar que es JSON válido + python3 -m json.tool "$TEST_DIR/search.json" > /dev/null 2>&1 + test_result "JSON de búsqueda válido" +else + echo -e "${RED}✗${NC} JSON de búsqueda válido (timeout esperado)" + ((FAILED++)) +fi + +echo "" +echo "==========================================" +echo "Resultados Integration Tests" +echo "==========================================" +echo -e "${GREEN}Passed:${NC} $PASSED" +echo -e "${RED}Failed:${NC} $FAILED" +echo "" + +# Cleanup +rm -rf "$TEST_DIR" +rm -rf "$HOME/.navegator/profiles/shared-test-"* +rm -rf "$HOME/.navegator/profiles/record-test-"* +rm -rf "$HOME/.navegator/profiles/nav-screenshot-"* +rm -rf "$HOME/.navegator/profiles/parallel-base-"* +rm -rf "$HOME/.navegator/profiles/json-test-"* + +if [ $FAILED -gt 0 ]; then + exit 1 +else + exit 0 +fi diff --git a/examples/README_youtube_comments.md b/examples/README_youtube_comments.md new file mode 100644 index 0000000..e73fbe5 --- /dev/null +++ b/examples/README_youtube_comments.md @@ -0,0 +1,150 @@ +# YouTube Comments Scraper + +Ejemplo de uso de **navegator** para extraer comentarios de videos de YouTube de manera sigilosa. + +## Características + +- ✅ Extracción de comentarios con autor, texto y likes +- ✅ Modo headless para producción +- ✅ Ventana pequeña para desarrollo/testing +- ✅ Manejo automático de banner de cookies +- ✅ Scroll automático para activar lazy loading +- ✅ Stealth flags para evitar detección +- ✅ Perfiles persistentes (cookies y sesiones se mantienen) + +## Uso + +### Modo básico (headless por defecto) +```bash +go run examples/youtube_comments.go +``` + +### Modo visible (para debugging) +```bash +go run examples/youtube_comments.go -visible +``` + +### Especificar video y número de comentarios +```bash +go run examples/youtube_comments.go -url "https://www.youtube.com/watch?v=VIDEO_ID" -n 20 +``` + +### Todas las opciones +```bash +go run examples/youtube_comments.go -visible -url "URL_DEL_VIDEO" -n 15 +``` + +## Parámetros + +| Flag | Descripción | Default | +|------|-------------|---------| +| `-visible` | Ejecutar con interfaz gráfica (para debugging) | `false` (headless) | +| `-url` | URL del video de YouTube | Video de ejemplo | +| `-n` | Número máximo de comentarios a extraer | `10` | + +## Compilar binario + +Para crear un binario standalone: + +```bash +go build -o youtube-comments examples/youtube_comments.go +``` + +Luego usar: +```bash +# Modo headless (default) +./youtube-comments -n 20 + +# Modo visible para debugging +./youtube-comments -visible -n 20 +``` + +## Ejemplo de salida + +``` +🚀 Lanzando navegador... +✅ Navegador iniciado. Perfil: /home/user/.navegator/profiles/youtube-scraper +📺 Navegando a YouTube: https://www.youtube.com/watch?v=S1J8rx2Jw98 +📊 Extrayendo hasta 5 comentarios +⏳ Esperando a que cargue la página... +🍪 Cookie banner clicked +📜 Haciendo scroll para cargar comentarios... +⏳ Esperando a que aparezcan los comentarios... +📝 Extrayendo comentarios... + +================================================================================ +📋 COMENTARIOS EXTRAÍDOS: +================================================================================ + +1. @herrpez (1K likes) + Insane move. Guy is clearly flying that route in August... + +2. @roland_does_things (336 likes) + This is the moment you treat all your friends to a great trip to Sicily... + +3. @Starcraft2Krauts (440 likes) + Getting money from Wizz air took me months and threatening a lawsuit... + +================================================================================ +📌 Información del Video: +================================================================================ +Título: I Tried to Profit From Flight Delays Using Data +Vistas: 43,845 views + +✅ Extracción completada exitosamente! +``` + +## Cómo funciona + +1. **Lanzamiento del navegador**: Inicia Chrome/Chromium con flags stealth +2. **Navegación**: Carga el video de YouTube especificado +3. **Cookies**: Detecta y acepta automáticamente el banner de cookies +4. **Scroll**: Hace scroll progresivo para activar el lazy loading de comentarios +5. **Extracción**: Usa JavaScript para extraer los datos de cada comentario +6. **Formato**: Muestra los comentarios de manera legible en la terminal + +## Selectores utilizados + +El script utiliza los siguientes selectores CSS de YouTube: + +- `ytd-comment-thread-renderer`: Contenedor de cada comentario +- `#author-text`: Nombre del autor +- `#content-text`: Texto del comentario +- `.published-time-text a`: Fecha de publicación +- `#vote-count-middle`: Contador de likes + +## Notas + +- **Perfiles persistentes**: El navegador guarda cookies y sesiones en `~/.navegator/profiles/youtube-scraper/` +- **Timeouts**: Si la página tarda mucho en cargar, aparecerá una advertencia pero continuará la ejecución +- **Stealth**: El navegador se configura con flags anti-detección para evitar ser bloqueado +- **Ventana pequeña**: Se usa ventana de 600x400 para menor consumo de recursos +- **Headless por defecto**: El navegador corre en segundo plano sin interfaz gráfica (usa `-visible` para debugging) + +## Troubleshooting + +### No se encuentran comentarios + +- Verifica que el video tenga comentarios habilitados +- Aumenta el tiempo de espera modificando los `time.Sleep()` +- Ejecuta con `-visible` para ver qué está pasando en el navegador + +### Timeout al navegar + +- Es normal, el script continúa de todos modos +- YouTube carga muchos recursos en segundo plano +- La advertencia no afecta la extracción de comentarios + +### Selectores no funcionan + +- YouTube puede cambiar su estructura HTML +- Ejecuta con `-visible` e inspecciona la página +- Actualiza los selectores en el script según sea necesario + +## Próximas mejoras + +- [ ] Exportar comentarios a JSON +- [ ] Extraer respuestas a comentarios +- [ ] Ordenar por fecha, likes, etc. +- [ ] Extraer información adicional (avatares, badges, etc.) +- [ ] Scroll infinito para extraer todos los comentarios diff --git a/examples/advanced.go b/examples/advanced.go new file mode 100644 index 0000000..c236669 --- /dev/null +++ b/examples/advanced.go @@ -0,0 +1,259 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "navegator/pkg/browser" +) + +func main() { + ctx := context.Background() + + // Configuración avanzada + config := browser.DefaultConfig() + config.ProfileName = "advanced-agent" + + // Personalizar flags stealth + config.StealthFlags.Headless = true + config.StealthFlags.NoSandbox = false // Solo activar en Docker + config.StealthFlags.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + + log.Println("Lanzando navegador con configuración avanzada...") + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("Error: %v", err) + } + defer b.Close() + + // ======================================== + // EJEMPLO 1: Gestión de Cookies + // ======================================== + log.Println("\n=== EJEMPLO 1: Cookies ===") + + // Establecer una cookie + cookie := browser.CreateCookie("session_id", "abc123", ".example.com") + if err := b.SetCookie(ctx, cookie); err != nil { + log.Printf("Error al establecer cookie: %v", err) + } else { + log.Println("Cookie establecida exitosamente") + } + + // ======================================== + // EJEMPLO 2: LocalStorage + // ======================================== + log.Println("\n=== EJEMPLO 2: LocalStorage ===") + + // Navegar primero + b.Navigate(ctx, "https://example.com", nil) + time.Sleep(1 * time.Second) + + // Establecer items en localStorage + if err := b.SetLocalStorage(ctx, "user_preference", "dark_mode"); err != nil { + log.Printf("Error: %v", err) + } else { + log.Println("LocalStorage item establecido") + } + + // Leer localStorage + items, err := b.GetLocalStorage(ctx) + if err != nil { + log.Printf("Error: %v", err) + } else { + log.Printf("LocalStorage items: %d\n", len(items)) + for _, item := range items { + log.Printf(" %s = %s\n", item.Key, item.Value) + } + } + + // ======================================== + // EJEMPLO 3: Interceptación de Red + // ======================================== + log.Println("\n=== EJEMPLO 3: Network Interception ===") + + // Bloquear imágenes y CSS para acelerar carga + ni, err := b.BlockResourceTypes(ctx, "Image", "Stylesheet") + if err != nil { + log.Printf("Error: %v", err) + } else { + log.Println("Bloqueando imágenes y CSS...") + defer ni.Disable(ctx) + } + + // Navegar con recursos bloqueados + log.Println("Navegando a página con recursos bloqueados...") + b.Navigate(ctx, "https://news.ycombinator.com", nil) + time.Sleep(2 * time.Second) + + // ======================================== + // EJEMPLO 4: Headers Personalizados + // ======================================== + log.Println("\n=== EJEMPLO 4: Custom Headers ===") + + headers := map[string]string{ + "X-Custom-Header": "MyValue", + "Accept-Language": "en-US,en;q=0.9", + } + + if err := b.SetExtraHTTPHeaders(ctx, headers); err != nil { + log.Printf("Error: %v", err) + } else { + log.Println("Headers personalizados establecidos") + } + + // ======================================== + // EJEMPLO 5: Evaluación de JavaScript + // ======================================== + log.Println("\n=== EJEMPLO 5: JavaScript Evaluation ===") + + // Ejecutar script complejo + script := ` + (() => { + const info = { + url: window.location.href, + title: document.title, + links: document.querySelectorAll('a').length, + images: document.querySelectorAll('img').length, + userAgent: navigator.userAgent, + webdriver: navigator.webdriver + }; + return info; + })() + ` + + result, err := b.Evaluate(ctx, script) + if err != nil { + log.Printf("Error: %v", err) + } else { + log.Printf("Resultado de evaluación:\n%+v\n", result.Value) + } + + // ======================================== + // EJEMPLO 6: Console Logging + // ======================================== + log.Println("\n=== EJEMPLO 6: Console Monitoring ===") + + b.EnableConsole(ctx) + b.OnConsole(func(msg *browser.ConsoleMessage) { + log.Printf("[CONSOLE.%s] %s\n", msg.Type, msg.Text) + }) + + // Ejecutar código que genera logs de consola + b.Evaluate(ctx, ` + console.log("Mensaje de log"); + console.warn("Mensaje de warning"); + console.error("Mensaje de error"); + `) + + time.Sleep(500 * time.Millisecond) + + // ======================================== + // EJEMPLO 7: Formularios e Interacción + // ======================================== + log.Println("\n=== EJEMPLO 7: Form Interaction ===") + + // Navegar a una página con formulario + b.Navigate(ctx, "https://httpbin.org/forms/post", nil) + time.Sleep(2 * time.Second) + + // Llenar formulario + log.Println("Llenando formulario...") + + // Focus y escribir en campo + if err := b.Type(ctx, "input[name='custname']", "John Doe", nil); err != nil { + log.Printf("Error: %v", err) + } + + if err := b.Type(ctx, "input[name='custtel']", "555-1234", nil); err != nil { + log.Printf("Error: %v", err) + } + + // Esperar un poco antes de hacer click + time.Sleep(500 * time.Millisecond) + + // Click en botón de submit + log.Println("Haciendo click en submit...") + if err := b.Click(ctx, "button[type='submit']"); err != nil { + log.Printf("Error al hacer click: %v", err) + } + + // Esperar navegación + time.Sleep(3 * time.Second) + + // Obtener URL actual + currentURL, _ := b.Evaluate(ctx, "window.location.href") + log.Printf("URL después de submit: %v\n", currentURL.Value) + + // ======================================== + // EJEMPLO 8: Esperar por Selector + // ======================================== + log.Println("\n=== EJEMPLO 8: Wait for Selector ===") + + b.Navigate(ctx, "https://example.com", nil) + + log.Println("Esperando a que aparezca el selector h1...") + if err := b.WaitForSelector(ctx, "h1", 10*time.Second); err != nil { + log.Printf("Error: %v", err) + } else { + log.Println("Selector encontrado!") + text, _ := b.GetText(ctx, "h1") + log.Printf("Texto: %s\n", text) + } + + // ======================================== + // EJEMPLO 9: Screenshot + // ======================================== + log.Println("\n=== EJEMPLO 9: Screenshots ===") + + // Screenshot de viewport + screenshot, err := b.Screenshot(ctx, false) + if err != nil { + log.Printf("Error: %v", err) + } else { + log.Printf("Screenshot viewport: %d bytes\n", len(screenshot)) + } + + // Screenshot de página completa + fullScreenshot, err := b.Screenshot(ctx, true) + if err != nil { + log.Printf("Error: %v", err) + } else { + log.Printf("Screenshot completo: %d bytes\n", len(fullScreenshot)) + } + + // ======================================== + // EJEMPLO 10: Navegación del Historial + // ======================================== + log.Println("\n=== EJEMPLO 10: History Navigation ===") + + // Navegar a varias páginas + b.Navigate(ctx, "https://example.com", nil) + time.Sleep(1 * time.Second) + + b.Navigate(ctx, "https://example.org", nil) + time.Sleep(1 * time.Second) + + // Ir atrás + log.Println("Navegando hacia atrás...") + if err := b.GoBack(ctx); err != nil { + log.Printf("Error: %v", err) + } else { + time.Sleep(1 * time.Second) + url, _ := b.Evaluate(ctx, "window.location.href") + log.Printf("URL después de GoBack: %v\n", url.Value) + } + + // Ir adelante + log.Println("Navegando hacia adelante...") + if err := b.GoForward(ctx); err != nil { + log.Printf("Error: %v", err) + } else { + time.Sleep(1 * time.Second) + url, _ := b.Evaluate(ctx, "window.location.href") + log.Printf("URL después de GoForward: %v\n", url.Value) + } + + log.Println("\n=== Todos los ejemplos completados ===") +} diff --git a/examples/basic.go b/examples/basic.go new file mode 100644 index 0000000..4a9562a --- /dev/null +++ b/examples/basic.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "navegator/pkg/browser" +) + +func main() { + ctx := context.Background() + + // Crear configuración con perfil personalizado + config := browser.DefaultConfig() + config.ProfileName = "my-agent-profile" + + // Si quieres modo visible, desactiva headless + // config.StealthFlags.Headless = false + + // Lanzar navegador + log.Println("Lanzando navegador...") + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("Error al lanzar navegador: %v", err) + } + defer b.Close() + + log.Printf("Navegador iniciado. Perfil: %s\n", b.ProfilePath()) + log.Printf("Debug URL: %s\n", b.DebugURL()) + + // Navegar a una página + log.Println("Navegando a example.com...") + if err := b.Navigate(ctx, "https://example.com", nil); err != nil { + log.Fatalf("Error al navegar: %v", err) + } + + // Esperar un poco para que cargue + time.Sleep(2 * time.Second) + + // Obtener HTML + log.Println("Obteniendo HTML...") + html, err := b.GetHTML(ctx, "") + if err != nil { + log.Fatalf("Error al obtener HTML: %v", err) + } + log.Printf("HTML length: %d bytes\n", len(html)) + + // Obtener texto del h1 + log.Println("Obteniendo texto del h1...") + text, err := b.GetText(ctx, "h1") + if err != nil { + log.Fatalf("Error al obtener texto: %v", err) + } + log.Printf("H1 text: %s\n", text) + + // Tomar screenshot + log.Println("Tomando screenshot...") + screenshot, err := b.Screenshot(ctx, false) + if err != nil { + log.Fatalf("Error al tomar screenshot: %v", err) + } + + if err := os.WriteFile("screenshot.png", screenshot, 0644); err != nil { + log.Fatalf("Error al guardar screenshot: %v", err) + } + log.Println("Screenshot guardado en screenshot.png") + + // Ejecutar JavaScript + log.Println("Ejecutando JavaScript...") + result, err := b.Evaluate(ctx, "window.location.href") + if err != nil { + log.Fatalf("Error al ejecutar JavaScript: %v", err) + } + log.Printf("Current URL: %v\n", result.Value) + + // Cookies + log.Println("Obteniendo cookies...") + cookies, err := b.GetCookies(ctx) + if err != nil { + log.Fatalf("Error al obtener cookies: %v", err) + } + log.Printf("Cookies encontradas: %d\n", len(cookies)) + + for _, cookie := range cookies { + log.Printf(" - %s = %s\n", cookie.Name, cookie.Value) + } + + log.Println("Ejemplo completado exitosamente!") +} diff --git a/examples/youtube_comments.go b/examples/youtube_comments.go new file mode 100644 index 0000000..aa0a599 --- /dev/null +++ b/examples/youtube_comments.go @@ -0,0 +1,216 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "strings" + "time" + + "navegator/pkg/browser" +) + +func main() { + // Flags de línea de comandos + visible := flag.Bool("visible", false, "Ejecutar en modo visible (con interfaz gráfica para debugging)") + videoURL := flag.String("url", "https://www.youtube.com/watch?v=S1J8rx2Jw98", "URL del video de YouTube") + numComments := flag.Int("n", 10, "Número de comentarios a extraer (máximo)") + flag.Parse() + + ctx := context.Background() + + // Configuración del navegador + config := browser.DefaultConfig() + config.ProfileName = "youtube-scraper" + + // Por defecto headless, solo visible si se especifica + config.StealthFlags.Headless = !*visible + + // Siempre usar ventana pequeña (incluso en modo visible) + config.StealthFlags.WindowSize = [2]int{600, 400} + + // User agent actualizado + config.StealthFlags.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + + log.Println("🚀 Lanzando navegador...") + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("❌ Error al lanzar navegador: %v", err) + } + defer b.Close() + + log.Printf("✅ Navegador iniciado. Perfil: %s\n", b.ProfilePath()) + + log.Printf("📺 Navegando a YouTube: %s\n", *videoURL) + log.Printf("📊 Extrayendo hasta %d comentarios\n", *numComments) + + // Simplemente navegar sin esperar eventos específicos (más confiable) + if err := b.Navigate(ctx, *videoURL, nil); err != nil { + // Si hay error, intentar continuar de todos modos + log.Printf("⚠️ Advertencia al navegar: %v", err) + } + + // Esperar a que cargue la página + log.Println("⏳ Esperando a que cargue la página...") + time.Sleep(3 * time.Second) + + // Manejar banner de cookies si aparece + log.Println("🍪 Verificando banner de cookies...") + cookieBannerScript := ` + (() => { + // Buscar botones de aceptar cookies + const selectors = [ + 'button[aria-label*="Accept"]', + 'button[aria-label*="Aceptar"]', + 'button:contains("Accept all")', + 'ytd-button-renderer button[aria-label*="Accept"]', + 'button.yt-spec-button-shape-next--filled' + ]; + + for (const selector of selectors) { + const button = document.querySelector(selector); + if (button && button.textContent.toLowerCase().includes('accept')) { + button.click(); + return 'Cookie banner clicked'; + } + } + + return 'No cookie banner found'; + })() + ` + + cookieResult, _ := b.Evaluate(ctx, cookieBannerScript) + if cookieResult != nil && cookieResult.Value != nil { + log.Printf("🍪 %v\n", cookieResult.Value) + } + + // Esperar después de manejar cookies + time.Sleep(2 * time.Second) + + // Scroll hacia abajo para activar la carga de comentarios (YouTube usa lazy loading) + log.Println("📜 Haciendo scroll para cargar comentarios...") + for i := 0; i < 5; i++ { + scrollScript := fmt.Sprintf(`window.scrollTo(0, %d);`, 400*(i+1)) + b.Evaluate(ctx, scrollScript) + time.Sleep(1500 * time.Millisecond) + } + + // Esperar más tiempo a que aparezcan los comentarios (especialmente en headless) + log.Println("⏳ Esperando a que aparezcan los comentarios...") + time.Sleep(3 * time.Second) + + // Extraer comentarios usando JavaScript + log.Println("📝 Extrayendo comentarios...") + + extractScript := fmt.Sprintf(` + (() => { + const comments = []; + const commentElements = document.querySelectorAll('ytd-comment-thread-renderer'); + + // Limitar según el parámetro + const limit = Math.min(commentElements.length, %d);`, *numComments) + ` + + for (let i = 0; i < limit; i++) { + const comment = commentElements[i]; + + // Extraer autor + const authorElement = comment.querySelector('#author-text'); + const author = authorElement ? authorElement.textContent.trim() : 'Unknown'; + + // Extraer texto del comentario + const contentElement = comment.querySelector('#content-text'); + const text = contentElement ? contentElement.textContent.trim() : ''; + + // Extraer fecha (si está disponible) + const dateElement = comment.querySelector('.published-time-text a'); + const date = dateElement ? dateElement.textContent.trim() : ''; + + // Extraer likes (si está disponible) + const likeElement = comment.querySelector('#vote-count-middle'); + const likes = likeElement ? likeElement.textContent.trim() : '0'; + + comments.push({ + author: author, + text: text, + date: date, + likes: likes, + index: i + 1 + }); + } + + return comments; + })() + ` + + result, err := b.Evaluate(ctx, extractScript) + if err != nil { + log.Fatalf("❌ Error al extraer comentarios: %v", err) + } + + // Mostrar resultados + separator := strings.Repeat("=", 80) + log.Println("\n" + separator) + log.Println("📋 COMENTARIOS EXTRAÍDOS:") + log.Println(separator + "\n") + + // El resultado viene como un array de mapas + if result.Value != nil { + if comments, ok := result.Value.([]interface{}); ok { + if len(comments) == 0 { + log.Println("⚠️ No se encontraron comentarios") + } else { + for _, c := range comments { + if comment, ok := c.(map[string]interface{}); ok { + index := comment["index"] + author := comment["author"] + likes := comment["likes"] + text := comment["text"] + + fmt.Printf("\n%v. %s (%s likes)\n", index, author, likes) + + // Truncar texto si es muy largo + textStr := fmt.Sprintf("%v", text) + if len(textStr) > 200 { + textStr = textStr[:200] + "..." + } + fmt.Printf(" %s\n", textStr) + } + } + } + } else { + log.Printf("⚠️ Formato inesperado: %+v\n", result.Value) + } + } else { + log.Println("⚠️ No se encontraron comentarios") + } + + // También podemos obtener el título del video + log.Println("\n" + separator) + log.Println("📌 Información del Video:") + log.Println(separator) + + titleResult, err := b.Evaluate(ctx, "document.querySelector('h1.ytd-watch-metadata yt-formatted-string')?.textContent || 'No title found'") + if err == nil && titleResult.Value != nil { + fmt.Printf("Título: %v\n", titleResult.Value) + } + + viewsResult, err := b.Evaluate(ctx, "document.querySelector('.view-count')?.textContent || 'No views found'") + if err == nil && viewsResult.Value != nil { + fmt.Printf("Vistas: %v\n", viewsResult.Value) + } + + log.Println("\n✅ Extracción completada exitosamente!") + + // Si es visible, mantener abierto brevemente para inspección + if *visible { + log.Println("⏳ Manteniendo navegador abierto por 2 segundos...") + time.Sleep(2 * time.Second) + } + + // Asegurar cierre del navegador + log.Println("🔒 Cerrando navegador...") + if err := b.Close(); err != nil { + log.Printf("⚠️ Error al cerrar navegador: %v", err) + } +} diff --git a/examples/youtube_comments_NOTA.md b/examples/youtube_comments_NOTA.md new file mode 100644 index 0000000..3cea43e --- /dev/null +++ b/examples/youtube_comments_NOTA.md @@ -0,0 +1,79 @@ +# ⚠️ NOTA IMPORTANTE - YouTube y Modo Headless + +## Problema Detectado + +YouTube detecta el modo headless y **no carga los comentarios** cuando el navegador está en ese modo, incluso con todas las flags stealth activadas. + +## Resultados de Testing + +### ✅ Modo Visible (funciona) +```bash +go run examples/youtube_comments.go -visible -n 5 +``` +**Resultado:** Extrae comentarios exitosamente + +### ❌ Modo Headless (no funciona) +```bash +go run examples/youtube_comments.go -n 5 +``` +**Resultado:** No encuentra comentarios (YouTube los bloquea) + +## Soluciones + +### Opción 1: Usar Modo Visible (Recomendado para YouTube) +```go +config.StealthFlags.Headless = false +config.StealthFlags.WindowSize = [2]int{600, 400} // Ventana pequeña +``` + +### Opción 2: Usar Xvfb (Linux) para simular display +```bash +# Instalar Xvfb +sudo apt-get install xvfb + +# Ejecutar con display virtual +xvfb-run -a go run examples/youtube_comments.go +``` + +### Opción 3: Ajustar el Script para Usar Selector de Comments Button +En lugar de scrollear directamente a los comentarios, podemos hacer click en el botón de comentarios primero: + +```javascript +// Click en el botón/section de comentarios +document.querySelector('#comments')?.scrollIntoView(); +``` + +### Opción 4: Usar API de YouTube (Alternativa) +Si necesitas headless absoluto, considera usar la YouTube Data API v3 en lugar de scraping. + +## Por Qué Sucede + +YouTube usa múltiples técnicas de detección: +1. Verifica `navigator.webdriver` (ya lo manejamos con stealth) +2. Detecta características ausentes en headless Chrome +3. Analiza patrones de comportamiento del usuario +4. Puede usar WebGL fingerprinting +5. Detecta ausencia de interacción humana + +## Recomendación Final + +Para este caso de uso específico (extracción de comentarios de YouTube): +- **Desarrollo/Testing:** Usar `-visible` con ventana pequeña +- **Producción:** Usar Xvfb o un servidor con display virtual +- **Alternativa:** Considerar YouTube Data API para uso a escala + +## Estado Actual del Código + +El código está configurado para: +- ✅ Headless por defecto +- ✅ Ventana pequeña (600x400) +- ✅ Cierre explícito del navegador +- ✅ Flags stealth optimizadas +- ⚠️ **Pero YouTube bloquea comentarios en headless** + +## Próximos Pasos Sugeridos + +1. Agregar flag `--force-display` que use Xvfb automáticamente +2. Implementar detector de "comentarios bloqueados" y reintento en modo visible +3. Agregar opción de usar YouTube Data API como fallback +4. Investigar si headless=new de Chrome evita la detección diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..afe5b65 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module navegator + +go 1.22.2 + +require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b1baed8 --- /dev/null +++ b/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "navegator/pkg/browser" +) + +func main() { + ctx := context.Background() + + // Obtener ruta absoluta de perfiles + currentDir, err := os.Getwd() + if err != nil { + log.Fatalf("Error al obtener directorio actual: %v", err) + } + + profilesDir := filepath.Join(currentDir, "perfiles") + + // Configuración + config := browser.DefaultConfig() + config.ProfilesBaseDir = profilesDir + config.ProfileName = "test-profile" + + // Modo visible (cambiar a true para headless) + config.StealthFlags.Headless = false + + // Ventana más pequeña + config.StealthFlags.WindowSize = [2]int{1280, 720} + + log.Println("========================================") + log.Println("Navegator - Control de Chrome via CDP") + log.Println("========================================") + log.Printf("Directorio de perfiles: %s\n", profilesDir) + log.Printf("Perfil: %s\n", config.ProfileName) + log.Println("Lanzando navegador...") + + // Lanzar navegador + b, err := browser.Launch(ctx, config) + if err != nil { + log.Fatalf("❌ Error al lanzar navegador: %v", err) + } + defer b.Close() + + log.Println("✅ Navegador lanzado exitosamente!") + log.Printf("📂 Perfil ubicado en: %s\n", b.ProfilePath()) + log.Printf("🔧 Debug URL: %s\n", b.DebugURL()) + + // Iniciar recording de acciones + recordingFile := filepath.Join(currentDir, "session.log") + if err := b.StartRecording(recordingFile); err != nil { + log.Printf("⚠️ No se pudo iniciar recording: %v", err) + } else { + log.Printf("📝 Recording activado: %s\n", recordingFile) + } + + // Navegar a página de prueba + b.AddComment("=== INICIO DE SESIÓN ===") + log.Println("\n📍 Navegando a example.com...") + if err := b.Navigate(ctx, "https://example.com", nil); err != nil { + log.Printf("❌ Error al navegar: %v", err) + } else { + log.Println("✅ Navegación completada") + } + + b.AddComment("Página cargada correctamente") + + time.Sleep(2 * time.Second) + + // Obtener información de la página + log.Println("\n📊 Obteniendo información de la página...") + + title, _ := b.Evaluate(ctx, "document.title") + log.Printf(" Título: %v", title.Value) + + url, _ := b.Evaluate(ctx, "window.location.href") + log.Printf(" URL: %v", url.Value) + + h1Text, _ := b.GetText(ctx, "h1") + log.Printf(" H1: %s", h1Text) + + // Verificar stealth + log.Println("\n🕵️ Verificando stealth...") + webdriver, _ := b.Evaluate(ctx, "navigator.webdriver") + log.Printf(" navigator.webdriver: %v", webdriver.Value) + + hasChrome, _ := b.Evaluate(ctx, "typeof window.chrome !== 'undefined'") + log.Printf(" window.chrome existe: %v", hasChrome.Value) + + // Mantener navegador abierto + log.Println("\n✋ Navegador abierto. Presiona Ctrl+C para cerrar...") + + // Esperar señal de interrupción + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + fmt.Println("\n👋 Cerrando navegador...") +} diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go new file mode 100644 index 0000000..4b1efd3 --- /dev/null +++ b/pkg/browser/browser.go @@ -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") +} diff --git a/pkg/browser/browser_test.go b/pkg/browser/browser_test.go new file mode 100644 index 0000000..fbab040 --- /dev/null +++ b/pkg/browser/browser_test.go @@ -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 +} diff --git a/pkg/browser/navigation.go b/pkg/browser/navigation.go new file mode 100644 index 0000000..a72477c --- /dev/null +++ b/pkg/browser/navigation.go @@ -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) +} diff --git a/pkg/browser/network.go b/pkg/browser/network.go new file mode 100644 index 0000000..9e5723d --- /dev/null +++ b/pkg/browser/network.go @@ -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) +} diff --git a/pkg/browser/recorder.go b/pkg/browser/recorder.go new file mode 100644 index 0000000..5e40d00 --- /dev/null +++ b/pkg/browser/recorder.go @@ -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) +} diff --git a/pkg/browser/runtime.go b/pkg/browser/runtime.go new file mode 100644 index 0000000..ef863f2 --- /dev/null +++ b/pkg/browser/runtime.go @@ -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 +} diff --git a/pkg/browser/storage.go b/pkg/browser/storage.go new file mode 100644 index 0000000..0f9aae9 --- /dev/null +++ b/pkg/browser/storage.go @@ -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", + } +} diff --git a/pkg/cdp/client.go b/pkg/cdp/client.go new file mode 100644 index 0000000..af9ca8d --- /dev/null +++ b/pkg/cdp/client.go @@ -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") +} diff --git a/pkg/stealth/flags.go b/pkg/stealth/flags.go new file mode 100644 index 0000000..3de4564 --- /dev/null +++ b/pkg/stealth/flags.go @@ -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:]) +} diff --git a/scripts/clonar_perfil.sh b/scripts/clonar_perfil.sh new file mode 100755 index 0000000..925919f --- /dev/null +++ b/scripts/clonar_perfil.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Script para clonar perfiles y usarlos en paralelo + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Uso: $0 " + echo "" + echo "Ejemplo:" + echo " $0 usuario-base usuario-clon-1" + echo "" + echo "Esto permite usar el mismo perfil en paralelo:" + echo " ./buscar -q 'golang' -profile usuario-clon-1 &" + echo " ./buscar -q 'python' -profile usuario-clon-2 &" + exit 1 +fi + +ORIGEN="$1" +DESTINO="$2" +PROFILES_DIR="${3:-$HOME/.navegator/profiles}" + +ORIGEN_PATH="$PROFILES_DIR/$ORIGEN" +DESTINO_PATH="$PROFILES_DIR/$DESTINO" + +# Verificar que origen existe +if [ ! -d "$ORIGEN_PATH" ]; then + echo "❌ Error: El perfil '$ORIGEN' no existe en $PROFILES_DIR" + exit 1 +fi + +# Verificar que destino no existe +if [ -d "$DESTINO_PATH" ]; then + echo "⚠️ El perfil '$DESTINO' ya existe. ¿Sobrescribir? (y/N)" + read -r respuesta + if [ "$respuesta" != "y" ] && [ "$respuesta" != "Y" ]; then + echo "Cancelado." + exit 0 + fi + rm -rf "$DESTINO_PATH" +fi + +# Copiar perfil +echo "📋 Clonando perfil..." +cp -r "$ORIGEN_PATH" "$DESTINO_PATH" + +# Limpiar archivos de lock y sesión +echo "🧹 Limpiando locks..." +rm -f "$DESTINO_PATH/SingletonLock" +rm -f "$DESTINO_PATH/SingletonSocket" +rm -f "$DESTINO_PATH/SingletonCookie" +rm -f "$DESTINO_PATH/DevToolsActivePort" + +echo "✅ Perfil clonado: $ORIGEN → $DESTINO" +echo "" +echo "Ahora puedes usar ambos en paralelo:" +echo " ./buscar -q 'query1' -profile $ORIGEN &" +echo " ./buscar -q 'query2' -profile $DESTINO &" diff --git a/scripts/demo_paralelo.sh b/scripts/demo_paralelo.sh new file mode 100755 index 0000000..8df7827 --- /dev/null +++ b/scripts/demo_paralelo.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Demostración de uso de perfiles en paralelo + +echo "==========================================" +echo "🎭 Demo: Perfiles en Paralelo" +echo "==========================================" +echo "" + +# Verificar que los binarios existen +if [ ! -f "./buscar" ] || [ ! -f "./screenshot" ]; then + echo "❌ Error: Binarios no encontrados" + echo "Ejecuta: go build -o buscar cmd/buscar.go" + exit 1 +fi + +# Crear perfil base +echo "📝 Paso 1: Crear perfil base" +echo "---" +PROFILES_DIR="$HOME/.navegator/profiles" +mkdir -p "$PROFILES_DIR/demo-base" +echo "✅ Perfil base creado: demo-base" +echo "" + +# Clonar perfiles +echo "📋 Paso 2: Clonar perfiles para uso paralelo" +echo "---" +for i in {1..3}; do + if [ -d "$PROFILES_DIR/demo-worker-$i" ]; then + rm -rf "$PROFILES_DIR/demo-worker-$i" + fi + cp -r "$PROFILES_DIR/demo-base" "$PROFILES_DIR/demo-worker-$i" + # Limpiar locks + rm -f "$PROFILES_DIR/demo-worker-$i/SingletonLock" + rm -f "$PROFILES_DIR/demo-worker-$i/SingletonSocket" + rm -f "$PROFILES_DIR/demo-worker-$i/SingletonCookie" + echo "✅ Clonado: demo-worker-$i" +done +echo "" + +# Ejecutar en paralelo +echo "🚀 Paso 3: Ejecutar búsquedas en paralelo" +echo "---" +echo "Iniciando 3 búsquedas simultáneas..." +echo "" + +./bin/buscar -q "golang tutorial" -n 3 -profile demo-worker-1 -output demo-result-1.json & +PID1=$! +echo "Worker 1 (PID $PID1): Buscando 'golang tutorial'" + +./bin/buscar -q "python basics" -n 3 -profile demo-worker-2 -output demo-result-2.json & +PID2=$! +echo "Worker 2 (PID $PID2): Buscando 'python basics'" + +./bin/buscar -q "javascript async" -n 3 -profile demo-worker-3 -output demo-result-3.json & +PID3=$! +echo "Worker 3 (PID $PID3): Buscando 'javascript async'" + +echo "" +echo "⏳ Esperando que terminen las búsquedas..." +wait + +echo "" +echo "==========================================" +echo "✅ Demo completada" +echo "==========================================" +echo "" + +# Mostrar resultados +echo "📊 Resultados generados:" +ls -lh demo-result-*.json 2>/dev/null | awk '{print " "$9" ("$5")"}' +echo "" + +echo "📂 Perfiles usados simultáneamente:" +for i in {1..3}; do + if [ -d "$PROFILES_DIR/demo-worker-$i" ]; then + size=$(du -sh "$PROFILES_DIR/demo-worker-$i" | cut -f1) + echo " demo-worker-$i ($size)" + fi +done +echo "" + +echo "💡 Conclusión:" +echo " - 3 búsquedas ejecutadas EN PARALELO" +echo " - Cada una con su propio perfil (cookies aisladas)" +echo " - Sin conflictos ni errores de lock" +echo " - Resultados guardados en JSON separados" +echo "" + +echo "🧹 Limpiar archivos de demo? (y/N)" +read -r respuesta +if [ "$respuesta" = "y" ] || [ "$respuesta" = "Y" ]; then + rm -f demo-result-*.json + rm -rf "$PROFILES_DIR/demo-worker-"* + rm -rf "$PROFILES_DIR/demo-base" + echo "✅ Demo limpiada" +else + echo "Archivos conservados en:" + echo " - Resultados: demo-result-*.json" + echo " - Perfiles: $PROFILES_DIR/demo-worker-*" +fi + +echo "" +echo "📖 Más info: cat PERFILES_AVANZADO.md" diff --git a/scripts/ejemplos_perfiles.sh b/scripts/ejemplos_perfiles.sh new file mode 100755 index 0000000..4c48543 --- /dev/null +++ b/scripts/ejemplos_perfiles.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Script de demostración: Simulación de múltiples usuarios navegando + +echo "==========================================" +echo "🎭 Simulación de Navegación Orgánica" +echo "==========================================" +echo "" +echo "Este script simula 3 usuarios diferentes navegando" +echo "Cada uno usa su propio perfil con cookies separadas" +echo "" + +# Usuario 1: Desarrollador buscando info técnica +echo "👨‍💻 Usuario 1: Desarrollador" +echo " - Perfil: dev-user-1" +echo " - Busca: golang tutorials" +echo "" +./bin/buscar -q "golang tutorials" -n 5 -profile dev-user-1 -headless=true -output dev1_results.json +echo "" + +# Usuario 2: Estudiante haciendo research +echo "👩‍🎓 Usuario 2: Estudiante" +echo " - Perfil: student-user-2" +echo " - Busca: machine learning basics" +echo "" +./bin/buscar -q "machine learning basics" -n 5 -profile student-user-2 -headless=true -output student2_results.json +echo "" + +# Usuario 3: Diseñador buscando inspiración +echo "🎨 Usuario 3: Diseñador" +echo " - Perfil: designer-user-3" +echo " - Captura: dribbble.com" +echo "" +./bin/screenshot -url https://dribbble.com -profile designer-user-3 -o designer3_capture.png -headless=true +echo "" + +echo "==========================================" +echo "✅ Simulación completada" +echo "" +echo "📂 Perfiles creados:" +ls -1 perfiles/ | grep -E "dev-user|student-user|designer-user" +echo "" +echo "📊 Archivos generados:" +ls -lh *_results.json *_capture.png 2>/dev/null | awk '{print " "$9" ("$5")"}' +echo "" +echo "💡 Cada perfil mantiene:" +echo " - Cookies separadas" +echo " - Historial independiente" +echo " - Cache propio" +echo " - Sesiones aisladas" +echo "" +echo "🔄 Para reutilizar un perfil:" +echo " ./navegar -url https://example.com -profile dev-user-1" +echo ""