feat(browser): auto-commit con 60 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:42:31 +02:00
parent 37aacfcfa9
commit 8742cb25be
71 changed files with 5660 additions and 192 deletions
+5 -1
View File
@@ -23,7 +23,9 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
**Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects, playgrounds y reports — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`.
**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/<p>/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006.
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
@@ -231,6 +233,8 @@ fn-registry/
docs/ # Specs de diseño
docs/templates/ # Plantillas de frontmatter
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado)
projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local)
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
```
+2
View File
@@ -40,3 +40,5 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/<p>/reports/`. Convencion + plantilla. ADR 0006. |
| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. |
+4 -1
View File
@@ -1,6 +1,6 @@
## Artefactos: termino colectivo
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez.
Tipos de artefacto:
@@ -11,6 +11,7 @@ Tipos de artefacto:
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
| **report** | `reports/`, `projects/<p>/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) |
Caracteristicas comunes de los artefactos:
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
@@ -18,6 +19,8 @@ Caracteristicas comunes de los artefactos:
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/<p>/reports/`.
### Cuando usar el termino
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
+76
View File
@@ -0,0 +1,76 @@
## Flow replay: guardar un flujo web como función reproducible
Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor
desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a
mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087
aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones
de un solo paso, no inflando funciones existentes.
Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con
el grupo `web-proxy`; destila y reproduce con `flow-replay`.
### El patrón: grabar → destilar → reproducir
1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano,
exportas el tramo a HAR (`query_mitm_flows --har`).
2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) →
`har_extract_calls_py_cybersecurity` (call specs reproducibles).
3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro):
| Nivel | Mecanismo | Cuándo |
|---|---|---|
| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. |
| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. |
| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. |
**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no
reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo
exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro.
### Flujo de autoría (cómo guardar una función-acción nueva)
1. Grabar el flujo y exportar el HAR del tramo.
2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el
HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción +
confirmación), descartando el resto.
3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las
reglas `extract` para los tokens que una respuesta genera y otro request consume.
4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1.
5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada
con verbo (`reboot_vps_server_<panel>`, `login_<panel>`, `export_<panel>_report`) que
internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros
del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el
dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno.
### Reglas duras de seguridad
- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no
indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por
`{{param}}`.
- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción.
- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar,
pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag
explícito (`confirm=True` / `--yes`) antes de disparar.
- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción
JSON es dot-path simple, no JSONPath completo.
### Anti-patrones
| Anti-patrón | Por qué es malo | Sustituir por |
|---|---|---|
| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción |
| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` |
| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla |
| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault |
| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored |
| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito |
### Relación con otras reglas
- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a
`fn-constructor`, no se escribe inline.
- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones
repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación.
- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP /
`fn run` / heredoc que importa).
- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0.
+1 -1
View File
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
### Excepciones
+78
View File
@@ -0,0 +1,78 @@
## Reports: reportes de trabajo como artefacto local
Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo.
Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia:
- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**.
- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`.
- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`.
### Qué NO es un report
| Es | Va a |
|---|---|
| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) |
| Norma operativa / convención | `.claude/rules/` (versionado) |
| Bitácora cronológica libre | `docs/diary/` (versionado) |
| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** |
Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia.
### Ubicación
Como cualquier artefacto, un report puede vivir en dos sitios:
| Ubicación | Para qué |
|---|---|
| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto |
| `projects/<p>/reports/` | Reportes del trabajo de un proyecto concreto |
Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …).
### Convención de nombre
```
NNNN-YYYY-MM-DD-slug-corto.md
```
- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003").
- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA).
- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`.
### Plantilla mínima
```markdown
# Report NNNN — Título
- **Fecha:** DD/MM/AAAA
- **Autor:** (agente/humano)
- **Ámbito:** (dominio/app/módulo tocado)
- **Estado:** done | parcial | bloqueado
## Resumen
Qué se hizo y el resultado, en 2-4 líneas.
## Cambios
Tabla o lista de lo tocado/creado, con el porqué.
## Verificación
Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia.
## Gaps / pendientes
Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.).
```
### Reglas
- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit.
- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`.
- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir.
- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio.
### Relación con otras reglas y ADRs
- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio).
- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito.
- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps.
- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`.
+7
View File
@@ -46,6 +46,13 @@ projects/*/
vaults/*/
!vaults/vault.yaml
# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a
# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en
# reports/ (raíz) o projects/<p>/reports/. Convención: .claude/rules/reports.md
reports/*
!reports/.gitkeep
projects/*/reports/
# Node / pnpm
**/node_modules/
@@ -0,0 +1,64 @@
---
name: reboot_all_claudes
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--go"
desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run."
- name: "--yes"
desc: "Alias de --go."
- name: "--resume-mode <resume|continue|none>"
desc: "Estrategia de reanudacion. resume (default): claude --resume <sessionId>. continue: claude --continue. none: sesion nueva en el mismo cwd."
- name: "--exclude-current"
desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude."
- name: "--only-idle"
desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy."
- name: "-h|--help"
desc: "Muestra el uso y termina."
output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/reboot_all_claudes.sh"
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory <cwd> zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre."
---
## Ejemplo
```bash
# Dry-run (default seguro): ver el plan sin tocar nada.
reboot_all_claudes
# Reiniciar de verdad todas las sesiones MENOS la terminal actual.
reboot_all_claudes --go --exclude-current
# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad.
reboot_all_claudes --go --only-idle
# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd.
reboot_all_claudes --go --resume-mode none
```
## Cuando usarla
Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`.
## Gotchas
- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes.<pid>.<ts>.sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual.
- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas.
- **Depende de `~/.claude/sessions/<PID>.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`.
- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code
# corriendo y las relanza retomando exactamente la sesion que tenian
# (claude --resume <sessionId>). Por defecto es DRY-RUN: imprime el plan sin
# tocar nada. Usar --go para ejecutarlo de verdad.
#
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito.
# - KITTY_PID del environ del proceso -> ventana kitty a cerrar.
# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos).
# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal.
set -euo pipefail
IFS=$' \t\n'
reboot_all_claudes() {
local mode="dry" # dry | go
local resume_mode="resume" # resume | continue | none
local exclude_current=0
local only_idle=0
# -----------------------------------------------------------------------
# Parseo de argumentos
# -----------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--go|--yes)
mode="go"
;;
--resume-mode)
shift
resume_mode="${1:-}"
case "$resume_mode" in
resume|continue|none) ;;
*)
echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2
return 2
;;
esac
;;
--exclude-current)
exclude_current=1
;;
--only-idle)
only_idle=1
;;
-h|--help)
cat <<'USAGE'
Uso: reboot_all_claudes [opciones]
Cierra todas las terminales con una sesion de Claude Code corriendo y las
relanza retomando la misma sesion (claude --resume <sessionId>).
Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan
y NO mata ni relanza nada.
Opciones:
--go, --yes Ejecuta de verdad (kills + relanzamientos detached).
--resume-mode <modo> resume (default) | continue | none.
resume -> claude --resume <sessionId>
continue -> claude --continue
none -> claude (sesion nueva en el mismo cwd)
--exclude-current No cierra ni relanza la terminal desde la que se invoca.
--only-idle Omite sesiones con status busy (no pierde turnos en vuelo).
-h, --help Muestra esta ayuda.
Ejemplos:
reboot_all_claudes # dry-run, ve el plan
reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal
USAGE
return 0
;;
*)
echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2
return 2
;;
esac
shift
done
# -----------------------------------------------------------------------
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
# -----------------------------------------------------------------------
local current_claude_pid=""
if [[ "$exclude_current" -eq 1 ]]; then
local walk="$$"
local guard=0
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
local comm=""
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
if [[ "$comm" == "claude" ]]; then
current_claude_pid="$walk"
break
fi
# campo 4 de /proc/<pid>/stat es el PPID
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
fi
# -----------------------------------------------------------------------
# Recolectar las sesiones vivas y validarlas.
# -----------------------------------------------------------------------
local sessions_dir="$HOME/.claude/sessions"
local pids=""
pids="$(pgrep -x claude 2>/dev/null || true)"
if [[ -z "$pids" ]]; then
echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
return 0
fi
# Arrays paralelos con el plan validado.
local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason
local pid
for pid in $pids; do
# Validacion 1: el proceso debe seguir vivo.
if ! kill -0 "$pid" 2>/dev/null; then
continue
fi
# Validacion 2: debe existir su JSON de sesion.
local json="$sessions_dir/$pid.json"
if [[ ! -f "$json" ]]; then
continue
fi
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
# Salida: lineas "clave=valor" en orden fijo.
local parsed=""
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as fh:
d = json.load(fh)
except Exception:
sys.exit(0)
print("sessionId=" + str(d.get("sessionId", "")))
print("cwd=" + str(d.get("cwd", "")))
print("status=" + str(d.get("status", "")))
print("procStart=" + str(d.get("procStart", "")))
PY
)"
[[ -z "$parsed" ]] && continue
local sid cwd status proc_start_json
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
[[ -z "$sid" ]] && continue
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
# con el campo 22 de /proc/<PID>/stat.
local proc_start_real=""
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
# JSON huerfano de un PID reciclado: omitir.
continue
fi
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
local kitty_pid=""
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
# Flags originales: leer cmdline, descartar argv0 (claude) y cualquier
# flag de resume/continue previo para no duplicarlos.
local raw_cmd=""
raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)"
local -a kept_flags=()
local first=1 tok skipnext=0
while IFS= read -r tok; do
[[ -z "$tok" ]] && continue
if [[ "$first" -eq 1 ]]; then
# argv0 (la ruta o nombre de claude) — descartar.
first=0
continue
fi
if [[ "$skipnext" -eq 1 ]]; then
skipnext=0
continue
fi
case "$tok" in
--resume|--continue|-r|-c)
# Resume/continue previos: omitir (y su posible valor para --resume).
if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then
skipnext=1
fi
continue
;;
esac
kept_flags+=("$tok")
done <<< "$raw_cmd"
# Construir la estrategia de resume.
local -a launch_args=()
case "$resume_mode" in
resume) launch_args=("--resume" "$sid") ;;
continue) launch_args=("--continue") ;;
none) launch_args=() ;;
esac
launch_args+=("${kept_flags[@]}")
# Comando claude final (para mostrar y ejecutar).
local claude_cmd="claude"
local a
for a in "${launch_args[@]}"; do
claude_cmd+=" $(printf '%q' "$a")"
done
# Decidir si se omite esta sesion del plan.
local skip=0 skipreason=""
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
skip=1
skipreason="terminal actual (--exclude-current)"
elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then
skip=1
skipreason="busy (--only-idle)"
fi
plan_pid+=("$pid")
plan_kitty+=("${kitty_pid:-}")
plan_status+=("${status:-?}")
plan_cwd+=("${cwd:-?}")
plan_sid+=("$sid")
plan_cmd+=("$claude_cmd")
plan_skip+=("$skip")
plan_skipreason+=("$skipreason")
done
local total="${#plan_pid[@]}"
if [[ "$total" -eq 0 ]]; then
echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)."
return 0
fi
# -----------------------------------------------------------------------
# Imprimir el plan (siempre, tanto en dry-run como en --go).
# -----------------------------------------------------------------------
echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}"
echo
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD"
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---"
local i busy_count=0 act_count=0
for ((i = 0; i < total; i++)); do
local accion="reinic"
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
accion="OMITE"
else
act_count=$((act_count + 1))
fi
[[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1))
printf '%-8s %-9s %-7s %-6s %-38s %s\n' \
"${plan_pid[$i]}" \
"${plan_kitty[$i]:-(none)}" \
"${plan_status[$i]}" \
"$accion" \
"${plan_sid[$i]}" \
"${plan_cwd[$i]}"
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
echo " -> omitida: ${plan_skipreason[$i]}"
else
echo " -> ${plan_cmd[$i]}"
fi
done
echo
# Aviso explicito de sesiones busy que SI se van a reiniciar.
if [[ "$only_idle" -eq 0 ]]; then
local warned=0
for ((i = 0; i < total; i++)); do
if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then
if [[ "$warned" -eq 0 ]]; then
echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo"
echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):"
warned=1
fi
echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}"
fi
done
[[ "$warned" -eq 1 ]] && echo
fi
# -----------------------------------------------------------------------
# DRY-RUN: parar aqui.
# -----------------------------------------------------------------------
if [[ "$mode" == "dry" ]]; then
echo "DRY-RUN: no se ha matado ni relanzado nada."
echo "Para ejecutar de verdad: reboot_all_claudes --go"
return 0
fi
if [[ "$act_count" -eq 0 ]]; then
echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)."
return 0
fi
# -----------------------------------------------------------------------
# MODO --go: construir un script desacoplado que mata las ventanas y
# relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre
# de la propia terminal (que es una de las victimas).
# -----------------------------------------------------------------------
local ts script log
ts="$(date +%s)"
script="/tmp/reboot_all_claudes.$$.$ts.sh"
log="/tmp/reboot_all_claudes.$ts.log"
{
echo '#!/usr/bin/env bash'
echo 'set -uo pipefail'
echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.'
echo 'sleep 1'
echo
for ((i = 0; i < total; i++)); do
[[ "${plan_skip[$i]}" -eq 1 ]] && continue
local kp="${plan_kitty[$i]}"
local cp="${plan_pid[$i]}"
local cwd="${plan_cwd[$i]}"
local cmd="${plan_cmd[$i]}"
echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---"
if [[ -n "$kp" ]]; then
# Cerrar la ventana kitty limpia con SIGTERM.
echo "kill $(printf '%q' "$kp") 2>/dev/null || true"
else
# Sin kitty: matar el propio claude.
echo "kill $(printf '%q' "$cp") 2>/dev/null || true"
fi
# Relanzar en una kitty nueva, detached, en el cwd correcto.
# zsh -ic '...; exec zsh' replica el patron del usuario: al salir de
# claude queda una shell interactiva viva.
printf 'setsid kitty --directory %q zsh -ic %q </dev/null >/dev/null 2>&1 &\n' \
"$cwd" "${cmd}; exec zsh"
echo
done
echo 'exit 0'
} > "$script"
chmod +x "$script"
echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)"
setsid bash "$script" </dev/null >>"$log" 2>&1 &
disown 2>/dev/null || true
echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s."
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reboot_all_claudes "$@"
fi
+2
View File
@@ -9,6 +9,8 @@ Registry personal de código con búsqueda FTS. Diseñado para composición func
- `integrity.md` — Reglas de integridad y referencias cruzadas
- `architecture.md` — Visión general del sistema
- `sync_setup.md` — Vincular una PC al server `registry.organic-machine.com` (env vars, `fn sync`, troubleshooting)
- `adr/` — Architecture Decision Records: decisiones de diseño (qué se decidió y por qué)
- `../reports/` — Reportes de trabajo: **artefacto local** (entregable de una tarea: qué se hizo, cómo se verificó, gaps). Gitignored salvo `.gitkeep`, NO sube a Gitea ni se versiona (como los vaults). Convención en `.claude/rules/reports.md`. Decisión: [ADR 0006](adr/0006-reports-folder.md)
## Tablas
+53
View File
@@ -0,0 +1,53 @@
# ADR 0006 — `reports/` como artefacto local para reportes de trabajo
- **Fecha:** 2026-06-06
- **Estado:** accepted
## Contexto
Cuando un agente termina una tarea no trivial (una auditoría, una tanda de fixes con verificación, un refactor, una investigación), el resumen ejecutable —qué se hizo, cómo se verificó, qué quedó pendiente— vivía solo en el chat de la sesión. Eso tiene tres problemas:
1. **Se pierde**: el chat no es consultable después; el resumen no queda en disco.
2. **No es compartible rápido**: para pasar el resultado hay que copiar a mano del chat.
3. **No tiene formato estable**: cada resumen sale distinto, sin garantía de evidencia ejecutable ni de declaración honesta de gaps.
Los contenedores existentes no encajan: los ADRs (`docs/adr/`) son decisiones de diseño; las reglas (`.claude/rules/`) son normas operativas; el diario (`docs/diary/`) es bitácora cronológica libre. Faltaba un sitio para el **entregable de una tarea concreta**: el resultado y su evidencia.
Punto clave de la decisión: un report **no es documentación del registry, es un artefacto** (en el sentido de `.claude/rules/artefactos.md`) — generado, con ciclo de vida propio, no código reutilizable. Y como artefacto del tipo "datos locales", se comporta como los **vaults**: no sube a Gitea ni se versiona en el git del padre.
## Decisión
Crear la carpeta `reports/` para reportes de trabajo, tratados como **artefacto local**:
1. **No versionados, no Gitea.** `reports/*` está en el `.gitignore` del padre (solo `reports/.gitkeep` se versiona, para mantener la carpeta presente). Un report no tiene repo propio: vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. Mismo trato que los vaults.
2. **Conviven en raíz o en proyectos**, como cualquier artefacto: `reports/` (sueltos) o `projects/<p>/reports/` (del trabajo de un proyecto). Ambas rutas gitignored (`reports/*`, `projects/*/reports/`). Se permiten subcarpetas para agrupar.
3. **No se indexan en `registry.db`.** Sin tabla `reports` ni schema (KISS) — son texto plano efímero, como los `playgrounds`.
4. **Convención y plantilla** viven en `.claude/rules/reports.md` (versionado): nombre `NNNN-YYYY-MM-DD-slug.md`, secciones Resumen/Cambios/Verificación/Gaps, evidencia ejecutable obligatoria.
Un report NO sustituye a un ADR ni a una regla: si durante el trabajo aparece una decisión de diseño, va a `docs/adr/` y el report solo la referencia.
## Alternativas consideradas
- **Versionar los reports en el repo padre.** Era el enfoque inicial de este ADR; descartado: un report es un artefacto (resultado de tarea, efímero, posiblemente voluminoso o ligado a un PC concreto), no documentación estable del registry. Versionarlos ensucia el historial del padre con entregables operativos. La convención correcta es la de los vaults: local, no Gitea.
- **Dejar los resúmenes solo en el chat.** Status quo; se pierden y no son compartibles. Es el motivo del ADR.
- **Usar `docs/diary/`.** El diario es cronológico, libre y versionado; mezclaría notas con entregables formales y no impone evidencia ejecutable.
- **Un ADR por tarea.** Sobrecarga el registro de decisiones con resultados operativos.
- **Indexar los reports en `registry.db`.** Añade schema y mantenimiento para un artefacto efímero. KISS: no se indexa, como los playgrounds.
## Consecuencias
- `.gitignore` del padre gana `reports/*` (con `!reports/.gitkeep`) y `projects/*/reports/`.
- Nueva regla `.claude/rules/reports.md` con convención + plantilla; entrada en `.claude/rules/INDEX.md`.
- `report` se añade como tipo de artefacto en `.claude/rules/artefactos.md` (NO indexado, NO sub-repo Gitea).
- Mención en la sección "Estructura" / "Artefactos" de `.claude/CLAUDE.md` y en `docs/README.md`.
- Los agentes pueden escribir un report al cerrar una tarea no trivial y pasar la ruta para compartir, en vez de volcar el resumen al chat. El report queda local (no viaja por git/`fn sync` salvo que el usuario lo copie aparte).
- Primer report: `projects/web_scraping/reports/0001-2026-06-06-browser-domain-audit-fixes.md` (local, gitignored; vive en el proyecto porque el trabajo tocó sus apps). Cada project que use reports añade `reports/*` (salvo `!reports/.gitkeep`) a su propio `.gitignore` para no subirlos a su Gitea.
## Relación con otras reglas y ADRs
- `.claude/rules/artefactos.md` — report es un tipo de artefacto; este ADR lo añade a la taxonomía.
- `.claude/rules/reports.md` — convención operativa derivada de este ADR.
- `.claude/rules/playgrounds.md` — mismo espíritu (artefacto local, no indexado).
- `.claude/rules/dod_quality.md` — los reports heredan su exigencia de evidencia ejecutable y gaps.
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses SÍ son sub-repos Gitea; los reports NO (se parecen a los vaults, no a las apps).
- [ADR 0005](0005-keep-parent-git-lean.md) — mantener el `.git` del padre ligero; no versionar reports refuerza esa línea.
+1
View File
@@ -63,3 +63,4 @@ Qué se aprendió después. Útil cuando un ADR se supersede.
| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted |
| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted |
| [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted |
| [0006](0006-reports-folder.md) | Carpeta `reports/` para reportes de trabajo (entregable de tarea con evidencia) | accepted |
+1
View File
@@ -24,6 +24,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
+117
View File
@@ -0,0 +1,117 @@
# Flow Replay — Guardar un flujo web como función reproducible
Tag: `flow-replay`. Grupo de funciones para convertir un flujo de navegador que se hizo
una vez a mano (login en un panel, reiniciar un servidor, rellenar un formulario) en una
**función del registry reproducible sin intervención**. Materializa la doctrina del issue
0087: el registry crece promoviendo secuencias repetidas a operaciones de un solo paso.
Filtro MCP: `mcp__registry__fn_search query="" tag="flow-replay"`.
Complementa al grupo [`web-proxy`](web-proxy.md): `web-proxy` **graba** el tráfico,
`flow-replay` lo **destila y reproduce**.
## El patrón: grabar → destilar → reproducir
Tres fases, con una jerarquía de reproducción de más barato a más caro:
```
Fase 0 — GRABAR (una vez, siempre con browser + proxy)
web_proxy ON → haces la acción a mano en el navegador → exportas el tramo a HAR
(funciones del grupo web-proxy: start_mitm_capture, launch_chromium_proxy, query_mitm_flows --har)
Fase 1 — DESTILAR (del HAR a una secuencia de requests)
har_filter_flows → descarta estáticos/analytics, deja los flujos que importan
har_extract_calls → normaliza cada flujo a una "call spec" reproducible (método, url,
headers, cookies, body), aislando los datos de auth
Fase 2 — REPRODUCIR, en orden de preferencia:
Nivel 1 HTTP puro http_replay_sequence — rápido, headless, scriptable. PREFERIDO.
Nivel 2 headless chromium (fallback) — cuando hay token dinámico firmado en cliente,
challenge JS o WAF con fingerprint de navegador. Reutiliza
cdp_extract_recipe + cdp_save_storage_state (ver Fronteras).
Nivel 3 chromium visible + acciones humanizadas — último recurso si headless es detectado
(cdp_click_xy_human, cdp_move_mouse_human del dominio browser).
```
La función-acción concreta que guardas en el registry (`reboot_<panel>_server`,
`login_<panel>`, etc.) envuelve el nivel que funcione: idealmente una llamada a
`http_replay_sequence` con su secuencia + parámetros, y los secretos resueltos desde
`pass`/vault.
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| [har_filter_flows_py_cybersecurity](../../python/functions/cybersecurity/har_filter_flows.md) | `har_filter_flows(har, *, hosts, methods, drop_static, drop_analytics) -> list[dict]` | Filtra un HAR: descarta recursos estáticos y hosts de telemetría, deja los flujos candidatos a "acción". Pura. |
| [har_extract_calls_py_cybersecurity](../../python/functions/cybersecurity/har_extract_calls.md) | `har_extract_calls(entries, *, drop_headers) -> list[dict]` | Convierte entries HAR en "call specs" normalizadas (método/url/headers/cookies/body/body_type), aislando cookies de auth y descartando headers hop-by-hop. Pura. |
| [http_replay_sequence_py_infra](../../python/functions/infra/http_replay_sequence.md) | `http_replay_sequence(calls, *, params, extract, timeout_s, verify_tls, allow_redirects, base_headers) -> dict` | Motor de replay HTTP: ejecuta la secuencia compartiendo cookie jar, substituye `{{param}}` y extrae valores de una respuesta para inyectarlos en pasos siguientes (flujo CSRF-like). Impura. |
## Ejemplo canónico end-to-end
Destilar un HAR capturado y reproducir el flujo sin navegador. Las tres funciones se
encadenan; la extracción del paso 1 (un token) se inyecta en el paso 2:
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity.har_filter_flows import har_filter_flows
from cybersecurity.har_extract_calls import har_extract_calls
from infra.http_replay_sequence import http_replay_sequence
# 1. HAR exportado por: query_mitm_flows ~/captures/traffic-*.mitm --har ~/sesion.har
import json
har = json.load(open(os.path.expanduser("~/sesion.har")))
# 2. Destilar: del ruido a la secuencia mínima
flows = har_filter_flows(har, hosts=["panel.midominio.com"]) # solo el host del panel
calls = har_extract_calls(flows) # call specs reproducibles
# 3. Reproducir (Nivel 1, HTTP puro). El token del GET inicial se inyecta en el POST.
res = http_replay_sequence(
calls,
params={"server_id": "vps-42"}, # parametrizado por el caller
extract=[{"from": 0, "type": "json", "expr": "csrf", "as": "csrf"}],
verify_tls=True,
)
print(res["status"], [s["status_code"] for s in res["steps"]])
```
Una vez validado, el flujo se promueve a una función-acción nombrada del registry
(p. ej. `reboot_vps_server_<panel>`) que internamente llama a `http_replay_sequence`
con su secuencia fija, recibe los parámetros del caller y resuelve los secretos desde
`pass`. Esa función-acción es lo que el agente invoca en un solo paso a partir de entonces.
## Fronteras
- **No graba**: la captura es del grupo [`web-proxy`](web-proxy.md). Este grupo empieza
con un HAR ya existente.
- **No auto-parametriza** (todavía). `har_extract_calls` normaliza pero NO detecta solo
qué valor es un token dinámico ni dónde se reinyecta. La parametrización (`{{param}}`)
y las reglas de `extract` las decide el humano/agente leyendo el HAR. La detección
automática de tokens/CSRF sería una función nueva del grupo, no una ampliación.
- **No incluye el runner de Nivel 2/3** (browser fallback). Está especificado en el
patrón pero no implementado: cuando un flujo real falle en HTTP puro, se construye un
"action recipe" reutilizando casi entero `cdp_extract_recipe_py_pipelines` (mismo
formato YAML, steps de acción en vez de extracción) + `cdp_save_storage_state_go_browser`
para saltarse el login. No se construye por adelantado (KISS / registry-first).
- **No gestiona secretos**: los secretos viajan como `{{param}}` desde `pass`/vault. El
grupo nunca los hardcodea ni los persiste.
## Gotchas (seguridad — leer antes de usar)
- **El HAR es sensible**: contiene cookies y tokens en crudo. Trátalo como un secreto —
gitignored, no subir a Gitea, no indexar, borrar tras destilar. El output de
`har_extract_calls` también lleva esos valores hasta que los sustituyes por `{{param}}`.
- **Secretos a `pass`/vault**, nunca en el código de la función-acción.
- **Replay con efectos = peligroso**: reproducir un POST que reinicia, borra o paga es
destructivo. La función-acción debe pedir confirmación o exponer un flag explícito
(`--yes`/`confirm=True`) antes de disparar. Nunca replay ciego de una acción irreversible.
- **HTTP puro no siempre reproduce**: token firmado en cliente, challenge JS, o WAF que
exige fingerprint de navegador → cae a Nivel 2 (headless) o 3 (visible humanizado).
- `http_replay_sequence` sigue redirects por defecto y `verify_tls=True`. La extracción
JSON es dot-path simple (`a.b.0.c`), no JSONPath completo.
## Prerequisitos
- Fase 0 (grabar): grupo `web-proxy` operativo (mitmproxy + chromium). Ver su página.
- Fase 1-2: `requests` en `python/.venv` (ya presente). Sin dependencias nuevas.
+34 -3
View File
@@ -23,18 +23,49 @@ func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
return cx, cy, nil
}
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
// CdpClickRef hace click sobre el elemento del #ref (un backendDOMNodeId extraído
// del AX outline por page_perceive). Por defecto usa click humanizado (Bézier +
// jitter) sobre el centro del bbox. Dos casos caen al click via element.click() JS:
// - opts.Mode == "instant": sin eventos de ratón reales (rápido, tests).
// - el nodo no tiene box model (display:contents, área 0): degradado natural en
// vez de fallar con error duro — un elemento clicable sin geometría sí se clica.
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click ref: conexión nil")
}
if opts.Mode == "instant" {
return clickRefViaJS(c, backendNodeID)
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp click ref: %w", err)
// Sin geometría: fallback a element.click() JS en vez de error duro.
return clickRefViaJS(c, backendNodeID)
}
return CdpClickXYHuman(c, cx, cy, opts)
}
// clickRefViaJS resuelve el nodo por backendDOMNodeId y llama element.click() en
// el contexto JS de la página. No dispara eventos de ratón reales (mousemove/
// mousedown), por lo que algunos listeners de hover no se activan; a cambio
// funciona sin geometría y al instante.
func clickRefViaJS(c *CDPConn, backendNodeID int) error {
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return fmt.Errorf("cdp click ref (js): resolveNode ref %d: %w", backendNodeID, err)
}
obj, _ := res["object"].(map[string]any)
objID, _ := obj["objectId"].(string)
if objID == "" {
return fmt.Errorf("cdp click ref (js): sin objectId para ref %d", backendNodeID)
}
if _, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
"objectId": objID,
"functionDeclaration": "function(){ this.click(); }",
}); err != nil {
return fmt.Errorf("cdp click ref (js): click ref %d: %w", backendNodeID, err)
}
return nil
}
+17 -2
View File
@@ -37,8 +37,10 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (30-90 ms).
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
// Pausa entre press y release según el modo de velocidad.
if pms := clickPauseMs(opts.Mode); pms > 0 {
time.Sleep(time.Duration(pms) * time.Millisecond)
}
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
@@ -47,3 +49,16 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
return nil
}
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
// velocidad: human 30-90, fast 5-15, instant 0.
func clickPauseMs(mode string) int {
switch mode {
case "instant":
return 0
case "fast":
return 5 + rand.Intn(11) // 5..15
default: // "human" o ""
return 30 + rand.Intn(61) // 30..90
}
}
+15
View File
@@ -6,6 +6,21 @@ import (
"syscall"
)
// CdpDisconnect cierra SOLO la conexion WebSocket CDP, sin tocar el proceso
// Chrome. Es un alias legible de CdpClose(c, 0): usalo cuando quieras soltar la
// sesion pero dejar el navegador vivo (p.ej. el navegador diario en 9222 al que
// te adjuntaste, no quieres matarlo).
func CdpDisconnect(c *CDPConn) error {
return CdpClose(c, 0)
}
// CdpQuit cierra la conexion WebSocket Y mata el proceso Chrome (y su grupo de
// proceso completo en Linux nativo). Es un alias legible de CdpClose(c, pid) con
// pid > 0: usalo para apagar un Chrome que TU lanzaste con ChromeLaunch.
func CdpQuit(c *CDPConn, pid int) error {
return CdpClose(c, pid)
}
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
+15 -6
View File
@@ -3,11 +3,11 @@ name: cdp_close
kind: function
lang: go
domain: browser
version: "1.1.0"
version: "1.2.0"
purity: impure
signature: "func CdpClose(c *CDPConn, pid int) error"
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle. Wrappers nombrados: CdpDisconnect(c) solo cierra el WebSocket; CdpQuit(c, pid) cierra y mata Chrome."
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux, navegator]
uses_functions: []
uses_types: []
returns: []
@@ -20,9 +20,9 @@ params:
- name: pid
desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["TestCdpCloseWrappers"]
test_file_path: "functions/browser/cdp_close_test.go"
file_path: "functions/browser/cdp_close.go"
---
@@ -43,6 +43,14 @@ defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
**Elige el wrapper según la intención** (más legible que el `pid` mágico):
| Quiero... | Usa | Equivale a |
|---|---|---|
| Soltar la sesión, dejar Chrome vivo (navegador diario en 9222) | `CdpDisconnect(c)` | `CdpClose(c, 0)` |
| Apagar el Chrome que yo lancé | `CdpQuit(c, pid)` | `CdpClose(c, pid)` |
| Control fino (decidir pid en runtime) | `CdpClose(c, pid)` | — |
## Gotchas
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
@@ -56,4 +64,5 @@ Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso so
## Capability growth log
- v1.2.0 (2026-06-06) — añade wrappers nombrados CdpDisconnect(c) (solo WebSocket) y CdpQuit(c, pid) (WebSocket + mata Chrome) para desambiguar el `pid` mágico; CdpClose sin cambios de comportamiento.
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
+25
View File
@@ -0,0 +1,25 @@
package browser
import "testing"
// TestCdpCloseWrappers es un smoke nil-safe de los wrappers nombrados. Sin Chrome:
// con conexión nil y pid 0 no hay nada que cerrar ni matar, así que no debe error.
func TestCdpCloseWrappers(t *testing.T) {
t.Run("CdpDisconnect(nil) no error (nada que cerrar)", func(t *testing.T) {
if err := CdpDisconnect(nil); err != nil {
t.Errorf("CdpDisconnect(nil) = %v, esperaba nil", err)
}
})
t.Run("CdpQuit(nil, 0) no error (sin conexion ni pid)", func(t *testing.T) {
if err := CdpQuit(nil, 0); err != nil {
t.Errorf("CdpQuit(nil, 0) = %v, esperaba nil", err)
}
})
t.Run("CdpClose(nil, 0) sigue siendo no-op", func(t *testing.T) {
if err := CdpClose(nil, 0); err != nil {
t.Errorf("CdpClose(nil, 0) = %v, esperaba nil", err)
}
})
}
+6
View File
@@ -35,6 +35,12 @@ type CDPConn struct {
closed bool
handlers map[string][]EventHandler
hMu sync.Mutex
// frameCtx cachea el executionContextId del isolated world por frameID, para
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
frameCtx *frameCtxCache
frameCtxMu sync.Mutex
}
type cdpRequest struct {
+125 -32
View File
@@ -3,75 +3,119 @@ package browser
import (
"encoding/json"
"fmt"
"strings"
"sync"
)
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
// Retorna el resultado serializado como string.
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp eval in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
}
// frameCtxCache mapea frameID -> executionContextId del isolated world creado
// para ese frame. Evita pagar Page.createIsolatedWorld en cada CdpEvalInFrame.
// Es puro y testeable de forma aislada (su propio mutex, sin tocar CDP).
type frameCtxCache struct {
mu sync.Mutex
m map[string]int
}
// Page.enable es idempotente; necesario antes de crear mundos aislados
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
}
func newFrameCtxCache() *frameCtxCache {
return &frameCtxCache{m: map[string]int{}}
}
// Crear un mundo aislado en el frame indicado para no contaminar su contexto JS
func (f *frameCtxCache) get(frameID string) (int, bool) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.m[frameID]
return id, ok
}
func (f *frameCtxCache) set(frameID string, ctxID int) {
f.mu.Lock()
f.m[frameID] = ctxID
f.mu.Unlock()
}
func (f *frameCtxCache) invalidate(frameID string) {
f.mu.Lock()
delete(f.m, frameID)
f.mu.Unlock()
}
// isStaleContextError reconoce el error de CDP cuando un executionContextId
// cacheado ya no existe (el frame recargó/navegó y su isolated world murió). Es
// puro: decide a partir del texto del error. Permite reintentar recreando el
// mundo en vez de fallar.
func isStaleContextError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "Cannot find context") ||
strings.Contains(s, "context with specified id") ||
strings.Contains(s, "Execution context was destroyed") ||
strings.Contains(s, "uniqueContextId")
}
// frameCtxCacheLazy devuelve el cache de contextos del frame de esta conexion,
// inicializandolo en el primer uso. El mutex de CDPConn solo protege este
// lazy-init del puntero.
func (c *CDPConn) frameCtxCacheLazy() *frameCtxCache {
c.frameCtxMu.Lock()
defer c.frameCtxMu.Unlock()
if c.frameCtx == nil {
c.frameCtx = newFrameCtxCache()
}
return c.frameCtx
}
// createIsolatedWorld crea un mundo aislado en el frame y devuelve su
// executionContextId.
func createIsolatedWorld(c *CDPConn, frameID string) (int, error) {
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
"frameId": frameID,
"worldName": "fn_registry_isolated",
"grantUniveralAccess": false,
"frameId": frameID,
"worldName": "fn_registry_isolated",
"grantUniversalAccess": false,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err)
return 0, fmt.Errorf("createIsolatedWorld: %w", err)
}
ctxIDRaw, ok := ctxRes["executionContextId"]
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta")
return 0, fmt.Errorf("createIsolatedWorld: executionContextId no encontrado en respuesta")
}
ctxID, ok := ctxIDRaw.(float64)
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw)
return 0, fmt.Errorf("createIsolatedWorld: executionContextId tipo inesperado: %T", ctxIDRaw)
}
return int(ctxID), nil
}
// Evaluar la expresion en el contexto aislado del frame
// evalInFrameContext ejecuta la expresion en el executionContextId dado y
// serializa el resultado como string (mismo patron que CdpEvaluate).
func evalInFrameContext(c *CDPConn, ctxID int, frameID, expression string) (string, error) {
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": expression,
"contextId": int(ctxID),
"contextId": ctxID,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err)
return "", fmt.Errorf("Runtime.evaluate: %w", err)
}
// Verificar excepcion JS
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text)
return "", fmt.Errorf("excepcion JS en frame %q: %s", frameID, text)
}
// Extraer valor del resultado (mismo patron que CdpEvaluate)
resVal, ok := evRes["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes)
return "", fmt.Errorf("resultado inesperado: %v", evRes)
}
value, ok := resVal["value"]
if !ok {
// undefined u otro tipo no serializable
typ, _ := resVal["type"].(string)
return typ, nil
}
// Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v").
if s, ok := value.(string); ok {
return s, nil
}
@@ -81,3 +125,52 @@ func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
}
return string(b), nil
}
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
// Retorna el resultado serializado como string.
//
// Cachea el executionContextId por frameID en la conexion: la primera llamada
// crea el mundo aislado, las siguientes lo reutilizan. Si el contexto cacheado
// caducó (el frame navegó/recargó), recrea el mundo una vez y reintenta.
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp eval in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
}
// Page.enable es idempotente; necesario antes de crear mundos aislados.
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
}
cache := c.frameCtxCacheLazy()
ctxID, cached := cache.get(frameID)
if !cached {
newID, err := createIsolatedWorld(c, frameID)
if err != nil {
return "", fmt.Errorf("cdp eval in frame: %w", err)
}
ctxID = newID
cache.set(frameID, ctxID)
}
out, evErr := evalInFrameContext(c, ctxID, frameID, expression)
if evErr != nil && cached && isStaleContextError(evErr) {
// El contexto cacheado murió (frame recargó). Recrear una vez.
cache.invalidate(frameID)
newID, err := createIsolatedWorld(c, frameID)
if err != nil {
return "", fmt.Errorf("cdp eval in frame: %w", err)
}
cache.set(frameID, newID)
out, evErr = evalInFrameContext(c, newID, frameID, expression)
}
if evErr != nil {
return "", fmt.Errorf("cdp eval in frame: %w", evErr)
}
return out, nil
}
+10 -3
View File
@@ -5,9 +5,11 @@ kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame."
version: 1.1.0
tested: true
tests: ["TestCdpEvalInFrame_guards", "TestFrameCtxCache", "TestIsStaleContextError"]
test_file_path: "functions/browser/cdp_eval_in_frame_test.go"
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. Cachea el executionContextId por frameID en la conexión para no recrear el isolated world en cada llamada; si el contexto caduca (frame recargó) lo recrea una vez y reintenta."
tags: [cdp, browser, iframe, javascript, eval, navegator]
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
uses_functions: []
@@ -71,3 +73,8 @@ Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
- **Cache de contexto por frameID**: la primera llamada crea el isolated world; las siguientes reutilizan su `executionContextId` (más rápido). Si el frame navega/recarga, el contexto cacheado caduca; la función detecta el error ("Cannot find context", "Execution context was destroyed") y recrea el mundo una vez automáticamente. El cache vive en la conexión: persiste entre llamadas mientras la conexión esté viva.
## Capability growth log
- v1.1.0 (2026-06-06) — corrige typo `grantUniveralAccess``grantUniversalAccess` (la opción nunca se aplicaba); cachea executionContextId por frameID en CDPConn (vía `frameCtxCache`) para no crear un isolated world por llamada; recrea+reintenta una vez si el contexto cacheado caducó.
@@ -0,0 +1,79 @@
package browser
import (
"errors"
"testing"
)
// TestCdpEvalInFrame_guards cubre precondiciones sin Chrome.
func TestCdpEvalInFrame_guards(t *testing.T) {
t.Run("conexion nula", func(t *testing.T) {
if _, err := CdpEvalInFrame(nil, "f1", "1"); err == nil {
t.Fatal("esperaba error con conexion nula")
}
})
t.Run("frameID vacio", func(t *testing.T) {
if _, err := CdpEvalInFrame(&CDPConn{}, "", "1"); err == nil {
t.Fatal("esperaba error con frameID vacio")
}
})
}
// TestFrameCtxCache cubre el núcleo puro del cache de contextos por frame.
func TestFrameCtxCache(t *testing.T) {
t.Run("golden: set/get devuelve el ctxId cacheado", func(t *testing.T) {
c := newFrameCtxCache()
if _, ok := c.get("frameA"); ok {
t.Fatal("cache recién creado no debería tener frameA")
}
c.set("frameA", 42)
id, ok := c.get("frameA")
if !ok || id != 42 {
t.Fatalf("get(frameA) = (%d,%v), esperaba (42,true)", id, ok)
}
})
t.Run("edge: frames distintos no se pisan", func(t *testing.T) {
c := newFrameCtxCache()
c.set("frameA", 1)
c.set("frameB", 2)
if id, _ := c.get("frameA"); id != 1 {
t.Errorf("frameA = %d, esperaba 1", id)
}
if id, _ := c.get("frameB"); id != 2 {
t.Errorf("frameB = %d, esperaba 2", id)
}
})
t.Run("invalidate: tras invalidar, get falla (fuerza recrear mundo)", func(t *testing.T) {
c := newFrameCtxCache()
c.set("frameA", 7)
c.invalidate("frameA")
if _, ok := c.get("frameA"); ok {
t.Error("tras invalidate, get(frameA) debería fallar")
}
})
}
// TestIsStaleContextError cubre el discriminador puro que decide si reintentar
// recreando el isolated world.
func TestIsStaleContextError(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil no es stale", nil, false},
{"error generico no es stale", errors.New("boom"), false},
{"Cannot find context es stale", errors.New("cdp error: Cannot find context with specified id"), true},
{"Execution context was destroyed es stale", errors.New("Execution context was destroyed"), true},
{"uniqueContextId es stale", errors.New("invalid uniqueContextId"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isStaleContextError(tc.err); got != tc.want {
t.Errorf("isStaleContextError(%v) = %v, esperaba %v", tc.err, got, tc.want)
}
})
}
}
+141
View File
@@ -0,0 +1,141 @@
package browser
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de
// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos.
// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno
// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve.
const findByTextCoreJS = `
var P = %s;
var target = P.cs ? P.text : P.text.toLowerCase();
var nodes = document.querySelectorAll(P.tag || '*');
function norm(v) {
v = (v || '').replace(/\s+/g, ' ').trim();
return P.cs ? v : v.toLowerCase();
}
function matches(el) {
var v = norm(el.innerText || el.textContent || '');
return P.exact ? v === target : v.indexOf(target) >= 0;
}
function leafmost(el) {
for (var i = 0; i < el.children.length; i++) {
if (matches(el.children[i])) return false;
}
return true;
}`
// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode.
// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un
// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía).
func parseBackendNodeID(resp map[string]any) (int, error) {
node, ok := resp["node"].(map[string]any)
if !ok {
return 0, fmt.Errorf("describeNode: respuesta sin campo node")
}
raw, ok := node["backendNodeId"]
if !ok {
return 0, fmt.Errorf("describeNode: node sin backendNodeId")
}
f, ok := raw.(float64)
if !ok {
return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw)
}
return int(f), nil
}
// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y
// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que
// produce el outline de page_perceive y que consume CdpClickRef. Así se puede
// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type).
//
// Retorna (backendNodeID, count, error):
// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef.
// - count: número total de elementos que matchean (tras el filtro leafmost).
// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda.
// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay
// ningún match (count == 0).
//
// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un
// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM
// (DOM.describeNode), evitando el round-trip por selector CSS.
func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) {
if c == nil {
return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula")
}
if text == "" {
return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio")
}
payload, _ := json.Marshal(map[string]any{
"text": text,
"tag": opts.Tag,
"exact": opts.Exact,
"cs": opts.CaseSensitive,
})
core := fmt.Sprintf(findByTextCoreJS, string(payload))
// 1. Contar matches (returnByValue=true vía CdpEvaluate).
countJS := "(function(){" + core + `
var n = 0;
for (var i = 0; i < nodes.length; i++) {
if (matches(nodes[i]) && leafmost(nodes[i])) n++;
}
return n;
})()`
countStr, err := CdpEvaluate(c, countJS)
if err != nil {
return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err)
}
count, _ := strconv.Atoi(strings.TrimSpace(countStr))
if count == 0 {
return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text)
}
// 2. Resolver el primer match a un RemoteObject (returnByValue=false para
// obtener un objectId que apunta al nodo DOM vivo).
elJS := "(function(){" + core + `
for (var i = 0; i < nodes.length; i++) {
if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i];
}
return null;
})()`
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": elJS,
"returnByValue": false,
})
if err != nil {
return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err)
}
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
txt, _ := excMap["text"].(string)
return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt)
}
remote, ok := evRes["result"].(map[string]any)
if !ok {
return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result")
}
objID, _ := remote["objectId"].(string)
if objID == "" {
// El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM
// mutó): tratamos como no encontrado para no devolver un ref inválido.
return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)")
}
// 3. Del RemoteObject al nodo DOM: backendNodeId.
dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID})
if err != nil {
return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err)
}
backendNodeID, err := parseBackendNodeID(dn)
if err != nil {
return 0, count, fmt.Errorf("cdp find ref by text: %w", err)
}
return backendNodeID, count, nil
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cdp_find_ref_by_text
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error)"
description: "Busca el primer elemento cuyo innerText matchea el texto dado y devuelve su backendDOMNodeId (#ref estable) en vez de un selector CSS. Resuelve el nodo JS a RemoteObject (Runtime.evaluate returnByValue=false) y de ahi al nodo DOM (DOM.describeNode), unificando la identidad con page_perceive y CdpClickRef. Devuelve tambien el numero de matches para detectar ambiguedad. Prefiere elementos hoja (leafmost)."
tags: [browser, cdp, find, locator, ref, accessibility, navegator]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: [encoding/json, fmt, strconv, strings]
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: text
desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)."
- name: opts
desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)."
output: "(backendNodeID, count, error): backendNodeID es el #ref del primer match listo para CdpClickRef; count es el numero total de matches (>1 = ambiguo); error si conexion nula, texto vacio, eval JS falla o no hay match (count==0)."
tested: true
tests: ["TestCdpFindRefByText_guards", "TestParseBackendNodeID"]
test_file_path: "functions/browser/cdp_find_ref_by_text_test.go"
file_path: "functions/browser/cdp_find_ref_by_text.go"
---
## Ejemplo
```go
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
// Encontrar el botón "Login" por su texto y clicar por #ref (sin selector CSS).
ref, count, err := browser.CdpFindRefByText(c, "Login", browser.FindByTextOpts{Tag: "button"})
if err != nil {
log.Fatal(err)
}
if count > 1 {
log.Printf("aviso: %d elementos matchean 'Login', usando el primero", count)
}
_ = browser.CdpClickRef(c, ref, browser.MouseProfileForMode("human"))
```
## Cuando usarla
Cuando quieras clicar/hacer hover sobre un elemento identificándolo por su texto visible y operar después por `#ref` (backendDOMNodeId) en vez de por un selector CSS frágil. Es el puente entre "lo veo por su texto" y el bucle percibir→actuar de `page_perceive` + `CdpClickRef`. Preferible a `cdp_find_by_text` (que devuelve selector `nth-of-type`) cuando el frontend cambia sus clases/estructura con cada build pero el texto es estable.
## Gotchas
- **count > 1 = ambigüedad**: la función devuelve el primer match pero te avisa con `count` cuántos hay. Refina con `opts.Tag` o `opts.Exact` si el texto aparece en varios sitios.
- **Elemento volátil**: si el DOM muta entre el conteo y la resolución del nodo (SPA re-renderizando), el `objectId` puede venir vacío y la función devuelve error "elemento volátil" en vez de un `#ref` inválido. Reintenta tras `CdpWaitIdle`.
- **El #ref es efímero por documento**: el `backendDOMNodeId` es estable mientras el nodo viva, pero se invalida tras navegar o recargar. No lo persistas entre páginas.
- **Tests sin Chrome**: el núcleo puro (`parseBackendNodeID`) y los guards se testean sin navegador. El flujo completo (eval + describeNode contra DOM real) requiere Chrome y se valida por e2e.
@@ -0,0 +1,70 @@
package browser
import (
"strings"
"testing"
)
// TestCdpFindRefByText_guards cubre las precondiciones (sin Chrome).
func TestCdpFindRefByText_guards(t *testing.T) {
t.Run("conexion nula", func(t *testing.T) {
if _, _, err := CdpFindRefByText(nil, "x", FindByTextOpts{}); err == nil {
t.Fatal("esperaba error con conexion nula")
}
})
t.Run("texto vacio", func(t *testing.T) {
c := &CDPConn{}
_, _, err := CdpFindRefByText(c, "", FindByTextOpts{})
if err == nil {
t.Fatal("esperaba error con texto vacio")
}
if !strings.Contains(err.Error(), "vacio") {
t.Fatalf("mensaje %q no menciona vacio", err.Error())
}
})
}
// TestParseBackendNodeID cubre el nucleo puro que convierte la respuesta de
// DOM.describeNode en el backendNodeId entero. No requiere Chrome.
func TestParseBackendNodeID(t *testing.T) {
t.Run("golden: node con backendNodeId", func(t *testing.T) {
resp := map[string]any{
"node": map[string]any{"backendNodeId": 123.0, "nodeName": "BUTTON"},
}
id, err := parseBackendNodeID(resp)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if id != 123 {
t.Fatalf("id = %d, esperaba 123", id)
}
})
t.Run("edge: backendNodeId grande se trunca a int correctamente", func(t *testing.T) {
resp := map[string]any{"node": map[string]any{"backendNodeId": 90001.0}}
id, err := parseBackendNodeID(resp)
if err != nil || id != 90001 {
t.Fatalf("id=%d err=%v, esperaba 90001 sin error", id, err)
}
})
t.Run("error: respuesta sin node", func(t *testing.T) {
if _, err := parseBackendNodeID(map[string]any{}); err == nil {
t.Error("esperaba error cuando falta node")
}
})
t.Run("error: node sin backendNodeId", func(t *testing.T) {
resp := map[string]any{"node": map[string]any{"nodeName": "DIV"}}
if _, err := parseBackendNodeID(resp); err == nil {
t.Error("esperaba error cuando falta backendNodeId")
}
})
t.Run("error: backendNodeId tipo no numerico", func(t *testing.T) {
resp := map[string]any{"node": map[string]any{"backendNodeId": "abc"}}
if _, err := parseBackendNodeID(resp); err == nil {
t.Error("esperaba error cuando backendNodeId no es numero")
}
})
}
+409
View File
@@ -0,0 +1,409 @@
package browser
import (
"fmt"
"strings"
)
// axoActionableRoles son los roles que el LLM puede referir con #ref. Misma
// lista que _ACTIONABLE_ROLES de render_ax_outline.py.
var axoActionableRoles = map[string]struct{}{
"button": {},
"link": {},
"textbox": {},
"searchbox": {},
"checkbox": {},
"radio": {},
"combobox": {},
"listbox": {},
"menuitem": {},
"menuitemcheckbox": {},
"menuitemradio": {},
"tab": {},
"option": {},
"switch": {},
"slider": {},
"spinbutton": {},
"treeitem": {},
"gridcell": {},
}
// axoSkipRoles son roles sin valor semantico: se omiten y sus hijos se elevan al
// nivel actual. Misma lista que _SKIP_ROLES de render_ax_outline.py.
var axoSkipRoles = map[string]struct{}{
"none": {},
"presentation": {},
"ignored": {},
}
// axoMaxDepth limita la profundidad de render (guard anti-RecursionError de
// arboles AX patologicos). Igual que _MAX_DEPTH del .py.
const axoMaxDepth = 60
// axNode es la representacion interna de un AXNode CDP, ya extraida del
// map[string]any de la respuesta. Los helpers de poda y render operan sobre
// estos structs, lo que los hace puros y testeables sin Chrome.
type axNode struct {
nodeID string
backendDOMNodeID string
ignored bool
role string
name string
value string
childIDs []string
parentID string
}
// CdpGetAXOutline percibe la pagina (o un iframe concreto via frameID) como un
// outline accesible indentado y accionable, reusando la conexion CDP viva del
// pool — sin abrir un WebSocket nuevo ni levantar el venv de Python.
//
// Envia Accessibility.enable (idempotente) y Accessibility.getFullAXTree. Si
// frameID != "", pasa {"frameId": frameID} para obtener el arbol DENTRO de ese
// iframe; con frameID == "" obtiene el arbol completo de la pagina (depth -1).
//
// El resultado se poda (trim) y luego se renderiza replicando exactamente el
// formato del pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline:
// indentacion de 2 espacios por nivel, `role "name"`, ` = 'value'` para inputs,
// y marcador ` #ref=<backendDOMNodeId>` en roles accionables. maxChars > 0
// trunca y añade "\n…[outline truncado]"; maxChars <= 0 = sin limite.
func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get ax outline: conexion nula")
}
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
}
var params map[string]any
if frameID != "" {
params = map[string]any{"frameId": frameID}
}
res, err := c.sendCDP("Accessibility.getFullAXTree", params)
if err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.getFullAXTree: %w", err)
}
nodes := axoParseNodes(res)
trimmed := trimAXTree(nodes)
return renderAXOutline(trimmed, maxChars), nil
}
// axoParseNodes extrae la lista de axNode del result de getFullAXTree. Tras el
// JSON unmarshal a map[string]any, los nodos vienen como []any de
// map[string]any y los enteros (backendDOMNodeId, nodeId) como float64; nodeId y
// childIds suelen llegar como strings. Normalizamos todo a string.
func axoParseNodes(result map[string]any) []axNode {
raw, ok := result["nodes"].([]any)
if !ok {
return nil
}
out := make([]axNode, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
n := axNode{
nodeID: axoStr(m["nodeId"]),
backendDOMNodeID: axoStr(m["backendDOMNodeId"]),
ignored: axoBool(m["ignored"]),
role: axoNested(m["role"]),
name: axoNested(m["name"]),
value: axoNested(m["value"]),
childIDs: axoStrSlice(m["childIds"]),
parentID: axoStr(m["parentId"]),
}
out = append(out, n)
}
return out
}
// axoNested extrae el campo "value" de un objeto CDP del tipo {value: ...} (role,
// name, value vienen asi). Devuelve "" si esta ausente o vacio.
func axoNested(v any) string {
m, ok := v.(map[string]any)
if !ok {
if v == nil {
return ""
}
return axoStr(v)
}
return axoStr(m["value"])
}
// axoStr normaliza cualquier escalar JSON a string. Los enteros CDP llegan como
// float64 tras el unmarshal; los renderizamos sin decimales.
func axoStr(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case float64:
// IDs CDP son enteros: evitar notacion 1.234e+06 / sufijo .0.
return fmt.Sprintf("%d", int64(t))
case bool:
if t {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", t)
}
}
func axoBool(v any) bool {
b, _ := v.(bool)
return b
}
func axoStrSlice(v any) []string {
raw, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
out = append(out, axoStr(item))
}
return out
}
// trimAXTree compacta la lista de axNode descartando nodos irrelevantes y
// colapsando cadenas padre->hijo del mismo role. Puro: porta trim_ax_tree.py.
//
// Descarta: ignored=true; role 'generic'/'none' sin name ni childIds;
// role 'StaticText' con name vacio. Colapsa: nodo con exactamente 1 hijo del
// mismo role hereda los childIds del hijo (el hijo se descarta). Itera hasta
// convergencia. Preserva el orden original de aparicion.
func trimAXTree(nodes []axNode) []axNode {
if len(nodes) == 0 {
return nil
}
shouldDiscard := func(n axNode) bool {
if n.ignored {
return true
}
if (n.role == "generic" || n.role == "none") && n.name == "" && len(n.childIDs) == 0 {
return true
}
if n.role == "StaticText" && n.name == "" {
return true
}
return false
}
byID := map[string]axNode{}
for _, n := range nodes {
if shouldDiscard(n) {
continue
}
byID[n.nodeID] = n
}
// Colapso iterativo hasta convergencia.
for {
changed := false
removed := map[string]struct{}{}
for _, node := range byID {
if _, gone := removed[node.nodeID]; gone {
continue
}
if len(node.childIDs) != 1 {
continue
}
childID := node.childIDs[0]
child, ok := byID[childID]
if !ok || child.role != node.role {
continue
}
// Fusionar: el padre hereda los childIds del hijo.
merged := node
merged.childIDs = child.childIDs
byID[node.nodeID] = merged
removed[childID] = struct{}{}
changed = true
}
if !changed {
break
}
for id := range removed {
delete(byID, id)
}
}
// Preservar orden original.
result := make([]axNode, 0, len(byID))
seen := map[string]struct{}{}
for _, n := range nodes {
node, ok := byID[n.nodeID]
if !ok {
continue
}
if _, dup := seen[n.nodeID]; dup {
continue
}
result = append(result, node)
seen[n.nodeID] = struct{}{}
}
return result
}
// renderAXOutline convierte axNode en un outline indentado, legible y
// accionable. Puro: porta render_ax_outline.py al caracter. La jerarquia se
// reconstruye con childIDs; las raices son nodeIds que no aparecen como hijo de
// nadie (fallback al primer nodo). maxChars > 0 trunca con sufijo.
func renderAXOutline(nodes []axNode, maxChars int) string {
if len(nodes) == 0 {
return ""
}
byID := map[string]axNode{}
for _, n := range nodes {
if n.nodeID != "" {
byID[n.nodeID] = n
}
}
allChildIDs := map[string]struct{}{}
for _, n := range nodes {
for _, cid := range n.childIDs {
allChildIDs[cid] = struct{}{}
}
}
var roots []axNode
for _, n := range nodes {
if _, isChild := allChildIDs[n.nodeID]; !isChild {
roots = append(roots, n)
}
}
if len(roots) == 0 {
roots = []axNode{nodes[0]}
}
var lines []string
visited := map[string]struct{}{} // guard de ciclo: un nodeId no se renderiza dos veces
var renderNode func(node axNode, depth int)
renderNode = func(node axNode, depth int) {
nid := node.nodeID
if depth > axoMaxDepth {
return
}
if nid != "" {
if _, dup := visited[nid]; dup {
return
}
visited[nid] = struct{}{}
}
if node.ignored {
return
}
role := node.role
if _, skip := axoSkipRoles[role]; role == "" || skip {
// Nodos sin role util: elevar los hijos al nivel actual.
for _, cid := range node.childIDs {
if child, ok := byID[cid]; ok {
renderNode(child, depth)
}
}
return
}
indent := strings.Repeat(" ", depth)
var base string
if node.name != "" {
base = fmt.Sprintf("%s%s %q", indent, role, node.name)
} else {
base = indent + role
}
// Estado actual del campo (texto escrito, valor de slider/combobox).
if node.value != "" {
base += " = " + axoPyRepr(node.value)
}
// Ref accionable, sin padding.
if _, ok := axoActionableRoles[role]; ok {
ref := axoRefID(node)
if ref != "" {
base += " #ref=" + ref
}
}
lines = append(lines, base)
for _, cid := range node.childIDs {
if child, ok := byID[cid]; ok {
renderNode(child, depth+1)
}
}
}
for _, root := range roots {
renderNode(root, 0)
}
result := strings.Join(lines, "\n")
if maxChars > 0 && len(result) > maxChars {
result = strings.TrimRight(result[:maxChars], " \t\n\r\v\f")
result += "\n…[outline truncado]"
}
return result
}
// axoRefID devuelve el ref estable del nodo: backendDOMNodeId (apunta al nodo DOM
// real, estable mientras el nodo viva) con fallback al nodeId. Igual que
// _ref_id() del .py.
func axoRefID(n axNode) string {
if n.backendDOMNodeID != "" {
return n.backendDOMNodeID
}
return n.nodeID
}
// axoPyRepr replica Python repr() para strings: comillas simples por defecto;
// comillas dobles si la cadena contiene comilla simple pero no doble; escape de
// backslash y de la comilla delimitadora. Reproduce el efecto de `{value!r}`
// del render_ax_outline.py para que la salida coincida al caracter.
func axoPyRepr(s string) string {
hasSingle := strings.Contains(s, "'")
hasDouble := strings.Contains(s, "\"")
quote := byte('\'')
if hasSingle && !hasDouble {
quote = '"'
}
var b strings.Builder
b.WriteByte(quote)
for i := 0; i < len(s); i++ {
ch := s[i]
switch ch {
case '\\':
b.WriteString("\\\\")
case '\n':
b.WriteString("\\n")
case '\r':
b.WriteString("\\r")
case '\t':
b.WriteString("\\t")
case quote:
b.WriteByte('\\')
b.WriteByte(quote)
default:
b.WriteByte(ch)
}
}
b.WriteByte(quote)
return b.String()
}
+64
View File
@@ -0,0 +1,64 @@
---
id: cdp_get_ax_outline_go_browser
name: cdp_get_ax_outline
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error)"
description: "Percibe la pagina (o un iframe via frameID) como outline accesible indentado y accionable reusando la conexion CDP viva del pool. Envia Accessibility.enable + getFullAXTree, poda el arbol y lo renderiza con #ref=backendDOMNodeId en roles accionables. Replica al caracter el pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline pero nativo en Go, sin subprocess ni venv."
tags: [browser, cdp, ax, accessibility, perceive, iframe, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["TestRenderAXOutline_ActionableRoleCarriesRef", "TestRenderAXOutline_InputShowsValue", "TestRenderAXOutline_SkipRoleElevatesChildren", "TestRenderAXOutline_IndentationPerLevel", "TestRenderAXOutline_TruncationAddsSuffix", "TestTrimAXTree_DiscardsIgnored", "TestTrimAXTree_CollapsesSameRoleSingleChild", "TestAxoPyRepr", "TestAxoParseNodes"]
test_file_path: "functions/browser/cdp_get_ax_outline_test.go"
file_path: "functions/browser/cdp_get_ax_outline.go"
params:
- name: c
desc: "Conexion CDP viva (*CDPConn) del pool, ya conectada al tab/target objetivo. No abre WebSocket nuevo: reusa la del pool. Nil devuelve error."
- name: frameID
desc: "frameId CDP del iframe a percibir. Cadena vacia ('') percibe el arbol completo de la pagina (depth -1). Con valor, obtiene el AX tree DENTRO de ese iframe."
- name: maxChars
desc: "Limite de caracteres del outline. >0 trunca y añade '\\n…[outline truncado]'. <=0 = sin limite."
output: "Outline accesible multi-linea: 2 espacios de indentacion por nivel, 'role \"name\"' por nodo, ' = '\\''value'\\''' en inputs, y marcador ' #ref=<backendDOMNodeId>' en roles accionables. Cadena vacia si no hay nodos utiles."
---
## Ejemplo
```go
// c es una *CDPConn viva del pool (la misma que usa el browser_mcp).
// Percibir la pagina entera, truncando a 8000 chars:
outline, err := CdpGetAXOutline(c, "", 8000)
if err != nil {
log.Fatal(err)
}
fmt.Println(outline)
// WebArea "Example Domain"
// heading "Example Domain"
// link "More information..." #ref=128
// Percibir DENTRO de un iframe concreto (frameId del frame tree):
inner, err := CdpGetAXOutline(c, "F1A2B3C4D5E6", 0) // 0 = sin limite
```
## Cuando usarla
- Cuando necesites **percibir la pagina (o un iframe) como outline accionable** para que un LLM decida sobre `#ref` sin reventar el contexto.
- **Reemplaza el subprocess Python** `fn run cdp_perceive_outline`: es nativo Go, reusa la conexion CDP viva del pool y no arranca el venv en cada percepcion (mas rapido y sin dependencia de runtime `fn`/venv).
- Pasa `frameID` cuando el contenido objetivo vive dentro de un iframe; deja `frameID=""` para la pagina top-level.
- El `#ref` que devuelve (backendDOMNodeId) se pasa luego a `cdp_click_ref` / `cdp_type_ref` / `cdp_hover_ref`.
## Gotchas
- **Impura**: requiere un Chrome vivo con CDP accesible y el dominio `Accessibility` disponible. `Accessibility.enable` se envia siempre (idempotente).
- **Conexion nula** devuelve error inmediato; no intenta reconectar.
- **OOPIF cross-origin**: un iframe de distinto origen corre en un target (proceso) separado. Si `Accessibility.getFullAXTree` con ese `frameId` no devuelve nodos, probablemente necesites una `*CDPConn` adjunta al target del frame, no el `frameId` desde el target padre.
- **`#ref` = backendDOMNodeId**: estable mientras el nodo DOM viva, pero si la pagina re-renderiza ese subarbol el ref puede invalidarse. Percibe de nuevo tras una mutacion grande antes de actuar.
- El outline omite roles `none`/`presentation`/`ignored` y nodos `ignored=true`, y eleva sus hijos al nivel actual; un arbol con todo ignorado devuelve cadena vacia.
- Guard de profundidad 60 y guard de ciclo: arboles patologicos no cuelgan, pero pueden quedar recortados a partir de la profundidad 60.
@@ -0,0 +1,279 @@
package browser
import (
"strings"
"testing"
)
// --- renderAXOutline: casos clave portados de render_ax_outline.py ---
func TestRenderAXOutline_ActionableRoleCarriesRef(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "Page", childIDs: []string{"2"}},
{nodeID: "2", backendDOMNodeID: "555", role: "button", name: "Submit"},
}
got := renderAXOutline(nodes, 0)
want := "WebArea \"Page\"\n button \"Submit\" #ref=555"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_NonActionableHasNoRef(t *testing.T) {
nodes := []axNode{
{nodeID: "1", backendDOMNodeID: "9", role: "heading", name: "Title"},
}
got := renderAXOutline(nodes, 0)
if strings.Contains(got, "#ref") {
t.Errorf("rol no accionable no debe llevar #ref: %q", got)
}
if got != "heading \"Title\"" {
t.Errorf("got %q", got)
}
}
func TestRenderAXOutline_InputShowsValue(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "form", childIDs: []string{"2"}},
{nodeID: "2", backendDOMNodeID: "42", role: "textbox", name: "Email", value: "a@b.com"},
}
got := renderAXOutline(nodes, 0)
want := "form\n textbox \"Email\" = 'a@b.com' #ref=42"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_ValueWithSingleQuoteUsesDoubleQuote(t *testing.T) {
// Python repr: "it's" -> "it's" (comilla doble como delimitador).
nodes := []axNode{
{nodeID: "1", backendDOMNodeID: "7", role: "textbox", value: "it's"},
}
got := renderAXOutline(nodes, 0)
want := "textbox = \"it's\" #ref=7"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestRenderAXOutline_SkipRoleElevatesChildren(t *testing.T) {
// El nodo 'none' se omite; su hijo button sube al nivel del padre (depth 1,
// no depth 2), porque el render del skip-node reusa el mismo depth.
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "Root", childIDs: []string{"2"}},
{nodeID: "2", role: "none", childIDs: []string{"3"}},
{nodeID: "3", backendDOMNodeID: "30", role: "button", name: "Go"},
}
got := renderAXOutline(nodes, 0)
want := "WebArea \"Root\"\n button \"Go\" #ref=30"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_EmptyRoleElevatesChildren(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "", childIDs: []string{"2"}}, // sin role: se omite
{nodeID: "2", backendDOMNodeID: "20", role: "link", name: "Home"},
}
got := renderAXOutline(nodes, 0)
// El nodo raiz sin role eleva su hijo a depth 0.
want := "link \"Home\" #ref=20"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestRenderAXOutline_IndentationPerLevel(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "A", childIDs: []string{"2"}},
{nodeID: "2", role: "group", name: "B", childIDs: []string{"3"}},
{nodeID: "3", role: "group", name: "C"},
}
got := renderAXOutline(nodes, 0)
want := "WebArea \"A\"\n group \"B\"\n group \"C\""
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_TruncationAddsSuffix(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "AAAAAAAAAAAAAAAAAAAA"},
}
got := renderAXOutline(nodes, 10)
if !strings.HasSuffix(got, "\n…[outline truncado]") {
t.Errorf("falta sufijo de truncado: %q", got)
}
// El cuerpo truncado (sin sufijo) no debe exceder los 10 chars.
body := strings.TrimSuffix(got, "\n…[outline truncado]")
if len([]byte(body)) > 10 {
t.Errorf("cuerpo truncado mas largo que maxChars: %q (%d bytes)", body, len(body))
}
}
func TestRenderAXOutline_NoTruncationWhenUnderLimit(t *testing.T) {
nodes := []axNode{{nodeID: "1", role: "button", name: "X", backendDOMNodeID: "1"}}
got := renderAXOutline(nodes, 1000)
if strings.Contains(got, "truncado") {
t.Errorf("no debe truncar bajo el limite: %q", got)
}
}
func TestRenderAXOutline_Empty(t *testing.T) {
if got := renderAXOutline(nil, 0); got != "" {
t.Errorf("nil -> %q, want vacio", got)
}
}
func TestRenderAXOutline_RefFallsBackToNodeID(t *testing.T) {
// Sin backendDOMNodeId, el #ref usa el nodeId.
nodes := []axNode{
{nodeID: "77", role: "button", name: "Fallback"},
}
got := renderAXOutline(nodes, 0)
want := "button \"Fallback\" #ref=77"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestRenderAXOutline_CycleGuard(t *testing.T) {
// Ciclo 1 -> 2 -> 1: no debe colgar ni duplicar nodos.
nodes := []axNode{
{nodeID: "1", role: "group", name: "A", childIDs: []string{"2"}},
{nodeID: "2", role: "group", name: "B", childIDs: []string{"1"}},
}
got := renderAXOutline(nodes, 0)
if strings.Count(got, "group \"A\"") != 1 {
t.Errorf("nodo A renderizado mas de una vez: %q", got)
}
}
// --- trimAXTree: casos clave portados de trim_ax_tree.py ---
func TestTrimAXTree_DiscardsIgnored(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "button", name: "Keep"},
{nodeID: "2", role: "button", name: "Drop", ignored: true},
}
got := trimAXTree(nodes)
if len(got) != 1 || got[0].nodeID != "1" {
t.Errorf("trim debe descartar ignored: %+v", got)
}
}
func TestTrimAXTree_DiscardsEmptyGeneric(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "generic"}, // sin name ni childIds -> descartado
{nodeID: "2", role: "none"}, // idem
{nodeID: "3", role: "StaticText", name: ""}, // staticText vacio -> descartado
{nodeID: "4", role: "StaticText", name: "Hola"},
}
got := trimAXTree(nodes)
if len(got) != 1 || got[0].nodeID != "4" {
t.Errorf("trim debe descartar generic/none/staticText vacios: %+v", got)
}
}
func TestTrimAXTree_KeepsGenericWithChildren(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "generic", childIDs: []string{"2"}}, // tiene hijos -> se queda
{nodeID: "2", role: "button", name: "X"},
}
got := trimAXTree(nodes)
if len(got) != 2 {
t.Errorf("generic con hijos debe conservarse: %+v", got)
}
}
func TestTrimAXTree_CollapsesSameRoleSingleChild(t *testing.T) {
// list -> list (1 hijo, mismo role): se fusiona, el padre hereda los childIds.
nodes := []axNode{
{nodeID: "1", role: "list", childIDs: []string{"2"}},
{nodeID: "2", role: "list", childIDs: []string{"3"}},
{nodeID: "3", role: "listitem", name: "item"},
}
got := trimAXTree(nodes)
// Nodo 2 desaparece; nodo 1 debe apuntar ahora a 3.
var saw1, saw2 bool
var node1 axNode
for _, n := range got {
if n.nodeID == "1" {
saw1 = true
node1 = n
}
if n.nodeID == "2" {
saw2 = true
}
}
if !saw1 || saw2 {
t.Fatalf("colapso fallido: saw1=%v saw2=%v got=%+v", saw1, saw2, got)
}
if len(node1.childIDs) != 1 || node1.childIDs[0] != "3" {
t.Errorf("padre fusionado debe heredar childIds del hijo: %+v", node1.childIDs)
}
}
func TestTrimAXTree_PreservesOrder(t *testing.T) {
nodes := []axNode{
{nodeID: "3", role: "button", name: "C"},
{nodeID: "1", role: "button", name: "A"},
{nodeID: "2", role: "button", name: "B"},
}
got := trimAXTree(nodes)
if len(got) != 3 || got[0].nodeID != "3" || got[1].nodeID != "1" || got[2].nodeID != "2" {
t.Errorf("orden original no preservado: %+v", got)
}
}
func TestTrimAXTree_Empty(t *testing.T) {
if got := trimAXTree(nil); got != nil {
t.Errorf("nil -> %+v, want nil", got)
}
}
// --- axoPyRepr: paridad con Python repr() ---
func TestAxoPyRepr(t *testing.T) {
cases := []struct{ in, want string }{
{"hola", "'hola'"},
{"it's", "\"it's\""}, // tiene ', no " -> delimitador "
{"say \"hi\"", "'say \"hi\"'"}, // tiene " -> delimitador '
{"both ' and \"", "'both \\' and \"'"}, // ambos -> ' con escape del '
{"a\nb", "'a\\nb'"},
{"back\\slash", "'back\\\\slash'"},
}
for _, c := range cases {
if got := axoPyRepr(c.in); got != c.want {
t.Errorf("axoPyRepr(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// --- axoParseNodes: extraccion del map CDP (numeros como float64) ---
func TestAxoParseNodes(t *testing.T) {
result := map[string]any{
"nodes": []any{
map[string]any{
"nodeId": "1",
"backendDOMNodeId": float64(555), // CDP int llega como float64
"ignored": false,
"role": map[string]any{"value": "button"},
"name": map[string]any{"value": "Go"},
"value": map[string]any{"value": "x"},
"childIds": []any{"2", "3"},
},
},
}
got := axoParseNodes(result)
if len(got) != 1 {
t.Fatalf("got %d nodos, want 1", len(got))
}
n := got[0]
if n.nodeID != "1" || n.backendDOMNodeID != "555" || n.role != "button" ||
n.name != "Go" || n.value != "x" || len(n.childIDs) != 2 {
t.Errorf("parse incorrecto: %+v", n)
}
}
+11 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func CdpGetHTML(c *CDPConn) (string, error)"
description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original."
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools]
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools, navegator]
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
uses_types: []
returns: []
@@ -35,6 +35,16 @@ html, err := CdpGetHTML(conn)
// html contiene el DOM completo con todos los cambios JS aplicados
```
## Cuando usarla
Cuando necesites el HTML completo del DOM vivo (post-JavaScript) para parsear/extraer con un selector externo, guardar un snapshot fiel, o alimentar un parser HTML. Ideal para scraping de SPAs (React, Vue, Angular) donde el HTML fuente original está vacío.
## Gotchas
- **Devuelve el HTML COMPLETO sin límite, a propósito**: no trunca ni resume. En páginas complejas pueden ser cientos de KB. Esto es deliberado: su trabajo es dar el DOM íntegro para parsing fiel, no un resumen.
- **NO usar para alimentar un LLM directamente**: el HTML crudo quema tokens y trae ruido (scripts, estilos inline, atributos). Para contexto de modelo usa `cdp_get_text` (innerText, con `maxBytes` opcional) o `cdp_perceive_outline` (outline accesible con #refs accionables). Reserva `cdp_get_html` para parsing programático.
- **Es el DOM actual, no el HTML fuente**: incluye los cambios que el JavaScript haya aplicado hasta el instante de la llamada. Si la página sigue hidratando, espera con `cdp_wait_idle` antes.
## Notas
A diferencia de `Page.getResourceContent`, esta funcion captura el estado actual del DOM incluyendo modificaciones hechas por JavaScript. Ideal para scraping de SPAs (React, Vue, Angular). El HTML retornado puede ser muy largo para paginas complejas.
@@ -0,0 +1,44 @@
package browser
import (
"fmt"
"unicode/utf8"
)
// CdpGetTextInFrame retorna el texto visible (innerText) del documento de un
// iframe especifico, componiendo sobre CdpEvalInFrame con un mundo aislado CDP.
//
// Lee document.body.innerText (cae a document.documentElement.innerText si no
// hay body), evitando parsear HTML crudo. Replica la politica de truncado de
// CdpGetText: si maxBytes > 0 trunca al limite dado con corte rune-safe y añade
// un sufijo con el total original en bytes; si maxBytes <= 0 no hay limite.
//
// Propaga los errores de CdpEvalInFrame (frame inexistente, contexto caducado)
// envueltos con %w.
func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get text in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp get text in frame: frameID vacio")
}
const expr = `(document.body ? document.body.innerText : document.documentElement.innerText) || ""`
text, err := CdpEvalInFrame(c, frameID, expr)
if err != nil {
return "", fmt.Errorf("cdp get text in frame: %w", err)
}
if maxBytes > 0 && len(text) > maxBytes {
total := len(text)
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
cut := maxBytes
for cut > 0 && !utf8.RuneStart(text[cut]) {
cut--
}
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
}
return text, nil
}
@@ -0,0 +1,73 @@
---
id: cdp_get_text_in_frame_go_browser
name: cdp_get_text_in_frame
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve el texto visible (innerText) del documento de un iframe concreto componiendo sobre CdpEvalInFrame en un mundo aislado CDP, sin parsear HTML crudo. Trunca a maxBytes con corte rune-safe igual que CdpGetText."
tags: [browser, cdp, iframe, frame, text, navegator]
signature: "func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error)"
uses_functions: [cdp_eval_in_frame_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_text_in_frame.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
text, err := CdpGetTextInFrame(conn, frames[1].ID, 4096)
fmt.Println(text) // texto visible del primer iframe, truncado a 4096 bytes
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame cuyo texto visible se quiere leer; obtenido de CdpListFrames (campo CdpFrame.ID)."
- name: maxBytes
desc: "Límite de bytes del texto devuelto. Si maxBytes > 0 trunca con corte rune-safe y añade un sufijo con el total original; si maxBytes <= 0 no hay límite."
output: "String con el innerText visible del documento del iframe (document.body.innerText, o document.documentElement.innerText si no hay body), opcionalmente truncado a maxBytes; error si la conexión es nula, el frameID está vacío o la evaluación CDP del frame falla."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 1. Listar frames para localizar el iframe deseado
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// 2. Leer el texto visible de cada iframe (saltando el frame raíz)
for _, f := range frames {
if f.ParentID == "" { // frame raíz, no es un iframe
continue
}
text, err := CdpGetTextInFrame(conn, f.ID, 4096)
if err != nil {
log.Printf("error en frame %s: %v", f.ID, err)
continue
}
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, text)
}
```
## Cuando usarla
Cuando necesites leer los datos visibles dentro de un iframe sin parsear HTML crudo: extraer el contenido textual de un widget embebido, un panel de pago, un captcha de texto o cualquier documento dentro de un `<iframe>`. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetTextInFrame`. Para HTML estructural completo usa `CdpGetFrameHTML`; para texto visible usa esta.
## Gotchas
- Impura: el frame debe existir y haber terminado de cargar. Un `frameID` obsoleto (frame recargado/navegado) o un frame aún sin cargar propaga el error de `CdpEvalInFrame`.
- Cross-origin OOPIF (out-of-process iframe): el mundo aislado puede vivir en un contexto distinto; si el frame es de otro origen y aislado del proceso, la lectura puede fallar o requerir el `frameID` exacto del OOPIF.
- `innerText` omite el texto oculto por CSS (`display:none`, `visibility:hidden`) y colapsa espacios; refleja lo *visible*, no el contenido literal del DOM. Si necesitas todo el texto del DOM usa `textContent` vía `CdpEvalInFrame`, o el HTML completo vía `CdpGetFrameHTML`.
- El corte por `maxBytes` es rune-safe pero ciego al contenido: puede cortar a mitad de una palabra o de una línea.
@@ -0,0 +1,21 @@
package browser
import (
"testing"
)
// TestCdpGetTextInFrame_guards cubre las precondiciones sin necesitar Chrome vivo.
// La lectura real del innerText de un iframe requiere una conexion CDP activa y
// un frame cargado, igual que los demas tests del paquete que la dejan gated.
func TestCdpGetTextInFrame_guards(t *testing.T) {
t.Run("conexion nula", func(t *testing.T) {
if _, err := CdpGetTextInFrame(nil, "f1", 0); err == nil {
t.Fatal("esperaba error con conexion nula")
}
})
t.Run("frameID vacio", func(t *testing.T) {
if _, err := CdpGetTextInFrame(&CDPConn{}, "", 0); err == nil {
t.Fatal("esperaba error con frameID vacio")
}
})
}
+90 -19
View File
@@ -1,35 +1,106 @@
package browser
import "fmt"
import (
"fmt"
"sync"
)
// DialogLog acumula lo que CdpHandleDialog auto-respondió. El worker lo rellena en
// cada diálogo; el caller lo lee con Snapshot() de forma segura (mutex interno).
// Los campos son públicos para inspección directa en tests controlados, pero en
// concurrencia usa siempre Snapshot() para evitar data races.
type DialogLog struct {
mu sync.Mutex
Count int // número de diálogos auto-respondidos
LastType string // tipo del último diálogo: alert|confirm|prompt|beforeunload
LastMessage string // mensaje del último diálogo
}
// record registra un diálogo auto-respondido. Es el núcleo puro (no toca CDP).
func (l *DialogLog) record(dialogType, message string) {
l.mu.Lock()
l.Count++
l.LastType = dialogType
l.LastMessage = message
l.mu.Unlock()
}
// Snapshot devuelve una copia consistente del estado actual del log.
func (l *DialogLog) Snapshot() (count int, lastType, lastMessage string) {
l.mu.Lock()
defer l.mu.Unlock()
return l.Count, l.LastType, l.LastMessage
}
// dialogJobBuffer es el tamaño del canal que desacopla el readLoop del worker
// que responde diálogos. Amplio para absorber ráfagas sin bloquear la lectura
// del WebSocket.
const dialogJobBuffer = 64
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame
// la funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame la
// funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
// Page.handleJavaScriptDialog del protocolo CDP.
//
// IMPORTANTE: el handler interno despacha la respuesta en una goroutine nueva
// para evitar deadlock — el evento llega en la goroutine de lectura del
// WebSocket, y sendCDP bloquea esperando una respuesta que leeria esa misma
// goroutine si se llamara de forma sincrona.
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error) {
// Devuelve, además del cancel, un *DialogLog que el handler rellena en cada
// diálogo: así el caller sabe cuántos diálogos se auto-respondieron y cuál fue
// el último (tipo + mensaje).
//
// Concurrencia: el handler de evento corre en la goroutine de lectura del
// WebSocket y NO puede llamar sendCDP de forma síncrona (deadlock). En vez de
// lanzar una goroutine nueva por diálogo (spawn ilimitado), encola el evento en
// un canal con buffer que consume UN único worker; el worker serializa las
// respuestas. cancel() detiene el worker y des-registra el handler; es
// idempotente (seguro llamarlo varias veces).
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error) {
if c == nil {
return nil, fmt.Errorf("cdp handle dialog: conexion nula")
return nil, nil, fmt.Errorf("cdp handle dialog: conexion nula")
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp handle dialog: %w", err)
return nil, nil, fmt.Errorf("cdp handle dialog: %w", err)
}
cancel := c.OnEvent("Page.javascriptDialogOpening", func(method string, params map[string]any) {
p := map[string]any{"accept": accept}
if promptText != "" {
p["promptText"] = promptText
dlog := &DialogLog{}
jobs := make(chan map[string]any, dialogJobBuffer)
done := make(chan struct{})
// Worker único: serializa las respuestas a diálogos. Una sola goroutine para
// toda la vida del handler, no una por diálogo.
go func() {
for {
select {
case params := <-jobs:
dtype, _ := params["type"].(string)
msg, _ := params["message"].(string)
dlog.record(dtype, msg)
p := map[string]any{"accept": accept}
if promptText != "" {
p["promptText"] = promptText
}
_, _ = c.sendCDP("Page.handleJavaScriptDialog", p)
case <-done:
return
}
}
}()
cancelEvent := c.OnEvent("Page.javascriptDialogOpening", func(_ string, params map[string]any) {
// Encolar sin bloquear el readLoop. Si el buffer está lleno (tormenta de
// diálogos), descartamos ese evento para no colgar la conexión entera.
select {
case jobs <- params:
default:
}
// go es OBLIGATORIO: el handler corre en la goroutine de lectura del
// WebSocket. Llamar sendCDP aqui directamente provoca deadlock porque
// sendCDP espera una respuesta que la misma goroutine deberia leer.
go c.sendCDP("Page.handleJavaScriptDialog", p) //nolint:errcheck
})
return cancel, nil
var once sync.Once
cancel := func() {
once.Do(func() {
cancelEvent()
close(done)
})
}
return cancel, dlog, nil
}
+23 -20
View File
@@ -5,13 +5,13 @@ kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto."
version: 1.1.0
tested: true
tests: ["TestCdpHandleDialog_nilConn", "TestDialogLog"]
test_file_path: "functions/browser/cdp_handle_dialog_test.go"
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto. Devuelve un *DialogLog con Count/LastType/LastMessage de lo auto-respondido. Un unico worker serializa las respuestas (no spawnea una goroutine por dialogo)."
tags: [cdp, browser, dialog, input, navegator]
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error)"
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error)"
uses_functions: []
uses_types: []
returns: []
@@ -32,7 +32,7 @@ params:
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
- name: promptText
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
output: "cancel func() para des-registrar el handler cuando ya no se necesite, y error si la conexion es nula o Page.enable falla. El cancel devuelto es seguro llamarlo multiples veces."
output: "(cancel func(), *DialogLog, error): cancel des-registra el handler y detiene el worker (idempotente, seguro llamarlo varias veces); DialogLog acumula Count/LastType/LastMessage de lo auto-respondido (leer con Snapshot()); error si la conexion es nula o Page.enable falla."
---
## Ejemplo
@@ -40,10 +40,10 @@ output: "cancel func() para des-registrar el handler cuando ya no se necesite, y
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://example.com/admin")
_ = CdpWaitLoad(conn, 3000)
_ = CdpWaitLoad(conn, 3*time.Second)
// Instalar handler antes de la accion que dispara el dialogo
cancel, err := CdpHandleDialog(conn, true, "")
cancel, dlog, err := CdpHandleDialog(conn, true, "")
if err != nil {
log.Fatal(err)
}
@@ -52,13 +52,11 @@ defer cancel()
// Este boton dispara confirm("¿Seguro que quieres borrar?")
// El handler lo acepta automaticamente sin bloquear
_ = CdpClick(conn, "#btn-delete-all")
_ = CdpWaitIdle(conn, 2000)
_ = CdpWaitIdle(conn, CdpWaitIdleOpts{})
// Ejemplo con prompt(): responder con texto especifico
cancelPrompt, _ := CdpHandleDialog(conn, true, "mi-respuesta-secreta")
defer cancelPrompt()
_ = CdpClick(conn, "#btn-ask-password")
_ = CdpWaitIdle(conn, 1000)
// Saber qué se auto-respondió
count, lastType, lastMsg := dlog.Snapshot()
fmt.Printf("auto-respondidos: %d (último %s: %q)\n", count, lastType, lastMsg)
```
## Cuando usarla
@@ -67,8 +65,13 @@ Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `p
## Gotchas
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion ya usa `go c.sendCDP(...)` para evitarlo — no modificar este patron.
- El handler se instala de forma permanente hasta que se llame `cancel()`. Si la pagina dispara multiples dialogos, todos seran respondidos con los mismos parametros `accept` y `promptText`.
- `Page.enable` es idempotente pero tiene coste de red; no llamar CdpHandleDialog en bucles tight.
- Para `beforeunload` (cuando el usuario cierra/navega fuera), `accept: true` permite la navegacion y `accept: false` la bloquea.
- Llamar `cancel()` no cierra dialogos ya abiertos; solo evita que los futuros sean respondidos automaticamente.
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion encola el evento en un canal y lo responde desde UN worker aparte — no modificar este patron.
- **Un único worker, no goroutine por diálogo**: el handler antiguo hacía `go c.sendCDP(...)` por cada diálogo (spawn ilimitado). Ahora encola en un canal con buffer (64) que consume un worker. Si la página dispara una tormenta de diálogos que llena el buffer, los excedentes se descartan (no se responden) para no colgar la conexión — caso patológico, raro en la práctica.
- **Leer el log con `Snapshot()`**: `DialogLog` tiene mutex interno. En concurrencia, usa `dlog.Snapshot()` en vez de leer los campos públicos directamente (evita data race con el worker).
- El handler responde todos los diálogos con los mismos `accept` y `promptText` hasta que se llame `cancel()`.
- `cancel()` es idempotente (seguro llamarlo varias veces) y detiene el worker. No cierra diálogos ya abiertos; solo evita responder los futuros.
- Para `beforeunload`, `accept: true` permite la navegacion y `accept: false` la bloquea.
## Capability growth log
- v1.1.0 (2026-06-06) — devuelve `*DialogLog` (Count/LastType/LastMessage) para que el caller sepa qué se auto-respondió; reemplaza el spawn de una goroutine por diálogo por un worker único alimentado por canal con buffer; `cancel()` ahora idempotente vía sync.Once.
@@ -0,0 +1,55 @@
package browser
import (
"sync"
"testing"
)
// TestCdpHandleDialog_nilConn cubre la precondición sin Chrome.
func TestCdpHandleDialog_nilConn(t *testing.T) {
_, _, err := CdpHandleDialog(nil, true, "")
if err == nil {
t.Fatal("esperaba error con conexion nula")
}
}
// TestDialogLog cubre el núcleo puro del registro de diálogos: contar, recordar
// el último, y la seguridad concurrente del mutex. No requiere Chrome.
func TestDialogLog(t *testing.T) {
t.Run("golden: cuenta y recuerda el ultimo", func(t *testing.T) {
l := &DialogLog{}
l.record("alert", "hola")
l.record("confirm", "¿seguro?")
count, lastType, lastMsg := l.Snapshot()
if count != 2 {
t.Errorf("count = %d, esperaba 2", count)
}
if lastType != "confirm" || lastMsg != "¿seguro?" {
t.Errorf("last = (%q,%q), esperaba (confirm, ¿seguro?)", lastType, lastMsg)
}
})
t.Run("edge: log vacio", func(t *testing.T) {
l := &DialogLog{}
count, lastType, lastMsg := l.Snapshot()
if count != 0 || lastType != "" || lastMsg != "" {
t.Errorf("log vacio = (%d,%q,%q), esperaba (0,\"\",\"\")", count, lastType, lastMsg)
}
})
t.Run("concurrencia: 100 records desde N goroutines no pierde cuentas", func(t *testing.T) {
l := &DialogLog{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
l.record("alert", "x")
}()
}
wg.Wait()
if count, _, _ := l.Snapshot(); count != 100 {
t.Errorf("count = %d, esperaba 100 (sin perder por race)", count)
}
})
}
+60 -12
View File
@@ -9,12 +9,21 @@ import (
// MouseHumanOpts configura el movimiento humano del ratón.
type MouseHumanOpts struct {
// Steps es el número de puntos intermedios de la curva (default 25).
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
// - fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
// para scraping masivo propio).
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
Mode string
// Steps es el número de puntos intermedios de la curva (default según Mode).
Steps int
// DurationMs es la duración total aproximada del movimiento en milisegundos.
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
// Si es 0, se elige según Mode.
DurationMs int
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default según Mode).
JitterPx float64
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
FromX float64
@@ -22,16 +31,49 @@ type MouseHumanOpts struct {
FromY float64
}
// mouseHumanDefaults aplica valores por defecto a opts.
// MouseProfileForMode construye las opciones de ratón para un modo de velocidad.
// Es la fuente única que MCP, runner YAML y CLI usan para mapear un modo a opts,
// sin duplicar números. El mapeo modo→valores concretos vive en mouseHumanDefaults.
// Un modo desconocido se trata como "human" (el más seguro).
func MouseProfileForMode(mode string) MouseHumanOpts {
switch mode {
case "fast", "instant", "human", "":
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
default:
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
}
}
// mouseHumanDefaults aplica valores por defecto a opts según opts.Mode.
func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
if opts.Steps <= 0 {
opts.Steps = 25
}
if opts.DurationMs <= 0 {
opts.DurationMs = 350 + rand.Intn(451) // 350..800
}
if opts.JitterPx <= 0 {
opts.JitterPx = 2.0
switch opts.Mode {
case "instant":
// El movimiento se omite en CdpMoveMouseHuman; valores mínimos por si acaso.
if opts.Steps <= 0 {
opts.Steps = 1
}
if opts.DurationMs <= 0 {
opts.DurationMs = 1
}
// JitterPx se queda en 0.
case "fast":
if opts.Steps <= 0 {
opts.Steps = 5
}
if opts.DurationMs <= 0 {
opts.DurationMs = 40 + rand.Intn(41) // 40..80
}
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast).
default: // "human" o ""
if opts.Steps <= 0 {
opts.Steps = 25
}
if opts.DurationMs <= 0 {
opts.DurationMs = 350 + rand.Intn(451) // 350..800
}
if opts.JitterPx <= 0 {
opts.JitterPx = 2.0
}
}
if opts.FromX < 0 {
opts.FromX = 0
@@ -119,6 +161,12 @@ func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error
}
opts = mouseHumanDefaults(opts)
// Modo instant: sin movimiento de ratón (el click lo resuelve quien llama,
// por coords directas o por element.click() JS).
if opts.Mode == "instant" {
return nil
}
p0 := [2]float64{opts.FromX, opts.FromY}
p3 := [2]float64{toX, toY}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
+44 -10
View File
@@ -15,21 +15,45 @@ type CdpStorageState struct {
SessionStorage map[string]string `json:"sessionStorage"`
}
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa. Si el
// origen no permite acceso (about:blank, chrome://) devuelve un mapa vacío.
func readWebStorage(c *CDPConn, store string) map[string]string {
// isStorageAccessDenied reconoce el error de CdpEvaluate cuando el origen no
// permite acceder a window.localStorage/sessionStorage (about:blank, chrome://,
// data:, sandbox sin allow-same-origin): el navegador lanza SecurityError. Es
// puro: decide a partir del texto del error. Distingue ese caso legítimo (no hay
// storage que guardar -> {}) de un error real (conexión caída, JS roto) que SÍ
// debe propagarse para no escribir una sesión incompleta en silencio.
func isStorageAccessDenied(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "SecurityError") ||
strings.Contains(s, "Access is denied") ||
strings.Contains(s, "operation is insecure") ||
strings.Contains(s, "denied for this document")
}
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa.
// Distingue tres casos:
// - storage accesible (con o sin datos) -> (mapa, nil)
// - origen sin storage accesible (about:blank, chrome://) -> ({}, nil)
// - error REAL de evaluación (conexión caída, JS roto, JSON inválido) -> (nil, error)
func readWebStorage(c *CDPConn, store string) (map[string]string, error) {
raw, err := CdpEvaluate(c, "JSON.stringify(Object.assign({}, window."+store+"))")
if err != nil {
return map[string]string{}
if isStorageAccessDenied(err) {
// Origen sin storage accesible: vacío legítimo, no error.
return map[string]string{}, nil
}
return nil, fmt.Errorf("leer %s: %w", store, err)
}
if raw == "" || raw == "undefined" || raw == "null" {
return map[string]string{}
return map[string]string{}, nil
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]string{}
return nil, fmt.Errorf("parsear %s: %w", store, err)
}
return m
return m, nil
}
// cookieDomainMatchesHost indica si una cookie con `domain` aplica al `host` dado.
@@ -100,11 +124,21 @@ func CdpSaveStorageState(c *CDPConn, outPath string) error {
}
}
// Capturar localStorage y sessionStorage del origen actualmente cargado.
// Capturar localStorage y sessionStorage del origen actualmente cargado. Un
// error real (no un origen sin storage) aborta el guardado: mejor fallar que
// escribir una sesión incompleta que el caller creería válida.
localStorage, err := readWebStorage(c, "localStorage")
if err != nil {
return fmt.Errorf("cdp save storage state: %w", err)
}
sessionStorage, err := readWebStorage(c, "sessionStorage")
if err != nil {
return fmt.Errorf("cdp save storage state: %w", err)
}
state := CdpStorageState{
Cookies: cookies,
LocalStorage: readWebStorage(c, "localStorage"),
SessionStorage: readWebStorage(c, "sessionStorage"),
LocalStorage: localStorage,
SessionStorage: sessionStorage,
}
data, err := json.MarshalIndent(state, "", " ")
+10 -4
View File
@@ -5,9 +5,11 @@ kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Captura cookies y localStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login."
version: 1.1.0
tested: true
tests: ["TestIsStorageAccessDenied", "TestCookieDomainMatchesHost"]
test_file_path: "functions/browser/cdp_save_storage_state_test.go"
description: "Captura cookies, localStorage y sessionStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login. Distingue 'origen sin storage accesible' (vacío legítimo) de un error real de evaluación, que aborta el guardado en vez de escribir una sesión incompleta en silencio."
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
signature: "func CdpSaveStorageState(c *CDPConn, outPath string) error"
uses_functions:
@@ -58,5 +60,9 @@ Tras completar un login en el browser (manual o automatizado), antes de cerrar l
- **localStorage es por-origen**: solo captura el localStorage del origen actualmente cargado en la pestaña. Si necesitas preservar localStorage de múltiples dominios, guarda un estado por cada dominio navegado.
- **Cookies globales del perfil**: `Network.getAllCookies` devuelve todas las cookies del perfil de Chrome, no solo las del origen activo. El JSON puede ser grande si el perfil tiene muchas cookies.
- **Páginas especiales** (`about:blank`, `chrome://`, extensiones): `CdpEvaluate` sobre localStorage fallará; la función lo maneja devolviendo un mapa vacío de forma defensiva, así que no romperá — pero el localStorage quedará vacío en el JSON.
- **Páginas especiales** (`about:blank`, `chrome://`, `data:`, extensiones): acceder a `window.localStorage` lanza `SecurityError`. La función lo detecta (`isStorageAccessDenied`) y devuelve `{}` legítimo, no error — el storage queda vacío en el JSON. **Pero** un error REAL (conexión caída, JS roto, JSON inválido) ahora SÍ se propaga y aborta el guardado: antes se tragaba en silencio y escribía una sesión incompleta que parecía válida.
- **Permisos**: el archivo se escribe con `0644`; asegúrate de que el directorio de destino existe antes de llamar a la función.
## Capability growth log
- v1.1.0 (2026-06-06) — `readWebStorage` distingue "origen sin storage accesible" (SecurityError → `{}`) de "error real de evaluación" (se propaga); `CdpSaveStorageState` aborta en error real en vez de guardar sesión incompleta en silencio; captura también sessionStorage; test del discriminador `isStorageAccessDenied` + del matcher `cookieDomainMatchesHost`.
@@ -0,0 +1,54 @@
package browser
import (
"errors"
"testing"
)
// TestIsStorageAccessDenied cubre el discriminador puro que separa "origen sin
// storage accesible" (vacío legítimo) de "error real" (que debe propagarse).
func TestIsStorageAccessDenied(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil no es denied", nil, false},
{"error de conexion (real) no es denied", errors.New("cdp evaluate: ws read: EOF"), false},
{"SecurityError es denied", errors.New("cdp evaluate: excepcion JS: SecurityError: ..."), true},
{"Access is denied es denied", errors.New("Access is denied for this document"), true},
{"operation is insecure es denied", errors.New("The operation is insecure"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isStorageAccessDenied(tc.err); got != tc.want {
t.Errorf("isStorageAccessDenied(%v) = %v, esperaba %v", tc.err, got, tc.want)
}
})
}
}
// TestCookieDomainMatchesHost cubre el matcher puro de dominio de cookie vs host
// (no tenía test previo).
func TestCookieDomainMatchesHost(t *testing.T) {
cases := []struct {
domain, host string
want bool
}{
{"example.com", "example.com", true}, // exacto
{".example.com", "example.com", true}, // punto inicial
{".example.com", "app.example.com", true}, // subdominio
{"example.com", "app.example.com", true}, // subdominio sin punto
{"example.com", "notexample.com", false}, // sufijo engañoso
{"example.com", "example.com.evil.com", false}, // no es subdominio real
{"", "example.com", false}, // dominio vacío
{"example.com", "", false}, // host vacío
{"other.com", "example.com", false}, // distinto
}
for _, tc := range cases {
got := cookieDomainMatchesHost(tc.domain, tc.host)
if got != tc.want {
t.Errorf("cookieDomainMatchesHost(%q, %q) = %v, esperaba %v", tc.domain, tc.host, got, tc.want)
}
}
}
+88 -13
View File
@@ -17,12 +17,57 @@ type CdpScreenshotOpts struct {
Format string
}
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
// fullPageClip es el rectangulo de recorte (en CSS pixels) que cubre la pagina
// completa. scale=1 mantiene la resolucion nativa.
type fullPageClip struct {
X, Y, Width, Height, Scale float64
}
// buildFullPageClip construye el clip de pagina completa a partir de la respuesta
// de Page.getLayoutMetrics. Es una funcion pura: no toca red, recibe el mapa ya
// deserializado por CDP y decide el rectangulo.
//
// Prefiere cssContentSize (dimensiones en CSS pixels, ya divididas por el DPR),
// que es lo que espera el campo "clip" de Page.captureScreenshot. Cae a
// contentSize (device pixels, protocolo antiguo) si cssContentSize no esta
// presente. Devuelve ok=false cuando no hay un tamano valido (>0 en ambos ejes),
// para que el caller capture solo el viewport en vez de un clip degenerado.
func buildFullPageClip(metrics map[string]any) (fullPageClip, bool) {
asFloat := func(v any) float64 {
f, _ := v.(float64)
return f
}
for _, key := range []string{"cssContentSize", "contentSize"} {
size, ok := metrics[key].(map[string]any)
if !ok {
continue
}
w := asFloat(size["width"])
h := asFloat(size["height"])
if w > 0 && h > 0 {
return fullPageClip{X: 0, Y: 0, Width: w, Height: h, Scale: 1}, true
}
}
return fullPageClip{}, false
}
// CdpScreenshotBytes captura un screenshot de la pagina actual y devuelve los
// bytes de imagen ya decodificados junto con su mimeType, sin tocar el disco.
// Usa Page.captureScreenshot del protocolo CDP.
// outputPath debe tener extension .png o .jpg/.jpeg segun el formato elegido.
func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error {
//
// El mimeType es "image/jpeg" cuando opts pide JPEG y "image/png" en cualquier
// otro caso (incluido el default cuando opts.Format esta vacio).
//
// Si opts.FullPage es true, consulta Page.getLayoutMetrics para construir un clip
// que cubra la altura completa del documento (no solo el viewport) y mantiene
// captureBeyondViewport=true para que Chrome renderice mas alla del area visible.
//
// Es la primitiva reutilizable de captura: util para devolver la imagen al LLM
// como image content (bytes) sin pasar por archivo. CdpScreenshot compone sobre
// ella para persistir a disco.
func CdpScreenshotBytes(c *CDPConn, opts CdpScreenshotOpts) ([]byte, string, error) {
if c == nil {
return fmt.Errorf("cdp screenshot: conexion nula")
return nil, "", fmt.Errorf("cdp screenshot: conexion nula")
}
if opts.Format == "" {
@@ -32,8 +77,13 @@ func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error
opts.Quality = 80
}
mimeType := "image/png"
if opts.Format == "jpeg" {
mimeType = "image/jpeg"
}
params := map[string]any{
"format": opts.Format,
"format": opts.Format,
"captureBeyondViewport": opts.FullPage,
}
if opts.Format == "jpeg" {
@@ -41,27 +91,52 @@ func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error
}
if opts.FullPage {
// Expandir clip para capturar toda la pagina
scrollHeight, err := CdpEvaluate(c, "document.documentElement.scrollHeight")
if err == nil {
params["clip"] = nil // dejar que Chrome capture todo
_ = scrollHeight
// Page.getLayoutMetrics da el tamano real del documento. Construimos el
// clip con la funcion pura buildFullPageClip. Si la consulta falla o no
// hay dimensiones validas, omitimos el clip y caemos a captura normal
// (con captureBeyondViewport=true Chrome aun captura algo razonable).
if metrics, err := c.sendCDP("Page.getLayoutMetrics", nil); err == nil {
if clip, ok := buildFullPageClip(metrics); ok {
params["clip"] = map[string]any{
"x": clip.X,
"y": clip.Y,
"width": clip.Width,
"height": clip.Height,
"scale": clip.Scale,
}
}
}
}
result, err := c.sendCDP("Page.captureScreenshot", params)
if err != nil {
return fmt.Errorf("cdp screenshot: %w", err)
return nil, "", fmt.Errorf("cdp screenshot: %w", err)
}
dataStr, ok := result["data"].(string)
if !ok {
return fmt.Errorf("cdp screenshot: campo data ausente en respuesta")
return nil, "", fmt.Errorf("cdp screenshot: campo data ausente en respuesta")
}
imgData, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
return fmt.Errorf("cdp screenshot: decodificar base64: %w", err)
return nil, "", fmt.Errorf("cdp screenshot: decodificar base64: %w", err)
}
return imgData, mimeType, nil
}
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
// outputPath debe tener extension .png o .jpg/.jpeg segun el formato elegido.
//
// Compone sobre CdpScreenshotBytes para obtener los bytes de imagen y luego crea
// el directorio destino si no existe y escribe el archivo. Mismo comportamiento
// observable que antes: mismos parametros, mismos efectos en disco, mismos
// errores de captura.
func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error {
imgData, _, err := CdpScreenshotBytes(c, opts)
if err != nil {
return err
}
// Crear directorio si no existe
+22 -6
View File
@@ -3,12 +3,12 @@ name: cdp_screenshot
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.2.0"
purity: impure
signature: "func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error"
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. Crea el directorio destino si no existe."
tags: [chrome, cdp, browser, automation, screenshot, devtools, png]
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. En modo FullPage usa Page.getLayoutMetrics (cssContentSize) para construir un clip que cubre la altura real del documento. Crea el directorio destino si no existe. Compone sobre CdpScreenshotBytes para la captura a memoria."
tags: [chrome, cdp, browser, automation, screenshot, devtools, png, navegator]
uses_functions: [cdp_screenshot_bytes_go_browser]
uses_types: []
returns: []
returns_optional: false
@@ -23,8 +23,8 @@ params:
desc: "opciones de captura (FullPage, Quality, Format)"
output: "error si falla la captura o la escritura del archivo"
tested: true
tests: ["TestCdpScreenshot"]
test_file_path: "functions/browser/chrome_launch_test.go"
tests: ["TestBuildFullPageClip", "TestCdpScreenshot"]
test_file_path: "functions/browser/cdp_screenshot_test.go"
file_path: "functions/browser/cdp_screenshot.go"
---
@@ -40,6 +40,22 @@ err := CdpScreenshot(conn, "/tmp/page.png", CdpScreenshotOpts{
})
```
## Cuando usarla
Para guardar evidencia visual de una página tras navegar o ejecutar acciones. Usa `FullPage: true` cuando necesites toda la altura del documento (capturas de auditoría, scraping visual de páginas largas); `false` (default) para capturar solo el viewport visible, más rápido.
## Gotchas
- **FullPage usa el tamaño real del documento**: consulta `Page.getLayoutMetrics` y construye el clip desde `cssContentSize` (CSS pixels). Si Chrome no devuelve dimensiones válidas, cae a captura normal con `captureBeyondViewport=true` en vez de fallar.
- **Páginas con lazy-loading**: el `cssContentSize` refleja el DOM en el instante de la captura. Si la página carga contenido al hacer scroll, haz scroll + `CdpWaitIdle` antes para que la altura sea la final.
- **Formato según extensión**: la función no infiere el formato de la extensión del `outputPath`; pásalo explícito en `opts.Format` ("png" o "jpeg"). El default es "png".
- **JPEG quality**: solo aplica si `Format == "jpeg"`; el default es 80.
## Notas
El struct `CdpScreenshotOpts` tiene campos: `FullPage bool`, `Quality int` (JPEG), `Format string` ("png" o "jpeg"). Chrome retorna la imagen como base64 que se decodifica y escribe al disco.
## Capability growth log
- v1.2.0 (2026-06-06) — refactor a composición: toda la lógica de captura (enable/clip FullPage/captureScreenshot/decode base64) se extrae a `CdpScreenshotBytes` (`cdp_screenshot_bytes_go_browser`), que devuelve bytes + mimeType en memoria. `CdpScreenshot` ahora compone sobre ella + crea el directorio + escribe el archivo. Firma pública y comportamiento observable intactos.
- v1.1.0 (2026-06-06) — FullPage implementado de verdad: clip desde Page.getLayoutMetrics (cssContentSize) vía función pura `buildFullPageClip`, en vez del código muerto que calculaba scrollHeight y lo descartaba.
+57
View File
@@ -0,0 +1,57 @@
---
name: cdp_screenshot_bytes
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpScreenshotBytes(c *CDPConn, opts CdpScreenshotOpts) ([]byte, string, error)"
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y devuelve los bytes de imagen ya decodificados junto con su mimeType, sin tocar el disco. mimeType es image/jpeg si opts pide JPEG, si no image/png. Soporta viewport o pagina completa: en modo FullPage usa Page.getLayoutMetrics (cssContentSize) para construir un clip que cubre la altura real del documento. Primitiva reutilizable para devolver la imagen al LLM como image content."
tags: [chrome, cdp, browser, automation, screenshot, devtools, png, image, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/base64, fmt]
params:
- name: c
desc: "conexión CDP activa"
- name: opts
desc: "opciones de captura (FullPage, Quality, Format)"
output: "bytes de imagen decodificados + mimeType (image/png o image/jpeg), o error si falla la captura"
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_screenshot.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
imgData, mimeType, err := CdpScreenshotBytes(conn, CdpScreenshotOpts{
FullPage: true,
Format: "png",
})
// imgData: bytes PNG listos para enviar al LLM como image content
// mimeType: "image/png"
```
## Cuando usarla
Cuando necesitas la imagen capturada en memoria, no en disco: típicamente para devolverla al LLM como image content (bytes + mimeType) en un MCP o tool, sin pasar por un archivo temporal. Es la primitiva de captura sobre la que compone `CdpScreenshot` (que persiste a disco). Úsala directamente cuando el destino no es el filesystem.
## Gotchas
- **Impura: requiere Chrome vivo**: necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
- **FullPage usa el tamaño real del documento**: consulta `Page.getLayoutMetrics` y construye el clip desde `cssContentSize` (CSS pixels). Si Chrome no devuelve dimensiones válidas, cae a captura normal con `captureBeyondViewport=true` en vez de fallar.
- **mimeType según opts, no según extensión**: devuelve `"image/jpeg"` solo cuando `opts.Format == "jpeg"`; en cualquier otro caso (incluido el default con `Format` vacío) devuelve `"image/png"`. No hay archivo, así que no infiere nada de una extensión.
- **JPEG quality**: solo aplica si `Format == "jpeg"`; el default es 80.
- **Páginas con lazy-loading**: el `cssContentSize` refleja el DOM en el instante de la captura. Si la página carga contenido al hacer scroll, haz scroll + `CdpWaitIdle` antes para que la altura sea la final.
## Notas
Adición de `cdp_screenshot` (estilo ADR 0003): el `.go` vive junto a `cdp_screenshot.go` en el mismo paquete `browser`. El struct `CdpScreenshotOpts` (campos `FullPage bool`, `Quality int`, `Format string`) es compartido con `CdpScreenshot`. Chrome retorna la imagen como base64; esta función la decodifica a `[]byte` y la devuelve sin escribir a disco. `CdpScreenshot` compone sobre esta primitiva añadiendo creación de directorio + escritura del archivo.
+76
View File
@@ -0,0 +1,76 @@
package browser
import "testing"
// TestBuildFullPageClip cubre el nucleo puro del modo FullPage: dado el mapa de
// Page.getLayoutMetrics, construir el clip que cubre el documento entero. No
// requiere Chrome.
func TestBuildFullPageClip(t *testing.T) {
t.Run("golden: pagina larga via cssContentSize", func(t *testing.T) {
metrics := map[string]any{
"cssContentSize": map[string]any{
"x": 0.0, "y": 0.0, "width": 1280.0, "height": 8000.0,
},
}
clip, ok := buildFullPageClip(metrics)
if !ok {
t.Fatal("esperaba ok=true para cssContentSize valido")
}
if clip.Width != 1280 || clip.Height != 8000 {
t.Errorf("clip = %+v, esperaba width=1280 height=8000", clip)
}
if clip.X != 0 || clip.Y != 0 || clip.Scale != 1 {
t.Errorf("clip = %+v, esperaba x=0 y=0 scale=1", clip)
}
})
t.Run("edge: viewport pequeno (pagina corta) sigue produciendo clip valido", func(t *testing.T) {
metrics := map[string]any{
"cssContentSize": map[string]any{"width": 320.0, "height": 480.0},
}
clip, ok := buildFullPageClip(metrics)
if !ok {
t.Fatal("esperaba ok=true para pagina corta")
}
if clip.Width != 320 || clip.Height != 480 {
t.Errorf("clip = %+v, esperaba 320x480", clip)
}
})
t.Run("edge: fallback a contentSize cuando falta cssContentSize", func(t *testing.T) {
metrics := map[string]any{
"contentSize": map[string]any{"width": 1024.0, "height": 2048.0},
}
clip, ok := buildFullPageClip(metrics)
if !ok {
t.Fatal("esperaba ok=true via contentSize")
}
if clip.Width != 1024 || clip.Height != 2048 {
t.Errorf("clip = %+v, esperaba 1024x2048", clip)
}
})
t.Run("error: dimensiones cero -> ok=false (captura solo viewport)", func(t *testing.T) {
metrics := map[string]any{
"cssContentSize": map[string]any{"width": 0.0, "height": 0.0},
}
if _, ok := buildFullPageClip(metrics); ok {
t.Error("esperaba ok=false para dimensiones cero")
}
})
t.Run("error: pagina vacia (metrics sin tamano) -> ok=false", func(t *testing.T) {
if _, ok := buildFullPageClip(map[string]any{}); ok {
t.Error("esperaba ok=false para metrics vacio")
}
})
t.Run("error: width valido pero height cero -> ok=false", func(t *testing.T) {
metrics := map[string]any{
"cssContentSize": map[string]any{"width": 800.0, "height": 0.0},
}
if _, ok := buildFullPageClip(metrics); ok {
t.Error("esperaba ok=false cuando un eje es cero")
}
})
}
+88 -33
View File
@@ -10,17 +10,84 @@ import (
type CdpWaitIdleOpts struct {
QuietMs int // ms que inflight debe permanecer <= MaxInflight (default 500)
Timeout time.Duration // maximo total a esperar (default 8s)
MaxInflight int // requests en vuelo tolerados para considerar idle (default 0)
MaxInflight int // requests en vuelo tolerados para considerar idle (default 2)
PollMs int // intervalo de chequeo en ms (default 100)
}
// isPersistentResourceType indica si un Network resourceType corresponde a una
// conexion de larga duracion que NO emite loadingFinished/loadingFailed y por
// tanto colgaria el contador inflight para siempre. La pagina abre estas
// conexiones (analytics en vivo, push, hot-reload) y nunca "terminan".
func isPersistentResourceType(resourceType string) bool {
switch resourceType {
case "WebSocket", "EventSource":
return true
default:
return false
}
}
// InflightTracker cuenta requests de red en vuelo de forma pura y testeable: no
// toca red ni CDP, solo recibe eventos ya parseados (requestId + resourceType) y
// mantiene el conjunto de requests activos. Trackea por requestId para que el
// loadingFinished/loadingFailed de un request que nunca contamos (una conexion
// persistente) sea un no-op en vez de un decremento espurio.
//
// Las conexiones persistentes (WebSocket, EventSource) se excluyen del conteo
// porque no emiten un evento de finalizacion: contarlas haria que la red nunca
// pareciera idle.
type InflightTracker struct {
mu sync.Mutex
tracked map[string]bool
}
// NewInflightTracker crea un tracker vacio listo para recibir eventos.
func NewInflightTracker() *InflightTracker {
return &InflightTracker{tracked: map[string]bool{}}
}
// OnRequest registra el inicio de un request (Network.requestWillBeSent). Ignora
// las conexiones persistentes para no contaminar el conteo.
func (t *InflightTracker) OnRequest(requestID, resourceType string) {
if isPersistentResourceType(resourceType) {
return
}
t.mu.Lock()
t.tracked[requestID] = true
t.mu.Unlock()
}
// OnFinish marca un request como completado (Network.loadingFinished).
func (t *InflightTracker) OnFinish(requestID string) { t.complete(requestID) }
// OnFail marca un request como fallido (Network.loadingFailed). A efectos de
// inflight, fallar y terminar son lo mismo: el request ya no esta en vuelo.
func (t *InflightTracker) OnFail(requestID string) { t.complete(requestID) }
func (t *InflightTracker) complete(requestID string) {
t.mu.Lock()
delete(t.tracked, requestID)
t.mu.Unlock()
}
// Inflight retorna el numero de requests actualmente en vuelo.
func (t *InflightTracker) Inflight() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.tracked)
}
// IsIdle indica si el numero de requests en vuelo esta dentro del umbral dado.
func (t *InflightTracker) IsIdle(maxInflight int) bool {
return t.Inflight() <= maxInflight
}
// CdpWaitIdle espera a que la actividad de red de la pagina llegue a idle.
// Suscribe eventos Network.requestWillBeSent / Network.loadingFinished /
// Network.loadingFailed via el mecanismo OnEvent del CDPConn para mantener
// un contador de requests en vuelo (inflight). Cuando inflight <= MaxInflight
// de forma continuada durante QuietMs milisegundos, la funcion retorna nil.
// Si se alcanza Timeout sin lograr esa ventana quieta, retorna error con el
// inflight actual en el mensaje.
// Network.loadingFailed via el mecanismo OnEvent del CDPConn y delega el conteo
// en un InflightTracker. Cuando inflight <= MaxInflight de forma continuada
// durante QuietMs milisegundos, la funcion retorna nil. Si se alcanza Timeout
// sin lograr esa ventana quieta, retorna error con el inflight actual.
//
// Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones
// JS, ya que la señal es red, no DOM.
@@ -36,41 +103,36 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
if opts.Timeout <= 0 {
opts.Timeout = 8 * time.Second
}
// MaxInflight 0 es el default semantico: queremos red completamente idle.
// MaxInflight default 2: la web moderna mantiene 1-2 beacons/analytics de
// fondo que casi nunca dejan inflight en 0; exigir 0 cuelga hasta el timeout.
if opts.MaxInflight <= 0 {
opts.MaxInflight = 2
}
if opts.PollMs <= 0 {
opts.PollMs = 100
}
var (
mu sync.Mutex
inflight int
)
tracker := NewInflightTracker()
// Suscribir eventos Network usando el mismo mecanismo que cdp_har_record:
// c.OnEvent retorna una funcion cancel que des-registra el handler.
// Multiples consumidores del mismo metodo son soportados (slice de handlers).
cancel1 := c.OnEvent("Network.requestWillBeSent", func(_ string, p map[string]any) {
mu.Lock()
inflight++
mu.Unlock()
id, _ := p["requestId"].(string)
typ, _ := p["type"].(string)
tracker.OnRequest(id, typ)
})
defer cancel1()
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
mu.Lock()
if inflight > 0 {
inflight--
}
mu.Unlock()
id, _ := p["requestId"].(string)
tracker.OnFinish(id)
})
defer cancel2()
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
mu.Lock()
if inflight > 0 {
inflight--
}
mu.Unlock()
id, _ := p["requestId"].(string)
tracker.OnFail(id)
})
defer cancel3()
@@ -89,11 +151,7 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
mu.Lock()
current := inflight
mu.Unlock()
if current <= opts.MaxInflight {
if tracker.IsIdle(opts.MaxInflight) {
// Red idle: iniciar o mantener la ventana de quietud.
if quietSince.IsZero() {
quietSince = time.Now()
@@ -107,8 +165,5 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
}
}
mu.Lock()
current := inflight
mu.Unlock()
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, current)
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, tracker.Inflight())
}
+12 -12
View File
@@ -3,10 +3,10 @@ name: cdp_wait_idle
kind: function
lang: go
domain: browser
version: "1.1.0"
version: "1.2.0"
purity: impure
signature: "func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error"
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight): +1 en requestWillBeSent, -1 en loadingFinished/loadingFailed. Cuando inflight <= MaxInflight de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight) via InflightTracker: trackea por requestId, excluye conexiones persistentes (WebSocket, EventSource) que nunca terminan. Cuando inflight <= MaxInflight (default 2) de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
tags: [cdp, chrome, browser, wait, spa, network, idle, polling, hydration, navegator]
uses_functions: []
uses_types: []
@@ -18,14 +18,12 @@ params:
- name: c
desc: "conexion CDP activa (obtenida con CdpConnect)"
- name: opts
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 0), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 2), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
output: "nil si la red llega a idle dentro del timeout; error descriptivo con inflight actual si se agota el tiempo o la conexion falla"
tested: true
tests:
- "conexion nula retorna error inmediato"
- "opts con ceros aplica defaults antes de usar"
- "error de conexion nula contiene texto descriptivo"
- "mensaje de error nil-conn menciona cdp wait idle"
- "TestCdpWaitIdleDefaults"
- "TestInflightTracker"
test_file_path: "functions/browser/cdp_wait_idle_test.go"
file_path: "functions/browser/cdp_wait_idle.go"
---
@@ -64,12 +62,14 @@ La funcion suscribe `Network.requestWillBeSent`, `Network.loadingFinished` y `Ne
## Gotchas
- **Paginas con polling persistente o WebSockets**: si la pagina lanza un request periodico (ej. SSE, long-poll cada 30 s), inflight puede no llegar a 0 durante `QuietMs`. Solucionar con `MaxInflight: 1` para tolerar ese request de fondo, o reducir `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
- **Timeout corto por defecto (8 s)**: es deliberado. Para paginas de polling persistente donde inflight nunca llega a 0, un timeout largo solo bloquea. Preferir `MaxInflight > 0` o `Timeout` mas largo explicitamente.
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para facilitar diagnostico (saber cuantos requests quedaron colgados).
- **Network.enable/disable**: la funcion habilita el dominio Network al entrar y lo deshabilita al salir via defer. Si otra funcion en la misma conexion (ej. `cdp_har_record`) ya lo tiene habilitado, el disable al salir lo desactivara para todos. Usar `MaxInflight` y `Timeout` razonables y no interleave con `cdp_har_record` en la misma conexion salvo que el orden de cierre sea controlado.
- **Test e2e real**: los tests del paquete no requieren Chrome. Para pruebas reales, lanzar Chrome con `--remote-debugging-port=9222`, navegar a la pagina objetivo y llamar esta funcion tras `CdpWaitLoad`.
- **MaxInflight default = 2**: la web moderna mantiene 1-2 beacons/analytics de fondo que rara vez dejan inflight en 0. El zero-value de `MaxInflight` (0) se reescribe a 2 para no colgar hasta el timeout. Para exigir idle absoluto en una página simple, no hay valor de "0 explícito" (0 == default); usa una página sin analytics o asume el umbral 2.
- **WebSocket / EventSource excluidos del conteo**: estas conexiones persistentes no emiten `loadingFinished`, así que contarlas dejaría inflight clavado para siempre. El `InflightTracker` las ignora en `requestWillBeSent` (por `params.type`). Un stream WS/SSE abierto ya NO impide llegar a idle.
- **Polling/long-poll periódico**: si la página lanza un XHR cada N segundos, inflight oscila; con `MaxInflight: 2` (default) suele tolerarse. Si no, reduce `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para diagnóstico.
- **Network.enable/disable**: la función habilita Network al entrar y lo deshabilita al salir via defer. No interleave con `cdp_har_record` en la misma conexión salvo orden de cierre controlado.
- **Tests sin Chrome**: el núcleo (`InflightTracker`) se testea con secuencias de eventos sintéticas. El bucle de polling con timeout real requiere Chrome y no está simulado.
## Capability growth log
- v1.2.0 (2026-06-06) — refactor a `InflightTracker` puro (testeable sin red); default MaxInflight 0→2 (analytics ya no cuelga); excluye WebSocket/EventSource del conteo (no terminan); tracking por requestId (finish de request no contado = no-op).
- v1.1.0 (2026-06-05) — cambia señal DOM-length → network-idle via eventos CDP Network.*; añade MaxInflight configurable; defaults mas ajustados (QuietMs 800→500, Timeout 15s→8s, PollMs 200→100).
+82 -12
View File
@@ -25,7 +25,7 @@ func TestCdpWaitIdleDefaults(t *testing.T) {
}
})
t.Run("error de conexion nula contiene texto descriptivo", func(t *testing.T) {
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{
QuietMs: 100,
Timeout: 500 * time.Millisecond,
@@ -34,19 +34,89 @@ func TestCdpWaitIdleDefaults(t *testing.T) {
if err == nil {
t.Fatal("esperaba error, got nil")
}
msg := err.Error()
if len(msg) == 0 {
t.Error("el mensaje de error no debe estar vacio")
}
})
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
if err == nil {
t.Fatal("esperaba error, got nil")
}
if !strings.Contains(err.Error(), "cdp wait idle") {
t.Errorf("mensaje de error %q no contiene 'cdp wait idle'", err.Error())
}
})
}
// TestInflightTracker cubre el nucleo puro del contador de red. No requiere Chrome:
// alimenta secuencias de eventos {requestId, resourceType} y verifica el conteo.
func TestInflightTracker(t *testing.T) {
t.Run("golden: carga normal llega a idle", func(t *testing.T) {
tr := NewInflightTracker()
tr.OnRequest("r1", "Document")
tr.OnRequest("r2", "Script")
tr.OnRequest("r3", "Image")
if got := tr.Inflight(); got != 3 {
t.Fatalf("inflight tras 3 requests = %d, esperaba 3", got)
}
tr.OnFinish("r1")
tr.OnFinish("r2")
tr.OnFail("r3") // un recurso que falla tambien deja de estar en vuelo
if got := tr.Inflight(); got != 0 {
t.Fatalf("inflight tras completar todo = %d, esperaba 0", got)
}
if !tr.IsIdle(0) {
t.Error("esperaba IsIdle(0)=true con inflight=0")
}
})
t.Run("edge: analytics residual idle ok con MaxInflight=2", func(t *testing.T) {
tr := NewInflightTracker()
// La pagina cargo, pero 2 beacons de analytics quedan sin finalizar.
tr.OnRequest("doc", "Document")
tr.OnFinish("doc")
tr.OnRequest("beacon1", "Ping")
tr.OnRequest("beacon2", "XHR")
if got := tr.Inflight(); got != 2 {
t.Fatalf("inflight = %d, esperaba 2 (beacons residuales)", got)
}
if tr.IsIdle(0) {
t.Error("con MaxInflight=0 NO deberia ser idle (2 beacons en vuelo)")
}
if !tr.IsIdle(2) {
t.Error("con MaxInflight=2 (default) SI deberia ser idle")
}
})
t.Run("error/regresion: WebSocket abierto NO impide idle", func(t *testing.T) {
tr := NewInflightTracker()
tr.OnRequest("doc", "Document")
tr.OnFinish("doc")
// Un stream WebSocket se abre y nunca emite loadingFinished.
tr.OnRequest("ws1", "WebSocket")
// Un EventSource (SSE) tampoco termina.
tr.OnRequest("sse1", "EventSource")
if got := tr.Inflight(); got != 0 {
t.Fatalf("inflight = %d, esperaba 0 (WS/SSE excluidos)", got)
}
if !tr.IsIdle(0) {
t.Error("con WS+SSE abiertos pero excluidos, deberia ser idle absoluto")
}
})
t.Run("edge: finish de request no trackeado es no-op (no va negativo)", func(t *testing.T) {
tr := NewInflightTracker()
// loadingFinished de un requestId que nunca contamos (p.ej. el handshake
// de un WebSocket excluido) no debe romper el conteo.
tr.OnFinish("desconocido")
tr.OnFail("ws-handshake")
if got := tr.Inflight(); got != 0 {
t.Fatalf("inflight = %d, esperaba 0 (no negativo)", got)
}
tr.OnRequest("r1", "Fetch")
if got := tr.Inflight(); got != 1 {
t.Fatalf("inflight tras un request real = %d, esperaba 1", got)
}
})
t.Run("edge: requestId duplicado no infla el conteo", func(t *testing.T) {
tr := NewInflightTracker()
tr.OnRequest("r1", "Fetch")
tr.OnRequest("r1", "Fetch") // mismo id (redirect re-emite)
if got := tr.Inflight(); got != 1 {
t.Fatalf("inflight = %d, esperaba 1 (id deduplicado)", got)
}
})
}
+92
View File
@@ -0,0 +1,92 @@
---
name: cdp_click_xy
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_click_xy(x: int, y: int, *, port: int = 9222, target_url_substr: str = '', move_first: bool = True, timeout_s: float = 10.0) -> dict"
description: "Hace un click izquierdo de raton REAL en coordenadas (x, y) del viewport de una pestana de un Chrome con remote debugging, via CDP Input.dispatchMouseEvent (mouseMoved opcional + mousePressed + mouseReleased). Primitiva de input CDP reutilizable: necesaria cuando element.click() de JavaScript NO dispara los handlers de React de SPAs (WhatsApp Web): abrir un chat de la lista o un resultado de busqueda requiere un click de raton sintetico real. El caller resuelve las coordenadas con cdp_eval (getBoundingClientRect -> centro) y las pasa aqui."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "time", "urllib.request", "websocket"]
params_schema:
params:
- name: x
desc: "Coordenada X en CSS px del viewport donde hacer click. Normalmente el centro del elemento (getBoundingClientRect via cdp_eval)."
- name: y
desc: "Coordenada Y en CSS px del viewport donde hacer click. Normalmente el centro del elemento (getBoundingClientRect via cdp_eval)."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: move_first
desc: "Si True, emite un mouseMoved a (x, y) antes del click para que la SPA registre el hover. Default True."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, error: str, x: int, y: int}. ok=True si los eventos de raton (mouseMoved opcional, mousePressed, mouseReleased) se emitieron sin error; x/y son eco de los argumentos. Nunca lanza: errores de red/conexion/transport se devuelven en 'error' con ok=False."
tested: true
tests: ["test_golden_click_emite_movido_pressed_released_left", "test_edge_move_first_false_omite_mousemoved", "test_error_create_connection_lanza_ok_false"]
test_file_path: "python/functions/browser/cdp_click_xy_test.py"
file_path: "python/functions/browser/cdp_click_xy.py"
---
## Ejemplo
```python
import sys, os, json
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_eval import cdp_eval
from browser.cdp_click_xy import cdp_click_xy
# Requiere un Chrome lanzado con --remote-debugging-port=9222
# y una pestana de WhatsApp Web abierta.
# Localizar el row de un chat por nombre exacto y abrirlo con click REAL.
r = cdp_eval(r'''(() => {
const row = [...document.querySelectorAll('#side [role="row"]')]
.find(x => /^NOTAS WASAP\b/.test(x.innerText.replace(/\s+/g,' ').trim()));
if(!row) return null;
const b = row.getBoundingClientRect();
return JSON.stringify({x: Math.round(b.x+b.width/2), y: Math.round(b.y+b.height/2)});
})()''', target_url_substr="whatsapp")
c = json.loads(r["value"])
res = cdp_click_xy(c["x"], c["y"], target_url_substr="whatsapp") # abre el chat
print(res["ok"], res["error"])
```
O directo por CLI: `python3 python/functions/browser/cdp_click_xy.py 100 200 "whatsapp"`.
## Cuando usarla
Cuando necesites **clickar un elemento** y `element.click()` de JavaScript NO dispara
sus handlers (SPAs React como WhatsApp Web): **abrir un chat de la lista**, abrir un
**resultado de busqueda**, pulsar un boton que React renderiza con listeners propios.
Resuelve primero las coordenadas del elemento con `cdp_eval_py_browser`
(`getBoundingClientRect` -> centro) y pasa `x, y` aqui. Es la primitiva de input de
raton sobre la que se construyen funciones `whatsapp_*_py_browser` y cualquier script
que opere una pestana existente via CDP. Para teclas usa `cdp_press_key_py_browser`;
para escribir texto, `cdp_type_chars_py_browser`.
## Gotchas
- **Coordenadas en CSS px del viewport**: `getBoundingClientRect()` ya las devuelve en
ese sistema, por eso encaja directo. No uses `pageX/pageY` ni coords absolutas de
documento.
- **El elemento debe estar VISIBLE en el viewport**: si esta fuera de pantalla por
scroll, las coords del rect son invalidas (negativas o fuera de rango) y el click cae
en el lugar equivocado. Haz scroll al elemento primero (via `cdp_eval` con
`scrollIntoView`) y vuelve a leer el rect.
- Es un **click izquierdo simple** (clickCount=1, button=left). No hace doble click,
click derecho ni drag.
- `move_first=True` (default) emite un `mouseMoved` previo para que la SPA registre el
hover; algunas UIs solo muestran/activan controles tras hover. Ponlo en False si no
lo necesitas.
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases).
Sin remote debugging, `GET /json` falla y devuelve `ok=False`.
- Nunca lanza: errores de red, conexion WS o transport se reportan en el campo `error`
con `ok=False`.
+166
View File
@@ -0,0 +1,166 @@
"""Hace un click de raton real en coordenadas (x, y) de una pestana de Chrome via CDP.
Primitiva de input CDP: localiza un target (pestana) por substring de su URL, abre el
WebSocket de depuracion y emite los eventos `Input.dispatchMouseEvent` que componen un
click izquierdo sintetico real (mousePressed + mouseReleased), opcionalmente precedido
de un mouseMoved para que la SPA registre el hover.
Necesario porque `element.click()` de JavaScript NO dispara los handlers de React de
muchas SPAs (WhatsApp Web entre ellas): abrir un chat de la lista o un resultado de
busqueda requiere un click de raton sintetico real. El caller resuelve las coordenadas
del elemento con `cdp_eval_py_browser` (getBoundingClientRect -> centro) y las pasa aqui.
"""
import json
import time
import urllib.request
import websocket
def cdp_click_xy(
x: int,
y: int,
*,
port: int = 9222,
target_url_substr: str = "",
move_first: bool = True,
timeout_s: float = 10.0,
) -> dict:
"""Hace un click izquierdo real en (x, y) del viewport de una pestana de Chrome.
Localiza el target `page` por substring de URL, abre el WebSocket CDP y emite un
click izquierdo simple via `Input.dispatchMouseEvent`: si `move_first`, primero un
`mouseMoved` a (x, y) (para que la SPA registre hover), luego `mousePressed` y
`mouseReleased` con `button=left`, `buttons=1`, `clickCount=1`. Las coordenadas son
CSS px del viewport; el caller las resuelve normalmente con `cdp_eval_py_browser`
(getBoundingClientRect -> centro).
Args:
x: Coordenada X en CSS px del viewport donde hacer click.
y: Coordenada Y en CSS px del viewport donde hacer click.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana).
Si "", usa el primer target de tipo "page".
move_first: Si True, emite un mouseMoved a (x, y) antes del click para que la
SPA registre el hover. Default True.
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
Returns:
dict con claves:
ok: bool — True si los eventos de raton se emitieron sin error.
error: str — mensaje de error (vacio si ok).
x: int — coordenada X usada (eco del argumento).
y: int — coordenada Y usada (eco del argumento).
Nunca lanza: errores de red/conexion/transport se devuelven en "error" con
ok=False.
"""
# 1. Listar targets via HTTP.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "error": str(e), "x": x, "y": y}
# 2. Elegir el primer target type=="page" cuya url contenga el substring.
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {
"ok": False,
"error": f"no target matching {target_url_substr}",
"x": x,
"y": y,
}
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 3. Abrir WS.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "error": str(e), "x": x, "y": y}
try:
msg_id = 1
# 3b. Hover previo opcional: ayuda a que la SPA registre el mouseover.
if move_first:
ws.send(json.dumps({
"id": msg_id,
"method": "Input.dispatchMouseEvent",
"params": {"type": "mouseMoved", "x": x, "y": y},
}))
msg_id += 1
time.sleep(0.03)
# 4. Click izquierdo real: mousePressed + mouseReleased.
press_id = msg_id
ws.send(json.dumps({
"id": press_id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"buttons": 1,
"clickCount": 1,
},
}))
time.sleep(0.04)
release_id = msg_id + 1
ws.send(json.dumps({
"id": release_id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mouseReleased",
"x": x,
"y": y,
"button": "left",
"buttons": 1,
"clickCount": 1,
},
}))
# Drenar respuestas hasta ver el id del release (o agotar el stream).
# Ignoramos eventos del server y frames no-JSON.
while True:
raw = ws.recv()
if not raw:
break
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == release_id:
break
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
return {"ok": False, "error": str(e), "x": x, "y": y}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
return {"ok": True, "error": "", "x": x, "y": y}
if __name__ == "__main__":
import sys
arg_x = int(sys.argv[1]) if len(sys.argv) > 1 else 0
arg_y = int(sys.argv[2]) if len(sys.argv) > 2 else 0
substr = sys.argv[3] if len(sys.argv) > 3 else ""
out = cdp_click_xy(arg_x, arg_y, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,143 @@
"""Tests para cdp_click_xy — mockean urlopen + create_connection.
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
websocket.create_connection (un fake que captura los mensajes enviados y devuelve
las respuestas CDP con el id correspondiente).
"""
import json
import os
import sys
from contextlib import contextmanager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser import cdp_click_xy as mod # noqa: E402
from browser.cdp_click_xy import cdp_click_xy # noqa: E402
class _FakeResp:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload: list):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return json.dumps(self._payload).encode()
class _FakeWS:
"""WebSocket falso: captura los mensajes enviados y responde por id."""
def __init__(self):
self.sent = []
self._inbox = []
self.closed = False
def send(self, raw: str):
msg = json.loads(raw)
self.sent.append(msg)
# Encola una respuesta CDP vacia con el mismo id (como Chrome devuelve).
self._inbox.append(json.dumps({"id": msg["id"], "result": {}}))
def recv(self):
if self._inbox:
return self._inbox.pop(0)
return ""
def close(self):
self.closed = True
@contextmanager
def _patch(targets, ws_obj=None, urlopen_exc=None, create_conn_exc=None):
"""Parchea urlopen y create_connection del modulo. Restaura al salir."""
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
def fake_urlopen(url, timeout=5):
if urlopen_exc is not None:
raise urlopen_exc
return _FakeResp(targets)
def fake_create(ws_url, timeout=10):
if create_conn_exc is not None:
raise create_conn_exc
return ws_obj
mod.urllib.request.urlopen = fake_urlopen
mod.websocket.create_connection = fake_create
try:
yield
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
]
def test_golden_click_emite_movido_pressed_released_left():
"""Click en (100, 200) emite mouseMoved + mousePressed + mouseReleased correctos."""
ws = _FakeWS()
with _patch(_TARGETS, ws_obj=ws):
res = cdp_click_xy(100, 200, target_url_substr="whatsapp")
assert res == {"ok": True, "error": "", "x": 100, "y": 200}
assert len(ws.sent) == 3
moved, pressed, released = ws.sent
assert moved["method"] == "Input.dispatchMouseEvent"
assert moved["params"]["type"] == "mouseMoved"
assert moved["params"]["x"] == 100
assert moved["params"]["y"] == 200
assert pressed["params"]["type"] == "mousePressed"
assert pressed["params"]["x"] == 100
assert pressed["params"]["y"] == 200
assert pressed["params"]["button"] == "left"
assert pressed["params"]["buttons"] == 1
assert pressed["params"]["clickCount"] == 1
assert released["params"]["type"] == "mouseReleased"
assert released["params"]["x"] == 100
assert released["params"]["y"] == 200
assert released["params"]["button"] == "left"
assert released["params"]["buttons"] == 1
assert released["params"]["clickCount"] == 1
assert ws.closed is True
def test_edge_move_first_false_omite_mousemoved():
"""Con move_first=False no se emite el mouseMoved previo, solo press + release."""
ws = _FakeWS()
with _patch(_TARGETS, ws_obj=ws):
res = cdp_click_xy(50, 60, target_url_substr="whatsapp", move_first=False)
assert res["ok"] is True
assert len(ws.sent) == 2
types = [m["params"]["type"] for m in ws.sent]
assert types == ["mousePressed", "mouseReleased"]
assert all(m["params"]["type"] != "mouseMoved" for m in ws.sent)
def test_error_create_connection_lanza_ok_false():
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
res = cdp_click_xy(10, 20, target_url_substr="whatsapp")
assert res["ok"] is False
assert "ws down" in res["error"]
assert res["x"] == 10
assert res["y"] == 20
+65
View File
@@ -0,0 +1,65 @@
---
name: cdp_eval
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_eval(expression: str, *, port: int = 9222, target_url_substr: str = '', await_promise: bool = False, timeout_s: float = 10.0) -> dict"
description: "Evalua una expresion JavaScript en una pestana de un Chrome con remote debugging, eligiendo el target por substring de su URL. Primitiva de transport CDP reutilizable para operar el navegador diario por codigo sin abrir ventana nueva."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.request", "websocket"]
params_schema:
params:
- name: expression
desc: "Expresion JavaScript a evaluar en el contexto de la pagina (ej. 'document.title', 'document.querySelector(\".x\").click()')."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: await_promise
desc: "Si True, espera a que la expresion resuelva una Promise antes de devolver el valor (awaitPromise de CDP). Default False."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, value: <valor Python serializable o None>, error: str, target_url: str}. ok=True si la evaluacion produjo valor sin excepcion. Nunca lanza: errores de red/conexion/excepcion JS se devuelven en 'error'."
tested: true
tests: ["test_golden_selecciona_target_por_substr_y_devuelve_value", "test_edge_substr_sin_match_devuelve_ok_false", "test_error_urlopen_lanza_devuelve_ok_false"]
test_file_path: "python/functions/browser/cdp_eval_test.py"
file_path: "python/functions/browser/cdp_eval.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_eval import cdp_eval
# Requiere un Chrome lanzado con --remote-debugging-port=9222
# y una pestana de WhatsApp Web abierta.
res = cdp_eval("document.title", port=9222, target_url_substr="whatsapp")
print(res["value"]) # -> "WhatsApp" (o None si no hay target)
print(res["ok"], res["target_url"])
```
O directo por CLI: `python3 python/functions/browser/cdp_eval.py "document.title" "whatsapp"`.
## Cuando usarla
Cuando quieras leer datos o ejecutar JS (focus, querySelector, `element.click()`, scroll)
sobre una pestana **ya abierta** del navegador diario sin abrir ventana nueva ni darle
foco al sistema. Es la primitiva de transport sobre la que se construyen las funciones
`whatsapp_*_py_browser` y cualquier script que opere una pestana existente via CDP.
## Gotchas
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases). Sin remote debugging, `GET /json` falla y devuelve `ok=False`.
- `target_url_substr` hace match de **substring** sobre la URL del target (no regex). El primer `page` que contenga el substring gana.
- `returnByValue` solo serializa valores JSON (str, num, bool, list, dict, None). Los nodos del DOM NO son serializables: devuelve la expresion ya reducida a un valor (ej. `el.textContent`, no `el`).
- Para **escribir** en inputs o `contenteditable` NO uses `document.execCommand` ni `el.value = ...`: editores como React/Lexical (WhatsApp Web) ignoran esos cambios programaticos. Usa `cdp_type_chars_py_browser` (teclea caracter a caracter via CDP `Input.dispatchKeyEvent`).
- Nunca lanza: errores de red, conexion WS o excepciones de la propia evaluacion JS se reportan en el campo `error` con `ok=False`.
+139
View File
@@ -0,0 +1,139 @@
"""Evalua una expresion JavaScript en una pestana de Chrome via Chrome DevTools Protocol.
Primitiva de transport CDP: localiza un target (pestana) por substring de su URL,
abre el WebSocket de depuracion, ejecuta `Runtime.evaluate` y devuelve el valor
serializado. Base reutilizable para automatizar el navegador diario sin abrir
ventana nueva ni darle foco.
"""
import json
import urllib.request
import websocket
def cdp_eval(
expression: str,
*,
port: int = 9222,
target_url_substr: str = "",
await_promise: bool = False,
timeout_s: float = 10.0,
) -> dict:
"""Evalua una expresion JS en una pestana de Chrome elegida por substring de URL.
Args:
expression: Expresion JavaScript a evaluar en el contexto de la pagina.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target. Si "",
usa el primer target de tipo "page".
await_promise: Si True, espera a que se resuelva una Promise antes de
devolver el valor (`awaitPromise` de CDP).
timeout_s: Timeout (segundos) para la conexion WebSocket.
Returns:
dict con claves:
ok: bool — True si la evaluacion produjo un valor sin excepcion.
value: valor Python serializable devuelto por la expresion, o None.
error: str — mensaje de error (vacio si ok).
target_url: str — URL del target usado (vacio si ninguno).
"""
# 1. Listar targets via HTTP.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "value": None, "error": str(e), "target_url": ""}
# 2. Elegir el primer target type=="page" cuya url contenga el substring.
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {
"ok": False,
"value": None,
"error": f"no target matching {target_url_substr}",
"target_url": "",
}
target_url = chosen.get("url", "")
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 3-4. Abrir WS, enviar Runtime.evaluate, drenar eventos hasta id==1.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "value": None, "error": str(e), "target_url": ""}
try:
ws.send(json.dumps({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": expression,
"returnByValue": True,
"awaitPromise": await_promise,
},
}))
msg = None
# Drenar eventos intermedios hasta encontrar la respuesta con id==1.
while True:
raw = ws.recv()
if not raw:
break
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == 1:
msg = parsed
break
except Exception as e: # noqa: BLE001 — fallo de transport durante recv/send
return {"ok": False, "value": None, "error": str(e), "target_url": target_url}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
if msg is None:
return {
"ok": False,
"value": None,
"error": "no response for evaluate (id=1)",
"target_url": target_url,
}
result = msg.get("result", {})
# 5. Si hubo excepcion en la evaluacion, devolverla como error.
exc = result.get("exceptionDetails")
if exc:
text = exc.get("text", "evaluation exception")
exception = exc.get("exception", {})
detail = exception.get("description") or exception.get("value")
error = f"{text}: {detail}" if detail else text
return {"ok": False, "value": None, "error": error, "target_url": target_url}
# 6. Extraer el valor serializado por returnByValue.
value = result.get("result", {}).get("value")
return {"ok": True, "value": value, "error": "", "target_url": target_url}
if __name__ == "__main__":
import sys
expr = sys.argv[1] if len(sys.argv) > 1 else "document.title"
substr = sys.argv[2] if len(sys.argv) > 2 else ""
out = cdp_eval(expr, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
+146
View File
@@ -0,0 +1,146 @@
"""Tests para cdp_eval.
Como cdp_eval requiere un Chrome vivo con remote debugging, se mockean las dos
fronteras de I/O:
- urllib.request.urlopen -> devuelve un /json con 2 targets (uno whatsapp).
- websocket.create_connection -> un fake que responde al id==1 con un value.
"""
import io
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import urllib.request
import websocket
from browser.cdp_eval import cdp_eval
# --- Fakes -----------------------------------------------------------------
def _targets_json():
"""Dos targets de tipo page: uno de Google, otro de WhatsApp Web."""
return [
{
"type": "page",
"url": "https://www.google.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/GOOGLE",
},
{
"type": "page",
"url": "https://web.whatsapp.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/WA",
},
]
class _FakeHTTPResponse:
"""Context manager que imita la respuesta de urlopen con .read()."""
def __init__(self, payload):
self._buf = io.BytesIO(json.dumps(payload).encode())
def read(self):
return self._buf.read()
def __enter__(self):
return self
def __exit__(self, *exc):
return False
class _FakeWS:
"""WebSocket fake: guarda el ws_url usado y responde al evaluate con value.
Antes de la respuesta con id==1, emite un evento intermedio (sin id) para
verificar que cdp_eval drena eventos hasta encontrar su respuesta.
"""
last_url = None
def __init__(self, url, value):
_FakeWS.last_url = url
self._value = value
self._queue = []
def send(self, raw):
msg = json.loads(raw)
if msg.get("id") == 1:
# Primero un evento de CDP sin id (debe drenarse), luego la respuesta.
self._queue.append(json.dumps({
"method": "Runtime.consoleAPICalled",
"params": {"type": "log"},
}))
self._queue.append(json.dumps({
"id": 1,
"result": {"result": {"type": "string", "value": self._value}},
}))
def recv(self):
if self._queue:
return self._queue.pop(0)
return ""
def close(self):
pass
# --- Tests -----------------------------------------------------------------
def test_golden_selecciona_target_por_substr_y_devuelve_value(monkeypatch):
monkeypatch.setattr(
urllib.request, "urlopen",
lambda url, timeout=5: _FakeHTTPResponse(_targets_json()),
)
monkeypatch.setattr(
websocket, "create_connection",
lambda url, timeout=10.0: _FakeWS(url, "WhatsApp"),
)
res = cdp_eval("document.title", port=9222, target_url_substr="whatsapp")
assert res["ok"] is True
assert res["value"] == "WhatsApp"
assert res["error"] == ""
assert res["target_url"] == "https://web.whatsapp.com/"
# Confirma que eligio el target whatsapp, no el de google.
assert _FakeWS.last_url.endswith("/WA")
def test_edge_substr_sin_match_devuelve_ok_false(monkeypatch):
monkeypatch.setattr(
urllib.request, "urlopen",
lambda url, timeout=5: _FakeHTTPResponse(_targets_json()),
)
# create_connection no deberia llamarse; si lo hace, revienta el test.
monkeypatch.setattr(
websocket, "create_connection",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("no debe conectar")),
)
res = cdp_eval("document.title", port=9222, target_url_substr="nope-no-existe")
assert res["ok"] is False
assert res["value"] is None
assert "no target matching" in res["error"]
assert "nope-no-existe" in res["error"]
assert res["target_url"] == ""
def test_error_urlopen_lanza_devuelve_ok_false(monkeypatch):
def _boom(url, timeout=5):
raise OSError("connection refused")
monkeypatch.setattr(urllib.request, "urlopen", _boom)
res = cdp_eval("document.title", port=9222, target_url_substr="whatsapp")
assert res["ok"] is False
assert res["value"] is None
assert "connection refused" in res["error"]
assert res["target_url"] == ""
+80
View File
@@ -0,0 +1,80 @@
---
name: cdp_press_key
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_press_key(key: str, *, port: int = 9222, target_url_substr: str = '', modifiers: int = 0, timeout_s: float = 10.0) -> dict"
description: "Pulsa una tecla nombrada (Enter, Escape, Backspace, Tab, ArrowDown, Delete, ...) sobre el elemento enfocado de una pestana de un Chrome con remote debugging, via CDP Input.dispatchKeyEvent (rawKeyDown + keyUp). Primitiva de input CDP reutilizable: enviar mensajes (Enter en el composer de WhatsApp), cerrar overlays (Escape), navegar resultados (ArrowDown), borrar (Backspace) o combos con modificadores (Ctrl+A)."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.request", "websocket"]
params_schema:
params:
- name: key
desc: "Nombre canonico de la tecla a pulsar. Soportadas: Enter, Escape, Backspace, Tab, Delete, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Home, End. Una tecla no soportada devuelve ok=False sin tocar la red."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: modifiers
desc: "Bitmask de modificadores CDP combinables con OR: Alt=1, Ctrl=2, Meta/Cmd=4, Shift=8. Ej. Ctrl+Shift = 2|8 = 10. Default 0."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, error: str}. ok=True si los eventos rawKeyDown+keyUp se emitieron sin error. Nunca lanza: errores de red/conexion/transport y teclas no soportadas se devuelven en 'error' con ok=False."
tested: true
tests: ["test_golden_enter_emite_rawkeydown_y_keyup_vk13", "test_edge_tecla_no_soportada_ok_false_sin_abrir_ws", "test_error_create_connection_lanza_ok_false"]
test_file_path: "python/functions/browser/cdp_press_key_test.py"
file_path: "python/functions/browser/cdp_press_key.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_press_key import cdp_press_key
# Requiere un Chrome lanzado con --remote-debugging-port=9222
# y una pestana de WhatsApp Web abierta con el composer enfocado y texto escrito.
res = cdp_press_key("Enter", target_url_substr="whatsapp") # envia el mensaje
print(res["ok"], res["error"])
# Combo con modificadores (Ctrl+A para seleccionar todo): Ctrl=2.
cdp_press_key("Home", target_url_substr="whatsapp", modifiers=2)
```
O directo por CLI: `python3 python/functions/browser/cdp_press_key.py "Enter" "whatsapp"`.
## Cuando usarla
Cuando necesites enviar una pulsacion de tecla sobre la pestana **ya abierta** del
navegador diario sin abrir ventana nueva ni darle foco al sistema: **Enter** para
enviar (composer de WhatsApp), **Escape** para cerrar overlays/dialogos,
**ArrowDown/ArrowUp** para navegar resultados de busqueda, **Backspace/Delete** para
borrar, o combos con `modifiers` (Ctrl/Shift/Alt/Meta). Tipicamente **despues** de
escribir con `cdp_type_chars_py_browser` para confirmar la accion. Es la primitiva de
input sobre la que se construyen funciones `whatsapp_*_py_browser` y cualquier script
que opere una pestana existente via CDP.
## Gotchas
- Actua sobre el elemento **enfocado** de la pagina: CDP `Input.dispatchKeyEvent` no
apunta a un selector, va al foco actual. Asegura el foco (clic o `el.focus()` via
`cdp_eval_py_browser`) antes de pulsar.
- **Enter en WhatsApp Web envia el mensaje** (no inserta salto de linea). Para newline
dentro del composer usa Shift+Enter (`modifiers=8`) o no uses esta funcion para eso.
- `modifiers` es el bitmask de CDP, no un string: Alt=1, Ctrl=2, Meta/Cmd=4, Shift=8;
combina con OR (ej. Ctrl+Shift = 10).
- No se emite evento `char` aparte: el par `rawKeyDown`+`keyUp` con el
`windowsVirtualKeyCode` correcto (Enter=13) basta para disparar el envio en WhatsApp
(validado via `press_key` del MCP del navegador).
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases).
Sin remote debugging, `GET /json` falla y devuelve `ok=False`.
- Nunca lanza: errores de red, conexion WS, transport o tecla no soportada se reportan
en el campo `error` con `ok=False`.
+144
View File
@@ -0,0 +1,144 @@
"""Pulsa una tecla nombrada sobre el elemento enfocado de una pestana de Chrome via CDP.
Primitiva de input CDP: localiza un target (pestana) por substring de su URL, abre el
WebSocket de depuracion y emite el par de eventos `Input.dispatchKeyEvent`
(rawKeyDown + keyUp) de una tecla con nombre canonico (Enter, Escape, Backspace, Tab,
ArrowDown, ...) con modificadores opcionales. Base reutilizable para enviar mensajes
(Enter en el composer de WhatsApp), cerrar overlays (Escape), navegar resultados
(ArrowDown), borrar (Backspace) o combos (Ctrl+A con modifiers).
"""
import json
import urllib.request
import websocket
# Mapa de teclas soportadas -> {key, code, windowsVirtualKeyCode}.
# vk = windowsVirtualKeyCode == nativeVirtualKeyCode (codigos VK de Windows).
_KEY_MAP = {
"Enter": {"key": "Enter", "code": "Enter", "vk": 13},
"Escape": {"key": "Escape", "code": "Escape", "vk": 27},
"Backspace": {"key": "Backspace", "code": "Backspace", "vk": 8},
"Tab": {"key": "Tab", "code": "Tab", "vk": 9},
"Delete": {"key": "Delete", "code": "Delete", "vk": 46},
"ArrowDown": {"key": "ArrowDown", "code": "ArrowDown", "vk": 40},
"ArrowUp": {"key": "ArrowUp", "code": "ArrowUp", "vk": 38},
"ArrowLeft": {"key": "ArrowLeft", "code": "ArrowLeft", "vk": 37},
"ArrowRight": {"key": "ArrowRight", "code": "ArrowRight", "vk": 39},
"Home": {"key": "Home", "code": "Home", "vk": 36},
"End": {"key": "End", "code": "End", "vk": 35},
}
def cdp_press_key(
key: str,
*,
port: int = 9222,
target_url_substr: str = "",
modifiers: int = 0,
timeout_s: float = 10.0,
) -> dict:
"""Pulsa una tecla nombrada sobre el elemento enfocado de una pestana de Chrome.
Args:
key: Nombre canonico de la tecla. Soportadas: Enter, Escape, Backspace,
Tab, Delete, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Home, End.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana).
Si "", usa el primer target de tipo "page".
modifiers: Bitmask de modificadores CDP (Alt=1, Ctrl=2, Meta/Cmd=4,
Shift=8; combinables con OR). Default 0 (sin modificadores).
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
Returns:
dict con claves:
ok: bool — True si los eventos de tecla se emitieron sin error.
error: str — mensaje de error (vacio si ok).
Nunca lanza: errores de red/conexion/transport se devuelven en "error"
con ok=False.
"""
# 1. Validar la tecla contra el mapa interno (antes de tocar la red).
entry = _KEY_MAP.get(key)
if entry is None:
return {"ok": False, "error": f"unsupported key: {key}"}
k = entry["key"]
c = entry["code"]
vk = entry["vk"]
# 2. Listar targets via HTTP.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "error": str(e)}
# 3. Elegir el primer target type=="page" cuya url contenga el substring.
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {"ok": False, "error": f"no target matching {target_url_substr}"}
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 4. Abrir WS y emitir rawKeyDown + keyUp.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "error": str(e)}
try:
for msg_id, ev_type in ((1, "rawKeyDown"), (2, "keyUp")):
ws.send(json.dumps({
"id": msg_id,
"method": "Input.dispatchKeyEvent",
"params": {
"type": ev_type,
"key": k,
"code": c,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
"modifiers": modifiers,
},
}))
# Drenar respuestas hasta ver el id==2 (o agotar el stream). Ignoramos
# eventos del server y frames no-JSON.
while True:
raw = ws.recv()
if not raw:
break
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == 2:
break
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
return {"ok": False, "error": str(e)}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
return {"ok": True, "error": ""}
if __name__ == "__main__":
import sys
key_arg = sys.argv[1] if len(sys.argv) > 1 else "Enter"
substr = sys.argv[2] if len(sys.argv) > 2 else ""
out = cdp_press_key(key_arg, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,143 @@
"""Tests para cdp_press_key — mockean urlopen + create_connection.
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
websocket.create_connection (un fake que captura los mensajes enviados y devuelve
las respuestas CDP con el id correspondiente).
"""
import json
import os
import sys
from contextlib import contextmanager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser import cdp_press_key as mod # noqa: E402
from browser.cdp_press_key import cdp_press_key # noqa: E402
class _FakeResp:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload: list):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return json.dumps(self._payload).encode()
class _FakeWS:
"""WebSocket falso: captura los mensajes enviados y responde por id."""
def __init__(self):
self.sent = []
self._inbox = []
self.closed = False
def send(self, raw: str):
msg = json.loads(raw)
self.sent.append(msg)
# Encola una respuesta CDP vacia con el mismo id (como Chrome devuelve).
self._inbox.append(json.dumps({"id": msg["id"], "result": {}}))
def recv(self):
if self._inbox:
return self._inbox.pop(0)
return ""
def close(self):
self.closed = True
@contextmanager
def _patch(monkeypatch_targets, ws_obj=None, urlopen_exc=None, create_conn_exc=None):
"""Parchea urlopen y create_connection del modulo. Restaura al salir."""
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
def fake_urlopen(url, timeout=5):
if urlopen_exc is not None:
raise urlopen_exc
return _FakeResp(monkeypatch_targets)
def fake_create(ws_url, timeout=10):
if create_conn_exc is not None:
raise create_conn_exc
return ws_obj
mod.urllib.request.urlopen = fake_urlopen
mod.websocket.create_connection = fake_create
try:
yield
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
]
def test_golden_enter_emite_rawkeydown_y_keyup_vk13():
"""Enter envia rawKeyDown + keyUp con windowsVirtualKeyCode 13."""
ws = _FakeWS()
with _patch(_TARGETS, ws_obj=ws):
res = cdp_press_key("Enter", target_url_substr="whatsapp")
assert res == {"ok": True, "error": ""}
assert len(ws.sent) == 2
down, up = ws.sent
assert down["method"] == "Input.dispatchKeyEvent"
assert down["params"]["type"] == "rawKeyDown"
assert down["params"]["key"] == "Enter"
assert down["params"]["code"] == "Enter"
assert down["params"]["windowsVirtualKeyCode"] == 13
assert down["params"]["nativeVirtualKeyCode"] == 13
assert down["params"]["modifiers"] == 0
assert up["params"]["type"] == "keyUp"
assert up["params"]["windowsVirtualKeyCode"] == 13
assert ws.closed is True
def test_edge_tecla_no_soportada_ok_false_sin_abrir_ws():
"""Una tecla fuera del mapa devuelve ok=False sin tocar la red ni abrir WS."""
ws = _FakeWS()
def fail_create(ws_url, timeout=10):
raise AssertionError("create_connection no debe llamarse para tecla no soportada")
def fail_urlopen(url, timeout=5):
raise AssertionError("urlopen no debe llamarse para tecla no soportada")
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
mod.urllib.request.urlopen = fail_urlopen
mod.websocket.create_connection = fail_create
try:
res = cdp_press_key("F13", target_url_substr="whatsapp")
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
assert res["ok"] is False
assert "unsupported key: F13" in res["error"]
assert ws.sent == []
def test_error_create_connection_lanza_ok_false():
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
res = cdp_press_key("Escape", target_url_substr="whatsapp")
assert res["ok"] is False
assert "ws down" in res["error"]
@@ -0,0 +1,87 @@
---
name: cdp_type_chars
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_type_chars(text: str, *, port: int = 9222, target_url_substr: str = '', delay_ms: int = 12, timeout_s: float = 10.0) -> dict"
description: "Escribe texto caracter a caracter en el elemento ENFOCADO de una pestana via CDP (Input.dispatchKeyEvent keyDown/keyUp por char). Unico metodo validado para el editor Lexical de WhatsApp Web, donde document.execCommand/el.value no funcionan. El caller debe enfocar el elemento antes."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["urllib.request", "json", "time", "websocket"]
tested: true
tests: ["escribir ab envia 4 dispatchKeyEvent con text correctos", "text vacio no envia nada y devuelve ok", "fallo de create_connection devuelve ok False"]
test_file_path: "python/functions/browser/cdp_type_chars_test.py"
file_path: "python/functions/browser/cdp_type_chars.py"
params:
- name: text
desc: "Texto a escribir, caracter a caracter. Cada char emite un par keyDown/keyUp."
- name: port
desc: "Puerto de depuracion remota de Chrome (DevTools). Default 9222."
- name: target_url_substr
desc: "Substring para elegir el target page por su URL. Si vacio, usa el primer page disponible."
- name: delay_ms
desc: "Pausa en milisegundos entre cada par de teclas. Humaniza y da tiempo al re-render de editores como Lexical. Default 12."
- name: timeout_s
desc: "Timeout en segundos para el GET /json y la apertura del WebSocket. Default 10.0."
output: "dict {ok: bool, chars_sent: int, error: str}. ok True si todos los chars se enviaron; chars_sent cuenta los enviados (incluso si falla a mitad); error con el mensaje o cadena vacia. Nunca lanza."
---
## Ejemplo
Requiere Chrome lanzado con remote debugging (`--remote-debugging-port=9222`)
y un elemento editable ENFOCADO en la pestana. Primero se enfoca con cdp_eval
(ejecutando `.focus()` sobre el composer), luego se escribe:
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_eval import cdp_eval # enfoca el elemento
from browser.cdp_type_chars import cdp_type_chars
# 1. Enfocar el composer (contenteditable de Lexical) de WhatsApp Web.
cdp_eval(
"document.querySelector('footer div[contenteditable=\"true\"]').focus()",
target_url_substr="whatsapp",
)
# 2. Escribir caracter a caracter en el elemento enfocado.
res = cdp_type_chars("hola", target_url_substr="whatsapp")
print(res) # {'ok': True, 'chars_sent': 4, 'error': ''}
```
> Nota: esta funcion NO enfoca por si misma. El enfoque previo lo hace el
> caller con `cdp_eval_py_browser` (ejecutando `.focus()` sobre el selector).
> El contrato de `cdp_type_chars` es solo "escribir en lo que ya esta
> enfocado".
## Cuando usarla
- Para escribir en inputs, textarea y contenteditable de una pestana ya
abierta: el composer Lexical de WhatsApp Web, su search box, o cualquier
campo que requiera key events reales en vez de asignacion directa de valor.
- SIEMPRE despues de enfocar el elemento destino con cdp_eval ejecutando
`.focus()` sobre el selector.
- Cuando `el.value = ...` o `document.execCommand('insertText')` no surten
efecto porque el editor escucha eventos de teclado (caso Lexical).
## Gotchas
- Escribe en el elemento ACTUALMENTE ENFOCADO, no recibe selector: enfoca
antes con cdp_eval (`...focus()`). Sin foco, los chars se pierden o van al
body.
- NO mezclar con `document.execCommand('insertText')`: en Lexical produce
texto duplicado o intercalado (gotcha real observado). Usa solo una via.
- `delay_ms` muy bajo (cerca de 0) puede perder caracteres en Lexical, que
necesita un re-render entre teclas. 12 ms es un default seguro; subir si
ves chars perdidos.
- Solo inserta texto plano via el campo `text`. Para Enter, Backspace, flechas
u otras teclas especiales (incluido enviar el mensaje) usa
`cdp_press_key_py_browser`.
- Impura: depende de un Chrome con remote debugging accesible en `port`. Si no
hay target page valido devuelve `{"ok": False, ...}` (no lanza).
+123
View File
@@ -0,0 +1,123 @@
"""Escribe texto caracter a caracter en el elemento enfocado via CDP.
Primitiva de input de bajo nivel: envia pares keyDown/keyUp de
`Input.dispatchKeyEvent` por cada caracter del texto al target `page`
seleccionado. El campo `text` del keyDown es lo que inserta el caracter
en el elemento actualmente enfocado (inputs, textarea, contenteditable).
Es el unico metodo validado para el editor Lexical de WhatsApp Web:
`document.execCommand('insertText')` y `el.value = ...` no disparan los
listeners internos de Lexical y el texto no persiste. El caller debe
enfocar el elemento ANTES (p.ej. con cdp_eval ejecutando `.focus()`).
"""
import json
import time
import urllib.request
import websocket
def cdp_type_chars(
text: str,
*,
port: int = 9222,
target_url_substr: str = "",
delay_ms: int = 12,
timeout_s: float = 10.0,
) -> dict:
"""Escribe texto caracter a caracter en el elemento enfocado de una pestana.
Localiza el target `page` por substring de URL, abre el WebSocket CDP y
envia un par keyDown/keyUp de Input.dispatchKeyEvent por cada caracter,
con `delay_ms` de pausa entre pares (humaniza y deja re-renderizar a
editores como Lexical). Escribe en el elemento ACTUALMENTE ENFOCADO; el
caller debe enfocarlo antes (cdp_eval ejecutando `.focus()`).
Args:
text: Texto a escribir, caracter a caracter.
port: Puerto de depuracion remota de Chrome. Default 9222.
target_url_substr: Substring para elegir el target page. Si vacio,
usa el primer page disponible.
delay_ms: Pausa en milisegundos entre cada par de teclas. Default 12.
timeout_s: Timeout de apertura del WebSocket en segundos. Default 10.0.
Returns:
dict con:
ok (bool): True si todos los caracteres se enviaron sin error.
chars_sent (int): Numero de caracteres efectivamente enviados.
error (str): Mensaje de error si lo hubo, "" en caso contrario.
No lanza excepciones: cualquier error de red/WS se devuelve en el dict.
"""
# 1. Localizar el target page por substring de URL.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=timeout_s
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e:
return {"ok": False, "chars_sent": 0,
"error": f"no se pudo conectar a Chrome en port {port}: {e}"}
ws_url = ""
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr and target_url_substr not in url:
continue
ws_url = t.get("webSocketDebuggerUrl", "")
if ws_url:
break
if not ws_url:
hint = f" matching '{target_url_substr}'" if target_url_substr else ""
return {"ok": False, "chars_sent": 0,
"error": f"no target page{hint} con webSocketDebuggerUrl"}
# 2. Abrir WebSocket y enviar las teclas.
chars_sent = 0
ws = None
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
# Lectura no bloqueante para drenar respuestas/eventos sin colgarse.
ws.settimeout(0.1)
msg_id = 1
for ch in text:
ws.send(json.dumps({
"id": msg_id,
"method": "Input.dispatchKeyEvent",
"params": {"type": "keyDown", "text": ch},
}))
ws.send(json.dumps({
"id": msg_id + 1,
"method": "Input.dispatchKeyEvent",
"params": {"type": "keyUp", "text": ch},
}))
msg_id += 2
chars_sent += 1
# Drenar el socket sin bloquear: descarta lo que haya llegado.
try:
while True:
ws.recv()
except Exception:
pass
time.sleep(delay_ms / 1000.0)
return {"ok": True, "chars_sent": chars_sent, "error": ""}
except Exception as e:
return {"ok": False, "chars_sent": chars_sent, "error": str(e)}
finally:
if ws is not None:
try:
ws.close()
except Exception:
pass
if __name__ == "__main__":
import sys
txt = sys.argv[1] if len(sys.argv) > 1 else "hola"
substr = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cdp_type_chars(txt, target_url_substr=substr), ensure_ascii=False))
@@ -0,0 +1,123 @@
"""Tests para cdp_type_chars.
Mockea urllib.request.urlopen (GET /json) y websocket.create_connection
(conexion fake que acumula los mensajes enviados) para validar el transporte
CDP sin un Chrome real.
"""
import io
import json
import cdp_type_chars as mod
from cdp_type_chars import cdp_type_chars
class _FakeResp:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload):
self._buf = io.BytesIO(json.dumps(payload).encode())
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return self._buf.read()
class _FakeWS:
"""Conexion WebSocket fake: acumula los mensajes enviados y nunca recibe."""
def __init__(self):
self.sent = []
self.closed = False
def settimeout(self, _):
pass
def send(self, payload):
self.sent.append(json.loads(payload))
def recv(self):
# Socket vacio: simula timeout no bloqueante para drenar.
raise TimeoutError("empty")
def close(self):
self.closed = True
def _patch_targets(monkeypatch, payload):
monkeypatch.setattr(
mod.urllib.request, "urlopen", lambda *a, **k: _FakeResp(payload)
)
def _patch_sleep(monkeypatch):
# Evita esperas reales por delay_ms.
monkeypatch.setattr(mod.time, "sleep", lambda _s: None)
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/AB12"},
]
def test_escribir_ab_envia_4_dispatchkeyevent_con_text_correctos(monkeypatch):
"""Golden: 'ab' produce 4 Input.dispatchKeyEvent con los text correctos."""
fake_ws = _FakeWS()
_patch_targets(monkeypatch, _TARGETS)
_patch_sleep(monkeypatch)
monkeypatch.setattr(mod.websocket, "create_connection", lambda *a, **k: fake_ws)
res = cdp_type_chars("ab", target_url_substr="whatsapp")
assert res["ok"] is True
assert res["chars_sent"] == 2
assert res["error"] == ""
methods = [m["method"] for m in fake_ws.sent]
assert methods == ["Input.dispatchKeyEvent"] * 4
types = [m["params"]["type"] for m in fake_ws.sent]
assert types == ["keyDown", "keyUp", "keyDown", "keyUp"]
texts = [m["params"]["text"] for m in fake_ws.sent]
assert texts == ["a", "a", "b", "b"]
assert fake_ws.closed is True
def test_text_vacio_no_envia_nada_y_devuelve_ok(monkeypatch):
"""Edge: texto vacio -> 0 chars, ok True, sin mensajes enviados."""
fake_ws = _FakeWS()
_patch_targets(monkeypatch, _TARGETS)
_patch_sleep(monkeypatch)
monkeypatch.setattr(mod.websocket, "create_connection", lambda *a, **k: fake_ws)
res = cdp_type_chars("", target_url_substr="whatsapp")
assert res["ok"] is True
assert res["chars_sent"] == 0
assert res["error"] == ""
assert fake_ws.sent == []
def test_fallo_de_create_connection_devuelve_ok_false(monkeypatch):
"""Error: create_connection lanza -> ok False, error poblado."""
_patch_targets(monkeypatch, _TARGETS)
_patch_sleep(monkeypatch)
def _boom(*a, **k):
raise ConnectionRefusedError("connection refused")
monkeypatch.setattr(mod.websocket, "create_connection", _boom)
res = cdp_type_chars("hola", target_url_substr="whatsapp")
assert res["ok"] is False
assert res["chars_sent"] == 0
assert "connection refused" in res["error"]
@@ -0,0 +1,80 @@
---
name: har_extract_calls
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "def har_extract_calls(entries: list[dict], *, drop_headers: list[str] | None = None) -> list[dict]"
description: "Normaliza una lista de entries HAR (salida de har_filter_flows) en call specs reproducibles: extrae cookies del header Cookie, limpia headers hop-by-hop, infiere body_type y expone los datos de auth para parametrizar luego con {{param}}. Segundo paso del patron grabar->destilar->reproducir un flujo web. NO auto-parametriza."
tags: [flow-replay, har, http, proxy, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: entries
desc: "lista de entries HAR (cada uno con request y, opcional, response). Tipicamente la salida de har_filter_flows."
- name: drop_headers
desc: "nombres extra de headers a eliminar (case-insensitive), aparte de los hop-by-hop por defecto. None = no quitar extras."
output: "lista de call specs (una por entry) con claves: method (upper), url, headers (sin hop-by-hop ni Cookie), cookies (parseadas del header Cookie), body, body_type (json|form|raw|None), status (int|None), sets_cookies (nombres de cookies que setea la respuesta)"
tested: true
tests:
- "test_golden_post_con_cookie_y_body_json"
- "test_edge_get_sin_body"
- "test_drop_headers_extra_respetado"
- "test_form_body_y_set_cookie_desde_headers"
- "test_lista_vacia"
test_file_path: "python/functions/cybersecurity/har_extract_calls_test.py"
file_path: "python/functions/cybersecurity/har_extract_calls.py"
---
## Ejemplo
```python
from har_extract_calls import har_extract_calls
entries = [
{
"request": {
"method": "post",
"url": "https://api.example.com/login",
"headers": [
{"name": "Host", "value": "api.example.com"},
{"name": "Content-Type", "value": "application/json"},
{"name": "Cookie", "value": "session=abc; csrf=xyz"},
],
"postData": {
"mimeType": "application/json",
"text": '{"user":"neo","pass":"secret"}',
},
},
"response": {"status": 200, "cookies": [{"name": "session", "value": "new"}]},
}
]
har_extract_calls(entries)
# [{
# "method": "POST",
# "url": "https://api.example.com/login",
# "headers": {"Content-Type": "application/json"}, # Host (hop-by-hop) y Cookie removidos
# "cookies": {"session": "abc", "csrf": "xyz"}, # parseadas del header Cookie
# "body": '{"user":"neo","pass":"secret"}',
# "body_type": "json", # inferido del mimeType
# "status": 200,
# "sets_cookies": ["session"], # cookies que setea la respuesta
# }]
```
## Cuando usarla
Usala tras `har_filter_flows`, una vez tienes los entries HAR del flujo que te interesa, para obtener el boceto normalizado de los requests. Las call specs resultantes son el punto de partida para: (1) marcar a mano los valores dinamicos con `{{param}}` y guardar el flujo como funcion-accion del registry, o (2) reproducir la secuencia con `http_replay_sequence_py_infra`. Tambien para auditar rapido que cookies/headers de auth lleva cada peticion de un flujo capturado.
## Gotchas
- **NO auto-parametriza.** Deja todos los valores tal cual aparecen en el HAR. La deteccion de CSRF tokens, anti-forgery y otros valores dinamicos es responsabilidad del humano/Claude, que los marca despues con `{{param}}`. La auto-deteccion es v2, fuera de scope.
- **El output contiene secretos.** Las cookies de sesion, tokens `Authorization` y demas auth del HAR viajan tal cual en las call specs. NO commitear el output crudo ni pegarlo en sitios publicos: redactar/parametrizar antes de persistir.
- **Headers hop-by-hop se descartan siempre** (host, content-length, connection, keep-alive, proxy-connection, accept-encoding, te, trailer, transfer-encoding, upgrade). Si necesitas conservar alguno para reproducir un caso especial, tendras que reañadirlo manualmente en la call spec.
- **El header `Cookie` se mueve a `cookies`** y desaparece de `headers`: al reproducir, el cliente HTTP debe re-serializar las cookies (no asumir que siguen en headers).
@@ -0,0 +1,168 @@
"""Normaliza entries HAR en call specs reproducibles.
Segundo paso del patron "grabar -> destilar -> reproducir" un flujo web como
funcion del registry. Toma la salida de `har_filter_flows` (lista de entries
HAR) y produce call specs limpias, con auth (cookies/headers) expuesta para
que el humano/Claude marque luego los valores dinamicos con `{{param}}`.
Funcion PURA: sin I/O, transforma listas/dicts de forma determinista.
"""
# Headers hop-by-hop / ruidosos que se eliminan por defecto (case-insensitive).
# `cookie` se trata aparte: se extrae a `cookies` y se quita de `headers`.
_HOP_BY_HOP = frozenset(
{
"host",
"content-length",
"connection",
"keep-alive",
"proxy-connection",
"accept-encoding",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
)
def _parse_cookie_header(value: str) -> dict:
"""Parsea el valor de un header `Cookie` en un dict {name: value}.
Formato: `a=1; b=2; c=3`. El ultimo gana si hay nombres repetidos.
"""
cookies: dict = {}
for pair in value.split(";"):
pair = pair.strip()
if not pair:
continue
name, sep, val = pair.partition("=")
name = name.strip()
if not name:
continue
cookies[name] = val.strip() if sep else ""
return cookies
def _infer_body_type(mime_type: str | None) -> str | None:
"""Infiere el tipo de body a partir del mimeType de postData.
application/json -> "json"
application/x-www-form-... -> "form"
multipart/* -> "raw"
otro / None -> "raw" si hay body, None lo decide el caller.
"""
if not mime_type:
return None
mt = mime_type.split(";", 1)[0].strip().lower()
if mt == "application/json":
return "json"
if mt == "application/x-www-form-urlencoded":
return "form"
if mt.startswith("multipart/"):
return "raw"
return "raw"
def _set_cookie_names_from_headers(headers: list[dict]) -> list[str]:
"""Extrae nombres de cookies de los headers `Set-Cookie` de la respuesta."""
names: list[str] = []
for h in headers:
if str(h.get("name", "")).lower() != "set-cookie":
continue
raw = str(h.get("value", ""))
# Set-Cookie: name=value; Path=/; HttpOnly -> nos quedamos con `name`.
first = raw.split(";", 1)[0].strip()
name = first.split("=", 1)[0].strip()
if name:
names.append(name)
return names
def har_extract_calls(
entries: list[dict],
*,
drop_headers: list[str] | None = None,
) -> list[dict]:
"""Convierte entries HAR en call specs normalizadas y reproducibles.
Por cada entry HAR produce un dict call spec con method, url, headers
(sin hop-by-hop, sin Cookie), cookies (parseadas del header Cookie),
body, body_type inferido, status de respuesta y nombres de cookies que
la respuesta setea. NO auto-parametriza: deja los valores tal cual para
que el humano marque despues los dinamicos con `{{param}}`.
Args:
entries: lista de entries HAR (cada uno con `request` y, opcional,
`response`). Tipicamente la salida de `har_filter_flows`.
drop_headers: nombres extra de headers a eliminar (case-insensitive),
aparte de los hop-by-hop por defecto. None = no quitar extras.
Returns:
lista de call specs, una por entry, con las claves: method, url,
headers, cookies, body, body_type, status, sets_cookies.
"""
extra_drop = {h.lower() for h in (drop_headers or [])}
specs: list[dict] = []
for entry in entries:
request = entry.get("request") or {}
response = entry.get("response") or {}
method = str(request.get("method", "")).upper()
url = request.get("url", "")
# Headers: lista [{name, value}] -> dict, con drop de hop-by-hop +
# extras, y extraccion del header Cookie a `cookies`.
headers: dict = {}
cookies: dict = {}
for h in request.get("headers") or []:
name = str(h.get("name", ""))
value = str(h.get("value", ""))
lname = name.lower()
if lname == "cookie":
cookies.update(_parse_cookie_header(value))
continue
if lname in _HOP_BY_HOP or lname in extra_drop:
continue
headers[name] = value # ultimo gana si repetidos
# Body desde postData.
post_data = request.get("postData") or {}
body = post_data.get("text")
mime_type = post_data.get("mimeType")
body_type = _infer_body_type(mime_type) if body is not None else None
# Status de respuesta.
raw_status = response.get("status")
status = int(raw_status) if isinstance(raw_status, (int, float)) and raw_status else None
if isinstance(raw_status, str) and raw_status.isdigit():
status = int(raw_status)
# Cookies que setea la respuesta: preferir response.cookies (HAR);
# si no, parsear los headers Set-Cookie.
sets_cookies: list[str] = []
resp_cookies = response.get("cookies")
if resp_cookies:
sets_cookies = [
str(c.get("name", "")) for c in resp_cookies if c.get("name")
]
else:
sets_cookies = _set_cookie_names_from_headers(
response.get("headers") or []
)
specs.append(
{
"method": method,
"url": url,
"headers": headers,
"cookies": cookies,
"body": body,
"body_type": body_type,
"status": status,
"sets_cookies": sets_cookies,
}
)
return specs
@@ -0,0 +1,150 @@
"""Tests para har_extract_calls."""
from har_extract_calls import har_extract_calls
def test_golden_post_con_cookie_y_body_json():
"""Golden: POST con header Cookie + body json -> spec correcta,
cookie extraida, hop-by-hop dropeados, set-cookie de respuesta."""
entries = [
{
"request": {
"method": "post",
"url": "https://api.example.com/login",
"headers": [
{"name": "Host", "value": "api.example.com"},
{"name": "Content-Length", "value": "42"},
{"name": "Accept-Encoding", "value": "gzip"},
{"name": "Content-Type", "value": "application/json"},
{"name": "Authorization", "value": "Bearer tok123"},
{"name": "Cookie", "value": "session=abc; csrf=xyz"},
],
"postData": {
"mimeType": "application/json",
"text": '{"user":"neo","pass":"secret"}',
},
},
"response": {
"status": 200,
"cookies": [
{"name": "session", "value": "newsess"},
{"name": "remember", "value": "1"},
],
"headers": [],
},
}
]
[spec] = har_extract_calls(entries)
assert spec["method"] == "POST"
assert spec["url"] == "https://api.example.com/login"
# Hop-by-hop dropeados, Cookie extraido fuera de headers.
assert spec["headers"] == {
"Content-Type": "application/json",
"Authorization": "Bearer tok123",
}
assert "Host" not in spec["headers"]
assert "Content-Length" not in spec["headers"]
assert "Accept-Encoding" not in spec["headers"]
assert "Cookie" not in spec["headers"]
# Cookies parseadas del header Cookie.
assert spec["cookies"] == {"session": "abc", "csrf": "xyz"}
# Body + tipo inferido.
assert spec["body"] == '{"user":"neo","pass":"secret"}'
assert spec["body_type"] == "json"
# Status de respuesta.
assert spec["status"] == 200
# Cookies que setea la respuesta.
assert spec["sets_cookies"] == ["session", "remember"]
def test_edge_get_sin_body():
"""Edge: GET sin body -> body None, body_type None, sin cookies."""
entries = [
{
"request": {
"method": "get",
"url": "https://api.example.com/me",
"headers": [
{"name": "Accept", "value": "application/json"},
],
},
"response": {"status": 304, "headers": []},
}
]
[spec] = har_extract_calls(entries)
assert spec["method"] == "GET"
assert spec["body"] is None
assert spec["body_type"] is None
assert spec["cookies"] == {}
assert spec["headers"] == {"Accept": "application/json"}
assert spec["status"] == 304
assert spec["sets_cookies"] == []
def test_drop_headers_extra_respetado():
"""drop_headers extra elimina headers adicionales (case-insensitive)."""
entries = [
{
"request": {
"method": "GET",
"url": "https://api.example.com/data",
"headers": [
{"name": "User-Agent", "value": "curl/8"},
{"name": "X-Trace-Id", "value": "noise-123"},
{"name": "Accept", "value": "*/*"},
],
},
"response": {"status": 200, "headers": []},
}
]
[spec] = har_extract_calls(entries, drop_headers=["x-trace-id"])
assert "X-Trace-Id" not in spec["headers"]
assert spec["headers"] == {"User-Agent": "curl/8", "Accept": "*/*"}
def test_form_body_y_set_cookie_desde_headers():
"""Body form-urlencoded -> body_type form; set-cookie parseado de headers
cuando no hay response.cookies."""
entries = [
{
"request": {
"method": "POST",
"url": "https://api.example.com/form",
"headers": [
{
"name": "Content-Type",
"value": "application/x-www-form-urlencoded",
},
],
"postData": {
"mimeType": "application/x-www-form-urlencoded",
"text": "a=1&b=2",
},
},
"response": {
"status": 201,
"headers": [
{"name": "Set-Cookie", "value": "auth=tok; Path=/; HttpOnly"},
{"name": "Content-Type", "value": "text/html"},
{"name": "Set-Cookie", "value": "lang=es; Path=/"},
],
},
}
]
[spec] = har_extract_calls(entries)
assert spec["body_type"] == "form"
assert spec["body"] == "a=1&b=2"
assert spec["sets_cookies"] == ["auth", "lang"]
def test_lista_vacia():
"""Sin entries -> lista vacia."""
assert har_extract_calls([]) == []
@@ -0,0 +1,77 @@
---
name: har_filter_flows
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "def har_filter_flows(har: dict, *, hosts: list[str] | None = None, methods: list[str] | None = None, drop_static: bool = True, drop_analytics: bool = True) -> list[dict]"
description: "Filtra un HAR (formato W3C, el que exporta query_mitm_flows --har) dejando solo los flujos relevantes para reconstruir una accion HTTP: descarta recursos estaticos y dominios de analytics, y restringe por host/metodo. Primer paso del patron grabar->destilar->reproducir."
tags: [flow-replay, har, proxy, cybersecurity, web-proxy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [urllib.parse]
params:
- name: har
desc: "HAR ya parseado como dict (formato W3C). Se leen los entries de har['log']['entries']; si la estructura no existe devuelve []"
- name: hosts
desc: "lista de hosts a conservar (match exacto del host de cada URL). None = no filtra por host"
- name: methods
desc: "lista de metodos HTTP a conservar (GET/POST/...). Se normaliza a mayusculas en ambos lados. None = no filtra por metodo"
- name: drop_static
desc: "si True (default) descarta recursos estaticos: mimeType image/*, font/*, text/css, application|text/javascript, o ruta terminada en .css .js .mjs .map .png .jpg .jpeg .gif .svg .webp .ico .woff .woff2 .ttf .eot"
- name: drop_analytics
desc: "si True (default) descarta entries cuyo host caiga en la blocklist heuristica de telemetria (google-analytics, googletagmanager, doubleclick, sentry.io, segment, mixpanel, hotjar, datadoghq, etc.) por substring sobre el host"
output: "lista de dicts: subconjunto de los entries HAR de entrada (sin mutarlos) que pasan todos los filtros, en su orden original"
tested: true
tests: ["test_golden_solo_sobrevive_el_post_de_api", "test_har_vacio_devuelve_lista_vacia", "test_har_sin_log_entries_devuelve_lista_vacia", "test_filtro_por_hosts", "test_filtro_por_methods"]
test_file_path: "python/functions/cybersecurity/har_filter_flows_test.py"
file_path: "python/functions/cybersecurity/har_filter_flows.py"
---
## Ejemplo
```python
har = {
"log": {
"entries": [
{ # estatico: CSS -> se descarta
"request": {"method": "GET", "url": "https://app.example.com/styles/main.css"},
"response": {"content": {"mimeType": "text/css"}},
},
{ # analytics -> se descarta
"request": {"method": "POST", "url": "https://www.google-analytics.com/collect"},
"response": {"content": {"mimeType": "application/json"}},
},
{ # el POST de API que queremos reproducir -> sobrevive
"request": {"method": "POST", "url": "https://api.example.com/v1/login"},
"response": {"content": {"mimeType": "application/json"}},
},
]
}
}
flows = har_filter_flows(har)
print(len(flows)) # 1
print(flows[0]["request"]["url"]) # https://api.example.com/v1/login
# Restringir aun mas al host y metodo de interes:
flows = har_filter_flows(har, hosts=["api.example.com"], methods=["POST"])
print(len(flows)) # 1
```
## Cuando usarla
- Justo despues de capturar trafico con `web_proxy` / `query_mitm_flows --har`, cuando quieres **destilar** un HAR ruidoso a los pocos flujos que componen una accion (login, alta, transferencia) antes de convertirla en una funcion reproducible del registry.
- Cuando necesitas quedarte solo con las llamadas de API (`hosts=[...]`, `methods=["POST","PUT"]`) y descartar de un golpe estaticos y telemetria.
- Como primer paso del patron **grabar -> destilar -> reproducir** un flujo web: graba con el proxy, filtra con esta funcion, y reproduce los entries resultantes.
## Gotchas
- Funcion pura: no hace I/O. Recibe el HAR ya parseado (carga el `.har` con `json.load` antes de llamarla).
- La blocklist de analytics es **heuristica y ampliable** (substring sobre el host). Si un dominio de telemetria propio no esta en la lista, no se descarta; pasa `drop_analytics=False` y filtra a mano, o amplia la blocklist en el codigo.
- El filtro `hosts` es **match exacto de host** (no substring, no subdominios): `api.example.com` no captura `www.api.example.com`. Lista cada host que quieras conservar.
- El host se obtiene con `urllib.parse.urlsplit(...).hostname`; URLs sin host valido cuentan como host vacio `""`.
@@ -0,0 +1,148 @@
"""Filtra los flujos relevantes de un HAR para reconstruir una accion HTTP.
Primer paso del patron "grabar -> destilar -> reproducir": dado un HAR
(formato estandar W3C, el que exporta `query_mitm_flows --har` de mitmproxy),
descarta el ruido (recursos estaticos, dominios de analytics/telemetria) y
opcionalmente restringe por host y metodo, dejando solo los entries que
importan para reproducir una accion como funcion del registry.
Funcion pura: recibe el HAR ya parseado como dict, no hace I/O y no muta los
dicts de entrada (devuelve un subconjunto de los entries originales tal cual).
"""
from urllib.parse import urlsplit
# Extensiones de recursos estaticos (sobre el path, ignorando querystring).
_STATIC_EXTENSIONS = (
".css",
".js",
".mjs",
".map",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".webp",
".ico",
".woff",
".woff2",
".ttf",
".eot",
)
# Prefijos de mimeType considerados estaticos.
_STATIC_MIME_PREFIXES = (
"image/",
"font/",
"text/css",
)
# mimeTypes exactos considerados estaticos (JavaScript en sus dos formas).
_STATIC_MIME_EXACT = (
"application/javascript",
"text/javascript",
)
# Blocklist heuristica de dominios de telemetria/analytics (substring sobre host).
_ANALYTICS_BLOCKLIST = (
"google-analytics.com",
"googletagmanager.com",
"analytics.google.com",
"doubleclick.net",
"facebook.com/tr",
"connect.facebook.net",
"sentry.io",
"segment.io",
"segment.com",
"mixpanel.com",
"hotjar.com",
"fullstory.com",
"clarity.ms",
"cdn.amplitude.com",
"stats.g.doubleclick.net",
"datadoghq.com",
"bugsnag.com",
)
def _is_static(entry: dict) -> bool:
"""True si el entry HAR es un recurso estatico (por mimeType o extension)."""
mime = (
entry.get("response", {})
.get("content", {})
.get("mimeType", "")
)
mime = (mime or "").split(";", 1)[0].strip().lower()
if mime:
if mime.startswith(_STATIC_MIME_PREFIXES):
return True
if mime in _STATIC_MIME_EXACT:
return True
url = entry.get("request", {}).get("url", "") or ""
path = urlsplit(url).path.lower()
return path.endswith(_STATIC_EXTENSIONS)
def _is_analytics(host: str) -> bool:
"""True si el host coincide (substring) con la blocklist de analytics."""
host = (host or "").lower()
return any(blocked in host for blocked in _ANALYTICS_BLOCKLIST)
def har_filter_flows(
har: dict,
*,
hosts: list[str] | None = None,
methods: list[str] | None = None,
drop_static: bool = True,
drop_analytics: bool = True,
) -> list[dict]:
"""Devuelve solo los entries HAR relevantes para reproducir una accion HTTP.
Args:
har: HAR ya parseado como dict (formato W3C). Se leen los entries de
`har["log"]["entries"]`; si la estructura no existe, devuelve [].
hosts: si no es None, mantiene solo entries cuyo host (de urlsplit)
este en la lista (match exacto de host).
methods: si no es None, mantiene solo entries cuyo metodo HTTP este en
la lista (ambos lados normalizados a mayusculas).
drop_static: si True, descarta recursos estaticos (CSS/JS/imagenes/
fuentes) por mimeType o por extension de la ruta.
drop_analytics: si True, descarta entries cuyo host caiga en la
blocklist de dominios de telemetria/analytics.
Returns:
Sublista de los dicts de entrada (los entries HAR originales, sin
mutar) que pasan todos los filtros.
"""
entries = har.get("log", {}).get("entries")
if not isinstance(entries, list):
return []
methods_upper = (
{m.upper() for m in methods} if methods is not None else None
)
hosts_set = set(hosts) if hosts is not None else None
result: list[dict] = []
for entry in entries:
request = entry.get("request", {})
url = request.get("url", "") or ""
host = urlsplit(url).hostname or ""
if drop_static and _is_static(entry):
continue
if drop_analytics and _is_analytics(host):
continue
if hosts_set is not None and host not in hosts_set:
continue
if methods_upper is not None:
method = (request.get("method", "") or "").upper()
if method not in methods_upper:
continue
result.append(entry)
return result
@@ -0,0 +1,93 @@
"""Tests para har_filter_flows."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from har_filter_flows import har_filter_flows
def _entry(method: str, url: str, mime: str = "application/json") -> dict:
return {
"request": {"method": method, "url": url},
"response": {"content": {"mimeType": mime}},
}
def _mixed_har() -> dict:
return {
"log": {
"entries": [
# Recursos estaticos por mimeType.
_entry("GET", "https://app.example.com/styles/main.css", "text/css"),
_entry("GET", "https://app.example.com/bundle.js", "application/javascript"),
_entry("GET", "https://cdn.example.com/logo.png", "image/png"),
_entry("GET", "https://cdn.example.com/font.woff2", "font/woff2"),
# Estatico por extension de la ruta aunque el mime no lo delate.
_entry("GET", "https://cdn.example.com/icons/star.svg?v=2", "application/octet-stream"),
# Analytics / telemetria.
_entry("POST", "https://www.google-analytics.com/collect", "application/json"),
_entry("GET", "https://browser.sentry.io/api/123/envelope/", "application/json"),
# El POST de API que queremos reproducir.
_entry("POST", "https://api.example.com/v1/login", "application/json"),
]
}
}
def test_golden_solo_sobrevive_el_post_de_api():
flows = har_filter_flows(_mixed_har())
assert len(flows) == 1
assert flows[0]["request"]["method"] == "POST"
assert flows[0]["request"]["url"] == "https://api.example.com/v1/login"
def test_har_vacio_devuelve_lista_vacia():
assert har_filter_flows({}) == []
def test_har_sin_log_entries_devuelve_lista_vacia():
assert har_filter_flows({"log": {}}) == []
assert har_filter_flows({"log": {"entries": None}}) == []
def test_filtro_por_hosts():
har = {
"log": {
"entries": [
_entry("POST", "https://api.example.com/v1/login"),
_entry("POST", "https://other.example.com/v1/track"),
]
}
}
flows = har_filter_flows(har, hosts=["api.example.com"])
assert len(flows) == 1
assert flows[0]["request"]["url"] == "https://api.example.com/v1/login"
def test_filtro_por_methods():
har = {
"log": {
"entries": [
_entry("GET", "https://api.example.com/v1/me"),
_entry("POST", "https://api.example.com/v1/login"),
_entry("post", "https://api.example.com/v1/refresh"),
]
}
}
flows = har_filter_flows(har, methods=["post"])
assert len(flows) == 2
assert {f["request"]["url"] for f in flows} == {
"https://api.example.com/v1/login",
"https://api.example.com/v1/refresh",
}
def test_no_muta_los_entries_de_entrada():
har = _mixed_har()
original_count = len(har["log"]["entries"])
flows = har_filter_flows(har)
assert len(har["log"]["entries"]) == original_count
# El entry devuelto es el mismo objeto, no una copia.
assert flows[0] is har["log"]["entries"][-1]
+2
View File
@@ -1,8 +1,10 @@
from .setup_logger import setup_logger, get_logger
from .generate_app_icon import generate_app_icon
from .http_replay_sequence import http_replay_sequence
__all__ = [
"setup_logger",
"get_logger",
"generate_app_icon",
"http_replay_sequence",
]
+8 -1
View File
@@ -12,9 +12,12 @@ import io
import os
from pathlib import Path
import cairosvg
from PIL import Image, ImageDraw
# cairosvg se importa de forma perezosa dentro de los renderers que lo usan
# (ver _render_glyph_*). Asi el modulo (y el paquete infra que lo reexporta)
# se importa sin requerir cairosvg instalado; solo rasterizar SVGs lo exige.
DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256]
@@ -62,6 +65,8 @@ def _luminance(accent_hex: str) -> float:
def _render_glyph_colored(svg_path: Path, size: int, fill: str) -> Image.Image:
"""Renderiza un SVG Phosphor reemplazando currentColor por `fill`."""
import cairosvg
svg = svg_path.read_text(encoding="utf-8")
svg = svg.replace('fill="currentColor"', f'fill="{fill}"')
png_bytes = cairosvg.svg2png(
@@ -82,6 +87,8 @@ def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
Returns:
Imagen RGBA con el glyph en blanco sobre fondo transparente.
"""
import cairosvg
svg = svg_path.read_text(encoding="utf-8")
# Phosphor usa fill="currentColor" — forzar blanco.
svg = svg.replace('fill="currentColor"', 'fill="#ffffff"')
@@ -0,0 +1,87 @@
---
name: http_replay_sequence
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def http_replay_sequence(calls: list[dict], *, params: dict | None = None, extract: list[dict] | None = None, timeout_s: float = 30.0, verify_tls: bool = True, allow_redirects: bool = True, base_headers: dict | None = None) -> dict"
description: "Motor de replay HTTP: ejecuta en orden una secuencia de call specs (las que produce har_extract_calls_py_cybersecurity) compartiendo una sesion (cookie jar) entre pasos, con substitucion de parametros {{param}} y extraccion de valores de una respuesta para usarlos en pasos siguientes (p.ej. token CSRF del GET inicial -> header del POST). Pieza reutilizable del Nivel 1 (HTTP puro) del patron grabar->destilar->reproducir."
tags: [flow-replay, http, replay, client, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [re, requests]
tested: true
tests: ["test_golden_extract_and_subst", "test_edge_missing_param", "test_error_path_request_exception"]
test_file_path: "python/functions/infra/http_replay_sequence_test.py"
file_path: "python/functions/infra/http_replay_sequence.py"
params:
- name: calls
desc: "Lista ordenada de call specs. Cada spec: {method, url, headers(dict), cookies(dict opc), body(str|None), body_type:'json'|'form'|'raw'|None}. El body ya es texto (no se re-serializa). Es el formato de salida de har_extract_calls_py_cybersecurity."
- name: params
desc: "Dict inicial de contexto para la substitucion {{param}}. Se copia (no se muta el original). Pasa aqui secretos/tokens desde un vault/pass, nunca hardcodeados en los call specs."
- name: extract
desc: "Lista de reglas de extraccion {from: int|'last', type: 'json'|'regex'|'header'|'set_cookie', expr: str, as: str}. Se aplican justo tras ejecutar el step indicado en 'from' ('last' = el step recien ejecutado) y guardan el valor en ctx[as] para los pasos siguientes."
- name: timeout_s
desc: "Timeout por request en segundos (default: 30.0)."
- name: verify_tls
desc: "Verificar certificados TLS; se setea en la sesion (default: True). No desactivar salvo entorno de pruebas controlado."
- name: allow_redirects
desc: "Si los requests siguen redirects (default: True)."
- name: base_headers
desc: "Headers por defecto que se mezclan en la sesion (se aplican a todos los pasos). Util para User-Agent / Accept comunes."
output: "Dict {status: 'ok'|'error', steps: [{idx, method, url, status_code, ok, extracted, missing_params, error}], params_final: dict (ctx tras todos los pasos), error: str}. status='error' solo ante excepcion de transporte (requests.RequestException) o entrada invalida; en ese caso corta y deja de ejecutar. Un 4xx/5xx NO corta: el step queda con ok=False y status global sigue 'ok'."
---
## Ejemplo
```python
from infra import http_replay_sequence
# Requiere red: usa httpbin.org (publico). 2 pasos:
# 1) GET /uuid -> extrae el uuid del JSON como param "u"
# 2) POST /anything -> manda header X-Token: {{u}} (el uuid del paso 1)
calls = [
{"method": "GET", "url": "https://httpbin.org/uuid",
"headers": {"Accept": "application/json"}, "body": None, "body_type": None},
{"method": "POST", "url": "https://httpbin.org/anything",
"headers": {"X-Token": "{{u}}", "Content-Type": "application/json"},
"body": '{"hello": "world"}', "body_type": "json"},
]
extract = [
{"from": 0, "type": "json", "expr": "uuid", "as": "u"},
]
result = http_replay_sequence(calls, extract=extract)
print(result["status"]) # "ok"
token = result["params_final"]["u"] # el uuid extraido del paso 0
print("token:", token)
# httpbin /anything devuelve los headers que recibio; comprobamos que el
# paso 2 llevo el valor substituido:
print(result["steps"][1]["status_code"]) # 200
print(result["steps"][1]["ok"]) # True
# El header X-Token: {{u}} se substituyo por el uuid antes de enviarse.
```
## Cuando usarla
Usala tras `har_extract_calls_py_cybersecurity`, para validar que un flujo capturado se reproduce SIN navegador (Nivel 1 del patron grabar->destilar->reproducir). Es la base de las funciones-accion guardadas en el registry: cuando una secuencia HTTP demuestra reproducir un login + accion, se promueve a una funcion/pipeline dedicada. Tambien sirve para encadenar requests dependientes (token CSRF, session id, paginacion con cursor) compartiendo cookie jar y propagando valores entre pasos.
## Gotchas
- **Seguridad — secretos via params, nunca hardcodeados.** Los call specs pueden contener cookies/tokens. El caller debe inyectarlos via `{{param}}` desde un vault/pass (`params={...}`), no escribirlos en los specs ni commitearlos.
- **Seguridad — replay con efectos es PELIGROSO.** Reproducir una secuencia con efectos (POST que reinicia un server, borra, paga, envia) ejecuta esos efectos de verdad. El caller debe confirmar antes de lanzar una secuencia mutante.
- **Seguridad — `verify_tls` default True.** No lo pongas en False salvo en un entorno de pruebas controlado; desactivar la verificacion TLS abre la puerta a MITM.
- **Extraccion JSON es dot-path simple, NO jsonpath completo.** `"data.items.0.token"` funciona (claves + indices de lista por digito), pero no hay filtros, wildcards ni expresiones. Para casos complejos, usa `type: regex` o post-procesa.
- **Sigue redirects por defecto** (`allow_redirects=True`). Si la secuencia capturada depende del 302 explicito (p.ej. para leer el Location o una cookie intermedia), pon `allow_redirects=False`.
- **Params faltantes NO abortan.** Si un `{{nombre}}` no esta en ctx, se deja el literal `{{nombre}}` y se anade a `step.missing_params`. El request se envia igual; solo una excepcion de transporte corta la ejecucion.
- **El body no se re-serializa.** `body_type: "json"` solo documenta el tipo; el body ya es texto y se manda como `data=body`. Asegurate de incluir el header `Content-Type` adecuado en el spec.
- **4xx/5xx no es error global.** El step queda `ok=False` con su `status_code`, pero `status` global sigue `"ok"`. Solo `requests.RequestException` (DNS, conexion, timeout) marca `status="error"` y corta.
## Capability growth log
v1.0.0 — version inicial.
@@ -0,0 +1,252 @@
"""HTTP replay engine: reproduce an ordered sequence of captured HTTP calls.
This is the reusable core of Level 1 ("pure HTTP") of the record -> distill ->
replay pattern. It takes the call specs produced by
``har_extract_calls_py_cybersecurity`` and replays them in order over a single
``requests.Session`` (shared cookie jar), supporting ``{{param}}`` substitution
and extracting values from one response to feed later steps (e.g. a CSRF token
from the initial GET injected as a header in a subsequent POST).
"""
import re
import requests
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
def _subst(value, ctx, missing):
"""Replace every ``{{name}}`` occurrence in ``value`` using ``ctx``.
If a referenced param is missing from ``ctx``, the literal ``{{name}}`` is
kept untouched and the name is appended to ``missing`` (deduplicated).
Non-string values are returned unchanged.
"""
if not isinstance(value, str):
return value
def repl(match: "re.Match") -> str:
name = match.group(1)
if name in ctx and ctx[name] is not None:
return str(ctx[name])
if name not in missing:
missing.append(name)
return match.group(0)
return _PLACEHOLDER_RE.sub(repl, value)
def _subst_dict(d, ctx, missing):
"""Apply ``_subst`` to every value of a dict, returning a new dict."""
if not d:
return {}
out = {}
for k, v in d.items():
out[k] = _subst(v, ctx, missing)
return out
def _json_dot_path(data, expr: str):
"""Walk a simple dot-path over a parsed JSON value.
Supports dict keys and list indices: ``"data.items.0.token"``. A segment
that is all digits is treated as a list index. Returns the value or ``None``
if any segment cannot be resolved.
"""
cur = data
for seg in expr.split("."):
if seg == "":
continue
if isinstance(cur, list) and seg.isdigit():
idx = int(seg)
if 0 <= idx < len(cur):
cur = cur[idx]
else:
return None
elif isinstance(cur, dict) and seg in cur:
cur = cur[seg]
else:
return None
return cur
def _apply_extract_rule(rule, resp, session):
"""Resolve a single extract rule against a response. Returns str value or "".
Rule types:
- json: dot-path over ``resp.json()``.
- regex: ``re.search`` over ``resp.text``; group(1) if present, else group(0).
- header: ``resp.headers.get(expr)``.
- set_cookie: ``session.cookies.get(expr)``.
"""
rtype = rule.get("type", "json")
expr = rule.get("expr", "")
try:
if rtype == "json":
value = _json_dot_path(resp.json(), expr)
return "" if value is None else str(value)
if rtype == "regex":
m = re.search(expr, resp.text)
if not m:
return ""
if m.groups():
return "" if m.group(1) is None else str(m.group(1))
return str(m.group(0))
if rtype == "header":
value = resp.headers.get(expr)
return "" if value is None else str(value)
if rtype == "set_cookie":
value = session.cookies.get(expr)
return "" if value is None else str(value)
except (ValueError, TypeError):
return ""
return ""
def http_replay_sequence(
calls: list[dict],
*,
params: dict | None = None,
extract: list[dict] | None = None,
timeout_s: float = 30.0,
verify_tls: bool = True,
allow_redirects: bool = True,
base_headers: dict | None = None,
) -> dict:
"""Replay an ordered sequence of HTTP call specs over a shared session.
Args:
calls: List of call specs, each
``{"method","url","headers"(dict),"cookies"(dict opc),"body"(str|None),
"body_type":"json"|"form"|"raw"|None}``.
params: Initial context dict for ``{{param}}`` substitution (copied).
extract: List of extract rules
``{"from": int|"last", "type": "json"|"regex"|"header"|"set_cookie",
"expr": str, "as": str}``. Applied right after the referenced step runs.
timeout_s: Per-request timeout in seconds.
verify_tls: Whether to verify TLS certificates (set on the session).
allow_redirects: Whether requests should follow redirects.
base_headers: Default headers merged into the session.
Returns:
Dict with ``status`` ("ok"|"error"), ``steps`` (per-step records),
``params_final`` (the context after all steps) and ``error`` (message
when ``status == "error"``).
"""
ctx: dict = dict(params) if params else {}
extract = extract or []
steps: list[dict] = []
# Validate input shape before opening a session.
if not isinstance(calls, list):
return {
"status": "error",
"steps": [],
"params_final": ctx,
"error": "calls must be a list of call specs",
}
session = requests.Session()
session.verify = verify_tls
if base_headers:
session.headers.update(base_headers)
status = "ok"
error_msg = ""
try:
for i, call in enumerate(calls):
if not isinstance(call, dict):
status = "error"
error_msg = f"step {i}: call spec must be a dict"
steps.append(
{
"idx": i,
"method": "",
"url": "",
"status_code": 0,
"ok": False,
"extracted": {},
"missing_params": [],
"error": "call spec must be a dict",
}
)
break
missing: list[str] = []
method = (call.get("method") or "GET").upper()
url = _subst(call.get("url") or "", ctx, missing)
headers = _subst_dict(call.get("headers"), ctx, missing)
cookies = _subst_dict(call.get("cookies"), ctx, missing)
body = _subst(call.get("body"), ctx, missing)
body_type = call.get("body_type")
kwargs: dict = {
"headers": headers or None,
"cookies": cookies or None,
"timeout": timeout_s,
"allow_redirects": allow_redirects,
}
# json/form/raw all send the body as-is via data= (the body is
# already a serialized string; do NOT re-serialize JSON).
if body is not None:
kwargs["data"] = body
try:
resp = session.request(method, url, **kwargs)
except requests.RequestException as exc:
status = "error"
error_msg = f"step {i}: {exc}"
steps.append(
{
"idx": i,
"method": method,
"url": url,
"status_code": 0,
"ok": False,
"extracted": {},
"missing_params": missing,
"error": str(exc),
}
)
break
code = resp.status_code
ok = 200 <= code < 400
# Apply extract rules targeting this step. "last" == the step just run.
extracted: dict = {}
extract_notes: list[str] = []
for rule in extract:
frm = rule.get("from")
if frm == "last" or frm == i:
as_name = rule.get("as")
if not as_name:
continue
value = _apply_extract_rule(rule, resp, session)
ctx[as_name] = value
extracted[as_name] = value
if value == "":
extract_notes.append(f"extract '{as_name}' not found")
steps.append(
{
"idx": i,
"method": method,
"url": url,
"status_code": code,
"ok": ok,
"extracted": extracted,
"missing_params": missing,
"error": "; ".join(extract_notes),
}
)
finally:
session.close()
return {
"status": status,
"steps": steps,
"params_final": ctx,
"error": error_msg,
}
@@ -0,0 +1,120 @@
"""Tests para http_replay_sequence.
No dependen de red: mockean requests.Session.request con unittest.mock para
verificar substitucion, extraccion y manejo de errores de transporte.
"""
from unittest.mock import patch
import requests
from .http_replay_sequence import http_replay_sequence
class _FakeResp:
"""Respuesta minima que imita lo que usa la funcion de requests.Response."""
def __init__(self, status_code=200, json_data=None, text="", headers=None):
self.status_code = status_code
self._json = json_data if json_data is not None else {}
self.text = text
self.headers = headers or {}
def json(self):
return self._json
def test_golden_extract_and_subst():
"""2 pasos: extract json del paso 0 -> usado en {{token}} del paso 1.
Verifica que la url y el header del paso 1 llevaron el valor substituido.
"""
sent = [] # captura (method, url, kwargs) de cada request
def fake_request(self, method, url, **kwargs):
sent.append((method, url, kwargs))
if "/uuid" in url:
return _FakeResp(200, json_data={"data": {"items": [{"token": "ABC123"}]}})
return _FakeResp(200, json_data={"echo": True})
calls = [
{"method": "GET", "url": "https://api.example/uuid",
"headers": {"Accept": "application/json"}, "body": None, "body_type": None},
{"method": "POST", "url": "https://api.example/use/{{token}}",
"headers": {"X-Token": "{{token}}"}, "body": '{"k": "v"}', "body_type": "json"},
]
extract = [
{"from": 0, "type": "json", "expr": "data.items.0.token", "as": "token"},
]
with patch.object(requests.Session, "request", fake_request):
result = http_replay_sequence(calls, extract=extract)
assert result["status"] == "ok"
assert result["error"] == ""
assert result["params_final"]["token"] == "ABC123"
# Paso 0 extrajo el token.
assert result["steps"][0]["extracted"] == {"token": "ABC123"}
assert result["steps"][0]["ok"] is True
# Paso 1: la URL fue substituida.
method1, url1, kwargs1 = sent[1]
assert method1 == "POST"
assert url1 == "https://api.example/use/ABC123"
# El header X-Token llevo el valor substituido.
assert kwargs1["headers"]["X-Token"] == "ABC123"
# El body se manda como data= sin re-serializar.
assert kwargs1["data"] == '{"k": "v"}'
assert result["steps"][1]["ok"] is True
assert result["steps"][1]["missing_params"] == []
def test_edge_missing_param():
"""Param faltante -> missing_params poblado y literal {{x}} intacto."""
sent = []
def fake_request(self, method, url, **kwargs):
sent.append((method, url, kwargs))
return _FakeResp(200, json_data={})
calls = [
{"method": "GET", "url": "https://api.example/path/{{missing}}",
"headers": {"X-H": "{{missing}}"}, "body": None, "body_type": None},
]
with patch.object(requests.Session, "request", fake_request):
result = http_replay_sequence(calls)
assert result["status"] == "ok"
# El literal {{missing}} queda intacto tanto en url como en header.
method0, url0, kwargs0 = sent[0]
assert url0 == "https://api.example/path/{{missing}}"
assert kwargs0["headers"]["X-H"] == "{{missing}}"
# El step registra el param faltante (deduplicado, una sola vez).
assert result["steps"][0]["missing_params"] == ["missing"]
def test_error_path_request_exception():
"""La sesion lanza requests.RequestException -> status=error, corta, step.error poblado."""
def fake_request(self, method, url, **kwargs):
raise requests.RequestException("connection refused")
calls = [
{"method": "GET", "url": "https://down.example/a", "headers": {},
"body": None, "body_type": None},
{"method": "GET", "url": "https://down.example/b", "headers": {},
"body": None, "body_type": None},
]
with patch.object(requests.Session, "request", fake_request):
result = http_replay_sequence(calls)
assert result["status"] == "error"
assert "connection refused" in result["error"]
# Corta tras la excepcion: solo se registro el primer step.
assert len(result["steps"]) == 1
assert result["steps"][0]["ok"] is False
assert result["steps"][0]["status_code"] == 0
assert "connection refused" in result["steps"][0]["error"]
View File