feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CdpClickHuman hace click en el elemento identificado por selector CSS con
|
// CdpClickHuman hace click en el elemento identificado por selector CSS con
|
||||||
@@ -53,31 +52,10 @@ func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
|
|||||||
toX := bx + bw/2 + offX
|
toX := bx + bw/2 + offX
|
||||||
toY := by + bh/2 + offY
|
toY := by + bh/2 + offY
|
||||||
|
|
||||||
// Mover el ratón con trayectoria humana
|
// Delegar en el primitivo compartido: mueve el ratón con trayectoria humana
|
||||||
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
|
// y despacha press/release con micro-pausa.
|
||||||
return fmt.Errorf("cdp click human: mover raton: %w", err)
|
if err := CdpClickXYHuman(c, toX, toY, opts); err != nil {
|
||||||
}
|
return fmt.Errorf("cdp click human: %w", err)
|
||||||
|
|
||||||
// mousePressed
|
|
||||||
clickParams := map[string]any{
|
|
||||||
"type": "mousePressed",
|
|
||||||
"x": toX,
|
|
||||||
"y": toY,
|
|
||||||
"button": "left",
|
|
||||||
"clickCount": 1,
|
|
||||||
}
|
|
||||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
|
||||||
return fmt.Errorf("cdp click human: mousePressed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Micro-pausa humana entre press y release (30–90 ms)
|
|
||||||
pauseMs := 30 + rand.Intn(61)
|
|
||||||
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
|
|
||||||
|
|
||||||
// mouseReleased
|
|
||||||
clickParams["type"] = "mouseReleased"
|
|
||||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
|
||||||
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
|
||||||
|
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
|
||||||
|
func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
|
||||||
|
res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
model, ok := res["model"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID)
|
||||||
|
}
|
||||||
|
content, ok := model["content"].([]any)
|
||||||
|
if !ok || len(content) < 8 {
|
||||||
|
return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID)
|
||||||
|
}
|
||||||
|
num := func(i int) float64 { f, _ := content[i].(float64); return f }
|
||||||
|
cx := (num(0) + num(2) + num(4) + num(6)) / 4
|
||||||
|
cy := (num(1) + num(3) + num(5) + num(7)) / 4
|
||||||
|
return cx, cy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
|
||||||
|
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||||
|
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
|
||||||
|
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp click ref: conexión nil")
|
||||||
|
}
|
||||||
|
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||||
|
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||||
|
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp click ref: %w", err)
|
||||||
|
}
|
||||||
|
return CdpClickXYHuman(c, cx, cy, opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: cdp_click_ref
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||||
|
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
|
||||||
|
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||||
|
uses_functions: [cdp_click_xy_human_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexión CDP activa al tab objetivo."
|
||||||
|
- name: backendNodeID
|
||||||
|
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||||
|
- name: opts
|
||||||
|
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
|
||||||
|
output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla."
|
||||||
|
file_path: "functions/browser/cdp_click_ref.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Tras un page_perceive que devuelve outline con #ref=1234:
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
err := CdpClickRef(conn, 1234, MouseHumanOpts{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||||
|
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa.
|
||||||
|
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas.
|
||||||
|
- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con
|
||||||
|
// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de
|
||||||
|
// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una
|
||||||
|
// micro-pausa variable (30-90 ms) entre ambos.
|
||||||
|
//
|
||||||
|
// Es el PRIMITIVO de click compartido por las tres vías de acción del agente:
|
||||||
|
// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí).
|
||||||
|
// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí).
|
||||||
|
// - por visión → click sobre el bounding box que devuelve OCR/YOLO.
|
||||||
|
// Construir un único primitivo evita tener tres caminos de click divergentes.
|
||||||
|
func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp click xy human: conexion nula")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mover el ratón hasta el destino con trayectoria humana.
|
||||||
|
if err := CdpMoveMouseHuman(c, x, y, opts); err != nil {
|
||||||
|
return fmt.Errorf("cdp click xy human: mover raton: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clickParams := map[string]any{
|
||||||
|
"type": "mousePressed",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"button": "left",
|
||||||
|
"clickCount": 1,
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||||
|
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Micro-pausa humana entre press y release (30-90 ms).
|
||||||
|
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
|
||||||
|
|
||||||
|
clickParams["type"] = "mouseReleased"
|
||||||
|
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||||
|
return fmt.Errorf("cdp click xy human: mouseReleased: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
id: cdp_click_xy_human_go_browser
|
||||||
|
name: cdp_click_xy_human
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
purity: impure
|
||||||
|
version: 1.0.0
|
||||||
|
tested: false
|
||||||
|
description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)."
|
||||||
|
tags: [cdp, browser, action, humanized, click, navegator]
|
||||||
|
signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error"
|
||||||
|
uses_functions:
|
||||||
|
- cdp_move_mouse_human_go_browser
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: error_go_core
|
||||||
|
imports: []
|
||||||
|
file_path: "functions/browser/cdp_click_xy_human.go"
|
||||||
|
example: |
|
||||||
|
conn, _ := browser.CdpConnect(9333)
|
||||||
|
defer browser.CdpClose(conn, 0)
|
||||||
|
// Click humanizado en el centro de un elemento detectado por visión (bbox):
|
||||||
|
browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{})
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexión CDP activa (de CdpConnect)."
|
||||||
|
- name: x
|
||||||
|
desc: "Coordenada X absoluta en la página, en px CSS del viewport."
|
||||||
|
- name: y
|
||||||
|
desc: "Coordenada Y absoluta en la página, en px CSS del viewport."
|
||||||
|
- name: opts
|
||||||
|
desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY."
|
||||||
|
output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
conn, _ := browser.CdpConnect(9333)
|
||||||
|
defer browser.CdpClose(conn, 0)
|
||||||
|
// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la
|
||||||
|
// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto:
|
||||||
|
if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento
|
||||||
|
(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el
|
||||||
|
único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física.
|
||||||
|
- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto
|
||||||
|
volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`),
|
||||||
|
no humanización completa por acción.
|
||||||
|
- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el
|
||||||
|
// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover.
|
||||||
|
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||||
|
func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp hover ref: conexión nil")
|
||||||
|
}
|
||||||
|
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||||
|
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||||
|
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp hover ref: %w", err)
|
||||||
|
}
|
||||||
|
return CdpMoveMouseHuman(c, cx, cy, opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: cdp_hover_ref
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||||
|
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
|
||||||
|
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||||
|
uses_functions: [cdp_move_mouse_human_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexión CDP activa al tab objetivo."
|
||||||
|
- name: backendNodeID
|
||||||
|
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||||
|
- name: opts
|
||||||
|
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
|
||||||
|
output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla."
|
||||||
|
file_path: "functions/browser/cdp_hover_ref.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Activar un menú desplegable cuyo trigger tiene #ref=9999:
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
err := CdpHoverRef(conn, 9999, MouseHumanOpts{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
// esperar a que el menú aparezca y re-percibir el outline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||||
|
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa.
|
||||||
|
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal).
|
||||||
|
- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`.
|
||||||
|
- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CdpTypeRef enfoca el elemento del #ref vía CDP y escribe el texto dado.
|
||||||
|
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||||
|
// Equivale a: focus(ref) → CdpTypeText. El elemento debe aceptar input de texto.
|
||||||
|
func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp type ref: conexión nil")
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
|
||||||
|
return fmt.Errorf("cdp type ref: focus ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
return CdpTypeText(c, text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: cdp_type_ref
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error"
|
||||||
|
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText. El #ref es el backendDOMNodeId estable del nodo DOM. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
|
||||||
|
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||||
|
uses_functions: [cdp_type_text_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexión CDP activa al tab objetivo."
|
||||||
|
- name: backendNodeID
|
||||||
|
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||||
|
- name: text
|
||||||
|
desc: "Texto a escribir en el elemento enfocado. Se envía carácter a carácter simulando escritura humana."
|
||||||
|
output: "nil si el focus y la escritura se completaron; error si la conexión es nil, DOM.focus falla, o CdpTypeText falla."
|
||||||
|
file_path: "functions/browser/cdp_type_ref.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Tras un page_perceive que devuelve un input con #ref=5678:
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
err := CdpTypeRef(conn, 5678, "hola mundo")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Tras `page_perceive` / `render_ax_outline`, cuando el agente quiere escribir en un campo de texto identificado por su `#ref` — cierra el bucle percibir→actuar para inputs. Preferir sobre secuencias manuales `DOM.focus` + `CdpTypeText` cuando el nodo viene directamente del AX outline.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||||
|
- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa.
|
||||||
|
- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`.
|
||||||
|
- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""
|
"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""
|
||||||
|
|
||||||
|
|
||||||
# Roles que se consideran accionables (el LLM puede referirlos con #ref=nodeId).
|
# Roles que se consideran accionables (el LLM puede referirlos con #ref).
|
||||||
_ACTIONABLE_ROLES = frozenset({
|
_ACTIONABLE_ROLES = frozenset({
|
||||||
"button",
|
"button",
|
||||||
"link",
|
"link",
|
||||||
@@ -23,9 +23,12 @@ _ACTIONABLE_ROLES = frozenset({
|
|||||||
"gridcell",
|
"gridcell",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Roles sin valor semántico para el outline: se omiten si tampoco tienen hijos.
|
# Roles sin valor semántico para el outline: se omiten (sus hijos se elevan).
|
||||||
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
|
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
|
||||||
|
|
||||||
|
# Límite de profundidad: evita RecursionError en árboles AX patológicos.
|
||||||
|
_MAX_DEPTH = 60
|
||||||
|
|
||||||
|
|
||||||
def _role_val(node: dict) -> str:
|
def _role_val(node: dict) -> str:
|
||||||
"""Extrae el valor de role del nodo CDP."""
|
"""Extrae el valor de role del nodo CDP."""
|
||||||
@@ -43,37 +46,63 @@ def _name_val(node: dict) -> str:
|
|||||||
return str(n) if n else ""
|
return str(n) if n else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _value_val(node: dict) -> str:
|
||||||
|
"""Estado actual del nodo: texto escrito en un input, valor de un slider o
|
||||||
|
combobox, etc. El LLM necesita saber qué hay ya en el campo."""
|
||||||
|
v = node.get("value", {})
|
||||||
|
if isinstance(v, dict):
|
||||||
|
val = v.get("value", "")
|
||||||
|
return str(val) if val not in (None, "") else ""
|
||||||
|
return str(v) if v else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_id(node: dict) -> str:
|
||||||
|
"""Ref ESTABLE para acciones por referencia.
|
||||||
|
|
||||||
|
Usa backendDOMNodeId (apunta al nodo DOM real, estable mientras el nodo viva)
|
||||||
|
en lugar del nodeId del AX tree, que es efímero y cambia en cada
|
||||||
|
Accessibility.getFullAXTree. Esto hace que el #ref que lee el LLM siga siendo
|
||||||
|
válido cuando actúa sobre él un instante después. Fallback al nodeId si el
|
||||||
|
backend no viene poblado.
|
||||||
|
"""
|
||||||
|
bid = node.get("backendDOMNodeId")
|
||||||
|
if bid is not None:
|
||||||
|
return str(bid)
|
||||||
|
return str(node.get("nodeId", ""))
|
||||||
|
|
||||||
|
|
||||||
def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
|
def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
|
||||||
"""Convierte nodos AX tree CDP en un outline indentado legible y accionable.
|
"""Convierte nodos AX tree CDP en un outline indentado legible y accionable.
|
||||||
|
|
||||||
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera
|
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera una
|
||||||
una representación de texto con indentación de 2 espacios por nivel.
|
representación de texto con indentación de 2 espacios por nivel. Los nodos
|
||||||
Los nodos accionables llevan un marcador #ref=nodeId para que el LLM
|
accionables llevan un marcador #ref=<backendDOMNodeId> para que el LLM pueda
|
||||||
pueda referenciarlos en acciones posteriores.
|
referenciarlos en acciones posteriores (click_ref/type_ref/hover_ref). El
|
||||||
|
estado actual de inputs (texto escrito, valor) se muestra como `= 'valor'`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
nodes: Lista de AXNode en formato CDP (campos: nodeId, role, name,
|
nodes: Lista de AXNode en formato CDP (campos: nodeId, backendDOMNodeId,
|
||||||
childIds, parentId, ignored). Formato devuelto por
|
role, name, value, childIds, parentId, ignored). Formato devuelto
|
||||||
cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes
|
por cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes para
|
||||||
para reducir ruido.
|
reducir ruido (conserva backendDOMNodeId y value.value).
|
||||||
max_chars: Si > 0, trunca la salida a ese número de caracteres y
|
max_chars: Si > 0, trunca la salida a ese número de caracteres y añade una
|
||||||
añade una línea final '…[outline truncado]'. 0 = sin límite.
|
línea final '…[outline truncado]'. 0 = sin límite.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
String multi-línea con el outline indentado. Vacío si nodes es vacío
|
String multi-línea con el outline indentado. Vacío si nodes es vacío o
|
||||||
o todos los nodos son ignorados/sin role útil.
|
todos los nodos son ignorados/sin role útil.
|
||||||
"""
|
"""
|
||||||
if not nodes:
|
if not nodes:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Construir lookup por nodeId
|
# Lookup por nodeId (la jerarquía childIds usa nodeId, no backendDOMNodeId).
|
||||||
by_id: dict[str, dict] = {}
|
by_id: dict[str, dict] = {}
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
nid = node.get("nodeId")
|
nid = node.get("nodeId")
|
||||||
if nid:
|
if nid:
|
||||||
by_id[nid] = node
|
by_id[nid] = node
|
||||||
|
|
||||||
# Detectar nodos raíz: nodeId no aparece como childId de nadie visible
|
# Detectar nodos raíz: nodeId que no aparece como childId de nadie visible.
|
||||||
all_child_ids: set[str] = set()
|
all_child_ids: set[str] = set()
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
for cid in node.get("childIds", []):
|
for cid in node.get("childIds", []):
|
||||||
@@ -81,19 +110,25 @@ def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
|
|||||||
|
|
||||||
roots = [n for n in nodes if n.get("nodeId") not in all_child_ids]
|
roots = [n for n in nodes if n.get("nodeId") not in all_child_ids]
|
||||||
if not roots:
|
if not roots:
|
||||||
# Fallback: usar el primer nodo
|
|
||||||
roots = [nodes[0]]
|
roots = [nodes[0]]
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
visited: set[str] = set() # guard de ciclo: un nodeId no se renderiza dos veces
|
||||||
|
|
||||||
def _render_node(node: dict, depth: int) -> None:
|
def _render_node(node: dict, depth: int) -> None:
|
||||||
"""Renderiza un nodo y sus hijos recursivamente."""
|
"""Renderiza un nodo y sus hijos recursivamente."""
|
||||||
|
nid = node.get("nodeId")
|
||||||
|
if depth > _MAX_DEPTH or (nid and nid in visited):
|
||||||
|
return
|
||||||
|
if nid:
|
||||||
|
visited.add(nid)
|
||||||
|
|
||||||
if node.get("ignored", False):
|
if node.get("ignored", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
role = _role_val(node)
|
role = _role_val(node)
|
||||||
if not role or role in _SKIP_ROLES:
|
if not role or role in _SKIP_ROLES:
|
||||||
# Nodos sin role útil: intentar renderizar hijos directamente
|
# Nodos sin role útil: elevar los hijos al nivel actual.
|
||||||
for cid in node.get("childIds", []):
|
for cid in node.get("childIds", []):
|
||||||
child = by_id.get(cid)
|
child = by_id.get(cid)
|
||||||
if child:
|
if child:
|
||||||
@@ -101,27 +136,26 @@ def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
|
|||||||
return
|
return
|
||||||
|
|
||||||
name = _name_val(node)
|
name = _name_val(node)
|
||||||
node_id = node.get("nodeId", "")
|
|
||||||
indent = " " * depth
|
indent = " " * depth
|
||||||
|
|
||||||
# Construir línea base: role ["name"]
|
|
||||||
if name:
|
if name:
|
||||||
base = f'{indent}{role} "{name}"'
|
base = f'{indent}{role} "{name}"'
|
||||||
else:
|
else:
|
||||||
base = f"{indent}{role}"
|
base = f"{indent}{role}"
|
||||||
|
|
||||||
# Añadir #ref si es accionable
|
# Estado actual del campo (texto escrito, valor del slider/combobox).
|
||||||
if role in _ACTIONABLE_ROLES and node_id:
|
value = _value_val(node)
|
||||||
# Alinear #ref a columna 60 para legibilidad, o adjunto si la línea es larga
|
if value:
|
||||||
ref_tag = f"#ref={node_id}"
|
base += f" = {value!r}"
|
||||||
if len(base) < 58:
|
|
||||||
base = base.ljust(60) + ref_tag
|
# Ref accionable, sin padding (el relleno con espacios gastaba tokens).
|
||||||
else:
|
if role in _ACTIONABLE_ROLES:
|
||||||
base = base + " " + ref_tag
|
ref = _ref_id(node)
|
||||||
|
if ref:
|
||||||
|
base += f" #ref={ref}"
|
||||||
|
|
||||||
lines.append(base)
|
lines.append(base)
|
||||||
|
|
||||||
# Renderizar hijos en orden
|
|
||||||
for cid in node.get("childIds", []):
|
for cid in node.get("childIds", []):
|
||||||
child = by_id.get(cid)
|
child = by_id.get(cid)
|
||||||
if child:
|
if child:
|
||||||
|
|||||||
Reference in New Issue
Block a user