feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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=<wid> pid=<pid> port=<port> tab=<target_id_o_->'. 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 <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/<target_id>` 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.
|
||||||
@@ -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 <port> [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: <wid> <desktop> <pid> <host> <title...>
|
||||||
|
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 <port> [target_id]
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
focus_cdp_tab_window "$@"
|
||||||
|
fi
|
||||||
@@ -3,10 +3,10 @@ name: launch_fleetclaude
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.3.2"
|
version: "1.4.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--cols <n>]"
|
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
|
||||||
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."
|
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]
|
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
||||||
params:
|
params:
|
||||||
- name: --cwd
|
- name: --cwd
|
||||||
@@ -14,7 +14,9 @@ params:
|
|||||||
- name: --bin
|
- name: --bin
|
||||||
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
|
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
|
||||||
- name: --session
|
- 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
|
- name: --cols
|
||||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
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."
|
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):
|
# Via fn run (resuelve por nombre o ID):
|
||||||
fn run launch_fleetclaude
|
fn run launch_fleetclaude
|
||||||
|
|
||||||
# Directo, con cwd explicito:
|
# Perfil nuevo automatico (fleet la 1a vez; fleet2, fleet3, ... si ya hay uno):
|
||||||
launch_fleetclaude --cwd ~/fn_registry
|
launch_fleetclaude
|
||||||
|
|
||||||
# Sesion y ancho de pane personalizados:
|
# Reattach a la flota principal 'fleet' (comportamiento idempotente clasico):
|
||||||
launch_fleetclaude --session fleet --cols 50
|
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
|
Tras invocarlo aparece una ventana kitty titulada `FleetView (<perfil>)` con dos
|
||||||
lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
|
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
|
`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux
|
||||||
sesion: reusa la existente y solo abre otra kitty adjunta.
|
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 <nombre>` para volver a una flota concreta.
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
@@ -57,9 +64,23 @@ al retomar el trabajo en el repo `fn_registry`.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Idempotencia tmux**: si la sesion `<session>` (default `fleet`) ya existe,
|
- **Perfiles multiples (default = perfil nuevo)**: sin `--session` ni `--reuse`,
|
||||||
NO se recrea el layout; solo se abre una kitty nueva adjunta a la misma
|
cada invocacion abre un perfil NUEVO usando el primer nombre libre de la
|
||||||
sesion. Para empezar de cero: `tmux kill-session -t fleet` antes de invocar.
|
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 <nombre>` (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
|
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
||||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
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
|
- **`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`.
|
|||||||
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
||||||
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
||||||
silencio. Compila la TUI antes para el flujo completo.
|
silencio. Compila la TUI antes para el flujo completo.
|
||||||
- **Socket tmux aislado (`-L fleet`)**: toda la sesion vive en un server tmux
|
- **Socket tmux aislado por perfil (`-L <perfil>`)**: cada perfil vive en su
|
||||||
propio, separado del tmux por defecto del usuario. Asi los atajos `bind -n`
|
propio server tmux (socket = nombre del perfil), separado del tmux por defecto
|
||||||
NO afectan otras sesiones (ej. una sesion `mobile-1` del movil) y matar el
|
del usuario y de los demas perfiles. Asi los atajos `bind -n` NO afectan otras
|
||||||
server fleet no toca nada mas: `tmux -L fleet kill-server`.
|
sesiones (ej. una sesion `mobile-1` del movil) y matar un perfil no toca los
|
||||||
|
otros: `tmux -L <perfil> kill-server` (o `alt+q` dentro de la TUI).
|
||||||
- **Atajos en el socket, NO en kitty.conf**: instala `bind -n` para
|
- **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
|
`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
|
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
|
## 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 (<perfil>)`. 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
|
- 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
|
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`
|
`~/.tmux.conf` define `base-index 1`/`pane-base-index 1` (el socket `-L fleet`
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ launch_fleetclaude() {
|
|||||||
local bin=""
|
local bin=""
|
||||||
local session="fleet"
|
local session="fleet"
|
||||||
local cols=52
|
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 <name> 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
|
# Parseo de argumentos
|
||||||
@@ -39,6 +41,10 @@ launch_fleetclaude() {
|
|||||||
--session)
|
--session)
|
||||||
shift
|
shift
|
||||||
session="${1:-}"
|
session="${1:-}"
|
||||||
|
explicit_session=1
|
||||||
|
;;
|
||||||
|
--reuse)
|
||||||
|
reuse=1
|
||||||
;;
|
;;
|
||||||
--cols)
|
--cols)
|
||||||
shift
|
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
|
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.
|
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:
|
Opciones:
|
||||||
--cwd <dir> Directorio de trabajo de los panes.
|
--cwd <dir> Directorio de trabajo de los panes.
|
||||||
Default: raiz del repo fn_registry (derivada dinamicamente).
|
Default: raiz del repo fn_registry (derivada dinamicamente).
|
||||||
--bin <path> Ruta al binario de la TUI fleetview.
|
--bin <path> Ruta al binario de la TUI fleetview.
|
||||||
Default: <repo>/apps/fleetview/fleetview
|
Default: <repo>/apps/fleetview/fleetview
|
||||||
--session <name> Nombre de la sesion tmux. Default: fleet.
|
--session <name> 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 <n> Ancho (columnas) del pane izquierdo. Default: 40.
|
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
|
||||||
-h, --help Muestra esta ayuda.
|
-h, --help Muestra esta ayuda.
|
||||||
|
|
||||||
Ejemplos:
|
Ejemplos:
|
||||||
launch_fleetclaude
|
launch_fleetclaude # perfil nuevo (fleet, luego fleet2, ...)
|
||||||
launch_fleetclaude --cwd ~/fn_registry
|
launch_fleetclaude --reuse # reattach a la flota principal 'fleet'
|
||||||
launch_fleetclaude --session fleet --cols 50
|
launch_fleetclaude --session trabajo # perfil con nombre fijo 'trabajo'
|
||||||
|
launch_fleetclaude --cwd ~/fn_registry --cols 50
|
||||||
USAGE
|
USAGE
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
@@ -111,6 +126,34 @@ USAGE
|
|||||||
echo "launch_fleetclaude: tmux no esta instalado." >&2
|
echo "launch_fleetclaude: tmux no esta instalado." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Resolver el PERFIL (socket+sesion tmux comparten nombre).
|
||||||
|
#
|
||||||
|
# - --session <name> -> 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
|
# Nota: kitty NO se exige aqui. La ruta interactiva (TTY) reutiliza la
|
||||||
# terminal actual con `exec tmux attach` y no necesita kitty. Solo 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
|
# 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 el binario fleetview existe -> ejecutarlo (exec, sin shell colgado).
|
||||||
# - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio).
|
# - 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
|
local left_cmd
|
||||||
if [[ -x "$bin" ]]; then
|
if [[ -x "$bin" ]]; then
|
||||||
left_cmd="exec $(printf '%q' "$bin")"
|
left_cmd="$envpfx exec $(printf '%q' "$bin")"
|
||||||
else
|
else
|
||||||
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
# 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\""
|
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-r send-keys -t "$left_pane" r
|
||||||
$T bind -n M-u send-keys -t "$left_pane" u
|
$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-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-Left send-keys -t "$left_pane" Escape
|
||||||
$T bind -n M-q send-keys -t "$left_pane" Q
|
$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.
|
# Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta.
|
||||||
$T set -g mouse on
|
$T set -g mouse on
|
||||||
# Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de
|
# 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.)
|
# (Mismo patron que reboot_all_claudes para relanzar terminales.)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Adjuntar la sesion:
|
# Adjuntar la sesion:
|
||||||
# - Si se invoca desde una terminal interactiva, convertir ESA terminal en
|
# - Terminal interactiva y FUERA de tmux: convertir ESA terminal en el
|
||||||
# el panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
|
# panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
|
||||||
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
|
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
|
||||||
# - Si NO hay TTY (atajo de escritorio, cron, script), abrir una ventana
|
# - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir
|
||||||
# kitty nueva desacoplada (setsid) como antes.
|
# una ventana kitty nueva desacoplada (setsid). No hacemos `attach`
|
||||||
if [ -t 0 ] && [ -t 1 ]; then
|
# anidado dentro de otra sesion tmux (rompe / da el warning de nesting).
|
||||||
exec tmux -L fleet attach -t "$session"
|
if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then
|
||||||
|
exec tmux -L "$session" attach -t "$session"
|
||||||
fi
|
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
|
if ! command -v kitty >/dev/null 2>&1; then
|
||||||
echo "launch_fleetclaude: kitty no esta instalado (necesario solo sin TTY)." >&2
|
echo "launch_fleetclaude: kitty no esta instalado (necesario para abrir ventana nueva)." >&2
|
||||||
echo "launch_fleetclaude: lanzalo desde una terminal interactiva, o instala kitty." >&2
|
echo "launch_fleetclaude: lanzalo desde una terminal interactiva fuera de tmux, o instala kitty." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
setsid kitty --title "FleetView" -e tmux -L fleet attach -t "$session" </dev/null >/dev/null 2>&1 &
|
setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" </dev/null >/dev/null 2>&1 &
|
||||||
disown 2>/dev/null || true
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: open_doc_onlyoffice
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "open_doc_onlyoffice <ruta_archivo> [--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
|
||||||
|
```
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# open_doc_onlyoffice — abre un documento ofimático con OnlyOffice Desktop Editors.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# open_doc_onlyoffice <ruta_archivo> [--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 <ruta_archivo> [--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"
|
||||||
@@ -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 |
|
| [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-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 |
|
| [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 |
|
| [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_<instance>): 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) |
|
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): 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
|
## Como anadir grupo
|
||||||
|
|
||||||
|
|||||||
@@ -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=<ruta>` 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.
|
||||||
@@ -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.
|
||||||
@@ -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_<table>_<ts>.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.
|
||||||
@@ -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/<cuenta>-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.<dominio>:993` | `mail.<dominio>: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/<cuenta>-apppass`).
|
||||||
|
- 2FA activado en las cuentas Google.
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CdpNewTabBackground abre una pestaña nueva via Target.createTarget con el
|
||||||
|
// parametro "background": true, de forma que la pestaña se crea SIN activarse y
|
||||||
|
// SIN elevar la ventana del navegador (no roba el foco del WM).
|
||||||
|
//
|
||||||
|
// Es el drop-in sin-foco de CdpNewTab: misma firma, mismo CdpTab de retorno.
|
||||||
|
// La diferencia tecnica es el mecanismo:
|
||||||
|
// - CdpNewTab usa el endpoint HTTP PUT /json/new, que NO admite background y
|
||||||
|
// por tanto SIEMPRE eleva la ventana (roba foco al usuario).
|
||||||
|
// - Aqui usamos el comando CDP browser-level Target.createTarget con
|
||||||
|
// "background": true, que en Linux/Chromium crea la pestaña en segundo plano.
|
||||||
|
//
|
||||||
|
// host vacio = "localhost". startURL vacio = "about:blank".
|
||||||
|
func CdpNewTabBackground(host string, port int, startURL string) (CdpTab, error) {
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
if startURL == "" {
|
||||||
|
startURL = "about:blank"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target.createTarget debe ejecutarse contra el browser target (no una page),
|
||||||
|
// por eso resolvemos el webSocketDebuggerUrl browser-level via /json/version.
|
||||||
|
wsURL, err := cdpGetWSURL(port)
|
||||||
|
if err != nil {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := cdpConnectWS(wsURL, port)
|
||||||
|
if err != nil {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: conectar: %w", err)
|
||||||
|
}
|
||||||
|
// Soltar solo el WebSocket; dejar el navegador vivo.
|
||||||
|
defer CdpDisconnect(conn)
|
||||||
|
|
||||||
|
res, err := conn.sendCDP("Target.createTarget", map[string]any{
|
||||||
|
"url": startURL,
|
||||||
|
"background": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: createTarget: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, _ := res["targetId"].(string)
|
||||||
|
if targetID == "" {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: createTarget no devolvio targetId")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver el CdpTab completo (con webSocketDebuggerUrl, title, etc.) buscando
|
||||||
|
// el target recien creado en /json.
|
||||||
|
tabs, err := CdpListTabs(host, port)
|
||||||
|
if err == nil {
|
||||||
|
for _, t := range tabs {
|
||||||
|
if t.ID == targetID {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback en caso de carrera (el target aun no aparece en /json): devolvemos
|
||||||
|
// un CdpTab minimo con el id, tipo y URL inicial conocidos.
|
||||||
|
return CdpTab{ID: targetID, Type: "page", URL: startURL}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: cdp_new_tab_background
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpNewTabBackground(host string, port int, startURL string) (CdpTab, error)"
|
||||||
|
description: "Abre una pestaña nueva via CDP Target.createTarget con background:true, sin activarla ni elevar la ventana del navegador (no roba el foco del WM). Drop-in sin-foco de CdpNewTab: misma firma y mismo CdpTab de retorno, pero usando el comando CDP browser-level en lugar del endpoint HTTP /json/new (que SI roba foco)."
|
||||||
|
tags: [browser, cdp, tabs, spawn, background, no-focus]
|
||||||
|
uses_functions: [cdp_list_tabs_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt]
|
||||||
|
example: |
|
||||||
|
tab, err := browser.CdpNewTabBackground("localhost", 9333, "https://example.com")
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println("nueva tab en segundo plano id=", tab.ID)
|
||||||
|
}
|
||||||
|
tested: true
|
||||||
|
tests: ["TestCdpNewTabBackground_closedPort", "TestCdpNewTabBackground_emptyStartURLClosedPort"]
|
||||||
|
test_file_path: "functions/browser/cdp_new_tab_background_test.go"
|
||||||
|
file_path: "functions/browser/cdp_new_tab_background.go"
|
||||||
|
notes: |
|
||||||
|
- Usa los helpers privados del paquete: cdpGetWSURL (browser-level WS),
|
||||||
|
cdpConnectWS, (*CDPConn).sendCDP y CdpListTabs. No reescribe el transporte CDP.
|
||||||
|
- El cierre del WebSocket se hace con CdpDisconnect (solo suelta la sesion, deja
|
||||||
|
el navegador vivo).
|
||||||
|
- Resuelve el CdpTab completo via CdpListTabs buscando por targetId; si hay
|
||||||
|
carrera y aun no aparece, devuelve un CdpTab minimo (id, type, url) como fallback.
|
||||||
|
documentation: |
|
||||||
|
Alternativa a CdpNewTab cuando NO quieres que la ventana del navegador robe el
|
||||||
|
foco del window manager — por ejemplo, mientras el usuario escribe en otra
|
||||||
|
ventana. El endpoint HTTP /json/new no admite el parametro background, asi que
|
||||||
|
CdpNewTab siempre eleva la ventana; esta funcion usa Target.createTarget con
|
||||||
|
"background": true para crear la pestaña en segundo plano.
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
desc: "Host CDP donde escucha el navegador (vacio = localhost)."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto remote-debugging de Chrome/Chromium (ej. 9333)."
|
||||||
|
- name: startURL
|
||||||
|
desc: "URL inicial de la pestaña. Vacio = about:blank."
|
||||||
|
output: "CdpTab del target recien creado (id, webSocketDebuggerUrl, title, url, ...). Error si /json/version o el comando CDP fallan."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Abrir una pestaña en segundo plano sin robar el foco del usuario.
|
||||||
|
tab, err := browser.CdpNewTabBackground("localhost", 9333, "https://example.com")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("pestaña creada en background:", tab.ID, tab.URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando abras una pestaña por CDP y NO quieras que la ventana del navegador robe
|
||||||
|
el foco del WM (el usuario esta escribiendo en otra ventana). Alternativa
|
||||||
|
sin-foco a `CdpNewTab` / endpoint HTTP `/json/new`, que siempre eleva la ventana.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion impura: abre un WebSocket al navegador y manda un comando CDP. Falla si
|
||||||
|
el puerto no responde o el comando no devuelve `targetId`.
|
||||||
|
- El parametro `background` de `Target.createTarget` no aplica en MacOS (alli la
|
||||||
|
pestaña se activa igual). Esto esta pensado para Linux/Chromium.
|
||||||
|
- Requiere conexion **browser-level** (`/json/version`), no page-level: por eso usa
|
||||||
|
`cdpGetWSURL` y no la primera tab `page`.
|
||||||
|
- Si el navegador corre headless, el foco es irrelevante — `CdpNewTab` y esta
|
||||||
|
funcion son equivalentes en ese caso.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCdpNewTabBackground_closedPort(t *testing.T) {
|
||||||
|
// Sin Chrome escuchando esperamos error de red al resolver /json/version,
|
||||||
|
// pero NO panic ni nil-deref. Puerto 1 garantizado cerrado.
|
||||||
|
_, err := CdpNewTabBackground("", 1, "https://example.com")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error talking to closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdpNewTabBackground_emptyStartURLClosedPort(t *testing.T) {
|
||||||
|
// startURL vacio debe normalizarse a about:blank sin romper; con puerto
|
||||||
|
// cerrado seguimos esperando error de red, no panic.
|
||||||
|
_, err := CdpNewTabBackground("localhost", 1, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error talking to closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectCaptchaJS es la unica evaluacion que DetectCaptcha corre en el top frame.
|
||||||
|
// Detecta reCAPTCHA, hCaptcha y Cloudflare Turnstile por la presencia de sus
|
||||||
|
// iframes/widgets (los iframe[src] son legibles desde el top aunque su contenido
|
||||||
|
// sea cross-origin) y el JS-challenge de Cloudflare por texto en innerText.
|
||||||
|
// Siempre retorna un JSON serializable; en caso de excepcion devuelve detected=false
|
||||||
|
// con un campo "error" para que el caller no rompa (best-effort).
|
||||||
|
const detectCaptchaJS = `(function(){
|
||||||
|
try {
|
||||||
|
var sigs = [];
|
||||||
|
var q = function(s){ return document.querySelector(s); };
|
||||||
|
if (q('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"], .g-recaptcha, #recaptcha')) sigs.push('recaptcha');
|
||||||
|
if (q('iframe[src*="hcaptcha.com"], .h-captcha')) sigs.push('hcaptcha');
|
||||||
|
if (q('iframe[src*="challenges.cloudflare.com"], .cf-turnstile')) sigs.push('turnstile');
|
||||||
|
var t = ((document.body && document.body.innerText) || '').toLowerCase().slice(0, 4000);
|
||||||
|
if (/checking your browser|verify(ing)? you are human|i'?m not a robot|are you a robot|unusual traffic|complete the security check|press and hold/.test(t)) sigs.push('challenge');
|
||||||
|
var seen = {}, uniq = [];
|
||||||
|
for (var i=0;i<sigs.length;i++){ if(!seen[sigs[i]]){seen[sigs[i]]=1;uniq.push(sigs[i]);} }
|
||||||
|
return JSON.stringify({detected: uniq.length>0, types: uniq, url: location.href});
|
||||||
|
} catch(e){ return JSON.stringify({detected:false, types:[], url: (location&&location.href)||'', error:String(e)}); }
|
||||||
|
})()`
|
||||||
|
|
||||||
|
// captchaResult es el shape del JSON que produce detectCaptchaJS.
|
||||||
|
type captchaResult struct {
|
||||||
|
Detected bool `json:"detected"`
|
||||||
|
Types []string `json:"types"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCaptchaSignals parsea el JSON que produce detectCaptchaJS. Es puro y
|
||||||
|
// testeable sin navegador. Si el JSON trae un campo "error" (excepcion JS en la
|
||||||
|
// pagina) se trata como detected=false best-effort, no como fallo. types es
|
||||||
|
// siempre un slice no nulo (vacio si no hay senales). Solo retorna error si el
|
||||||
|
// JSON es invalido / no parseable.
|
||||||
|
func parseCaptchaSignals(raw string) (detected bool, types []string, url string, err error) {
|
||||||
|
var r captchaResult
|
||||||
|
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||||
|
return false, nil, "", fmt.Errorf("parse captcha signals: json invalido: %w", err)
|
||||||
|
}
|
||||||
|
if r.Types == nil {
|
||||||
|
r.Types = []string{}
|
||||||
|
}
|
||||||
|
return r.Detected, r.Types, r.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectCaptcha detecta si la pagina actual presenta un captcha o challenge
|
||||||
|
// anti-bot. Corre UNA evaluacion JS en el top frame y parsea el resultado.
|
||||||
|
// NO resuelve ni notifica nada — solo detecta. Una responsabilidad.
|
||||||
|
//
|
||||||
|
// Retorna detected=true si hay al menos una senal, junto con los tipos
|
||||||
|
// detectados (subconjunto de: "recaptcha", "hcaptcha", "turnstile",
|
||||||
|
// "challenge") y la URL del top frame. Best-effort: una excepcion JS en la
|
||||||
|
// pagina se trata como "no detectado" sin romper.
|
||||||
|
func DetectCaptcha(c *CDPConn) (detected bool, types []string, url string, err error) {
|
||||||
|
if c == nil {
|
||||||
|
return false, nil, "", fmt.Errorf("detect captcha: conexion nula")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := CdpEvaluate(c, detectCaptchaJS)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, "", fmt.Errorf("detect captcha: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detected, types, url, err = parseCaptchaSignals(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, "", fmt.Errorf("detect captcha: %w", err)
|
||||||
|
}
|
||||||
|
return detected, types, url, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: detect_captcha
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DetectCaptcha(c *CDPConn) (detected bool, types []string, url string, err error)"
|
||||||
|
description: "Detecta captchas y challenges anti-bot en la pagina actual via CDP: reCAPTCHA, hCaptcha, Cloudflare Turnstile (por iframe/widget) y el JS-challenge de Cloudflare (por texto). Solo detecta — no resuelve ni notifica. Una responsabilidad."
|
||||||
|
tags: [captcha, browser, cdp, antibot, detection, perception]
|
||||||
|
uses_functions: [cdp_evaluate_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [encoding/json, fmt]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexion CDP activa a una tab de Chrome de tipo 'page'. La evaluacion corre en el top frame."
|
||||||
|
output: "Tupla (detected, types, url, err). detected=true si hay al menos una senal anti-bot. types es el subconjunto de senales detectadas (de: 'recaptcha', 'hcaptcha', 'turnstile', 'challenge'), siempre slice no nulo (vacio si nada). url es la location.href del top frame. err si la conexion es nula, falla el eval CDP, o el JSON resultante es invalido. Una excepcion JS en la pagina se trata como detected=false best-effort, sin error."
|
||||||
|
tested: true
|
||||||
|
tests: ["recaptcha detectado", "hcaptcha detectado", "turnstile detectado", "challenge por texto", "multiples senales", "ninguno", "campo error best-effort no rompe", "types ausente se normaliza a slice vacio", "json invalido devuelve error"]
|
||||||
|
test_file_path: "functions/browser/detect_captcha_test.go"
|
||||||
|
file_path: "functions/browser/detect_captcha.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Conectar a un Chrome con CDP abierto (mismo patron que cdp_get_text)
|
||||||
|
conn, err := CdpConnect(9222)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer CdpDisconnect(conn)
|
||||||
|
|
||||||
|
// Tras navegar y esperar la carga, comprobar si la pagina puso un captcha
|
||||||
|
detected, types, url, err := DetectCaptcha(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if detected {
|
||||||
|
fmt.Printf("captcha detectado en %s: %v\n", url, types)
|
||||||
|
// p.ej. -> "captcha detectado en https://x.test/login: [recaptcha]"
|
||||||
|
} else {
|
||||||
|
fmt.Println("sin captcha, seguir clicando")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Tras navegar o esperar la carga de una pagina, para saber si esta puso un captcha o challenge anti-bot antes de seguir clicando o enviando formularios. La usa el `browser_mcp` en sus handlers de navegacion para decidir el handoff humano: si `DetectCaptcha` devuelve `detected=true`, el flujo automatico se detiene y avisa para resolucion manual en vez de chocar contra el muro.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Solo top frame**: la evaluacion corre en el frame principal. Un captcha incrustado en un iframe anidado profundo cuyo `src` no matchee los patrones no se detecta.
|
||||||
|
- **Iframes cross-origin**: el contenido de los iframes de reCAPTCHA/hCaptcha/Turnstile NO se lee (politica same-origin), pero SI se detectan por su `src` y por las clases del widget host (`.g-recaptcha`, `.h-captcha`, `.cf-turnstile`), que viven en el top document.
|
||||||
|
- **Falsos positivos posibles**: la senal `challenge` viene de regex sobre `innerText` (p.ej. "verify you are human", "unusual traffic"). Una pagina con ese texto en otro contexto (un articulo, una FAQ sobre bots) puede dar `detected=true` sin haber captcha real.
|
||||||
|
- **No detecta captchas custom**: solo cubre los proveedores listados (reCAPTCHA, hCaptcha, Turnstile) + el JS-challenge de Cloudflare. Captchas propios o de otros vendors no se reconocen.
|
||||||
|
- **Depende de innerText**: la pagina debe haber pintado el body. En una tab aun cargando (`document.body` nulo o vacio) la senal `challenge` puede no dispararse — esperar con `cdp_wait_load` antes de detectar si el contenido es dinamico.
|
||||||
|
- **Impura**: hace un round-trip CDP (I/O de red). Requiere conexion activa a una tab de tipo `page`.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCaptchaSignals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantDetected bool
|
||||||
|
wantTypes []string
|
||||||
|
wantURL string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "recaptcha detectado",
|
||||||
|
raw: `{"detected":true,"types":["recaptcha"],"url":"https://x.test/login"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"recaptcha"},
|
||||||
|
wantURL: "https://x.test/login",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hcaptcha detectado",
|
||||||
|
raw: `{"detected":true,"types":["hcaptcha"],"url":"https://y.test/signup"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"hcaptcha"},
|
||||||
|
wantURL: "https://y.test/signup",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "turnstile detectado",
|
||||||
|
raw: `{"detected":true,"types":["turnstile"],"url":"https://z.test/"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"turnstile"},
|
||||||
|
wantURL: "https://z.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge por texto",
|
||||||
|
raw: `{"detected":true,"types":["challenge"],"url":"https://cf.test/"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"challenge"},
|
||||||
|
wantURL: "https://cf.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiples senales",
|
||||||
|
raw: `{"detected":true,"types":["turnstile","challenge"],"url":"https://cf.test/"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"turnstile", "challenge"},
|
||||||
|
wantURL: "https://cf.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ninguno",
|
||||||
|
raw: `{"detected":false,"types":[],"url":"https://clean.test/"}`,
|
||||||
|
wantDetected: false,
|
||||||
|
wantTypes: []string{},
|
||||||
|
wantURL: "https://clean.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campo error best-effort no rompe",
|
||||||
|
raw: `{"detected":false,"types":[],"url":"https://err.test/","error":"boom"}`,
|
||||||
|
wantDetected: false,
|
||||||
|
wantTypes: []string{},
|
||||||
|
wantURL: "https://err.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "types ausente se normaliza a slice vacio",
|
||||||
|
raw: `{"detected":false,"url":"https://n.test/"}`,
|
||||||
|
wantDetected: false,
|
||||||
|
wantTypes: []string{},
|
||||||
|
wantURL: "https://n.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json invalido devuelve error",
|
||||||
|
raw: `not-json`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
detected, types, url, err := parseCaptchaSignals(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("esperaba error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error inesperado: %v", err)
|
||||||
|
}
|
||||||
|
if detected != tt.wantDetected {
|
||||||
|
t.Errorf("detected: got %v, want %v", detected, tt.wantDetected)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(types, tt.wantTypes) {
|
||||||
|
t.Errorf("types: got %v, want %v", types, tt.wantTypes)
|
||||||
|
}
|
||||||
|
if url != tt.wantURL {
|
||||||
|
t.Errorf("url: got %q, want %q", url, tt.wantURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": <motivo>} 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
|
||||||
@@ -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.
|
||||||
@@ -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}")
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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 `<profile_dir>:<service>:<identity>`).
|
||||||
|
- **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.
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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: <profile_dir>'}. 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.
|
||||||
@@ -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: <profile_dir>"}.
|
||||||
|
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}")
|
||||||
@@ -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"]
|
||||||
@@ -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:<selector>', 'text:<texto>' o 'no-button'); si se dispara el fallback LLM pasa a 'llm:<selector>' (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=<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
|
||||||
|
`<iframe>` (`sp_message_container_*`)**: el clic desde el documento principal NO
|
||||||
|
alcanza el boton dentro del iframe, asi que `accept_method` saldra `no-button` para
|
||||||
|
Sourcepoint y los vendors seguiran sin poblarse. No esta resuelto (no hay sitios
|
||||||
|
Sourcepoint en el set actual); resolverlo requeriria evaluar el JS dentro del frame
|
||||||
|
del iframe (otro target CDP). El parametro nunca lanza por esto: simplemente reporta
|
||||||
|
`no-button`.
|
||||||
|
- **`llm_fallback=True` gasta una llamada a `ask_llm` (haiku) por cada sitio que lo
|
||||||
|
dispare** (rate limits de la API Anthropic). El fallback solo se invoca cuando el
|
||||||
|
flujo normal de selectores fallo de verdad (`vendor_ids` vacio tras leer el TCData):
|
||||||
|
los sitios cuyo CMP estandar (Didomi/OneTrust/Quantcast por selector o texto) ya
|
||||||
|
recupera vendors NO gastan la llamada. Caso clave: Didomi expone
|
||||||
|
`getRequiredVendorIds()` sin necesidad de consentir, asi que aunque el clic salga
|
||||||
|
`no-button` el `vendor_ids` ya viene poblado y el LLM **no** se dispara. Para un escaneo masivo
|
||||||
|
esto acota el gasto a los CMP con clases dinamicas / texto no estandar. El fallback
|
||||||
|
marca `accept_method='llm:<selector>'` (clic LLM exitoso), o `'llm:no-control'` si el
|
||||||
|
LLM no encontro un boton aceptable / el clic fallo, y siempre anade `llm_used:True` +
|
||||||
|
`llm_reason`. NO resuelve banners dentro de iframes (Sourcepoint): el LLM recolecta
|
||||||
|
controles del documento principal, igual que el flujo de selectores.
|
||||||
|
- Nunca lanza: cualquier error de red, CDP o parseo JSON se reporta en `error` con
|
||||||
|
`status="error"`.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.3.0 (2026-06-18) — llm_fallback: si el clic por selectores falla (no-button), usa find_consent_controls_llm (haiku) para localizar y clicar 'aceptar todo' antes de leer el TCF. Gotcha: el fallback gasta una llamada a ask_llm (rate limits) por sitio que lo necesite.
|
||||||
|
- v1.2.0 (2026-06-18) — accept_first: acepta el banner (Didomi/OneTrust/Quantcast por selector + fallback por texto) antes de leer el TCF, para CMPs que no exponen vendors pre-consent (Quantcast). Gotcha: Sourcepoint mete el banner en un iframe, el clic desde el documento principal no lo alcanza (sale 'no-button').
|
||||||
|
- v1.1.0 (2026-06-18) — vendor_ids genericos desde tcData.vendor.consents/legitimateInterests para CMPs no-Didomi (Quantcast, otro_tcf); +settle para CMPs lentos.
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Detecta el CMP (Consent Management Platform) de un sitio web y lee su objeto IAB TCF.
|
||||||
|
|
||||||
|
Navega por CDP a un Chrome con remote debugging, identifica que CMP usa la pagina
|
||||||
|
(Didomi, OneTrust, Sourcepoint, Quantcast u otro TCF generico) y vuelca su TC Data
|
||||||
|
v2 (`__tcfapi('getTCData', 2, ...)`) para contar vendors (data brokers) y propositos
|
||||||
|
declarados. Pensado para escanear masivamente periodicos espanoles y cruzar los
|
||||||
|
vendor IDs contra la GVL (Global Vendor List).
|
||||||
|
|
||||||
|
Reutiliza la primitiva de transport CDP `cdp_eval_py_browser`: navega via
|
||||||
|
`location.href = url` y evalua el JS de deteccion/volcado con la misma pestana.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Permite importar funciones del registry tanto si se ejecuta desde la raiz del
|
||||||
|
# repo (cwd) como si se invoca el modulo directamente.
|
||||||
|
_FN_ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||||
|
if _FN_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FN_ROOT)
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval # noqa: E402
|
||||||
|
from browser.find_consent_controls_llm import find_consent_controls_llm # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# JS de deteccion del CMP + arranque del volcado TCF. El stub de __tcfapi encola
|
||||||
|
# el callback hasta que el CMP termina de inicializar; el resultado queda en
|
||||||
|
# window.__tcdump y se lee en la segunda pasada.
|
||||||
|
_JS_DETECT = r"""
|
||||||
|
(function(){
|
||||||
|
var out={url:location.href,title:document.title,
|
||||||
|
has_tcfapi:typeof window.__tcfapi==='function',
|
||||||
|
has_gpp:typeof window.__gpp==='function',
|
||||||
|
didomi:!!(window.Didomi||window.didomiConfig||document.getElementById('didomi-host')||document.querySelector('[id*=didomi]')),
|
||||||
|
onetrust:!!(window.OneTrust||window.Optanon||document.getElementById('onetrust-banner-sdk')),
|
||||||
|
sourcepoint:!!(window._sp_||window.__sp||document.querySelector('[id^=sp_message_container]')),
|
||||||
|
quantcast:!!(window.__cmp||document.querySelector('.qc-cmp2-container,.qc-cmp-cleanslate'))};
|
||||||
|
window.__tcdump=null;
|
||||||
|
if(out.has_tcfapi){try{window.__tcfapi('getTCData',2,function(d,ok){
|
||||||
|
var vc=(d&&d.vendor&&d.vendor.consents)||{};
|
||||||
|
var vl=(d&&d.vendor&&d.vendor.legitimateInterests)||{};
|
||||||
|
var ids={};
|
||||||
|
Object.keys(vc).forEach(function(k){ids[k]=1;});
|
||||||
|
Object.keys(vl).forEach(function(k){ids[k]=1;});
|
||||||
|
window.__tcdump={ok:ok,cmpId:d&&d.cmpId,cmpVersion:d&&d.cmpVersion,tcfPolicyVersion:d&&d.tcfPolicyVersion,
|
||||||
|
gdprApplies:d&&d.gdprApplies,tcString_len:((d&&d.tcString)||'').length,
|
||||||
|
n_vendor_consents:Object.keys(vc).length,
|
||||||
|
n_vendor_li:Object.keys(vl).length,
|
||||||
|
n_purposes:(d&&d.purpose&&d.purpose.consents)?Object.keys(d.purpose.consents).length:0,
|
||||||
|
tcf_vendor_ids:Object.keys(ids).map(function(x){return parseInt(x,10);}).filter(function(x){return x>0;})};});}catch(e){window.__tcdump={err:String(e)};}}
|
||||||
|
return JSON.stringify(out);
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JS de clic en el boton "aceptar todo" del banner de consentimiento. Devuelve
|
||||||
|
# que metodo funciono: 'sel:<selector>', 'text:<texto>' o 'no-button'. Usado solo
|
||||||
|
# cuando accept_first=True, para CMPs (Quantcast) que no exponen vendors pre-consent.
|
||||||
|
_JS_ACCEPT = r"""
|
||||||
|
(function(){
|
||||||
|
function clk(el){ if(el){el.click(); return true;} return false; }
|
||||||
|
// 1) selectores conocidos por CMP
|
||||||
|
var sels=['#didomi-notice-agree-button','#onetrust-accept-btn-handler',
|
||||||
|
'.qc-cmp2-summary-buttons button[mode=primary]',
|
||||||
|
'button[aria-label*=Aceptar]','button[aria-label*=Accept]'];
|
||||||
|
for(var i=0;i<sels.length;i++){var e=document.querySelector(sels[i]); if(e){e.click(); return 'sel:'+sels[i];}}
|
||||||
|
// 2) fallback por texto del boton
|
||||||
|
var btns=[].slice.call(document.querySelectorAll('button, a[role=button], [role=button]'));
|
||||||
|
var rx=/^(aceptar y continuar|aceptar todo|aceptar|consentir|estoy de acuerdo|de acuerdo|accept all|i agree|agree)$/i;
|
||||||
|
for(var j=0;j<btns.length;j++){var t=((btns[j].innerText||btns[j].textContent||'').trim()); if(rx.test(t)){btns[j].click(); return 'text:'+t;}}
|
||||||
|
return 'no-button';
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JS de lectura del volcado + recuento de vendors de Didomi + deteccion de muro
|
||||||
|
# "pago o consientes".
|
||||||
|
_JS_READ = r"""
|
||||||
|
(function(){var r={tcdump:window.__tcdump};
|
||||||
|
try{if(window.Didomi){var v=Didomi.getVendors?Didomi.getVendors():null;
|
||||||
|
r.didomi_total_vendors=v?v.length:null;
|
||||||
|
var req=Didomi.getRequiredVendorIds?Didomi.getRequiredVendorIds():null;
|
||||||
|
r.didomi_required=req?req.length:null;
|
||||||
|
r.didomi_required_ids=req?req:null;}
|
||||||
|
}catch(e){r.didomi_err=String(e);}
|
||||||
|
try{var t=(document.body.innerText||'').toLowerCase();
|
||||||
|
r.paywall_consent=/(acepta y suscr|suscr[ií]bete|pago o|aceptar y continuar gratis|pay or|consent or pay|navega sin publicidad|acceder pagando)/.test(t);
|
||||||
|
}catch(e){}
|
||||||
|
return JSON.stringify(r);})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_value(value) -> dict:
|
||||||
|
"""Convierte el string JSON devuelto por cdp_eval en dict; {} si falla."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value):
|
||||||
|
"""Devuelve int(value) si es un entero/float valido, si no None."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ids_from_list(raw):
|
||||||
|
"""Normaliza una lista heterogenea de IDs a una lista de int positivos."""
|
||||||
|
ids = []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for vid in raw:
|
||||||
|
iv = _coerce_int(vid)
|
||||||
|
if iv is None and isinstance(vid, str) and vid.isdigit():
|
||||||
|
iv = int(vid)
|
||||||
|
if iv is not None and iv > 0:
|
||||||
|
ids.append(iv)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _read_vendors(port: int, timeout_s: float) -> dict:
|
||||||
|
"""Re-ejecuta el volcado + lectura del TCF y consolida los vendor_ids.
|
||||||
|
|
||||||
|
Pone `__tcdump=null` y vuelve a pedir getTCData (`_JS_DETECT`), espera un
|
||||||
|
settle corto, lee el volcado (`_JS_READ`) y resuelve los vendor_ids de forma
|
||||||
|
generica (Didomi required ids o union de consents+legitimateInterests).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con {"ok":bool, "error":str|None, "read":dict, "vendor_ids":[int],
|
||||||
|
"n_vendors":int, "n_vendors_total":int|None,
|
||||||
|
"n_vendors_required":int|None}. Reusado por el flujo normal y
|
||||||
|
por la re-lectura tras el clic del fallback LLM.
|
||||||
|
"""
|
||||||
|
det = cdp_eval(_JS_DETECT, port=port, timeout_s=timeout_s)
|
||||||
|
if not det.get("ok"):
|
||||||
|
return {"ok": False, "error": "detect eval failed: " + str(det.get("error", ""))}
|
||||||
|
time.sleep(2.0)
|
||||||
|
rd = cdp_eval(_JS_READ, port=port, timeout_s=timeout_s)
|
||||||
|
if not rd.get("ok"):
|
||||||
|
return {"ok": False, "error": "read eval failed: " + str(rd.get("error", ""))}
|
||||||
|
read = _parse_json_value(rd.get("value"))
|
||||||
|
|
||||||
|
tcdump = read.get("tcdump") or {}
|
||||||
|
if not isinstance(tcdump, dict):
|
||||||
|
tcdump = {}
|
||||||
|
|
||||||
|
n_vendor_li = _coerce_int(tcdump.get("n_vendor_li")) or 0
|
||||||
|
n_vendors_total = _coerce_int(read.get("didomi_total_vendors"))
|
||||||
|
n_vendors_required = _coerce_int(read.get("didomi_required"))
|
||||||
|
|
||||||
|
didomi_ids = _ids_from_list(read.get("didomi_required_ids"))
|
||||||
|
if didomi_ids:
|
||||||
|
vendor_ids = didomi_ids
|
||||||
|
else:
|
||||||
|
vendor_ids = _ids_from_list(tcdump.get("tcf_vendor_ids"))
|
||||||
|
|
||||||
|
if vendor_ids:
|
||||||
|
n_vendors = len(vendor_ids)
|
||||||
|
elif n_vendors_required:
|
||||||
|
n_vendors = n_vendors_required
|
||||||
|
elif n_vendors_total:
|
||||||
|
n_vendors = n_vendors_total
|
||||||
|
else:
|
||||||
|
n_vendors = n_vendor_li
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"error": None,
|
||||||
|
"read": read,
|
||||||
|
"tcdump": tcdump,
|
||||||
|
"vendor_ids": vendor_ids,
|
||||||
|
"n_vendors": n_vendors,
|
||||||
|
"n_vendors_total": n_vendors_total,
|
||||||
|
"n_vendors_required": n_vendors_required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Detecta el CMP de `url` y lee su TC Data v2 via CDP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL del sitio a escanear (se navega la pestana activa del Chrome).
|
||||||
|
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||||
|
wait_load_s: Segundos a esperar tras navegar para que la pagina cargue.
|
||||||
|
settle_s: Segundos extra a esperar para que el CMP inicialice antes de
|
||||||
|
arrancar el volcado del TCF.
|
||||||
|
timeout_s: Timeout (segundos) para cada evaluacion CDP.
|
||||||
|
accept_first: 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. Default False
|
||||||
|
(comportamiento identico al historico, no toca el banner).
|
||||||
|
settle_accept_s: 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.
|
||||||
|
llm_fallback: Si True (y accept_first=True), SOLO cuando el intento normal de
|
||||||
|
aceptar el banner deja `vendor_ids` vacio tras leer el TCData, recurre a
|
||||||
|
`find_consent_controls_llm` (haiku) para localizar el control "aceptar todo"
|
||||||
|
cuyos selectores hardcodeados no encajaban, lo clica via cdp_eval, espera
|
||||||
|
`settle_accept_s` y RE-EJECUTA el volcado del TCF. Default False (no llama
|
||||||
|
nunca al LLM, comportamiento identico). El LLM solo se invoca cuando de
|
||||||
|
verdad hace falta: si el flujo de selectores/texto ya recupero vendors, NO
|
||||||
|
gasta la llamada a ask_llm — incluso si el clic salio 'no-button' (caso
|
||||||
|
Didomi, que expone getRequiredVendorIds sin necesidad de consentir).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict plano consolidado. En el caso feliz:
|
||||||
|
{"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": lo que devolvio el
|
||||||
|
JS de clic ('sel:<selector>', 'text:<texto>' o 'no-button').
|
||||||
|
Cuando ademas se dispara el fallback LLM (llm_fallback=True y el intento normal
|
||||||
|
fallo) se anaden "llm_used":True y "llm_reason":str (la explicacion del locator),
|
||||||
|
y accept_method pasa a 'llm:<selector>' (clic LLM exitoso) o 'llm:no-control'
|
||||||
|
(el LLM no encontro un control aceptable / el clic fallo).
|
||||||
|
En cualquier fallo (navegacion, eval, JSON parse):
|
||||||
|
{"status":"error","url":url,"error":"..."}
|
||||||
|
Nunca lanza.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. Navegar la pestana activa via JS (reutiliza el transport CDP).
|
||||||
|
nav_expr = "location.href=" + json.dumps(url) + "; true"
|
||||||
|
nav = cdp_eval(nav_expr, port=port, timeout_s=timeout_s)
|
||||||
|
if not nav.get("ok"):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"url": url,
|
||||||
|
"error": "navigate failed: " + str(nav.get("error", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Esperar carga + settle para que el CMP inicialice.
|
||||||
|
time.sleep(max(0.0, wait_load_s))
|
||||||
|
time.sleep(max(0.0, settle_s))
|
||||||
|
|
||||||
|
# 3. Deteccion del CMP + arranque del volcado del TCF.
|
||||||
|
det = cdp_eval(_JS_DETECT, port=port, timeout_s=timeout_s)
|
||||||
|
if not det.get("ok"):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"url": url,
|
||||||
|
"error": "detect eval failed: " + str(det.get("error", "")),
|
||||||
|
}
|
||||||
|
detect = _parse_json_value(det.get("value"))
|
||||||
|
|
||||||
|
# 3b. Si accept_first: aceptar el banner y re-arrancar el volcado del TCF.
|
||||||
|
# Algunos CMP (Quantcast) no exponen ningun vendor en getTCData hasta que
|
||||||
|
# el usuario interactua con el banner. Tras aceptar, re-ejecutamos _JS_DETECT
|
||||||
|
# (que pone __tcdump=null y vuelve a pedir getTCData), ahora ya poblado.
|
||||||
|
accept_method = None
|
||||||
|
if accept_first:
|
||||||
|
ac = cdp_eval(_JS_ACCEPT, port=port, timeout_s=timeout_s)
|
||||||
|
accept_method = ac.get("value") if ac.get("ok") else "eval-failed"
|
||||||
|
time.sleep(max(0.0, settle_accept_s))
|
||||||
|
|
||||||
|
# 4. Lectura del volcado + consolidacion de vendors (helper reutilizable).
|
||||||
|
rv = _read_vendors(port, timeout_s)
|
||||||
|
if not rv.get("ok"):
|
||||||
|
return {"status": "error", "url": url, "error": rv.get("error", "read failed")}
|
||||||
|
read = rv["read"]
|
||||||
|
tcdump = rv["tcdump"]
|
||||||
|
vendor_ids = rv["vendor_ids"]
|
||||||
|
n_vendors = rv["n_vendors"]
|
||||||
|
n_vendors_total = rv["n_vendors_total"]
|
||||||
|
n_vendors_required = rv["n_vendors_required"]
|
||||||
|
|
||||||
|
# 4b. Fallback LLM — SOLO si el flujo normal de selectores fallo de verdad.
|
||||||
|
# "Fallo de verdad" = no se recuperaron vendors (vendor_ids vacio). El criterio
|
||||||
|
# rector del encargo es no malgastar ask_llm en sitios que ya dieron vendors:
|
||||||
|
# por eso un clic 'no-button' que aun asi dejo vendor_ids poblado (caso Didomi,
|
||||||
|
# que expone getRequiredVendorIds sin consentir) NO dispara el LLM. El LLM solo
|
||||||
|
# entra cuando ni los selectores ni el texto lograron poblar vendor_ids.
|
||||||
|
llm_used = False
|
||||||
|
llm_reason = None
|
||||||
|
normal_failed = not vendor_ids
|
||||||
|
if accept_first and llm_fallback and normal_failed:
|
||||||
|
llm_used = True
|
||||||
|
locator = find_consent_controls_llm(port=port, max_candidates=80)
|
||||||
|
llm_reason = locator.get("reason")
|
||||||
|
accept_selector = locator.get("accept_selector")
|
||||||
|
if accept_selector:
|
||||||
|
# Clicar el control elegido por el LLM. accept_selector tiene
|
||||||
|
# comillas dobles ([data-fnllm="N"]); json.dumps lo escapa bien
|
||||||
|
# al incrustarlo como string-literal JS.
|
||||||
|
sel_lit = json.dumps(accept_selector)
|
||||||
|
click_expr = (
|
||||||
|
"(function(){var e=document.querySelector(" + sel_lit + ");"
|
||||||
|
"if(e){e.click();return true;}return false;})()"
|
||||||
|
)
|
||||||
|
cdp_eval(click_expr, port=port, timeout_s=timeout_s)
|
||||||
|
time.sleep(max(0.0, settle_accept_s))
|
||||||
|
rv2 = _read_vendors(port, timeout_s)
|
||||||
|
if rv2.get("ok"):
|
||||||
|
read = rv2["read"]
|
||||||
|
tcdump = rv2["tcdump"]
|
||||||
|
vendor_ids = rv2["vendor_ids"]
|
||||||
|
n_vendors = rv2["n_vendors"]
|
||||||
|
n_vendors_total = rv2["n_vendors_total"]
|
||||||
|
n_vendors_required = rv2["n_vendors_required"]
|
||||||
|
accept_method = "llm:" + accept_selector
|
||||||
|
else:
|
||||||
|
# El LLM no encontro un control aceptable (accept_idx null) o
|
||||||
|
# status error: marcar sin romper y seguir con lo que haya.
|
||||||
|
accept_method = "llm:no-control"
|
||||||
|
|
||||||
|
# 5. Consolidar el resto de campos a partir del tcdump/detect.
|
||||||
|
cmp_id = _coerce_int(tcdump.get("cmpId"))
|
||||||
|
tcf_policy = _coerce_int(tcdump.get("tcfPolicyVersion"))
|
||||||
|
gdpr_applies = tcdump.get("gdprApplies")
|
||||||
|
if not isinstance(gdpr_applies, bool):
|
||||||
|
gdpr_applies = None
|
||||||
|
|
||||||
|
n_purposes = _coerce_int(tcdump.get("n_purposes"))
|
||||||
|
tcstring_len = _coerce_int(tcdump.get("tcString_len")) or 0
|
||||||
|
|
||||||
|
# Derivar el CMP.
|
||||||
|
if cmp_id == 7 or detect.get("didomi"):
|
||||||
|
cmp = "didomi"
|
||||||
|
elif detect.get("onetrust"):
|
||||||
|
cmp = "onetrust"
|
||||||
|
elif detect.get("sourcepoint"):
|
||||||
|
cmp = "sourcepoint"
|
||||||
|
elif detect.get("quantcast"):
|
||||||
|
cmp = "quantcast"
|
||||||
|
elif detect.get("has_tcfapi"):
|
||||||
|
cmp = "otro_tcf"
|
||||||
|
else:
|
||||||
|
cmp = "ninguno"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"status": "ok",
|
||||||
|
"url": url,
|
||||||
|
"final_url": detect.get("url") or read.get("url") or url,
|
||||||
|
"title": detect.get("title", ""),
|
||||||
|
"cmp": cmp,
|
||||||
|
"cmp_id": cmp_id,
|
||||||
|
"tcf_policy": tcf_policy,
|
||||||
|
"gdpr_applies": gdpr_applies,
|
||||||
|
"n_vendors": n_vendors,
|
||||||
|
"n_vendors_total": n_vendors_total,
|
||||||
|
"n_vendors_required": n_vendors_required,
|
||||||
|
"n_purposes": n_purposes,
|
||||||
|
"tcstring_len": tcstring_len,
|
||||||
|
"paywall_consent": bool(read.get("paywall_consent")),
|
||||||
|
"vendor_ids": vendor_ids,
|
||||||
|
}
|
||||||
|
if accept_first:
|
||||||
|
result["accept_method"] = accept_method
|
||||||
|
if llm_used:
|
||||||
|
result["llm_used"] = True
|
||||||
|
result["llm_reason"] = llm_reason
|
||||||
|
return result
|
||||||
|
except Exception as e: # noqa: BLE001 — nunca relanzar, devolver status error
|
||||||
|
return {"status": "error", "url": url, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
target = sys.argv[1] if len(sys.argv) > 1 else "https://www.lavanguardia.com"
|
||||||
|
p = int(sys.argv[2]) if len(sys.argv) > 2 else 9222
|
||||||
|
accept = len(sys.argv) > 3 and sys.argv[3] in ("1", "true", "accept", "--accept")
|
||||||
|
llm = len(sys.argv) > 4 and sys.argv[4] in ("1", "true", "llm", "--llm")
|
||||||
|
out = extract_cmp_tcf(target, port=p, accept_first=accept, llm_fallback=llm)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: find_consent_controls_llm
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def find_consent_controls_llm(*, port: int = 9222, max_candidates: int = 40, model: str = 'claude-haiku-4-5-20251001') -> dict"
|
||||||
|
description: "Identifica los botones de un banner de cookies/consentimiento usando un LLM en vez de selectores hardcodeados por CMP. Recolecta los controles clicables visibles de la pagina via CDP, los marca con un atributo estable data-fnllm='N' en el DOM, y pregunta a haiku (ask_llm) cual es ACEPTAR TODO, cual RECHAZAR y cual el enlace VER SOCIOS/configurar/mas opciones/finalidades. Resuelve los CMP cuyos botones no encajan con selectores fijos (casos no-button del scanner de databrokers)."
|
||||||
|
tags: [consent, llm, cdp, browser, navegator, claude-direct, cookies, cmp, tcf, python, automation]
|
||||||
|
uses_functions: [cdp_eval_py_browser, ask_llm_py_core]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json", "os", "re", "sys"]
|
||||||
|
params_schema:
|
||||||
|
params:
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome donde esta el banner. Default 9222."
|
||||||
|
- name: max_candidates
|
||||||
|
desc: "Maximo de controles clicables a recolectar y enviar al LLM. Default 40."
|
||||||
|
- name: model
|
||||||
|
desc: "Modelo Anthropic a usar via ask_llm para clasificar los controles. Default claude-haiku-4-5-20251001."
|
||||||
|
output: "dict {status: 'ok'|'error', candidates: [{idx, tag, text, aria, id, cls}], accept_idx/reject_idx/vendors_idx: int|None, accept_selector/reject_selector/vendors_selector: '[data-fnllm=\"N\"]'|None, reason: str, error?: str}. Los selectores se construyen a partir del idx elegido por el LLM y sirven para clicar el control con cdp_eval. Nunca lanza: errores de CDP/eval/LLM se devuelven en el dict."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/find_consent_controls_llm.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, time
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from browser.find_consent_controls_llm import find_consent_controls_llm
|
||||||
|
|
||||||
|
# Requiere un Chrome con --remote-debugging-port=9335 y una pestana abierta.
|
||||||
|
# Navega primero al sitio con banner y espera a que cargue el CMP.
|
||||||
|
cdp_eval("location.href='https://www.elpuntavui.cat'", port=9335)
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
res = find_consent_controls_llm(port=9335)
|
||||||
|
print(res["accept_idx"], res["accept_selector"], res["reason"])
|
||||||
|
|
||||||
|
# Clicar el boton de aceptar elegido por el LLM:
|
||||||
|
sel = res["accept_selector"]
|
||||||
|
if sel:
|
||||||
|
cdp_eval(f"document.querySelector('{sel}').click()", port=9335)
|
||||||
|
```
|
||||||
|
|
||||||
|
O directo por CLI: `python3 python/functions/browser/find_consent_controls_llm.py 9335`.
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando un banner de cookies/consentimiento NO se resuelve con selectores fijos por
|
||||||
|
CMP (los casos "no-button" del scanner de databrokers): textos en otro idioma,
|
||||||
|
marcas TCF poco comunes, botones renderizados con clases dinamicas. La funcion deja
|
||||||
|
que un LLM lea los controles visibles y decida cual es aceptar/rechazar/ver-socios,
|
||||||
|
devolviendo selectores `[data-fnllm="N"]` estables que persisten en el DOM para que
|
||||||
|
el caller clique con `cdp_eval`. Usala como fallback despues de que los selectores
|
||||||
|
hardcodeados fallen, no como primer intento (cuesta una llamada al LLM).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **El banner debe estar YA en el DOM**: navega al sitio y espera unos segundos
|
||||||
|
(`time.sleep(~6)`) ANTES de llamar. Si el CMP aun no se ha renderizado, la lista
|
||||||
|
de candidatos no lo incluira y el LLM no podra elegir.
|
||||||
|
- **El LLM puede equivocarse**: haiku es rapido pero falible. Verifica el `text` del
|
||||||
|
candidato en `accept_idx` antes de clicar acciones irreversibles. Sube de modelo
|
||||||
|
(`model="claude-opus-4-8"`) si la precision importa mas que el coste.
|
||||||
|
- **Rate limits de ask_llm**: cada llamada consume cuota de la API directa de
|
||||||
|
Anthropic. No la invoques en bucle cerrado sobre muchas pestanas sin throttling.
|
||||||
|
- **Marca el DOM**: pone `data-fnllm="N"` en hasta `max_candidates` elementos. Si
|
||||||
|
re-llamas tras cambiar la pagina, los atributos viejos pueden quedar; los selectores
|
||||||
|
solo son fiables sobre el mismo render donde se recolectaron.
|
||||||
|
- **Requiere remote debugging**: sin un Chrome con `--remote-debugging-port`, `cdp_eval`
|
||||||
|
falla y devuelve `{status: "error", error: "cdp_eval: ..."}`.
|
||||||
|
- Solo recolecta controles **visibles** (`getClientRects().length>0`) y con texto
|
||||||
|
corto (<=60 chars). Controles dentro de shadow DOM o iframes cross-origin no se ven.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Identifica los botones de un banner de cookies/consentimiento usando un LLM.
|
||||||
|
|
||||||
|
En lugar de depender de selectores hardcodeados por CMP (que rompen cuando el
|
||||||
|
banner usa marcas/textos distintos), esta funcion recolecta los controles
|
||||||
|
clicables visibles de la pagina via CDP, los marca con un atributo estable
|
||||||
|
`data-fnllm="N"` en el DOM, y pregunta a un modelo (haiku via ask_llm) cual es
|
||||||
|
el boton de "ACEPTAR TODO", cual el de "RECHAZAR" y cual el enlace de
|
||||||
|
"VER SOCIOS / configurar / mas opciones / finalidades".
|
||||||
|
|
||||||
|
Resuelve los CMP cuyos botones no encajan con selectores fijos (los casos
|
||||||
|
"no-button" del scanner de databrokers). El caller usa los selectores
|
||||||
|
`[data-fnllm="N"]` devueltos para clicar el control elegido con cdp_eval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval # noqa: E402
|
||||||
|
from core.ask_llm import ask_llm # noqa: E402
|
||||||
|
|
||||||
|
# JS que recolecta controles clicables visibles y los marca con data-fnllm="N".
|
||||||
|
# {MAXC} se sustituye en Python por max_candidates.
|
||||||
|
_COLLECT_JS = """
|
||||||
|
(function(){
|
||||||
|
var nodes=[].slice.call(document.querySelectorAll('button, a[role=button], [role=button], input[type=button], input[type=submit], a'));
|
||||||
|
var out=[],n=0;
|
||||||
|
for(var i=0;i<nodes.length && n<{MAXC};i++){
|
||||||
|
var el=nodes[i];
|
||||||
|
if(!el.getClientRects().length) continue;
|
||||||
|
var txt=((el.innerText||el.textContent||el.value||'').trim()).slice(0,60);
|
||||||
|
if(!txt) continue;
|
||||||
|
el.setAttribute('data-fnllm', String(n));
|
||||||
|
out.push({idx:n, tag:el.tagName.toLowerCase(), text:txt,
|
||||||
|
aria:(el.getAttribute('aria-label')||'').slice(0,60),
|
||||||
|
id:(el.id||'').slice(0,40), cls:((el.className||'').toString().split(' ')[0]||'').slice(0,40)});
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return JSON.stringify(out);
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SYSTEM = (
|
||||||
|
"Eres un clasificador de banners de consentimiento de cookies (espanol/ingles). "
|
||||||
|
"Respondes SOLO con JSON valido, sin texto extra."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selector(idx):
|
||||||
|
"""Construye el selector estable `[data-fnllm="N"]` o None si idx es None."""
|
||||||
|
if idx is None:
|
||||||
|
return None
|
||||||
|
return '[data-fnllm="{}"]'.format(idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_block(raw: str):
|
||||||
|
"""Extrae el primer bloque {...} de la respuesta del LLM y lo parsea.
|
||||||
|
|
||||||
|
El modelo puede envolver en ```json o anadir texto; nos quedamos con el
|
||||||
|
primer objeto JSON balanceado. Devuelve dict o lanza ValueError.
|
||||||
|
"""
|
||||||
|
# Buscar el primer '{' y emparejar llaves para soportar objetos anidados.
|
||||||
|
start = raw.find("{")
|
||||||
|
if start == -1:
|
||||||
|
raise ValueError("no json object found")
|
||||||
|
depth = 0
|
||||||
|
for i in range(start, len(raw)):
|
||||||
|
c = raw[i]
|
||||||
|
if c == "{":
|
||||||
|
depth += 1
|
||||||
|
elif c == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return json.loads(raw[start : i + 1])
|
||||||
|
raise ValueError("unbalanced json object")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_idx(val, n_candidates):
|
||||||
|
"""Normaliza un indice del LLM: int valido en rango o None."""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
i = int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if 0 <= i < n_candidates:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_consent_controls_llm(
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
max_candidates: int = 40,
|
||||||
|
model: str = "claude-haiku-4-5-20251001",
|
||||||
|
) -> dict:
|
||||||
|
"""Identifica accept/reject/vendors de un banner de cookies via LLM.
|
||||||
|
|
||||||
|
Recolecta los controles clicables visibles de la pagina (marcandolos en el
|
||||||
|
DOM con `data-fnllm="N"`) y pregunta al modelo cual es cada uno. Util para
|
||||||
|
CMP cuyos botones no encajan con selectores hardcodeados.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||||
|
max_candidates: Maximo de controles a recolectar. Default 40.
|
||||||
|
model: Modelo Anthropic a usar via ask_llm. Default haiku.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con claves:
|
||||||
|
status: "ok" | "error".
|
||||||
|
candidates: lista de {idx, tag, text, aria, id, cls}.
|
||||||
|
accept_idx / reject_idx / vendors_idx: int|None elegidos por el LLM.
|
||||||
|
accept_selector / reject_selector / vendors_selector: str|None,
|
||||||
|
formato `[data-fnllm="N"]` para clicar via cdp_eval.
|
||||||
|
reason: str — explicacion breve del LLM.
|
||||||
|
error: str — presente solo si status=="error".
|
||||||
|
Nunca lanza: cualquier fallo de CDP/eval/LLM se devuelve en el dict.
|
||||||
|
"""
|
||||||
|
# 1. Recolectar controles clicables visibles y marcarlos en el DOM.
|
||||||
|
expr = _COLLECT_JS.replace("{MAXC}", str(int(max_candidates)))
|
||||||
|
res = cdp_eval(expr, port=port)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "cdp_eval: " + (res.get("error") or "fallo evaluando JS"),
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_list = res.get("value")
|
||||||
|
try:
|
||||||
|
candidates = json.loads(raw_list) if isinstance(raw_list, str) else (raw_list or [])
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
return {"status": "error", "error": "candidates_parse: " + str(e)}
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"candidates": [],
|
||||||
|
"accept_idx": None,
|
||||||
|
"reject_idx": None,
|
||||||
|
"vendors_idx": None,
|
||||||
|
"accept_selector": None,
|
||||||
|
"reject_selector": None,
|
||||||
|
"vendors_selector": None,
|
||||||
|
"reason": "sin controles visibles",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Construir el prompt para el LLM.
|
||||||
|
listing = json.dumps(candidates, ensure_ascii=False)
|
||||||
|
prompt = (
|
||||||
|
"Recibes la lista de controles clicables de un banner de cookies / "
|
||||||
|
"consentimiento de una pagina web. Cada control tiene un 'idx' numerico "
|
||||||
|
"y su texto/atributos. Identifica:\n"
|
||||||
|
' - accept_idx: el boton para ACEPTAR / CONSENTIR TODO ("Aceptar", '
|
||||||
|
'"Aceptar todo", "Accept all", "Consentir", "De acuerdo", "Estoy de acuerdo").\n'
|
||||||
|
' - reject_idx: el boton para RECHAZAR TODO ("Rechazar", "Rechazar todo", '
|
||||||
|
'"Reject all", "No acepto", "Continuar sin aceptar").\n'
|
||||||
|
' - vendors_idx: el enlace para VER SOCIOS / partners / proveedores / '
|
||||||
|
'configurar / mas opciones / finalidades / "Ver socios", "Configurar", '
|
||||||
|
'"Mas informacion", "Gestionar opciones", "Personalizar".\n'
|
||||||
|
"Si alguno no existe en la lista, usa null para ese campo.\n\n"
|
||||||
|
"Responde EXACTAMENTE con este JSON (sin markdown, sin texto extra):\n"
|
||||||
|
'{"accept_idx": N|null, "reject_idx": N|null, "vendors_idx": N|null, "reason": "..."}\n\n'
|
||||||
|
"Controles:\n" + listing
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Preguntar al modelo (sin stream a stdout, respuesta corta).
|
||||||
|
answer = ask_llm(prompt, model=model, system=_SYSTEM, max_tokens=300, echo=False)
|
||||||
|
if not answer:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "llm_empty",
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Parsear el JSON de la respuesta de forma robusta.
|
||||||
|
try:
|
||||||
|
parsed = _extract_json_block(answer)
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "llm_parse",
|
||||||
|
"raw": answer[:500],
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
n = len(candidates)
|
||||||
|
accept_idx = _coerce_idx(parsed.get("accept_idx"), n)
|
||||||
|
reject_idx = _coerce_idx(parsed.get("reject_idx"), n)
|
||||||
|
vendors_idx = _coerce_idx(parsed.get("vendors_idx"), n)
|
||||||
|
reason = str(parsed.get("reason", ""))
|
||||||
|
|
||||||
|
# 5. Devolver con selectores estables construidos a partir de los idx.
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"candidates": candidates,
|
||||||
|
"accept_idx": accept_idx,
|
||||||
|
"reject_idx": reject_idx,
|
||||||
|
"vendors_idx": vendors_idx,
|
||||||
|
"accept_selector": _selector(accept_idx),
|
||||||
|
"reject_selector": _selector(reject_idx),
|
||||||
|
"vendors_selector": _selector(vendors_idx),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_port = int(sys.argv[1]) if len(sys.argv) > 1 else 9222
|
||||||
|
out = find_consent_controls_llm(port=_port)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: scrape_aliexpress_cdp
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def scrape_aliexpress_cdp(query: str, sort: str = 'total_tranpro_desc', limit: int = 40, port: int = 9222, timeout_s: float = 25.0) -> dict"
|
||||||
|
description: "Scrapea productos de AliExpress por Chrome DevTools Protocol (CDP) sobre el navegador diario logueado (chromium-personal, puerto 9222, IP residencial), evitando el captcha que bloquea el scraper HTTP. Capta coste en China (EUR) y numero de pedidos (demanda real) como senal de dropshipping: que importar de China. Ordena por defecto por numero de pedidos."
|
||||||
|
tags: [market-intel, aliexpress, cdp, dropship, scraper, browser]
|
||||||
|
uses_functions: [cdp_eval_py_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: [json, os, re, sys, time, datetime, browser.cdp_eval]
|
||||||
|
params:
|
||||||
|
- name: query
|
||||||
|
desc: "Termino de busqueda (ej. 'organizador maletero coche'). Los espacios se convierten en guiones para la URL de busqueda."
|
||||||
|
- name: sort
|
||||||
|
desc: "Orden de resultados. 'total_tranpro_desc' = por numero de pedidos (demanda real, default util para dropshipping). Otros: 'default', 'price_asc', 'price_desc'."
|
||||||
|
- name: limit
|
||||||
|
desc: "Numero objetivo de productos a recolectar. El scroll itera (cap de 8 scrolls) hasta acercarse a este valor o hasta que el conteo de cards deja de crecer."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging de Chrome. Default 9222 (chromium-personal)."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout en segundos para cada evaluacion CDP. Default 25.0."
|
||||||
|
output: "dict autosuficiente {status: 'ok'|'error'|'captcha', source:'aliexpress', query, url, count, products:[...]}. Cada product: item_id(str), url(str), title(str), price(float EUR|None), price_orig(float|None), rating(float|None), orders(str crudo p.ej. '10.000+ vendidos'|None), orders_num(int aprox), ship_from(str|None), scraped_at(iso). Nunca inventa datos: sin cards -> status='error' products=[]; captcha -> status='captcha' products=[]. Nunca lanza."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/scrape_aliexpress_cdp.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requiere chromium-personal con remote debugging en 9222 y sesion logueada.
|
||||||
|
cd "$HOME/fn_registry"
|
||||||
|
python/.venv/bin/python3 python/functions/browser/scrape_aliexpress_cdp.py "organizador maletero coche" "total_tranpro_desc" 40
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.scrape_aliexpress_cdp import scrape_aliexpress_cdp
|
||||||
|
|
||||||
|
res = scrape_aliexpress_cdp("organizador maletero coche", sort="total_tranpro_desc", limit=40)
|
||||||
|
print(res["status"], res["count"])
|
||||||
|
for p in res["products"][:3]:
|
||||||
|
print(p["price"], "EUR -", p["orders_num"], "pedidos -", p["title"][:50])
|
||||||
|
# ok 7
|
||||||
|
# 14.49 EUR - 10000 pedidos - Caja organizadora de maletero de coche, gran capac...
|
||||||
|
# 56.87 EUR - 5000 pedidos - YZ para Tesla Model Y Juniper 2021-2026, caja de al...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites el **coste en China + la demanda (numero de pedidos)** de un producto
|
||||||
|
para decidir que importar (market intelligence de dropshipping, proyecto
|
||||||
|
`captacion_clientes`). Usala en lugar de `scrape_aliexpress_trending_py_datascience`
|
||||||
|
cuando ese scraper HTTP devuelva captcha: esta via opera el navegador diario logueado
|
||||||
|
con IP residencial y no dispara el muro anti-bot. La persistencia (DuckDB/Postgres/Excel)
|
||||||
|
la hace un componente aparte: el dict de salida es autosuficiente y no casa con ninguna tabla.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura, depende del navegador diario**: requiere `chromium-personal` corriendo con
|
||||||
|
`--remote-debugging-port=9222` y la sesion de AliExpress logueada (IP residencial). Sin
|
||||||
|
CDP vivo, `cdp_eval` devuelve `ok=False` y la funcion retorna `status='error'`.
|
||||||
|
- **Pisa la pestana activa de AliExpress**: navega via `location.href` sobre el primer
|
||||||
|
target `page` cuya URL contenga "aliexpress" (o el primer page si no hay). Si tienes una
|
||||||
|
pestana de AliExpress con trabajo en curso, la reemplaza.
|
||||||
|
- **Volumen real bajo por pagina**: la galeria `/w/wholesale-...` suele exponer solo
|
||||||
|
~7-12 cards reales (el resto son banners promocionales "GRATIScon una compra" sin precio,
|
||||||
|
que se descartan). `count` reflejara los productos reales disponibles en la pagina, no
|
||||||
|
siempre llegara a `limit`. Para mas volumen hay que paginar (`&page=2`), fuera del alcance
|
||||||
|
de esta funcion.
|
||||||
|
- **Fragil ante cambios de HTML de AliExpress**: depende del selector
|
||||||
|
`.search-item-card-wrapper-gallery` y del formato del texto de la card
|
||||||
|
(`14,49€32,2€ -55%4.610.000+ vendidos`). Si AliExpress cambia el markup, la extraccion
|
||||||
|
devolvera campos None o `status='error'` (no inventa datos).
|
||||||
|
- **Lee `textContent`, no `innerText`**: las cards fuera del viewport devuelven `innerText`
|
||||||
|
vacio; por eso se usa `textContent` normalizado. El texto viene pegado sin saltos de
|
||||||
|
linea y los regex no dependen de `\n`.
|
||||||
|
- **Captcha posible**: si AliExpress muestra el slider "nc" / punish page, la funcion lo
|
||||||
|
detecta y devuelve `status='captcha'` sin intentar resolverlo. Reaccion correcta:
|
||||||
|
handoff humano (activar la pestana y resolver a mano).
|
||||||
|
- `orders_num` es aproximado: `'10.000+'` -> 10000, `'5.000+'` -> 5000, `'1.234'` -> 1234
|
||||||
|
(quita puntos de millar y el `+`). El `+` significa "al menos ese numero".
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""Scrapea productos de AliExpress por Chrome DevTools Protocol (CDP).
|
||||||
|
|
||||||
|
Via para captar señales de dropshipping (que importar de China) sin chocar con el
|
||||||
|
captcha que bloquea el scraper HTTP `scrape_aliexpress_trending_py_datascience`.
|
||||||
|
Opera el navegador diario logueado (chromium-personal con remote debugging en el
|
||||||
|
puerto 9222, IP residencial): navega a la pagina de busqueda, hace scroll para
|
||||||
|
disparar el lazy-load de las cards y extrae cada producto con coste en EUR y numero
|
||||||
|
de pedidos (demanda real).
|
||||||
|
|
||||||
|
Reutiliza la primitiva de transport CDP `cdp_eval_py_browser`: navega via
|
||||||
|
`location.href = url` y evalua el JS de extraccion sobre la misma pestana, igual
|
||||||
|
patron que `extract_cmp_tcf_py_browser`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Permite importar funciones del registry tanto si se ejecuta desde la raiz del
|
||||||
|
# repo (cwd) como si se invoca el modulo directamente. Deriva la raiz dinamica
|
||||||
|
# desde la ubicacion de este archivo (nunca hardcodear paths de usuario).
|
||||||
|
_FN_ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||||
|
if _FN_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FN_ROOT)
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# JS de extraccion de las cards de la galeria de busqueda. Devuelve un string JSON
|
||||||
|
# con la lista de productos crudos (campos sin parsear, como los ve el navegador).
|
||||||
|
# Usa textContent NORMALIZADO (no innerText): innerText devuelve "" para las cards
|
||||||
|
# fuera del viewport (respeta layout/visibilidad), mientras que textContent siempre
|
||||||
|
# trae el texto aunque la card no este pintada en pantalla. El texto viene todo
|
||||||
|
# pegado sin saltos de linea, por eso los regex no dependen de "\n".
|
||||||
|
#
|
||||||
|
# Forma real del texto de una card (es.aliexpress.com, validado):
|
||||||
|
# "<titulo> 14,49€32,2€ -55%4.610.000+ vendidos12,42€ por Ud..."
|
||||||
|
# - precio: primer \d+[.,]\d+ seguido de € -> "14,49€"
|
||||||
|
# - price_orig: segundo \d+[.,]\d+ seguido de € -> "32,2€"
|
||||||
|
# - rating+ord: \d\.\d (rating) PEGADO a [\d.,]+\+? vendidos -> "4.6" + "10.000+"
|
||||||
|
# Cards promocionales "GRATIScon una compra" no tienen precio EUR -> price None.
|
||||||
|
_JS_EXTRACT = r"""
|
||||||
|
JSON.stringify(Array.from(document.querySelectorAll('.search-item-card-wrapper-gallery')).map(card => {
|
||||||
|
const a = card.querySelector('a[href*="/item/"]');
|
||||||
|
const href = a ? a.href.split('?')[0] : null;
|
||||||
|
const id = href ? ((href.match(/item\/(\d+)\.html/)||[])[1]) : null;
|
||||||
|
const txt = (card.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const all_eur = (txt.match(/(\d+(?:[.,]\d+)?)\s*€/g) || []);
|
||||||
|
const price = all_eur.length ? all_eur[0].replace('€','').trim() : null;
|
||||||
|
const price_orig = all_eur.length>1 ? all_eur[1].replace('€','').trim() : null;
|
||||||
|
// rating (\d\.\d) pegado a las unidades vendidas: "4.610.000+ vendidos".
|
||||||
|
const ro = txt.match(/(\d\.\d)([\d.,]+\+?)\s*vendidos/);
|
||||||
|
const rating = ro ? ro[1] : null;
|
||||||
|
const orders = ro ? (ro[2] + ' vendidos') : ((txt.match(/([\d.,]+\+?\s*vendidos)/)||[])[1] || null);
|
||||||
|
const ship = (txt.match(/(Env[ií]o[^·]*?)(?:·|$)/)||[])[1] || null;
|
||||||
|
const img = a ? a.querySelector('img') : null;
|
||||||
|
const title = (img && img.alt) ? img.alt.trim() : (txt.split('€')[0]||'').trim();
|
||||||
|
return {item_id:id, url:href, title, price, price_orig, rating, orders, ship_from:ship};
|
||||||
|
}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JS de deteccion de captcha / muro anti-bot (slider "nc", punish page, etc.).
|
||||||
|
_JS_CAPTCHA = r"""
|
||||||
|
(function(){
|
||||||
|
var t=(document.body && document.body.innerText || '').toLowerCase();
|
||||||
|
var hasSlider=!!document.querySelector('.nc_iconfont, .nc-container, #nc_1_wrapper, [id*="nocaptcha"]');
|
||||||
|
var punish=/punish|verify to continue|slide to verify|desliza para|arrastra el control/.test(t);
|
||||||
|
var title=(document.title||'').toLowerCase();
|
||||||
|
return JSON.stringify({captcha: hasSlider || punish, title: title, has_cards: document.querySelectorAll('.search-item-card-wrapper-gallery').length});
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify_query(query: str) -> str:
|
||||||
|
"""Convierte la query en el slug que usa la URL de busqueda (espacios -> guiones)."""
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
q = re.sub(r"\s+", "-", q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_eur(raw) -> float:
|
||||||
|
"""Parsea un precio EU ('12,34' o '1.234,56') a float. None si no es valido."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Quitar separador de millar (.) y usar . como decimal (la coma EU).
|
||||||
|
if "," in s:
|
||||||
|
s = s.replace(".", "").replace(",", ".")
|
||||||
|
try:
|
||||||
|
return round(float(s), 2)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(raw) -> float:
|
||||||
|
"""Parsea un float simple (rating). None si no es valido."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(str(raw).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_orders_num(raw) -> int:
|
||||||
|
"""Aproxima el numero de pedidos a int.
|
||||||
|
|
||||||
|
'10.000+ vendidos' -> 10000, '5.000+' -> 5000, '1.234' -> 1234, '500+' -> 500.
|
||||||
|
Quita puntos de millar, el '+' y el texto. None si no hay digitos.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = str(raw)
|
||||||
|
m = re.search(r"([\d.,]+)\s*\+?", s)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
num = m.group(1).replace(".", "").replace(",", "")
|
||||||
|
if not num.isdigit():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(num)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_products(raw_list, query: str) -> list:
|
||||||
|
"""Normaliza la lista cruda de cards a la forma de salida (parsea precios/pedidos)."""
|
||||||
|
scraped_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
out = []
|
||||||
|
for c in raw_list:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
if not c.get("item_id") and not c.get("url"):
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"item_id": c.get("item_id"),
|
||||||
|
"url": c.get("url"),
|
||||||
|
"title": (c.get("title") or "").strip(),
|
||||||
|
"price": _parse_eur(c.get("price")),
|
||||||
|
"price_orig": _parse_eur(c.get("price_orig")),
|
||||||
|
"rating": _parse_float(c.get("rating")),
|
||||||
|
"orders": c.get("orders"),
|
||||||
|
"orders_num": _parse_orders_num(c.get("orders")),
|
||||||
|
"ship_from": (c.get("ship_from") or None),
|
||||||
|
"scraped_at": scraped_at,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_value(value) -> object:
|
||||||
|
"""Convierte el string JSON devuelto por cdp_eval en objeto Python; None si falla."""
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_aliexpress_cdp(
|
||||||
|
query: str,
|
||||||
|
sort: str = "total_tranpro_desc",
|
||||||
|
limit: int = 40,
|
||||||
|
port: int = 9222,
|
||||||
|
timeout_s: float = 25.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Scrapea la pagina de busqueda de AliExpress por CDP y devuelve los productos.
|
||||||
|
|
||||||
|
Navega el navegador diario (Chrome con remote debugging) a la URL de busqueda
|
||||||
|
ordenada por `sort`, hace scroll para disparar el lazy-load hasta acercarse a
|
||||||
|
`limit` cards, extrae cada producto y parsea precios (EUR) y numero de pedidos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Termino de busqueda (ej. "organizador maletero coche"). Los espacios
|
||||||
|
se convierten en guiones para la URL.
|
||||||
|
sort: Orden de resultados. "total_tranpro_desc" = por numero de pedidos
|
||||||
|
(demanda real, el default util para dropshipping). Otros: "default",
|
||||||
|
"price_asc", "price_desc".
|
||||||
|
limit: Numero objetivo de productos a recolectar. El scroll itera hasta
|
||||||
|
acercarse a este valor (cap de seguridad en el numero de scrolls).
|
||||||
|
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||||
|
timeout_s: Timeout (segundos) para cada evaluacion CDP.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict autosuficiente:
|
||||||
|
{"status": "ok"|"error"|"captcha",
|
||||||
|
"source": "aliexpress",
|
||||||
|
"query": str,
|
||||||
|
"url": str, # URL navegada
|
||||||
|
"count": int,
|
||||||
|
"products": [
|
||||||
|
{"item_id", "url", "title", "price"(float EUR|None),
|
||||||
|
"price_orig"(float|None), "rating"(float|None),
|
||||||
|
"orders"(str crudo|None), "orders_num"(int|None),
|
||||||
|
"ship_from"(str|None), "scraped_at"(iso)}
|
||||||
|
],
|
||||||
|
"error": str # solo presente si status=="error"
|
||||||
|
}
|
||||||
|
Nunca inventa datos: sin cards -> status="error" products=[]; captcha
|
||||||
|
detectado -> status="captcha" products=[]. Nunca lanza.
|
||||||
|
"""
|
||||||
|
slug = _slugify_query(query)
|
||||||
|
url = f"https://es.aliexpress.com/w/wholesale-{slug}.html?SortType={sort}"
|
||||||
|
base = {"status": "error", "source": "aliexpress", "query": query, "url": url,
|
||||||
|
"count": 0, "products": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Navegar la pestana activa a la URL de busqueda (reutiliza transport CDP).
|
||||||
|
# Se prioriza una pestana cuya URL ya contenga "aliexpress" para no pisar
|
||||||
|
# otra pestana del navegador diario; si no hay, cae al primer target page.
|
||||||
|
nav_expr = "location.href=" + json.dumps(url) + "; true"
|
||||||
|
nav = cdp_eval(nav_expr, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
|
||||||
|
if not nav.get("ok"):
|
||||||
|
nav = cdp_eval(nav_expr, port=port, timeout_s=timeout_s)
|
||||||
|
if not nav.get("ok"):
|
||||||
|
base["error"] = "navigate failed: " + str(nav.get("error", ""))
|
||||||
|
return base
|
||||||
|
|
||||||
|
# 2. Esperar la carga inicial de la SPA + primeras cards.
|
||||||
|
time.sleep(8.0)
|
||||||
|
|
||||||
|
# 3. Deteccion temprana de captcha / muro anti-bot.
|
||||||
|
cap = cdp_eval(_JS_CAPTCHA, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
|
||||||
|
cap_data = _parse_json_value(cap.get("value")) or {}
|
||||||
|
if isinstance(cap_data, dict) and cap_data.get("captcha"):
|
||||||
|
res = dict(base)
|
||||||
|
res["status"] = "captcha"
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 4. Scroll para disparar el lazy-load. AliExpress carga ~15 cards iniciales
|
||||||
|
# y va anadiendo mas al bajar. Iteramos hasta acercarnos a `limit` o
|
||||||
|
# hasta que el conteo deje de crecer (cap de seguridad de 8 scrolls).
|
||||||
|
last_count = 0
|
||||||
|
stable_rounds = 0
|
||||||
|
max_scrolls = 8
|
||||||
|
for _ in range(max_scrolls):
|
||||||
|
cnt = cdp_eval(
|
||||||
|
"document.querySelectorAll('.search-item-card-wrapper-gallery').length",
|
||||||
|
port=port, target_url_substr="aliexpress", timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
current = cnt.get("value") if cnt.get("ok") else 0
|
||||||
|
if not isinstance(current, int):
|
||||||
|
current = 0
|
||||||
|
if current >= limit:
|
||||||
|
break
|
||||||
|
if current <= last_count:
|
||||||
|
stable_rounds += 1
|
||||||
|
if stable_rounds >= 2:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
stable_rounds = 0
|
||||||
|
last_count = current
|
||||||
|
cdp_eval(
|
||||||
|
"window.scrollTo(0, document.body.scrollHeight); true",
|
||||||
|
port=port, target_url_substr="aliexpress", timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
# 5. Extraer las cards con el JS validado.
|
||||||
|
ext = cdp_eval(_JS_EXTRACT, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
|
||||||
|
if not ext.get("ok"):
|
||||||
|
base["error"] = "extract eval failed: " + str(ext.get("error", ""))
|
||||||
|
return base
|
||||||
|
|
||||||
|
raw_list = _parse_json_value(ext.get("value"))
|
||||||
|
if not isinstance(raw_list, list):
|
||||||
|
base["error"] = "extract returned non-list value"
|
||||||
|
return base
|
||||||
|
|
||||||
|
products = _coerce_products(raw_list, query)
|
||||||
|
|
||||||
|
# Sin cards: re-comprobar captcha por si el muro aparecio tras el scroll.
|
||||||
|
if not products:
|
||||||
|
cap2 = cdp_eval(_JS_CAPTCHA, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
|
||||||
|
cap2_data = _parse_json_value(cap2.get("value")) or {}
|
||||||
|
if isinstance(cap2_data, dict) and cap2_data.get("captcha"):
|
||||||
|
res = dict(base)
|
||||||
|
res["status"] = "captcha"
|
||||||
|
return res
|
||||||
|
base["error"] = "no product cards found"
|
||||||
|
return base
|
||||||
|
|
||||||
|
# Respetar el limite (la galeria puede traer mas que `limit`).
|
||||||
|
products = products[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"source": "aliexpress",
|
||||||
|
"query": query,
|
||||||
|
"url": url,
|
||||||
|
"count": len(products),
|
||||||
|
"products": products,
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001 — nunca relanzar, devolver status error
|
||||||
|
base["error"] = str(e)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
q = sys.argv[1] if len(sys.argv) > 1 else "organizador maletero coche"
|
||||||
|
srt = sys.argv[2] if len(sys.argv) > 2 else "total_tranpro_desc"
|
||||||
|
lim = int(sys.argv[3]) if len(sys.argv) > 3 else 40
|
||||||
|
out = scrape_aliexpress_cdp(q, sort=srt, limit=lim)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: scrape_amazon_movers_cdp
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def scrape_amazon_movers_cdp(marketplace: str = 'amazon.es', categories: list[str] | None = None, port: int = 9222, max_items: int = 30, timeout_s: float = 25.0) -> dict"
|
||||||
|
description: "Scraper de Amazon Movers & Shakers (productos que mas suben en ranking de ventas = demanda emergente, clave dropshipping) via Chrome DevTools Protocol. La pagina monta las cards por JavaScript (el GET HTTP devuelve 0 productos), asi que renderiza via CDP, espera el grid async, extrae el outerHTML y lo pasa al parser puro parse_amazon_ranking_html. Aporta precio de venta + % de subida de ranking por producto. Nunca lanza ni inventa datos."
|
||||||
|
tags: [amazon, movers, cdp, dropship, market-intel, browser, scraping]
|
||||||
|
uses_functions: [cdp_open_url_and_wait_py_pipelines, cdp_eval_py_browser, parse_amazon_ranking_html_py_datascience]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [websocket]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/scrape_amazon_movers_cdp.py"
|
||||||
|
params:
|
||||||
|
- name: marketplace
|
||||||
|
desc: "Dominio Amazon objetivo (amazon.es, amazon.com, ...). Determina la URL de movers y la moneda fallback del parser. Para España usa amazon.es (precios en EUR)."
|
||||||
|
- name: categories
|
||||||
|
desc: "Lista de slugs de categoria de movers (ej. 'automotive' para coche, 'pet-supplies' para mascotas). Si es None scrapea la portada general de movers. Cada slug navega a /gp/movers-and-shakers/<slug>."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal residential de produccion, pasa el anti-bot mejor que requests). Para un Chrome aislado apunta a 9333 (browser_mcp)."
|
||||||
|
- name: max_items
|
||||||
|
desc: "Numero maximo de productos recolectados por categoria. Default 30."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout (segundos) por categoria, tanto para la navegacion como para el polling de aparicion de cards. Default 25.0."
|
||||||
|
output: "dict autosuficiente {status, source:'amazon_movers', count, products:[...]}. status='ok' si extrajo productos; 'error' si no hubo cards (categoria sin movers ahora, o chromium degradado); 'captcha' si Amazon sirvio un interstitial anti-bot. Cada product: marketplace, list_type ('movers_shakers'), category, rank (int), asin, title, price (float EUR), currency, rating (float|None), reviews (int|None), pct_change (float|None), url, source ('amazon_movers'), scraped_at (ISO8601 UTC). En error/captcha products=[] y se incluye 'error' con el mensaje."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, json
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.scrape_amazon_movers_cdp import scrape_amazon_movers_cdp
|
||||||
|
|
||||||
|
# Movers & Shakers de coche y mascotas en amazon.es (chromium-personal en 9222).
|
||||||
|
out = scrape_amazon_movers_cdp(
|
||||||
|
marketplace="amazon.es",
|
||||||
|
categories=["automotive", "pet-supplies"],
|
||||||
|
port=9222,
|
||||||
|
max_items=30,
|
||||||
|
timeout_s=25.0,
|
||||||
|
)
|
||||||
|
print(out["status"], out.get("count"))
|
||||||
|
if out["status"] == "ok":
|
||||||
|
print(json.dumps(out["products"][0], ensure_ascii=False, indent=2))
|
||||||
|
# {'marketplace': 'amazon.es', 'list_type': 'movers_shakers', 'category': 'automotive',
|
||||||
|
# 'rank': 1, 'asin': 'B0...', 'title': '...', 'price': 19.99, 'currency': 'EUR',
|
||||||
|
# 'rating': 4.1, 'reviews': 380, 'pct_change': 150.0, 'url': 'https://www.amazon.es/dp/B0...',
|
||||||
|
# 'source': 'amazon_movers', 'scraped_at': '2026-06-20T...'}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambien lanzable por CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd $HOME/fn_registry
|
||||||
|
python/.venv/bin/python3 python/functions/browser/scrape_amazon_movers_cdp.py \
|
||||||
|
--marketplace amazon.es --categories automotive,pet-supplies --port 9222
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando quieras captar demanda EMERGENTE en Amazon (no lo que mas se vende ya, sino lo que mas SUBE de golpe en ranking de ventas en 24h) para market intelligence de dropshipping. Es la fuente de senal de "nichos en alza": cada producto trae precio de venta en el marketplace y el % de subida de ranking. Alternativa renderizada al scraper HTTP `scrape_amazon_bestsellers` cuando ese cae por anti-bot o cuando necesitas movers (que se montan por JS y no salen en el GET puro). Combinala con un upsert a DuckDB/Postgres + snapshots diarios para detectar tendencias.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Necesita un Chrome con remote debugging vivo en `port`.** Por defecto 9222 (el navegador diario residential). Si no hay Chrome usable devuelve `status='error'` con un mensaje claro (no lanza). El chromium-personal debe estar logueado y con sesion limpia; un chromium recien arrancado sin sesion puede recibir una pagina degradada de Amazon.
|
||||||
|
- **Amazon puede no tener movers para una categoria AHORA.** La pagina muestra literalmente "no movers and shakers available in this category. Please check back later." En ese caso devuelve `status='error'` con ese motivo y `products=[]` — NO inventa datos. La disponibilidad de movers varia por categoria, marketplace y hora; reintenta mas tarde o prueba otra categoria/marketplace.
|
||||||
|
- **Captcha / anti-bot.** Si Amazon sirve un interstitial de verificacion, devuelve `status='captcha'`. Reaccion correcta: handoff humano (activar la pestana y resolver a mano), no auto-resolver — el token va atado a esa sesion.
|
||||||
|
- **DOM fragil.** Amazon rota plantillas del grid (A/B test). El parser puro tiene fallbacks por campo; aun asi, si Amazon rompe la plantilla, el scraper devuelve `status='error'` ("se montaron N cards pero el parser no extrajo productos"). Mantener selectores en `parse_amazon_ranking_html`.
|
||||||
|
- **Render async**: el load event NO garantiza cards en el DOM; la funcion hace polling (`querySelectorAll(...).length`) cada ~1s hasta que el grid monta o se agota `timeout_s`.
|
||||||
|
- **Una pestana por categoria**: cada slug crea un tab nuevo en el Chrome remoto. Listas largas de categorias abren muchos tabs — espacia o limpia pestanas si scrapeas muchas.
|
||||||
|
- **Campos opcionales = None**: no todos los productos traen rating/reviews/pct_change. `pct_change` solo se rellena cuando el card de movers expone el badge de subida de ranking.
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"""Scraper de Amazon Movers & Shakers via Chrome DevTools Protocol (CDP).
|
||||||
|
|
||||||
|
Funcion IMPURA: la pagina ``/gp/movers-and-shakers/`` de Amazon monta sus cards
|
||||||
|
por JavaScript (el GET HTTP puro devuelve 0 productos), asi que esta funcion
|
||||||
|
renderiza la pagina en un Chrome con remote debugging, espera a que el grid de
|
||||||
|
ranking monte async, extrae el ``outerHTML`` renderizado y se lo pasa al parser
|
||||||
|
PURO del registry (``parse_amazon_ranking_html``) — el mismo que usa el scraper
|
||||||
|
HTTP de bestsellers, sin reescribir el parsing.
|
||||||
|
|
||||||
|
Movers & Shakers = productos cuyo ranking de ventas mas sube en las ultimas 24h
|
||||||
|
= la mejor senal publica de demanda emergente (clave para dropshipping). Aporta
|
||||||
|
el PRECIO DE VENTA en el marketplace (ej. amazon.es en EUR) y el % de subida en
|
||||||
|
ranking por producto.
|
||||||
|
|
||||||
|
Compone DOS funciones del registry (no reescribe transporte CDP ni parsing):
|
||||||
|
1. ``cdp_open_url_and_wait`` (pipeline) — crea tab nuevo en el Chrome remoto,
|
||||||
|
navega a la URL de listado y espera ``Page.loadEventFired``.
|
||||||
|
2. ``cdp_eval`` (browser) — evalua JS en la pestana cuyo URL contiene un
|
||||||
|
substring (polling de cards + extraccion del ``outerHTML`` del grid).
|
||||||
|
|
||||||
|
Devuelve SIEMPRE un dict autosuficiente (estilo del grupo market-intel): nunca
|
||||||
|
lanza. NUNCA inventa datos: si no hay cards tras el timeout devuelve
|
||||||
|
``status="error"``; si Amazon sirve un captcha, ``status="captcha"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from datascience.parse_amazon_ranking_html import parse_amazon_ranking_html
|
||||||
|
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||||
|
|
||||||
|
# Marcadores de un interstitial anti-bot / captcha de Amazon.
|
||||||
|
_CAPTCHA_MARKERS = (
|
||||||
|
"enter the characters you see below",
|
||||||
|
"to discuss automated access",
|
||||||
|
"api-services-support@amazon",
|
||||||
|
"robot check",
|
||||||
|
"/errors/validatecaptcha",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Selectores de los cards del grid de ranking (movers comparte plantilla con
|
||||||
|
# bestsellers). Se usan en el JS de polling para contar cards montados.
|
||||||
|
_CARD_COUNT_JS = (
|
||||||
|
"(document.querySelectorAll('div[id=\"gridItemRoot\"]').length || "
|
||||||
|
"document.querySelectorAll('li.zg-item-immersion').length || "
|
||||||
|
"document.querySelectorAll('.p13n-desktop-grid div[data-asin]').length)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_url(marketplace: str, category: str | None) -> str:
|
||||||
|
"""URL de Movers & Shakers para un marketplace y slug de categoria.
|
||||||
|
|
||||||
|
Base: ``https://www.<marketplace>/gp/movers-and-shakers``. Si ``category``
|
||||||
|
es None se usa la portada general; si no, se anade ``/<slug>``.
|
||||||
|
"""
|
||||||
|
url = f"https://www.{marketplace}/gp/movers-and-shakers"
|
||||||
|
if category:
|
||||||
|
url = f"{url}/{category.strip('/')}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_captcha(port: int, target_substr: str) -> bool:
|
||||||
|
"""True si la pagina renderizada parece un interstitial anti-bot/captcha."""
|
||||||
|
r = cdp_eval(
|
||||||
|
"document.body ? document.body.innerText.slice(0, 4000) : ''",
|
||||||
|
port=port,
|
||||||
|
target_url_substr=target_substr,
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
if not r.get("ok"):
|
||||||
|
return False
|
||||||
|
lowered = (r.get("value") or "").lower()
|
||||||
|
return any(m in lowered for m in _CAPTCHA_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_cards(port: int, target_substr: str, deadline: float) -> int:
|
||||||
|
"""Polling de ``document.querySelectorAll`` hasta >0 cards o deadline.
|
||||||
|
|
||||||
|
El grid monta async tras la hidratacion, asi que el load event NO garantiza
|
||||||
|
que las cards esten en el DOM. Devuelve el numero de cards (0 si se agota).
|
||||||
|
"""
|
||||||
|
while time.time() < deadline:
|
||||||
|
r = cdp_eval(
|
||||||
|
_CARD_COUNT_JS,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=target_substr,
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
if r.get("ok"):
|
||||||
|
try:
|
||||||
|
n = int(r.get("value") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 0
|
||||||
|
if n > 0:
|
||||||
|
return n
|
||||||
|
time.sleep(1.0)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _grab_grid_html(port: int, target_substr: str, timeout_s: float) -> str:
|
||||||
|
"""Extrae el ``outerHTML`` del grid de ranking renderizado (o del body)."""
|
||||||
|
expr = (
|
||||||
|
"(() => { const g = document.querySelector('.p13n-desktop-grid'); "
|
||||||
|
"return g ? g.outerHTML : (document.body ? document.body.outerHTML : ''); })()"
|
||||||
|
)
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=target_substr,
|
||||||
|
timeout_s=max(15.0, timeout_s),
|
||||||
|
)
|
||||||
|
if not r.get("ok"):
|
||||||
|
return ""
|
||||||
|
return r.get("value") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _scrape_one_category(
|
||||||
|
marketplace: str,
|
||||||
|
category: str | None,
|
||||||
|
port: int,
|
||||||
|
max_items: int,
|
||||||
|
timeout_s: float,
|
||||||
|
scraped_at: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Navega a una categoria de movers, espera cards y extrae los productos.
|
||||||
|
|
||||||
|
Devuelve ``{"ok": bool, "products": [...], "error": str, "captcha": bool}``.
|
||||||
|
Cada product lleva ya ``marketplace``, ``category``, ``source`` y
|
||||||
|
``scraped_at``. Filtra filas sin asin ni title.
|
||||||
|
"""
|
||||||
|
url = _build_url(marketplace, category)
|
||||||
|
target_substr = "movers-and-shakers"
|
||||||
|
|
||||||
|
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
|
||||||
|
try:
|
||||||
|
cdp_open_url_and_wait(port, url, int(timeout_s))
|
||||||
|
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
|
||||||
|
msg = str(e)
|
||||||
|
if (
|
||||||
|
"no se pudo crear tab" in msg
|
||||||
|
or "URLError" in msg
|
||||||
|
or "Connection refused" in msg
|
||||||
|
or "timeout" in msg.lower()
|
||||||
|
):
|
||||||
|
msg = (
|
||||||
|
f"no hay Chrome usable en el puerto {port} "
|
||||||
|
f"(¿remote debugging activo?): {e}"
|
||||||
|
)
|
||||||
|
return {"ok": False, "products": [], "error": msg, "captcha": False}
|
||||||
|
|
||||||
|
# 2. Detectar captcha lo antes posible.
|
||||||
|
if _detect_captcha(port, target_substr):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"products": [],
|
||||||
|
"error": "Amazon sirvio un captcha / interstitial anti-bot",
|
||||||
|
"captcha": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Polling hasta que los cards monten (render async tras hidratacion).
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
n_cards = _wait_for_cards(port, target_substr, deadline)
|
||||||
|
if n_cards == 0:
|
||||||
|
# Re-chequear captcha (puede haber aparecido tras la hidratacion).
|
||||||
|
if _detect_captcha(port, target_substr):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"products": [],
|
||||||
|
"error": "Amazon sirvio un captcha / interstitial anti-bot",
|
||||||
|
"captcha": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"products": [],
|
||||||
|
"error": (
|
||||||
|
"no hay cards de ranking (la categoria puede no tener movers ahora "
|
||||||
|
"—Amazon muestra 'no movers and shakers available'— o el chromium "
|
||||||
|
"del puerto sirvio una pagina degradada / no logueada)"
|
||||||
|
),
|
||||||
|
"captcha": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Extraer el outerHTML del grid y parsearlo con el parser PURO.
|
||||||
|
html = _grab_grid_html(port, target_substr, timeout_s)
|
||||||
|
rows = parse_amazon_ranking_html(
|
||||||
|
html,
|
||||||
|
marketplace=marketplace,
|
||||||
|
list_type="movers_shakers",
|
||||||
|
max_items=max_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Enriquecer: category + source + scraped_at; filtrar filas vacias.
|
||||||
|
products = []
|
||||||
|
for row in rows:
|
||||||
|
if not row.get("asin") and not row.get("title"):
|
||||||
|
continue
|
||||||
|
row["category"] = category
|
||||||
|
row["source"] = "amazon_movers"
|
||||||
|
row["scraped_at"] = scraped_at
|
||||||
|
products.append(row)
|
||||||
|
|
||||||
|
if not products:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"products": [],
|
||||||
|
"error": (
|
||||||
|
f"se montaron {n_cards} cards pero el parser no extrajo productos "
|
||||||
|
"(¿Amazon roto la plantilla del DOM?)"
|
||||||
|
),
|
||||||
|
"captcha": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"ok": True, "products": products, "error": "", "captcha": False}
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_amazon_movers_cdp(
|
||||||
|
marketplace: str = "amazon.es",
|
||||||
|
categories: list[str] | None = None,
|
||||||
|
port: int = 9222,
|
||||||
|
max_items: int = 30,
|
||||||
|
timeout_s: float = 25.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Scrapea Amazon Movers & Shakers renderizando la pagina via CDP.
|
||||||
|
|
||||||
|
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en
|
||||||
|
``port`` (el navegador diario residential en 9222 pasa el anti-bot mejor que
|
||||||
|
``requests``). Por cada categoria navega a la URL de movers, espera a que el
|
||||||
|
grid (montado por JS) aparezca, extrae el ``outerHTML`` renderizado y lo pasa
|
||||||
|
al parser PURO ``parse_amazon_ranking_html``. Nunca lanza: cualquier fallo
|
||||||
|
devuelve ``{"status": "error"|"captcha", ...}`` con ``products: []``. NUNCA
|
||||||
|
inventa datos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
marketplace: Dominio Amazon objetivo (``"amazon.es"``, ``"amazon.com"``,
|
||||||
|
...). Determina la URL y la moneda fallback del parser.
|
||||||
|
categories: Lista de slugs de categoria de movers (ej. ``"automotive"``,
|
||||||
|
``"pet-supplies"``). Si es None, scrapea la portada general de movers.
|
||||||
|
Cada slug navega a ``/gp/movers-and-shakers/<slug>``.
|
||||||
|
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
||||||
|
chromium-personal residential de produccion). Para un Chrome aislado
|
||||||
|
apunta a 9333 (el del browser_mcp).
|
||||||
|
max_items: Numero maximo de productos recolectados por categoria.
|
||||||
|
timeout_s: Timeout (segundos) por categoria, tanto para la navegacion como
|
||||||
|
para el polling de aparicion de cards. Default 25.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict autosuficiente. En exito::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"source": "amazon_movers",
|
||||||
|
"count": <N productos>,
|
||||||
|
"products": [ {product_dict}, ... ],
|
||||||
|
}
|
||||||
|
|
||||||
|
donde cada product_dict tiene las claves: marketplace, list_type
|
||||||
|
("movers_shakers"), category, rank (int), asin, title, price (float EUR),
|
||||||
|
currency, rating (float|None), reviews (int|None), pct_change (float|None),
|
||||||
|
url, source ("amazon_movers"), scraped_at (ISO8601 UTC).
|
||||||
|
|
||||||
|
En error::
|
||||||
|
|
||||||
|
{"status": "error", "error": <msg>, "source": "amazon_movers", "products": []}
|
||||||
|
|
||||||
|
Si Amazon sirve captcha::
|
||||||
|
|
||||||
|
{"status": "captcha", "error": <msg>, "source": "amazon_movers", "products": []}
|
||||||
|
"""
|
||||||
|
scraped_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
cats: list[str | None] = list(categories) if categories else [None]
|
||||||
|
|
||||||
|
all_products: list[dict] = []
|
||||||
|
last_error = ""
|
||||||
|
saw_captcha = False
|
||||||
|
|
||||||
|
for category in cats:
|
||||||
|
res = _scrape_one_category(
|
||||||
|
marketplace=marketplace,
|
||||||
|
category=category,
|
||||||
|
port=port,
|
||||||
|
max_items=max_items,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
scraped_at=scraped_at,
|
||||||
|
)
|
||||||
|
if res["ok"]:
|
||||||
|
all_products.extend(res["products"])
|
||||||
|
else:
|
||||||
|
last_error = res["error"]
|
||||||
|
if res.get("captcha"):
|
||||||
|
saw_captcha = True
|
||||||
|
|
||||||
|
if all_products:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"source": "amazon_movers",
|
||||||
|
"count": len(all_products),
|
||||||
|
"products": all_products,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sin productos en ninguna categoria: error o captcha.
|
||||||
|
return {
|
||||||
|
"status": "captcha" if saw_captcha else "error",
|
||||||
|
"error": last_error or "no se extrajo ningun producto",
|
||||||
|
"source": "amazon_movers",
|
||||||
|
"products": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Scraper de Amazon Movers & Shakers via CDP."
|
||||||
|
)
|
||||||
|
parser.add_argument("--marketplace", default="amazon.es")
|
||||||
|
parser.add_argument(
|
||||||
|
"--categories",
|
||||||
|
default="",
|
||||||
|
help="slugs separados por coma (ej. automotive,pet-supplies). Vacio = portada.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--port", type=int, default=9222)
|
||||||
|
parser.add_argument("--max-items", type=int, default=30)
|
||||||
|
parser.add_argument("--timeout-s", type=float, default=25.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cats = [c.strip() for c in args.categories.split(",") if c.strip()] or None
|
||||||
|
out = scrape_amazon_movers_cdp(
|
||||||
|
marketplace=args.marketplace,
|
||||||
|
categories=cats,
|
||||||
|
port=args.port,
|
||||||
|
max_items=args.max_items,
|
||||||
|
timeout_s=args.timeout_s,
|
||||||
|
)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: scrape_amazon_search_saturation_cdp
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def scrape_amazon_search_saturation_cdp(query: str, marketplace: str = 'amazon.es', port: int = 9222, timeout_s: float = 25.0) -> dict"
|
||||||
|
description: "Mide la saturacion de mercado de un termino en la busqueda de Amazon (/s?k=) renderizando la pagina via CDP en el navegador diario. Devuelve nº de resultados que declara Amazon, anuncios patrocinados en el top y cards de la primera pagina. Senal para decidir dropshipping. Compone cdp_open_url_and_wait + cdp_eval; nunca inventa datos (captcha -> status='captcha', sin oferta -> status='error')."
|
||||||
|
tags: [market-intel, amazon, saturation, cdp, dropship]
|
||||||
|
uses_functions: [cdp_open_url_and_wait_py_pipelines, cdp_eval_py_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/scrape_amazon_search_saturation_cdp.py"
|
||||||
|
params:
|
||||||
|
- name: query
|
||||||
|
desc: "Termino de busqueda (ej. 'cepillo gato'). Los espacios se convierten a '+' en la URL /s?k=."
|
||||||
|
- name: marketplace
|
||||||
|
desc: "Dominio Amazon objetivo. Default 'amazon.es'. Tambien 'amazon.com', etc. Determina la URL."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal residential de produccion). 9333 = Chrome aislado del browser_mcp."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout en segundos para la navegacion. Default 25.0. Tras el load event espera ~2s extra para que el grid monte async."
|
||||||
|
output: "dict autosuficiente {status, source:'amazon_saturation', query, marketplace, total_results (int|None = nº de resultados que declara Amazon, aproximado/redondeado), sponsored_top (int = anuncios entre los primeros 16 cards), n_cards (int = cards de la 1a pagina), scraped_at (ISO8601 UTC)}. status='ok' en exito; 'captcha' si Amazon sirve interstitial anti-bot; 'error' si no hay resultados ni cards (con clave 'error' explicando el motivo). total_results puede ser None aun con status='ok' si Amazon oculta el contador."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requiere chromium-personal con CDP vivo en 9222 (navegador diario residential).
|
||||||
|
cd $HOME/fn_registry
|
||||||
|
./fn run scrape_amazon_search_saturation_cdp "cepillo gato"
|
||||||
|
# -> {"status":"ok","source":"amazon_saturation","query":"cepillo gato",
|
||||||
|
# "marketplace":"amazon.es","total_results":50000,"sponsored_top":3,
|
||||||
|
# "n_cards":60,"scraped_at":"2026-06-20T14:38:04+00:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.scrape_amazon_search_saturation_cdp import scrape_amazon_search_saturation_cdp
|
||||||
|
|
||||||
|
r = scrape_amazon_search_saturation_cdp("soporte movil coche")
|
||||||
|
if r["status"] == "ok":
|
||||||
|
print(r["total_results"], "resultados |", r["sponsored_top"], "sponsored top16")
|
||||||
|
elif r["status"] == "captcha":
|
||||||
|
print("handoff humano: Amazon pidio captcha")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando evaluas si un producto/nicho merece la pena para dropshipping y necesitas
|
||||||
|
medir cuanta OFERTA ya existe en Amazon. Muchos resultados + muchos sponsored =
|
||||||
|
mercado saturado y disputado (mala apuesta); pocos resultados = hueco. Usala como
|
||||||
|
filtro de saturacion en el pipeline de market-intel de `captacion_clientes`,
|
||||||
|
junto a las senales de demanda (`scrape_amazon_movers_cdp`) y precios de la
|
||||||
|
competencia. Tambien para comparar la misma query entre marketplaces.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **IMPURA, depende de chromium-personal**: necesita un Chrome con remote
|
||||||
|
debugging vivo en `port` (9222 = navegador diario residential). Si el puerto no
|
||||||
|
responde -> `status='error'` con mensaje "no hay Chrome usable en el puerto N".
|
||||||
|
El residential pasa el anti-bot mucho mejor que `requests`/Chrome aislado.
|
||||||
|
- **El nº de resultados es APROXIMADO**: Amazon redondea el contador (ej. "more
|
||||||
|
than 50,000 results"). `total_results` es esa cifra redondeada, no un conteo
|
||||||
|
exacto. Util como orden de magnitud, no como dato fino.
|
||||||
|
- **`total_results` puede ser None aun con `status='ok'`**: Amazon a veces oculta
|
||||||
|
el `s-result-info-bar` para ciertas busquedas. Si hay cards (`n_cards > 0`) el
|
||||||
|
status sigue siendo `'ok'`; usa `n_cards` y `sponsored_top` como respaldo.
|
||||||
|
- **Header en ingles aun en amazon.es**: el bar puede salir "over 50,000 results"
|
||||||
|
en vez de "mas de 50.000 resultados". El regex y el parser de int cubren ambos
|
||||||
|
idiomas y ambos separadores de millar (`,` y `.`).
|
||||||
|
- **Captcha = handoff humano**: si `status='captcha'`, NO reintentar en bucle ni
|
||||||
|
auto-resolver. El token va atado a la sesion del navegador; activa la pestana
|
||||||
|
(`fn run focus_cdp_tab_window`) y avisa al humano.
|
||||||
|
- **Fragil ante cambios de layout**: depende de los selectores de Amazon
|
||||||
|
(`s-result-info-bar`, `s-search-result`, texto "sponsored/patrocinado"). Si
|
||||||
|
Amazon cambia la plantilla, el JS deja de extraer y devuelve `status='error'`.
|
||||||
|
- **`sponsored_top` solo mira los primeros 16 cards**: es una muestra de la
|
||||||
|
densidad de anuncios arriba, no el total de sponsored de la pagina.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Scraper de saturacion de la busqueda de Amazon via Chrome DevTools Protocol (CDP).
|
||||||
|
|
||||||
|
Funcion IMPURA. Mide cuanta OFERTA existe ya en Amazon para un termino de
|
||||||
|
busqueda — el numero de resultados que Amazon declara, cuantos de los primeros
|
||||||
|
cards son anuncios patrocinados y cuantos cards monta la primera pagina. Es una
|
||||||
|
senal de SATURACION de mercado: muchos resultados + muchos sponsored = nicho
|
||||||
|
disputado, mala apuesta para dropshipping; pocos resultados = hueco.
|
||||||
|
|
||||||
|
La pagina ``/s?k=<query>`` de Amazon monta sus cards por JavaScript (el GET HTTP
|
||||||
|
puro devuelve un esqueleto), asi que esta funcion renderiza la pagina en un
|
||||||
|
Chrome con remote debugging (el navegador diario residential en 9222 pasa el
|
||||||
|
anti-bot mejor que ``requests``), espera al render y extrae las metricas con un
|
||||||
|
unico ``Runtime.evaluate``.
|
||||||
|
|
||||||
|
Compone DOS funciones del registry (no reescribe transporte CDP):
|
||||||
|
1. ``cdp_open_url_and_wait`` (pipeline) — crea tab nuevo en el Chrome remoto,
|
||||||
|
navega a la URL de busqueda y espera ``Page.loadEventFired``.
|
||||||
|
2. ``cdp_eval`` (browser) — evalua el JS de extraccion en la pestana cuya URL
|
||||||
|
contiene un substring unico del query.
|
||||||
|
|
||||||
|
Devuelve SIEMPRE un dict autosuficiente (estilo del grupo market-intel): nunca
|
||||||
|
lanza. NUNCA inventa datos: si Amazon sirve un captcha devuelve
|
||||||
|
``status="captcha"``; si no hay resultados ni cards, ``status="error"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||||
|
|
||||||
|
# JS de extraccion YA VALIDADO a mano contra amazon.es. Devuelve un JSON string
|
||||||
|
# con: captcha (bool), total_raw (str|None = numero de resultados tal cual lo
|
||||||
|
# escribe Amazon), n_cards (int), sponsored_top16 (int). Cubre el header tanto
|
||||||
|
# en ingles ("over 50,000 results") como en espanol ("mas de 50.000 resultados")
|
||||||
|
# porque Amazon a veces sirve el bar en ingles aun en amazon.es.
|
||||||
|
_EXTRACT_JS = r"""
|
||||||
|
(() => {
|
||||||
|
const r={};
|
||||||
|
r.captcha=/captcha|robot check|introduce los caracteres/i.test(document.body.innerText.slice(0,400));
|
||||||
|
const bar=document.querySelector('[data-component-type="s-result-info-bar"]');
|
||||||
|
const ht=(bar?bar.innerText:document.body.innerText.slice(0,400));
|
||||||
|
const m=ht.match(/over\s+([\d.,]+)\s+results/i)||ht.match(/m[aá]s de\s+([\d.,]+)\s+resultados/i)||ht.match(/([\d.,]+)\s+results/i)||ht.match(/([\d.,]+)\s+resultados/i);
|
||||||
|
r.total_raw=m?m[1]:null;
|
||||||
|
const cards=[...document.querySelectorAll('div[data-component-type="s-search-result"]')];
|
||||||
|
r.n_cards=cards.length;
|
||||||
|
let sp=0;
|
||||||
|
cards.slice(0,16).forEach(c=>{ if(/sponsored|patrocinado/i.test(c.innerText.slice(0,50)))sp++; });
|
||||||
|
r.sponsored_top16=sp;
|
||||||
|
return JSON.stringify(r);
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_url(marketplace: str, query: str) -> str:
|
||||||
|
"""URL de la busqueda de Amazon para un marketplace y query.
|
||||||
|
|
||||||
|
Base ``https://www.<marketplace>/s?k=<query>`` con los espacios del query
|
||||||
|
convertidos a ``+`` (formato de busqueda de Amazon).
|
||||||
|
"""
|
||||||
|
k = "+".join(query.split())
|
||||||
|
return f"https://www.{marketplace}/s?k={k}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_total(total_raw: str | None) -> int | None:
|
||||||
|
"""Convierte el numero de resultados de Amazon a int.
|
||||||
|
|
||||||
|
Amazon escribe el numero con separadores de miles que varian por locale
|
||||||
|
(``"50,000"`` en ingles, ``"50.000"`` en espanol). Se quitan ``,`` y ``.``
|
||||||
|
porque son separadores de millar, no decimales (el conteo de resultados es
|
||||||
|
siempre entero). Devuelve None si no hay numero o no se puede parsear.
|
||||||
|
"""
|
||||||
|
if not total_raw:
|
||||||
|
return None
|
||||||
|
digits = total_raw.replace(",", "").replace(".", "").replace(" ", "")
|
||||||
|
if not digits.isdigit():
|
||||||
|
return None
|
||||||
|
return int(digits)
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_amazon_search_saturation_cdp(
|
||||||
|
query: str,
|
||||||
|
marketplace: str = "amazon.es",
|
||||||
|
port: int = 9222,
|
||||||
|
timeout_s: float = 25.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Mide la saturacion de la busqueda de Amazon para un termino, via CDP.
|
||||||
|
|
||||||
|
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en
|
||||||
|
``port`` (el chromium-personal residential en 9222 pasa el anti-bot mejor
|
||||||
|
que ``requests``). Navega a ``/s?k=<query>``, espera al render, y extrae con
|
||||||
|
un solo ``Runtime.evaluate`` el numero de resultados que Amazon declara,
|
||||||
|
cuantos cards monta la primera pagina y cuantos de los primeros 16 son
|
||||||
|
anuncios patrocinados. Nunca lanza: cualquier fallo devuelve
|
||||||
|
``{"status": "error"|"captcha", ...}``. NUNCA inventa datos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Termino de busqueda (ej. ``"cepillo gato"``). Los espacios se
|
||||||
|
convierten a ``+`` en la URL.
|
||||||
|
marketplace: Dominio Amazon objetivo (``"amazon.es"``, ``"amazon.com"``,
|
||||||
|
...). Determina la URL.
|
||||||
|
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
||||||
|
chromium-personal residential de produccion). Para un Chrome aislado
|
||||||
|
apunta a 9333 (el del browser_mcp).
|
||||||
|
timeout_s: Timeout (segundos) para la navegacion. Default 25.0. Tras el
|
||||||
|
load event la funcion espera ~2s extra para que el grid monte async.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict autosuficiente. En exito::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"source": "amazon_saturation",
|
||||||
|
"query": <query>,
|
||||||
|
"marketplace": <marketplace>,
|
||||||
|
"total_results": <int|None>, # nº de resultados que declara Amazon
|
||||||
|
"sponsored_top": <int>, # anuncios entre los primeros 16 cards
|
||||||
|
"n_cards": <int>, # cards en la primera pagina
|
||||||
|
"scraped_at": <ISO8601 UTC>,
|
||||||
|
}
|
||||||
|
|
||||||
|
``total_results`` puede ser None aunque el resto sea valido (Amazon a
|
||||||
|
veces oculta el contador de resultados para algunas busquedas). En ese
|
||||||
|
caso, si hay cards (``n_cards > 0``) el status sigue siendo ``"ok"``.
|
||||||
|
|
||||||
|
En captcha::
|
||||||
|
|
||||||
|
{"status": "captcha", "source": "amazon_saturation", "query": ...,
|
||||||
|
"marketplace": ..., "total_results": None, "sponsored_top": 0,
|
||||||
|
"n_cards": 0, "scraped_at": ..., "error": <msg>}
|
||||||
|
|
||||||
|
En error (sin captcha pero sin resultados ni cards)::
|
||||||
|
|
||||||
|
{"status": "error", ... , "error": <msg>}
|
||||||
|
"""
|
||||||
|
scraped_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def _fail(status: str, error: str) -> dict:
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"source": "amazon_saturation",
|
||||||
|
"query": query,
|
||||||
|
"marketplace": marketplace,
|
||||||
|
"total_results": None,
|
||||||
|
"sponsored_top": 0,
|
||||||
|
"n_cards": 0,
|
||||||
|
"scraped_at": scraped_at,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
url = _build_url(marketplace, query)
|
||||||
|
# Substring unico del query en la URL para localizar la pestana en cdp_eval.
|
||||||
|
first_word = (query.split() or [""])[0]
|
||||||
|
target_substr = "k=" + first_word
|
||||||
|
|
||||||
|
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
|
||||||
|
try:
|
||||||
|
cdp_open_url_and_wait(port, url, int(timeout_s))
|
||||||
|
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
|
||||||
|
msg = str(e)
|
||||||
|
if (
|
||||||
|
"no se pudo crear tab" in msg
|
||||||
|
or "URLError" in msg
|
||||||
|
or "Connection refused" in msg
|
||||||
|
or "timeout" in msg.lower()
|
||||||
|
):
|
||||||
|
msg = (
|
||||||
|
f"no hay Chrome usable en el puerto {port} "
|
||||||
|
f"(¿remote debugging activo?): {e}"
|
||||||
|
)
|
||||||
|
return _fail("error", msg)
|
||||||
|
|
||||||
|
# 2. Esperar al render async del grid antes de extraer.
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
# 3. Extraer las metricas con un unico Runtime.evaluate.
|
||||||
|
r = cdp_eval(
|
||||||
|
_EXTRACT_JS,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=target_substr,
|
||||||
|
timeout_s=max(10.0, timeout_s),
|
||||||
|
)
|
||||||
|
if not r.get("ok"):
|
||||||
|
return _fail("error", r.get("error") or "cdp_eval fallo sin mensaje")
|
||||||
|
|
||||||
|
raw = r.get("value")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
return _fail("error", f"extraccion no devolvio JSON valido: {e}")
|
||||||
|
|
||||||
|
if data.get("captcha"):
|
||||||
|
return _fail(
|
||||||
|
"captcha", "Amazon sirvio un captcha / interstitial anti-bot"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_results = _parse_total(data.get("total_raw"))
|
||||||
|
n_cards = int(data.get("n_cards") or 0)
|
||||||
|
sponsored_top = int(data.get("sponsored_top16") or 0)
|
||||||
|
|
||||||
|
# Sin resultados declarados Y sin cards = pagina degradada / sin ofertas.
|
||||||
|
if total_results is None and n_cards == 0:
|
||||||
|
return _fail(
|
||||||
|
"error",
|
||||||
|
"no hay resultados ni cards (¿query sin oferta, pagina degradada "
|
||||||
|
"o chromium no logueado?)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"source": "amazon_saturation",
|
||||||
|
"query": query,
|
||||||
|
"marketplace": marketplace,
|
||||||
|
"total_results": total_results,
|
||||||
|
"sponsored_top": sponsored_top,
|
||||||
|
"n_cards": n_cards,
|
||||||
|
"scraped_at": scraped_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Scraper de saturacion de la busqueda de Amazon via CDP."
|
||||||
|
)
|
||||||
|
parser.add_argument("query", nargs="?", default="cepillo gato")
|
||||||
|
parser.add_argument("--marketplace", default="amazon.es")
|
||||||
|
parser.add_argument("--port", type=int, default=9222)
|
||||||
|
parser.add_argument("--timeout-s", type=float, default=25.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out = scrape_amazon_search_saturation_cdp(
|
||||||
|
query=args.query,
|
||||||
|
marketplace=args.marketplace,
|
||||||
|
port=args.port,
|
||||||
|
timeout_s=args.timeout_s,
|
||||||
|
)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
name: scrape_upwork_projects
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "0.1.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def scrape_upwork_projects(query: str = '', sort: str = 'recency', pages: int = 1, port: int = 9222, timeout_s: float = 25.0) -> dict"
|
||||||
|
description: "Scraper de ofertas de trabajo (jobs) de Upwork via Chrome DevTools Protocol sobre una pestana YA LOGUEADA del navegador diario (chromium-personal, port 9222). Upwork tiene anti-bot fuerte (Cloudflare + PerimeterX): HTTP puro recibe 403 y la busqueda real exige sesion. Por eso navega via CDP a /nx/search/jobs, hace polling hasta que montan las job tiles (SPA) y extrae con un solo eval. Pieza 2 (hermana de scrape_workana_projects) de un monitor de captacion de clientes. Devuelve el shape unificado: status, source='upwork', count, projects con job_id, url, title, budget, posted, bids, skills, snippet, country, scraped_at. NUNCA inventa datos: sin tiles devuelve status error."
|
||||||
|
tags: [market-intel, recon, flow-replay, browser, cdp, upwork, scraper, jobs, freelance, captacion-clientes]
|
||||||
|
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: query
|
||||||
|
desc: "Busqueda libre, ej. 'custom software'. Se url-encodea. '' (vacio) = listado de jobs por defecto."
|
||||||
|
- name: sort
|
||||||
|
desc: "Orden de resultados: 'recency' (mas recientes, default) o 'relevance'. Cualquier otro valor cae a 'recency'."
|
||||||
|
- name: pages
|
||||||
|
desc: "Numero de paginas de resultados a recorrer (>=1). Default 1. Cada pagina = navegacion + extraccion."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome LOGUEADO en Upwork. Default 9222 (chromium-personal, navegador diario con sesion activa). NO usar 9333 (Chrome aislado del browser_mcp, sin login)."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout por pagina en segundos para navegacion + aparicion de las job tiles (polling cada 1s). Default 25.0."
|
||||||
|
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'upwork', count:N, projects:[{source:'upwork', job_id, url, title, budget, posted, bids, skills:list[str], snippet, country, scraped_at:ISO8601-UTC}, ...]}. En error (sin job tiles): {status:'error', error:<mensaje claro>, source:'upwork', projects:[]}. Shape IDENTICO al scraper de Workana para que un agregador downstream consuma ambas fuentes sin ramas. Campos no encontrados en el DOM quedan a null."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/scrape_upwork_projects.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requiere chromium-personal LOGUEADO en Upwork escuchando en port 9222.
|
||||||
|
fn run scrape_upwork_projects --query "custom software" --sort recency
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, json
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.scrape_upwork_projects import scrape_upwork_projects
|
||||||
|
|
||||||
|
# Navega a la busqueda logueada y extrae las job tiles de la primera pagina.
|
||||||
|
res = scrape_upwork_projects(query="custom software", sort="recency", pages=1, port=9222)
|
||||||
|
if res["status"] == "ok":
|
||||||
|
print(f"{res['count']} jobs de Upwork")
|
||||||
|
for job in res["projects"][:3]:
|
||||||
|
print(job["title"], "|", job["budget"], "|", job["country"])
|
||||||
|
else:
|
||||||
|
# Sin sesion logueada o selectores desfasados → error explicito, projects vacio.
|
||||||
|
print("error:", res["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites el feed de jobs/ofertas de Upwork para un monitor de captacion de
|
||||||
|
clientes y tengas el navegador diario (chromium-personal) LOGUEADO en Upwork. Es la
|
||||||
|
pieza 2 del par con `scrape_workana_projects`: ambas devuelven el mismo shape
|
||||||
|
unificado, asi que un agregador downstream las consume sin ramas especiales. Usala
|
||||||
|
cuando el HTTP puro no sirve (Upwork = 403 por Cloudflare + PerimeterX) y la
|
||||||
|
busqueda exige sesion. Para una sola consulta puntual: `fn run scrape_upwork_projects
|
||||||
|
--query "..."`. Para recorrer varias paginas: sube `pages`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Selectores NO validados en vivo (sin sesion al crearla).** El extractor JS usa
|
||||||
|
selectores best-effort de Upwork con cascada por campo. Estan declarados en la
|
||||||
|
constante `SELECTORS` del `.py` para que corregirlos sea trivial. **Valida los
|
||||||
|
selectores con una busqueda real ANTES de confiar en produccion**: Upwork cambia
|
||||||
|
el DOM con frecuencia. Si un campo sale `null` de forma sistematica, el selector de
|
||||||
|
ese campo esta desfasado.
|
||||||
|
- **Requiere chromium-personal LOGUEADO en Upwork en `port` (9222).** Sin sesion la
|
||||||
|
pagina de busqueda no muestra resultados reales (redirige a login / challenge). El
|
||||||
|
servicio systemd `chromium-personal` debe estar vivo con remote debugging activo.
|
||||||
|
Sin Chrome en el puerto: error claro, no lanza.
|
||||||
|
- **NO usar `port=9333`** (Chrome aislado del browser_mcp): no tiene tu login de
|
||||||
|
Upwork, asi que no veria los resultados logueados.
|
||||||
|
- **Sin job tiles → `status:"error"` con `projects` vacio.** La funcion NUNCA inventa
|
||||||
|
datos. El mensaje distingue las dos causas probables: sesion no logueada o
|
||||||
|
selectores desactualizados ("Validar con sesion real").
|
||||||
|
- **Anti-bot puede mostrar un challenge** (Cloudflare/PerimeterX) en vez de los
|
||||||
|
resultados aunque haya sesion. En ese caso no aparecen tiles y devuelve error: hay
|
||||||
|
que resolver el challenge a mano en el navegador antes de reintentar.
|
||||||
|
- **Mezcla tu sesion personal.** Con `port=9222` abre tabs en TU navegador diario (los
|
||||||
|
cierra best-effort con `window.close()` al terminar). Respeta los terminos de
|
||||||
|
servicio de Upwork y el scope legal del scraping.
|
||||||
|
- **`scraped_at` y `source` los pone Python**, no el JS, para garantizar el sello UTC
|
||||||
|
consistente en todas las filas de la misma corrida.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.1.0 (2026-06-17) — version inicial. Selectores best-effort PENDIENTES de
|
||||||
|
validacion en vivo (no habia sesion Upwork logueada al crear la funcion). El
|
||||||
|
extractor JS lee la constante `SELECTORS`; corregir alli tras validar con una
|
||||||
|
busqueda real. Sin smoke test ejecutado contra Upwork.
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""Scraper de ofertas de trabajo (jobs) de Upwork via Chrome DevTools Protocol.
|
||||||
|
|
||||||
|
Funcion IMPURA: usa una pestana del navegador diario YA LOGUEADA en Upwork
|
||||||
|
(Chrome con remote debugging en `port`, normalmente 9222 / chromium-personal)
|
||||||
|
para ejecutar la busqueda de jobs y extraer las tarjetas de resultado.
|
||||||
|
|
||||||
|
POR QUE CDP Y NO HTTP PURO:
|
||||||
|
Upwork esta protegido por Cloudflare + PerimeterX. Un GET con urllib/requests
|
||||||
|
recibe 403 y la busqueda real (`/nx/search/jobs/`) exige SESION LOGUEADA. Por eso
|
||||||
|
vamos por CDP sobre el chromium diario del usuario, que ya tiene login: navegamos
|
||||||
|
a la URL de busqueda, esperamos a que monten las job tiles (la pagina es una SPA),
|
||||||
|
y extraemos con un solo `Runtime.evaluate`.
|
||||||
|
|
||||||
|
Es la PIEZA 2 (hermana de scrape_workana_projects) de un monitor de captacion de
|
||||||
|
clientes. Devuelve EXACTAMENTE el mismo shape unificado que el scraper de Workana
|
||||||
|
para que un agregador downstream consuma ambas fuentes sin ramas especiales.
|
||||||
|
|
||||||
|
COMPONE dos funciones del registry (no reescribe transporte CDP):
|
||||||
|
1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en el Chrome remoto,
|
||||||
|
navega a la URL de busqueda y espera `Page.loadEventFired`. Devuelve tab_id.
|
||||||
|
2. `cdp_eval` (browser) — evalua el extractor JS en la pestana cuyo URL contiene
|
||||||
|
un substring (aqui: "upwork.com/nx/search/jobs").
|
||||||
|
|
||||||
|
NUNCA inventa datos: si tras el timeout no aparecen job tiles, devuelve
|
||||||
|
`{"status": "error", ...}` con `projects` vacio. Nunca lanza excepciones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SELECTORES — best-effort, NO validados en vivo (sin sesion al crear la funcion).
|
||||||
|
#
|
||||||
|
# Upwork cambia el DOM con frecuencia. Cada campo usa una CASCADA de selectores
|
||||||
|
# (se prueba el primero que matchee; si ninguno → null). Para corregir tras una
|
||||||
|
# validacion real, edita SOLO este dict: el extractor JS lo lee tal cual.
|
||||||
|
#
|
||||||
|
# Referencia de los data-test conocidos (2025-2026):
|
||||||
|
# - tile contenedor: article.job-tile | [data-test="JobTile"]
|
||||||
|
# - lista de tiles: section[data-test="job-tile-list"]
|
||||||
|
# - titulo + link: a[data-test="job-tile-title-link"] | h2 a | h3 a
|
||||||
|
# - presupuesto: [data-test="job-type-label"] | [data-test="is-fixed-price"]
|
||||||
|
# | [data-test="budget"]
|
||||||
|
# - propuestas: [data-test="proposals-tier"] | [data-test="proposals"]
|
||||||
|
# - skills (tokens): [data-test="token"] | .air3-token | [data-test="attr-item"]
|
||||||
|
# - snippet: [data-test="job-description-text"] | [data-test="UpCLineClamp"]
|
||||||
|
# | p
|
||||||
|
# - pais: [data-test="location"] | [data-test="client-country"]
|
||||||
|
# - fecha publicada: [data-test="job-pubilshed-date"] | [data-test="posted-on"]
|
||||||
|
# | small[data-test="job-publish-time"]
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SELECTORS = {
|
||||||
|
# Tarjetas (tiles). Se prueban en orden hasta que alguna devuelva >0 nodos.
|
||||||
|
"tile": [
|
||||||
|
'section[data-test="job-tile-list"] article.job-tile',
|
||||||
|
'section[data-test="job-tile-list"] [data-test="JobTile"]',
|
||||||
|
'article.job-tile',
|
||||||
|
'[data-test="JobTile"]',
|
||||||
|
'[data-test="job-tile"]',
|
||||||
|
],
|
||||||
|
# Dentro de cada tile — todos relativos al nodo de la tile.
|
||||||
|
"title_link": [
|
||||||
|
'a[data-test="job-tile-title-link"]',
|
||||||
|
'h2 a',
|
||||||
|
'h3 a',
|
||||||
|
'a[href*="/jobs/"]',
|
||||||
|
],
|
||||||
|
"budget": [
|
||||||
|
'[data-test="job-type-label"]',
|
||||||
|
'[data-test="is-fixed-price"]',
|
||||||
|
'[data-test="budget"]',
|
||||||
|
'[data-test="JobInfoByLine"]',
|
||||||
|
],
|
||||||
|
"bids": [
|
||||||
|
'[data-test="proposals-tier"]',
|
||||||
|
'[data-test="proposals"]',
|
||||||
|
'[data-test="ProposalsTier"]',
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
'[data-test="token"]',
|
||||||
|
'.air3-token',
|
||||||
|
'[data-test="attr-item"]',
|
||||||
|
],
|
||||||
|
"snippet": [
|
||||||
|
'[data-test="job-description-text"]',
|
||||||
|
'[data-test="UpCLineClamp"]',
|
||||||
|
'p',
|
||||||
|
],
|
||||||
|
"country": [
|
||||||
|
'[data-test="location"]',
|
||||||
|
'[data-test="client-country"]',
|
||||||
|
'small[data-test="client-location"]',
|
||||||
|
],
|
||||||
|
"posted": [
|
||||||
|
'[data-test="job-pubilshed-date"]',
|
||||||
|
'[data-test="posted-on"]',
|
||||||
|
'small[data-test="job-publish-time"]',
|
||||||
|
'[data-test="JobInfoByLine"] span',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_extractor_js(selectors: dict) -> str:
|
||||||
|
"""Construye el extractor JS que lee las job tiles del DOM ya montado.
|
||||||
|
|
||||||
|
El JS recibe el dict de selectores serializado e implementa la cascada por
|
||||||
|
campo. Devuelve `JSON.stringify({tiles_found, projects})`. Si no encuentra
|
||||||
|
ninguna tile, `tiles_found` es 0 y `projects` queda vacio — el lado Python
|
||||||
|
decide entonces el error (sesion no logueada o selectores desfasados).
|
||||||
|
"""
|
||||||
|
sel_json = json.dumps(selectors)
|
||||||
|
return (
|
||||||
|
"(function(){"
|
||||||
|
f" var S = {sel_json};"
|
||||||
|
# firstMatch: primer nodo que matchee alguno de los selectores (en root).
|
||||||
|
" function firstMatch(root, list){"
|
||||||
|
" for (var i=0;i<list.length;i++){"
|
||||||
|
" try { var n = root.querySelector(list[i]); if (n) return n; } catch(e){}"
|
||||||
|
" }"
|
||||||
|
" return null;"
|
||||||
|
" }"
|
||||||
|
# allMatch: nodos del primer selector de la lista que devuelva >0.
|
||||||
|
" function allMatch(root, list){"
|
||||||
|
" for (var i=0;i<list.length;i++){"
|
||||||
|
" try { var ns = root.querySelectorAll(list[i]); if (ns && ns.length) return Array.prototype.slice.call(ns); } catch(e){}"
|
||||||
|
" }"
|
||||||
|
" return [];"
|
||||||
|
" }"
|
||||||
|
" function txt(node){ return node ? (node.textContent||'').replace(/\\s+/g,' ').trim() : null; }"
|
||||||
|
# Localizar las tiles probando los selectores de tile en orden.
|
||||||
|
" var tiles = [];"
|
||||||
|
" for (var t=0;t<S.tile.length;t++){"
|
||||||
|
" try { var found = document.querySelectorAll(S.tile[t]); if (found && found.length){ tiles = Array.prototype.slice.call(found); break; } } catch(e){}"
|
||||||
|
" }"
|
||||||
|
" var out = [];"
|
||||||
|
" for (var k=0;k<tiles.length;k++){"
|
||||||
|
" var tile = tiles[k];"
|
||||||
|
" var a = firstMatch(tile, S.title_link);"
|
||||||
|
" var url = null, title = null, jobId = null;"
|
||||||
|
" if (a){"
|
||||||
|
" title = txt(a);"
|
||||||
|
" var href = a.getAttribute('href') || '';"
|
||||||
|
" if (href){"
|
||||||
|
" url = href.indexOf('http') === 0 ? href : ('https://www.upwork.com' + href);"
|
||||||
|
# job_id = ultimo segmento ~XXXX de la URL del job, o el href crudo si no.
|
||||||
|
" var m = href.match(/~[0-9a-zA-Z]+/);"
|
||||||
|
" jobId = m ? m[0] : href;"
|
||||||
|
" }"
|
||||||
|
" }"
|
||||||
|
" var budget = txt(firstMatch(tile, S.budget));"
|
||||||
|
" var bids = txt(firstMatch(tile, S.bids));"
|
||||||
|
" var snippet = txt(firstMatch(tile, S.snippet));"
|
||||||
|
" var country = txt(firstMatch(tile, S.country));"
|
||||||
|
" var posted = txt(firstMatch(tile, S.posted));"
|
||||||
|
" var skillNodes = allMatch(tile, S.skills);"
|
||||||
|
" var skills = [];"
|
||||||
|
" for (var s=0;s<skillNodes.length;s++){ var st = txt(skillNodes[s]); if (st) skills.push(st); }"
|
||||||
|
" out.push({"
|
||||||
|
" job_id: jobId,"
|
||||||
|
" url: url,"
|
||||||
|
" title: title,"
|
||||||
|
" budget: budget,"
|
||||||
|
" posted: posted,"
|
||||||
|
" bids: bids,"
|
||||||
|
" skills: skills,"
|
||||||
|
" snippet: snippet,"
|
||||||
|
" country: country"
|
||||||
|
" });"
|
||||||
|
" }"
|
||||||
|
" return JSON.stringify({tiles_found: tiles.length, projects: out});"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_upwork_projects(
|
||||||
|
query: str = "",
|
||||||
|
sort: str = "recency",
|
||||||
|
pages: int = 1,
|
||||||
|
port: int = 9222,
|
||||||
|
timeout_s: float = 25.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Scrapea jobs de Upwork via CDP sobre una pestana YA LOGUEADA del navegador.
|
||||||
|
|
||||||
|
Funcion IMPURA: requiere un Chrome con remote debugging en `port` (normalmente
|
||||||
|
9222, el chromium-personal del usuario, con sesion de Upwork activa). Para cada
|
||||||
|
pagina: navega a la URL de busqueda, hace polling hasta que aparecen las job
|
||||||
|
tiles (SPA), y extrae con un solo eval. Nunca lanza: cualquier fallo devuelve
|
||||||
|
`{"status": "error", ...}`. NUNCA inventa datos: sin tiles → error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Busqueda libre, ej. "custom software". "" = listado por defecto.
|
||||||
|
sort: Orden de resultados: "recency" (mas recientes) o "relevance".
|
||||||
|
pages: Numero de paginas de resultados a recorrer (>=1). Default 1.
|
||||||
|
port: Puerto de remote debugging del Chrome logueado. Default 9222.
|
||||||
|
timeout_s: Timeout por pagina (segundos) para navegacion + aparicion de
|
||||||
|
tiles. Default 25.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con el shape unificado (identico al scraper de Workana). En exito::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"source": "upwork",
|
||||||
|
"count": <N>,
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"source": "upwork",
|
||||||
|
"job_id": <id ~XXXX o href>,
|
||||||
|
"url": <url absoluta del job>,
|
||||||
|
"title": <titulo o None>,
|
||||||
|
"budget": <texto presupuesto/tipo o None>,
|
||||||
|
"posted": <fecha publicada o None>,
|
||||||
|
"bids": <propuestas/"Proposals" o None>,
|
||||||
|
"skills": [<skill>, ...],
|
||||||
|
"snippet": <descripcion corta o None>,
|
||||||
|
"country": <pais del cliente o None>,
|
||||||
|
"scraped_at": <ISO8601 UTC>,
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
En error::
|
||||||
|
|
||||||
|
{"status": "error", "error": <mensaje claro>, "source": "upwork", "projects": []}
|
||||||
|
"""
|
||||||
|
if pages < 1:
|
||||||
|
pages = 1
|
||||||
|
if sort not in ("recency", "relevance"):
|
||||||
|
sort = "recency"
|
||||||
|
|
||||||
|
extractor_js = _build_extractor_js(SELECTORS)
|
||||||
|
substr = "upwork.com/nx/search/jobs"
|
||||||
|
|
||||||
|
all_projects: list[dict] = []
|
||||||
|
last_error: str = ""
|
||||||
|
any_tiles_seen = False
|
||||||
|
|
||||||
|
for page_num in range(1, pages + 1):
|
||||||
|
params = {"q": query, "sort": sort, "page": page_num}
|
||||||
|
# url-encode de los params (la query libre puede llevar espacios/acentos).
|
||||||
|
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""})
|
||||||
|
url = f"https://www.upwork.com/nx/search/jobs/?{qs}"
|
||||||
|
|
||||||
|
# 1. Navegar: crea tab nuevo en el Chrome logueado y espera el load event.
|
||||||
|
try:
|
||||||
|
cdp_open_url_and_wait(port, url, int(timeout_s))
|
||||||
|
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
|
||||||
|
msg = str(e)
|
||||||
|
if "no se pudo crear tab" in msg or "URLError" in msg or "Connection refused" in msg:
|
||||||
|
msg = f"no hay Chrome en el puerto {port} (¿remote debugging / chromium-personal activo?): {e}"
|
||||||
|
last_error = f"navegacion fallo (page {page_num}): {msg}"
|
||||||
|
# Sin navegacion no hay nada que extraer en esta pagina; continua a la siguiente.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Polling hasta que aparezcan las tiles (la SPA monta el DOM en runtime).
|
||||||
|
# Se reintenta el extractor cada 1s hasta timeout_s; en cuanto encuentra
|
||||||
|
# tiles (o agota el tiempo) sale del bucle.
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
page_projects: list[dict] = []
|
||||||
|
page_tiles = 0
|
||||||
|
eval_error = ""
|
||||||
|
while True:
|
||||||
|
r = cdp_eval(
|
||||||
|
extractor_js,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=substr,
|
||||||
|
timeout_s=max(10.0, timeout_s),
|
||||||
|
)
|
||||||
|
if not r.get("ok"):
|
||||||
|
eval_error = r.get("error") or "eval CDP fallo sin mensaje"
|
||||||
|
else:
|
||||||
|
raw_value = r.get("value")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or {})
|
||||||
|
except Exception: # noqa: BLE001 — JSON malformado del eval
|
||||||
|
data = {}
|
||||||
|
page_tiles = int(data.get("tiles_found") or 0)
|
||||||
|
page_projects = data.get("projects") or []
|
||||||
|
if page_tiles > 0:
|
||||||
|
break # ya hay resultados, no seguir esperando
|
||||||
|
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# 3. (best-effort) cerrar el tab para no dejar pestanas abiertas.
|
||||||
|
try:
|
||||||
|
cdp_eval("window.close()", port=port, target_url_substr=substr, timeout_s=5.0)
|
||||||
|
except Exception: # noqa: BLE001 — cierre best-effort
|
||||||
|
pass
|
||||||
|
|
||||||
|
if page_tiles > 0:
|
||||||
|
any_tiles_seen = True
|
||||||
|
all_projects.extend(page_projects)
|
||||||
|
elif eval_error:
|
||||||
|
last_error = f"eval fallo (page {page_num}): {eval_error}"
|
||||||
|
|
||||||
|
# 4. Sin tiles en NINGUNA pagina → error explicito (no inventar datos).
|
||||||
|
if not any_tiles_seen:
|
||||||
|
err = (
|
||||||
|
"no job tiles — ¿sesion Upwork no logueada en port, o selectores "
|
||||||
|
"desactualizados? Validar con sesion real"
|
||||||
|
)
|
||||||
|
if last_error:
|
||||||
|
err = f"{err} | detalle: {last_error}"
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": err,
|
||||||
|
"source": "upwork",
|
||||||
|
"projects": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Enriquecer cada fila: source + scraped_at (Python, no el JS).
|
||||||
|
scraped_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
for p in all_projects:
|
||||||
|
p["source"] = "upwork"
|
||||||
|
p["scraped_at"] = scraped_at
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"source": "upwork",
|
||||||
|
"count": len(all_projects),
|
||||||
|
"projects": all_projects,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Scrapea jobs de Upwork via CDP (sesion logueada).")
|
||||||
|
parser.add_argument("--query", default="custom software", help="busqueda libre")
|
||||||
|
parser.add_argument("--sort", default="recency", choices=["recency", "relevance"])
|
||||||
|
parser.add_argument("--pages", type=int, default=1)
|
||||||
|
parser.add_argument("--port", type=int, default=9222)
|
||||||
|
parser.add_argument("--timeout-s", type=float, default=25.0, dest="timeout_s")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out = scrape_upwork_projects(
|
||||||
|
query=args.query,
|
||||||
|
sort=args.sort,
|
||||||
|
pages=args.pages,
|
||||||
|
port=args.port,
|
||||||
|
timeout_s=args.timeout_s,
|
||||||
|
)
|
||||||
|
# No volcar snippets enormes: resumen compacto.
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: scrape_workana_projects
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict"
|
||||||
|
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
|
||||||
|
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
|
||||||
|
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: category
|
||||||
|
desc: "Categoria de Workana (segmento de la URL ?category=). Default 'it-programming'. Otros ejemplos: 'design-multimedia', 'writing-translation'."
|
||||||
|
- name: language
|
||||||
|
desc: "Idioma de los proyectos (?language=). Default 'es'."
|
||||||
|
- name: extra_query
|
||||||
|
desc: "Query libre opcional (?query=...). Si '', se omite. Util para filtrar por palabra clave (ej. 'python', 'scraping')."
|
||||||
|
- name: pages
|
||||||
|
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
|
||||||
|
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/scrape_workana_projects.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
|
||||||
|
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
|
||||||
|
|
||||||
|
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login):
|
||||||
|
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
||||||
|
|
||||||
|
# Produccion (chromium-personal, port 9222 por defecto):
|
||||||
|
fn run scrape_workana_projects it-programming es "" 1 9222 20
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
|
||||||
|
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
|
||||||
|
--category it-programming --language es --port 9222
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, json
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.scrape_workana_projects import scrape_workana_projects
|
||||||
|
|
||||||
|
# Detecta proyectos nuevos en it-programming (es), 1 pagina, via Chrome diario.
|
||||||
|
res = scrape_workana_projects(category="it-programming", language="es", port=9222)
|
||||||
|
if res["status"] == "ok":
|
||||||
|
print(f"{res['count']} proyectos")
|
||||||
|
for p in res["projects"][:3]:
|
||||||
|
print("-", p["title"], "|", p["budget"], "|", p["posted"])
|
||||||
|
else:
|
||||||
|
print("error:", res["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Monitor de captacion: detectar proyectos freelance nuevos en Workana sin abrir el
|
||||||
|
navegador a mano. Lanzala periodicamente (ej. desde el dag_engine) para vigilar una
|
||||||
|
categoria/idioma y alimentar el pipeline de market-intel. Usala cuando necesites el
|
||||||
|
listado renderizado de Workana de forma programatica — el GET HTTP puro NO sirve
|
||||||
|
porque la pagina es una SPA Vue que monta los cards en runtime.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal
|
||||||
|
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin
|
||||||
|
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
||||||
|
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
|
||||||
|
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
|
||||||
|
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
|
||||||
|
Si la conexion es lenta, sube `timeout_s`.
|
||||||
|
- **HTTP puro NO sirve**: un GET a la URL de listado trae 0 cards (HTML inicial vacio).
|
||||||
|
CDP es obligatorio para renderizar el JavaScript.
|
||||||
|
- **NUNCA inventa datos**: si no aparecen cards tras el timeout (chromium en port no
|
||||||
|
logueado, DOM cambiado), devuelve `status='error'` con `projects:[]`. No hay filas falsas.
|
||||||
|
- **Respeta el rate-limit de Workana**: no abuses (no la lances en bucle agresivo ni
|
||||||
|
con muchas paginas seguidas). Workana puede aplicar anti-bot si detecta scraping intensivo.
|
||||||
|
- **El selector del DOM (`div.project-item.js-project`) y el extractor JS dependen del
|
||||||
|
HTML actual de Workana.** Si Workana cambia su markup, el extractor deja de encontrar
|
||||||
|
cards y la funcion devuelve `status='error'` (no datos corruptos). En ese caso hay que
|
||||||
|
actualizar `_CARD_SELECTOR` y `_EXTRACTOR_JS`.
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""Scraper de proyectos freelance de Workana via Chrome DevTools Protocol (CDP).
|
||||||
|
|
||||||
|
Funcion IMPURA: Workana (https://www.workana.com/jobs) es una SPA Vue cuyo GET
|
||||||
|
HTTP NO trae los proyectos (el HTML inicial tiene 0 cards: el framework los monta
|
||||||
|
en runtime tras hidratacion). Por eso esta funcion renderiza la pagina con un
|
||||||
|
Chrome con remote debugging, espera a que los cards monten async, y extrae cada
|
||||||
|
proyecto con un evaluador JS validado contra la pagina real.
|
||||||
|
|
||||||
|
Es la pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance
|
||||||
|
nuevos sin abrir el navegador a mano. El shape de cada proyecto esta UNIFICADO con
|
||||||
|
el scraper hermano de Upwork para que ambos alimenten el mismo pipeline.
|
||||||
|
|
||||||
|
Compone DOS funciones del registry (no reescribe transporte CDP):
|
||||||
|
1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en el Chrome remoto,
|
||||||
|
navega a la URL de listado y espera `Page.loadEventFired`.
|
||||||
|
2. `cdp_eval` (browser) — evalua JS en la pestana cuyo URL contiene un substring.
|
||||||
|
|
||||||
|
Devuelve SIEMPRE un dict (estilo del grupo recon/market-intel): nunca lanza.
|
||||||
|
NUNCA inventa datos: si no hay cards tras el timeout, devuelve status="error" con
|
||||||
|
la lista de proyectos vacia.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||||
|
|
||||||
|
|
||||||
|
# Selector de los cards de proyecto en el DOM de Workana (SPA Vue).
|
||||||
|
_CARD_SELECTOR = "div.project-item.js-project"
|
||||||
|
|
||||||
|
# Extractor JS validado en vivo contra la pagina real (devolvio 9 proyectos
|
||||||
|
# correctos). Es una IIFE que devuelve un JSON string con el array de proyectos.
|
||||||
|
# El budget exige moneda (USD|EUR|R$) o "Menos de"/"Mas de" y excluye textos de
|
||||||
|
# fecha ("Publicado"/"Hace"/"Ayer") para no confundir presupuesto con fecha.
|
||||||
|
_EXTRACTOR_JS = r"""
|
||||||
|
(() => {
|
||||||
|
const cards = [...document.querySelectorAll('div.project-item.js-project')];
|
||||||
|
const ex = c => {
|
||||||
|
const a = c.querySelector('h2.project-title a[href^="/job/"]');
|
||||||
|
const titleSpan = c.querySelector('h2.project-title span[title]');
|
||||||
|
const dateEl = c.querySelector('.project-main-details .date') || c.querySelector('p.date strong');
|
||||||
|
const bidsEl = c.querySelector('.project-main-details .bids');
|
||||||
|
const descEl = c.querySelector('.html-desc .text-expander-content span');
|
||||||
|
const skills = [...c.querySelectorAll('.skills a.skill')].map(s => (s.textContent||'').trim()).filter(Boolean);
|
||||||
|
const cn = c.querySelector('.country-name, [class*="country"]');
|
||||||
|
let budget = null;
|
||||||
|
const cand = [...c.querySelectorAll('p, span, div')].find(e =>
|
||||||
|
e.childElementCount===0 &&
|
||||||
|
/(USD|EUR|R\$|Menos de|Más de)/.test((e.textContent||'')) &&
|
||||||
|
!/(Publicado|Hace|Ayer)/.test((e.textContent||'')) &&
|
||||||
|
(e.textContent||'').trim().length < 40);
|
||||||
|
if(cand) budget = cand.textContent.trim();
|
||||||
|
return {
|
||||||
|
job_id: a ? a.getAttribute('href').replace('/job/','') : null,
|
||||||
|
url: a ? 'https://www.workana.com'+a.getAttribute('href') : null,
|
||||||
|
title: titleSpan ? titleSpan.getAttribute('title') : (a? a.textContent.trim() : null),
|
||||||
|
budget,
|
||||||
|
posted: dateEl ? dateEl.textContent.replace('Publicado:','').trim() : null,
|
||||||
|
bids: bidsEl ? bidsEl.textContent.replace('Propuestas:','').trim() : null,
|
||||||
|
skills,
|
||||||
|
snippet: descEl ? descEl.textContent.trim().slice(0,300) : null,
|
||||||
|
country: cn ? cn.textContent.trim() : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return JSON.stringify(cards.map(ex));
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_url(category: str, language: str, extra_query: str, page: int) -> str:
|
||||||
|
"""Construye la URL de listado de Workana con sus query params.
|
||||||
|
|
||||||
|
Base: https://www.workana.com/jobs?category=...&language=...
|
||||||
|
Anade `&query=...` si extra_query no esta vacio, y `&page=N` si page > 1.
|
||||||
|
"""
|
||||||
|
params = [("category", category), ("language", language)]
|
||||||
|
if extra_query:
|
||||||
|
params.append(("query", extra_query))
|
||||||
|
if page > 1:
|
||||||
|
params.append(("page", str(page)))
|
||||||
|
qs = urllib.parse.urlencode(params)
|
||||||
|
return f"https://www.workana.com/jobs?{qs}"
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_cards(port: int, deadline: float) -> int:
|
||||||
|
"""Polling de `document.querySelectorAll(selector).length` hasta >0 o deadline.
|
||||||
|
|
||||||
|
Los cards de la SPA montan async tras la hidratacion, asi que el load event NO
|
||||||
|
garantiza que esten en el DOM. Devuelve el numero de cards encontrados (0 si se
|
||||||
|
agota el deadline sin que aparezcan).
|
||||||
|
"""
|
||||||
|
count_expr = (
|
||||||
|
f"document.querySelectorAll('{_CARD_SELECTOR}').length"
|
||||||
|
)
|
||||||
|
while time.time() < deadline:
|
||||||
|
r = cdp_eval(
|
||||||
|
count_expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr="workana.com",
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
if r.get("ok"):
|
||||||
|
try:
|
||||||
|
n = int(r.get("value") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 0
|
||||||
|
if n > 0:
|
||||||
|
return n
|
||||||
|
time.sleep(0.5)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _scrape_one_page(
|
||||||
|
category: str,
|
||||||
|
language: str,
|
||||||
|
extra_query: str,
|
||||||
|
page: int,
|
||||||
|
port: int,
|
||||||
|
timeout_s: float,
|
||||||
|
scraped_at: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Navega a una pagina de listado, espera los cards y extrae los proyectos.
|
||||||
|
|
||||||
|
Devuelve {"ok": bool, "projects": [...], "error": str}. Cada proyecto lleva ya
|
||||||
|
`source="workana"` y `scraped_at` anadidos. Filtra filas con job_id null.
|
||||||
|
"""
|
||||||
|
url = _build_url(category, language, extra_query, page)
|
||||||
|
|
||||||
|
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
|
||||||
|
try:
|
||||||
|
cdp_open_url_and_wait(port, url, int(timeout_s))
|
||||||
|
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
|
||||||
|
msg = str(e)
|
||||||
|
if (
|
||||||
|
"no se pudo crear tab" in msg
|
||||||
|
or "URLError" in msg
|
||||||
|
or "Connection refused" in msg
|
||||||
|
):
|
||||||
|
msg = f"no hay Chrome en el puerto {port} (¿remote debugging activo?): {e}"
|
||||||
|
return {"ok": False, "projects": [], "error": msg}
|
||||||
|
|
||||||
|
# 2. Polling hasta que los cards monten (SPA Vue: render async tras hidratacion).
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
n_cards = _wait_for_cards(port, deadline)
|
||||||
|
if n_cards == 0:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"projects": [],
|
||||||
|
"error": (
|
||||||
|
"no project cards (¿chromium en port no logueado / Workana cambió DOM?)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Ejecutar el extractor JS y parsear el JSON resultante.
|
||||||
|
r = cdp_eval(
|
||||||
|
_EXTRACTOR_JS,
|
||||||
|
port=port,
|
||||||
|
target_url_substr="workana.com",
|
||||||
|
timeout_s=max(10.0, timeout_s),
|
||||||
|
)
|
||||||
|
if not r.get("ok"):
|
||||||
|
err = r.get("error") or "eval CDP fallo sin mensaje"
|
||||||
|
return {"ok": False, "projects": [], "error": f"no se pudo evaluar el extractor JS ({err})"}
|
||||||
|
|
||||||
|
raw_value = r.get("value")
|
||||||
|
try:
|
||||||
|
rows = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or [])
|
||||||
|
except Exception: # noqa: BLE001 — JSON malformado del eval
|
||||||
|
return {"ok": False, "projects": [], "error": "el extractor JS no devolvio JSON valido"}
|
||||||
|
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return {"ok": False, "projects": [], "error": "el extractor JS no devolvio una lista"}
|
||||||
|
|
||||||
|
# 4. Enriquecer: source + scraped_at; filtrar filas sin job_id.
|
||||||
|
projects = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
if not row.get("job_id"):
|
||||||
|
continue
|
||||||
|
row["source"] = "workana"
|
||||||
|
row["scraped_at"] = scraped_at
|
||||||
|
projects.append(row)
|
||||||
|
|
||||||
|
return {"ok": True, "projects": projects, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_workana_projects(
|
||||||
|
category: str = "it-programming",
|
||||||
|
language: str = "es",
|
||||||
|
extra_query: str = "",
|
||||||
|
pages: int = 1,
|
||||||
|
port: int = 9222,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
|
||||||
|
|
||||||
|
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en `port`.
|
||||||
|
Por cada pagina navega a la URL de listado, espera a que los cards (SPA Vue)
|
||||||
|
monten async, y extrae cada proyecto con un evaluador JS validado. Nunca lanza:
|
||||||
|
cualquier fallo (Chrome muerto, DOM cambiado, eval con error) devuelve
|
||||||
|
``{"status": "error", ...}`` con la lista de proyectos vacia. NUNCA inventa datos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Categoria de Workana (segmento de la URL ?category=). Default
|
||||||
|
"it-programming". Otros ejemplos: "design-multimedia", "writing-translation".
|
||||||
|
language: Idioma de los proyectos (?language=). Default "es".
|
||||||
|
extra_query: Query libre opcional (?query=...). Si "", se omite. Util para
|
||||||
|
filtrar por palabra clave (ej. "python", "scraping").
|
||||||
|
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
|
||||||
|
adicional se navega con &page=N.
|
||||||
|
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
||||||
|
chromium-personal de produccion). Para un Chrome aislado (smoke / recon
|
||||||
|
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp).
|
||||||
|
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
|
||||||
|
el polling de aparicion de cards. Default 20.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"source": "workana",
|
||||||
|
"count": <N proyectos>,
|
||||||
|
"projects": [ {project_dict}, ... ],
|
||||||
|
}
|
||||||
|
|
||||||
|
donde cada project_dict tiene EXACTAMENTE las claves: source ("workana"),
|
||||||
|
job_id (slug), url (absoluta), title, budget (str|None), posted (str),
|
||||||
|
bids (str|None), skills (list[str]), snippet (str), country (str|None),
|
||||||
|
scraped_at (ISO8601 UTC).
|
||||||
|
|
||||||
|
En error::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": <mensaje claro>,
|
||||||
|
"source": "workana",
|
||||||
|
"projects": [],
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
scraped_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
all_projects: list[dict] = []
|
||||||
|
last_error = ""
|
||||||
|
|
||||||
|
n_pages = max(1, int(pages))
|
||||||
|
for page in range(1, n_pages + 1):
|
||||||
|
res = _scrape_one_page(
|
||||||
|
category=category,
|
||||||
|
language=language,
|
||||||
|
extra_query=extra_query,
|
||||||
|
page=page,
|
||||||
|
port=port,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
scraped_at=scraped_at,
|
||||||
|
)
|
||||||
|
if res["ok"]:
|
||||||
|
all_projects.extend(res["projects"])
|
||||||
|
else:
|
||||||
|
last_error = res["error"]
|
||||||
|
# Si la PRIMERA pagina ya falla, no hay nada que devolver: error duro.
|
||||||
|
if page == 1:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": last_error,
|
||||||
|
"source": "workana",
|
||||||
|
"projects": [],
|
||||||
|
}
|
||||||
|
# Paginas posteriores: cortamos el recorrido pero conservamos lo extraido.
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"source": "workana",
|
||||||
|
"count": len(all_projects),
|
||||||
|
"projects": all_projects,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Scraper de proyectos Workana via CDP.")
|
||||||
|
parser.add_argument("--category", default="it-programming")
|
||||||
|
parser.add_argument("--language", default="es")
|
||||||
|
parser.add_argument("--extra-query", default="")
|
||||||
|
parser.add_argument("--pages", type=int, default=1)
|
||||||
|
parser.add_argument("--port", type=int, default=9222)
|
||||||
|
parser.add_argument("--timeout-s", type=float, default=20.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out = scrape_workana_projects(
|
||||||
|
category=args.category,
|
||||||
|
language=args.language,
|
||||||
|
extra_query=args.extra_query,
|
||||||
|
pages=args.pages,
|
||||||
|
port=args.port,
|
||||||
|
timeout_s=args.timeout_s,
|
||||||
|
)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: build_vevent
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def build_vevent(event: dict) -> str"
|
||||||
|
description: "Serializa un evento (dict) a un texto VCALENDAR (RFC 5545) con un VEVENT dentro. Analoga de build_vcard pero para calendarios. Pura, solo compone texto (sin red, sin disco, sin reloj: nunca usa datetime.now). Acepta claves en espanol e ingles, normaliza fechas de varios formatos humanos a iCal compacto, sintetiza UID determinista si falta, soporta all_day, RRULE y VALARM. Salida CRLF terminando en END:VCALENDAR."
|
||||||
|
tags: [dav, caldav, ical, vevent, calendar, serialize]
|
||||||
|
params:
|
||||||
|
- name: event
|
||||||
|
desc: "dict del evento. Claves opcionales salvo lo indicado (acepta nombre ES o EN): uid (identificador; si falta se sintetiza determinista 'evt-'+md5(summary+start)[:16]), summary/titulo/resumen (-> SUMMARY, OBLIGATORIO), start/inicio (fecha/hora inicio -> DTSTART, OBLIGATORIO), end/fin (-> DTEND; si falta deriva +1h o dia siguiente si all_day), all_day/todo_el_dia (bool -> DTSTART;VALUE=DATE), location/ubicacion/lugar (-> LOCATION), description/descripcion/notas (-> DESCRIPTION), rrule/recurrencia (string RRULE -> linea RRULE), dtstamp (iCal opcional; fallback determinista a DTSTART), alarm_minutes/recordatorio_min (int -> bloque VALARM con TRIGGER:-PTnM). Fechas aceptadas: 'YYYY-MM-DDTHH:MM[:SS]', con sufijo 'Z' para UTC, 'YYYY-MM-DD', o iCal compacto ya formado."
|
||||||
|
output: "Texto VCALENDAR (RFC 5545) con lineas separadas por CRLF: BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN, un VEVENT con UID, DTSTAMP, DTSTART, DTEND, SUMMARY y campos opcionales, terminando en END:VCALENDAR\\r\\n. Valores de texto escapados segun RFC 5545; RRULE no se escapa (sus ';'/',' son separadores propios)."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [hashlib, datetime]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_evento_con_hora", "test_all_day", "test_rrule", "test_uid_sintetico_determinista", "test_end_derivado_mas_una_hora", "test_utc_con_z", "test_escape_caracteres_especiales", "test_alarm", "test_claves_espanol_equivalentes", "test_falta_summary_lanza_valueerror", "test_falta_start_lanza_valueerror"]
|
||||||
|
test_file_path: "python/functions/core/build_vevent_test.py"
|
||||||
|
file_path: "python/functions/core/build_vevent.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.build_vevent import build_vevent
|
||||||
|
|
||||||
|
vcal = build_vevent({
|
||||||
|
"summary": "Cita dentista",
|
||||||
|
"start": "2026-06-20T17:00",
|
||||||
|
"end": "2026-06-20T18:00",
|
||||||
|
"location": "Clinica",
|
||||||
|
"description": "Revision anual",
|
||||||
|
"alarm_minutes": 30,
|
||||||
|
})
|
||||||
|
print(vcal)
|
||||||
|
# BEGIN:VCALENDAR
|
||||||
|
# VERSION:2.0
|
||||||
|
# PRODID:-//fn_registry//build_vevent//ES
|
||||||
|
# CALSCALE:GREGORIAN
|
||||||
|
# BEGIN:VEVENT
|
||||||
|
# UID:evt-<md5(summary+start)>
|
||||||
|
# DTSTAMP:20260620T170000
|
||||||
|
# DTSTART:20260620T170000
|
||||||
|
# DTEND:20260620T180000
|
||||||
|
# SUMMARY:Cita dentista
|
||||||
|
# LOCATION:Clinica
|
||||||
|
# DESCRIPTION:Revision anual
|
||||||
|
# BEGIN:VALARM
|
||||||
|
# ACTION:DISPLAY
|
||||||
|
# DESCRIPTION:Cita dentista
|
||||||
|
# TRIGGER:-PT30M
|
||||||
|
# END:VALARM
|
||||||
|
# END:VEVENT
|
||||||
|
# END:VCALENDAR
|
||||||
|
|
||||||
|
# Evento de todo el dia recurrente:
|
||||||
|
build_vevent({"titulo": "Cumpleanos", "inicio": "2026-06-20", "all_day": True,
|
||||||
|
"recurrencia": "FREQ=YEARLY"})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando hay que materializar un evento a texto iCalendar para subirlo a CalDAV.
|
||||||
|
Es el paso "componer el VCALENDAR" previo a `caldav_put_event_py_infra`: le pasas
|
||||||
|
el texto que devuelve y el UID. La usa el pipeline `add_event_dav_py_pipelines`
|
||||||
|
para anadir un evento de un tiro. Si no das `uid`, el UID sintetico determinista
|
||||||
|
hace que re-construir el mismo evento produzca el mismo recurso `<uid>.ics`
|
||||||
|
(idempotente al subir). Reserva `build_vcard_py_core` para contactos (vCard) y
|
||||||
|
esta para eventos (VEVENT).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura salvo `ValueError`**: determinista, sin efectos (no red, no disco, no
|
||||||
|
reloj). NUNCA llama `datetime.now()` — `datetime.strptime`/`timedelta` solo se
|
||||||
|
usan para parsear y derivar fechas a partir de los inputs. La unica excepcion
|
||||||
|
posible es `ValueError` cuando falta `summary` o `start` (sin ellos no hay
|
||||||
|
evento) — validacion de entrada aceptable en una pura, en paridad con
|
||||||
|
`build_vcard`.
|
||||||
|
- **DTSTAMP siempre presente**: RFC 5545 lo exige. Si no se pasa `dtstamp`, se
|
||||||
|
usa el valor de `DTSTART` como fallback determinista (no la hora actual), para
|
||||||
|
que la salida sea reproducible y la funcion siga siendo pura.
|
||||||
|
- **RRULE no se escapa**: el valor de `rrule` es un recurrence rule estructurado
|
||||||
|
(`FREQ=...;BYDAY=...`) cuyos `;` y `,` son separadores propios. Se emite tal
|
||||||
|
cual (stripeado). El resto de campos de texto (SUMMARY/LOCATION/DESCRIPTION) si
|
||||||
|
se escapan (RFC 5545: `\`, `\n`, `,`, `;`; el `\r` crudo se elimina).
|
||||||
|
- **all_day usa VALUE=DATE**: con `all_day=True` el DTSTART/DTEND salen como
|
||||||
|
`;VALUE=DATE:YYYYMMDD` y el DTEND por defecto es el dia siguiente (convencion
|
||||||
|
iCal: fin exclusivo). Sin `all_day`, son datetime y el DTEND por defecto es
|
||||||
|
start+1h.
|
||||||
|
- **Formatos de fecha**: acepta varios formatos humanos y los normaliza, pero un
|
||||||
|
string mal formado (que `strptime` no entienda) lanza `ValueError` del propio
|
||||||
|
`strptime` — valida tus inputs si vienen de fuera.
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Serializa un evento (dict) a un VCALENDAR completo con un VEVENT dentro.
|
||||||
|
|
||||||
|
Analoga de ``build_vcard`` pero para calendarios: compone un texto iCalendar
|
||||||
|
(RFC 5545) con un envoltorio VCALENDAR y un unico VEVENT. Es una funcion pura —
|
||||||
|
solo compone texto, sin red, sin disco y sin reloj (nunca usa ``datetime.now``).
|
||||||
|
La unica excepcion posible es ``ValueError`` por validacion de entrada (falta de
|
||||||
|
``summary`` o ``start``), lo cual es aceptable para una funcion pura, en paridad
|
||||||
|
con ``build_vcard``.
|
||||||
|
|
||||||
|
Acepta claves en espanol e ingles. Las fechas se aceptan en varios formatos
|
||||||
|
humanos y se normalizan al formato iCal compacto. Si falta ``uid``, se sintetiza
|
||||||
|
un UID determinista (md5 de summary+start) para que el mismo evento produzca
|
||||||
|
siempre el mismo recurso (idempotencia al subir a CalDAV).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
_PRODID = "-//fn_registry//build_vevent//ES"
|
||||||
|
|
||||||
|
|
||||||
|
def _ical_escape(value: str) -> str:
|
||||||
|
"""Escapa un valor de texto para una linea iCal (RFC 5545).
|
||||||
|
|
||||||
|
Reglas: ``\\`` -> ``\\\\``, salto de linea -> ``\\n``, ``,`` -> ``\\,``,
|
||||||
|
``;`` -> ``\\;``. El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa),
|
||||||
|
mismo criterio que el escape vCard de ``build_vcard``: un ``\\r`` solo sin
|
||||||
|
``\\n`` que lo siga sobreviviria al escape de ``\\n`` y quedaria como caracter
|
||||||
|
de control capaz de inyectar propiedades nuevas. Eliminarlo cierra ese vector.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
value.replace("\\", "\\\\")
|
||||||
|
.replace("\r", "")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace(",", "\\,")
|
||||||
|
.replace(";", "\\;")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(event: dict, *keys):
|
||||||
|
"""Devuelve el primer valor no vacio entre ``keys`` (acepta ES/EN)."""
|
||||||
|
for key in keys:
|
||||||
|
val = event.get(key)
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_compact_datetime(s: str) -> bool:
|
||||||
|
"""True si ``s`` ya viene en formato iCal compacto (YYYYMMDDTHHMMSS[Z])."""
|
||||||
|
body = s[:-1] if s.endswith("Z") else s
|
||||||
|
if "T" not in body:
|
||||||
|
# Posible fecha compacta YYYYMMDD.
|
||||||
|
return len(body) == 8 and body.isdigit()
|
||||||
|
date_part, _, time_part = body.partition("T")
|
||||||
|
return (
|
||||||
|
len(date_part) == 8
|
||||||
|
and date_part.isdigit()
|
||||||
|
and len(time_part) == 6
|
||||||
|
and time_part.isdigit()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_compact_date(s: str) -> bool:
|
||||||
|
"""True si ``s`` es una fecha iCal compacta YYYYMMDD (sin hora)."""
|
||||||
|
return len(s) == 8 and s.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> str:
|
||||||
|
"""Normaliza una fecha (sin hora) a iCal compacto YYYYMMDD.
|
||||||
|
|
||||||
|
Acepta 'YYYY-MM-DD', 'YYYYMMDD', o un datetime humano del que toma la fecha.
|
||||||
|
"""
|
||||||
|
s = str(value).strip()
|
||||||
|
if _is_compact_date(s):
|
||||||
|
return s
|
||||||
|
if _is_compact_datetime(s):
|
||||||
|
# Tiene hora pero se pidio all_day: quedarse con la parte de fecha.
|
||||||
|
return s[:8]
|
||||||
|
# Formato con guiones, posiblemente con hora.
|
||||||
|
if "T" in s:
|
||||||
|
s = s.split("T", 1)[0]
|
||||||
|
dt = datetime.strptime(s, "%Y-%m-%d")
|
||||||
|
return dt.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value: str) -> str:
|
||||||
|
"""Normaliza una fecha/hora a iCal compacto YYYYMMDDTHHMMSS[Z].
|
||||||
|
|
||||||
|
Acepta:
|
||||||
|
- '2026-06-20T17:00' o '2026-06-20T17:00:00' (naive local)
|
||||||
|
- '2026-06-20T17:00:00Z' o '2026-06-20T17:00Z' (UTC, sufijo Z)
|
||||||
|
- '2026-06-20' (solo fecha -> medianoche local)
|
||||||
|
- '20260620T170000' / '20260620T170000Z' (ya compacto, se respeta)
|
||||||
|
Nunca usa el reloj del sistema: la conversion es determinista.
|
||||||
|
"""
|
||||||
|
s = str(value).strip()
|
||||||
|
if _is_compact_datetime(s):
|
||||||
|
return s
|
||||||
|
if _is_compact_date(s):
|
||||||
|
return s + "T000000"
|
||||||
|
|
||||||
|
utc = s.endswith("Z")
|
||||||
|
if utc:
|
||||||
|
s = s[:-1]
|
||||||
|
|
||||||
|
if "T" in s:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M")
|
||||||
|
else:
|
||||||
|
# Solo fecha -> medianoche.
|
||||||
|
dt = datetime.strptime(s, "%Y-%m-%d")
|
||||||
|
|
||||||
|
compact = dt.strftime("%Y%m%dT%H%M%S")
|
||||||
|
return compact + "Z" if utc else compact
|
||||||
|
|
||||||
|
|
||||||
|
def _next_day_compact(date_compact: str) -> str:
|
||||||
|
"""Dada una fecha iCal compacta YYYYMMDD devuelve la del dia siguiente."""
|
||||||
|
dt = datetime.strptime(date_compact, "%Y%m%d") + timedelta(days=1)
|
||||||
|
return dt.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _plus_one_hour(dt_compact: str) -> str:
|
||||||
|
"""Dada una datetime iCal compacta devuelve la misma +1h preservando Z."""
|
||||||
|
utc = dt_compact.endswith("Z")
|
||||||
|
body = dt_compact[:-1] if utc else dt_compact
|
||||||
|
dt = datetime.strptime(body, "%Y%m%dT%H%M%S") + timedelta(hours=1)
|
||||||
|
out = dt.strftime("%Y%m%dT%H%M%S")
|
||||||
|
return out + "Z" if utc else out
|
||||||
|
|
||||||
|
|
||||||
|
def build_vevent(event: dict) -> str:
|
||||||
|
"""Serializa un evento (dict) a un VCALENDAR completo con un VEVENT.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: dict del evento. Claves opcionales salvo lo indicado (acepta
|
||||||
|
nombre ES o EN):
|
||||||
|
- ``uid``: identificador del evento. Si falta, se sintetiza
|
||||||
|
determinista a partir de summary+start: '<evt->md5(...)[:16]>'.
|
||||||
|
- ``summary`` / ``titulo`` / ``resumen``: -> SUMMARY (OBLIGATORIO).
|
||||||
|
- ``start`` / ``inicio``: fecha/hora de inicio -> DTSTART (OBLIGATORIO).
|
||||||
|
- ``end`` / ``fin``: fecha/hora de fin -> DTEND. Si falta y no es
|
||||||
|
all_day, se deriva +1h del start; si es all_day, el dia siguiente.
|
||||||
|
- ``all_day`` / ``todo_el_dia`` (bool): si True emite
|
||||||
|
DTSTART;VALUE=DATE:YYYYMMDD (y DTEND como fecha siguiente).
|
||||||
|
- ``location`` / ``ubicacion`` / ``lugar``: -> LOCATION.
|
||||||
|
- ``description`` / ``descripcion`` / ``notas``: -> DESCRIPTION.
|
||||||
|
- ``rrule`` / ``recurrencia``: string RRULE -> linea RRULE:...
|
||||||
|
- ``dtstamp``: timestamp iCal opcional. Si falta, se usa el valor de
|
||||||
|
DTSTART como fallback DETERMINISTA (nunca datetime.now). DTSTAMP es
|
||||||
|
obligatorio en RFC 5545, por eso siempre se emite.
|
||||||
|
- ``alarm_minutes`` / ``recordatorio_min`` (int): si presente, anade
|
||||||
|
un bloque VALARM (display) con TRIGGER:-PT<N>M (N minutos antes).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Texto VCALENDAR (RFC 5545) con lineas separadas por CRLF, empezando en
|
||||||
|
BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN, conteniendo
|
||||||
|
un VEVENT, y terminando en ``END:VCALENDAR\\r\\n``. Valores de texto
|
||||||
|
escapados segun RFC 5545.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si falta ``summary`` o ``start`` (sin estos no hay evento).
|
||||||
|
"""
|
||||||
|
summary = _pick(event, "summary", "titulo", "resumen")
|
||||||
|
if not summary:
|
||||||
|
raise ValueError("build_vevent: falta summary (titulo/resumen)")
|
||||||
|
summary = str(summary).strip()
|
||||||
|
|
||||||
|
start_raw = _pick(event, "start", "inicio")
|
||||||
|
if not start_raw:
|
||||||
|
raise ValueError("build_vevent: falta start (inicio)")
|
||||||
|
|
||||||
|
all_day = bool(event.get("all_day") or event.get("todo_el_dia"))
|
||||||
|
|
||||||
|
if all_day:
|
||||||
|
dtstart = _parse_date(start_raw)
|
||||||
|
end_raw = _pick(event, "end", "fin")
|
||||||
|
dtend = _parse_date(end_raw) if end_raw else _next_day_compact(dtstart)
|
||||||
|
else:
|
||||||
|
dtstart = _parse_datetime(start_raw)
|
||||||
|
end_raw = _pick(event, "end", "fin")
|
||||||
|
dtend = _parse_datetime(end_raw) if end_raw else _plus_one_hour(dtstart)
|
||||||
|
|
||||||
|
# UID: explicito o sintetico determinista (md5 de summary+start crudo).
|
||||||
|
uid = event.get("uid")
|
||||||
|
if uid:
|
||||||
|
uid = str(uid).strip()
|
||||||
|
else:
|
||||||
|
digest = hashlib.md5(
|
||||||
|
("%s%s" % (summary, dtstart)).encode("utf-8")
|
||||||
|
).hexdigest()[:16]
|
||||||
|
uid = "evt-%s" % digest
|
||||||
|
|
||||||
|
# DTSTAMP: explicito o fallback determinista al DTSTART.
|
||||||
|
dtstamp = event.get("dtstamp")
|
||||||
|
dtstamp = str(dtstamp).strip() if dtstamp else dtstart
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:%s" % _PRODID,
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:%s" % _ical_escape(uid),
|
||||||
|
"DTSTAMP:%s" % dtstamp,
|
||||||
|
]
|
||||||
|
|
||||||
|
if all_day:
|
||||||
|
lines.append("DTSTART;VALUE=DATE:%s" % dtstart)
|
||||||
|
lines.append("DTEND;VALUE=DATE:%s" % dtend)
|
||||||
|
else:
|
||||||
|
lines.append("DTSTART:%s" % dtstart)
|
||||||
|
lines.append("DTEND:%s" % dtend)
|
||||||
|
|
||||||
|
lines.append("SUMMARY:%s" % _ical_escape(summary))
|
||||||
|
|
||||||
|
location = _pick(event, "location", "ubicacion", "lugar")
|
||||||
|
if location:
|
||||||
|
lines.append("LOCATION:%s" % _ical_escape(str(location)))
|
||||||
|
|
||||||
|
description = _pick(event, "description", "descripcion", "notas")
|
||||||
|
if description:
|
||||||
|
lines.append("DESCRIPTION:%s" % _ical_escape(str(description)))
|
||||||
|
|
||||||
|
rrule = _pick(event, "rrule", "recurrencia")
|
||||||
|
if rrule:
|
||||||
|
# RRULE es un valor estructurado (FREQ=...;BYDAY=...): NO se escapa el
|
||||||
|
# contenido, sus ';' y ',' son separadores propios del recurrence rule.
|
||||||
|
lines.append("RRULE:%s" % str(rrule).strip())
|
||||||
|
|
||||||
|
alarm_minutes = _pick(event, "alarm_minutes", "recordatorio_min")
|
||||||
|
if alarm_minutes:
|
||||||
|
minutes = int(alarm_minutes)
|
||||||
|
lines.append("BEGIN:VALARM")
|
||||||
|
lines.append("ACTION:DISPLAY")
|
||||||
|
lines.append("DESCRIPTION:%s" % _ical_escape(summary))
|
||||||
|
lines.append("TRIGGER:-PT%dM" % minutes)
|
||||||
|
lines.append("END:VALARM")
|
||||||
|
|
||||||
|
lines.append("END:VEVENT")
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Tests para build_vevent."""
|
||||||
|
|
||||||
|
from core.build_vevent import build_vevent
|
||||||
|
|
||||||
|
|
||||||
|
def _lines(text: str) -> list:
|
||||||
|
"""Parte la salida CRLF en lineas para asserts puntuales."""
|
||||||
|
return text.split("\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_evento_con_hora():
|
||||||
|
out = build_vevent({
|
||||||
|
"uid": "evt-demo",
|
||||||
|
"summary": "Cita dentista",
|
||||||
|
"start": "2026-06-20T17:00",
|
||||||
|
"end": "2026-06-20T18:00",
|
||||||
|
"location": "Clinica",
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert lines[0] == "BEGIN:VCALENDAR"
|
||||||
|
assert "VERSION:2.0" in lines
|
||||||
|
assert "CALSCALE:GREGORIAN" in lines
|
||||||
|
assert "BEGIN:VEVENT" in lines
|
||||||
|
assert "UID:evt-demo" in lines
|
||||||
|
assert "DTSTART:20260620T170000" in lines
|
||||||
|
assert "DTEND:20260620T180000" in lines
|
||||||
|
assert "SUMMARY:Cita dentista" in lines
|
||||||
|
assert "LOCATION:Clinica" in lines
|
||||||
|
assert "DTSTAMP:20260620T170000" in lines # fallback determinista a DTSTART
|
||||||
|
assert out.endswith("END:VCALENDAR\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_day():
|
||||||
|
out = build_vevent({
|
||||||
|
"summary": "Cumpleanos",
|
||||||
|
"start": "2026-06-20",
|
||||||
|
"all_day": True,
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert "DTSTART;VALUE=DATE:20260620" in lines
|
||||||
|
assert "DTEND;VALUE=DATE:20260621" in lines # dia siguiente derivado
|
||||||
|
assert "DTSTAMP:20260620" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_rrule():
|
||||||
|
out = build_vevent({
|
||||||
|
"summary": "Standup",
|
||||||
|
"start": "2026-06-22T09:00",
|
||||||
|
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
# El contenido del RRULE NO se escapa (sus ';' son separadores propios).
|
||||||
|
assert "RRULE:FREQ=WEEKLY;BYDAY=MO" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_sintetico_determinista():
|
||||||
|
e = {"summary": "Reunion", "start": "2026-06-20T17:00"}
|
||||||
|
a = build_vevent(e)
|
||||||
|
b = build_vevent(e)
|
||||||
|
assert a == b # mismo input -> misma salida
|
||||||
|
uid_lines = [l for l in _lines(a) if l.startswith("UID:")]
|
||||||
|
assert len(uid_lines) == 1
|
||||||
|
assert uid_lines[0].startswith("UID:evt-")
|
||||||
|
# Cambiar el summary cambia el UID sintetico.
|
||||||
|
c = build_vevent({"summary": "Otra", "start": "2026-06-20T17:00"})
|
||||||
|
uid_c = [l for l in _lines(c) if l.startswith("UID:")][0]
|
||||||
|
assert uid_c != uid_lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_derivado_mas_una_hora():
|
||||||
|
out = build_vevent({"summary": "X", "start": "2026-06-20T23:30"})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert "DTSTART:20260620T233000" in lines
|
||||||
|
assert "DTEND:20260621T003000" in lines # cruza medianoche +1h
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_con_z():
|
||||||
|
out = build_vevent({
|
||||||
|
"summary": "Llamada",
|
||||||
|
"start": "2026-06-20T17:00:00Z",
|
||||||
|
"end": "2026-06-20T18:00:00Z",
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert "DTSTART:20260620T170000Z" in lines
|
||||||
|
assert "DTEND:20260620T180000Z" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_caracteres_especiales():
|
||||||
|
out = build_vevent({
|
||||||
|
"summary": "Reunion, urgente; con notas\nlinea2",
|
||||||
|
"start": "2026-06-20T10:00",
|
||||||
|
"location": "Sala A, planta 2",
|
||||||
|
"description": "punto 1; punto 2",
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert "SUMMARY:Reunion\\, urgente\\; con notas\\nlinea2" in lines
|
||||||
|
assert "LOCATION:Sala A\\, planta 2" in lines
|
||||||
|
assert "DESCRIPTION:punto 1\\; punto 2" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_alarm():
|
||||||
|
out = build_vevent({
|
||||||
|
"summary": "Cita",
|
||||||
|
"start": "2026-06-20T17:00",
|
||||||
|
"alarm_minutes": 30,
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert "BEGIN:VALARM" in lines
|
||||||
|
assert "ACTION:DISPLAY" in lines
|
||||||
|
assert "TRIGGER:-PT30M" in lines
|
||||||
|
assert "END:VALARM" in lines
|
||||||
|
# El VALARM va dentro del VEVENT (antes de END:VEVENT).
|
||||||
|
assert lines.index("END:VALARM") < lines.index("END:VEVENT")
|
||||||
|
|
||||||
|
|
||||||
|
def test_claves_espanol_equivalentes():
|
||||||
|
out = build_vevent({
|
||||||
|
"titulo": "Evento ES",
|
||||||
|
"inicio": "2026-06-20T12:00",
|
||||||
|
"fin": "2026-06-20T13:00",
|
||||||
|
"ubicacion": "Madrid",
|
||||||
|
"descripcion": "desc",
|
||||||
|
"recurrencia": "FREQ=DAILY",
|
||||||
|
"recordatorio_min": 15,
|
||||||
|
})
|
||||||
|
lines = _lines(out)
|
||||||
|
assert "SUMMARY:Evento ES" in lines
|
||||||
|
assert "DTSTART:20260620T120000" in lines
|
||||||
|
assert "DTEND:20260620T130000" in lines
|
||||||
|
assert "LOCATION:Madrid" in lines
|
||||||
|
assert "DESCRIPTION:desc" in lines
|
||||||
|
assert "RRULE:FREQ=DAILY" in lines
|
||||||
|
assert "TRIGGER:-PT15M" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_falta_summary_lanza_valueerror():
|
||||||
|
try:
|
||||||
|
build_vevent({"start": "2026-06-20T10:00"})
|
||||||
|
assert False, "deberia haber lanzado ValueError"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_falta_start_lanza_valueerror():
|
||||||
|
try:
|
||||||
|
build_vevent({"summary": "X"})
|
||||||
|
assert False, "deberia haber lanzado ValueError"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: fetch_iab_gvl
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def fetch_iab_gvl(out_path: str = \"\", url: str = \"\", lang: str = \"\") -> dict"
|
||||||
|
description: "Descarga y parsea la Global Vendor List (GVL) de IAB Europe TCF: el catalogo maestro de data brokers (vendors) con sus propositos de tratamiento, intereses legitimos, special purposes, features y categorias de datos. Recon de privacidad/tracking."
|
||||||
|
tags: [consent, tcf, gvl, iab, privacy, data-brokers, vendor-list, recon, cmp]
|
||||||
|
params:
|
||||||
|
- name: out_path
|
||||||
|
desc: "Ruta de archivo donde guardar el JSON crudo descargado. Si vacio no guarda nada. Crea los directorios padre si no existen."
|
||||||
|
- name: url
|
||||||
|
desc: "Endpoint de la GVL. Si vacio usa el endpoint TCF v3.2 por defecto (vendor-list.consensu.org/v3/vendor-list.json) y, si falla, hace fallback al v2."
|
||||||
|
- name: lang
|
||||||
|
desc: "Codigo de idioma ISO opcional (ej. es). NO cambia el endpoint principal: las traducciones de propositos viven en endpoints aparte (purposes-<lang>.json). Hoy solo se acepta el parametro; no se descargan traducciones."
|
||||||
|
output: "dict resumen de la GVL. En exito status=ok con versiones (gvlSpecificationVersion, vendorListVersion, tcfPolicyVersion), lastUpdated, contadores (n_vendors, n_purposes, n_specialPurposes, n_features, n_dataCategories) y los mapas vendors / purposes / dataCategories indexados por id (string). En fallo de red o parseo status=error con el mensaje; nunca lanza excepcion."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/cybersecurity/fetch_iab_gvl.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity.fetch_iab_gvl import fetch_iab_gvl
|
||||||
|
|
||||||
|
# Descarga real del endpoint v3 (fallback automatico a v2 si falla) y guarda
|
||||||
|
# el JSON crudo para inspeccion posterior.
|
||||||
|
gvl = fetch_iab_gvl(out_path="/tmp/gvl.json")
|
||||||
|
print(gvl["status"]) # ok
|
||||||
|
print(gvl["vendorListVersion"]) # ej. 163
|
||||||
|
print(gvl["n_vendors"]) # > 1000
|
||||||
|
# Mirar un vendor concreto (Google = id 755 en TCF v3)
|
||||||
|
print(gvl["vendors"].get("755", {}).get("name"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Lanzable directo desde la raiz del registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python/.venv/bin/python3 python/functions/cybersecurity/fetch_iab_gvl.py /tmp/gvl.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando hagas recon de privacidad/tracking de un sitio web y necesites
|
||||||
|
mapear los `vendorId` que aparecen en una cookie de consentimiento (TC String /
|
||||||
|
__tcfapi) a nombres reales de empresas, sus propositos de tratamiento y sus
|
||||||
|
politicas de privacidad. Es el primer paso para auditar quien recibe los datos
|
||||||
|
del usuario via un CMP que implementa el IAB Europe TCF. Tambien para construir
|
||||||
|
un dataset local de data brokers (los `vendors`) y sus declaraciones de datos.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura, hace HTTP.** Depende de que `vendor-list.consensu.org` este accesible.
|
||||||
|
En fallo de red o JSON corrupto devuelve `{"status": "error", "error": "..."}`
|
||||||
|
y NO lanza — el caller DEBE comprobar `status` antes de usar el resultado.
|
||||||
|
- **Fallback v3 -> v2.** Si no pasas `url`, intenta v3 y luego v2. Si pasas `url`
|
||||||
|
explicito, solo se intenta esa (sin fallback).
|
||||||
|
- **`policyUrl` derivado.** En GVL v3 los vendors NO tienen un campo `policyUrl`
|
||||||
|
directo; la URL de privacidad vive en `urls[].privacy` (lista por idioma).
|
||||||
|
La funcion la deriva tolerando ambos formatos (v2/v3) y devuelve `""` si no hay.
|
||||||
|
- **`dataCategories` puede faltar** en versiones antiguas (v2). Se tolera la
|
||||||
|
ausencia: `n_dataCategories` sera 0 y el mapa estara vacio.
|
||||||
|
- **`lang` no descarga traducciones.** El parametro existe para la firma futura,
|
||||||
|
pero hoy el resumen siempre viene del endpoint principal (textos en ingles).
|
||||||
|
Las traducciones de propositos estan en endpoints separados
|
||||||
|
(`.../purposes-es.json`) que esta funcion no consulta todavia.
|
||||||
|
- **Payload grande** (~varios MB, >1000 vendors). El dict resumido recorta cada
|
||||||
|
vendor a los campos utiles, pero sigue siendo grande: no lo imprimas entero.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Descarga y parsea la Global Vendor List (GVL) de IAB Europe TCF.
|
||||||
|
|
||||||
|
La GVL es el catalogo maestro de "data brokers" (vendors) del Transparency &
|
||||||
|
Consent Framework de IAB Europe, con sus propositos de tratamiento de datos,
|
||||||
|
intereses legitimos, special purposes, features y categorias de datos.
|
||||||
|
|
||||||
|
Sin credenciales. Usa solo stdlib (urllib.request) para no anadir dependencias.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DEFAULT_URL_V3 = "https://vendor-list.consensu.org/v3/vendor-list.json"
|
||||||
|
FALLBACK_URL_V2 = "https://vendor-list.consensu.org/v2/vendor-list.json"
|
||||||
|
|
||||||
|
_USER_AGENT = "fn_registry-fetch_iab_gvl/1.0 (+recon)"
|
||||||
|
_TIMEOUT_S = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _download_json(url: str) -> dict:
|
||||||
|
"""Descarga un JSON via HTTP GET y lo parsea. Lanza en fallo."""
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||||
|
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_policy_url(vendor: dict) -> str:
|
||||||
|
"""Deriva la URL de politica de privacidad de un vendor de forma tolerante.
|
||||||
|
|
||||||
|
En GVL v3 los vendors no exponen `policyUrl` directo: la privacy URL vive
|
||||||
|
en `urls[].privacy` (lista por idioma). En v2 algunos vendors si traen
|
||||||
|
`policyUrl`. Esta funcion cubre ambos casos.
|
||||||
|
"""
|
||||||
|
direct = vendor.get("policyUrl")
|
||||||
|
if isinstance(direct, str) and direct:
|
||||||
|
return direct
|
||||||
|
urls = vendor.get("urls") or []
|
||||||
|
if isinstance(urls, list):
|
||||||
|
# Preferir el bloque en ingles si existe; si no, el primero con privacy.
|
||||||
|
for entry in urls:
|
||||||
|
if isinstance(entry, dict) and entry.get("langId") == "en" and entry.get("privacy"):
|
||||||
|
return str(entry["privacy"])
|
||||||
|
for entry in urls:
|
||||||
|
if isinstance(entry, dict) and entry.get("privacy"):
|
||||||
|
return str(entry["privacy"])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_vendor(vendor: dict) -> dict:
|
||||||
|
"""Extrae los campos utiles de un vendor, tolerando claves ausentes."""
|
||||||
|
return {
|
||||||
|
"id": vendor.get("id", 0),
|
||||||
|
"name": vendor.get("name", ""),
|
||||||
|
"purposes": vendor.get("purposes", []) or [],
|
||||||
|
"legIntPurposes": vendor.get("legIntPurposes", []) or [],
|
||||||
|
"specialPurposes": vendor.get("specialPurposes", []) or [],
|
||||||
|
"features": vendor.get("features", []) or [],
|
||||||
|
"dataDeclaration": vendor.get("dataDeclaration", []) or [],
|
||||||
|
"policyUrl": _vendor_policy_url(vendor),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_definitions(defs: dict) -> dict:
|
||||||
|
"""Resume un diccionario de definiciones (purposes, dataCategories, ...)."""
|
||||||
|
out: dict = {}
|
||||||
|
for key, item in (defs or {}).items():
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
out[str(key)] = {
|
||||||
|
"id": item.get("id", 0),
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"description": item.get("description", ""),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_iab_gvl(out_path: str = "", url: str = "", lang: str = "") -> dict:
|
||||||
|
"""Descarga y parsea la Global Vendor List (GVL) de IAB Europe TCF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
out_path: si no esta vacio, guarda el JSON crudo descargado en esa ruta
|
||||||
|
(crea los directorios padre si hace falta).
|
||||||
|
url: endpoint de la GVL. Si esta vacio usa el endpoint TCF v3.2 por
|
||||||
|
defecto y, si falla, hace fallback al endpoint v2.
|
||||||
|
lang: codigo de idioma ISO opcional (ej. "es"). NO cambia el endpoint
|
||||||
|
principal: las traducciones de propositos viven en endpoints aparte
|
||||||
|
(purposes-<lang>.json). Hoy solo se documenta el parametro; el
|
||||||
|
resumen devuelto sigue siendo el del endpoint principal (ingles).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con el resumen de la GVL. En exito:
|
||||||
|
{"status": "ok", "gvlSpecificationVersion": ..., "vendorListVersion": ...,
|
||||||
|
"tcfPolicyVersion": ..., "lastUpdated": ..., "n_vendors": int,
|
||||||
|
"n_purposes": int, "n_specialPurposes": int, "n_features": int,
|
||||||
|
"n_dataCategories": int, "vendors": {...}, "purposes": {...},
|
||||||
|
"dataCategories": {...}}.
|
||||||
|
En fallo de red o parseo: {"status": "error", "error": "..."} (no lanza).
|
||||||
|
"""
|
||||||
|
candidates = [url] if url else [DEFAULT_URL_V3, FALLBACK_URL_V2]
|
||||||
|
|
||||||
|
data = None
|
||||||
|
last_error = ""
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
data = _download_json(candidate)
|
||||||
|
break
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, ValueError, OSError) as exc:
|
||||||
|
last_error = f"{candidate}: {exc}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return {"status": "error", "error": last_error or "no url candidates"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if out_path:
|
||||||
|
parent = os.path.dirname(out_path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
with open(out_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False)
|
||||||
|
|
||||||
|
vendors_raw = data.get("vendors", {}) or {}
|
||||||
|
purposes_raw = data.get("purposes", {}) or {}
|
||||||
|
special_purposes_raw = data.get("specialPurposes", {}) or {}
|
||||||
|
features_raw = data.get("features", {}) or {}
|
||||||
|
data_categories_raw = data.get("dataCategories", {}) or {}
|
||||||
|
|
||||||
|
vendors = {str(vid): _summarize_vendor(v) for vid, v in vendors_raw.items()}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"gvlSpecificationVersion": data.get("gvlSpecificationVersion"),
|
||||||
|
"vendorListVersion": data.get("vendorListVersion"),
|
||||||
|
"tcfPolicyVersion": data.get("tcfPolicyVersion"),
|
||||||
|
"lastUpdated": data.get("lastUpdated"),
|
||||||
|
"n_vendors": len(vendors_raw),
|
||||||
|
"n_purposes": len(purposes_raw),
|
||||||
|
"n_specialPurposes": len(special_purposes_raw),
|
||||||
|
"n_features": len(features_raw),
|
||||||
|
"n_dataCategories": len(data_categories_raw),
|
||||||
|
"vendors": vendors,
|
||||||
|
"purposes": _summarize_definitions(purposes_raw),
|
||||||
|
"dataCategories": _summarize_definitions(data_categories_raw),
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001 - contrato: nunca lanzar.
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
result = fetch_iab_gvl(out_path=sys.argv[1] if len(sys.argv) > 1 else "")
|
||||||
|
print(json.dumps(
|
||||||
|
{k: v for k, v in result.items() if k not in ("vendors", "purposes", "dataCategories")},
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
if result.get("status") == "ok":
|
||||||
|
print(f"sample vendors: {list(result['vendors'].items())[:1]}")
|
||||||
@@ -15,13 +15,69 @@ from .scrape_google_trends import scrape_google_trends
|
|||||||
from .scrape_competitor_prices import scrape_competitor_prices
|
from .scrape_competitor_prices import scrape_competitor_prices
|
||||||
from .scrape_tiktok_creative import scrape_tiktok_creative
|
from .scrape_tiktok_creative import scrape_tiktok_creative
|
||||||
from .scrape_aliexpress_trending import scrape_aliexpress_trending
|
from .scrape_aliexpress_trending import scrape_aliexpress_trending
|
||||||
|
from .fetch_reddit_search import fetch_reddit_search
|
||||||
|
from .fetch_hackernews_search import fetch_hackernews_search
|
||||||
|
from .score_demand_signal import score_demand_signal
|
||||||
|
from .pull_gsc_search_analytics import pull_gsc_search_analytics
|
||||||
|
from .summarize_table_duckdb import summarize_table_duckdb
|
||||||
|
from .describe_numeric import describe_numeric
|
||||||
|
from .summarize_categorical import summarize_categorical
|
||||||
|
from .infer_semantic_type import infer_semantic_type
|
||||||
|
from .column_quality_score import column_quality_score
|
||||||
|
from .render_eda_markdown import render_eda_markdown
|
||||||
|
from .detect_distribution_type import detect_distribution_type
|
||||||
|
from .spearman_corr import spearman_corr
|
||||||
|
from .cramers_v import cramers_v
|
||||||
|
from .theils_u import theils_u
|
||||||
|
from .correlation_ratio import correlation_ratio
|
||||||
|
from .mutual_info_columns import mutual_info_columns
|
||||||
|
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
|
||||||
|
from .build_join_graph import build_join_graph
|
||||||
|
from .association_matrix import association_matrix
|
||||||
|
from .correlation_matrix_duckdb import correlation_matrix_duckdb
|
||||||
|
from .pca_explained import pca_explained
|
||||||
|
from .kmeans_segments import kmeans_segments
|
||||||
|
from .isolation_forest_outliers import isolation_forest_outliers
|
||||||
|
from .normality_tests import normality_tests
|
||||||
|
from .trend_slope import trend_slope
|
||||||
|
from .run_eda_models import run_eda_models
|
||||||
|
from .eda_llm_insights import eda_llm_insights
|
||||||
|
from .build_eda_notebook import build_eda_notebook
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"summarize_table_duckdb",
|
||||||
|
"spearman_corr",
|
||||||
|
"cramers_v",
|
||||||
|
"theils_u",
|
||||||
|
"correlation_ratio",
|
||||||
|
"mutual_info_columns",
|
||||||
|
"infer_fk_containment_duckdb",
|
||||||
|
"build_join_graph",
|
||||||
|
"association_matrix",
|
||||||
|
"correlation_matrix_duckdb",
|
||||||
|
"pca_explained",
|
||||||
|
"kmeans_segments",
|
||||||
|
"isolation_forest_outliers",
|
||||||
|
"normality_tests",
|
||||||
|
"trend_slope",
|
||||||
|
"run_eda_models",
|
||||||
|
"eda_llm_insights",
|
||||||
|
"build_eda_notebook",
|
||||||
|
"describe_numeric",
|
||||||
|
"summarize_categorical",
|
||||||
|
"infer_semantic_type",
|
||||||
|
"column_quality_score",
|
||||||
|
"render_eda_markdown",
|
||||||
|
"detect_distribution_type",
|
||||||
|
"pull_gsc_search_analytics",
|
||||||
"scrape_amazon_bestsellers",
|
"scrape_amazon_bestsellers",
|
||||||
"scrape_google_trends",
|
"scrape_google_trends",
|
||||||
"scrape_competitor_prices",
|
"scrape_competitor_prices",
|
||||||
"scrape_tiktok_creative",
|
"scrape_tiktok_creative",
|
||||||
"scrape_aliexpress_trending",
|
"scrape_aliexpress_trending",
|
||||||
|
"fetch_reddit_search",
|
||||||
|
"fetch_hackernews_search",
|
||||||
|
"score_demand_signal",
|
||||||
"pearson",
|
"pearson",
|
||||||
"standardize",
|
"standardize",
|
||||||
"min_max_scale",
|
"min_max_scale",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: association_matrix
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
|
||||||
|
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
|
||||||
|
tags: [eda, correlation, association, statistics, mixed-types, mutual-information]
|
||||||
|
params:
|
||||||
|
- name: columns
|
||||||
|
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
|
||||||
|
- name: strong_threshold
|
||||||
|
desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
||||||
|
- name: top_n
|
||||||
|
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
||||||
|
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
||||||
|
uses_functions:
|
||||||
|
- pearson_py_datascience
|
||||||
|
- spearman_corr_py_datascience
|
||||||
|
- cramers_v_py_datascience
|
||||||
|
- theils_u_py_datascience
|
||||||
|
- correlation_ratio_py_datascience
|
||||||
|
- mutual_info_columns_py_datascience
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"]
|
||||||
|
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
||||||
|
file_path: "python/functions/datascience/association_matrix.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import association_matrix
|
||||||
|
|
||||||
|
columns = {
|
||||||
|
# Numerica correlada linealmente con "size" (y ~ 2x + ruido pequeno).
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1], "type": "numeric"},
|
||||||
|
# Categorica que explica la varianza de "score" (cada region -> nivel distinto).
|
||||||
|
"region": {"values": ["N", "N", "S", "S", "E", "E", "W", "W"], "type": "categorical"},
|
||||||
|
"score": {"values": [10.0, 11.0, 50.0, 49.0, 90.0, 91.0, 30.0, 31.0], "type": "numeric"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = association_matrix(columns, strong_threshold=0.5, top_n=10)
|
||||||
|
|
||||||
|
# Pares fuertes detectados (orden por relevancia):
|
||||||
|
for p in result["strong"]:
|
||||||
|
print(p["a"], p["b"], p["method"], round(p["value"], 2))
|
||||||
|
# size price pearson/spearman 1.0 -> num-num lineal casi perfecta
|
||||||
|
# region score correlation_ratio 0.99 -> la categoria explica la numerica
|
||||||
|
|
||||||
|
print(result["methods_legend"]["correlation_ratio"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites una **matriz de relaciones de una tabla entera mezclando tipos**
|
||||||
|
(numericas, categoricas, fechas, booleanos) en una sola pasada, sin tener que
|
||||||
|
elegir a mano que metrica aplicar a cada par. Ideal en la fase EDA para detectar
|
||||||
|
de un vistazo que columnas estan asociadas (y por que metodo), priorizando los
|
||||||
|
pares fuertes. Reusa las funciones atomicas del registry (`pearson`,
|
||||||
|
`spearman_corr`, `cramers_v`, `theils_u`, `correlation_ratio`,
|
||||||
|
`mutual_info_columns`) y anade informacion mutua normalizada como medida comun
|
||||||
|
no-lineal a todos los pares.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Pura: las atomicas que compone son puras y deterministas; no hace I/O.
|
||||||
|
- `pearson` no limpia None/NaN internamente, asi que los pares num-num se
|
||||||
|
limpian aqui antes de llamarla (se emparejan por indice y se descartan pares
|
||||||
|
con algun lado no numerico).
|
||||||
|
- En num-num el `value` principal es el de mayor valor absoluto entre Pearson y
|
||||||
|
Spearman; ambos quedan en `extra` (`pearson`, `spearman`).
|
||||||
|
- En cat-cat el `value` es Cramer's V (simetrico) y `extra` lleva Theil's U
|
||||||
|
direccional en ambos sentidos (`u_ab` = U(a|b), `u_ba` = U(b|a)).
|
||||||
|
- En num-cat el `value` es el correlation ratio (eta) llamando siempre con la
|
||||||
|
categorica como primer argumento y la numerica como segundo.
|
||||||
|
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
||||||
|
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Matriz de asociacion unificada para una tabla con columnas de tipos mezclados.
|
||||||
|
|
||||||
|
Funcion pura del grupo eda. Para cada par de columnas elige la metrica de
|
||||||
|
asociacion adecuada al par de tipos (Pearson/Spearman para num-num, Cramer's V
|
||||||
|
para cat-cat, correlation ratio para num-cat) y, ademas, calcula informacion
|
||||||
|
mutua normalizada como medida comun no-lineal para todos los pares. Devuelve la
|
||||||
|
lista de pares evaluados, el subconjunto de pares fuertes y una leyenda de los
|
||||||
|
metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from datascience import (
|
||||||
|
correlation_ratio,
|
||||||
|
cramers_v,
|
||||||
|
mutual_info_columns,
|
||||||
|
pearson,
|
||||||
|
spearman_corr,
|
||||||
|
theils_u,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
||||||
|
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_num(v) -> bool:
|
||||||
|
"""True si v es un numero real (int/float) que no es bool ni NaN."""
|
||||||
|
return (
|
||||||
|
isinstance(v, (int, float))
|
||||||
|
and not isinstance(v, bool)
|
||||||
|
and not (isinstance(v, float) and math.isnan(v))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_numeric_type(t: str) -> bool:
|
||||||
|
return t == "numeric"
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_count(values: list, numeric: bool) -> int:
|
||||||
|
"""Numero de valores validos: numericos finitos si numeric, no-None si cat."""
|
||||||
|
if numeric:
|
||||||
|
return sum(1 for v in values if _is_num(v))
|
||||||
|
return sum(1 for v in values if v is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _cardinality(values: list) -> int:
|
||||||
|
"""Numero de valores distintos no-None."""
|
||||||
|
return len({v for v in values if v is not None})
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
|
||||||
|
"""Empareja por indice y conserva solo pares con ambos lados numericos."""
|
||||||
|
cx: list[float] = []
|
||||||
|
cy: list[float] = []
|
||||||
|
for x, y in zip(xs, ys):
|
||||||
|
if _is_num(x) and _is_num(y):
|
||||||
|
cx.append(float(x))
|
||||||
|
cy.append(float(y))
|
||||||
|
return cx, cy
|
||||||
|
|
||||||
|
|
||||||
|
def association_matrix(
|
||||||
|
columns: dict,
|
||||||
|
strong_threshold: float = 0.5,
|
||||||
|
top_n: int = 20,
|
||||||
|
) -> dict:
|
||||||
|
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
||||||
|
|
||||||
|
Para cada par de columnas (i < j) selecciona la metrica adecuada al par de
|
||||||
|
tipos y calcula tambien informacion mutua normalizada como medida comun:
|
||||||
|
|
||||||
|
- num-num: `pearson` (lineal) y `spearman_corr` (monotonica). El `value`
|
||||||
|
principal es el de mayor valor absoluto; ambos se guardan en `extra`.
|
||||||
|
- cat-cat: `cramers_v` (simetrica) como `value`; `theils_u` en ambas
|
||||||
|
direcciones en `extra` (u_ab = U(a|b), u_ba = U(b|a)).
|
||||||
|
- num-cat: `correlation_ratio(categorias, valores)` como `value`.
|
||||||
|
- Todos los pares: `mutual_info_columns` normalizada en `extra["mi"]`.
|
||||||
|
|
||||||
|
Se saltan los pares donde alguna columna tenga menos de 3 valores validos o
|
||||||
|
sea de tipo `text` con cardinalidad cercana al numero de filas (ruido sin
|
||||||
|
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
||||||
|
columna (devuelve `pairs=[]`, `strong=[]`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
||||||
|
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
||||||
|
Los tipos datetime/boolean/text se tratan como categoricos.
|
||||||
|
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
|
||||||
|
abs(value) >= umbral o extra["mi"] >= umbral.
|
||||||
|
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
||||||
|
relevancia (max(abs(value), mi)) descendente.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con claves:
|
||||||
|
pairs: lista de todos los pares evaluados, cada uno
|
||||||
|
{a, b, a_type, b_type, method, value, extra}.
|
||||||
|
strong: subconjunto de pairs por encima del umbral, ordenado por
|
||||||
|
relevancia descendente y truncado a top_n.
|
||||||
|
methods_legend: dict {metodo: descripcion}.
|
||||||
|
"""
|
||||||
|
legend = {
|
||||||
|
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
||||||
|
"spearman": "num-num monotonica (Spearman rho), robusta a outliers, [-1, 1]",
|
||||||
|
"cramers_v": "cat-cat simetrica (Cramer's V, sesgo-corregido), [0, 1]",
|
||||||
|
"theils_u": "cat-cat direccional (Theil's U), incertidumbre explicada, [0, 1]",
|
||||||
|
"correlation_ratio": "num-cat (eta), varianza numerica explicada por la categoria, [0, 1]",
|
||||||
|
"mutual_info": "general no-lineal (NMI normalizada) para cualquier par de tipos, [0, 1]",
|
||||||
|
}
|
||||||
|
|
||||||
|
names = list(columns.keys())
|
||||||
|
if len(names) < 2:
|
||||||
|
return {"pairs": [], "strong": [], "methods_legend": legend}
|
||||||
|
|
||||||
|
n_rows = max(
|
||||||
|
(len(columns[name].get("values", [])) for name in names),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _skip(name: str) -> bool:
|
||||||
|
"""True si la columna no aporta asociacion util (pocos validos o text ruidoso)."""
|
||||||
|
col = columns[name]
|
||||||
|
vals = col.get("values", [])
|
||||||
|
ctype = col.get("type", "categorical")
|
||||||
|
numeric = _is_numeric_type(ctype)
|
||||||
|
if _valid_count(vals, numeric) < 3:
|
||||||
|
return True
|
||||||
|
# Texto de cardinalidad ~ n: identificadores/free-text, sin asociacion util.
|
||||||
|
if ctype == "text" and n_rows > 0 and _cardinality(vals) >= 0.9 * n_rows:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
pairs: list[dict] = []
|
||||||
|
|
||||||
|
for i in range(len(names)):
|
||||||
|
a_name = names[i]
|
||||||
|
if _skip(a_name):
|
||||||
|
continue
|
||||||
|
a_col = columns[a_name]
|
||||||
|
a_vals = a_col.get("values", [])
|
||||||
|
a_type = a_col.get("type", "categorical")
|
||||||
|
a_numeric = _is_numeric_type(a_type)
|
||||||
|
|
||||||
|
for j in range(i + 1, len(names)):
|
||||||
|
b_name = names[j]
|
||||||
|
if _skip(b_name):
|
||||||
|
continue
|
||||||
|
b_col = columns[b_name]
|
||||||
|
b_vals = b_col.get("values", [])
|
||||||
|
b_type = b_col.get("type", "categorical")
|
||||||
|
b_numeric = _is_numeric_type(b_type)
|
||||||
|
|
||||||
|
extra: dict = {}
|
||||||
|
|
||||||
|
# Medida comun no-lineal para todos los pares.
|
||||||
|
mi = mutual_info_columns(
|
||||||
|
a_vals,
|
||||||
|
b_vals,
|
||||||
|
a_numeric=a_numeric,
|
||||||
|
b_numeric=b_numeric,
|
||||||
|
normalized=True,
|
||||||
|
)
|
||||||
|
extra["mi"] = mi
|
||||||
|
|
||||||
|
if a_numeric and b_numeric:
|
||||||
|
method = "pearson/spearman"
|
||||||
|
cx, cy = _clean_numeric_pairs(a_vals, b_vals)
|
||||||
|
p = pearson(cx, cy)
|
||||||
|
s = spearman_corr(a_vals, b_vals)
|
||||||
|
extra["pearson"] = p
|
||||||
|
extra["spearman"] = s
|
||||||
|
value = p if abs(p) >= abs(s) else s
|
||||||
|
elif (not a_numeric) and (not b_numeric):
|
||||||
|
method = "cramers_v"
|
||||||
|
value = cramers_v(a_vals, b_vals)
|
||||||
|
extra["u_ab"] = theils_u(a_vals, b_vals)
|
||||||
|
extra["u_ba"] = theils_u(b_vals, a_vals)
|
||||||
|
else:
|
||||||
|
method = "correlation_ratio"
|
||||||
|
if a_numeric:
|
||||||
|
# a numerica, b categorica.
|
||||||
|
value = correlation_ratio(b_vals, a_vals)
|
||||||
|
else:
|
||||||
|
# a categorica, b numerica.
|
||||||
|
value = correlation_ratio(a_vals, b_vals)
|
||||||
|
|
||||||
|
pairs.append(
|
||||||
|
{
|
||||||
|
"a": a_name,
|
||||||
|
"b": b_name,
|
||||||
|
"a_type": a_type,
|
||||||
|
"b_type": b_type,
|
||||||
|
"method": method,
|
||||||
|
"value": value,
|
||||||
|
"extra": extra,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _relevance(pair: dict) -> float:
|
||||||
|
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
||||||
|
|
||||||
|
strong = [
|
||||||
|
pair
|
||||||
|
for pair in pairs
|
||||||
|
if abs(pair["value"]) >= strong_threshold
|
||||||
|
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
||||||
|
]
|
||||||
|
strong.sort(key=_relevance, reverse=True)
|
||||||
|
strong = strong[:top_n]
|
||||||
|
|
||||||
|
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests para association_matrix."""
|
||||||
|
|
||||||
|
from datascience import association_matrix
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pair(pairs, a, b):
|
||||||
|
"""Devuelve el par (a, b) sin importar el orden en que aparezca, o None."""
|
||||||
|
for p in pairs:
|
||||||
|
if {p["a"], p["b"]} == {a, b}:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_correlated_numerics_strong_pearson():
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, strong_threshold=0.5)
|
||||||
|
|
||||||
|
pair = _find_pair(result["pairs"], "size", "price")
|
||||||
|
assert pair is not None
|
||||||
|
assert pair["method"] == "pearson/spearman"
|
||||||
|
assert abs(pair["value"]) > 0.95
|
||||||
|
assert "pearson" in pair["extra"] and "spearman" in pair["extra"]
|
||||||
|
# El par fuertemente correlado aparece en strong.
|
||||||
|
assert _find_pair(result["strong"], "size", "price") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_explained_by_category_strong_correlation_ratio():
|
||||||
|
columns = {
|
||||||
|
"region": {
|
||||||
|
"values": ["N", "N", "S", "S", "E", "E", "W", "W"],
|
||||||
|
"type": "categorical",
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"values": [10.0, 11.0, 50.0, 49.0, 90.0, 91.0, 30.0, 31.0],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, strong_threshold=0.5)
|
||||||
|
|
||||||
|
pair = _find_pair(result["pairs"], "region", "score")
|
||||||
|
assert pair is not None
|
||||||
|
assert pair["method"] == "correlation_ratio"
|
||||||
|
# La categoria explica casi toda la varianza de la numerica.
|
||||||
|
assert pair["value"] > 0.9
|
||||||
|
assert _find_pair(result["strong"], "region", "score") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_pair_not_strong():
|
||||||
|
# x e y construidos para ser practicamente independientes (sin relacion).
|
||||||
|
columns = {
|
||||||
|
"x": {"values": [1, 2, 1, 2, 1, 2, 1, 2], "type": "numeric"},
|
||||||
|
"y": {"values": [5, 5, 5, 5, 5, 5, 5, 6], "type": "numeric"},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, strong_threshold=0.5)
|
||||||
|
|
||||||
|
pair = _find_pair(result["pairs"], "x", "y")
|
||||||
|
assert pair is not None
|
||||||
|
# Ni la metrica principal ni la MI superan el umbral fuerte.
|
||||||
|
assert abs(pair["value"]) < 0.5
|
||||||
|
assert pair["extra"]["mi"] < 0.5
|
||||||
|
assert _find_pair(result["strong"], "x", "y") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dict_does_not_crash():
|
||||||
|
result = association_matrix({})
|
||||||
|
assert result["pairs"] == []
|
||||||
|
assert result["strong"] == []
|
||||||
|
assert "methods_legend" in result
|
||||||
|
assert "pearson" in result["methods_legend"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_column_returns_empty():
|
||||||
|
columns = {"only": {"values": [1, 2, 3, 4], "type": "numeric"}}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
assert result["pairs"] == []
|
||||||
|
assert result["strong"] == []
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: build_eda_notebook
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def build_eda_notebook(db_path: str, table: str, notebook_path: str, run_models: bool = False, run_llm: bool = False) -> dict"
|
||||||
|
description: "Genera un notebook Jupyter de EDA (nbformat v4) para una tabla DuckDB usando el grupo eda. Escribe el .ipynb a disco listo para abrir/ejecutar; no ejecuta el notebook. dict-no-throw."
|
||||||
|
tags: [eda, notebook, jupyter, datascience, duckdb, profiling]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [json, os]
|
||||||
|
params:
|
||||||
|
- name: db_path
|
||||||
|
desc: "Ruta al archivo DuckDB que contiene la tabla a perfilar. Se referencia dentro del notebook, no se abre en esta funcion."
|
||||||
|
- name: table
|
||||||
|
desc: "Nombre de la tabla DuckDB a perfilar."
|
||||||
|
- name: notebook_path
|
||||||
|
desc: "Ruta de salida del .ipynb. El directorio padre se crea si no existe."
|
||||||
|
- name: run_models
|
||||||
|
desc: "Si True, añade celda con prof['models'] (PCA explained_variance_ratio, kmeans best_k, outliers n_outliers) y pasa run_models=True a profile_table dentro del notebook. Default False."
|
||||||
|
- name: run_llm
|
||||||
|
desc: "Si True, añade celda que llama eda_llm_insights(prof) para insights generados por LLM. Default False."
|
||||||
|
output: "dict. En exito {status:'ok', notebook_path:str, n_cells:int}. En error {status:'error', error:str}."
|
||||||
|
tested: true
|
||||||
|
tests: ["genera notebook ok", "notebook es json nbformat valido", "run_models añade celda de modelos", "run_llm añade celda de insights", "sin flags no añade celdas opcionales", "crea directorio padre"]
|
||||||
|
test_file_path: "python/functions/datascience/build_eda_notebook_test.py"
|
||||||
|
file_path: "python/functions/datascience/build_eda_notebook.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience.build_eda_notebook import build_eda_notebook
|
||||||
|
|
||||||
|
r = build_eda_notebook(
|
||||||
|
db_path="/home/enmanuel/data/ventas.duckdb",
|
||||||
|
table="cubo_ventas",
|
||||||
|
notebook_path="/tmp/eda_demo.ipynb",
|
||||||
|
run_models=True,
|
||||||
|
run_llm=False,
|
||||||
|
)
|
||||||
|
# {'status': 'ok', 'notebook_path': '/tmp/eda_demo.ipynb', 'n_cells': 10}
|
||||||
|
# Luego se abre/ejecuta en Jupyter; este paso solo escribe el .ipynb.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieras entregar un EDA como **notebook ejecutable** (no un report estatico):
|
||||||
|
perfilar una tabla DuckDB con el grupo `eda` y dejar un `.ipynb` listo. El notebook
|
||||||
|
se lanza despues en Jupyter colaborativo con las funciones del grupo `notebook`
|
||||||
|
(`jupyter_discover` / `jupyter_exec` / `jupyter_write`) y el usuario lo ve ejecutarse
|
||||||
|
en vivo. Es la base de la entrega "analysis EDA".
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: escribe un archivo `.ipynb` a `notebook_path` (crea el directorio padre).
|
||||||
|
- **NO ejecuta el notebook**: solo emite las celdas. La ejecucion la hace Jupyter despues.
|
||||||
|
- Las celdas asumen que `python/functions` del registry esta accesible desde el kernel:
|
||||||
|
el startup `00_fn_registry.py` del analysis lo expone, o como fallback la primera celda
|
||||||
|
inserta `~/fn_registry/python/functions` en `sys.path`. Si el repo no esta ahi y el
|
||||||
|
kernel no lo expone, las celdas de import fallaran al ejecutarse (no al generar).
|
||||||
|
- `profile_table` se invoca con `write_report=False` dentro del notebook: no toca disco
|
||||||
|
para reports, el perfil vive en la variable `prof`.
|
||||||
|
- `run_llm=True` emite una celda que llama `eda_llm_insights`, que requiere token OAuth
|
||||||
|
de Claude disponible para el kernel; sin el, esa celda fallara al ejecutarse.
|
||||||
|
- dict-no-throw: cualquier fallo de escritura se devuelve como `{status:'error', error}`,
|
||||||
|
no se propaga excepcion.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Genera un notebook Jupyter de EDA (nbformat v4) para una tabla DuckDB.
|
||||||
|
|
||||||
|
Construye un .ipynb listo para abrir/ejecutar que perfila una tabla con el
|
||||||
|
grupo `eda` del registry (profile_table + render_eda_markdown + run_eda_models +
|
||||||
|
eda_llm_insights). La funcion NO ejecuta el notebook: solo escribe el archivo
|
||||||
|
con las celdas. Es la base de la entrega "analysis EDA" que luego se lanza en el
|
||||||
|
navegador colaborativo con las funciones del grupo `notebook`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _code_cell(source: str) -> dict:
|
||||||
|
"""Construye una celda de codigo nbformat v4."""
|
||||||
|
return {
|
||||||
|
"cell_type": "code",
|
||||||
|
"source": source,
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_cell(source: str) -> dict:
|
||||||
|
"""Construye una celda markdown nbformat v4."""
|
||||||
|
return {"cell_type": "markdown", "source": source, "metadata": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def build_eda_notebook(
|
||||||
|
db_path: str,
|
||||||
|
table: str,
|
||||||
|
notebook_path: str,
|
||||||
|
run_models: bool = False,
|
||||||
|
run_llm: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Genera un notebook Jupyter de EDA para una tabla DuckDB.
|
||||||
|
|
||||||
|
Construye un dict nbformat v4 (a mano, sin depender de la libreria nbformat)
|
||||||
|
con celdas que perfilan la tabla usando el grupo `eda` del registry, lo
|
||||||
|
serializa como JSON a disco y devuelve un resumen. NO ejecuta el notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: ruta al archivo DuckDB que contiene la tabla a perfilar.
|
||||||
|
table: nombre de la tabla a perfilar dentro de la DuckDB.
|
||||||
|
notebook_path: ruta de salida del .ipynb. El directorio padre se crea
|
||||||
|
si no existe.
|
||||||
|
run_models: si True, añade una celda que muestra prof["models"]
|
||||||
|
(PCA explained_variance_ratio, kmeans best_k, outliers n_outliers).
|
||||||
|
Tambien pasa run_models=True a profile_table dentro del notebook.
|
||||||
|
run_llm: si True, añade una celda que llama eda_llm_insights(prof) para
|
||||||
|
obtener insights generados por LLM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', notebook_path: str, n_cells: int}.
|
||||||
|
En error (sin lanzar): {status:'error', error: str}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cells = []
|
||||||
|
|
||||||
|
# 1) Titulo.
|
||||||
|
cells.append(
|
||||||
|
_markdown_cell(
|
||||||
|
f"# EDA — {table}\nGenerado por el grupo `eda` del registry."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Setup: sys.path + import de profile_table.
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
"import sys, os\n"
|
||||||
|
"# El kernel startup del analysis (00_fn_registry.py) ya suele\n"
|
||||||
|
"# exponer python/functions en sys.path. Como fallback asumimos\n"
|
||||||
|
"# el repo en ~/fn_registry.\n"
|
||||||
|
'_fns = os.path.join(os.path.expanduser("~"), "fn_registry", "python", "functions")\n'
|
||||||
|
"if _fns not in sys.path:\n"
|
||||||
|
" sys.path.insert(0, _fns)\n"
|
||||||
|
"from pipelines.profile_table import profile_table"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) Perfilar la tabla.
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
f"r = profile_table({db_path!r}, {table!r}, run_models={run_models}, write_report=False)\n"
|
||||||
|
'prof = r["profile"]\n'
|
||||||
|
'prof["n_rows"], prof["n_cols"], prof["quality_score"]'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) Report markdown renderizado.
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
"from datascience import render_eda_markdown\n"
|
||||||
|
"from IPython.display import Markdown, display\n"
|
||||||
|
"display(Markdown(render_eda_markdown(prof)))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5) Tabla de columnas con pandas.
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
"import pandas as pd\n"
|
||||||
|
"pd.DataFrame([\n"
|
||||||
|
" {k: c.get(k) for k in (\n"
|
||||||
|
' "name", "inferred_type", "semantic_type", "null_pct",\n'
|
||||||
|
' "distinct_count", "unique_pct", "quality_score",\n'
|
||||||
|
" )}\n"
|
||||||
|
' for c in prof["columns"]\n'
|
||||||
|
"])"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6) Correlaciones fuertes.
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
'corr = prof.get("correlations")\n'
|
||||||
|
'pd.DataFrame(corr["strong"]) if corr and corr.get("strong") else "sin correlaciones fuertes"'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7) Modelos (solo si run_models).
|
||||||
|
if run_models:
|
||||||
|
cells.append(
|
||||||
|
_markdown_cell("## Modelos no supervisados")
|
||||||
|
)
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
'models = prof.get("models") or {}\n'
|
||||||
|
'pca = models.get("pca") or {}\n'
|
||||||
|
'kmeans = models.get("kmeans") or {}\n'
|
||||||
|
'outliers = models.get("outliers") or {}\n'
|
||||||
|
"{\n"
|
||||||
|
' "pca_explained_variance_ratio": pca.get("explained_variance_ratio"),\n'
|
||||||
|
' "kmeans_best_k": kmeans.get("best_k"),\n'
|
||||||
|
' "outliers_n_outliers": outliers.get("n_outliers"),\n'
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8) Insights LLM (solo si run_llm).
|
||||||
|
if run_llm:
|
||||||
|
cells.append(_markdown_cell("## Insights (LLM)"))
|
||||||
|
cells.append(
|
||||||
|
_code_cell(
|
||||||
|
"from datascience import eda_llm_insights\n"
|
||||||
|
"ins = eda_llm_insights(prof)\n"
|
||||||
|
"ins"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9) Notas finales.
|
||||||
|
cells.append(
|
||||||
|
_markdown_cell(
|
||||||
|
"## Notas\n\n"
|
||||||
|
"- Este notebook fue generado por `build_eda_notebook` del grupo `eda`.\n"
|
||||||
|
"- Ejecuta las celdas en orden. La primera celda de codigo asume que\n"
|
||||||
|
" python/functions del registry esta en `sys.path` (kernel startup\n"
|
||||||
|
" del analysis o `~/fn_registry`).\n"
|
||||||
|
"- `profile_table` se llama con `write_report=False`: no escribe reports\n"
|
||||||
|
" a disco, todo el perfil vive en la variable `prof`.\n"
|
||||||
|
"- Para regenerar con modelos o insights LLM, vuelve a llamar a\n"
|
||||||
|
" `build_eda_notebook(..., run_models=True, run_llm=True)`."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
notebook = {
|
||||||
|
"cells": cells,
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3",
|
||||||
|
},
|
||||||
|
"language_info": {"name": "python"},
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = os.path.dirname(os.path.abspath(notebook_path))
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
|
||||||
|
with open(notebook_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(notebook, f, indent=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"notebook_path": notebook_path,
|
||||||
|
"n_cells": len(cells),
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001 - dict-no-throw
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests para build_eda_notebook.
|
||||||
|
|
||||||
|
No ejecuta el notebook generado: solo valida que el .ipynb se escribe como JSON
|
||||||
|
nbformat v4 valido y que las celdas opcionales (modelos / LLM) aparecen segun
|
||||||
|
los flags. La validacion del contenido se hace sobre el dict deserializado.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from functions.datascience.build_eda_notebook import build_eda_notebook
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: str) -> dict:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def test_genera_notebook_ok(tmp_path):
|
||||||
|
out = str(tmp_path / "eda.ipynb")
|
||||||
|
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
|
||||||
|
assert r["status"] == "ok"
|
||||||
|
assert r["notebook_path"] == out
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert r["n_cells"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_notebook_es_json_nbformat_valido(tmp_path):
|
||||||
|
out = str(tmp_path / "eda.ipynb")
|
||||||
|
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
|
||||||
|
assert r["status"] == "ok"
|
||||||
|
nb = _load(out)
|
||||||
|
assert nb["nbformat"] == 4
|
||||||
|
assert isinstance(nb.get("cells"), list)
|
||||||
|
assert len(nb["cells"]) > 0
|
||||||
|
# Cada celda tiene cell_type valido.
|
||||||
|
for cell in nb["cells"]:
|
||||||
|
assert cell["cell_type"] in ("code", "markdown")
|
||||||
|
# n_cells coincide con las celdas del archivo.
|
||||||
|
assert r["n_cells"] == len(nb["cells"])
|
||||||
|
# El titulo referencia la tabla.
|
||||||
|
assert any(
|
||||||
|
c["cell_type"] == "markdown" and "ventas" in "".join(c["source"])
|
||||||
|
for c in nb["cells"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_models_anade_celda_de_modelos(tmp_path):
|
||||||
|
out = str(tmp_path / "eda.ipynb")
|
||||||
|
base = build_eda_notebook("/tmp/x.duckdb", "ventas", out, run_models=False)
|
||||||
|
|
||||||
|
out2 = str(tmp_path / "eda_models.ipynb")
|
||||||
|
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out2, run_models=True)
|
||||||
|
assert r["status"] == "ok"
|
||||||
|
nb = _load(out2)
|
||||||
|
sources = "".join("".join(c["source"]) for c in nb["cells"])
|
||||||
|
assert "models" in sources
|
||||||
|
assert "explained_variance_ratio" in sources
|
||||||
|
assert "best_k" in sources
|
||||||
|
assert "n_outliers" in sources
|
||||||
|
# run_models=True añade celdas respecto al base.
|
||||||
|
assert r["n_cells"] > base["n_cells"]
|
||||||
|
# profile_table dentro del notebook usa run_models=True.
|
||||||
|
assert "run_models=True" in sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_llm_anade_celda_de_insights(tmp_path):
|
||||||
|
out = str(tmp_path / "eda_llm.ipynb")
|
||||||
|
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out, run_llm=True)
|
||||||
|
assert r["status"] == "ok"
|
||||||
|
nb = _load(out)
|
||||||
|
sources = "".join("".join(c["source"]) for c in nb["cells"])
|
||||||
|
assert "eda_llm_insights" in sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_flags_no_anade_celdas_opcionales(tmp_path):
|
||||||
|
out = str(tmp_path / "eda_plain.ipynb")
|
||||||
|
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
|
||||||
|
assert r["status"] == "ok"
|
||||||
|
nb = _load(out)
|
||||||
|
sources = "".join("".join(c["source"]) for c in nb["cells"])
|
||||||
|
assert "eda_llm_insights" not in sources
|
||||||
|
assert "explained_variance_ratio" not in sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_crea_directorio_padre(tmp_path):
|
||||||
|
out = str(tmp_path / "nested" / "deep" / "eda.ipynb")
|
||||||
|
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
|
||||||
|
assert r["status"] == "ok"
|
||||||
|
assert os.path.exists(out)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
id: build_join_graph_py_datascience
|
||||||
|
name: build_join_graph
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def build_join_graph(fk_candidates: list, tables: list = None) -> dict"
|
||||||
|
description: "Construye un grafo de relaciones inter-tabla a partir de FK candidatas (salida fk_candidates de infer_fk_containment_duckdb): nodos con grados y rol (fact/dimension/bridge/standalone), aristas por FK, hubs (candidatas a tabla de hechos) y un diagrama Mermaid graph LR pegable. Funcion pura, sin deps externas, no muta el input."
|
||||||
|
tags: [eda, relations, join, schema, graph, mermaid, star-schema, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
example: |
|
||||||
|
from datascience import build_join_graph
|
||||||
|
fks = [
|
||||||
|
{"from_table": "orders", "from_col": "customer_id",
|
||||||
|
"to_table": "customers", "to_col": "id",
|
||||||
|
"inclusion": 1.0, "cardinality": "many-to-one"},
|
||||||
|
{"from_table": "orders", "from_col": "product_id",
|
||||||
|
"to_table": "products", "to_col": "id",
|
||||||
|
"inclusion": 0.98, "cardinality": "many-to-one"},
|
||||||
|
]
|
||||||
|
g = build_join_graph(fks)
|
||||||
|
# g["hubs"] == ["orders"]; orders -> role "fact", customers/products -> "dimension"
|
||||||
|
print(g["mermaid"])
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_star_schema_roles_and_hub"
|
||||||
|
- "test_two_edges_built"
|
||||||
|
- "test_mermaid_contains_tables_and_arrows"
|
||||||
|
- "test_bridge_role"
|
||||||
|
- "test_standalone_node_from_tables_list"
|
||||||
|
- "test_empty_list_does_not_crash"
|
||||||
|
- "test_none_input_does_not_crash"
|
||||||
|
- "test_malformed_entries_skipped"
|
||||||
|
- "test_does_not_mutate_input"
|
||||||
|
test_file_path: "python/functions/datascience/build_join_graph_test.py"
|
||||||
|
file_path: "python/functions/datascience/build_join_graph.py"
|
||||||
|
params:
|
||||||
|
- name: fk_candidates
|
||||||
|
desc: >
|
||||||
|
lista de dicts, cada uno una FK candidata con al menos las claves
|
||||||
|
from_table, from_col, to_table, to_col, inclusion, cardinality. Suele ser
|
||||||
|
la salida `fk_candidates` de infer_fk_containment_duckdb. Las claves se
|
||||||
|
leen de forma defensiva con .get(...); entradas que no son dict o que no
|
||||||
|
tienen from_table/to_table se ignoran sin fallar. None se trata como [].
|
||||||
|
- name: tables
|
||||||
|
desc: >
|
||||||
|
lista opcional de nombres de TODAS las tablas. Sirve para incluir como
|
||||||
|
nodos aislados (role "standalone") las tablas que no aparecen en ninguna
|
||||||
|
FK. Si es None, los nodos se derivan solo de las aristas.
|
||||||
|
output: >
|
||||||
|
dict con nodes (list[dict] con table, out_degree, in_degree, role donde role
|
||||||
|
es "fact"|"dimension"|"bridge"|"standalone"), edges (list[dict] con
|
||||||
|
from_table, from_col, to_table, to_col, inclusion, cardinality, una por FK
|
||||||
|
valida), mermaid (str con un diagrama `graph LR` pegable en un bloque
|
||||||
|
```mermaid, una arista por FK etiquetada `from_col->to_col`) y hubs (list[str]
|
||||||
|
de tablas con out_degree>0 ordenadas por out_degree descendente, candidatas a
|
||||||
|
tabla de hechos / star schema).
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import build_join_graph
|
||||||
|
|
||||||
|
# fk_candidates concreto: orders apunta a customers y a products (estrella).
|
||||||
|
fks = [
|
||||||
|
{"from_table": "orders", "from_col": "customer_id",
|
||||||
|
"to_table": "customers", "to_col": "id",
|
||||||
|
"inclusion": 1.0, "cardinality": "many-to-one"},
|
||||||
|
{"from_table": "orders", "from_col": "product_id",
|
||||||
|
"to_table": "products", "to_col": "id",
|
||||||
|
"inclusion": 0.98, "cardinality": "many-to-one"},
|
||||||
|
]
|
||||||
|
|
||||||
|
g = build_join_graph(fks)
|
||||||
|
|
||||||
|
g["hubs"] # ["orders"]
|
||||||
|
# nodes: orders -> role "fact" (out_degree 2, in_degree 0),
|
||||||
|
# customers/products -> role "dimension" (in_degree 1, out_degree 0)
|
||||||
|
print(g["mermaid"])
|
||||||
|
```
|
||||||
|
|
||||||
|
El campo `mermaid` se pega tal cual en un bloque ```mermaid:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
orders["orders"] -->|customer_id->id| customers["customers"]
|
||||||
|
orders["orders"] -->|product_id->id| products["products"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando hayas inferido las foreign keys de una base de datos con
|
||||||
|
`infer_fk_containment_duckdb` (grupo `eda`) y necesites **visualizar el esquema
|
||||||
|
relacional**: ver de un vistazo que tabla es la de hechos (hub/star schema),
|
||||||
|
cuales son dimensiones y cuales quedan sueltas. Devuelve un diagrama Mermaid
|
||||||
|
pegable en docs, un report o un dashboard, mas el grafo en dict para razonar
|
||||||
|
sobre los grados (priorizar joins, detectar tablas puente, planear el modelo
|
||||||
|
dimensional). Es la capa de grafo sobre las FK crudas: lee las candidatas, no
|
||||||
|
toca la base de datos.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura, sin I/O ni dependencias externas (solo stdlib), no muta
|
||||||
|
`fk_candidates`. Tolera lista vacia o `None` (devuelve grafo vacio con un
|
||||||
|
mermaid minimo `graph LR` con nota `empty`) y entradas malformadas (no-dict o
|
||||||
|
sin from_table/to_table se ignoran).
|
||||||
|
|
||||||
|
Heuristica de `role` por nodo, basada solo en grados:
|
||||||
|
|
||||||
|
- **fact** — `out_degree > 0` y `in_degree == 0`: apunta a otras tablas y nadie
|
||||||
|
le apunta. Es la candidata a tabla de hechos.
|
||||||
|
- **dimension** — `in_degree > 0` y `out_degree == 0`: solo recibe referencias
|
||||||
|
(tabla maestra / catalogo).
|
||||||
|
- **bridge** — `out_degree > 0` e `in_degree > 0`: apunta y recibe (tabla puente
|
||||||
|
o asociativa de una relacion many-to-many).
|
||||||
|
- **standalone** — sin aristas (solo aparece si se paso en `tables`).
|
||||||
|
|
||||||
|
`hubs` ordena por `out_degree` descendente las tablas con `out_degree > 0`. Para
|
||||||
|
un star schema limpio, `hubs[0]` es la tabla de hechos. Los IDs de nodo en el
|
||||||
|
Mermaid se sanean (no-alfanumerico -> `_`) pero la etiqueta visible conserva el
|
||||||
|
nombre original de la tabla.
|
||||||
|
```
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Construye un grafo de relaciones inter-tabla a partir de FK candidatas.
|
||||||
|
|
||||||
|
Toma la lista `fk_candidates` (salida de infer_fk_containment_duckdb) y produce un
|
||||||
|
grafo de relaciones: nodos (tablas) con grados y rol inferido (fact/dimension/
|
||||||
|
bridge/standalone), aristas (una por FK), un diagrama Mermaid pegable y la lista
|
||||||
|
de hubs (tablas con mayor out_degree, candidatas a tabla de hechos / star schema).
|
||||||
|
|
||||||
|
Funcion pura: lista de dicts -> dict de grafo. Sin I/O ni dependencias externas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _mermaid_id(name: str) -> str:
|
||||||
|
"""Sanea un nombre de tabla para usarlo como identificador Mermaid.
|
||||||
|
|
||||||
|
Mermaid no admite espacios, guiones ni puntos en los IDs de nodo. Se sustituyen
|
||||||
|
por guion bajo. El nombre original se conserva como etiqueta visible del nodo.
|
||||||
|
"""
|
||||||
|
safe = []
|
||||||
|
for ch in str(name):
|
||||||
|
safe.append(ch if (ch.isalnum() or ch == "_") else "_")
|
||||||
|
out = "".join(safe)
|
||||||
|
if not out:
|
||||||
|
out = "node"
|
||||||
|
if out[0].isdigit():
|
||||||
|
out = "t_" + out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_join_graph(fk_candidates: list, tables: list = None) -> dict:
|
||||||
|
"""Construye un grafo de relaciones inter-tabla desde FK candidatas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fk_candidates: lista de dicts, cada uno una FK candidata con al menos
|
||||||
|
las claves from_table, from_col, to_table, to_col, inclusion,
|
||||||
|
cardinality. Claves ausentes se toleran con .get(...). Suele ser la
|
||||||
|
salida `fk_candidates` de infer_fk_containment_duckdb.
|
||||||
|
tables: lista opcional de nombres de TODAS las tablas. Sirve para incluir
|
||||||
|
como nodos aislados (role "standalone") las tablas que no aparecen en
|
||||||
|
ninguna FK. Si es None, los nodos se derivan solo de las aristas.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- nodes: list[dict] con table, out_degree, in_degree, role
|
||||||
|
(role: "fact" | "dimension" | "bridge" | "standalone").
|
||||||
|
- edges: list[dict] con from_table, from_col, to_table, to_col,
|
||||||
|
inclusion, cardinality (una por FK valida).
|
||||||
|
- mermaid: str con un diagrama `graph LR` pegable en un bloque
|
||||||
|
```mermaid, una arista por FK etiquetada con las columnas.
|
||||||
|
- hubs: list[str] de tablas con mayor out_degree (>0), ordenadas por
|
||||||
|
out_degree descendente. Candidatas a tabla de hechos.
|
||||||
|
"""
|
||||||
|
fk_candidates = fk_candidates or []
|
||||||
|
|
||||||
|
out_degree: dict = {}
|
||||||
|
in_degree: dict = {}
|
||||||
|
node_order: list = []
|
||||||
|
|
||||||
|
def _ensure(name) -> None:
|
||||||
|
if name is None:
|
||||||
|
return
|
||||||
|
if name not in out_degree:
|
||||||
|
out_degree[name] = 0
|
||||||
|
in_degree[name] = 0
|
||||||
|
node_order.append(name)
|
||||||
|
|
||||||
|
# Sembrar nodos aislados si se pasaron todas las tablas.
|
||||||
|
for t in tables or []:
|
||||||
|
_ensure(t)
|
||||||
|
|
||||||
|
edges: list = []
|
||||||
|
for fk in fk_candidates:
|
||||||
|
if not isinstance(fk, dict):
|
||||||
|
continue
|
||||||
|
ft = fk.get("from_table")
|
||||||
|
tt = fk.get("to_table")
|
||||||
|
if ft is None or tt is None:
|
||||||
|
continue
|
||||||
|
_ensure(ft)
|
||||||
|
_ensure(tt)
|
||||||
|
out_degree[ft] += 1
|
||||||
|
in_degree[tt] += 1
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"from_table": ft,
|
||||||
|
"from_col": fk.get("from_col"),
|
||||||
|
"to_table": tt,
|
||||||
|
"to_col": fk.get("to_col"),
|
||||||
|
"inclusion": fk.get("inclusion"),
|
||||||
|
"cardinality": fk.get("cardinality"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
nodes: list = []
|
||||||
|
for name in node_order:
|
||||||
|
od = out_degree[name]
|
||||||
|
ind = in_degree[name]
|
||||||
|
if od == 0 and ind == 0:
|
||||||
|
role = "standalone"
|
||||||
|
elif od > 0 and ind == 0:
|
||||||
|
# Apunta a otras tablas pero nadie le apunta: tabla de hechos.
|
||||||
|
role = "fact"
|
||||||
|
elif od == 0 and ind > 0:
|
||||||
|
# Solo recibe referencias: tabla de dimension / maestra.
|
||||||
|
role = "dimension"
|
||||||
|
else:
|
||||||
|
# Apunta y recibe: tabla puente / asociativa.
|
||||||
|
role = "bridge"
|
||||||
|
nodes.append(
|
||||||
|
{
|
||||||
|
"table": name,
|
||||||
|
"out_degree": od,
|
||||||
|
"in_degree": ind,
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
max_out = max((n["out_degree"] for n in nodes), default=0)
|
||||||
|
hubs: list = []
|
||||||
|
if max_out > 0:
|
||||||
|
hubs = [
|
||||||
|
n["table"]
|
||||||
|
for n in sorted(
|
||||||
|
(n for n in nodes if n["out_degree"] > 0),
|
||||||
|
key=lambda n: n["out_degree"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
mermaid = _build_mermaid(nodes, edges)
|
||||||
|
|
||||||
|
return {"nodes": nodes, "edges": edges, "mermaid": mermaid, "hubs": hubs}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mermaid(nodes: list, edges: list) -> str:
|
||||||
|
"""Renderiza el grafo como un diagrama Mermaid `graph LR` pegable.
|
||||||
|
|
||||||
|
Una arista por FK, etiquetada con `from_col->to_col`. Los nodos aislados se
|
||||||
|
declaran sueltos para que aparezcan en el diagrama. Si no hay nodos ni
|
||||||
|
aristas, devuelve un diagrama minimo valido con una nota.
|
||||||
|
"""
|
||||||
|
lines = ["graph LR"]
|
||||||
|
|
||||||
|
if not nodes and not edges:
|
||||||
|
lines.append(" empty[No relations]")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# Declarar nodos aislados (sin ninguna arista) para que se rendericen.
|
||||||
|
connected = set()
|
||||||
|
for e in edges:
|
||||||
|
connected.add(e["from_table"])
|
||||||
|
connected.add(e["to_table"])
|
||||||
|
for n in nodes:
|
||||||
|
name = n["table"]
|
||||||
|
if name not in connected:
|
||||||
|
nid = _mermaid_id(name)
|
||||||
|
lines.append(f' {nid}["{name}"]')
|
||||||
|
|
||||||
|
for e in edges:
|
||||||
|
ft = e["from_table"]
|
||||||
|
tt = e["to_table"]
|
||||||
|
fc = e.get("from_col")
|
||||||
|
tc = e.get("to_col")
|
||||||
|
label = f"{fc}->{tc}" if (fc is not None and tc is not None) else ""
|
||||||
|
fid = _mermaid_id(ft)
|
||||||
|
tid = _mermaid_id(tt)
|
||||||
|
if label:
|
||||||
|
lines.append(f' {fid}["{ft}"] -->|{label}| {tid}["{tt}"]')
|
||||||
|
else:
|
||||||
|
lines.append(f' {fid}["{ft}"] --> {tid}["{tt}"]')
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Tests para build_join_graph."""
|
||||||
|
|
||||||
|
from build_join_graph import build_join_graph
|
||||||
|
|
||||||
|
|
||||||
|
def _star_fks():
|
||||||
|
"""Esquema en estrella: orders apunta a customers y a products."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"from_table": "orders",
|
||||||
|
"from_col": "customer_id",
|
||||||
|
"to_table": "customers",
|
||||||
|
"to_col": "id",
|
||||||
|
"inclusion": 1.0,
|
||||||
|
"cardinality": "many-to-one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from_table": "orders",
|
||||||
|
"from_col": "product_id",
|
||||||
|
"to_table": "products",
|
||||||
|
"to_col": "id",
|
||||||
|
"inclusion": 0.98,
|
||||||
|
"cardinality": "many-to-one",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_star_schema_roles_and_hub():
|
||||||
|
g = build_join_graph(_star_fks())
|
||||||
|
nodes = {n["table"]: n for n in g["nodes"]}
|
||||||
|
|
||||||
|
assert nodes["orders"]["role"] == "fact"
|
||||||
|
assert nodes["orders"]["out_degree"] == 2
|
||||||
|
assert nodes["orders"]["in_degree"] == 0
|
||||||
|
|
||||||
|
assert nodes["customers"]["role"] == "dimension"
|
||||||
|
assert nodes["customers"]["in_degree"] == 1
|
||||||
|
assert nodes["customers"]["out_degree"] == 0
|
||||||
|
|
||||||
|
assert nodes["products"]["role"] == "dimension"
|
||||||
|
|
||||||
|
# orders es el hub (mayor out_degree).
|
||||||
|
assert g["hubs"][0] == "orders"
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_edges_built():
|
||||||
|
g = build_join_graph(_star_fks())
|
||||||
|
assert len(g["edges"]) == 2
|
||||||
|
pairs = {(e["from_table"], e["to_table"]) for e in g["edges"]}
|
||||||
|
assert pairs == {("orders", "customers"), ("orders", "products")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mermaid_contains_tables_and_arrows():
|
||||||
|
g = build_join_graph(_star_fks())
|
||||||
|
m = g["mermaid"]
|
||||||
|
assert "orders" in m
|
||||||
|
assert "customers" in m
|
||||||
|
assert "products" in m
|
||||||
|
assert "-->" in m
|
||||||
|
# Etiqueta de columnas en la arista.
|
||||||
|
assert "customer_id->id" in m
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_role():
|
||||||
|
# order_items apunta a orders y products, y nadie le apunta -> fact en este
|
||||||
|
# subgrafo. Para forzar bridge, hacemos que reciba tambien una FK.
|
||||||
|
fks = [
|
||||||
|
{"from_table": "shipments", "from_col": "order_item_id",
|
||||||
|
"to_table": "order_items", "to_col": "id",
|
||||||
|
"inclusion": 1.0, "cardinality": "many-to-one"},
|
||||||
|
{"from_table": "order_items", "from_col": "product_id",
|
||||||
|
"to_table": "products", "to_col": "id",
|
||||||
|
"inclusion": 1.0, "cardinality": "many-to-one"},
|
||||||
|
]
|
||||||
|
g = build_join_graph(fks)
|
||||||
|
nodes = {n["table"]: n for n in g["nodes"]}
|
||||||
|
assert nodes["order_items"]["role"] == "bridge"
|
||||||
|
assert nodes["order_items"]["in_degree"] == 1
|
||||||
|
assert nodes["order_items"]["out_degree"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_node_from_tables_list():
|
||||||
|
g = build_join_graph(_star_fks(), tables=["orders", "customers", "products", "audit_log"])
|
||||||
|
nodes = {n["table"]: n for n in g["nodes"]}
|
||||||
|
assert "audit_log" in nodes
|
||||||
|
assert nodes["audit_log"]["role"] == "standalone"
|
||||||
|
assert nodes["audit_log"]["out_degree"] == 0
|
||||||
|
assert nodes["audit_log"]["in_degree"] == 0
|
||||||
|
# El nodo aislado aparece declarado en el mermaid.
|
||||||
|
assert "audit_log" in g["mermaid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_does_not_crash():
|
||||||
|
g = build_join_graph([])
|
||||||
|
assert g["nodes"] == []
|
||||||
|
assert g["edges"] == []
|
||||||
|
assert g["hubs"] == []
|
||||||
|
assert g["mermaid"].startswith("graph LR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_input_does_not_crash():
|
||||||
|
g = build_join_graph(None)
|
||||||
|
assert g["edges"] == []
|
||||||
|
assert "graph LR" in g["mermaid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_entries_skipped():
|
||||||
|
fks = [
|
||||||
|
{"from_table": "a", "from_col": "x", "to_table": "b", "to_col": "y"},
|
||||||
|
{"from_table": "a"}, # falta to_table -> se ignora
|
||||||
|
"not a dict", # no es dict -> se ignora
|
||||||
|
{"to_table": "b"}, # falta from_table -> se ignora
|
||||||
|
]
|
||||||
|
g = build_join_graph(fks)
|
||||||
|
assert len(g["edges"]) == 1
|
||||||
|
assert g["edges"][0]["from_table"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_mutate_input():
|
||||||
|
fks = _star_fks()
|
||||||
|
snapshot = [dict(fk) for fk in fks]
|
||||||
|
build_join_graph(fks)
|
||||||
|
assert fks == snapshot
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
id: column_quality_score_py_datascience
|
||||||
|
name: column_quality_score
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def column_quality_score(col: dict) -> dict"
|
||||||
|
description: "Calcula un score de calidad de datos 0-100 para un ColumnProfile del grupo eda, con desglose completeness/validity/consistency y lista de issues legibles. Funcion pura, no muta el input."
|
||||||
|
tags: [eda, data-quality, profiling, scoring, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
example: |
|
||||||
|
from datascience import column_quality_score
|
||||||
|
col = {"name": "precio", "inferred_type": "float", "null_pct": 0.2,
|
||||||
|
"unique_pct": 0.4, "flags": [], "numeric": {"outlier_pct": 0.08}}
|
||||||
|
column_quality_score(col)
|
||||||
|
# {"score": 86.8, "completeness": 0.8, "validity": 0.92,
|
||||||
|
# "consistency": 1.0, "issues": ["20% nulos", "8% outliers"]}
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_clean_column_high_score"
|
||||||
|
- "test_half_null_lowers_completeness_and_score"
|
||||||
|
- "test_constant_column_flags_issue"
|
||||||
|
- "test_empty_dict_does_not_crash"
|
||||||
|
- "test_outliers_penalize_validity"
|
||||||
|
- "test_mostly_null_flag_halves_validity"
|
||||||
|
- "test_high_cardinality_text_flagged_as_id"
|
||||||
|
- "test_none_values_treated_defensively"
|
||||||
|
- "test_does_not_mutate_input"
|
||||||
|
test_file_path: "python/functions/datascience/column_quality_score_test.py"
|
||||||
|
file_path: "python/functions/datascience/column_quality_score.py"
|
||||||
|
params:
|
||||||
|
- name: col
|
||||||
|
desc: >
|
||||||
|
ColumnProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb).
|
||||||
|
Se leen sus claves de forma defensiva con .get(...) y se toleran valores
|
||||||
|
None. Claves usadas: null_pct (0-1), inferred_type, semantic_type,
|
||||||
|
unique_pct (0-1), flags (list[str], reconoce "constant"/"mostly_null"),
|
||||||
|
numeric ({outlier_pct: 0-1, ...}|None) y match_rate (opcional, 0-1).
|
||||||
|
output: >
|
||||||
|
dict con score (float 0-100, redondeado a 1 decimal), completeness (0-1),
|
||||||
|
validity (0-1), consistency (0-1) e issues (list[str] de descripciones
|
||||||
|
legibles de los problemas detectados). score = round(100 * (0.5*completeness
|
||||||
|
+ 0.3*validity + 0.2*consistency), 1).
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import column_quality_score
|
||||||
|
|
||||||
|
# ColumnProfile de una columna numerica con 20% nulls y 8% outliers.
|
||||||
|
col = {
|
||||||
|
"name": "precio",
|
||||||
|
"physical_type": "DOUBLE",
|
||||||
|
"inferred_type": "float",
|
||||||
|
"semantic_type": "",
|
||||||
|
"count": 800,
|
||||||
|
"n_rows": 1000,
|
||||||
|
"null_count": 200,
|
||||||
|
"null_pct": 0.20,
|
||||||
|
"distinct_count": 400,
|
||||||
|
"unique_pct": 0.40,
|
||||||
|
"flags": [],
|
||||||
|
"numeric": {"outlier_pct": 0.08},
|
||||||
|
"categorical": None,
|
||||||
|
"datetime": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
column_quality_score(col)
|
||||||
|
# {
|
||||||
|
# "score": 86.8,
|
||||||
|
# "completeness": 0.8, # 1 - 0.20
|
||||||
|
# "validity": 0.92, # 1 - min(0.08, 0.3)
|
||||||
|
# "consistency": 1.0,
|
||||||
|
# "issues": ["20% nulos", "8% outliers"],
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando hayas perfilado una tabla con el grupo `eda` (p.ej.
|
||||||
|
`summarize_table_duckdb`) y necesites un numero 0-100 por columna para
|
||||||
|
ordenar/priorizar limpieza de datos, pintar semaforos de calidad en un
|
||||||
|
dashboard, o decidir que columnas descartar antes de modelar. Es la capa de
|
||||||
|
scoring sobre el ColumnProfile crudo: lee el perfil, no toca los datos.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura, sin I/O ni dependencias externas, no muta `col`. Lee todas las
|
||||||
|
claves con `.get(...)` y tolera que vengan en `None` (un ColumnProfile recien
|
||||||
|
salido de `summarize_table_duckdb` trae muchas claves a `None`), por lo que
|
||||||
|
nunca falla por claves ausentes — un `{}` produce un resultado bien definido.
|
||||||
|
|
||||||
|
Pesos del score: completeness 0.5, validity 0.3, consistency 0.2.
|
||||||
|
|
||||||
|
- **completeness** = `1 - null_pct` (None -> 0 nulls -> 1.0).
|
||||||
|
- **validity**: parte de 1.0 y penaliza `min(outlier_pct, 0.3)` en columnas
|
||||||
|
numericas, `0.5 * (1 - match_rate)` si hay `semantic_type` declarado con
|
||||||
|
`match_rate` bajo disponible, y multiplica por 0.5 si el flag `mostly_null`
|
||||||
|
esta presente.
|
||||||
|
- **consistency**: 1.0 salvo flag `constant` (-> 0.3, columna poco informativa)
|
||||||
|
o texto con `unique_pct > 0.9` (-> 0.6, posible id de alta cardinalidad).
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Score de calidad de datos (0-100) para un ColumnProfile del grupo eda.
|
||||||
|
|
||||||
|
Funcion pura: dado el perfil de una columna producido por el grupo de
|
||||||
|
capacidad `eda` (p.ej. summarize_table_duckdb), calcula un score agregado
|
||||||
|
de calidad junto a su desglose en completeness / validity / consistency y
|
||||||
|
una lista de issues legibles. No realiza I/O ni muta el input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def column_quality_score(col: dict) -> dict:
|
||||||
|
"""Calcula un score de calidad de datos 0-100 para un ColumnProfile.
|
||||||
|
|
||||||
|
El score pondera tres dimensiones:
|
||||||
|
- completeness (0.5): proporcion de valores no nulos.
|
||||||
|
- validity (0.3): ausencia de outliers / heuristicas de validez.
|
||||||
|
- consistency (0.2): la columna aporta informacion (no constante, no ruido).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
col: ColumnProfile dict del grupo eda. Se leen las claves de forma
|
||||||
|
defensiva con .get(...) y se tolera que muchas vengan en None.
|
||||||
|
Claves relevantes: null_pct, inferred_type, semantic_type,
|
||||||
|
unique_pct, flags (list[str]), numeric ({outlier_pct, ...}|None),
|
||||||
|
match_rate (opcional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
score (float, 0-100, redondeado a 1 decimal),
|
||||||
|
completeness (float, 0-1),
|
||||||
|
validity (float, 0-1),
|
||||||
|
consistency (float, 0-1),
|
||||||
|
issues (list[str]) descripciones legibles de los problemas.
|
||||||
|
"""
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
col = {}
|
||||||
|
|
||||||
|
flags = col.get("flags") or []
|
||||||
|
if not isinstance(flags, (list, tuple)):
|
||||||
|
flags = []
|
||||||
|
flags = set(flags)
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
|
||||||
|
# --- completeness -------------------------------------------------
|
||||||
|
null_pct = col.get("null_pct")
|
||||||
|
if null_pct is None:
|
||||||
|
null_pct = 0.0
|
||||||
|
try:
|
||||||
|
null_pct = float(null_pct)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
null_pct = 0.0
|
||||||
|
null_pct = _clamp(null_pct, 0.0, 1.0)
|
||||||
|
completeness = 1.0 - null_pct
|
||||||
|
if null_pct > 0:
|
||||||
|
issues.append(f"{round(null_pct * 100)}% nulos")
|
||||||
|
|
||||||
|
# --- validity -----------------------------------------------------
|
||||||
|
validity = 1.0
|
||||||
|
inferred_type = col.get("inferred_type") or ""
|
||||||
|
|
||||||
|
numeric = col.get("numeric")
|
||||||
|
is_numeric = inferred_type in ("integer", "float", "numeric") or isinstance(numeric, dict)
|
||||||
|
if isinstance(numeric, dict):
|
||||||
|
outlier_pct = numeric.get("outlier_pct")
|
||||||
|
if outlier_pct is not None:
|
||||||
|
try:
|
||||||
|
outlier_pct = float(outlier_pct)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
outlier_pct = 0.0
|
||||||
|
outlier_pct = _clamp(outlier_pct, 0.0, 1.0)
|
||||||
|
if outlier_pct > 0:
|
||||||
|
penalty = min(outlier_pct, 0.3)
|
||||||
|
validity -= penalty
|
||||||
|
issues.append(f"{round(outlier_pct * 100)}% outliers")
|
||||||
|
|
||||||
|
# semantic_type declarado pero con baja tasa de match (si la conocemos).
|
||||||
|
semantic_type = col.get("semantic_type") or ""
|
||||||
|
match_rate = col.get("match_rate")
|
||||||
|
if semantic_type and match_rate is not None:
|
||||||
|
try:
|
||||||
|
match_rate = float(match_rate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
match_rate = None
|
||||||
|
if match_rate is not None:
|
||||||
|
match_rate = _clamp(match_rate, 0.0, 1.0)
|
||||||
|
if match_rate < 1.0:
|
||||||
|
shortfall = 1.0 - match_rate
|
||||||
|
validity -= 0.5 * shortfall
|
||||||
|
issues.append(
|
||||||
|
f"semantic_type '{semantic_type}' con baja coincidencia "
|
||||||
|
f"({round(match_rate * 100)}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "mostly_null" in flags:
|
||||||
|
validity *= 0.5
|
||||||
|
issues.append("mayoritariamente nula")
|
||||||
|
|
||||||
|
validity = _clamp(validity, 0.0, 1.0)
|
||||||
|
|
||||||
|
# --- consistency --------------------------------------------------
|
||||||
|
consistency = 1.0
|
||||||
|
if "constant" in flags:
|
||||||
|
consistency = 0.3
|
||||||
|
issues.append("columna constante")
|
||||||
|
else:
|
||||||
|
unique_pct = col.get("unique_pct")
|
||||||
|
if unique_pct is not None:
|
||||||
|
try:
|
||||||
|
unique_pct = float(unique_pct)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
unique_pct = None
|
||||||
|
if (
|
||||||
|
inferred_type == "text"
|
||||||
|
and unique_pct is not None
|
||||||
|
and _clamp(unique_pct, 0.0, 1.0) > 0.9
|
||||||
|
):
|
||||||
|
consistency = 0.6
|
||||||
|
issues.append("posible id de alta cardinalidad")
|
||||||
|
|
||||||
|
consistency = _clamp(consistency, 0.0, 1.0)
|
||||||
|
|
||||||
|
# --- score agregado ----------------------------------------------
|
||||||
|
score = round(
|
||||||
|
100.0 * (0.5 * completeness + 0.3 * validity + 0.2 * consistency),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Silencia warnings sobre la variable de tipo no usada.
|
||||||
|
_ = is_numeric
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"completeness": completeness,
|
||||||
|
"validity": validity,
|
||||||
|
"consistency": consistency,
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||||
|
"""Recorta x al rango [lo, hi]."""
|
||||||
|
if x < lo:
|
||||||
|
return lo
|
||||||
|
if x > hi:
|
||||||
|
return hi
|
||||||
|
return x
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Tests para column_quality_score."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from column_quality_score import column_quality_score
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_numeric_col() -> dict:
|
||||||
|
"""ColumnProfile de una columna numerica sana, sin problemas."""
|
||||||
|
return {
|
||||||
|
"name": "edad",
|
||||||
|
"physical_type": "INTEGER",
|
||||||
|
"inferred_type": "integer",
|
||||||
|
"semantic_type": "",
|
||||||
|
"count": 1000,
|
||||||
|
"n_rows": 1000,
|
||||||
|
"null_count": 0,
|
||||||
|
"null_pct": 0.0,
|
||||||
|
"distinct_count": 80,
|
||||||
|
"unique_pct": 0.08,
|
||||||
|
"flags": [],
|
||||||
|
"numeric": {"outlier_pct": 0.0},
|
||||||
|
"categorical": None,
|
||||||
|
"datetime": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_column_high_score():
|
||||||
|
out = column_quality_score(_clean_numeric_col())
|
||||||
|
assert out["score"] > 90
|
||||||
|
assert out["completeness"] == 1.0
|
||||||
|
assert out["validity"] == 1.0
|
||||||
|
assert out["consistency"] == 1.0
|
||||||
|
assert out["issues"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_half_null_lowers_completeness_and_score():
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["null_count"] = 500
|
||||||
|
col["null_pct"] = 0.5
|
||||||
|
clean_score = column_quality_score(_clean_numeric_col())["score"]
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["completeness"] == 0.5
|
||||||
|
assert out["score"] < clean_score
|
||||||
|
assert any("nulos" in issue for issue in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_column_flags_issue():
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["flags"] = ["constant"]
|
||||||
|
col["distinct_count"] = 1
|
||||||
|
col["unique_pct"] = 0.001
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["consistency"] == 0.3
|
||||||
|
assert any("constante" in issue for issue in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dict_does_not_crash():
|
||||||
|
out = column_quality_score({})
|
||||||
|
assert isinstance(out["score"], float)
|
||||||
|
assert out["completeness"] == 1.0
|
||||||
|
assert 0.0 <= out["score"] <= 100.0
|
||||||
|
assert isinstance(out["issues"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outliers_penalize_validity():
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["numeric"] = {"outlier_pct": 0.2}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["validity"] < 1.0
|
||||||
|
assert any("outliers" in issue for issue in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mostly_null_flag_halves_validity():
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["null_pct"] = 0.85
|
||||||
|
col["flags"] = ["mostly_null"]
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["validity"] == 0.5
|
||||||
|
assert any("mayoritariamente nula" in issue for issue in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_cardinality_text_flagged_as_id():
|
||||||
|
col = {
|
||||||
|
"name": "uuid",
|
||||||
|
"inferred_type": "text",
|
||||||
|
"semantic_type": "",
|
||||||
|
"null_pct": 0.0,
|
||||||
|
"unique_pct": 0.99,
|
||||||
|
"flags": [],
|
||||||
|
"numeric": None,
|
||||||
|
}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["consistency"] < 1.0
|
||||||
|
assert any("alta cardinalidad" in issue for issue in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_values_treated_defensively():
|
||||||
|
col = {
|
||||||
|
"name": "x",
|
||||||
|
"inferred_type": None,
|
||||||
|
"semantic_type": None,
|
||||||
|
"null_pct": None,
|
||||||
|
"unique_pct": None,
|
||||||
|
"flags": None,
|
||||||
|
"numeric": None,
|
||||||
|
}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["completeness"] == 1.0
|
||||||
|
assert isinstance(out["score"], float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_mutate_input():
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["flags"] = ["constant"]
|
||||||
|
before = {k: (list(v) if isinstance(v, list) else v) for k, v in col.items()}
|
||||||
|
column_quality_score(col)
|
||||||
|
assert col["flags"] == before["flags"]
|
||||||
|
assert col == before
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: correlation_matrix_duckdb
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def correlation_matrix_duckdb(db_path: str, table: str, columns: list = None, strong_threshold: float = 0.7) -> dict"
|
||||||
|
description: "Matriz de correlacion de Pearson entre columnas numericas de una tabla DuckDB calculada con push-down SQL (funcion nativa corr()), sin traer filas a RAM. Apta para tablas grandes donde no quieres muestrear en Python."
|
||||||
|
tags: [eda, correlation, duckdb, pearson, datascience, push-down]
|
||||||
|
uses_functions: [duckdb_table_schema_py_infra, duckdb_query_readonly_py_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: db_path
|
||||||
|
desc: "Ruta al archivo DuckDB. Debe existir; el modo read_only NO crea la base."
|
||||||
|
- name: table
|
||||||
|
desc: "Nombre de la tabla. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se interpola citado (DuckDB no admite parametros para identificadores)."
|
||||||
|
- name: columns
|
||||||
|
desc: "Lista de columnas numericas a correlacionar. None (default) = autodescubre las columnas de tipo numerico DuckDB leyendo el schema."
|
||||||
|
- name: strong_threshold
|
||||||
|
desc: "Umbral en valor absoluto para marcar una pareja como fuerte (default 0.7). Pares con abs(corr) >= threshold se devuelven en `strong`."
|
||||||
|
output: "dict. En exito {status:'ok', columns:[...], matrix:{a:{b:corr}}, pairs:[{a,b,corr}], strong:[{a,b,corr}]} con corr float o None (columna constante / <2 valores -> corr() = NULL); strong omite los None y va ordenado por abs(corr) desc. En error {status:'error', error:str} (no lanza)."
|
||||||
|
tested: true
|
||||||
|
tests: ["correla dos columnas linealmente dependientes y aparece en strong", "columna constante no rompe y queda fuera de strong", "tabla con menos de dos columnas numericas devuelve error", "columns explicitas respetan el orden y la matriz es simetrica"]
|
||||||
|
test_file_path: "python/functions/datascience/correlation_matrix_duckdb_test.py"
|
||||||
|
file_path: "python/functions/datascience/correlation_matrix_duckdb.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import duckdb
|
||||||
|
from datascience import correlation_matrix_duckdb
|
||||||
|
|
||||||
|
# Crear una tabla DuckDB de prueba con 3 columnas numericas (col_a y col_b correladas).
|
||||||
|
db = "/tmp/corr_demo.duckdb"
|
||||||
|
con = duckdb.connect(db)
|
||||||
|
con.execute("CREATE TABLE m AS SELECT i AS col_a, 2*i AS col_b, (i*7) % 5 AS col_c FROM range(100) t(i)")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = correlation_matrix_duckdb(db, "m")
|
||||||
|
print(res["status"]) # ok
|
||||||
|
print(round(res["matrix"]["col_a"]["col_b"], 3)) # ~1.0
|
||||||
|
print([(p["a"], p["b"]) for p in res["strong"]]) # [('col_a', 'col_b')]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesitas la correlacion de Pearson entre muchas columnas numericas de una
|
||||||
|
tabla con MUCHAS filas y NO quieres muestrear ni traerla a RAM con pandas/numpy. Todo
|
||||||
|
el calculo se hace push-down en el motor de DuckDB con la funcion nativa `corr()`.
|
||||||
|
Util en el flujo `eda` para detectar pares fuertemente correlados (multicolinealidad)
|
||||||
|
antes de modelar, o para resumir relaciones lineales en datasets que no caben en memoria.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion impura: lee un archivo DuckDB del disco (read_only, nunca lo modifica).
|
||||||
|
- Solo correlacion de PEARSON (lineal). Para monotona usa `spearman_corr_py_datascience`;
|
||||||
|
para asociacion categorica `cramers_v_py_datascience`.
|
||||||
|
- `corr()` de DuckDB ignora las filas con NULL POR PAREJA (pairwise complete): cada
|
||||||
|
coeficiente usa solo las filas donde ambas columnas son no-NULL, asi que distintos
|
||||||
|
pares pueden basarse en distinto numero de filas.
|
||||||
|
- Una columna constante o con menos de 2 valores distintos da varianza cero: DuckDB
|
||||||
|
devuelve `NaN` (y `NULL` si la tabla esta vacia). Ambos casos se normalizan a
|
||||||
|
`corr: None`, de modo que ese par se omite de `strong` y la matriz nunca contiene
|
||||||
|
`NaN` (no rompe ni el orden de `strong`).
|
||||||
|
- Tabla vacia -> matriz de None (salvo la diagonal 1.0). Menos de 2 columnas numericas
|
||||||
|
-> `{status:'error'}`.
|
||||||
|
- La query se ejecuta con `sandbox=False` en `duckdb_query_readonly` (uso interno
|
||||||
|
confiable: el SQL lo construye esta funcion, no un cliente externo).
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""correlation_matrix_duckdb — matriz de correlacion de Pearson con push-down SQL.
|
||||||
|
|
||||||
|
Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del
|
||||||
|
grupo `duckdb`: `duckdb_table_schema` para descubrir las columnas numericas y
|
||||||
|
`duckdb_query_readonly` para ejecutar la query de correlacion). Pertenece al grupo
|
||||||
|
de capacidad `eda` (exploratory data analysis).
|
||||||
|
|
||||||
|
Calcula la matriz de correlacion de Pearson entre columnas NUMERICAS de una tabla
|
||||||
|
DuckDB usando la funcion agregada nativa `corr()` del motor. TODO el calculo ocurre
|
||||||
|
en el motor de DuckDB (push-down): se construye UN solo SELECT con un `corr()` por
|
||||||
|
cada pareja (i < j) y se traen unicamente los coeficientes, nunca las filas. Esto la
|
||||||
|
hace apta para tablas grandes donde muestrear en Python (pandas/numpy) seria caro o
|
||||||
|
imposible.
|
||||||
|
|
||||||
|
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
|
||||||
|
devuelve {status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
|
||||||
|
from infra import duckdb_query_readonly, duckdb_table_schema
|
||||||
|
|
||||||
|
# Identificador SQL valido. DuckDB no admite parametros posicionales para nombres
|
||||||
|
# de tabla/columna, asi que hay que validar e interpolar citado con dobles comillas.
|
||||||
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
# Tipos fisicos DuckDB que mapean a "numeric" y por tanto admiten corr().
|
||||||
|
_NUMERIC_TYPES = {
|
||||||
|
"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
|
||||||
|
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
|
||||||
|
"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_type(duckdb_type: str) -> str:
|
||||||
|
"""Normaliza un tipo DuckDB a su nombre base en mayusculas.
|
||||||
|
|
||||||
|
DuckDB reporta tipos como 'DECIMAL(18,3)' o 'BIGINT'. Nos quedamos con el
|
||||||
|
prefijo antes de '(' para mapearlo contra _NUMERIC_TYPES.
|
||||||
|
"""
|
||||||
|
return duckdb_type.split("(", 1)[0].strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _quote(ident: str) -> str:
|
||||||
|
"""Cita un identificador SQL con dobles comillas (ya validado por el regex)."""
|
||||||
|
return '"' + ident.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def correlation_matrix_duckdb(
|
||||||
|
db_path: str,
|
||||||
|
table: str,
|
||||||
|
columns: list = None,
|
||||||
|
strong_threshold: float = 0.7,
|
||||||
|
) -> dict:
|
||||||
|
"""Matriz de correlacion de Pearson entre columnas numericas, push-down en DuckDB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: ruta al archivo DuckDB. Debe existir (read_only NO crea la base).
|
||||||
|
table: nombre de la tabla. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes
|
||||||
|
de interpolarlo (DuckDB no admite parametros para identificadores).
|
||||||
|
columns: lista de columnas numericas a correlacionar. Si es None (default),
|
||||||
|
se descubren automaticamente leyendo el schema de la tabla y quedandose
|
||||||
|
con las de tipo numerico DuckDB. Cada nombre se valida con el mismo regex.
|
||||||
|
strong_threshold: umbral en valor absoluto para marcar una pareja como
|
||||||
|
"fuerte" (default 0.7). Las parejas con abs(corr) >= threshold se devuelven
|
||||||
|
ademas en `strong`, ordenadas por abs(corr) descendente.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito:
|
||||||
|
{status:'ok',
|
||||||
|
columns:[...], # columnas usadas, en orden
|
||||||
|
matrix:{a:{b:corr, ...}, ...}, # matriz simetrica; diagonal=1.0
|
||||||
|
pairs:[{a, b, corr}, ...], # cada pareja i<j una vez
|
||||||
|
strong:[{a, b, corr}, ...]} # pares con abs(corr)>=threshold
|
||||||
|
donde corr es float o None (columna constante / <2 valores -> corr() = NULL).
|
||||||
|
Los pares con corr None se omiten de `strong`. En error (sin lanzar):
|
||||||
|
{status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
# 1. Validar tabla.
|
||||||
|
if not isinstance(table, str) or not _IDENT_RE.match(table):
|
||||||
|
return {"status": "error", "error": f"invalid table identifier: {table!r}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 2. Resolver columnas numericas si no se especificaron.
|
||||||
|
if columns is None:
|
||||||
|
schema = duckdb_table_schema(db_path, table)
|
||||||
|
if schema.get("status") != "ok":
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "could not read schema: "
|
||||||
|
+ str(schema.get("error", "unknown")),
|
||||||
|
}
|
||||||
|
columns = [
|
||||||
|
col["name"]
|
||||||
|
for col in schema.get("columns", [])
|
||||||
|
if _base_type(col.get("type", "")) in _NUMERIC_TYPES
|
||||||
|
]
|
||||||
|
|
||||||
|
# Validar cada nombre de columna.
|
||||||
|
if not isinstance(columns, list):
|
||||||
|
return {"status": "error", "error": "columns must be a list or None"}
|
||||||
|
for col in columns:
|
||||||
|
if not isinstance(col, str) or not _IDENT_RE.match(col):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"invalid column identifier: {col!r}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) < 2:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "need at least 2 numeric columns to correlate, got "
|
||||||
|
+ str(len(columns)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Construir UNA query con un corr() por pareja (i < j). El alias usa el
|
||||||
|
# indice de cada columna (c0__c1) para evitar colisiones y nombres invalidos
|
||||||
|
# cuando los nombres de columna son largos o repiten substrings.
|
||||||
|
select_terms = []
|
||||||
|
pair_index = [] # (i, j) en el mismo orden que los terminos del SELECT
|
||||||
|
for i in range(len(columns)):
|
||||||
|
for j in range(i + 1, len(columns)):
|
||||||
|
alias = f"c{i}__c{j}"
|
||||||
|
select_terms.append(
|
||||||
|
f"corr({_quote(columns[i])}, {_quote(columns[j])}) AS {alias}"
|
||||||
|
)
|
||||||
|
pair_index.append((i, j))
|
||||||
|
|
||||||
|
sql = f"SELECT {', '.join(select_terms)} FROM {_quote(table)}"
|
||||||
|
result = duckdb_query_readonly(db_path, sql, max_rows=1, sandbox=False)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "correlation query failed: "
|
||||||
|
+ str(result.get("error", "unknown")),
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = result.get("rows", [])
|
||||||
|
if not rows:
|
||||||
|
# Tabla vacia: corr() de cero filas es NULL; devolvemos matriz de None.
|
||||||
|
row = {}
|
||||||
|
else:
|
||||||
|
row = rows[0]
|
||||||
|
|
||||||
|
# 4. Parsear a matriz simetrica.
|
||||||
|
matrix = {a: {b: None for b in columns} for a in columns}
|
||||||
|
for a in columns:
|
||||||
|
matrix[a][a] = 1.0
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
for term_pos, (i, j) in enumerate(pair_index):
|
||||||
|
alias = f"c{i}__c{j}"
|
||||||
|
value = row.get(alias)
|
||||||
|
# corr() devuelve NULL (cero filas) o NaN (varianza cero: columna
|
||||||
|
# constante / <2 valores). Ambos casos significan "sin correlacion
|
||||||
|
# definida": los normalizamos a None para que `strong` y la matriz
|
||||||
|
# nunca contengan NaN.
|
||||||
|
if value is None or (isinstance(value, float) and math.isnan(value)):
|
||||||
|
corr = None
|
||||||
|
else:
|
||||||
|
corr = float(value)
|
||||||
|
a, b = columns[i], columns[j]
|
||||||
|
matrix[a][b] = corr
|
||||||
|
matrix[b][a] = corr
|
||||||
|
pairs.append({"a": a, "b": b, "corr": corr})
|
||||||
|
|
||||||
|
strong = sorted(
|
||||||
|
(p for p in pairs if p["corr"] is not None and abs(p["corr"]) >= strong_threshold),
|
||||||
|
key=lambda p: abs(p["corr"]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"columns": list(columns),
|
||||||
|
"matrix": matrix,
|
||||||
|
"pairs": pairs,
|
||||||
|
"strong": strong,
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests para correlation_matrix_duckdb."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
|
# Permitir importar funciones del registry (from infra import ..., from datascience import ...).
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "functions"))
|
||||||
|
|
||||||
|
from datascience.correlation_matrix_duckdb import correlation_matrix_duckdb
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(tmp_name: str) -> str:
|
||||||
|
"""Crea una DuckDB en /tmp con col_a, col_b=2*col_a (corr ~1) y col_c aleatoria."""
|
||||||
|
db = os.path.join("/tmp", tmp_name)
|
||||||
|
if os.path.exists(db):
|
||||||
|
os.remove(db)
|
||||||
|
con = duckdb.connect(db)
|
||||||
|
# col_b = 2*col_a => correlacion de Pearson exactamente 1.0.
|
||||||
|
# col_c usa un patron pseudo-aleatorio acotado, no perfectamente correlado.
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE m AS "
|
||||||
|
"SELECT i AS col_a, 2*i AS col_b, (i*7 + 3) % 11 AS col_c "
|
||||||
|
"FROM range(200) t(i)"
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_correla_dos_columnas_linealmente_dependientes_y_aparece_en_strong():
|
||||||
|
db = _make_db("corr_test_strong.duckdb")
|
||||||
|
res = correlation_matrix_duckdb(db, "m")
|
||||||
|
assert res["status"] == "ok", res
|
||||||
|
# col_a y col_b son linealmente dependientes -> corr ~1.0.
|
||||||
|
assert abs(res["matrix"]["col_a"]["col_b"] - 1.0) < 1e-9
|
||||||
|
assert abs(res["matrix"]["col_b"]["col_a"] - 1.0) < 1e-9
|
||||||
|
# El par (a, b) debe aparecer en strong (abs(corr) >= 0.7).
|
||||||
|
strong_pairs = {frozenset((p["a"], p["b"])) for p in res["strong"]}
|
||||||
|
assert frozenset(("col_a", "col_b")) in strong_pairs
|
||||||
|
# strong ordenado por abs(corr) descendente.
|
||||||
|
abs_vals = [abs(p["corr"]) for p in res["strong"]]
|
||||||
|
assert abs_vals == sorted(abs_vals, reverse=True)
|
||||||
|
os.remove(db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_columna_constante_no_rompe_y_queda_fuera_de_strong():
|
||||||
|
db = os.path.join("/tmp", "corr_test_const.duckdb")
|
||||||
|
if os.path.exists(db):
|
||||||
|
os.remove(db)
|
||||||
|
con = duckdb.connect(db)
|
||||||
|
# col_k es constante => corr() = NULL para cualquier par que la incluya.
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE m AS "
|
||||||
|
"SELECT i AS col_a, 2*i AS col_b, 42 AS col_k "
|
||||||
|
"FROM range(50) t(i)"
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = correlation_matrix_duckdb(db, "m")
|
||||||
|
assert res["status"] == "ok", res
|
||||||
|
# La columna constante produce corr None, no rompe.
|
||||||
|
assert res["matrix"]["col_a"]["col_k"] is None
|
||||||
|
assert res["matrix"]["col_k"]["col_b"] is None
|
||||||
|
# Diagonal sigue siendo 1.0.
|
||||||
|
assert res["matrix"]["col_k"]["col_k"] == 1.0
|
||||||
|
# Ningun par con corr None entra en strong.
|
||||||
|
for p in res["strong"]:
|
||||||
|
assert p["corr"] is not None
|
||||||
|
# El par correlado a-b sigue presente en strong.
|
||||||
|
strong_pairs = {frozenset((p["a"], p["b"])) for p in res["strong"]}
|
||||||
|
assert frozenset(("col_a", "col_b")) in strong_pairs
|
||||||
|
os.remove(db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_menos_de_dos_columnas_numericas_devuelve_error():
|
||||||
|
db = os.path.join("/tmp", "corr_test_few.duckdb")
|
||||||
|
if os.path.exists(db):
|
||||||
|
os.remove(db)
|
||||||
|
con = duckdb.connect(db)
|
||||||
|
con.execute("CREATE TABLE m AS SELECT i AS col_a, 'x' AS label FROM range(10) t(i)")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = correlation_matrix_duckdb(db, "m")
|
||||||
|
assert res["status"] == "error", res
|
||||||
|
assert "at least 2 numeric columns" in res["error"]
|
||||||
|
os.remove(db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns_explicitas_respetan_orden_y_matriz_simetrica():
|
||||||
|
db = _make_db("corr_test_explicit.duckdb")
|
||||||
|
res = correlation_matrix_duckdb(db, "m", columns=["col_c", "col_a", "col_b"])
|
||||||
|
assert res["status"] == "ok", res
|
||||||
|
assert res["columns"] == ["col_c", "col_a", "col_b"]
|
||||||
|
# Matriz simetrica.
|
||||||
|
for a in res["columns"]:
|
||||||
|
for b in res["columns"]:
|
||||||
|
assert res["matrix"][a][b] == res["matrix"][b][a]
|
||||||
|
# pairs contiene cada pareja i<j una sola vez: C(3,2) = 3.
|
||||||
|
assert len(res["pairs"]) == 3
|
||||||
|
os.remove(db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabla_invalida_devuelve_error():
|
||||||
|
res = correlation_matrix_duckdb("/tmp/nope.duckdb", "drop table; --")
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert "invalid table identifier" in res["error"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: correlation_ratio
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def correlation_ratio(categories: list, values: list) -> float"
|
||||||
|
description: "Correlation ratio eta (η): mide cuanto explica una variable categorica de la varianza de una variable numerica, en [0,1]. η²=varianza entre grupos/varianza total; devuelve η=sqrt(η²). Es la metrica num↔cat de una matriz de asociacion mixta (analoga a Cramer's V para cat↔cat o Pearson para num↔num). Descarta pares con categoria None o valor None/NaN/no-numerico. Si <2 grupos distintos o varianza total 0 devuelve 0.0 (float, nunca None ni excepcion)."
|
||||||
|
tags: [eda, correlation, association, categorical, numeric, statistics, datascience]
|
||||||
|
params:
|
||||||
|
- name: categories
|
||||||
|
desc: "Lista de etiquetas categoricas (cualquier hashable: str, int, etc.). Define los grupos. None en una posicion descarta ese par."
|
||||||
|
- name: values
|
||||||
|
desc: "Lista de valores numericos pareada con categories (mismo orden e indice). None, NaN o no-numerico descarta ese par."
|
||||||
|
output: "eta (η) en rango [0,1] como float. 1.0 = la categorica explica toda la varianza de la numerica (medias de grupo muy separadas); 0.0 = no la explica (medias de grupo iguales o datos degenerados). Nunca None ni excepcion."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_high_eta_when_groups_separated", "test_low_eta_when_random", "test_single_group_returns_zero", "test_zero_total_variance_returns_zero", "test_skips_none_and_nan_pairs", "test_result_in_unit_range"]
|
||||||
|
test_file_path: "python/functions/datascience/correlation_ratio_test.py"
|
||||||
|
file_path: "python/functions/datascience/correlation_ratio.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import correlation_ratio
|
||||||
|
|
||||||
|
# La categoria separa claramente los valores -> eta alto (cercano a 1)
|
||||||
|
categories = ["A", "A", "A", "B", "B", "B", "C", "C", "C"]
|
||||||
|
values = [ 1, 2, 1, 10, 11, 10, 20, 21, 20 ]
|
||||||
|
print(round(correlation_ratio(categories, values), 3)) # ~0.997
|
||||||
|
|
||||||
|
# Categoria sin relacion con los valores -> eta bajo (cercano a 0)
|
||||||
|
import random
|
||||||
|
random.seed(0)
|
||||||
|
cats = [random.choice(["x", "y", "z"]) for _ in range(300)]
|
||||||
|
vals = [random.gauss(0, 1) for _ in range(300)]
|
||||||
|
print(round(correlation_ratio(cats, vals), 3)) # ~0.0 - 0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieras saber **si una variable categorica explica una numerica**: pais → salario,
|
||||||
|
ciudad → precio de vivienda, segmento de cliente → ticket medio. Es la celda num↔cat de una
|
||||||
|
matriz de asociacion mixta para EDA — combinala con Pearson/Spearman (num↔num) y Cramer's V
|
||||||
|
(cat↔cat). Un η alto indica que conocer el grupo reduce mucho la incertidumbre sobre el valor.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
Funcion pura, sin gotchas de efectos. Notas de comportamiento:
|
||||||
|
- η NO es simetrica: mide cat→num, no num→cat. No la uses al reves.
|
||||||
|
- η no distingue direccion ni linealidad: solo cuanta varianza separan los grupos.
|
||||||
|
- Pocos datos por grupo inflan η al alza (sobreajuste a medias ruidosas); con grupos de
|
||||||
|
tamaño 1 cada grupo "explica" su punto. Interpretar con cautela en muestras pequeñas.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Correlation ratio eta (η): asociacion entre una variable categorica y una numerica."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def correlation_ratio(categories: list, values: list) -> float:
|
||||||
|
"""Correlation ratio eta (η) entre una variable categorica y una numerica.
|
||||||
|
|
||||||
|
Mide cuanto de la varianza de la variable numerica (`values`) queda
|
||||||
|
explicada por la pertenencia a cada grupo de la variable categorica
|
||||||
|
(`categories`). Es la metrica num↔cat de una matriz de asociacion mixta
|
||||||
|
(analoga a Cramer's V para cat↔cat o Pearson para num↔num).
|
||||||
|
|
||||||
|
Definicion: η² = varianza entre grupos / varianza total, donde
|
||||||
|
|
||||||
|
ss_between = Σ_g n_g · (mean_g − mean_global)²
|
||||||
|
ss_total = Σ_i (value_i − mean_global)²
|
||||||
|
η² = ss_between / ss_total
|
||||||
|
η = sqrt(max(0, η²))
|
||||||
|
|
||||||
|
Descarta los pares en los que la categoria sea None o el valor sea None,
|
||||||
|
NaN o no numerico. Si tras la limpieza quedan menos de 2 grupos distintos,
|
||||||
|
o la varianza total es cero, devuelve 0.0. El resultado se clampa a [0, 1].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
categories: lista de etiquetas categoricas (cualquier hashable). None
|
||||||
|
descarta el par.
|
||||||
|
values: lista de valores numericos pareada con categories. None, NaN o
|
||||||
|
no numerico descarta el par.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
eta (η) en rango [0, 1] como float. Nunca None ni excepcion: ante datos
|
||||||
|
insuficientes o degenerados devuelve 0.0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _is_num(v) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(v, (int, float))
|
||||||
|
and not isinstance(v, bool)
|
||||||
|
and not (isinstance(v, float) and math.isnan(v))
|
||||||
|
)
|
||||||
|
|
||||||
|
groups: dict = {}
|
||||||
|
all_values: list[float] = []
|
||||||
|
for cat, val in zip(categories, values):
|
||||||
|
if cat is None or not _is_num(val):
|
||||||
|
continue
|
||||||
|
fv = float(val)
|
||||||
|
groups.setdefault(cat, []).append(fv)
|
||||||
|
all_values.append(fv)
|
||||||
|
|
||||||
|
if len(groups) < 2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
arr = np.asarray(all_values, dtype=float)
|
||||||
|
mean_global = float(arr.mean())
|
||||||
|
|
||||||
|
ss_total = float(np.sum((arr - mean_global) ** 2))
|
||||||
|
if ss_total == 0.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
ss_between = 0.0
|
||||||
|
for vals in groups.values():
|
||||||
|
g = np.asarray(vals, dtype=float)
|
||||||
|
n_g = g.size
|
||||||
|
mean_g = float(g.mean())
|
||||||
|
ss_between += n_g * (mean_g - mean_global) ** 2
|
||||||
|
|
||||||
|
eta2 = ss_between / ss_total
|
||||||
|
eta = math.sqrt(max(0.0, eta2))
|
||||||
|
return float(min(1.0, max(0.0, eta)))
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests para correlation_ratio."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
from correlation_ratio import correlation_ratio
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_eta_when_groups_separated():
|
||||||
|
# Tres grupos con medias muy distintas y poca varianza intra-grupo -> eta alto.
|
||||||
|
categories = ["A", "A", "A", "B", "B", "B", "C", "C", "C"]
|
||||||
|
values = [1, 2, 1, 10, 11, 10, 20, 21, 20]
|
||||||
|
eta = correlation_ratio(categories, values)
|
||||||
|
assert eta > 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_eta_when_random():
|
||||||
|
# Categoria asignada al azar, valores gaussianos independientes -> eta bajo.
|
||||||
|
random.seed(0)
|
||||||
|
cats = [random.choice(["x", "y", "z"]) for _ in range(500)]
|
||||||
|
vals = [random.gauss(0.0, 1.0) for _ in range(500)]
|
||||||
|
eta = correlation_ratio(cats, vals)
|
||||||
|
assert eta < 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_group_returns_zero():
|
||||||
|
# Menos de 2 grupos distintos -> 0.0
|
||||||
|
assert correlation_ratio(["A", "A", "A"], [1.0, 2.0, 3.0]) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_total_variance_returns_zero():
|
||||||
|
# Todos los valores iguales -> varianza total 0 -> 0.0
|
||||||
|
assert correlation_ratio(["A", "B", "C"], [5.0, 5.0, 5.0]) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_none_and_nan_pairs():
|
||||||
|
# Los pares con categoria None o valor None/NaN/no-numerico se descartan
|
||||||
|
# sin afectar el resultado de los pares validos.
|
||||||
|
base_cats = ["A", "A", "B", "B"]
|
||||||
|
base_vals = [1.0, 1.0, 9.0, 9.0]
|
||||||
|
clean = correlation_ratio(base_cats, base_vals)
|
||||||
|
|
||||||
|
noisy_cats = ["A", "A", "B", "B", None, "C", "D"]
|
||||||
|
noisy_vals = [1.0, 1.0, 9.0, 9.0, 7.0, float("nan"), "no-num"]
|
||||||
|
noisy = correlation_ratio(noisy_cats, noisy_vals)
|
||||||
|
|
||||||
|
assert math.isclose(clean, noisy, rel_tol=1e-9, abs_tol=1e-9)
|
||||||
|
assert clean == 1.0 # grupos perfectamente separados y constantes -> eta = 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_in_unit_range():
|
||||||
|
random.seed(7)
|
||||||
|
cats = [random.choice(["p", "q"]) for _ in range(200)]
|
||||||
|
vals = [random.gauss(2.0, 3.0) for _ in range(200)]
|
||||||
|
eta = correlation_ratio(cats, vals)
|
||||||
|
assert 0.0 <= eta <= 1.0
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
id: cramers_v_py_datascience
|
||||||
|
name: cramers_v
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def cramers_v(a: list, b: list) -> float"
|
||||||
|
description: "Cramer's V del grupo eda: asociacion simetrica entre dos columnas categoricas pareadas (0=independientes, 1=asociacion perfecta), con correccion de sesgo Bergsma-Wicher. Descarta pares con None y devuelve 0.0 si hay <2 categorias o <2 pares. Funcion pura, sin pandas."
|
||||||
|
tags: [eda, correlation, association, categorical, statistics, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
example: |
|
||||||
|
from datascience import cramers_v
|
||||||
|
a = ["red", "green", "blue", "red", "green", "blue"]
|
||||||
|
b = ["hot", "cool", "cool", "hot", "cool", "cool"] # derivada de a
|
||||||
|
cramers_v(a, b)
|
||||||
|
# -> ~1.0 (asociacion perfecta)
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_perfect_association_is_near_one"
|
||||||
|
- "test_independent_columns_low_value"
|
||||||
|
- "test_single_category_returns_zero"
|
||||||
|
- "test_fewer_than_two_pairs_returns_zero"
|
||||||
|
- "test_none_pairs_are_discarded"
|
||||||
|
- "test_always_returns_float_never_none"
|
||||||
|
- "test_derived_column_high_association"
|
||||||
|
test_file_path: "python/functions/datascience/cramers_v_test.py"
|
||||||
|
file_path: "python/functions/datascience/cramers_v.py"
|
||||||
|
params:
|
||||||
|
- name: a
|
||||||
|
desc: >
|
||||||
|
Lista de valores categoricos hashables. Se empareja posicion a posicion
|
||||||
|
con `b`. Los pares donde `a[i]` sea None se descartan.
|
||||||
|
- name: b
|
||||||
|
desc: >
|
||||||
|
Lista de valores categoricos hashables pareada con `a` (idealmente misma
|
||||||
|
longitud). Los pares donde `b[i]` sea None se descartan. zip recorta a la
|
||||||
|
longitud minima de ambas listas.
|
||||||
|
output: >
|
||||||
|
float en [0, 1]. 0.0 = variables independientes, 1.0 = asociacion perfecta.
|
||||||
|
Devuelve 0.0 cuando hay menos de 2 pares validos o menos de 2 categorias
|
||||||
|
distintas en alguna de las dos variables. Nunca devuelve None ni lanza
|
||||||
|
excepcion.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import cramers_v
|
||||||
|
|
||||||
|
# Dos categoricas asociadas: b se deriva de a con un mapeo fijo.
|
||||||
|
a = ["red", "green", "blue", "red", "green", "blue", "red", "green", "blue"]
|
||||||
|
mapping = {"red": "hot", "green": "cool", "blue": "cool"}
|
||||||
|
b = [mapping[x] for x in a]
|
||||||
|
|
||||||
|
cramers_v(a, b)
|
||||||
|
# -> ~1.0 (saber el color predice perfectamente la temperatura)
|
||||||
|
|
||||||
|
# Dos categoricas independientes (aleatorias) -> V cercana a 0.
|
||||||
|
import random
|
||||||
|
rng = random.Random(42)
|
||||||
|
cats = ["a", "b", "c", "d"]
|
||||||
|
x = [rng.choice(cats) for _ in range(2000)]
|
||||||
|
y = [rng.choice(cats) for _ in range(2000)]
|
||||||
|
cramers_v(x, y)
|
||||||
|
# -> < 0.5 (no hay asociacion)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando perfiles o exploras un dataset y necesites medir la **asociacion entre
|
||||||
|
dos columnas categoricas** (no numericas): construir un heatmap de correlacion
|
||||||
|
categorica, detectar columnas redundantes/derivadas una de otra, o decidir que
|
||||||
|
features categoricas aportan informacion antes de modelar. Es el equivalente
|
||||||
|
categorico de un coeficiente de correlacion: simetrica (`cramers_v(a, b) ==
|
||||||
|
cramers_v(b, a)`) y normalizada a [0, 1].
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura, sin I/O, sin pandas y sin mutar los inputs. Construye la tabla de
|
||||||
|
contingencia con `collections.Counter` sobre los pares `(a_i, b_i)` y calcula
|
||||||
|
chi-cuadrado a mano (`sum((obs-exp)^2/exp)`), por lo que solo depende de la
|
||||||
|
stdlib.
|
||||||
|
|
||||||
|
Aplica la **correccion de sesgo de Bergsma-Wicher**, que reduce el inflado de V
|
||||||
|
en tablas pequenas: `phi2corr = max(0, phi2 - (r-1)(k-1)/(n-1))`, con `r`/`k`
|
||||||
|
filas/columnas corregidas y `n` el numero de pares validos. El resultado se
|
||||||
|
clampa a [0, 1] por seguridad numerica.
|
||||||
|
|
||||||
|
Casos borde resueltos sin excepcion: listas vacias, un solo par, columna con una
|
||||||
|
sola categoria, o None en cualquiera de los dos lados (el par se descarta) ->
|
||||||
|
todos devuelven `0.0` o una V bien definida sobre los pares que queden.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Cramer's V: asociacion simetrica entre dos columnas categoricas pareadas.
|
||||||
|
|
||||||
|
Funcion pura del grupo eda. Mide la fuerza de asociacion entre dos variables
|
||||||
|
categoricas (0 = independientes, 1 = asociacion perfecta) usando la estadistica
|
||||||
|
chi-cuadrado de la tabla de contingencia, con la correccion de sesgo de
|
||||||
|
Bergsma-Wicher para tablas pequenas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def cramers_v(a: list, b: list) -> float:
|
||||||
|
"""Calcula Cramer's V (con correccion de sesgo) entre dos categoricas.
|
||||||
|
|
||||||
|
Empareja `a` y `b` posicion a posicion, descarta los pares donde cualquiera
|
||||||
|
de los dos sea None, construye la tabla de contingencia y devuelve la V de
|
||||||
|
Cramer corregida (Bergsma-Wicher), clampada a [0, 1].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a: lista de valores categoricos (hashables; None se descarta).
|
||||||
|
b: lista de valores categoricos pareada con `a` (mismo criterio).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float en [0, 1]: 0.0 si hay menos de 2 pares validos o menos de 2
|
||||||
|
categorias distintas en alguna de las dos variables; en otro caso la V
|
||||||
|
de Cramer corregida. Nunca devuelve None ni lanza excepcion.
|
||||||
|
"""
|
||||||
|
# Empareja y descarta pares con None en cualquiera de los dos lados.
|
||||||
|
pairs = [
|
||||||
|
(x, y)
|
||||||
|
for x, y in zip(a, b)
|
||||||
|
if x is not None and y is not None
|
||||||
|
]
|
||||||
|
n = len(pairs)
|
||||||
|
if n < 2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
rows = sorted({x for x, _ in pairs}, key=repr)
|
||||||
|
cols = sorted({y for _, y in pairs}, key=repr)
|
||||||
|
r = len(rows)
|
||||||
|
k = len(cols)
|
||||||
|
if r < 2 or k < 2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
row_idx = {v: i for i, v in enumerate(rows)}
|
||||||
|
col_idx = {v: j for j, v in enumerate(cols)}
|
||||||
|
|
||||||
|
cell = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
|
||||||
|
row_tot = [0.0] * r
|
||||||
|
col_tot = [0.0] * k
|
||||||
|
for (i, j), c in cell.items():
|
||||||
|
row_tot[i] += c
|
||||||
|
col_tot[j] += c
|
||||||
|
|
||||||
|
# chi2 = sum((obs - exp)^2 / exp) sobre toda la tabla.
|
||||||
|
chi2 = 0.0
|
||||||
|
for i in range(r):
|
||||||
|
for j in range(k):
|
||||||
|
obs = cell.get((i, j), 0)
|
||||||
|
exp = row_tot[i] * col_tot[j] / n
|
||||||
|
if exp > 0.0:
|
||||||
|
diff = obs - exp
|
||||||
|
chi2 += diff * diff / exp
|
||||||
|
|
||||||
|
phi2 = chi2 / n
|
||||||
|
# Correccion de sesgo Bergsma-Wicher.
|
||||||
|
phi2corr = max(0.0, phi2 - (r - 1) * (k - 1) / (n - 1))
|
||||||
|
rcorr = r - (r - 1) ** 2 / (n - 1)
|
||||||
|
kcorr = k - (k - 1) ** 2 / (n - 1)
|
||||||
|
|
||||||
|
denom = max(1e-12, min(kcorr - 1.0, rcorr - 1.0))
|
||||||
|
v = (phi2corr / denom) ** 0.5
|
||||||
|
# Clampa a [0, 1] por seguridad numerica.
|
||||||
|
return max(0.0, min(1.0, v))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests para cramers_v."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from cramers_v import cramers_v
|
||||||
|
|
||||||
|
|
||||||
|
def test_perfect_association_is_near_one():
|
||||||
|
a = ["x", "y", "z", "x", "y", "z", "x", "y", "z", "x", "y", "z"]
|
||||||
|
b = list(a) # b == a -> asociacion perfecta
|
||||||
|
v = cramers_v(a, b)
|
||||||
|
assert v > 0.95
|
||||||
|
assert v <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_columns_low_value():
|
||||||
|
rng = random.Random(42)
|
||||||
|
cats = ["a", "b", "c", "d"]
|
||||||
|
a = [rng.choice(cats) for _ in range(2000)]
|
||||||
|
b = [rng.choice(cats) for _ in range(2000)]
|
||||||
|
v = cramers_v(a, b)
|
||||||
|
assert 0.0 <= v < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_category_returns_zero():
|
||||||
|
a = ["only"] * 10 # <2 categorias en a
|
||||||
|
b = ["x", "y", "x", "y", "x", "y", "x", "y", "x", "y"]
|
||||||
|
assert cramers_v(a, b) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fewer_than_two_pairs_returns_zero():
|
||||||
|
assert cramers_v([], []) == 0.0
|
||||||
|
assert cramers_v(["a"], ["b"]) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_pairs_are_discarded():
|
||||||
|
a = ["x", None, "y", "x", None, "y", "x", "y"]
|
||||||
|
b = ["x", "z", "y", "x", "z", "y", None, "y"]
|
||||||
|
v = cramers_v(a, b)
|
||||||
|
assert isinstance(v, float)
|
||||||
|
assert 0.0 <= v <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_returns_float_never_none():
|
||||||
|
assert isinstance(cramers_v(["a", "b"], ["a", "b"]), float)
|
||||||
|
assert isinstance(cramers_v([None], [None]), float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_column_high_association():
|
||||||
|
rng = random.Random(7)
|
||||||
|
a = [rng.choice(["red", "green", "blue"]) for _ in range(600)]
|
||||||
|
# b derivada de a (mapeo deterministico) -> alta asociacion.
|
||||||
|
mapping = {"red": "hot", "green": "cool", "blue": "cool"}
|
||||||
|
b = [mapping[x] for x in a]
|
||||||
|
v = cramers_v(a, b)
|
||||||
|
assert v > 0.5
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: describe_numeric
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def describe_numeric(values: list, bins: int = 20) -> dict"
|
||||||
|
description: "Calcula el bloque estadistico fino numeric de un ColumnProfile del grupo eda sobre una MUESTRA de una columna numerica. Descarta None/NaN/no-numericos y devuelve min/max/mean/median/mode/std/variance/cv, percentiles, iqr, skew, kurtosis, outliers, zero_pct, negative_pct, distribution_type e histogram. Reusa detect_distribution_type, detect_outliers y histogram del registry."
|
||||||
|
tags: [eda, statistics, profiling, distribution, histogram, datascience]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "Lista de valores crudos de una columna (muestra). Puede contener None, NaN, infinitos y strings no numericos: se descartan antes de calcular. bool se trata como no numerico."
|
||||||
|
- name: bins
|
||||||
|
desc: "Numero de buckets equiespaciados del histograma. Default 20."
|
||||||
|
output: "Dict con las claves exactas del contrato numeric_sub del grupo eda: {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}. cv = std/mean (None si mean==0). iqr = p75-p25. mode = valor mas frecuente (menor en empate). histogram = lista de {lo, hi, count}. Si tras limpiar quedan 0 valores: todas las claves None y histogram=[]."
|
||||||
|
uses_functions:
|
||||||
|
- detect_distribution_type_py_datascience
|
||||||
|
- detect_outliers_py_datascience
|
||||||
|
- histogram_py_datascience
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [numpy, math]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_lista_con_outlier_y_none", "test_lista_vacia_todo_none", "test_cv_none_cuando_mean_cero", "test_iqr_y_percentiles"]
|
||||||
|
test_file_path: "python/functions/datascience/describe_numeric_test.py"
|
||||||
|
file_path: "python/functions/datascience/describe_numeric.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience.describe_numeric import describe_numeric
|
||||||
|
|
||||||
|
# Muestra de una columna numerica (con un None y un outlier claro):
|
||||||
|
prof = describe_numeric([1, 2, 2, 3, 100, None, 4])
|
||||||
|
print(prof["min"], prof["max"], prof["median"], prof["mode"])
|
||||||
|
# 1.0 100.0 2.5 2.0
|
||||||
|
print(prof["distribution_type"]) # etiqueta de forma (too_few_samples si n < 30)
|
||||||
|
print(prof["histogram"][:2]) # [{'lo': 1.0, 'hi': 5.95, 'count': ...}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Usala cuando construyas el bloque `numeric` de un `ColumnProfile` del grupo `eda` a partir de una **muestra** de una columna numerica (no la tabla entera).
|
||||||
|
- Cuando necesites de un solo paso percentiles finos (p1..p99), iqr, dispersion (std, variance, cv), forma (skew, kurtosis, distribution_type), outliers por z-score e histograma con bordes.
|
||||||
|
- Antes de decidir transformaciones (log, winsorize, escalado) sobre una columna: el `distribution_type`, `n_outliers` y `skew` orientan la decision.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion pura, sin I/O. Descarta silenciosamente None, NaN, infinitos, strings y bool (True/False no cuentan como datos numericos).
|
||||||
|
- `distribution_type`, `skew` y `kurtosis` vienen de `detect_distribution_type`, que devuelve `too_few_samples` (y skew/kurtosis None) cuando la muestra limpia tiene **menos de 30 valores**.
|
||||||
|
- Los outliers usan z-score con `std` poblacional y threshold 3.0 (de `detect_outliers`): en muestras muy pequeñas un unico valor extremo puede inflar la `std` y no marcarse como outlier (efecto masking). Para deteccion fiable, pasa una muestra suficientemente grande.
|
||||||
|
- `cv` es `None` cuando `mean == 0` (division indefinida).
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""describe_numeric — Fine-grained numeric statistics block for an EDA ColumnProfile.
|
||||||
|
|
||||||
|
Pure function: no I/O, deterministic. Computes the `numeric` sub-block of a
|
||||||
|
ColumnProfile (group `eda`) over a SAMPLE of a numeric column. Non-numeric and
|
||||||
|
missing values (None, NaN, non-numeric strings) are discarded before computing.
|
||||||
|
|
||||||
|
Reuses registry functions instead of reimplementing their logic:
|
||||||
|
- detect_distribution_type (skew, kurtosis, distribution label)
|
||||||
|
- detect_outliers (z-score outlier flags)
|
||||||
|
- histogram (counts per equal-width bucket)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from datascience import detect_outliers, histogram # noqa: E402
|
||||||
|
from detect_distribution_type import detect_distribution_type # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# Keys of the numeric sub-block contract for the eda group.
|
||||||
|
_NULL_KEYS = (
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list:
|
||||||
|
"""Keep only finite numeric values, discarding None/NaN/non-numeric/bool."""
|
||||||
|
out: list = []
|
||||||
|
for v in values:
|
||||||
|
# bool is a subclass of int; treat True/False as non-numeric data.
|
||||||
|
if isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
f = float(v)
|
||||||
|
if not math.isnan(f) and not math.isinf(f):
|
||||||
|
out.append(f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _mode(values: list) -> float:
|
||||||
|
"""Most frequent value; on a tie, the smallest value wins."""
|
||||||
|
counts: dict = {}
|
||||||
|
for v in values:
|
||||||
|
counts[v] = counts.get(v, 0) + 1
|
||||||
|
best_count = max(counts.values())
|
||||||
|
return min(v for v, c in counts.items() if c == best_count)
|
||||||
|
|
||||||
|
|
||||||
|
def describe_numeric(values: list, bins: int = 20) -> dict:
|
||||||
|
"""Compute the fine-grained numeric statistics block for an EDA ColumnProfile.
|
||||||
|
|
||||||
|
Designed to run on a SAMPLE of a single column, not the whole table.
|
||||||
|
None, NaN, infinities and non-numeric values are discarded first. If no
|
||||||
|
numeric value survives the cleaning, every key is None and histogram is [].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: List of raw column values (may contain None/NaN/strings).
|
||||||
|
bins: Number of equal-width buckets for the histogram (default 20).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with the exact keys of the eda `numeric_sub` contract:
|
||||||
|
{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}.
|
||||||
|
"""
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
result = {k: None for k in _NULL_KEYS}
|
||||||
|
result["histogram"] = []
|
||||||
|
return result
|
||||||
|
|
||||||
|
arr = np.array(clean, dtype=float)
|
||||||
|
|
||||||
|
minimum = float(np.min(arr))
|
||||||
|
maximum = float(np.max(arr))
|
||||||
|
mean = float(np.mean(arr))
|
||||||
|
std = float(np.std(arr))
|
||||||
|
variance = float(np.var(arr))
|
||||||
|
cv = (std / mean) if mean != 0 else None
|
||||||
|
|
||||||
|
p1 = float(np.percentile(arr, 1))
|
||||||
|
p5 = float(np.percentile(arr, 5))
|
||||||
|
p25 = float(np.percentile(arr, 25))
|
||||||
|
p50 = float(np.percentile(arr, 50))
|
||||||
|
p75 = float(np.percentile(arr, 75))
|
||||||
|
p95 = float(np.percentile(arr, 95))
|
||||||
|
p99 = float(np.percentile(arr, 99))
|
||||||
|
median = p50
|
||||||
|
iqr = p75 - p25
|
||||||
|
|
||||||
|
mode = _mode(clean)
|
||||||
|
|
||||||
|
# Distribution shape: reuse detect_distribution_type for skew/kurtosis/type.
|
||||||
|
dist = detect_distribution_type(clean)
|
||||||
|
distribution_type = dist.get("type")
|
||||||
|
dist_stats = dist.get("stats", {})
|
||||||
|
skew = dist_stats.get("skew")
|
||||||
|
kurtosis = dist_stats.get("kurtosis")
|
||||||
|
|
||||||
|
# Outliers: reuse detect_outliers (z-score, threshold 3.0). Count the True.
|
||||||
|
outlier_flags = detect_outliers(clean, 3.0)
|
||||||
|
n_outliers = sum(1 for flag in outlier_flags if flag)
|
||||||
|
outlier_pct = 100.0 * n_outliers / n
|
||||||
|
|
||||||
|
zero_pct = 100.0 * sum(1 for v in clean if v == 0) / n
|
||||||
|
negative_pct = 100.0 * sum(1 for v in clean if v < 0) / n
|
||||||
|
|
||||||
|
# Histogram: reuse histogram for the per-bucket counts, then attach the
|
||||||
|
# equal-width [lo, hi) edges so the eda contract gets {lo, hi, count}.
|
||||||
|
counts = histogram(clean, bins)
|
||||||
|
hist: list = []
|
||||||
|
if counts:
|
||||||
|
if maximum == minimum:
|
||||||
|
# Degenerate range: histogram() places everything in bucket 0.
|
||||||
|
for i, count in enumerate(counts):
|
||||||
|
hist.append({"lo": minimum, "hi": maximum, "count": int(count)})
|
||||||
|
else:
|
||||||
|
width = (maximum - minimum) / bins
|
||||||
|
for i, count in enumerate(counts):
|
||||||
|
lo = minimum + i * width
|
||||||
|
hi = minimum + (i + 1) * width
|
||||||
|
hist.append({"lo": float(lo), "hi": float(hi), "count": int(count)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min": minimum,
|
||||||
|
"max": maximum,
|
||||||
|
"mean": mean,
|
||||||
|
"median": median,
|
||||||
|
"mode": mode,
|
||||||
|
"std": std,
|
||||||
|
"variance": variance,
|
||||||
|
"cv": cv,
|
||||||
|
"p1": p1,
|
||||||
|
"p5": p5,
|
||||||
|
"p25": p25,
|
||||||
|
"p50": p50,
|
||||||
|
"p75": p75,
|
||||||
|
"p95": p95,
|
||||||
|
"p99": p99,
|
||||||
|
"iqr": iqr,
|
||||||
|
"skew": skew,
|
||||||
|
"kurtosis": kurtosis,
|
||||||
|
"n_outliers": n_outliers,
|
||||||
|
"outlier_pct": outlier_pct,
|
||||||
|
"zero_pct": zero_pct,
|
||||||
|
"negative_pct": negative_pct,
|
||||||
|
"distribution_type": distribution_type,
|
||||||
|
"histogram": hist,
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests para describe_numeric."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from describe_numeric import describe_numeric
|
||||||
|
|
||||||
|
# Keys that every result dict must always contain (the eda numeric_sub contract).
|
||||||
|
_EXPECTED_KEYS = {
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_con_outlier_y_none():
|
||||||
|
"""Lista con outlier claro y None descartado."""
|
||||||
|
# Tight cluster around 2-4 plus a None to drop and a clear extreme outlier.
|
||||||
|
# A wide cluster (n=40) keeps std small so the extreme value's z-score
|
||||||
|
# exceeds the 3.0 threshold used by detect_outliers.
|
||||||
|
cluster = [1, 2, 2, 3, 4] * 8 # 40 numeric values, mode == 2
|
||||||
|
values = cluster + [None, 1000]
|
||||||
|
result = describe_numeric(values)
|
||||||
|
|
||||||
|
# Contract: all keys present.
|
||||||
|
assert set(result.keys()) == _EXPECTED_KEYS
|
||||||
|
|
||||||
|
# Non-numeric / missing dropped: 41 numeric values remain.
|
||||||
|
assert result["min"] == 1.0
|
||||||
|
assert result["max"] == 1000.0
|
||||||
|
|
||||||
|
# mean/median reasonable: median sits in the cluster, mean pulled up by 1000.
|
||||||
|
assert result["median"] < result["mean"]
|
||||||
|
assert 0.0 < result["median"] <= 5.0
|
||||||
|
assert result["mean"] > result["median"]
|
||||||
|
|
||||||
|
# mode = most frequent (2 appears twice per block).
|
||||||
|
assert result["mode"] == 2.0
|
||||||
|
|
||||||
|
# At least one z-score outlier detected (the 1000).
|
||||||
|
assert result["n_outliers"] >= 1
|
||||||
|
assert result["outlier_pct"] > 0.0
|
||||||
|
|
||||||
|
# Histogram non-empty and counts cover every numeric value.
|
||||||
|
assert len(result["histogram"]) > 0
|
||||||
|
total = sum(bucket["count"] for bucket in result["histogram"])
|
||||||
|
assert total == 41
|
||||||
|
for bucket in result["histogram"]:
|
||||||
|
assert "lo" in bucket and "hi" in bucket and "count" in bucket
|
||||||
|
|
||||||
|
# No zeros, no negatives in this sample.
|
||||||
|
assert result["zero_pct"] == 0.0
|
||||||
|
assert result["negative_pct"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_vacia_todo_none():
|
||||||
|
"""Lista vacia (o sin numericos) devuelve todas las claves en None."""
|
||||||
|
result = describe_numeric([None, "abc", float("nan")])
|
||||||
|
|
||||||
|
assert set(result.keys()) == _EXPECTED_KEYS
|
||||||
|
for key in _EXPECTED_KEYS - {"histogram"}:
|
||||||
|
assert result[key] is None, f"{key} debe ser None"
|
||||||
|
assert result["histogram"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cv_none_cuando_mean_cero():
|
||||||
|
"""cv es None cuando la media es 0."""
|
||||||
|
# Symmetric around zero so mean == 0.
|
||||||
|
result = describe_numeric([-2, -1, 0, 1, 2])
|
||||||
|
assert result["mean"] == 0.0
|
||||||
|
assert result["cv"] is None
|
||||||
|
assert result["zero_pct"] == 20.0
|
||||||
|
assert result["negative_pct"] == 40.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_iqr_y_percentiles():
|
||||||
|
"""iqr = p75 - p25 y percentiles coherentes."""
|
||||||
|
result = describe_numeric(list(range(1, 101))) # 1..100
|
||||||
|
assert result["iqr"] == result["p75"] - result["p25"]
|
||||||
|
assert result["p1"] <= result["p25"] <= result["p50"] <= result["p75"] <= result["p99"]
|
||||||
|
assert result["min"] == 1.0
|
||||||
|
assert result["max"] == 100.0
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: eda_llm_insights
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def eda_llm_insights(profile: dict, model: str = \"claude-haiku-4-5-20251001\") -> dict"
|
||||||
|
description: "Capa LLM interpretativa del grupo eda. Toma un TableProfile YA CALCULADO (el dict de profile_table) y, con UNA sola llamada al LLM, genera el bloque 'llm': resumen de la tabla, significado de una fila, diccionario de datos, deteccion de PII (RGPD), sugerencias de limpieza y analisis sugeridos. Clave de coste/privacidad: NO envia filas crudas al LLM, solo el perfil AGREGADO (nombres, tipos, % nulos, distinct, top valores agregados de categoricas, stats de numericas, pares de correlacion fuertes). Reusa ask_llm del grupo claude-direct (API directa con token OAuth de Claude). Impura, dict-no-throw."
|
||||||
|
tags: [eda, llm, claude-direct, datascience, profiling, pii, data-dictionary]
|
||||||
|
params:
|
||||||
|
- name: profile
|
||||||
|
desc: "TableProfile ya calculado (el dict que devuelve profile_table()['profile']). Se espera {table, n_rows, columns:[{name, inferred_type, semantic_type, null_pct, distinct_count, numeric:{min,max,mean,p50,...}, categorical:{top:[{value,count,pct}], mode,...}}], correlations:{strong:[{a,b,method,value}]} | None}. Solo se le envia al LLM un resumen agregado; nunca filas crudas."
|
||||||
|
- name: model
|
||||||
|
desc: "id del modelo Anthropic a usar. Default 'claude-haiku-4-5-20251001' (haiku, coste bajo). Para mayor calidad interpretativa, pasar p.ej. 'claude-opus-4-8'."
|
||||||
|
output: "dict dict-no-throw. En exito: {status:'ok', llm:{summary:str, row_meaning:str, dictionary:[{column,description,business_meaning,unit}], pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}}. Las claves que el LLM omita se rellenan con defaults vacios. En error (sin lanzar): {status:'error', error:str}."
|
||||||
|
uses_functions: [ask_llm_py_core]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_build_prompt_includes_table_and_columns", "test_build_prompt_includes_numeric_stats_and_top_values", "test_build_prompt_handles_empty_profile", "test_parse_llm_json_plain", "test_parse_llm_json_with_fences", "test_parse_llm_json_with_surrounding_text", "test_parse_llm_json_nested_braces_in_strings", "test_parse_llm_json_raises_without_object", "test_eda_llm_insights_ok_with_monkeypatched_llm", "test_eda_llm_insights_fills_missing_keys", "test_eda_llm_insights_error_on_empty_profile", "test_eda_llm_insights_error_on_empty_llm_response", "test_eda_llm_insights_error_on_unparseable_llm_response"]
|
||||||
|
test_file_path: "python/functions/datascience/eda_llm_insights_test.py"
|
||||||
|
file_path: "python/functions/datascience/eda_llm_insights.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
|
||||||
|
from pipelines.profile_table import profile_table
|
||||||
|
from datascience import eda_llm_insights
|
||||||
|
|
||||||
|
# 1) Perfila la tabla (calculo agregado, sin LLM).
|
||||||
|
r = profile_table("data/ventas.duckdb", "ventas", write_report=False)
|
||||||
|
profile = r["profile"]
|
||||||
|
|
||||||
|
# 2) Interpreta el perfil con UNA llamada al LLM (solo el perfil agregado viaja).
|
||||||
|
out = eda_llm_insights(profile) # haiku por defecto
|
||||||
|
# out = eda_llm_insights(profile, model="claude-opus-4-8") # mas calidad
|
||||||
|
|
||||||
|
if out["status"] == "ok":
|
||||||
|
llm = out["llm"]
|
||||||
|
print(llm["summary"]) # que es la tabla, 2-3 frases
|
||||||
|
print(llm["row_meaning"]) # que representa una fila
|
||||||
|
for d in llm["dictionary"]: # diccionario de datos por columna
|
||||||
|
print(d["column"], "->", d["description"], f"({d['unit']})")
|
||||||
|
for p in llm["pii"]: # datos personales/sensibles RGPD
|
||||||
|
print("PII:", p["column"], p["kind"], p["severity"])
|
||||||
|
print(llm["cleaning"]) # sugerencias de limpieza
|
||||||
|
print(llm["analyses"]) # analisis sugeridos + hipotesis
|
||||||
|
else:
|
||||||
|
print("error:", out["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites entender SEMANTICAMENTE una tabla ya perfilada: generar un
|
||||||
|
diccionario de datos legible, detectar PII/datos sensibles RGPD, recibir
|
||||||
|
sugerencias de limpieza y una lista de analisis/hipotesis a explorar. Es el
|
||||||
|
paso interpretativo que sigue a `profile_table`: este calcula las metricas, y
|
||||||
|
`eda_llm_insights` las traduce a lenguaje de negocio. El resultado encaja en la
|
||||||
|
clave `llm` del TableProfile (la que `render_eda_markdown` renderiza en la
|
||||||
|
seccion "Analisis LLM").
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura: hace 1 llamada de red al LLM.** No es determinista ni gratis.
|
||||||
|
- **Requiere token OAuth de Claude** en `~/.claude/.credentials.json` (via
|
||||||
|
`ask_llm` / grupo `claude-direct`). Sin token, devuelve `{status:'error'}`.
|
||||||
|
- **NO envia filas crudas al LLM**, solo el perfil AGREGADO (nombres, tipos,
|
||||||
|
% nulos, distinct, top valores ya agregados, stats numericas, correlaciones
|
||||||
|
fuertes). Privacidad y coste minimos por diseno — pero requiere que el
|
||||||
|
`profile` venga ya calculado por `profile_table`.
|
||||||
|
- **Modelo `haiku` por defecto** para coste bajo; sube a `claude-opus-4-8` si
|
||||||
|
necesitas interpretacion mas fina (mas caro y lento).
|
||||||
|
- El LLM puede omitir claves: las que falten se rellenan con defaults vacios
|
||||||
|
(`""` o `[]`), nunca lanza por shape incompleto.
|
||||||
|
- El parseo tolera `\`\`\`json` fences y texto alrededor del objeto, pero si el
|
||||||
|
modelo no devuelve ningun objeto JSON, retorna `{status:'error'}`.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""eda_llm_insights — capa LLM interpretativa del grupo de capacidad `eda`.
|
||||||
|
|
||||||
|
Toma un TableProfile YA CALCULADO (el dict que produce `profile_table`) y, con
|
||||||
|
UNA sola llamada al LLM, genera el bloque interpretativo "llm": resumen de la
|
||||||
|
tabla, significado de una fila, diccionario de datos, deteccion de PII (RGPD),
|
||||||
|
sugerencias de limpieza y analisis sugeridos.
|
||||||
|
|
||||||
|
Clave de coste y privacidad: NO se envian filas crudas al LLM. Solo viaja el
|
||||||
|
perfil AGREGADO (nombres, tipos, % nulos, distinct, top valores ya agregados de
|
||||||
|
categoricas, stats de numericas y pares de correlacion fuertes). Asi el coste es
|
||||||
|
minimo y ningun dato fila-a-fila sale del proceso.
|
||||||
|
|
||||||
|
Reusa `ask_llm` del registry (grupo claude-direct, API directa con el token
|
||||||
|
OAuth de Claude en ~/.claude/.credentials.json, arranque 0). Impura: una llamada
|
||||||
|
de red. Estilo dict-no-throw del grupo: nunca lanza; ante cualquier fallo (red,
|
||||||
|
LLM, parseo) devuelve {status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from core import ask_llm
|
||||||
|
|
||||||
|
# Claves que el LLM debe devolver. Las que falten se rellenan con estos defaults.
|
||||||
|
_EXPECTED_KEYS = {
|
||||||
|
"summary": "",
|
||||||
|
"row_meaning": "",
|
||||||
|
"dictionary": [],
|
||||||
|
"pii": [],
|
||||||
|
"cleaning": [],
|
||||||
|
"analyses": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
_SYSTEM = (
|
||||||
|
"Eres un analista de datos senior. Recibes el PERFIL AGREGADO de una tabla "
|
||||||
|
"(nunca filas crudas) y lo interpretas de forma util para un humano de "
|
||||||
|
"negocio. Detectas datos personales/sensibles segun el RGPD. Respondes "
|
||||||
|
"SIEMPRE y SOLO con un unico objeto JSON valido, sin texto alrededor, sin "
|
||||||
|
"fences de markdown, con EXACTAMENTE estas claves: "
|
||||||
|
'"summary" (str: que es la tabla, 2-3 frases), '
|
||||||
|
'"row_meaning" (str: que representa una fila y su granularidad), '
|
||||||
|
'"dictionary" (lista de objetos {"column","description","business_meaning","unit"}), '
|
||||||
|
'"pii" (lista de objetos {"column","kind","severity"} con severity en '
|
||||||
|
'low|medium|high, solo columnas con datos personales/sensibles), '
|
||||||
|
'"cleaning" (lista de strings con sugerencias de limpieza/transformacion), '
|
||||||
|
'"analyses" (lista de strings con preguntas/analisis sugeridos e hipotesis '
|
||||||
|
"de relaciones). Responde en el mismo idioma que los nombres de columna."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_num(value) -> str:
|
||||||
|
"""Formatea un numero de forma compacta para el prompt (None -> '?')."""
|
||||||
|
if value is None:
|
||||||
|
return "?"
|
||||||
|
if isinstance(value, float):
|
||||||
|
if value == int(value):
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:.4g}"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(profile: dict) -> str:
|
||||||
|
"""Construye un resumen textual compacto del perfil para el LLM.
|
||||||
|
|
||||||
|
Funcion interna PURA: no toca red ni disco, es testeable sin credenciales.
|
||||||
|
Incluye, por columna: name, inferred_type, semantic_type, null_pct, distinct;
|
||||||
|
top-3 valores si categorical; min/max/mean/median si numeric. Cierra con la
|
||||||
|
lista de correlations["strong"] si existe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: TableProfile (dict de profile_table["profile"]).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
El texto del prompt.
|
||||||
|
"""
|
||||||
|
profile = profile or {}
|
||||||
|
table = profile.get("table", "(desconocida)")
|
||||||
|
n_rows = profile.get("n_rows")
|
||||||
|
cols = profile.get("columns") or []
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"Perfil agregado de una tabla. No hay filas crudas, solo metricas.",
|
||||||
|
f"Tabla: {table}",
|
||||||
|
f"Filas (n_rows): {_fmt_num(n_rows)}",
|
||||||
|
f"Columnas: {len(cols)}",
|
||||||
|
"",
|
||||||
|
"Columnas:",
|
||||||
|
]
|
||||||
|
|
||||||
|
for col in cols:
|
||||||
|
name = col.get("name", "?")
|
||||||
|
itype = col.get("inferred_type") or "?"
|
||||||
|
stype = col.get("semantic_type") or ""
|
||||||
|
null_pct = col.get("null_pct")
|
||||||
|
null_str = f"{null_pct * 100:.1f}%" if isinstance(null_pct, (int, float)) else "?"
|
||||||
|
distinct = col.get("distinct_count")
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f"- {name}",
|
||||||
|
f"tipo={itype}",
|
||||||
|
]
|
||||||
|
if stype:
|
||||||
|
parts.append(f"semantic={stype}")
|
||||||
|
parts.append(f"nulos={null_str}")
|
||||||
|
parts.append(f"distinct={_fmt_num(distinct)}")
|
||||||
|
|
||||||
|
if itype == "numeric" and isinstance(col.get("numeric"), dict):
|
||||||
|
num = col["numeric"]
|
||||||
|
parts.append(
|
||||||
|
"stats[min={} max={} mean={} median={}]".format(
|
||||||
|
_fmt_num(num.get("min")),
|
||||||
|
_fmt_num(num.get("max")),
|
||||||
|
_fmt_num(num.get("mean")),
|
||||||
|
_fmt_num(num.get("p50") if num.get("p50") is not None else num.get("median")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif isinstance(col.get("categorical"), dict):
|
||||||
|
top = col["categorical"].get("top") or []
|
||||||
|
top3 = ", ".join(
|
||||||
|
f"{t.get('value')!r}({_fmt_num(t.get('count'))})" for t in top[:3]
|
||||||
|
)
|
||||||
|
if top3:
|
||||||
|
parts.append(f"top3=[{top3}]")
|
||||||
|
|
||||||
|
lines.append(" | ".join(parts))
|
||||||
|
|
||||||
|
correlations = profile.get("correlations")
|
||||||
|
strong = (correlations or {}).get("strong") if isinstance(correlations, dict) else None
|
||||||
|
if strong:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Correlaciones/asociaciones fuertes:")
|
||||||
|
for pair in strong:
|
||||||
|
lines.append(
|
||||||
|
"- {} ~ {} ({}={})".format(
|
||||||
|
pair.get("a", "?"),
|
||||||
|
pair.get("b", "?"),
|
||||||
|
pair.get("method", "?"),
|
||||||
|
_fmt_num(pair.get("value")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Devuelve el objeto JSON descrito en las instrucciones del sistema."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_llm_json(text: str) -> dict:
|
||||||
|
"""Extrae el primer objeto JSON de la respuesta del LLM.
|
||||||
|
|
||||||
|
Funcion interna testeable sin red. Tolera fences ```json ... ``` y texto
|
||||||
|
alrededor del objeto. Localiza el primer '{' y hace matching de llaves
|
||||||
|
(respetando strings/escapes) hasta cerrar el objeto, luego json.loads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: respuesta cruda del LLM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
El dict parseado.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si no se encuentra un objeto JSON valido.
|
||||||
|
"""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
raise ValueError("empty LLM response")
|
||||||
|
|
||||||
|
s = text.strip()
|
||||||
|
# Quita fences de markdown si los hay.
|
||||||
|
if s.startswith("```"):
|
||||||
|
# Elimina la primera linea de fence (```json o ```) y un posible cierre.
|
||||||
|
first_nl = s.find("\n")
|
||||||
|
if first_nl != -1:
|
||||||
|
s = s[first_nl + 1 :]
|
||||||
|
if s.rstrip().endswith("```"):
|
||||||
|
s = s.rstrip()[:-3]
|
||||||
|
s = s.strip()
|
||||||
|
|
||||||
|
start = s.find("{")
|
||||||
|
if start == -1:
|
||||||
|
raise ValueError("no JSON object found in LLM response")
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
in_str = False
|
||||||
|
escape = False
|
||||||
|
end = -1
|
||||||
|
for i in range(start, len(s)):
|
||||||
|
ch = s[i]
|
||||||
|
if in_str:
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escape = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = False
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
in_str = True
|
||||||
|
elif ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = i + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
if end == -1:
|
||||||
|
raise ValueError("unbalanced JSON object in LLM response")
|
||||||
|
|
||||||
|
return json.loads(s[start:end])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(parsed: dict) -> dict:
|
||||||
|
"""Asegura todas las claves esperadas, rellenando las que falten."""
|
||||||
|
out = {}
|
||||||
|
for key, default in _EXPECTED_KEYS.items():
|
||||||
|
val = parsed.get(key, None)
|
||||||
|
if val is None:
|
||||||
|
out[key] = [] if isinstance(default, list) else default
|
||||||
|
else:
|
||||||
|
out[key] = val
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def eda_llm_insights(
|
||||||
|
profile: dict, model: str = "claude-haiku-4-5-20251001"
|
||||||
|
) -> dict:
|
||||||
|
"""Interpreta semanticamente un TableProfile con UNA llamada al LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: TableProfile ya calculado (el dict que devuelve
|
||||||
|
profile_table()["profile"]). Solo se le envia al LLM el resumen
|
||||||
|
AGREGADO, nunca filas crudas.
|
||||||
|
model: id del modelo Anthropic. Default claude-haiku-4-5-20251001
|
||||||
|
(haiku, coste bajo).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', llm:{summary, row_meaning, dictionary,
|
||||||
|
pii, cleaning, analyses}}. En error (sin lanzar):
|
||||||
|
{status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not isinstance(profile, dict) or not profile:
|
||||||
|
return {"status": "error", "error": "profile vacio o no es dict"}
|
||||||
|
|
||||||
|
prompt = _build_prompt(profile)
|
||||||
|
text = ask_llm(prompt, model=model, system=_SYSTEM, echo=False)
|
||||||
|
if not text:
|
||||||
|
return {"status": "error", "error": "respuesta vacia del LLM"}
|
||||||
|
|
||||||
|
parsed = _parse_llm_json(text)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return {"status": "error", "error": "el LLM no devolvio un objeto JSON"}
|
||||||
|
|
||||||
|
return {"status": "ok", "llm": _normalize(parsed)}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Tests para eda_llm_insights.
|
||||||
|
|
||||||
|
NO acceden a red ni a credenciales: _build_prompt y _parse_llm_json son puras y
|
||||||
|
testeables aisladas; la unica via que llamaria al LLM (eda_llm_insights) se
|
||||||
|
prueba monkeypatcheando ask_llm con una respuesta simulada.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from datascience.eda_llm_insights import (
|
||||||
|
_build_prompt,
|
||||||
|
_parse_llm_json,
|
||||||
|
eda_llm_insights,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perfil de ejemplo con la forma que produce profile_table.
|
||||||
|
_PROFILE = {
|
||||||
|
"table": "ventas",
|
||||||
|
"n_rows": 1000,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "importe",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"semantic_type": "currency",
|
||||||
|
"null_pct": 0.0,
|
||||||
|
"distinct_count": 950,
|
||||||
|
"numeric": {"min": 1.0, "max": 999.0, "mean": 50.5, "p50": 42.0},
|
||||||
|
"categorical": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "categoria",
|
||||||
|
"inferred_type": "categorical",
|
||||||
|
"semantic_type": "",
|
||||||
|
"null_pct": 0.05,
|
||||||
|
"distinct_count": 3,
|
||||||
|
"numeric": None,
|
||||||
|
"categorical": {
|
||||||
|
"top": [
|
||||||
|
{"value": "neumaticos", "count": 600, "pct": 0.6},
|
||||||
|
{"value": "frenos", "count": 300, "pct": 0.3},
|
||||||
|
{"value": "aceite", "count": 100, "pct": 0.1},
|
||||||
|
],
|
||||||
|
"mode": "neumaticos",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"correlations": {
|
||||||
|
"strong": [
|
||||||
|
{"a": "importe", "b": "categoria", "method": "correlation_ratio", "value": 0.72},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prompt_includes_table_and_columns():
|
||||||
|
prompt = _build_prompt(_PROFILE)
|
||||||
|
assert isinstance(prompt, str)
|
||||||
|
assert "ventas" in prompt
|
||||||
|
assert "importe" in prompt
|
||||||
|
assert "categoria" in prompt
|
||||||
|
# n_rows presente.
|
||||||
|
assert "1000" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prompt_includes_numeric_stats_and_top_values():
|
||||||
|
prompt = _build_prompt(_PROFILE)
|
||||||
|
# Stats numericas de importe.
|
||||||
|
assert "stats[" in prompt
|
||||||
|
assert "mean=50.5" in prompt
|
||||||
|
# Top valores de categorica.
|
||||||
|
assert "neumaticos" in prompt
|
||||||
|
# Correlaciones fuertes.
|
||||||
|
assert "correlation_ratio" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prompt_handles_empty_profile():
|
||||||
|
prompt = _build_prompt({})
|
||||||
|
assert isinstance(prompt, str)
|
||||||
|
assert "Columnas: 0" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_llm_json_plain():
|
||||||
|
payload = {"summary": "una tabla", "dictionary": [], "pii": []}
|
||||||
|
text = json.dumps(payload)
|
||||||
|
parsed = _parse_llm_json(text)
|
||||||
|
assert parsed["summary"] == "una tabla"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_llm_json_with_fences():
|
||||||
|
payload = {"summary": "con fences", "analyses": ["a1"]}
|
||||||
|
text = "```json\n" + json.dumps(payload) + "\n```"
|
||||||
|
parsed = _parse_llm_json(text)
|
||||||
|
assert parsed["summary"] == "con fences"
|
||||||
|
assert parsed["analyses"] == ["a1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_llm_json_with_surrounding_text():
|
||||||
|
payload = {"summary": "rodeado"}
|
||||||
|
text = "Aqui tienes el resultado:\n" + json.dumps(payload) + "\nEspero que sirva."
|
||||||
|
parsed = _parse_llm_json(text)
|
||||||
|
assert parsed["summary"] == "rodeado"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_llm_json_nested_braces_in_strings():
|
||||||
|
# Un valor string con llaves no debe romper el matching.
|
||||||
|
text = '{"summary": "usa {placeholders}", "cleaning": ["fix {x}"]}'
|
||||||
|
parsed = _parse_llm_json(text)
|
||||||
|
assert parsed["summary"] == "usa {placeholders}"
|
||||||
|
assert parsed["cleaning"] == ["fix {x}"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_llm_json_raises_without_object():
|
||||||
|
try:
|
||||||
|
_parse_llm_json("no hay json aqui")
|
||||||
|
assert False, "esperaba ValueError"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
|
||||||
|
"""Simula la respuesta del LLM y verifica el shape de salida (sin red)."""
|
||||||
|
fake = {
|
||||||
|
"summary": "Tabla de ventas",
|
||||||
|
"row_meaning": "Una fila = una venta",
|
||||||
|
"dictionary": [
|
||||||
|
{
|
||||||
|
"column": "importe",
|
||||||
|
"description": "monto",
|
||||||
|
"business_meaning": "ingreso",
|
||||||
|
"unit": "EUR",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pii": [],
|
||||||
|
"cleaning": ["normalizar categoria"],
|
||||||
|
"analyses": ["ventas por categoria"],
|
||||||
|
}
|
||||||
|
|
||||||
|
import datascience.eda_llm_insights as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: json.dumps(fake)
|
||||||
|
)
|
||||||
|
|
||||||
|
out = eda_llm_insights(_PROFILE)
|
||||||
|
assert out["status"] == "ok"
|
||||||
|
llm = out["llm"]
|
||||||
|
assert set(llm.keys()) == {
|
||||||
|
"summary",
|
||||||
|
"row_meaning",
|
||||||
|
"dictionary",
|
||||||
|
"pii",
|
||||||
|
"cleaning",
|
||||||
|
"analyses",
|
||||||
|
}
|
||||||
|
assert llm["summary"] == "Tabla de ventas"
|
||||||
|
assert llm["dictionary"][0]["unit"] == "EUR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eda_llm_insights_fills_missing_keys(monkeypatch):
|
||||||
|
"""Si el LLM omite claves, se rellenan con defaults vacios."""
|
||||||
|
import datascience.eda_llm_insights as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod,
|
||||||
|
"ask_llm",
|
||||||
|
lambda prompt, model="x", system="", echo=True: '{"summary": "solo summary"}',
|
||||||
|
)
|
||||||
|
|
||||||
|
out = eda_llm_insights(_PROFILE)
|
||||||
|
assert out["status"] == "ok"
|
||||||
|
llm = out["llm"]
|
||||||
|
assert llm["summary"] == "solo summary"
|
||||||
|
assert llm["dictionary"] == []
|
||||||
|
assert llm["pii"] == []
|
||||||
|
assert llm["cleaning"] == []
|
||||||
|
assert llm["analyses"] == []
|
||||||
|
assert llm["row_meaning"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_eda_llm_insights_error_on_empty_profile():
|
||||||
|
out = eda_llm_insights({})
|
||||||
|
assert out["status"] == "error"
|
||||||
|
assert "profile" in out["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
|
||||||
|
import datascience.eda_llm_insights as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: ""
|
||||||
|
)
|
||||||
|
out = eda_llm_insights(_PROFILE)
|
||||||
|
assert out["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eda_llm_insights_error_on_unparseable_llm_response(monkeypatch):
|
||||||
|
import datascience.eda_llm_insights as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "sin json"
|
||||||
|
)
|
||||||
|
out = eda_llm_insights(_PROFILE)
|
||||||
|
assert out["status"] == "error"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: fetch_hackernews_search
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def fetch_hackernews_search(query: str, limit: int = 50, tags: str = \"story\") -> list[dict]"
|
||||||
|
description: "Busca en Hacker News via la API Algolia publica (sin auth ni anti-bot) y normaliza cada hit a un shape comun de market intelligence. GET a hn.algolia.com/api/v1/search filtrando por tags (story/comment/...)."
|
||||||
|
tags: [market-intel, hackernews, scraping, http, social, demand, impure, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [requests]
|
||||||
|
params:
|
||||||
|
- name: query
|
||||||
|
desc: "termino de busqueda (ej: 'i wish there was a tool')"
|
||||||
|
- name: limit
|
||||||
|
desc: "maximo de resultados (hitsPerPage de Algolia, topea ~1000)"
|
||||||
|
- name: tags
|
||||||
|
desc: "filtro de tipo de item Algolia: 'story' (default), 'comment', 'story,comment', 'show_hn', 'ask_hn'"
|
||||||
|
output: "list[dict] (puede ser []). Cada fila: {source:'hackernews', platform_id:str, title:str, body:str, url:str, author:str, channel:'hn', created_utc:float, platform_score:int, query:str}"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "parser normaliza hits al shape exacto"
|
||||||
|
- "hit sin url externa cae a news.ycombinator.com item link"
|
||||||
|
- "points None se mapea a 0"
|
||||||
|
- "hits vacio devuelve lista vacia"
|
||||||
|
test_file_path: "python/functions/datascience/fetch_hackernews_search_test.py"
|
||||||
|
file_path: "python/functions/datascience/fetch_hackernews_search.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import fetch_hackernews_search
|
||||||
|
|
||||||
|
# Buscar stories
|
||||||
|
rows = fetch_hackernews_search("i wish there was a tool", limit=50, tags="story")
|
||||||
|
for r in rows[:3]:
|
||||||
|
print(r["platform_score"], r["title"], r["url"])
|
||||||
|
|
||||||
|
# Buscar comentarios (mas senal de demanda conversacional)
|
||||||
|
comments = fetch_hackernews_search("alternative to", limit=100, tags="comment")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala como fuente complementaria a `fetch_reddit_search` en pipelines de market
|
||||||
|
intelligence. HN concentra demanda tecnica/SaaS y la API Algolia es estable y
|
||||||
|
sin anti-bot, ideal para escaneos recurrentes. Pasa `tags="comment"` para captar
|
||||||
|
demanda expresada en hilos (suele ser mas rica que los titulos de story).
|
||||||
|
Combina con `score_demand_signal` para puntuar cada hit.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Sin red = lista vacia, no excepcion**: si la peticion falla (red, 5xx,
|
||||||
|
JSON malformado) la funcion devuelve `[]`. Revisa el tamano del resultado.
|
||||||
|
- `created_utc` viene de `created_at_i` (epoch en segundos, float).
|
||||||
|
- `platform_score` son los `points` del item, `0` si Algolia no lo provee
|
||||||
|
(tipico en comentarios, que no tienen puntos visibles en la API).
|
||||||
|
- `url`: si el hit es una story con enlace externo, `url` es ese enlace; si no
|
||||||
|
(Ask HN, comentarios, Show HN sin link), cae al permalink
|
||||||
|
`https://news.ycombinator.com/item?id={objectID}`.
|
||||||
|
- A diferencia de Reddit, Algolia **no** exige User-Agent ni rate-limitea de
|
||||||
|
forma agresiva en uso normal, pero conviene no abusar.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""fetch_hackernews_search — busca en Hacker News via la API Algolia publica.
|
||||||
|
|
||||||
|
Funcion impura: hace peticiones HTTP a hn.algolia.com (sin auth ni anti-bot).
|
||||||
|
Normaliza cada hit a un shape comun de market intelligence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hits(hits: list, query: str) -> list[dict]:
|
||||||
|
"""Normaliza la lista hits de la respuesta de Algolia al shape comun."""
|
||||||
|
rows = []
|
||||||
|
for hit in hits:
|
||||||
|
if not isinstance(hit, dict):
|
||||||
|
continue
|
||||||
|
object_id = str(hit.get("objectID", ""))
|
||||||
|
external_url = hit.get("url")
|
||||||
|
url = external_url if external_url else (
|
||||||
|
f"https://news.ycombinator.com/item?id={object_id}"
|
||||||
|
)
|
||||||
|
body = hit.get("story_text") or hit.get("comment_text") or ""
|
||||||
|
rows.append({
|
||||||
|
"source": "hackernews",
|
||||||
|
"platform_id": object_id,
|
||||||
|
"title": hit.get("title", "") or "",
|
||||||
|
"body": body,
|
||||||
|
"url": url,
|
||||||
|
"author": hit.get("author", "") or "",
|
||||||
|
"channel": "hn",
|
||||||
|
"created_utc": float(hit.get("created_at_i") or 0.0),
|
||||||
|
"platform_score": int(hit.get("points") or 0),
|
||||||
|
"query": query,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_hackernews_search(
|
||||||
|
query: str,
|
||||||
|
limit: int = 50,
|
||||||
|
tags: str = "story",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Busca en Hacker News usando la API Algolia publica (sin autenticacion).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Termino de busqueda.
|
||||||
|
limit: Maximo de resultados (hitsPerPage de Algolia).
|
||||||
|
tags: Filtro de tipo de item: "story" (default), "comment",
|
||||||
|
"story,comment", "show_hn", "ask_hn", etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de dicts normalizados (puede ser []). Cada dict tiene las claves:
|
||||||
|
source, platform_id, title, body, url, author, channel, created_utc,
|
||||||
|
platform_score, query.
|
||||||
|
"""
|
||||||
|
url = "https://hn.algolia.com/api/v1/search"
|
||||||
|
params = {
|
||||||
|
"query": query,
|
||||||
|
"tags": tags,
|
||||||
|
"hitsPerPage": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params=params, timeout=_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
hits = payload.get("hits", []) if isinstance(payload, dict) else []
|
||||||
|
return _parse_hits(hits, query)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests para fetch_hackernews_search.
|
||||||
|
|
||||||
|
El parser (_parse_hits) se testea con un fixture offline. La funcion completa
|
||||||
|
fetch_hackernews_search hace red real; aqui solo validamos el shape del parser
|
||||||
|
para no depender de conectividad en CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from fetch_hackernews_search import _parse_hits
|
||||||
|
|
||||||
|
|
||||||
|
_FIXTURE_HITS = [
|
||||||
|
{
|
||||||
|
"objectID": "39000000",
|
||||||
|
"title": "Show HN: a tool to dedupe CSVs",
|
||||||
|
"story_text": "I wish there was a better way",
|
||||||
|
"url": "https://example.com/tool",
|
||||||
|
"author": "hnuser",
|
||||||
|
"created_at_i": 1700000000,
|
||||||
|
"points": 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectID": "39000001",
|
||||||
|
"title": "Ask HN: alternative to X?",
|
||||||
|
"comment_text": "Looking for a tool that does Y",
|
||||||
|
"url": None,
|
||||||
|
"author": "asker",
|
||||||
|
"created_at_i": 1700001234,
|
||||||
|
"points": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_EXPECTED_KEYS = {
|
||||||
|
"source", "platform_id", "title", "body", "url", "author",
|
||||||
|
"channel", "created_utc", "platform_score", "query",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_normaliza_hits_al_shape_exacto():
|
||||||
|
rows = _parse_hits(_FIXTURE_HITS, "csv dedupe")
|
||||||
|
assert len(rows) == 2
|
||||||
|
r = rows[0]
|
||||||
|
assert set(r.keys()) == _EXPECTED_KEYS
|
||||||
|
assert r["source"] == "hackernews"
|
||||||
|
assert r["platform_id"] == "39000000"
|
||||||
|
assert r["title"] == "Show HN: a tool to dedupe CSVs"
|
||||||
|
assert r["body"] == "I wish there was a better way"
|
||||||
|
assert r["url"] == "https://example.com/tool"
|
||||||
|
assert r["author"] == "hnuser"
|
||||||
|
assert r["channel"] == "hn"
|
||||||
|
assert r["created_utc"] == 1700000000.0
|
||||||
|
assert isinstance(r["created_utc"], float)
|
||||||
|
assert r["platform_score"] == 120
|
||||||
|
assert isinstance(r["platform_score"], int)
|
||||||
|
assert r["query"] == "csv dedupe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hit_sin_url_externa_cae_a_news_ycombinator_item_link():
|
||||||
|
rows = _parse_hits(_FIXTURE_HITS, "q")
|
||||||
|
assert rows[1]["url"] == "https://news.ycombinator.com/item?id=39000001"
|
||||||
|
# body cae a comment_text cuando no hay story_text
|
||||||
|
assert rows[1]["body"] == "Looking for a tool that does Y"
|
||||||
|
|
||||||
|
|
||||||
|
def test_points_none_se_mapea_a_0():
|
||||||
|
rows = _parse_hits(_FIXTURE_HITS, "q")
|
||||||
|
assert rows[1]["platform_score"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_hits_vacio_devuelve_lista_vacia():
|
||||||
|
assert _parse_hits([], "q") == []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_parser_normaliza_hits_al_shape_exacto()
|
||||||
|
test_hit_sin_url_externa_cae_a_news_ycombinator_item_link()
|
||||||
|
test_points_none_se_mapea_a_0()
|
||||||
|
test_hits_vacio_devuelve_lista_vacia()
|
||||||
|
print("All tests passed.")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: fetch_reddit_search
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def fetch_reddit_search(query: str, subreddits: list[str] = None, limit: int = 50, sort: str = \"new\") -> list[dict]"
|
||||||
|
description: "Busca posts en Reddit via la API JSON publica (sin auth) y los normaliza a un shape comun de market intelligence. Por subreddit (o global si None), GET a search.json con t=year. Tolera errores por subreddit (429, red) continuando con los demas. Requiere User-Agent obligatorio."
|
||||||
|
tags: [market-intel, reddit, scraping, http, social, demand, impure, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [requests]
|
||||||
|
params:
|
||||||
|
- name: query
|
||||||
|
desc: "termino de busqueda (ej: 'csv dedupe tool')"
|
||||||
|
- name: subreddits
|
||||||
|
desc: "lista de subreddits sin prefijo r/ (ej: ['SaaS','Entrepreneur']). Si None o vacio -> busqueda global en todo Reddit"
|
||||||
|
- name: limit
|
||||||
|
desc: "maximo de resultados por subreddit (o por la busqueda global). Reddit topea ~100"
|
||||||
|
- name: sort
|
||||||
|
desc: "orden de Reddit: 'new' (default), 'relevance', 'top', 'comments', 'hot'"
|
||||||
|
output: "list[dict] (puede ser []). Cada fila: {source:'reddit', platform_id:str, title:str, body:str, url:str, author:str, channel:str, created_utc:float, platform_score:int, query:str}"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "parser normaliza children al shape exacto"
|
||||||
|
- "selftext vacio se mapea a body vacio"
|
||||||
|
- "children vacio devuelve lista vacia"
|
||||||
|
test_file_path: "python/functions/datascience/fetch_reddit_search_test.py"
|
||||||
|
file_path: "python/functions/datascience/fetch_reddit_search.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import fetch_reddit_search
|
||||||
|
|
||||||
|
# Buscar en subreddits concretos
|
||||||
|
rows = fetch_reddit_search(
|
||||||
|
"csv dedupe tool",
|
||||||
|
subreddits=["SaaS", "Entrepreneur"],
|
||||||
|
limit=25,
|
||||||
|
sort="new",
|
||||||
|
)
|
||||||
|
for r in rows[:3]:
|
||||||
|
print(r["channel"], r["platform_score"], r["title"])
|
||||||
|
|
||||||
|
# Busqueda global (sin subreddits)
|
||||||
|
rows_global = fetch_reddit_search("i wish there was a tool", limit=50)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala como primera fase de un pipeline de market intelligence: recolectar
|
||||||
|
conversaciones reales de Reddit donde la gente expresa necesidades o busca
|
||||||
|
herramientas. Combina la salida con `score_demand_signal` para puntuar cada
|
||||||
|
post por senal de demanda. Cubre subreddits de nicho (`subreddits=[...]`) o
|
||||||
|
escanea todo Reddit (busqueda global).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **User-Agent obligatorio**: Reddit devuelve `429 Too Many Requests` si no se
|
||||||
|
envia un User-Agent identificable. Esta funcion envia
|
||||||
|
`demand_radar/0.1 (registry market-intel)` por defecto.
|
||||||
|
- **Rate limiting**: la API publica sin auth tiene limites estrictos. Si haces
|
||||||
|
muchas llamadas seguidas o pides muchos subreddits, Reddit puede empezar a
|
||||||
|
devolver 429. La funcion **tolera** estos fallos por subreddit (try/except) y
|
||||||
|
sigue con los demas — un 429 en un subreddit no aborta la busqueda completa,
|
||||||
|
simplemente ese subreddit aporta 0 filas.
|
||||||
|
- **Sin red = lista vacia, no excepcion**: si todas las peticiones fallan,
|
||||||
|
devuelve `[]`. Revisa el tamano del resultado, no asumas exito.
|
||||||
|
- `created_utc` es epoch en segundos (float). `platform_score` son los upvotes
|
||||||
|
netos (`ups`), 0 si Reddit no lo provee.
|
||||||
|
- `t=year` fija la ventana temporal a un ano; no es parametrizable en esta
|
||||||
|
version (mantiene la firma simple).
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""fetch_reddit_search — busca posts en Reddit via la API JSON publica (sin auth).
|
||||||
|
|
||||||
|
Funcion impura: hace peticiones HTTP a www.reddit.com. Tolera errores por
|
||||||
|
subreddit y normaliza cada post a un shape comun de market intelligence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_UA = "demand_radar/0.1 (registry market-intel)"
|
||||||
|
_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_children(children: list, query: str) -> list[dict]:
|
||||||
|
"""Normaliza la lista children de la respuesta de Reddit al shape comun."""
|
||||||
|
rows = []
|
||||||
|
for child in children:
|
||||||
|
data = child.get("data", {}) if isinstance(child, dict) else {}
|
||||||
|
permalink = data.get("permalink", "") or ""
|
||||||
|
rows.append({
|
||||||
|
"source": "reddit",
|
||||||
|
"platform_id": str(data.get("id", "")),
|
||||||
|
"title": data.get("title", "") or "",
|
||||||
|
"body": data.get("selftext", "") or "",
|
||||||
|
"url": "https://www.reddit.com" + permalink,
|
||||||
|
"author": data.get("author", "") or "",
|
||||||
|
"channel": data.get("subreddit", "") or "",
|
||||||
|
"created_utc": float(data.get("created_utc") or 0.0),
|
||||||
|
"platform_score": int(data.get("ups") or 0),
|
||||||
|
"query": query,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_reddit_search(
|
||||||
|
query: str,
|
||||||
|
subreddits: list[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
sort: str = "new",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Busca posts en Reddit usando la API JSON publica (sin autenticacion).
|
||||||
|
|
||||||
|
Por cada subreddit en `subreddits` hace una busqueda restringida a ese
|
||||||
|
subreddit. Si `subreddits` es None o vacio hace una busqueda global. Cada
|
||||||
|
fallo por subreddit (red, 429, JSON malformado) se captura y se omite,
|
||||||
|
continuando con los demas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Termino de busqueda.
|
||||||
|
subreddits: Lista de subreddits a buscar (sin el prefijo "r/"). Si None
|
||||||
|
o vacio, busqueda global en todo Reddit.
|
||||||
|
limit: Maximo de resultados por subreddit (o por la busqueda global).
|
||||||
|
sort: Orden de Reddit: "new", "relevance", "top", "comments", "hot".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de dicts normalizados (puede ser []). Cada dict tiene las claves:
|
||||||
|
source, platform_id, title, body, url, author, channel, created_utc,
|
||||||
|
platform_score, query.
|
||||||
|
"""
|
||||||
|
headers = {"User-Agent": _UA}
|
||||||
|
results: list[dict] = []
|
||||||
|
|
||||||
|
targets = subreddits if subreddits else [None]
|
||||||
|
|
||||||
|
for sub in targets:
|
||||||
|
try:
|
||||||
|
if sub:
|
||||||
|
url = f"https://www.reddit.com/r/{sub}/search.json"
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"restrict_sr": 1,
|
||||||
|
"sort": sort,
|
||||||
|
"limit": limit,
|
||||||
|
"t": "year",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
url = "https://www.reddit.com/search.json"
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"sort": sort,
|
||||||
|
"limit": limit,
|
||||||
|
"t": "year",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.get(
|
||||||
|
url, params=params, headers=headers, timeout=_TIMEOUT
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
children = (
|
||||||
|
payload.get("data", {}).get("children", [])
|
||||||
|
if isinstance(payload, dict)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
results.extend(_parse_children(children, query))
|
||||||
|
except Exception:
|
||||||
|
# Tolerar fallo por subreddit (red, 429, parsing) y seguir.
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Tests para fetch_reddit_search.
|
||||||
|
|
||||||
|
El parser (_parse_children) se testea con un fixture offline. La funcion
|
||||||
|
completa fetch_reddit_search hace red real; aqui solo validamos el shape del
|
||||||
|
parser para no depender de conectividad en CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from fetch_reddit_search import _parse_children
|
||||||
|
|
||||||
|
|
||||||
|
_FIXTURE_CHILDREN = [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "abc123",
|
||||||
|
"title": "I wish there was a CSV dedupe tool",
|
||||||
|
"selftext": "Anyone know a tool for this?",
|
||||||
|
"permalink": "/r/SaaS/comments/abc123/foo/",
|
||||||
|
"author": "user1",
|
||||||
|
"subreddit": "SaaS",
|
||||||
|
"created_utc": 1700000000.0,
|
||||||
|
"ups": 42,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "def456",
|
||||||
|
"title": "Link post no body",
|
||||||
|
"selftext": "",
|
||||||
|
"permalink": "/r/Entrepreneur/comments/def456/bar/",
|
||||||
|
"author": "user2",
|
||||||
|
"subreddit": "Entrepreneur",
|
||||||
|
"created_utc": 1700001234.0,
|
||||||
|
"ups": 7,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_EXPECTED_KEYS = {
|
||||||
|
"source", "platform_id", "title", "body", "url", "author",
|
||||||
|
"channel", "created_utc", "platform_score", "query",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_normaliza_children_al_shape_exacto():
|
||||||
|
rows = _parse_children(_FIXTURE_CHILDREN, "csv dedupe")
|
||||||
|
assert len(rows) == 2
|
||||||
|
r = rows[0]
|
||||||
|
assert set(r.keys()) == _EXPECTED_KEYS
|
||||||
|
assert r["source"] == "reddit"
|
||||||
|
assert r["platform_id"] == "abc123"
|
||||||
|
assert r["title"] == "I wish there was a CSV dedupe tool"
|
||||||
|
assert r["body"] == "Anyone know a tool for this?"
|
||||||
|
assert r["url"] == "https://www.reddit.com/r/SaaS/comments/abc123/foo/"
|
||||||
|
assert r["author"] == "user1"
|
||||||
|
assert r["channel"] == "SaaS"
|
||||||
|
assert r["created_utc"] == 1700000000.0
|
||||||
|
assert isinstance(r["created_utc"], float)
|
||||||
|
assert r["platform_score"] == 42
|
||||||
|
assert isinstance(r["platform_score"], int)
|
||||||
|
assert r["query"] == "csv dedupe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_selftext_vacio_se_mapea_a_body_vacio():
|
||||||
|
rows = _parse_children(_FIXTURE_CHILDREN, "q")
|
||||||
|
assert rows[1]["body"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_children_vacio_devuelve_lista_vacia():
|
||||||
|
assert _parse_children([], "q") == []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_parser_normaliza_children_al_shape_exacto()
|
||||||
|
test_selftext_vacio_se_mapea_a_body_vacio()
|
||||||
|
test_children_vacio_devuelve_lista_vacia()
|
||||||
|
print("All tests passed.")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
name: infer_fk_containment_duckdb
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def infer_fk_containment_duckdb(db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000) -> dict"
|
||||||
|
description: "Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores: para un par (col A de T1, col B de T2), inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|; si inclusion >= min_inclusion y B parece clave (distinct/count >= 0.95) entonces A -> B es FK candidata. Poda por tipo base y push-down SQL (COUNT DISTINCT / INTERSECT) sin traer filas a RAM. Parte del grupo eda (relaciones inter-tabla)."
|
||||||
|
tags: [eda, relations, duckdb, foreign-key, schema-inference, datascience, exploratory-data-analysis]
|
||||||
|
params:
|
||||||
|
- name: db_path
|
||||||
|
desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via las primitivas del grupo duckdb; no se crea)."
|
||||||
|
- name: tables
|
||||||
|
desc: "Lista de nombres de tabla a considerar. None (default) usa todas las del esquema main (duckdb_list_tables). Cada nombre se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el SQL."
|
||||||
|
- name: min_inclusion
|
||||||
|
desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata. inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|. Default 0.9."
|
||||||
|
- name: max_card
|
||||||
|
desc: "Tope de filas en la tabla destino (lado B, el caro del INTERSECT). Si count(T2) > max_card, los pares hacia T2 se saltan para no disparar un INTERSECT gigante; se acumula una nota en skipped[]. Default 200000."
|
||||||
|
output: "dict dict-no-throw. En exito {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key}, ...], tables:[str], skipped:[str]} con fk_candidates ordenado por inclusion descendente; cardinality es '1:1' (A casi unica en T1) o 'N:1' (A se repite, apunta a la key de T2). En error {status:'error', error:str}."
|
||||||
|
uses_functions: [duckdb_list_tables_py_infra, duckdb_table_schema_py_infra, duckdb_query_readonly_py_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_detecta_fk_orders_customer_id", "test_shape_resultado", "test_no_inventa_fk_columnas_no_relacionadas", "test_no_fk_entre_tipos_incompatibles", "test_min_inclusion_alto_filtra", "test_subset_explicito_de_tablas", "test_db_inexistente_devuelve_error", "test_tabla_invalida_devuelve_error"]
|
||||||
|
test_file_path: "python/functions/datascience/infer_fk_containment_duckdb_test.py"
|
||||||
|
file_path: "python/functions/datascience/infer_fk_containment_duckdb.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, duckdb
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience import infer_fk_containment_duckdb
|
||||||
|
|
||||||
|
# Base de ejemplo en /tmp: orders.customer_id -> customers.id
|
||||||
|
path = "/tmp/fk_demo.duckdb"
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute("CREATE TABLE customers (id INTEGER, region VARCHAR)")
|
||||||
|
con.execute("INSERT INTO customers VALUES (1,'norte'),(2,'sur'),(3,'este'),(4,'oeste')")
|
||||||
|
con.execute("CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, total DOUBLE)")
|
||||||
|
con.execute("INSERT INTO orders VALUES (10,1,99.5),(11,2,12.0),(12,1,45.25),(13,3,7.75),(14,4,60.0)")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = infer_fk_containment_duckdb(path, min_inclusion=0.9)
|
||||||
|
if res["status"] == "ok":
|
||||||
|
for fk in res["fk_candidates"]:
|
||||||
|
print(f"{fk['from_table']}.{fk['from_col']} -> "
|
||||||
|
f"{fk['to_table']}.{fk['to_col']} "
|
||||||
|
f"inclusion={fk['inclusion']:.2f} {fk['cardinality']}")
|
||||||
|
# -> orders.customer_id -> customers.id inclusion=1.00 N:1
|
||||||
|
else:
|
||||||
|
print("error:", res["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando exploras un esquema DuckDB que no conoces y quieres descubrir el grafo de relaciones (que tabla referencia a cual) sin que la base haya declarado FKs.
|
||||||
|
- Como paso del grupo `eda` que va mas alla del perfil por tabla (`summarize_table_duckdb`): aqui se modelan las relaciones INTER-tabla.
|
||||||
|
- Antes de migrar un esquema sin constraints a otro motor (PostgreSQL, etc.) para proponer las FOREIGN KEYs que faltan.
|
||||||
|
- Para auditar integridad referencial: una inclusion < 1.0 en una FK que crees que deberia ser total indica valores huerfanos (filas de T1 cuyo valor no existe en la key de T2).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: lee de disco via las primitivas read-only del grupo `duckdb` (no crea ni modifica la base). El `db_path` debe existir.
|
||||||
|
- **Coste O(pares podados)**: el numero de comparaciones es O(tablas^2 x columnas^2) ANTES de la poda. La poda por tipo base (solo se comparan columnas de la misma clase: ambos enteros, ambos varchar, ...) recorta drasticamente ese espacio, pero en esquemas con muchas tablas y columnas del mismo tipo puede seguir siendo costoso. Cada par evaluado dispara un `INTERSECT` en el motor.
|
||||||
|
- **`INTERSECT` puede ser caro en tablas enormes**: por eso `max_card` (default 200000) limita el lado destino. Si `count(T2) > max_card`, los pares hacia T2 se saltan y se anota en `skipped[]`. Sube `max_card` con cuidado: el INTERSECT materializa los distintos de ambos lados.
|
||||||
|
- **Containment != FK declarada**: que A este contenido en B (con B key-ish) es una FK *probable*, no una garantia. Una columna puede estar contenida por coincidencia (rangos pequenos de enteros, banderas, fechas solapadas) sin ser una relacion real. Revisa siempre las candidatas; trata `inclusion` y `cardinality` como senales, no como verdad.
|
||||||
|
- **Entero y float NO se mezclan**: la poda por tipo pone INTEGER/BIGINT/... en la clase `integer` y FLOAT/DOUBLE/DECIMAL en `float`, y solo empareja columnas de la misma clase. Una FK entera contra una columna float casi nunca es real, asi que se descarta de entrada.
|
||||||
|
- **Solo esquema `main`** cuando `tables=None`: hereda el alcance de `duckdb_list_tables` (esquema `main`).
|
||||||
|
- **Identificadores interpolados**: nombres de tabla/columna se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` y se citan (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para identificadores). Una tabla con nombre invalido devuelve `{status:'error'}`; una columna con nombre invalido se ignora sin abortar.
|
||||||
|
- **Direccion**: cada candidata es A -> B (A es la FK, B es la key referenciada). El par inverso (B -> A) se evalua por separado y normalmente no pasa el filtro de inclusion o el de key.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Definicion de containment usada:
|
||||||
|
|
||||||
|
```text
|
||||||
|
inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|
|
||||||
|
```
|
||||||
|
|
||||||
|
Criterio de emision de FK candidata A (de T1) -> B (de T2):
|
||||||
|
|
||||||
|
1. T1 != T2 y `type_class(A) == type_class(B)` (poda por clase de tipo base).
|
||||||
|
2. `count(T2) <= max_card` (si no, los pares hacia T2 se saltan -> `skipped[]`).
|
||||||
|
3. `distinct(A) > 0`.
|
||||||
|
4. B es key-ish: `distinct(B) / count(T2) >= 0.95`.
|
||||||
|
5. `inclusion(A subseteq B) >= min_inclusion`.
|
||||||
|
|
||||||
|
Cardinalidad: si A es (casi) unica en T1 (`distinct(A) / count(T1) >= 0.95`) ->
|
||||||
|
`1:1`; si no -> `N:1` (A se repite y apunta a la key de T2).
|
||||||
|
|
||||||
|
Todo se calcula con push-down (`COUNT(DISTINCT)`, `INTERSECT`) — nunca se traen
|
||||||
|
filas a RAM. Los `count(*)` por tabla y los `distinct` por columna se cachean para
|
||||||
|
no recomputarlos entre pares.
|
||||||
|
```text
|
||||||
|
fk_candidate = {
|
||||||
|
from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""infer_fk_containment_duckdb — infiere FOREIGN KEYs candidatas por containment.
|
||||||
|
|
||||||
|
Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del
|
||||||
|
grupo `duckdb`: duckdb_list_tables, duckdb_table_schema, duckdb_query_readonly).
|
||||||
|
Pertenece al grupo de capacidad `eda` (relaciones inter-tabla): descubre que
|
||||||
|
columnas de una tabla son una clave foranea probable hacia la clave de otra,
|
||||||
|
SIN que la base la haya declarado.
|
||||||
|
|
||||||
|
Idea: para un par (columna A de T1, columna B de T2), la inclusion (o containment)
|
||||||
|
de A en B es:
|
||||||
|
|
||||||
|
inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|
|
||||||
|
|
||||||
|
Si inclusion >= min_inclusion y B "parece clave" (alta unicidad en T2, distinct(B)
|
||||||
|
/ count(T2) >= 0.95), entonces A -> B es una FK candidata. Todo se calcula con
|
||||||
|
push-down en el motor de DuckDB (COUNT DISTINCT / INTERSECT); nunca se traen filas
|
||||||
|
a RAM.
|
||||||
|
|
||||||
|
PODA por tipo: solo se evaluan pares cuyas columnas comparten tipo base (ambos
|
||||||
|
enteros, ambos varchar, ambos fecha, ...). Esto evita el O(n^2) de calcular
|
||||||
|
containment para todos los pares de columnas, y descarta pares incompatibles que
|
||||||
|
nunca podrian ser una FK real.
|
||||||
|
|
||||||
|
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
|
||||||
|
devuelve {status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from infra import (
|
||||||
|
duckdb_list_tables,
|
||||||
|
duckdb_query_readonly,
|
||||||
|
duckdb_table_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Identificador SQL valido. Los nombres de tabla/columna se interpolan citados en
|
||||||
|
# el SQL (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para
|
||||||
|
# identificadores), asi que se validan antes de tocar la base.
|
||||||
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
# Clases de tipo base. Dos columnas solo se comparan si caen en la misma clase.
|
||||||
|
# Agrupar por clase (no por tipo exacto) permite emparejar INTEGER con BIGINT,
|
||||||
|
# DECIMAL con DOUBLE, etc. — combinaciones legitimas de FK numerica.
|
||||||
|
_INTEGER_TYPES = {
|
||||||
|
"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
|
||||||
|
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
|
||||||
|
}
|
||||||
|
_FLOAT_TYPES = {"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC"}
|
||||||
|
_TEXT_TYPES = {"VARCHAR", "TEXT", "STRING", "CHAR", "BPCHAR", "UUID"}
|
||||||
|
_DATETIME_TYPES = {
|
||||||
|
"DATE", "TIME", "TIMESTAMP", "DATETIME",
|
||||||
|
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_US",
|
||||||
|
"TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "TIMETZ",
|
||||||
|
}
|
||||||
|
_BOOL_TYPES = {"BOOLEAN", "BOOL"}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_physical_type(column_type: str) -> str:
|
||||||
|
"""Normaliza un tipo fisico DuckDB a su forma base en mayusculas.
|
||||||
|
|
||||||
|
Quita parametros (DECIMAL(10,2) -> DECIMAL) y modificadores de array
|
||||||
|
(INTEGER[] -> INTEGER) para poder mapearlo a una clase de tipo.
|
||||||
|
"""
|
||||||
|
t = (column_type or "").strip().upper()
|
||||||
|
t = re.sub(r"\[.*\]$", "", t).strip() # INTEGER[] -> INTEGER
|
||||||
|
t = re.sub(r"\(.*\)$", "", t).strip() # VARCHAR(50) -> VARCHAR
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _type_class(column_type: str) -> str:
|
||||||
|
"""Mapea un tipo fisico DuckDB a una clase comparable.
|
||||||
|
|
||||||
|
Devuelve 'integer' | 'float' | 'text' | 'datetime' | 'boolean' | 'other'.
|
||||||
|
Dos columnas solo se consideran emparejables para FK si comparten clase y la
|
||||||
|
clase no es 'other'. Entero y float NO se mezclan: una FK entera contra una
|
||||||
|
columna float es semanticamente sospechosa y casi nunca una FK real.
|
||||||
|
"""
|
||||||
|
base = _base_physical_type(column_type)
|
||||||
|
if base in _INTEGER_TYPES:
|
||||||
|
return "integer"
|
||||||
|
if base in _FLOAT_TYPES:
|
||||||
|
return "float"
|
||||||
|
if base in _TEXT_TYPES:
|
||||||
|
return "text"
|
||||||
|
if base in _DATETIME_TYPES:
|
||||||
|
return "datetime"
|
||||||
|
if base in _BOOL_TYPES:
|
||||||
|
return "boolean"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_idents(*names) -> bool:
|
||||||
|
"""True si todos los identificadores casan con ^[A-Za-z_][A-Za-z0-9_]*$."""
|
||||||
|
return all(isinstance(n, str) and _IDENT_RE.match(n) for n in names)
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(res: dict):
|
||||||
|
"""Extrae el unico valor escalar de un resultado duckdb_query_readonly.
|
||||||
|
|
||||||
|
Devuelve None si el resultado no es ok o no trae filas.
|
||||||
|
"""
|
||||||
|
if res["status"] != "ok" or not res["rows"]:
|
||||||
|
return None
|
||||||
|
row = res["rows"][0]
|
||||||
|
# La query siempre alias-a la unica columna; devolvemos su valor.
|
||||||
|
return next(iter(row.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def infer_fk_containment_duckdb(
|
||||||
|
db_path: str,
|
||||||
|
tables: list = None,
|
||||||
|
min_inclusion: float = 0.9,
|
||||||
|
max_card: int = 200000,
|
||||||
|
) -> dict:
|
||||||
|
"""Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only via las
|
||||||
|
primitivas del grupo duckdb; no se crea).
|
||||||
|
tables: lista de nombres de tabla a considerar. None (default) usa todas
|
||||||
|
las del esquema main (duckdb_list_tables).
|
||||||
|
min_inclusion: umbral minimo de inclusion (0-1) para emitir una FK
|
||||||
|
candidata. inclusion(A subseteq B) = |distinct(A) interseccion
|
||||||
|
distinct(B)| / |distinct(A)|. Default 0.9.
|
||||||
|
max_card: tope de filas en la tabla destino (lado B, el caro del INTERSECT).
|
||||||
|
Si count(T2) > max_card, el par se salta para no disparar un INTERSECT
|
||||||
|
gigante; se acumula una nota en skipped[]. Default 200000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict dict-no-throw. En exito:
|
||||||
|
{status:'ok',
|
||||||
|
fk_candidates:[{from_table, from_col, to_table, to_col, inclusion,
|
||||||
|
cardinality, to_is_key}, ...], # ordenado por inclusion desc
|
||||||
|
tables:[str], skipped:[str]}
|
||||||
|
En error (sin lanzar): {status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1) Lista de tablas a considerar.
|
||||||
|
if tables is None:
|
||||||
|
list_res = duckdb_list_tables(db_path)
|
||||||
|
if list_res["status"] != "ok":
|
||||||
|
return {"status": "error", "error": list_res["error"]}
|
||||||
|
tables = list_res["tables"]
|
||||||
|
|
||||||
|
if not isinstance(tables, list):
|
||||||
|
return {"status": "error", "error": "tables debe ser una lista o None"}
|
||||||
|
|
||||||
|
tables = [t for t in tables if isinstance(t, str)]
|
||||||
|
if not _valid_idents(*tables):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "algun nombre de tabla no casa con ^[A-Za-z_][A-Za-z0-9_]*$",
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped = []
|
||||||
|
|
||||||
|
# 2) Schema + count + cache de columnas por tabla.
|
||||||
|
# cols_by_table[t] = [{name, type, type_class}, ...]
|
||||||
|
cols_by_table = {}
|
||||||
|
count_by_table = {}
|
||||||
|
for t in tables:
|
||||||
|
sch = duckdb_table_schema(db_path, t)
|
||||||
|
if sch["status"] != "ok":
|
||||||
|
return {"status": "error", "error": sch["error"]}
|
||||||
|
cols = []
|
||||||
|
for c in sch["columns"]:
|
||||||
|
if not _valid_idents(c["name"]):
|
||||||
|
# Columna con nombre no interpolable: la ignoramos sin abortar.
|
||||||
|
continue
|
||||||
|
cols.append(
|
||||||
|
{
|
||||||
|
"name": c["name"],
|
||||||
|
"type": c["type"],
|
||||||
|
"type_class": _type_class(c["type"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cols_by_table[t] = cols
|
||||||
|
|
||||||
|
cnt = _scalar(
|
||||||
|
duckdb_query_readonly(db_path, f'SELECT count(*) AS n FROM "{t}"')
|
||||||
|
)
|
||||||
|
count_by_table[t] = int(cnt) if cnt is not None else 0
|
||||||
|
|
||||||
|
# 3) Cache de distinct(col) por (tabla, columna) para no recomputarlo.
|
||||||
|
distinct_cache = {}
|
||||||
|
|
||||||
|
def distinct_count(table: str, col: str):
|
||||||
|
key = (table, col)
|
||||||
|
if key in distinct_cache:
|
||||||
|
return distinct_cache[key]
|
||||||
|
val = _scalar(
|
||||||
|
duckdb_query_readonly(
|
||||||
|
db_path, f'SELECT count(DISTINCT "{col}") AS d FROM "{table}"'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val = int(val) if val is not None else 0
|
||||||
|
distinct_cache[key] = val
|
||||||
|
return val
|
||||||
|
|
||||||
|
# 4) Cache de "B es key-ish" por (tabla destino, columna). distinct/count
|
||||||
|
# >= 0.95. Solo se evalua para columnas que aparecen como lado B.
|
||||||
|
key_cache = {}
|
||||||
|
|
||||||
|
def to_is_key(table: str, col: str):
|
||||||
|
cache_key = (table, col)
|
||||||
|
if cache_key in key_cache:
|
||||||
|
return key_cache[cache_key]
|
||||||
|
n = count_by_table[table]
|
||||||
|
if n <= 0:
|
||||||
|
key_cache[cache_key] = (False, 0.0)
|
||||||
|
return key_cache[cache_key]
|
||||||
|
d = distinct_count(table, col)
|
||||||
|
ratio = d / n
|
||||||
|
key_cache[cache_key] = (ratio >= 0.95, ratio)
|
||||||
|
return key_cache[cache_key]
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
# 5) Pares (A en T1, B en T2) con T1 != T2 y misma clase de tipo (PODA).
|
||||||
|
for t1 in tables:
|
||||||
|
for t2 in tables:
|
||||||
|
if t1 == t2:
|
||||||
|
continue
|
||||||
|
# Lado caro: el INTERSECT lee distinct de T2. Si T2 es enorme,
|
||||||
|
# saltamos todos los pares hacia el (B en T2) y dejamos nota.
|
||||||
|
if count_by_table[t2] > max_card:
|
||||||
|
note = (
|
||||||
|
f"skip pares -> '{t2}': count {count_by_table[t2]} "
|
||||||
|
f"> max_card {max_card}"
|
||||||
|
)
|
||||||
|
if note not in skipped:
|
||||||
|
skipped.append(note)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for a in cols_by_table[t1]:
|
||||||
|
if a["type_class"] == "other":
|
||||||
|
continue
|
||||||
|
for b in cols_by_table[t2]:
|
||||||
|
# PODA: solo pares con la misma clase de tipo base.
|
||||||
|
if a["type_class"] != b["type_class"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# distinct(A); si es 0, no hay containment que medir.
|
||||||
|
d_a = distinct_count(t1, a["name"])
|
||||||
|
if d_a == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# B debe parecer key (alta unicidad en T2).
|
||||||
|
b_is_key, _b_ratio = to_is_key(t2, b["name"])
|
||||||
|
if not b_is_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# interseccion de distintos via INTERSECT (push-down).
|
||||||
|
inter_sql = (
|
||||||
|
"SELECT count(*) AS c FROM ("
|
||||||
|
f'SELECT DISTINCT "{a["name"]}" FROM "{t1}" '
|
||||||
|
"INTERSECT "
|
||||||
|
f'SELECT DISTINCT "{b["name"]}" FROM "{t2}"'
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
inter = _scalar(duckdb_query_readonly(db_path, inter_sql))
|
||||||
|
if inter is None:
|
||||||
|
continue
|
||||||
|
inter = int(inter)
|
||||||
|
|
||||||
|
inclusion = inter / d_a
|
||||||
|
if inclusion < min_inclusion:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cardinalidad: si A es (casi) unica en T1 -> 1:1; si no N:1.
|
||||||
|
n_t1 = count_by_table[t1]
|
||||||
|
a_unique = n_t1 > 0 and (d_a / n_t1) >= 0.95
|
||||||
|
cardinality = "1:1" if a_unique else "N:1"
|
||||||
|
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"from_table": t1,
|
||||||
|
"from_col": a["name"],
|
||||||
|
"to_table": t2,
|
||||||
|
"to_col": b["name"],
|
||||||
|
"inclusion": inclusion,
|
||||||
|
"cardinality": cardinality,
|
||||||
|
"to_is_key": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.sort(key=lambda c: c["inclusion"], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"fk_candidates": candidates,
|
||||||
|
"tables": tables,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests para infer_fk_containment_duckdb."""
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path):
|
||||||
|
"""DuckDB temporal: customers (id PK) + orders (customer_id FK -> customers.id).
|
||||||
|
|
||||||
|
Ademas una columna `total` (DOUBLE) en orders y `region` (VARCHAR) en customers
|
||||||
|
que NO estan relacionadas, para comprobar que la funcion no inventa FKs entre
|
||||||
|
columnas sin containment ni entre tipos incompatibles.
|
||||||
|
"""
|
||||||
|
path = str(tmp_path / "fk_test.duckdb")
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE customers ("
|
||||||
|
" id INTEGER," # PK: 1..4, unica
|
||||||
|
" region VARCHAR" # categorica, no relacionada con nada de orders
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO customers VALUES "
|
||||||
|
"(1, 'norte'), (2, 'sur'), (3, 'este'), (4, 'oeste')"
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE orders ("
|
||||||
|
" order_id INTEGER," # PK de orders, unica
|
||||||
|
" customer_id INTEGER," # FK -> customers.id (todos en 1..4)
|
||||||
|
" total DOUBLE" # numerica float, no relacionada
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO orders VALUES "
|
||||||
|
"(10, 1, 99.5), "
|
||||||
|
"(11, 2, 12.0), "
|
||||||
|
"(12, 1, 45.25), " # customer_id se repite -> N:1
|
||||||
|
"(13, 3, 7.75), "
|
||||||
|
"(14, 4, 60.0)"
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _find(candidates, from_table, from_col, to_table, to_col):
|
||||||
|
"""Devuelve la primera FK candidata que casa con la firma dada, o None."""
|
||||||
|
for c in candidates:
|
||||||
|
if (
|
||||||
|
c["from_table"] == from_table
|
||||||
|
and c["from_col"] == from_col
|
||||||
|
and c["to_table"] == to_table
|
||||||
|
and c["to_col"] == to_col
|
||||||
|
):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detecta_fk_orders_customer_id(db):
|
||||||
|
"""orders.customer_id subseteq customers.id con inclusion 1.0 y cardinalidad N:1."""
|
||||||
|
res = infer_fk_containment_duckdb(db)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
|
||||||
|
fk = _find(res["fk_candidates"], "orders", "customer_id", "customers", "id")
|
||||||
|
assert fk is not None, "no detecto orders.customer_id -> customers.id"
|
||||||
|
# Los 4 valores distintos de customer_id (1,2,3,4) estan todos en customers.id.
|
||||||
|
assert fk["inclusion"] == pytest.approx(1.0)
|
||||||
|
# customers.id es key (4 distintos / 4 filas = 1.0 >= 0.95).
|
||||||
|
assert fk["to_is_key"] is True
|
||||||
|
# customer_id NO es unica en orders (1 se repite) -> N:1.
|
||||||
|
assert fk["cardinality"] == "N:1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_resultado(db):
|
||||||
|
"""Estructura del resultado y de cada FK candidata."""
|
||||||
|
res = infer_fk_containment_duckdb(db)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
for key in ("status", "fk_candidates", "tables", "skipped"):
|
||||||
|
assert key in res
|
||||||
|
assert set(res["tables"]) == {"customers", "orders"}
|
||||||
|
for fk in res["fk_candidates"]:
|
||||||
|
for key in (
|
||||||
|
"from_table", "from_col", "to_table", "to_col",
|
||||||
|
"inclusion", "cardinality", "to_is_key",
|
||||||
|
):
|
||||||
|
assert key in fk, f"falta clave {key} en fk_candidate"
|
||||||
|
assert 0.0 <= fk["inclusion"] <= 1.0
|
||||||
|
assert fk["cardinality"] in ("1:1", "N:1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_inventa_fk_columnas_no_relacionadas(db):
|
||||||
|
"""No emite FK entre columnas sin containment real.
|
||||||
|
|
||||||
|
- orders.total (DOUBLE) no debe relacionarse con nada (es float aislado).
|
||||||
|
- customers.region (VARCHAR) no tiene contraparte text con la que casar.
|
||||||
|
- order_id (PK de orders) no esta contenido en ninguna key de customers.
|
||||||
|
"""
|
||||||
|
res = infer_fk_containment_duckdb(db)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
candidates = res["fk_candidates"]
|
||||||
|
|
||||||
|
# total nunca aparece como origen de una FK.
|
||||||
|
assert _find(candidates, "orders", "total", "customers", "id") is None
|
||||||
|
assert not any(c["from_col"] == "total" for c in candidates)
|
||||||
|
|
||||||
|
# region (varchar de customers) no casa con ninguna columna text de orders.
|
||||||
|
assert not any(c["from_col"] == "region" for c in candidates)
|
||||||
|
|
||||||
|
# order_id (10..14) NO esta contenido en customers.id (1..4): inclusion baja.
|
||||||
|
assert _find(candidates, "orders", "order_id", "customers", "id") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_fk_entre_tipos_incompatibles(db):
|
||||||
|
"""customer_id (INTEGER) jamas se empareja con total (DOUBLE): poda por tipo."""
|
||||||
|
res = infer_fk_containment_duckdb(db)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
# No debe existir ninguna candidata cuyo destino sea orders.total.
|
||||||
|
assert not any(c["to_col"] == "total" for c in res["fk_candidates"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_inclusion_alto_filtra(db):
|
||||||
|
"""Subir min_inclusion por encima de 1.0 deja la lista vacia."""
|
||||||
|
res = infer_fk_containment_duckdb(db, min_inclusion=1.01)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["fk_candidates"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_subset_explicito_de_tablas(db):
|
||||||
|
"""Pasar tables=[...] limita las tablas consideradas."""
|
||||||
|
res = infer_fk_containment_duckdb(db, tables=["customers", "orders"])
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert set(res["tables"]) == {"customers", "orders"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_inexistente_devuelve_error(tmp_path):
|
||||||
|
"""Una base que no existe devuelve {status:'error'} sin lanzar."""
|
||||||
|
res = infer_fk_containment_duckdb(str(tmp_path / "no_existe.duckdb"))
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert isinstance(res["error"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabla_invalida_devuelve_error(db):
|
||||||
|
"""Un nombre de tabla no interpolable devuelve error sin tocar la base."""
|
||||||
|
res = infer_fk_containment_duckdb(db, tables=["orders; DROP TABLE orders"])
|
||||||
|
assert res["status"] == "error"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
id: infer_semantic_type_py_datascience
|
||||||
|
name: infer_semantic_type
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def infer_semantic_type(values: list, sample: int = 200, min_match: float = 0.8) -> dict"
|
||||||
|
description: "Detects the semantic type of a text column via regex (email, url, ipv4, ipv6, uuid, iban, credit_card, phone_intl, postal_code_es, currency, datetime_iso, date_eu, integer, decimal, boolean, hex_color). Cheap first pass for EDA without an LLM: samples non-null values and returns the type whose match rate is highest and above a threshold."
|
||||||
|
tags: [eda, semantic-type, profiling, regex, column-inference, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [re]
|
||||||
|
example: |
|
||||||
|
from infer_semantic_type import infer_semantic_type
|
||||||
|
infer_semantic_type(["ana@example.com", "bob@test.org"])
|
||||||
|
# {"semantic_type": "email", "match_rate": 1.0, "candidates": [...]}
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_emails_dominante"
|
||||||
|
- "test_uuids_dominante"
|
||||||
|
- "test_mezcla_sin_tipo_dominante"
|
||||||
|
- "test_lista_vacia"
|
||||||
|
- "test_solo_nulos_y_blancos"
|
||||||
|
test_file_path: "python/functions/datascience/infer_semantic_type_test.py"
|
||||||
|
file_path: "python/functions/datascience/infer_semantic_type.py"
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "Column values (any type). Each is coerced to str and stripped before matching. None and empty/whitespace-only strings are treated as null and skipped."
|
||||||
|
- name: sample
|
||||||
|
desc: "Maximum number of non-null values to test against the pattern catalog. Default 200. Caps cost on large columns."
|
||||||
|
- name: min_match
|
||||||
|
desc: "Minimum fraction (0.0-1.0) of sampled values that must match a type for it to be returned as semantic_type. Default 0.8."
|
||||||
|
output: >
|
||||||
|
Dict with three keys: "semantic_type" (str) = best matching type if its
|
||||||
|
match_rate >= min_match, else ""; "match_rate" (float) = fraction of sampled
|
||||||
|
values matching the best type (0.0 when no candidate); "candidates"
|
||||||
|
(list of {"type": str, "match_rate": float}) = every type with match_rate > 0,
|
||||||
|
sorted by match_rate descending (for debugging).
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infer_semantic_type import infer_semantic_type
|
||||||
|
|
||||||
|
# Columna homogenea de emails -> tipo claro
|
||||||
|
infer_semantic_type([
|
||||||
|
"ana@example.com",
|
||||||
|
"bob@test.org",
|
||||||
|
"carol.smith@mail.co.uk",
|
||||||
|
"dev+tag@domain.io",
|
||||||
|
])
|
||||||
|
# {"semantic_type": "email", "match_rate": 1.0,
|
||||||
|
# "candidates": [{"type": "email", "match_rate": 1.0}]}
|
||||||
|
|
||||||
|
# Columna mezclada sin tipo dominante -> "" pero candidates ayuda a depurar
|
||||||
|
infer_semantic_type([
|
||||||
|
"ana@example.com",
|
||||||
|
"https://example.com/path",
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"just free text",
|
||||||
|
])
|
||||||
|
# {"semantic_type": "", "match_rate": 0.25,
|
||||||
|
# "candidates": [{"type": "email", "match_rate": 0.25}, ...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando perfilas un dataset y necesitas saber QUE representa una columna de texto
|
||||||
|
(email, url, iban, uuid, fecha, importe...) antes de decidir parsing, validacion
|
||||||
|
o anonimizacion. Es el primer paso barato del EDA: corre en regex puro, sin LLM
|
||||||
|
ni dependencias, y dejas la inferencia cara (LLM, ML) solo para las columnas que
|
||||||
|
salen ambiguas (`semantic_type == ""`, mirar `candidates`).
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Funcion pura: solo usa `re` de stdlib, sin I/O ni estado mutable.
|
||||||
|
- El match es por `fullmatch` (el valor entero debe conformar al tipo, no un
|
||||||
|
substring), asi un texto libre que "contiene" un email no cuenta como email.
|
||||||
|
- Tipos solapan a proposito (un entero matchea `integer` y `boolean` para "0"/"1");
|
||||||
|
por eso se devuelve el de mayor `match_rate` y, en empate, el alfabeticamente
|
||||||
|
menor para que el resultado sea determinista. Revisar `candidates` cuando el
|
||||||
|
resultado sorprenda.
|
||||||
|
- `credit_card` no aplica validacion Luhn; el regex de 16 digitos basta para EDA.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Infer the semantic type of a text column via regex pattern matching.
|
||||||
|
|
||||||
|
Pure, stdlib-only. No LLM, no I/O, no external dependencies. Cheap first pass
|
||||||
|
for exploratory data analysis: classify what a column "means" (email, url,
|
||||||
|
iban, ...) by sampling values and matching them against a regex catalog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Catalog of semantic types -> compiled regex.
|
||||||
|
# Each pattern is anchored (fullmatch semantics) so a value only counts as a
|
||||||
|
# match when the whole string conforms to the type, not just a substring.
|
||||||
|
_PATTERNS = {
|
||||||
|
"email": re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+", re.IGNORECASE),
|
||||||
|
"url": re.compile(r"https?://[^\s]+", re.IGNORECASE),
|
||||||
|
"ipv4": re.compile(
|
||||||
|
r"(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}"
|
||||||
|
r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
|
||||||
|
),
|
||||||
|
"ipv6": re.compile(
|
||||||
|
r"(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{0,4}", re.IGNORECASE
|
||||||
|
),
|
||||||
|
"uuid": re.compile(
|
||||||
|
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
||||||
|
r"[0-9a-f]{4}-[0-9a-f]{12}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"iban": re.compile(r"[A-Z]{2}\d{2}[A-Z0-9]{11,30}", re.IGNORECASE),
|
||||||
|
"credit_card": re.compile(r"\d{4}(?:[ -]?\d{4}){3}"),
|
||||||
|
"phone_intl": re.compile(r"\+\d[\d\s()-]{6,}\d"),
|
||||||
|
"postal_code_es": re.compile(r"\d{5}"),
|
||||||
|
"currency": re.compile(
|
||||||
|
r"(?:[€$£]\s?\d[\d.,]*|\d[\d.,]*\s?(?:EUR|USD|GBP))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"datetime_iso": re.compile(
|
||||||
|
r"\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?"
|
||||||
|
r"(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?"
|
||||||
|
),
|
||||||
|
"date_eu": re.compile(r"\d{1,2}/\d{1,2}/\d{4}"),
|
||||||
|
"integer": re.compile(r"[+-]?\d+"),
|
||||||
|
"decimal": re.compile(r"[+-]?\d+[.,]\d+"),
|
||||||
|
"boolean": re.compile(r"true|false|0|1|si|no|yes", re.IGNORECASE),
|
||||||
|
"hex_color": re.compile(r"#[0-9a-f]{6}", re.IGNORECASE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_semantic_type(
|
||||||
|
values: list, sample: int = 200, min_match: float = 0.8
|
||||||
|
) -> dict:
|
||||||
|
"""Detect the semantic type of a column of values via regex.
|
||||||
|
|
||||||
|
Samples up to ``sample`` non-null values, tests each against a catalog of
|
||||||
|
regex patterns (email, url, ipv4, uuid, iban, ...), and returns the type
|
||||||
|
whose match rate is the highest and at least ``min_match``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: Column values (any type; each is coerced to ``str`` and
|
||||||
|
stripped before matching). ``None`` and empty/whitespace-only
|
||||||
|
strings are treated as null and skipped.
|
||||||
|
sample: Maximum number of non-null values to test (default 200).
|
||||||
|
min_match: Minimum fraction of sampled values that must match a type
|
||||||
|
for it to be returned as ``semantic_type`` (default 0.8).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with three keys:
|
||||||
|
- ``semantic_type`` (str): best matching type if its match_rate is
|
||||||
|
>= ``min_match``, otherwise ``""``.
|
||||||
|
- ``match_rate`` (float): fraction of sampled values matching the best
|
||||||
|
type (0.0 when there is no candidate).
|
||||||
|
- ``candidates`` (list[dict]): every type with match_rate > 0 as
|
||||||
|
``{"type": str, "match_rate": float}``, sorted by match_rate desc.
|
||||||
|
"""
|
||||||
|
# Collect non-null, stripped string values up to the sample size.
|
||||||
|
sampled: list = []
|
||||||
|
for v in values:
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
s = str(v).strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
sampled.append(s)
|
||||||
|
if len(sampled) >= sample:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not sampled:
|
||||||
|
return {"semantic_type": "", "match_rate": 0.0, "candidates": []}
|
||||||
|
|
||||||
|
n = len(sampled)
|
||||||
|
candidates: list = []
|
||||||
|
for type_name, pattern in _PATTERNS.items():
|
||||||
|
hits = sum(1 for s in sampled if pattern.fullmatch(s) is not None)
|
||||||
|
if hits > 0:
|
||||||
|
candidates.append(
|
||||||
|
{"type": type_name, "match_rate": hits / n}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by match_rate desc, then type name for deterministic ties.
|
||||||
|
candidates.sort(key=lambda c: (-c["match_rate"], c["type"]))
|
||||||
|
|
||||||
|
if candidates and candidates[0]["match_rate"] >= min_match:
|
||||||
|
best = candidates[0]
|
||||||
|
return {
|
||||||
|
"semantic_type": best["type"],
|
||||||
|
"match_rate": best["match_rate"],
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
best_rate = candidates[0]["match_rate"] if candidates else 0.0
|
||||||
|
return {
|
||||||
|
"semantic_type": "",
|
||||||
|
"match_rate": best_rate,
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Tests para infer_semantic_type."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from infer_semantic_type import infer_semantic_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_emails_dominante():
|
||||||
|
"""Lista de emails devuelve semantic_type email con match_rate alto."""
|
||||||
|
values = [
|
||||||
|
"ana@example.com",
|
||||||
|
"bob@test.org",
|
||||||
|
"carol.smith@mail.co.uk",
|
||||||
|
"dev+tag@domain.io",
|
||||||
|
"user@sub.domain.net",
|
||||||
|
]
|
||||||
|
result = infer_semantic_type(values)
|
||||||
|
assert result["semantic_type"] == "email"
|
||||||
|
assert result["match_rate"] >= 0.8
|
||||||
|
assert any(c["type"] == "email" for c in result["candidates"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_uuids_dominante():
|
||||||
|
"""Lista de UUIDs devuelve semantic_type uuid."""
|
||||||
|
values = [
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
]
|
||||||
|
result = infer_semantic_type(values)
|
||||||
|
assert result["semantic_type"] == "uuid"
|
||||||
|
assert result["match_rate"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mezcla_sin_tipo_dominante():
|
||||||
|
"""Lista mezclada sin tipo dominante devuelve cadena vacia."""
|
||||||
|
values = [
|
||||||
|
"ana@example.com",
|
||||||
|
"https://example.com/path",
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"#ff00aa",
|
||||||
|
"just some free text here",
|
||||||
|
]
|
||||||
|
result = infer_semantic_type(values)
|
||||||
|
assert result["semantic_type"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_vacia():
|
||||||
|
"""Lista vacia devuelve semantic_type vacio y match_rate 0."""
|
||||||
|
result = infer_semantic_type([])
|
||||||
|
assert result["semantic_type"] == ""
|
||||||
|
assert result["match_rate"] == 0.0
|
||||||
|
assert result["candidates"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_solo_nulos_y_blancos():
|
||||||
|
"""Valores nulos y en blanco se tratan como vacio."""
|
||||||
|
result = infer_semantic_type([None, "", " ", None])
|
||||||
|
assert result["semantic_type"] == ""
|
||||||
|
assert result["match_rate"] == 0.0
|
||||||
|
assert result["candidates"] == []
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: isolation_forest_outliers
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def isolation_forest_outliers(columns: dict, contamination: float = 0.05, max_report: int = 50) -> dict"
|
||||||
|
description: "Detecta outliers MULTIVARIANTE (filas anomalas considerando todas las columnas a la vez, no columna a columna) con sklearn IsolationForest. Estandariza con StandardScaler, descarta filas con None y usa random_state=0 para resultados deterministas. Devuelve conteo, porcentaje, filas anomalas ordenadas (mas anomala primero) con su score, umbral y dimensiones usadas. Con <2 columnas numericas o <10 filas validas devuelve note 'datos insuficientes' sin petar."
|
||||||
|
tags: [eda, models, outliers, anomaly-detection, isolation-forest, multivariate, sklearn]
|
||||||
|
params:
|
||||||
|
- name: columns
|
||||||
|
desc: "dict {nombre_columna: [valores numericos]}. Listas alineadas por fila (la fila i de cada columna forma una observacion). Solo se usan columnas cuyos valores sean todos numericos (None permitido por fila, NaN/Inf descartan la columna); el resto se ignora."
|
||||||
|
- name: contamination
|
||||||
|
desc: "Proporcion esperada de outliers en [0, 0.5], pasada a IsolationForest. Sube/baja la fraccion de filas marcadas. Default 0.05."
|
||||||
|
- name: max_report
|
||||||
|
desc: "Maximo de filas anomalas a devolver en outlier_rows, mas anomala primero. n_outliers cuenta TODAS aunque se trunque el detalle. Default 50."
|
||||||
|
output: "dict {n_outliers: total de filas outlier; outlier_pct: % sobre filas validas (0-100); outlier_rows: lista de {row_index, score} ordenada por score asc (mas anomala primero), truncada a max_report; threshold: umbral de decision (model.offset_), outlier <=> decision_function < threshold; n_rows_used: filas validas tras descartar None; n_features: columnas numericas usadas}. row_index cuenta SOLO las filas validas (sin None), en orden de aparicion empezando en 0 — no es el indice original si se descarto alguna fila. Si <2 columnas numericas o <10 filas validas: {n_outliers: 0, note: 'datos insuficientes'}."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_cloud_with_three_far_points_flags_them", "test_insufficient_columns_returns_note", "test_insufficient_rows_returns_note"]
|
||||||
|
test_file_path: "python/functions/datascience/isolation_forest_outliers_test.py"
|
||||||
|
file_path: "python/functions/datascience/isolation_forest_outliers.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import isolation_forest_outliers
|
||||||
|
|
||||||
|
# Nube densa alrededor de (0, 0) + 3 puntos claramente alejados al final.
|
||||||
|
xs = [0.1, -0.2, 0.0, 0.3, -0.1, 0.2, -0.3, 0.05, -0.15, 0.25, 0.0, -0.05]
|
||||||
|
ys = [0.0, 0.1, -0.1, 0.2, -0.2, 0.05, -0.05, 0.15, -0.25, 0.1, 0.0, 0.2]
|
||||||
|
# 3 outliers multivariante (lejos de la nube en el plano):
|
||||||
|
xs += [9.0, -8.5, 10.0]
|
||||||
|
ys += [9.5, -9.0, -8.0]
|
||||||
|
|
||||||
|
columns = {"x": xs, "y": ys}
|
||||||
|
result = isolation_forest_outliers(columns, contamination=0.2, max_report=10)
|
||||||
|
|
||||||
|
print(result["n_outliers"]) # >= 3
|
||||||
|
print(result["n_rows_used"], result["n_features"]) # 15 2
|
||||||
|
for row in result["outlier_rows"]:
|
||||||
|
print(row["row_index"], round(row["score"], 4))
|
||||||
|
# Las filas 12, 13, 14 (los 3 puntos lejanos) aparecen primero, score mas bajo.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieras encontrar **filas anomalas de una tabla mirando todas sus
|
||||||
|
columnas a la vez** en la fase EDA, en lugar de buscar outliers columna a
|
||||||
|
columna con z-score/IQR. Es el caso en que una observacion es razonable en cada
|
||||||
|
variable por separado pero rara en su combinacion (p.ej. peso bajo + altura
|
||||||
|
alta). Pasale las columnas numericas alineadas por fila y te devuelve las filas
|
||||||
|
mas sospechosas ordenadas por anomalia para inspeccionarlas. Modelo barato y
|
||||||
|
determinista (`random_state=0`), apto para correr de forma reproducible dentro
|
||||||
|
de un perfilado.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura solo porque fija `random_state=0`**: IsolationForest es estocastico;
|
||||||
|
sin la semilla los resultados variarian entre llamadas. No cambiar la semilla
|
||||||
|
si se quiere determinismo.
|
||||||
|
- **row_index es relativo a las filas validas**: si alguna fila tenia None en
|
||||||
|
una columna usada, se descarta y los indices se recalculan sobre las filas
|
||||||
|
que quedan (orden de aparicion, base 0). No mapea 1:1 con las listas de
|
||||||
|
entrada cuando hay None.
|
||||||
|
- **Seleccion de columnas estricta**: una columna con cualquier valor no
|
||||||
|
numerico (str, bool, NaN, Inf) se ignora por completo. Si quedan <2 columnas
|
||||||
|
numericas, devuelve `note: "datos insuficientes"`.
|
||||||
|
- **Minimo 10 filas validas**: con menos, devuelve `note` en vez de un modelo
|
||||||
|
poco fiable.
|
||||||
|
- `contamination` influye en cuantas filas se marcan; con datos sin outliers
|
||||||
|
reales un valor alto forzara falsos positivos.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Deteccion de outliers multivariante con Isolation Forest.
|
||||||
|
|
||||||
|
Detecta filas anomalas considerando TODAS las columnas a la vez (no columna a
|
||||||
|
columna): una fila puede ser normal en cada variable por separado y aun asi ser
|
||||||
|
un outlier por la combinacion de sus valores. Pura y determinista
|
||||||
|
(`random_state=0`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.ensemble import IsolationForest
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
|
||||||
|
|
||||||
|
def isolation_forest_outliers(
|
||||||
|
columns: dict,
|
||||||
|
contamination: float = 0.05,
|
||||||
|
max_report: int = 50,
|
||||||
|
) -> dict:
|
||||||
|
"""Detecta outliers multivariante con Isolation Forest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
columns: dict {nombre_columna: [valores numericos]}. Todas las listas se
|
||||||
|
asumen alineadas por fila (misma longitud, la fila i de cada columna
|
||||||
|
forma una observacion). Solo se usan columnas cuyos valores sean
|
||||||
|
numericos; las demas se ignoran.
|
||||||
|
contamination: proporcion esperada de outliers en [0, 0.5], pasada a
|
||||||
|
IsolationForest. Default 0.05.
|
||||||
|
max_report: numero maximo de filas anomalas a devolver en
|
||||||
|
outlier_rows, las mas anomalas primero. Default 50.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
n_outliers: numero total de filas marcadas como outlier.
|
||||||
|
outlier_pct: porcentaje de outliers sobre filas validas (0-100).
|
||||||
|
outlier_rows: lista de {row_index, score} de los outliers, mas
|
||||||
|
anomalo primero, truncada a max_report.
|
||||||
|
threshold: umbral de decision del modelo (offset_). Una fila es
|
||||||
|
outlier cuando su score (decision_function) es < threshold.
|
||||||
|
n_rows_used: filas validas usadas (tras descartar filas con None).
|
||||||
|
n_features: numero de columnas numericas usadas.
|
||||||
|
|
||||||
|
IMPORTANTE: row_index es el indice contando SOLO las filas validas (las
|
||||||
|
que no tenian ningun None en las columnas numericas usadas), empezando
|
||||||
|
en 0 en orden de aparicion. No es el indice en las listas originales si
|
||||||
|
se descarto alguna fila por contener None.
|
||||||
|
|
||||||
|
Si hay menos de 2 columnas numericas o menos de 10 filas validas,
|
||||||
|
devuelve {n_outliers: 0, note: "datos insuficientes"} sin petar.
|
||||||
|
"""
|
||||||
|
# Selecciona solo columnas con todos los valores numericos (ints/floats,
|
||||||
|
# bool no cuenta). None se permite a nivel de fila y se filtra despues.
|
||||||
|
numeric_cols: dict[str, list] = {}
|
||||||
|
for name, values in columns.items():
|
||||||
|
if not isinstance(values, (list, tuple)):
|
||||||
|
continue
|
||||||
|
ok = True
|
||||||
|
for v in values:
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
if isinstance(v, float) and (np.isnan(v) or np.isinf(v)):
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
if ok:
|
||||||
|
numeric_cols[name] = list(values)
|
||||||
|
|
||||||
|
if len(numeric_cols) < 2:
|
||||||
|
return {"n_outliers": 0, "note": "datos insuficientes"}
|
||||||
|
|
||||||
|
col_names = list(numeric_cols.keys())
|
||||||
|
n_rows_total = min(len(numeric_cols[c]) for c in col_names)
|
||||||
|
|
||||||
|
# Construye matriz fila a fila, descartando filas con None en cualquier
|
||||||
|
# columna usada. row_index = posicion entre las filas validas.
|
||||||
|
rows: list[list[float]] = []
|
||||||
|
for i in range(n_rows_total):
|
||||||
|
row = [numeric_cols[c][i] for c in col_names]
|
||||||
|
if any(v is None for v in row):
|
||||||
|
continue
|
||||||
|
rows.append([float(v) for v in row])
|
||||||
|
|
||||||
|
if len(rows) < 10:
|
||||||
|
return {"n_outliers": 0, "note": "datos insuficientes"}
|
||||||
|
|
||||||
|
matrix = np.asarray(rows, dtype=float)
|
||||||
|
n_rows_used = matrix.shape[0]
|
||||||
|
n_features = matrix.shape[1]
|
||||||
|
|
||||||
|
# Estandariza para que ninguna columna domine por escala.
|
||||||
|
scaled = StandardScaler().fit_transform(matrix)
|
||||||
|
|
||||||
|
model = IsolationForest(contamination=contamination, random_state=0)
|
||||||
|
labels = model.fit_predict(scaled) # -1 = outlier, 1 = inlier
|
||||||
|
# decision_function: cuanto menor, mas anomalo. Outlier <=> score < 0
|
||||||
|
# tras el ajuste de offset_ que aplica sklearn (score = raw - offset_).
|
||||||
|
scores = model.decision_function(scaled)
|
||||||
|
threshold = float(model.offset_)
|
||||||
|
|
||||||
|
outlier_idx = [i for i, lab in enumerate(labels) if lab == -1]
|
||||||
|
# Mas anomalo primero (score mas bajo primero).
|
||||||
|
outlier_idx.sort(key=lambda i: scores[i])
|
||||||
|
|
||||||
|
n_outliers = len(outlier_idx)
|
||||||
|
outlier_rows = [
|
||||||
|
{"row_index": int(i), "score": float(scores[i])}
|
||||||
|
for i in outlier_idx[:max_report]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n_outliers": n_outliers,
|
||||||
|
"outlier_pct": round(100.0 * n_outliers / n_rows_used, 4),
|
||||||
|
"outlier_rows": outlier_rows,
|
||||||
|
"threshold": threshold,
|
||||||
|
"n_rows_used": n_rows_used,
|
||||||
|
"n_features": n_features,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests para isolation_forest_outliers."""
|
||||||
|
|
||||||
|
from isolation_forest_outliers import isolation_forest_outliers
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_with_three_far_points_flags_them():
|
||||||
|
# Nube densa alrededor del origen.
|
||||||
|
xs = [0.1, -0.2, 0.0, 0.3, -0.1, 0.2, -0.3, 0.05, -0.15, 0.25, 0.0, -0.05]
|
||||||
|
ys = [0.0, 0.1, -0.1, 0.2, -0.2, 0.05, -0.05, 0.15, -0.25, 0.1, 0.0, 0.2]
|
||||||
|
n_cloud = len(xs)
|
||||||
|
|
||||||
|
# 3 puntos claramente alejados de la nube (outliers multivariante).
|
||||||
|
far = [(9.0, 9.5), (-8.5, -9.0), (10.0, -8.0)]
|
||||||
|
for fx, fy in far:
|
||||||
|
xs.append(fx)
|
||||||
|
ys.append(fy)
|
||||||
|
far_indices = {n_cloud, n_cloud + 1, n_cloud + 2}
|
||||||
|
|
||||||
|
result = isolation_forest_outliers(
|
||||||
|
{"x": xs, "y": ys}, contamination=0.2, max_report=50
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "note" not in result
|
||||||
|
assert result["n_rows_used"] == len(xs)
|
||||||
|
assert result["n_features"] == 2
|
||||||
|
assert result["n_outliers"] >= 3
|
||||||
|
|
||||||
|
reported = {row["row_index"] for row in result["outlier_rows"]}
|
||||||
|
# Los 3 puntos lejanos deben estar entre los outliers detectados.
|
||||||
|
assert far_indices.issubset(reported)
|
||||||
|
|
||||||
|
# outlier_rows ordenadas: mas anomalo (score mas bajo) primero.
|
||||||
|
scores = [row["score"] for row in result["outlier_rows"]]
|
||||||
|
assert scores == sorted(scores)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_columns_returns_note():
|
||||||
|
# Una sola columna numerica -> multivariante no aplica.
|
||||||
|
result = isolation_forest_outliers(
|
||||||
|
{"x": list(range(20))}, contamination=0.05
|
||||||
|
)
|
||||||
|
assert result == {"n_outliers": 0, "note": "datos insuficientes"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_rows_returns_note():
|
||||||
|
# Dos columnas pero <10 filas validas.
|
||||||
|
result = isolation_forest_outliers(
|
||||||
|
{"x": [1.0, 2.0, 3.0, 4.0], "y": [4.0, 3.0, 2.0, 1.0]},
|
||||||
|
contamination=0.05,
|
||||||
|
)
|
||||||
|
assert result == {"n_outliers": 0, "note": "datos insuficientes"}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: kmeans_segments
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def kmeans_segments(columns: dict, k_min: int = 2, k_max: int = 8) -> dict"
|
||||||
|
description: "Detecta segmentos naturales con KMeans eligiendo el mejor k automaticamente por silhouette. Estandariza, descarta filas con None y prueba k de k_min a min(k_max, n_rows-1)."
|
||||||
|
tags: [eda, models, kmeans, clustering, segmentation, silhouette, unsupervised]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [numpy, scikit-learn]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_three_separated_blobs_finds_k3", "test_insufficient_rows_returns_note", "test_insufficient_numeric_columns_returns_note", "test_rows_with_none_are_dropped"]
|
||||||
|
test_file_path: "python/functions/datascience/kmeans_segments_test.py"
|
||||||
|
file_path: "python/functions/datascience/kmeans_segments.py"
|
||||||
|
params:
|
||||||
|
- name: columns
|
||||||
|
desc: "dict {col_name: [valores numericos]} con todas las listas alineadas por fila (misma longitud). Solo se usan columnas numericas; las no numericas se ignoran. Las filas con algun None se descartan."
|
||||||
|
- name: k_min
|
||||||
|
desc: "Numero minimo de clusters a probar. Default 2. El minimo efectivo de filas validas requerido es k_min*2."
|
||||||
|
- name: k_max
|
||||||
|
desc: "Numero maximo de clusters a probar. Default 8. Se acota internamente a min(k_max, n_rows_validas-1)."
|
||||||
|
output: "dict con best_k (k de mayor silhouette), silhouette (score del mejor k), scores_by_k (lista de {k, silhouette, inertia} por cada k probado), cluster_sizes (tamano de cada cluster del mejor modelo), centers (centroides en espacio estandarizado), n_rows_used (filas validas) y n_features (columnas numericas). Si hay <2 columnas numericas o <k_min*2 filas validas devuelve {best_k: 0, note: 'datos insuficientes'}."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience.kmeans_segments import kmeans_segments
|
||||||
|
|
||||||
|
# Tres grupos claramente separados en 2D.
|
||||||
|
g1 = [(0.0, 0.0)] * 30
|
||||||
|
g2 = [(10.0, 10.0)] * 30
|
||||||
|
g3 = [(0.0, 10.0)] * 30
|
||||||
|
pts = g1 + g2 + g3
|
||||||
|
columns = {
|
||||||
|
"x": [p[0] for p in pts],
|
||||||
|
"y": [p[1] for p in pts],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = kmeans_segments(columns, k_min=2, k_max=6)
|
||||||
|
print(result["best_k"]) # 3
|
||||||
|
print(round(result["silhouette"], 2)) # ~1.0 (grupos perfectos)
|
||||||
|
print(result["cluster_sizes"]) # [30, 30, 30] (en algun orden)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando, durante un EDA, quieras descubrir cuantos segmentos naturales hay en un
|
||||||
|
conjunto de columnas numericas sin saber el numero de grupos de antemano: clientes por
|
||||||
|
comportamiento, productos por metricas, regiones por indicadores. Elige el k optimo por
|
||||||
|
ti via silhouette, asi que no tienes que fijarlo a mano. Pasale solo las columnas
|
||||||
|
numericas relevantes alineadas por fila.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
Funcion pura y determinista (KMeans con random_state=0 y n_init=10), pero requiere
|
||||||
|
`numpy` y `scikit-learn` instalados. Los centroides (`centers`) estan en el espacio
|
||||||
|
estandarizado (z-scores), no en las unidades originales de las columnas. La silhouette
|
||||||
|
puede ser baja o negativa si los datos no tienen estructura de cluster real; un best_k
|
||||||
|
alto con silhouette baja sugiere ausencia de segmentacion clara.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Estandariza con StandardScaler antes de clusterizar para que todas las features pesen
|
||||||
|
igual. Para cada k en [k_min, min(k_max, n_rows-1)] ajusta KMeans y calcula silhouette;
|
||||||
|
devuelve el modelo con mayor silhouette. Guardas de datos insuficientes: <2 columnas
|
||||||
|
numericas o <k_min*2 filas validas devuelven {best_k: 0, note: "datos insuficientes"}
|
||||||
|
sin lanzar excepcion.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Detección de segmentos naturales con KMeans + selección automática de k por silhouette."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
|
||||||
|
|
||||||
|
def kmeans_segments(columns: dict, k_min: int = 2, k_max: int = 8) -> dict:
|
||||||
|
"""Detecta segmentos naturales en columnas numéricas con KMeans.
|
||||||
|
|
||||||
|
Estandariza las features, descarta las filas con algún valor None, y prueba
|
||||||
|
cada k en el rango [k_min, min(k_max, n_rows-1)] eligiendo el de mayor
|
||||||
|
silhouette. Determinista: KMeans usa random_state=0 y n_init fijo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
columns: dict {col_name: [valores numéricos]} con todas las listas
|
||||||
|
alineadas por fila (misma longitud).
|
||||||
|
k_min: número mínimo de clusters a probar (>= 2).
|
||||||
|
k_max: número máximo de clusters a probar (se acota a n_rows-1).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- best_k: k con mejor silhouette.
|
||||||
|
- silhouette: silhouette del mejor k.
|
||||||
|
- scores_by_k: lista de {k, silhouette, inertia} por cada k probado.
|
||||||
|
- cluster_sizes: tamaño de cada cluster del mejor modelo.
|
||||||
|
- centers: centroides del mejor modelo en el espacio estandarizado.
|
||||||
|
- n_rows_used: filas válidas usadas tras descartar None.
|
||||||
|
- n_features: número de columnas numéricas usadas.
|
||||||
|
Si hay menos de 2 columnas numéricas o menos de k_min*2 filas válidas,
|
||||||
|
devuelve {"best_k": 0, "note": "datos insuficientes"} sin lanzar error.
|
||||||
|
"""
|
||||||
|
# Quedarse solo con columnas cuyos valores sean numéricos (o None).
|
||||||
|
numeric_cols: list[str] = []
|
||||||
|
for name, values in columns.items():
|
||||||
|
ok = True
|
||||||
|
for v in values:
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
if ok:
|
||||||
|
numeric_cols.append(name)
|
||||||
|
|
||||||
|
if len(numeric_cols) < 2:
|
||||||
|
return {"best_k": 0, "note": "datos insuficientes"}
|
||||||
|
|
||||||
|
# Construir matriz alineada por fila y descartar filas con algún None.
|
||||||
|
col_lists = [columns[name] for name in numeric_cols]
|
||||||
|
n_rows_total = min(len(c) for c in col_lists)
|
||||||
|
rows: list[list[float]] = []
|
||||||
|
for i in range(n_rows_total):
|
||||||
|
row = [col_lists[j][i] for j in range(len(numeric_cols))]
|
||||||
|
if any(v is None for v in row):
|
||||||
|
continue
|
||||||
|
rows.append([float(v) for v in row])
|
||||||
|
|
||||||
|
n_rows_used = len(rows)
|
||||||
|
n_features = len(numeric_cols)
|
||||||
|
|
||||||
|
if n_rows_used < k_min * 2:
|
||||||
|
return {"best_k": 0, "note": "datos insuficientes"}
|
||||||
|
|
||||||
|
X = np.asarray(rows, dtype=float)
|
||||||
|
X_scaled = StandardScaler().fit_transform(X)
|
||||||
|
|
||||||
|
upper_k = min(k_max, n_rows_used - 1)
|
||||||
|
if upper_k < k_min:
|
||||||
|
return {"best_k": 0, "note": "datos insuficientes"}
|
||||||
|
|
||||||
|
scores_by_k: list[dict] = []
|
||||||
|
best = None # (silhouette, k, model, labels)
|
||||||
|
for k in range(k_min, upper_k + 1):
|
||||||
|
model = KMeans(n_clusters=k, n_init=10, random_state=0)
|
||||||
|
labels = model.fit_predict(X_scaled)
|
||||||
|
# silhouette necesita al menos 2 clusters efectivos.
|
||||||
|
if len(set(labels)) < 2:
|
||||||
|
sil = -1.0
|
||||||
|
else:
|
||||||
|
sil = float(silhouette_score(X_scaled, labels))
|
||||||
|
scores_by_k.append(
|
||||||
|
{"k": k, "silhouette": sil, "inertia": float(model.inertia_)}
|
||||||
|
)
|
||||||
|
if best is None or sil > best[0]:
|
||||||
|
best = (sil, k, model, labels)
|
||||||
|
|
||||||
|
best_sil, best_k, best_model, best_labels = best
|
||||||
|
cluster_sizes = [int(np.sum(best_labels == c)) for c in range(best_k)]
|
||||||
|
centers = [[float(x) for x in center] for center in best_model.cluster_centers_]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"best_k": best_k,
|
||||||
|
"silhouette": best_sil,
|
||||||
|
"scores_by_k": scores_by_k,
|
||||||
|
"cluster_sizes": cluster_sizes,
|
||||||
|
"centers": centers,
|
||||||
|
"n_rows_used": n_rows_used,
|
||||||
|
"n_features": n_features,
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tests para kmeans_segments."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from kmeans_segments import kmeans_segments
|
||||||
|
|
||||||
|
|
||||||
|
def _three_blobs(seed: int = 0, per_blob: int = 40):
|
||||||
|
"""Genera 3 blobs gaussianos bien separados en 2D, alineados por fila."""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
centers = [(0.0, 0.0), (12.0, 12.0), (0.0, 12.0)]
|
||||||
|
xs: list[float] = []
|
||||||
|
ys: list[float] = []
|
||||||
|
for cx, cy in centers:
|
||||||
|
pts = rng.normal(loc=(cx, cy), scale=0.4, size=(per_blob, 2))
|
||||||
|
xs.extend(float(p[0]) for p in pts)
|
||||||
|
ys.extend(float(p[1]) for p in pts)
|
||||||
|
return {"x": xs, "y": ys}
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_separated_blobs_finds_k3():
|
||||||
|
columns = _three_blobs(seed=0, per_blob=40)
|
||||||
|
result = kmeans_segments(columns, k_min=2, k_max=8)
|
||||||
|
|
||||||
|
assert result["best_k"] == 3
|
||||||
|
assert result["silhouette"] > 0.5
|
||||||
|
assert result["n_features"] == 2
|
||||||
|
assert result["n_rows_used"] == 120
|
||||||
|
assert sum(result["cluster_sizes"]) == 120
|
||||||
|
assert len(result["centers"]) == 3
|
||||||
|
# scores_by_k cubre todo el rango probado.
|
||||||
|
ks = [s["k"] for s in result["scores_by_k"]]
|
||||||
|
assert ks == list(range(2, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_rows_returns_note():
|
||||||
|
# Solo 3 filas válidas, k_min*2 = 4 -> insuficiente.
|
||||||
|
columns = {"x": [1.0, 2.0, 3.0], "y": [1.0, 2.0, 3.0]}
|
||||||
|
result = kmeans_segments(columns, k_min=2, k_max=8)
|
||||||
|
|
||||||
|
assert result["best_k"] == 0
|
||||||
|
assert result["note"] == "datos insuficientes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_numeric_columns_returns_note():
|
||||||
|
# Una sola columna numérica; la otra es texto -> menos de 2 numéricas.
|
||||||
|
columns = {
|
||||||
|
"x": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
|
||||||
|
"label": ["a", "b", "c", "d", "e", "f"],
|
||||||
|
}
|
||||||
|
result = kmeans_segments(columns, k_min=2, k_max=8)
|
||||||
|
|
||||||
|
assert result["best_k"] == 0
|
||||||
|
assert result["note"] == "datos insuficientes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_with_none_are_dropped():
|
||||||
|
columns = _three_blobs(seed=1, per_blob=40)
|
||||||
|
# Inyectar None en una fila; debe descartarse, dejando 119.
|
||||||
|
columns["x"][0] = None
|
||||||
|
result = kmeans_segments(columns, k_min=2, k_max=8)
|
||||||
|
|
||||||
|
assert result["best_k"] == 3
|
||||||
|
assert result["n_rows_used"] == 119
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
id: mutual_info_columns_py_datascience
|
||||||
|
name: mutual_info_columns
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def mutual_info_columns(a: list, b: list, a_numeric: bool = False, b_numeric: bool = False, bins: int = 10, normalized: bool = True) -> float"
|
||||||
|
description: "Informacion mutua entre dos columnas pareadas del grupo eda: detector general de dependencia que capta relaciones de cualquier forma (lineal o no, num-num, cat-cat, num-cat). Discretiza numericas por cuantiles, factoriza categoricas, devuelve NMI en [0,1] (normalized) o MI en nats. Funcion pura."
|
||||||
|
tags: [eda, correlation, mutual-information, association, profiling, datascience]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
example: |
|
||||||
|
from datascience import mutual_info_columns
|
||||||
|
a = [i - 1000 for i in range(2000)]
|
||||||
|
b = [abs(x) for x in a] # V-shape: no lineal, Pearson ~ 0
|
||||||
|
mutual_info_columns(a, b, a_numeric=True, b_numeric=True) # ~0.69, NMI alto
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_identical_categoricals_nmi_near_one"
|
||||||
|
- "test_nonlinear_numeric_relation_has_positive_nmi"
|
||||||
|
- "test_independent_columns_near_zero"
|
||||||
|
- "test_fewer_than_two_pairs_returns_zero"
|
||||||
|
- "test_none_pairs_are_discarded"
|
||||||
|
- "test_constant_column_returns_zero_when_normalized"
|
||||||
|
- "test_unnormalized_returns_mi_in_nats"
|
||||||
|
- "test_always_returns_float_never_none"
|
||||||
|
test_file_path: "python/functions/datascience/mutual_info_columns_test.py"
|
||||||
|
file_path: "python/functions/datascience/mutual_info_columns.py"
|
||||||
|
params:
|
||||||
|
- name: a
|
||||||
|
desc: >
|
||||||
|
Lista de valores de la primera columna, pareada posicion a posicion con
|
||||||
|
`b`. None se descarta (junto con su contraparte en `b`).
|
||||||
|
- name: b
|
||||||
|
desc: >
|
||||||
|
Lista de valores de la segunda columna, pareada con `a` (mismo criterio
|
||||||
|
de descarte de None).
|
||||||
|
- name: a_numeric
|
||||||
|
desc: >
|
||||||
|
Si True, `a` se discretiza en `bins` cubos por cuantiles antes de medir;
|
||||||
|
si False se trata como categorica (factorizacion valor->id entero).
|
||||||
|
- name: b_numeric
|
||||||
|
desc: "Idem que a_numeric pero para la columna `b`."
|
||||||
|
- name: bins
|
||||||
|
desc: >
|
||||||
|
Numero de cubos por cuantiles para las columnas numericas. Cuantiles
|
||||||
|
repetidos colapsan en menos cubos (columnas de baja variacion).
|
||||||
|
- name: normalized
|
||||||
|
desc: >
|
||||||
|
Si True devuelve la informacion mutua normalizada NMI = MI / sqrt(H(a)*H(b))
|
||||||
|
en [0, 1] (1 = dependencia total). Si False devuelve la MI cruda en nats.
|
||||||
|
output: >
|
||||||
|
float. NMI en [0, 1] cuando normalized=True; MI en nats (>= 0) cuando
|
||||||
|
normalized=False. Devuelve 0.0 si hay menos de 2 pares validos o si alguna
|
||||||
|
columna discretizada tiene entropia 0 (constante) bajo normalized. Nunca
|
||||||
|
devuelve None ni lanza excepcion.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import mutual_info_columns
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Relacion NO lineal: b = |a| (forma de V). Pearson ~ 0, pero la dependencia es real.
|
||||||
|
a = [i - 1000 for i in range(2000)] # -1000 .. 999
|
||||||
|
b = [abs(x) for x in a]
|
||||||
|
|
||||||
|
mutual_info_columns(a, b, a_numeric=True, b_numeric=True)
|
||||||
|
# -> ~0.69 (NMI alto: a determina b por completo dentro de cada cubo)
|
||||||
|
|
||||||
|
# Comparalo con la correlacion lineal, que no ve la relacion:
|
||||||
|
from datascience import pearson
|
||||||
|
pearson([float(x) for x in a], [float(x) for x in b]) # -> ~0.0
|
||||||
|
|
||||||
|
# Tambien capta relaciones oscilantes resueltas por los bins:
|
||||||
|
ax = [2 * math.pi * i / 2000 for i in range(2000)] # un periodo de seno
|
||||||
|
bx = [1.0 if math.sin(x) >= 0 else -1.0 for x in ax]
|
||||||
|
mutual_info_columns(ax, bx, a_numeric=True) # -> ~0.55, Pearson ~ -0.87
|
||||||
|
|
||||||
|
# Dos categoricas identicas -> dependencia total.
|
||||||
|
c = ["red", "green", "blue", "red", "green", "blue"]
|
||||||
|
mutual_info_columns(c, list(c)) # -> ~1.0
|
||||||
|
|
||||||
|
# MI cruda en nats (sin normalizar).
|
||||||
|
mutual_info_columns(c, list(c), normalized=False) # -> ~log(3) nats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites un **detector general de dependencia** entre dos columnas y no
|
||||||
|
sepas (o no quieras asumir) la forma de la relacion. Pearson solo ve lineal y
|
||||||
|
solo num-num; `cramers_v` solo cat-cat. La informacion mutua funciona para
|
||||||
|
**cualquier par de tipos** (num-num, cat-cat, num-cat) y capta relaciones no
|
||||||
|
lineales (sinusoidales, escalon, agrupamientos) que la correlacion lineal pasa
|
||||||
|
por alto. Es la celda "comodin" de una matriz de asociacion en un EDA: usala
|
||||||
|
para descubrir relaciones ocultas antes de modelar, o para rankear que columnas
|
||||||
|
predicen mejor un objetivo. Activa `a_numeric`/`b_numeric` por columna segun su
|
||||||
|
tipo y deja `normalized=True` para obtener un score comparable en [0, 1].
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura y determinista: misma entrada -> misma salida (sin estado, sin
|
||||||
|
I/O, sin aleatoriedad; `sklearn.metrics.mutual_info_score` es determinista).
|
||||||
|
|
||||||
|
- **Discretizacion**: numericas via `np.digitize` sobre los bordes de cuantil
|
||||||
|
(`np.quantile`); categoricas via mapa valor->id en orden de aparicion. La
|
||||||
|
eleccion de `bins` afecta la estimacion de MI en columnas numericas: pocos
|
||||||
|
bins suavizan, muchos bins capturan mas estructura pero inflan el ruido en
|
||||||
|
muestras pequenas. Una relacion que oscila mas rapido que la resolucion de
|
||||||
|
los bins (p.ej. un seno de muchos periodos sobre el rango de `a`) da NMI bajo
|
||||||
|
con `bins` pequeno aunque la dependencia sea real: sube `bins` para resolverla.
|
||||||
|
- **Sesgo de la MI**: en muestras pequenas la MI cruda tiende a sobreestimarse
|
||||||
|
(sesgo positivo). La normalizacion NMI lo atenua parcialmente pero no lo
|
||||||
|
elimina; para columnas independientes con muchos bins y pocos datos el valor
|
||||||
|
puede no ser exactamente 0.
|
||||||
|
- **Entropia 0**: si una columna discretizada es constante, H = 0 y la NMI se
|
||||||
|
define como 0.0 (no hay informacion compartida medible); la MI cruda tambien
|
||||||
|
es 0 en ese caso.
|
||||||
|
- **NMI** = MI / sqrt(H(a) * H(b)), clampada a [0, 1] por seguridad numerica.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Informacion mutua entre dos columnas pareadas (relaciones lineales y no lineales).
|
||||||
|
|
||||||
|
Funcion pura del grupo eda. Mide la dependencia estadistica general entre dos
|
||||||
|
columnas (numericas, categoricas o mezcla), capturando relaciones de cualquier
|
||||||
|
forma -- no solo lineales como Pearson. Es la metrica "general" de la matriz de
|
||||||
|
asociacion: complementa a `pearson` (solo lineal num-num) y `cramers_v` (solo
|
||||||
|
cat-cat).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.metrics import mutual_info_score
|
||||||
|
|
||||||
|
|
||||||
|
def _discretize(values: list, numeric: bool, bins: int) -> list:
|
||||||
|
"""Discretiza una columna a etiquetas enteras.
|
||||||
|
|
||||||
|
Columnas numericas -> `bins` cubos por cuantiles (np.digitize sobre los
|
||||||
|
bordes de cuantil). Columnas categoricas -> factorizacion valor->id.
|
||||||
|
"""
|
||||||
|
if numeric:
|
||||||
|
arr = np.asarray(values, dtype=float)
|
||||||
|
# Bordes interiores por cuantiles (excluye 0 y 1 para usar digitize).
|
||||||
|
qs = np.linspace(0.0, 1.0, bins + 1)[1:-1]
|
||||||
|
if qs.size == 0:
|
||||||
|
# bins <= 1 -> todo cae en un unico cubo.
|
||||||
|
return [0] * len(arr)
|
||||||
|
edges = np.quantile(arr, qs)
|
||||||
|
# Bordes unicos: cuantiles repetidos (columnas con poca variacion)
|
||||||
|
# colapsan en menos cubos, lo cual es correcto (menos entropia).
|
||||||
|
edges = np.unique(edges)
|
||||||
|
return list(np.digitize(arr, edges))
|
||||||
|
# Categorica: mapa valor -> id entero, en orden de aparicion.
|
||||||
|
ids: dict = {}
|
||||||
|
out = []
|
||||||
|
for v in values:
|
||||||
|
if v not in ids:
|
||||||
|
ids[v] = len(ids)
|
||||||
|
out.append(ids[v])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _entropy(labels: list) -> float:
|
||||||
|
"""Entropia de Shannon (nats) de una secuencia de etiquetas."""
|
||||||
|
n = len(labels)
|
||||||
|
if n == 0:
|
||||||
|
return 0.0
|
||||||
|
h = 0.0
|
||||||
|
for c in Counter(labels).values():
|
||||||
|
p = c / n
|
||||||
|
h -= p * math.log(p)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def mutual_info_columns(
|
||||||
|
a: list,
|
||||||
|
b: list,
|
||||||
|
a_numeric: bool = False,
|
||||||
|
b_numeric: bool = False,
|
||||||
|
bins: int = 10,
|
||||||
|
normalized: bool = True,
|
||||||
|
) -> float:
|
||||||
|
"""Informacion mutua entre dos columnas pareadas posicion a posicion.
|
||||||
|
|
||||||
|
Empareja `a` y `b`, descarta los pares donde cualquiera de los dos sea None,
|
||||||
|
discretiza cada columna (numericas por cuantiles, categoricas por
|
||||||
|
factorizacion) y calcula la informacion mutua. Captura relaciones de
|
||||||
|
cualquier forma (lineal o no, num-num, cat-cat, num-cat).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a: lista de valores de la primera columna (None se descarta).
|
||||||
|
b: lista de valores pareada con `a` (mismo criterio).
|
||||||
|
a_numeric: si True, `a` se discretiza en `bins` cuantiles; si False se
|
||||||
|
factoriza como categorica.
|
||||||
|
b_numeric: idem para `b`.
|
||||||
|
bins: numero de cubos por cuantiles para columnas numericas.
|
||||||
|
normalized: si True devuelve la NMI = MI / sqrt(H(a)*H(b)) en [0, 1]
|
||||||
|
(1 = dependencia total). Si False devuelve la MI cruda en nats.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float. NMI en [0, 1] si normalized; MI en nats (>= 0) si no. Devuelve
|
||||||
|
0.0 si hay menos de 2 pares validos o si alguna columna discretizada
|
||||||
|
tiene entropia 0 (constante) bajo normalized. Nunca None ni excepcion.
|
||||||
|
"""
|
||||||
|
pairs = [
|
||||||
|
(x, y)
|
||||||
|
for x, y in zip(a, b)
|
||||||
|
if x is not None and y is not None
|
||||||
|
]
|
||||||
|
if len(pairs) < 2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
a_vals = [x for x, _ in pairs]
|
||||||
|
b_vals = [y for _, y in pairs]
|
||||||
|
|
||||||
|
a_disc = _discretize(a_vals, a_numeric, bins)
|
||||||
|
b_disc = _discretize(b_vals, b_numeric, bins)
|
||||||
|
|
||||||
|
mi = float(mutual_info_score(a_disc, b_disc))
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
return max(0.0, mi)
|
||||||
|
|
||||||
|
ha = _entropy(a_disc)
|
||||||
|
hb = _entropy(b_disc)
|
||||||
|
if ha <= 0.0 or hb <= 0.0:
|
||||||
|
# Alguna columna es constante -> no hay informacion compartida medible.
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
nmi = mi / math.sqrt(ha * hb)
|
||||||
|
# Clampa a [0, 1] por seguridad numerica.
|
||||||
|
return max(0.0, min(1.0, nmi))
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Tests para mutual_info_columns."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from mutual_info_columns import mutual_info_columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_categoricals_nmi_near_one():
|
||||||
|
a = ["x", "y", "z", "x", "y", "z", "x", "y", "z", "w", "w", "w"]
|
||||||
|
b = list(a) # b == a -> dependencia total
|
||||||
|
nmi = mutual_info_columns(a, b)
|
||||||
|
assert nmi > 0.99
|
||||||
|
assert nmi <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonlinear_numeric_relation_has_positive_nmi():
|
||||||
|
# b = sign(sin(a)) -> relacion NO lineal fuerte (Pearson ~ 0).
|
||||||
|
rng = random.Random(11)
|
||||||
|
a = [rng.uniform(0.0, 6.0 * math.pi) for _ in range(2000)]
|
||||||
|
b = [1.0 if math.sin(x) >= 0 else -1.0 for x in a]
|
||||||
|
nmi = mutual_info_columns(a, b, a_numeric=True, b_numeric=False, bins=20)
|
||||||
|
assert nmi > 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_columns_near_zero():
|
||||||
|
rng = random.Random(42)
|
||||||
|
a = [rng.gauss(0.0, 1.0) for _ in range(3000)]
|
||||||
|
b = [rng.gauss(0.0, 1.0) for _ in range(3000)]
|
||||||
|
nmi = mutual_info_columns(a, b, a_numeric=True, b_numeric=True, bins=10)
|
||||||
|
assert 0.0 <= nmi < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fewer_than_two_pairs_returns_zero():
|
||||||
|
assert mutual_info_columns([], []) == 0.0
|
||||||
|
assert mutual_info_columns(["a"], ["b"]) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_pairs_are_discarded():
|
||||||
|
a = ["x", None, "y", "x", None, "y", "x", "y"]
|
||||||
|
b = ["x", "z", "y", "x", "z", "y", None, "y"]
|
||||||
|
nmi = mutual_info_columns(a, b)
|
||||||
|
assert isinstance(nmi, float)
|
||||||
|
assert 0.0 <= nmi <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_column_returns_zero_when_normalized():
|
||||||
|
a = ["c"] * 20 # entropia 0
|
||||||
|
b = ["x", "y"] * 10
|
||||||
|
assert mutual_info_columns(a, b) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unnormalized_returns_mi_in_nats():
|
||||||
|
a = ["x", "y", "z", "x", "y", "z", "x", "y", "z"]
|
||||||
|
b = list(a)
|
||||||
|
mi = mutual_info_columns(a, b, normalized=False)
|
||||||
|
# MI cruda de columnas identicas = entropia ~ log(3) nats.
|
||||||
|
assert mi > 0.9
|
||||||
|
assert mi == mi # no NaN
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_returns_float_never_none():
|
||||||
|
assert isinstance(mutual_info_columns(["a", "b"], ["a", "b"]), float)
|
||||||
|
assert isinstance(mutual_info_columns([None], [None]), float)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
id: normality_tests_py_datascience
|
||||||
|
name: normality_tests
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def normality_tests(values: list, alpha: float = 0.05) -> dict"
|
||||||
|
description: "Tests de normalidad (Jarque-Bera, D'Agostino-Pearson, Shapiro-Wilk) sobre una columna numerica para decidir si sigue una distribucion normal. Descarta None/NaN/no-numericos y reporta consenso de los tests aplicables."
|
||||||
|
tags: [eda, models, statistics, normality, hypothesis-test, distribution, shapiro, jarque-bera]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math, scipy]
|
||||||
|
example: |
|
||||||
|
from normality_tests import normality_tests
|
||||||
|
import numpy as np
|
||||||
|
result = normality_tests(np.random.default_rng(0).normal(0, 1, 1000).tolist())
|
||||||
|
# result["is_normal"] == True
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_normal_large_sample_is_normal"
|
||||||
|
- "test_skewed_sample_is_not_normal"
|
||||||
|
- "test_small_sample_returns_note"
|
||||||
|
- "test_drops_none_nan_and_non_numeric"
|
||||||
|
- "test_shapiro_skipped_above_5000"
|
||||||
|
- "test_normal_below_eight_after_cleaning_is_note"
|
||||||
|
test_file_path: "python/functions/datascience/normality_tests_test.py"
|
||||||
|
file_path: "python/functions/datascience/normality_tests.py"
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "Lista de valores numericos (una columna). None, NaN, infinitos y no-numericos se descartan antes de testear. Los booleanos se excluyen."
|
||||||
|
- name: alpha
|
||||||
|
desc: "Nivel de significancia por test (default 0.05). normal = p > alpha (no se rechaza H0 de normalidad)."
|
||||||
|
output: >
|
||||||
|
dict. Si n < 8 (tras limpiar): {n, note: "muestra insuficiente", is_normal: None}.
|
||||||
|
En otro caso: {n, jarque_bera:{stat,p,normal}, dagostino:{stat,p,normal},
|
||||||
|
shapiro:{stat,p,normal}|None (solo 3<=n<=5000), is_normal:bool}. En cada test
|
||||||
|
normal = p > alpha. is_normal es el consenso (todos los tests aplicables coinciden
|
||||||
|
en que los datos son normales).
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from normality_tests import normality_tests
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Muestra normal -> is_normal True
|
||||||
|
normal = np.random.default_rng(0).normal(loc=10, scale=2, size=1000).tolist()
|
||||||
|
r = normality_tests(normal)
|
||||||
|
r["is_normal"] # True
|
||||||
|
r["jarque_bera"]["normal"] # True
|
||||||
|
r["shapiro"]["p"] > 0.05 # True
|
||||||
|
|
||||||
|
# Muestra exponencial (sesgada) -> is_normal False
|
||||||
|
expo = np.random.default_rng(7).exponential(scale=1.0, size=1000).tolist()
|
||||||
|
normality_tests(expo)["is_normal"] # False
|
||||||
|
|
||||||
|
# Muestra insuficiente
|
||||||
|
normality_tests([1, 2, 3, 4, 5])
|
||||||
|
# {"n": 5, "note": "muestra insuficiente", "is_normal": None}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de aplicar un test parametrico o un estimador que asume normalidad
|
||||||
|
(t-test, ANOVA, regresion OLS con intervalos de confianza, z-score para
|
||||||
|
outliers): comprueba primero si la columna es realmente normal. Tambien en la
|
||||||
|
fase EDA para decidir entre media (datos normales) y mediana/transformacion log
|
||||||
|
(datos sesgados), y como gate barato antes de elegir un modelo que asuma
|
||||||
|
errores gaussianos.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion pura y determinista para una entrada dada, pero los p-valores
|
||||||
|
dependen del tamano de muestra: con n muy grande casi cualquier desviacion
|
||||||
|
minuscula de la normalidad rechaza H0 (poder estadistico alto). Interpreta
|
||||||
|
`is_normal` junto al tamano `n` y al contexto, no como verdad absoluta.
|
||||||
|
- Shapiro-Wilk solo se ejecuta para `3 <= n <= 5000`; fuera de ese rango su
|
||||||
|
clave es `None` y `is_normal` se decide solo con Jarque-Bera y D'Agostino.
|
||||||
|
- Con `n < 8` no se ejecuta ningun test: devuelve `note` e `is_normal: None`.
|
||||||
|
Cuenta el `n` tras limpiar (None/NaN/no-numericos descartados), no la longitud
|
||||||
|
bruta de la lista.
|
||||||
|
- D'Agostino-Pearson (`normaltest`) requiere internamente `n >= 8` para skew y
|
||||||
|
kurtosis; por eso el umbral de muestra insuficiente es 8.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user