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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-24 23:33:07 +01:00
commit 3253828fef
36 changed files with 8116 additions and 0 deletions
+150
View File
@@ -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
+259
View File
@@ -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 ===")
}
+92
View File
@@ -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!")
}
+216
View File
@@ -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)
}
}
+79
View File
@@ -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