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:
Developer
2026-03-25 00:47:45 +01:00
parent 01c6cafd23
commit 6c570fe9cb
2 changed files with 393 additions and 0 deletions
+117
View File
@@ -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)
}
}
}
+276
View File
@@ -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
}