Files
fn_registry/functions/browser/cdp_get_ax_outline.go
T
egutierrez 216cad4c12 perf(browser): acelera CDP — enable cacheado, wait_load por evento, timeout en sendCDP, escritura insertText
Optimiza el dominio browser para que el manejo del navegador via CDP sea mucho más rápido en automatización propia, manteniendo el camino sigiloso disponible.

- CDPConn cachea los enable de Accessibility/Network/Page por conexión (ensureAX/ensureNetwork/ensurePage): elimina un round-trip redundante en cada percepción y espera, que son las operaciones más frecuentes del bucle percibir->actuar del agente.
- sendCDP adquiere timeout (cdpCmdTimeout 30s): antes una respuesta que Chrome nunca enviaba colgaba la goroutine del tool indefinidamente; ahora falla limpio y el retry puede reconectar.
- CdpWaitLoad pasa de polling de document.readyState cada 200ms a esperar el evento Page.loadEventFired, con fast path inicial de readyState y re-chequeo anti-carrera tras suscribir. Si la página ya está cargada retorna en microsegundos.
- cdp_wait_idle usa ensureNetwork y deja de hacer Network.disable al salir (borraba el estado y forzaba el enable de nuevo).
- Nuevas funciones de escritura rápida: CdpInsertText (todo el texto en un solo Input.insertText) y CdpTypeRefFast (focus + insertText). El chequeo de foco se extrajo a assertEditableFocus, compartido con CdpTypeText.
- CdpTypeText pasa su pausa entre caracteres de 10ms fija a aleatoria 15-65ms (ritmo humano irregular).
- El modo 'auto' se añade al perfil de ratón (MouseProfileForMode, mouseHumanDefaults, clickPauseMs) como alias rápido de 'fast'.

No se tocan las firmas públicas existentes; CdpTypeRef y CdpTypeText conservan su comportamiento (camino human).
2026-06-13 14:27:10 +02:00

412 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 (idempotente, cacheado por conexion): necesario antes de
// getFullAXTree. Cachear el flag evita un round-trip extra en cada percepcion,
// que es la operacion mas frecuente del bucle percibir->actuar del agente.
if err := c.ensureAX(); 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()
}