feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user