Files
navegator/pkg/browser/accessibility.go
T
Developer 6c570fe9cb 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
2026-03-25 00:47:45 +01:00

277 lines
7.2 KiB
Go

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
}