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 }