8742cb25be
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
5.1 KiB
Go
142 lines
5.1 KiB
Go
package browser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de
|
|
// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos.
|
|
// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno
|
|
// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve.
|
|
const findByTextCoreJS = `
|
|
var P = %s;
|
|
var target = P.cs ? P.text : P.text.toLowerCase();
|
|
var nodes = document.querySelectorAll(P.tag || '*');
|
|
function norm(v) {
|
|
v = (v || '').replace(/\s+/g, ' ').trim();
|
|
return P.cs ? v : v.toLowerCase();
|
|
}
|
|
function matches(el) {
|
|
var v = norm(el.innerText || el.textContent || '');
|
|
return P.exact ? v === target : v.indexOf(target) >= 0;
|
|
}
|
|
function leafmost(el) {
|
|
for (var i = 0; i < el.children.length; i++) {
|
|
if (matches(el.children[i])) return false;
|
|
}
|
|
return true;
|
|
}`
|
|
|
|
// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode.
|
|
// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un
|
|
// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía).
|
|
func parseBackendNodeID(resp map[string]any) (int, error) {
|
|
node, ok := resp["node"].(map[string]any)
|
|
if !ok {
|
|
return 0, fmt.Errorf("describeNode: respuesta sin campo node")
|
|
}
|
|
raw, ok := node["backendNodeId"]
|
|
if !ok {
|
|
return 0, fmt.Errorf("describeNode: node sin backendNodeId")
|
|
}
|
|
f, ok := raw.(float64)
|
|
if !ok {
|
|
return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw)
|
|
}
|
|
return int(f), nil
|
|
}
|
|
|
|
// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y
|
|
// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que
|
|
// produce el outline de page_perceive y que consume CdpClickRef. Así se puede
|
|
// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type).
|
|
//
|
|
// Retorna (backendNodeID, count, error):
|
|
// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef.
|
|
// - count: número total de elementos que matchean (tras el filtro leafmost).
|
|
// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda.
|
|
// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay
|
|
// ningún match (count == 0).
|
|
//
|
|
// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un
|
|
// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM
|
|
// (DOM.describeNode), evitando el round-trip por selector CSS.
|
|
func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) {
|
|
if c == nil {
|
|
return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula")
|
|
}
|
|
if text == "" {
|
|
return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio")
|
|
}
|
|
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"text": text,
|
|
"tag": opts.Tag,
|
|
"exact": opts.Exact,
|
|
"cs": opts.CaseSensitive,
|
|
})
|
|
core := fmt.Sprintf(findByTextCoreJS, string(payload))
|
|
|
|
// 1. Contar matches (returnByValue=true vía CdpEvaluate).
|
|
countJS := "(function(){" + core + `
|
|
var n = 0;
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
if (matches(nodes[i]) && leafmost(nodes[i])) n++;
|
|
}
|
|
return n;
|
|
})()`
|
|
countStr, err := CdpEvaluate(c, countJS)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err)
|
|
}
|
|
count, _ := strconv.Atoi(strings.TrimSpace(countStr))
|
|
if count == 0 {
|
|
return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text)
|
|
}
|
|
|
|
// 2. Resolver el primer match a un RemoteObject (returnByValue=false para
|
|
// obtener un objectId que apunta al nodo DOM vivo).
|
|
elJS := "(function(){" + core + `
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i];
|
|
}
|
|
return null;
|
|
})()`
|
|
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
|
"expression": elJS,
|
|
"returnByValue": false,
|
|
})
|
|
if err != nil {
|
|
return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err)
|
|
}
|
|
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
|
excMap, _ := exc.(map[string]any)
|
|
txt, _ := excMap["text"].(string)
|
|
return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt)
|
|
}
|
|
remote, ok := evRes["result"].(map[string]any)
|
|
if !ok {
|
|
return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result")
|
|
}
|
|
objID, _ := remote["objectId"].(string)
|
|
if objID == "" {
|
|
// El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM
|
|
// mutó): tratamos como no encontrado para no devolver un ref inválido.
|
|
return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)")
|
|
}
|
|
|
|
// 3. Del RemoteObject al nodo DOM: backendNodeId.
|
|
dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID})
|
|
if err != nil {
|
|
return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err)
|
|
}
|
|
backendNodeID, err := parseBackendNodeID(dn)
|
|
if err != nil {
|
|
return 0, count, fmt.Errorf("cdp find ref by text: %w", err)
|
|
}
|
|
return backendNodeID, count, nil
|
|
}
|