6c570fe9cb
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
277 lines
7.2 KiB
Go
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
|
|
}
|