Initial commit: navegator - Chrome CDP automation for LLMs
Add complete navegator system for stealthy browser automation: - CDP client with WebSocket communication - Browser API with navigation, storage, network, runtime - Stealth flags and anti-detection scripts - Persistent profile support - Examples and comprehensive documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"navegator/pkg/browser"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear configuración con perfil personalizado
|
||||
config := browser.DefaultConfig()
|
||||
config.ProfileName = "my-agent-profile"
|
||||
|
||||
// Si quieres modo visible, desactiva headless
|
||||
// config.StealthFlags.Headless = false
|
||||
|
||||
// Lanzar navegador
|
||||
log.Println("Lanzando navegador...")
|
||||
b, err := browser.Launch(ctx, config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error al lanzar navegador: %v", err)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
log.Printf("Navegador iniciado. Perfil: %s\n", b.ProfilePath())
|
||||
log.Printf("Debug URL: %s\n", b.DebugURL())
|
||||
|
||||
// Navegar a una página
|
||||
log.Println("Navegando a example.com...")
|
||||
if err := b.Navigate(ctx, "https://example.com", nil); err != nil {
|
||||
log.Fatalf("Error al navegar: %v", err)
|
||||
}
|
||||
|
||||
// Esperar un poco para que cargue
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Obtener HTML
|
||||
log.Println("Obteniendo HTML...")
|
||||
html, err := b.GetHTML(ctx, "")
|
||||
if err != nil {
|
||||
log.Fatalf("Error al obtener HTML: %v", err)
|
||||
}
|
||||
log.Printf("HTML length: %d bytes\n", len(html))
|
||||
|
||||
// Obtener texto del h1
|
||||
log.Println("Obteniendo texto del h1...")
|
||||
text, err := b.GetText(ctx, "h1")
|
||||
if err != nil {
|
||||
log.Fatalf("Error al obtener texto: %v", err)
|
||||
}
|
||||
log.Printf("H1 text: %s\n", text)
|
||||
|
||||
// Tomar screenshot
|
||||
log.Println("Tomando screenshot...")
|
||||
screenshot, err := b.Screenshot(ctx, false)
|
||||
if err != nil {
|
||||
log.Fatalf("Error al tomar screenshot: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile("screenshot.png", screenshot, 0644); err != nil {
|
||||
log.Fatalf("Error al guardar screenshot: %v", err)
|
||||
}
|
||||
log.Println("Screenshot guardado en screenshot.png")
|
||||
|
||||
// Ejecutar JavaScript
|
||||
log.Println("Ejecutando JavaScript...")
|
||||
result, err := b.Evaluate(ctx, "window.location.href")
|
||||
if err != nil {
|
||||
log.Fatalf("Error al ejecutar JavaScript: %v", err)
|
||||
}
|
||||
log.Printf("Current URL: %v\n", result.Value)
|
||||
|
||||
// Cookies
|
||||
log.Println("Obteniendo cookies...")
|
||||
cookies, err := b.GetCookies(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Error al obtener cookies: %v", err)
|
||||
}
|
||||
log.Printf("Cookies encontradas: %d\n", len(cookies))
|
||||
|
||||
for _, cookie := range cookies {
|
||||
log.Printf(" - %s = %s\n", cookie.Name, cookie.Value)
|
||||
}
|
||||
|
||||
log.Println("Ejemplo completado exitosamente!")
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user