chore: auto-commit (3 archivos)

- .mcp.json
- CAPABILITIES_TODO.md
- demo_e2e/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:49:54 +02:00
parent 2527fd306a
commit 23f9aa90e8
14 changed files with 1858 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"browser": {
"command": "/home/enmanuel/fn_registry/projects/web_scraping/apps/browser_mcp/browser_mcp",
"args": []
}
}
}
+460
View File
@@ -0,0 +1,460 @@
---
title: Capacidades de navegador (CDP) + construcción del MCP full-CDP
artefacto: project · projects/web_scraping
created: 06/06/2026 00:00
updated: 06/06/2026 07:00
status: in_progress
related_issues: []
related_flows: []
---
## Objetivo
Dos objetivos encadenados:
1. **Inventario** — mapear todas las capacidades de control de navegador del proyecto `web_scraping`
contra el dominio `browser` del registry (funciones Go, Bash y pipelines Python) y la app
`script_navegador`. Marcar qué está cubierto, qué está a medias y qué falta.
2. **MCP full-CDP** — construir un servidor MCP (`browser_mcp`) que exponga TODAS estas capacidades como
tools, para que cualquier agente Claude controle el navegador de punta a punta. Los gaps que faltan
se construyen en paralelo con varios `fn-constructor`, y el MCP los envuelve a medida que aparecen.
Este documento es la lista de trabajo viva del proyecto: cada gap es una tarea candidata a delegar a
`fn-constructor`, y cada función del registry es una tool candidata del `browser_mcp`.
Convención de estado por capacidad:
- `[x]` Cubierto — hay función(es) del registry dedicadas y probadas.
- `[~]` Parcial — se puede hacer pero indirecto (vía `cdp_evaluate`) o incompleto (falta parte del CRUD).
- `[ ]` Falta — no existe ninguna función para esto.
---
## Resumen ejecutivo
| # | Capacidad pedida | Estado | Notas |
|---|---|---|---|
| 1 | CRUD de perfiles | `[x]` | Create + Read + Delete + Update(apariencia/clonar/reset). Completo. |
| 2 | CRUD de ventanas | `[ ]` | No hay nada. Falta crear/listar/mover/redimensionar/cerrar ventanas (Browser.*WindowBounds). |
| 3 | CRUD de pestañas | `[x]` | Create/List/Navigate + **close/activate** (cdp_close_tab/activate_tab) + **back/forward**. Completo. |
| 4 | Lanzador personalizado | `[x]` | Perfil, flags, extensiones, proxy, headless. Completo. |
| 5 | Configuración de detalles | `[~]` | Apariencia + flag CDP global + policy de extensiones. Falta config genérica de prefs. |
| 6 | Datos del navegador (cookies, historial, marcadores) | `[~]` | Cookies: **CRUD completo** (get/set/delete/clear). Historial perfil: nada. Marcadores: backup/restore. |
| 7 | Lectura de página (HTML, AX tree, texto) | `[~]` | HTML sí, AX tree sí. Texto plano solo vía `cdp_evaluate` (no dedicado). |
| 8 | Selección de elementos del DOM | `[x]` | find_by_text, wait_element, picker interactivo, querySelector vía evaluate. |
| 9 | CRUD de iframes | `[x]` | **list_frames + eval_in_frame + get_frame_html**. Manejo de frames completo. |
| 10 | Lanzamiento de JS en la página | `[x]` | `cdp_evaluate` + steps `js` de `cdp_extract_recipe`. Completo. |
Extras que ya tenemos y no estaban en la lista (capital acumulado): captura de tráfico (HAR +
mitmproxy), interacción humanizada (curva Bézier + jitter anti-bot), esperas inteligentes
(idle/load/element), screenshot.
---
## Catálogo completo — todas nuestras habilidades de un vistazo
39 funciones del dominio `browser` (23 Go + 12 Bash + 4 pipelines Python) + 1 pipeline Bash de reset.
Cada una es una tool candidata del futuro `browser_mcp`. Columna "MCP tool" = nombre propuesto de la tool.
### CDP core — Go (`functions/browser/`, importables directo por el MCP)
| Función (registry id) | Qué hace | MCP tool propuesta |
|---|---|---|
| `chrome_launch_go_browser` | Lanza Chrome/Chromium con remote debugging, mata árbol de proceso | `browser_launch` |
| `cdp_connect_go_browser` | Handshake WebSocket CDP sobre localhost:port → conexión lista | (interno, lo usa el MCP) |
| `cdp_close_go_browser` | Cierra conexión WS y/o mata proceso Chrome por PID | `browser_close` |
| `cdp_navigate_go_browser` | Navega la pestaña a una URL (`Page.navigate`) | `tab_navigate` |
| `cdp_new_tab_go_browser` | Abre pestaña nueva (`/json/new`) → CdpTab | `tab_new` |
| `cdp_list_tabs_go_browser` | Lista pestañas/targets (`GET /json`), sólo HTTP | `tab_list` |
| `cdp_get_html_go_browser` | HTML del DOM vivo post-JS (`outerHTML`) | `page_get_html` |
| `cdp_screenshot_go_browser` | Screenshot PNG/JPEG, viewport o página completa | `page_screenshot` |
| `cdp_evaluate_go_browser` | Ejecuta JS arbitrario (`Runtime.evaluate`, soporta await) | `page_eval_js` |
| `cdp_click_go_browser` | Click por selector CSS (scroll + mousedown/up) | `dom_click` |
| `cdp_click_human_go_browser` | Click humanizado (Bézier + jitter + micro-pausa) anti-bot | `dom_click_human` |
| `cdp_click_text_go_browser` | Click sobre el elemento cuyo innerText matchea | `dom_click_text` |
| `cdp_type_text_go_browser` | Escribe texto char a char en el elemento activo | `dom_type` |
| `cdp_move_mouse_human_go_browser` | Mueve ratón con curva Bézier humanizada | `mouse_move_human` |
| `cdp_find_by_text_go_browser` | Texto visible → selector CSS único | `dom_find_by_text` |
| `cdp_wait_element_go_browser` | Espera a que un selector exista en el DOM | `dom_wait_element` |
| `cdp_pick_element_js_go_browser` | Picker interactivo: hover overlay + click captura selector/XPath/bbox | `dom_pick_element` |
| `cdp_wait_load_go_browser` | Espera `document.readyState === complete` | `page_wait_load` |
| `cdp_wait_idle_go_browser` | Espera red en idle (inflight ≤ N durante quietMs) | `page_wait_idle` |
| `cdp_set_cookie_go_browser` | Set cookie (incl. HttpOnly) vía `Network.setCookie` | `cookie_set` |
| `cdp_har_record_go_browser` | Captura HAR 1.2 de todo el tráfico de una acción | `traffic_record_har` |
| `list_chrome_profiles_go_browser` | Lista perfiles de un user-data-dir | `profile_list` |
| `list_chrome_profile_extensions_go_browser` | Lista extensiones instaladas de un perfil | `profile_list_extensions` |
### Perfiles + sistema — Bash (`bash/functions/browser/`, el MCP las invoca vía `fn run`/shell)
| Función (registry id) | Qué hace | MCP tool propuesta |
|---|---|---|
| `create_chrome_profile_bash_browser` | Crea perfil nuevo (con/sin lanzar headless para policy) | `profile_create` |
| `delete_chrome_profile_bash_browser` | Borra perfil(es) + limpia Local State (backup automático) | `profile_delete` |
| `prepare_chrome_profile_bash_browser` | Clona user-data-dir limpio (whitelist de extensiones) | `profile_prepare` |
| `set_chrome_profile_appearance_bash_browser` | Avatar + color de tema de un perfil | `profile_set_appearance` |
| `backup_chrome_bookmarks_bash_browser` | Backup byte a byte de Bookmarks (preserva checksum) | `bookmark_backup` |
| `restore_chrome_bookmarks_bash_browser` | Restaura Bookmarks desde backup | `bookmark_restore` |
| `chrome_load_extensions_bash_browser` | Lanza Chrome con extensiones unpacked (`--load-extension`) | `ext_load_unpacked` |
| `clean_chrome_profile_extensions_bash_browser` | Purga extensiones fuera de la whitelist de un perfil | `ext_clean` |
| `apply_chromium_extension_policy_bash_browser` | Policy managed: forcelist + blocklist de extensiones | `ext_apply_policy` |
| `install_chromium_proxy_extension_bash_browser` | Instala extensión unpacked en todos los perfiles (persistente) | `ext_install_persistent` |
| `apply_chromium_cdp_flag_bash_browser` | Activa CDP global del sistema (`/etc/chromium.d/cdp`) | `system_cdp_flag` |
| `launch_chromium_proxy_bash_browser` | Lanza Chromium con perfil aislado apuntando a proxy mitm | `browser_launch_proxy` |
### Pipelines — Python + Bash (`*/functions/pipelines/`, el MCP los invoca vía `fn run`)
| Pipeline (registry id) | Qué hace | MCP tool propuesta |
|---|---|---|
| `cdp_get_ax_tree_py_pipelines` | Accessibility tree completo de un tab | `page_get_ax_tree` |
| `cdp_open_url_and_wait_py_pipelines` | Crea tab + navega + espera loadEventFired → tab_id | `tab_open_and_wait` |
| `cdp_extract_recipe_py_pipelines` | Ejecuta recipe YAML (wait_selector/js) contra Chrome | `run_recipe` |
| `extract_hls_from_cdp_tab_py_pipelines` | Extrae manifests HLS de tabs + iframes | `extract_hls` |
| `reset_chrome_profiles_bash_pipelines` | Reset destructivo de perfiles (preserva bookmarks) | `profile_reset` |
> El MCP tendrá ~28 tools al ensamblar lo existente, y crecerá hasta ~40 al cerrar los gaps de abajo.
---
## Benchmark vs estado del arte (Playwright MCP + Chrome DevTools MCP)
Comparación contra los dos servidores MCP de referencia de la comunidad para fijar qué nos falta
para tener paridad con un "lanzador típico":
- **Microsoft Playwright MCP** — ~60+ tools (incluye testing/assertions/video). Modo por defecto =
accessibility snapshot, no HTML crudo. Fuente: github.com/microsoft/playwright-mcp.
- **Google Chrome DevTools MCP** — 26 tools en 6 categorías (input, navegación, emulación,
performance, network, debugging), CDP crudo igual que nosotros. Fuente: github.com/ChromeDevTools/chrome-devtools-mcp.
### Tabla de paridad por categoría
| Categoría | Ellos tienen | Nosotros | Veredicto |
|---|---|---|---|
| Navegación URL | navigate, back/forward | navigate ✅, back/forward ❌ | falta back/forward |
| Pestañas | list/new/close/select | list/new ✅, close/activate ❌ (planeado) | en gaps tanda 1 |
| Lectura DOM | snapshot (AX) + HTML | AX tree ✅, HTML ✅ | paridad |
| Selección | locators, find by text/role | find_by_text ✅, picker ✅, wait_element ✅ | paridad |
| Click / type | click, type, **press_key**, **hover** | click(+human) ✅, type ✅, press_key ❌, hover ❌ | falta press_key, hover |
| Formularios | **fill_form**, **select_option** | ❌ (manual con click+type) | falta |
| Mouse | xy click/move/**wheel(scroll)**/drag | move_human ✅, click ✅, wheel(scroll) ❌, drag ❌ | falta scroll + drag |
| Diálogos | **handle_dialog** (alert/confirm) | ❌ | falta (bloquea flujos) |
| Subida archivos | **file_upload** | ❌ | falta |
| JS | evaluate | evaluate ✅ | paridad |
| Consola | **console_messages** | ❌ | falta (debug + detección) |
| Cookies | get/list/set/delete/clear | set ✅, resto ❌ (planeado) | en gaps tanda 1 |
| Storage local | **localStorage/sessionStorage** CRUD | ❌ | falta |
| Estado de sesión | **storage_state** save/restore | ❌ | falta (login persistente) |
| Network captura | requests list + inspect, **HAR** | HAR ✅, list/inspect en vivo ❌ | HAR cubre; falta inspección puntual |
| Network mock | **route/abort/fulfill** (intercept) | ❌ (sí mitmproxy externo) | falta intercept inline CDP |
| Network emulación | online/offline, throttle | ❌ | falta |
| Emulación device | **emulate** (device/CPU), **resize** viewport | ❌ | falta |
| Screenshot | screenshot, **PDF** | screenshot ✅, PDF ❌ | falta PDF |
| Performance | **trace + lighthouse** | ❌ | falta (nicho) |
| Anti-bot humanizado | — (ellos NO tienen) | click_human, move_human, jitter ✅ | **ventaja nuestra** |
| Captura tráfico proxy | — (vía HAR) | web_proxy mitmproxy ✅ | **ventaja nuestra** |
| Perfiles (CRUD disco) | — (ellos NO gestionan perfiles) | create/delete/prepare/appearance/reset ✅ | **ventaja nuestra** |
**Conclusión**: en lectura/selección/JS/click hay paridad. Nuestras ventajas: humanización anti-bot,
captura mitmproxy y gestión de perfiles en disco (Playwright/CDP-MCP no hacen nada de esto). Nos
faltan, además de los gaps de la lista 1 (tabs/iframes/cookies/ventanas/historial), las capacidades
de abajo (tanda 2) para alcanzar paridad de automatización.
---
## Pendiente (gaps a construir)
> ✅ **CERRADOS en la tanda mínima viable (06/06/2026)**: #2 (tabs close/activate), #3 (cookies get/delete/clear),
> #6 (iframes) + tanda 2 #10 (press_key), #11 (handle_dialog), #13 (storage_state), #14 (scroll), #15 (nav back/forward).
> Ver sección **Hecho**. Lo de abajo es lo que QUEDA.
- [ ] 1. **CRUD de ventanas** — funciones nuevas dominio `browser`:
- `cdp_list_windows``Browser.getWindowForTarget` por cada target → id de ventana + bounds.
- `cdp_set_window_bounds` — mover/redimensionar/maximizar/minimizar (`Browser.setWindowBounds`).
- `cdp_new_window` — abrir ventana nueva (Target con `newWindow:true`) vs pestaña.
- `cdp_close_window` — cerrar una ventana concreta sin matar todo el proceso.
- [ ] 2. **Cerrar/activar pestaña individual** — hoy `cdp_close` mata el proceso entero o cierra la conexión:
- `cdp_close_tab``Target.closeTarget(targetId)` (cierra UNA pestaña).
- `cdp_activate_tab``Target.activateTarget` / `/json/activate/<id>` (traer al frente).
- [ ] 3. **Cookies completas** — hoy solo `cdp_set_cookie`:
- `cdp_get_cookies``Network.getCookies` / `getAllCookies` (leer, filtrar por dominio).
- `cdp_delete_cookies``Network.deleteCookies`.
- `cdp_clear_cookies``Network.clearBrowserCookies` (wipe completo).
- [ ] 4. **Historial del navegador** — no existe nada:
- `cdp_get_history` — leer historial (vía `History` DB del perfil o `Page.getNavigationHistory` para la sesión actual).
- `cdp_clear_history` — limpiar historial del perfil (decidir: SQLite del perfil con Chromium cerrado, como bookmarks).
- [ ] 5. **Marcadores CRUD individual** — hoy solo backup/restore byte a byte:
- `cdp_add_bookmark` / `cdp_remove_bookmark` / `cdp_list_bookmarks` — editar el archivo `Bookmarks` (JSON)
preservando el checksum, o vía CDP si hay endpoint. Complementa, no sustituye, backup/restore.
- [ ] 6. **CRUD de iframes** — solo hay lectura indirecta en `extract_hls_from_cdp_tab`:
- `cdp_list_frames` — árbol de frames (`Page.getFrameTree`): id, url, parent.
- `cdp_eval_in_frame` — ejecutar JS en el **contexto de ejecución** de un iframe concreto
(`Runtime.evaluate` con el `executionContextId`/`uniqueContextId` del frame).
- `cdp_get_frame_html` — HTML de un iframe específico.
- (opcional) `cdp_navigate_frame` — navegar un iframe a otra URL.
- [ ] 7. **Texto plano de página dedicado** — hoy se saca con `cdp_evaluate("document.body.innerText")`:
- `cdp_get_text` — función dedicada que devuelve el texto visible limpio (útil para LLM/scraping rápido).
Decidir si vale la pena o si el patrón vía evaluate es suficiente (no inflar por inflar).
- [ ] 8. **Configuración de detalles genérica** — hoy solo apariencia + flag CDP + policy extensiones:
- Evaluar si hace falta `set_chrome_profile_pref` (editar `Preferences` del perfil: idioma, descargas,
permisos por defecto, etc.) o si se cubre caso por caso. NO construir hasta tener caso real.
- [ ] 9. **Playground del proyecto**`web_scraping` **no tiene** `playground/` (sí lo tienen `analysis/nats`
y `message_bus/unibus`). Candidato: un `playground/` con UI mínima (server single-file) que liste las
capacidades CDP y deje lanzarlas contra una pestaña viva para probarlas visualmente. Opcional, solo si
aporta para validar las funciones nuevas.
### Tanda 2 — gaps detectados en el benchmark (paridad con Playwright/CDP-MCP)
Prioridad ALTA (bloquean automatización real, los construiría antes que ventanas/historial):
- [x] 10. **`cdp_press_key`** ✅ HECHO — `Input.dispatchKeyEvent` con tabla de teclas especiales.
- [x] 11. **`cdp_handle_dialog`** ✅ HECHO — auto-handler `Page.javascriptDialogOpening` (con `go sendCDP` anti-deadlock).
- [ ] 12. **`cdp_get_console`** — capturar mensajes de consola y excepciones JS
(`Runtime.consoleAPICalled` + `Runtime.exceptionThrown`). Debug + detección de errores de la página.
**PENDIENTE** — único ALTA de tanda 2 sin construir. Sube a P1 (ver análisis LLM-readiness).
- [x] 13. **`cdp_save_storage_state` / `cdp_load_storage_state`** ✅ HECHO — cookies + localStorage a archivo.
⚠️ Falta `sessionStorage` y forzar navigate-first (ver deuda P2 del análisis).
- [x] 14. **`cdp_scroll`** ✅ HECHO — `Input.dispatchMouseEvent mouseWheel`. ⚠️ punto (100,100) hardcodeado (deuda P1).
- [x] 15. **`cdp_nav_back` / `cdp_nav_forward`** ✅ HECHO — `Page.getNavigationHistory` + `navigateToHistoryEntry`.
Prioridad MEDIA (formularios, storage fino, subida, intercept):
- [ ] 16. **`cdp_select_option`** — seleccionar valor en `<select>` (vía `cdp_evaluate` envuelto o CDP).
- [ ] 17. **`cdp_hover`** — hover sobre elemento (`Input.dispatchMouseEvent mouseMoved`) para menús
desplegables que aparecen al pasar el ratón.
- [ ] 18. **`cdp_file_upload`** — adjuntar archivos a un `<input type=file>` (`DOM.setFileInputFiles`).
- [ ] 19. **`cdp_storage_get` / `set` / `clear`** — CRUD de localStorage y sessionStorage (vía evaluate
o `DOMStorage` domain). Útil si no se quiere todo el storage_state.
- [ ] 20. **`cdp_intercept_requests`** — interceptar/abortar/modificar/mockear peticiones inline vía
`Fetch.enable` (bloquear ads/trackers, mockear respuestas, inyectar headers). Complementa, no
sustituye, al `web_proxy` mitmproxy (este es inline sin proxy externo).
- [ ] 21. **`cdp_emulate_network`** — online/offline + throttle (`Network.emulateNetworkConditions`).
- [ ] 22. **`cdp_save_pdf`** — guardar la página como PDF (`Page.printToPDF`).
Prioridad BAJA (formularios compuestos, emulación device, performance, drag):
- [ ] 23. **`cdp_fill_form`** — rellenar varios campos de una (composición de find+click+type, candidato
a **pipeline** no a función — encaja con la doctrina de promover composiciones).
- [ ] 24. **`cdp_emulate_device`** — viewport/userAgent/touch móvil (`Emulation.setDeviceMetricsOverride`).
- [ ] 25. **`cdp_drag_drop`** — drag and drop entre elementos.
- [ ] 26. **Performance/Lighthouse**`cdp_perf_trace` (Tracing domain) + audit Lighthouse. Nicho, solo
si aparece caso de análisis de rendimiento web.
## En curso
- [~] (ninguna ahora mismo — documento recién creado)
## Hecho (lo que YA tenemos)
- [x] **Tanda de deuda A+D+E+B — 4 fixes + 8/8 e2e** (06/06/2026)
- **A** aislamiento robusto: `chrome_launch` usa el binario real (salta el wrapper que pisaba flags).
- **D** `sessionStorage` añadido a `storage_state` (save+load). Validado por prueba e2e 6.
- **E** `cdp_find_by_text` devuelve error en no-encontrado (antes vacío silencioso). Validado por prueba 7.
- **B** fin del fire-and-forget: `cdp_click` verifica visibilidad, `cdp_type_text` verifica foco. Validado por prueba 8.
- La batería e2e pasó de 5 a 8 pruebas, todas verdes. Pendiente: C (Enter en widgets JS), `cdp_scroll`
con target (P1.5), puente percepción→acción por nodeId (P1.3).
- enlace: functions/browser/{chrome_launch,cdp_save_storage_state,cdp_load_storage_state,cdp_find_by_text,cdp_click,cdp_type_text}.go
- [x] **Fase C — validación e2e real: 5/5 PASS** (06/06/2026, headless + ventana visible)
- resultado: batería `projects/web_scraping/demo_e2e/` contra sitios sandbox (quotes/books.toscrape.com,
the-internet.herokuapp.com). 5 tareas simples→complejas: extracción estructurada, percepción AX,
teclado/form, **login persistente (storage_state)**, scraping paginado+dedup. Cliente MCP stdio
secuencial. Chrome aislado en 9333.
- **3 bugs reales encontrados y arreglados ejecutando** (lo que "compila" no detecta):
`page_perceive` (args posicionales a fn run), `cdp_save_storage_state` (filtrar cookies por dominio),
`cdp_load_storage_state` (añadir `url` por cookie para httpOnly). Login persistente ahora funciona.
- enlace: projects/web_scraping/demo_e2e/RESUMEN.md + results/
- [x] **`browser_mcp` v1 — servidor MCP de control de navegador** (Go, 06/06/2026)
- resultado: app en `projects/web_scraping/apps/browser_mcp/` (sub-repo Gitea, `git init` hecho).
**36 tools** (v0.2.0), pool de conexiones por puerto, stdio + `--http` + `--read-only`. **Build verde**
(smoke `tools/list`=36). Registrado en `projects/web_scraping/.mcp.json` como server `browser`.
- ✅ Fase B.5 (P0 LLM-readiness) cerrada: Chrome aislado 9333, `tab_select` determinista, `page_get_text`,
`page_perceive`. Pendiente Fase C (e2e real contra Chrome) + P1 (verificación post-acción).
- enlace: projects/web_scraping/apps/browser_mcp/ — patrón `apps/registry_mcp`.
- [x] **Fix bug `%v` en `cdp_evaluate` + `cdp_eval_in_frame`** (06/06/2026)
- resultado: objetos/arrays JS ahora se serializan con `json.Marshal` (antes repr de Go inservible).
Build+vet+test del paquete `browser` verdes. Reindexado.
- enlace: functions/browser/cdp_evaluate.go, cdp_eval_in_frame.go
- [x] **Tanda mínima viable del MCP — 15 funciones CDP nuevas** (Go, dominio `browser`, 06/06/2026)
- resultado: 5 `fn-constructor` en paralelo. Compila (`go build`/`vet`/`test` verdes), indexado (`fn index`),
15 entradas confirmadas. Tag de grupo `navegator` en todas.
- **Tabs/navegación**: `cdp_close_tab_go_browser`, `cdp_activate_tab_go_browser` (registradas — el código ya
vivía en `cdp_list_tabs.go`), `cdp_nav_back_go_browser`, `cdp_nav_forward_go_browser`.
- **Iframes**: `cdp_list_frames_go_browser`, `cdp_eval_in_frame_go_browser`, `cdp_get_frame_html_go_browser`.
- **Cookies**: `cdp_get_cookies_go_browser`, `cdp_delete_cookies_go_browser`, `cdp_clear_cookies_go_browser`.
- **Input/diálogos**: `cdp_press_key_go_browser`, `cdp_scroll_go_browser`, `cdp_handle_dialog_go_browser`.
- **Sesión**: `cdp_save_storage_state_go_browser`, `cdp_load_storage_state_go_browser` (login persistente).
- enlace: functions/browser/cdp_*.go — cierra gaps #2, #3, #6(cookies), #9 + tanda2 #10/#11/#13/#14/#15.
- [x] **Perfiles — CRUD completo** (Bash, dominio `browser`)
- resultado: Create `create_chrome_profile`, Read `list_chrome_profiles` (+ `list_chrome_profile_extensions`),
Delete `delete_chrome_profile`, Update `set_chrome_profile_appearance` (avatar + color de tema) /
`prepare_chrome_profile` (clonar limpio) / `reset_chrome_profiles` (pipeline reset destructivo con
preservación de bookmarks).
- enlace: bash/functions/browser/, bash/functions/pipelines/reset_chrome_profiles.md
- [x] **Lanzador personalizado** (Go + Bash)
- resultado: `chrome_launch` (remote debugging, multi-OS, mata árbol de proceso),
`launch_chromium_proxy` (perfil aislado apuntando a proxy mitm/Burp),
`chrome_load_extensions` (unpacked), `apply_chromium_cdp_flag` (CDP global del sistema),
`apply_chromium_extension_policy` + `install_chromium_proxy_extension` (distribuir extensiones),
y subcomando `launch` de `script_navegador`.
- enlace: functions/browser/chrome_launch.go, bash/functions/browser/
- [x] **Pestañas — crear / listar / navegar**
- resultado: `cdp_new_tab`, `cdp_open_url_and_wait` (crear+navegar+esperar load), `cdp_list_tabs`,
`cdp_navigate`. (Falta cerrar/activar una pestaña concreta → Pendiente #2.)
- enlace: functions/browser/cdp_new_tab.md, cdp_list_tabs.go, cdp_navigate.go
- [x] **Lectura de página — HTML + Accessibility tree**
- resultado: `cdp_get_html` (DOM vivo post-JS), `cdp_get_ax_tree` (pipeline Python, AX tree completo).
Texto plano hoy vía `cdp_evaluate` (ver Pendiente #7).
- enlace: functions/browser/cdp_get_html.go, python/functions/pipelines/cdp_get_ax_tree.md
- [x] **Selección de elementos del DOM**
- resultado: `cdp_find_by_text` (texto→selector CSS único), `cdp_wait_element` (polling existencia),
`cdp_pick_element_js` (picker interactivo: hover overlay + click captura selector/XPath/bbox),
querySelector arbitrario vía `cdp_evaluate`.
- enlace: functions/browser/cdp_find_by_text.go, cdp_pick_element_js.js, cdp_wait_element.go
- [x] **Lanzamiento de JS en la página**
- resultado: `cdp_evaluate` (expresión JS arbitraria, soporta await, reporta excepciones),
steps `js` de `cdp_extract_recipe` (recipe YAML).
- enlace: functions/browser/cdp_evaluate.go, python/functions/pipelines/cdp_extract_recipe.md
- [x] **Marcadores — backup / restore**
- resultado: `backup_chrome_bookmarks` + `restore_chrome_bookmarks` (copia byte a byte preservando
checksum, sin reserializar JSON). CRUD individual de bookmarks pendiente (#5).
- enlace: bash/functions/browser/backup_chrome_bookmarks.sh, restore_chrome_bookmarks.sh
- [x] **Cookies — set**
- resultado: `cdp_set_cookie` (incl. HttpOnly, para auth e2e). get/delete/clear pendientes (#3).
- enlace: functions/browser/cdp_set_cookie.go
- [x] **Extras (capital acumulado, fuera de la lista pedida)**
- resultado: captura de tráfico `cdp_har_record` (HAR 1.2) + app `web_proxy` (mitmproxy siempre activo);
interacción humanizada `cdp_click_human` / `cdp_move_mouse_human` / `cdp_type_text` (anti-detección);
esperas inteligentes `cdp_wait_idle` / `cdp_wait_load` / `cdp_wait_element`; `cdp_screenshot`;
pipeline `extract_hls_from_cdp_tab` (manifests HLS de tabs+iframes).
- enlace: functions/browser/, projects/web_scraping/apps/web_proxy
## MCP full-CDP (`browser_mcp`) — estado e iteración
**Meta**: un servidor MCP que da a cualquier agente Claude control total del navegador vía CDP.
Nombre **resuelto: `browser_mcp`** (genérico, para todo control de navegador, no solo CDP).
### Estado actual — v1 construido (06/06/2026)
- **App**: `projects/web_scraping/apps/browser_mcp/` (Go, sub-repo Gitea propio, `git init` hecho).
Patrón `registry_mcp`: `github.com/mark3labs/mcp-go` v0.52.0, archivos `tools_<grupo>.go`, registro en
`main.go`, stdio por defecto + `--http` opcional + flag `--read-only`. **Build verde, 33 tools.**
- **Pool de conexiones** (resuelto: pool por puerto, NO connect-per-tool). Ver explicación abajo.
- **Registro**: `projects/web_scraping/.mcp.json` con el server `browser`.
- **Omitido v1**: `cdp_har_record` (requiere callback), `cdp_get_ax_tree` (pipeline Python), perfiles Bash
(requieren Chrome cerrado → incompatible con un Chrome vivo).
### Pool de conexiones — por qué es requisito, no opción
`browser.CdpConnect(port)` hace un handshake WebSocket a una tab "page" de Chrome (~50-200 ms) y esa
conexión ES una sesión viva (Page.*/Runtime.*/Input.* + un `readLoop` en goroutine + event handlers).
Si reconectáramos en cada tool: (1) pagaríamos el handshake cada vez, (2) **perderíamos estado entre
tools** — los handlers de eventos (p.ej. el auto-handler de `handle_dialog`) mueren al cerrar la conexión.
Por eso `browser_mcp` mantiene `map[port]→*CDPConn`: la primera tool que toca el puerto abre la conexión,
las siguientes la reusan; se cierra al apagar el MCP o con `browser_disconnect`. **Sin pool, encadenar
navigate→wait→click→eval es imposible** (cada `fn run` suelto pierde la conexión al terminar el proceso).
### ⚠️ Deuda crítica (análisis LLM-readiness) — ver sección dedicada abajo
El v1 **envuelve las funciones tal cual**. Un LLM percibe-decide-actúa-verifica; las funciones están
hechas para un script que ya sabe qué hacer. Antes de declarar el MCP "usable por un agente" hay que
cerrar los P0 de la sección siguiente (percepción compacta + verificación + target determinista). El
valor del MCP está en lo que AÑADE encima, no en el wrapping.
### Fases
- **Fase 0** — inventario + catálogo + plan. ✅
- **Fase A** — 15 funciones gap de la tanda mínima viable. ✅ (5 fn-constructor paralelos)
- **Fase B** — ensamblar `browser_mcp` v1 (33 tools, pool, build verde). ✅
- **Fase B.5 — NUEVO, BLOQUEANTE** — cerrar los P0 del análisis (abajo) y elevar el MCP de "wrapper" a
"capa de agente": percepción compacta, verificación post-acción, target determinista.
- **Fase C** — registrar en Claude + e2e (un agente abre Chrome aislado, navega, lee, click, extrae) +
documentar en `LLM_BROWSER_GUIDE.md`.
### Capability group
Crear/actualizar `docs/capabilities/browser.md` con el cluster completo (39 funciones + 15 nuevas) +
ejemplo canónico end-to-end + las tools del MCP, para cargar el grupo en un solo read.
---
## Análisis LLM-readiness — deuda P0/P1/P2
Crítica recibida (06/06/2026): el núcleo CDP es sólido y la cobertura de verbos casi completa, pero el
sistema está hecho para un programa que ya sabe qué hacer, no para un LLM que percibe-decide-actúa-verifica.
Faltan las dos piezas que un agente necesita: **percibir sin colapsar el contexto** y **saber si la acción
funcionó**. Evaluado contra el bucle PERCIBIR → DECIDIR → ACTUAR → VERIFICAR (+ ESTADO transversal).
### Ya corregido este turno
- [x] **BUG GRAVE `cdp_evaluate` / `cdp_eval_in_frame`** — usaban `fmt.Sprintf("%v", value)`; objetos/arrays
JS llegaban como repr de Go (`map[a:1]`) en vez de JSON. Arreglado: `json.Marshal` para no-strings.
(Sin esto el scraping de datos estructurados era inservible.)
- [x] **Comentario mentiroso de `cdp_navigate`** — decía "espera Page.loadEventFired"; el código solo lanza
`Page.navigate`. Comentario corregido: NO espera carga, hay que encadenar `CdpWaitLoad`/`CdpWaitIdle`.
### P0 — CERRADOS (06/06/2026, Fase B.5) ✅
- [x] **P0.1 `render_ax_outline`** ✅ — `render_ax_outline_py_core` (puro: nodos AX → outline indentado con
`#ref`) + pipeline `cdp_perceive_outline_py_pipelines` (compone `cdp_get_ax_tree` + `trim_ax_tree` +
`render_ax_outline`). Expuesto como tool `page_perceive` del MCP (vía `fn run`). La pieza de PERCEPCIÓN.
- [x] **P0.2 Lecturas con límite** ✅ — `cdp_get_text_go_browser` (texto visible, selector opcional, `maxBytes`
con corte rune-safe). Tool `page_get_text` del MCP (default 20000 bytes). `get_html` se deja intacto
(sin romper firma); el truncado de HTML vive en la tool del MCP (200k). Decisión KISS documentada.
- [x] **P0.3 Target determinista + Chrome aislado** ✅ — `cdp_connect_target_go_browser` (elige target por id o
substring de URL) + refactor `cdp_connect.go` (helper `cdpConnectWS`). En el MCP: **default puerto 9333 =
Chrome aislado del MCP** (no el 9222 diario), `browser_launch` con `user_data_dir` dedicado, y tool
`tab_select` para fijar la pestaña determinísticamente. **Cierra el riesgo de operar sobre banca/correo.**
- [x] **Aislamiento robusto del binario** ✅ (06/06) — `chrome_launch` usa el binario real
`/usr/lib/chromium/chromium` (salta el wrapper `/etc/chromium.d/cdp` que inyectaba flags globales).
Antes el aislamiento dependía del orden de los flags.
### P1 — fiabilidad de acción
- [x] **P1.1 Verificación post-acción** ✅ (06/06) — `cdp_click` verifica visibilidad (oculto → error, no clic
en (0,0)); `cdp_type_text` verifica foco editable (sin foco → error). Validado por prueba e2e 8. PENDIENTE:
`cdp_scroll`/`press_key` con confirmación de efecto (menor).
- [x] **P1.2 `cdp_find_by_text` honesto** ✅ (06/06) — ahora devuelve error explícito en no-encontrado (antes
`("", nil)` silencioso). Validado por prueba e2e 7. (Aviso de múltiples matches: pendiente, menor.)
- [ ] **P1.3 Puente percepción→acción** — el LLM percibe en AX tree (role/name/nodeId) pero actúa por selector
CSS. Falta `click(nodeId)`/`act(#ref)` para actuar por ref del snapshot (como Playwright), sin adivinar CSS.
- [x] **P1.4 `cdp_type_text` verifica focus** ✅ (06/06) — error claro si no hay campo editable enfocado
(antes el texto iba a `document.body` en silencio). Validado por prueba e2e 8.
- [ ] **P1.5 `cdp_scroll` con target** — punto (100,100) hardcodeado; si ahí hay un navbar fixed no scrollea el
contenido. Permitir x,y o selector/elemento objetivo.
- [ ] **P1.6 `cdp_get_console`** (era tanda2 #12) — capturar consola + excepciones; el LLM detecta errores de página.
### P2 — paridad
- [ ] **P2.1** `cdp_select_option`, `cdp_file_upload`, `cdp_hover` (ver tanda 2 #16-18).
- [x] **P2.2 `storage_state`** ✅ (06/06) — cookies (filtradas por dominio) + localStorage + **sessionStorage**
+ login persistente, todo validado e2e (pruebas 4 y 6). `load` sigue exigiendo navigate-first al dominio (documentado).
> Implicación: el MCP NO es "envolver 39 funciones". Es la capa que arregla los 4 ejes (pool=estado,
> representaciones compactas=percepción, JSON real=ya hecho, verificación=acción fiable). Un MCP que solo
> expone las funciones hereda todos los defectos.
---
## Enlaces
- App orquestadora — projects/web_scraping/apps/script_navegador (CLI rápido: open/click/type/eval/html/shot/wait/tabs/launch/close/profiles + runner YAML)
- App captura — projects/web_scraping/apps/web_proxy (mitmproxy, service systemd-user puerto 8080)
- Guía LLM — projects/web_scraping/LLM_BROWSER_GUIDE.md
- Setup CDP global — projects/web_scraping/CHROMIUM_SYSTEM.md
- Dominio browser en registry — `mcp__registry__fn_search query="" domain="browser"`
## Issues / flows relacionados
- (ninguno aún — al priorizar los gaps, abrir issue por bloque o delegar a fn-constructor)
## Notas
- **Decisión de arquitectura**: el control de navegador es CDP crudo sobre Chrome/Chromium en Linux nativo,
no Playwright/Selenium. Toda capacidad reutilizable vive en `functions/browser/`; las apps solo orquestan.
- **Prioridad sugerida de gaps** (mayor impacto scraping/automatización primero):
1) Cerrar/activar pestaña individual (#2) y CRUD de iframes (#6) — bloquean automatización de SPAs reales.
2) Cookies completas (#3) — leer/borrar cookies es básico para sesiones y limpieza.
3) CRUD de ventanas (#1) — útil para multi-ventana y posicionamiento, menos urgente para scraping headless.
4) Historial (#4) y bookmarks CRUD (#5) — nicho, construir cuando haya caso concreto.
- **No inflar por inflar**: `cdp_get_text` (#7) y config genérica de prefs (#8) solo si aparece un caso real
repetido; el patrón vía `cdp_evaluate` puede ser suficiente (regla function_growth_and_self_docs).
- Cada función nueva del dominio `browser` debe declararse en `uses_functions` del `app.md` que la consuma
(el indexer no deduce deps en Go automáticamente para apps).
+128
View File
@@ -0,0 +1,128 @@
# Ejercicio e2e — validación de capacidades del browser_mcp
Fecha: 06/06/2026. Objetivo: comprobar, ejecutando de verdad contra sitios reales, que el servidor
`browser_mcp` (control de navegador vía CDP) puede hacer tareas distintas de recopilación de datos —
de simples a complejas. No "compila", sino "funciona".
## Montaje
- **Servidor**: `projects/web_scraping/apps/browser_mcp/browser_mcp` (36 tools, pool de conexiones).
- **Navegador**: Chrome/Chromium 148 aislado en el puerto CDP **9333** (no el 9222 del navegador diario),
`user-data-dir` dedicado. Lanzado headless para la batería y luego **sin headless** (ventana visible)
para inspección humana — ambos con 5/5.
- **Cliente**: `mcp_client.py` — cliente JSON-RPC stdio **secuencial** (espera la respuesta de cada tool
antes de mandar la siguiente, como hace un cliente MCP real). Un primer intento mandando todos los
mensajes de golpe falló por una race: el servidor procesa requests de forma concurrente.
- **Runner**: `run_demo.py` — ejecuta las 5 pruebas, guarda pasos/respuestas/veredicto en `results/`.
## Resultado: 8/8 PASS (headless y con ventana visible)
Las pruebas 1-5 son la batería inicial; 6-8 se añadieron para validar las tandas de fixes (A/D/E y B).
| # | Prueba | Sitio sandbox | Capacidades ejercitadas | Resultado |
|---|---|---|---|---|
| 1 | Extraer citas estructuradas | quotes.toscrape.com | navigate, wait_load, **eval → JSON real** | PASS — 10 citas `{text,author,tags}` |
| 2 | Percibir página como agente | the-internet.herokuapp.com | **page_perceive** (AX outline) | PASS — outline 4021 chars con `#ref` accionables |
| 3 | Submit de formulario con teclado | the-internet.herokuapp.com/login | dom_click, dom_type, **press_key Enter**, wait_element | PASS — "You logged into a secure area!" |
| 4 | Login + sesión persistente | the-internet.herokuapp.com | form, **storage_save/load**, cookie_clear | PASS — sesión restaurada sin re-login |
| 5 | Scraping paginado + dedup | books.toscrape.com | navegación multi-página, eval, composición | PASS — 60 libros únicos (3 páginas) |
| 6 | sessionStorage en storage_state | the-internet.herokuapp.com | set→save→clear→load→get | PASS — `clear=null`, `restore=demo_v` (fix D) |
| 7 | `find_by_text` honesto | quotes.toscrape.com | dom_find_by_text presente vs inexistente | PASS — texto inexistente devuelve error, no vacío (fix E) |
| 8 | Verificación post-acción | quotes.toscrape.com | dom_click sobre oculto / dom_type sin foco | PASS — ambos devuelven error en vez de actuar al vacío (fix B) |
Cobertura conjunta: lanzar/atar navegador, navegar, esperar carga, evaluar JS con JSON real, percibir
(AX outline), leer texto, teclado, formularios, cookies, estado de sesión, y composición multi-página.
## Valor del ejercicio: 3 bugs reales encontrados y arreglados
Ejecutar de verdad reveló defectos que "compila" jamás habría detectado:
1. **`page_perceive` roto** (bug de integración del MCP). Invocaba `fn run cdp_perceive_outline
--debug-port 9333 ...` con flags, pero `fn run` pasa los argumentos **posicionalmente** a la función
del pipeline → `int('--debug-port')` reventaba. Toda la percepción AX caía. Fix: argumentos
posicionales en el orden de la firma. (`tools_read.go`)
2. **`cdp_save_storage_state` guardaba cookies de todos los dominios.** Usaba `Network.getAllCookies`
(global), así que el `storage_state` arrastraba cookies de sitios visitados antes en la misma sesión
(en la prueba, cookies de Wikipedia contaminaban la sesión de the-internet). Fix: filtrar por el host
actual (`location.hostname`). (`cdp_save_storage_state.go`)
3. **`cdp_load_storage_state` no restauraba la sesión httpOnly.** `Network.setCookies` no aplicaba de
forma fiable la cookie de sesión (`rack.session`, httpOnly) porque a cada cookie le faltaba el campo
`url`. Fix: sintetizar `url` por cookie a partir de `domain`/`secure`/`path`. Con esto el login
persistente (la pieza estrella) funciona de verdad. (`cdp_load_storage_state.go`)
## Segunda tanda de fixes (A + D + E) — a partir del análisis de deuda
Tras la primera batería se atacaron tres deudas, cada una validada:
- **A — Aislamiento robusto del navegador del agente** (`chrome_launch.go`). El wrapper del sistema
`/etc/chromium.d/cdp` inyecta `--user-data-dir`/`--remote-debugging-port` globales a todo chromium;
el aislamiento dependía de que nuestros flags fueran al final (Chrome usa el último duplicado). Fix:
`findChrome` prefiere el **binario real** (`/usr/lib/chromium/chromium`), que al ejecutarse directo no
pasa por el wrapper y por tanto no hereda esos flags. Validado por construcción (el binario existe y va
primero; `browser_launch` devuelve PID correcto); la inspección observable del cmdline choca con el
exit-144 del entorno de pruebas al lanzar chromium desde el harness, no con el fix.
- **D — `sessionStorage` en `storage_state`** (`cdp_save_storage_state.go`, `cdp_load_storage_state.go`).
Antes solo cookies + localStorage. Ahora también el "cajón temporal". Validado por la prueba 6.
- **E — `cdp_find_by_text` honesto** (`cdp_find_by_text.go`). Antes devolvía `("", nil)` cuando no
encontraba (el caller creía que había encontrado algo). Ahora devuelve error explícito. Validado por la
prueba 7.
## Tercera tanda de fix (B) — verificación post-acción
- **B — fin del "fire-and-forget"** (`cdp_click.go`, `cdp_type_text.go`). `cdp_click` ahora verifica que
el elemento es **visible** antes de clicar (display:none / tamaño 0 / opacity 0 → error, en vez de clicar
en (0,0) sin efecto). `cdp_type_text` verifica que hay un **campo editable enfocado** (input/textarea/
select/contenteditable) antes de escribir (sin foco → error claro "usa CdpClick primero", en vez de
escribir a la nada). Validado por la prueba 8. Pendiente de esta familia: `cdp_scroll` con target
explícito (P1.5) y el puente percepción→acción por nodeId (P1.3).
## Hallazgos secundarios
- **`press_key Enter` no dispara widgets JS complejos.** Contra el buscador de Wikipedia (typeahead Vue
del skin Vector) el keyevent sintético se ejecutó sin error pero el widget no reaccionó. La prueba 3 se
reorientó a un formulario HTML normal (login de the-internet), donde Enter sí envía el form. Deuda: para
widgets JS-driven puede hacer falta disparar el evento del framework o submit explícito.
- **Las pruebas comparten un mismo Chrome**, por lo que el estado (cookies) se acumula entre ellas. Se
añadió `cookie_clear` al inicio de las pruebas con login para aislarlas. Un harness más estricto usaría
un contexto/perfil por prueba.
## Deuda pendiente (no bloqueó el ejercicio)
- `storage_state` aún no captura `sessionStorage` (sí cookies + localStorage). Suficiente para sesiones
basadas en cookie como the-internet; insuficiente para sitios que guardan el token en sessionStorage.
- Verificación post-acción (P1 del análisis LLM-readiness): `dom_click`/`dom_type` siguen siendo
fire-and-forget. En estas pruebas se compensó con `dom_wait_element` y checks por `eval`, pero las tools
no confirman su efecto por sí mismas.
## Cómo reproducir
```bash
# 1. Chrome aislado en 9333 (headless)
systemd-run --user -q --unit=browser_demo \
chromium --headless=new --remote-debugging-port=9333 \
--user-data-dir=/tmp/browser_mcp_userdata about:blank
# 1-bis. O con ventana visible (Linux con sesión gráfica):
systemd-run --user -q --unit=browser_demo \
--setenv=DISPLAY=:0 --setenv=XAUTHORITY=$HOME/.Xauthority \
chromium --remote-debugging-port=9333 \
--user-data-dir=/tmp/browser_mcp_visible --start-maximized about:blank
# 2. Compilar el MCP y ejecutar la batería
cd projects/web_scraping/apps/browser_mcp && go build -o browser_mcp .
cd ../../demo_e2e && python3 run_demo.py
# 3. Parar el navegador
systemctl --user stop browser_demo.service
```
## Archivos
- `mcp_client.py` — cliente MCP stdio secuencial (reutilizable).
- `run_demo.py` — las 5 pruebas.
- `results/prueba_N_*.json` — pasos, respuestas y datos extraídos por prueba.
- `results/run.log` — log de la corrida.
- `results/summary.json` — veredicto agregado.
- `results/mcp_stderr.log` — stderr del servidor MCP.
+108
View File
@@ -0,0 +1,108 @@
"""Cliente JSON-RPC stdio mínimo para hablar con un servidor MCP (mark3labs/mcp-go).
Secuencial por diseño: cada call() manda un request y bloquea hasta recibir la
respuesta con el mismo id, ignorando notificaciones intermedias. Esto replica
cómo un cliente MCP real (Claude) usa el servidor — a diferencia de mandar todos
los mensajes de golpe, que provoca una race porque el servidor procesa los
requests en goroutines concurrentes.
Un hilo lector vuelca stdout a una cola; _read_until consume de la cola con
timeout para no colgarse si una tool no responde.
"""
import json
import os
import queue
import subprocess
import threading
import time
class MCPClient:
def __init__(self, exe, env=None, cwd=None, stderr_path=None):
full_env = dict(os.environ)
if env:
full_env.update(env)
self._stderr = open(stderr_path, "w") if stderr_path else subprocess.DEVNULL
self.p = subprocess.Popen(
[exe],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=self._stderr,
text=True,
bufsize=1,
env=full_env,
cwd=cwd,
)
self._id = 0
self._q = queue.Queue()
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
def _read_loop(self):
for line in self.p.stdout:
line = line.strip()
if line:
self._q.put(line)
def _send(self, obj):
self.p.stdin.write(json.dumps(obj) + "\n")
self.p.stdin.flush()
def _read_until(self, want_id, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
try:
line = self._q.get(timeout=max(0.1, deadline - time.time()))
except queue.Empty:
break
try:
m = json.loads(line)
except json.JSONDecodeError:
continue
if m.get("id") == want_id:
return m
raise TimeoutError(f"sin respuesta para id {want_id} en {timeout}s")
def initialize(self):
self._id += 1
iid = self._id
self._send({
"jsonrpc": "2.0", "id": iid, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "demo_e2e", "version": "1"},
},
})
r = self._read_until(iid, 15)
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
return r
def call(self, name, arguments, timeout=60):
"""Llama una tool. Devuelve (texto, is_error)."""
self._id += 1
cid = self._id
self._send({
"jsonrpc": "2.0", "id": cid, "method": "tools/call",
"params": {"name": name, "arguments": arguments},
})
r = self._read_until(cid, timeout)
if "error" in r:
return json.dumps(r["error"]), True
res = r.get("result", {})
content = res.get("content", [])
text = "".join(c.get("text", "") for c in content if isinstance(c, dict))
return text, bool(res.get("isError", False))
def close(self):
try:
self.p.stdin.close()
except Exception:
pass
try:
self.p.wait(timeout=5)
except Exception:
self.p.kill()
if self._stderr not in (subprocess.DEVNULL, None):
self._stderr.close()
+68
View File
@@ -0,0 +1,68 @@
{
"name": "1 - Extraer citas estructuradas (quotes.toscrape.com)",
"verdict": "PASS",
"extracted_count": 10,
"sample": [
{
"author": "Albert Einstein",
"tags": [
"change",
"deep-thoughts",
"thinking",
"world"
],
"text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"
},
{
"author": "J.K. Rowling",
"tags": [
"abilities",
"choices"
],
"text": "“It is our choices, Harry, that show what we truly are, far more than our abilities.”"
},
{
"author": "Albert Einstein",
"tags": [
"inspirational",
"life",
"live",
"miracle",
"miracles"
],
"text": "“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”"
}
],
"steps": [
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://quotes.toscrape.com"
},
"ms": 735,
"is_error": false,
"response": "navigated to https://quotes.toscrape.com"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 437,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "[...document.querySelectorAll('.quote')].map(q=>({text:q.querySelector('.text').innerText,author:q.querySelector('.author').innerText,tags:[...q.querySelectorAll('.tag')].map(t=>t.innerText)}))"
},
"ms": 2,
"is_error": false,
"response": "[{\"author\":\"Albert Einstein\",\"tags\":[\"change\",\"deep-thoughts\",\"thinking\",\"world\"],\"text\":\"“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”\"},{\"author\":\"J.K. Rowling\",\"tags\":[\"abilities\",\"choices\"],\"text\":\"“It is our choices, Harry, that show what we truly are, far more than our abilities.”\"},{\"author\":\"Albert Einstein\",\"tags\":[\"inspirational\",\"life\",\"live\",\"miracle\",\"miracles\"],\"text\":\"“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”\"},{\"author\":\"Jane Austen\",\"tags\":[\"aliteracy\",\"books\",\"classic\",\"humor\"],\"text\":\"“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.”\"},{\"author\":\"Marilyn Monroe\",\"tags\":[\"be-yourself\",\"inspirational\"],\"text\":\"“Imperfection is beauty, madness is genius and it's better to be absolutely ridiculous than absolutely boring.”\"},{\"author\":\"Albert Einstein\",\"tags\":[\"adulthood\",\"success\",\"value\"],\"text\":\"“Try not to become a man of success. Rather become a man of value.”\"},{\"author\":\"André Gide\",\"tags\":[\"life\",\"love\"],\"text\":\"“It is better to be hated for what you are than to be loved for what you are not.”\"},{\"author\":\"Thomas A. Edison\",\"tags\":[\"edison\",\"failure\",\"inspirational\",\"paraphrased\"],\"text\":\"“I have not failed. I've just found 10,000 ways that won't work.”\"},{\"author\":\"Eleanor Roosevelt\",\"tags\":[\"misattributed-eleanor-roosevelt\"],\"text\":\"“A woman is like a tea bag; you never know how strong it is until it's in hot water.”\"},{\"author\":\"Steve Martin\",\"tags\":[\"humor\",\"obvious\",\"simile\"],\"text\":\"“A day without sunshine is like, you know, night.”\"}]"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "2 - Percibir página (page_perceive AX outline)",
"verdict": "PASS",
"has_refs": true,
"has_link": true,
"outline_len": 4021,
"outline_preview": "RootWebArea \"The Internet\"\ngeneric\n generic\n separator\n generic\n StaticText \"Powered by \"\n InlineTextBox \"Powered by \"\n link \"Elemental Selenium\" #ref=169\n StaticText \"Elemental Selenium\"\n InlineTextBox \"Elemental \"\n InlineTextBox \"Selenium\"\nlink \"Fork me on GitHub\" #ref=72\n image \"Fork me on GitHub\"\ngeneric\n heading \"Welcome to the-internet\"\n StaticText \"Welcome to the-internet\"\n InlineTextBox \"Welcome to the-internet\"\n heading \"Available Examples\"\n StaticText \"Available Examples\"\n InlineTextBox \"Available Examples\"\n list\n listitem\n link \"A/B Testing\" #ref=79\n StaticText \"A/B Testing\"\n InlineTextBox \"A/B Testing\"\n listitem\n link \"Add/Remove Elements\" #ref=81\n StaticText \"Add/Remove Elements\"\n InlineTextBox \"Add/Remove Elements\"\n listitem\n link \"Basic Auth\" #ref=83\n StaticText \"Basic Auth\"\n InlineTextBox \"Basic Auth\"\n StaticText \" (user and pass: admin)\"\n InlineTextBox \" (user and pass: admin)\"\n listitem\n link \"Broken Images\" #ref=85\n StaticText \"Broken Images\"\n InlineTextBox \"Broken Images\"\n listitem\n link \"Challenging DOM\" #ref=87\n StaticText \"Challenging DOM\"\n ",
"steps": [
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com"
},
"ms": 388,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 833,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_perceive",
"args": {
"port": 9333,
"max_chars": 4000
},
"ms": 125,
"is_error": false,
"response": "RootWebArea \"The Internet\"\ngeneric\n generic\n separator\n generic\n StaticText \"Powered by \"\n InlineTextBox \"Powered by \"\n link \"Elemental Selenium\" #ref=169\n StaticText \"Elemental Selenium\"\n InlineTextBox \"Elemental \"\n InlineTextBox \"Selenium\"\nlink \"Fork me on GitHub\" #ref=72\n image \"Fork me on GitHub\"\ngeneric\n heading \"Welcome to the-internet\"\n StaticText \"Welcome to the-internet\"\n InlineTextBox \"Welcome to the-internet\"\n heading \"Available Examples\"\n StaticText \"Available Examples\"\n InlineTextBox \"Available Examples\"\n list\n listitem\n link \"A/B Testing\" #ref=79\n StaticText \"A/B Testing\"\n InlineTextBox \"A/B Testing\"\n listitem\n link \"Add/Remove Elements\" #ref=81\n StaticText \"Add/Remove Elements\"\n InlineTextBox \"Add/Remove Elements\"\n listitem\n link \"Basic Auth\" #ref=83\n StaticText \"Basic Auth\"\n InlineTextBox \"Basic Auth\"\n StaticText \" (user and pass: admin)\"\n InlineTextBox \" (user and pass: admin)\"\n listitem\n link \"Broken Images\" #ref=85\n StaticText \"Broken Images\"\n InlineTextBox \"Broken Images\"\n listitem\n link \"Challenging DOM\" #ref=87\n StaticText \"Challenging DOM\"\n InlineTextBox \"Challenging DOM\"\n listitem\n link \"Checkboxes\" #ref=89\n StaticText \"Checkboxes\"\n InlineTextBox \"Checkboxes\"\n listitem\n link \"Context Menu\" #ref=91\n StaticText \"Context Menu\"\n InlineTextBox \"Context Menu\"\n listitem\n link \"Digest Authentication\" #ref=93\n StaticText \"Digest Authentication\"\n InlineTextBox \"Digest Au"
}
]
}
+118
View File
@@ -0,0 +1,118 @@
{
"name": "3 - Submit de formulario con teclado Enter (the-internet/login)",
"verdict": "PASS",
"flash": "You logged into a secure area!\n×",
"steps": [
{
"tool": "cookie_clear",
"args": {
"port": 9333
},
"ms": 1,
"is_error": false,
"response": "cookies cleared"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com/login"
},
"ms": 91,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com/login"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 249,
"is_error": false,
"response": "page loaded"
},
{
"tool": "dom_click",
"args": {
"port": 9333,
"selector": "#username"
},
"ms": 23,
"is_error": false,
"response": "clicked #username"
},
{
"tool": "dom_type",
"args": {
"port": 9333,
"text": "tomsmith"
},
"ms": 115,
"is_error": false,
"response": "typed text"
},
{
"tool": "dom_click",
"args": {
"port": 9333,
"selector": "#password"
},
"ms": 8,
"is_error": false,
"response": "clicked #password"
},
{
"tool": "dom_type",
"args": {
"port": 9333,
"text": "SuperSecretPassword!"
},
"ms": 279,
"is_error": false,
"response": "typed text"
},
{
"tool": "press_key",
"args": {
"port": 9333,
"key": "Enter"
},
"ms": 4,
"is_error": false,
"response": "pressed Enter"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 1,
"is_error": false,
"response": "page loaded"
},
{
"tool": "dom_wait_element",
"args": {
"port": 9333,
"selector": "#flash",
"timeout_ms": 8000
},
"ms": 435,
"is_error": false,
"response": "element appeared: #flash"
},
{
"tool": "page_get_text",
"args": {
"port": 9333,
"selector": "#flash",
"max_bytes": 200
},
"ms": 2,
"is_error": false,
"response": " You logged into a secure area!\n×"
}
]
}
@@ -0,0 +1,233 @@
{
"name": "4 - Login + sesión persistente (storage_state)",
"verdict": "PASS",
"logged_in": true,
"kicked_after_clear": true,
"restored_after_load": true,
"flash_login": " You logged into a secure area!\n×",
"flash_after_clear": " You must login to view the secure area!\n×",
"flash_restored": "{\"path\":\"/secure\",\"secure\":true}",
"steps": [
{
"tool": "cookie_clear",
"args": {
"port": 9333
},
"ms": 2,
"is_error": false,
"response": "cookies cleared"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com/login"
},
"ms": 96,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com/login"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 247,
"is_error": false,
"response": "page loaded"
},
{
"tool": "dom_click",
"args": {
"port": 9333,
"selector": "#username"
},
"ms": 4,
"is_error": false,
"response": "clicked #username"
},
{
"tool": "dom_type",
"args": {
"port": 9333,
"text": "tomsmith"
},
"ms": 93,
"is_error": false,
"response": "typed text"
},
{
"tool": "dom_click",
"args": {
"port": 9333,
"selector": "#password"
},
"ms": 9,
"is_error": false,
"response": "clicked #password"
},
{
"tool": "dom_type",
"args": {
"port": 9333,
"text": "SuperSecretPassword!"
},
"ms": 290,
"is_error": false,
"response": "typed text"
},
{
"tool": "dom_click",
"args": {
"port": 9333,
"selector": "button[type=submit]"
},
"ms": 10,
"is_error": false,
"response": "clicked button[type=submit]"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 2,
"is_error": false,
"response": "page loaded"
},
{
"tool": "dom_wait_element",
"args": {
"port": 9333,
"selector": "#flash",
"timeout_ms": 8000
},
"ms": 431,
"is_error": false,
"response": "element appeared: #flash"
},
{
"tool": "page_get_text",
"args": {
"port": 9333,
"selector": "#flash",
"max_bytes": 300
},
"ms": 3,
"is_error": false,
"response": " You logged into a secure area!\n×"
},
{
"tool": "storage_save",
"args": {
"port": 9333,
"path": "/tmp/demo_session.json"
},
"ms": 9,
"is_error": false,
"response": "storage state saved to /tmp/demo_session.json"
},
{
"tool": "cookie_clear",
"args": {
"port": 9333
},
"ms": 4,
"is_error": false,
"response": "cookies cleared"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com/secure"
},
"ms": 195,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com/secure"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 229,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_get_text",
"args": {
"port": 9333,
"selector": "#flash",
"max_bytes": 300
},
"ms": 0,
"is_error": false,
"response": " You must login to view the secure area!\n×"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com"
},
"ms": 92,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 241,
"is_error": false,
"response": "page loaded"
},
{
"tool": "storage_load",
"args": {
"port": 9333,
"path": "/tmp/demo_session.json"
},
"ms": 5,
"is_error": false,
"response": "storage state loaded from /tmp/demo_session.json"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com/secure"
},
"ms": 102,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com/secure"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 220,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "JSON.stringify({path:location.pathname,secure:document.body.innerText.includes('Secure Area')})"
},
"ms": 2,
"is_error": false,
"response": "{\"path\":\"/secure\",\"secure\":true}"
}
]
}
+115
View File
@@ -0,0 +1,115 @@
{
"name": "5 - Scraping paginado + dedup (books.toscrape.com, 3 páginas)",
"verdict": "PASS",
"total_scraped": 60,
"unique_count": 60,
"sample": [
{
"price": "£51.77",
"stock": "In stock",
"title": "A Light in the Attic"
},
{
"price": "£53.74",
"stock": "In stock",
"title": "Tipping the Velvet"
},
{
"price": "£50.10",
"stock": "In stock",
"title": "Soumission"
}
],
"steps": [
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://books.toscrape.com/catalogue/page-1.html"
},
"ms": 164,
"is_error": false,
"response": "navigated to https://books.toscrape.com/catalogue/page-1.html"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 659,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "[...document.querySelectorAll('.product_pod')].map(b=>({title:b.querySelector('h3 a').getAttribute('title'),price:b.querySelector('.price_color').innerText,stock:b.querySelector('.availability').innerText.trim()}))"
},
"ms": 3,
"is_error": false,
"response": "[{\"price\":\"£51.77\",\"stock\":\"In stock\",\"title\":\"A Light in the Attic\"},{\"price\":\"£53.74\",\"stock\":\"In stock\",\"title\":\"Tipping the Velvet\"},{\"price\":\"£50.10\",\"stock\":\"In stock\",\"title\":\"Soumission\"},{\"price\":\"£47.82\",\"stock\":\"In stock\",\"title\":\"Sharp Objects\"},{\"price\":\"£54.23\",\"stock\":\"In stock\",\"title\":\"Sapiens: A Brief History of Humankind\"},{\"price\":\"£22.65\",\"stock\":\"In stock\",\"title\":\"The Requiem Red\"},{\"price\":\"£33.34\",\"stock\":\"In stock\",\"title\":\"The Dirty Little Secrets of Getting Your Dream Job\"},{\"price\":\"£17.93\",\"stock\":\"In stock\",\"title\":\"The Coming Woman: A Novel Based on the Life of the Infamous Feminist, Victoria Woodhull\"},{\"price\":\"£22.60\",\"stock\":\"In stock\",\"title\":\"The Boys in the Boat: Nine Americans and Their Epic Quest for Gold at the 1936 Berlin Olympics\"},{\"price\":\"£52.15\",\"stock\":\"In stock\",\"title\":\"The Black Maria\"},{\"price\":\"£13.99\",\"stock\":\"In stock\",\"title\":\"Starving Hearts (Triangular Trade Trilogy, #1)\"},{\"price\":\"£20.66\",\"stock\":\"In stock\",\"title\":\"Shakespeare's Sonnets\"},{\"price\":\"£17.46\",\"stock\":\"In stock\",\"title\":\"Set Me Free\"},{\"price\":\"£52.29\",\"stock\":\"In stock\",\"title\":\"Scott Pilgrim's Precious Little Life (Scott Pilgrim #1)\"},{\"price\":\"£35.02\",\"stock\":\"In stock\",\"title\":\"Rip it Up and Start Again\"},{\"price\":\"£57.25\",\"stock\":\"In stock\",\"title\":\"Our Band Could Be Your Life: Scenes from the American Indie Underground, 1981-1991\"},{\"price\":\"£23.88\",\"stock\":\"In stock\",\"title\":\"Olio\"},{\"price\":\"£37.59\",\"stock\":\"In stock\",\"title\":\"Mesaerion: The Best Science Fiction Stories 1800-1849\"},{\"price\":\"£51.33\",\"stock\":\"In stock\",\"title\":\"Libertarianism for Beginners\"},{\"price\":\"£45.17\",\"stock\":\"In stock\",\"title\":\"It's Only the Himalayas\"}]"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://books.toscrape.com/catalogue/page-2.html"
},
"ms": 107,
"is_error": false,
"response": "navigated to https://books.toscrape.com/catalogue/page-2.html"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 632,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "[...document.querySelectorAll('.product_pod')].map(b=>({title:b.querySelector('h3 a').getAttribute('title'),price:b.querySelector('.price_color').innerText,stock:b.querySelector('.availability').innerText.trim()}))"
},
"ms": 4,
"is_error": false,
"response": "[{\"price\":\"£12.84\",\"stock\":\"In stock\",\"title\":\"In Her Wake\"},{\"price\":\"£37.32\",\"stock\":\"In stock\",\"title\":\"How Music Works\"},{\"price\":\"£30.52\",\"stock\":\"In stock\",\"title\":\"Foolproof Preserving: A Guide to Small Batch Jams, Jellies, Pickles, Condiments, and More: A Foolproof Guide to Making Small Batch Jams, Jellies, Pickles, Condiments, and More\"},{\"price\":\"£25.27\",\"stock\":\"In stock\",\"title\":\"Chase Me (Paris Nights #2)\"},{\"price\":\"£34.53\",\"stock\":\"In stock\",\"title\":\"Black Dust\"},{\"price\":\"£54.64\",\"stock\":\"In stock\",\"title\":\"Birdsong: A Story in Pictures\"},{\"price\":\"£22.50\",\"stock\":\"In stock\",\"title\":\"America's Cradle of Quarterbacks: Western Pennsylvania's Football Factory from Johnny Unitas to Joe Montana\"},{\"price\":\"£53.13\",\"stock\":\"In stock\",\"title\":\"Aladdin and His Wonderful Lamp\"},{\"price\":\"£40.30\",\"stock\":\"In stock\",\"title\":\"Worlds Elsewhere: Journeys Around Shakespeares Globe\"},{\"price\":\"£44.18\",\"stock\":\"In stock\",\"title\":\"Wall and Piece\"},{\"price\":\"£17.66\",\"stock\":\"In stock\",\"title\":\"The Four Agreements: A Practical Guide to Personal Freedom\"},{\"price\":\"£31.05\",\"stock\":\"In stock\",\"title\":\"The Five Love Languages: How to Express Heartfelt Commitment to Your Mate\"},{\"price\":\"£23.82\",\"stock\":\"In stock\",\"title\":\"The Elephant Tree\"},{\"price\":\"£36.89\",\"stock\":\"In stock\",\"title\":\"The Bear and the Piano\"},{\"price\":\"£15.94\",\"stock\":\"In stock\",\"title\":\"Sophie's World\"},{\"price\":\"£33.29\",\"stock\":\"In stock\",\"title\":\"Penny Maybe\"},{\"price\":\"£18.02\",\"stock\":\"In stock\",\"title\":\"Maude (1883-1993):She Grew Up with the country\"},{\"price\":\"£19.63\",\"stock\":\"In stock\",\"title\":\"In a Dark, Dark Wood\"},{\"price\":\"£52.22\",\"stock\":\"In stock\",\"title\":\"Behind Closed Doors\"},{\"price\":\"£33.63\",\"stock\":\"In stock\",\"title\":\"You can't bury them all: Poems\"}]"
},
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://books.toscrape.com/catalogue/page-3.html"
},
"ms": 110,
"is_error": false,
"response": "navigated to https://books.toscrape.com/catalogue/page-3.html"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 429,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "[...document.querySelectorAll('.product_pod')].map(b=>({title:b.querySelector('h3 a').getAttribute('title'),price:b.querySelector('.price_color').innerText,stock:b.querySelector('.availability').innerText.trim()}))"
},
"ms": 4,
"is_error": false,
"response": "[{\"price\":\"£57.31\",\"stock\":\"In stock\",\"title\":\"Slow States of Collapse: Poems\"},{\"price\":\"£26.41\",\"stock\":\"In stock\",\"title\":\"Reasons to Stay Alive\"},{\"price\":\"£47.61\",\"stock\":\"In stock\",\"title\":\"Private Paris (Private #10)\"},{\"price\":\"£23.11\",\"stock\":\"In stock\",\"title\":\"#HigherSelfie: Wake Up Your Life. Free Your Soul. Find Your Tribe.\"},{\"price\":\"£45.07\",\"stock\":\"In stock\",\"title\":\"Without Borders (Wanderlove #1)\"},{\"price\":\"£31.77\",\"stock\":\"In stock\",\"title\":\"When We Collided\"},{\"price\":\"£50.27\",\"stock\":\"In stock\",\"title\":\"We Love You, Charlie Freeman\"},{\"price\":\"£14.27\",\"stock\":\"In stock\",\"title\":\"Untitled Collection: Sabbath Poems 2014\"},{\"price\":\"£44.18\",\"stock\":\"In stock\",\"title\":\"Unseen City: The Majesty of Pigeons, the Discreet Charm of Snails \\u0026 Other Wonders of the Urban Wilderness\"},{\"price\":\"£18.78\",\"stock\":\"In stock\",\"title\":\"Unicorn Tracks\"},{\"price\":\"£25.52\",\"stock\":\"In stock\",\"title\":\"Unbound: How Eight Technologies Made Us Human, Transformed Society, and Brought Our World to the Brink\"},{\"price\":\"£16.28\",\"stock\":\"In stock\",\"title\":\"Tsubasa: WoRLD CHRoNiCLE 2 (Tsubasa WoRLD CHRoNiCLE #2)\"},{\"price\":\"£31.12\",\"stock\":\"In stock\",\"title\":\"Throwing Rocks at the Google Bus: How Growth Became the Enemy of Prosperity\"},{\"price\":\"£19.49\",\"stock\":\"In stock\",\"title\":\"This One Summer\"},{\"price\":\"£17.27\",\"stock\":\"In stock\",\"title\":\"Thirst\"},{\"price\":\"£19.09\",\"stock\":\"In stock\",\"title\":\"The Torch Is Passed: A Harding Family Story\"},{\"price\":\"£56.13\",\"stock\":\"In stock\",\"title\":\"The Secret of Dreadwillow Carse\"},{\"price\":\"£56.41\",\"stock\":\"In stock\",\"title\":\"The Pioneer Woman Cooks: Dinnertime: Comfort Classics, Freezer Food, 16-Minute Meals, and Other Delicious Ways to Solve Supper!\"},{\"price\":\"£56.50\",\"stock\":\"In stock\",\"title\":\"The Past Never Ends\"},{\"price\":\"£45.22\",\"stock\":\"In stock\",\"title\":\"The Natural History of Us (The Fine Art of Pretending #2)\"}]"
}
]
}
@@ -0,0 +1,89 @@
{
"name": "6 - sessionStorage en storage_state (fix D)",
"verdict": "PASS",
"cleared_value": "null",
"restored_value": "demo_v",
"json_has_sessionstorage": true,
"steps": [
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://the-internet.herokuapp.com"
},
"ms": 112,
"is_error": false,
"response": "navigated to https://the-internet.herokuapp.com"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 224,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "window.sessionStorage.setItem('demo_k','demo_v'); 'set'"
},
"ms": 4,
"is_error": false,
"response": "set"
},
{
"tool": "storage_save",
"args": {
"port": 9333,
"path": "/tmp/demo_ss.json"
},
"ms": 10,
"is_error": false,
"response": "storage state saved to /tmp/demo_ss.json"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "window.sessionStorage.clear(); 'cleared'"
},
"ms": 2,
"is_error": false,
"response": "cleared"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "String(window.sessionStorage.getItem('demo_k'))"
},
"ms": 1,
"is_error": false,
"response": "null"
},
{
"tool": "storage_load",
"args": {
"port": 9333,
"path": "/tmp/demo_ss.json"
},
"ms": 6,
"is_error": false,
"response": "storage state loaded from /tmp/demo_ss.json"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "String(window.sessionStorage.getItem('demo_k'))"
},
"ms": 1,
"is_error": false,
"response": "demo_v"
}
]
}
@@ -0,0 +1,49 @@
{
"name": "7 - find_by_text honesto: error en no-encontrado (fix E)",
"verdict": "PASS",
"found_present": "body > div > div:nth-of-type(1) > div:nth-of-type(2) > p > a",
"missing_is_error": true,
"missing_response": "cdp find by text: no se encontro elemento con texto \"ZZZ_texto_inexistente_42\"",
"steps": [
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://quotes.toscrape.com"
},
"ms": 122,
"is_error": false,
"response": "navigated to https://quotes.toscrape.com"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 232,
"is_error": false,
"response": "page loaded"
},
{
"tool": "dom_find_by_text",
"args": {
"port": 9333,
"text": "Login"
},
"ms": 7,
"is_error": false,
"response": "body > div > div:nth-of-type(1) > div:nth-of-type(2) > p > a"
},
{
"tool": "dom_find_by_text",
"args": {
"port": 9333,
"text": "ZZZ_texto_inexistente_42"
},
"ms": 5,
"is_error": true,
"response": "cdp find by text: no se encontro elemento con texto \"ZZZ_texto_inexistente_42\""
}
]
}
@@ -0,0 +1,68 @@
{
"name": "8 - Verificación post-acción: click oculto / type sin foco dan error (fix B)",
"verdict": "PASS",
"click_hidden_error": true,
"type_nofocus_error": true,
"steps": [
{
"tool": "tab_navigate",
"args": {
"port": 9333,
"url": "https://quotes.toscrape.com"
},
"ms": 112,
"is_error": false,
"response": "navigated to https://quotes.toscrape.com"
},
{
"tool": "page_wait_load",
"args": {
"port": 9333,
"timeout_ms": 12000
},
"ms": 212,
"is_error": false,
"response": "page loaded"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "var b=document.createElement('button');b.id='hidden_btn';b.textContent='x';b.style.display='none';document.body.appendChild(b);'injected'"
},
"ms": 2,
"is_error": false,
"response": "injected"
},
{
"tool": "dom_click",
"args": {
"port": 9333,
"selector": "#hidden_btn"
},
"ms": 2,
"is_error": true,
"response": "cdp click: elemento \"#hidden_btn\" existe pero no es visible/clickable (display:none, oculto, opacity 0 o tamaño 0)"
},
{
"tool": "page_eval_js",
"args": {
"port": 9333,
"expression": "if(document.activeElement){document.activeElement.blur();} document.body.focus(); 'blurred'"
},
"ms": 1,
"is_error": false,
"response": "blurred"
},
{
"tool": "dom_type",
"args": {
"port": 9333,
"text": "fantasma"
},
"ms": 1,
"is_error": true,
"response": "cdp type text: no hay campo de texto enfocado (activeElement: body); usa CdpClick sobre el input primero"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
[
{
"prueba": "prueba_1_quotes",
"verdict": "PASS",
"detail": "10 citas"
},
{
"prueba": "prueba_2_perceive",
"verdict": "PASS",
"detail": "outline 4021 chars, refs=True"
},
{
"prueba": "prueba_3_search",
"verdict": "PASS",
"detail": "flash='You logged into a secure area!\n×'"
},
{
"prueba": "prueba_4_login_session",
"verdict": "PASS",
"detail": "login=True restore=True"
},
{
"prueba": "prueba_5_books",
"verdict": "PASS",
"detail": "60 libros únicos"
},
{
"prueba": "prueba_6_session_storage",
"verdict": "PASS",
"detail": "clear='null' restore='demo_v'"
},
{
"prueba": "prueba_7_find_honesto",
"verdict": "PASS",
"detail": "present_ok=True miss_error=True"
},
{
"prueba": "prueba_8_verificacion",
"verdict": "PASS",
"detail": "click_hidden_err=True type_nofocus_err=True"
}
]
+332
View File
@@ -0,0 +1,332 @@
"""Ejecuta 5 pruebas e2e graduadas contra el servidor browser_mcp para validar
las capacidades de control de navegador (CDP) sobre sitios sandbox estables.
Cada prueba se conecta al Chrome aislado del MCP en el puerto 9333 (que debe
estar ya corriendo) y ejerce un conjunto de tools. Los resultados (pasos,
respuestas, veredicto y datos extraídos) se guardan en results/.
Uso:
python3 run_demo.py
Requisitos:
- Chrome/Chromium headless en CDP 9333 (Chrome aislado del MCP).
- Binario browser_mcp compilado.
- FN_REGISTRY_ROOT para que la tool page_perceive pueda invocar fn run.
"""
import json
import os
import time
from mcp_client import MCPClient
ROOT = "/home/enmanuel/fn_registry"
EXE = os.path.join(ROOT, "projects/web_scraping/apps/browser_mcp/browser_mcp")
RESULTS = os.path.join(os.path.dirname(__file__), "results")
PORT = 9333
os.makedirs(RESULTS, exist_ok=True)
class Recorder:
def __init__(self, client, log):
self.c = client
self.log = log
self.steps = []
def step(self, tool, args, timeout=60):
t0 = time.time()
try:
text, is_err = self.c.call(tool, args, timeout=timeout)
except Exception as e:
text, is_err = f"EXCEPTION: {e}", True
dt = round((time.time() - t0) * 1000)
rec = {"tool": tool, "args": args, "ms": dt, "is_error": is_err,
"response": text[:2000]}
self.steps.append(rec)
self.log.write(f" [{tool}] {dt}ms err={is_err} -> {text[:160]}\n")
self.log.flush()
return text, is_err
def save(name, payload):
path = os.path.join(RESULTS, name)
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def prueba_1_quotes(c, log):
"""Extraer citas estructuradas (valida fix %v: array JS -> JSON real)."""
r = Recorder(c, log)
r.step("tab_navigate", {"port": PORT, "url": "https://quotes.toscrape.com"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
expr = ("[...document.querySelectorAll('.quote')].map(q=>({"
"text:q.querySelector('.text').innerText,"
"author:q.querySelector('.author').innerText,"
"tags:[...q.querySelectorAll('.tag')].map(t=>t.innerText)}))")
text, err = r.step("page_eval_js", {"port": PORT, "expression": expr})
quotes = []
try:
quotes = json.loads(text)
except Exception:
pass
ok = (not err) and isinstance(quotes, list) and len(quotes) >= 10 \
and all("author" in q for q in quotes)
verdict = "PASS" if ok else "FAIL"
save("prueba_1_quotes.json", {
"name": "1 - Extraer citas estructuradas (quotes.toscrape.com)",
"verdict": verdict,
"extracted_count": len(quotes),
"sample": quotes[:3],
"steps": r.steps,
})
return verdict, f"{len(quotes)} citas"
def prueba_2_perceive(c, log):
"""Percibir página como outline AX accionable (P0.1)."""
r = Recorder(c, log)
r.step("tab_navigate", {"port": PORT, "url": "https://the-internet.herokuapp.com"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
text, err = r.step("page_perceive", {"port": PORT, "max_chars": 4000}, timeout=90)
has_refs = "#ref=" in text
has_link = "link" in text.lower()
ok = (not err) and has_refs and has_link and len(text) > 100
verdict = "PASS" if ok else "FAIL"
save("prueba_2_perceive.json", {
"name": "2 - Percibir página (page_perceive AX outline)",
"verdict": verdict,
"has_refs": has_refs, "has_link": has_link, "outline_len": len(text),
"outline_preview": text[:1500],
"steps": r.steps,
})
return verdict, f"outline {len(text)} chars, refs={has_refs}"
def prueba_3_search(c, log):
"""Submit de formulario con teclado: type + press_key Enter (sin click submit)."""
r = Recorder(c, log)
# Form HTML normal (the-internet/login): tras escribir credenciales, Enter
# envía el form. Valida type + press_key Enter de forma fiable, sin depender
# de un widget JS (como el typeahead de Wikipedia, que ignora el keyevent).
base = "https://the-internet.herokuapp.com"
r.step("cookie_clear", {"port": PORT})
r.step("tab_navigate", {"port": PORT, "url": base + "/login"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
r.step("dom_click", {"port": PORT, "selector": "#username"})
r.step("dom_type", {"port": PORT, "text": "tomsmith"})
r.step("dom_click", {"port": PORT, "selector": "#password"})
r.step("dom_type", {"port": PORT, "text": "SuperSecretPassword!"})
r.step("press_key", {"port": PORT, "key": "Enter"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
r.step("dom_wait_element", {"port": PORT, "selector": "#flash", "timeout_ms": 8000})
flash, err = r.step("page_get_text", {"port": PORT, "selector": "#flash", "max_bytes": 200})
ok = (not err) and ("logged into" in flash.lower())
verdict = "PASS" if ok else "FAIL"
save("prueba_3_search.json", {
"name": "3 - Submit de formulario con teclado Enter (the-internet/login)",
"verdict": verdict,
"flash": flash.strip(),
"steps": r.steps,
})
return verdict, f"flash='{flash.strip()[:40]}'"
def prueba_4_login_session(c, log):
"""Login + persistir sesión: storage_save -> cookie_clear -> storage_load."""
r = Recorder(c, log)
base = "https://the-internet.herokuapp.com"
# Sesión limpia: las cookies de pruebas previas (otros dominios) no deben
# contaminar el storage_state que guardaremos.
r.step("cookie_clear", {"port": PORT})
r.step("tab_navigate", {"port": PORT, "url": base + "/login"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
r.step("dom_click", {"port": PORT, "selector": "#username"})
r.step("dom_type", {"port": PORT, "text": "tomsmith"})
r.step("dom_click", {"port": PORT, "selector": "#password"})
r.step("dom_type", {"port": PORT, "text": "SuperSecretPassword!"})
r.step("dom_click", {"port": PORT, "selector": "button[type=submit]"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
r.step("dom_wait_element", {"port": PORT, "selector": "#flash", "timeout_ms": 8000})
flash1, _ = r.step("page_get_text", {"port": PORT, "selector": "#flash", "max_bytes": 300})
# "logged into" sólo aparece en el flash de ÉXITO; evita colisión con el
# mensaje de error "view the secure area" que contiene "secure area".
logged_in = "logged into" in flash1.lower()
# Guardar sesión, limpiar cookies, restaurar.
r.step("storage_save", {"port": PORT, "path": "/tmp/demo_session.json"})
r.step("cookie_clear", {"port": PORT})
# Tras limpiar cookies, /secure debe expulsar a login.
r.step("tab_navigate", {"port": PORT, "url": base + "/secure"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
after_clear, _ = r.step("page_get_text", {"port": PORT, "selector": "#flash", "max_bytes": 300})
kicked = "must login" in after_clear.lower()
# Restaurar sesión: navegar al dominio, load, volver a /secure.
r.step("tab_navigate", {"port": PORT, "url": base})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
r.step("storage_load", {"port": PORT, "path": "/tmp/demo_session.json"})
r.step("tab_navigate", {"port": PORT, "url": base + "/secure"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
# Check robusto (no #flash, que sufre timing): si seguimos en /secure y el
# body menciona "Secure Area", la sesión se restauró; si nos echó, pathname
# vuelve a "/".
probe, _ = r.step("page_eval_js", {"port": PORT, "expression":
"JSON.stringify({path:location.pathname,secure:document.body.innerText.includes('Secure Area')})"})
flash2 = probe
try:
pj = json.loads(json.loads(probe) if probe.strip().startswith('"') else probe)
except Exception:
pj = {}
restored = (pj.get("path") == "/secure") and bool(pj.get("secure"))
ok = logged_in and kicked and restored
verdict = "PASS" if ok else "FAIL"
save("prueba_4_login_session.json", {
"name": "4 - Login + sesión persistente (storage_state)",
"verdict": verdict,
"logged_in": logged_in, "kicked_after_clear": kicked, "restored_after_load": restored,
"flash_login": flash1[:200], "flash_after_clear": after_clear[:200], "flash_restored": flash2[:200],
"steps": r.steps,
})
return verdict, f"login={logged_in} restore={restored}"
def prueba_5_books(c, log):
"""Scraping paginado multi-página + dedup (books.toscrape.com, 3 páginas)."""
r = Recorder(c, log)
all_books = []
for page in (1, 2, 3):
url = f"https://books.toscrape.com/catalogue/page-{page}.html"
r.step("tab_navigate", {"port": PORT, "url": url})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
expr = ("[...document.querySelectorAll('.product_pod')].map(b=>({"
"title:b.querySelector('h3 a').getAttribute('title'),"
"price:b.querySelector('.price_color').innerText,"
"stock:b.querySelector('.availability').innerText.trim()}))")
text, err = r.step("page_eval_js", {"port": PORT, "expression": expr})
try:
all_books.extend(json.loads(text))
except Exception:
pass
unique = {b["title"]: b for b in all_books if isinstance(b, dict) and b.get("title")}
ok = len(unique) >= 60
verdict = "PASS" if ok else "FAIL"
save("prueba_5_books.json", {
"name": "5 - Scraping paginado + dedup (books.toscrape.com, 3 páginas)",
"verdict": verdict,
"total_scraped": len(all_books), "unique_count": len(unique),
"sample": list(unique.values())[:3],
"steps": r.steps,
})
return verdict, f"{len(unique)} libros únicos"
def prueba_6_session_storage(c, log):
"""sessionStorage en storage_state: set -> save -> clear -> load -> get (fix D)."""
r = Recorder(c, log)
r.step("tab_navigate", {"port": PORT, "url": "https://the-internet.herokuapp.com"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
r.step("page_eval_js", {"port": PORT, "expression":
"window.sessionStorage.setItem('demo_k','demo_v'); 'set'"})
r.step("storage_save", {"port": PORT, "path": "/tmp/demo_ss.json"})
r.step("page_eval_js", {"port": PORT, "expression": "window.sessionStorage.clear(); 'cleared'"})
cleared, _ = r.step("page_eval_js", {"port": PORT, "expression":
"String(window.sessionStorage.getItem('demo_k'))"})
r.step("storage_load", {"port": PORT, "path": "/tmp/demo_ss.json"})
got, err = r.step("page_eval_js", {"port": PORT, "expression":
"String(window.sessionStorage.getItem('demo_k'))"})
# Verificar también que el JSON guardado incluye el campo sessionStorage.
saved_has_ss = False
try:
with open("/tmp/demo_ss.json", encoding="utf-8") as f:
saved_has_ss = json.load(f).get("sessionStorage", {}).get("demo_k") == "demo_v"
except Exception:
pass
ok = (not err) and (cleared.strip() == "null") and ("demo_v" in got) and saved_has_ss
verdict = "PASS" if ok else "FAIL"
save("prueba_6_session_storage.json", {
"name": "6 - sessionStorage en storage_state (fix D)",
"verdict": verdict,
"cleared_value": cleared.strip(), "restored_value": got.strip(), "json_has_sessionstorage": saved_has_ss,
"steps": r.steps,
})
return verdict, f"clear='{cleared.strip()}' restore='{got.strip()}'"
def prueba_7_find_honesto(c, log):
"""find_by_text con texto inexistente -> error explícito, no vacío (fix E)."""
r = Recorder(c, log)
r.step("tab_navigate", {"port": PORT, "url": "https://quotes.toscrape.com"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
# Texto presente: debe encontrar (no error).
found, ferr = r.step("dom_find_by_text", {"port": PORT, "text": "Login"})
# Texto inexistente: debe dar error explícito (antes: vacío sin error).
miss, merr = r.step("dom_find_by_text", {"port": PORT, "text": "ZZZ_texto_inexistente_42"})
ok = (not ferr) and bool(found.strip()) and merr and ("no se encontro" in miss.lower())
verdict = "PASS" if ok else "FAIL"
save("prueba_7_find_honesto.json", {
"name": "7 - find_by_text honesto: error en no-encontrado (fix E)",
"verdict": verdict,
"found_present": found.strip()[:80], "missing_is_error": merr, "missing_response": miss.strip()[:120],
"steps": r.steps,
})
return verdict, f"present_ok={bool(found.strip())} miss_error={merr}"
def prueba_8_verificacion(c, log):
"""Verificación post-acción: click oculto y type sin foco dan error (fix B / P1)."""
r = Recorder(c, log)
r.step("tab_navigate", {"port": PORT, "url": "https://quotes.toscrape.com"})
r.step("page_wait_load", {"port": PORT, "timeout_ms": 12000})
# Inyectar un botón oculto y comprobar que click da error (no clic al aire).
r.step("page_eval_js", {"port": PORT, "expression":
"var b=document.createElement('button');b.id='hidden_btn';b.textContent='x';"
"b.style.display='none';document.body.appendChild(b);'injected'"})
_, click_hidden_err = r.step("dom_click", {"port": PORT, "selector": "#hidden_btn"})
# Quitar el foco y comprobar que type da error (no escribe a la nada).
r.step("page_eval_js", {"port": PORT, "expression":
"if(document.activeElement){document.activeElement.blur();} document.body.focus(); 'blurred'"})
_, type_nofocus_err = r.step("dom_type", {"port": PORT, "text": "fantasma"})
ok = bool(click_hidden_err) and bool(type_nofocus_err)
verdict = "PASS" if ok else "FAIL"
save("prueba_8_verificacion.json", {
"name": "8 - Verificación post-acción: click oculto / type sin foco dan error (fix B)",
"verdict": verdict,
"click_hidden_error": bool(click_hidden_err),
"type_nofocus_error": bool(type_nofocus_err),
"steps": r.steps,
})
return verdict, f"click_hidden_err={bool(click_hidden_err)} type_nofocus_err={bool(type_nofocus_err)}"
def main():
log = open(os.path.join(RESULTS, "run.log"), "w", encoding="utf-8")
log.write(f"=== Demo e2e browser_mcp @ {time.strftime('%d/%m/%Y %H:%M')} ===\n")
client = MCPClient(EXE, env={"FN_REGISTRY_ROOT": ROOT}, cwd=ROOT,
stderr_path=os.path.join(RESULTS, "mcp_stderr.log"))
init = client.initialize()
log.write(f"initialize: {json.dumps(init.get('result', {}).get('serverInfo', {}))}\n")
pruebas = [prueba_1_quotes, prueba_2_perceive, prueba_3_search,
prueba_4_login_session, prueba_5_books,
prueba_6_session_storage, prueba_7_find_honesto,
prueba_8_verificacion]
summary = []
for fn in pruebas:
name = fn.__doc__.split("\n")[0]
log.write(f"\n--- {fn.__name__}: {name}\n")
try:
verdict, detail = fn(client, log)
except Exception as e:
verdict, detail = "ERROR", str(e)
summary.append({"prueba": fn.__name__, "verdict": verdict, "detail": detail})
log.write(f" => {verdict} ({detail})\n")
client.close()
save("summary.json", summary)
log.write("\n=== RESUMEN ===\n")
for s in summary:
log.write(f"{s['verdict']:6} {s['prueba']:24} {s['detail']}\n")
log.close()
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()