merge: quick/advanced-browser-features — Funcionalidades avanzadas de navegación web
Agrega 10 funcionalidades principales inspiradas en Selenium y Playwright: ✅ Conversor web a Markdown ✅ Árbol de accesibilidad (Accessibility Tree) ✅ Gestión avanzada de cookies (import/export) ✅ Gestión de extensiones de Chrome ✅ Manejo de múltiples tabs/ventanas ✅ Manejo de iFrames ✅ Actions API (hover, drag&drop, double-click, shortcuts) ✅ File uploads ✅ Expected conditions mejoradas (WaitUntil*) ✅ Eliminación de timeouts arbitrarios Incluye 19 issues técnicas documentadas en dev/issues/. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -8,7 +8,6 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"navegator/pkg/browser"
|
||||
)
|
||||
@@ -67,13 +66,13 @@ func main() {
|
||||
searchURL := fmt.Sprintf("https://duckduckgo.com/?q=%s", *query)
|
||||
log.Println("🌐 Navegando a DuckDuckGo...")
|
||||
|
||||
if err := b.Navigate(ctx, searchURL, nil); err != nil {
|
||||
navOpts := browser.DefaultNavigateOptions()
|
||||
navOpts.WaitUntil = "networkidle"
|
||||
|
||||
if err := b.Navigate(ctx, searchURL, navOpts); err != nil {
|
||||
log.Fatalf("❌ Error al navegar: %v", err)
|
||||
}
|
||||
|
||||
// Esperar a que carguen los resultados
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
log.Println("📥 Extrayendo resultados...")
|
||||
|
||||
// Script para extraer resultados
|
||||
|
||||
+4
-5
@@ -8,7 +8,6 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"navegator/pkg/browser"
|
||||
)
|
||||
@@ -85,13 +84,13 @@ func main() {
|
||||
searchURL := fmt.Sprintf("https://duckduckgo.com/?q=%s", *query)
|
||||
log.Println("🌐 Navegando a DuckDuckGo...")
|
||||
|
||||
if err := b.Navigate(ctx, searchURL, nil); err != nil {
|
||||
navOpts := browser.DefaultNavigateOptions()
|
||||
navOpts.WaitUntil = "networkidle"
|
||||
|
||||
if err := b.Navigate(ctx, searchURL, navOpts); err != nil {
|
||||
log.Fatalf("❌ Error al navegar: %v", err)
|
||||
}
|
||||
|
||||
// Esperar a que carguen los resultados
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
log.Println("📥 Extrayendo resultados...")
|
||||
|
||||
// Script para extraer resultados
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,6 @@ func main() {
|
||||
log.Println("✅ Página cargada")
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Click si se especificó
|
||||
if *click != "" {
|
||||
b.AddComment(fmt.Sprintf("Click en: %s", *click))
|
||||
@@ -90,7 +88,6 @@ func main() {
|
||||
log.Printf("⚠️ Error al hacer click: %v", err)
|
||||
} else {
|
||||
log.Println("✅ Click realizado")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +99,6 @@ func main() {
|
||||
log.Printf("⚠️ Error al escribir: %v", err)
|
||||
} else {
|
||||
log.Println("✅ Texto escrito")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
+3
-6
@@ -2,10 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"navegator/pkg/browser"
|
||||
)
|
||||
@@ -33,13 +31,12 @@ func main() {
|
||||
|
||||
// Navegar a una página
|
||||
log.Println("Navegando a example.com...")
|
||||
if err := b.Navigate(ctx, "https://example.com", nil); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Esperar un poco para que cargue
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Obtener HTML
|
||||
log.Println("Obteniendo HTML...")
|
||||
html, err := b.GetHTML(ctx, "")
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"navegator/pkg/browser"
|
||||
)
|
||||
@@ -64,7 +63,9 @@ func main() {
|
||||
// Navegar a página de prueba
|
||||
b.AddComment("=== INICIO DE SESIÓN ===")
|
||||
log.Println("\n📍 Navegando a example.com...")
|
||||
if err := b.Navigate(ctx, "https://example.com", nil); err != nil {
|
||||
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")
|
||||
@@ -72,8 +73,6 @@ func main() {
|
||||
|
||||
b.AddComment("Página cargada correctamente")
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Obtener información de la página
|
||||
log.Println("\n📊 Obteniendo información de la página...")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -44,6 +44,12 @@ type Config struct {
|
||||
// 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
|
||||
|
||||
@@ -92,6 +98,10 @@ func Launch(ctx context.Context, config *Config) (*Browser, error) {
|
||||
// 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...)
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestRecorder(t *testing.T) {
|
||||
|
||||
// Verificar que contiene JSON
|
||||
contentStr := string(content)
|
||||
if !contains(contentStr, "TestAction") {
|
||||
if !containsStr(contentStr, "TestAction") {
|
||||
t.Error("Recording doesn't contain action name")
|
||||
}
|
||||
}
|
||||
@@ -277,11 +277,11 @@ func TestProfilePersistence(t *testing.T) {
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr))
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsStrHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
func containsStrHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
|
||||
@@ -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,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,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
|
||||
}
|
||||
@@ -204,6 +204,15 @@ func (c *Client) Execute(ctx context.Context, method string, params interface{},
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user