Files
fn_registry/functions/browser/cdp_get_ax_outline.go
T
egutierrez 8742cb25be feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:42:31 +02:00

410 lines
10 KiB
Go

package browser
import (
"fmt"
"strings"
)
// axoActionableRoles son los roles que el LLM puede referir con #ref. Misma
// lista que _ACTIONABLE_ROLES de render_ax_outline.py.
var axoActionableRoles = map[string]struct{}{
"button": {},
"link": {},
"textbox": {},
"searchbox": {},
"checkbox": {},
"radio": {},
"combobox": {},
"listbox": {},
"menuitem": {},
"menuitemcheckbox": {},
"menuitemradio": {},
"tab": {},
"option": {},
"switch": {},
"slider": {},
"spinbutton": {},
"treeitem": {},
"gridcell": {},
}
// axoSkipRoles son roles sin valor semantico: se omiten y sus hijos se elevan al
// nivel actual. Misma lista que _SKIP_ROLES de render_ax_outline.py.
var axoSkipRoles = map[string]struct{}{
"none": {},
"presentation": {},
"ignored": {},
}
// axoMaxDepth limita la profundidad de render (guard anti-RecursionError de
// arboles AX patologicos). Igual que _MAX_DEPTH del .py.
const axoMaxDepth = 60
// axNode es la representacion interna de un AXNode CDP, ya extraida del
// map[string]any de la respuesta. Los helpers de poda y render operan sobre
// estos structs, lo que los hace puros y testeables sin Chrome.
type axNode struct {
nodeID string
backendDOMNodeID string
ignored bool
role string
name string
value string
childIDs []string
parentID string
}
// CdpGetAXOutline percibe la pagina (o un iframe concreto via frameID) como un
// outline accesible indentado y accionable, reusando la conexion CDP viva del
// pool — sin abrir un WebSocket nuevo ni levantar el venv de Python.
//
// Envia Accessibility.enable (idempotente) y Accessibility.getFullAXTree. Si
// frameID != "", pasa {"frameId": frameID} para obtener el arbol DENTRO de ese
// iframe; con frameID == "" obtiene el arbol completo de la pagina (depth -1).
//
// El resultado se poda (trim) y luego se renderiza replicando exactamente el
// formato del pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline:
// indentacion de 2 espacios por nivel, `role "name"`, ` = 'value'` para inputs,
// y marcador ` #ref=<backendDOMNodeId>` en roles accionables. maxChars > 0
// trunca y añade "\n…[outline truncado]"; maxChars <= 0 = sin limite.
func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get ax outline: conexion nula")
}
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
}
var params map[string]any
if frameID != "" {
params = map[string]any{"frameId": frameID}
}
res, err := c.sendCDP("Accessibility.getFullAXTree", params)
if err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.getFullAXTree: %w", err)
}
nodes := axoParseNodes(res)
trimmed := trimAXTree(nodes)
return renderAXOutline(trimmed, maxChars), nil
}
// axoParseNodes extrae la lista de axNode del result de getFullAXTree. Tras el
// JSON unmarshal a map[string]any, los nodos vienen como []any de
// map[string]any y los enteros (backendDOMNodeId, nodeId) como float64; nodeId y
// childIds suelen llegar como strings. Normalizamos todo a string.
func axoParseNodes(result map[string]any) []axNode {
raw, ok := result["nodes"].([]any)
if !ok {
return nil
}
out := make([]axNode, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
n := axNode{
nodeID: axoStr(m["nodeId"]),
backendDOMNodeID: axoStr(m["backendDOMNodeId"]),
ignored: axoBool(m["ignored"]),
role: axoNested(m["role"]),
name: axoNested(m["name"]),
value: axoNested(m["value"]),
childIDs: axoStrSlice(m["childIds"]),
parentID: axoStr(m["parentId"]),
}
out = append(out, n)
}
return out
}
// axoNested extrae el campo "value" de un objeto CDP del tipo {value: ...} (role,
// name, value vienen asi). Devuelve "" si esta ausente o vacio.
func axoNested(v any) string {
m, ok := v.(map[string]any)
if !ok {
if v == nil {
return ""
}
return axoStr(v)
}
return axoStr(m["value"])
}
// axoStr normaliza cualquier escalar JSON a string. Los enteros CDP llegan como
// float64 tras el unmarshal; los renderizamos sin decimales.
func axoStr(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case float64:
// IDs CDP son enteros: evitar notacion 1.234e+06 / sufijo .0.
return fmt.Sprintf("%d", int64(t))
case bool:
if t {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", t)
}
}
func axoBool(v any) bool {
b, _ := v.(bool)
return b
}
func axoStrSlice(v any) []string {
raw, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
out = append(out, axoStr(item))
}
return out
}
// trimAXTree compacta la lista de axNode descartando nodos irrelevantes y
// colapsando cadenas padre->hijo del mismo role. Puro: porta trim_ax_tree.py.
//
// Descarta: ignored=true; role 'generic'/'none' sin name ni childIds;
// role 'StaticText' con name vacio. Colapsa: nodo con exactamente 1 hijo del
// mismo role hereda los childIds del hijo (el hijo se descarta). Itera hasta
// convergencia. Preserva el orden original de aparicion.
func trimAXTree(nodes []axNode) []axNode {
if len(nodes) == 0 {
return nil
}
shouldDiscard := func(n axNode) bool {
if n.ignored {
return true
}
if (n.role == "generic" || n.role == "none") && n.name == "" && len(n.childIDs) == 0 {
return true
}
if n.role == "StaticText" && n.name == "" {
return true
}
return false
}
byID := map[string]axNode{}
for _, n := range nodes {
if shouldDiscard(n) {
continue
}
byID[n.nodeID] = n
}
// Colapso iterativo hasta convergencia.
for {
changed := false
removed := map[string]struct{}{}
for _, node := range byID {
if _, gone := removed[node.nodeID]; gone {
continue
}
if len(node.childIDs) != 1 {
continue
}
childID := node.childIDs[0]
child, ok := byID[childID]
if !ok || child.role != node.role {
continue
}
// Fusionar: el padre hereda los childIds del hijo.
merged := node
merged.childIDs = child.childIDs
byID[node.nodeID] = merged
removed[childID] = struct{}{}
changed = true
}
if !changed {
break
}
for id := range removed {
delete(byID, id)
}
}
// Preservar orden original.
result := make([]axNode, 0, len(byID))
seen := map[string]struct{}{}
for _, n := range nodes {
node, ok := byID[n.nodeID]
if !ok {
continue
}
if _, dup := seen[n.nodeID]; dup {
continue
}
result = append(result, node)
seen[n.nodeID] = struct{}{}
}
return result
}
// renderAXOutline convierte axNode en un outline indentado, legible y
// accionable. Puro: porta render_ax_outline.py al caracter. La jerarquia se
// reconstruye con childIDs; las raices son nodeIds que no aparecen como hijo de
// nadie (fallback al primer nodo). maxChars > 0 trunca con sufijo.
func renderAXOutline(nodes []axNode, maxChars int) string {
if len(nodes) == 0 {
return ""
}
byID := map[string]axNode{}
for _, n := range nodes {
if n.nodeID != "" {
byID[n.nodeID] = n
}
}
allChildIDs := map[string]struct{}{}
for _, n := range nodes {
for _, cid := range n.childIDs {
allChildIDs[cid] = struct{}{}
}
}
var roots []axNode
for _, n := range nodes {
if _, isChild := allChildIDs[n.nodeID]; !isChild {
roots = append(roots, n)
}
}
if len(roots) == 0 {
roots = []axNode{nodes[0]}
}
var lines []string
visited := map[string]struct{}{} // guard de ciclo: un nodeId no se renderiza dos veces
var renderNode func(node axNode, depth int)
renderNode = func(node axNode, depth int) {
nid := node.nodeID
if depth > axoMaxDepth {
return
}
if nid != "" {
if _, dup := visited[nid]; dup {
return
}
visited[nid] = struct{}{}
}
if node.ignored {
return
}
role := node.role
if _, skip := axoSkipRoles[role]; role == "" || skip {
// Nodos sin role util: elevar los hijos al nivel actual.
for _, cid := range node.childIDs {
if child, ok := byID[cid]; ok {
renderNode(child, depth)
}
}
return
}
indent := strings.Repeat(" ", depth)
var base string
if node.name != "" {
base = fmt.Sprintf("%s%s %q", indent, role, node.name)
} else {
base = indent + role
}
// Estado actual del campo (texto escrito, valor de slider/combobox).
if node.value != "" {
base += " = " + axoPyRepr(node.value)
}
// Ref accionable, sin padding.
if _, ok := axoActionableRoles[role]; ok {
ref := axoRefID(node)
if ref != "" {
base += " #ref=" + ref
}
}
lines = append(lines, base)
for _, cid := range node.childIDs {
if child, ok := byID[cid]; ok {
renderNode(child, depth+1)
}
}
}
for _, root := range roots {
renderNode(root, 0)
}
result := strings.Join(lines, "\n")
if maxChars > 0 && len(result) > maxChars {
result = strings.TrimRight(result[:maxChars], " \t\n\r\v\f")
result += "\n…[outline truncado]"
}
return result
}
// axoRefID devuelve el ref estable del nodo: backendDOMNodeId (apunta al nodo DOM
// real, estable mientras el nodo viva) con fallback al nodeId. Igual que
// _ref_id() del .py.
func axoRefID(n axNode) string {
if n.backendDOMNodeID != "" {
return n.backendDOMNodeID
}
return n.nodeID
}
// axoPyRepr replica Python repr() para strings: comillas simples por defecto;
// comillas dobles si la cadena contiene comilla simple pero no doble; escape de
// backslash y de la comilla delimitadora. Reproduce el efecto de `{value!r}`
// del render_ax_outline.py para que la salida coincida al caracter.
func axoPyRepr(s string) string {
hasSingle := strings.Contains(s, "'")
hasDouble := strings.Contains(s, "\"")
quote := byte('\'')
if hasSingle && !hasDouble {
quote = '"'
}
var b strings.Builder
b.WriteByte(quote)
for i := 0; i < len(s); i++ {
ch := s[i]
switch ch {
case '\\':
b.WriteString("\\\\")
case '\n':
b.WriteString("\\n")
case '\r':
b.WriteString("\\r")
case '\t':
b.WriteString("\\t")
case quote:
b.WriteByte('\\')
b.WriteByte(quote)
default:
b.WriteByte(ch)
}
}
b.WriteByte(quote)
return b.String()
}