diff --git a/bash/functions/infra/focus_cdp_tab_window.md b/bash/functions/infra/focus_cdp_tab_window.md new file mode 100644 index 00000000..28fde7a0 --- /dev/null +++ b/bash/functions/infra/focus_cdp_tab_window.md @@ -0,0 +1,73 @@ +--- +name: focus_cdp_tab_window +id: focus_cdp_tab_window_bash_infra +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "focus_cdp_tab_window(port: int, [target_id: string]) -> void" +description: "Handoff humano de captcha: trae al frente la pestaña (via CDP /json/activate) y la ventana del SO de un Chrome con CDP, para que el humano resuelva el captcha a mano. Promocion del patron inline que acompaña a detect_captcha_go_browser." +tags: [browser, captcha, handoff, cdp, wmctrl, xdotool, infra, navegator] +params: + - name: "port" + desc: "Puerto CDP del Chrome (ej. 9333 = Chrome aislado del browser_mcp; 9222 = navegador diario). Obligatorio." + - name: "target_id" + desc: "Opcional. Target/tab id CDP de la pestaña del captcha. Si se pasa, se activa esa pestaña dentro del browser antes de levantar la ventana del SO. Si se omite, solo se levanta la ventana." +output: "Stdout una linea legible y JSON-parseable simple: 'focus_cdp_tab_window: focused win= pid= port= tab='. Exit 0 en exito; 2 sin puerto, 3 sin DISPLAY, 4 falta wmctrl/xdotool, 5 no hay chromium en el puerto, 6 sin ventana top-level." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/focus_cdp_tab_window.sh" +--- + +## Ejemplo + +```bash +# Activar la pestaña del captcha (por su target id CDP) y levantar la ventana del Chrome aislado +focus_cdp_tab_window 9333 20EF6E28AA792C53AF0D260F34A768B3 +# -> focus_cdp_tab_window: focused win=0x03a00007 pid=48213 port=9333 tab=20EF6E28AA792C53AF0D260F34A768B3 + +# Solo levantar la ventana del Chrome (sin activar tab concreta) +focus_cdp_tab_window 9333 +# -> focus_cdp_tab_window: focused win=0x03a00007 pid=48213 port=9333 tab=- +``` + +Invocacion canonica via el CLI del registry (despacho bash automatico): + +```bash +./fn run focus_cdp_tab_window 9333 20EF6E28AA792C53AF0D260F34A768B3 +``` + +## Cuando usarla + +En el handoff humano de captcha: cuando el `browser_mcp` marca `⚠️ CAPTCHA-DETECTED` +(via `detect_captcha_go_browser`), usa esta funcion para traer la pestaña del captcha y la +ventana del Chrome al frente para que el humano lo resuelva a mano; luego se le notifica y se +para la automatizacion. Pasa el `target_id` de la tab donde se detecto el captcha para activar +esa pestaña exacta; omitelo si solo necesitas levantar la ventana del navegador. + +## Gotchas + +- **Impura, requiere X11**: necesita un entorno grafico (`$DISPLAY` no vacio) + `wmctrl` + `xdotool` + instalados. No sirve headless ni por SSH sin X forwarding — sale con error y exit != 0. +- **Match pid->ventana fragil**: resuelve la ventana cruzando el PID del browser principal con la + columna PID de `wmctrl -lp`. Puede fallar si el window manager agrupa ventanas o si chromium no + expone `_NET_WM_PID` en el main; de ahi el fallback a `xdotool search --pid --onlyvisible`. +- **No reposiciona entre monitores**: solo activa/levanta la ventana donde ya esta; no la mueve a + otra pantalla. +- **Varias ventanas del mismo Chrome**: si el browser tiene varias ventanas top-level, coge la + primera que matchea el PID. +- **Activate CDP best-effort**: `curl /json/activate/` puede dar 404 si el `target_id` + caduco (la tab cambio de id o se cerro). La funcion NO aborta: sigue con el raise de la ventana + igualmente. +- **Reintento por XFCE**: xfwm pisa el primer `windowactivate`/`windowraise`, por eso se hace el + activate+raise dos veces con una espera corta entre medias. +- **Identifica el browser process por ausencia de `--type=`**: las lineas de `pgrep` con + `--type=renderer/gpu/utility/zygote` son procesos hijos; se descartan para quedarse con el main. diff --git a/bash/functions/infra/focus_cdp_tab_window.sh b/bash/functions/infra/focus_cdp_tab_window.sh new file mode 100644 index 00000000..6558fbec --- /dev/null +++ b/bash/functions/infra/focus_cdp_tab_window.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# focus_cdp_tab_window — trae al frente la pestaña + la ventana del SO de un Chrome con CDP +# +# Handoff humano de captcha: activa la tab del captcha (opcional, via CDP) y levanta +# la ventana X11 del proceso browser principal de ese puerto para que un humano resuelva +# el captcha a mano. Best-effort y robusto: cada paso continua aunque uno falle. + +focus_cdp_tab_window() { + set -uo pipefail + + local port="${1:-}" + local target_id="${2:-}" + + # 1. Validacion de entorno y dependencias. + if [[ -z "$port" ]]; then + echo "focus_cdp_tab_window: falta el puerto CDP (uso: focus_cdp_tab_window [target_id])" >&2 + return 2 + fi + if [[ -z "${DISPLAY:-}" ]]; then + echo "focus_cdp_tab_window: sin entorno grafico (DISPLAY vacio)" >&2 + return 3 + fi + if ! command -v wmctrl >/dev/null 2>&1 || ! command -v xdotool >/dev/null 2>&1; then + echo "focus_cdp_tab_window: falta wmctrl/xdotool" >&2 + return 4 + fi + + # 2. Activar la tab del captcha dentro del browser (best-effort, no aborta). + if [[ -n "$target_id" ]]; then + curl -sf "http://127.0.0.1:${port}/json/activate/${target_id}" >/dev/null 2>&1 || true + fi + + # 3. Encontrar el PID del proceso BROWSER principal de ese puerto. + # De las lineas que matchean el flag de debugging, el browser process es el que + # NO lleva --type= (los renderers/gpu/utility/zygote son procesos hijos). + local browser_pid="" + local line + while IFS= read -r line; do + [[ -z "$line" ]] && continue + if [[ "$line" == *"--type="* ]]; then + continue + fi + # pgrep -af antepone el PID seguido de la cmdline. + browser_pid="${line%% *}" + break + done < <(pgrep -af -- "remote-debugging-port=${port}" 2>/dev/null) + + if [[ -z "$browser_pid" ]]; then + echo "focus_cdp_tab_window: no hay chromium en el puerto ${port}" >&2 + return 5 + fi + + # 4. Resolver el window id top-level. + # Primero por wmctrl -lp (columna 3 = PID). Fallback xdotool si el main no expone _NET_WM_PID. + local wid="" + while IFS= read -r line; do + [[ -z "$line" ]] && continue + # Formato: + local w_id w_pid + w_id="$(awk '{print $1}' <<<"$line")" + w_pid="$(awk '{print $3}' <<<"$line")" + if [[ "$w_pid" == "$browser_pid" ]]; then + wid="$w_id" + break + fi + done < <(wmctrl -lp 2>/dev/null) + + if [[ -z "$wid" ]]; then + wid="$(xdotool search --pid "$browser_pid" --onlyvisible 2>/dev/null | head -n1)" + fi + + if [[ -z "$wid" ]]; then + echo "focus_cdp_tab_window: no se encontro ventana top-level para pid ${browser_pid} (puerto ${port})" >&2 + return 6 + fi + + # 5. Traer al frente con REINTENTO (xfwm de XFCE pisa el primer activate/raise). + # Espera no bloqueante con read -t en vez de sleep. + local attempt + for attempt in 1 2; do + xdotool windowactivate "$wid" >/dev/null 2>&1 || true + read -r -t 0.2 _ < /dev/zero 2>/dev/null || true + xdotool windowraise "$wid" >/dev/null 2>&1 || true + done + + # 6. Salida legible y JSON-parseable simple. + echo "focus_cdp_tab_window: focused win=${wid} pid=${browser_pid} port=${port} tab=${target_id:--}" + return 0 +} + +# Permitir ejecucion directa: focus_cdp_tab_window [target_id] +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + focus_cdp_tab_window "$@" +fi diff --git a/bash/functions/infra/launch_fleetclaude.md b/bash/functions/infra/launch_fleetclaude.md index cb5e931b..6bc5ccb2 100644 --- a/bash/functions/infra/launch_fleetclaude.md +++ b/bash/functions/infra/launch_fleetclaude.md @@ -3,10 +3,10 @@ name: launch_fleetclaude kind: function lang: bash domain: infra -version: "1.3.2" +version: "1.4.0" purity: impure -signature: "launch_fleetclaude [--cwd ] [--bin ] [--session ] [--cols ]" -description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado -L fleet) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks." +signature: "launch_fleetclaude [--cwd ] [--bin ] [--session ] [--reuse] [--cols ]" +description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks." tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher] params: - name: --cwd @@ -14,7 +14,9 @@ params: - name: --bin desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: /apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva." - name: --session - desc: "Nombre de la sesion tmux a crear o reutilizar. Opcional. Default: fleet. La funcion es idempotente sobre este nombre." + desc: "Fija el perfil (socket+sesion tmux comparten nombre) por nombre exacto; reutiliza el existente si ya vive (idempotente sobre ese nombre). Opcional. Sin esta opcion, el perfil se elige automaticamente (primer nombre libre de la secuencia fleet, fleet2, ...)." + - name: --reuse + desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)." - name: --cols desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40." output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito." @@ -36,17 +38,22 @@ file_path: "bash/functions/infra/launch_fleetclaude.sh" # Via fn run (resuelve por nombre o ID): fn run launch_fleetclaude -# Directo, con cwd explicito: -launch_fleetclaude --cwd ~/fn_registry +# Perfil nuevo automatico (fleet la 1a vez; fleet2, fleet3, ... si ya hay uno): +launch_fleetclaude -# Sesion y ancho de pane personalizados: -launch_fleetclaude --session fleet --cols 50 +# Reattach a la flota principal 'fleet' (comportamiento idempotente clasico): +launch_fleetclaude --reuse + +# Perfil con nombre fijo y ancho de pane personalizado: +launch_fleetclaude --session trabajo --cols 50 ``` -Tras invocarlo aparece una ventana kitty titulada `FleetView` con dos panes -lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de -`claude --dangerously-skip-permissions`. Volver a invocarlo NO duplica la -sesion: reusa la existente y solo abre otra kitty adjunta. +Tras invocarlo aparece una ventana kitty titulada `FleetView ()` con dos +panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de +`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux +aislados con su propia flota: puedes tener varias FleetView abiertas a la vez. +Por defecto, volver a invocarlo abre un perfil NUEVO (no reusa); usa `--reuse` +o `--session ` para volver a una flota concreta. ## Cuando usarla @@ -57,9 +64,23 @@ al retomar el trabajo en el repo `fn_registry`. ## Gotchas -- **Idempotencia tmux**: si la sesion `` (default `fleet`) ya existe, - NO se recrea el layout; solo se abre una kitty nueva adjunta a la misma - sesion. Para empezar de cero: `tmux kill-session -t fleet` antes de invocar. +- **Perfiles multiples (default = perfil nuevo)**: sin `--session` ni `--reuse`, + cada invocacion abre un perfil NUEVO usando el primer nombre libre de la + secuencia `fleet`, `fleet2`, `fleet3`, ... (socket+sesion tmux comparten el + nombre del perfil). Asi puedes tener varias FleetView abiertas a la vez, cada + una con su flota independiente. Un perfil cerrado libera su nombre: tras matar + `fleet`, el siguiente lanzamiento vuelve a `fleet`. Para reattach a una flota + concreta: `--reuse` (principal `fleet`) o `--session ` (idempotente + sobre ese nombre, reusa el layout si ya vive). +- **Perfil ↔ TUI por entorno**: el launcher inyecta `FLEET_SOCKET`/`FLEET_SESSION` + al pane de la TUI (y los fija en el server con `set-environment -g`, para que + `respawn-pane` de alt+R y los Claude nuevos hereden el socket). `main.go` los + lee con fallback a `fleet`. Por eso cada panel ve SOLO los Claude de su perfil + (cruza la lista del sistema con los panes de su socket). +- **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de + una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de + nesting); cae a la ruta kitty y abre una ventana nueva. Fuera de tmux y con + TTY, reutiliza la terminal actual con `exec tmux attach`. - **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre. - **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi @@ -70,10 +91,11 @@ al retomar el trabajo en el repo `fn_registry`. `/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en silencio. Compila la TUI antes para el flujo completo. -- **Socket tmux aislado (`-L fleet`)**: toda la sesion vive en un server tmux - propio, separado del tmux por defecto del usuario. Asi los atajos `bind -n` - NO afectan otras sesiones (ej. una sesion `mobile-1` del movil) y matar el - server fleet no toca nada mas: `tmux -L fleet kill-server`. +- **Socket tmux aislado por perfil (`-L `)**: cada perfil vive en su + propio server tmux (socket = nombre del perfil), separado del tmux por defecto + del usuario y de los demas perfiles. Asi los atajos `bind -n` NO afectan otras + sesiones (ej. una sesion `mobile-1` del movil) y matar un perfil no toca los + otros: `tmux -L kill-server` (o `alt+q` dentro de la TUI). - **Atajos en el socket, NO en kitty.conf**: instala `bind -n` para `alt+flechas` (mover el cursor de la TUI), `alt+enter` (conmutar al Claude seleccionado) y `alt+n` (abrir Claude nuevo). Son bindings de tmux que @@ -91,6 +113,15 @@ al retomar el trabajo en el repo `fn_registry`. ## Capability growth log +- v1.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el + fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/ + `--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...), + asi abrir FleetView con uno ya abierto arranca otra flota en vez de reusarla. + Nuevo flag `--reuse` para el reattach idempotente clasico. El launcher inyecta + `FLEET_SOCKET`/`FLEET_SESSION` (env + `set-environment -g`) y `main.go` de + `fleetview` los lee (fallback `fleet`), de modo que cada panel ve solo su flota. + Titulo de kitty `FleetView ()`. Guard anti-nesting: invocado dentro de + tmux abre ventana kitty nueva en vez de `attach` anidado. - v1.3.2 (2026-06-17) — targeting de panes por **pane ID** (`%0`/`%1`) en vez de por indice (`console.0`). Antes fallaba con `can't find pane: 0` en hosts cuyo `~/.tmux.conf` define `base-index 1`/`pane-base-index 1` (el socket `-L fleet` diff --git a/bash/functions/infra/launch_fleetclaude.sh b/bash/functions/infra/launch_fleetclaude.sh index 6d5a96f1..9d44001d 100644 --- a/bash/functions/infra/launch_fleetclaude.sh +++ b/bash/functions/infra/launch_fleetclaude.sh @@ -21,7 +21,9 @@ launch_fleetclaude() { local bin="" local session="fleet" local cols=52 - local T="tmux -L fleet" # socket tmux aislado: no toca el tmux normal del usuario + local explicit_session=0 # 1 si el usuario pasó --session a mano + local reuse=0 # 1 si el usuario pidió --reuse (reattach al perfil principal) + local T="" # socket tmux aislado; se fija al resolver el perfil # ----------------------------------------------------------------------- # Parseo de argumentos @@ -39,6 +41,10 @@ launch_fleetclaude() { --session) shift session="${1:-}" + explicit_session=1 + ;; + --reuse) + reuse=1 ;; --cols) shift @@ -51,19 +57,28 @@ Uso: launch_fleetclaude [opciones] Abre una ventana kitty con una sesion tmux de dos panes: la TUI fleetview a la izquierda y 'claude --dangerously-skip-permissions' a la derecha. +Cada PERFIL de FleetView es un socket+sesion tmux aislados (su propia flota de +Claudes). Sin --session ni --reuse, cada invocacion abre un perfil NUEVO: usa +el primer nombre libre de la secuencia fleet, fleet2, fleet3, ... Asi puedes +tener varias FleetView abiertas a la vez, cada una con su flota independiente. + Opciones: --cwd Directorio de trabajo de los panes. Default: raiz del repo fn_registry (derivada dinamicamente). --bin Ruta al binario de la TUI fleetview. Default: /apps/fleetview/fleetview - --session Nombre de la sesion tmux. Default: fleet. + --session Fija el perfil (socket+sesion) por nombre exacto; reutiliza + el existente si ya esta vivo. Sin esta opcion, perfil auto. + --reuse Reattach al perfil principal 'fleet' en vez de abrir uno + nuevo (vuelve al comportamiento idempotente clasico). --cols Ancho (columnas) del pane izquierdo. Default: 40. -h, --help Muestra esta ayuda. Ejemplos: - launch_fleetclaude - launch_fleetclaude --cwd ~/fn_registry - launch_fleetclaude --session fleet --cols 50 + launch_fleetclaude # perfil nuevo (fleet, luego fleet2, ...) + launch_fleetclaude --reuse # reattach a la flota principal 'fleet' + launch_fleetclaude --session trabajo # perfil con nombre fijo 'trabajo' + launch_fleetclaude --cwd ~/fn_registry --cols 50 USAGE return 0 ;; @@ -111,6 +126,34 @@ USAGE echo "launch_fleetclaude: tmux no esta instalado." >&2 return 1 fi + + # ----------------------------------------------------------------------- + # Resolver el PERFIL (socket+sesion tmux comparten nombre). + # + # - --session -> usa ese nombre exacto (reutiliza si ya vive). + # - --reuse -> usa 'fleet' (el perfil principal), idempotente. + # - sin nada -> perfil NUEVO: primer nombre libre de la secuencia + # fleet, fleet2, fleet3, ... Asi abrir FleetView con + # uno ya abierto arranca otra flota, no la reusa. + # + # "Libre" = no hay un server tmux con esa sesion (has-session falla). Un + # perfil cerrado libera su nombre, asi que tras cerrar 'fleet' el siguiente + # lanzamiento vuelve a 'fleet'. + # ----------------------------------------------------------------------- + if [[ "$explicit_session" -eq 0 && "$reuse" -eq 0 ]]; then + local base="$session" n=1 cand + while :; do + if [[ "$n" -eq 1 ]]; then cand="$base"; else cand="${base}${n}"; fi + if ! tmux -L "$cand" has-session -t "$cand" 2>/dev/null; then + session="$cand" + break + fi + n=$((n + 1)) + done + echo "launch_fleetclaude: perfil nuevo '$session'." + fi + # A partir de aqui el socket aislado es el del perfil resuelto. + T="tmux -L $session" # Nota: kitty NO se exige aqui. La ruta interactiva (TTY) reutiliza la # terminal actual con `exec tmux attach` y no necesita kitty. Solo la # ruta sin-TTY (abrir ventana nueva con setsid kitty) lo requiere, y ahi @@ -121,9 +164,13 @@ USAGE # - Si el binario fleetview existe -> ejecutarlo (exec, sin shell colgado). # - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio). # ----------------------------------------------------------------------- + # La TUI necesita saber a qué perfil pertenece: se lo pasamos por entorno + # (FLEET_SOCKET/FLEET_SESSION), que main.go lee con fallback a "fleet". + local envpfx + envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")" local left_cmd if [[ -x "$bin" ]]; then - left_cmd="exec $(printf '%q' "$bin")" + left_cmd="$envpfx exec $(printf '%q' "$bin")" else # Fallback claro: instruye como compilar la TUI y deja una shell viva. left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\"" @@ -181,8 +228,14 @@ USAGE $T bind -n M-r send-keys -t "$left_pane" r $T bind -n M-u send-keys -t "$left_pane" u $T bind -n M-h send-keys -t "$left_pane" h + $T bind -n M-R send-keys -t "$left_pane" R $T bind -n M-Left send-keys -t "$left_pane" Escape $T bind -n M-q send-keys -t "$left_pane" Q + # Entorno del perfil en el server tmux: respawn-pane (alt+R, recompila la TUI) + # y los Claude nuevos heredan FLEET_SOCKET/FLEET_SESSION para apuntar al + # socket correcto aunque no sea el default "fleet". + $T set-environment -g FLEET_SOCKET "$session" + $T set-environment -g FLEET_SESSION "$session" # Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta. $T set -g mouse on # Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de @@ -207,24 +260,25 @@ USAGE # (Mismo patron que reboot_all_claudes para relanzar terminales.) # ----------------------------------------------------------------------- # Adjuntar la sesion: - # - Si se invoca desde una terminal interactiva, convertir ESA terminal en - # el panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la + # - Terminal interactiva y FUERA de tmux: convertir ESA terminal en el + # panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la # shell). Asi `fleetclaude` no abre otra ventana: usa la actual. - # - Si NO hay TTY (atajo de escritorio, cron, script), abrir una ventana - # kitty nueva desacoplada (setsid) como antes. - if [ -t 0 ] && [ -t 1 ]; then - exec tmux -L fleet attach -t "$session" + # - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir + # una ventana kitty nueva desacoplada (setsid). No hacemos `attach` + # anidado dentro de otra sesion tmux (rompe / da el warning de nesting). + if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then + exec tmux -L "$session" attach -t "$session" fi - # Ruta sin-TTY: necesitamos kitty para abrir la ventana nueva. + # Ruta ventana-nueva: necesitamos kitty para abrirla. if ! command -v kitty >/dev/null 2>&1; then - echo "launch_fleetclaude: kitty no esta instalado (necesario solo sin TTY)." >&2 - echo "launch_fleetclaude: lanzalo desde una terminal interactiva, o instala kitty." >&2 + echo "launch_fleetclaude: kitty no esta instalado (necesario para abrir ventana nueva)." >&2 + echo "launch_fleetclaude: lanzalo desde una terminal interactiva fuera de tmux, o instala kitty." >&2 return 1 fi - setsid kitty --title "FleetView" -e tmux -L fleet attach -t "$session" /dev/null 2>&1 & + setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" /dev/null 2>&1 & disown 2>/dev/null || true - echo "launch_fleetclaude: ventana kitty 'FleetView' adjunta a la sesion tmux '$session'." + echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$session'." return 0 } diff --git a/bash/functions/infra/open_doc_onlyoffice.md b/bash/functions/infra/open_doc_onlyoffice.md new file mode 100644 index 00000000..1f50a227 --- /dev/null +++ b/bash/functions/infra/open_doc_onlyoffice.md @@ -0,0 +1,55 @@ +--- +name: open_doc_onlyoffice +kind: function +lang: bash +domain: infra +version: 1.0.0 +purity: impure +signature: "open_doc_onlyoffice [--restart]" +description: "Abre un documento ofimático (xlsx, docx, pptx, csv, ods, odt, ...) con OnlyOffice Desktop Editors, desacoplado del shell (setsid + background). Localiza el binario por PATH sin hardcodear rutas. Flag --restart cierra toda la app OnlyOffice y la relanza para forzar la recarga desde disco de un archivo regenerado (OnlyOffice cachea en memoria la versión vieja de los documentos abiertos)." +tags: + - onlyoffice + - desktop + - office + - open +uses_functions: [] +uses_types: [] +returns: [] +error_type: error_go_core +params: + - name: ruta_archivo + desc: "Ruta (relativa o absoluta) del documento ofimático a abrir. Debe existir." + - name: --restart + desc: "Opcional. Si se pasa, cierra TODA la instancia de OnlyOffice (pkill -x DesktopEditors) antes de relanzar, forzando la recarga desde disco. Cierra cualquier otro documento abierto: usar solo si ninguno tiene cambios sin guardar." +output: "Imprime la ruta absoluta abierta. Exit 0 si lanza OnlyOffice; exit 1 si el archivo no existe o el binario no está en PATH; exit 2 en error de uso." +file_path: bash/functions/infra/open_doc_onlyoffice.sh +--- + +## Ejemplo + +```bash +# Abrir un documento (lo enfoca si OnlyOffice ya está corriendo) +fn run open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx + +# Tras regenerar el archivo en disco, forzar que OnlyOffice lo recargue +fn run open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx --restart +``` + +## Cuando usarla + +Cuando necesites abrir o mostrar al usuario un documento ofimático (`.xlsx`, `.docx`, `.pptx`, `.csv`, `.ods`, `.odt`) en su escritorio. Es la forma canónica de abrir documentos en este equipo: el usuario usa OnlyOffice, nunca LibreOffice. Usa `--restart` cuando acabas de regenerar un archivo que probablemente ya está abierto y OnlyOffice muestra la versión cacheada en memoria. + +## Gotchas + +- OnlyOffice es **instancia única**: lanzarlo con un archivo ya abierto reenfoca la pestaña existente con la versión cacheada en memoria, NO recarga desde disco. Por eso existe `--restart`. +- `--restart` cierra **toda** la app (`pkill -x DesktopEditors`), no solo la pestaña del archivo. Cualquier otro documento abierto se cierra. No usar si hay documentos con cambios sin guardar. +- No hay forma por CLI de cerrar/recargar una sola pestaña: o se acepta la versión cacheada, o se reinicia la app entera. +- Usa `setsid` + `&` para que el editor sobreviva al proceso que lo invoca (no muere al cerrar la terminal/sesión). +- Localiza el binario con `command -v onlyoffice-desktopeditors`; el proceso real subyacente es `/opt/onlyoffice/desktopeditors/DesktopEditors`. + +## example + +```bash +open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx +open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx --restart # fuerza recarga desde disco +``` diff --git a/bash/functions/infra/open_doc_onlyoffice.sh b/bash/functions/infra/open_doc_onlyoffice.sh new file mode 100644 index 00000000..c7ab8dba --- /dev/null +++ b/bash/functions/infra/open_doc_onlyoffice.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# open_doc_onlyoffice — abre un documento ofimático con OnlyOffice Desktop Editors. +# +# Uso: +# open_doc_onlyoffice [--restart] +# +# Lanza el editor desacoplado del shell (setsid + background) para que sobreviva +# al proceso que lo invoca. Localiza el binario por PATH, sin hardcodear rutas. +# +# --restart cierra toda la instancia de OnlyOffice antes de relanzar, para forzar +# la recarga desde disco de un archivo que se regeneró (OnlyOffice mantiene en +# memoria la versión vieja de los documentos ya abiertos). +set -euo pipefail + +usage() { + echo "uso: open_doc_onlyoffice [--restart]" >&2 + exit 2 +} + +[ $# -ge 1 ] || usage + +doc="" +restart=0 +for arg in "$@"; do + case "$arg" in + --restart) restart=1 ;; + -h|--help) usage ;; + *) doc="$arg" ;; + esac +done + +[ -n "$doc" ] || usage + +if [ ! -f "$doc" ]; then + echo "error: archivo no encontrado: $doc" >&2 + exit 1 +fi + +bin="$(command -v onlyoffice-desktopeditors || true)" +if [ -z "$bin" ]; then + echo "error: onlyoffice-desktopeditors no esta en PATH" >&2 + exit 1 +fi + +# Ruta absoluta para que OnlyOffice no dependa del directorio de trabajo. +doc_abs="$(readlink -f "$doc")" + +if [ "$restart" -eq 1 ]; then + # Cierra la app entera para descartar la copia en memoria de los documentos. + # pkill -x sobre el comm exacto del proceso real (no -f, para no auto-matar + # el propio script si su ruta contiene el patrón). + pkill -x DesktopEditors 2>/dev/null || true + # Espera (máx ~5s) a que el proceso principal termine antes de relanzar. + for _ in $(seq 1 25); do + pgrep -x DesktopEditors >/dev/null 2>&1 || break + sleep 0.2 + done +fi + +setsid "$bin" "$doc_abs" >/dev/null 2>&1 & +echo "abierto en OnlyOffice: $doc_abs" diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 0f5f45d4..f6715139 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -59,8 +59,13 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados | | [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks | | [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments | +| [browser-profiles](browser-profiles.md) | 4 | Catalogo de perfiles del navegador Chromium para investigaciones multicuenta OSINT: por perfil guarda que correo/cuentas usar (secret_ref a pass, nunca el password), proposito, persona y nota del vault, y lanza el perfil listo via systemd-run. Fuente de verdad en el service osint_db (tablas browser_profiles + browser_profile_accounts) | | [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP | +| [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` | | [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) | +| [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza | +| [eda](eda.md) | 8 | Exploratory Data Analysis por tabla con motor DuckDB push-down: perfil base SQL (SUMMARIZE), estadística numérica/categórica sobre muestra, tipo semántico por regex, score de calidad, render markdown con sparklines y el orquestador one-shot `profile_table` (promueve VARCHAR→numeric/datetime, emite TableProfile + report md/json). Fases siguientes: correlaciones, relaciones inter-tabla, modelos baratos, LLM, notebook | +| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay | ## Como anadir grupo diff --git a/docs/capabilities/browser-profiles.md b/docs/capabilities/browser-profiles.md new file mode 100644 index 00000000..d570a790 --- /dev/null +++ b/docs/capabilities/browser-profiles.md @@ -0,0 +1,102 @@ +# Capability: browser-profiles + +Catálogo operativo de los perfiles del navegador Chromium para investigaciones +multicuenta OSINT. Por cada perfil de Chromium (un `--profile-directory` dentro +de un user-data-dir) guarda **qué correo/cuentas usar, propósito, persona e +identidad de la investigación** y la nota del vault que lo documenta, y permite +**lanzar el perfil** listo para trabajar mostrando sus cuentas. La fuente de +verdad vive en el service `osint_db` (FastAPI + DuckDB, `http://127.0.0.1:8771`), +en las tablas `browser_profiles` + `browser_profile_accounts` (schema main, +pobladas solo por API, como `network_scans`). Estas funciones son clientes HTTP +finos a ese service. + +**Regla de seguridad dura:** una cuenta guarda `secret_ref` — una **referencia** +al secreto (ej. `pass show osint/p1/gmail`), NUNCA la contraseña en claro. Ni el +service ni estas funciones almacenan o resuelven credenciales: `browser_profile_open` +solo expone el `secret_ref` para que el operador (o otra herramienta) lo resuelva +con `pass`/keepass. + +Comparte el ecosistema del project `osint` (vault Obsidian + service `osint_db`) +con los grupos `recon`, `osint-passive` y `dav`. El perfil real de Chromium vive +en `~/.config/chromium-cdp` (user-data-dir con CDP 9222 inyectado por el wrapper +`/usr/bin/chromium`); el catálogo NO toca el perfil en disco, solo su metadata. + +## Funciones + +| ID | Firma | Qué hace | +|---|---|---| +| `browser_profile_register_py_browser` | `browser_profile_register(profile_dir, label="", persona="", purpose="", note_path="", tags=None, notes="", user_data_dir="", status="active", accounts=None, base_url=...) -> dict` | Registra/actualiza un perfil y, opcionalmente, sus cuentas en una sola llamada (1 POST del perfil + 1 POST por cuenta). Idempotente (upsert por `profile_dir` y por `id` de cuenta). `accounts` es una lista de dicts `{service, identity, secret_ref?, role?, status?, notes?}`. | +| `browser_profile_list_py_browser` | `browser_profile_list(status=None, base_url=...) -> dict` | Lista los perfiles del catálogo con su nº de cuentas (`n_accounts`). Filtro opcional por `status` (active/archived). Devuelve `{"status":"ok","profiles":[...]}`. | +| `browser_profile_show_py_browser` | `browser_profile_show(profile_dir, base_url=...) -> dict` | Muestra un perfil con todas sus cuentas. Devuelve `{"status":"ok","profile":{...},"accounts":[...]}` o error si no existe. Las cuentas traen `secret_ref` (referencia), nunca el password. | +| `browser_profile_open_py_browser` | `browser_profile_open(profile_dir, url=None, base_url=..., dry_run=False) -> dict` | Lanza Chromium en el perfil (`--profile-directory`) vía `systemd-run --user --scope` (evita exit-144) y devuelve sus cuentas/`secret_ref` para saber qué usar. `dry_run=True` devuelve el comando sin abrir nada. Compone `browser_profile_show` para leer la metadata. | + +## Ejemplo canónico (end-to-end) + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.browser_profile_register import browser_profile_register +from browser.browser_profile_list import browser_profile_list +from browser.browser_profile_show import browser_profile_show +from browser.browser_profile_open import browser_profile_open + +# 1. Registrar un perfil con sus cuentas (secret_ref = referencia a pass, NO el password) +browser_profile_register( + "osint_01", + label="osint_01", + persona="sock-puppet Marta R.", + purpose="infiltración foros nicho X", + tags=["osint", "sockpuppet"], + accounts=[ + {"service": "gmail", "identity": "marta.r.osint@gmail.com", "secret_ref": "pass show osint/osint_01/gmail", "role": "primary"}, + {"service": "twitter", "identity": "@marta_r_osint", "secret_ref": "pass show osint/osint_01/x", "role": "burner"}, + ], +) + +# 2. Listar el catálogo +browser_profile_list() # {"status":"ok","profiles":[{profile_dir, label, n_accounts, ...}]} + +# 3. Ver un perfil con sus cuentas +browser_profile_show("osint_01") # {"profile": {...}, "accounts": [{service, identity, secret_ref, role}]} + +# 4. Abrir el perfil listo para trabajar (lanza Chromium + dice qué cuentas usar) +browser_profile_open("osint_01", url="https://twitter.com") +# -> systemd-run --user --scope -- chromium --profile-directory=osint_01 https://twitter.com +# -> accounts: [(gmail, pass show osint/osint_01/gmail), (twitter, pass show osint/osint_01/x)] +``` + +Vía `fn run` (un id conocido a la vez): + +```bash +./fn run browser_profile_list +./fn run browser_profile_show osint_01 +./fn run browser_profile_open osint_01 https://twitter.com +``` + +## Fronteras (qué NO cubre) + +- **No gestiona el perfil de Chromium en disco** (crear/clonar/extensiones/avatar): + eso es `create_chrome_profile_bash_browser`, `list_chrome_profiles_go_browser`, + `set_chrome_profile_appearance_bash_browser`. Este grupo solo guarda metadata + operativa y lanza un perfil existente. +- **No almacena ni resuelve contraseñas.** Solo referencias (`secret_ref`). El + password se resuelve aparte con `pass`/keepass. +- **No automatiza el login** ni rellena formularios: para eso usa el `browser_mcp` + o el grupo `flow-replay` una vez el perfil está abierto. +- **Requiere el service `osint_db` vivo** en `:8771`. Si está caído, las funciones + devuelven `{"status":"error", ...}` sin lanzar. + +## Gotchas + +- El `profile_dir` es el nombre del directorio REAL del perfil de Chromium (lo + que va en `--profile-directory`): `"Default"`, `"Profile 1"`, `"osint_01"`. NO + es el nombre legible (ese es `label`). Verlos con + `list_chrome_profiles_go_browser` o el `Local State` del user-data-dir. +- `browser_profile_open` por defecto NO pasa `--user-data-dir` (el perfil vive en + `~/.config/chromium-cdp`, que el wrapper `/usr/bin/chromium` ya inyecta). Si el + perfil está en otro user-data-dir, regístralo con `user_data_dir=` y la + función lo pasará explícito. +- Se lanza vía `systemd-run --user --scope` a propósito: lanzar Chromium directo + desde un proceso hijo da exit-144 en este entorno. +- `secret_ref` NUNCA es el password. Si te ves tentado a meter la contraseña ahí, + para: guárdala en `pass`/keepass y referencia el comando. diff --git a/docs/capabilities/consent.md b/docs/capabilities/consent.md new file mode 100644 index 00000000..39578bf7 --- /dev/null +++ b/docs/capabilities/consent.md @@ -0,0 +1,57 @@ +# consent — CMP / IAB TCF / data brokers + +Operar banners de consentimiento (Consent Management Platforms) y el ecosistema IAB TCF: +detectar qué CMP usa un sitio, leer cuántos *vendors* (data brokers) declara, aceptar el +banner cuando hace falta y cruzar los IDs de vendor contra la Global Vendor List de IAB para +nominar a cada broker y describir qué datos personales recopila. + +Nació de la investigación `projects/databrokers/` (data brokers de la prensa española). + +## Funciones del grupo + +| ID | Firma corta | Qué hace | +|---|---|---| +| `extract_cmp_tcf_py_browser` | `extract_cmp_tcf(url, *, port=9222, accept_first=False, llm_fallback=False, ...) -> dict` | Navega a `url` por CDP, detecta el CMP (Didomi/OneTrust/Sourcepoint/Quantcast/otro_tcf), lee `window.__tcfapi` y devuelve nº de vendors, propósitos, muro "pago o consientes" y `vendor_ids`. Con `accept_first` acepta el banner antes de leer; con `llm_fallback` recurre a `find_consent_controls_llm` si el clic por selector falla. | +| `find_consent_controls_llm_py_browser` | `find_consent_controls_llm(*, port=9222, max_candidates=40, model="claude-haiku-4-5-20251001") -> dict` | Recolecta los controles clicables del banner (los marca con `data-fnllm="N"`) y pregunta a un LLM (haiku) cuál es Aceptar / Rechazar / Ver socios. Devuelve los selectores. Resuelve CMP con clases dinámicas/texto no estándar sin selectores hardcodeados. | +| `fetch_iab_gvl_py_cybersecurity` | `fetch_iab_gvl(out_path="", url="", lang="") -> dict` | Descarga y parsea la Global Vendor List de IAB (catálogo maestro de vendors: nombre, propósitos, `dataDeclaration`, retención, política). Endpoint v3 con fallback v2. | + +## Ejemplo canónico (end-to-end) + +Escanear un medio, contar sus brokers y nombrarlos cruzando con la GVL: + +```python +import sys; sys.path.insert(0, "python/functions") +from browser.extract_cmp_tcf import extract_cmp_tcf +from cybersecurity.fetch_iab_gvl import fetch_iab_gvl + +# 1. Catálogo maestro de vendors (una vez). +gvl = fetch_iab_gvl(out_path="/tmp/gvl.json") # {status, vendors:{id:{name,purposes,...}}, ...} + +# 2. Escanear un sitio (Chrome con CDP en el puerto indicado; perfil limpio para que salga el banner). +# accept_first acepta el banner; llm_fallback usa haiku si el botón no encaja con selectores fijos. +scan = extract_cmp_tcf("https://www.lavanguardia.com", port=9335, + accept_first=True, llm_fallback=True) +# scan -> {status, cmp:'didomi', n_vendors:1092, vendor_ids:[...], paywall_consent:True, ...} + +# 3. Nominar los brokers de ese medio. +nombres = [gvl["vendors"].get(str(v), {}).get("name", f"(vendor {v})") for v in scan["vendor_ids"]] +``` + +Orquestador completo sobre un censo de dominios: `projects/databrokers/scanner/scan_all.py` +(itera → `extract_cmp_tcf` → persiste → cruza con la GVL → Excel). + +## Prerrequisitos + +- Un Chrome/Chromium con remote debugging (CDP) en el puerto usado. Lánzalo aislado del navegador + diario (no 9222) con su propio `user_data_dir`. **Perfil limpio**: una vez aceptado el banner, + la cookie de consent persiste en el perfil y los re-escaneos ya no muestran banner. +- `ask_llm` (grupo `claude-direct`) requiere el token OAuth de Claude Max en `~/.claude/.credentials.json`. + +## Fronteras (lo que el grupo NO cubre) + +- No extrae la lista de vendors de CMP cuyo `getTCData` no rellena `vendor.consents`/`legitimateInterests` + por la vía estándar, ni de banners alojados en iframe (Sourcepoint): el clic desde el documento + principal no alcanza el iframe. +- No interpreta el `tcString` (qué propósitos consintió el usuario en concreto); solo el universo de + vendors declarado. Para decodificar el TCString haría falta una pieza aparte. +- No es un bloqueador ni un gestor de consentimiento propio: solo observa y mide. diff --git a/docs/capabilities/eda.md b/docs/capabilities/eda.md new file mode 100644 index 00000000..1a8a33e9 --- /dev/null +++ b/docs/capabilities/eda.md @@ -0,0 +1,80 @@ +# eda — Exploratory Data Analysis por tabla + +Grupo de capacidad para perfilar tablas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, outliers). + +El orquestador one-shot es `profile_table_py_pipelines`: "hazme un EDA de esta tabla" → un `TableProfile` completo + report markdown + JSON sidecar en `reports/`. + +> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`. + +## Funciones + +| ID | Pureza | Qué hace | +|---|---|---| +| `summarize_table_duckdb_py_datascience` | impure | Corazón: `SUMMARIZE` push-down → esqueleto del `TableProfile` con perfil base por columna (tipo inferido, nulls, distinct exacto ≤200k filas, flags). Reusa `duckdb_query_readonly`. | +| `describe_numeric_py_datascience` | pure | Bloque `numeric` sobre una muestra: min/max/mean/median/mode/std/cv, percentiles p1-p99, IQR, skew, kurtosis, outliers, %zeros/%neg, tipo de distribución, histograma. | +| `summarize_categorical_py_datascience` | pure | Bloque `categorical`: top-k frecuencias, mode, distinct, entropía de Shannon (bits), imbalance, longitudes. | +| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/integer/decimal/...) sin LLM. Primera pasada barata. | +| `column_quality_score_py_datascience` | pure | Score de calidad 0-100 (completeness/validity/consistency) + issues legibles para un `ColumnProfile`. | +| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown autosuficiente (Overview, Columnas, Numéricas con sparkline ASCII, Categóricas, Calidad). | +| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75) de una lista de floats. | +| `profile_table_py_pipelines` | pipeline | Orquestador end-to-end: compone todo lo anterior, promueve tipos VARCHAR→numeric/datetime por contenido, y emite `TableProfile` + report markdown + JSON. | + +## Contrato de datos + +Todas las funciones producen/consumen el mismo shape (dict JSON), lo que desacopla cálculo, render y (futuro) LLM: + +``` +TableProfile = { + table, source, profiled_at, n_rows, n_cols, size_bytes, + duplicate_rows, duplicate_pct, constant_cols:[str], all_null_cols:[str], + null_cell_pct, type_breakdown:{numeric,categorical,datetime,text,boolean}, + columns:[ColumnProfile], correlations, key_candidates:[str], + quality_score, llm, models +} + +ColumnProfile = { + name, physical_type, inferred_type, # numeric|categorical|datetime|boolean|text|id + semantic_type, count, n_rows, null_count, null_pct, empty_count, empty_pct, + distinct_count, unique_pct, # *_pct son FRACCIONES 0-1; el render las muestra ×100 + flags:[constant|possible_id|high_cardinality|mostly_null], + quality_score, + numeric: {min,max,mean,median,mode,std,variance,cv,p1,p5,p25,p50,p75,p95,p99,iqr, + skew,kurtosis,n_outliers,outlier_pct,zero_pct,negative_pct,distribution_type, + histogram:[{lo,hi,count}]} | None, + categorical: {top:[{value,count,pct}],mode,mode_pct,n_distinct,entropy,imbalance, + len_mean,len_min,len_max} | None, + datetime: {min,max,range_days,granularity,n_gaps,future_pct,monotonic} | None +} +``` + +## Ejemplo canónico + +EDA de una tabla DuckDB en una línea (escribe `reports/eda__.md` + `.json`): + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from pipelines.profile_table import profile_table + +r = profile_table(os.path.expanduser("~/.fn_freelance/freelance.duckdb"), "freelance_projects") +print(r["status"], r["report_md_path"]) +prof = r["profile"] +print(prof["type_breakdown"], "key_candidates:", prof["key_candidates"], "calidad:", prof["quality_score"]) +``` + +La promoción de tipo por contenido resuelve el caso típico de scrapers/CSV donde los números y fechas llegan como `VARCHAR`: `bids` ('10','20') se detecta `integer` y se perfila como numérica (mean/median/percentiles); `scraped_at` se detecta `datetime_iso`. + +## Fronteras + +- **NO carga la tabla entera a RAM**: solo metadata SQL + una muestra (`sample`, default 5000) por columna. Para distribución exacta de una columna enorme, sube `sample` o consulta SQL directa. +- **Distinct exacto solo hasta 200k filas**; por encima usa aproximado (HyperLogLog) capado a nº de filas. +- **Solo DuckDB** por ahora (CSV/Parquet/Excel entran gratis vía `read_csv_auto`/`read_parquet`/`read_xlsx` cargándolos antes a DuckDB). PostgreSQL y BigQuery requieren adaptador (pendiente). +- **No es estadística inferencial ni modelado**: es perfilado descriptivo. Correlaciones, modelos baratos (PCA/KMeans/IsolationForest) y capa LLM son fases siguientes del grupo. + +## Roadmap (fases siguientes) + +- **Correlación / asociación**: Spearman, Cramér's V, Theil's U, correlation ratio η², Mutual Information, VIF → `correlations` del `TableProfile`. +- **Relaciones inter-tabla**: FK inference por containment, cardinalidad de relación, join graph (mermaid), star-schema hints → `profile_database`. +- **Modelos baratos** (flag `--models`, sklearn/scipy): PCA 2D, KMeans + silhouette, Isolation Forest, feature importance, tests de normalidad, tendencia temporal. +- **Capa LLM** (flag `--llm`, grupo `claude-direct`): data dictionary, resumen ejecutivo (qué es 1 fila + granularidad), flag PII/RGPD, limpieza sugerida, análisis sugeridos. +- **Entrega notebook**: analysis Jupyter auto-generado y ejecutado en el navegador colaborativo. diff --git a/docs/capabilities/email.md b/docs/capabilities/email.md new file mode 100644 index 00000000..09d6402e --- /dev/null +++ b/docs/capabilities/email.md @@ -0,0 +1,137 @@ +# Email — Gestionar cuentas de correo por IMAP + SMTP (tecnología propia) + +Tag: `email`. Grupo de funciones Python (solo stdlib: `imaplib`, `smtplib`, `email`) para +**leer, hacer CRUD y enviar correo hablando los protocolos directamente** — sin browser CDP +y sin el MCP Gmail de claude.ai. Es la base de un sistema multi-proveedor de gestión de +cuentas: una conexión IMAP por buzón + SMTP para envío, con las credenciales resueltas desde +`pass`/vault por la capa de aplicación. + +Filtro MCP: `mcp__registry__fn_search query="" tag="email"`. + +## Cuándo usar este grupo (y cuándo NO) + +| Caso | Vía | +|---|---| +| Leer/buscar/clasificar/mover/borrar/enviar correo de forma programática y fiable, multi-cuenta | **Este grupo** (IMAP+SMTP directo). | +| Leer correo *interactivo* del usuario en su sesión (códigos de verificación al instante en su Gmail logueado) | Browser MCP sobre Gmail web (perfil 9222). Ver memoria `correos-por-browser-no-mcp-gmail`. | +| — | El MCP Gmail de `claude.ai` queda descartado en ambos casos (indexa con latencia). | + +IMAP directo **no** sustituye al browser para el flujo interactivo del usuario; lo complementa +para automatización fiable con credenciales propias. + +## Autenticación + +Usuario + **app-password** (NO OAuth). Gmail exige 2FA activado y un App Password de 16 chars +(`myaccount.google.com/apppasswords`). Otros proveedores con IMAP/SMTP clásico (Dovecot, +dominio propio) aceptan user+pass directo. La credencial se guarda en `pass` +(`email/-apppass`) y la resuelve la capa app, **nunca** se hardcodea ni se pasa a +estas funciones desde el código del registry. + +**Outlook/Hotmail/Office365 NO entran por aquí**: Microsoft desactivó basic auth para +IMAP/SMTP; requieren OAuth2 (pista aparte, no cubierta por este grupo hoy). + +## Servidores comunes + +| Proveedor | IMAP | SMTP | +|---|---|---| +| Gmail | `imap.gmail.com:993` (SSL) | `smtp.gmail.com:465` (SSL) o `587` (STARTTLS) | +| Dominio propio (Dovecot+Postfix) | `mail.:993` | `mail.:465`/`587` | + +## Funciones del grupo + +Núcleo IMAP — el primer argumento `conn` de toda operación es el objeto `imaplib.IMAP4_SSL` +vivo que produce `imap_connect`. Todas operan por **UID** (estable), nunca por número de +secuencia, y devuelven `dict {"status": "ok"|"error", ...}` sin lanzar. + +| ID | Firma corta | Qué hace | +|---|---|---| +| [imap_connect_py_infra](../../python/functions/infra/imap_connect.md) | `imap_connect(host, port=993, user, password, mailbox='INBOX', use_ssl=True, timeout_s=30) -> dict` | Abre IMAP4_SSL, login + select(mailbox), devuelve el `conn` vivo + `num_messages`. Impura. | +| [imap_list_mailboxes_py_infra](../../python/functions/infra/imap_list_mailboxes.md) | `imap_list_mailboxes(conn) -> dict` | Lista carpetas decodificando modified-UTF-7 (Gmail: `[Gmail]/Sent Mail`, etc.). Impura. | +| [imap_search_py_infra](../../python/functions/infra/imap_search.md) | `imap_search(conn, criteria='UNSEEN', mailbox='') -> dict` | Busca por criterio IMAP crudo (UNSEEN, FROM, SINCE…) y devuelve UIDs. Impura. | +| [imap_fetch_message_py_infra](../../python/functions/infra/imap_fetch_message.md) | `imap_fetch_message(conn, uid, mark_seen=False) -> dict` | Baja y parsea un mensaje (from/to/cc/subject/date/body_text/body_html/attachments). `BODY.PEEK` no marca leído. Impura. | +| [imap_mark_seen_py_infra](../../python/functions/infra/imap_mark_seen.md) | `imap_mark_seen(conn, uid, seen=True) -> dict` | Añade/quita la bandera `\Seen`. Impura. | +| [imap_move_message_py_infra](../../python/functions/infra/imap_move_message.md) | `imap_move_message(conn, uid, dest_mailbox) -> dict` | Mueve por UID (UID MOVE RFC 6851, fallback COPY+EXPUNGE). Impura. | +| [imap_delete_message_py_infra](../../python/functions/infra/imap_delete_message.md) | `imap_delete_message(conn, uid, expunge=True) -> dict` | Marca `\Deleted` y opcionalmente EXPUNGE. Impura. | +| [imap_save_draft_py_infra](../../python/functions/infra/imap_save_draft.md) | `imap_save_draft(conn, raw_rfc822, mailbox='[Gmail]/Drafts', flags='\Draft') -> dict` | Guarda un borrador (bytes MIME) vía APPEND. Impura. | + +Construir + enviar (SMTP): + +| ID | Firma corta | Qué hace | +|---|---|---| +| [email_build_html_py_infra](../../python/functions/infra/email_build_html.md) | `email_build_html(from_addr, to, subject, body_html) -> EmailMessagePy` | Construye un mensaje HTML inmutable. Pura. | +| [smtp_send_py_infra](../../python/functions/infra/smtp_send.md) | `smtp_send(cfg, from_addr, to, subject, body_html='', body_text='', cc, bcc, attachments, headers) -> None` | Conecta SMTP, arma MIME y envía en un paso (TLS/STARTTLS/claro). Impura. | + +## Ejemplo canónico end-to-end + +Conectar a Gmail con app-password resuelto desde `pass`, listar no leídos, leer el primero, +marcarlo leído, y enviar una respuesta. Las funciones se componen en un heredoc Python que +**importa** del registry (no reescribe protocolo): + +```python +import sys, os, subprocess +sys.path.insert(0, os.path.join("python", "functions")) +from infra.imap_connect import imap_connect +from infra.imap_search import imap_search +from infra.imap_fetch_message import imap_fetch_message +from infra.imap_mark_seen import imap_mark_seen +from infra.smtp_send import smtp_send, SMTPConfigPy + +EMAIL = "gutierenmanuel15@gmail.com" +# Credencial desde pass (o usar pass_get_secret del registry). NUNCA hardcodear. +PW = subprocess.run(["pass", "show", "email/gmail-enmanuel-apppass"], + capture_output=True, text=True).stdout.splitlines()[0] + +# 1. Conectar (IMAP) — el conn vivo viaja dentro del dict +c = imap_connect(host="imap.gmail.com", port=993, user=EMAIL, password=PW, mailbox="INBOX") +assert c["status"] == "ok", c +conn = c["conn"] + +# 2. Buscar no leídos y leer el primero (PEEK: no marca leído) +s = imap_search(conn, criteria="UNSEEN") +print("no leídos:", s["count"]) +if s["uids"]: + uid = s["uids"][0] + m = imap_fetch_message(conn, uid)["message"] + print(m["from"], "—", m["subject"]) + imap_mark_seen(conn, uid) # marcar leído + +# 3. Enviar (SMTP) — mismo app-password +smtp_send( + SMTPConfigPy(host="smtp.gmail.com", port=465, username=EMAIL, password=PW, tls_mode="tls"), + from_addr=EMAIL, to=["dest@example.com"], + subject="Probando IMAP+SMTP propios", body_text="Enviado sin browser, protocolo directo.", +) +conn.logout() # cerrar siempre +``` + +## Fronteras + +- **No gestiona la cuenta multi-proveedor**: estas son primitivas de protocolo. El registro + de N cuentas (host/port/auth_type por buzón) y la resolución de credenciales desde `pass` + son responsabilidad de una **app** (p. ej. `apps/mail_manager`), no de este grupo. +- **No hace OAuth**: solo user+app-password. Outlook/Office365 (basic auth muerto) quedan fuera + hasta que exista una función `*_oauth_token` dedicada. +- **No reemplaza al browser para el flujo interactivo del usuario** (ver tabla arriba). +- **`imap_save_draft` no construye el MIME**: recibe bytes RFC822 ya serializados; el caller + los arma con `email.message.EmailMessage().as_bytes()` (stdlib) o con `email_build_*` + + serialización. + +## Gotchas + +- **`conn` es un objeto vivo dentro del dict**: estas funciones se componen en heredocs/apps + Python, NO por `fn run` (que no puede serializar el socket). Cerrar siempre con `conn.logout()`. +- **UID, no número de secuencia**: los seq se renumeran al borrar; los UID son estables + mientras no cambie `UIDVALIDITY` del buzón. +- **Gmail `\Deleted` ≠ borrar**: marcar `\Deleted` solo quita la etiqueta de la carpeta actual. + Para borrar de verdad hay que **mover a `[Gmail]/Trash`** con `imap_move_message`. +- **Nombres de carpeta Gmail** llevan prefijo `[Gmail]/` (`[Gmail]/Sent Mail`, `[Gmail]/Drafts`, + `[Gmail]/Trash`, `[Gmail]/Spam`). +- **App-password requiere 2FA** activado en la cuenta Google; sin 2FA no se puede generar. +- **Charsets**: `imap_fetch_message` decodifica RFC 2047 en cabeceras y respeta el charset de + cada parte del cuerpo; aun así correos malformados pueden traer texto degradado. + +## Prerequisitos + +- `python/.venv` (solo stdlib, sin dependencias nuevas). +- App-password de cada cuenta guardado en `pass` (`email/-apppass`). +- 2FA activado en las cuentas Google. diff --git a/docs/capabilities/seo.md b/docs/capabilities/seo.md new file mode 100644 index 00000000..79582241 --- /dev/null +++ b/docs/capabilities/seo.md @@ -0,0 +1,79 @@ +# Capability: seo + +SEO orientado a datos sobre Google Search Console (GSC): autenticar contra la Search Console +API con una cuenta de servicio, extraer Search Analytics (impresiones, clicks, CTR, posición +por query y página) y aterrizarlo en DuckDB (verdad acumulada) + Postgres (espejo para +Metabase). Es la cadena de ingesta del proyecto `seo_analytics`. + +La tesis del grupo: el SEO deja de hacerse a ciegas y se convierte en un problema de datos +con loop medible — el dashboard señala la oportunidad (striking distance, CTR bajo, content +decay), se aplica el cambio y se mide el impacto en la siguiente ingesta. + +## Funciones + +| ID | Firma | Qué hace | +|---|---|---| +| `gsc_auth_py_infra` | `gsc_auth(credentials_path="", subject="") -> service` | Autentica contra la Search Console API v1 con una service account JSON (scope `webmasters.readonly`). Fallback a env `GSC_SA_JSON`. Devuelve el `service` de googleapiclient listo para consultar. | +| `pull_gsc_search_analytics_py_datascience` | `pull_gsc_search_analytics(service, site_url, start_date, end_date, dimensions=None, row_limit=25000, max_total_rows=0, search_type="web") -> list[dict]` | Extrae Search Analytics paginando (startRow) hasta agotar. Aplana cada fila (keys → nombres de dimensión + clicks/impressions/ctr/position). `dimensions` por defecto `["query","page"]`. | +| `ingest_gsc_search_analytics_py_pipelines` | `ingest_gsc_search_analytics(site_url="", duckdb_path="", pg_dsn="", start_date="", end_date="", lookback_days=5, credentials_path="") -> dict` | Pipeline: auth → pull (dims date,query,page) → upsert idempotente en DuckDB → espejo a Postgres (`mode=replace`). Resuelve defaults de env (`GSC_SITE_URL`, `SEO_DSN`, `GSC_SA_JSON`). Lo invoca el DAG `seo-gsc-daily`. | + +## Ejemplo canónico (end-to-end) + +```bash +# Greenfield: ver projects/seo_analytics/docs/SETUP.md para crear la service account, +# verificar la propiedad en Search Console y darle acceso a la SA. + +# 1. Variables (el .env del proyecto las agrupa) +export GSC_SITE_URL="sc-domain:ejemplo.com" +export SEO_DSN="postgresql://captacion:PASS@localhost:5433/seo" +export GSC_SA_JSON="$HOME/.config/seo/gsc-sa.json" + +# 2. Ingesta diaria (auth + pull + DuckDB + espejo Postgres) — la corre el DAG seo-gsc-daily +python/.venv/bin/python3 python/functions/pipelines/ingest_gsc_search_analytics.py + +# 3. Dashboards en Metabase (una vez): añade la DB seo + 4 cards + dashboard +SEO_PG_PASS=... METABASE_USER=... METABASE_PASS=... \ + python/.venv/bin/python3 projects/seo_analytics/setup_metabase.py +``` + +Uso desde Python, componiendo las tres: + +```python +import sys; sys.path.insert(0, "python/functions") +from infra import gsc_auth +from datascience import pull_gsc_search_analytics + +svc = gsc_auth() # lee GSC_SA_JSON +rows = pull_gsc_search_analytics(svc, "sc-domain:ejemplo.com", + "2026-05-01", "2026-05-28", + dimensions=["date", "query", "page"]) +print(len(rows), rows[0]) +``` + +## Fronteras + +- **NO hace keyword research ni rank tracking externo**. GSC dice por qué keywords ya apareces + en Google; descubrir keywords nuevas o medir SERP de competidores es otro trabajo (scrapers). +- **NO escribe los dashboards**. Las cards/dashboard de Metabase los construye el script del + proyecto `setup_metabase.py` componiendo el grupo `metabase`. Este grupo solo ingiere datos. +- **NO gestiona el scheduling**. Eso es `dag_engine` (DAG `seo-gsc-daily`, grupo `scheduler`). +- **NO cubre Bing/otros buscadores**. Solo Google Search Console. + +## Gotchas del grupo + +- Los datos de GSC llegan con **~2-3 días de lag**. El pipeline pide hasta hoy menos 3 días. +- Google **anonimiza queries de baja frecuencia** (privacy threshold): la suma por query no + cuadra con el total del sitio. Es esperado, no un bug. +- El formato de `site_url` importa: `sc-domain:ejemplo.com` (propiedad de dominio) vs URL + completa con esquema (propiedad de prefijo). +- La service account accede porque su email está **añadido como usuario en Search Console** + (Settings > Users), no por domain-wide delegation. El JSON de la SA es un secreto. +- **DuckDB es la verdad** (upsert idempotente, acumula histórico); **Postgres es un espejo** + que se regenera por `replace` en cada sync. No acumular en Postgres directamente. + +## Prerequisitos + +- Sitio verificado en Search Console + service account con acceso (ver SETUP.md del proyecto). +- Stack Postgres + Metabase del proyecto `captacion_clientes` (contenedores `captacion-postgres` + :5433 y `captacion-metabase` :3030), con la DB `seo` creada. +- Deps Python `google-api-python-client` + `google-auth` (ya en el venv del registry). diff --git a/functions/browser/cdp_new_tab_background.go b/functions/browser/cdp_new_tab_background.go new file mode 100644 index 00000000..04c515fa --- /dev/null +++ b/functions/browser/cdp_new_tab_background.go @@ -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 +} diff --git a/functions/browser/cdp_new_tab_background.md b/functions/browser/cdp_new_tab_background.md new file mode 100644 index 00000000..ced17123 --- /dev/null +++ b/functions/browser/cdp_new_tab_background.md @@ -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. diff --git a/functions/browser/cdp_new_tab_background_test.go b/functions/browser/cdp_new_tab_background_test.go new file mode 100644 index 00000000..fe8ab72c --- /dev/null +++ b/functions/browser/cdp_new_tab_background_test.go @@ -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") + } +} diff --git a/functions/browser/detect_captcha.go b/functions/browser/detect_captcha.go new file mode 100644 index 00000000..b4ee4310 --- /dev/null +++ b/functions/browser/detect_captcha.go @@ -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;i0, 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 +} diff --git a/functions/browser/detect_captcha.md b/functions/browser/detect_captcha.md new file mode 100644 index 00000000..2ec7be3e --- /dev/null +++ b/functions/browser/detect_captcha.md @@ -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`. diff --git a/functions/browser/detect_captcha_test.go b/functions/browser/detect_captcha_test.go new file mode 100644 index 00000000..78951548 --- /dev/null +++ b/functions/browser/detect_captcha_test.go @@ -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) + } + }) + } +} diff --git a/python/functions/browser/_osint_db_client.py b/python/functions/browser/_osint_db_client.py new file mode 100644 index 00000000..e7c6c66e --- /dev/null +++ b/python/functions/browser/_osint_db_client.py @@ -0,0 +1,119 @@ +"""Cliente HTTP minimo compartido para el service osint_db (FastAPI + DuckDB). + +NO es una funcion del registry — es un helper privado (modulo prefijado con `_`) +que comparten las funciones `browser_profile_*`. Por eso no tiene `.md` con +frontmatter ni se indexa. Mantiene KISS: solo dos helpers sobre `urllib.request` +de la stdlib (sin `requests`). + +Contrato del service (FIJO): SIEMPRE responde HTTP 200 con un body JSON +`{"status":"ok"|"error", ...}`. El codigo HTTP NO indica exito — se parsea el body. +Estos helpers nunca lanzan por logica de negocio; convierten cualquier fallo de red +o de parseo en un dict `{"status":"error","error":...}` para que las funciones que +los usan respeten el contrato "no lanzar, devolver dict de estado". +""" + +import json +import urllib.error +import urllib.request + +# Timeout por defecto de cada request HTTP al service (segundos). +_TIMEOUT_S = 10 + + +def _request(base_url: str, path: str, method: str, payload: dict | None = None) -> dict: + """Hace una request JSON al service osint_db y devuelve el body parseado. + + Args: + base_url: base del service (ej. http://127.0.0.1:8771). Se le quita el "/" final. + path: ruta del endpoint (ej. /api/browser-profile). Debe empezar por "/". + method: verbo HTTP (POST, DELETE, GET). + payload: dict a serializar como JSON en el body (None para no enviar body). + + Returns: + El body JSON del service como dict. Si el service esta caido, la respuesta no + es JSON, o ocurre cualquier error de transporte, devuelve + {"status":"error","error": } para no romper al llamante. + """ + url = base_url.rstrip("/") + path + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + raw = resp.read().decode("utf-8") + parsed = json.loads(raw) if raw else {} + if not isinstance(parsed, dict): + return {"status": "error", "error": f"respuesta no-dict del service: {raw[:200]}"} + return parsed + except urllib.error.HTTPError as e: + # El contrato dice HTTP 200 siempre; un HTTPError es anomalia del transporte. + try: + body = e.read().decode("utf-8") + parsed = json.loads(body) if body else {} + if isinstance(parsed, dict): + return parsed + except Exception: # noqa: BLE001 - el cuerpo del error puede no ser JSON + pass + return {"status": "error", "error": f"HTTP {e.code} desde {url}: {e.reason}"} + except urllib.error.URLError as e: + return {"status": "error", "error": f"service osint_db inaccesible en {url}: {e.reason}"} + except (ValueError, UnicodeDecodeError) as e: + return {"status": "error", "error": f"respuesta no parseable de {url}: {e}"} + except Exception as e: # noqa: BLE001 - contrato: nunca lanzar + return {"status": "error", "error": f"{type(e).__name__}: {e}"} + + +def post_json(base_url: str, path: str, payload: dict) -> dict: + """POST JSON al service. Devuelve el body parseado (o dict de error).""" + return _request(base_url, path, "POST", payload) + + +def delete(base_url: str, path: str) -> dict: + """DELETE al service. Devuelve el body parseado (o dict de error).""" + return _request(base_url, path, "DELETE", None) + + +def query(base_url: str, sql: str, params: list | None = None, max_rows: int | None = None) -> dict: + """POST /api/query (read-only). Devuelve {status, columns, rows, row_count} del service. + + Args: + base_url: base del service. + sql: SELECT a ejecutar (read-only en el service). + params: lista de parametros posicionales para el SQL (None -> []). + max_rows: tope opcional de filas devueltas. + + Returns: + El body JSON del service. En caso ok trae columns/rows/row_count; en error + trae {"status":"error","error":...}. + """ + body: dict = {"sql": sql} + if params is not None: + body["params"] = params + if max_rows is not None: + body["max_rows"] = max_rows + return _request(base_url, "/api/query", "POST", body) + + +def rows_to_dicts(resp: dict) -> list: + """Normaliza las filas de una respuesta de /api/query a lista de dicts. + + El service osint_db devuelve ``rows`` YA como lista de dicts (claves = + nombres de columna), así que el caso normal es un passthrough. Por robustez, + si alguna fila viniera como lista/tupla posicional se mapea con ``columns``. + Si la respuesta no es un read ok (sin ``rows``), devuelve []. + """ + rows = resp.get("rows") + if not isinstance(rows, list): + return [] + columns = resp.get("columns") + out: list = [] + for row in rows: + if isinstance(row, dict): + out.append(row) + elif isinstance(row, (list, tuple)) and isinstance(columns, list): + out.append(dict(zip(columns, row))) + return out diff --git a/python/functions/browser/browser_profile_list.md b/python/functions/browser/browser_profile_list.md new file mode 100644 index 00000000..16f19cb8 --- /dev/null +++ b/python/functions/browser/browser_profile_list.md @@ -0,0 +1,68 @@ +--- +name: browser_profile_list +kind: function +lang: py +domain: browser +version: "1.0.0" +purity: impure +signature: "def browser_profile_list(status: str | None = None, base_url: str = 'http://127.0.0.1:8771') -> dict" +description: "Lista los perfiles de Chromium del catalogo del service osint_db con su numero de cuentas. Hace POST /api/query (read-only) con un SELECT que une browser_profiles LEFT JOIN un agregado COUNT de browser_profile_accounts por profile_dir, y mapea columns->rows a una lista de dicts con claves profile_dir, label, persona, purpose, status, note_path, n_accounts. Filtra por status si se pasa. El service responde SIEMPRE HTTP 200 con body {status:ok|error}. Impura (red). No lanza: devuelve dict de estado." +tags: [browser-profiles, osint, chromium, profile, multicuenta] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_golden_lista_perfiles_mapea_columns_a_dicts", "test_edge_filtro_status_agrega_where_y_param", "test_error_query_falla_devuelve_status_error"] +test_file_path: "python/functions/browser/browser_profile_list_test.py" +file_path: "python/functions/browser/browser_profile_list.py" +params: + - name: status + desc: "Si no es None, filtra por estado del perfil (ej. 'active', 'archived', 'burned'). None -> devuelve todos los perfiles." + - name: base_url + desc: "Base del service osint_db. Default http://127.0.0.1:8771." +output: "dict de estado. Caso ok: {status:'ok', profiles: list de dicts con claves profile_dir, label, persona, purpose, status, note_path, n_accounts (int: cuentas asociadas al perfil)}. Caso error (service caido o query rechazada): {status:'error', error: str}." +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.browser_profile_list import browser_profile_list + +res = browser_profile_list() # todos los perfiles +for p in res["profiles"]: + print(p["profile_dir"], p["persona"], p["n_accounts"]) + +activos = browser_profile_list(status="active") # solo perfiles activos +print(len(activos["profiles"])) +``` + +## Cuando usarla + +Cuando necesites un inventario rapido de los perfiles Chromium catalogados para OSINT: +ver que personas existen, su proposito y cuantas cuentas tiene cada uno, antes de +abrir uno con `browser_profile_open` o inspeccionarlo a fondo con `browser_profile_show`. +Usa `status="active"` para filtrar los perfiles vivos y descartar los archivados/quemados. + +## Gotchas + +- **Impura**: hace red (HTTP POST /api/query al service). El service `osint_db` debe estar + vivo en `http://127.0.0.1:8771`. Si esta caido, devuelve `{status:'error', error:'... inaccesible'}` + sin lanzar. +- **El codigo HTTP NO indica exito**: el service responde SIEMPRE HTTP 200 con body + `{status:ok|error}`; se parsea el body. +- **Read-only**: usa `/api/query` con un SELECT; no muta nada en el catalogo. +- **n_accounts viene de un LEFT JOIN agregado**: los perfiles sin cuentas aparecen con + `n_accounts=0` (COALESCE), no se omiten. +- **No expone secretos**: este listado NO trae los `secret_ref` de las cuentas (solo el + conteo). Para ver cuentas y sus referencias usa `browser_profile_show`. + +## Notas + +Usa el helper compartido `python/functions/browser/_osint_db_client.py` (modulo privado +no indexado) para el POST sobre `urllib.request` de stdlib (sin `requests`). El SELECT +ordena por `profile_dir`. Timeout HTTP de 10s. diff --git a/python/functions/browser/browser_profile_list.py b/python/functions/browser/browser_profile_list.py new file mode 100644 index 00000000..e8990572 --- /dev/null +++ b/python/functions/browser/browser_profile_list.py @@ -0,0 +1,63 @@ +"""Lista los perfiles Chromium del catalogo osint_db con su numero de cuentas. + +Wrapper cliente del service local `osint_db`: hace POST /api/query (read-only) con un +SELECT que une `browser_profiles` con el conteo agregado de `browser_profile_accounts`, +y mapea columns->rows a una lista de dicts. + +Funcion impura: hace red (HTTP al service). No lanza; devuelve un dict de estado. +""" + +from browser._osint_db_client import query, rows_to_dicts + +# SELECT con LEFT JOIN al conteo agregado de cuentas por perfil. Columnas en orden fijo. +_SQL_BASE = ( + "SELECT p.profile_dir, p.label, p.persona, p.purpose, p.status, p.note_path, " + "COALESCE(a.n_accounts, 0) AS n_accounts " + "FROM browser_profiles p " + "LEFT JOIN (SELECT profile_dir, COUNT(*) AS n_accounts " + "FROM browser_profile_accounts GROUP BY profile_dir) a " + "ON p.profile_dir = a.profile_dir" +) + + +def browser_profile_list( + status: str | None = None, + base_url: str = "http://127.0.0.1:8771", +) -> dict: + """Lista los perfiles Chromium del catalogo con su numero de cuentas. + + Args: + status: si no es None, filtra por estado del perfil (ej. "active", "archived"). + None -> devuelve todos los perfiles. + base_url: base del service osint_db. Default http://127.0.0.1:8771. + + Returns: + Caso ok: {"status":"ok", "profiles": list de dicts con claves + profile_dir, label, persona, purpose, status, note_path, n_accounts}. + Caso error (service caido o query rechazada): {"status":"error", "error": str}. + """ + try: + if status is None: + sql = _SQL_BASE + " ORDER BY p.profile_dir" + params: list = [] + else: + sql = _SQL_BASE + " WHERE p.status = ? ORDER BY p.profile_dir" + params = [status] + + resp = query(base_url, sql, params) + if resp.get("status") != "ok": + return { + "status": "error", + "error": resp.get("error", f"el service rechazo la query: {resp}"), + } + return {"status": "ok", "profiles": rows_to_dicts(resp)} + except Exception as e: # noqa: BLE001 - contrato: nunca lanzar + return {"status": "error", "error": f"{type(e).__name__}: {e}"} + + +if __name__ == "__main__": + # Smoke contra un puerto muerto: ejercita la degradacion graceful (service inaccesible). + res = browser_profile_list(base_url="http://127.0.0.1:1") + assert res["status"] == "error", res + print("browser_profile_list smoke OK (service caido -> status error)") + print(f" {res}") diff --git a/python/functions/browser/browser_profile_list_test.py b/python/functions/browser/browser_profile_list_test.py new file mode 100644 index 00000000..7521a587 --- /dev/null +++ b/python/functions/browser/browser_profile_list_test.py @@ -0,0 +1,78 @@ +"""Tests para browser_profile_list. + +Se mockea el helper `query` (ligado en el modulo por el `from browser._osint_db_client +import query`) para validar el armado del SELECT (filtro por status) y el mapeo +columns->rows a lista de dicts. NO toca el service real. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import browser.browser_profile_list as bpl +from browser.browser_profile_list import browser_profile_list + + +class _QuerySpy: + def __init__(self, ret): + self.calls = [] # lista de (sql, params) + self.ret = ret + + def __call__(self, base_url, sql, params=None, max_rows=None): + self.calls.append((sql, params)) + return self.ret + + +def test_golden_lista_perfiles_mapea_columns_a_dicts(monkeypatch): + ret = { + "status": "ok", + "columns": ["profile_dir", "label", "persona", "purpose", "status", + "note_path", "n_accounts"], + "rows": [ + ["Profile 1", "Maria", "maria_fake", "rastreo", "active", "notes/p1.md", 2], + ["osint_01", "", "", "", "active", "", 0], + ], + "row_count": 2, + } + spy = _QuerySpy(ret) + monkeypatch.setattr(bpl, "query", spy) + + res = browser_profile_list() + + assert res["status"] == "ok" + assert len(res["profiles"]) == 2 + assert res["profiles"][0] == { + "profile_dir": "Profile 1", "label": "Maria", "persona": "maria_fake", + "purpose": "rastreo", "status": "active", "note_path": "notes/p1.md", + "n_accounts": 2, + } + assert res["profiles"][1]["n_accounts"] == 0 + # Sin filtro: no debe haber WHERE y params vacio. + sql, params = spy.calls[0] + assert "WHERE" not in sql + assert params == [] + assert "LEFT JOIN" in sql + + +def test_edge_filtro_status_agrega_where_y_param(monkeypatch): + spy = _QuerySpy({"status": "ok", "columns": [], "rows": [], "row_count": 0}) + monkeypatch.setattr(bpl, "query", spy) + + res = browser_profile_list(status="archived") + + assert res["status"] == "ok" + assert res["profiles"] == [] + sql, params = spy.calls[0] + assert "WHERE p.status = ?" in sql + assert params == ["archived"] + + +def test_error_query_falla_devuelve_status_error(monkeypatch): + spy = _QuerySpy({"status": "error", "error": "service osint_db inaccesible en ..."}) + monkeypatch.setattr(bpl, "query", spy) + + res = browser_profile_list() + + assert res["status"] == "error" + assert "inaccesible" in res["error"] diff --git a/python/functions/browser/browser_profile_open.md b/python/functions/browser/browser_profile_open.md new file mode 100644 index 00000000..2bba13b6 --- /dev/null +++ b/python/functions/browser/browser_profile_open.md @@ -0,0 +1,88 @@ +--- +name: browser_profile_open +kind: function +lang: py +domain: browser +version: "1.0.0" +purity: impure +signature: "def browser_profile_open(profile_dir: str, url: str | None = None, base_url: str = 'http://127.0.0.1:8771', dry_run: bool = False) -> dict" +description: "Lanza Chromium en un perfil del catalogo osint_db y devuelve sus cuentas/secret_refs para que el operador sepa que credenciales usar. Compone browser_profile_show para leer la metadata del perfil (resuelve user_data_dir) y sus cuentas, luego lanza Chromium con --profile-directory via systemd-run --user --scope -- (proceso aislado, en background, para evitar exit-144). Gotcha del entorno: el wrapper /usr/bin/chromium ya inyecta --user-data-dir=$HOME/.config/chromium-cdp via /etc/chromium.d/cdp; por eso solo pasa --user-data-dir explicito cuando el perfil usa un dir distinto del default. Con dry_run=True no lanza nada: devuelve el comando que lanzaria. NUNCA resuelve el secreto: solo expone el secret_ref. Impura (red + lanza proceso). No lanza excepciones: devuelve dict de estado." +tags: [browser-profiles, osint, chromium, launcher, multicuenta] +uses_functions: [browser_profile_show_py_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_dry_run_default_user_data_dir_no_pasa_user_data_dir", "test_dry_run_custom_user_data_dir_pasa_flag_explicito", "test_dry_run_user_data_dir_default_explicito_no_se_pasa", "test_error_perfil_no_existe_propaga_sin_lanzar"] +test_file_path: "python/functions/browser/browser_profile_open_test.py" +file_path: "python/functions/browser/browser_profile_open.py" +params: + - name: profile_dir + desc: "Nombre del directorio real del perfil Chromium (ej. 'Profile 1', 'osint_01'). Debe existir en el catalogo osint_db." + - name: url + desc: "URL a abrir al arrancar (ej. 'https://mail.google.com'). Se anade al final del comando. None -> arranca sin URL." + - name: base_url + desc: "Base del service osint_db. Default http://127.0.0.1:8771." + - name: dry_run + desc: "Si True NO lanza nada y devuelve el comando (lista de args) que lanzaria. Util para testear sin abrir navegador y para revisar el comando antes de ejecutar." +output: "dict de estado. Caso dry_run ok: {status:'ok', profile_dir, cmd: list[str] (argv que lanzaria), accounts: list de {service, identity, secret_ref, role}}. Caso real ok: {status:'ok', profile_dir, launched: True, cmd: list[str], accounts: [...]}. Caso perfil no existe / service caido: {status:'error', error: str} (no lanza navegador). secret_ref es REFERENCIA al secreto, nunca el password." +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.browser_profile_open import browser_profile_open + +# dry_run: ver el comando sin abrir nada +preview = browser_profile_open("Profile 1", url="https://mail.google.com", dry_run=True) +print(preview["cmd"]) +# ['systemd-run','--user','--scope','--','chromium', +# '--profile-directory=Profile 1','https://mail.google.com'] +for a in preview["accounts"]: + print(a["service"], a["identity"], a["secret_ref"]) # resuelve tu con: pass show ... + +# real: lanza Chromium en el perfil (proceso aislado, no bloquea) +res = browser_profile_open("Profile 1", url="https://mail.google.com") +print(res["launched"]) # True +``` + +## Cuando usarla + +Cuando vayas a operar con una cuenta de un perfil OSINT concreto: abre Chromium en ese +perfil con su contexto (cookies/sesiones aislados) y obten de golpe los `secret_ref` de +las cuentas para saber que credenciales usar. Usa `dry_run=True` primero para revisar el +comando o para testear sin abrir el navegador. Es el ultimo paso del grupo +`browser-profiles` tras registrar (`browser_profile_register`) e inspeccionar +(`browser_profile_show`). + +## Gotchas + +- **Impura**: hace red (lee metadata del service osint_db, que debe estar vivo en + `http://127.0.0.1:8771`) y LANZA un proceso. Si el service esta caido o el perfil no + existe, propaga `{status:'error', ...}` sin abrir navegador. +- **exit-144 si lanzas chromium directo**: en este entorno lanzar chromium como hijo da + exit-144. Por eso SIEMPRE se lanza via `systemd-run --user --scope --` (proceso aislado), + en background, sin esperar. No bloquea al operador. +- **Wrapper chromium-cdp**: `/usr/bin/chromium` ya inyecta + `--user-data-dir=$HOME/.config/chromium-cdp` y `--remote-debugging-port=9222` via + `/etc/chromium.d/cdp`. Si el `user_data_dir` del perfil ES ese default, la funcion NO + pasa `--user-data-dir` (lo hereda el wrapper); si es OTRO directorio, lo pasa explicito. +- **secret_ref NUNCA es el password**: la funcion solo expone la REFERENCIA (ej. + `"pass show osint/p1/gmail"`). El humano/otra herramienta resuelve el secreto con `pass`. +- **dry_run no abre nada**: con `dry_run=True` no se lanza el proceso; util para test y + revision. En ese modo el dict NO trae `launched`. +- **El codigo HTTP NO indica exito**: el service responde SIEMPRE HTTP 200 con body + `{status:ok|error}`; se parsea el body (via browser_profile_show). + +## Notas + +Compone `browser_profile_show_py_browser` (mismo paquete: `from +browser.browser_profile_show import browser_profile_show`) para leer metadata + cuentas. +El default del wrapper se compara con `os.path.normpath` tras expandir `~`, asi que una +fila con `user_data_dir="~/.config/chromium-cdp"` tampoco fuerza el flag. Usa +`subprocess.Popen` con `start_new_session=True` y stdout/stderr a DEVNULL para el +lanzamiento desacoplado. diff --git a/python/functions/browser/browser_profile_open.py b/python/functions/browser/browser_profile_open.py new file mode 100644 index 00000000..b3124b76 --- /dev/null +++ b/python/functions/browser/browser_profile_open.py @@ -0,0 +1,118 @@ +"""Lanza Chromium en un perfil del catalogo osint_db y expone sus cuentas/secret_refs. + +Wrapper que compone `browser_profile_show` (para leer la metadata del perfil y sus +cuentas desde el service osint_db) y luego lanza Chromium en ese perfil. Devuelve las +cuentas con sus `secret_ref` (REFERENCIAS a secretos, nunca el password) para que el +operador sepa que credenciales usar. + +GOTCHAS de este entorno (Linux nativo de enmanuel): +- El wrapper `/usr/bin/chromium` ya inyecta `--user-data-dir=$HOME/.config/chromium-cdp` + y `--remote-debugging-port=9222` via `/etc/chromium.d/cdp`. Por eso, si el + `user_data_dir` resuelto ES ese default, NO se pasa `--user-data-dir` (se hereda del + wrapper); si es OTRO directorio, se pasa explicito. +- Lanzar chromium directamente como hijo da exit-144 en este entorno. Se lanza SIEMPRE + via `systemd-run --user --scope --` (proceso aislado), en background, sin esperar. + +Funcion impura: hace red (HTTP al service) y lanza un proceso. No lanza excepciones; +devuelve un dict de estado. Con `dry_run=True` no abre nada (devuelve el comando). +""" + +import os +import subprocess + +from browser.browser_profile_show import browser_profile_show + +# Default del wrapper /etc/chromium.d/cdp en esta maquina (se compara expandido). +_DEFAULT_USER_DATA_DIR = os.path.expanduser("~/.config/chromium-cdp") + + +def browser_profile_open( + profile_dir: str, + url: str | None = None, + base_url: str = "http://127.0.0.1:8771", + dry_run: bool = False, +) -> dict: + """Lanza Chromium en el perfil indicado y devuelve sus cuentas/secret_refs. + + Args: + profile_dir: nombre del directorio real del perfil Chromium (ej. "Profile 1", + "osint_01"). Debe existir en el catalogo osint_db. + url: URL a abrir al arrancar (ej. "https://mail.google.com"). None -> sin URL. + base_url: base del service osint_db. Default http://127.0.0.1:8771. + dry_run: si True, NO lanza nada; devuelve el comando que lanzaria. Util para test + y para revisar el comando antes de abrir el navegador. + + Returns: + Caso dry_run ok: {"status":"ok", "profile_dir": str, "cmd": list[str] (argv que + se lanzaria), "accounts": list de dicts {service, identity, secret_ref, role}}. + Caso real ok: {"status":"ok", "profile_dir": str, "launched": True, + "cmd": list[str], "accounts": list de dicts {service, identity, secret_ref, role}}. + Caso perfil no existe / service caido: {"status":"error", "error": str} (no lanza). + """ + try: + meta = browser_profile_show(profile_dir, base_url=base_url) + if meta.get("status") != "ok": + # Perfil inexistente o service caido: propaga el error sin lanzar nada. + return meta + + profile = meta.get("profile", {}) + raw_accounts = meta.get("accounts", []) + accounts = [ + { + "service": a.get("service"), + "identity": a.get("identity"), + "secret_ref": a.get("secret_ref"), + "role": a.get("role"), + } + for a in raw_accounts + ] + + # Resolver user_data_dir: el de la fila si no esta vacio; si no, el default del wrapper. + row_udd = (profile.get("user_data_dir") or "").strip() + resolved_udd = os.path.expanduser(row_udd) if row_udd else _DEFAULT_USER_DATA_DIR + + chromium_args = ["chromium", f'--profile-directory={profile_dir}'] + # Solo pasar --user-data-dir si NO es el default del wrapper (que ya lo inyecta). + if os.path.normpath(resolved_udd) != os.path.normpath(_DEFAULT_USER_DATA_DIR): + chromium_args.append(f"--user-data-dir={resolved_udd}") + if url: + chromium_args.append(url) + + # Lanzamiento aislado para evitar exit-144 (ver gotcha del modulo). + cmd = ["systemd-run", "--user", "--scope", "--", *chromium_args] + + if dry_run: + return { + "status": "ok", + "profile_dir": profile_dir, + "cmd": cmd, + "accounts": accounts, + } + + # Background, sin esperar: no bloquear al operador ni capturar el navegador. + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + return { + "status": "ok", + "profile_dir": profile_dir, + "launched": True, + "cmd": cmd, + "accounts": accounts, + } + except Exception as e: # noqa: BLE001 - contrato: nunca lanzar + return {"status": "error", "error": f"{type(e).__name__}: {e}"} + + +if __name__ == "__main__": + # Smoke contra un puerto muerto: el service caido -> browser_profile_show falla, + # browser_profile_open propaga el error sin abrir navegador. + res = browser_profile_open("Profile 1", url="https://example.com", + base_url="http://127.0.0.1:1", dry_run=True) + assert res["status"] == "error", res + print("browser_profile_open smoke OK (service caido -> status error, sin lanzar)") + print(f" {res}") diff --git a/python/functions/browser/browser_profile_open_test.py b/python/functions/browser/browser_profile_open_test.py new file mode 100644 index 00000000..d5eca2de --- /dev/null +++ b/python/functions/browser/browser_profile_open_test.py @@ -0,0 +1,94 @@ +"""Tests para browser_profile_open. + +browser_profile_open compone browser_profile_show (lectura de metadata) y lanza +Chromium via systemd-run. Aqui se mockea browser_profile_show (ligado en el modulo por +el `from browser.browser_profile_show import browser_profile_show`) y se usa dry_run=True +para NO abrir navegador. Se valida el comando construido en los dos casos clave: +- user_data_dir vacio -> NO se pasa --user-data-dir (lo hereda el wrapper chromium-cdp). +- user_data_dir custom -> SI se pasa --user-data-dir explicito. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import browser.browser_profile_open as bpo +from browser.browser_profile_open import browser_profile_open + + +def _show_ret(user_data_dir=""): + """Construye una respuesta ok de browser_profile_show con cuentas.""" + return { + "status": "ok", + "profile": {"profile_dir": "Profile 1", "user_data_dir": user_data_dir, + "label": "Maria", "status": "active"}, + "accounts": [ + {"id": "Profile 1:gmail:maria@example.com", "service": "gmail", + "identity": "maria@example.com", "secret_ref": "pass show osint/p1/gmail", + "role": "primary", "status": "active", "notes": ""}, + ], + } + + +def test_dry_run_default_user_data_dir_no_pasa_user_data_dir(monkeypatch): + # user_data_dir vacio en la fila -> default del wrapper -> NO --user-data-dir. + monkeypatch.setattr(bpo, "browser_profile_show", lambda pd, base_url="": _show_ret("")) + + res = browser_profile_open("Profile 1", url="https://mail.google.com", dry_run=True) + + assert res["status"] == "ok" + assert res["profile_dir"] == "Profile 1" + cmd = res["cmd"] + # Lanzamiento aislado via systemd-run --user --scope --. + assert cmd[:5] == ["systemd-run", "--user", "--scope", "--", "chromium"] + assert '--profile-directory=Profile 1' in cmd + # Caso default: NO debe aparecer --user-data-dir (lo inyecta el wrapper). + assert not any(a.startswith("--user-data-dir=") for a in cmd) + # La URL va al final. + assert cmd[-1] == "https://mail.google.com" + # Las cuentas se exponen con su secret_ref (referencia, nunca el password). + assert res["accounts"][0]["secret_ref"] == "pass show osint/p1/gmail" + assert res["accounts"][0]["service"] == "gmail" + + +def test_dry_run_custom_user_data_dir_pasa_flag_explicito(monkeypatch): + custom = "/mnt/data/chromium-osint" + monkeypatch.setattr(bpo, "browser_profile_show", lambda pd, base_url="": _show_ret(custom)) + + res = browser_profile_open("Profile 1", dry_run=True) + + assert res["status"] == "ok" + cmd = res["cmd"] + # Caso custom: SI debe aparecer --user-data-dir explicito con el dir de la fila. + assert f"--user-data-dir={custom}" in cmd + assert '--profile-directory=Profile 1' in cmd + # Sin url -> el ultimo arg NO es una URL. + assert not cmd[-1].startswith("http") + + +def test_dry_run_user_data_dir_default_explicito_no_se_pasa(monkeypatch): + # Si la fila trae EXACTAMENTE el default (con ~), tampoco debe pasarse --user-data-dir. + monkeypatch.setattr( + bpo, "browser_profile_show", + lambda pd, base_url="": _show_ret("~/.config/chromium-cdp"), + ) + + res = browser_profile_open("Profile 1", dry_run=True) + + assert res["status"] == "ok" + cmd = res["cmd"] + assert not any(a.startswith("--user-data-dir=") for a in cmd) + + +def test_error_perfil_no_existe_propaga_sin_lanzar(monkeypatch): + err = {"status": "error", "error": "perfil no encontrado: fantasma"} + monkeypatch.setattr(bpo, "browser_profile_show", lambda pd, base_url="": err) + + res = browser_profile_open("fantasma", dry_run=True) + + assert res["status"] == "error" + assert "no encontrado" in res["error"] + # No hay cmd ni launched cuando el perfil no existe. + assert "cmd" not in res + assert "launched" not in res diff --git a/python/functions/browser/browser_profile_register.md b/python/functions/browser/browser_profile_register.md new file mode 100644 index 00000000..f77f5461 --- /dev/null +++ b/python/functions/browser/browser_profile_register.md @@ -0,0 +1,102 @@ +--- +name: browser_profile_register +kind: function +lang: py +domain: browser +version: "1.0.0" +purity: impure +signature: "def browser_profile_register(profile_dir: str, label: str = '', persona: str = '', purpose: str = '', note_path: str = '', tags: list | None = None, notes: str = '', user_data_dir: str = '', status: str = 'active', accounts: list | None = None, base_url: str = 'http://127.0.0.1:8771') -> dict" +description: "Registra o actualiza un perfil de Chromium (y opcionalmente sus cuentas) en el catalogo del service osint_db (FastAPI + DuckDB single-writer) usado para investigaciones multicuenta OSINT. En una sola llamada hace POST /api/browser-profile con la metadata del perfil (upsert idempotente sobre profile_dir) y un POST /api/browser-profile/account por cada cuenta de la lista accounts. El service responde SIEMPRE HTTP 200 con body {status:ok|error}, se parsea el body. Impura (red). No lanza: devuelve dict de estado. secret_ref de cada cuenta es una REFERENCIA al secreto (ej. 'pass show osint/p1/gmail'), nunca el password en claro." +tags: [browser-profiles, osint, chromium, profile, multicuenta] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_golden_registra_perfil_con_dos_cuentas", "test_edge_cuenta_invalida_se_reporta_y_no_se_envia", "test_error_post_perfil_falla_devuelve_status_error"] +test_file_path: "python/functions/browser/browser_profile_register_test.py" +file_path: "python/functions/browser/browser_profile_register.py" +params: + - name: profile_dir + desc: "Nombre del directorio real del perfil Chromium (ej. 'Profile 1', 'Default', 'osint_01'). Es la PK; el upsert es idempotente sobre el." + - name: label + desc: "Etiqueta humana del perfil (ej. 'Persona Maria - OSINT'). '' para omitir." + - name: persona + desc: "Identidad/alias ficticio asociado al perfil (sock puppet). '' para omitir." + - name: purpose + desc: "Proposito de la investigacion (ej. 'rastreo cuentas falsas'). '' para omitir." + - name: note_path + desc: "Ruta (rel al vault OSINT) de la nota ligada al perfil. '' para omitir." + - name: tags + desc: "Lista de strings de etiquetas del perfil (ej. ['osint','sock-puppet']). None -> []." + - name: notes + desc: "Notas libres sobre el perfil. '' para omitir." + - name: user_data_dir + desc: "user-data-dir de Chromium si NO es el default del wrapper chromium-cdp. '' -> el perfil hereda el default al abrirlo con browser_profile_open." + - name: status + desc: "Estado del perfil (active|archived|burned...). Default 'active'." + - name: accounts + desc: "Lista de dicts de cuentas a registrar: {service, identity, secret_ref?, role?, status?, notes?}. None -> sin cuentas. service ej. 'gmail', identity ej. 'x@y.com' o '@handle'. secret_ref es REFERENCIA al secreto, NUNCA el password." + - name: base_url + desc: "Base del service osint_db. Default http://127.0.0.1:8771." +output: "dict de estado. Caso ok: {status:'ok', profile_dir, accounts (int: cuentas registradas con exito), account_errors (list: errores por cuenta invalida o rechazada, vacia si todo OK)}. Caso error (fallo del POST del perfil): {status:'error', error: str}." +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.browser_profile_register import browser_profile_register + +res = browser_profile_register( + "Profile 1", + label="Persona Maria - OSINT", + persona="maria_ficticia", + purpose="rastreo cuentas falsas", + tags=["osint", "sock-puppet"], + accounts=[ + {"service": "gmail", "identity": "maria@example.com", + "secret_ref": "pass show osint/p1/gmail"}, + {"service": "x", "identity": "@maria_fake", "role": "primary"}, + ], +) +print(res["status"]) # "ok" si el service esta vivo +print(res["accounts"]) # 2 (cuentas registradas) +``` + +## Cuando usarla + +Cuando crees un perfil nuevo de Chromium para una investigacion multicuenta OSINT y +quieras dejarlo catalogado (con su persona, proposito y cuentas) en el service osint_db. +Llamala tambien para ACTUALIZAR un perfil existente: el upsert es idempotente sobre +`profile_dir`, asi que reejecutarla con mas cuentas o metadata nueva no duplica nada. +Es el punto de entrada del grupo `browser-profiles`; luego se lista con +`browser_profile_list`, se inspecciona con `browser_profile_show` y se abre con +`browser_profile_open`. + +## Gotchas + +- **Impura**: hace red (HTTP POST al service). El service `osint_db` debe estar vivo en + `http://127.0.0.1:8771`. Si esta caido, devuelve `{status:'error', error:'... inaccesible'}` + sin lanzar. +- **El codigo HTTP NO indica exito**: el service responde SIEMPRE HTTP 200 con body + `{status:ok|error}`. La funcion parsea el body, no el codigo HTTP. +- **secret_ref NUNCA es el password**: es una REFERENCIA al secreto (ej. + `"pass show osint/p1/gmail"`). No metas credenciales en claro — se resuelven con `pass` + en el momento de usarlas. +- **Idempotente**: reejecutar con el mismo `profile_dir` actualiza (upsert), no duplica. + Lo mismo para cada cuenta (PK `::`). +- **Errores parciales de cuentas**: si el perfil se registra pero una cuenta falla (o le + falta `service`/`identity`), el `status` global sigue siendo `"ok"` y el detalle del + fallo va en `account_errors`. Solo `status:'error'` si falla el POST del PERFIL. +- **Single-writer DuckDB**: la DB la abre el service. NUNCA abrir `osint.duckdb` en + paralelo; todo pasa por HTTP. + +## Notas + +Usa el helper compartido `python/functions/browser/_osint_db_client.py` (modulo privado +no indexado) para el POST sobre `urllib.request` de stdlib (sin `requests`). Timeout HTTP +de 10s por request. diff --git a/python/functions/browser/browser_profile_register.py b/python/functions/browser/browser_profile_register.py new file mode 100644 index 00000000..b2133077 --- /dev/null +++ b/python/functions/browser/browser_profile_register.py @@ -0,0 +1,126 @@ +"""Registra/actualiza un perfil de Chromium (y opcionalmente sus cuentas) en osint_db. + +Wrapper cliente del service local `osint_db` (FastAPI + DuckDB single-writer) que +mantiene el catalogo de perfiles del navegador usados para investigaciones multicuenta +OSINT. En una sola llamada hace: + +1. POST /api/browser-profile con la metadata del perfil (upsert idempotente). +2. Un POST /api/browser-profile/account por cada cuenta de la lista `accounts`. + +Funcion impura: hace red (HTTP al service). No lanza; devuelve un dict de estado. +El service responde SIEMPRE HTTP 200 con body `{"status":...}` (se parsea el body). +""" + +from browser._osint_db_client import post_json + + +def browser_profile_register( + profile_dir: str, + label: str = "", + persona: str = "", + purpose: str = "", + note_path: str = "", + tags: list | None = None, + notes: str = "", + user_data_dir: str = "", + status: str = "active", + accounts: list | None = None, + base_url: str = "http://127.0.0.1:8771", +) -> dict: + """Registra o actualiza un perfil Chromium y sus cuentas en el catalogo osint_db. + + Args: + profile_dir: nombre del directorio real del perfil Chromium (ej. "Profile 1", + "Default", "osint_01"). Es la PK del perfil; el upsert es idempotente sobre el. + label: etiqueta humana del perfil (ej. "Persona Maria - OSINT"). "" para omitir. + persona: identidad/alias ficticio asociado al perfil. "" para omitir. + purpose: proposito de la investigacion (ej. "rastreo cuentas falsas"). "" para omitir. + note_path: ruta (rel al vault) de la nota OSINT ligada al perfil. "" para omitir. + tags: lista de strings de etiquetas (ej. ["osint", "sock-puppet"]). None -> []. + notes: notas libres sobre el perfil. "" para omitir. + user_data_dir: directorio user-data-dir de Chromium si NO es el default del wrapper. + "" -> el perfil hereda el default chromium-cdp al abrirlo. + status: estado del perfil (active|archived|burned...). Default "active". + accounts: lista de dicts de cuentas a registrar, cada uno + {service, identity, secret_ref?, role?, status?, notes?}. None -> sin cuentas. + `secret_ref` es una REFERENCIA al secreto (ej. "pass show osint/p1/gmail"), + NUNCA el password en claro. + base_url: base del service osint_db. Default http://127.0.0.1:8771. + + Returns: + Caso ok: {"status":"ok", "profile_dir": str, "accounts": int (cuentas registradas + con exito), "account_errors": list (errores por cuenta, vacia si todo OK)}. + Caso error (fallo del POST del perfil): {"status":"error", "error": str}. + """ + try: + profile_payload: dict = {"profile_dir": profile_dir, "status": status} + if label: + profile_payload["label"] = label + if persona: + profile_payload["persona"] = persona + if purpose: + profile_payload["purpose"] = purpose + if note_path: + profile_payload["note_path"] = note_path + if tags: + profile_payload["tags"] = list(tags) + if notes: + profile_payload["notes"] = notes + if user_data_dir: + profile_payload["user_data_dir"] = user_data_dir + + resp = post_json(base_url, "/api/browser-profile", profile_payload) + if resp.get("status") != "ok": + return { + "status": "error", + "error": resp.get("error", f"el service rechazo el perfil: {resp}"), + } + + registered_accounts = 0 + account_errors: list = [] + for acc in accounts or []: + if not isinstance(acc, dict) or not acc.get("service") or not acc.get("identity"): + account_errors.append( + {"account": acc, "error": "cuenta requiere al menos {service, identity}"} + ) + continue + acc_payload = {"profile_dir": profile_dir} + for key in ("service", "identity", "secret_ref", "role", "status", "notes"): + if acc.get(key): + acc_payload[key] = acc[key] + acc_resp = post_json(base_url, "/api/browser-profile/account", acc_payload) + if acc_resp.get("status") == "ok": + registered_accounts += 1 + else: + account_errors.append( + { + "account": {"service": acc.get("service"), "identity": acc.get("identity")}, + "error": acc_resp.get("error", str(acc_resp)), + } + ) + + return { + "status": "ok", + "profile_dir": profile_dir, + "accounts": registered_accounts, + "account_errors": account_errors, + } + except Exception as e: # noqa: BLE001 - contrato: nunca lanzar + return {"status": "error", "error": f"{type(e).__name__}: {e}"} + + +if __name__ == "__main__": + # Smoke contra un puerto muerto: ejercita la degradacion graceful (service inaccesible). + res = browser_profile_register( + "Profile 1", + label="Persona Maria - OSINT", + persona="maria_ficticia", + purpose="rastreo cuentas falsas", + tags=["osint", "sock-puppet"], + accounts=[{"service": "gmail", "identity": "maria@example.com", + "secret_ref": "pass show osint/p1/gmail"}], + base_url="http://127.0.0.1:1", + ) + assert res["status"] == "error", res + print("browser_profile_register smoke OK (service caido -> status error)") + print(f" {res}") diff --git a/python/functions/browser/browser_profile_register_test.py b/python/functions/browser/browser_profile_register_test.py new file mode 100644 index 00000000..5893421e --- /dev/null +++ b/python/functions/browser/browser_profile_register_test.py @@ -0,0 +1,112 @@ +"""Tests para browser_profile_register. + +browser_profile_register hace POST al service osint_db (perfil + cuentas). Aqui se +mockea el helper compartido `post_json` (ligado en el modulo por el `from +browser._osint_db_client import post_json`) para NO tocar el service real. Se valida +el armado de los payloads y el conteo/errores de cuentas. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import browser.browser_profile_register as bpr +from browser.browser_profile_register import browser_profile_register + + +class _PostSpy: + """Registra cada (path, payload) y devuelve respuestas segun el path.""" + + def __init__(self, profile_resp, account_resp): + self.calls = [] # lista de (path, payload) + self.profile_resp = profile_resp + self.account_resp = account_resp + + def __call__(self, base_url, path, payload): + self.calls.append((path, payload)) + if path == "/api/browser-profile": + return self.profile_resp + return self.account_resp + + +def test_golden_registra_perfil_con_dos_cuentas(monkeypatch): + spy = _PostSpy( + profile_resp={"status": "ok", "profile_dir": "Profile 1", "inserted": 1, "updated": 0}, + account_resp={"status": "ok", "id": "x", "inserted": 1, "updated": 0}, + ) + monkeypatch.setattr(bpr, "post_json", spy) + + res = browser_profile_register( + "Profile 1", + label="Persona Maria", + persona="maria_ficticia", + purpose="rastreo", + tags=["osint", "sock-puppet"], + accounts=[ + {"service": "gmail", "identity": "maria@example.com", + "secret_ref": "pass show osint/p1/gmail"}, + {"service": "x", "identity": "@maria_fake", "role": "primary"}, + ], + ) + + assert res["status"] == "ok" + assert res["profile_dir"] == "Profile 1" + assert res["accounts"] == 2 + assert res["account_errors"] == [] + + # 1 POST de perfil + 2 POST de cuentas = 3 llamadas. + assert len(spy.calls) == 3 + profile_path, profile_payload = spy.calls[0] + assert profile_path == "/api/browser-profile" + assert profile_payload["profile_dir"] == "Profile 1" + assert profile_payload["label"] == "Persona Maria" + assert profile_payload["tags"] == ["osint", "sock-puppet"] + assert profile_payload["status"] == "active" + # user_data_dir vacio no debe ir en el payload. + assert "user_data_dir" not in profile_payload + + # Las cuentas llevan profile_dir y solo las claves no vacias. + acc_path, acc_payload = spy.calls[1] + assert acc_path == "/api/browser-profile/account" + assert acc_payload["profile_dir"] == "Profile 1" + assert acc_payload["service"] == "gmail" + assert acc_payload["secret_ref"] == "pass show osint/p1/gmail" + + +def test_edge_cuenta_invalida_se_reporta_y_no_se_envia(monkeypatch): + spy = _PostSpy( + profile_resp={"status": "ok", "profile_dir": "osint_01", "inserted": 0, "updated": 1}, + account_resp={"status": "ok"}, + ) + monkeypatch.setattr(bpr, "post_json", spy) + + res = browser_profile_register( + "osint_01", + accounts=[ + {"service": "gmail"}, # falta identity -> invalida + {"service": "x", "identity": "@ok"}, # valida + ], + ) + + assert res["status"] == "ok" + assert res["accounts"] == 1 # solo la valida se registro + assert len(res["account_errors"]) == 1 + assert "identity" in res["account_errors"][0]["error"] + # La cuenta invalida NO genero POST: 1 perfil + 1 cuenta valida = 2 llamadas. + assert len(spy.calls) == 2 + + +def test_error_post_perfil_falla_devuelve_status_error(monkeypatch): + spy = _PostSpy( + profile_resp={"status": "error", "error": "service osint_db inaccesible"}, + account_resp={"status": "ok"}, + ) + monkeypatch.setattr(bpr, "post_json", spy) + + res = browser_profile_register("Profile 1", accounts=[{"service": "g", "identity": "a@b"}]) + + assert res["status"] == "error" + assert "inaccesible" in res["error"] + # Si el perfil falla, NO se intentan las cuentas: solo 1 llamada (la del perfil). + assert len(spy.calls) == 1 diff --git a/python/functions/browser/browser_profile_show.md b/python/functions/browser/browser_profile_show.md new file mode 100644 index 00000000..223cf169 --- /dev/null +++ b/python/functions/browser/browser_profile_show.md @@ -0,0 +1,70 @@ +--- +name: browser_profile_show +kind: function +lang: py +domain: browser +version: "1.0.0" +purity: impure +signature: "def browser_profile_show(profile_dir: str, base_url: str = 'http://127.0.0.1:8771') -> dict" +description: "Muestra un perfil de Chromium del catalogo del service osint_db con todas sus cuentas. Hace dos POST /api/query (read-only): el perfil (1 fila de browser_profiles WHERE profile_dir=?) y sus cuentas (N filas de browser_profile_accounts WHERE profile_dir=?). Devuelve la metadata del perfil y la lista de cuentas (con sus secret_ref, que son REFERENCIAS al secreto, no el password). Si el perfil no existe devuelve status error. El service responde SIEMPRE HTTP 200 con body {status:ok|error}. Impura (red). No lanza: devuelve dict de estado." +tags: [browser-profiles, osint, chromium, profile, multicuenta] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_golden_muestra_perfil_y_cuentas", "test_edge_perfil_no_existe_devuelve_error", "test_error_query_falla_devuelve_status_error"] +test_file_path: "python/functions/browser/browser_profile_show_test.py" +file_path: "python/functions/browser/browser_profile_show.py" +params: + - name: profile_dir + desc: "Nombre del directorio real del perfil Chromium (ej. 'Profile 1', 'osint_01'). Es la PK por la que se busca." + - name: base_url + desc: "Base del service osint_db. Default http://127.0.0.1:8771." +output: "dict de estado. Caso ok: {status:'ok', profile: dict (metadata: profile_dir, user_data_dir, label, persona, purpose, status, note_path, tags, notes, created_at, updated_at), accounts: list de dicts (cuentas con id, profile_dir, service, identity, secret_ref, role, status, notes, timestamps; posiblemente vacia)}. Caso no existe: {status:'error', error:'perfil no encontrado: '}. Caso service caido/query rechazada: {status:'error', error: str}." +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.browser_profile_show import browser_profile_show + +res = browser_profile_show("Profile 1") +if res["status"] == "ok": + print(res["profile"]["persona"]) # alias ficticio + for a in res["accounts"]: + print(a["service"], a["identity"], a["secret_ref"]) + # ej: gmail maria@example.com pass show osint/p1/gmail +``` + +## Cuando usarla + +Cuando necesites el detalle completo de UN perfil concreto: su persona/proposito y todas +sus cuentas con los `secret_ref` para saber que credenciales usar. Es la lectura de +inspeccion previa a operar con ese perfil. La compone internamente `browser_profile_open` +para resolver el `user_data_dir` y devolver las cuentas al lanzar el navegador. + +## Gotchas + +- **Impura**: hace red (dos HTTP POST /api/query al service). El service `osint_db` debe + estar vivo en `http://127.0.0.1:8771`. Si esta caido, devuelve `{status:'error', error:'... inaccesible'}` + sin lanzar. +- **El codigo HTTP NO indica exito**: el service responde SIEMPRE HTTP 200 con body + `{status:ok|error}`; se parsea el body. +- **secret_ref NO es el password**: las cuentas traen el `secret_ref` (REFERENCIA, ej. + `"pass show osint/p1/gmail"`), nunca la credencial en claro. Resolver con `pass` en el + momento de usar. +- **Perfil inexistente = status error**: si el `profile_dir` no esta en el catalogo, + devuelve `{status:'error', error:'perfil no encontrado: ...'}` (no es un fallo de red). + En ese caso NO se consulta la tabla de cuentas. +- **Read-only**: dos SELECT; no muta nada. + +## Notas + +Usa el helper compartido `python/functions/browser/_osint_db_client.py` (modulo privado +no indexado) para los POST sobre `urllib.request` de stdlib (sin `requests`). Las cuentas +se ordenan por `service, identity`. Timeout HTTP de 10s por request. diff --git a/python/functions/browser/browser_profile_show.py b/python/functions/browser/browser_profile_show.py new file mode 100644 index 00000000..16138b7d --- /dev/null +++ b/python/functions/browser/browser_profile_show.py @@ -0,0 +1,74 @@ +"""Muestra un perfil Chromium del catalogo osint_db con todas sus cuentas. + +Wrapper cliente del service local `osint_db`: hace dos POST /api/query (read-only): +1. El perfil (1 fila de `browser_profiles` WHERE profile_dir=?). +2. Sus cuentas (N filas de `browser_profile_accounts` WHERE profile_dir=?). + +Funcion impura: hace red (HTTP al service). No lanza; devuelve un dict de estado. +""" + +from browser._osint_db_client import query, rows_to_dicts + +_SQL_PROFILE = ( + "SELECT profile_dir, user_data_dir, label, persona, purpose, status, note_path, " + "tags, notes, created_at, updated_at " + "FROM browser_profiles WHERE profile_dir = ?" +) + +_SQL_ACCOUNTS = ( + "SELECT id, profile_dir, service, identity, secret_ref, role, status, notes, " + "created_at, updated_at " + "FROM browser_profile_accounts WHERE profile_dir = ? ORDER BY service, identity" +) + + +def browser_profile_show( + profile_dir: str, + base_url: str = "http://127.0.0.1:8771", +) -> dict: + """Muestra un perfil Chromium concreto con todas sus cuentas. + + Args: + profile_dir: nombre del directorio real del perfil Chromium (ej. "Profile 1", + "osint_01"). Es la PK por la que se busca. + base_url: base del service osint_db. Default http://127.0.0.1:8771. + + Returns: + Caso ok: {"status":"ok", "profile": dict (metadata del perfil), + "accounts": list de dicts (cuentas, posiblemente vacia)}. + Caso no existe: {"status":"error", "error": "perfil no encontrado: "}. + Caso error (service caido o query rechazada): {"status":"error", "error": str}. + """ + try: + prof_resp = query(base_url, _SQL_PROFILE, [profile_dir], max_rows=1) + if prof_resp.get("status") != "ok": + return { + "status": "error", + "error": prof_resp.get("error", f"el service rechazo la query: {prof_resp}"), + } + profiles = rows_to_dicts(prof_resp) + if not profiles: + return {"status": "error", "error": f"perfil no encontrado: {profile_dir}"} + + acc_resp = query(base_url, _SQL_ACCOUNTS, [profile_dir]) + if acc_resp.get("status") != "ok": + return { + "status": "error", + "error": acc_resp.get("error", f"el service rechazo la query de cuentas: {acc_resp}"), + } + + return { + "status": "ok", + "profile": profiles[0], + "accounts": rows_to_dicts(acc_resp), + } + except Exception as e: # noqa: BLE001 - contrato: nunca lanzar + return {"status": "error", "error": f"{type(e).__name__}: {e}"} + + +if __name__ == "__main__": + # Smoke contra un puerto muerto: ejercita la degradacion graceful (service inaccesible). + res = browser_profile_show("Profile 1", base_url="http://127.0.0.1:1") + assert res["status"] == "error", res + print("browser_profile_show smoke OK (service caido -> status error)") + print(f" {res}") diff --git a/python/functions/browser/browser_profile_show_test.py b/python/functions/browser/browser_profile_show_test.py new file mode 100644 index 00000000..d7c3ce22 --- /dev/null +++ b/python/functions/browser/browser_profile_show_test.py @@ -0,0 +1,96 @@ +"""Tests para browser_profile_show. + +Se mockea el helper `query` (ligado en el modulo por el `from browser._osint_db_client +import query`) para validar las dos queries (perfil + cuentas), el caso perfil-no-existe +y la propagacion de error. NO toca el service real. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import browser.browser_profile_show as bps +from browser.browser_profile_show import browser_profile_show + + +class _QueryRouter: + """Devuelve una respuesta distinta segun si el SQL es de perfil o de cuentas.""" + + def __init__(self, profile_ret, accounts_ret): + self.calls = [] # lista de (sql, params) + self.profile_ret = profile_ret + self.accounts_ret = accounts_ret + + def __call__(self, base_url, sql, params=None, max_rows=None): + self.calls.append((sql, params)) + if "browser_profile_accounts" in sql: + return self.accounts_ret + return self.profile_ret + + +def test_golden_muestra_perfil_y_cuentas(monkeypatch): + profile_ret = { + "status": "ok", + "columns": ["profile_dir", "user_data_dir", "label", "persona", "purpose", + "status", "note_path", "tags", "notes", "created_at", "updated_at"], + "rows": [["Profile 1", "", "Maria", "maria_fake", "rastreo", "active", + "notes/p1.md", '["osint"]', "n", "t0", "t1"]], + "row_count": 1, + } + accounts_ret = { + "status": "ok", + "columns": ["id", "profile_dir", "service", "identity", "secret_ref", "role", + "status", "notes", "created_at", "updated_at"], + "rows": [ + ["Profile 1:gmail:maria@example.com", "Profile 1", "gmail", + "maria@example.com", "pass show osint/p1/gmail", "primary", "active", + "", "t0", "t1"], + ], + "row_count": 1, + } + router = _QueryRouter(profile_ret, accounts_ret) + monkeypatch.setattr(bps, "query", router) + + res = browser_profile_show("Profile 1") + + assert res["status"] == "ok" + assert res["profile"]["profile_dir"] == "Profile 1" + assert res["profile"]["persona"] == "maria_fake" + assert len(res["accounts"]) == 1 + assert res["accounts"][0]["service"] == "gmail" + assert res["accounts"][0]["secret_ref"] == "pass show osint/p1/gmail" + + # Se hicieron dos queries, ambas con el profile_dir como param. + assert len(router.calls) == 2 + assert router.calls[0][1] == ["Profile 1"] + assert router.calls[1][1] == ["Profile 1"] + + +def test_edge_perfil_no_existe_devuelve_error(monkeypatch): + # El perfil no aparece (0 filas); no se debe llegar a consultar cuentas. + router = _QueryRouter( + profile_ret={"status": "ok", "columns": ["profile_dir"], "rows": [], "row_count": 0}, + accounts_ret={"status": "ok", "columns": [], "rows": [], "row_count": 0}, + ) + monkeypatch.setattr(bps, "query", router) + + res = browser_profile_show("inexistente") + + assert res["status"] == "error" + assert "no encontrado" in res["error"] + # Solo se ejecuto la query del perfil (1 llamada), no la de cuentas. + assert len(router.calls) == 1 + + +def test_error_query_falla_devuelve_status_error(monkeypatch): + router = _QueryRouter( + profile_ret={"status": "error", "error": "service osint_db inaccesible en ..."}, + accounts_ret={"status": "ok", "columns": [], "rows": []}, + ) + monkeypatch.setattr(bps, "query", router) + + res = browser_profile_show("Profile 1") + + assert res["status"] == "error" + assert "inaccesible" in res["error"] diff --git a/python/functions/browser/extract_cmp_tcf.md b/python/functions/browser/extract_cmp_tcf.md new file mode 100644 index 00000000..c9a79abc --- /dev/null +++ b/python/functions/browser/extract_cmp_tcf.md @@ -0,0 +1,155 @@ +--- +name: extract_cmp_tcf +kind: function +lang: py +domain: browser +version: "1.3.0" +purity: impure +signature: "def extract_cmp_tcf(url: str, *, port: int = 9222, wait_load_s: float = 7.0, settle_s: float = 5.0, timeout_s: float = 30.0, accept_first: bool = False, settle_accept_s: float = 4.0, llm_fallback: bool = False) -> dict" +description: "Navega por CDP a un Chrome con remote debugging, detecta el CMP (Consent Management Platform: Didomi, OneTrust, Sourcepoint, Quantcast u otro TCF) de un sitio web y lee su objeto IAB TCF v2 para contar vendors (data brokers) y propositos declarados, mas detectar muro pago-o-consientes. Pensado para escanear masivamente periodicos espanoles y cruzar vendor IDs contra la GVL." +tags: [cdp, browser, consent, cmp, tcf, iab, privacy, data-broker, python, navegator] +uses_functions: [cdp_eval_py_browser, find_consent_controls_llm_py_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["json", "os", "sys", "time"] +params_schema: + params: + - name: url + desc: "URL del sitio a escanear. Se navega la pestana activa del Chrome con remote debugging hacia esta URL." + - name: port + desc: "Puerto de remote debugging de Chrome. Default 9222. Usar 9333 para el Chrome aislado del MCP (NO 9222 si es el navegador personal del usuario)." + - name: wait_load_s + desc: "Segundos a esperar tras navegar para que la pagina cargue. Default 7.0." + - name: settle_s + desc: "Segundos extra para que el CMP termine de inicializar antes de arrancar el volcado del TCF. Default 5.0. Subir (8-10) para CMPs lentos que inyectan __tcfapi de forma diferida." + - name: timeout_s + desc: "Timeout (segundos) para cada evaluacion CDP. Default 30.0." + - name: accept_first + desc: "Si True, antes de leer el TCData definitivo intenta ACEPTAR el banner de consentimiento (clic en 'aceptar todo': selectores conocidos de Didomi/OneTrust/Quantcast + fallback por texto del boton), espera settle_accept_s y re-ejecuta el volcado del TCF. Necesario para CMPs (Quantcast) que no exponen vendors pre-consent: devuelven consents/legitimateInterests vacios hasta que el usuario interactua. Default False (no toca el banner, comportamiento identico al historico)." + - name: settle_accept_s + desc: "Segundos a esperar tras aceptar el banner para que el CMP re-emita el TCData poblado. Default 4.0. Solo aplica si accept_first=True." + - name: llm_fallback + desc: "Si True (y accept_first=True), SOLO cuando el intento normal de aceptar deja vendor_ids vacio tras leer el TCData recurre a find_consent_controls_llm (haiku, max_candidates=80) para localizar el control 'aceptar todo' que los selectores hardcodeados no encontraron, lo clica via cdp_eval, espera settle_accept_s y re-ejecuta el volcado del TCF. Default False (nunca llama al LLM, comportamiento identico). El LLM solo se invoca cuando hace falta de verdad: si el flujo de selectores/texto ya recupero vendors, NO gasta la llamada a ask_llm (ni siquiera cuando el clic salio 'no-button' pero habia vendors, p.ej. Didomi que expone getRequiredVendorIds sin consentir). Gotcha: cada sitio que dispare el fallback consume una llamada a ask_llm (rate limits)." + output: "dict plano. Caso ok: {status:'ok', url, final_url, title, cmp:'didomi'|'onetrust'|'sourcepoint'|'quantcast'|'otro_tcf'|'ninguno', cmp_id:int|None, tcf_policy:int|None, gdpr_applies:bool|None, n_vendors:int, n_vendors_total:int|None, n_vendors_required:int|None, n_purposes:int|None, tcstring_len:int, paywall_consent:bool, vendor_ids:[int]}. Cuando accept_first=True se anade ademas accept_method (str): lo que devolvio el JS de clic ('sel:', 'text:' o 'no-button'); si se dispara el fallback LLM pasa a 'llm:' (clic LLM exitoso) o 'llm:no-control' (el LLM no encontro control). Cuando se dispara el fallback LLM se anaden llm_used:True y llm_reason:str (la explicacion del locator); si llm_fallback=False o el flujo normal ya dio vendors, esos campos NO aparecen. vendor_ids es generico para cualquier CMP TCF v2: si Didomi expone los required ids se usan esos (lo que el sitio solicita); si no (Quantcast, Sourcepoint, otro_tcf) se usa la union de claves de tcData.vendor.consents + legitimateInterests. n_vendors = len(vendor_ids) cuando hay lista. Caso fallo: {status:'error', url, error:str}. Nunca lanza." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/browser/extract_cmp_tcf.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.extract_cmp_tcf import extract_cmp_tcf + +# Requiere un Chrome lanzado con --remote-debugging-port=9333 (el aislado del MCP). +res = extract_cmp_tcf("https://www.lavanguardia.com", port=9333) +print(res["status"], res["cmp"], res["n_vendors"], res["paywall_consent"]) +# -> ok didomi 700 True (recuentos reales varian por sitio/fecha) +``` + +Para CMPs que NO exponen vendors pre-consent (Quantcast), aceptar el banner primero: + +```python +# bolsamania.com / confilegal.com usan Quantcast: pre-consent dan 0 vendors. +res = extract_cmp_tcf("https://www.bolsamania.com", port=9335, accept_first=True) +print(res["cmp"], len(res["vendor_ids"]), res["accept_method"]) +# -> quantcast 1613 sel:.qc-cmp2-summary-buttons button[mode=primary] +``` + +Para CMP con clases dinamicas / texto no estandar donde el clic por selectores +sale `no-button`, activar el fallback LLM (haiku localiza el "aceptar todo"): + +```python +# Solo gasta ask_llm si el flujo de selectores fallo de verdad. +res = extract_cmp_tcf("https://www.periodistadigital.com", port=9335, + accept_first=True, llm_fallback=True) +print(res["accept_method"], res.get("llm_used"), len(res["vendor_ids"])) +# -> llm:[data-fnllm="3"] True 812 (si el LLM localizo el control) +# En sitios que ya dieron vendors por selector, llm_used NO aparece. +``` + +O directo por CLI: `python3 python/functions/browser/extract_cmp_tcf.py "https://www.lavanguardia.com" 9333` +(tercer arg `1`/`accept` activa `accept_first`; cuarto arg `1`/`llm` activa `llm_fallback`). + +## Cuando usarla + +Cuando necesites auditar de forma masiva que data brokers (vendors IAB TCF) y +propositos declara el banner de cookies de un sitio: escaneo de periodicos, paneles +de prensa, o cualquier corpus de webs con muro de consentimiento. Devuelve un dict +plano listo para volcar a una tabla (DuckDB / Excel) y cruzar `vendor_ids` contra la +Global Vendor List. Usala como paso de captura dentro de un pipeline de escaneo; los +`vendor_ids` enriquecidos con la GVL dan el nombre de cada data broker. + +## Gotchas + +- Requiere un Chrome lanzado con `--remote-debugging-port=` y al menos una + pestana de tipo `page`. Sin remote debugging, la navegacion falla y devuelve + `{"status":"error", ...}`. **NO usar el puerto 9222 si es el navegador personal** + (tiene sesiones del usuario abiertas): usar 9333, el Chrome aislado del MCP. +- Navega la **pestana activa** (`location.href = url`) — reusa el target que elija + `cdp_eval` (primer `page`). No abre pestana nueva; si necesitas aislar, abre una + pestana dedicada antes. +- El CMP puede tardar en inicializar. Si `n_vendors` sale 0 o `cmp` sale `otro_tcf` + cuando esperabas Didomi, sube `wait_load_s` / `settle_s` — algunos sitios cargan + el SDK del CMP de forma diferida. +- El stub `__tcfapi('getTCData', 2, cb)` **encola** el callback hasta que el CMP real + carga; por eso hay dos pasadas (arrancar volcado, luego leer `window.__tcdump`). Si + el usuario aun no acepto el banner, los recuentos de `vendor.consents` pueden ser 0 + pero `vendor.legitimateInterests` y el recuento de Didomi suelen estar poblados. +- Headless puede ser **detectado** por algunos CMP (cambian comportamiento o no + cargan). Para resultados fiables usar un Chrome con UI (el del MCP, 9333). +- `vendor_ids` se obtiene de forma **generica** para cualquier CMP TCF v2: con Didomi + se usan los `getRequiredVendorIds()` (lo que el sitio realmente solicita); con + cualquier otro CMP (Quantcast, Sourcepoint, `otro_tcf`) se usa la **union de claves** + de `tcData.vendor.consents` + `tcData.vendor.legitimateInterests` (los IDs del + universo GVL que el CMP tiene configurado). Antes de v1.1.0 solo Didomi rellenaba + `vendor_ids`; los demas CMP TCF quedaban con la lista vacia y `n_vendors=0`. +- `n_vendors` = `len(vendor_ids)` cuando hay lista resuelta; si no, cae a la mejor + estimacion `didomi_required` > `didomi_total_vendors` > `n_vendor_li`. +- Si un sitio TCF sigue devolviendo `vendor_ids` vacio, casi siempre es porque el CMP + inyecta `__tcfapi` de forma muy diferida: sube `settle_s` a 8-10 en esa llamada. +- **Quantcast (cmp_id 10) pre-consent devuelve TCData vacio**: mientras el banner solo + esta mostrado (`eventStatus:"cmpuishown"`, `tcString` vacio), `vendor.consents`, + `vendor.legitimateInterests` y `vendor.disclosedVendors` estan TODOS a 0 — no hay + forma de leer vendors sin que el usuario interactue con el banner. En cuanto se + acepta (o se rechaza) el banner, el TCData se puebla y la funcion extrae cientos/miles + de vendor_ids correctamente (verificado: bolsamania.com pasa de 0 a 1613 vendors tras + cerrar el banner). Didomi NO sufre esto: expone `getRequiredVendorIds()` aunque no + haya consentimiento. Para escaneo masivo de sitios Quantcast, pasar `accept_first=True` + (desde v1.2.0): la funcion acepta el banner por selector/texto antes de leer el TCF. +- **`accept_first=True` clica desde el documento PRINCIPAL**: los selectores conocidos + (`#didomi-notice-agree-button`, `#onetrust-accept-btn-handler`, + `.qc-cmp2-summary-buttons button[mode=primary]`, `button[aria-label*=Aceptar/Accept]`) + y el fallback por texto del boton funcionan para Didomi, OneTrust y Quantcast porque + renderizan el banner en el DOM de la pagina. **Sourcepoint mete el banner dentro de un + `