Files
fn_registry/functions/browser/cdp_find_ref_by_text.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

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
}