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,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