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:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"browser": {
|
||||||
|
"command": "/home/enmanuel/fn_registry/projects/web_scraping/apps/browser_mcp/browser_mcp",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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.”\"}]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 Shakespeare’s 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user