feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
@@ -0,0 +1,66 @@
package browser
import "fmt"
// CdpNewTabBackground abre una pestaña nueva via Target.createTarget con el
// parametro "background": true, de forma que la pestaña se crea SIN activarse y
// SIN elevar la ventana del navegador (no roba el foco del WM).
//
// Es el drop-in sin-foco de CdpNewTab: misma firma, mismo CdpTab de retorno.
// La diferencia tecnica es el mecanismo:
// - CdpNewTab usa el endpoint HTTP PUT /json/new, que NO admite background y
// por tanto SIEMPRE eleva la ventana (roba foco al usuario).
// - Aqui usamos el comando CDP browser-level Target.createTarget con
// "background": true, que en Linux/Chromium crea la pestaña en segundo plano.
//
// host vacio = "localhost". startURL vacio = "about:blank".
func CdpNewTabBackground(host string, port int, startURL string) (CdpTab, error) {
if host == "" {
host = "localhost"
}
if startURL == "" {
startURL = "about:blank"
}
// Target.createTarget debe ejecutarse contra el browser target (no una page),
// por eso resolvemos el webSocketDebuggerUrl browser-level via /json/version.
wsURL, err := cdpGetWSURL(port)
if err != nil {
return CdpTab{}, fmt.Errorf("cdp new tab background: %w", err)
}
conn, err := cdpConnectWS(wsURL, port)
if err != nil {
return CdpTab{}, fmt.Errorf("cdp new tab background: conectar: %w", err)
}
// Soltar solo el WebSocket; dejar el navegador vivo.
defer CdpDisconnect(conn)
res, err := conn.sendCDP("Target.createTarget", map[string]any{
"url": startURL,
"background": true,
})
if err != nil {
return CdpTab{}, fmt.Errorf("cdp new tab background: createTarget: %w", err)
}
targetID, _ := res["targetId"].(string)
if targetID == "" {
return CdpTab{}, fmt.Errorf("cdp new tab background: createTarget no devolvio targetId")
}
// Resolver el CdpTab completo (con webSocketDebuggerUrl, title, etc.) buscando
// el target recien creado en /json.
tabs, err := CdpListTabs(host, port)
if err == nil {
for _, t := range tabs {
if t.ID == targetID {
return t, nil
}
}
}
// Fallback en caso de carrera (el target aun no aparece en /json): devolvemos
// un CdpTab minimo con el id, tipo y URL inicial conocidos.
return CdpTab{ID: targetID, Type: "page", URL: startURL}, nil
}
@@ -0,0 +1,75 @@
---
name: cdp_new_tab_background
kind: function
lang: go
domain: browser
version: 1.0.0
purity: impure
signature: "func CdpNewTabBackground(host string, port int, startURL string) (CdpTab, error)"
description: "Abre una pestaña nueva via CDP Target.createTarget con background:true, sin activarla ni elevar la ventana del navegador (no roba el foco del WM). Drop-in sin-foco de CdpNewTab: misma firma y mismo CdpTab de retorno, pero usando el comando CDP browser-level en lugar del endpoint HTTP /json/new (que SI roba foco)."
tags: [browser, cdp, tabs, spawn, background, no-focus]
uses_functions: [cdp_list_tabs_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
example: |
tab, err := browser.CdpNewTabBackground("localhost", 9333, "https://example.com")
if err == nil {
fmt.Println("nueva tab en segundo plano id=", tab.ID)
}
tested: true
tests: ["TestCdpNewTabBackground_closedPort", "TestCdpNewTabBackground_emptyStartURLClosedPort"]
test_file_path: "functions/browser/cdp_new_tab_background_test.go"
file_path: "functions/browser/cdp_new_tab_background.go"
notes: |
- Usa los helpers privados del paquete: cdpGetWSURL (browser-level WS),
cdpConnectWS, (*CDPConn).sendCDP y CdpListTabs. No reescribe el transporte CDP.
- El cierre del WebSocket se hace con CdpDisconnect (solo suelta la sesion, deja
el navegador vivo).
- Resuelve el CdpTab completo via CdpListTabs buscando por targetId; si hay
carrera y aun no aparece, devuelve un CdpTab minimo (id, type, url) como fallback.
documentation: |
Alternativa a CdpNewTab cuando NO quieres que la ventana del navegador robe el
foco del window manager — por ejemplo, mientras el usuario escribe en otra
ventana. El endpoint HTTP /json/new no admite el parametro background, asi que
CdpNewTab siempre eleva la ventana; esta funcion usa Target.createTarget con
"background": true para crear la pestaña en segundo plano.
params:
- name: host
desc: "Host CDP donde escucha el navegador (vacio = localhost)."
- name: port
desc: "Puerto remote-debugging de Chrome/Chromium (ej. 9333)."
- name: startURL
desc: "URL inicial de la pestaña. Vacio = about:blank."
output: "CdpTab del target recien creado (id, webSocketDebuggerUrl, title, url, ...). Error si /json/version o el comando CDP fallan."
---
## Ejemplo
```go
// Abrir una pestaña en segundo plano sin robar el foco del usuario.
tab, err := browser.CdpNewTabBackground("localhost", 9333, "https://example.com")
if err != nil {
log.Fatal(err)
}
fmt.Println("pestaña creada en background:", tab.ID, tab.URL)
```
## Cuando usarla
Cuando abras una pestaña por CDP y NO quieras que la ventana del navegador robe
el foco del WM (el usuario esta escribiendo en otra ventana). Alternativa
sin-foco a `CdpNewTab` / endpoint HTTP `/json/new`, que siempre eleva la ventana.
## Gotchas
- Funcion impura: abre un WebSocket al navegador y manda un comando CDP. Falla si
el puerto no responde o el comando no devuelve `targetId`.
- El parametro `background` de `Target.createTarget` no aplica en MacOS (alli la
pestaña se activa igual). Esto esta pensado para Linux/Chromium.
- Requiere conexion **browser-level** (`/json/version`), no page-level: por eso usa
`cdpGetWSURL` y no la primera tab `page`.
- Si el navegador corre headless, el foco es irrelevante — `CdpNewTab` y esta
funcion son equivalentes en ese caso.
@@ -0,0 +1,21 @@
package browser
import "testing"
func TestCdpNewTabBackground_closedPort(t *testing.T) {
// Sin Chrome escuchando esperamos error de red al resolver /json/version,
// pero NO panic ni nil-deref. Puerto 1 garantizado cerrado.
_, err := CdpNewTabBackground("", 1, "https://example.com")
if err == nil {
t.Fatal("expected error talking to closed port")
}
}
func TestCdpNewTabBackground_emptyStartURLClosedPort(t *testing.T) {
// startURL vacio debe normalizarse a about:blank sin romper; con puerto
// cerrado seguimos esperando error de red, no panic.
_, err := CdpNewTabBackground("localhost", 1, "")
if err == nil {
t.Fatal("expected error talking to closed port")
}
}
+76
View File
@@ -0,0 +1,76 @@
package browser
import (
"encoding/json"
"fmt"
)
// detectCaptchaJS es la unica evaluacion que DetectCaptcha corre en el top frame.
// Detecta reCAPTCHA, hCaptcha y Cloudflare Turnstile por la presencia de sus
// iframes/widgets (los iframe[src] son legibles desde el top aunque su contenido
// sea cross-origin) y el JS-challenge de Cloudflare por texto en innerText.
// Siempre retorna un JSON serializable; en caso de excepcion devuelve detected=false
// con un campo "error" para que el caller no rompa (best-effort).
const detectCaptchaJS = `(function(){
try {
var sigs = [];
var q = function(s){ return document.querySelector(s); };
if (q('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"], .g-recaptcha, #recaptcha')) sigs.push('recaptcha');
if (q('iframe[src*="hcaptcha.com"], .h-captcha')) sigs.push('hcaptcha');
if (q('iframe[src*="challenges.cloudflare.com"], .cf-turnstile')) sigs.push('turnstile');
var t = ((document.body && document.body.innerText) || '').toLowerCase().slice(0, 4000);
if (/checking your browser|verify(ing)? you are human|i'?m not a robot|are you a robot|unusual traffic|complete the security check|press and hold/.test(t)) sigs.push('challenge');
var seen = {}, uniq = [];
for (var i=0;i<sigs.length;i++){ if(!seen[sigs[i]]){seen[sigs[i]]=1;uniq.push(sigs[i]);} }
return JSON.stringify({detected: uniq.length>0, types: uniq, url: location.href});
} catch(e){ return JSON.stringify({detected:false, types:[], url: (location&&location.href)||'', error:String(e)}); }
})()`
// captchaResult es el shape del JSON que produce detectCaptchaJS.
type captchaResult struct {
Detected bool `json:"detected"`
Types []string `json:"types"`
URL string `json:"url"`
Error string `json:"error"`
}
// parseCaptchaSignals parsea el JSON que produce detectCaptchaJS. Es puro y
// testeable sin navegador. Si el JSON trae un campo "error" (excepcion JS en la
// pagina) se trata como detected=false best-effort, no como fallo. types es
// siempre un slice no nulo (vacio si no hay senales). Solo retorna error si el
// JSON es invalido / no parseable.
func parseCaptchaSignals(raw string) (detected bool, types []string, url string, err error) {
var r captchaResult
if err := json.Unmarshal([]byte(raw), &r); err != nil {
return false, nil, "", fmt.Errorf("parse captcha signals: json invalido: %w", err)
}
if r.Types == nil {
r.Types = []string{}
}
return r.Detected, r.Types, r.URL, nil
}
// DetectCaptcha detecta si la pagina actual presenta un captcha o challenge
// anti-bot. Corre UNA evaluacion JS en el top frame y parsea el resultado.
// NO resuelve ni notifica nada — solo detecta. Una responsabilidad.
//
// Retorna detected=true si hay al menos una senal, junto con los tipos
// detectados (subconjunto de: "recaptcha", "hcaptcha", "turnstile",
// "challenge") y la URL del top frame. Best-effort: una excepcion JS en la
// pagina se trata como "no detectado" sin romper.
func DetectCaptcha(c *CDPConn) (detected bool, types []string, url string, err error) {
if c == nil {
return false, nil, "", fmt.Errorf("detect captcha: conexion nula")
}
raw, err := CdpEvaluate(c, detectCaptchaJS)
if err != nil {
return false, nil, "", fmt.Errorf("detect captcha: %w", err)
}
detected, types, url, err = parseCaptchaSignals(raw)
if err != nil {
return false, nil, "", fmt.Errorf("detect captcha: %w", err)
}
return detected, types, url, nil
}
+61
View File
@@ -0,0 +1,61 @@
---
name: detect_captcha
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func DetectCaptcha(c *CDPConn) (detected bool, types []string, url string, err error)"
description: "Detecta captchas y challenges anti-bot en la pagina actual via CDP: reCAPTCHA, hCaptcha, Cloudflare Turnstile (por iframe/widget) y el JS-challenge de Cloudflare (por texto). Solo detecta — no resuelve ni notifica. Una responsabilidad."
tags: [captcha, browser, cdp, antibot, detection, perception]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt]
params:
- name: c
desc: "Conexion CDP activa a una tab de Chrome de tipo 'page'. La evaluacion corre en el top frame."
output: "Tupla (detected, types, url, err). detected=true si hay al menos una senal anti-bot. types es el subconjunto de senales detectadas (de: 'recaptcha', 'hcaptcha', 'turnstile', 'challenge'), siempre slice no nulo (vacio si nada). url es la location.href del top frame. err si la conexion es nula, falla el eval CDP, o el JSON resultante es invalido. Una excepcion JS en la pagina se trata como detected=false best-effort, sin error."
tested: true
tests: ["recaptcha detectado", "hcaptcha detectado", "turnstile detectado", "challenge por texto", "multiples senales", "ninguno", "campo error best-effort no rompe", "types ausente se normaliza a slice vacio", "json invalido devuelve error"]
test_file_path: "functions/browser/detect_captcha_test.go"
file_path: "functions/browser/detect_captcha.go"
---
## Ejemplo
```go
// Conectar a un Chrome con CDP abierto (mismo patron que cdp_get_text)
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpDisconnect(conn)
// Tras navegar y esperar la carga, comprobar si la pagina puso un captcha
detected, types, url, err := DetectCaptcha(conn)
if err != nil {
log.Fatal(err)
}
if detected {
fmt.Printf("captcha detectado en %s: %v\n", url, types)
// p.ej. -> "captcha detectado en https://x.test/login: [recaptcha]"
} else {
fmt.Println("sin captcha, seguir clicando")
}
```
## Cuando usarla
Tras navegar o esperar la carga de una pagina, para saber si esta puso un captcha o challenge anti-bot antes de seguir clicando o enviando formularios. La usa el `browser_mcp` en sus handlers de navegacion para decidir el handoff humano: si `DetectCaptcha` devuelve `detected=true`, el flujo automatico se detiene y avisa para resolucion manual en vez de chocar contra el muro.
## Gotchas
- **Solo top frame**: la evaluacion corre en el frame principal. Un captcha incrustado en un iframe anidado profundo cuyo `src` no matchee los patrones no se detecta.
- **Iframes cross-origin**: el contenido de los iframes de reCAPTCHA/hCaptcha/Turnstile NO se lee (politica same-origin), pero SI se detectan por su `src` y por las clases del widget host (`.g-recaptcha`, `.h-captcha`, `.cf-turnstile`), que viven en el top document.
- **Falsos positivos posibles**: la senal `challenge` viene de regex sobre `innerText` (p.ej. "verify you are human", "unusual traffic"). Una pagina con ese texto en otro contexto (un articulo, una FAQ sobre bots) puede dar `detected=true` sin haber captcha real.
- **No detecta captchas custom**: solo cubre los proveedores listados (reCAPTCHA, hCaptcha, Turnstile) + el JS-challenge de Cloudflare. Captchas propios o de otros vendors no se reconocen.
- **Depende de innerText**: la pagina debe haber pintado el body. En una tab aun cargando (`document.body` nulo o vacio) la senal `challenge` puede no dispararse — esperar con `cdp_wait_load` antes de detectar si el contenido es dinamico.
- **Impura**: hace un round-trip CDP (I/O de red). Requiere conexion activa a una tab de tipo `page`.
+103
View File
@@ -0,0 +1,103 @@
package browser
import (
"reflect"
"testing"
)
func TestParseCaptchaSignals(t *testing.T) {
tests := []struct {
name string
raw string
wantDetected bool
wantTypes []string
wantURL string
wantErr bool
}{
{
name: "recaptcha detectado",
raw: `{"detected":true,"types":["recaptcha"],"url":"https://x.test/login"}`,
wantDetected: true,
wantTypes: []string{"recaptcha"},
wantURL: "https://x.test/login",
},
{
name: "hcaptcha detectado",
raw: `{"detected":true,"types":["hcaptcha"],"url":"https://y.test/signup"}`,
wantDetected: true,
wantTypes: []string{"hcaptcha"},
wantURL: "https://y.test/signup",
},
{
name: "turnstile detectado",
raw: `{"detected":true,"types":["turnstile"],"url":"https://z.test/"}`,
wantDetected: true,
wantTypes: []string{"turnstile"},
wantURL: "https://z.test/",
},
{
name: "challenge por texto",
raw: `{"detected":true,"types":["challenge"],"url":"https://cf.test/"}`,
wantDetected: true,
wantTypes: []string{"challenge"},
wantURL: "https://cf.test/",
},
{
name: "multiples senales",
raw: `{"detected":true,"types":["turnstile","challenge"],"url":"https://cf.test/"}`,
wantDetected: true,
wantTypes: []string{"turnstile", "challenge"},
wantURL: "https://cf.test/",
},
{
name: "ninguno",
raw: `{"detected":false,"types":[],"url":"https://clean.test/"}`,
wantDetected: false,
wantTypes: []string{},
wantURL: "https://clean.test/",
},
{
name: "campo error best-effort no rompe",
raw: `{"detected":false,"types":[],"url":"https://err.test/","error":"boom"}`,
wantDetected: false,
wantTypes: []string{},
wantURL: "https://err.test/",
},
{
name: "types ausente se normaliza a slice vacio",
raw: `{"detected":false,"url":"https://n.test/"}`,
wantDetected: false,
wantTypes: []string{},
wantURL: "https://n.test/",
},
{
name: "json invalido devuelve error",
raw: `not-json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detected, types, url, err := parseCaptchaSignals(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("esperaba error, got nil")
}
return
}
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if detected != tt.wantDetected {
t.Errorf("detected: got %v, want %v", detected, tt.wantDetected)
}
if !reflect.DeepEqual(types, tt.wantTypes) {
t.Errorf("types: got %v, want %v", types, tt.wantTypes)
}
if url != tt.wantURL {
t.Errorf("url: got %q, want %q", url, tt.wantURL)
}
})
}
}