diff --git a/cmd/accessibility.go b/cmd/accessibility.go new file mode 100644 index 0000000..73e800d --- /dev/null +++ b/cmd/accessibility.go @@ -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 [-output ] [-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) + } + } +} diff --git a/pkg/browser/accessibility.go b/pkg/browser/accessibility.go new file mode 100644 index 0000000..456206b --- /dev/null +++ b/pkg/browser/accessibility.go @@ -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 +}