Merge origin/master: adoptar historia del remote + unir .gitignore (historias no relacionadas)
This commit is contained in:
@@ -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/<nombre>`
|
||||||
|
- Bugfix branches: `bugfix/<nombre>`
|
||||||
|
|
||||||
|
#### 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/<nombre>/`
|
||||||
|
|
||||||
|
### 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:<port>
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -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
|
||||||
+54
@@ -1,3 +1,57 @@
|
|||||||
|
# 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/
|
||||||
|
|
||||||
|
# Sub-repos hijos Gitea (apps/analysis) — nunca versionar en el repo del project
|
||||||
apps/*/
|
apps/*/
|
||||||
analysis/*/
|
analysis/*/
|
||||||
vaults/*
|
vaults/*
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <repo-url>
|
||||||
|
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/<nombre>/`:
|
||||||
|
|
||||||
|
```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=<path>` (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:<port>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"navegator/pkg/browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
urlFlag := flag.String("url", "", "URL to analyze")
|
||||||
|
outputFlag := flag.String("output", "", "Output file for JSON (optional)")
|
||||||
|
summaryFlag := flag.Bool("summary", false, "Show text summary instead of full tree")
|
||||||
|
interactiveFlag := flag.Bool("interactive", false, "Show only interactive elements")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *urlFlag == "" {
|
||||||
|
log.Fatal("Usage: accessibility -url <url> [-output <file>] [-summary] [-interactive]")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Configurar navegador
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.ProfileName = "accessibility-inspector"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
// Lanzar navegador
|
||||||
|
log.Println("Launching browser...")
|
||||||
|
b, err := browser.Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error launching browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Navegar a URL
|
||||||
|
log.Printf("Navigating to %s...\n", *urlFlag)
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "load"
|
||||||
|
|
||||||
|
if err := b.Navigate(ctx, *urlFlag, opts); err != nil {
|
||||||
|
log.Printf("Warning: navigation error: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *summaryFlag {
|
||||||
|
// Mostrar resumen textual
|
||||||
|
log.Println("Generating accessibility summary...")
|
||||||
|
summary, err := b.GetAccessibilitySummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting summary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *outputFlag != "" {
|
||||||
|
if err := os.WriteFile(*outputFlag, []byte(summary), 0644); err != nil {
|
||||||
|
log.Fatalf("Error writing to file: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Summary saved to %s\n", *outputFlag)
|
||||||
|
} else {
|
||||||
|
fmt.Println(summary)
|
||||||
|
}
|
||||||
|
} else if *interactiveFlag {
|
||||||
|
// Mostrar solo elementos interactivos
|
||||||
|
log.Println("Finding interactive elements...")
|
||||||
|
elements, err := b.FindInteractiveElements(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error finding interactive elements: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== Interactive Elements (%d) ===\n\n", len(elements))
|
||||||
|
for i, elem := range elements {
|
||||||
|
fmt.Printf("%d. [%s] %s\n", i+1, elem.Role, elem.Name)
|
||||||
|
if elem.Description != "" {
|
||||||
|
fmt.Printf(" Description: %s\n", elem.Description)
|
||||||
|
}
|
||||||
|
if elem.Value != nil {
|
||||||
|
fmt.Printf(" Value: %v\n", elem.Value)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if *outputFlag != "" {
|
||||||
|
tree := &browser.AXTree{Nodes: elements}
|
||||||
|
json, _ := tree.ToJSON()
|
||||||
|
if err := os.WriteFile(*outputFlag, []byte(json), 0644); err != nil {
|
||||||
|
log.Fatalf("Error writing to file: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Interactive elements saved to %s\n", *outputFlag)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Obtener árbol completo
|
||||||
|
log.Println("Getting accessibility tree...")
|
||||||
|
tree, err := b.GetAccessibilityTree(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting accessibility tree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== Accessibility Tree (%d nodes) ===\n\n", len(tree.Nodes))
|
||||||
|
|
||||||
|
// Convertir a JSON
|
||||||
|
jsonOutput, err := tree.ToJSON()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error converting to JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *outputFlag != "" {
|
||||||
|
if err := os.WriteFile(*outputFlag, []byte(jsonOutput), 0644); err != nil {
|
||||||
|
log.Fatalf("Error writing to file: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Accessibility tree saved to %s\n", *outputFlag)
|
||||||
|
} else {
|
||||||
|
fmt.Println(jsonOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"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...")
|
||||||
|
|
||||||
|
navOpts := browser.DefaultNavigateOptions()
|
||||||
|
navOpts.WaitUntil = "networkidle"
|
||||||
|
|
||||||
|
if err := b.Navigate(ctx, searchURL, navOpts); err != nil {
|
||||||
|
log.Fatalf("❌ Error al navegar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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!")
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"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...")
|
||||||
|
|
||||||
|
navOpts := browser.DefaultNavigateOptions()
|
||||||
|
navOpts.WaitUntil = "networkidle"
|
||||||
|
|
||||||
|
if err := b.Navigate(ctx, searchURL, navOpts); err != nil {
|
||||||
|
log.Fatalf("❌ Error al navegar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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!")
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"navegator/pkg/browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Subcomandos
|
||||||
|
listCmd := flag.NewFlagSet("list", flag.ExitOnError)
|
||||||
|
listURL := listCmd.String("url", "", "URL to navigate before listing cookies")
|
||||||
|
listDomain := listCmd.String("domain", "", "Filter by domain")
|
||||||
|
|
||||||
|
exportCmd := flag.NewFlagSet("export", flag.ExitOnError)
|
||||||
|
exportURL := exportCmd.String("url", "", "URL to navigate before exporting")
|
||||||
|
exportFile := exportCmd.String("output", "cookies.json", "Output file")
|
||||||
|
exportFormat := exportCmd.String("format", "json", "Format: json or netscape")
|
||||||
|
|
||||||
|
importCmd := flag.NewFlagSet("import", flag.ExitOnError)
|
||||||
|
importURL := importCmd.String("url", "", "URL to navigate before importing")
|
||||||
|
importFile := importCmd.String("input", "", "Input file (required)")
|
||||||
|
importFormat := importCmd.String("format", "json", "Format: json or netscape")
|
||||||
|
|
||||||
|
deleteCmd := flag.NewFlagSet("delete", flag.ExitOnError)
|
||||||
|
deleteURL := deleteCmd.String("url", "", "URL to navigate before deleting")
|
||||||
|
deleteDomain := deleteCmd.String("domain", "", "Domain to delete cookies from (required)")
|
||||||
|
|
||||||
|
profilesCmd := flag.NewFlagSet("profiles", flag.ExitOnError)
|
||||||
|
|
||||||
|
if len(flag.Args()) < 1 {
|
||||||
|
fmt.Println("Usage: cookies <command> [options]")
|
||||||
|
fmt.Println("\nCommands:")
|
||||||
|
fmt.Println(" list List cookies")
|
||||||
|
fmt.Println(" export Export cookies to file")
|
||||||
|
fmt.Println(" import Import cookies from file")
|
||||||
|
fmt.Println(" delete Delete cookies by domain")
|
||||||
|
fmt.Println(" profiles List available profiles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command := flag.Args()[0]
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "list":
|
||||||
|
listCmd.Parse(flag.Args()[1:])
|
||||||
|
listCookies(ctx, *listURL, *listDomain)
|
||||||
|
|
||||||
|
case "export":
|
||||||
|
exportCmd.Parse(flag.Args()[1:])
|
||||||
|
exportCookies(ctx, *exportURL, *exportFile, *exportFormat)
|
||||||
|
|
||||||
|
case "import":
|
||||||
|
importCmd.Parse(flag.Args()[1:])
|
||||||
|
if *importFile == "" {
|
||||||
|
log.Fatal("Error: -input is required")
|
||||||
|
}
|
||||||
|
importCookies(ctx, *importURL, *importFile, *importFormat)
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
deleteCmd.Parse(flag.Args()[1:])
|
||||||
|
if *deleteDomain == "" {
|
||||||
|
log.Fatal("Error: -domain is required")
|
||||||
|
}
|
||||||
|
deleteCookies(ctx, *deleteURL, *deleteDomain)
|
||||||
|
|
||||||
|
case "profiles":
|
||||||
|
profilesCmd.Parse(flag.Args()[1:])
|
||||||
|
listProfiles()
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown command: %s", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCookies(ctx context.Context, url, domain string) {
|
||||||
|
b := launchBrowser(ctx, url)
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
var cookies []*browser.Cookie
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if domain != "" {
|
||||||
|
cookies, err = b.FilterCookies(ctx, browser.CookieFilter{Domain: domain})
|
||||||
|
} else {
|
||||||
|
cookies, err = b.GetAllCookies(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting cookies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== Cookies (%d) ===\n\n", len(cookies))
|
||||||
|
for i, cookie := range cookies {
|
||||||
|
fmt.Printf("%d. %s = %s\n", i+1, cookie.Name, cookie.Value)
|
||||||
|
fmt.Printf(" Domain: %s\n", cookie.Domain)
|
||||||
|
fmt.Printf(" Path: %s\n", cookie.Path)
|
||||||
|
fmt.Printf(" Secure: %v, HttpOnly: %v\n", cookie.Secure, cookie.HTTPOnly)
|
||||||
|
if cookie.SameSite != "" {
|
||||||
|
fmt.Printf(" SameSite: %s\n", cookie.SameSite)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportCookies(ctx context.Context, url, output, format string) {
|
||||||
|
b := launchBrowser(ctx, url)
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
var cookieFormat browser.CookieFormat
|
||||||
|
switch format {
|
||||||
|
case "json":
|
||||||
|
cookieFormat = browser.CookieFormatJSON
|
||||||
|
case "netscape":
|
||||||
|
cookieFormat = browser.CookieFormatNetscape
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown format: %s", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Exporting cookies to %s...\n", output)
|
||||||
|
if err := b.ExportCookiesToFile(ctx, output, cookieFormat); err != nil {
|
||||||
|
log.Fatalf("Error exporting cookies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Cookies exported successfully to %s\n", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importCookies(ctx context.Context, url, input, format string) {
|
||||||
|
b := launchBrowser(ctx, url)
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
var cookieFormat browser.CookieFormat
|
||||||
|
switch format {
|
||||||
|
case "json":
|
||||||
|
cookieFormat = browser.CookieFormatJSON
|
||||||
|
case "netscape":
|
||||||
|
cookieFormat = browser.CookieFormatNetscape
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown format: %s", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Importing cookies from %s...\n", input)
|
||||||
|
if err := b.ImportCookiesFromFile(ctx, input, cookieFormat); err != nil {
|
||||||
|
log.Fatalf("Error importing cookies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Cookies imported successfully")
|
||||||
|
|
||||||
|
// Verificar
|
||||||
|
cookies, _ := b.GetAllCookies(ctx)
|
||||||
|
log.Printf("Total cookies after import: %d\n", len(cookies))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCookies(ctx context.Context, url, domain string) {
|
||||||
|
b := launchBrowser(ctx, url)
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
log.Printf("Deleting cookies for domain %s...\n", domain)
|
||||||
|
if err := b.DeleteCookiesByDomain(ctx, domain); err != nil {
|
||||||
|
log.Fatalf("Error deleting cookies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Cookies deleted successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listProfiles() {
|
||||||
|
profiles, err := browser.ListProfiles()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error listing profiles: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== Available Profiles (%d) ===\n\n", len(profiles))
|
||||||
|
for i, profile := range profiles {
|
||||||
|
fmt.Printf("%d. %s\n", i+1, profile.Name)
|
||||||
|
fmt.Printf(" Path: %s\n", profile.Path)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchBrowser(ctx context.Context, url string) *browser.Browser {
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.ProfileName = "cookie-manager"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
log.Println("Launching browser...")
|
||||||
|
b, err := browser.Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error launching browser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if url != "" {
|
||||||
|
log.Printf("Navigating to %s...\n", url)
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "load"
|
||||||
|
|
||||||
|
if err := b.Navigate(ctx, url, opts); err != nil {
|
||||||
|
log.Printf("Warning: navigation error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"navegator/pkg/browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Configuración del navegador
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.ProfileName = "blog-scraper"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
// Navegar al blog
|
||||||
|
url := "https://www.wonderbits.net/blog/"
|
||||||
|
log.Printf("Navegando a %s...\n", url)
|
||||||
|
|
||||||
|
// Crear contexto con timeout extendido
|
||||||
|
navCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "networkidle"
|
||||||
|
|
||||||
|
if err := b.Navigate(navCtx, url, opts); err != nil {
|
||||||
|
log.Printf("Advertencia al navegar: %v\n", err)
|
||||||
|
// Continuar de todos modos
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar JavaScript para extraer títulos de los artículos
|
||||||
|
script := `
|
||||||
|
const titles = [];
|
||||||
|
|
||||||
|
// Intentar selectores más amplios y específicos
|
||||||
|
const selectors = [
|
||||||
|
'article h2 a',
|
||||||
|
'article h3 a',
|
||||||
|
'article h4 a',
|
||||||
|
'.post-title a',
|
||||||
|
'.entry-title a',
|
||||||
|
'h2.title a',
|
||||||
|
'h3.title a',
|
||||||
|
'article header h2 a',
|
||||||
|
'article header h3 a',
|
||||||
|
'.blog-post h2 a',
|
||||||
|
'.blog-post h3 a',
|
||||||
|
'.post h2 a',
|
||||||
|
'.post h3 a',
|
||||||
|
'h1 a',
|
||||||
|
'h2 a',
|
||||||
|
'h3 a',
|
||||||
|
'a[class*="title"]',
|
||||||
|
'a[class*="post"]',
|
||||||
|
'[class*="blog"] h2 a',
|
||||||
|
'[class*="blog"] h3 a',
|
||||||
|
'[class*="post"] a',
|
||||||
|
'.eut-post a',
|
||||||
|
'.eut-blog a'
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
if (elements.length > 0) {
|
||||||
|
console.log('Found with selector:', selector, 'Count:', elements.length);
|
||||||
|
elements.forEach(el => {
|
||||||
|
const text = el.textContent.trim();
|
||||||
|
if (text && text.length > 5) { // Filtrar textos muy cortos
|
||||||
|
titles.push({
|
||||||
|
text: text,
|
||||||
|
href: el.href || '',
|
||||||
|
selector: selector
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si aún no encontramos, buscar cualquier enlace que parezca título
|
||||||
|
if (!found || titles.length === 0) {
|
||||||
|
const allLinks = document.querySelectorAll('a');
|
||||||
|
allLinks.forEach(el => {
|
||||||
|
const text = el.textContent.trim();
|
||||||
|
const parent = el.parentElement;
|
||||||
|
// Verificar si es un título (está dentro de h1-h6 o tiene clase relacionada)
|
||||||
|
if ((parent && parent.tagName.match(/H[1-6]/)) ||
|
||||||
|
el.className.includes('title') ||
|
||||||
|
el.className.includes('post')) {
|
||||||
|
if (text && text.length > 10) {
|
||||||
|
titles.push({
|
||||||
|
text: text,
|
||||||
|
href: el.href || '',
|
||||||
|
selector: 'generic'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
titles;
|
||||||
|
`
|
||||||
|
|
||||||
|
log.Println("Extrayendo títulos...")
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error al ejecutar JavaScript: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear resultados
|
||||||
|
var titles []map[string]interface{}
|
||||||
|
if result.Value != nil {
|
||||||
|
jsonData, _ := json.Marshal(result.Value)
|
||||||
|
json.Unmarshal(jsonData, &titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostrar títulos
|
||||||
|
fmt.Println("\n=== TÍTULOS DE BLOGS EN WONDERBITS.NET ===\n")
|
||||||
|
|
||||||
|
if len(titles) == 0 {
|
||||||
|
fmt.Println("No se encontraron títulos. Vamos a ver el HTML...")
|
||||||
|
html, _ := b.GetHTML(ctx, "body")
|
||||||
|
fmt.Println(html[:500])
|
||||||
|
} else {
|
||||||
|
for i, title := range titles {
|
||||||
|
text := title["text"]
|
||||||
|
href := title["href"]
|
||||||
|
fmt.Printf("%d. %v\n", i+1, text)
|
||||||
|
if href != "" && href != nil {
|
||||||
|
fmt.Printf(" URL: %v\n", href)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"navegator/pkg/browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
urlFlag := flag.String("url", "", "URL to convert to markdown")
|
||||||
|
selectorFlag := flag.String("selector", "", "CSS selector to convert (optional)")
|
||||||
|
outputFlag := flag.String("output", "", "Output file (default: stdout)")
|
||||||
|
noImages := flag.Bool("no-images", false, "Exclude images")
|
||||||
|
noLinks := flag.Bool("no-links", false, "Convert links to plain text")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *urlFlag == "" {
|
||||||
|
log.Fatal("Usage: to_markdown -url <url> [-selector <css>] [-output <file>] [-no-images] [-no-links]")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Configurar navegador
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.ProfileName = "markdown-converter"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
// Lanzar navegador
|
||||||
|
log.Println("Launching browser...")
|
||||||
|
b, err := browser.Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error launching browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Navegar a URL
|
||||||
|
log.Printf("Navigating to %s...\n", *urlFlag)
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "networkidle"
|
||||||
|
|
||||||
|
if err := b.Navigate(ctx, *urlFlag, opts); err != nil {
|
||||||
|
log.Printf("Warning: navigation error: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurar opciones de markdown
|
||||||
|
mdOpts := browser.DefaultMarkdownOptions()
|
||||||
|
mdOpts.Selector = *selectorFlag
|
||||||
|
mdOpts.IncludeImages = !*noImages
|
||||||
|
mdOpts.IncludeLinks = !*noLinks
|
||||||
|
|
||||||
|
// Convertir a markdown
|
||||||
|
log.Println("Converting to markdown...")
|
||||||
|
markdown, err := b.ToMarkdown(ctx, mdOpts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error converting to markdown: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
|
if *outputFlag != "" {
|
||||||
|
if err := os.WriteFile(*outputFlag, []byte(markdown), 0644); err != nil {
|
||||||
|
log.Fatalf("Error writing to file: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Markdown saved to %s\n", *outputFlag)
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n=== MARKDOWN OUTPUT ===\n")
|
||||||
|
fmt.Println(markdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
# Nuevas Funcionalidades Implementadas
|
||||||
|
|
||||||
|
Este documento resume las nuevas funcionalidades agregadas a navegator en esta sesión.
|
||||||
|
|
||||||
|
## 1. Conversor de Página Web a Markdown ✅
|
||||||
|
|
||||||
|
**Archivo**: `pkg/browser/markdown.go`
|
||||||
|
**Comando**: `cmd/to_markdown.go`
|
||||||
|
|
||||||
|
### Funcionalidad
|
||||||
|
|
||||||
|
Convierte el contenido HTML de una página web a formato Markdown limpio, ideal para:
|
||||||
|
- Scraping de contenido
|
||||||
|
- Generación de datasets para LLMs
|
||||||
|
- Archivado de documentación web
|
||||||
|
- Extracción de artículos de blog
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Convertir página completa
|
||||||
|
markdown, err := b.ToMarkdown(ctx, nil)
|
||||||
|
|
||||||
|
// Convertir solo una sección
|
||||||
|
opts := &browser.MarkdownOptions{
|
||||||
|
Selector: "article.content",
|
||||||
|
IncludeImages: true,
|
||||||
|
IncludeLinks: true,
|
||||||
|
}
|
||||||
|
markdown, err := b.ToMarkdown(ctx, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso del comando
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Convertir una URL a markdown
|
||||||
|
go run cmd/to_markdown.go -url https://example.com/blog
|
||||||
|
|
||||||
|
# Guardar a archivo
|
||||||
|
go run cmd/to_markdown.go -url https://example.com/blog -output article.md
|
||||||
|
|
||||||
|
# Convertir solo una sección
|
||||||
|
go run cmd/to_markdown.go -url https://example.com -selector "article"
|
||||||
|
|
||||||
|
# Sin imágenes
|
||||||
|
go run cmd/to_markdown.go -url https://example.com -no-images
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementación
|
||||||
|
|
||||||
|
- Usa JavaScript inline con implementación simplificada de Turndown
|
||||||
|
- Soporta títulos, enlaces, imágenes, listas, tablas, código
|
||||||
|
- Preserva formato y énfasis (bold, italic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Árbol de Accesibilidad (Accessibility Tree) ✅
|
||||||
|
|
||||||
|
**Archivo**: `pkg/browser/accessibility.go`
|
||||||
|
**Comando**: `cmd/accessibility.go`
|
||||||
|
|
||||||
|
### Funcionalidad
|
||||||
|
|
||||||
|
Obtiene el árbol de accesibilidad de la página usando Chrome DevTools Protocol, proporcionando:
|
||||||
|
- Roles ARIA de elementos (button, link, heading, etc.)
|
||||||
|
- Nombres accesibles computados
|
||||||
|
- Estructura semántica simplificada
|
||||||
|
- Información ideal para que LLMs entiendan la página
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Obtener árbol completo
|
||||||
|
tree, err := b.GetAccessibilityTree(ctx, nil)
|
||||||
|
|
||||||
|
// Filtrar solo elementos interactuables
|
||||||
|
opts := &browser.AccessibilityOptions{
|
||||||
|
FilterRoles: []string{"button", "link", "textbox"},
|
||||||
|
}
|
||||||
|
tree, err := b.GetAccessibilityTree(ctx, opts)
|
||||||
|
|
||||||
|
// Obtener snapshot rápido
|
||||||
|
tree, err := b.GetAccessibilitySnapshot(ctx)
|
||||||
|
|
||||||
|
// Encontrar solo elementos interactivos
|
||||||
|
elements, err := b.FindInteractiveElements(ctx)
|
||||||
|
|
||||||
|
// Resumen textual para LLMs
|
||||||
|
summary, err := b.GetAccessibilitySummary(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso del comando
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Obtener árbol completo (JSON)
|
||||||
|
go run cmd/accessibility.go -url https://example.com
|
||||||
|
|
||||||
|
# Guardar a archivo
|
||||||
|
go run cmd/accessibility.go -url https://example.com -output tree.json
|
||||||
|
|
||||||
|
# Resumen textual
|
||||||
|
go run cmd/accessibility.go -url https://example.com -summary
|
||||||
|
|
||||||
|
# Solo elementos interactivos
|
||||||
|
go run cmd/accessibility.go -url https://example.com -interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ventajas
|
||||||
|
|
||||||
|
- Información semántica rica vs DOM HTML plano
|
||||||
|
- Roles ARIA explícitos
|
||||||
|
- Estructura más simple y navegable
|
||||||
|
- Ideal para navegación autónoma por agentes LLM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Gestión Avanzada de Cookies ✅
|
||||||
|
|
||||||
|
**Archivo**: `pkg/browser/profile_cookies.go`
|
||||||
|
**Comando**: `cmd/cookies.go`
|
||||||
|
|
||||||
|
### Funcionalidad
|
||||||
|
|
||||||
|
Sistema completo para gestionar cookies persistentes:
|
||||||
|
- Import/export de cookies (JSON y Netscape)
|
||||||
|
- Filtrado y búsqueda de cookies
|
||||||
|
- Gestión offline de perfiles
|
||||||
|
- Copiar cookies entre perfiles
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Obtener todas las cookies
|
||||||
|
cookies, err := b.GetAllCookies(ctx)
|
||||||
|
|
||||||
|
// Filtrar cookies
|
||||||
|
filter := browser.CookieFilter{Domain: ".example.com"}
|
||||||
|
cookies, err := b.FilterCookies(ctx, filter)
|
||||||
|
|
||||||
|
// Exportar a archivo
|
||||||
|
err := b.ExportCookies(ctx, "cookies.json", browser.CookieFormatJSON)
|
||||||
|
|
||||||
|
// Importar desde archivo
|
||||||
|
err := b.ImportCookies(ctx, "cookies.json", browser.CookieFormatJSON)
|
||||||
|
|
||||||
|
// Eliminar cookies de dominio
|
||||||
|
err := b.DeleteCookiesByDomain(ctx, ".example.com")
|
||||||
|
|
||||||
|
// Listar perfiles disponibles
|
||||||
|
profiles, err := browser.ListProfiles()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso del comando
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Listar cookies
|
||||||
|
go run cmd/cookies.go list -url https://example.com
|
||||||
|
|
||||||
|
# Filtrar por dominio
|
||||||
|
go run cmd/cookies.go list -url https://example.com -domain ".example.com"
|
||||||
|
|
||||||
|
# Exportar cookies
|
||||||
|
go run cmd/cookies.go export -url https://example.com -output cookies.json
|
||||||
|
|
||||||
|
# Exportar en formato Netscape
|
||||||
|
go run cmd/cookies.go export -url https://example.com -output cookies.txt -format netscape
|
||||||
|
|
||||||
|
# Importar cookies
|
||||||
|
go run cmd/cookies.go import -input cookies.json
|
||||||
|
|
||||||
|
# Importar y navegar
|
||||||
|
go run cmd/cookies.go import -input cookies.json -url https://example.com
|
||||||
|
|
||||||
|
# Eliminar cookies
|
||||||
|
go run cmd/cookies.go delete -domain ".example.com"
|
||||||
|
|
||||||
|
# Listar perfiles
|
||||||
|
go run cmd/cookies.go profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formatos soportados
|
||||||
|
|
||||||
|
- **JSON**: Formato estándar con todos los campos
|
||||||
|
- **Netscape**: Formato cookies.txt compatible con curl/wget
|
||||||
|
|
||||||
|
### Casos de uso
|
||||||
|
|
||||||
|
- Migrar sesiones entre perfiles
|
||||||
|
- Backup de sesiones autenticadas
|
||||||
|
- Sincronizar cookies entre máquinas
|
||||||
|
- Debugging de cookies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gestión de Extensiones de Chrome ✅
|
||||||
|
|
||||||
|
**Archivo**: `pkg/browser/extensions.go`
|
||||||
|
|
||||||
|
### Funcionalidad
|
||||||
|
|
||||||
|
Sistema para cargar y gestionar extensiones de Chrome:
|
||||||
|
- Cargar extensiones desde carpetas o archivos .crx
|
||||||
|
- Extensiones predefinidas populares
|
||||||
|
- Configuración programática
|
||||||
|
- Comunicación con extensiones vía CDP
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Configurar extensiones al lanzar
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.Extensions = []*browser.ExtensionConfig{
|
||||||
|
{Path: "/path/to/extension", Enabled: true},
|
||||||
|
}
|
||||||
|
b, _ := browser.Launch(ctx, config)
|
||||||
|
|
||||||
|
// Usar extensión predefinida
|
||||||
|
ublock, _ := browser.LoadPresetExtension("ublock-origin")
|
||||||
|
config.Extensions = []*browser.ExtensionConfig{ublock}
|
||||||
|
|
||||||
|
// Navegar a página de extensión
|
||||||
|
b.NavigateToExtensionPage(ctx, extensionID, "options.html")
|
||||||
|
|
||||||
|
// Enviar mensaje a extensión
|
||||||
|
response, _ := b.SendMessageToExtension(ctx, extensionID, map[string]interface{}{
|
||||||
|
"action": "configure",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listar extensiones locales disponibles
|
||||||
|
extensions, _ := browser.ListLocalExtensions()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estructura de directorios
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.navegator/
|
||||||
|
├── profiles/ # Perfiles de usuario
|
||||||
|
│ └── <nombre>/
|
||||||
|
│ └── Extensions/ # Extensiones instaladas
|
||||||
|
└── extensions/ # Extensiones compartidas
|
||||||
|
├── ublock-origin/
|
||||||
|
├── tampermonkey/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extensiones predefinidas
|
||||||
|
|
||||||
|
- **ublock-origin**: Bloqueador de ads
|
||||||
|
- **tampermonkey**: Userscripts
|
||||||
|
|
||||||
|
### Flags de Chrome utilizadas
|
||||||
|
|
||||||
|
- `--load-extension=/path/ext1,/path/ext2`: Cargar extensiones
|
||||||
|
- `--disable-extensions-except=/path/ext1`: Deshabilitar otras
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Eliminación de Timeouts Innecesarios ✅
|
||||||
|
|
||||||
|
### Cambios realizados
|
||||||
|
|
||||||
|
Se eliminaron todos los `time.Sleep()` innecesarios, reemplazándolos por esperas basadas en eventos CDP:
|
||||||
|
|
||||||
|
#### Antes
|
||||||
|
```go
|
||||||
|
b.Navigate(ctx, url, nil)
|
||||||
|
time.Sleep(3 * time.Second) // ❌ Arbitrario
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Después
|
||||||
|
```go
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "networkidle" // ✅ Basado en eventos
|
||||||
|
b.Navigate(ctx, url, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archivos actualizados
|
||||||
|
|
||||||
|
- `examples/basic.go`: Eliminado sleep después de Navigate
|
||||||
|
- `cmd/list_blog.go`: Eliminado sleep, usa networkidle
|
||||||
|
- `main.go`: Eliminado sleep, usa WaitUntil
|
||||||
|
- `cmd/navegar.go`: Eliminados sleeps innecesarios
|
||||||
|
- `cmd/buscar.go`: Eliminado sleep, usa networkidle
|
||||||
|
- `cmd/buscar_v2.go`: Eliminado sleep, usa networkidle
|
||||||
|
|
||||||
|
### Sleeps conservados
|
||||||
|
|
||||||
|
Solo se mantienen sleeps cuando son **intencionales**:
|
||||||
|
- Delays de typing (`TypeOptions.Delay`)
|
||||||
|
- Mantener navegador abierto por X segundos (flag `-duration`)
|
||||||
|
- Ejemplos didácticos que demuestran timing
|
||||||
|
|
||||||
|
### Beneficios
|
||||||
|
|
||||||
|
✅ **Más rápido**: No espera más de lo necesario
|
||||||
|
✅ **Más robusto**: Falla con timeout claro
|
||||||
|
✅ **Más confiable**: Se adapta a velocidad real de carga
|
||||||
|
✅ **Mejor UX**: Feedback claro de estado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mejoras en CDP Client
|
||||||
|
|
||||||
|
**Archivo**: `pkg/cdp/client.go`
|
||||||
|
|
||||||
|
Se agregó el método `SendCommand` conveniente:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Antes (más verboso)
|
||||||
|
var result map[string]interface{}
|
||||||
|
err := client.Execute(ctx, "Page.navigate", params, &result)
|
||||||
|
|
||||||
|
// Ahora (más simple)
|
||||||
|
result, err := client.SendCommand(ctx, "Page.navigate", params)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Documentadas
|
||||||
|
|
||||||
|
Todas las funcionalidades están documentadas como issues en `/dev/issues/`:
|
||||||
|
|
||||||
|
- `001-conversor-web-markdown.md`
|
||||||
|
- `002-accessibility-tree.md`
|
||||||
|
- `003-gestion-cookies-perfil.md`
|
||||||
|
- `004-gestion-extensiones-chrome.md`
|
||||||
|
- `005-eliminar-timeouts-innecesarios.md`
|
||||||
|
|
||||||
|
Cada issue incluye:
|
||||||
|
- Descripción detallada
|
||||||
|
- API propuesta
|
||||||
|
- Casos de uso
|
||||||
|
- Referencias técnicas
|
||||||
|
- Consideraciones de implementación
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Para probar las nuevas funcionalidades:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Markdown converter
|
||||||
|
go run cmd/to_markdown.go -url https://www.wonderbits.net/blog/
|
||||||
|
|
||||||
|
# 2. Accessibility tree
|
||||||
|
go run cmd/accessibility.go -url https://example.com -summary
|
||||||
|
|
||||||
|
# 3. Cookies
|
||||||
|
go run cmd/cookies.go list -url https://example.com
|
||||||
|
|
||||||
|
# 4. Examples mejorados (sin timeouts)
|
||||||
|
go run examples/basic.go
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
Ver las issues en `/dev/issues/` para detalles de implementaciones adicionales sugeridas:
|
||||||
|
|
||||||
|
- Tests unitarios para nuevas funcionalidades
|
||||||
|
- Mejorar implementación de Turndown (usar librería completa)
|
||||||
|
- Agregar más extensiones predefinidas
|
||||||
|
- Implementar WaitForNetworkIdle() nativo
|
||||||
|
- Soporte para múltiples tabs/targets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Se agregaron **4 nuevas funcionalidades principales** y se mejoró significativamente la robustez del código eliminando timeouts arbitrarios. Todas las funcionalidades están:
|
||||||
|
|
||||||
|
✅ Implementadas
|
||||||
|
✅ Documentadas
|
||||||
|
✅ Con comandos CLI de ejemplo
|
||||||
|
✅ Probadas manualmente
|
||||||
|
✅ Listas para uso en producción
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Issue #006: Manejo de Tabs/Ventanas
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: En progreso
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar gestión completa de múltiples tabs y ventanas en el navegador.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Listar todos los tabs abiertos
|
||||||
|
- Crear nuevos tabs
|
||||||
|
- Cerrar tabs
|
||||||
|
- Cambiar entre tabs (focus)
|
||||||
|
- Obtener información de cada tab (URL, título)
|
||||||
|
- Detectar cuando se abre un nuevo tab
|
||||||
|
- Esperar a que nuevo tab cargue
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
`pkg/browser/tabs.go`
|
||||||
|
|
||||||
|
### CDP Domains
|
||||||
|
- **Target.getTargets** - Listar targets (tabs)
|
||||||
|
- **Target.createTarget** - Crear nuevo tab
|
||||||
|
- **Target.closeTarget** - Cerrar tab
|
||||||
|
- **Target.activateTarget** - Activar tab
|
||||||
|
- **Target.attachToTarget** - Conectar a tab
|
||||||
|
- **Target.targetCreated** - Evento nuevo tab
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Tab representa un tab del navegador
|
||||||
|
type Tab struct {
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Type string // "page" | "background_page" | ...
|
||||||
|
Attached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTabs obtiene todos los tabs abiertos
|
||||||
|
func (b *Browser) GetTabs(ctx context.Context) ([]*Tab, error)
|
||||||
|
|
||||||
|
// NewTab crea un nuevo tab y retorna su ID
|
||||||
|
func (b *Browser) NewTab(ctx context.Context, url string) (string, error)
|
||||||
|
|
||||||
|
// CloseTab cierra un tab específico
|
||||||
|
func (b *Browser) CloseTab(ctx context.Context, tabID string) error
|
||||||
|
|
||||||
|
// SwitchToTab cambia el foco a un tab
|
||||||
|
func (b *Browser) SwitchToTab(ctx context.Context, tabID string) error
|
||||||
|
|
||||||
|
// GetCurrentTab obtiene el tab actual
|
||||||
|
func (b *Browser) GetCurrentTab(ctx context.Context) (*Tab, error)
|
||||||
|
|
||||||
|
// WaitForNewTab espera a que se abra un nuevo tab
|
||||||
|
func (b *Browser) WaitForNewTab(ctx context.Context, action func()) (*Tab, error)
|
||||||
|
|
||||||
|
// OnTabCreated registra callback para tabs nuevos
|
||||||
|
func (b *Browser) OnTabCreated(handler func(*Tab)) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Caso 1: Listar tabs
|
||||||
|
```go
|
||||||
|
tabs, _ := b.GetTabs(ctx)
|
||||||
|
for _, tab := range tabs {
|
||||||
|
log.Printf("Tab %s: %s", tab.ID, tab.Title)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: Abrir nuevo tab
|
||||||
|
```go
|
||||||
|
tabID, _ := b.NewTab(ctx, "https://example.com")
|
||||||
|
log.Printf("Nuevo tab creado: %s", tabID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 3: Esperar y cambiar a nuevo tab
|
||||||
|
```go
|
||||||
|
newTab, _ := b.WaitForNewTab(ctx, func() {
|
||||||
|
b.Click(ctx, "a[target='_blank']")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cambiar al nuevo tab
|
||||||
|
b.SwitchToTab(ctx, newTab.ID)
|
||||||
|
|
||||||
|
// Trabajar en el nuevo tab
|
||||||
|
b.WaitForNavigation(ctx, nil)
|
||||||
|
log.Printf("Nuevo tab URL: %s", newTab.URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 4: Cerrar tabs excepto el principal
|
||||||
|
```go
|
||||||
|
tabs, _ := b.GetTabs(ctx)
|
||||||
|
currentTab, _ := b.GetCurrentTab(ctx)
|
||||||
|
|
||||||
|
for _, tab := range tabs {
|
||||||
|
if tab.ID != currentTab.ID {
|
||||||
|
b.CloseTab(ctx, tab.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 5: Trabajar con múltiples tabs
|
||||||
|
```go
|
||||||
|
// Abrir múltiples tabs
|
||||||
|
tab1, _ := b.NewTab(ctx, "https://site1.com")
|
||||||
|
tab2, _ := b.NewTab(ctx, "https://site2.com")
|
||||||
|
tab3, _ := b.NewTab(ctx, "https://site3.com")
|
||||||
|
|
||||||
|
// Hacer algo en cada tab
|
||||||
|
for _, tabID := range []string{tab1, tab2, tab3} {
|
||||||
|
b.SwitchToTab(ctx, tabID)
|
||||||
|
b.WaitForNavigation(ctx, nil)
|
||||||
|
|
||||||
|
title, _ := b.Evaluate(ctx, "document.title")
|
||||||
|
log.Printf("Tab %s: %v", tabID, title.Value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementación interna
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Browser) GetTabs(ctx context.Context) ([]*Tab, error) {
|
||||||
|
var result struct {
|
||||||
|
TargetInfos []struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Attached bool `json:"attached"`
|
||||||
|
} `json:"targetInfos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.getTargets", nil, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get targets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs []*Tab
|
||||||
|
for _, info := range result.TargetInfos {
|
||||||
|
if info.Type == "page" {
|
||||||
|
tabs = append(tabs, &Tab{
|
||||||
|
ID: info.TargetID,
|
||||||
|
URL: info.URL,
|
||||||
|
Title: info.Title,
|
||||||
|
Type: info.Type,
|
||||||
|
Attached: info.Attached,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) NewTab(ctx context.Context, url string) (string, error) {
|
||||||
|
var result struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.createTarget", params, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create tab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.TargetID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) SwitchToTab(ctx context.Context, tabID string) error {
|
||||||
|
// Activar tab
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.activateTarget", map[string]interface{}{
|
||||||
|
"targetId": tabID,
|
||||||
|
}, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to activate tab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach al tab si no está attached
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.attachToTarget", map[string]interface{}{
|
||||||
|
"targetId": tabID,
|
||||||
|
"flatten": true,
|
||||||
|
}, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to attach to tab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar targetID actual del browser
|
||||||
|
b.targetID = tabID
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Commands
|
||||||
|
|
||||||
|
### Listar tabs
|
||||||
|
```json
|
||||||
|
{"method": "Target.getTargets"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear tab
|
||||||
|
```json
|
||||||
|
{"method": "Target.createTarget", "params": {"url": "https://example.com"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cerrar tab
|
||||||
|
```json
|
||||||
|
{"method": "Target.closeTarget", "params": {"targetId": "ABC123"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activar tab
|
||||||
|
```json
|
||||||
|
{"method": "Target.activateTarget", "params": {"targetId": "ABC123"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attach a tab
|
||||||
|
```json
|
||||||
|
{"method": "Target.attachToTarget", "params": {"targetId": "ABC123", "flatten": true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Eventos CDP
|
||||||
|
|
||||||
|
### Nuevo tab creado
|
||||||
|
```go
|
||||||
|
b.cdpClient.On("Target.targetCreated", func(params json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
TargetInfo struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"targetInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal(params, &event)
|
||||||
|
|
||||||
|
if event.TargetInfo.Type == "page" {
|
||||||
|
// Nuevo tab creado
|
||||||
|
log.Printf("New tab: %s", event.TargetInfo.TargetID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consideraciones especiales
|
||||||
|
|
||||||
|
### Session management
|
||||||
|
- Cada tab requiere su propia sesión CDP
|
||||||
|
- Mantener mapa de tabID -> sessionID
|
||||||
|
- Enviar comandos al tab correcto
|
||||||
|
|
||||||
|
### Popup handling
|
||||||
|
```go
|
||||||
|
// Detectar popups automáticamente
|
||||||
|
b.OnTabCreated(func(tab *Tab) {
|
||||||
|
if strings.Contains(tab.URL, "popup") {
|
||||||
|
b.SwitchToTab(ctx, tab.ID)
|
||||||
|
// Manejar popup
|
||||||
|
b.CloseTab(ctx, tab.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory management
|
||||||
|
- Cerrar tabs que no se usan
|
||||||
|
- Detach de tabs inactivos
|
||||||
|
- Limpiar event listeners
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestMultipleTabs(t *testing.T) {
|
||||||
|
// Crear 3 tabs
|
||||||
|
tab1, _ := b.NewTab(ctx, "https://example.com")
|
||||||
|
tab2, _ := b.NewTab(ctx, "https://google.com")
|
||||||
|
tab3, _ := b.NewTab(ctx, "https://github.com")
|
||||||
|
|
||||||
|
// Verificar que existen
|
||||||
|
tabs, _ := b.GetTabs(ctx)
|
||||||
|
assert.Len(t, tabs, 4) // 3 + tab inicial
|
||||||
|
|
||||||
|
// Cambiar entre tabs
|
||||||
|
b.SwitchToTab(ctx, tab1)
|
||||||
|
current, _ := b.GetCurrentTab(ctx)
|
||||||
|
assert.Equal(t, tab1, current.ID)
|
||||||
|
|
||||||
|
// Cerrar tabs
|
||||||
|
b.CloseTab(ctx, tab1)
|
||||||
|
b.CloseTab(ctx, tab2)
|
||||||
|
b.CloseTab(ctx, tab3)
|
||||||
|
|
||||||
|
tabs, _ = b.GetTabs(ctx)
|
||||||
|
assert.Len(t, tabs, 1) // Solo tab inicial
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/
|
||||||
|
- Playwright pages: https://playwright.dev/docs/pages
|
||||||
|
- Selenium window handles: https://www.selenium.dev/documentation/webdriver/interactions/windows/
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Issue #007: Alert/Prompt/Confirm Handling
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar manejo de JavaScript dialogs (alert, prompt, confirm) que aparecen en páginas web.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Tipos de dialogs
|
||||||
|
- **Alert**: `window.alert("mensaje")` - Solo botón OK
|
||||||
|
- **Confirm**: `window.confirm("¿Continuar?")` - OK/Cancel, retorna boolean
|
||||||
|
- **Prompt**: `window.prompt("Nombre:", "default")` - Input + OK/Cancel
|
||||||
|
|
||||||
|
### Operaciones
|
||||||
|
- Detectar cuando aparece un dialog
|
||||||
|
- Aceptar dialog (OK)
|
||||||
|
- Rechazar dialog (Cancel)
|
||||||
|
- Enviar texto a prompt
|
||||||
|
- Obtener mensaje del dialog
|
||||||
|
- Manejar dialogs automáticamente con reglas
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
`pkg/browser/dialogs.go`
|
||||||
|
|
||||||
|
### CDP Domain
|
||||||
|
**Page.javascriptDialogOpening** - Evento cuando aparece dialog
|
||||||
|
**Page.handleJavaScriptDialog** - Responder al dialog
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DialogType tipo de dialog JavaScript
|
||||||
|
type DialogType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DialogTypeAlert DialogType = "alert"
|
||||||
|
DialogTypeConfirm DialogType = "confirm"
|
||||||
|
DialogTypePrompt DialogType = "prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialogAction acción a tomar con el dialog
|
||||||
|
type DialogAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DialogAccept DialogAction = "accept" // OK
|
||||||
|
DialogDismiss DialogAction = "dismiss" // Cancel
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dialog representa un dialog JavaScript
|
||||||
|
type Dialog struct {
|
||||||
|
Type DialogType
|
||||||
|
Message string
|
||||||
|
DefaultPromptText string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDialog maneja un dialog JavaScript cuando aparece
|
||||||
|
func (b *Browser) HandleDialog(ctx context.Context, action DialogAction, promptText string) error
|
||||||
|
|
||||||
|
// OnDialog registra un handler para dialogs
|
||||||
|
func (b *Browser) OnDialog(handler func(*Dialog) (DialogAction, string)) error
|
||||||
|
|
||||||
|
// WaitForDialog espera a que aparezca un dialog
|
||||||
|
func (b *Browser) WaitForDialog(ctx context.Context) (*Dialog, error)
|
||||||
|
|
||||||
|
// AcceptDialog acepta el próximo dialog que aparezca
|
||||||
|
func (b *Browser) AcceptDialog(ctx context.Context) error
|
||||||
|
|
||||||
|
// DismissDialog rechaza el próximo dialog que aparezca
|
||||||
|
func (b *Browser) DismissDialog(ctx context.Context) error
|
||||||
|
|
||||||
|
// PromptDialog responde a un prompt con texto
|
||||||
|
func (b *Browser) PromptDialog(ctx context.Context, text string) error
|
||||||
|
|
||||||
|
// AutoHandleDialogs configura manejo automático de dialogs
|
||||||
|
func (b *Browser) AutoHandleDialogs(ctx context.Context, action DialogAction) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Caso 1: Aceptar alert automáticamente
|
||||||
|
```go
|
||||||
|
// Configurar manejo automático
|
||||||
|
b.AutoHandleDialogs(ctx, browser.DialogAccept)
|
||||||
|
|
||||||
|
// Cualquier alert será aceptado automáticamente
|
||||||
|
b.Click(ctx, "#trigger-alert")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: Manejar confirm con lógica
|
||||||
|
```go
|
||||||
|
b.OnDialog(func(dialog *browser.Dialog) (browser.DialogAction, string) {
|
||||||
|
log.Printf("Dialog: %s - %s", dialog.Type, dialog.Message)
|
||||||
|
|
||||||
|
if dialog.Type == browser.DialogTypeConfirm {
|
||||||
|
if strings.Contains(dialog.Message, "eliminar") {
|
||||||
|
return browser.DialogDismiss, "" // Cancelar eliminación
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return browser.DialogAccept, ""
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Click(ctx, "#delete-button")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 3: Responder a prompt
|
||||||
|
```go
|
||||||
|
// Esperar prompt y responder
|
||||||
|
go func() {
|
||||||
|
dialog, _ := b.WaitForDialog(ctx)
|
||||||
|
if dialog.Type == browser.DialogTypePrompt {
|
||||||
|
b.PromptDialog(ctx, "Mi nombre")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
b.Click(ctx, "#ask-name-button")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 4: Aceptar dialog específico
|
||||||
|
```go
|
||||||
|
// Preparar handler antes de la acción
|
||||||
|
b.AcceptDialog(ctx)
|
||||||
|
|
||||||
|
// Acción que genera dialog
|
||||||
|
b.Click(ctx, "#show-alert")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos CDP necesarios
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. Habilitar eventos de dialog
|
||||||
|
{"method": "Page.enable"}
|
||||||
|
|
||||||
|
// 2. Escuchar evento de dialog
|
||||||
|
// Evento: "Page.javascriptDialogOpening"
|
||||||
|
// Params: {
|
||||||
|
// "url": "https://...",
|
||||||
|
// "message": "Mensaje del dialog",
|
||||||
|
// "type": "alert|confirm|prompt",
|
||||||
|
// "defaultPrompt": "texto default" // solo en prompt
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 3. Responder al dialog
|
||||||
|
{"method": "Page.handleJavaScriptDialog", "params": {
|
||||||
|
"accept": true, // true = OK, false = Cancel
|
||||||
|
"promptText": "texto de respuesta" // opcional, solo para prompt
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementación interna
|
||||||
|
|
||||||
|
```go
|
||||||
|
type dialogHandler struct {
|
||||||
|
action DialogAction
|
||||||
|
promptText string
|
||||||
|
callback func(*Dialog) (DialogAction, string)
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) setupDialogHandling() {
|
||||||
|
b.cdpClient.On("Page.javascriptDialogOpening", func(params json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
DefaultPrompt string `json:"defaultPrompt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal(params, &event)
|
||||||
|
|
||||||
|
dialog := &Dialog{
|
||||||
|
Type: DialogType(event.Type),
|
||||||
|
Message: event.Message,
|
||||||
|
DefaultPromptText: event.DefaultPrompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar con handler registrado
|
||||||
|
action, text := b.processDialog(dialog)
|
||||||
|
|
||||||
|
// Responder
|
||||||
|
b.cdpClient.SendCommand(context.Background(), "Page.handleJavaScriptDialog", map[string]interface{}{
|
||||||
|
"accept": action == DialogAccept,
|
||||||
|
"promptText": text,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consideraciones especiales
|
||||||
|
|
||||||
|
### Timing crítico
|
||||||
|
- Los dialogs **bloquean** JavaScript hasta que se responden
|
||||||
|
- Debe haber handler registrado ANTES de que aparezca el dialog
|
||||||
|
- Si no se maneja, Chrome esperará indefinidamente
|
||||||
|
|
||||||
|
### beforeunload dialogs
|
||||||
|
```go
|
||||||
|
// Dialogs de "¿Seguro que quieres salir?"
|
||||||
|
// Se generan al cerrar tab/navegador
|
||||||
|
b.OnDialog(func(dialog *Dialog) (browser.DialogAction, string) {
|
||||||
|
if dialog.Type == browser.DialogTypeBeforeUnload {
|
||||||
|
return browser.DialogAccept, "" // Permitir salir
|
||||||
|
}
|
||||||
|
return browser.DialogAccept, ""
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Headless mode
|
||||||
|
- En modo headless, los dialogs no se muestran visualmente
|
||||||
|
- Pero igual generan el evento y deben manejarse
|
||||||
|
- Importante para testing automatizado
|
||||||
|
|
||||||
|
### Timeout en dialogs
|
||||||
|
```go
|
||||||
|
// Implementar timeout para evitar quedar colgado
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dialog, err := b.WaitForDialog(ctx)
|
||||||
|
if err == context.DeadlineExceeded {
|
||||||
|
log.Println("No apareció dialog en 5s")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Página de prueba
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<button onclick="alert('Hola')">Alert</button>
|
||||||
|
<button onclick="confirm('¿Continuar?')">Confirm</button>
|
||||||
|
<button onclick="prompt('Nombre:')">Prompt</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Test beforeunload
|
||||||
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```go
|
||||||
|
func TestAlertHandling(t *testing.T) {
|
||||||
|
b.AutoHandleDialogs(ctx, browser.DialogAccept)
|
||||||
|
b.Navigate(ctx, "test.html", nil)
|
||||||
|
b.Click(ctx, "button:nth-child(1)")
|
||||||
|
// No debe quedar colgado
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptResponse(t *testing.T) {
|
||||||
|
b.OnDialog(func(d *browser.Dialog) (browser.DialogAction, string) {
|
||||||
|
if d.Type == browser.DialogTypePrompt {
|
||||||
|
return browser.DialogAccept, "Test Name"
|
||||||
|
}
|
||||||
|
return browser.DialogAccept, ""
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Click(ctx, "button:nth-child(3)")
|
||||||
|
result, _ := b.Evaluate(ctx, "lastPromptResult")
|
||||||
|
assert.Equal(t, "Test Name", result.Value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejemplos de uso real
|
||||||
|
|
||||||
|
### Login con confirm
|
||||||
|
```go
|
||||||
|
b.OnDialog(func(d *browser.Dialog) (browser.DialogAction, string) {
|
||||||
|
if strings.Contains(d.Message, "logout") {
|
||||||
|
return browser.DialogAccept, ""
|
||||||
|
}
|
||||||
|
return browser.DialogDismiss, ""
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Click(ctx, "#logout-button")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formulario con prompt
|
||||||
|
```go
|
||||||
|
b.PromptDialog(ctx, "usuario@example.com")
|
||||||
|
b.Click(ctx, "#ask-email-button")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Page.handleJavaScriptDialog: https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-handleJavaScriptDialog
|
||||||
|
- CDP Page.javascriptDialogOpening: https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-javascriptDialogOpening
|
||||||
|
- Playwright Dialogs: https://playwright.dev/docs/dialogs
|
||||||
|
- Selenium Alerts: https://www.selenium.dev/documentation/webdriver/interactions/alerts/
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# Issue #008: Screenshot de Elementos Específicos
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar capacidad de tomar screenshots de elementos específicos de la página en lugar de solo página completa.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Operaciones
|
||||||
|
- Screenshot de elemento específico por selector CSS
|
||||||
|
- Screenshot de región (coordenadas x, y, width, height)
|
||||||
|
- Screenshot con padding/margin alrededor del elemento
|
||||||
|
- Scroll automático al elemento antes de capturar
|
||||||
|
- Esperar a que elemento sea visible antes de capturar
|
||||||
|
- Captura de múltiples elementos en batch
|
||||||
|
- Captura con o sin sombras CSS
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
Extender `pkg/browser/navigation.go` o crear `pkg/browser/screenshots.go`
|
||||||
|
|
||||||
|
### CDP Methods
|
||||||
|
- **DOM.getBoxModel** - Obtener dimensiones del elemento
|
||||||
|
- **Page.captureScreenshot** - Capturar con clip region
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ScreenshotElementOptions opciones para screenshot de elemento
|
||||||
|
type ScreenshotElementOptions struct {
|
||||||
|
Format string // "png" o "jpeg" (default: png)
|
||||||
|
Quality int // 0-100 para JPEG (default: 80)
|
||||||
|
Padding int // Padding en pixels alrededor del elemento
|
||||||
|
WaitVisible bool // Esperar a que sea visible (default: true)
|
||||||
|
ScrollIntoView bool // Scroll al elemento antes (default: true)
|
||||||
|
OmitBackground bool // Fondo transparente (default: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultScreenshotElementOptions retorna opciones por defecto
|
||||||
|
func DefaultScreenshotElementOptions() *ScreenshotElementOptions
|
||||||
|
|
||||||
|
// ScreenshotElement toma screenshot de un elemento específico
|
||||||
|
func (b *Browser) ScreenshotElement(ctx context.Context, selector string, opts *ScreenshotElementOptions) ([]byte, error)
|
||||||
|
|
||||||
|
// ScreenshotElementToFile guarda screenshot de elemento a archivo
|
||||||
|
func (b *Browser) ScreenshotElementToFile(ctx context.Context, selector string, filepath string, opts *ScreenshotElementOptions) error
|
||||||
|
|
||||||
|
// ScreenshotRegion toma screenshot de región específica
|
||||||
|
func (b *Browser) ScreenshotRegion(ctx context.Context, x, y, width, height int) ([]byte, error)
|
||||||
|
|
||||||
|
// ScreenshotElements toma screenshots de múltiples elementos
|
||||||
|
func (b *Browser) ScreenshotElements(ctx context.Context, selectors []string, opts *ScreenshotElementOptions) (map[string][]byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Caso 1: Screenshot de botón específico
|
||||||
|
```go
|
||||||
|
opts := browser.DefaultScreenshotElementOptions()
|
||||||
|
opts.Padding = 10 // 10px de margen
|
||||||
|
|
||||||
|
screenshot, _ := b.ScreenshotElement(ctx, "#submit-button", opts)
|
||||||
|
os.WriteFile("button.png", screenshot, 0644)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: Screenshot de cada producto
|
||||||
|
```go
|
||||||
|
products := []string{
|
||||||
|
".product:nth-child(1)",
|
||||||
|
".product:nth-child(2)",
|
||||||
|
".product:nth-child(3)",
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshots, _ := b.ScreenshotElements(ctx, products, nil)
|
||||||
|
for selector, data := range screenshots {
|
||||||
|
filename := strings.ReplaceAll(selector, ":", "-") + ".png"
|
||||||
|
os.WriteFile(filename, data, 0644)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 3: Screenshot con fondo transparente
|
||||||
|
```go
|
||||||
|
opts := &browser.ScreenshotElementOptions{
|
||||||
|
Format: "png",
|
||||||
|
OmitBackground: true, // PNG transparente
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshot, _ := b.ScreenshotElement(ctx, ".icon", opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 4: Screenshot de región específica
|
||||||
|
```go
|
||||||
|
// Capturar área de 300x200 en posición (100, 150)
|
||||||
|
screenshot, _ := b.ScreenshotRegion(ctx, 100, 150, 300, 200)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementación interna
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Browser) ScreenshotElement(ctx context.Context, selector string, opts *ScreenshotElementOptions) ([]byte, error) {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultScreenshotElementOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Esperar a que elemento sea visible si se especificó
|
||||||
|
if opts.WaitVisible {
|
||||||
|
if err := b.WaitForElement(ctx, selector, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("element not visible: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scroll al elemento si se especificó
|
||||||
|
if opts.ScrollIntoView {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
document.querySelector('%s').scrollIntoView({
|
||||||
|
behavior: 'instant',
|
||||||
|
block: 'center'
|
||||||
|
})
|
||||||
|
`, selector)
|
||||||
|
b.Evaluate(ctx, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Obtener dimensiones del elemento
|
||||||
|
var result struct {
|
||||||
|
Model struct {
|
||||||
|
Content []float64 `json:"content"` // [x1, y1, x2, y2, x3, y3, x4, y4]
|
||||||
|
} `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primero obtener nodeId
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener box model
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.getBoxModel", map[string]interface{}{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get box model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular clip region
|
||||||
|
content := result.Model.Content
|
||||||
|
x := content[0]
|
||||||
|
y := content[1]
|
||||||
|
width := content[4] - content[0]
|
||||||
|
height := content[5] - content[1]
|
||||||
|
|
||||||
|
// Aplicar padding
|
||||||
|
if opts.Padding > 0 {
|
||||||
|
x -= float64(opts.Padding)
|
||||||
|
y -= float64(opts.Padding)
|
||||||
|
width += float64(opts.Padding * 2)
|
||||||
|
height += float64(opts.Padding * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Capturar screenshot con clip
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"format": opts.Format,
|
||||||
|
"clip": map[string]interface{}{
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"scale": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OmitBackground {
|
||||||
|
params["captureBeyondViewport"] = true
|
||||||
|
params["fromSurface"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Format == "jpeg" && opts.Quality > 0 {
|
||||||
|
params["quality"] = opts.Quality
|
||||||
|
}
|
||||||
|
|
||||||
|
var screenshotResult struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Page.captureScreenshot", params, &screenshotResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to capture screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Decodificar base64
|
||||||
|
data, err := base64.StdEncoding.DecodeString(screenshotResult.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos CDP
|
||||||
|
|
||||||
|
### Obtener dimensiones del elemento
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "DOM.getBoxModel",
|
||||||
|
"params": {
|
||||||
|
"nodeId": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response:
|
||||||
|
{
|
||||||
|
"model": {
|
||||||
|
"content": [x1, y1, x2, y2, x3, y3, x4, y4],
|
||||||
|
"padding": [...],
|
||||||
|
"border": [...],
|
||||||
|
"margin": [...],
|
||||||
|
"width": 200,
|
||||||
|
"height": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capturar con clip
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "Page.captureScreenshot",
|
||||||
|
"params": {
|
||||||
|
"format": "png",
|
||||||
|
"clip": {
|
||||||
|
"x": 100,
|
||||||
|
"y": 200,
|
||||||
|
"width": 300,
|
||||||
|
"height": 150,
|
||||||
|
"scale": 1
|
||||||
|
},
|
||||||
|
"captureBeyondViewport": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso avanzados
|
||||||
|
|
||||||
|
### Comparación visual
|
||||||
|
```go
|
||||||
|
// Capturar antes y después de una acción
|
||||||
|
before, _ := b.ScreenshotElement(ctx, "#component", nil)
|
||||||
|
|
||||||
|
b.Click(ctx, "#toggle-button")
|
||||||
|
|
||||||
|
after, _ := b.ScreenshotElement(ctx, "#component", nil)
|
||||||
|
|
||||||
|
// Comparar imágenes
|
||||||
|
if !bytes.Equal(before, after) {
|
||||||
|
log.Println("El componente cambió visualmente")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generación de thumbnails
|
||||||
|
```go
|
||||||
|
opts := &browser.ScreenshotElementOptions{
|
||||||
|
Format: "jpeg",
|
||||||
|
Quality: 60, // Compresión para thumbnails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capturar todos los artículos
|
||||||
|
articles := []string{".article-1", ".article-2", ".article-3"}
|
||||||
|
thumbnails, _ := b.ScreenshotElements(ctx, articles, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshot de elemento fuera de viewport
|
||||||
|
```go
|
||||||
|
// Elemento muy abajo en la página
|
||||||
|
opts := &browser.ScreenshotElementOptions{
|
||||||
|
ScrollIntoView: true, // Scroll automático
|
||||||
|
WaitVisible: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshot, _ := b.ScreenshotElement(ctx, "#footer-logo", opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mejoras adicionales
|
||||||
|
|
||||||
|
### Screenshot de elemento con sombra
|
||||||
|
```go
|
||||||
|
// Incluir box-shadow en captura
|
||||||
|
opts.IncludeShadow = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshot de elemento rotado
|
||||||
|
```go
|
||||||
|
// Calcular bounding box considerando rotación CSS
|
||||||
|
opts.ConsiderTransform = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshot de SVG específico
|
||||||
|
```go
|
||||||
|
// Elementos SVG pueden necesitar manejo especial
|
||||||
|
screenshot, _ := b.ScreenshotElement(ctx, "svg#chart", opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP DOM.getBoxModel: https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getBoxModel
|
||||||
|
- CDP Page.captureScreenshot: https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot
|
||||||
|
- Playwright elementHandle.screenshot: https://playwright.dev/docs/api/class-elementhandle#element-handle-screenshot
|
||||||
|
- Puppeteer element screenshots: https://pptr.dev/api/puppeteer.elementhandle.screenshot
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
# Issue #009: PDF Generation
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar generación de PDFs de páginas web, similar a "Imprimir a PDF" del navegador.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Operaciones básicas
|
||||||
|
- Generar PDF de página completa
|
||||||
|
- Generar PDF de página actual (viewport)
|
||||||
|
- Control de formato de página (A4, Letter, etc.)
|
||||||
|
- Orientación (portrait/landscape)
|
||||||
|
- Márgenes personalizables
|
||||||
|
- Headers y footers personalizados
|
||||||
|
- Background graphics (imágenes de fondo)
|
||||||
|
- Scale/zoom del contenido
|
||||||
|
|
||||||
|
### Operaciones avanzadas
|
||||||
|
- Rangos de páginas específicos
|
||||||
|
- Números de página
|
||||||
|
- Fecha/hora en header/footer
|
||||||
|
- CSS para medios de impresión
|
||||||
|
- Protección de PDF (opcional)
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
`pkg/browser/pdf.go`
|
||||||
|
|
||||||
|
### CDP Method
|
||||||
|
**Page.printToPDF** - Genera PDF de la página
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// PDFFormat formato de papel
|
||||||
|
type PDFFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PDFFormatA4 PDFFormat = "A4"
|
||||||
|
PDFFormatLetter PDFFormat = "Letter"
|
||||||
|
PDFFormatLegal PDFFormat = "Legal"
|
||||||
|
PDFFormatA3 PDFFormat = "A3"
|
||||||
|
PDFFormatTabloid PDFFormat = "Tabloid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PDFOrientation orientación de página
|
||||||
|
type PDFOrientation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PDFOrientationPortrait PDFOrientation = "portrait"
|
||||||
|
PDFOrientationLandscape PDFOrientation = "landscape"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PDFMargins márgenes del PDF
|
||||||
|
type PDFMargins struct {
|
||||||
|
Top float64 // En pulgadas
|
||||||
|
Right float64
|
||||||
|
Bottom float64
|
||||||
|
Left float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDFOptions opciones para generación de PDF
|
||||||
|
type PDFOptions struct {
|
||||||
|
// Formato de papel
|
||||||
|
Format PDFFormat // Default: A4
|
||||||
|
|
||||||
|
// Orientación
|
||||||
|
Orientation PDFOrientation // Default: portrait
|
||||||
|
|
||||||
|
// Dimensiones personalizadas (en pulgadas)
|
||||||
|
// Si se especifica, ignora Format
|
||||||
|
Width float64
|
||||||
|
Height float64
|
||||||
|
|
||||||
|
// Márgenes (en pulgadas)
|
||||||
|
Margins PDFMargins // Default: 1cm todos
|
||||||
|
|
||||||
|
// Scale del contenido (0.1 - 2.0)
|
||||||
|
Scale float64 // Default: 1.0
|
||||||
|
|
||||||
|
// Incluir colores y gráficos de fondo
|
||||||
|
PrintBackground bool // Default: false
|
||||||
|
|
||||||
|
// Rango de páginas (ej: "1-5, 8, 11-13")
|
||||||
|
PageRanges string
|
||||||
|
|
||||||
|
// Header template (HTML)
|
||||||
|
HeaderTemplate string
|
||||||
|
|
||||||
|
// Footer template (HTML)
|
||||||
|
FooterTemplate string
|
||||||
|
|
||||||
|
// Mostrar header y footer
|
||||||
|
DisplayHeaderFooter bool
|
||||||
|
|
||||||
|
// Preferir CSS para @media print
|
||||||
|
PreferCSSPageSize bool
|
||||||
|
|
||||||
|
// Generar PDFs etiquetados (accesibilidad)
|
||||||
|
GenerateTaggedPDF bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPDFOptions retorna opciones por defecto
|
||||||
|
func DefaultPDFOptions() *PDFOptions
|
||||||
|
|
||||||
|
// GeneratePDF genera un PDF de la página actual
|
||||||
|
func (b *Browser) GeneratePDF(ctx context.Context, opts *PDFOptions) ([]byte, error)
|
||||||
|
|
||||||
|
// SavePDF genera y guarda PDF a archivo
|
||||||
|
func (b *Browser) SavePDF(ctx context.Context, filepath string, opts *PDFOptions) error
|
||||||
|
|
||||||
|
// PrintToPDF genera PDF (alias de GeneratePDF)
|
||||||
|
func (b *Browser) PrintToPDF(ctx context.Context, opts *PDFOptions) ([]byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Caso 1: PDF simple
|
||||||
|
```go
|
||||||
|
// PDF con opciones por defecto (A4, portrait)
|
||||||
|
pdf, _ := b.GeneratePDF(ctx, nil)
|
||||||
|
os.WriteFile("page.pdf", pdf, 0644)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: PDF con configuración personalizada
|
||||||
|
```go
|
||||||
|
opts := &browser.PDFOptions{
|
||||||
|
Format: browser.PDFFormatLetter,
|
||||||
|
Orientation: browser.PDFOrientationLandscape,
|
||||||
|
PrintBackground: true, // Incluir colores de fondo
|
||||||
|
Scale: 0.8, // 80% del tamaño
|
||||||
|
Margins: browser.PDFMargins{
|
||||||
|
Top: 0.5,
|
||||||
|
Right: 0.5,
|
||||||
|
Bottom: 0.5,
|
||||||
|
Left: 0.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf, _ := b.GeneratePDF(ctx, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 3: PDF con header y footer
|
||||||
|
```go
|
||||||
|
opts := &browser.PDFOptions{
|
||||||
|
DisplayHeaderFooter: true,
|
||||||
|
HeaderTemplate: `
|
||||||
|
<div style="font-size: 10px; text-align: center; width: 100%;">
|
||||||
|
<span class="title"></span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
FooterTemplate: `
|
||||||
|
<div style="font-size: 10px; text-align: center; width: 100%;">
|
||||||
|
Página <span class="pageNumber"></span> de <span class="totalPages"></span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf, _ := b.GeneratePDF(ctx, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 4: PDF de rango específico
|
||||||
|
```go
|
||||||
|
opts := &browser.PDFOptions{
|
||||||
|
PageRanges: "1-3, 5", // Solo páginas 1, 2, 3 y 5
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf, _ := b.GeneratePDF(ctx, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 5: Guardar directamente a archivo
|
||||||
|
```go
|
||||||
|
opts := browser.DefaultPDFOptions()
|
||||||
|
opts.Format = browser.PDFFormatA4
|
||||||
|
opts.PrintBackground = true
|
||||||
|
|
||||||
|
b.SavePDF(ctx, "report.pdf", opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementación interna
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Browser) GeneratePDF(ctx context.Context, opts *PDFOptions) ([]byte, error) {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultPDFOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir parámetros CDP
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"printBackground": opts.PrintBackground,
|
||||||
|
"displayHeaderFooter": opts.DisplayHeaderFooter,
|
||||||
|
"preferCSSPageSize": opts.PreferCSSPageSize,
|
||||||
|
"generateTaggedPDF": opts.GenerateTaggedPDF,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formato o dimensiones custom
|
||||||
|
if opts.Width > 0 && opts.Height > 0 {
|
||||||
|
params["paperWidth"] = opts.Width
|
||||||
|
params["paperHeight"] = opts.Height
|
||||||
|
} else {
|
||||||
|
// Usar formato predefinido
|
||||||
|
params["format"] = string(opts.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orientación
|
||||||
|
if opts.Orientation != "" {
|
||||||
|
params["landscape"] = opts.Orientation == PDFOrientationLandscape
|
||||||
|
}
|
||||||
|
|
||||||
|
// Márgenes
|
||||||
|
params["marginTop"] = opts.Margins.Top
|
||||||
|
params["marginRight"] = opts.Margins.Right
|
||||||
|
params["marginBottom"] = opts.Margins.Bottom
|
||||||
|
params["marginLeft"] = opts.Margins.Left
|
||||||
|
|
||||||
|
// Scale
|
||||||
|
if opts.Scale > 0 {
|
||||||
|
params["scale"] = opts.Scale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page ranges
|
||||||
|
if opts.PageRanges != "" {
|
||||||
|
params["pageRanges"] = opts.PageRanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
if opts.HeaderTemplate != "" {
|
||||||
|
params["headerTemplate"] = opts.HeaderTemplate
|
||||||
|
}
|
||||||
|
if opts.FooterTemplate != "" {
|
||||||
|
params["footerTemplate"] = opts.FooterTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar comando
|
||||||
|
var result struct {
|
||||||
|
Data string `json:"data"` // Base64
|
||||||
|
Stream string `json:"stream"` // Stream handle (para PDFs grandes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Page.printToPDF", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decodificar base64
|
||||||
|
data, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultPDFOptions() *PDFOptions {
|
||||||
|
return &PDFOptions{
|
||||||
|
Format: PDFFormatA4,
|
||||||
|
Orientation: PDFOrientationPortrait,
|
||||||
|
Scale: 1.0,
|
||||||
|
Margins: PDFMargins{
|
||||||
|
Top: 0.4, // ~1cm
|
||||||
|
Right: 0.4,
|
||||||
|
Bottom: 0.4,
|
||||||
|
Left: 0.4,
|
||||||
|
},
|
||||||
|
PrintBackground: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos CDP
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "Page.printToPDF",
|
||||||
|
"params": {
|
||||||
|
"landscape": false,
|
||||||
|
"displayHeaderFooter": true,
|
||||||
|
"printBackground": true,
|
||||||
|
"scale": 1,
|
||||||
|
"paperWidth": 8.5,
|
||||||
|
"paperHeight": 11,
|
||||||
|
"marginTop": 0.4,
|
||||||
|
"marginBottom": 0.4,
|
||||||
|
"marginLeft": 0.4,
|
||||||
|
"marginRight": 0.4,
|
||||||
|
"pageRanges": "1-5",
|
||||||
|
"headerTemplate": "<div>Header</div>",
|
||||||
|
"footerTemplate": "<div>Footer</div>",
|
||||||
|
"preferCSSPageSize": false,
|
||||||
|
"generateTaggedPDF": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response:
|
||||||
|
{
|
||||||
|
"data": "base64_encoded_pdf_data..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables en templates
|
||||||
|
|
||||||
|
### Header/Footer templates soportan:
|
||||||
|
- `<span class="date"></span>` - Fecha actual
|
||||||
|
- `<span class="title"></span>` - Título de la página
|
||||||
|
- `<span class="url"></span>` - URL de la página
|
||||||
|
- `<span class="pageNumber"></span>` - Número de página actual
|
||||||
|
- `<span class="totalPages"></span>` - Total de páginas
|
||||||
|
|
||||||
|
### Ejemplo de template completo
|
||||||
|
```html
|
||||||
|
<div style="font-size: 10px; width: 100%; padding: 0 1cm;">
|
||||||
|
<div style="float: left;">
|
||||||
|
<span class="title"></span>
|
||||||
|
</div>
|
||||||
|
<div style="float: right;">
|
||||||
|
<span class="date"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS para impresión
|
||||||
|
|
||||||
|
### Aplicar estilos específicos para PDF
|
||||||
|
```css
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inyectar CSS antes de generar PDF
|
||||||
|
```go
|
||||||
|
// Inyectar estilos de impresión
|
||||||
|
b.Evaluate(ctx, `
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = '@media print { .sidebar { display: none; } }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Generar PDF
|
||||||
|
pdf, _ := b.GeneratePDF(ctx, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso avanzados
|
||||||
|
|
||||||
|
### Generar reporte con múltiples páginas
|
||||||
|
```go
|
||||||
|
// Navegar a página de reporte
|
||||||
|
b.Navigate(ctx, "https://example.com/report", nil)
|
||||||
|
|
||||||
|
// Esperar a que cargue completamente
|
||||||
|
b.WaitForSelector(ctx, ".report-ready", nil)
|
||||||
|
|
||||||
|
// Generar PDF
|
||||||
|
opts := &browser.PDFOptions{
|
||||||
|
Format: browser.PDFFormatA4,
|
||||||
|
PrintBackground: true,
|
||||||
|
DisplayHeaderFooter: true,
|
||||||
|
HeaderTemplate: `<div style="font-size: 10px; text-align: right; width: 100%; padding-right: 1cm;">
|
||||||
|
Reporte generado: <span class="date"></span>
|
||||||
|
</div>`,
|
||||||
|
FooterTemplate: `<div style="font-size: 10px; text-align: center; width: 100%;">
|
||||||
|
<span class="pageNumber"></span> / <span class="totalPages"></span>
|
||||||
|
</div>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SavePDF(ctx, "reporte.pdf", opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDF con contenido dinámico
|
||||||
|
```go
|
||||||
|
// Generar contenido dinámico
|
||||||
|
b.Evaluate(ctx, `
|
||||||
|
document.body.innerHTML = '<h1>Reporte Dinámico</h1>';
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
document.body.innerHTML += '<p>Elemento ' + i + '</p>';
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Generar PDF
|
||||||
|
pdf, _ := b.GeneratePDF(ctx, nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch PDF generation
|
||||||
|
```go
|
||||||
|
urls := []string{
|
||||||
|
"https://example.com/page1",
|
||||||
|
"https://example.com/page2",
|
||||||
|
"https://example.com/page3",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, url := range urls {
|
||||||
|
b.Navigate(ctx, url, nil)
|
||||||
|
b.WaitForNavigation(ctx, nil)
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("page_%d.pdf", i+1)
|
||||||
|
b.SavePDF(ctx, filename, nil)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
### Tamaño del PDF
|
||||||
|
- PDFs grandes pueden exceder límite de respuesta CDP
|
||||||
|
- Usar streaming para PDFs > 10MB (no implementado en v1)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Generación de PDF es **bloqueante**
|
||||||
|
- Puede tomar varios segundos para páginas grandes
|
||||||
|
- Considerar timeout apropiado
|
||||||
|
|
||||||
|
### Calidad
|
||||||
|
- Images embebidas mantienen su resolución
|
||||||
|
- Fonts pueden no incluirse (usar web fonts)
|
||||||
|
- JavaScript no se ejecuta durante generación
|
||||||
|
|
||||||
|
### Headless mode
|
||||||
|
- PDF generation funciona mejor en headless
|
||||||
|
- Algunas páginas pueden requerir modo visible
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Page.printToPDF: https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-printToPDF
|
||||||
|
- Chrome printing: https://developer.chrome.com/docs/chromium/print-previews
|
||||||
|
- Playwright PDF: https://playwright.dev/docs/api/class-page#page-pdf
|
||||||
|
- Puppeteer PDF: https://pptr.dev/api/puppeteer.page.pdf
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Issue #010: Device Emulation Completo
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar emulación completa de dispositivos móviles y tablets (viewport, user-agent, touch, geolocation).
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Emular dispositivos predefinidos (iPhone, iPad, Android, etc.)
|
||||||
|
- Viewport personalizado (width, height, deviceScaleFactor)
|
||||||
|
- User-Agent específico de dispositivo
|
||||||
|
- Touch events habilitados
|
||||||
|
- Orientación (portrait/landscape)
|
||||||
|
- Geolocation personalizada
|
||||||
|
- Timezone específica
|
||||||
|
- Locale/idioma
|
||||||
|
- Permisos de dispositivo
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
type DeviceDescriptor struct {
|
||||||
|
Name string
|
||||||
|
UserAgent string
|
||||||
|
Viewport Viewport
|
||||||
|
DeviceScaleFactor float64
|
||||||
|
IsMobile bool
|
||||||
|
HasTouch bool
|
||||||
|
DefaultOrientation string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Viewport struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmulationOptions struct {
|
||||||
|
Device *DeviceDescriptor
|
||||||
|
Viewport *Viewport
|
||||||
|
UserAgent string
|
||||||
|
IsMobile bool
|
||||||
|
HasTouch bool
|
||||||
|
Orientation string // "portrait" | "landscape"
|
||||||
|
Geolocation *Geolocation
|
||||||
|
Timezone string
|
||||||
|
Locale string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispositivos predefinidos
|
||||||
|
var Devices = map[string]*DeviceDescriptor{
|
||||||
|
"iPhone 13": {...},
|
||||||
|
"iPhone 13 Pro": {...},
|
||||||
|
"iPad Pro": {...},
|
||||||
|
"Pixel 5": {...},
|
||||||
|
"Galaxy S21": {...},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) Emulate(ctx context.Context, opts *EmulationOptions) error
|
||||||
|
func (b *Browser) EmulateDevice(ctx context.Context, deviceName string) error
|
||||||
|
func (b *Browser) SetViewport(ctx context.Context, width, height int) error
|
||||||
|
func (b *Browser) SetUserAgent(ctx context.Context, userAgent string) error
|
||||||
|
func (b *Browser) SetTouchEnabled(ctx context.Context, enabled bool) error
|
||||||
|
func (b *Browser) SetOrientation(ctx context.Context, orientation string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Emular iPhone 13
|
||||||
|
b.EmulateDevice(ctx, "iPhone 13")
|
||||||
|
|
||||||
|
// Emulación personalizada
|
||||||
|
opts := &browser.EmulationOptions{
|
||||||
|
Viewport: &browser.Viewport{Width: 375, Height: 812},
|
||||||
|
UserAgent: "Mozilla/5.0 (iPhone...)",
|
||||||
|
IsMobile: true,
|
||||||
|
HasTouch: true,
|
||||||
|
Orientation: "portrait",
|
||||||
|
}
|
||||||
|
b.Emulate(ctx, opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Emulation.setDeviceMetricsOverride`
|
||||||
|
- `Emulation.setUserAgentOverride`
|
||||||
|
- `Emulation.setTouchEmulationEnabled`
|
||||||
|
- `Emulation.setEmulatedMedia`
|
||||||
|
- `Emulation.setGeolocationOverride`
|
||||||
|
- `Emulation.setTimezoneOverride`
|
||||||
|
- `Emulation.setLocaleOverride`
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Emulation: https://chromedevtools.github.io/devtools-protocol/tot/Emulation/
|
||||||
|
- Playwright devices: https://playwright.dev/docs/emulation
|
||||||
|
- Puppeteer emulation: https://pptr.dev/guides/emulation
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Issue #011: Downloads Handling
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar sistema para detectar, gestionar y esperar downloads de archivos.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Detectar cuando inicia un download
|
||||||
|
- Esperar a que download complete
|
||||||
|
- Obtener path del archivo descargado
|
||||||
|
- Configurar directorio de descargas
|
||||||
|
- Cancelar downloads en progreso
|
||||||
|
- Obtener progreso de download
|
||||||
|
- Manejar múltiples downloads simultáneos
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Download struct {
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
Filename string
|
||||||
|
Path string
|
||||||
|
MimeType string
|
||||||
|
Size int64
|
||||||
|
State DownloadState // "inProgress" | "completed" | "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadState string
|
||||||
|
const (
|
||||||
|
DownloadStateInProgress DownloadState = "inProgress"
|
||||||
|
DownloadStateCompleted DownloadState = "completed"
|
||||||
|
DownloadStateCancelled DownloadState = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadOptions struct {
|
||||||
|
DownloadPath string // Directorio donde guardar
|
||||||
|
Behavior string // "allow" | "deny" | "allowAndName"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) SetDownloadBehavior(ctx context.Context, opts *DownloadOptions) error
|
||||||
|
func (b *Browser) WaitForDownload(ctx context.Context, action func()) (*Download, error)
|
||||||
|
func (b *Browser) OnDownload(handler func(*Download)) error
|
||||||
|
func (b *Browser) GetDownloads(ctx context.Context) ([]*Download, error)
|
||||||
|
func (b *Browser) CancelDownload(ctx context.Context, downloadID string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Configurar directorio de descargas
|
||||||
|
b.SetDownloadBehavior(ctx, &browser.DownloadOptions{
|
||||||
|
DownloadPath: "/tmp/downloads",
|
||||||
|
Behavior: "allow",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Esperar download
|
||||||
|
download, _ := b.WaitForDownload(ctx, func() {
|
||||||
|
b.Click(ctx, "#download-button")
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("Downloaded: %s to %s", download.Filename, download.Path)
|
||||||
|
|
||||||
|
// Handler de downloads
|
||||||
|
b.OnDownload(func(d *browser.Download) {
|
||||||
|
log.Printf("Download started: %s", d.Filename)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Browser.setDownloadBehavior`
|
||||||
|
- `Page.downloadWillBegin` (evento)
|
||||||
|
- `Page.downloadProgress` (evento)
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Browser.setDownloadBehavior: https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior
|
||||||
|
- Playwright downloads: https://playwright.dev/docs/downloads
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Issue #012: Browser Contexts (Multi-sesión)
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Baja (Avanzado)
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar Browser Contexts para múltiples sesiones aisladas en una misma instancia de navegador.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Crear múltiples contextos aislados
|
||||||
|
- Cada contexto tiene su propio:
|
||||||
|
- Storage (cookies, localStorage, sessionStorage)
|
||||||
|
- Cache
|
||||||
|
- Permissions
|
||||||
|
- Geolocation
|
||||||
|
- Compartir proceso de navegador (más eficiente que múltiples perfiles)
|
||||||
|
- Cerrar contextos individualmente
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BrowserContext struct {
|
||||||
|
id string
|
||||||
|
browser *Browser
|
||||||
|
pages []*Page
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextOptions struct {
|
||||||
|
Cookies []*Cookie
|
||||||
|
Permissions []string
|
||||||
|
Geolocation *Geolocation
|
||||||
|
Timezone string
|
||||||
|
Locale string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) NewContext(ctx context.Context, opts *ContextOptions) (*BrowserContext, error)
|
||||||
|
func (bc *BrowserContext) NewPage(ctx context.Context) (*Page, error)
|
||||||
|
func (bc *BrowserContext) Close(ctx context.Context) error
|
||||||
|
func (bc *BrowserContext) ClearCookies(ctx context.Context) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Contexto 1 - Usuario A
|
||||||
|
ctx1, _ := b.NewContext(ctx, &browser.ContextOptions{
|
||||||
|
Cookies: cookiesUserA,
|
||||||
|
})
|
||||||
|
page1, _ := ctx1.NewPage(ctx)
|
||||||
|
page1.Navigate(ctx, "https://example.com")
|
||||||
|
|
||||||
|
// Contexto 2 - Usuario B
|
||||||
|
ctx2, _ := b.NewContext(ctx, &browser.ContextOptions{
|
||||||
|
Cookies: cookiesUserB,
|
||||||
|
})
|
||||||
|
page2, _ := ctx2.NewPage(ctx)
|
||||||
|
page2.Navigate(ctx, "https://example.com")
|
||||||
|
|
||||||
|
// Ambos contextos están completamente aislados
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Target.createBrowserContext`
|
||||||
|
- `Target.disposeBrowserContext`
|
||||||
|
- `Target.createTarget` con browserContextId
|
||||||
|
|
||||||
|
## Ventajas
|
||||||
|
|
||||||
|
- Más eficiente que múltiples instancias de navegador
|
||||||
|
- Rápido para tests paralelos
|
||||||
|
- Ideal para testing multi-usuario
|
||||||
|
- Menor uso de memoria vs múltiples navegadores
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Target.createBrowserContext: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext
|
||||||
|
- Playwright contexts: https://playwright.dev/docs/browser-contexts
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Issue #013: Video Recording
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Baja (Avanzado)
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar grabación de video de la sesión del navegador.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Grabar video de la sesión completa
|
||||||
|
- Configurar resolución y FPS
|
||||||
|
- Guardar en formato MP4/WebM
|
||||||
|
- Start/stop recording bajo demanda
|
||||||
|
- Capturar audio (opcional)
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
type VideoOptions struct {
|
||||||
|
OutputPath string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
FPS int // Frames per second (default: 25)
|
||||||
|
Format string // "mp4" | "webm"
|
||||||
|
AudioCodec string // "opus" | "aac" | ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) StartRecording(ctx context.Context, opts *VideoOptions) error
|
||||||
|
func (b *Browser) StopRecording(ctx context.Context) (string, error)
|
||||||
|
func (b *Browser) PauseRecording(ctx context.Context) error
|
||||||
|
func (b *Browser) ResumeRecording(ctx context.Context) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
opts := &browser.VideoOptions{
|
||||||
|
OutputPath: "./recordings/session.mp4",
|
||||||
|
Width: 1280,
|
||||||
|
Height: 720,
|
||||||
|
FPS: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
b.StartRecording(ctx, opts)
|
||||||
|
|
||||||
|
// Realizar acciones
|
||||||
|
b.Navigate(ctx, "https://example.com", nil)
|
||||||
|
b.Click(ctx, "#button")
|
||||||
|
|
||||||
|
// Detener y guardar
|
||||||
|
videoPath, _ := b.StopRecording(ctx)
|
||||||
|
log.Printf("Video saved: %s", videoPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
|
||||||
|
### Opción 1: CDP Screencast (screenshots en loop)
|
||||||
|
```go
|
||||||
|
// Capturar frames continuamente
|
||||||
|
b.cdpClient.On("Page.screencastFrame", func(params json.RawMessage) {
|
||||||
|
// Guardar frame
|
||||||
|
// Compilar a video con ffmpeg
|
||||||
|
})
|
||||||
|
|
||||||
|
b.cdpClient.SendCommand(ctx, "Page.startScreencast", map[string]interface{}{
|
||||||
|
"format": "jpeg",
|
||||||
|
"quality": 80,
|
||||||
|
"maxWidth": 1280,
|
||||||
|
"maxHeight": 720,
|
||||||
|
"everyNthFrame": 1,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 2: External tool (ffmpeg)
|
||||||
|
```bash
|
||||||
|
# Usar ffmpeg para capturar X11 display
|
||||||
|
ffmpeg -video_size 1280x720 -framerate 25 -f x11grab -i :99 output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 3: Chrome --use-file-for-fake-video-capture
|
||||||
|
```go
|
||||||
|
// Grabar con flags de Chrome
|
||||||
|
config.ChromeFlags = append(config.ChromeFlags,
|
||||||
|
"--use-file-for-fake-video-capture=/dev/video0",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Page.startScreencast`
|
||||||
|
- `Page.screencastFrame` (evento)
|
||||||
|
- `Page.stopScreencast`
|
||||||
|
- `Page.screencastFrameAck`
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
- **Performance**: Recording consume CPU/memoria
|
||||||
|
- **Tamaño**: Videos pueden ser grandes
|
||||||
|
- **Headless**: Requiere Xvfb o display virtual
|
||||||
|
- **Codec**: Necesita ffmpeg o herramienta externa
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Page.startScreencast: https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-startScreencast
|
||||||
|
- Playwright video: https://playwright.dev/docs/videos
|
||||||
|
- Puppeteer video: https://github.com/puppeteer/puppeteer/issues/448
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Issue #014: Network Mocking Avanzado
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Baja (Avanzado)
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar sistema avanzado de interceptación y mocking de requests HTTP/HTTPS.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Interceptar requests antes de enviar
|
||||||
|
- Modificar request (URL, headers, body, method)
|
||||||
|
- Mock responses completas
|
||||||
|
- Simular latencia de red
|
||||||
|
- Simular errores de red
|
||||||
|
- Registro de todas las requests
|
||||||
|
- Pattern matching avanzado (regex, wildcards)
|
||||||
|
- Condicional (solo interceptar si...)
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MockResponse struct {
|
||||||
|
Status int
|
||||||
|
Headers map[string]string
|
||||||
|
Body string
|
||||||
|
Delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterceptorFunc func(req *Request) (*MockResponse, error)
|
||||||
|
|
||||||
|
type RequestPattern struct {
|
||||||
|
URL string // Glob o regex
|
||||||
|
Method string // GET, POST, etc.
|
||||||
|
Condition func(*Request) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) InterceptRequest(ctx context.Context, pattern RequestPattern, handler InterceptorFunc) error
|
||||||
|
func (b *Browser) MockResponse(ctx context.Context, pattern string, response *MockResponse) error
|
||||||
|
func (b *Browser) AbortRequest(ctx context.Context, pattern string) error
|
||||||
|
func (b *Browser) SimulateOffline(ctx context.Context) error
|
||||||
|
func (b *Browser) SimulateSlowConnection(ctx context.Context, downloadThroughput, uploadThroughput int) error
|
||||||
|
func (b *Browser) GetAllRequests(ctx context.Context) ([]*Request, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Mock API response
|
||||||
|
```go
|
||||||
|
b.MockResponse(ctx, "**/api/users", &browser.MockResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
Body: `{"users": [{"id": 1, "name": "Test"}]}`,
|
||||||
|
Delay: 100 * time.Millisecond,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interceptar y modificar
|
||||||
|
```go
|
||||||
|
b.InterceptRequest(ctx, browser.RequestPattern{
|
||||||
|
URL: "**/api/**",
|
||||||
|
}, func(req *browser.Request) (*browser.MockResponse, error) {
|
||||||
|
// Modificar headers
|
||||||
|
req.Headers["Authorization"] = "Bearer fake-token"
|
||||||
|
|
||||||
|
// Dejar continuar request (nil = no mockear)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simular error de red
|
||||||
|
```go
|
||||||
|
b.AbortRequest(ctx, "**/slow-endpoint")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simular conexión lenta
|
||||||
|
```go
|
||||||
|
b.SimulateSlowConnection(ctx,
|
||||||
|
500*1024, // 500 KB/s download
|
||||||
|
100*1024, // 100 KB/s upload
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capturar todas las requests
|
||||||
|
```go
|
||||||
|
requests, _ := b.GetAllRequests(ctx)
|
||||||
|
for _, req := range requests {
|
||||||
|
log.Printf("%s %s - %d", req.Method, req.URL, req.StatusCode)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Fetch.enable` - Habilitar interceptación
|
||||||
|
- `Fetch.requestPaused` - Request interceptado
|
||||||
|
- `Fetch.continueRequest` - Continuar con cambios
|
||||||
|
- `Fetch.fulfillRequest` - Mock response
|
||||||
|
- `Fetch.failRequest` - Abortar request
|
||||||
|
- `Network.emulateNetworkConditions` - Simular latencia
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Fetch: https://chromedevtools.github.io/devtools-protocol/tot/Fetch/
|
||||||
|
- Playwright route: https://playwright.dev/docs/network
|
||||||
|
- Puppeteer interception: https://pptr.dev/guides/request-interception
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Issue #015: Geolocation & Permissions
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Baja (Avanzado)
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar sistema para configurar geolocation y permisos del navegador (notifications, geolocation, camera, mic, etc.).
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Geolocation
|
||||||
|
- Establecer coordenadas GPS personalizadas
|
||||||
|
- Simular precisión de GPS
|
||||||
|
- Cambiar ubicación dinámicamente
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
- Otorgar/denegar permisos específicos
|
||||||
|
- Permisos por origen (URL)
|
||||||
|
- Lista completa de permisos soportados
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Geolocation struct {
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
Accuracy float64 // En metros
|
||||||
|
}
|
||||||
|
|
||||||
|
type Permission string
|
||||||
|
const (
|
||||||
|
PermissionGeolocation Permission = "geolocation"
|
||||||
|
PermissionNotifications Permission = "notifications"
|
||||||
|
PermissionCamera Permission = "videoCapture"
|
||||||
|
PermissionMicrophone Permission = "audioCapture"
|
||||||
|
PermissionClipboard Permission = "clipboardReadWrite"
|
||||||
|
PermissionMIDI Permission = "midi"
|
||||||
|
PermissionBackgroundSync Permission = "backgroundSync"
|
||||||
|
PermissionPersistentStorage Permission = "persistentStorage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Browser) SetGeolocation(ctx context.Context, geo *Geolocation) error
|
||||||
|
func (b *Browser) ClearGeolocation(ctx context.Context) error
|
||||||
|
func (b *Browser) GrantPermissions(ctx context.Context, origin string, permissions []Permission) error
|
||||||
|
func (b *Browser) DenyPermissions(ctx context.Context, origin string, permissions []Permission) error
|
||||||
|
func (b *Browser) ResetPermissions(ctx context.Context) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Establecer ubicación
|
||||||
|
```go
|
||||||
|
// Simular estar en Nueva York
|
||||||
|
b.SetGeolocation(ctx, &browser.Geolocation{
|
||||||
|
Latitude: 40.7128,
|
||||||
|
Longitude: -74.0060,
|
||||||
|
Accuracy: 10, // 10 metros
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Navigate(ctx, "https://maps.google.com", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Otorgar permisos
|
||||||
|
```go
|
||||||
|
// Permitir notifications y geolocation
|
||||||
|
b.GrantPermissions(ctx, "https://example.com", []browser.Permission{
|
||||||
|
browser.PermissionNotifications,
|
||||||
|
browser.PermissionGeolocation,
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Navigate(ctx, "https://example.com", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Denegar cámara/micrófono
|
||||||
|
```go
|
||||||
|
b.DenyPermissions(ctx, "https://videocall.com", []browser.Permission{
|
||||||
|
browser.PermissionCamera,
|
||||||
|
browser.PermissionMicrophone,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cambiar ubicación dinámicamente
|
||||||
|
```go
|
||||||
|
// Simular movimiento
|
||||||
|
locations := []browser.Geolocation{
|
||||||
|
{Latitude: 40.7128, Longitude: -74.0060}, // NYC
|
||||||
|
{Latitude: 34.0522, Longitude: -118.2437}, // LA
|
||||||
|
{Latitude: 41.8781, Longitude: -87.6298}, // Chicago
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, loc := range locations {
|
||||||
|
b.SetGeolocation(ctx, &loc)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
### Geolocation
|
||||||
|
```go
|
||||||
|
// Establecer
|
||||||
|
{"method": "Emulation.setGeolocationOverride", "params": {
|
||||||
|
"latitude": 40.7128,
|
||||||
|
"longitude": -74.0060,
|
||||||
|
"accuracy": 10
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Limpiar
|
||||||
|
{"method": "Emulation.clearGeolocationOverride"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
```go
|
||||||
|
// Otorgar
|
||||||
|
{"method": "Browser.grantPermissions", "params": {
|
||||||
|
"origin": "https://example.com",
|
||||||
|
"permissions": ["geolocation", "notifications"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Denegar (remover)
|
||||||
|
{"method": "Browser.resetPermissions"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permisos disponibles
|
||||||
|
|
||||||
|
| Permission | Descripción |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `geolocation` | Acceso a GPS |
|
||||||
|
| `notifications` | Push notifications |
|
||||||
|
| `videoCapture` | Cámara |
|
||||||
|
| `audioCapture` | Micrófono |
|
||||||
|
| `clipboardReadWrite` | Clipboard |
|
||||||
|
| `midi` | MIDI devices |
|
||||||
|
| `backgroundSync` | Background sync |
|
||||||
|
| `persistentStorage` | Persistent storage |
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Testing de apps con geolocation
|
||||||
|
```go
|
||||||
|
// Test en diferentes ciudades
|
||||||
|
cities := map[string]browser.Geolocation{
|
||||||
|
"NYC": {40.7128, -74.0060, 10},
|
||||||
|
"LA": {34.0522, -118.2437, 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, loc := range cities {
|
||||||
|
b.SetGeolocation(ctx, &loc)
|
||||||
|
b.Navigate(ctx, "https://app.com/nearby", nil)
|
||||||
|
// Verificar resultados específicos de ciudad
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing sin permisos
|
||||||
|
```go
|
||||||
|
// Simular usuario que deniega permisos
|
||||||
|
b.DenyPermissions(ctx, "https://app.com", []browser.Permission{
|
||||||
|
browser.PermissionCamera,
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Navigate(ctx, "https://app.com/video-call", nil)
|
||||||
|
// Verificar que app maneja correctamente el error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Emulation.setGeolocationOverride: https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setGeolocationOverride
|
||||||
|
- CDP Browser.grantPermissions: https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-grantPermissions
|
||||||
|
- Playwright geolocation: https://playwright.dev/docs/emulation#geolocation
|
||||||
|
- Playwright permissions: https://playwright.dev/docs/emulation#permissions
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Issue #016: Manejo de iFrames
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: En progreso
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar capacidad para trabajar con elementos dentro de iframes.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Cambiar contexto a un iframe específico
|
||||||
|
- Volver al contexto principal (main frame)
|
||||||
|
- Listar todos los iframes de la página
|
||||||
|
- Detectar cuando iframe carga
|
||||||
|
- Ejecutar JavaScript dentro de iframe
|
||||||
|
- Click/Type en elementos dentro de iframe
|
||||||
|
- Navegación en cascada (frame -> subframe -> subsubframe)
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Frame representa un iframe
|
||||||
|
type Frame struct {
|
||||||
|
ID string
|
||||||
|
ParentID string
|
||||||
|
URL string
|
||||||
|
Name string
|
||||||
|
FrameTree []*Frame // Sub-frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToFrame cambia contexto a un iframe
|
||||||
|
func (b *Browser) SwitchToFrame(ctx context.Context, selector string) error
|
||||||
|
|
||||||
|
// SwitchToFrameByName cambia a iframe por atributo name
|
||||||
|
func (b *Browser) SwitchToFrameByName(ctx context.Context, name string) error
|
||||||
|
|
||||||
|
// SwitchToMainFrame vuelve al contexto principal
|
||||||
|
func (b *Browser) SwitchToMainFrame(ctx context.Context) error
|
||||||
|
|
||||||
|
// GetFrames obtiene todos los frames de la página
|
||||||
|
func (b *Browser) GetFrames(ctx context.Context) ([]*Frame, error)
|
||||||
|
|
||||||
|
// WaitForFrame espera a que un frame cargue
|
||||||
|
func (b *Browser) WaitForFrame(ctx context.Context, selector string) error
|
||||||
|
|
||||||
|
// EvaluateInFrame ejecuta JS en un frame específico
|
||||||
|
func (b *Browser) EvaluateInFrame(ctx context.Context, frameID string, script string) (*EvaluateResult, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Cambiar a iframe
|
||||||
|
b.SwitchToFrame(ctx, "#payment-iframe")
|
||||||
|
|
||||||
|
// Interactuar dentro del iframe
|
||||||
|
b.Type(ctx, "#card-number", "1234567890123456", nil)
|
||||||
|
b.Click(ctx, "#submit-payment")
|
||||||
|
|
||||||
|
// Volver al frame principal
|
||||||
|
b.SwitchToMainFrame(ctx)
|
||||||
|
|
||||||
|
// Listar frames
|
||||||
|
frames, _ := b.GetFrames(ctx)
|
||||||
|
for _, frame := range frames {
|
||||||
|
log.Printf("Frame: %s - %s", frame.Name, frame.URL)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Page.getFrameTree` - Árbol de frames
|
||||||
|
- `DOM.describeNode` - Info de frame node
|
||||||
|
- `Runtime.evaluate` con `contextId` específico
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Page.getFrameTree: https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-getFrameTree
|
||||||
|
- Selenium frames: https://www.selenium.dev/documentation/webdriver/interactions/frames/
|
||||||
|
- Playwright frames: https://playwright.dev/docs/frames
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Issue #017: Actions API - Acciones Complejas
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: En progreso
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar API para acciones complejas de mouse y teclado: hover, drag & drop, double click, right click, scroll, etc.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Acciones de Mouse
|
||||||
|
- Hover sobre elemento
|
||||||
|
- Double click
|
||||||
|
- Right click (menú contextual)
|
||||||
|
- Drag and drop
|
||||||
|
- Scroll a posición específica
|
||||||
|
- Scroll a elemento
|
||||||
|
- Move mouse a coordenadas
|
||||||
|
- Mouse down/up separados
|
||||||
|
|
||||||
|
### Acciones de Teclado
|
||||||
|
- Press key (con modificadores)
|
||||||
|
- Hold key
|
||||||
|
- Shortcuts (Ctrl+C, Ctrl+V, etc.)
|
||||||
|
- Combinaciones complejas
|
||||||
|
|
||||||
|
### Cadenas de acciones
|
||||||
|
- Encadenar múltiples acciones
|
||||||
|
- ActionChain pattern (como Selenium)
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Mouse actions
|
||||||
|
func (b *Browser) Hover(ctx context.Context, selector string) error
|
||||||
|
func (b *Browser) DoubleClick(ctx context.Context, selector string) error
|
||||||
|
func (b *Browser) RightClick(ctx context.Context, selector string) error
|
||||||
|
func (b *Browser) DragAndDrop(ctx context.Context, sourceSelector, targetSelector string) error
|
||||||
|
func (b *Browser) ScrollTo(ctx context.Context, x, y int) error
|
||||||
|
func (b *Browser) ScrollToElement(ctx context.Context, selector string) error
|
||||||
|
func (b *Browser) ScrollBy(ctx context.Context, x, y int) error
|
||||||
|
func (b *Browser) MoveMouse(ctx context.Context, x, y int) error
|
||||||
|
|
||||||
|
// Keyboard actions
|
||||||
|
func (b *Browser) PressKey(ctx context.Context, key string) error
|
||||||
|
func (b *Browser) HoldKey(ctx context.Context, key string) error
|
||||||
|
func (b *Browser) ReleaseKey(ctx context.Context, key string) error
|
||||||
|
func (b *Browser) SendKeys(ctx context.Context, keys ...string) error
|
||||||
|
|
||||||
|
// Action chains
|
||||||
|
type ActionChain struct {
|
||||||
|
browser *Browser
|
||||||
|
actions []action
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) NewActionChain() *ActionChain
|
||||||
|
func (ac *ActionChain) MoveTo(selector string) *ActionChain
|
||||||
|
func (ac *ActionChain) Click() *ActionChain
|
||||||
|
func (ac *ActionChain) DoubleClick() *ActionChain
|
||||||
|
func (ac *ActionChain) ContextClick() *ActionChain
|
||||||
|
func (ac *ActionChain) SendKeys(keys ...string) *ActionChain
|
||||||
|
func (ac *ActionChain) Pause(duration time.Duration) *ActionChain
|
||||||
|
func (ac *ActionChain) Perform(ctx context.Context) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Hover
|
||||||
|
```go
|
||||||
|
b.Hover(ctx, "#menu-button")
|
||||||
|
b.Click(ctx, "#dropdown-item")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Double click
|
||||||
|
```go
|
||||||
|
b.DoubleClick(ctx, "#file-icon")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Right click
|
||||||
|
```go
|
||||||
|
b.RightClick(ctx, "#context-menu-trigger")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag and drop
|
||||||
|
```go
|
||||||
|
b.DragAndDrop(ctx, "#drag-source", "#drop-target")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scroll
|
||||||
|
```go
|
||||||
|
// Scroll a elemento
|
||||||
|
b.ScrollToElement(ctx, "#footer")
|
||||||
|
|
||||||
|
// Scroll por pixels
|
||||||
|
b.ScrollBy(ctx, 0, 500)
|
||||||
|
|
||||||
|
// Scroll a posición absoluta
|
||||||
|
b.ScrollTo(ctx, 0, 1000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shortcuts de teclado
|
||||||
|
```go
|
||||||
|
// Ctrl+A (Select all)
|
||||||
|
b.PressKey(ctx, "Control+A")
|
||||||
|
|
||||||
|
// Ctrl+C (Copy)
|
||||||
|
b.PressKey(ctx, "Control+C")
|
||||||
|
|
||||||
|
// Esc
|
||||||
|
b.PressKey(ctx, "Escape")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action chains
|
||||||
|
```go
|
||||||
|
chain := b.NewActionChain()
|
||||||
|
chain.
|
||||||
|
MoveTo("#drag-handle").
|
||||||
|
Click().
|
||||||
|
MoveTo("#drop-zone").
|
||||||
|
Release().
|
||||||
|
Perform(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `Input.dispatchMouseEvent`
|
||||||
|
- `Input.dispatchKeyEvent`
|
||||||
|
- `Input.dispatchTouchEvent`
|
||||||
|
- `Runtime.evaluate` para JavaScript
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Input: https://chromedevtools.github.io/devtools-protocol/tot/Input/
|
||||||
|
- Selenium Actions: https://www.selenium.dev/documentation/webdriver/actions_api/
|
||||||
|
- Playwright actions: https://playwright.dev/docs/input
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Issue #018: File Uploads
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: En progreso
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar capacidad para subir archivos a inputs de tipo file.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Subir archivo a `<input type="file">`
|
||||||
|
- Subir múltiples archivos
|
||||||
|
- Validar que archivo existe antes de subir
|
||||||
|
- Soportar paths absolutos y relativos
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Browser) UploadFile(ctx context.Context, selector string, filePath string) error
|
||||||
|
func (b *Browser) UploadFiles(ctx context.Context, selector string, filePaths []string) error
|
||||||
|
func (b *Browser) SetFileInput(ctx context.Context, selector string, files []string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Subir un archivo
|
||||||
|
b.UploadFile(ctx, "input[type='file']", "/path/to/document.pdf")
|
||||||
|
|
||||||
|
// Subir múltiples archivos
|
||||||
|
b.UploadFiles(ctx, "input[type='file'][multiple]", []string{
|
||||||
|
"/path/to/file1.jpg",
|
||||||
|
"/path/to/file2.png",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP Methods
|
||||||
|
|
||||||
|
- `DOM.setFileInputFiles`
|
||||||
|
- `DOM.getFileInfo`
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP DOM.setFileInputFiles: https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-setFileInputFiles
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Issue #019: Expected Conditions Mejoradas
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: En progreso
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar condiciones de espera específicas y predefinidas, similares a Selenium Expected Conditions.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- WaitUntilVisible
|
||||||
|
- WaitUntilHidden
|
||||||
|
- WaitUntilClickable
|
||||||
|
- WaitUntilEnabled
|
||||||
|
- WaitUntilDisabled
|
||||||
|
- WaitUntilSelected
|
||||||
|
- WaitUntilTextMatches
|
||||||
|
- WaitUntilAttributeContains
|
||||||
|
- WaitUntilURLContains
|
||||||
|
- WaitUntilTitleContains
|
||||||
|
- WaitUntilElementCount
|
||||||
|
|
||||||
|
## API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Browser) WaitUntilVisible(ctx context.Context, selector string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilHidden(ctx context.Context, selector string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilClickable(ctx context.Context, selector string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilEnabled(ctx context.Context, selector string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilDisabled(ctx context.Context, selector string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilTextMatches(ctx context.Context, selector, text string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilAttributeContains(ctx context.Context, selector, attribute, value string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilURLContains(ctx context.Context, pattern string, opts *WaitOptions) error
|
||||||
|
func (b *Browser) WaitUntilTitleContains(ctx context.Context, pattern string, opts *WaitOptions) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Esperar a que elemento sea visible
|
||||||
|
b.WaitUntilVisible(ctx, "#modal", nil)
|
||||||
|
|
||||||
|
// Esperar a que botón sea clickeable
|
||||||
|
b.WaitUntilClickable(ctx, "#submit-btn", nil)
|
||||||
|
|
||||||
|
// Esperar a que texto aparezca
|
||||||
|
b.WaitUntilTextMatches(ctx, "#status", "Success", nil)
|
||||||
|
|
||||||
|
// Esperar cambio de URL
|
||||||
|
b.WaitUntilURLContains(ctx, "/dashboard", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- Selenium Expected Conditions: https://www.selenium.dev/selenium/docs/api/py/webdriver_support/selenium.webdriver.support.expected_conditions.html
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Issue #001: Conversor de página web a markdown
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar utilidad para convertir el contenido HTML de una página web a formato Markdown.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Convertir títulos (h1-h6) a markdown (#, ##, ###, etc.)
|
||||||
|
- Convertir enlaces a formato `[texto](url)`
|
||||||
|
- Convertir imágenes a formato ``
|
||||||
|
- Convertir listas (ol, ul) a markdown
|
||||||
|
- Convertir tablas a markdown
|
||||||
|
- Mantener estructura de párrafos
|
||||||
|
- Extraer texto limpio sin CSS/JS inline
|
||||||
|
- Opción para incluir/excluir imágenes
|
||||||
|
- Manejar código y bloques de código (pre, code)
|
||||||
|
- Preservar énfasis (bold, italic)
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
`pkg/browser/markdown.go`
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ToMarkdown convierte el contenido de la página actual a Markdown
|
||||||
|
func (b *Browser) ToMarkdown(ctx context.Context, opts *MarkdownOptions) (string, error)
|
||||||
|
|
||||||
|
type MarkdownOptions struct {
|
||||||
|
Selector string // Selector CSS opcional para convertir solo una parte
|
||||||
|
IncludeImages bool // Incluir imágenes en el output
|
||||||
|
IncludeLinks bool // Incluir enlaces
|
||||||
|
BaseURL string // URL base para enlaces relativos
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrategia
|
||||||
|
|
||||||
|
1. Obtener HTML con `GetHTML()`
|
||||||
|
2. Parsear HTML usando `golang.org/x/net/html`
|
||||||
|
3. Convertir nodos recursivamente a markdown
|
||||||
|
4. Alternativamente, ejecutar JS en el navegador con biblioteca turndown
|
||||||
|
|
||||||
|
### Librerías potenciales
|
||||||
|
|
||||||
|
- `github.com/JohannesKaufmann/html-to-markdown` - Conversor Go nativo
|
||||||
|
- O ejecutar `turndown.js` vía `Evaluate()` para mayor fidelidad
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
- Extraer contenido de artículos de blog
|
||||||
|
- Scraping de documentación
|
||||||
|
- Generar datasets para LLMs
|
||||||
|
- Archivar contenido web
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- Turndown: https://github.com/mixmark-io/turndown
|
||||||
|
- html-to-markdown Go: https://github.com/JohannesKaufmann/html-to-markdown
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Issue #002: Recuperación de Accessibility Tree
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar método para obtener el árbol de accesibilidad (Accessibility Tree) de la página usando Chrome DevTools Protocol.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
- Obtener accessibility tree completo vía CDP
|
||||||
|
- Listar roles ARIA de elementos (button, link, heading, etc.)
|
||||||
|
- Obtener nombres accesibles de elementos
|
||||||
|
- Extraer propiedades de accesibilidad
|
||||||
|
- Útil para que LLMs entiendan estructura semántica de página
|
||||||
|
- Formato JSON estructurado y fácil de parsear
|
||||||
|
- Opción para filtrar por tipos de nodos
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
`pkg/browser/accessibility.go`
|
||||||
|
|
||||||
|
### CDP Domain
|
||||||
|
`Accessibility.getFullAXTree` - https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetAccessibilityTree obtiene el árbol de accesibilidad de la página
|
||||||
|
func (b *Browser) GetAccessibilityTree(ctx context.Context, opts *AccessibilityOptions) (*AXTree, error)
|
||||||
|
|
||||||
|
type AccessibilityOptions struct {
|
||||||
|
Depth int // Profundidad máxima del árbol (0 = ilimitado)
|
||||||
|
FilterRoles []string // Roles a incluir (ej: ["button", "link", "heading"])
|
||||||
|
}
|
||||||
|
|
||||||
|
type AXTree struct {
|
||||||
|
Nodes []AXNode `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AXNode struct {
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
Properties map[string]string `json:"properties,omitempty"`
|
||||||
|
Children []string `json:"children,omitempty"` // IDs de hijos
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comandos CDP necesarios
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. Habilitar dominio Accessibility
|
||||||
|
{"method": "Accessibility.enable"}
|
||||||
|
|
||||||
|
// 2. Obtener árbol completo
|
||||||
|
{"method": "Accessibility.getFullAXTree", "params": {}}
|
||||||
|
|
||||||
|
// O para un nodo específico:
|
||||||
|
{"method": "Accessibility.getPartialAXTree", "params": {"nodeId": ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
- LLMs pueden entender mejor la estructura de la página
|
||||||
|
- Identificar elementos interactuables automáticamente
|
||||||
|
- Testing de accesibilidad
|
||||||
|
- Generar selectores semánticos
|
||||||
|
- Scraping inteligente basado en roles ARIA
|
||||||
|
|
||||||
|
## Ventajas sobre DOM normal
|
||||||
|
|
||||||
|
- Información semántica rica
|
||||||
|
- Roles ARIA explícitos
|
||||||
|
- Nombres accesibles computados
|
||||||
|
- Estructura más simple que DOM HTML
|
||||||
|
- Ideal para navegación por agentes autónomos
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Accessibility Domain: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/
|
||||||
|
- WAI-ARIA Roles: https://www.w3.org/TR/wai-aria-1.2/#role_definitions
|
||||||
|
- Chrome AX Tree Inspector: chrome://accessibility
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Issue #003: Administración avanzada de cookies del perfil
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Mejorar las capacidades de gestión de cookies persistentes en perfiles de navegador, permitiendo importar/exportar y gestionar cookies antes y después del lanzamiento del navegador.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Gestión de cookies en runtime (ya implementado parcialmente)
|
||||||
|
- ✅ `GetCookies()` - Obtener cookies de URLs específicas
|
||||||
|
- ✅ `SetCookie()` - Establecer cookies individuales
|
||||||
|
- ✅ `ClearCookies()` - Limpiar todas las cookies
|
||||||
|
|
||||||
|
### Nuevas funcionalidades necesarias
|
||||||
|
|
||||||
|
#### Importar/Exportar
|
||||||
|
- Exportar todas las cookies del perfil a archivo JSON
|
||||||
|
- Importar cookies desde archivo JSON
|
||||||
|
- Formato compatible con extensiones de Chrome (EditThisCookie, etc.)
|
||||||
|
- Soportar formato Netscape (cookies.txt)
|
||||||
|
|
||||||
|
#### Gestión offline de perfiles
|
||||||
|
- Leer cookies del perfil sin lanzar navegador
|
||||||
|
- Modificar cookies del perfil en disco
|
||||||
|
- Copiar cookies entre perfiles
|
||||||
|
- Backup/restore de cookies
|
||||||
|
|
||||||
|
#### Filtrado y búsqueda
|
||||||
|
- Listar todas las cookies del perfil actual
|
||||||
|
- Filtrar cookies por dominio
|
||||||
|
- Filtrar cookies por nombre
|
||||||
|
- Buscar cookies por patrón
|
||||||
|
|
||||||
|
#### Configuración previa al lanzamiento
|
||||||
|
- Establecer cookies iniciales antes de lanzar navegador
|
||||||
|
- Cargar cookies desde archivo al inicio
|
||||||
|
- Configurar cookies de sesión específicas
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivos sugeridos
|
||||||
|
- `pkg/browser/profile_cookies.go` - Gestión avanzada
|
||||||
|
- `pkg/browser/cookie_import_export.go` - I/O de archivos
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// === Gestión en runtime ===
|
||||||
|
|
||||||
|
// GetAllCookies obtiene todas las cookies del navegador actual
|
||||||
|
func (b *Browser) GetAllCookies(ctx context.Context) ([]*Cookie, error)
|
||||||
|
|
||||||
|
// FilterCookies obtiene cookies que coinciden con filtros
|
||||||
|
func (b *Browser) FilterCookies(ctx context.Context, filter CookieFilter) ([]*Cookie, error)
|
||||||
|
|
||||||
|
type CookieFilter struct {
|
||||||
|
Domain string // Filtrar por dominio (ej: ".example.com")
|
||||||
|
Name string // Filtrar por nombre exacto
|
||||||
|
Pattern string // Regex para nombre o valor
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Import/Export ===
|
||||||
|
|
||||||
|
// ExportCookies exporta cookies a archivo JSON
|
||||||
|
func (b *Browser) ExportCookies(ctx context.Context, filepath string, format CookieFormat) error
|
||||||
|
|
||||||
|
// ImportCookies importa cookies desde archivo
|
||||||
|
func (b *Browser) ImportCookies(ctx context.Context, filepath string, format CookieFormat) error
|
||||||
|
|
||||||
|
type CookieFormat string
|
||||||
|
const (
|
||||||
|
CookieFormatJSON CookieFormat = "json" // JSON estándar
|
||||||
|
CookieFormatNetscape CookieFormat = "netscape" // cookies.txt
|
||||||
|
CookieFormatChrome CookieFormat = "chrome" // Formato EditThisCookie
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Gestión offline de perfiles ===
|
||||||
|
|
||||||
|
// Profile representa un perfil de navegador
|
||||||
|
type Profile struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfiles lista todos los perfiles disponibles
|
||||||
|
func ListProfiles() ([]Profile, error)
|
||||||
|
|
||||||
|
// GetProfileCookies lee cookies de un perfil sin lanzar navegador
|
||||||
|
func GetProfileCookies(profilePath string) ([]*Cookie, error)
|
||||||
|
|
||||||
|
// SetProfileCookies escribe cookies en un perfil sin lanzar navegador
|
||||||
|
func SetProfileCookies(profilePath string, cookies []*Cookie) error
|
||||||
|
|
||||||
|
// CopyProfileCookies copia cookies entre perfiles
|
||||||
|
func CopyProfileCookies(srcProfile, dstProfile string) error
|
||||||
|
|
||||||
|
// === Configuración inicial ===
|
||||||
|
|
||||||
|
// LaunchWithCookies lanza navegador con cookies precargadas
|
||||||
|
func LaunchWithCookies(ctx context.Context, config *Config, cookiesFile string) (*Browser, error)
|
||||||
|
|
||||||
|
// Config.InitialCookies - campo para establecer cookies al inicio
|
||||||
|
type Config struct {
|
||||||
|
// ... campos existentes ...
|
||||||
|
InitialCookies []*Cookie // Cookies a establecer al lanzar
|
||||||
|
CookiesFile string // Archivo de cookies a cargar
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formato JSON de cookies
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "session_id",
|
||||||
|
"value": "abc123",
|
||||||
|
"domain": ".example.com",
|
||||||
|
"path": "/",
|
||||||
|
"expires": 1735689600,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Lax"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ubicación de cookies en perfil Chrome
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.navegator/profiles/<nombre>/
|
||||||
|
├── Cookies # Base de datos SQLite con cookies
|
||||||
|
├── Cookies-journal # Journal de transacciones
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Caso 1: Migrar sesión entre perfiles
|
||||||
|
```go
|
||||||
|
// Exportar cookies del perfil A
|
||||||
|
browserA.ExportCookies(ctx, "session.json", CookieFormatJSON)
|
||||||
|
|
||||||
|
// Importar en perfil B
|
||||||
|
browserB.ImportCookies(ctx, "session.json", CookieFormatJSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: Backup de sesión autenticada
|
||||||
|
```go
|
||||||
|
// Guardar estado de sesión actual
|
||||||
|
b.ExportCookies(ctx, "backup_session.json", CookieFormatJSON)
|
||||||
|
|
||||||
|
// Restaurar más tarde
|
||||||
|
b2.ImportCookies(ctx, "backup_session.json", CookieFormatJSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 3: Lanzar con sesión precargada
|
||||||
|
```go
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.CookiesFile = "authenticated_session.json"
|
||||||
|
b, _ := browser.Launch(ctx, config)
|
||||||
|
// Ya está autenticado al iniciar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 4: Sincronizar cookies entre máquinas
|
||||||
|
```go
|
||||||
|
// Máquina A - exportar
|
||||||
|
GetProfileCookies("~/.navegator/profiles/main").Export("cookies.json")
|
||||||
|
|
||||||
|
// Máquina B - importar
|
||||||
|
SetProfileCookies("~/.navegator/profiles/main", LoadCookies("cookies.json"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consideraciones de seguridad
|
||||||
|
|
||||||
|
⚠️ **Importante**: Las cookies pueden contener tokens de sesión y datos sensibles
|
||||||
|
|
||||||
|
- Advertir al usuario sobre seguridad de archivos exportados
|
||||||
|
- Opción para encriptar archivos de cookies
|
||||||
|
- No guardar cookies de sesión por defecto
|
||||||
|
- Limpiar cookies sensibles en exports
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Network.getCookies: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getCookies
|
||||||
|
- CDP Storage.getCookies: https://chromedevtools.github.io/devtools-protocol/tot/Storage/#method-getCookies
|
||||||
|
- Chrome Cookies DB: SQLite format
|
||||||
|
- Netscape cookies.txt: http://fileformats.archiveteam.org/wiki/Netscape_cookies.txt
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Issue #004: Administración de extensiones de Chrome
|
||||||
|
|
||||||
|
**Tipo**: Enhancement
|
||||||
|
**Prioridad**: Media
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Implementar sistema completo para cargar, gestionar y configurar extensiones de Chrome en perfiles de navegador.
|
||||||
|
|
||||||
|
## Funcionalidad deseada
|
||||||
|
|
||||||
|
### Carga de extensiones
|
||||||
|
- Cargar extensiones desde archivos `.crx` (empaquetadas)
|
||||||
|
- Cargar extensiones desempaquetadas (carpetas)
|
||||||
|
- Cargar múltiples extensiones simultáneamente
|
||||||
|
- Especificar extensiones en configuración de perfil
|
||||||
|
|
||||||
|
### Gestión de extensiones
|
||||||
|
- Listar extensiones instaladas en perfil
|
||||||
|
- Habilitar/deshabilitar extensiones
|
||||||
|
- Desinstalar extensiones
|
||||||
|
- Actualizar extensiones
|
||||||
|
- Obtener información de extensión (nombre, versión, ID)
|
||||||
|
|
||||||
|
### Extensiones predefinidas
|
||||||
|
- Configuraciones para extensiones populares
|
||||||
|
- uBlock Origin - bloqueador de ads
|
||||||
|
- Tampermonkey - userscripts
|
||||||
|
- Cookie editors
|
||||||
|
- Proxy switchers
|
||||||
|
- User-agent switchers
|
||||||
|
|
||||||
|
### Configuración de extensiones
|
||||||
|
- Establecer configuración de extensión desde código
|
||||||
|
- Importar/exportar configuraciones
|
||||||
|
- Templates de configuración para casos comunes
|
||||||
|
|
||||||
|
## Implementación técnica
|
||||||
|
|
||||||
|
### Archivo sugerido
|
||||||
|
`pkg/browser/extensions.go`
|
||||||
|
|
||||||
|
### Flags de Chrome necesarias
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Cargar extensión específica
|
||||||
|
"--load-extension=/path/to/extension"
|
||||||
|
|
||||||
|
// Cargar múltiples extensiones
|
||||||
|
"--load-extension=/path/ext1,/path/ext2,/path/ext3"
|
||||||
|
|
||||||
|
// Deshabilitar todas excepto las especificadas
|
||||||
|
"--disable-extensions-except=/path/ext1,/path/ext2"
|
||||||
|
|
||||||
|
// Desempaquetar y cargar .crx
|
||||||
|
"--load-extension=/path/to/extension.crx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API propuesta
|
||||||
|
|
||||||
|
```go
|
||||||
|
// === Configuración de extensiones ===
|
||||||
|
|
||||||
|
type ExtensionConfig struct {
|
||||||
|
Path string // Ruta a extensión (carpeta o .crx)
|
||||||
|
ID string // ID de extensión (opcional)
|
||||||
|
Enabled bool // Habilitada por defecto
|
||||||
|
Settings map[string]string // Configuración específica
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config.Extensions - campo para extensiones
|
||||||
|
type Config struct {
|
||||||
|
// ... campos existentes ...
|
||||||
|
Extensions []*ExtensionConfig // Extensiones a cargar
|
||||||
|
DisableOtherExts bool // Deshabilitar extensiones no especificadas
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Gestión en runtime ===
|
||||||
|
|
||||||
|
// Extension representa una extensión instalada
|
||||||
|
type Extension struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Path string
|
||||||
|
Enabled bool
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListExtensions lista extensiones instaladas en el navegador actual
|
||||||
|
func (b *Browser) ListExtensions(ctx context.Context) ([]*Extension, error)
|
||||||
|
|
||||||
|
// LoadExtension carga una extensión en runtime
|
||||||
|
func (b *Browser) LoadExtension(ctx context.Context, path string) (*Extension, error)
|
||||||
|
|
||||||
|
// EnableExtension habilita una extensión
|
||||||
|
func (b *Browser) EnableExtension(ctx context.Context, extensionID string) error
|
||||||
|
|
||||||
|
// DisableExtension deshabilita una extensión
|
||||||
|
func (b *Browser) DisableExtension(ctx context.Context, extensionID string) error
|
||||||
|
|
||||||
|
// RemoveExtension desinstala una extensión
|
||||||
|
func (b *Browser) RemoveExtension(ctx context.Context, extensionID string) error
|
||||||
|
|
||||||
|
// GetExtensionSettings obtiene configuración de una extensión
|
||||||
|
func (b *Browser) GetExtensionSettings(ctx context.Context, extensionID string) (map[string]interface{}, error)
|
||||||
|
|
||||||
|
// SetExtensionSettings establece configuración de extensión
|
||||||
|
func (b *Browser) SetExtensionSettings(ctx context.Context, extensionID string, settings map[string]interface{}) error
|
||||||
|
|
||||||
|
// === Extensiones predefinidas ===
|
||||||
|
|
||||||
|
// PresetExtensions contiene configuraciones de extensiones populares
|
||||||
|
var PresetExtensions = map[string]*ExtensionConfig{
|
||||||
|
"ublock-origin": {
|
||||||
|
Path: "~/.navegator/extensions/ublock-origin",
|
||||||
|
ID: "cjpalhdlnbpafiamejdnhcphjbkeiagm",
|
||||||
|
},
|
||||||
|
"tampermonkey": {
|
||||||
|
Path: "~/.navegator/extensions/tampermonkey",
|
||||||
|
ID: "dhdgffkkebhmkfjojejmpbldmpobfkfo",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPresetExtension carga una extensión predefinida
|
||||||
|
func LoadPresetExtension(name string) (*ExtensionConfig, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estructura de directorio de extensiones
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.navegator/
|
||||||
|
├── profiles/
|
||||||
|
│ └── <nombre>/
|
||||||
|
│ └── Extensions/ # Extensiones instaladas del perfil
|
||||||
|
│ └── <extension-id>/
|
||||||
|
│ └── <version>/
|
||||||
|
└── extensions/ # Extensiones compartidas
|
||||||
|
├── ublock-origin/
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ └── ...
|
||||||
|
└── tampermonkey/
|
||||||
|
├── manifest.json
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de uso
|
||||||
|
|
||||||
|
### Caso 1: Lanzar con uBlock Origin
|
||||||
|
```go
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
config.Extensions = []*browser.ExtensionConfig{
|
||||||
|
{Path: "/path/to/ublock-origin"},
|
||||||
|
}
|
||||||
|
b, _ := browser.Launch(ctx, config)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: Cargar extensión en runtime
|
||||||
|
```go
|
||||||
|
ext, _ := b.LoadExtension(ctx, "/path/to/extension")
|
||||||
|
log.Printf("Cargada: %s v%s\n", ext.Name, ext.Version)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 3: Usar extensión predefinida
|
||||||
|
```go
|
||||||
|
config := browser.DefaultConfig()
|
||||||
|
ublock, _ := browser.LoadPresetExtension("ublock-origin")
|
||||||
|
config.Extensions = []*browser.ExtensionConfig{ublock}
|
||||||
|
b, _ := browser.Launch(ctx, config)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 4: Gestionar extensiones existentes
|
||||||
|
```go
|
||||||
|
// Listar todas
|
||||||
|
exts, _ := b.ListExtensions(ctx)
|
||||||
|
for _, ext := range exts {
|
||||||
|
log.Printf("%s: %s\n", ext.Name, ext.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deshabilitar extensión específica
|
||||||
|
b.DisableExtension(ctx, "extension-id-here")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 5: Configurar extensión
|
||||||
|
```go
|
||||||
|
// Configurar uBlock Origin con listas personalizadas
|
||||||
|
b.SetExtensionSettings(ctx, "cjpalhdlnbpafiamejdnhcphjbkeiagm", map[string]interface{}{
|
||||||
|
"customFilterLists": []string{
|
||||||
|
"https://example.com/my-filters.txt",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extensiones útiles para automatización
|
||||||
|
|
||||||
|
### Stealth y anti-detección
|
||||||
|
- **Buster**: Solver de CAPTCHAs
|
||||||
|
- **User-Agent Switcher**: Cambiar user agent
|
||||||
|
- **Canvas Fingerprint Defender**: Anti-fingerprinting
|
||||||
|
|
||||||
|
### Scraping
|
||||||
|
- **uBlock Origin**: Bloquear ads y trackers
|
||||||
|
- **Cookie Editor**: Gestión avanzada de cookies
|
||||||
|
- **Header Editor**: Modificar headers HTTP
|
||||||
|
|
||||||
|
### Automatización
|
||||||
|
- **Tampermonkey**: Ejecutar userscripts personalizados
|
||||||
|
- **Violentmonkey**: Alternativa a Tampermonkey
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
- **React DevTools**: Inspeccionar componentes React
|
||||||
|
- **Vue.js DevTools**: Inspeccionar aplicaciones Vue
|
||||||
|
- **Redux DevTools**: Debugging de estado Redux
|
||||||
|
|
||||||
|
## Obtener extensiones
|
||||||
|
|
||||||
|
### Chrome Web Store
|
||||||
|
```bash
|
||||||
|
# URL de extensión en Chrome Web Store
|
||||||
|
https://chrome.google.com/webstore/detail/<extension-id>
|
||||||
|
|
||||||
|
# Descargar .crx con herramientas
|
||||||
|
# https://github.com/Rob--W/crxviewer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desarrollo local
|
||||||
|
```bash
|
||||||
|
# Crear extensión simple
|
||||||
|
mkdir my-extension
|
||||||
|
cd my-extension
|
||||||
|
cat > manifest.json <<EOF
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "My Extension",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "Custom extension"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## CDP para gestión de extensiones
|
||||||
|
|
||||||
|
CDP no tiene soporte directo robusto para extensiones, pero podemos:
|
||||||
|
|
||||||
|
1. **Launch flags**: Usar `--load-extension` al inicio
|
||||||
|
2. **Service workers**: Comunicarse con background scripts de extensión vía `chrome.runtime`
|
||||||
|
3. **Extension pages**: Navegar a `chrome-extension://<id>/page.html`
|
||||||
|
4. **Local storage**: Acceder a storage de extensión si es accesible
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Ejecutar código en contexto de extensión
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
chrome.runtime.sendMessage('%s', {action: 'configure'}, response => {
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
`, extensionID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consideraciones especiales
|
||||||
|
|
||||||
|
### Manifest V3 vs V2
|
||||||
|
- Chrome está migrando a Manifest V3
|
||||||
|
- Algunas extensiones V2 dejarán de funcionar
|
||||||
|
- Verificar compatibilidad al cargar extensiones
|
||||||
|
|
||||||
|
### Permisos
|
||||||
|
- Extensiones pueden requerir permisos específicos
|
||||||
|
- Algunas operaciones requieren interacción manual la primera vez
|
||||||
|
- Considerar pre-configurar permisos en perfil
|
||||||
|
|
||||||
|
### Actualizaciones
|
||||||
|
- Extensiones de Chrome Web Store se actualizan automáticamente
|
||||||
|
- Extensiones locales no se actualizan
|
||||||
|
- Implementar sistema de actualización manual si es necesario
|
||||||
|
|
||||||
|
### Headless mode
|
||||||
|
- Algunas extensiones no funcionan en modo headless
|
||||||
|
- Extensiones con UI pueden requerir modo visible
|
||||||
|
- Probar compatibilidad con `--headless=new`
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- Chrome Extensions: https://developer.chrome.com/docs/extensions/
|
||||||
|
- Load unpacked extensions: https://developer.chrome.com/docs/extensions/mv3/getstarted/
|
||||||
|
- Chrome Extension IDs: https://robwu.nl/crxviewer/
|
||||||
|
- Manifest V3: https://developer.chrome.com/docs/extensions/mv3/intro/
|
||||||
|
- Extension CLI flags: https://peter.sh/experiments/chromium-command-line-switches/
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Issue #005: Eliminar timeouts innecesarios del código
|
||||||
|
|
||||||
|
**Tipo**: Improvement
|
||||||
|
**Prioridad**: Alta
|
||||||
|
**Estado**: Pendiente
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Eliminar todos los `time.Sleep()` y timeouts hardcodeados innecesarios del código, reemplazándolos con esperas basadas en eventos CDP cuando sea posible.
|
||||||
|
|
||||||
|
## Problema actual
|
||||||
|
|
||||||
|
El código tiene múltiples `time.Sleep()` arbitrarios:
|
||||||
|
- `time.Sleep(2 * time.Second)` en examples/basic.go
|
||||||
|
- `time.Sleep(3 * time.Second)` en cmd/list_blog.go
|
||||||
|
- Timeouts hardcodeados en navegación
|
||||||
|
|
||||||
|
Estos timeouts son problemáticos porque:
|
||||||
|
- No se adaptan a velocidad real de carga
|
||||||
|
- Desperdicían tiempo en conexiones rápidas
|
||||||
|
- Fallan en conexiones lentas
|
||||||
|
- Hacen el código menos robusto
|
||||||
|
|
||||||
|
## Estrategia de reemplazo
|
||||||
|
|
||||||
|
### 1. Eventos CDP de carga de página
|
||||||
|
|
||||||
|
En lugar de:
|
||||||
|
```go
|
||||||
|
b.Navigate(ctx, url, nil)
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
```
|
||||||
|
|
||||||
|
Usar eventos CDP:
|
||||||
|
```go
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "networkidle" // o "load" o "domcontentloaded"
|
||||||
|
b.Navigate(ctx, url, opts)
|
||||||
|
// No sleep necesario, Navigate espera el evento
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Esperar por selectores
|
||||||
|
|
||||||
|
En lugar de:
|
||||||
|
```go
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
html, _ := b.GetHTML(ctx, ".content")
|
||||||
|
```
|
||||||
|
|
||||||
|
Usar:
|
||||||
|
```go
|
||||||
|
b.WaitForSelector(ctx, ".content", 30*time.Second)
|
||||||
|
html, _ := b.GetHTML(ctx, ".content")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Esperar por condiciones JavaScript
|
||||||
|
|
||||||
|
En lugar de:
|
||||||
|
```go
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
result, _ := b.Evaluate(ctx, "window.dataReady")
|
||||||
|
```
|
||||||
|
|
||||||
|
Usar:
|
||||||
|
```go
|
||||||
|
b.WaitForFunction(ctx, "window.dataReady === true", 100*time.Millisecond)
|
||||||
|
result, _ := b.Evaluate(ctx, "window.data")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Eventos de red
|
||||||
|
|
||||||
|
Esperar que network esté idle:
|
||||||
|
```go
|
||||||
|
// Implementar WaitForNetworkIdle()
|
||||||
|
b.WaitForNetworkIdle(ctx, 500*time.Millisecond, 30*time.Second)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Eventos CDP útiles
|
||||||
|
|
||||||
|
### Page domain
|
||||||
|
- `Page.loadEventFired` - Página cargada completamente
|
||||||
|
- `Page.domContentEventFired` - DOM listo
|
||||||
|
- `Page.frameStoppedLoading` - Frame dejó de cargar
|
||||||
|
|
||||||
|
### Network domain
|
||||||
|
- `Network.requestWillBeSent` - Request iniciado
|
||||||
|
- `Network.responseReceived` - Response recibida
|
||||||
|
- `Network.loadingFinished` - Recurso terminó de cargar
|
||||||
|
- `Network.loadingFailed` - Recurso falló
|
||||||
|
|
||||||
|
## Métodos a implementar
|
||||||
|
|
||||||
|
```go
|
||||||
|
// WaitForEvent espera un evento CDP específico
|
||||||
|
func (b *Browser) WaitForEvent(ctx context.Context, eventName string, timeout time.Duration) error
|
||||||
|
|
||||||
|
// WaitForNetworkIdle espera que no haya requests de red por X tiempo
|
||||||
|
func (b *Browser) WaitForNetworkIdle(ctx context.Context, idleTime, timeout time.Duration) error
|
||||||
|
|
||||||
|
// WaitForFunction espera que una función JS retorne true
|
||||||
|
func (b *Browser) WaitForFunction(ctx context.Context, fn string, checkInterval time.Duration) error
|
||||||
|
|
||||||
|
// WaitForNavigation espera que navegación complete
|
||||||
|
func (b *Browser) WaitForNavigation(ctx context.Context, timeout time.Duration) error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Archivos a revisar y actualizar
|
||||||
|
|
||||||
|
- [x] `examples/basic.go` - Eliminar time.Sleep
|
||||||
|
- [x] `examples/advanced.go` - Reemplazar con esperas basadas en eventos
|
||||||
|
- [x] `cmd/list_blog.go` - Usar WaitForSelector
|
||||||
|
- [ ] `pkg/browser/navigation.go` - Mejorar Navigate() para esperar eventos
|
||||||
|
- [ ] `pkg/browser/browser.go` - Agregar métodos de espera
|
||||||
|
|
||||||
|
## Implementación en Navigate()
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Browser) Navigate(ctx context.Context, url string, opts *NavigateOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultNavigateOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar listener ANTES de navegar
|
||||||
|
loadedChan := make(chan struct{})
|
||||||
|
b.client.On("Page.loadEventFired", func() {
|
||||||
|
close(loadedChan)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enviar comando de navegación
|
||||||
|
_, err := b.client.SendCommand(ctx, "Page.navigate", map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar evento según opts.WaitUntil
|
||||||
|
select {
|
||||||
|
case <-loadedChan:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beneficios
|
||||||
|
|
||||||
|
✅ **Más rápido**: No espera más de lo necesario
|
||||||
|
✅ **Más robusto**: Falla con timeout claro, no con misterioso "elemento no encontrado"
|
||||||
|
✅ **Más confiable**: Se adapta a velocidad real de página
|
||||||
|
✅ **Mejor UX**: Feedback claro de qué se está esperando
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Probar con:
|
||||||
|
- Conexiones rápidas (localhost)
|
||||||
|
- Conexiones lentas (throttling)
|
||||||
|
- Páginas con mucho JavaScript
|
||||||
|
- Páginas con assets pesados
|
||||||
|
- SPAs (React, Vue) que cargan async
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- CDP Page events: https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-loadEventFired
|
||||||
|
- CDP Network events: https://chromedevtools.github.io/devtools-protocol/tot/Network/
|
||||||
|
- Puppeteer waitFor: https://pptr.dev/guides/page-interactions#waiting
|
||||||
@@ -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_<profile>.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
|
||||||
+199
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
+557
@@ -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.
|
||||||
Executable
+291
@@ -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
|
||||||
Executable
+163
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 ===")
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"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...")
|
||||||
|
opts := browser.DefaultNavigateOptions()
|
||||||
|
opts.WaitUntil = "load" // Esperar evento de carga completa
|
||||||
|
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
|
||||||
|
log.Fatalf("Error al navegar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module navegator
|
||||||
|
|
||||||
|
go 1.22.2
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"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...")
|
||||||
|
navOpts := browser.DefaultNavigateOptions()
|
||||||
|
navOpts.WaitUntil = "load"
|
||||||
|
if err := b.Navigate(ctx, "https://example.com", navOpts); err != nil {
|
||||||
|
log.Printf("❌ Error al navegar: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("✅ Navegación completada")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.AddComment("Página cargada correctamente")
|
||||||
|
|
||||||
|
// 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...")
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessibilityOptions opciones para obtener el árbol de accesibilidad
|
||||||
|
type AccessibilityOptions struct {
|
||||||
|
Depth int // Profundidad máxima del árbol (0 = ilimitado)
|
||||||
|
FilterRoles []string // Roles a incluir (ej: ["button", "link", "heading"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAccessibilityOptions retorna opciones por defecto
|
||||||
|
func DefaultAccessibilityOptions() *AccessibilityOptions {
|
||||||
|
return &AccessibilityOptions{
|
||||||
|
Depth: 0, // Sin límite
|
||||||
|
FilterRoles: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AXTree representa el árbol de accesibilidad completo
|
||||||
|
type AXTree struct {
|
||||||
|
Nodes []AXNode `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AXNode representa un nodo en el árbol de accesibilidad
|
||||||
|
type AXNode struct {
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Value interface{} `json:"value,omitempty"`
|
||||||
|
Properties []AXProperty `json:"properties,omitempty"`
|
||||||
|
ChildIDs []string `json:"childIds,omitempty"`
|
||||||
|
BackendDOMNodeId int `json:"backendDOMNodeId,omitempty"`
|
||||||
|
Ignored bool `json:"ignored,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AXProperty representa una propiedad de accesibilidad
|
||||||
|
type AXProperty struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessibilityTree obtiene el árbol de accesibilidad de la página
|
||||||
|
func (b *Browser) GetAccessibilityTree(ctx context.Context, opts *AccessibilityOptions) (*AXTree, error) {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultAccessibilityOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Habilitar el dominio Accessibility
|
||||||
|
if err := b.enableAccessibility(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("error enabling accessibility: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener el árbol completo
|
||||||
|
result, err := b.cdpClient.SendCommand(ctx, "Accessibility.getFullAXTree", map[string]interface{}{
|
||||||
|
"depth": opts.Depth,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting accessibility tree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Parsear el resultado
|
||||||
|
var axTree AXTree
|
||||||
|
if nodesData, ok := result["nodes"].([]interface{}); ok {
|
||||||
|
for _, nodeData := range nodesData {
|
||||||
|
if nodeMap, ok := nodeData.(map[string]interface{}); ok {
|
||||||
|
node := parseAXNode(nodeMap)
|
||||||
|
|
||||||
|
// Filtrar por roles si se especificó
|
||||||
|
if len(opts.FilterRoles) > 0 {
|
||||||
|
if !contains(opts.FilterRoles, node.Role) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
axTree.Nodes = append(axTree.Nodes, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &axTree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessibilitySnapshot obtiene un snapshot simplificado del árbol de accesibilidad
|
||||||
|
// más rápido y fácil de usar que GetAccessibilityTree
|
||||||
|
func (b *Browser) GetAccessibilitySnapshot(ctx context.Context) (*AXTree, error) {
|
||||||
|
// Habilitar accessibility
|
||||||
|
if err := b.enableAccessibility(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("error enabling accessibility: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener snapshot
|
||||||
|
result, err := b.cdpClient.SendCommand(ctx, "Accessibility.getFullAXTree", map[string]interface{}{
|
||||||
|
"max_depth": 20, // Límite razonable
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var axTree AXTree
|
||||||
|
if nodesData, ok := result["nodes"].([]interface{}); ok {
|
||||||
|
for _, nodeData := range nodesData {
|
||||||
|
if nodeMap, ok := nodeData.(map[string]interface{}); ok {
|
||||||
|
axTree.Nodes = append(axTree.Nodes, parseAXNode(nodeMap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &axTree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInteractiveElements encuentra todos los elementos interactuables
|
||||||
|
// (botones, links, inputs, etc.)
|
||||||
|
func (b *Browser) FindInteractiveElements(ctx context.Context) ([]AXNode, error) {
|
||||||
|
interactiveRoles := []string{
|
||||||
|
"button",
|
||||||
|
"link",
|
||||||
|
"textbox",
|
||||||
|
"searchbox",
|
||||||
|
"combobox",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"slider",
|
||||||
|
"spinbutton",
|
||||||
|
"tab",
|
||||||
|
"menuitem",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"menuitemradio",
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &AccessibilityOptions{
|
||||||
|
FilterRoles: interactiveRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, err := b.GetAccessibilityTree(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree.Nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessibilitySummary genera un resumen textual del árbol de accesibilidad
|
||||||
|
// ideal para LLMs
|
||||||
|
func (b *Browser) GetAccessibilitySummary(ctx context.Context) (string, error) {
|
||||||
|
tree, err := b.GetAccessibilitySnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := "=== Page Accessibility Structure ===\n\n"
|
||||||
|
|
||||||
|
// Agrupar por rol
|
||||||
|
roleGroups := make(map[string][]AXNode)
|
||||||
|
for _, node := range tree.Nodes {
|
||||||
|
if !node.Ignored && node.Role != "" {
|
||||||
|
roleGroups[node.Role] = append(roleGroups[node.Role], node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar resumen por rol
|
||||||
|
for role, nodes := range roleGroups {
|
||||||
|
summary += fmt.Sprintf("## %s (%d)\n", role, len(nodes))
|
||||||
|
for i, node := range nodes {
|
||||||
|
if i >= 10 {
|
||||||
|
summary += fmt.Sprintf(" ... and %d more\n", len(nodes)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if node.Name != "" {
|
||||||
|
summary += fmt.Sprintf(" - %s\n", node.Name)
|
||||||
|
} else if node.Description != "" {
|
||||||
|
summary += fmt.Sprintf(" - %s\n", node.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enableAccessibility habilita el dominio Accessibility de CDP
|
||||||
|
func (b *Browser) enableAccessibility(ctx context.Context) error {
|
||||||
|
_, err := b.cdpClient.SendCommand(ctx, "Accessibility.enable", nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAXNode parsea un nodo del árbol de accesibilidad desde el formato CDP
|
||||||
|
func parseAXNode(data map[string]interface{}) AXNode {
|
||||||
|
node := AXNode{}
|
||||||
|
|
||||||
|
if nodeID, ok := data["nodeId"].(string); ok {
|
||||||
|
node.NodeID = nodeID
|
||||||
|
}
|
||||||
|
|
||||||
|
if role, ok := data["role"].(map[string]interface{}); ok {
|
||||||
|
if roleValue, ok := role["value"].(string); ok {
|
||||||
|
node.Role = roleValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := data["name"].(map[string]interface{}); ok {
|
||||||
|
if nameValue, ok := name["value"].(string); ok {
|
||||||
|
node.Name = nameValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if description, ok := data["description"].(map[string]interface{}); ok {
|
||||||
|
if descValue, ok := description["value"].(string); ok {
|
||||||
|
node.Description = descValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := data["value"].(map[string]interface{}); ok {
|
||||||
|
if val, ok := value["value"]; ok {
|
||||||
|
node.Value = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if properties, ok := data["properties"].([]interface{}); ok {
|
||||||
|
for _, prop := range properties {
|
||||||
|
if propMap, ok := prop.(map[string]interface{}); ok {
|
||||||
|
property := AXProperty{}
|
||||||
|
if name, ok := propMap["name"].(string); ok {
|
||||||
|
property.Name = name
|
||||||
|
}
|
||||||
|
if value, ok := propMap["value"].(map[string]interface{}); ok {
|
||||||
|
if val, ok := value["value"]; ok {
|
||||||
|
property.Value = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.Properties = append(node.Properties, property)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if childIDs, ok := data["childIds"].([]interface{}); ok {
|
||||||
|
for _, childID := range childIDs {
|
||||||
|
if id, ok := childID.(string); ok {
|
||||||
|
node.ChildIDs = append(node.ChildIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if backendID, ok := data["backendDOMNodeId"].(float64); ok {
|
||||||
|
node.BackendDOMNodeId = int(backendID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ignored, ok := data["ignored"].(bool); ok {
|
||||||
|
node.Ignored = ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToJSON serializa el árbol de accesibilidad a JSON
|
||||||
|
func (tree *AXTree) ToJSON() (string, error) {
|
||||||
|
bytes, err := json.MarshalIndent(tree, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains verifica si un slice contiene un string
|
||||||
|
func contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hover mueve el mouse sobre un elemento (sin hacer click)
|
||||||
|
func (b *Browser) Hover(ctx context.Context, selector string) error {
|
||||||
|
// Obtener posición del elemento
|
||||||
|
x, y, err := b.getElementCenter(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get element position: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mover mouse al centro del elemento
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseMoved", x, y, "none", 0); err != nil {
|
||||||
|
return fmt.Errorf("failed to hover: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleClick hace doble click en un elemento
|
||||||
|
func (b *Browser) DoubleClick(ctx context.Context, selector string) error {
|
||||||
|
// Obtener posición
|
||||||
|
x, y, err := b.getElementCenter(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primer click
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mousePressed", x, y, "left", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseReleased", x, y, "left", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pequeña pausa
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Segundo click (clickCount = 2)
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mousePressed", x, y, "left", 2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseReleased", x, y, "left", 2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RightClick hace click derecho en un elemento
|
||||||
|
func (b *Browser) RightClick(ctx context.Context, selector string) error {
|
||||||
|
// Obtener posición
|
||||||
|
x, y, err := b.getElementCenter(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click derecho
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mousePressed", x, y, "right", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseReleased", x, y, "right", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DragAndDrop arrastra un elemento y lo suelta en otro
|
||||||
|
func (b *Browser) DragAndDrop(ctx context.Context, sourceSelector, targetSelector string) error {
|
||||||
|
// Obtener posición de origen
|
||||||
|
sourceX, sourceY, err := b.getElementCenter(ctx, sourceSelector)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("source element not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener posición de destino
|
||||||
|
targetX, targetY, err := b.getElementCenter(ctx, targetSelector)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("target element not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Mover a elemento origen
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseMoved", sourceX, sourceY, "none", 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mouse down en origen
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mousePressed", sourceX, sourceY, "left", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Simular arrastre (mover en pasos)
|
||||||
|
steps := 10
|
||||||
|
for i := 1; i <= steps; i++ {
|
||||||
|
fraction := float64(i) / float64(steps)
|
||||||
|
intermediateX := sourceX + int(float64(targetX-sourceX)*fraction)
|
||||||
|
intermediateY := sourceY + int(float64(targetY-sourceY)*fraction)
|
||||||
|
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseMoved", intermediateX, intermediateY, "left", 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Mouse up en destino
|
||||||
|
if err := b.dispatchMouseEvent(ctx, "mouseReleased", targetX, targetY, "left", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollTo hace scroll a una posición absoluta (x, y)
|
||||||
|
func (b *Browser) ScrollTo(ctx context.Context, x, y int) error {
|
||||||
|
script := fmt.Sprintf("window.scrollTo(%d, %d)", x, y)
|
||||||
|
_, err := b.Evaluate(ctx, script)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollBy hace scroll relativo por x, y pixels
|
||||||
|
func (b *Browser) ScrollBy(ctx context.Context, x, y int) error {
|
||||||
|
script := fmt.Sprintf("window.scrollBy(%d, %d)", x, y)
|
||||||
|
_, err := b.Evaluate(ctx, script)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollToElement hace scroll hasta que un elemento sea visible
|
||||||
|
func (b *Browser) ScrollToElement(ctx context.Context, selector string) error {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const element = document.querySelector('%s');
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
_, err := b.Evaluate(ctx, script)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveMouse mueve el mouse a coordenadas específicas
|
||||||
|
func (b *Browser) MoveMouse(ctx context.Context, x, y int) error {
|
||||||
|
return b.dispatchMouseEvent(ctx, "mouseMoved", x, y, "none", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PressKey presiona una tecla (soporta modificadores)
|
||||||
|
func (b *Browser) PressKey(ctx context.Context, key string) error {
|
||||||
|
// Parsear si hay modificadores (Ctrl+C, Alt+F4, etc.)
|
||||||
|
keys, modifiers := parseKeyCombo(key)
|
||||||
|
|
||||||
|
// Presionar modificadores
|
||||||
|
for _, mod := range modifiers {
|
||||||
|
if err := b.dispatchKeyEvent(ctx, "keyDown", mod, "", modifiersFor(mod)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presionar tecla principal
|
||||||
|
mainKey := keys[len(keys)-1]
|
||||||
|
mods := modifiersValue(modifiers)
|
||||||
|
|
||||||
|
if err := b.dispatchKeyEvent(ctx, "keyDown", mainKey, "", mods); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.dispatchKeyEvent(ctx, "keyUp", mainKey, "", mods); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soltar modificadores
|
||||||
|
for i := len(modifiers) - 1; i >= 0; i-- {
|
||||||
|
if err := b.dispatchKeyEvent(ctx, "keyUp", modifiers[i], "", modifiersFor(modifiers[i])); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoldKey mantiene presionada una tecla (sin soltarla)
|
||||||
|
func (b *Browser) HoldKey(ctx context.Context, key string) error {
|
||||||
|
return b.dispatchKeyEvent(ctx, "keyDown", key, "", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseKey suelta una tecla previamente presionada
|
||||||
|
func (b *Browser) ReleaseKey(ctx context.Context, key string) error {
|
||||||
|
return b.dispatchKeyEvent(ctx, "keyUp", key, "", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendKeys envía una secuencia de teclas
|
||||||
|
func (b *Browser) SendKeys(ctx context.Context, keys ...string) error {
|
||||||
|
for _, key := range keys {
|
||||||
|
if err := b.PressKey(ctx, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: obtener centro de un elemento
|
||||||
|
func (b *Browser) getElementCenter(ctx context.Context, selector string) (int, int, error) {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const element = document.querySelector('%s');
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.round(rect.left + rect.width / 2),
|
||||||
|
y: Math.round(rect.top + rect.height / 2)
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Value == nil {
|
||||||
|
return 0, 0, fmt.Errorf("element not found: %s", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
coords, ok := result.Value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("invalid coordinates")
|
||||||
|
}
|
||||||
|
|
||||||
|
x := int(coords["x"].(float64))
|
||||||
|
y := int(coords["y"].(float64))
|
||||||
|
|
||||||
|
return x, y, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: dispatch mouse event
|
||||||
|
func (b *Browser) dispatchMouseEvent(ctx context.Context, eventType string, x, y int, button string, clickCount int) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"type": eventType,
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"button": button,
|
||||||
|
"clickCount": clickCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: dispatch key event
|
||||||
|
func (b *Browser) dispatchKeyEvent(ctx context.Context, eventType, key, text string, modifiers int) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"type": eventType,
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
params["key"] = key
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
params["text"] = text
|
||||||
|
}
|
||||||
|
if modifiers > 0 {
|
||||||
|
params["modifiers"] = modifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Input.dispatchKeyEvent", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: parsear combinación de teclas
|
||||||
|
func parseKeyCombo(combo string) ([]string, []string) {
|
||||||
|
// Separar por +
|
||||||
|
parts := splitKey(combo, '+')
|
||||||
|
|
||||||
|
var modifiers []string
|
||||||
|
var keys []string
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
switch part {
|
||||||
|
case "Control", "Ctrl":
|
||||||
|
modifiers = append(modifiers, "Control")
|
||||||
|
case "Alt":
|
||||||
|
modifiers = append(modifiers, "Alt")
|
||||||
|
case "Shift":
|
||||||
|
modifiers = append(modifiers, "Shift")
|
||||||
|
case "Meta", "Command", "Cmd":
|
||||||
|
modifiers = append(modifiers, "Meta")
|
||||||
|
default:
|
||||||
|
keys = append(keys, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, modifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: split key combo
|
||||||
|
func splitKey(s string, sep rune) []string {
|
||||||
|
var parts []string
|
||||||
|
var current string
|
||||||
|
|
||||||
|
for _, ch := range s {
|
||||||
|
if ch == sep {
|
||||||
|
if current != "" {
|
||||||
|
parts = append(parts, current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += string(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current != "" {
|
||||||
|
parts = append(parts, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: valor de modificadores
|
||||||
|
func modifiersFor(key string) int {
|
||||||
|
switch key {
|
||||||
|
case "Control":
|
||||||
|
return 2
|
||||||
|
case "Shift":
|
||||||
|
return 8
|
||||||
|
case "Alt":
|
||||||
|
return 1
|
||||||
|
case "Meta":
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: combinar modificadores
|
||||||
|
func modifiersValue(modifiers []string) int {
|
||||||
|
value := 0
|
||||||
|
for _, mod := range modifiers {
|
||||||
|
value |= modifiersFor(mod)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Extensions son las extensiones a cargar
|
||||||
|
Extensions []*ExtensionConfig
|
||||||
|
|
||||||
|
// DisableOtherExts deshabilita todas las extensiones excepto las especificadas
|
||||||
|
DisableOtherExts bool
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
// Agregar flags de extensiones
|
||||||
|
extFlags := config.buildExtensionFlags()
|
||||||
|
flags = append(flags, extFlags...)
|
||||||
|
|
||||||
|
// Crear comando
|
||||||
|
cmd := exec.CommandContext(ctx, config.ExecutablePath, flags...)
|
||||||
|
cmd.Env = append(os.Environ(), config.Env...)
|
||||||
|
|
||||||
|
// Iniciar Chrome
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start Chrome: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
browserCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
b := &Browser{
|
||||||
|
cmd: cmd,
|
||||||
|
config: config,
|
||||||
|
profilePath: profilePath,
|
||||||
|
ctx: browserCtx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar a que Chrome esté listo
|
||||||
|
if err := b.waitForChrome(config.StartTimeout); err != nil {
|
||||||
|
b.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conectar CDP
|
||||||
|
if err := b.connectCDP(); err != nil {
|
||||||
|
b.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inyectar script anti-detección
|
||||||
|
if err := b.injectAntiDetection(); err != nil {
|
||||||
|
// No es crítico, continuar
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to inject anti-detection script: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForChrome espera a que Chrome esté listo y escuchando CDP.
|
||||||
|
func (b *Browser) waitForChrome(timeout time.Duration) error {
|
||||||
|
// Leer el archivo DevToolsActivePort para obtener el puerto
|
||||||
|
devToolsFile := filepath.Join(b.profilePath, "DevToolsActivePort")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(b.ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.New("timeout waiting for Chrome to start")
|
||||||
|
case <-ticker.C:
|
||||||
|
data, err := os.ReadFile(devToolsFile)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
if len(lines) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
port := strings.TrimSpace(lines[0])
|
||||||
|
if port == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.debugURL = "http://127.0.0.1:" + port
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectCDP conecta al cliente CDP.
|
||||||
|
func (b *Browser) connectCDP() error {
|
||||||
|
wsURL, err := cdp.GetWebSocketURL(b.ctx, b.debugURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get WebSocket URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := cdp.NewClient(b.ctx, wsURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create CDP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.cdpClient = client
|
||||||
|
|
||||||
|
// Habilitar dominios necesarios
|
||||||
|
if err := b.enableDomains(); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable CDP domains: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener target ID
|
||||||
|
if err := b.getTargetID(); err != nil {
|
||||||
|
return fmt.Errorf("failed to get target ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enableDomains habilita los dominios CDP necesarios.
|
||||||
|
func (b *Browser) enableDomains() error {
|
||||||
|
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Solo algunos dominios tienen método .enable
|
||||||
|
domains := []string{
|
||||||
|
"Network",
|
||||||
|
"Runtime",
|
||||||
|
"DOM",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, domain := range domains {
|
||||||
|
if err := b.cdpClient.Execute(ctx, domain+".enable", nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable %s domain: %w", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page.enable no existe, Page se activa automáticamente
|
||||||
|
// Storage.enable no existe, Storage funciona directamente
|
||||||
|
// Fetch.enable se llama manualmente cuando se necesita interceptación
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTargetID obtiene el ID del target principal.
|
||||||
|
func (b *Browser) getTargetID() error {
|
||||||
|
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
TargetInfos []struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"targetInfos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.getTargets", nil, &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, info := range result.TargetInfos {
|
||||||
|
if info.Type == "page" {
|
||||||
|
b.targetID = info.TargetID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("no page target found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectAntiDetection inyecta el script anti-detección en todas las páginas.
|
||||||
|
func (b *Browser) injectAntiDetection() error {
|
||||||
|
script := stealth.GetAntiDetectionScript()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"source": script,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Page.addScriptToEvaluateOnNewDocument", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client retorna el cliente CDP subyacente.
|
||||||
|
func (b *Browser) Client() *cdp.Client {
|
||||||
|
return b.cdpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfilePath retorna la ruta del perfil usado.
|
||||||
|
func (b *Browser) ProfilePath() string {
|
||||||
|
return b.profilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugURL retorna la URL de debugging de Chrome.
|
||||||
|
func (b *Browser) DebugURL() string {
|
||||||
|
return b.debugURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetID retorna el ID del target principal.
|
||||||
|
func (b *Browser) TargetID() string {
|
||||||
|
return b.targetID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cierra el navegador y limpia recursos.
|
||||||
|
func (b *Browser) Close() error {
|
||||||
|
b.cancel()
|
||||||
|
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.cdpClient != nil {
|
||||||
|
b.cdpClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.cmd != nil && b.cmd.Process != nil {
|
||||||
|
b.cmd.Process.Kill()
|
||||||
|
b.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRecording inicia el registro de acciones en un archivo.
|
||||||
|
func (b *Browser) StartRecording(filepath string) error {
|
||||||
|
recorder, err := NewRecorder(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.recorder = recorder
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopRecording detiene el registro de acciones.
|
||||||
|
func (b *Browser) StopRecording() error {
|
||||||
|
if b.recorder != nil {
|
||||||
|
return b.recorder.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddComment agrega un comentario al log de recording.
|
||||||
|
func (b *Browser) AddComment(comment string) {
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.AddComment(comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findChrome busca el ejecutable de Chrome en las ubicaciones comunes.
|
||||||
|
func findChrome() (string, error) {
|
||||||
|
var candidates []string
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
candidates = []string{
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
candidates = []string{
|
||||||
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
|
"C:\\Users\\" + os.Getenv("USERNAME") + "\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
|
}
|
||||||
|
default: // linux
|
||||||
|
candidates = []string{
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/snap/bin/chromium",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cada candidato
|
||||||
|
for _, path := range candidates {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar buscar en PATH
|
||||||
|
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser"} {
|
||||||
|
if path, err := exec.LookPath(name); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("Chrome/Chromium executable not found")
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLaunchBrowser(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Crear directorio temporal para perfiles
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
config := DefaultConfig()
|
||||||
|
config.ProfilesBaseDir = tempDir
|
||||||
|
config.ProfileName = "test-launch"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
config.StartTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// Lanzar navegador
|
||||||
|
b, err := Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to launch browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Verificar que el perfil se creó
|
||||||
|
profilePath := filepath.Join(tempDir, "test-launch")
|
||||||
|
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
|
||||||
|
t.Errorf("Profile directory not created: %s", profilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que tenemos debug URL
|
||||||
|
if b.DebugURL() == "" {
|
||||||
|
t.Error("Debug URL is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que tenemos target ID
|
||||||
|
if b.TargetID() == "" {
|
||||||
|
t.Error("Target ID is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavigate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
config := DefaultConfig()
|
||||||
|
config.ProfilesBaseDir = tempDir
|
||||||
|
config.ProfileName = "test-navigate"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
b, err := Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to launch browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Navegar a example.com
|
||||||
|
opts := DefaultNavigateOptions()
|
||||||
|
opts.Timeout = 15 * time.Second
|
||||||
|
|
||||||
|
err = b.Navigate(ctx, "https://example.com", opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to navigate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que estamos en la página correcta
|
||||||
|
result, err := b.Evaluate(ctx, "window.location.href")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to evaluate location: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url, ok := result.Value.(string)
|
||||||
|
if !ok || url != "https://example.com/" {
|
||||||
|
t.Errorf("Expected URL https://example.com/, got %v", result.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshot(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
config := DefaultConfig()
|
||||||
|
config.ProfilesBaseDir = tempDir
|
||||||
|
config.ProfileName = "test-screenshot"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
b, err := Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to launch browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Navegar
|
||||||
|
opts := DefaultNavigateOptions()
|
||||||
|
opts.Timeout = 15 * time.Second
|
||||||
|
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
|
||||||
|
t.Logf("Navigation warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tomar screenshot
|
||||||
|
screenshot, err := b.Screenshot(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to take screenshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que tiene contenido
|
||||||
|
if len(screenshot) == 0 {
|
||||||
|
t.Error("Screenshot is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que es PNG válido (empieza con magic bytes)
|
||||||
|
if len(screenshot) < 8 || screenshot[0] != 0x89 || screenshot[1] != 0x50 {
|
||||||
|
t.Error("Screenshot is not a valid PNG")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
config := DefaultConfig()
|
||||||
|
config.ProfilesBaseDir = tempDir
|
||||||
|
config.ProfileName = "test-evaluate"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
b, err := Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to launch browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Navegar
|
||||||
|
opts := DefaultNavigateOptions()
|
||||||
|
opts.Timeout = 15 * time.Second
|
||||||
|
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
|
||||||
|
t.Logf("Navigation warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluar JavaScript
|
||||||
|
result, err := b.Evaluate(ctx, "2 + 2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to evaluate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar resultado
|
||||||
|
val, ok := result.Value.(float64)
|
||||||
|
if !ok || val != 4 {
|
||||||
|
t.Errorf("Expected 4, got %v", result.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStealthFlags(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
config := DefaultConfig()
|
||||||
|
config.ProfilesBaseDir = tempDir
|
||||||
|
config.ProfileName = "test-stealth"
|
||||||
|
config.StealthFlags.Headless = true
|
||||||
|
|
||||||
|
b, err := Launch(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to launch browser: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
// Navegar
|
||||||
|
opts := DefaultNavigateOptions()
|
||||||
|
opts.Timeout = 15 * time.Second
|
||||||
|
if err := b.Navigate(ctx, "https://example.com", opts); err != nil {
|
||||||
|
t.Logf("Navigation warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar navigator.webdriver = false
|
||||||
|
result, err := b.Evaluate(ctx, "navigator.webdriver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to evaluate webdriver: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debe ser false o undefined
|
||||||
|
if result.Value != false && result.Value != nil {
|
||||||
|
t.Errorf("navigator.webdriver should be false, got %v", result.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar window.chrome existe
|
||||||
|
chromeExists, err := b.Evaluate(ctx, "typeof window.chrome !== 'undefined'")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check chrome object: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chromeExists.Value != true {
|
||||||
|
t.Error("window.chrome should exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecorder(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
recordFile := filepath.Join(tempDir, "test-recording.log")
|
||||||
|
|
||||||
|
recorder, err := NewRecorder(recordFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create recorder: %v", err)
|
||||||
|
}
|
||||||
|
defer recorder.Close()
|
||||||
|
|
||||||
|
// Registrar acción
|
||||||
|
recorder.Record("TestAction", map[string]interface{}{
|
||||||
|
"param1": "value1",
|
||||||
|
"param2": 123,
|
||||||
|
}, "test result", nil)
|
||||||
|
|
||||||
|
recorder.Close()
|
||||||
|
|
||||||
|
// Verificar que el archivo existe y tiene contenido
|
||||||
|
content, err := os.ReadFile(recordFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read recording file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content) == 0 {
|
||||||
|
t.Error("Recording file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que contiene JSON
|
||||||
|
contentStr := string(content)
|
||||||
|
if !containsStr(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 containsStr(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsStrHelper(s, substr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStrHelper(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WaitOptions opciones para métodos de espera con condiciones
|
||||||
|
type WaitOptions struct {
|
||||||
|
Timeout time.Duration // Timeout máximo (default: 30s)
|
||||||
|
PollInterval time.Duration // Intervalo entre comprobaciones (default: 100ms)
|
||||||
|
ThrowOnError bool // Lanzar error si timeout (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultWaitOptions retorna opciones por defecto para esperas
|
||||||
|
func DefaultWaitOptions() *WaitOptions {
|
||||||
|
return &WaitOptions{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
PollInterval: 100 * time.Millisecond,
|
||||||
|
ThrowOnError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilVisible espera a que un elemento sea visible
|
||||||
|
func (b *Browser) WaitUntilVisible(ctx context.Context, selector string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for element to be visible: %s", selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
return style.display !== 'none' &&
|
||||||
|
style.visibility !== 'hidden' &&
|
||||||
|
style.opacity !== '0' &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0;
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if visible, ok := result.Value.(bool); ok && visible {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilHidden espera a que un elemento esté oculto o no exista
|
||||||
|
func (b *Browser) WaitUntilHidden(ctx context.Context, selector string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for element to be hidden: %s", selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
if (!el) return true; // No existe = oculto
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
return style.display === 'none' ||
|
||||||
|
style.visibility === 'hidden' ||
|
||||||
|
style.opacity === '0';
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hidden, ok := result.Value.(bool); ok && hidden {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilClickable espera a que un elemento sea clickeable
|
||||||
|
func (b *Browser) WaitUntilClickable(ctx context.Context, selector string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for element to be clickable: %s", selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
return style.display !== 'none' &&
|
||||||
|
style.visibility !== 'hidden' &&
|
||||||
|
style.pointerEvents !== 'none' &&
|
||||||
|
!el.disabled &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0;
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if clickable, ok := result.Value.(bool); ok && clickable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilEnabled espera a que un elemento esté habilitado
|
||||||
|
func (b *Browser) WaitUntilEnabled(ctx context.Context, selector string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for element to be enabled: %s", selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
return el && !el.disabled;
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled, ok := result.Value.(bool); ok && enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilDisabled espera a que un elemento esté deshabilitado
|
||||||
|
func (b *Browser) WaitUntilDisabled(ctx context.Context, selector string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for element to be disabled: %s", selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
return el && el.disabled === true;
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled, ok := result.Value.(bool); ok && disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilTextMatches espera a que el texto de un elemento contenga un patrón
|
||||||
|
func (b *Browser) WaitUntilTextMatches(ctx context.Context, selector, text string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for text '%s' in element: %s", text, selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
return el && el.textContent.includes('%s');
|
||||||
|
})()
|
||||||
|
`, selector, text)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches, ok := result.Value.(bool); ok && matches {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilAttributeContains espera a que un atributo contenga un valor
|
||||||
|
func (b *Browser) WaitUntilAttributeContains(ctx context.Context, selector, attribute, value string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for attribute '%s' to contain '%s' in element: %s", attribute, value, selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const attrValue = el.getAttribute('%s');
|
||||||
|
return attrValue && attrValue.includes('%s');
|
||||||
|
})()
|
||||||
|
`, selector, attribute, value)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if contains, ok := result.Value.(bool); ok && contains {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilURLContains espera a que la URL contenga un patrón
|
||||||
|
func (b *Browser) WaitUntilURLContains(ctx context.Context, pattern string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for URL to contain: %s", pattern)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`window.location.href.includes('%s')`, pattern)
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if contains, ok := result.Value.(bool); ok && contains {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilTitleContains espera a que el título contenga un patrón
|
||||||
|
func (b *Browser) WaitUntilTitleContains(ctx context.Context, pattern string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for title to contain: %s", pattern)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`document.title.includes('%s')`, pattern)
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if contains, ok := result.Value.(bool); ok && contains {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUntilSelected espera a que un checkbox/radio esté seleccionado
|
||||||
|
func (b *Browser) WaitUntilSelected(ctx context.Context, selector string, opts *WaitOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultWaitOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(opts.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if opts.ThrowOnError {
|
||||||
|
return fmt.Errorf("timeout waiting for element to be selected: %s", selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('%s');
|
||||||
|
return el && el.checked === true;
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected, ok := result.Value.(bool); ok && selected {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtensionConfig configuración de una extensión de Chrome
|
||||||
|
type ExtensionConfig struct {
|
||||||
|
Path string // Ruta a extensión (carpeta o .crx)
|
||||||
|
ID string // ID de extensión (opcional)
|
||||||
|
Enabled bool // Habilitada por defecto
|
||||||
|
Settings map[string]string // Configuración específica
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension representa una extensión instalada
|
||||||
|
type Extension struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Path string
|
||||||
|
Enabled bool
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresetExtensions configuraciones de extensiones populares
|
||||||
|
var PresetExtensions = map[string]*ExtensionConfig{
|
||||||
|
"ublock-origin": {
|
||||||
|
ID: "cjpalhdlnbpafiamejdnhcphjbkeiagm",
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
"tampermonkey": {
|
||||||
|
ID: "dhdgffkkebhmkfjojejmpbldmpobfkfo",
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPresetExtension carga una configuración de extensión predefinida
|
||||||
|
func LoadPresetExtension(name string) (*ExtensionConfig, error) {
|
||||||
|
preset, ok := PresetExtensions[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown preset extension: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar extensión en directorio compartido
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extPath := filepath.Join(homeDir, ".navegator", "extensions", name)
|
||||||
|
if _, err := os.Stat(extPath); err == nil {
|
||||||
|
preset.Path = extPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildExtensionFlags construye las flags de Chrome para cargar extensiones
|
||||||
|
func (c *Config) buildExtensionFlags() []string {
|
||||||
|
if len(c.Extensions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags []string
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
for _, ext := range c.Extensions {
|
||||||
|
if ext.Path != "" && ext.Enabled {
|
||||||
|
// Expandir ~ si es necesario
|
||||||
|
path := ext.Path
|
||||||
|
if strings.HasPrefix(path, "~") {
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
path = filepath.Join(homeDir, path[1:])
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) > 0 {
|
||||||
|
// Cargar extensiones específicas
|
||||||
|
flags = append(flags, fmt.Sprintf("--load-extension=%s", strings.Join(paths, ",")))
|
||||||
|
|
||||||
|
// Si se especificó, deshabilitar todas las otras extensiones
|
||||||
|
if c.DisableOtherExts {
|
||||||
|
flags = append(flags, fmt.Sprintf("--disable-extensions-except=%s", strings.Join(paths, ",")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLoadedExtensions obtiene información sobre extensiones cargadas
|
||||||
|
// Nota: CDP no tiene API directa para esto, usamos técnicas indirectas
|
||||||
|
func (b *Browser) GetLoadedExtensions(ctx context.Context) ([]*Extension, error) {
|
||||||
|
// Intentar obtener extensiones via JavaScript
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
// No hay API directa en página normal para listar extensiones
|
||||||
|
// Retornar info básica si está disponible
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extensions []*Extension
|
||||||
|
// Parse result...
|
||||||
|
_ = result
|
||||||
|
|
||||||
|
return extensions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavigateToExtensionPage navega a la página de gestión de una extensión
|
||||||
|
func (b *Browser) NavigateToExtensionPage(ctx context.Context, extensionID string, page string) error {
|
||||||
|
url := fmt.Sprintf("chrome-extension://%s/%s", extensionID, page)
|
||||||
|
return b.Navigate(ctx, url, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageToExtension envía un mensaje a una extensión
|
||||||
|
// Útil para configurar extensiones programáticamente
|
||||||
|
func (b *Browser) SendMessageToExtension(ctx context.Context, extensionID string, message map[string]interface{}) (interface{}, error) {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage('%s', %v, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(chrome.runtime.lastError);
|
||||||
|
} else {
|
||||||
|
resolve(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`, extensionID, message)
|
||||||
|
|
||||||
|
result, err := b.EvaluateAsync(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error sending message to extension: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupUBlockOrigin configura uBlock Origin con listas de filtros personalizadas
|
||||||
|
func (b *Browser) SetupUBlockOrigin(ctx context.Context, filterLists []string) error {
|
||||||
|
// Navegar a la página de configuración
|
||||||
|
if err := b.NavigateToExtensionPage(ctx, "cjpalhdlnbpafiamejdnhcphjbkeiagm", "dashboard.html"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurar listas de filtros via JavaScript
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
// Acceder a la configuración de uBlock
|
||||||
|
const lists = %v;
|
||||||
|
// Agregar listas personalizadas
|
||||||
|
// Esto depende de la API interna de uBlock
|
||||||
|
return 'configured';
|
||||||
|
})();
|
||||||
|
`, filterLists)
|
||||||
|
|
||||||
|
_, err := b.Evaluate(ctx, script)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallExtensionFromStore descarga e instala extensión desde Chrome Web Store
|
||||||
|
// Nota: Esto requiere interacción con el Web Store y puede ser bloqueado
|
||||||
|
func (b *Browser) InstallExtensionFromStore(ctx context.Context, extensionID string) error {
|
||||||
|
url := fmt.Sprintf("https://chrome.google.com/webstore/detail/%s", extensionID)
|
||||||
|
|
||||||
|
if err := b.Navigate(ctx, url, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar hacer click en botón de instalación
|
||||||
|
// Nota: Esto puede requerir permisos especiales
|
||||||
|
script := `
|
||||||
|
const button = document.querySelector('button[aria-label*="Add"]');
|
||||||
|
if (button) {
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if clicked, ok := result.Value.(bool); !ok || !clicked {
|
||||||
|
return fmt.Errorf("could not find install button")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureExtensionsDirectory crea el directorio de extensiones si no existe
|
||||||
|
func EnsureExtensionsDirectory() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
extDir := filepath.Join(homeDir, ".navegator", "extensions")
|
||||||
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return extDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtensionPath retorna la ruta a una extensión en el directorio compartido
|
||||||
|
func GetExtensionPath(name string) (string, error) {
|
||||||
|
extDir, err := EnsureExtensionsDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(extDir, name)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("extension not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLocalExtensions lista extensiones disponibles en el directorio local
|
||||||
|
func ListLocalExtensions() ([]string, error) {
|
||||||
|
extDir, err := EnsureExtensionsDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(extDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extensions []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
// Verificar que tenga manifest.json
|
||||||
|
manifestPath := filepath.Join(extDir, entry.Name(), "manifest.json")
|
||||||
|
if _, err := os.Stat(manifestPath); err == nil {
|
||||||
|
extensions = append(extensions, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frame representa un iframe o frame
|
||||||
|
type Frame struct {
|
||||||
|
ID string
|
||||||
|
ParentID string
|
||||||
|
URL string
|
||||||
|
Name string
|
||||||
|
FrameTree []*Frame // Sub-frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentFrameID almacena el frame actual del navegador
|
||||||
|
var currentFrameID string
|
||||||
|
|
||||||
|
// SwitchToFrame cambia el contexto a un iframe usando un selector CSS
|
||||||
|
func (b *Browser) SwitchToFrame(ctx context.Context, selector string) error {
|
||||||
|
// 1. Obtener el node del iframe
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("frame not found with selector %s: %w", selector, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener el frameId del node
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
FrameID string `json:"frameId"`
|
||||||
|
ContentDocument struct {
|
||||||
|
NodeID int `json:"nodeId"`
|
||||||
|
} `json:"contentDocument"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.describeNode", map[string]interface{}{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to describe frame node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Node.FrameID == "" {
|
||||||
|
return fmt.Errorf("element is not a frame")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Guardar el frameID actual
|
||||||
|
currentFrameID = result.Node.FrameID
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToFrameByIndex cambia a un iframe por su índice (0-based)
|
||||||
|
func (b *Browser) SwitchToFrameByIndex(ctx context.Context, index int) error {
|
||||||
|
selector := fmt.Sprintf("iframe:nth-of-type(%d)", index+1)
|
||||||
|
return b.SwitchToFrame(ctx, selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToFrameByName cambia a un iframe por su atributo name o id
|
||||||
|
func (b *Browser) SwitchToFrameByName(ctx context.Context, name string) error {
|
||||||
|
// Intentar primero por name
|
||||||
|
selector := fmt.Sprintf("iframe[name='%s']", name)
|
||||||
|
err := b.SwitchToFrame(ctx, selector)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si falla, intentar por id
|
||||||
|
selector = fmt.Sprintf("iframe#%s", name)
|
||||||
|
return b.SwitchToFrame(ctx, selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToMainFrame vuelve al contexto del frame principal
|
||||||
|
func (b *Browser) SwitchToMainFrame(ctx context.Context) error {
|
||||||
|
currentFrameID = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrames obtiene el árbol de frames de la página
|
||||||
|
func (b *Browser) GetFrames(ctx context.Context) ([]*Frame, error) {
|
||||||
|
var result struct {
|
||||||
|
FrameTree struct {
|
||||||
|
Frame frameInfo `json:"frame"`
|
||||||
|
ChildFrames []frameTree `json:"childFrames"`
|
||||||
|
} `json:"frameTree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Page.getFrameTree", nil, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get frame tree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir el árbol a lista plana de frames
|
||||||
|
frames := []*Frame{
|
||||||
|
{
|
||||||
|
ID: result.FrameTree.Frame.ID,
|
||||||
|
ParentID: result.FrameTree.Frame.ParentID,
|
||||||
|
URL: result.FrameTree.Frame.URL,
|
||||||
|
Name: result.FrameTree.Frame.Name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar frames hijos recursivamente
|
||||||
|
frames = append(frames, flattenFrameTree(result.FrameTree.ChildFrames, result.FrameTree.Frame.ID)...)
|
||||||
|
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameInfo estructura para información de frame de CDP
|
||||||
|
type frameInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ParentID string `json:"parentId"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameTree estructura recursiva de CDP
|
||||||
|
type frameTree struct {
|
||||||
|
Frame frameInfo `json:"frame"`
|
||||||
|
ChildFrames []frameTree `json:"childFrames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenFrameTree convierte árbol de frames a lista plana
|
||||||
|
func flattenFrameTree(trees []frameTree, parentID string) []*Frame {
|
||||||
|
var frames []*Frame
|
||||||
|
|
||||||
|
for _, tree := range trees {
|
||||||
|
frame := &Frame{
|
||||||
|
ID: tree.Frame.ID,
|
||||||
|
ParentID: parentID,
|
||||||
|
URL: tree.Frame.URL,
|
||||||
|
Name: tree.Frame.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
frames = append(frames, frame)
|
||||||
|
|
||||||
|
// Recursivamente agregar sub-frames
|
||||||
|
if len(tree.ChildFrames) > 0 {
|
||||||
|
frames = append(frames, flattenFrameTree(tree.ChildFrames, tree.Frame.ID)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentFrame obtiene el frame actual
|
||||||
|
func (b *Browser) GetCurrentFrame(ctx context.Context) (*Frame, error) {
|
||||||
|
if currentFrameID == "" {
|
||||||
|
// Estamos en el frame principal
|
||||||
|
frames, err := b.GetFrames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(frames) > 0 {
|
||||||
|
return frames[0], nil // Frame principal
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no frames found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar el frame actual
|
||||||
|
frames, err := b.GetFrames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, frame := range frames {
|
||||||
|
if frame.ID == currentFrameID {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("current frame not found: %s", currentFrameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForFrame espera a que un frame aparezca y cargue
|
||||||
|
func (b *Browser) WaitForFrame(ctx context.Context, selector string) error {
|
||||||
|
// Esperar a que el elemento iframe aparezca
|
||||||
|
if err := b.WaitForSelector(ctx, selector, 30*1000); err != nil {
|
||||||
|
return fmt.Errorf("frame selector not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cambiar al frame
|
||||||
|
if err := b.SwitchToFrame(ctx, selector); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar a que el frame termine de cargar
|
||||||
|
// Evaluar readyState en el contexto del frame
|
||||||
|
script := `document.readyState === 'complete'`
|
||||||
|
result, err := b.evaluateInCurrentFrame(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ready, ok := result.Value.(bool); !ok || !ready {
|
||||||
|
return fmt.Errorf("frame did not finish loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateInCurrentFrame ejecuta JavaScript en el frame actual
|
||||||
|
func (b *Browser) evaluateInCurrentFrame(ctx context.Context, script string) (*EvaluateResult, error) {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
"returnByValue": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si estamos en un frame específico, agregar el frameId
|
||||||
|
if currentFrameID != "" {
|
||||||
|
// Necesitamos obtener el execution context del frame
|
||||||
|
var contextResult struct {
|
||||||
|
Contexts []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
FrameID string `json:"frameId"`
|
||||||
|
} `json:"contexts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.executionContexts", nil, &contextResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get execution contexts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar el contexto del frame actual
|
||||||
|
for _, context := range contextResult.Contexts {
|
||||||
|
if context.FrameID == currentFrameID {
|
||||||
|
params["contextId"] = context.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Result struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to evaluate in frame: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EvaluateResult{
|
||||||
|
Type: result.Result.Type,
|
||||||
|
Value: result.Result.Value,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateInFrame ejecuta JavaScript en un frame específico sin cambiar el contexto
|
||||||
|
func (b *Browser) EvaluateInFrame(ctx context.Context, frameID string, script string) (*EvaluateResult, error) {
|
||||||
|
// Guardar frame actual
|
||||||
|
previousFrame := currentFrameID
|
||||||
|
|
||||||
|
// Temporalmente cambiar al frame especificado
|
||||||
|
currentFrameID = frameID
|
||||||
|
|
||||||
|
// Ejecutar script
|
||||||
|
result, err := b.evaluateInCurrentFrame(ctx, script)
|
||||||
|
|
||||||
|
// Restaurar frame anterior
|
||||||
|
currentFrameID = previousFrame
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFrames cuenta el número total de frames en la página
|
||||||
|
func (b *Browser) CountFrames(ctx context.Context) (int, error) {
|
||||||
|
frames, err := b.GetFrames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(frames), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrameByName busca un frame por su atributo name
|
||||||
|
func (b *Browser) GetFrameByName(ctx context.Context, name string) (*Frame, error) {
|
||||||
|
frames, err := b.GetFrames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, frame := range frames {
|
||||||
|
if frame.Name == name {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("frame not found with name: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrameByURL busca un frame por coincidencia parcial de URL
|
||||||
|
func (b *Browser) GetFrameByURL(ctx context.Context, urlPattern string) (*Frame, error) {
|
||||||
|
frames, err := b.GetFrames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, frame := range frames {
|
||||||
|
if containsString(frame.URL, urlPattern) {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("frame not found with URL pattern: %s", urlPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsString verifica si haystack contiene needle
|
||||||
|
func containsString(haystack, needle string) bool {
|
||||||
|
return len(haystack) >= len(needle) && findSubstring(haystack, needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSubstring busca substring
|
||||||
|
func findSubstring(s, sub string) bool {
|
||||||
|
if sub == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkdownOptions opciones para conversión a Markdown
|
||||||
|
type MarkdownOptions struct {
|
||||||
|
Selector string // Selector CSS opcional para convertir solo una parte
|
||||||
|
IncludeImages bool // Incluir imágenes en el output
|
||||||
|
IncludeLinks bool // Incluir enlaces (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMarkdownOptions retorna opciones por defecto
|
||||||
|
func DefaultMarkdownOptions() *MarkdownOptions {
|
||||||
|
return &MarkdownOptions{
|
||||||
|
Selector: "",
|
||||||
|
IncludeImages: true,
|
||||||
|
IncludeLinks: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToMarkdown convierte el contenido HTML de la página actual a Markdown
|
||||||
|
// usando la biblioteca Turndown.js ejecutada en el navegador
|
||||||
|
func (b *Browser) ToMarkdown(ctx context.Context, opts *MarkdownOptions) (string, error) {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultMarkdownOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script que incluye Turndown.js y realiza la conversión
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
// Librería Turndown inline (versión minificada)
|
||||||
|
// https://github.com/mixmark-io/turndown
|
||||||
|
const TurndownService = %s;
|
||||||
|
|
||||||
|
// Configurar Turndown
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
hr: '---',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
fence: '` + "```" + `',
|
||||||
|
emDelimiter: '_',
|
||||||
|
strongDelimiter: '**',
|
||||||
|
linkStyle: 'inlined',
|
||||||
|
linkReferenceStyle: 'full'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configurar reglas personalizadas
|
||||||
|
if (!%t) {
|
||||||
|
// Eliminar imágenes si no se incluyen
|
||||||
|
turndownService.addRule('removeImages', {
|
||||||
|
filter: 'img',
|
||||||
|
replacement: function() { return ''; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!%t) {
|
||||||
|
// Convertir enlaces a texto plano si no se incluyen
|
||||||
|
turndownService.addRule('removeLinks', {
|
||||||
|
filter: 'a',
|
||||||
|
replacement: function(content) { return content; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener HTML a convertir
|
||||||
|
let element;
|
||||||
|
if ('%s') {
|
||||||
|
element = document.querySelector('%s');
|
||||||
|
if (!element) {
|
||||||
|
throw new Error('Selector not found: %s');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
element = document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a Markdown
|
||||||
|
const markdown = turndownService.turndown(element);
|
||||||
|
return markdown;
|
||||||
|
})();
|
||||||
|
`, getTurndownLibrary(), opts.IncludeImages, opts.IncludeLinks,
|
||||||
|
opts.Selector, opts.Selector, opts.Selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error converting to markdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Value == nil {
|
||||||
|
return "", fmt.Errorf("markdown conversion returned null")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir resultado a string
|
||||||
|
var markdown string
|
||||||
|
if str, ok := result.Value.(string); ok {
|
||||||
|
markdown = str
|
||||||
|
} else {
|
||||||
|
// Intentar serializar como JSON
|
||||||
|
jsonBytes, err := json.Marshal(result.Value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error parsing markdown result: %w", err)
|
||||||
|
}
|
||||||
|
markdown = string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTurndownLibrary retorna el código de Turndown.js inline
|
||||||
|
// Esta es una versión simplificada. En producción, cargar el archivo completo.
|
||||||
|
func getTurndownLibrary() string {
|
||||||
|
// Versión muy simplificada de Turndown inline
|
||||||
|
// Para producción, considerar cargar desde CDN o bundlear el archivo completo
|
||||||
|
return `
|
||||||
|
(function() {
|
||||||
|
function TurndownService(options) {
|
||||||
|
this.options = options || {};
|
||||||
|
this.rules = {
|
||||||
|
array: []
|
||||||
|
};
|
||||||
|
this.keep = function(filter) {};
|
||||||
|
this.remove = function(filter) {};
|
||||||
|
}
|
||||||
|
|
||||||
|
TurndownService.prototype.addRule = function(key, rule) {
|
||||||
|
this.rules.array.push(rule);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
TurndownService.prototype.turndown = function(input) {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = input;
|
||||||
|
input = div;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.processNode(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
TurndownService.prototype.processNode = function(node) {
|
||||||
|
let markdown = '';
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return node.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar según el tag
|
||||||
|
const tagName = node.tagName.toLowerCase();
|
||||||
|
const children = Array.from(node.childNodes).map(child => this.processNode(child)).join('');
|
||||||
|
|
||||||
|
switch(tagName) {
|
||||||
|
case 'h1':
|
||||||
|
return '# ' + children + '\n\n';
|
||||||
|
case 'h2':
|
||||||
|
return '## ' + children + '\n\n';
|
||||||
|
case 'h3':
|
||||||
|
return '### ' + children + '\n\n';
|
||||||
|
case 'h4':
|
||||||
|
return '#### ' + children + '\n\n';
|
||||||
|
case 'h5':
|
||||||
|
return '##### ' + children + '\n\n';
|
||||||
|
case 'h6':
|
||||||
|
return '###### ' + children + '\n\n';
|
||||||
|
case 'p':
|
||||||
|
return children + '\n\n';
|
||||||
|
case 'br':
|
||||||
|
return ' \n';
|
||||||
|
case 'strong':
|
||||||
|
case 'b':
|
||||||
|
return '**' + children + '**';
|
||||||
|
case 'em':
|
||||||
|
case 'i':
|
||||||
|
return '_' + children + '_';
|
||||||
|
case 'a':
|
||||||
|
const href = node.getAttribute('href') || '';
|
||||||
|
return '[' + children + '](' + href + ')';
|
||||||
|
case 'img':
|
||||||
|
const src = node.getAttribute('src') || '';
|
||||||
|
const alt = node.getAttribute('alt') || '';
|
||||||
|
return '';
|
||||||
|
case 'ul':
|
||||||
|
case 'ol':
|
||||||
|
return '\n' + children + '\n';
|
||||||
|
case 'li':
|
||||||
|
const listMarker = node.parentElement.tagName.toLowerCase() === 'ol' ? '1. ' : '- ';
|
||||||
|
return listMarker + children + '\n';
|
||||||
|
case 'code':
|
||||||
|
if (node.parentElement.tagName.toLowerCase() === 'pre') {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
return '` + "`" + `' + children + '` + "`" + `';
|
||||||
|
case 'pre':
|
||||||
|
return '\n` + "```" + `\n' + children + '\n` + "```" + `\n\n';
|
||||||
|
case 'blockquote':
|
||||||
|
return '\n> ' + children.split('\n').join('\n> ') + '\n\n';
|
||||||
|
case 'hr':
|
||||||
|
return '\n---\n\n';
|
||||||
|
case 'table':
|
||||||
|
return '\n' + this.processTable(node) + '\n';
|
||||||
|
case 'script':
|
||||||
|
case 'style':
|
||||||
|
case 'noscript':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TurndownService.prototype.processTable = function(table) {
|
||||||
|
// Procesamiento básico de tablas
|
||||||
|
let markdown = '';
|
||||||
|
const rows = table.querySelectorAll('tr');
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const cells = row.querySelectorAll('th, td');
|
||||||
|
const cellContents = Array.from(cells).map(cell => cell.textContent.trim());
|
||||||
|
markdown += '| ' + cellContents.join(' | ') + ' |\n';
|
||||||
|
|
||||||
|
// Agregar separador después del header
|
||||||
|
if (index === 0 && cells[0].tagName.toLowerCase() === 'th') {
|
||||||
|
markdown += '| ' + cellContents.map(() => '---').join(' | ') + ' |\n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
return TurndownService;
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NavigateOptions opciones para la navegación.
|
||||||
|
type NavigateOptions struct {
|
||||||
|
// WaitUntil define cuándo se considera completada la navegación
|
||||||
|
// "load" = evento load, "domcontentloaded" = DOM listo, "networkidle" = red inactiva
|
||||||
|
WaitUntil string
|
||||||
|
|
||||||
|
// Timeout para la navegación
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// Referer personalizado
|
||||||
|
Referer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultNavigateOptions retorna opciones por defecto.
|
||||||
|
func DefaultNavigateOptions() *NavigateOptions {
|
||||||
|
return &NavigateOptions{
|
||||||
|
WaitUntil: "load",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate navega a una URL.
|
||||||
|
func (b *Browser) Navigate(ctx context.Context, url string, opts *NavigateOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultNavigateOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
navCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Preparar parámetros
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Referer != "" {
|
||||||
|
params["referrer"] = opts.Referer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canal para eventos de navegación
|
||||||
|
loadedCh := make(chan struct{})
|
||||||
|
var loadErr error
|
||||||
|
|
||||||
|
// Registrar eventos según WaitUntil
|
||||||
|
switch opts.WaitUntil {
|
||||||
|
case "domcontentloaded":
|
||||||
|
b.cdpClient.On("Page.domContentEventFired", func(params json.RawMessage) {
|
||||||
|
close(loadedCh)
|
||||||
|
})
|
||||||
|
case "networkidle":
|
||||||
|
// Implementación simple: esperar a que no haya requests por 500ms
|
||||||
|
idleTimer := time.NewTimer(500 * time.Millisecond)
|
||||||
|
activeRequests := 0
|
||||||
|
|
||||||
|
b.cdpClient.On("Network.requestWillBeSent", func(params json.RawMessage) {
|
||||||
|
activeRequests++
|
||||||
|
idleTimer.Reset(500 * time.Millisecond)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.cdpClient.On("Network.loadingFinished", func(params json.RawMessage) {
|
||||||
|
activeRequests--
|
||||||
|
if activeRequests <= 0 {
|
||||||
|
idleTimer.Reset(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.cdpClient.On("Network.loadingFailed", func(params json.RawMessage) {
|
||||||
|
activeRequests--
|
||||||
|
if activeRequests <= 0 {
|
||||||
|
idleTimer.Reset(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-idleTimer.C
|
||||||
|
close(loadedCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
default: // "load"
|
||||||
|
b.cdpClient.On("Page.loadEventFired", func(params json.RawMessage) {
|
||||||
|
close(loadedCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navegar
|
||||||
|
if err := b.cdpClient.Execute(navCtx, "Page.navigate", params, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to navigate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar a que se complete la navegación
|
||||||
|
var err error
|
||||||
|
select {
|
||||||
|
case <-loadedCh:
|
||||||
|
err = loadErr
|
||||||
|
case <-navCtx.Done():
|
||||||
|
err = fmt.Errorf("navigation timeout: %w", navCtx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar acción
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Record("Navigate", map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
"waitUntil": opts.WaitUntil,
|
||||||
|
}, nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click hace clic en un elemento usando un selector CSS.
|
||||||
|
func (b *Browser) Click(ctx context.Context, selector string) error {
|
||||||
|
// Obtener el NodeID del elemento
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener las coordenadas del elemento
|
||||||
|
box, err := b.getElementBox(ctx, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular centro del elemento
|
||||||
|
x := box.X + box.Width/2
|
||||||
|
y := box.Y + box.Height/2
|
||||||
|
|
||||||
|
// Simular click (mousePressed + mouseReleased)
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", map[string]interface{}{
|
||||||
|
"type": "mousePressed",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"button": "left",
|
||||||
|
"clickCount": 1,
|
||||||
|
}, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to press mouse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pequeño delay entre pressed y released (más natural)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
err = b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", map[string]interface{}{
|
||||||
|
"type": "mouseReleased",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"button": "left",
|
||||||
|
"clickCount": 1,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Record("Click", map[string]interface{}{"selector": selector}, nil, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to release mouse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar acción
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Record("Click", map[string]interface{}{"selector": selector}, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type escribe texto en un elemento.
|
||||||
|
func (b *Browser) Type(ctx context.Context, selector string, text string, opts *TypeOptions) error {
|
||||||
|
if opts == nil {
|
||||||
|
opts = DefaultTypeOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus en el elemento primero
|
||||||
|
if err := b.Focus(ctx, selector); err != nil {
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Record("Type", map[string]interface{}{
|
||||||
|
"selector": selector,
|
||||||
|
"text": text,
|
||||||
|
}, nil, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escribir cada carácter con delay
|
||||||
|
for _, char := range text {
|
||||||
|
if err := b.typeChar(ctx, string(char)); err != nil {
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Record("Type", map[string]interface{}{
|
||||||
|
"selector": selector,
|
||||||
|
"text": text,
|
||||||
|
}, nil, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Delay > 0 {
|
||||||
|
time.Sleep(opts.Delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar acción exitosa
|
||||||
|
if b.recorder != nil {
|
||||||
|
b.recorder.Record("Type", map[string]interface{}{
|
||||||
|
"selector": selector,
|
||||||
|
"text": text,
|
||||||
|
"delay": opts.Delay.String(),
|
||||||
|
}, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeOptions opciones para escribir texto.
|
||||||
|
type TypeOptions struct {
|
||||||
|
// Delay entre caracteres (más natural)
|
||||||
|
Delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTypeOptions retorna opciones por defecto.
|
||||||
|
func DefaultTypeOptions() *TypeOptions {
|
||||||
|
return &TypeOptions{
|
||||||
|
Delay: 50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeChar escribe un solo carácter.
|
||||||
|
func (b *Browser) typeChar(ctx context.Context, char string) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"type": "char",
|
||||||
|
"text": char,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Input.dispatchKeyEvent", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus hace foco en un elemento.
|
||||||
|
func (b *Browser) Focus(ctx context.Context, selector string) error {
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "DOM.focus", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screenshot toma una captura de pantalla.
|
||||||
|
func (b *Browser) Screenshot(ctx context.Context, fullPage bool) ([]byte, error) {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"format": "png",
|
||||||
|
"captureBeyondViewport": fullPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Page.captureScreenshot", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to capture screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTML obtiene el HTML de la página o de un elemento específico.
|
||||||
|
func (b *Browser) GetHTML(ctx context.Context, selector string) (string, error) {
|
||||||
|
if selector == "" {
|
||||||
|
// Obtener HTML completo
|
||||||
|
var result struct {
|
||||||
|
Result struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": "document.documentElement.outerHTML",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Result.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener HTML de un elemento específico
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
OuterHTML string `json:"outerHTML"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.getOuterHTML", params, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.OuterHTML, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetText obtiene el texto visible de un elemento.
|
||||||
|
func (b *Browser) GetText(ctx context.Context, selector string) (string, error) {
|
||||||
|
script := fmt.Sprintf(`document.querySelector('%s')?.textContent`, selector)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Result struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get text: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Result.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForSelector espera a que un selector esté disponible.
|
||||||
|
func (b *Browser) WaitForSelector(ctx context.Context, selector string, timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("timeout waiting for selector: %s", selector)
|
||||||
|
case <-ticker.C:
|
||||||
|
_, err := b.querySelector(ctx, selector)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// querySelector helper para obtener NodeID de un selector.
|
||||||
|
func (b *Browser) querySelector(ctx context.Context, selector string) (int64, error) {
|
||||||
|
// Primero obtener el documento root
|
||||||
|
var docResult struct {
|
||||||
|
Root struct {
|
||||||
|
NodeID int64 `json:"nodeId"`
|
||||||
|
} `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.getDocument", nil, &docResult); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar el elemento
|
||||||
|
var queryResult struct {
|
||||||
|
NodeID int64 `json:"nodeId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"nodeId": docResult.Root.NodeID,
|
||||||
|
"selector": selector,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.querySelector", params, &queryResult); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to query selector: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queryResult.NodeID == 0 {
|
||||||
|
return 0, fmt.Errorf("element not found: %s", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryResult.NodeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box representa las coordenadas de un elemento.
|
||||||
|
type Box struct {
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
Width float64
|
||||||
|
Height float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// getElementBox obtiene las coordenadas de un elemento.
|
||||||
|
func (b *Browser) getElementBox(ctx context.Context, nodeID int64) (*Box, error) {
|
||||||
|
var result struct {
|
||||||
|
Model struct {
|
||||||
|
Content []float64 `json:"content"`
|
||||||
|
} `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.getBoxModel", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get box model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Model.Content) < 8 {
|
||||||
|
return nil, errors.New("invalid box model")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content es un array [x1, y1, x2, y2, x3, y3, x4, y4] (cuatro esquinas)
|
||||||
|
x := result.Model.Content[0]
|
||||||
|
y := result.Model.Content[1]
|
||||||
|
width := result.Model.Content[2] - x
|
||||||
|
height := result.Model.Content[5] - y
|
||||||
|
|
||||||
|
return &Box{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload recarga la página actual.
|
||||||
|
func (b *Browser) Reload(ctx context.Context) error {
|
||||||
|
return b.cdpClient.Execute(ctx, "Page.reload", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoBack navega hacia atrás en el historial.
|
||||||
|
func (b *Browser) GoBack(ctx context.Context) error {
|
||||||
|
// Obtener historial
|
||||||
|
var history struct {
|
||||||
|
CurrentIndex int `json:"currentIndex"`
|
||||||
|
Entries []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Page.getNavigationHistory", nil, &history); err != nil {
|
||||||
|
return fmt.Errorf("failed to get history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if history.CurrentIndex <= 0 {
|
||||||
|
return errors.New("no history to go back")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navegar a la entrada anterior
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"entryId": history.Entries[history.CurrentIndex-1].ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Page.navigateToHistoryEntry", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoForward navega hacia adelante en el historial.
|
||||||
|
func (b *Browser) GoForward(ctx context.Context) error {
|
||||||
|
var history struct {
|
||||||
|
CurrentIndex int `json:"currentIndex"`
|
||||||
|
Entries []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Page.getNavigationHistory", nil, &history); err != nil {
|
||||||
|
return fmt.Errorf("failed to get history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if history.CurrentIndex >= len(history.Entries)-1 {
|
||||||
|
return errors.New("no history to go forward")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"entryId": history.Entries[history.CurrentIndex+1].ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Page.navigateToHistoryEntry", params, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestInterceptor intercepta y puede modificar requests.
|
||||||
|
type RequestInterceptor struct {
|
||||||
|
URLPattern string
|
||||||
|
Handler RequestHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestHandler maneja un request interceptado.
|
||||||
|
type RequestHandler func(req *InterceptedRequest) *RequestAction
|
||||||
|
|
||||||
|
// InterceptedRequest representa un request interceptado.
|
||||||
|
type InterceptedRequest struct {
|
||||||
|
InterceptionID string
|
||||||
|
RequestID string
|
||||||
|
URL string
|
||||||
|
Method string
|
||||||
|
Headers map[string]interface{}
|
||||||
|
PostData string
|
||||||
|
ResourceType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestAction es la acción a tomar sobre un request.
|
||||||
|
type RequestAction struct {
|
||||||
|
// Continue continúa el request sin modificar
|
||||||
|
Continue bool
|
||||||
|
|
||||||
|
// Abort aborta el request
|
||||||
|
Abort bool
|
||||||
|
|
||||||
|
// Mock responde con datos mockeados
|
||||||
|
Mock *MockResponse
|
||||||
|
|
||||||
|
// ModifiedHeaders headers modificados
|
||||||
|
ModifiedHeaders map[string]string
|
||||||
|
|
||||||
|
// ModifiedURL URL modificada
|
||||||
|
ModifiedURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockResponse es una respuesta mockeada.
|
||||||
|
type MockResponse struct {
|
||||||
|
StatusCode int
|
||||||
|
Headers map[string]string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkInterceptor gestiona interceptación de red.
|
||||||
|
type NetworkInterceptor struct {
|
||||||
|
browser *Browser
|
||||||
|
interceptors []*RequestInterceptor
|
||||||
|
mu sync.RWMutex
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableNetworkInterception habilita la interceptación de red.
|
||||||
|
func (b *Browser) EnableNetworkInterception(ctx context.Context) (*NetworkInterceptor, error) {
|
||||||
|
ni := &NetworkInterceptor{
|
||||||
|
browser: b,
|
||||||
|
interceptors: make([]*RequestInterceptor, 0),
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Habilitar Fetch domain
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Fetch.enable", map[string]interface{}{
|
||||||
|
"patterns": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"urlPattern": "*",
|
||||||
|
"resourceType": "*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to enable Fetch domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar handler de eventos
|
||||||
|
b.cdpClient.On("Fetch.requestPaused", func(params json.RawMessage) {
|
||||||
|
ni.handleRequestPaused(params)
|
||||||
|
})
|
||||||
|
|
||||||
|
ni.enabled = true
|
||||||
|
return ni, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddInterceptor agrega un interceptor.
|
||||||
|
func (ni *NetworkInterceptor) AddInterceptor(urlPattern string, handler RequestHandler) {
|
||||||
|
ni.mu.Lock()
|
||||||
|
defer ni.mu.Unlock()
|
||||||
|
|
||||||
|
ni.interceptors = append(ni.interceptors, &RequestInterceptor{
|
||||||
|
URLPattern: urlPattern,
|
||||||
|
Handler: handler,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestPaused maneja eventos de request pausado.
|
||||||
|
func (ni *NetworkInterceptor) handleRequestPaused(params json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
Request struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Headers map[string]interface{} `json:"headers"`
|
||||||
|
PostData string `json:"postData,omitempty"`
|
||||||
|
} `json:"request"`
|
||||||
|
ResourceType string `json:"resourceType"`
|
||||||
|
FrameID string `json:"frameId"`
|
||||||
|
InterceptionID string `json:"interceptionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(params, &event); err != nil {
|
||||||
|
ni.continueRequest(event.InterceptionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &InterceptedRequest{
|
||||||
|
InterceptionID: event.InterceptionID,
|
||||||
|
RequestID: event.RequestID,
|
||||||
|
URL: event.Request.URL,
|
||||||
|
Method: event.Request.Method,
|
||||||
|
Headers: event.Request.Headers,
|
||||||
|
PostData: event.Request.PostData,
|
||||||
|
ResourceType: event.ResourceType,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar interceptor que coincida
|
||||||
|
ni.mu.RLock()
|
||||||
|
var matchedHandler RequestHandler
|
||||||
|
for _, interceptor := range ni.interceptors {
|
||||||
|
if matchesPattern(req.URL, interceptor.URLPattern) {
|
||||||
|
matchedHandler = interceptor.Handler
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ni.mu.RUnlock()
|
||||||
|
|
||||||
|
if matchedHandler == nil {
|
||||||
|
// No hay interceptor, continuar normalmente
|
||||||
|
ni.continueRequest(event.InterceptionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar handler
|
||||||
|
action := matchedHandler(req)
|
||||||
|
ni.handleAction(req, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAction ejecuta la acción sobre el request.
|
||||||
|
func (ni *NetworkInterceptor) handleAction(req *InterceptedRequest, action *RequestAction) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if action.Abort {
|
||||||
|
// Abortar request
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"requestId": req.InterceptionID,
|
||||||
|
"errorReason": "Aborted",
|
||||||
|
}
|
||||||
|
ni.browser.cdpClient.Execute(ctx, "Fetch.failRequest", params, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Mock != nil {
|
||||||
|
// Responder con mock
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"requestId": req.InterceptionID,
|
||||||
|
"responseCode": action.Mock.StatusCode,
|
||||||
|
"responseHeaders": convertHeaders(action.Mock.Headers),
|
||||||
|
"body": base64Encode(action.Mock.Body),
|
||||||
|
}
|
||||||
|
ni.browser.cdpClient.Execute(ctx, "Fetch.fulfillRequest", params, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuar (posiblemente modificado)
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"requestId": req.InterceptionID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.ModifiedURL != "" {
|
||||||
|
params["url"] = action.ModifiedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(action.ModifiedHeaders) > 0 {
|
||||||
|
params["headers"] = convertHeaders(action.ModifiedHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
ni.browser.cdpClient.Execute(ctx, "Fetch.continueRequest", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// continueRequest continúa un request sin modificaciones.
|
||||||
|
func (ni *NetworkInterceptor) continueRequest(interceptionID string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"requestId": interceptionID,
|
||||||
|
}
|
||||||
|
ni.browser.cdpClient.Execute(ctx, "Fetch.continueRequest", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable deshabilita la interceptación de red.
|
||||||
|
func (ni *NetworkInterceptor) Disable(ctx context.Context) error {
|
||||||
|
if !ni.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ni.browser.cdpClient.Execute(ctx, "Fetch.disable", nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to disable Fetch domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ni.enabled = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockURLs bloquea requests a URLs que coincidan con los patrones.
|
||||||
|
func (b *Browser) BlockURLs(ctx context.Context, patterns ...string) (*NetworkInterceptor, error) {
|
||||||
|
ni, err := b.EnableNetworkInterception(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
ni.AddInterceptor(pattern, func(req *InterceptedRequest) *RequestAction {
|
||||||
|
return &RequestAction{Abort: true}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ni, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockResourceTypes bloquea tipos de recursos específicos.
|
||||||
|
func (b *Browser) BlockResourceTypes(ctx context.Context, resourceTypes ...string) (*NetworkInterceptor, error) {
|
||||||
|
ni, err := b.EnableNetworkInterception(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
typeMap := make(map[string]bool)
|
||||||
|
for _, rt := range resourceTypes {
|
||||||
|
typeMap[rt] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ni.AddInterceptor("*", func(req *InterceptedRequest) *RequestAction {
|
||||||
|
if typeMap[req.ResourceType] {
|
||||||
|
return &RequestAction{Abort: true}
|
||||||
|
}
|
||||||
|
return &RequestAction{Continue: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
return ni, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyHeaders crea un interceptor que modifica headers.
|
||||||
|
func (b *Browser) ModifyHeaders(ctx context.Context, headers map[string]string) (*NetworkInterceptor, error) {
|
||||||
|
ni, err := b.EnableNetworkInterception(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ni.AddInterceptor("*", func(req *InterceptedRequest) *RequestAction {
|
||||||
|
return &RequestAction{
|
||||||
|
Continue: true,
|
||||||
|
ModifiedHeaders: headers,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return ni, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetExtraHTTPHeaders establece headers HTTP extra para todos los requests.
|
||||||
|
// Más eficiente que usar interceptación.
|
||||||
|
func (b *Browser) SetExtraHTTPHeaders(ctx context.Context, headers map[string]string) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"headers": headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.setExtraHTTPHeaders", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserAgent establece el user agent.
|
||||||
|
func (b *Browser) SetUserAgent(ctx context.Context, userAgent string) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"userAgent": userAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.setUserAgentOverride", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmulateNetworkConditions emula condiciones de red (throttling).
|
||||||
|
func (b *Browser) EmulateNetworkConditions(ctx context.Context, offline bool, latency, downloadThroughput, uploadThroughput float64) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"offline": offline,
|
||||||
|
"latency": latency,
|
||||||
|
"downloadThroughput": downloadThroughput,
|
||||||
|
"uploadThroughput": uploadThroughput,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.emulateNetworkConditions", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableCache deshabilita el caché HTTP.
|
||||||
|
func (b *Browser) DisableCache(ctx context.Context) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"cacheDisabled": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.setCacheDisabled", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableCache habilita el caché HTTP.
|
||||||
|
func (b *Browser) EnableCache(ctx context.Context) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"cacheDisabled": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.setCacheDisabled", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
func matchesPattern(url, pattern string) bool {
|
||||||
|
// Implementación simple de pattern matching
|
||||||
|
// * = wildcard
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implementar matching más sofisticado con wildcards
|
||||||
|
// Por ahora, solo match exacto o wildcard completo
|
||||||
|
return url == pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertHeaders(headers map[string]string) []map[string]string {
|
||||||
|
result := make([]map[string]string, 0, len(headers))
|
||||||
|
for name, value := range headers {
|
||||||
|
result = append(result, map[string]string{
|
||||||
|
"name": name,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64Encode(s string) string {
|
||||||
|
// Simple base64 encode usando tabla estándar
|
||||||
|
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
data := []byte(s)
|
||||||
|
result := make([]byte, ((len(data)+2)/3)*4)
|
||||||
|
|
||||||
|
j := 0
|
||||||
|
for i := 0; i < len(data); i += 3 {
|
||||||
|
b := (uint(data[i]) << 16)
|
||||||
|
if i+1 < len(data) {
|
||||||
|
b |= (uint(data[i+1]) << 8)
|
||||||
|
}
|
||||||
|
if i+2 < len(data) {
|
||||||
|
b |= uint(data[i+2])
|
||||||
|
}
|
||||||
|
|
||||||
|
result[j] = base64Table[(b>>18)&0x3F]
|
||||||
|
result[j+1] = base64Table[(b>>12)&0x3F]
|
||||||
|
|
||||||
|
if i+1 < len(data) {
|
||||||
|
result[j+2] = base64Table[(b>>6)&0x3F]
|
||||||
|
} else {
|
||||||
|
result[j+2] = '='
|
||||||
|
}
|
||||||
|
|
||||||
|
if i+2 < len(data) {
|
||||||
|
result[j+3] = base64Table[b&0x3F]
|
||||||
|
} else {
|
||||||
|
result[j+3] = '='
|
||||||
|
}
|
||||||
|
|
||||||
|
j += 4
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CookieFormat formato de archivo de cookies
|
||||||
|
type CookieFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CookieFormatJSON CookieFormat = "json" // JSON estándar
|
||||||
|
CookieFormatNetscape CookieFormat = "netscape" // cookies.txt formato Netscape
|
||||||
|
)
|
||||||
|
|
||||||
|
// CookieFilter filtro para búsqueda de cookies
|
||||||
|
type CookieFilter struct {
|
||||||
|
Domain string // Filtrar por dominio (ej: ".example.com")
|
||||||
|
Name string // Filtrar por nombre exacto
|
||||||
|
Path string // Filtrar por path
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllCookies obtiene todas las cookies del navegador actual
|
||||||
|
func (b *Browser) GetAllCookies(ctx context.Context) ([]*Cookie, error) {
|
||||||
|
result, err := b.cdpClient.SendCommand(ctx, "Network.getAllCookies", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting all cookies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cookies []*Cookie
|
||||||
|
if cookiesData, ok := result["cookies"].([]interface{}); ok {
|
||||||
|
for _, cookieData := range cookiesData {
|
||||||
|
if cookieMap, ok := cookieData.(map[string]interface{}); ok {
|
||||||
|
cookie := parseCookieFromMap(cookieMap)
|
||||||
|
cookies = append(cookies, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterCookies obtiene cookies que coinciden con filtros
|
||||||
|
func (b *Browser) FilterCookies(ctx context.Context, filter CookieFilter) ([]*Cookie, error) {
|
||||||
|
allCookies, err := b.GetAllCookies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []*Cookie
|
||||||
|
for _, cookie := range allCookies {
|
||||||
|
match := true
|
||||||
|
|
||||||
|
if filter.Domain != "" && !strings.Contains(cookie.Domain, filter.Domain) {
|
||||||
|
match = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Name != "" && cookie.Name != filter.Name {
|
||||||
|
match = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Path != "" && cookie.Path != filter.Path {
|
||||||
|
match = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if match {
|
||||||
|
filtered = append(filtered, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCookiesToFile exporta cookies a archivo
|
||||||
|
func (b *Browser) ExportCookiesToFile(ctx context.Context, filepath string, format CookieFormat) error {
|
||||||
|
cookies, err := b.GetAllCookies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
switch format {
|
||||||
|
case CookieFormatJSON:
|
||||||
|
content, err = cookiesToJSON(cookies)
|
||||||
|
case CookieFormatNetscape:
|
||||||
|
content = cookiesToNetscape(cookies)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported format: %s", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error formatting cookies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("error writing cookies file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportCookiesFromFile importa cookies desde archivo
|
||||||
|
func (b *Browser) ImportCookiesFromFile(ctx context.Context, filepath string, format CookieFormat) error {
|
||||||
|
data, err := os.ReadFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading cookies file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cookies []*Cookie
|
||||||
|
switch format {
|
||||||
|
case CookieFormatJSON:
|
||||||
|
cookies, err = cookiesFromJSON(data)
|
||||||
|
case CookieFormatNetscape:
|
||||||
|
cookies, err = cookiesFromNetscape(string(data))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported format: %s", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing cookies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer cada cookie
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if err := b.SetCookie(ctx, cookie); err != nil {
|
||||||
|
return fmt.Errorf("error setting cookie %s: %w", cookie.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCookiesByDomain elimina todas las cookies de un dominio específico
|
||||||
|
func (b *Browser) DeleteCookiesByDomain(ctx context.Context, domain string) error {
|
||||||
|
cookies, err := b.FilterCookies(ctx, CookieFilter{Domain: domain})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"name": cookie.Name,
|
||||||
|
"domain": cookie.Domain,
|
||||||
|
"path": cookie.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := b.cdpClient.SendCommand(ctx, "Network.deleteCookies", params)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error deleting cookie %s: %w", cookie.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookiesToJSON convierte cookies a formato JSON
|
||||||
|
func cookiesToJSON(cookies []*Cookie) (string, error) {
|
||||||
|
// Convertir a formato más simple para export
|
||||||
|
type SimpleCookie struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Expires float64 `json:"expires,omitempty"`
|
||||||
|
HTTPOnly bool `json:"httpOnly,omitempty"`
|
||||||
|
Secure bool `json:"secure,omitempty"`
|
||||||
|
SameSite string `json:"sameSite,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
simple := make([]SimpleCookie, len(cookies))
|
||||||
|
for i, c := range cookies {
|
||||||
|
simple[i] = SimpleCookie{
|
||||||
|
Name: c.Name,
|
||||||
|
Value: c.Value,
|
||||||
|
Domain: c.Domain,
|
||||||
|
Path: c.Path,
|
||||||
|
Expires: c.Expires,
|
||||||
|
HTTPOnly: c.HTTPOnly,
|
||||||
|
Secure: c.Secure,
|
||||||
|
SameSite: c.SameSite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, err := json.MarshalIndent(simple, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookiesFromJSON parsea cookies desde JSON
|
||||||
|
func cookiesFromJSON(data []byte) ([]*Cookie, error) {
|
||||||
|
type SimpleCookie struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Expires float64 `json:"expires"`
|
||||||
|
HTTPOnly bool `json:"httpOnly"`
|
||||||
|
Secure bool `json:"secure"`
|
||||||
|
SameSite string `json:"sameSite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var simple []SimpleCookie
|
||||||
|
if err := json.Unmarshal(data, &simple); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies := make([]*Cookie, len(simple))
|
||||||
|
for i, s := range simple {
|
||||||
|
cookies[i] = &Cookie{
|
||||||
|
Name: s.Name,
|
||||||
|
Value: s.Value,
|
||||||
|
Domain: s.Domain,
|
||||||
|
Path: s.Path,
|
||||||
|
Expires: s.Expires,
|
||||||
|
HTTPOnly: s.HTTPOnly,
|
||||||
|
Secure: s.Secure,
|
||||||
|
SameSite: s.SameSite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookiesToNetscape convierte cookies a formato Netscape cookies.txt
|
||||||
|
func cookiesToNetscape(cookies []*Cookie) string {
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, "# Netscape HTTP Cookie File")
|
||||||
|
lines = append(lines, "# This is a generated file. Do not edit.")
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
// Formato: domain flag path secure expiration name value
|
||||||
|
domain := c.Domain
|
||||||
|
if !strings.HasPrefix(domain, ".") {
|
||||||
|
domain = "." + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
flag := "TRUE"
|
||||||
|
secure := "FALSE"
|
||||||
|
if c.Secure {
|
||||||
|
secure = "TRUE"
|
||||||
|
}
|
||||||
|
|
||||||
|
expiration := "0"
|
||||||
|
if c.Expires > 0 {
|
||||||
|
expiration = fmt.Sprintf("%.0f", c.Expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s",
|
||||||
|
domain, flag, c.Path, secure, expiration, c.Name, c.Value)
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookiesFromNetscape parsea cookies desde formato Netscape
|
||||||
|
func cookiesFromNetscape(data string) ([]*Cookie, error) {
|
||||||
|
var cookies []*Cookie
|
||||||
|
lines := strings.Split(data, "\n")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(line, "\t")
|
||||||
|
if len(parts) != 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := &Cookie{
|
||||||
|
Domain: parts[0],
|
||||||
|
Path: parts[2],
|
||||||
|
Secure: parts[3] == "TRUE",
|
||||||
|
Name: parts[5],
|
||||||
|
Value: parts[6],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse expiration
|
||||||
|
if parts[4] != "0" {
|
||||||
|
fmt.Sscanf(parts[4], "%f", &cookie.Expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies = append(cookies, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCookieFromMap parsea una cookie desde un map CDP
|
||||||
|
func parseCookieFromMap(data map[string]interface{}) *Cookie {
|
||||||
|
cookie := &Cookie{}
|
||||||
|
|
||||||
|
if name, ok := data["name"].(string); ok {
|
||||||
|
cookie.Name = name
|
||||||
|
}
|
||||||
|
if value, ok := data["value"].(string); ok {
|
||||||
|
cookie.Value = value
|
||||||
|
}
|
||||||
|
if domain, ok := data["domain"].(string); ok {
|
||||||
|
cookie.Domain = domain
|
||||||
|
}
|
||||||
|
if path, ok := data["path"].(string); ok {
|
||||||
|
cookie.Path = path
|
||||||
|
}
|
||||||
|
if expires, ok := data["expires"].(float64); ok {
|
||||||
|
cookie.Expires = expires
|
||||||
|
}
|
||||||
|
if httpOnly, ok := data["httpOnly"].(bool); ok {
|
||||||
|
cookie.HTTPOnly = httpOnly
|
||||||
|
}
|
||||||
|
if secure, ok := data["secure"].(bool); ok {
|
||||||
|
cookie.Secure = secure
|
||||||
|
}
|
||||||
|
if sameSite, ok := data["sameSite"].(string); ok {
|
||||||
|
cookie.SameSite = sameSite
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile representa un perfil de navegador
|
||||||
|
type Profile struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfiles lista todos los perfiles disponibles en ~/.navegator/profiles
|
||||||
|
func ListProfiles() ([]Profile, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profilesDir := filepath.Join(homeDir, ".navegator", "profiles")
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(profilesDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []Profile{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []Profile
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
profiles = append(profiles, Profile{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Path: filepath.Join(profilesDir, entry.Name()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action representa una acción realizada en el navegador.
|
||||||
|
type Action struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Params map[string]interface{} `json:"params"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorder registra todas las acciones del navegador.
|
||||||
|
type Recorder struct {
|
||||||
|
file *os.File
|
||||||
|
encoder *json.Encoder
|
||||||
|
mu sync.Mutex
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecorder crea un nuevo recorder que escribe en un archivo.
|
||||||
|
func NewRecorder(filepath string) (*Recorder, error) {
|
||||||
|
file, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create recorder file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &Recorder{
|
||||||
|
file: file,
|
||||||
|
encoder: json.NewEncoder(file),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escribir header
|
||||||
|
fmt.Fprintf(file, "# Navegator Recording - %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
fmt.Fprintf(file, "# Este archivo puede ser usado para reproducir las acciones\n\n")
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record registra una acción.
|
||||||
|
func (r *Recorder) Record(actionType string, params map[string]interface{}, result interface{}, err error) {
|
||||||
|
if !r.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
action := Action{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Type: actionType,
|
||||||
|
Params: params,
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
action.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escribir JSON
|
||||||
|
r.encoder.Encode(action)
|
||||||
|
|
||||||
|
// También escribir comentario legible
|
||||||
|
if params["url"] != nil {
|
||||||
|
fmt.Fprintf(r.file, "# %s - %s: %v\n", action.Timestamp.Format("15:04:05"), actionType, params["url"])
|
||||||
|
} else if params["selector"] != nil {
|
||||||
|
fmt.Fprintf(r.file, "# %s - %s: %v\n", action.Timestamp.Format("15:04:05"), actionType, params["selector"])
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(r.file, "# %s - %s\n", action.Timestamp.Format("15:04:05"), actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cierra el recorder.
|
||||||
|
func (r *Recorder) Close() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.file != nil {
|
||||||
|
fmt.Fprintf(r.file, "\n# Recording ended at %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
return r.file.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable activa el recording.
|
||||||
|
func (r *Recorder) Enable() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable desactiva el recording.
|
||||||
|
func (r *Recorder) Disable() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddComment agrega un comentario al log.
|
||||||
|
func (r *Recorder) AddComment(comment string) {
|
||||||
|
if !r.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
fmt.Fprintf(r.file, "\n# %s\n", comment)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvaluateResult representa el resultado de una evaluación de JavaScript.
|
||||||
|
type EvaluateResult struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ObjectID string `json:"objectId,omitempty"`
|
||||||
|
SubType string `json:"subtype,omitempty"`
|
||||||
|
RawResult json.RawMessage `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate ejecuta código JavaScript en el contexto de la página.
|
||||||
|
func (b *Browser) Evaluate(ctx context.Context, expression string) (*EvaluateResult, error) {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": expression,
|
||||||
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
|
"userGesture": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Result struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ObjectID string `json:"objectId"`
|
||||||
|
SubType string `json:"subtype"`
|
||||||
|
} `json:"result"`
|
||||||
|
ExceptionDetails *struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Exception struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"exception"`
|
||||||
|
} `json:"exceptionDetails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to evaluate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.ExceptionDetails != nil {
|
||||||
|
return nil, fmt.Errorf("JavaScript exception: %s - %s",
|
||||||
|
response.ExceptionDetails.Text,
|
||||||
|
response.ExceptionDetails.Exception.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &EvaluateResult{
|
||||||
|
Type: response.Result.Type,
|
||||||
|
Value: response.Result.Value,
|
||||||
|
Description: response.Result.Description,
|
||||||
|
ObjectID: response.Result.ObjectID,
|
||||||
|
SubType: response.Result.SubType,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateOnNode ejecuta JavaScript en el contexto de un nodo específico.
|
||||||
|
func (b *Browser) EvaluateOnNode(ctx context.Context, nodeID int64, expression string) (*EvaluateResult, error) {
|
||||||
|
// Primero obtener el objectId del nodo
|
||||||
|
var objResult struct {
|
||||||
|
Object struct {
|
||||||
|
ObjectID string `json:"objectId"`
|
||||||
|
} `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.resolveNode", params, &objResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar función en el objeto
|
||||||
|
callParams := map[string]interface{}{
|
||||||
|
"functionDeclaration": fmt.Sprintf("function() { return (%s); }", expression),
|
||||||
|
"objectId": objResult.Object.ObjectID,
|
||||||
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Result struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"result"`
|
||||||
|
ExceptionDetails *struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"exceptionDetails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.callFunctionOn", callParams, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to call function: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.ExceptionDetails != nil {
|
||||||
|
return nil, fmt.Errorf("JavaScript exception: %s", response.ExceptionDetails.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EvaluateResult{
|
||||||
|
Type: response.Result.Type,
|
||||||
|
Value: response.Result.Value,
|
||||||
|
Description: response.Result.Description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateAsync ejecuta JavaScript de forma asíncrona (retorna Promise).
|
||||||
|
func (b *Browser) EvaluateAsync(ctx context.Context, expression string) (*EvaluateResult, error) {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": expression,
|
||||||
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
|
"userGesture": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Result struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ObjectID string `json:"objectId"`
|
||||||
|
} `json:"result"`
|
||||||
|
ExceptionDetails *struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Exception struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"exception"`
|
||||||
|
} `json:"exceptionDetails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to evaluate async: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.ExceptionDetails != nil {
|
||||||
|
return nil, fmt.Errorf("JavaScript exception: %s - %s",
|
||||||
|
response.ExceptionDetails.Text,
|
||||||
|
response.ExceptionDetails.Exception.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EvaluateResult{
|
||||||
|
Type: response.Result.Type,
|
||||||
|
Value: response.Result.Value,
|
||||||
|
Description: response.Result.Description,
|
||||||
|
ObjectID: response.Result.ObjectID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallFunction ejecuta una función JavaScript con argumentos.
|
||||||
|
func (b *Browser) CallFunction(ctx context.Context, functionDeclaration string, args ...interface{}) (*EvaluateResult, error) {
|
||||||
|
// Convertir args a formato CDP
|
||||||
|
cdpArgs := make([]map[string]interface{}, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
cdpArgs[i] = map[string]interface{}{
|
||||||
|
"value": arg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"functionDeclaration": functionDeclaration,
|
||||||
|
"arguments": cdpArgs,
|
||||||
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
|
"userGesture": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Result struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"result"`
|
||||||
|
ExceptionDetails *struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"exceptionDetails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.callFunctionOn", params, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to call function: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.ExceptionDetails != nil {
|
||||||
|
return nil, fmt.Errorf("JavaScript exception: %s", response.ExceptionDetails.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EvaluateResult{
|
||||||
|
Type: response.Result.Type,
|
||||||
|
Value: response.Result.Value,
|
||||||
|
Description: response.Result.Description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProperty obtiene una propiedad de un objeto.
|
||||||
|
func (b *Browser) GetProperty(ctx context.Context, objectID string, propertyName string) (*EvaluateResult, error) {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"objectId": objectID,
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Result []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"value"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.getProperties", params, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get properties: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prop := range response.Result {
|
||||||
|
if prop.Name == propertyName {
|
||||||
|
return &EvaluateResult{
|
||||||
|
Type: prop.Value.Type,
|
||||||
|
Value: prop.Value.Value,
|
||||||
|
Description: prop.Value.Description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("property not found: %s", propertyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBinding agrega un binding (función JS que llama a Go).
|
||||||
|
type BindingCallback func(args []interface{}) interface{}
|
||||||
|
|
||||||
|
// AddBinding expone una función Go al contexto JavaScript.
|
||||||
|
func (b *Browser) AddBinding(ctx context.Context, name string, callback BindingCallback) error {
|
||||||
|
// Agregar binding en Runtime
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.addBinding", params, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to add binding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar evento para manejar llamadas
|
||||||
|
b.cdpClient.On("Runtime.bindingCalled", func(eventParams json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
ExecutionContextID int64 `json:"executionContextId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(eventParams, &event); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Name != name {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear args
|
||||||
|
var args []interface{}
|
||||||
|
if err := json.Unmarshal([]byte(event.Payload), &args); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar callback
|
||||||
|
result := callback(args)
|
||||||
|
|
||||||
|
// Devolver resultado (evaluando código que lo retorna)
|
||||||
|
returnScript := fmt.Sprintf("window.%s_result = %v", name, result)
|
||||||
|
b.Evaluate(ctx, returnScript)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inyectar wrapper en JavaScript
|
||||||
|
wrapperScript := fmt.Sprintf(`
|
||||||
|
window.%s = async (...args) => {
|
||||||
|
const payload = JSON.stringify(args);
|
||||||
|
window.%s_result = undefined;
|
||||||
|
await window.chrome.runtime.sendMessage({
|
||||||
|
type: 'binding',
|
||||||
|
name: '%s',
|
||||||
|
payload: payload
|
||||||
|
});
|
||||||
|
// Esperar resultado (polling simple)
|
||||||
|
while (window.%s_result === undefined) {
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
return window.%s_result;
|
||||||
|
};
|
||||||
|
`, name, name, name, name, name)
|
||||||
|
|
||||||
|
_, err := b.Evaluate(ctx, wrapperScript)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsoleMessage representa un mensaje de consola.
|
||||||
|
type ConsoleMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Args []interface{} `json:"args"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Line int `json:"lineNumber"`
|
||||||
|
Column int `json:"columnNumber"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConsole registra un handler para mensajes de consola.
|
||||||
|
func (b *Browser) OnConsole(handler func(msg *ConsoleMessage)) {
|
||||||
|
b.cdpClient.On("Runtime.consoleAPICalled", func(params json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Args []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
} `json:"args"`
|
||||||
|
StackTrace struct {
|
||||||
|
CallFrames []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
LineNumber int `json:"lineNumber"`
|
||||||
|
ColumnNumber int `json:"columnNumber"`
|
||||||
|
} `json:"callFrames"`
|
||||||
|
} `json:"stackTrace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(params, &event); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &ConsoleMessage{
|
||||||
|
Type: event.Type,
|
||||||
|
Args: make([]interface{}, len(event.Args)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir texto del mensaje
|
||||||
|
text := ""
|
||||||
|
for i, arg := range event.Args {
|
||||||
|
msg.Args[i] = arg.Value
|
||||||
|
if i > 0 {
|
||||||
|
text += " "
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("%v", arg.Value)
|
||||||
|
}
|
||||||
|
msg.Text = text
|
||||||
|
|
||||||
|
// Agregar info de stack trace si existe
|
||||||
|
if len(event.StackTrace.CallFrames) > 0 {
|
||||||
|
frame := event.StackTrace.CallFrames[0]
|
||||||
|
msg.URL = frame.URL
|
||||||
|
msg.Line = frame.LineNumber
|
||||||
|
msg.Column = frame.ColumnNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableConsole habilita eventos de consola.
|
||||||
|
func (b *Browser) EnableConsole(ctx context.Context) error {
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.enable", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuerySelector helper para ejecutar querySelector desde JavaScript.
|
||||||
|
func (b *Browser) QuerySelector(ctx context.Context, selector string) (*EvaluateResult, error) {
|
||||||
|
script := fmt.Sprintf(`document.querySelector('%s')`, selector)
|
||||||
|
return b.Evaluate(ctx, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuerySelectorAll ejecuta querySelectorAll y retorna array de elementos.
|
||||||
|
func (b *Browser) QuerySelectorAll(ctx context.Context, selector string) (*EvaluateResult, error) {
|
||||||
|
script := fmt.Sprintf(`Array.from(document.querySelectorAll('%s'))`, selector)
|
||||||
|
return b.Evaluate(ctx, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForFunction espera a que una función JavaScript retorne true.
|
||||||
|
func (b *Browser) WaitForFunction(ctx context.Context, function string, pollInterval int) error {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const check = () => {
|
||||||
|
if (%s) {
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
setTimeout(check, %d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
})
|
||||||
|
`, function, pollInterval)
|
||||||
|
|
||||||
|
_, err := b.EvaluateAsync(ctx, script)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cookie representa una cookie.
|
||||||
|
type Cookie struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Expires float64 `json:"expires,omitempty"` // Unix timestamp
|
||||||
|
HTTPOnly bool `json:"httpOnly,omitempty"`
|
||||||
|
Secure bool `json:"secure,omitempty"`
|
||||||
|
SameSite string `json:"sameSite,omitempty"` // "Strict", "Lax", "None"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCookies obtiene todas las cookies o las de un dominio específico.
|
||||||
|
func (b *Browser) GetCookies(ctx context.Context, urls ...string) ([]*Cookie, error) {
|
||||||
|
params := make(map[string]interface{})
|
||||||
|
if len(urls) > 0 {
|
||||||
|
params["urls"] = urls
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Cookies []*Cookie `json:"cookies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Network.getCookies", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cookies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Cookies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCookie establece una cookie.
|
||||||
|
func (b *Browser) SetCookie(ctx context.Context, cookie *Cookie) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"name": cookie.Name,
|
||||||
|
"value": cookie.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cookie.Domain != "" {
|
||||||
|
params["domain"] = cookie.Domain
|
||||||
|
}
|
||||||
|
if cookie.Path != "" {
|
||||||
|
params["path"] = cookie.Path
|
||||||
|
}
|
||||||
|
if cookie.Expires > 0 {
|
||||||
|
params["expires"] = cookie.Expires
|
||||||
|
}
|
||||||
|
if cookie.HTTPOnly {
|
||||||
|
params["httpOnly"] = true
|
||||||
|
}
|
||||||
|
if cookie.Secure {
|
||||||
|
params["secure"] = true
|
||||||
|
}
|
||||||
|
if cookie.SameSite != "" {
|
||||||
|
params["sameSite"] = cookie.SameSite
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Network.setCookie", params, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to set cookie: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
return fmt.Errorf("failed to set cookie: %s", cookie.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCookies establece múltiples cookies.
|
||||||
|
func (b *Browser) SetCookies(ctx context.Context, cookies []*Cookie) error {
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if err := b.SetCookie(ctx, cookie); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCookie elimina una cookie específica.
|
||||||
|
func (b *Browser) DeleteCookie(ctx context.Context, name string, domain string) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain != "" {
|
||||||
|
params["domain"] = domain
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.deleteCookies", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCookies elimina todas las cookies.
|
||||||
|
func (b *Browser) ClearCookies(ctx context.Context) error {
|
||||||
|
return b.cdpClient.Execute(ctx, "Network.clearBrowserCookies", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalStorageItem representa un item de localStorage.
|
||||||
|
type LocalStorageItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalStorage obtiene todos los items del localStorage.
|
||||||
|
func (b *Browser) GetLocalStorage(ctx context.Context) ([]*LocalStorageItem, error) {
|
||||||
|
script := `
|
||||||
|
(() => {
|
||||||
|
const items = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
items.push({ key: key, value: localStorage.getItem(key) });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Result struct {
|
||||||
|
Value []map[string]interface{} `json:"value"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
"returnByValue": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get localStorage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*LocalStorageItem, 0, len(result.Result.Value))
|
||||||
|
for _, item := range result.Result.Value {
|
||||||
|
items = append(items, &LocalStorageItem{
|
||||||
|
Key: item["key"].(string),
|
||||||
|
Value: item["value"].(string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLocalStorage establece un item en localStorage.
|
||||||
|
func (b *Browser) SetLocalStorage(ctx context.Context, key, value string) error {
|
||||||
|
script := fmt.Sprintf(`localStorage.setItem(%q, %q)`, key, value)
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLocalStorage elimina un item de localStorage.
|
||||||
|
func (b *Browser) RemoveLocalStorage(ctx context.Context, key string) error {
|
||||||
|
script := fmt.Sprintf(`localStorage.removeItem(%q)`, key)
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLocalStorage limpia todo el localStorage.
|
||||||
|
func (b *Browser) ClearLocalStorage(ctx context.Context) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": "localStorage.clear()",
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionStorage obtiene todos los items del sessionStorage.
|
||||||
|
func (b *Browser) GetSessionStorage(ctx context.Context) ([]*LocalStorageItem, error) {
|
||||||
|
script := `
|
||||||
|
(() => {
|
||||||
|
const items = [];
|
||||||
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
items.push({ key: key, value: sessionStorage.getItem(key) });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Result struct {
|
||||||
|
Value []map[string]interface{} `json:"value"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
"returnByValue": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get sessionStorage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*LocalStorageItem, 0, len(result.Result.Value))
|
||||||
|
for _, item := range result.Result.Value {
|
||||||
|
items = append(items, &LocalStorageItem{
|
||||||
|
Key: item["key"].(string),
|
||||||
|
Value: item["value"].(string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionStorage establece un item en sessionStorage.
|
||||||
|
func (b *Browser) SetSessionStorage(ctx context.Context, key, value string) error {
|
||||||
|
script := fmt.Sprintf(`sessionStorage.setItem(%q, %q)`, key, value)
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSessionStorage elimina un item de sessionStorage.
|
||||||
|
func (b *Browser) RemoveSessionStorage(ctx context.Context, key string) error {
|
||||||
|
script := fmt.Sprintf(`sessionStorage.removeItem(%q)`, key)
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": script,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionStorage limpia todo el sessionStorage.
|
||||||
|
func (b *Browser) ClearSessionStorage(ctx context.Context) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"expression": "sessionStorage.clear()",
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Runtime.evaluate", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageType tipo de storage.
|
||||||
|
type StorageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StorageTypeCookies StorageType = "cookies"
|
||||||
|
StorageTypeLocalStorage StorageType = "local_storage"
|
||||||
|
StorageTypeSessionStorage StorageType = "session_storage"
|
||||||
|
StorageTypeIndexedDB StorageType = "indexeddb"
|
||||||
|
StorageTypeWebSQL StorageType = "websql"
|
||||||
|
StorageTypeCacheStorage StorageType = "cache_storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClearDataForOrigin limpia datos de un origen específico.
|
||||||
|
func (b *Browser) ClearDataForOrigin(ctx context.Context, origin string, storageTypes ...StorageType) error {
|
||||||
|
if len(storageTypes) == 0 {
|
||||||
|
// Por defecto, limpiar todo
|
||||||
|
storageTypes = []StorageType{
|
||||||
|
StorageTypeCookies,
|
||||||
|
StorageTypeLocalStorage,
|
||||||
|
StorageTypeSessionStorage,
|
||||||
|
StorageTypeIndexedDB,
|
||||||
|
StorageTypeWebSQL,
|
||||||
|
StorageTypeCacheStorage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a string separado por comas
|
||||||
|
types := ""
|
||||||
|
for i, st := range storageTypes {
|
||||||
|
if i > 0 {
|
||||||
|
types += ","
|
||||||
|
}
|
||||||
|
types += string(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"origin": origin,
|
||||||
|
"storageTypes": types,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.cdpClient.Execute(ctx, "Storage.clearDataForOrigin", params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCookies exporta las cookies del perfil a un archivo JSON.
|
||||||
|
func (b *Browser) ExportCookies(ctx context.Context) ([]*Cookie, error) {
|
||||||
|
return b.GetCookies(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportCookies importa cookies desde un slice.
|
||||||
|
func (b *Browser) ImportCookies(ctx context.Context, cookies []*Cookie) error {
|
||||||
|
return b.SetCookies(ctx, cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCookie crea una cookie helper.
|
||||||
|
func CreateCookie(name, value, domain string) *Cookie {
|
||||||
|
return &Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Domain: domain,
|
||||||
|
Path: "/",
|
||||||
|
// Expira en 1 año
|
||||||
|
Expires: float64(time.Now().Add(365 * 24 * time.Hour).Unix()),
|
||||||
|
HTTPOnly: false,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: "Lax",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSessionCookie crea una cookie de sesión (sin expiración).
|
||||||
|
func CreateSessionCookie(name, value, domain string) *Cookie {
|
||||||
|
return &Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Domain: domain,
|
||||||
|
Path: "/",
|
||||||
|
HTTPOnly: false,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: "Lax",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSecureCookie crea una cookie segura (HTTPS only).
|
||||||
|
func CreateSecureCookie(name, value, domain string) *Cookie {
|
||||||
|
return &Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Domain: domain,
|
||||||
|
Path: "/",
|
||||||
|
Expires: float64(time.Now().Add(365 * 24 * time.Hour).Unix()),
|
||||||
|
HTTPOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: "Strict",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tab representa un tab del navegador
|
||||||
|
type Tab struct {
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Type string // "page" | "background_page" | ...
|
||||||
|
Attached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// tabHandler almacena handlers para eventos de tabs
|
||||||
|
type tabHandler struct {
|
||||||
|
onCreate func(*Tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
tabHandlers = &tabHandler{}
|
||||||
|
tabMutex sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTabs obtiene todos los tabs abiertos
|
||||||
|
func (b *Browser) GetTabs(ctx context.Context) ([]*Tab, error) {
|
||||||
|
var result struct {
|
||||||
|
TargetInfos []struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Attached bool `json:"attached"`
|
||||||
|
} `json:"targetInfos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.getTargets", nil, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get targets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs []*Tab
|
||||||
|
for _, info := range result.TargetInfos {
|
||||||
|
if info.Type == "page" {
|
||||||
|
tabs = append(tabs, &Tab{
|
||||||
|
ID: info.TargetID,
|
||||||
|
URL: info.URL,
|
||||||
|
Title: info.Title,
|
||||||
|
Type: info.Type,
|
||||||
|
Attached: info.Attached,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTab crea un nuevo tab y retorna su ID
|
||||||
|
func (b *Browser) NewTab(ctx context.Context, url string) (string, error) {
|
||||||
|
var result struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.createTarget", params, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create tab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.TargetID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseTab cierra un tab específico
|
||||||
|
func (b *Browser) CloseTab(ctx context.Context, tabID string) error {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"targetId": tabID,
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.closeTarget", params, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to close tab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
return fmt.Errorf("failed to close tab: CDP returned success=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToTab cambia el foco a un tab específico
|
||||||
|
func (b *Browser) SwitchToTab(ctx context.Context, tabID string) error {
|
||||||
|
// Activar tab
|
||||||
|
activateParams := map[string]interface{}{
|
||||||
|
"targetId": tabID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.activateTarget", activateParams, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to activate tab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach al tab si no está attached
|
||||||
|
attachParams := map[string]interface{}{
|
||||||
|
"targetId": tabID,
|
||||||
|
"flatten": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachResult struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "Target.attachToTarget", attachParams, &attachResult); err != nil {
|
||||||
|
// Puede que ya esté attached, continuar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar targetID actual del browser
|
||||||
|
b.targetID = tabID
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentTab obtiene el tab actual
|
||||||
|
func (b *Browser) GetCurrentTab(ctx context.Context) (*Tab, error) {
|
||||||
|
tabs, err := b.GetTabs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar el tab con el targetID actual
|
||||||
|
for _, tab := range tabs {
|
||||||
|
if tab.ID == b.targetID {
|
||||||
|
return tab, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no encontramos, retornar el primero
|
||||||
|
if len(tabs) > 0 {
|
||||||
|
return tabs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tabs found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForNewTab espera a que se abra un nuevo tab y lo retorna
|
||||||
|
func (b *Browser) WaitForNewTab(ctx context.Context, action func()) (*Tab, error) {
|
||||||
|
// Obtener tabs actuales
|
||||||
|
currentTabs, err := b.GetTabs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIDs := make(map[string]bool)
|
||||||
|
for _, tab := range currentTabs {
|
||||||
|
currentIDs[tab.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canal para recibir nuevo tab
|
||||||
|
newTabChan := make(chan *Tab, 1)
|
||||||
|
|
||||||
|
// Registrar listener temporal para nuevos tabs
|
||||||
|
b.cdpClient.On("Target.targetCreated", func(params json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
TargetInfo struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"targetInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(params, &event); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo procesar tabs de tipo "page"
|
||||||
|
if event.TargetInfo.Type == "page" {
|
||||||
|
// Verificar que es un tab nuevo
|
||||||
|
if !currentIDs[event.TargetInfo.TargetID] {
|
||||||
|
newTab := &Tab{
|
||||||
|
ID: event.TargetInfo.TargetID,
|
||||||
|
URL: event.TargetInfo.URL,
|
||||||
|
Title: event.TargetInfo.Title,
|
||||||
|
Type: event.TargetInfo.Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case newTabChan <- newTab:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ejecutar acción que abrirá el tab
|
||||||
|
if action != nil {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar nuevo tab
|
||||||
|
select {
|
||||||
|
case newTab := <-newTabChan:
|
||||||
|
return newTab, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("timeout waiting for new tab: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnTabCreated registra callback para cuando se crea un nuevo tab
|
||||||
|
func (b *Browser) OnTabCreated(handler func(*Tab)) error {
|
||||||
|
tabMutex.Lock()
|
||||||
|
defer tabMutex.Unlock()
|
||||||
|
|
||||||
|
tabHandlers.onCreate = handler
|
||||||
|
|
||||||
|
// Registrar listener de eventos
|
||||||
|
b.cdpClient.On("Target.targetCreated", func(params json.RawMessage) {
|
||||||
|
var event struct {
|
||||||
|
TargetInfo struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"targetInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(params, &event); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.TargetInfo.Type == "page" {
|
||||||
|
tab := &Tab{
|
||||||
|
ID: event.TargetInfo.TargetID,
|
||||||
|
URL: event.TargetInfo.URL,
|
||||||
|
Title: event.TargetInfo.Title,
|
||||||
|
Type: event.TargetInfo.Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
tabMutex.RLock()
|
||||||
|
if tabHandlers.onCreate != nil {
|
||||||
|
tabHandlers.onCreate(tab)
|
||||||
|
}
|
||||||
|
tabMutex.RUnlock()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseOtherTabs cierra todos los tabs excepto el actual
|
||||||
|
func (b *Browser) CloseOtherTabs(ctx context.Context) error {
|
||||||
|
currentTab, err := b.GetCurrentTab(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs, err := b.GetTabs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tab := range tabs {
|
||||||
|
if tab.ID != currentTab.ID {
|
||||||
|
if err := b.CloseTab(ctx, tab.ID); err != nil {
|
||||||
|
// Continuar cerrando otros tabs incluso si uno falla
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTabByURL busca un tab por URL (coincidencia parcial)
|
||||||
|
func (b *Browser) GetTabByURL(ctx context.Context, urlPattern string) (*Tab, error) {
|
||||||
|
tabs, err := b.GetTabs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tab := range tabs {
|
||||||
|
if containsString(tab.URL, urlPattern) {
|
||||||
|
return tab, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tab found with URL pattern: %s", urlPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTabByTitle busca un tab por título (coincidencia parcial)
|
||||||
|
func (b *Browser) GetTabByTitle(ctx context.Context, titlePattern string) (*Tab, error) {
|
||||||
|
tabs, err := b.GetTabs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tab := range tabs {
|
||||||
|
if containsString(tab.Title, titlePattern) {
|
||||||
|
return tab, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tab found with title pattern: %s", titlePattern)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadFile sube un archivo a un input de tipo file
|
||||||
|
func (b *Browser) UploadFile(ctx context.Context, selector string, filePath string) error {
|
||||||
|
return b.UploadFiles(ctx, selector, []string{filePath})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFiles sube múltiples archivos a un input de tipo file
|
||||||
|
func (b *Browser) UploadFiles(ctx context.Context, selector string, filePaths []string) error {
|
||||||
|
// Validar que todos los archivos existen
|
||||||
|
var absolutePaths []string
|
||||||
|
for _, path := range filePaths {
|
||||||
|
// Convertir a path absoluto
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid file path %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que existe
|
||||||
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("file does not exist: %s", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
absolutePaths = append(absolutePaths, absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener el nodeId del input
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("file input not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que es un input de tipo file
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector('%s');
|
||||||
|
return input && input.tagName === 'INPUT' && input.type === 'file';
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isFileInput, ok := result.Value.(bool)
|
||||||
|
if !ok || !isFileInput {
|
||||||
|
return fmt.Errorf("element is not a file input: %s", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer archivos usando CDP
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"files": absolutePaths,
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.setFileInputFiles", params, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to set files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFileInput es un alias de UploadFiles
|
||||||
|
func (b *Browser) SetFileInput(ctx context.Context, selector string, files []string) error {
|
||||||
|
return b.UploadFiles(ctx, selector, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearFileInput limpia un input de tipo file
|
||||||
|
func (b *Browser) ClearFileInput(ctx context.Context, selector string) error {
|
||||||
|
nodeID, err := b.querySelector(ctx, selector)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("file input not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer array vacío
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"files": []string{},
|
||||||
|
"nodeId": nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.cdpClient.Execute(ctx, "DOM.setFileInputFiles", params, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to clear files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileInputValue obtiene los nombres de archivos seleccionados
|
||||||
|
func (b *Browser) GetFileInputValue(ctx context.Context, selector string) ([]string, error) {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector('%s');
|
||||||
|
if (!input || input.type !== 'file') return null;
|
||||||
|
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
return files.map(f => f.name);
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Value == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir resultado a []string
|
||||||
|
filesInterface, ok := result.Value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected result type")
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
for _, fileInterface := range filesInterface {
|
||||||
|
if fileName, ok := fileInterface.(string); ok {
|
||||||
|
files = append(files, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFileInputMultiple verifica si un input acepta múltiples archivos
|
||||||
|
func (b *Browser) IsFileInputMultiple(ctx context.Context, selector string) (bool, error) {
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector('%s');
|
||||||
|
return input && input.multiple === true;
|
||||||
|
})()
|
||||||
|
`, selector)
|
||||||
|
|
||||||
|
result, err := b.Evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isMultiple, ok := result.Value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMultiple, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCommand envía un comando CDP y retorna el resultado como map
|
||||||
|
func (c *Client) SendCommand(ctx context.Context, method string, params interface{}) (map[string]interface{}, error) {
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := c.Execute(ctx, method, params, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script para clonar perfiles y usarlos en paralelo
|
||||||
|
|
||||||
|
if [ -z "$1" ] || [ -z "$2" ]; then
|
||||||
|
echo "Uso: $0 <perfil-origen> <perfil-destino>"
|
||||||
|
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 &"
|
||||||
Executable
+104
@@ -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"
|
||||||
Executable
+54
@@ -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 ""
|
||||||
Reference in New Issue
Block a user