Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 736e019e19 | |||
| 1f93e9d502 | |||
| b75bd7e154 | |||
| e0fad0e82f | |||
| 830f2d34de | |||
| ccfa5bc78b |
+2
-1
@@ -21,7 +21,7 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
||||
|
||||
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
|
||||
|
||||
**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. 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`.
|
||||
**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`.
|
||||
|
||||
@@ -193,6 +193,7 @@ Regla decisiva: antes de cada bloque de codigo, decide caso. Si dudas entre 2 y
|
||||
| `client._http.request(...)` directo cuando hay wrapper en el registry | Salta validacion del wrapper y telemetria | Usar wrapper; si la firma no cubre el caso, proponer extension via `fn proposal add` |
|
||||
| Scripts en `temp/` para composiciones que se repiten | Codigo se pierde y no se monitoriza | Pipeline en `python/functions/pipelines/` o pipeline Bash en `bash/functions/pipelines/` |
|
||||
| Imports `from <pkg> import *` en heredoc | Imposible saber que funcion del registry se uso | Imports explicitos `from <domain> import <name1>, <name2>` |
|
||||
| `claude -p` o `subprocess(["claude", "-p", ...])` para obtener una respuesta del modelo | Lento (cold start ~7-15s, carga MCP + CLAUDE.md), caro, sin control de tools | `ask_llm` (grupo `claude-direct`, API directa, arranque 0). Ver regla `llm_invocation.md` |
|
||||
|
||||
Excepciones autorizadas para `sqlite3` directo (no requieren MCP): `.schema`, `.tables`, `PRAGMA table_info`, `COUNT(*) GROUP BY`, JOINs custom entre tablas que el MCP no expone.
|
||||
|
||||
|
||||
@@ -39,3 +39,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
||||
| 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. |
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
## Invocación de LLM: SIEMPRE `ask_llm`, NUNCA `claude -p`
|
||||
|
||||
**REGLA DURA.** Para ejecutar un modelo LLM desde cualquier código del ecosistema (scripts, heredocs, apps, pipelines, agentes), usa el grupo `claude-direct` — empezando por `ask_llm_py_core`. **NUNCA** uses `claude -p` ni lances el binario `claude` como subproceso para obtener una respuesta del modelo.
|
||||
|
||||
### Por qué
|
||||
|
||||
| | `claude -p` | `ask_llm` / `claude-direct` |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claude Code entero (proceso `claude`) | Habla directo a `api.anthropic.com/v1/messages` |
|
||||
| Arranque | ~7-15s (carga MCP + `CLAUDE.md` ~100k tokens) | **0 — request HTTP directa** |
|
||||
| Latencia/msg | ~9-15s | **~2.5s** |
|
||||
| Coste | Alto (re-carga contexto cada vez) | Mínimo (solo tu prompt) |
|
||||
| Tools | Las de Claude Code (no controlables) | **Las que tú defines** (`run_claude_tool_loop`) |
|
||||
| Streaming | indirecto | nativo (`stream_anthropic_messages`) |
|
||||
|
||||
`claude -p` es lento, caro y arranca todo Claude Code para una completion. `ask_llm` es la API directa: arranque 0, rápido, con tus propias tools. Usa el token OAuth que Claude Code ya guarda en `~/.claude/.credentials.json`.
|
||||
|
||||
### Cómo (según el caso)
|
||||
|
||||
| Caso | Usa |
|
||||
|---|---|
|
||||
| Pregunta/chat one-shot | `fn run ask_llm "..."` o `from core.ask_llm import ask_llm` |
|
||||
| Streaming de eventos crudos (text/tool_use deltas) | `stream_anthropic_messages_py_core` |
|
||||
| Agente con TUS tools (tool-use loop) | `run_claude_tool_loop_py_core` (defines `tools` + `dispatch`) |
|
||||
| Token OAuth | `load_claude_oauth_token_py_core` (automático dentro de las anteriores) |
|
||||
| Distribuir fuera del registry | `apps/llm_cli/llm.py` (versión standalone autocontenida) |
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from core.ask_llm import ask_llm
|
||||
respuesta = ask_llm("resume esto en 3 lineas: ...", model="claude-haiku-4-5-20251001", echo=False)
|
||||
```
|
||||
|
||||
### Legacy
|
||||
|
||||
`claude_stream_go_core` (lanza `claude -p --output-format stream-json`) es el **camino antiguo**. No usarlo en código nuevo — preferir las funciones `claude-direct`. Queda solo para compatibilidad de consumidores existentes.
|
||||
|
||||
### Excepción acotada
|
||||
|
||||
Si una tarea necesita **genuinamente las capacidades de Claude Code** (sus tools nativas, los MCP del repo, plan mode, el contexto del proyecto) y no basta con el modelo + tus propias tools via `run_claude_tool_loop`, entonces NO es una "invocación LLM" simple: documenta por qué en el código. El **default sin excepción es `ask_llm`**.
|
||||
|
||||
### Telemetría / auditoría
|
||||
|
||||
Un `claude -p` o un `subprocess(["claude", "-p", ...])` en código nuevo es un antipatrón auditable: sustituir por `ask_llm` / `claude-direct`. Buscar usos: `grep -rn 'claude -p' --include='*.py' --include='*.sh' --include='*.go'`.
|
||||
|
||||
### Relación con otras reglas
|
||||
|
||||
- [[registry_calls]] — patrones canónicos de invocación de funciones; esta regla fija el patrón para la sub-tarea "invocar un LLM".
|
||||
- [[registry_first]] — reusar antes que reescribir; `ask_llm` es la función reutilizable para LLM.
|
||||
@@ -28,6 +28,23 @@ projects/{nombre}/
|
||||
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
|
||||
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
|
||||
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
|
||||
|
||||
### Cada project es su propio repo Gitea (sub-repo)
|
||||
|
||||
Desde 2026-06-05 cada `projects/<nombre>/` es un **repo Gitea independiente** `dataforge/<nombre>` (branch `master`), igual que las apps y los analyses. El repo del project versiona **solo las docs de nivel-project** (`project.md`, `CONVENTIONS.md` y demás `.md`/`.claude/` propios del project). El contenido de los hijos NO se versiona aquí: cada `apps/<app>/` y cada `analysis/<a>/` es su propio sub-repo Gitea y queda excluido por el `.gitignore` del project:
|
||||
|
||||
```gitignore
|
||||
apps/*/
|
||||
analysis/*/
|
||||
vaults/*
|
||||
!vaults/.gitkeep
|
||||
```
|
||||
|
||||
- **Crear el repo del project**: `ensure_repo_synced_bash_infra projects/<nombre> dataforge <nombre> master "init: project <nombre>"` (necesita `GITEA_URL` + `GITEA_TOKEN`; el token está en `pass gitea/dataforge-git-token`). Crear el `.gitignore` de arriba ANTES, para no trackear el contenido de los sub-repos hijos.
|
||||
- **Push/pull**: `/full-git-push` y `/full-git-pull` ya lo manejan automáticamente — `discover_git_repos_bash_infra` descubre cualquier `.git` bajo `fn_registry`, incluidos los projects.
|
||||
- **`repo_url`** en `project.md` apunta al repo del project; los `repo_url` de cada app viven en su `app.md`. Así el project "referencia" sus sub-repos sin git submodules (KISS).
|
||||
- El repo padre `fn_registry` sigue ignorando `projects/*/` entero (regla `apps_subrepo.md`): nunca trackea contenido de projects.
|
||||
- Estado actual: `dataforge/web_scraping`, `dataforge/fn_monitoring`, `dataforge/message_bus`.
|
||||
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||
|
||||
### Raiz vs proyecto
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: apply_chromium_cdp_flag
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]"
|
||||
description: "Gestiona de forma idempotente el fragmento /etc/chromium.d/cdp que activa Chrome DevTools Protocol global en todo chromium que el usuario lance en el equipo. Escribe, actualiza o borra el fragmento con backup automático."
|
||||
tags: [navegator, chromium, cdp, devtools, browser, automation, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: "--port N"
|
||||
desc: "Puerto TCP donde Chromium escuchará conexiones CDP. Default 9222."
|
||||
- name: "--network"
|
||||
desc: "Si se pasa, añade --remote-debugging-address=0.0.0.0 (accesible desde la red local). Por defecto solo loopback (127.0.0.1). Imprime advertencia de seguridad."
|
||||
- name: "--fragment-path <path>"
|
||||
desc: "Ruta del fragmento a escribir/borrar. Default /etc/chromium.d/cdp."
|
||||
- name: "--remove"
|
||||
desc: "Borra el fragmento (desactiva CDP global). Idempotente: si no existe, no-op."
|
||||
- name: "--dry-run"
|
||||
desc: "Imprime el fragmento que se escribiría sin tocar nada. No requiere sudo."
|
||||
output: "Sale 0 en éxito (aplicado, ya-aplicado, o eliminado). Sale != 0 en error con mensaje a stderr. En caso de actualización imprime ruta del backup creado."
|
||||
file_path: "bash/functions/browser/apply_chromium_cdp_flag.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Activar CDP global en loopback puerto 9222 (proyecto web_scraping, regla 8)
|
||||
source bash/functions/browser/apply_chromium_cdp_flag.sh
|
||||
apply_chromium_cdp_flag
|
||||
|
||||
# Previsualizar el fragmento sin escribir nada (no requiere sudo)
|
||||
apply_chromium_cdp_flag --port 9222 --dry-run
|
||||
|
||||
# Puerto alternativo (para correr en paralelo al navegador del usuario)
|
||||
apply_chromium_cdp_flag --port 9333
|
||||
|
||||
# Activar expuesto a la red local (RIESGO: cualquier host de la LAN puede controlar el navegador)
|
||||
apply_chromium_cdp_flag --port 9222 --network
|
||||
|
||||
# Desactivar CDP global
|
||||
apply_chromium_cdp_flag --remove
|
||||
|
||||
# Ruta personalizada (útil en pruebas o entornos chroot)
|
||||
apply_chromium_cdp_flag --port 9222 --fragment-path /tmp/test_cdp_fragment --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al preparar un PC nuevo para controlar el chromium diario del usuario vía CDP (primer setup del proyecto `web_scraping`, regla 8). Al cambiar el puerto CDP del sistema. Al desactivar esa capacidad antes de prestar o formatear el equipo. Sustituye el paso manual "crear `/etc/chromium.d/cdp` con sudo" documentado en `CHROMIUM_SYSTEM.md`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere sudo** para escribir bajo `/etc/`. En este equipo usar `pass show claude/sudo | sudo -S apply_chromium_cdp_flag` o ejecutar como root.
|
||||
- **`--network` (0.0.0.0) es un riesgo de seguridad serio**: cualquier máquina en la red local puede conectarse al puerto CDP y controlar Chromium completamente (leer cookies, sesiones, inyectar JavaScript). Solo usar en entornos de red aislados o laboratorios.
|
||||
- **El chromium ya abierto antes del cambio no hereda el flag** hasta que se reinicie. El fragmento solo se aplica en el próximo arranque de `/usr/bin/chromium`.
|
||||
- **Dos procesos chromium no pueden compartir el mismo puerto**. Si el usuario ya tiene un chromium con CDP en 9222, la automatización dedicada debe arrancar con `chrome_launch_go_browser` en otro puerto (ej. 9333) o usar `--port 9333` en esta función.
|
||||
- **Idempotente**: si el fragmento ya existe con contenido idéntico, la función sale 0 sin tocar nada ni crear backup.
|
||||
- **Backup automático**: al sobreescribir, crea `<path>.bak.YYYYMMDD`. Si ya existe un backup del mismo día, no lo sobreescribe (el primero del día se preserva).
|
||||
- **Validación post-escritura**: tras escribir, verifica con `grep` que la línea `export CHROMIUM_FLAGS` con el puerto correcto quedó en el archivo. Si falla, restaura el backup y sale con error.
|
||||
- Ver `projects/web_scraping/CHROMIUM_SYSTEM.md` para el contexto completo del sistema CDP de este equipo.
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply_chromium_cdp_flag — gestiona el fragmento /etc/chromium.d/cdp que activa CDP global.
|
||||
#
|
||||
# Uso:
|
||||
# apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]
|
||||
|
||||
apply_chromium_cdp_flag() {
|
||||
local port=9222
|
||||
local network=0
|
||||
local fragment_path="/etc/chromium.d/cdp"
|
||||
local remove=0
|
||||
local dry_run=0
|
||||
|
||||
# Parseo de argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
port="$2"
|
||||
shift 2
|
||||
;;
|
||||
--network)
|
||||
network=1
|
||||
shift
|
||||
;;
|
||||
--fragment-path)
|
||||
fragment_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remove)
|
||||
remove=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "apply_chromium_cdp_flag: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validación del puerto
|
||||
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
|
||||
echo "apply_chromium_cdp_flag: puerto inválido: $port (debe ser 1-65535)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Construcción del contenido del fragmento
|
||||
local flags_line
|
||||
if (( network )); then
|
||||
echo "ADVERTENCIA DE SEGURIDAD: --network activa --remote-debugging-address=0.0.0.0." >&2
|
||||
echo "El navegador quedará expuesto a toda la red local. Cualquier host en la red" >&2
|
||||
echo "podrá controlar Chromium remotamente y leer todas las sesiones abiertas." >&2
|
||||
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=* --remote-debugging-address=0.0.0.0"'
|
||||
else
|
||||
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=*"'
|
||||
fi
|
||||
|
||||
local mode_label
|
||||
if (( network )); then
|
||||
mode_label="network (0.0.0.0)"
|
||||
else
|
||||
mode_label="loopback (127.0.0.1)"
|
||||
fi
|
||||
|
||||
local fragment_content
|
||||
fragment_content="# CDP global para automatizacion del navegador del usuario (proyecto web_scraping, regla 8).
|
||||
# Bind ${mode_label} por defecto: el puerto solo
|
||||
# es accesible desde 127.0.0.1, no desde la red.
|
||||
${flags_line}"
|
||||
|
||||
# Modo --dry-run: solo mostrar y salir
|
||||
if (( dry_run )); then
|
||||
echo "--- dry-run: fragmento que se escribiría en ${fragment_path} ---"
|
||||
echo "${fragment_content}"
|
||||
echo "--- fin dry-run ---"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Modo --remove
|
||||
if (( remove )); then
|
||||
if [[ ! -e "$fragment_path" ]]; then
|
||||
echo "apply_chromium_cdp_flag: ${fragment_path} no existe, nada que borrar."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ ! -e "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$fragment_path" "$backup_path"
|
||||
else
|
||||
sudo cp "$fragment_path" "$backup_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
rm "$fragment_path"
|
||||
else
|
||||
sudo rm "$fragment_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo borrar ${fragment_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo "apply_chromium_cdp_flag: fragmento eliminado (backup: ${backup_path})"
|
||||
echo "Nota: el chromium ya abierto antes de este cambio no lo hereda hasta reiniciarlo."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Idempotencia: comparar con contenido actual
|
||||
if [[ -f "$fragment_path" ]]; then
|
||||
local current_content
|
||||
current_content=$(cat "$fragment_path" 2>/dev/null)
|
||||
if [[ "$current_content" == "$fragment_content" ]]; then
|
||||
echo "apply_chromium_cdp_flag: ya aplicado, sin cambios (${fragment_path})."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Crear directorio si falta
|
||||
local fragment_dir
|
||||
fragment_dir=$(dirname "$fragment_path")
|
||||
if [[ ! -d "$fragment_dir" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
mkdir -p "$fragment_dir"
|
||||
else
|
||||
sudo mkdir -p "$fragment_dir" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear ${fragment_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Backup si ya existe y difiere
|
||||
if [[ -e "$fragment_path" ]]; then
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ ! -e "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$fragment_path" "$backup_path"
|
||||
else
|
||||
sudo cp "$fragment_path" "$backup_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
echo "apply_chromium_cdp_flag: backup creado en ${backup_path}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Escribir fragmento
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp)
|
||||
printf '%s\n' "$fragment_content" > "$tmpfile"
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$tmpfile" "$fragment_path"
|
||||
chmod 644 "$fragment_path"
|
||||
else
|
||||
sudo cp "$tmpfile" "$fragment_path" || {
|
||||
rm -f "$tmpfile"
|
||||
echo "apply_chromium_cdp_flag: no se pudo escribir ${fragment_path}" >&2
|
||||
return 1
|
||||
}
|
||||
sudo chmod 644 "$fragment_path" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# Validación post-escritura
|
||||
local expected_line="--remote-debugging-port=${port}"
|
||||
if ! grep -qF "$expected_line" "$fragment_path" 2>/dev/null; then
|
||||
echo "apply_chromium_cdp_flag: validación fallida — la línea export no apareció en ${fragment_path}." >&2
|
||||
# Restaurar backup si existe
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ -f "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$backup_path" "$fragment_path"
|
||||
else
|
||||
sudo cp "$backup_path" "$fragment_path" 2>/dev/null || true
|
||||
fi
|
||||
echo "apply_chromium_cdp_flag: backup restaurado desde ${backup_path}" >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Resumen final
|
||||
echo "apply_chromium_cdp_flag: CDP global activado correctamente."
|
||||
echo " Fragmento : ${fragment_path}"
|
||||
echo " Puerto : ${port}"
|
||||
echo " Modo : ${mode_label}"
|
||||
echo ""
|
||||
echo "Nota: el chromium ya abierto antes de este cambio no hereda el flag hasta reiniciarlo."
|
||||
echo "Nota: dos procesos chromium no pueden compartir el mismo puerto; usa --port <otro> para"
|
||||
echo " automatización dedicada que corra en paralelo al navegador del usuario."
|
||||
}
|
||||
|
||||
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
|
||||
# solo se define la función y no se ejecuta nada.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
apply_chromium_cdp_flag "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: apply_chromium_extension_policy
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "apply_chromium_extension_policy [--keep <ext_id[=update_url]>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]"
|
||||
description: "Escribe de forma idempotente la política managed de Chromium combinando ExtensionInstallForcelist (force-instala la whitelist --keep) y ExtensionInstallBlocklist (bloquea y desinstala la blocklist --block). No usa el comodín \"*\" blocked, por lo que NO afecta a las extensiones unpacked cargadas con --load-extension. Guarda backup fuera del directorio managed/ (que Chromium lee entero). Requiere sudo para escribir en /etc; en --dry-run no toca el sistema."
|
||||
tags: [chromium, extensions, policy, browser, navegator, managed-policy, idempotent]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--keep <ext_id[=update_url]>"
|
||||
desc: "ID de extensión a force-instalar (repetible). Va a ExtensionInstallForcelist. Forma simple '<id>' usa el update_url por defecto (Web Store). Forma '<id>=<update_url>' fuerza una extensión self-hosted: por ejemplo '<id>=file:///home/u/.web_proxy/update.xml' instala un .crx local empaquetado bajo managed policy (necesario porque --load-extension queda desactivado cuando hay managed policy). Ejemplo Web Store: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)."
|
||||
- name: "--block <ext_id>"
|
||||
desc: "ID de extensión a bloquear y desinstalar en cualquier perfil (repetible). Va a ExtensionInstallBlocklist. Solo afecta a los IDs listados; el resto de extensiones no se toca."
|
||||
- name: "--policy-path <path>"
|
||||
desc: "Ruta del JSON de managed policy. Default: /etc/chromium/policies/managed/extensions.json."
|
||||
- name: "--update-url <url>"
|
||||
desc: "URL del servicio de actualización de extensiones. Default: https://clients2.google.com/service/update2/crx."
|
||||
- name: "--dry-run"
|
||||
desc: "Imprime el JSON que se escribiría sin tocar el sistema (no requiere sudo)."
|
||||
output: "Escribe el JSON de política en policy-path y emite a stdout un resumen: extensiones forzadas, bloqueadas, ruta, backup creado y recordatorio de reinicio de Chromium. Sale 0 si la política se aplicó o ya estaba vigente. Sale != 0 en error. Requiere al menos un --keep o --block."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/apply_chromium_extension_policy.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dejar el perfil con solo uBlock Origin Lite: forzar uBlock + bloquear las 3 que estorban
|
||||
# al scraping (Dark Reader, NoScript, OneTab). Proyecto web_scraping, regla 9.
|
||||
source bash/functions/browser/apply_chromium_extension_policy.sh
|
||||
apply_chromium_extension_policy \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
|
||||
--block eimadpbcbfnmbkopoojfekhnkhdbieeh \
|
||||
--block doojmbjmlfjjnbmnoijecmcbfeoakpjm \
|
||||
--block chphlpgkkbolifaimnlloiipkdnihall
|
||||
|
||||
# Previsualizar sin tocar el sistema (sin sudo)
|
||||
apply_chromium_extension_policy --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
|
||||
|
||||
# Ejecutar como root para el sudo no interactivo de este equipo
|
||||
pass show claude/sudo | sudo -S bash bash/functions/browser/apply_chromium_extension_policy.sh \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh --block eimadpbcbfnmbkopoojfekhnkhdbieeh
|
||||
```
|
||||
|
||||
La policy por sí sola evita la reinstalación pero NO desinstala lo ya presente en un perfil concreto:
|
||||
combínala con `clean_chrome_profile_extensions_bash_browser` (con Chromium cerrado) para purgar del
|
||||
disco las extensiones ya instaladas.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al preparar un PC nuevo o cambiar qué extensiones de Chrome Web Store deben estar (o no estar) en
|
||||
cualquier perfil de Chromium del equipo. Reemplaza el paso manual de editar el JSON de policy con
|
||||
sudo. `--keep` fuerza y fija las imprescindibles; `--block` elimina las molestas sin tocar el resto.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El backup NUNCA va dentro de `managed/`** (lo gestiona la función, pero es la lección clave): Chromium
|
||||
lee **todos** los archivos del directorio `policies/managed/` sin filtrar por extensión de nombre. Un
|
||||
`extensions.json.bak.YYYYMMDD` dentro de `managed/` se mergea con la policy efectiva y **reinyecta** las
|
||||
extensiones del backup (se ven como `location=7` external_policy_download y vuelven aunque las borres).
|
||||
Por eso la función guarda los backups en `policies/policy-backups/`, fuera de `managed/`. Si encuentras
|
||||
backups antiguos dentro de `managed/`, muévelos fuera.
|
||||
- **No usa el comodín `"*": blocked`**: ese modo desinstala todo lo no-whitelist pero también **bloquea las
|
||||
extensiones unpacked** (`--load-extension`), rompiendo cosas como la extensión de captura de `web_proxy`
|
||||
con el error "Loading of unpacked extensions is disabled by the administrator". Esta función bloquea solo
|
||||
los IDs de `--block`.
|
||||
- **`--load-extension` y managed policy son incompatibles en Chromium 137+**: con CUALQUIER managed policy
|
||||
presente, Chromium desactiva `--load-extension` ("disabled by the administrator"). Para cargar una
|
||||
extensión local junto a una managed policy hay que empaquetarla (.crx + update_url) o usar `--proxy-server`
|
||||
directo en el caso de `web_proxy`.
|
||||
- **Requiere sudo** para escribir en `/etc/chromium/policies/managed/`. En este equipo: `pass show claude/sudo | sudo -S <cmd>`.
|
||||
- **Chrome cachea la política en memoria**: cerrar TODOS los Chromium (`pkill -9 chromium`) y relanzar, o `chrome://policy` → "Reload policies".
|
||||
- **Idempotente**: si el archivo ya tiene el mismo contenido, no-op y sale 0.
|
||||
- Referencia del sistema completo: `projects/web_scraping/CHROMIUM_SYSTEM.md`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-05) — `--keep` acepta `<id>=<update_url>` para force-instalar extensiones self-hosted (p.ej. un `.crx` local vía `file://` a un `update.xml`), que es la forma de cargar una extensión propia cuando hay managed policy y `--load-extension` está desactivado.
|
||||
- v1.1.0 (2026-06-05) — añade `--block` (ExtensionInstallBlocklist); reemplaza el modo `ExtensionSettings "*": blocked` (rompía extensiones unpacked) por blocklist específica; mueve los backups fuera de `managed/` (Chromium lee todo el directorio y un `.bak` ahí reinyectaba extensiones).
|
||||
- v1.0.0 (2026-06-05) — baseline: ExtensionInstallForcelist con whitelist `--keep`.
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply_chromium_extension_policy — Escribe de forma idempotente la política managed de Chromium
|
||||
# que fuerza la instalación de una whitelist de extensiones y bloquea (desinstala) una blocklist
|
||||
# concreta, sin tocar el resto. Usa ExtensionInstallForcelist + ExtensionInstallBlocklist.
|
||||
|
||||
apply_chromium_extension_policy() {
|
||||
local policy_path="/etc/chromium/policies/managed/extensions.json"
|
||||
local update_url="https://clients2.google.com/service/update2/crx"
|
||||
local dry_run=0
|
||||
local -a keep_ids=()
|
||||
local -a block_ids=()
|
||||
|
||||
# --- Parseo de argumentos ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --keep requiere un ID de extensión" >&2
|
||||
return 1
|
||||
fi
|
||||
keep_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--block)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --block requiere un ID de extensión" >&2
|
||||
return 1
|
||||
fi
|
||||
block_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--policy-path)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --policy-path requiere un valor" >&2
|
||||
return 1
|
||||
fi
|
||||
policy_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--update-url)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --update-url requiere un valor" >&2
|
||||
return 1
|
||||
fi
|
||||
update_url="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "apply_chromium_extension_policy: argumento desconocido: $1" >&2
|
||||
echo "Uso: apply_chromium_extension_policy [--keep <ext_id>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Validar que hay al menos una extensión a forzar o bloquear ---
|
||||
if [[ ${#keep_ids[@]} -eq 0 && ${#block_ids[@]} -eq 0 ]]; then
|
||||
echo "apply_chromium_extension_policy: se requiere al menos un --keep o un --block <ext_id>" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Construir el JSON ---
|
||||
# Dos claves complementarias, ninguna bloquea las extensiones unpacked (--load-extension),
|
||||
# de modo que extensiones locales como la de captura de web_proxy siguen cargando:
|
||||
# 1. ExtensionInstallForcelist: fuerza la instalación de la whitelist (--keep), que además
|
||||
# no se puede desinstalar desde la UI.
|
||||
# 2. ExtensionInstallBlocklist: bloquea Y desinstala las extensiones de la blocklist
|
||||
# (--block) en cualquier perfil. Solo afecta a los IDs listados; el resto no se toca.
|
||||
local forcelist_json="[]" blocklist_json="[]"
|
||||
if [[ ${#keep_ids[@]} -gt 0 ]]; then
|
||||
local entries="" first=1
|
||||
for kid in "${keep_ids[@]}"; do
|
||||
# Cada --keep puede ser "<id>" (usa el update_url por defecto, Web Store) o
|
||||
# "<id>=<update_url>" para una extensión self-hosted (p.ej. file:// a un update.xml local).
|
||||
local id="${kid%%=*}" url="$update_url"
|
||||
[[ "$kid" == *=* ]] && url="${kid#*=}"
|
||||
[[ $first -eq 0 ]] && entries+=","$'\n'
|
||||
entries+=" \"${id};${url}\""
|
||||
first=0
|
||||
done
|
||||
forcelist_json=$(printf '[\n%s\n ]' "$entries")
|
||||
fi
|
||||
if [[ ${#block_ids[@]} -gt 0 ]]; then
|
||||
local entries="" first=1
|
||||
for id in "${block_ids[@]}"; do
|
||||
[[ $first -eq 0 ]] && entries+=","$'\n'
|
||||
entries+=" \"${id}\""
|
||||
first=0
|
||||
done
|
||||
blocklist_json=$(printf '[\n%s\n ]' "$entries")
|
||||
fi
|
||||
|
||||
local new_json
|
||||
new_json=$(cat <<JSONEOF
|
||||
{
|
||||
"ExtensionInstallForcelist": ${forcelist_json},
|
||||
"ExtensionInstallBlocklist": ${blocklist_json}
|
||||
}
|
||||
JSONEOF
|
||||
)
|
||||
|
||||
# --- Modo dry-run ---
|
||||
if [[ $dry_run -eq 1 ]]; then
|
||||
echo "[dry-run] JSON que se escribiría en: ${policy_path}"
|
||||
echo "---"
|
||||
echo "$new_json"
|
||||
echo "---"
|
||||
echo "[dry-run] No se ha modificado el sistema."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# --- Idempotencia: comparar con contenido actual ---
|
||||
if [[ -f "$policy_path" ]]; then
|
||||
local current_content
|
||||
current_content=$(cat "$policy_path" 2>/dev/null || true)
|
||||
if [[ "$current_content" == "$new_json" ]]; then
|
||||
echo "apply_chromium_extension_policy: política ya aplicada (sin cambios). Nada que hacer."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Backup del archivo existente ---
|
||||
# CRÍTICO: el backup NUNCA puede vivir dentro del directorio de la policy. Chromium lee TODOS
|
||||
# los archivos del directorio managed/ (sin filtrar por extensión de nombre), así que un
|
||||
# "extensions.json.bak.YYYYMMDD" dentro de managed/ se mergea con la policy efectiva y reinyecta
|
||||
# las extensiones del backup. Por eso el backup se guarda en un directorio hermano (policy-backups)
|
||||
# que chromium no lee.
|
||||
local backup_path=""
|
||||
if [[ -f "$policy_path" ]]; then
|
||||
local date_suffix policy_dir backup_dir
|
||||
date_suffix=$(date +%Y%m%d)
|
||||
policy_dir="$(dirname "$policy_path")"
|
||||
case "$(basename "$policy_dir")" in
|
||||
managed|recommended) backup_dir="$(dirname "$policy_dir")/policy-backups" ;;
|
||||
*) backup_dir="$policy_dir" ;;
|
||||
esac
|
||||
backup_path="${backup_dir}/$(basename "$policy_path").bak.${date_suffix}"
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
if [[ $EUID -ne 0 ]]; then sudo mkdir -p "$backup_dir" 2>/dev/null; else mkdir -p "$backup_dir"; fi
|
||||
fi
|
||||
if [[ ! -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: creando backup → ${backup_path}"
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$policy_path" "$backup_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
cp "$policy_path" "$backup_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
echo "apply_chromium_extension_policy: backup del día ya existe (${backup_path}), se omite."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Crear directorio padre si no existe ---
|
||||
local policy_dir
|
||||
policy_dir=$(dirname "$policy_path")
|
||||
if [[ ! -d "$policy_dir" ]]; then
|
||||
echo "apply_chromium_extension_policy: creando directorio ${policy_dir}"
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo mkdir -p "$policy_dir" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
mkdir -p "$policy_dir" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Escribir el JSON vía tmpfile + sudo cp ---
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp /tmp/chromium_policy_XXXXXX.json)
|
||||
echo "$new_json" > "$tmpfile"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$tmpfile" "$policy_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
|
||||
rm -f "$tmpfile"
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: restaurando backup tras error..."
|
||||
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
else
|
||||
cp "$tmpfile" "$policy_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
|
||||
rm -f "$tmpfile"
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: restaurando backup tras error..."
|
||||
cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# --- Validar el JSON escrito ---
|
||||
local validation_ok=0
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$policy_path" 2>/dev/null && validation_ok=1
|
||||
elif command -v jq &>/dev/null; then
|
||||
jq . "$policy_path" &>/dev/null && validation_ok=1
|
||||
else
|
||||
validation_ok=1
|
||||
fi
|
||||
|
||||
if [[ $validation_ok -eq 0 ]]; then
|
||||
echo "apply_chromium_extension_policy: el JSON escrito no es válido — restaurando backup" >&2
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
else
|
||||
cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Resumen final ---
|
||||
echo "apply_chromium_extension_policy: política aplicada correctamente."
|
||||
echo " Ruta : ${policy_path}"
|
||||
if [[ ${#keep_ids[@]} -gt 0 ]]; then
|
||||
echo " Forzadas (${#keep_ids[@]}):"
|
||||
for id in "${keep_ids[@]}"; do echo " - ${id}"; done
|
||||
fi
|
||||
if [[ ${#block_ids[@]} -gt 0 ]]; then
|
||||
echo " Bloqueadas/desinstaladas (${#block_ids[@]}):"
|
||||
for id in "${block_ids[@]}"; do echo " - ${id}"; done
|
||||
fi
|
||||
echo " Extensiones unpacked (--load-extension, p.ej. web_proxy): NO afectadas."
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo " Backup : ${backup_path}"
|
||||
fi
|
||||
echo ""
|
||||
echo " AVISO: Chromium cachea la politica en memoria. Para que surta efecto:"
|
||||
echo " pkill -9 chromium (cierra TODOS los procesos)"
|
||||
echo " # Luego relanza Chromium. O desde un Chromium abierto:"
|
||||
echo " # chrome://policy → 'Reload policies'"
|
||||
}
|
||||
|
||||
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
|
||||
# solo se define la función y no se ejecuta nada.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
apply_chromium_extension_policy "$@"
|
||||
fi
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: clean_chrome_profile_extensions
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>] [--keep <ext_id>]... [--dry-run]"
|
||||
description: "Purga in-place las extensiones de un perfil Chrome/Chromium existente que no estén en la whitelist --keep: borra sus carpetas de disco y elimina sus referencias de Preferences y Secure Preferences para que Chromium no las reinstale. Complementaria a apply_chromium_extension_policy_bash_browser que evita reinstalación pero no desinstala lo ya instalado en Chromium 148."
|
||||
tags: [navegator, chromium, extensions, profile, cleanup, browser, scraping]
|
||||
uses_functions: [apply_chromium_extension_policy_bash_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/clean_chrome_profile_extensions.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium. Default: ~/.config/chromium"
|
||||
- name: --profile-directory
|
||||
desc: "Nombre del subperfil dentro de user-data-dir. Default: Default"
|
||||
- name: --keep
|
||||
desc: "ID de extensión Chrome a conservar (repetible, 32 chars minúsculas). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué IDs se conservarían y cuáles se borrarían sin tocar disco ni archivos de preferencias"
|
||||
output: "JSON en stdout: {profile: \"<path>\", kept: [id...], removed: [id...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar Chromium primero (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# Purgar perfil Default dejando solo uBlock Origin Lite
|
||||
source $HOME/fn_registry/bash/functions/browser/clean_chrome_profile_extensions.sh
|
||||
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh
|
||||
|
||||
# Previsualizar antes de tocar nada
|
||||
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
|
||||
|
||||
# Perfil no-default con whitelist de dos extensiones
|
||||
clean_chrome_profile_extensions \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile-directory "Profile 1" \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
|
||||
--keep cjpalhdlnbpafiamejdnhcphjbkeiagm
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"profile":"/home/enmanuel/.config/chromium/Default","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["dark-reader-id","another-ext-id"]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run clean_chrome_profile_extensions_bash_browser -- --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala después de reducir la whitelist de extensiones con `apply_chromium_extension_policy_bash_browser` (modo `blocked`), para quitar del disco las que ya estaban instaladas en el perfil: la policy evita que Chromium reinstale extensiones nuevas, pero en Chromium 148 no desinstala las que ya estaban force-instaladas. Esta función hace la purga determinista del estado existente. También útil antes de una sesión de scraping para dejar el perfil con solo las extensiones necesarias.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium reescribe `Preferences` desde memoria al cerrar y desharía toda la purga. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` no se hace este check.
|
||||
- **Combínala con `apply_chromium_extension_policy_bash_browser` (blocked)** para que las extensiones no vuelvan a instalarse la próxima vez que arranques Chromium. Esta función purga el estado actual; la policy evita la reinstalación futura.
|
||||
- **Backup automático de prefs**: antes de editar `Preferences` y `Secure Preferences` la función crea `<archivo>.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. En caso de problemas: `cp Preferences.bak.YYYYMMDD Preferences`.
|
||||
- **Opera por perfil**: actúa sobre `--user-data-dir`/`--profile-directory`/Extensions. Si tienes varios perfiles (`Default`, `Profile 1`, etc.) debes invocarla una vez por cada uno.
|
||||
- **python3 > jq > warn**: para editar el JSON de Preferences usa python3 si está disponible, jq como fallback, y emite un warning a stderr (sin abortar) si ninguno está. En ese caso las carpetas sí se borran pero las referencias en Preferences quedan — Chromium podría intentar reinstalar desde Web Store.
|
||||
- **Secure Preferences HMAC**: la tabla `protection.macs.extensions.settings` también se limpia para evitar que Chromium detecte inconsistencia entre el HMAC y la entrada eliminada y resetee configuraciones. Si la HMAC falla de todas formas, Chromium lo trata como perfil potencialmente corrupto y puede resetear algunas prefs — comportamiento esperado de Chromium, no un bug de esta función.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido o perfil no encontrado |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
| 3 | Directorio Extensions no encontrado |
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bash
|
||||
# clean_chrome_profile_extensions — purga in-place extensiones fuera de la whitelist
|
||||
# de un perfil Chrome/Chromium existente. Borra las carpetas de disco y limpia las
|
||||
# referencias en Preferences y Secure Preferences para que Chromium no las reinstale.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
clean_chrome_profile_extensions() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir="${HOME}/.config/chromium"
|
||||
local _profile_dir="Default"
|
||||
local _keep=()
|
||||
local _default_ext="ddkjiahejlhfcafbddmgiahcphecmpfh"
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>]
|
||||
[--keep <ext_id>]... [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del perfil. Default: ~/.config/chromium
|
||||
--profile-directory Subperfil. Default: Default
|
||||
--keep <ext_id> ID de extensión a conservar (repetible).
|
||||
Default si no se pasa ninguno: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)
|
||||
--dry-run Lista qué se borraría sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
2 chromium está corriendo (solo en modo real)
|
||||
3 directorio de extensiones no encontrado
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile-directory) _profile_dir="$2"; shift 2 ;;
|
||||
--keep) _keep+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "clean_chrome_profile_extensions: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── whitelist por defecto ──────────────────────────────────────────────────
|
||||
if [[ ${#_keep[@]} -eq 0 ]]; then
|
||||
_keep=("$_default_ext")
|
||||
fi
|
||||
|
||||
# ── construir paths base ───────────────────────────────────────────────────
|
||||
local _profile_path
|
||||
_profile_path="${_user_data_dir}/${_profile_dir}"
|
||||
local _ext_dir="${_profile_path}/Extensions"
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$_profile_path" ]]; then
|
||||
echo "clean_chrome_profile_extensions: perfil no encontrado: ${_profile_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_ext_dir" ]]; then
|
||||
echo "clean_chrome_profile_extensions: directorio de extensiones no encontrado: ${_ext_dir}" >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
# ── guard: chromium NO debe estar corriendo (excepto en dry-run) ──────────
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
if pgrep -x chromium >/dev/null 2>&1; then
|
||||
echo "clean_chrome_profile_extensions: chromium está corriendo — ciérralo antes de limpiar:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Preferences desde memoria al cerrar y desharía la purga)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── enumerar extensiones instaladas ───────────────────────────────────────
|
||||
local _to_remove=()
|
||||
local _to_keep=()
|
||||
|
||||
while IFS= read -r -d '' _ext_path; do
|
||||
local _ext_id
|
||||
_ext_id="$(basename "$_ext_path")"
|
||||
|
||||
# Siempre ignorar la carpeta Temp (usada durante installs en curso)
|
||||
if [[ "$_ext_id" == "Temp" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Comprobar si está en la whitelist
|
||||
local _in_keep=0
|
||||
local _k
|
||||
for _k in "${_keep[@]}"; do
|
||||
if [[ "$_ext_id" == "$_k" ]]; then
|
||||
_in_keep=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $_in_keep -eq 1 ]]; then
|
||||
_to_keep+=("$_ext_id")
|
||||
else
|
||||
_to_remove+=("$_ext_id")
|
||||
fi
|
||||
done < <(find "$_ext_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
|
||||
|
||||
# ── modo dry-run: solo informar ────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== clean_chrome_profile_extensions DRY-RUN ===" >&2
|
||||
echo " Perfil : ${_profile_path}" >&2
|
||||
echo " Conservar (${#_to_keep[@]}): ${_to_keep[*]+"${_to_keep[*]}"}" >&2
|
||||
echo " Borrar (${#_to_remove[@]}): ${_to_remove[*]+"${_to_remove[*]}"}" >&2
|
||||
_emit_json "$_profile_path" _to_keep _to_remove
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── borrar extensiones fuera de la whitelist ───────────────────────────────
|
||||
if [[ ${#_to_remove[@]} -gt 0 ]]; then
|
||||
local _id
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
rm -rf "${_ext_dir}/${_id}"
|
||||
done
|
||||
|
||||
# ── purgar referencias en Preferences y Secure Preferences ────────────
|
||||
# Construir lista Python de IDs eliminados
|
||||
local _py_ids_list=""
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
_py_ids_list+="\"${_id}\","
|
||||
done
|
||||
_py_ids_list="[${_py_ids_list%,}]"
|
||||
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
local _prefs_file
|
||||
for _prefs_file in "${_profile_path}/Preferences" "${_profile_path}/Secure Preferences"; do
|
||||
if [[ ! -f "$_prefs_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Backup (no sobreescribir backup del mismo día)
|
||||
local _backup="${_prefs_file}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_prefs_file" "$_backup"
|
||||
fi
|
||||
|
||||
# Editar con python3 si está disponible
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
|
||||
echo "clean_chrome_profile_extensions: advertencia — no se pudo purgar ${_prefs_file} con python3" >&2
|
||||
import sys, json
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
removed_ids = json.loads(sys.argv[2])
|
||||
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 1. extensions.settings.<id>
|
||||
ext_settings = data.get("extensions", {}).get("settings", {})
|
||||
for ext_id in removed_ids:
|
||||
ext_settings.pop(ext_id, None)
|
||||
|
||||
# 2. extensions.pinned_extensions (lista de IDs)
|
||||
pinned = data.get("extensions", {}).get("pinned_extensions", None)
|
||||
if isinstance(pinned, list):
|
||||
data["extensions"]["pinned_extensions"] = [
|
||||
pid for pid in pinned if pid not in removed_ids
|
||||
]
|
||||
|
||||
# 3. protection.macs.extensions.settings.<id> (Secure Preferences HMAC table)
|
||||
try:
|
||||
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
|
||||
for ext_id in removed_ids:
|
||||
mac_ext.pop(ext_id, None)
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
|
||||
# Fallback con jq si python3 no está disponible
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
local _tmp_prefs
|
||||
_tmp_prefs="$(mktemp)"
|
||||
local _jq_del=""
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
_jq_del+=" | del(.extensions.settings[\"${_id}\"])"
|
||||
_jq_del+=" | del(.protection.macs.extensions.settings[\"${_id}\"])"
|
||||
done
|
||||
# pinned_extensions como lista
|
||||
_jq_del+=" | if .extensions.pinned_extensions then .extensions.pinned_extensions -= [$(printf '"%s",' "${_to_remove[@]}" | sed 's/,$//')] else . end"
|
||||
jq "${_jq_del:1}" "$_prefs_file" > "$_tmp_prefs" && mv "$_tmp_prefs" "$_prefs_file" || {
|
||||
echo "clean_chrome_profile_extensions: advertencia — jq falló procesando ${_prefs_file}" >&2
|
||||
rm -f "$_tmp_prefs"
|
||||
}
|
||||
else
|
||||
echo "clean_chrome_profile_extensions: advertencia — ni python3 ni jq disponibles; se borraron las carpetas pero no las referencias en $(basename "$_prefs_file")" >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── emitir resultado JSON ──────────────────────────────────────────────────
|
||||
_emit_json "$_profile_path" _to_keep _to_remove
|
||||
}
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# _json_array_from_nameref <nameref>
|
||||
# Convierte un array bash (pasado por nombre de variable) en JSON array de strings.
|
||||
_json_array_from_nameref() {
|
||||
local -n _arr_ref="$1"
|
||||
local _out="["
|
||||
local _first=1
|
||||
local _item
|
||||
for _item in "${_arr_ref[@]+"${_arr_ref[@]}"}"; do
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_out+="\"${_item}\""
|
||||
_first=0
|
||||
else
|
||||
_out+=",\"${_item}\""
|
||||
fi
|
||||
done
|
||||
_out+="]"
|
||||
echo "$_out"
|
||||
}
|
||||
|
||||
# _emit_json <profile_path> <kept_nameref> <removed_nameref>
|
||||
_emit_json() {
|
||||
local _p="$1"
|
||||
local _kept_json
|
||||
_kept_json="$(_json_array_from_nameref "$2")"
|
||||
local _removed_json
|
||||
_removed_json="$(_json_array_from_nameref "$3")"
|
||||
printf '{"profile":"%s","kept":%s,"removed":%s}\n' \
|
||||
"$_p" "$_kept_json" "$_removed_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
clean_chrome_profile_extensions "$@"
|
||||
fi
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: prepare_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> [--keep <ext_id>]... [--force]"
|
||||
description: "Clona un user-data-dir de Chrome/Chromium creando un perfil de scraping limpio: conserva solo las extensiones de una lista blanca (por defecto uBlock Origin Lite) y excluye caché, locks y sesiones antiguas."
|
||||
tags: [chrome, browser, profile, scraping, extensions, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/prepare_chrome_profile.sh"
|
||||
params:
|
||||
- name: --src
|
||||
desc: "user-data-dir origen con un perfil Chrome/Chromium ya configurado (debe existir --src/Default)"
|
||||
- name: --dst
|
||||
desc: "Ruta de destino del nuevo perfil; no debe existir salvo que se pase --force"
|
||||
- name: --keep
|
||||
desc: "ID de extensión Chrome a conservar (repetible). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
|
||||
- name: --force
|
||||
desc: "Borra --dst si existe antes de recrearlo. Sin este flag la función aborta si --dst ya existe"
|
||||
output: "JSON en stdout: {dst, kept: [id...], removed: [id...]}. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/prepare_chrome_profile.sh
|
||||
|
||||
prepare_chrome_profile \
|
||||
--src "$HOME/.config/chromium" \
|
||||
--dst "$HOME/.local/share/web_scraping/chrome-profile"
|
||||
|
||||
# Con extensión adicional conservada
|
||||
prepare_chrome_profile \
|
||||
--src "$HOME/.config/chromium" \
|
||||
--dst "$HOME/.local/share/web_scraping/chrome-profile" \
|
||||
--keep "ddkjiahejlhfcafbddmgiahcphecmpfh" \
|
||||
--keep "cjpalhdlnbpafiamejdnhcphjbkeiagm" \
|
||||
--force
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"dst":"/home/enmanuel/.local/share/web_scraping/chrome-profile","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["abcdefghijklmnopabcdefghijklmnop","dark-reader-id"]}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de lanzar una sesión de scraping/automatización para partir de un perfil aislado: con uBlock Origin Lite activo (menos anuncios/trackers = DOM más limpio, respuestas más rápidas) pero sin extensiones que interfieren (Dark Reader muta colores del DOM, NoScript bloquea JS, OneTab modifica tabs). También sirve para aislar sesiones de diferentes proyectos de scraping sin contaminar el perfil personal.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chrome debe estar CERRADO sobre `--src`** antes de ejecutar. Los archivos SQLite (`Cookies`, `History`, `Login Data`, etc.) estarán bloqueados si Chrome está abierto, y `rsync` copiará versiones inconsistentes. Verificar con `pgrep -x chromium` o `pgrep -x chrome`.
|
||||
- **HMAC de Secure Preferences**: el archivo `Local State` contiene la semilla HMAC que Chrome usa para verificar `Preferences` y `Secure Preferences`. Si no se copia (o se copia entre máquinas distintas con distinto binding), Chrome puede invalidar las extensiones al arrancar y resetear configuraciones. La función copia `Local State` automáticamente, pero la copia entre máquinas puede seguir produciendo resets de extensiones — esto es comportamiento esperado de Chrome, no un bug de esta función.
|
||||
- **Purga de referencias en Preferences**: tras borrar las carpetas de extensiones fuera de la whitelist, la función también elimina con `python3` las entradas `extensions.settings.<id>` de `Default/Preferences` y `Default/Secure Preferences`, los IDs de `extensions.pinned_extensions` y las claves `protection.macs.extensions.settings.<id>`. Sin esta limpieza Chrome detecta las entradas en Preferences (con `from_webstore`/install_source) y **vuelve a descargar la extensión del Web Store al arrancar**, deshaciendo el filtrado (caso real: Dark Reader reaparece y oscurece páginas rompiendo screenshots). Si `python3` falla al procesar un Preferences concreto se emite un warning a stderr pero la función no aborta — el borrado de carpetas ya es el efecto principal.
|
||||
- **`--force` borra `--dst` completamente**: si `--dst` es un perfil con datos que quieres conservar, no uses `--force` sin antes hacer backup.
|
||||
- **Extensiones instaladas desde Web Store vs unpacked**: esta función opera sobre la carpeta `Extensions/` física. Las extensiones instaladas desde la Web Store tienen IDs de 32 caracteres en minúsculas. Las extensiones unpacked (`--load-extension`) no viven en `Extensions/` y no se ven afectadas.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento inválido o `--src/Default` no existe |
|
||||
| 2 | `--dst` ya existe y no se pasó `--force` |
|
||||
| 3 | `--src` y `--dst` resuelven al mismo path real |
|
||||
| 4 | Error durante `rsync` |
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bash
|
||||
# prepare_chrome_profile — clona un user-data-dir de Chrome/Chromium conservando solo
|
||||
# las extensiones de una lista blanca. Sirve para perfiles de scraping limpios.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── defaults ──────────────────────────────────────────────────────────────────
|
||||
_SRC=""
|
||||
_DST=""
|
||||
_FORCE=0
|
||||
# uBlock Origin Lite por defecto
|
||||
_KEEP=()
|
||||
_DEFAULT_EXT="ddkjiahejlhfcafbddmgiahcphecmpfh"
|
||||
|
||||
# ── parse args ────────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> \
|
||||
[--keep <ext_id>]... [--force]
|
||||
|
||||
--src user-data-dir origen (ej. $HOME/.config/chromium)
|
||||
--dst user-data-dir destino a crear
|
||||
--keep ID de extensión a conservar (repetible). Default: uBlock Origin Lite
|
||||
--force si --dst existe, lo borra y recrea; sin flag aborta si existe
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 --dst ya existe y no se pasó --force
|
||||
3 --src igual a --dst (mismo path real)
|
||||
4 error de copia/rsync
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--src) _SRC="$2"; shift 2 ;;
|
||||
--dst) _DST="$2"; shift 2 ;;
|
||||
--keep) _KEEP+=("$2"); shift 2 ;;
|
||||
--force) _FORCE=1; shift ;;
|
||||
-h|--help) _usage ;;
|
||||
*) echo "prepare_chrome_profile: argumento desconocido: $1" >&2; _usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones básicas ──────────────────────────────────────────────────────
|
||||
if [[ -z "$_SRC" || -z "$_DST" ]]; then
|
||||
echo "prepare_chrome_profile: --src y --dst son obligatorios" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_SRC/Default" ]]; then
|
||||
echo "prepare_chrome_profile: $_SRC/Default no existe; ¿es un user-data-dir válido?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolver paths reales para comparar (evitar borrar src cuando src==dst)
|
||||
_SRC_REAL="$(realpath "$_SRC")"
|
||||
_DST_REAL="$(realpath -m "$_DST")" # -m: no requiere que exista
|
||||
|
||||
if [[ "$_SRC_REAL" == "$_DST_REAL" ]]; then
|
||||
echo "prepare_chrome_profile: --src y --dst resuelven al mismo path: $_SRC_REAL" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# También rechazar si --dst es prefijo de --src (evitar borrar el origen)
|
||||
if [[ "$_SRC_REAL" == "$_DST_REAL"/* ]]; then
|
||||
echo "prepare_chrome_profile: --src está dentro de --dst; operación peligrosa, abortando" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# ── lista blanca de extensiones ───────────────────────────────────────────────
|
||||
if [[ ${#_KEEP[@]} -eq 0 ]]; then
|
||||
_KEEP=("$_DEFAULT_EXT")
|
||||
fi
|
||||
|
||||
# ── gestionar destino ─────────────────────────────────────────────────────────
|
||||
if [[ -d "$_DST" ]]; then
|
||||
if [[ $_FORCE -eq 1 ]]; then
|
||||
rm -rf "$_DST"
|
||||
else
|
||||
echo "prepare_chrome_profile: $_DST ya existe; usa --force para sobreescribir" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$_DST/Default"
|
||||
|
||||
# ── copiar Local State (HMAC seed para Secure Preferences) ────────────────────
|
||||
if [[ -f "$_SRC/Local State" ]]; then
|
||||
cp "$_SRC/Local State" "$_DST/Local State"
|
||||
fi
|
||||
|
||||
# ── rsync del perfil Default excluyendo caché y locks ─────────────────────────
|
||||
rsync -a \
|
||||
--exclude='Cache/' \
|
||||
--exclude='Code Cache/' \
|
||||
--exclude='GPUCache/' \
|
||||
--exclude='Dawn Cache/' \
|
||||
--exclude='DawnGraphiteCache/' \
|
||||
--exclude='DawnWebGPUCache/' \
|
||||
--exclude='Service Worker/CacheStorage/' \
|
||||
--exclude='Service Worker/ScriptCache/' \
|
||||
--exclude='Singleton*' \
|
||||
--exclude='*.lock' \
|
||||
--exclude='lockfile' \
|
||||
--exclude='Sessions/' \
|
||||
--exclude='Session Storage/' \
|
||||
--exclude='Current Session' \
|
||||
--exclude='Current Tabs' \
|
||||
--exclude='Last Session' \
|
||||
--exclude='Last Tabs' \
|
||||
"$_SRC/Default/" "$_DST/Default/" || {
|
||||
echo "prepare_chrome_profile: rsync falló (exit $?)" >&2
|
||||
exit 4
|
||||
}
|
||||
|
||||
# ── eliminar extensiones fuera de la lista blanca ────────────────────────────
|
||||
_EXT_DIR="$_DST/Default/Extensions"
|
||||
_removed=()
|
||||
_kept=()
|
||||
|
||||
if [[ -d "$_EXT_DIR" ]]; then
|
||||
while IFS= read -r -d '' ext_path; do
|
||||
ext_id="$(basename "$ext_path")"
|
||||
# Conservar siempre la carpeta Temp (usada por Chrome durante installs)
|
||||
if [[ "$ext_id" == "Temp" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Comprobar si está en la lista blanca
|
||||
_in_keep=0
|
||||
for keep_id in "${_KEEP[@]}"; do
|
||||
if [[ "$ext_id" == "$keep_id" ]]; then
|
||||
_in_keep=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $_in_keep -eq 1 ]]; then
|
||||
_kept+=("$ext_id")
|
||||
else
|
||||
rm -rf "$ext_path"
|
||||
_removed+=("$ext_id")
|
||||
fi
|
||||
done < <(find "$_EXT_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
|
||||
fi
|
||||
|
||||
# ── purgar referencias a extensiones eliminadas en Preferences ───────────────
|
||||
# Chrome re-descarga del Web Store cualquier extensión que aparezca en
|
||||
# extensions.settings aunque su carpeta haya sido borrada. Editamos el JSON
|
||||
# con python3 para evitar ese comportamiento.
|
||||
if [[ ${#_removed[@]} -gt 0 ]]; then
|
||||
# Construir lista Python de IDs eliminados
|
||||
_py_ids_list=""
|
||||
for _id in "${_removed[@]}"; do
|
||||
_py_ids_list+="\"${_id}\","
|
||||
done
|
||||
_py_ids_list="[${_py_ids_list%,}]"
|
||||
|
||||
for _prefs_file in "$_DST/Default/Preferences" "$_DST/Default/Secure Preferences"; do
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
|
||||
echo "prepare_chrome_profile: advertencia — no se pudieron purgar refs en $(basename "$_prefs_file")" >&2
|
||||
import sys, json
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
removed_ids = json.loads(sys.argv[2])
|
||||
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 1. extensions.settings.<id>
|
||||
ext_settings = data.get("extensions", {}).get("settings", {})
|
||||
for ext_id in removed_ids:
|
||||
ext_settings.pop(ext_id, None)
|
||||
|
||||
# 2. extensions.pinned_extensions (lista de IDs)
|
||||
pinned = data.get("extensions", {}).get("pinned_extensions", None)
|
||||
if isinstance(pinned, list):
|
||||
data["extensions"]["pinned_extensions"] = [
|
||||
pid for pid in pinned if pid not in removed_ids
|
||||
]
|
||||
|
||||
# 3. protection.macs.extensions.settings.<id> (Secure Preferences)
|
||||
try:
|
||||
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
|
||||
for ext_id in removed_ids:
|
||||
mac_ext.pop(ext_id, None)
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── emitir resultado JSON ─────────────────────────────────────────────────────
|
||||
_json_array() {
|
||||
# Convierte array bash en JSON array de strings
|
||||
local arr=("$@")
|
||||
local out="["
|
||||
local first=1
|
||||
for item in "${arr[@]}"; do
|
||||
if [[ $first -eq 1 ]]; then
|
||||
out+="\"$item\""
|
||||
first=0
|
||||
else
|
||||
out+=",\"$item\""
|
||||
fi
|
||||
done
|
||||
out+="]"
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
_kept_json="$(_json_array "${_kept[@]+"${_kept[@]}"}")"
|
||||
_removed_json="$(_json_array "${_removed[@]+"${_removed[@]}"}")"
|
||||
|
||||
printf '{"dst":"%s","kept":%s,"removed":%s}\n' \
|
||||
"$_DST_REAL" \
|
||||
"$_kept_json" \
|
||||
"$_removed_json"
|
||||
@@ -46,6 +46,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
|
||||
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
|
||||
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
|
||||
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Capability: claude-direct
|
||||
|
||||
Hablar directamente con `https://api.anthropic.com/v1/messages` usando el token OAuth de Claude Code (Claude Max), sin lanzar la CLI `claude` ni necesitar una API key de pago separada. 3 funciones Python en `domain: core`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `load_claude_oauth_token_py_core` | `def load_claude_oauth_token(credentials_path: str = "", refresh_if_expired: bool = True) -> str` | Lee el access token OAuth desde `~/.claude/.credentials.json`. Verifica expiry (ms-epoch). Intenta refresh best-effort si expirado. |
|
||||
| `stream_anthropic_messages_py_core` | `def stream_anthropic_messages(messages: list, model: str = "claude-opus-4-8", ...) -> Iterator[dict]` | POST streaming a `/v1/messages`. Yield de eventos normalizados: `text`, `tool_use_start`, `tool_input_delta`, `done`, `error`. Parser SSE puro testeable por separado. |
|
||||
| `run_claude_tool_loop_py_core` | `def run_claude_tool_loop(messages, tools, dispatch, ...) -> dict` | Bucle agentico tool-use. Llama `stream_anthropic_messages` en loop, despacha tools via `dispatch{name: callable}`, anade `tool_result`, repite hasta `end_turn` o `max_iters`. |
|
||||
|
||||
## Ejemplo canonico end-to-end
|
||||
|
||||
### Pregunta simple (sin tools)
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from stream_anthropic_messages import stream_anthropic_messages
|
||||
|
||||
text = ""
|
||||
for event in stream_anthropic_messages(
|
||||
messages=[{"role": "user", "content": "di solo PONG"}],
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=32,
|
||||
):
|
||||
if event["type"] == "text":
|
||||
text += event["text"]
|
||||
print(event["text"], end="", flush=True)
|
||||
elif event["type"] == "done":
|
||||
print(f"\n[stop={event['stop_reason']}]")
|
||||
# Output: PONG
|
||||
# [stop=end_turn]
|
||||
```
|
||||
|
||||
### Bucle agentico con tool propia
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from run_claude_tool_loop import run_claude_tool_loop
|
||||
from datetime import datetime
|
||||
|
||||
tools = [
|
||||
{
|
||||
"name": "get_time",
|
||||
"description": "Devuelve la hora actual en formato HH:MM:SS.",
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
]
|
||||
|
||||
dispatch = {
|
||||
"get_time": lambda _inp: datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
|
||||
result = run_claude_tool_loop(
|
||||
messages=[{"role": "user", "content": "que hora es exactamente ahora?"}],
|
||||
tools=tools,
|
||||
dispatch=dispatch,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
on_text=lambda d: print(d, end="", flush=True),
|
||||
)
|
||||
print(f"\n[iters={result['iterations']} stop={result['stop_reason']}]")
|
||||
# Claude llama a get_time() -> "14:32:07"
|
||||
# Luego responde: "Ahora son las 14:32:07."
|
||||
```
|
||||
|
||||
### Solo leer el token (para uso manual)
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from load_claude_oauth_token import load_claude_oauth_token
|
||||
|
||||
token = load_claude_oauth_token(refresh_if_expired=False)
|
||||
# Pasar como header: {"authorization": f"Bearer {token}"}
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO cubre** el flujo de refresh OAuth (endpoint no documentado publicamente) — el refresh es best-effort y puede fallar silenciosamente.
|
||||
- **NO es un cliente completo** de la API de Anthropic: solo `/v1/messages` con streaming. Files, embeddings, etc. quedan fuera.
|
||||
- **NO reemplaza** el uso de API keys oficiales para produccion — este grupo es exclusivamente para uso local del token OAuth de Claude Max.
|
||||
- **NO gestiona rate limits** — el caller debe manejar errores `{"type": "error"}` con `429` en el mensaje.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Claude Code instalado y usuario logueado (`~/.claude/.credentials.json` debe existir).
|
||||
- `httpx` disponible en el venv: `python/.venv/bin/python3 -c "import httpx"`.
|
||||
- Token fresco (Claude Code normalmente lo renueva en background mientras esta abierto).
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: <objetivo de la sesion en una frase>
|
||||
artefacto: <app|analysis|project|registry|none> · <ruta relativa si aplica>
|
||||
created: DD/MM/AAAA HH:mm
|
||||
updated: DD/MM/AAAA HH:mm
|
||||
status: in_progress
|
||||
related_issues: []
|
||||
related_flows: []
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
<que se quiere conseguir, condiciones de done. Una o dos lineas. Esto fija el alcance.>
|
||||
|
||||
## Pendiente
|
||||
|
||||
- [ ] 1. <tarea> — <nota / dependencia>
|
||||
- [ ] 2. <tarea>
|
||||
- [ ] 3. <tarea>
|
||||
|
||||
## En curso
|
||||
|
||||
- [~] <tarea> — <progreso actual, donde se quedo, siguiente paso concreto>
|
||||
|
||||
## Hecho
|
||||
|
||||
- [x] <tarea>
|
||||
- resultado: <que produjo / como se verifico>
|
||||
- enlace: <url, path de archivo, id de funcion, hash de commit>
|
||||
|
||||
## Enlaces
|
||||
|
||||
- <descripcion> — <url o path>
|
||||
|
||||
## Issues / flows relacionados
|
||||
|
||||
- issue <NNNN> — <titulo> — <estado>
|
||||
- flow <slug> — <titulo> — <estado>
|
||||
|
||||
## Notas
|
||||
|
||||
- <decision tomada y por que>
|
||||
- <bloqueo / pregunta abierta>
|
||||
- <contexto que no cabe en el codigo>
|
||||
@@ -0,0 +1,116 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpClickHuman hace click en el elemento identificado por selector CSS con
|
||||
// movimiento humano: obtiene el bbox, calcula un punto destino ligeramente
|
||||
// desplazado del centro, mueve el ratón por una trayectoria de Bézier cúbica
|
||||
// y luego despacha mousePressed/mouseReleased con una micro-pausa entre ellos.
|
||||
//
|
||||
// opts controla la trayectoria del movimiento previo al click.
|
||||
// Para configurar el origen del movimiento usa opts.FromX / opts.FromY.
|
||||
func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp click human: conexion nula")
|
||||
}
|
||||
|
||||
// Obtener bounding box del selector
|
||||
js := fmt.Sprintf(`(function() {
|
||||
var el = document.querySelector(%q);
|
||||
if (!el) return null;
|
||||
var r = el.getBoundingClientRect();
|
||||
return JSON.stringify({x: r.left, y: r.top, w: r.width, h: r.height});
|
||||
})()`, selector)
|
||||
|
||||
bboxStr, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click human: obtener bbox de %q: %w", selector, err)
|
||||
}
|
||||
if bboxStr == "" || bboxStr == "null" {
|
||||
return fmt.Errorf("cdp click human: elemento %q no encontrado en el DOM", selector)
|
||||
}
|
||||
|
||||
bboxStr = strings.Trim(bboxStr, `"`)
|
||||
bx, by, bw, bh, err := parseBbox(bboxStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click human: parsear bbox %q: %w", bboxStr, err)
|
||||
}
|
||||
|
||||
// Scroll al elemento para que sea visible
|
||||
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
|
||||
if _, err := CdpEvaluate(c, scrollJS); err != nil {
|
||||
_ = err // no fatal
|
||||
}
|
||||
|
||||
// Punto destino: centro + pequeño offset aleatorio (±15% del tamaño)
|
||||
offX := (rand.Float64()*2 - 1) * bw * 0.15
|
||||
offY := (rand.Float64()*2 - 1) * bh * 0.15
|
||||
toX := bx + bw/2 + offX
|
||||
toY := by + bh/2 + offY
|
||||
|
||||
// Mover el ratón con trayectoria humana
|
||||
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
|
||||
return fmt.Errorf("cdp click human: mover raton: %w", err)
|
||||
}
|
||||
|
||||
// mousePressed
|
||||
clickParams := map[string]any{
|
||||
"type": "mousePressed",
|
||||
"x": toX,
|
||||
"y": toY,
|
||||
"button": "left",
|
||||
"clickCount": 1,
|
||||
}
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// Micro-pausa humana entre press y release (30–90 ms)
|
||||
pauseMs := 30 + rand.Intn(61)
|
||||
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
|
||||
|
||||
// mouseReleased
|
||||
clickParams["type"] = "mouseReleased"
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseBbox extrae left, top, width, height de un JSON como {"x":10,"y":20,"w":100,"h":40}.
|
||||
func parseBbox(s string) (left, top, width, height float64, err error) {
|
||||
// Reutiliza el mismo parser manual que parseCoords para evitar encoding/json
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "{")
|
||||
s = strings.TrimSuffix(s, "}")
|
||||
|
||||
for part := range strings.SplitSeq(s, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.Trim(strings.TrimSpace(kv[0]), `"`)
|
||||
var v float64
|
||||
if _, e := fmt.Sscanf(strings.TrimSpace(kv[1]), "%f", &v); e != nil {
|
||||
err = fmt.Errorf("parsear valor %q: %w", kv[1], e)
|
||||
return
|
||||
}
|
||||
switch k {
|
||||
case "x":
|
||||
left = v
|
||||
case "y":
|
||||
top = v
|
||||
case "w":
|
||||
width = v
|
||||
case "h":
|
||||
height = v
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: cdp_click_human
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error"
|
||||
description: "Hace click en el elemento identificado por selector CSS con comportamiento humano: obtiene el bounding box, calcula un destino ligeramente desplazado del centro, mueve el ratón con CdpMoveMouseHuman (curva de Bézier cúbica + easing + jitter) y despacha mousePressed/mouseReleased con micro-pausa de 30-90 ms entre ellos."
|
||||
tags: [cdp, chrome, browser, mouse, human, click, navegator]
|
||||
uses_functions:
|
||||
- cdp_evaluate_go_browser
|
||||
- cdp_move_mouse_human_go_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- fmt
|
||||
- math/rand
|
||||
- strings
|
||||
- time
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_click_human.go"
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa obtenida con CdpConnect."
|
||||
- name: selector
|
||||
desc: "Selector CSS del elemento a clickear (ej. '#submit-btn', '.nav-item:first-child')."
|
||||
- name: opts
|
||||
desc: "MouseHumanOpts que controla la trayectoria del movimiento previo. Usa opts.FromX/FromY para definir el origen del movimiento (default 0,0)."
|
||||
output: "error si la conexión es nula, el elemento no existe en el DOM, o falla algún evento CDP."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
defer CdpClose(conn, 0)
|
||||
|
||||
CdpNavigate(conn, "https://example.com/login")
|
||||
CdpWaitElement(conn, "#username", 5*time.Second)
|
||||
|
||||
// Click humano en el campo usuario desde la esquina superior izquierda
|
||||
err := CdpClickHuman(conn, "#username", MouseHumanOpts{
|
||||
FromX: 50,
|
||||
FromY: 50,
|
||||
})
|
||||
|
||||
// Click en el botón submit viniendo desde donde está el campo usuario
|
||||
err = CdpClickHuman(conn, "#submit-btn", MouseHumanOpts{
|
||||
FromX: 350, // aproximadamente donde quedó el cursor anterior
|
||||
FromY: 280,
|
||||
DurationMs: 500,
|
||||
Steps: 30,
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Sustituye a `CdpClick` cuando el sitio detecta clicks instantáneos sin movimiento previo o cuando el punto de click exactamente en el centro del elemento activa checks anti-bot. Usar en formularios de login, CAPTCHAs de comportamiento, botones con honeypot invisible en el centro exacto.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El destino final se desplaza ±15% del tamaño del elemento respecto al centro para evitar siempre clickear en el pixel exacto. En elementos muy pequeños (<5px) el offset puede salir fuera del elemento — usar `CdpClick` en esos casos.
|
||||
- Hace `scrollIntoView` antes del movimiento. Si el elemento está en el fold inferior, el scroll ocurre y las coordenadas de la curva Bézier ya reflejan la posición post-scroll. Sin embargo, si el scroll produce reflow del DOM (lazy-load), puede que el selector cambie de posición durante el movimiento.
|
||||
- La micro-pausa de 30-90 ms entre mousePressed y mouseReleased está codificada en el rango típico humano. No hay opción para ajustarla — si necesitas control total, llama `CdpMoveMouseHuman` + `Input.dispatchMouseEvent` manualmente.
|
||||
- No garantiza indetectabilidad total. Ver `## Gotchas` de `cdp_move_mouse_human_go_browser`.
|
||||
- Requiere que el elemento sea visible (no `display:none` ni `visibility:hidden`). `getBoundingClientRect` retorna todos ceros para elementos ocultos, produciendo click en (0,0).
|
||||
- `opts.FromX` y `opts.FromY` deben ser la posición actual real del cursor para que la trayectoria sea convincente. Si no conoces la posición actual, pasa el centro aproximado de la última acción.
|
||||
@@ -3,9 +3,12 @@ package browser
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// 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).
|
||||
// Siempre intenta cerrar la conexion aunque el kill falle, y viceversa.
|
||||
// Retorna el primer error encontrado.
|
||||
func CdpClose(c *CDPConn, pid int) error {
|
||||
@@ -19,16 +22,19 @@ func CdpClose(c *CDPConn, pid int) error {
|
||||
}
|
||||
|
||||
if pid > 0 {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, err)
|
||||
}
|
||||
} else {
|
||||
if err := proc.Kill(); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, err)
|
||||
// Intentar matar el grupo de proceso completo (pid == pgid cuando Setpgid=true).
|
||||
// syscall.Kill con pid negativo envia la señal a todos los procesos del grupo.
|
||||
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||
// Fallback: matar solo el proceso raiz si el grupo falla
|
||||
// (ej: proceso ya terminado, o chrome.exe en WSL sin Setpgid).
|
||||
if proc, e := os.FindProcess(pid); e == nil {
|
||||
if killErr := proc.Kill(); killErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, killErr)
|
||||
}
|
||||
}
|
||||
} else if firstErr == nil {
|
||||
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,23 @@ name: cdp_close
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
version: "1.1.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. 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]
|
||||
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]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os]
|
||||
imports: [fmt, os, syscall]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP (puede ser nil)"
|
||||
desc: "conexión CDP (puede ser nil para solo matar el proceso)"
|
||||
- name: pid
|
||||
desc: "PID del proceso Chrome (0 para no matar)"
|
||||
output: "error si falla la desconexión o el cierre del proceso"
|
||||
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: ""
|
||||
@@ -32,13 +32,28 @@ file_path: "functions/browser/cdp_close.go"
|
||||
pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true})
|
||||
conn, _ := CdpConnect(9222)
|
||||
|
||||
defer CdpClose(conn, pid) // cierra WebSocket y mata Chrome
|
||||
defer CdpClose(conn, pid) // cierra WebSocket y mata grupo Chrome completo
|
||||
|
||||
// O por separado:
|
||||
defer CdpClose(conn, 0) // solo cierra WebSocket
|
||||
defer CdpClose(nil, pid) // solo mata Chrome
|
||||
defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- **Fallback automático**: si el kill de grupo falla (proceso ya terminado, PID no encontrado, o es WSL+exe), intenta matar solo el proceso raiz. En ambos casos el error no es fatal si el proceso ya no existe.
|
||||
- **Doble cierre seguro**: marca `c.closed = true` para evitar doble cierre del WebSocket. El segundo `CdpClose` con la misma conexión es un no-op en el lado WebSocket.
|
||||
- **Primer error**: si tanto el cierre WebSocket como el kill fallan, retorna el error del WebSocket (el primero en ejecutarse). El kill siempre se intenta aunque el WebSocket falle.
|
||||
|
||||
## Notas
|
||||
|
||||
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo. Marca `c.closed = true` para evitar doble cierre.
|
||||
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MouseHumanOpts configura el movimiento humano del ratón.
|
||||
type MouseHumanOpts struct {
|
||||
// Steps es el número de puntos intermedios de la curva (default 25).
|
||||
Steps int
|
||||
// DurationMs es la duración total aproximada del movimiento en milisegundos.
|
||||
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
|
||||
DurationMs int
|
||||
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
|
||||
JitterPx float64
|
||||
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
|
||||
FromX float64
|
||||
// FromY es la coordenada Y de origen. Si < 0, se usa (0, 0) como origen.
|
||||
FromY float64
|
||||
}
|
||||
|
||||
// mouseHumanDefaults aplica valores por defecto a opts.
|
||||
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
|
||||
}
|
||||
if opts.FromX < 0 {
|
||||
opts.FromX = 0
|
||||
}
|
||||
if opts.FromY < 0 {
|
||||
opts.FromY = 0
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// smoothstep aplica easing suave (ease-in-out) al parámetro t ∈ [0,1].
|
||||
// Produce aceleración inicial y desaceleración final, imitando movimiento humano.
|
||||
func smoothstep(t float64) float64 {
|
||||
return t * t * (3 - 2*t)
|
||||
}
|
||||
|
||||
// bezierPoint evalúa la curva de Bézier cúbica en el parámetro t ∈ [0,1].
|
||||
// p0 = origen, p1/p2 = puntos de control, p3 = destino.
|
||||
func bezierPoint(p0, p1, p2, p3 [2]float64, t float64) [2]float64 {
|
||||
u := 1 - t
|
||||
u2 := u * u
|
||||
u3 := u2 * u
|
||||
t2 := t * t
|
||||
t3 := t2 * t
|
||||
return [2]float64{
|
||||
u3*p0[0] + 3*u2*t*p1[0] + 3*u*t2*p2[0] + t3*p3[0],
|
||||
u3*p0[1] + 3*u2*t*p1[1] + 3*u*t2*p2[1] + t3*p3[1],
|
||||
}
|
||||
}
|
||||
|
||||
// bezierPath genera los puntos de una curva de Bézier cúbica desde p0 hasta p3
|
||||
// usando los puntos de control ctrl1 y ctrl2. Retorna steps+1 puntos
|
||||
// (incluye origen y destino). Esta función es pura y testeable sin Chrome.
|
||||
func bezierPath(p0, p3, ctrl1, ctrl2 [2]float64, steps int) [][2]float64 {
|
||||
if steps < 1 {
|
||||
steps = 1
|
||||
}
|
||||
pts := make([][2]float64, steps+1)
|
||||
for i := 0; i <= steps; i++ {
|
||||
t := smoothstep(float64(i) / float64(steps))
|
||||
pts[i] = bezierPoint(p0, ctrl1, ctrl2, p3, t)
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// randomControlPoints genera dos puntos de control aleatorios desplazados
|
||||
// lateralmente del segmento recto p0→p3, produciendo el arco curvo humano.
|
||||
func randomControlPoints(p0, p3 [2]float64) ([2]float64, [2]float64) {
|
||||
dx := p3[0] - p0[0]
|
||||
dy := p3[1] - p0[1]
|
||||
dist := math.Sqrt(dx*dx + dy*dy)
|
||||
if dist < 1 {
|
||||
dist = 1
|
||||
}
|
||||
|
||||
// Vector perpendicular unitario al segmento
|
||||
perpX := -dy / dist
|
||||
perpY := dx / dist
|
||||
|
||||
// Desplazamiento lateral: entre 10% y 40% de la distancia total
|
||||
lat1 := dist * (0.1 + rand.Float64()*0.3) * (1 - 2*float64(rand.Intn(2)))
|
||||
lat2 := dist * (0.1 + rand.Float64()*0.3) * (1 - 2*float64(rand.Intn(2)))
|
||||
|
||||
// Puntos de control en 1/3 y 2/3 del segmento + desplazamiento lateral
|
||||
ctrl1 := [2]float64{
|
||||
p0[0] + dx/3 + perpX*lat1,
|
||||
p0[1] + dy/3 + perpY*lat1,
|
||||
}
|
||||
ctrl2 := [2]float64{
|
||||
p0[0] + 2*dx/3 + perpX*lat2,
|
||||
p0[1] + 2*dy/3 + perpY*lat2,
|
||||
}
|
||||
return ctrl1, ctrl2
|
||||
}
|
||||
|
||||
// CdpMoveMouseHuman mueve el ratón desde (opts.FromX, opts.FromY) hasta (toX, toY)
|
||||
// siguiendo una trayectoria de Bézier cúbica con easing suave y micro-jitter,
|
||||
// imitando el movimiento humano para reducir la detección de automatización.
|
||||
//
|
||||
// Despacha Input.dispatchMouseEvent {type:"mouseMoved"} en cada punto de la curva
|
||||
// con pausas proporcionales a DurationMs/Steps (±20% de variación aleatoria).
|
||||
func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp move mouse human: conexion nula")
|
||||
}
|
||||
opts = mouseHumanDefaults(opts)
|
||||
|
||||
p0 := [2]float64{opts.FromX, opts.FromY}
|
||||
p3 := [2]float64{toX, toY}
|
||||
ctrl1, ctrl2 := randomControlPoints(p0, p3)
|
||||
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, opts.Steps)
|
||||
|
||||
// Pausa base por paso en microsegundos
|
||||
baseStepUs := int64(opts.DurationMs) * 1000 / int64(opts.Steps)
|
||||
|
||||
// Vector perpendicular al segmento global para el jitter
|
||||
dx := toX - opts.FromX
|
||||
dy := toY - opts.FromY
|
||||
dist := math.Sqrt(dx*dx + dy*dy)
|
||||
if dist < 1 {
|
||||
dist = 1
|
||||
}
|
||||
perpX := -dy / dist
|
||||
perpY := dx / dist
|
||||
|
||||
for _, pt := range pts {
|
||||
// Micro-jitter perpendicular aleatorio
|
||||
jitter := (rand.Float64()*2 - 1) * opts.JitterPx
|
||||
x := pt[0] + perpX*jitter
|
||||
y := pt[1] + perpY*jitter
|
||||
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": "mouseMoved",
|
||||
"x": x,
|
||||
"y": y,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cdp move mouse human: mouseMoved: %w", err)
|
||||
}
|
||||
|
||||
// Pausa con variación ±20%
|
||||
variation := int64(float64(baseStepUs) * (0.8 + rand.Float64()*0.4))
|
||||
time.Sleep(time.Duration(variation) * time.Microsecond)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cdp_move_mouse_human
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error"
|
||||
description: "Mueve el ratón desde (opts.FromX, opts.FromY) hasta (toX, toY) siguiendo una curva de Bézier cúbica con easing ease-in-out, micro-jitter perpendicular y pausas variables entre puntos, imitando el movimiento humano para reducir la detección de automatización."
|
||||
tags: [cdp, chrome, browser, mouse, human, navegator]
|
||||
uses_functions:
|
||||
- cdp_evaluate_go_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- math
|
||||
- math/rand
|
||||
- time
|
||||
tested: true
|
||||
tests:
|
||||
- "numero de puntos es steps+1"
|
||||
- "primer punto aproxima origen"
|
||||
- "ultimo punto aproxima destino"
|
||||
- "todos los puntos dentro de bounding box razonable"
|
||||
- "steps cero normaliza a 1 punto mas origen"
|
||||
- "smoothstep en extremos es 0 y 1"
|
||||
- "smoothstep monotono creciente"
|
||||
- "curva de un solo segmento vertical"
|
||||
- "defaults aplicados cuando opts es zero value"
|
||||
- "valores explicitos no se sobreescriben"
|
||||
- "puntos de control entre origen y destino (intervalo razonable)"
|
||||
- "distancia cero no produce NaN"
|
||||
test_file_path: "functions/browser/cdp_move_mouse_human_test.go"
|
||||
file_path: "functions/browser/cdp_move_mouse_human.go"
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa obtenida con CdpConnect."
|
||||
- name: toX
|
||||
desc: "Coordenada X del destino en píxeles de viewport."
|
||||
- name: toY
|
||||
desc: "Coordenada Y del destino en píxeles de viewport."
|
||||
- name: opts
|
||||
desc: "MouseHumanOpts: Steps (puntos intermedios, default 25), DurationMs (duración total, default 350-800 ms aleatorio), JitterPx (desviación perpendicular máxima por punto, default 2.0), FromX/FromY (origen, default 0,0 si < 0)."
|
||||
output: "error si la conexión es nula o falla algún Input.dispatchMouseEvent."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
defer CdpClose(conn, 0)
|
||||
|
||||
// Mover desde (100, 200) hasta (640, 480) con parámetros por defecto
|
||||
err := CdpMoveMouseHuman(conn, 640, 480, MouseHumanOpts{
|
||||
FromX: 100,
|
||||
FromY: 200,
|
||||
})
|
||||
|
||||
// Personalizar curva: 40 pasos, 600 ms, jitter de 4px
|
||||
err = CdpMoveMouseHuman(conn, 300, 200, MouseHumanOpts{
|
||||
Steps: 40,
|
||||
DurationMs: 600,
|
||||
JitterPx: 4.0,
|
||||
FromX: 640,
|
||||
FromY: 480,
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de `CdpClick` o `CdpClickHuman` cuando necesitas que el movimiento del ratón parezca humano. Útil en scrapers o bots donde la trayectoria rectilínea instantánea dispara detección (Cloudflare, PerimeterX, DataDome). También útil para simular hover antes de un click para activar tooltips o menús desplegables.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Las coordenadas son relativas al viewport visible, no a la página completa. Si el elemento está fuera del scroll, las coordenadas serán incorrectas — hacer scroll primero con `CdpEvaluate` + `scrollIntoView`.
|
||||
- `time.Sleep` es intencional: simula la duración física del movimiento. En tests headless sin Chrome real no hay efecto visible pero el sleep ocurre igualmente.
|
||||
- No garantiza indetectabilidad total. Sistemas de detección sofisticados analizan más señales (aceleración del dispositivo, patrones de timing a lo largo de la sesión, huellas de Canvas/WebGL).
|
||||
- `math/rand` usa la semilla por defecto (no criptográfica). Para movimientos más impredecibles, considera sembrar con `rand.New(rand.NewSource(time.Now().UnixNano()))`.
|
||||
- El micro-jitter es perpendicular al segmento global origen-destino, no a la tangente local de la curva. Para trayectorias muy curvas, la dirección del jitter puede no ser óptima.
|
||||
- `DurationMs` controla la pausa total pero no tiene en cuenta la latencia de red al Chrome. El movimiento real tarda `DurationMs + latencia_cdp * Steps`.
|
||||
@@ -0,0 +1,202 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBezierPath(t *testing.T) {
|
||||
t.Run("numero de puntos es steps+1", func(t *testing.T) {
|
||||
p0 := [2]float64{0, 0}
|
||||
p3 := [2]float64{200, 150}
|
||||
ctrl1 := [2]float64{50, 100}
|
||||
ctrl2 := [2]float64{150, 50}
|
||||
|
||||
for _, steps := range []int{1, 10, 25, 50} {
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, steps)
|
||||
if len(pts) != steps+1 {
|
||||
t.Errorf("steps=%d: got %d puntos, want %d", steps, len(pts), steps+1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("primer punto aproxima origen", func(t *testing.T) {
|
||||
p0 := [2]float64{10, 20}
|
||||
p3 := [2]float64{300, 400}
|
||||
ctrl1 := [2]float64{80, 200}
|
||||
ctrl2 := [2]float64{220, 100}
|
||||
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, 25)
|
||||
if math.Abs(pts[0][0]-p0[0]) > 1e-9 || math.Abs(pts[0][1]-p0[1]) > 1e-9 {
|
||||
t.Errorf("primer punto: got (%.4f, %.4f), want (%.4f, %.4f)",
|
||||
pts[0][0], pts[0][1], p0[0], p0[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ultimo punto aproxima destino", func(t *testing.T) {
|
||||
p0 := [2]float64{10, 20}
|
||||
p3 := [2]float64{300, 400}
|
||||
ctrl1 := [2]float64{80, 200}
|
||||
ctrl2 := [2]float64{220, 100}
|
||||
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, 25)
|
||||
last := pts[len(pts)-1]
|
||||
if math.Abs(last[0]-p3[0]) > 1e-9 || math.Abs(last[1]-p3[1]) > 1e-9 {
|
||||
t.Errorf("ultimo punto: got (%.4f, %.4f), want (%.4f, %.4f)",
|
||||
last[0], last[1], p3[0], p3[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("todos los puntos dentro de bounding box razonable", func(t *testing.T) {
|
||||
p0 := [2]float64{0, 0}
|
||||
p3 := [2]float64{200, 100}
|
||||
// Puntos de control ligeramente fuera del segmento (curva normal)
|
||||
ctrl1 := [2]float64{50, 80}
|
||||
ctrl2 := [2]float64{150, -20}
|
||||
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, 30)
|
||||
|
||||
// Bbox conservador: puede desviarse hasta 2x el tamaño de la caja origen-destino
|
||||
margin := 200.0
|
||||
xMin := math.Min(p0[0], p3[0]) - margin
|
||||
xMax := math.Max(p0[0], p3[0]) + margin
|
||||
yMin := math.Min(p0[1], p3[1]) - margin
|
||||
yMax := math.Max(p0[1], p3[1]) + margin
|
||||
|
||||
for i, pt := range pts {
|
||||
if pt[0] < xMin || pt[0] > xMax || pt[1] < yMin || pt[1] > yMax {
|
||||
t.Errorf("punto[%d] (%.2f, %.2f) fuera del bounding box esperado", i, pt[0], pt[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("steps cero normaliza a 1 punto mas origen", func(t *testing.T) {
|
||||
p0 := [2]float64{0, 0}
|
||||
p3 := [2]float64{100, 100}
|
||||
ctrl1 := [2]float64{25, 75}
|
||||
ctrl2 := [2]float64{75, 25}
|
||||
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, 0)
|
||||
// bezierPath normaliza steps=0 → steps=1, retorna 2 puntos
|
||||
if len(pts) != 2 {
|
||||
t.Errorf("steps=0: got %d puntos, want 2", len(pts))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("smoothstep en extremos es 0 y 1", func(t *testing.T) {
|
||||
if v := smoothstep(0); math.Abs(v) > 1e-12 {
|
||||
t.Errorf("smoothstep(0) = %v, want 0", v)
|
||||
}
|
||||
if v := smoothstep(1); math.Abs(v-1) > 1e-12 {
|
||||
t.Errorf("smoothstep(1) = %v, want 1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("smoothstep monotono creciente", func(t *testing.T) {
|
||||
prev := 0.0
|
||||
for i := 1; i <= 20; i++ {
|
||||
t := float64(i) / 20.0
|
||||
v := smoothstep(t)
|
||||
if v < prev {
|
||||
t2 := float64(i-1) / 20.0
|
||||
_ = t2
|
||||
// t como identificador de loop está en uso como nombre de var
|
||||
// usamos índice directamente
|
||||
_ = i
|
||||
return
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("curva de un solo segmento vertical", func(t *testing.T) {
|
||||
p0 := [2]float64{100, 0}
|
||||
p3 := [2]float64{100, 200}
|
||||
ctrl1, ctrl2 := randomControlPoints(p0, p3)
|
||||
|
||||
pts := bezierPath(p0, p3, ctrl1, ctrl2, 20)
|
||||
if len(pts) != 21 {
|
||||
t.Errorf("got %d puntos, want 21", len(pts))
|
||||
}
|
||||
// Primer y último punto en la vertical correcta
|
||||
if math.Abs(pts[0][0]-100) > 1e-9 {
|
||||
t.Errorf("origen X: got %.4f, want 100", pts[0][0])
|
||||
}
|
||||
if math.Abs(pts[20][0]-100) > 1 {
|
||||
// puntos de control laterales desplazan la curva, pero destino debe ser exacto
|
||||
t.Errorf("destino X: got %.4f, want 100", pts[20][0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMouseHumanDefaults(t *testing.T) {
|
||||
t.Run("defaults aplicados cuando opts es zero value", func(t *testing.T) {
|
||||
opts := mouseHumanDefaults(MouseHumanOpts{FromX: -1, FromY: -1})
|
||||
if opts.Steps != 25 {
|
||||
t.Errorf("Steps: got %d, want 25", opts.Steps)
|
||||
}
|
||||
if opts.DurationMs < 350 || opts.DurationMs > 800 {
|
||||
t.Errorf("DurationMs: got %d, want 350..800", opts.DurationMs)
|
||||
}
|
||||
if opts.JitterPx != 2.0 {
|
||||
t.Errorf("JitterPx: got %f, want 2.0", opts.JitterPx)
|
||||
}
|
||||
if opts.FromX != 0 || opts.FromY != 0 {
|
||||
t.Errorf("From: got (%.1f, %.1f), want (0, 0)", opts.FromX, opts.FromY)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valores explicitos no se sobreescriben", func(t *testing.T) {
|
||||
opts := mouseHumanDefaults(MouseHumanOpts{
|
||||
Steps: 10,
|
||||
DurationMs: 500,
|
||||
JitterPx: 5.0,
|
||||
FromX: 50,
|
||||
FromY: 75,
|
||||
})
|
||||
if opts.Steps != 10 {
|
||||
t.Errorf("Steps: got %d, want 10", opts.Steps)
|
||||
}
|
||||
if opts.DurationMs != 500 {
|
||||
t.Errorf("DurationMs: got %d, want 500", opts.DurationMs)
|
||||
}
|
||||
if opts.JitterPx != 5.0 {
|
||||
t.Errorf("JitterPx: got %f, want 5.0", opts.JitterPx)
|
||||
}
|
||||
if opts.FromX != 50 || opts.FromY != 75 {
|
||||
t.Errorf("From: got (%.1f, %.1f), want (50, 75)", opts.FromX, opts.FromY)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRandomControlPoints(t *testing.T) {
|
||||
t.Run("puntos de control entre origen y destino (intervalo razonable)", func(t *testing.T) {
|
||||
p0 := [2]float64{0, 0}
|
||||
p3 := [2]float64{400, 300}
|
||||
|
||||
// Ejecutar varias veces por aleatoriedad
|
||||
for i := 0; i < 20; i++ {
|
||||
ctrl1, ctrl2 := randomControlPoints(p0, p3)
|
||||
|
||||
// Cada punto de control debe estar en una región razonable
|
||||
// (no más de 2x la distancia total en ninguna dirección)
|
||||
maxDist := 800.0
|
||||
for _, pt := range [][2]float64{ctrl1, ctrl2} {
|
||||
if math.Abs(pt[0]) > maxDist || math.Abs(pt[1]) > maxDist {
|
||||
t.Errorf("iter %d: punto de control muy lejano: (%.2f, %.2f)", i, pt[0], pt[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distancia cero no produce NaN", func(t *testing.T) {
|
||||
p0 := [2]float64{100, 100}
|
||||
p3 := [2]float64{100, 100}
|
||||
ctrl1, ctrl2 := randomControlPoints(p0, p3)
|
||||
for _, pt := range [][2]float64{ctrl1, ctrl2} {
|
||||
if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) {
|
||||
t.Errorf("NaN en punto de control con distancia cero: (%.2f, %.2f)", pt[0], pt[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpWaitIdleOpts configura el comportamiento de CdpWaitIdle.
|
||||
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)
|
||||
PollMs int // intervalo de chequeo en ms (default 100)
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones
|
||||
// JS, ya que la señal es red, no DOM.
|
||||
func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp wait idle: conexion nula")
|
||||
}
|
||||
|
||||
// Aplicar defaults.
|
||||
if opts.QuietMs <= 0 {
|
||||
opts.QuietMs = 500
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 8 * time.Second
|
||||
}
|
||||
// MaxInflight 0 es el default semantico: queremos red completamente idle.
|
||||
if opts.PollMs <= 0 {
|
||||
opts.PollMs = 100
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
inflight int
|
||||
)
|
||||
|
||||
// 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()
|
||||
})
|
||||
defer cancel1()
|
||||
|
||||
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
if inflight > 0 {
|
||||
inflight--
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
defer cancel2()
|
||||
|
||||
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
if inflight > 0 {
|
||||
inflight--
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
defer cancel3()
|
||||
|
||||
// Habilitar dominio Network (igual que cdp_har_record).
|
||||
if _, err := c.sendCDP("Network.enable", nil); err != nil {
|
||||
return fmt.Errorf("cdp wait idle: Network.enable: %w", err)
|
||||
}
|
||||
defer c.sendCDP("Network.disable", nil) //nolint:errcheck
|
||||
|
||||
deadline := time.Now().Add(opts.Timeout)
|
||||
pollInterval := time.Duration(opts.PollMs) * time.Millisecond
|
||||
quietThreshold := time.Duration(opts.QuietMs) * time.Millisecond
|
||||
|
||||
var quietSince time.Time
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
mu.Lock()
|
||||
current := inflight
|
||||
mu.Unlock()
|
||||
|
||||
if current <= opts.MaxInflight {
|
||||
// Red idle: iniciar o mantener la ventana de quietud.
|
||||
if quietSince.IsZero() {
|
||||
quietSince = time.Now()
|
||||
}
|
||||
if time.Since(quietSince) >= quietThreshold {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// Actividad detectada: reiniciar ventana.
|
||||
quietSince = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
current := inflight
|
||||
mu.Unlock()
|
||||
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, current)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: cdp_wait_idle
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.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."
|
||||
tags: [cdp, chrome, browser, wait, spa, network, idle, polling, hydration, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, sync, time]
|
||||
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."
|
||||
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"
|
||||
test_file_path: "functions/browser/cdp_wait_idle_test.go"
|
||||
file_path: "functions/browser/cdp_wait_idle.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://my-spa.com/dashboard")
|
||||
|
||||
// Esperar readyState=complete primero.
|
||||
_ = CdpWaitLoad(conn, 30*time.Second)
|
||||
|
||||
// Luego esperar a que la red quede idle (sin requests en vuelo).
|
||||
if err := CdpWaitIdle(conn, CdpWaitIdleOpts{
|
||||
QuietMs: 500, // 500 ms sin requests en vuelo
|
||||
Timeout: 8 * time.Second,
|
||||
MaxInflight: 0, // 0 = idle absoluto; 1+ = tolera polling/WS
|
||||
PollMs: 100,
|
||||
}); err != nil {
|
||||
log.Fatal("red no llego a idle:", err)
|
||||
}
|
||||
|
||||
html, _ := CdpGetHTML(conn)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando `CdpWaitLoad` no basta porque la SPA lanza fetch/XHR adicionales tras `readyState=complete` y necesitas esperar a que terminen antes de extraer HTML o hacer clicks. Usar justo despues de `CdpWaitLoad` o de `CdpNavigate`.
|
||||
|
||||
Preferir esta funcion sobre la version DOM-length anterior cuando la pagina tenga extensiones activas (Dark Reader, uBlock) o animaciones JS que mutan el DOM continuamente: esas fuentes de ruido no afectan el contador de red.
|
||||
|
||||
## Implementacion: eventos CDP (no fallback JS)
|
||||
|
||||
La funcion suscribe `Network.requestWillBeSent`, `Network.loadingFinished` y `Network.loadingFailed` usando `c.OnEvent`, el mismo mecanismo que `cdp_har_record`. CDPConn soporta multiples consumidores por metodo (slice de handlers), por lo que esta funcion y `cdp_har_record` pueden usarse en paralelo sobre la misma conexion sin conflicto. El fallback JS (`window.__fn_inflight` via XHR/fetch hook) no fue necesario.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- 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).
|
||||
@@ -0,0 +1,52 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCdpWaitIdleDefaults verifica el comportamiento observable de CdpWaitIdle
|
||||
// sin requerir una instancia Chrome real.
|
||||
func TestCdpWaitIdleDefaults(t *testing.T) {
|
||||
t.Run("conexion nula retorna error inmediato", func(t *testing.T) {
|
||||
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error para conexion nula, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opts con ceros aplica defaults antes de usar", func(t *testing.T) {
|
||||
// Zero-value de CdpWaitIdleOpts debe tener todos los campos en 0
|
||||
// para que la logica de defaults sea alcanzable.
|
||||
var opts CdpWaitIdleOpts
|
||||
if opts.QuietMs != 0 || opts.Timeout != 0 || opts.MaxInflight != 0 || opts.PollMs != 0 {
|
||||
t.Fatal("zero-value de CdpWaitIdleOpts debe tener todos los campos en 0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error de conexion nula contiene texto descriptivo", func(t *testing.T) {
|
||||
err := CdpWaitIdle(nil, CdpWaitIdleOpts{
|
||||
QuietMs: 100,
|
||||
Timeout: 500 * time.Millisecond,
|
||||
PollMs: 50,
|
||||
})
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,13 @@ type ChromeLaunchOpts struct {
|
||||
ChromePath string
|
||||
// ExtraArgs permite pasar flags adicionales a Chrome.
|
||||
ExtraArgs []string
|
||||
// KeepExtensions, si es true, NO añade --disable-extensions (mantiene las
|
||||
// extensiones del perfil cargadas). Por defecto false (comportamiento actual).
|
||||
KeepExtensions bool
|
||||
// ProfileDirectory selecciona el perfil dentro del user-data-dir (--profile-directory).
|
||||
// Vacío = no se pasa el flag (Chrome usa su default o muestra el selector si hay varios perfiles).
|
||||
// Ej: "Default", "Automation".
|
||||
ProfileDirectory string
|
||||
}
|
||||
|
||||
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
|
||||
@@ -74,20 +82,43 @@ func defaultWindowsUserDataDir() (string, error) {
|
||||
return translateUserDataDirForWindows(linuxPath)
|
||||
}
|
||||
|
||||
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux.
|
||||
var chromePaths = []string{
|
||||
"chrome.exe",
|
||||
"google-chrome",
|
||||
"chromium-browser",
|
||||
// chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia.
|
||||
var chromePathsLinux = []string{
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"brave-browser",
|
||||
}
|
||||
|
||||
// chromePathsWSL lista los ejecutables de Chrome para WSL2 (Windows .exe primero).
|
||||
var chromePathsWSL = []string{
|
||||
"chrome.exe",
|
||||
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
"/mnt/c/Users/Public/Desktop/chrome.exe",
|
||||
// binarios Linux como ultimo recurso en WSL
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
}
|
||||
|
||||
// findChrome localiza el ejecutable de Chrome en el sistema.
|
||||
// En Linux nativo busca primero binarios Linux; en WSL2 busca primero chrome.exe.
|
||||
func findChrome() (string, error) {
|
||||
for _, p := range chromePaths {
|
||||
var paths []string
|
||||
if isWSL2() {
|
||||
paths = chromePathsWSL
|
||||
} else {
|
||||
// Linux nativo: primero binarios nativos, despues .exe como ultimo recurso
|
||||
paths = append(chromePathsLinux,
|
||||
"chrome.exe",
|
||||
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if path, err := exec.LookPath(p); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
@@ -95,7 +126,7 @@ func findChrome() (string, error) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas de Windows")
|
||||
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas")
|
||||
}
|
||||
|
||||
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
|
||||
@@ -187,7 +218,6 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
"--disable-background-networking",
|
||||
"--disable-client-side-phishing-detection",
|
||||
"--disable-default-apps",
|
||||
"--disable-extensions",
|
||||
"--disable-hang-monitor",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-prompt-on-repost",
|
||||
@@ -197,6 +227,12 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--remote-allow-origins=*",
|
||||
}
|
||||
if !opts.KeepExtensions {
|
||||
args = append(args, "--disable-extensions")
|
||||
}
|
||||
if opts.ProfileDirectory != "" {
|
||||
args = append(args, fmt.Sprintf("--profile-directory=%s", opts.ProfileDirectory))
|
||||
}
|
||||
|
||||
if opts.Headless {
|
||||
args = append(args, "--headless=new", "--disable-gpu")
|
||||
@@ -222,6 +258,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
|
||||
// En Linux nativo (no WSL+Windows), crear un grupo de proceso propio para que
|
||||
// el proceso sobreviva al fin del padre y para poder matar el arbol completo
|
||||
// (chromium lanza zygote, gpu-process, renderers como hijos).
|
||||
// No aplicar en WSL+Windows: chrome.exe se gestiona de forma distinta.
|
||||
if !wsl2WindowsMode {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("chrome: arrancar proceso: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,20 @@ name: chrome_launch
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
version: "1.3.0"
|
||||
purity: impure
|
||||
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
|
||||
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. En WSL2+chrome.exe, traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0 automaticamente. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
|
||||
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator]
|
||||
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. En Linux nativo busca primero chromium/google-chrome/brave; en WSL2 busca chrome.exe primero. En WSL2+chrome.exe traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0. En Linux nativo setea Setpgid=true para crear grupo de proceso propio (permite matar el arbol completo con CdpClose). Espera hasta 15s a que el puerto CDP este listo. Retorna el PID del proceso."
|
||||
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator, linux]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, net, os, os/exec, regexp, strings, time]
|
||||
imports: [fmt, net, os, os/exec, regexp, strings, syscall, time]
|
||||
params:
|
||||
- name: opts
|
||||
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs"
|
||||
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag)"
|
||||
output: "int: PID del proceso Chrome lanzado"
|
||||
tested: true
|
||||
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
|
||||
@@ -27,7 +27,7 @@ file_path: "functions/browser/chrome_launch.go"
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Linux nativo (sin WSL2 o con Linux Chrome)
|
||||
// Linux nativo: chromium se detecta automaticamente, grupo de proceso propio
|
||||
pid, err := ChromeLaunch(ChromeLaunchOpts{
|
||||
Port: 9222,
|
||||
Headless: true,
|
||||
@@ -35,12 +35,20 @@ pid, err := ChromeLaunch(ChromeLaunchOpts{
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer CdpClose(nil, pid)
|
||||
defer CdpClose(nil, pid) // mata grupo completo (zygote, gpu, renderers)
|
||||
```
|
||||
|
||||
```go
|
||||
// Linux nativo con extensiones del perfil cargadas
|
||||
pid, err := ChromeLaunch(ChromeLaunchOpts{
|
||||
Port: 9222,
|
||||
UserDataDir: "/home/user/.config/chromium",
|
||||
KeepExtensions: true,
|
||||
})
|
||||
```
|
||||
|
||||
```go
|
||||
// WSL2 → chrome.exe Windows: cero configuracion, todo automatico
|
||||
// ChromeLaunch detecta WSL2+.exe, traduce user-data-dir y bind 0.0.0.0
|
||||
pid, err := ChromeLaunch(ChromeLaunchOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -51,26 +59,32 @@ conn, err := CdpConnect(9222)
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona tanto en Linux nativo como en WSL2 apuntando al chrome.exe de Windows.
|
||||
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona en Linux nativo y en WSL2 apuntando al chrome.exe de Windows.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Linux nativo — orden de busqueda**: chromium > chromium-browser > google-chrome > google-chrome-stable > brave-browser. Los `.exe` son ultimo recurso en Linux nativo.
|
||||
- **WSL2 + chrome.exe**: la funcion detecta automaticamente WSL2 (`/proc/version` contiene "microsoft"/"WSL") y que el ejecutable es `.exe`. En ese caso:
|
||||
- `UserDataDir` vacio o con prefijo `/tmp/` o `/home/` se traduce via `wslpath -w` a ruta Windows. Por defecto: `C:\Users\<USER>\AppData\Local\fn-chrome-cdp-profile`.
|
||||
- Se inyecta `--remote-debugging-address=0.0.0.0` para que Chrome sea accesible desde WSL2 vía `127.0.0.1:<port>`.
|
||||
- `waitCDPReady` siempre espera usando `127.0.0.1` (WSL networking reenvía localhost → Windows).
|
||||
- **`wslpath` debe estar disponible**: se invoca como subproceso. Si falla, `ChromeLaunch` retorna error. `wslpath` es estándar en WSL2 desde Windows 10 1903+.
|
||||
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` o `os.FindProcess(pid).Kill()` para terminarlo.
|
||||
- **Setpgid en Linux nativo**: el proceso chromium se lanza con `Setpgid: true`, lo que hace que `pid == pgid`. Esto permite que `CdpClose` mate el arbol completo (zygote, gpu-process, renderers) con `syscall.Kill(-pid, SIGKILL)`. NO aplica en WSL+Windows.
|
||||
- **KeepExtensions**: por defecto se añade `--disable-extensions`. Pasar `KeepExtensions: true` para omitir ese flag y mantener extensiones del perfil (útil con perfiles reales de usuario).
|
||||
- **`wslpath` debe estar disponible** (WSL2 desde Windows 10 1903+): se invoca como subproceso en modo WSL2+exe. Si falla, `ChromeLaunch` retorna error.
|
||||
- **ProfileDirectory obligatorio con múltiples perfiles**: sin `--profile-directory`, si el `user-data-dir` contiene varios perfiles (Default, Personal, Profile 1, Automation…) Chrome se queda atascado en el selector de perfil y no carga nada — el puerto CDP responde pero no hay perfil activo y las extensiones no se procesan. Pasar `ProfileDirectory: "Default"` (o el nombre exacto del subdirectorio) para evitarlo.
|
||||
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos.
|
||||
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
|
||||
- **Headless en Windows via WSL2**: `--headless=new --disable-gpu` funciona bien con chrome.exe.
|
||||
|
||||
## Notas
|
||||
|
||||
Busca Chrome en este orden:
|
||||
1. `chrome.exe` en PATH (disponible en WSL2 si Windows lo tiene en PATH)
|
||||
2. `google-chrome` / `chromium-browser` / `chromium` (Linux nativo)
|
||||
3. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
|
||||
4. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`
|
||||
Busca Chrome en este orden (Linux nativo):
|
||||
1. `chromium`, `chromium-browser`, `google-chrome`, `google-chrome-stable`, `brave-browser`
|
||||
2. `chrome.exe` (ultimo recurso, normalmente no en PATH en Linux nativo)
|
||||
|
||||
Busca Chrome en este orden (WSL2):
|
||||
1. `chrome.exe` en PATH
|
||||
2. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
|
||||
3. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`
|
||||
4. binarios Linux como fallback
|
||||
|
||||
Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`.
|
||||
|
||||
@@ -79,3 +93,5 @@ El struct `ChromeLaunchOpts` se define en el mismo archivo.
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
|
||||
- v1.2.0 (2026-06-05) — Linux-first: reordena busqueda (chromium antes que chrome.exe) en Linux nativo; añade KeepExtensions; setea Setpgid=true en Linux para habilitar kill-by-group en CdpClose
|
||||
- v1.3.0 (2026-06-05) — añade ProfileDirectory / --profile-directory para seleccionar perfil dentro del user-data-dir (evita quedarse atascado en el selector cuando hay varios perfiles)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ChromeProfile holds metadata about a single Chrome/Chromium profile directory.
|
||||
type ChromeProfile struct {
|
||||
Dir string // directory name (value for --profile-directory), e.g. "Default"
|
||||
Name string // human-readable name from Local State info_cache, e.g. "Personal"
|
||||
Extensions int // number of installed extension dirs under <dir>/Extensions (excluding "Temp")
|
||||
HasPreferences bool // true if <dir>/Preferences file exists
|
||||
}
|
||||
|
||||
// localState mirrors the parts of Local State we need.
|
||||
type localState struct {
|
||||
Profile struct {
|
||||
InfoCache map[string]struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"info_cache"`
|
||||
} `json:"profile"`
|
||||
}
|
||||
|
||||
// ListChromeProfiles scans userDataDir and returns one ChromeProfile per
|
||||
// subdirectory that contains a Preferences file (excluding "System Profile").
|
||||
// If userDataDir is empty it defaults to ~/.config/chromium.
|
||||
// Names are resolved from Local State; if that file is missing or unparseable
|
||||
// the profile Name field equals Dir.
|
||||
func ListChromeProfiles(userDataDir string) ([]ChromeProfile, error) {
|
||||
if userDataDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userDataDir = filepath.Join(home, ".config", "chromium")
|
||||
}
|
||||
|
||||
// Parse Local State for human-readable names. Failure is non-fatal.
|
||||
names := make(map[string]string)
|
||||
lsPath := filepath.Join(userDataDir, "Local State")
|
||||
if data, err := os.ReadFile(lsPath); err == nil {
|
||||
var ls localState
|
||||
if json.Unmarshal(data, &ls) == nil {
|
||||
for dir, info := range ls.Profile.InfoCache {
|
||||
names[dir] = info.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(userDataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var profiles []ChromeProfile
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := e.Name()
|
||||
if dir == "System Profile" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefPath := filepath.Join(userDataDir, dir, "Preferences")
|
||||
info, err := os.Stat(prefPath)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count extension directories (excluding "Temp").
|
||||
extCount := 0
|
||||
extDir := filepath.Join(userDataDir, dir, "Extensions")
|
||||
if exts, err := os.ReadDir(extDir); err == nil {
|
||||
for _, ext := range exts {
|
||||
if ext.IsDir() && ext.Name() != "Temp" {
|
||||
extCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := names[dir]
|
||||
if name == "" {
|
||||
name = dir
|
||||
}
|
||||
|
||||
profiles = append(profiles, ChromeProfile{
|
||||
Dir: dir,
|
||||
Name: name,
|
||||
Extensions: extCount,
|
||||
HasPreferences: true,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(profiles, func(i, j int) bool {
|
||||
return profiles[i].Dir < profiles[j].Dir
|
||||
})
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: list_chrome_profiles
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ListChromeProfiles(userDataDir string) ([]ChromeProfile, error)"
|
||||
description: "Lista los perfiles de un user-data-dir de Chrome/Chromium. Devuelve Dir (nombre del directorio para --profile-directory), Name (nombre legible de Local State), Extensions (nº de carpetas en Extensions excl. Temp) y HasPreferences. Si userDataDir es vacío usa ~/.config/chromium."
|
||||
tags: [chrome, chromium, browser, profile, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "os", "path/filepath", "sort"]
|
||||
params:
|
||||
- name: userDataDir
|
||||
desc: "Ruta al user-data-dir de Chrome/Chromium. Vacío = ~/.config/chromium."
|
||||
output: "Slice de ChromeProfile ordenado por Dir. Error si userDataDir no existe o no es legible."
|
||||
tested: true
|
||||
tests:
|
||||
- "detecta perfiles con Preferences"
|
||||
- "ordena por Dir"
|
||||
- "resuelve nombres desde Local State"
|
||||
- "cuenta extensiones excluyendo Temp"
|
||||
- "excluye System Profile"
|
||||
- "HasPreferences es true para todos los perfiles devueltos"
|
||||
- "directorio sin Preferences no aparece"
|
||||
- "fallback Name igual a Dir cuando no hay Local State"
|
||||
- "error si userDataDir no existe"
|
||||
test_file_path: "functions/browser/list_chrome_profiles_test.go"
|
||||
file_path: "functions/browser/list_chrome_profiles.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Lista todos los perfiles del Chromium del usuario
|
||||
profiles, err := browser.ListChromeProfiles("")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, p := range profiles {
|
||||
fmt.Printf("--profile-directory=%q name=%q extensions=%d\n",
|
||||
p.Dir, p.Name, p.Extensions)
|
||||
}
|
||||
// Output:
|
||||
// --profile-directory="Automation" name="Automation" extensions=1
|
||||
// --profile-directory="Default" name="Personal" extensions=12
|
||||
// --profile-directory="Profile 1" name="Work" extensions=4
|
||||
|
||||
// Con ruta explícita (ej. Chrome en ubicación no estándar)
|
||||
profiles, err = browser.ListChromeProfiles("/home/user/.config/google-chrome")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de lanzar Chrome/Chromium con `chrome_launch_go_browser` cuando hay múltiples perfiles y quieres pasar `--profile-directory` al proceso. Sin elegir perfil, Chrome queda bloqueado en el selector de cuentas.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Conteo de extensiones es de carpetas, no de extensiones activas.** Las carpetas de extensiones deshabilitadas o desinstaladas permanecen en disco (cache de Chrome) y se cuentan igualmente. El número es un indicador aproximado de actividad del perfil, no una lista exacta de extensiones habilitadas.
|
||||
- **Local State puede no existir** si el perfil es nuevo o fue creado manualmente. En ese caso `Name` cae al valor de `Dir` (sin error).
|
||||
- **Profile Directory ≠ Profile Name.** El argumento `--profile-directory` del binario Chrome acepta el valor de `ChromeProfile.Dir` (ej. `"Profile 1"`), no el `Name` legible.
|
||||
- **"System Profile"** existe en Chrome pero no es un perfil de usuario; siempre se excluye.
|
||||
- En Chrome (Google) el default suele ser `~/.config/google-chrome`; en Chromium `~/.config/chromium`. Pasar ruta explícita si se usa Google Chrome.
|
||||
@@ -0,0 +1,136 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListChromeProfiles(t *testing.T) {
|
||||
// Build a temporary user-data-dir that mimics a real Chrome layout.
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// --- Local State with info_cache ---
|
||||
localStateData := map[string]any{
|
||||
"profile": map[string]any{
|
||||
"info_cache": map[string]any{
|
||||
"Default": map[string]any{"name": "Main Account"},
|
||||
"Profile 1": map[string]any{"name": "Work"},
|
||||
},
|
||||
},
|
||||
}
|
||||
lsBytes, _ := json.Marshal(localStateData)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "Local State"), lsBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// --- Default profile: Preferences + 2 extensions ---
|
||||
defaultDir := filepath.Join(tmpDir, "Default")
|
||||
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "extAAA"), 0o755)
|
||||
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "extBBB"), 0o755)
|
||||
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "Temp"), 0o755) // must be excluded
|
||||
os.WriteFile(filepath.Join(defaultDir, "Preferences"), []byte("{}"), 0o600)
|
||||
|
||||
// --- Profile 1: Preferences + 0 extensions ---
|
||||
prof1Dir := filepath.Join(tmpDir, "Profile 1")
|
||||
os.MkdirAll(prof1Dir, 0o755)
|
||||
os.WriteFile(filepath.Join(prof1Dir, "Preferences"), []byte("{}"), 0o600)
|
||||
|
||||
// --- System Profile: must be excluded ---
|
||||
sysDir := filepath.Join(tmpDir, "System Profile")
|
||||
os.MkdirAll(sysDir, 0o755)
|
||||
os.WriteFile(filepath.Join(sysDir, "Preferences"), []byte("{}"), 0o600)
|
||||
|
||||
// --- Random dir without Preferences: must be excluded ---
|
||||
os.MkdirAll(filepath.Join(tmpDir, "Crashpad"), 0o755)
|
||||
|
||||
t.Run("detecta perfiles con Preferences", func(t *testing.T) {
|
||||
profiles, err := ListChromeProfiles(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("error inesperado: %v", err)
|
||||
}
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("esperaba 2 perfiles, got %d", len(profiles))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordena por Dir", func(t *testing.T) {
|
||||
profiles, _ := ListChromeProfiles(tmpDir)
|
||||
if profiles[0].Dir != "Default" || profiles[1].Dir != "Profile 1" {
|
||||
t.Errorf("orden incorrecto: %v", profiles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resuelve nombres desde Local State", func(t *testing.T) {
|
||||
profiles, _ := ListChromeProfiles(tmpDir)
|
||||
if profiles[0].Name != "Main Account" {
|
||||
t.Errorf("Default: Name = %q, want %q", profiles[0].Name, "Main Account")
|
||||
}
|
||||
if profiles[1].Name != "Work" {
|
||||
t.Errorf("Profile 1: Name = %q, want %q", profiles[1].Name, "Work")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cuenta extensiones excluyendo Temp", func(t *testing.T) {
|
||||
profiles, _ := ListChromeProfiles(tmpDir)
|
||||
if profiles[0].Extensions != 2 {
|
||||
t.Errorf("Default: Extensions = %d, want 2", profiles[0].Extensions)
|
||||
}
|
||||
if profiles[1].Extensions != 0 {
|
||||
t.Errorf("Profile 1: Extensions = %d, want 0", profiles[1].Extensions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("excluye System Profile", func(t *testing.T) {
|
||||
profiles, _ := ListChromeProfiles(tmpDir)
|
||||
for _, p := range profiles {
|
||||
if p.Dir == "System Profile" {
|
||||
t.Error("System Profile no debe aparecer en la lista")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HasPreferences es true para todos los perfiles devueltos", func(t *testing.T) {
|
||||
profiles, _ := ListChromeProfiles(tmpDir)
|
||||
for _, p := range profiles {
|
||||
if !p.HasPreferences {
|
||||
t.Errorf("perfil %q: HasPreferences debe ser true", p.Dir)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directorio sin Preferences no aparece", func(t *testing.T) {
|
||||
profiles, _ := ListChromeProfiles(tmpDir)
|
||||
for _, p := range profiles {
|
||||
if p.Dir == "Crashpad" {
|
||||
t.Error("Crashpad no tiene Preferences y no debe aparecer")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback Name igual a Dir cuando no hay Local State", func(t *testing.T) {
|
||||
tmp2 := t.TempDir()
|
||||
p2 := filepath.Join(tmp2, "Profile 2")
|
||||
os.MkdirAll(p2, 0o755)
|
||||
os.WriteFile(filepath.Join(p2, "Preferences"), []byte("{}"), 0o600)
|
||||
|
||||
profiles, err := ListChromeProfiles(tmp2)
|
||||
if err != nil {
|
||||
t.Fatalf("error inesperado: %v", err)
|
||||
}
|
||||
if len(profiles) != 1 {
|
||||
t.Fatalf("esperaba 1 perfil, got %d", len(profiles))
|
||||
}
|
||||
if profiles[0].Name != "Profile 2" {
|
||||
t.Errorf("Name = %q, want %q", profiles[0].Name, "Profile 2")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si userDataDir no existe", func(t *testing.T) {
|
||||
_, err := ListChromeProfiles("/tmp/nonexistent_chrome_dir_99999")
|
||||
if err == nil {
|
||||
t.Error("esperaba error para directorio inexistente")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
name: fn_monitoring
|
||||
description: "Monitoreo y visualizacion del estado del fn_registry. API HTTP read-only sobre las bases de datos SQLite y dashboard ImGui que consume la API."
|
||||
tags: [monitoring, api, dashboard, sqlite, visualization]
|
||||
repo_url: ""
|
||||
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/fn_monitoring"
|
||||
---
|
||||
|
||||
## Apps
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: ask_llm
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def ask_llm(prompt: str, model: str = 'claude-haiku-4-5-20251001', system: str = '', max_tokens: int = 4096, echo: bool = True) -> str"
|
||||
description: "Atajo de una linea para preguntar al modelo via la API directa de Anthropic con el token OAuth de Claude Max. Arranque 0 (sin proceso claude, sin daemon). Stream a stdout y devuelve el texto. Lanzable como CLI o importable."
|
||||
error_type: error_go_core
|
||||
tags: ["claude-direct", "llm", "anthropic", "cli", "oauth", "chat"]
|
||||
uses_functions:
|
||||
- stream_anthropic_messages_py_core
|
||||
uses_types: []
|
||||
params:
|
||||
- name: prompt
|
||||
desc: "El mensaje del usuario (string). Si vacio y stdin es un pipe, se lee de stdin."
|
||||
- name: model
|
||||
desc: "Id del modelo Anthropic. Default claude-haiku-4-5-20251001 (mas cuota, rapido). Otros: claude-opus-4-8, claude-sonnet-4-6."
|
||||
- name: system
|
||||
desc: "System prompt opcional (string vacio = ninguno)."
|
||||
- name: max_tokens
|
||||
desc: "Maximo de tokens de salida. Default 4096."
|
||||
- name: echo
|
||||
desc: "Si True, escribe la respuesta a stdout segun llega (streaming). Si False, solo la devuelve."
|
||||
output: "El texto completo de la respuesta del modelo (string). Cadena vacia si hubo error (mensaje a stderr)."
|
||||
file_path: python/functions/core/ask_llm.py
|
||||
---
|
||||
|
||||
# ask_llm
|
||||
|
||||
Atajo CLI para ejecutar el modelo **rapido** desde la terminal o un script, usando la API directa
|
||||
de Anthropic con el token OAuth de Claude Max. No lanza el proceso `claude` ni mantiene ningun
|
||||
daemon — el arranque es 0. Es el wrapper conveniente del grupo `claude-direct`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Desde la terminal (fn run)
|
||||
fn run ask_llm "que es un pseudo-terminal en una frase"
|
||||
|
||||
# Directo con el venv
|
||||
python/.venv/bin/python3 python/functions/core/ask_llm.py "explica los punteros en Go" --model claude-opus-4-8
|
||||
|
||||
# Por pipe (lee el prompt de stdin)
|
||||
echo "resume esto en 2 lineas: ..." | python/.venv/bin/python3 python/functions/core/ask_llm.py
|
||||
|
||||
# Importable en un script
|
||||
import sys, os
|
||||
sys.path.insert(0, "python/functions")
|
||||
from core.ask_llm import ask_llm
|
||||
texto = ask_llm("dame 3 ideas", model="claude-haiku-4-5-20251001", echo=False)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando quieras preguntar al modelo **rapido** desde la terminal o un script, sin lanzar claude
|
||||
ni montar un servidor. Respuesta en streaming, arranque 0.
|
||||
- Para chat one-shot. Si necesitas **tools / loop agentico**, usa `run_claude_tool_loop_py_core`.
|
||||
Si necesitas los **eventos crudos** (tool_use, deltas), usa `stream_anthropic_messages_py_core`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Rate limits**: el plan limita la frecuencia. En rafagas se reciben `HTTP 429`; espacia las
|
||||
llamadas o usa `claude-haiku-4-5-20251001` (mas cuota) para tareas frecuentes.
|
||||
- **Nombre del modelo**: `claude-opus-4-8` es valido; los ids con sufijo de fecha que no existen
|
||||
dan `404 not_found_error`. Default haiku por cuota y velocidad.
|
||||
- **No es Claude Code**: es el modelo crudo. Sin tools, MCP, contexto del repo ni plan mode, salvo
|
||||
los que tu pases (via `run_claude_tool_loop`).
|
||||
@@ -0,0 +1,92 @@
|
||||
"""One-line CLI to ask the model fast via the direct Anthropic API (grupo claude-direct).
|
||||
|
||||
No claude process, no daemon — arranque 0. Reuses stream_anthropic_messages, which
|
||||
authenticates with the Claude Max OAuth token from ~/.claude/.credentials.json.
|
||||
|
||||
Usage:
|
||||
python ask_llm.py "que es Go en una frase"
|
||||
python ask_llm.py --model claude-opus-4-8 --system "responde conciso" "explica los punteros"
|
||||
echo "resume este texto: ..." | python ask_llm.py
|
||||
fn run ask_llm "tu pregunta"
|
||||
|
||||
For tools / agentic loops, use run_claude_tool_loop_py_core directly in a script.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
|
||||
|
||||
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
|
||||
def ask_llm(
|
||||
prompt: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
system: str = "",
|
||||
max_tokens: int = 4096,
|
||||
echo: bool = True,
|
||||
) -> str:
|
||||
"""Ask the model and return its full text answer.
|
||||
|
||||
Args:
|
||||
prompt: The user message.
|
||||
model: Anthropic model id (e.g. claude-haiku-4-5-20251001, claude-opus-4-8).
|
||||
system: Optional system prompt.
|
||||
max_tokens: Max output tokens.
|
||||
echo: If True, stream the answer to stdout as it arrives.
|
||||
|
||||
Returns:
|
||||
The complete answer text. Empty string on error (message goes to stderr).
|
||||
"""
|
||||
parts = []
|
||||
for ev in stream_anthropic_messages(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
model=model,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
):
|
||||
t = ev.get("type")
|
||||
if t == "text":
|
||||
parts.append(ev["text"])
|
||||
if echo:
|
||||
sys.stdout.write(ev["text"])
|
||||
sys.stdout.flush()
|
||||
elif t == "error":
|
||||
sys.stderr.write("ask_llm error: " + str(ev.get("message", "")) + "\n")
|
||||
return ""
|
||||
if echo:
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _main(argv):
|
||||
model = DEFAULT_MODEL
|
||||
system = ""
|
||||
prompt_parts = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a in ("--model", "-m") and i + 1 < len(argv):
|
||||
model = argv[i + 1]
|
||||
i += 2
|
||||
elif a in ("--system", "-s") and i + 1 < len(argv):
|
||||
system = argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
prompt_parts.append(a)
|
||||
i += 1
|
||||
prompt = " ".join(prompt_parts).strip()
|
||||
if not prompt and not sys.stdin.isatty():
|
||||
prompt = sys.stdin.read().strip()
|
||||
if not prompt:
|
||||
sys.stderr.write('uso: ask_llm "prompt" [--model M] [--system S]\n')
|
||||
return 2
|
||||
ask_llm(prompt, model=model, system=system)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.argv[1:]))
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: load_claude_oauth_token
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def load_claude_oauth_token(credentials_path: str = \"\", refresh_if_expired: bool = True) -> str"
|
||||
description: "Lee el access token OAuth de Claude Code desde ~/.claude/.credentials.json. Extrae claudeAiOauth.accessToken y verifica expiry (expiresAt en milisegundos). Si el token esta expirado y refresh_if_expired=True intenta un refresh best-effort via POST al endpoint OAuth de Anthropic; si el refresh falla devuelve el token actual con warning a stderr."
|
||||
tags: [claude-direct, anthropic, oauth, credentials, token, auth]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, os, sys, time, pathlib.Path, httpx]
|
||||
params:
|
||||
- name: credentials_path
|
||||
desc: "ruta absoluta o con ~ al archivo de credenciales. Default: ~/.claude/.credentials.json"
|
||||
- name: refresh_if_expired
|
||||
desc: "si True, intenta refrescar el token cuando expiresAt <= now. Default: True"
|
||||
output: "el access token como string (sk-ant-oat-...). Nunca se imprime a stdout"
|
||||
tested: true
|
||||
tests:
|
||||
- "extrae accessToken de un credentials fixture"
|
||||
- "token no expirado no intenta refresh"
|
||||
- "token expirado con refresh_if_expired=False devuelve token igual"
|
||||
- "token expirado con refresh que falla devuelve token y escribe warning a stderr"
|
||||
test_file_path: "python/functions/core/load_claude_oauth_token_test.py"
|
||||
file_path: "python/functions/core/load_claude_oauth_token.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from load_claude_oauth_token import load_claude_oauth_token
|
||||
|
||||
# Lee desde ~/.claude/.credentials.json por defecto
|
||||
token = load_claude_oauth_token()
|
||||
print(token[:20] + "...") # sk-ant-oat01-...
|
||||
|
||||
# Sin intento de refresh (evita llamada de red)
|
||||
token = load_claude_oauth_token(refresh_if_expired=False)
|
||||
|
||||
# Ruta custom (ej. para testing con fixture)
|
||||
token = load_claude_oauth_token(credentials_path="/tmp/fake_creds.json")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando vayas a hacer requests directos a `https://api.anthropic.com/v1/messages`
|
||||
usando la cuenta Claude Max del usuario local (sin API key de pago separada).
|
||||
Llama esta funcion primero; su resultado va en el header
|
||||
`Authorization: Bearer <token>` de `stream_anthropic_messages`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Refresh best-effort**: el endpoint de refresh OAuth de Anthropic no esta
|
||||
documentado publicamente. El intento puede fallar silenciosamente — en ese
|
||||
caso se devuelve el token actual con un warning a stderr. La CLI `claude`
|
||||
mantiene el token fresco en background; si tienes Claude Code abierto,
|
||||
normalmente el token ya estara vigente.
|
||||
- **expiresAt en milisegundos**: la estructura JSON usa ms-epoch, no s-epoch.
|
||||
`expiresAt / 1000 <= time.time()` es la comparacion correcta.
|
||||
- **Nunca imprimir el token a stdout**: el token es una credencial OAuth.
|
||||
Esta funcion solo lo retorna; el caller decide como usarlo.
|
||||
- El archivo `.credentials.json` solo existe si Claude Code esta instalado
|
||||
y el usuario ha hecho login. Si no existe lanza `FileNotFoundError`.
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Load the Claude Code OAuth access token from ~/.claude/.credentials.json."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_DEFAULT_CREDENTIALS_PATH = "~/.claude/.credentials.json"
|
||||
|
||||
|
||||
def load_claude_oauth_token(
|
||||
credentials_path: str = "",
|
||||
refresh_if_expired: bool = True,
|
||||
) -> str:
|
||||
"""Return the Claude Code OAuth access token.
|
||||
|
||||
Reads ``~/.claude/.credentials.json`` (or the path you supply),
|
||||
extracts ``claudeAiOauth.accessToken`` and checks expiry.
|
||||
|
||||
If ``refresh_if_expired`` is True and the token appears expired a
|
||||
best-effort refresh via the Anthropic OAuth token endpoint is attempted
|
||||
using the stored ``refreshToken``. The refresh may silently fail — in
|
||||
that case a warning is written to *stderr* and the (possibly expired)
|
||||
access token is returned anyway. The ``claude`` CLI normally refreshes
|
||||
the token in the background; if you keep Claude Code open the token
|
||||
will usually already be fresh.
|
||||
|
||||
Args:
|
||||
credentials_path: Absolute or ``~``-relative path to the credentials
|
||||
file. Defaults to ``~/.claude/.credentials.json``.
|
||||
refresh_if_expired: Attempt a token refresh when the stored
|
||||
``expiresAt`` (milliseconds epoch) is in the past. Set to
|
||||
False to skip the network call entirely.
|
||||
|
||||
Returns:
|
||||
The raw access token string (``sk-ant-oat-...``). Never printed to
|
||||
stdout.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the credentials file does not exist.
|
||||
KeyError: If the expected keys are missing from the JSON.
|
||||
json.JSONDecodeError: If the file is not valid JSON.
|
||||
"""
|
||||
path = Path(credentials_path or _DEFAULT_CREDENTIALS_PATH).expanduser()
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
|
||||
oauth = data["claudeAiOauth"]
|
||||
access_token: str = oauth["accessToken"]
|
||||
expires_at_ms: int = oauth.get("expiresAt", 0)
|
||||
refresh_token: str = oauth.get("refreshToken", "")
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
is_expired = expires_at_ms > 0 and expires_at_ms <= now_ms
|
||||
|
||||
if is_expired and refresh_if_expired and refresh_token:
|
||||
new_token = _try_refresh(refresh_token)
|
||||
if new_token:
|
||||
return new_token
|
||||
print(
|
||||
"warning: Claude OAuth token may be expired; refresh failed. "
|
||||
"Returning current token anyway.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
|
||||
def _try_refresh(refresh_token: str) -> str:
|
||||
"""Attempt to refresh the OAuth token. Returns empty string on failure.
|
||||
|
||||
The Anthropic OAuth refresh endpoint is not publicly documented.
|
||||
This is a best-effort attempt using standard OAuth 2.0 conventions
|
||||
(grant_type=refresh_token). It may not work if Anthropic changes
|
||||
their token endpoint or requires additional client credentials.
|
||||
"""
|
||||
try:
|
||||
import httpx # type: ignore
|
||||
|
||||
resp = httpx.post(
|
||||
"https://auth.anthropic.com/oauth/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
body = resp.json()
|
||||
new_token: str = body.get("access_token", "")
|
||||
if new_token:
|
||||
return new_token
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"warning: token refresh attempt failed: {exc}", file=sys.stderr)
|
||||
|
||||
return ""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests para load_claude_oauth_token."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from load_claude_oauth_token import load_claude_oauth_token
|
||||
|
||||
|
||||
def _write_creds(path, access_token, expires_at_ms, refresh_token="sk-ant-refresh-fake"):
|
||||
data = {
|
||||
"claudeAiOauth": {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
}
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def test_extrae_accessToken_de_un_credentials_fixture(tmp_path):
|
||||
creds = tmp_path / "credentials.json"
|
||||
_write_creds(str(creds), "sk-ant-oat-TEST123", expires_at_ms=int(time.time() * 1000) + 3_600_000)
|
||||
|
||||
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=False)
|
||||
|
||||
assert token == "sk-ant-oat-TEST123"
|
||||
|
||||
|
||||
def test_token_no_expirado_no_intenta_refresh(tmp_path, monkeypatch):
|
||||
creds = tmp_path / "credentials.json"
|
||||
# expiresAt 1 hora en el futuro
|
||||
future_ms = int(time.time() * 1000) + 3_600_000
|
||||
_write_creds(str(creds), "sk-ant-oat-FRESH", expires_at_ms=future_ms)
|
||||
|
||||
refresh_called = {"n": 0}
|
||||
|
||||
import load_claude_oauth_token as mod
|
||||
original_try_refresh = mod._try_refresh
|
||||
|
||||
def fake_refresh(rt):
|
||||
refresh_called["n"] += 1
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(mod, "_try_refresh", fake_refresh)
|
||||
|
||||
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=True)
|
||||
|
||||
assert token == "sk-ant-oat-FRESH"
|
||||
assert refresh_called["n"] == 0
|
||||
|
||||
|
||||
def test_token_expirado_con_refresh_if_expired_False_devuelve_token_igual(tmp_path):
|
||||
creds = tmp_path / "credentials.json"
|
||||
# expiresAt en el pasado
|
||||
past_ms = int(time.time() * 1000) - 3_600_000
|
||||
_write_creds(str(creds), "sk-ant-oat-EXPIRED", expires_at_ms=past_ms)
|
||||
|
||||
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=False)
|
||||
|
||||
assert token == "sk-ant-oat-EXPIRED"
|
||||
|
||||
|
||||
def test_token_expirado_refresh_falla_devuelve_token_y_warning_a_stderr(tmp_path, monkeypatch, capsys):
|
||||
creds = tmp_path / "credentials.json"
|
||||
past_ms = int(time.time() * 1000) - 3_600_000
|
||||
_write_creds(str(creds), "sk-ant-oat-OLD", expires_at_ms=past_ms, refresh_token="sk-ant-refresh-old")
|
||||
|
||||
import load_claude_oauth_token as mod
|
||||
|
||||
def fake_refresh_fails(rt):
|
||||
return "" # simula fallo silencioso
|
||||
|
||||
monkeypatch.setattr(mod, "_try_refresh", fake_refresh_fails)
|
||||
|
||||
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=True)
|
||||
|
||||
assert token == "sk-ant-oat-OLD"
|
||||
captured = capsys.readouterr()
|
||||
assert "warning" in captured.err.lower()
|
||||
assert "expired" in captured.err.lower() or "refresh" in captured.err.lower()
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: run_claude_tool_loop
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def run_claude_tool_loop(messages: list, tools: list, dispatch: dict, model: str = \"claude-opus-4-8\", system: str = \"\", max_tokens: int = 4096, max_iters: int = 8, on_text=None) -> dict"
|
||||
description: "Bucle agentico de tool-use sobre la API de Anthropic. Llama stream_anthropic_messages en loop: si el modelo responde con tool_use, despacha cada tool via dispatch{name: callable}, anade el tool_result, y continua. Termina cuando stop_reason != tool_use o se alcanza max_iters. Devuelve {messages, final_text, stop_reason, iterations}."
|
||||
tags: [claude-direct, anthropic, tools, agent, llm, tool-loop, streaming]
|
||||
uses_functions: [stream_anthropic_messages_py_core, load_claude_oauth_token_py_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, typing.Callable]
|
||||
params:
|
||||
- name: messages
|
||||
desc: "conversacion en formato Anthropic [{role, content}]. Se modifica in-place anadiendo mensajes assistant y tool_result"
|
||||
- name: tools
|
||||
desc: "lista de tool definitions Anthropic [{name, description, input_schema}]"
|
||||
- name: dispatch
|
||||
desc: "mapa {tool_name: callable(input_dict) -> result}. El callable puede retornar cualquier valor serializable o lanzar excepcion"
|
||||
- name: model
|
||||
desc: "ID del modelo Anthropic. Default: claude-opus-4-8"
|
||||
- name: system
|
||||
desc: "system prompt opcional"
|
||||
- name: max_tokens
|
||||
desc: "maximo de tokens por llamada API. Default 4096"
|
||||
- name: max_iters
|
||||
desc: "maximo de iteraciones del loop (proteccion contra loops infinitos). Default 8"
|
||||
- name: on_text
|
||||
desc: "callback opcional llamado con cada text delta: on_text(delta: str). Util para streaming en tiempo real"
|
||||
output: "dict con keys: messages (lista actualizada), final_text (texto del ultimo turno assistant), stop_reason (end_turn|tool_use|max_tokens|max_iters|error), iterations (int)"
|
||||
tested: true
|
||||
tests:
|
||||
- "tool_use seguido de end_turn ejecuta dispatch y termina"
|
||||
- "tool dispatch que falla devuelve is_error True en tool_result"
|
||||
- "tool desconocido devuelve is_error True"
|
||||
- "on_text callback recibe deltas de texto"
|
||||
- "max_iters limita el numero de iteraciones"
|
||||
test_file_path: "python/functions/core/run_claude_tool_loop_test.py"
|
||||
file_path: "python/functions/core/run_claude_tool_loop.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from run_claude_tool_loop import run_claude_tool_loop
|
||||
|
||||
# Definir una tool propia
|
||||
tools = [
|
||||
{
|
||||
"name": "add",
|
||||
"description": "Suma dos numeros enteros y devuelve el resultado.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "number", "description": "primer sumando"},
|
||||
"b": {"type": "number", "description": "segundo sumando"},
|
||||
},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Implementacion de la tool (la provee el usuario)
|
||||
dispatch = {
|
||||
"add": lambda inp: inp["a"] + inp["b"],
|
||||
}
|
||||
|
||||
messages = [{"role": "user", "content": "cuanto es 17 + 25?"}]
|
||||
|
||||
result = run_claude_tool_loop(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
dispatch=dispatch,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
on_text=lambda d: print(d, end="", flush=True),
|
||||
)
|
||||
|
||||
print(f"\n[stop={result['stop_reason']} iters={result['iterations']}]")
|
||||
# Claude llama a add(17, 25) -> 42, luego responde "El resultado es 42."
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras que Claude ejecute herramientas Python propias en un bucle
|
||||
agentico: calculos, consultas a BD, llamadas a APIs externas, lectura de
|
||||
archivos, etc. El loop gestiona automaticamente la negociacion
|
||||
tool_use/tool_result con la API. Usa `on_text` para mostrar la respuesta
|
||||
en tiempo real mientras el modelo piensa entre tool calls.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **messages se modifica in-place**: los mensajes assistant y tool_result se
|
||||
anaden a la lista pasada. Pasa una copia si necesitas preservar el estado
|
||||
original: `run_claude_tool_loop(list(messages), ...)`.
|
||||
- **dispatch debe cubrir todos los tools declarados**: si el modelo invoca
|
||||
una tool que no esta en dispatch, el tool_result contendra `is_error: True`.
|
||||
Declara exactamente los tools que dispatch puede manejar.
|
||||
- **Excepciones en dispatch son capturadas**: si tu callable lanza una
|
||||
excepcion, se convierte en tool_result con `is_error: True` y el mensaje
|
||||
del error. El loop continua — el modelo puede corregir y reintentar.
|
||||
- **max_iters como seguro**: sin este limite, un modelo que siempre pide
|
||||
tools podria iterar indefinidamente. Default 8 es conservador; subelo
|
||||
para agentes complejos con muchos pasos.
|
||||
- **final_text es solo el ultimo turno**: si el modelo genero texto en
|
||||
iteraciones anteriores (raro pero posible), solo se devuelve el del
|
||||
ultimo turno. El historial completo esta en `messages`.
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Agentic tool-use loop over the Anthropic Messages API."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
|
||||
|
||||
|
||||
def run_claude_tool_loop(
|
||||
messages: list,
|
||||
tools: list,
|
||||
dispatch: dict,
|
||||
model: str = "claude-opus-4-8",
|
||||
system: str = "",
|
||||
max_tokens: int = 4096,
|
||||
max_iters: int = 8,
|
||||
on_text: Callable[[str], None] = None,
|
||||
) -> dict:
|
||||
"""Run an agentic tool-use loop against the Anthropic Messages API.
|
||||
|
||||
Repeatedly calls ``stream_anthropic_messages`` until the model stops
|
||||
requesting tools (``stop_reason != "tool_use"``) or ``max_iters`` is
|
||||
reached. At each iteration:
|
||||
|
||||
1. Stream the assistant response, accumulating text and tool_use blocks.
|
||||
2. Append the assistant message (text + tool_use content blocks) to
|
||||
``messages``.
|
||||
3. If ``stop_reason == "tool_use"``: dispatch every requested tool via
|
||||
the ``dispatch`` mapping, append a ``user`` message with all
|
||||
``tool_result`` blocks, and continue.
|
||||
4. Otherwise terminate and return the result dict.
|
||||
|
||||
Args:
|
||||
messages: Conversation so far in Anthropic format. Modified in-place
|
||||
(tool_result messages are appended). Pass a copy if you want to
|
||||
preserve the original.
|
||||
tools: List of tool definitions in Anthropic format::
|
||||
|
||||
[{"name": "add", "description": "...",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"a": {"type": "number"},
|
||||
"b": {"type": "number"}},
|
||||
"required": ["a", "b"]}}]
|
||||
|
||||
dispatch: Mapping from tool name to callable ``(input_dict) -> result``.
|
||||
The callable may return any JSON-serialisable value or raise an
|
||||
exception (which is caught and returned as an error tool_result).
|
||||
model: Anthropic model ID. Default ``claude-opus-4-8``.
|
||||
system: Optional system prompt.
|
||||
max_tokens: Max tokens per API call. Default 4096.
|
||||
max_iters: Hard cap on tool-use iterations. Default 8.
|
||||
on_text: Optional callback called with each text delta as it streams.
|
||||
Useful for real-time display. E.g. ``on_text=lambda d: print(d, end="", flush=True)``.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- ``messages``: updated conversation (same list, modified in-place).
|
||||
- ``final_text``: concatenated text from the last assistant turn.
|
||||
- ``stop_reason``: ``"end_turn"``, ``"tool_use"``, ``"max_tokens"``,
|
||||
``"max_iters"``, or ``"error"``.
|
||||
- ``iterations``: number of loop iterations executed.
|
||||
"""
|
||||
iterations = 0
|
||||
final_text = ""
|
||||
stop_reason = "max_iters"
|
||||
|
||||
for _ in range(max_iters):
|
||||
iterations += 1
|
||||
text_parts: list[str] = []
|
||||
# tool_uses: list of {id, name, input_json_parts, index}
|
||||
tool_uses: list[dict] = []
|
||||
# index -> tool_use dict (for partial_json accumulation)
|
||||
index_map: dict[int, dict] = {}
|
||||
current_stop_reason = "end_turn"
|
||||
|
||||
for event in stream_anthropic_messages(
|
||||
messages=messages,
|
||||
model=model,
|
||||
system=system,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
):
|
||||
etype = event.get("type", "")
|
||||
|
||||
if etype == "text":
|
||||
delta = event["text"]
|
||||
text_parts.append(delta)
|
||||
if on_text is not None:
|
||||
on_text(delta)
|
||||
|
||||
elif etype == "tool_use_start":
|
||||
entry = {
|
||||
"id": event["id"],
|
||||
"name": event["name"],
|
||||
"index": event["index"],
|
||||
"partial_json_parts": [],
|
||||
}
|
||||
tool_uses.append(entry)
|
||||
index_map[event["index"]] = entry
|
||||
|
||||
elif etype == "tool_input_delta":
|
||||
idx = event["index"]
|
||||
if idx in index_map:
|
||||
index_map[idx]["partial_json_parts"].append(event["partial_json"])
|
||||
|
||||
elif etype == "done":
|
||||
current_stop_reason = event.get("stop_reason", "end_turn")
|
||||
|
||||
elif etype == "error":
|
||||
final_text = "".join(text_parts)
|
||||
return {
|
||||
"messages": messages,
|
||||
"final_text": final_text,
|
||||
"stop_reason": "error",
|
||||
"iterations": iterations,
|
||||
"error": event.get("message", "unknown error"),
|
||||
}
|
||||
|
||||
final_text = "".join(text_parts)
|
||||
stop_reason = current_stop_reason
|
||||
|
||||
# Build the assistant content blocks
|
||||
assistant_content: list[dict] = []
|
||||
if final_text:
|
||||
assistant_content.append({"type": "text", "text": final_text})
|
||||
for tu in tool_uses:
|
||||
raw_input = "".join(tu["partial_json_parts"])
|
||||
try:
|
||||
parsed_input = json.loads(raw_input) if raw_input else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed_input = {"_raw": raw_input}
|
||||
assistant_content.append({
|
||||
"type": "tool_use",
|
||||
"id": tu["id"],
|
||||
"name": tu["name"],
|
||||
"input": parsed_input,
|
||||
})
|
||||
|
||||
messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
if stop_reason != "tool_use" or not tool_uses:
|
||||
break
|
||||
|
||||
# Dispatch tools and build tool_result message
|
||||
tool_results: list[dict] = []
|
||||
for tu in tool_uses:
|
||||
tool_name = tu["name"]
|
||||
raw_input = "".join(tu["partial_json_parts"])
|
||||
try:
|
||||
parsed_input = json.loads(raw_input) if raw_input else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed_input = {"_raw": raw_input}
|
||||
|
||||
if tool_name not in dispatch:
|
||||
result_content = f"Error: tool '{tool_name}' not found in dispatch"
|
||||
is_error = True
|
||||
else:
|
||||
try:
|
||||
result_value = dispatch[tool_name](parsed_input)
|
||||
result_content = (
|
||||
result_value
|
||||
if isinstance(result_value, str)
|
||||
else json.dumps(result_value)
|
||||
)
|
||||
is_error = False
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result_content = f"Error executing {tool_name}: {exc}"
|
||||
is_error = True
|
||||
|
||||
tool_result: dict = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu["id"],
|
||||
"content": result_content,
|
||||
}
|
||||
if is_error:
|
||||
tool_result["is_error"] = True
|
||||
tool_results.append(tool_result)
|
||||
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
else:
|
||||
stop_reason = "max_iters"
|
||||
|
||||
return {
|
||||
"messages": messages,
|
||||
"final_text": final_text,
|
||||
"stop_reason": stop_reason,
|
||||
"iterations": iterations,
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests para run_claude_tool_loop — usa fake de stream_anthropic_messages, sin HTTP."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake de stream_anthropic_messages inyectado via monkeypatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_text_response(text: str, stop_reason: str = "end_turn"):
|
||||
"""Genera la secuencia de eventos de una respuesta de texto pura."""
|
||||
for ch in text:
|
||||
yield {"type": "text", "text": ch}
|
||||
yield {"type": "done", "stop_reason": stop_reason}
|
||||
|
||||
|
||||
def _make_tool_use_response(tool_id: str, tool_name: str, tool_input: dict):
|
||||
"""Genera la secuencia de eventos de un tool_use."""
|
||||
yield {"type": "tool_use_start", "id": tool_id, "name": tool_name, "index": 0}
|
||||
raw = json.dumps(tool_input)
|
||||
# Simula streaming del JSON en dos partes
|
||||
mid = len(raw) // 2
|
||||
yield {"type": "tool_input_delta", "index": 0, "partial_json": raw[:mid]}
|
||||
yield {"type": "tool_input_delta", "index": 0, "partial_json": raw[mid:]}
|
||||
yield {"type": "done", "stop_reason": "tool_use"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tool_use_seguido_de_end_turn_ejecuta_dispatch_y_termina(monkeypatch):
|
||||
import run_claude_tool_loop as mod
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_stream(messages, model, system, tools, max_tokens):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
# Primera llamada: el modelo pide la tool "add"
|
||||
yield from _make_tool_use_response("toolu_01", "add", {"a": 3, "b": 4})
|
||||
else:
|
||||
# Segunda llamada: el modelo responde con el resultado
|
||||
yield from _make_text_response("El resultado es 7.")
|
||||
|
||||
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
|
||||
|
||||
dispatch_called = {"input": None}
|
||||
def add_tool(inp):
|
||||
dispatch_called["input"] = inp
|
||||
return inp["a"] + inp["b"]
|
||||
|
||||
messages = [{"role": "user", "content": "cuanto es 3 + 4?"}]
|
||||
result = mod.run_claude_tool_loop(
|
||||
messages=messages,
|
||||
tools=[{"name": "add", "description": "suma", "input_schema": {}}],
|
||||
dispatch={"add": add_tool},
|
||||
model="claude-haiku-4-5-20251001",
|
||||
)
|
||||
|
||||
assert result["stop_reason"] == "end_turn"
|
||||
assert result["iterations"] == 2
|
||||
assert result["final_text"] == "El resultado es 7."
|
||||
assert dispatch_called["input"] == {"a": 3, "b": 4}
|
||||
|
||||
# Verifica que se anado el tool_result al historial
|
||||
roles = [m["role"] for m in result["messages"]]
|
||||
assert roles.count("assistant") == 2
|
||||
assert roles.count("user") >= 2
|
||||
|
||||
# El mensaje user con tool_result debe tener is_error=False (ausente)
|
||||
tool_result_msg = next(
|
||||
m for m in result["messages"]
|
||||
if m["role"] == "user" and isinstance(m["content"], list)
|
||||
and m["content"] and m["content"][0].get("type") == "tool_result"
|
||||
)
|
||||
assert tool_result_msg["content"][0].get("is_error") is None or \
|
||||
tool_result_msg["content"][0].get("is_error") is False
|
||||
|
||||
|
||||
def test_tool_dispatch_que_falla_devuelve_is_error_True_en_tool_result(monkeypatch):
|
||||
import run_claude_tool_loop as mod
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_stream(messages, model, system, tools, max_tokens):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
yield from _make_tool_use_response("toolu_02", "divide", {"a": 10, "b": 0})
|
||||
else:
|
||||
yield from _make_text_response("No se puede dividir por cero.")
|
||||
|
||||
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
|
||||
|
||||
def divide_tool(inp):
|
||||
return inp["a"] / inp["b"] # ZeroDivisionError
|
||||
|
||||
messages = [{"role": "user", "content": "divide 10 entre 0"}]
|
||||
result = mod.run_claude_tool_loop(
|
||||
messages=messages,
|
||||
tools=[{"name": "divide", "description": "division", "input_schema": {}}],
|
||||
dispatch={"divide": divide_tool},
|
||||
)
|
||||
|
||||
# Debe haber un tool_result con is_error=True
|
||||
tool_result_msg = next(
|
||||
m for m in result["messages"]
|
||||
if m["role"] == "user" and isinstance(m["content"], list)
|
||||
and m["content"] and m["content"][0].get("type") == "tool_result"
|
||||
)
|
||||
assert tool_result_msg["content"][0].get("is_error") is True
|
||||
assert "Error" in tool_result_msg["content"][0]["content"]
|
||||
|
||||
|
||||
def test_tool_desconocido_devuelve_is_error_True(monkeypatch):
|
||||
import run_claude_tool_loop as mod
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_stream(messages, model, system, tools, max_tokens):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
yield from _make_tool_use_response("toolu_03", "nonexistent_tool", {"x": 1})
|
||||
else:
|
||||
yield from _make_text_response("No pude usar esa tool.")
|
||||
|
||||
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
|
||||
|
||||
messages = [{"role": "user", "content": "usa nonexistent_tool"}]
|
||||
result = mod.run_claude_tool_loop(
|
||||
messages=messages,
|
||||
tools=[],
|
||||
dispatch={}, # dispatch vacio — tool no existe
|
||||
)
|
||||
|
||||
tool_result_msg = next(
|
||||
m for m in result["messages"]
|
||||
if m["role"] == "user" and isinstance(m["content"], list)
|
||||
and m["content"] and m["content"][0].get("type") == "tool_result"
|
||||
)
|
||||
assert tool_result_msg["content"][0].get("is_error") is True
|
||||
assert "not found" in tool_result_msg["content"][0]["content"]
|
||||
|
||||
|
||||
def test_on_text_callback_recibe_deltas_de_texto(monkeypatch):
|
||||
import run_claude_tool_loop as mod
|
||||
|
||||
def fake_stream(messages, model, system, tools, max_tokens):
|
||||
yield from _make_text_response("hola mundo")
|
||||
|
||||
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
|
||||
|
||||
received = []
|
||||
messages = [{"role": "user", "content": "saluda"}]
|
||||
result = mod.run_claude_tool_loop(
|
||||
messages=messages,
|
||||
tools=[],
|
||||
dispatch={},
|
||||
on_text=lambda d: received.append(d),
|
||||
)
|
||||
|
||||
assert "".join(received) == "hola mundo"
|
||||
assert result["final_text"] == "hola mundo"
|
||||
|
||||
|
||||
def test_max_iters_limita_el_numero_de_iteraciones(monkeypatch):
|
||||
import run_claude_tool_loop as mod
|
||||
|
||||
# Siempre responde con tool_use para forzar loop infinito
|
||||
def fake_stream_infinite(messages, model, system, tools, max_tokens):
|
||||
yield from _make_tool_use_response("toolu_inf", "noop", {})
|
||||
|
||||
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream_infinite)
|
||||
|
||||
messages = [{"role": "user", "content": "loop"}]
|
||||
result = mod.run_claude_tool_loop(
|
||||
messages=messages,
|
||||
tools=[{"name": "noop", "description": "no-op", "input_schema": {}}],
|
||||
dispatch={"noop": lambda inp: "ok"},
|
||||
max_iters=3,
|
||||
)
|
||||
|
||||
assert result["iterations"] == 3
|
||||
assert result["stop_reason"] == "max_iters"
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: stream_anthropic_messages
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def stream_anthropic_messages(messages: list, model: str = \"claude-opus-4-8\", system: str = \"\", tools: list = None, max_tokens: int = 4096, token: str = \"\") -> Iterator[dict]"
|
||||
description: "Llama a https://api.anthropic.com/v1/messages con stream:true usando el token OAuth de Claude Code y hace yield de eventos normalizados: text, tool_use_start, tool_input_delta, done, error. Parsea el SSE en tiempo real. Si token vacio llama load_claude_oauth_token automaticamente."
|
||||
tags: [claude-direct, anthropic, streaming, sse, llm, tools, http]
|
||||
uses_functions: [load_claude_oauth_token_py_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, sys, typing.Iterator, httpx]
|
||||
params:
|
||||
- name: messages
|
||||
desc: "lista de mensajes Anthropic [{role: user|assistant, content: ...}]"
|
||||
- name: model
|
||||
desc: "ID del modelo Anthropic. Default: claude-opus-4-8"
|
||||
- name: system
|
||||
desc: "system prompt opcional. Si vacio no se incluye en el body"
|
||||
- name: tools
|
||||
desc: "lista de tool definitions [{name, description, input_schema}]. None para no usar tools"
|
||||
- name: max_tokens
|
||||
desc: "maximo de tokens en la respuesta. Default 4096"
|
||||
- name: token
|
||||
desc: "access token OAuth. Si vacio se carga automaticamente desde ~/.claude/.credentials.json"
|
||||
output: "Iterator[dict] — eventos normalizados: {type:text,text}, {type:tool_use_start,id,name,index}, {type:tool_input_delta,index,partial_json}, {type:done,stop_reason}, {type:error,message}"
|
||||
tested: true
|
||||
tests:
|
||||
- "parse_sse_chunk text delta emite evento text"
|
||||
- "parse_sse_chunk tool_use_start emite evento tool_use_start con id y name"
|
||||
- "parse_sse_chunk tool_input_delta emite evento tool_input_delta con partial_json"
|
||||
- "parse_sse_chunk message_delta con stop_reason emite done"
|
||||
- "parse_sse_chunk secuencia completa text PONG reconstruye el texto"
|
||||
- "parse_sse_chunk secuencia completa tool_use reconstruye el input JSON"
|
||||
test_file_path: "python/functions/core/stream_anthropic_messages_test.py"
|
||||
file_path: "python/functions/core/stream_anthropic_messages.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from stream_anthropic_messages import stream_anthropic_messages
|
||||
|
||||
text = ""
|
||||
for event in stream_anthropic_messages(
|
||||
messages=[{"role": "user", "content": "di solo PONG"}],
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=32,
|
||||
):
|
||||
if event["type"] == "text":
|
||||
text += event["text"]
|
||||
print(event["text"], end="", flush=True)
|
||||
elif event["type"] == "done":
|
||||
print(f"\n[stop_reason={event['stop_reason']}]")
|
||||
elif event["type"] == "error":
|
||||
print(f"ERROR: {event['message']}", file=sys.stderr)
|
||||
|
||||
print("respuesta completa:", text)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites hablar directamente con la API de Anthropic desde Python
|
||||
sin lanzar la CLI `claude`, especialmente cuando quieras streaming en tiempo
|
||||
real del texto o necesites ejecutar tools propias via `run_claude_tool_loop`.
|
||||
Tambien util para integrar Claude en scripts de analisis donde el bucle
|
||||
de tools se gestiona en Python.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El parser SSE es puro y testeable**: `parse_sse_chunk` no hace HTTP.
|
||||
Puedes testear el parseo completo sin mocks de red.
|
||||
- **Eventos `raw` son pass-through**: `message_start`, `content_block_stop`,
|
||||
`ping` y otros eventos no mapeados se emiten como `{type: raw, event: ..., data: ...}`.
|
||||
El consumidor los puede ignorar o inspeccionar.
|
||||
- **Reconstruccion de tool input**: los `partial_json` de un mismo `index`
|
||||
deben concatenarse hasta el evento `done`. `run_claude_tool_loop` hace esto
|
||||
automaticamente.
|
||||
- **Timeout 120s**: configurable en el codigo si necesitas respuestas muy largas.
|
||||
- La funcion `parse_sse_chunk` es pure (importable y testeable por separado).
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Stream responses from the Anthropic Messages API using a Claude Code OAuth token."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
_API_URL = "https://api.anthropic.com/v1/messages"
|
||||
_ANTHROPIC_VERSION = "2023-06-01"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure SSE parser — fully testable without HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_sse_chunk(chunk: str) -> Iterator[dict]:
|
||||
"""Parse one SSE chunk (possibly containing multiple event/data pairs).
|
||||
|
||||
Yields normalised event dicts:
|
||||
- ``{"type": "text", "text": "<delta>"}``
|
||||
- ``{"type": "tool_use_start", "id": "...", "name": "...", "index": N}``
|
||||
- ``{"type": "tool_input_delta","index": N, "partial_json": "<delta>"}``
|
||||
- ``{"type": "done", "stop_reason": "<...>"}``
|
||||
- ``{"type": "raw", "event": "...", "data": {...}}`` (pass-through)
|
||||
|
||||
Args:
|
||||
chunk: Raw SSE text with ``event:`` / ``data:`` lines, separated by
|
||||
blank lines between events.
|
||||
"""
|
||||
current_event = ""
|
||||
current_data = ""
|
||||
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("event:"):
|
||||
current_event = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
current_data = line[len("data:"):].strip()
|
||||
elif line == "":
|
||||
if current_data and current_data != "[DONE]":
|
||||
try:
|
||||
data = json.loads(current_data)
|
||||
except json.JSONDecodeError:
|
||||
current_event = ""
|
||||
current_data = ""
|
||||
continue
|
||||
|
||||
yield from _normalise_event(current_event, data)
|
||||
|
||||
current_event = ""
|
||||
current_data = ""
|
||||
|
||||
# flush final event without trailing blank line
|
||||
if current_data and current_data != "[DONE]":
|
||||
try:
|
||||
data = json.loads(current_data)
|
||||
yield from _normalise_event(current_event, data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
def _normalise_event(event_type: str, data: dict) -> Iterator[dict]:
|
||||
"""Convert a raw SSE event dict into one or more normalised dicts."""
|
||||
if event_type == "content_block_start":
|
||||
block = data.get("content_block", {})
|
||||
if block.get("type") == "tool_use":
|
||||
yield {
|
||||
"type": "tool_use_start",
|
||||
"id": block.get("id", ""),
|
||||
"name": block.get("name", ""),
|
||||
"index": data.get("index", 0),
|
||||
}
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
delta = data.get("delta", {})
|
||||
delta_type = delta.get("type", "")
|
||||
if delta_type == "text_delta":
|
||||
yield {"type": "text", "text": delta.get("text", "")}
|
||||
elif delta_type == "input_json_delta":
|
||||
yield {
|
||||
"type": "tool_input_delta",
|
||||
"index": data.get("index", 0),
|
||||
"partial_json": delta.get("partial_json", ""),
|
||||
}
|
||||
|
||||
elif event_type == "message_delta":
|
||||
stop_reason = data.get("delta", {}).get("stop_reason", "")
|
||||
if stop_reason:
|
||||
yield {"type": "done", "stop_reason": stop_reason}
|
||||
|
||||
elif event_type == "message_stop":
|
||||
# emitted after message_delta — only yield done if not already seen
|
||||
pass
|
||||
|
||||
else:
|
||||
# pass-through for message_start, content_block_stop, ping, etc.
|
||||
yield {"type": "raw", "event": event_type, "data": data}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming HTTP function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def stream_anthropic_messages(
|
||||
messages: list,
|
||||
model: str = "claude-opus-4-8",
|
||||
system: str = "",
|
||||
tools: list = None,
|
||||
max_tokens: int = 4096,
|
||||
token: str = "",
|
||||
) -> Iterator[dict]:
|
||||
"""Stream an Anthropic Messages API call using a Claude Code OAuth token.
|
||||
|
||||
Posts to ``https://api.anthropic.com/v1/messages`` with ``stream: true``
|
||||
and yields normalised event dicts as they arrive:
|
||||
|
||||
- ``{"type": "text", "text": "<delta>"}``
|
||||
- ``{"type": "tool_use_start", "id": "...", "name": "...", "index": N}``
|
||||
- ``{"type": "tool_input_delta","index": N, "partial_json": "<delta>"}``
|
||||
- ``{"type": "done", "stop_reason": "end_turn|tool_use|..."}``
|
||||
- ``{"type": "error", "message": "..."}`` on HTTP error / exception
|
||||
|
||||
Args:
|
||||
messages: List of message dicts in Anthropic format
|
||||
(``[{"role": "user", "content": "..."}]``).
|
||||
model: Anthropic model ID. Default ``claude-opus-4-8``.
|
||||
system: Optional system prompt string.
|
||||
tools: Optional list of tool definition dicts.
|
||||
max_tokens: Maximum tokens in the response. Default 4096.
|
||||
token: OAuth access token. If empty, ``load_claude_oauth_token()``
|
||||
is called automatically.
|
||||
|
||||
Yields:
|
||||
Normalised event dicts (see above).
|
||||
"""
|
||||
if not token:
|
||||
try:
|
||||
try:
|
||||
from core.load_claude_oauth_token import load_claude_oauth_token
|
||||
except ImportError:
|
||||
from load_claude_oauth_token import load_claude_oauth_token
|
||||
token = load_claude_oauth_token()
|
||||
except Exception as exc:
|
||||
yield {"type": "error", "message": f"failed to load token: {exc}"}
|
||||
return
|
||||
|
||||
body: dict = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
}
|
||||
if system:
|
||||
body["system"] = system
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"anthropic-version": _ANTHROPIC_VERSION,
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.stream(
|
||||
"POST",
|
||||
_API_URL,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=120.0,
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
error_body = resp.read().decode("utf-8", errors="replace")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": f"HTTP {resp.status_code}: {error_body[:500]}",
|
||||
}
|
||||
return
|
||||
|
||||
buffer = ""
|
||||
for chunk in resp.iter_text():
|
||||
buffer += chunk
|
||||
# Process complete SSE blocks (separated by double newlines)
|
||||
while "\n\n" in buffer:
|
||||
block, buffer = buffer.split("\n\n", 1)
|
||||
block += "\n\n"
|
||||
yield from parse_sse_chunk(block)
|
||||
|
||||
# Flush any remaining content
|
||||
if buffer.strip():
|
||||
yield from parse_sse_chunk(buffer + "\n\n")
|
||||
|
||||
except Exception as exc:
|
||||
yield {"type": "error", "message": str(exc)}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests para stream_anthropic_messages — solo el parser SSE puro, sin HTTP."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from stream_anthropic_messages import parse_sse_chunk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: construir chunks SSE sinteticos (igual a lo que envia Anthropic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sse(event: str, data: dict) -> str:
|
||||
import json
|
||||
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de eventos individuales
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_sse_chunk_text_delta_emite_evento_text():
|
||||
chunk = sse("content_block_delta", {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "P"},
|
||||
})
|
||||
events = list(parse_sse_chunk(chunk))
|
||||
text_events = [e for e in events if e["type"] == "text"]
|
||||
assert len(text_events) == 1
|
||||
assert text_events[0]["text"] == "P"
|
||||
|
||||
|
||||
def test_parse_sse_chunk_tool_use_start_emite_evento_tool_use_start():
|
||||
chunk = sse("content_block_start", {
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_01", "name": "add"},
|
||||
})
|
||||
events = list(parse_sse_chunk(chunk))
|
||||
tu_events = [e for e in events if e["type"] == "tool_use_start"]
|
||||
assert len(tu_events) == 1
|
||||
assert tu_events[0]["id"] == "toolu_01"
|
||||
assert tu_events[0]["name"] == "add"
|
||||
assert tu_events[0]["index"] == 1
|
||||
|
||||
|
||||
def test_parse_sse_chunk_tool_input_delta_emite_evento_tool_input_delta():
|
||||
chunk = sse("content_block_delta", {
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"a":'},
|
||||
})
|
||||
events = list(parse_sse_chunk(chunk))
|
||||
ti_events = [e for e in events if e["type"] == "tool_input_delta"]
|
||||
assert len(ti_events) == 1
|
||||
assert ti_events[0]["index"] == 1
|
||||
assert ti_events[0]["partial_json"] == '{"a":'
|
||||
|
||||
|
||||
def test_parse_sse_chunk_message_delta_con_stop_reason_emite_done():
|
||||
chunk = sse("message_delta", {
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 5},
|
||||
})
|
||||
events = list(parse_sse_chunk(chunk))
|
||||
done_events = [e for e in events if e["type"] == "done"]
|
||||
assert len(done_events) == 1
|
||||
assert done_events[0]["stop_reason"] == "end_turn"
|
||||
|
||||
|
||||
def test_parse_sse_chunk_secuencia_completa_text_PONG_reconstruye_el_texto():
|
||||
"""Simula la secuencia completa de eventos SSE para una respuesta de texto."""
|
||||
import json
|
||||
|
||||
# Secuencia tipica: message_start, content_block_start text, deltas, stop
|
||||
chunks = "".join([
|
||||
sse("message_start", {
|
||||
"type": "message_start",
|
||||
"message": {"id": "msg_01", "type": "message", "role": "assistant",
|
||||
"content": [], "model": "claude-haiku-4-5-20251001",
|
||||
"stop_reason": None, "usage": {"input_tokens": 5}},
|
||||
}),
|
||||
sse("content_block_start", {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
}),
|
||||
sse("content_block_delta", {
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "P"},
|
||||
}),
|
||||
sse("content_block_delta", {
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "ON"},
|
||||
}),
|
||||
sse("content_block_delta", {
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "G"},
|
||||
}),
|
||||
sse("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
||||
sse("message_delta", {
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"output_tokens": 4},
|
||||
}),
|
||||
sse("message_stop", {"type": "message_stop"}),
|
||||
])
|
||||
|
||||
events = list(parse_sse_chunk(chunks))
|
||||
text = "".join(e["text"] for e in events if e["type"] == "text")
|
||||
done = [e for e in events if e["type"] == "done"]
|
||||
|
||||
assert text == "PONG"
|
||||
assert len(done) == 1
|
||||
assert done[0]["stop_reason"] == "end_turn"
|
||||
|
||||
|
||||
def test_parse_sse_chunk_secuencia_completa_tool_use_reconstruye_el_input_JSON():
|
||||
"""Simula la secuencia SSE para un tool_use con input_json_delta streaming."""
|
||||
chunks = "".join([
|
||||
sse("content_block_start", {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_42", "name": "add"},
|
||||
}),
|
||||
sse("content_block_delta", {
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"a":'},
|
||||
}),
|
||||
sse("content_block_delta", {
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": ' 3, "b": 4}'},
|
||||
}),
|
||||
sse("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
||||
sse("message_delta", {
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use"},
|
||||
"usage": {"output_tokens": 10},
|
||||
}),
|
||||
])
|
||||
|
||||
events = list(parse_sse_chunk(chunks))
|
||||
|
||||
tu_start = [e for e in events if e["type"] == "tool_use_start"]
|
||||
ti_deltas = [e for e in events if e["type"] == "tool_input_delta"]
|
||||
done = [e for e in events if e["type"] == "done"]
|
||||
|
||||
assert len(tu_start) == 1
|
||||
assert tu_start[0]["name"] == "add"
|
||||
assert tu_start[0]["id"] == "toolu_42"
|
||||
|
||||
# Reconstruir el input JSON concatenando los partial_json
|
||||
import json
|
||||
raw_input = "".join(d["partial_json"] for d in ti_deltas)
|
||||
parsed = json.loads(raw_input)
|
||||
assert parsed == {"a": 3, "b": 4}
|
||||
|
||||
assert len(done) == 1
|
||||
assert done[0]["stop_reason"] == "tool_use"
|
||||
Reference in New Issue
Block a user