feat: árbol de accesibilidad (accessibility tree)
Implementa GetAccessibilityTree() para obtener estructura semántica vía CDP. Incluye: - Roles ARIA de elementos (button, link, heading, etc) - Nombres accesibles computados - FindInteractiveElements() para elementos clickeables - GetAccessibilitySummary() para resumen textual - Comando CLI accessibility.go Ideal para que LLMs entiendan estructura de páginas web. Archivo: pkg/browser/accessibility.go, cmd/accessibility.go
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user