---
id: 0032
title: Fase 2 — Navegador controlable con sesion persistente (cookies, login, JS)
status: pending
priority: high
created: 2026-05-02
depends_on: [0028, 0029]
---

## Contexto y objetivo

La fase 1 (enrichers `fetch_webpage`, `web_search`, `extract_links`,
`extract_text_entities`, `extract_domain`) cubre el caso "sitio publico,
HTML estatico, sin JS". Limitaciones reales que estamos viendo:

- DDG HTML cambia el markup y hay que reparar el parser. Ademas en
  busquedas masivas devuelve captcha tras N requests.
- Sitios SPA (LinkedIn, X, Telegram) no se pueden enriquecer porque
  `requests` no ejecuta JS — el HTML viene vacio.
- Para investigaciones serias hacen falta sesiones autenticadas
  (LinkedIn, foros, Telegram web). Sin cookies persistentes, cada
  enricher empieza de cero y se rompe el flujo.

Fase 2 introduce un **navegador controlable** que comparten todos los
enrichers que necesiten JS, cookies o login.

## Decision de stack

| Opcion | Pros | Contras | Veredicto |
|---|---|---|---|
| Playwright (Python) | Bateria incluida, captura cookies/storage, easy install via pip, multinavegador | 200 MB de Chromium descargado | **Elegida** — mejor DX para enrichers Python |
| pychrome + Chrome instalado | Mas ligero | API basica, reinventar capa de helpers | descartada |
| CDP en Go (issue 0029) | Reusa funciones del registry, binario unico | Mantener wrapper aparte por enricher | aplazado a v3 |
| Selenium | Estandar viejo | Mas lento que Playwright, peor API moderna | descartada |

**Stack final:** Playwright Python en `python/.venv`, persistencia de
estado con `BrowserContext.storage_state` (cookies + localStorage) en
`<app_dir>/browser_profiles/<profile>.json`.

## Arquitectura

### Componente compartido: `browser_session_py_browser`

Funcion del registry nueva en `python/functions/browser/`. NO es un
enricher — es la primitiva que usan los enrichers.

```python
def open_session(profile: str, *, headless: bool = True,
                 user_agent: str | None = None) -> BrowserSession: ...
```

`BrowserSession` expone:
- `goto(url, wait="load") -> Page`
- `html() -> str`
- `screenshot(path, full_page=True)`
- `cookies() -> list[dict]` / `set_cookies(list)`
- `evaluate(js) -> any`
- `close()` — persiste storage_state al disco antes de cerrar.

El profile vive en `<app_dir>/browser_profiles/<name>.json`. Si no
existe se crea vacio. Echo y los enrichers comparten profile via param
`browser_profile` (default `"default"`).

### Enrichers nuevos

| id | applies_to | reemplaza/complementa |
|---|---|---|
| `fetch_webpage_browser` | Url, Webpage | superset de `fetch_webpage` para JS/auth |
| `web_search_browser` | text, Concept | superset de `web_search` (Google/DDG con JS y sin captcha facil) |
| `fetch_screenshot` | Url, Webpage | nuevo — solo evidencia visual |
| `browser_login` | Account, Credential | nuevo — abre login interactivo, guarda cookies |
| `extract_dom_data` | Webpage | nuevo — extrae datos via selector CSS o XPath (JSON-LD, microdata, etc.) |

Todos comparten params:
```yaml
- { name: browser_profile, type: string, default: "default" }
- { name: headless, type: bool, default: true }
- { name: timeout_s, type: int, default: 30 }
```

### Login interactivo (`browser_login`)

Modo especial: lanza Chromium **no-headless** y deja que el humano
haga el login a mano (CAPTCHA, 2FA, etc.). Cuando el usuario cierra la
ventana, las cookies quedan guardadas en el profile. Los enrichers
posteriores que usen ese profile heredan la sesion.

UX en Echo:
> Usuario: "Echo, prepara una sesion para LinkedIn"
> Echo: ejecuta `browser_login` con `profile=linkedin`, target=linkedin.com.
> Aparece la ventana del browser. Usuario hace login. Cierra ventana.
> Echo confirma: "sesion linkedin guardada, 15 cookies".

### `web_search_browser` con multiples motores

```yaml
params:
  - { name: engine, type: string, default: "google" }   # google|ddg|bing|brave
  - { name: limit, type: int, default: 10 }
  - { name: browser_profile, type: string, default: "default" }
```

Con browser real, Google deja de bloquear. Cada engine tiene su
selector CSS para resultados (mantenidos en `engines.yaml` dentro del
enricher). El enricher cae en orden engine → engine si uno falla:
google → ddg → bing.

### Extraccion CSS/XPath (`extract_dom_data`)

Para nodos `Webpage` enriquecidos con un browser, permite definir
selectores en el `Type` del nodo:

```yaml
# en types.yaml de un proyecto
- name: LinkedInProfile
  selectors:
    full_name: "h1.profile-name"
    headline: "div.profile-headline"
    company: "[data-section=experience] li:first-child .company"
```

`extract_dom_data` lee los selectores del Type, los aplica al DOM
post-JS y guarda los valores en `metadata`. Esto **es lo que conecta**
el grafo con scrapers tipados sin escribir un enricher por sitio.

## Integracion con Echo

Echo gana 3 tools nuevas en el MCP server (`gx-cli`):
- `browser_login(profile, url)` — pide al usuario hacer login.
- `browser_session_status(profile)` — lista profiles, valid/expired,
  cookie count, ultima url visitada.
- `browser_close(profile)` — cierra la sesion y persiste.

System prompt amplia el bloque WORKFLOW:
> Cuando una URL devuelva HTML vacio o redirija a login, propon usar
> `fetch_webpage_browser` con un profile autenticado. Si no existe
> profile, propon `browser_login` antes.

## Plan de implementacion (fases)

1. **0032a** — `python/functions/browser/browser_session.py` con
   Playwright. Test que abre about:blank, persiste storage_state,
   recarga y verifica cookies.
2. **0032b** — `fetch_webpage_browser` enricher. Test contra
   `httpbin.org/cookies/set` para verificar persistencia.
3. **0032c** — `fetch_screenshot` (la mas simple, valida la pipeline
   visual end-to-end).
4. **0032d** — `web_search_browser` con google + ddg + fallback.
   Tests con paginas guardadas en `tests/fixtures/`.
5. **0032e** — `browser_login` con UI no-headless. Test manual.
6. **0032f** — `extract_dom_data` + extension del schema de Types
   con `selectors`. Test con HTML local complejo.
7. **0032g** — Tools MCP en `gx-cli` y prompt update en chat.cpp.

## Riesgos y mitigaciones

| Riesgo | Mitigacion |
|---|---|
| Playwright pesa 200 MB | Lazy install: `pip install playwright && playwright install chromium` solo cuando se ejecuta el primer enricher browser. Documentar en app.md. |
| Profiles con secretos en disco | `<app_dir>/browser_profiles/` en .gitignore. Documentar advertencia en `browser_login`. |
| Sites detectan headless | Default user-agent realista. Bloque opcional `stealth: true` en params (usa playwright-stealth). |
| Concurrencia: 2 jobs leyendo el mismo profile | Lock por profile en sqlite (`browser_locks` tabla en operations.db). Si esta tomado, esperar 30s antes de fallar. |
| Tests con red real | NO hay tests con red real. Fixtures HTML guardados o servidor mock con `pytest-httpserver`. |

## Definicion de hecho

- Echo puede pedir al usuario "abre LinkedIn y haz login" y a partir
  de ahi enriquecer perfiles.
- `web_search_browser` engine=google funciona masivamente (50+
  busquedas seguidas) sin captcha.
- Un Webpage enriquecido con `extract_dom_data` usando un Type con
  `selectors` queda con todos los campos en `metadata`.
- Tests pasan en CI sin red — todos los enrichers browser tienen
  tests con fixtures locales.

## Fuera de alcance (v3)

- Reescribir los enrichers a Go con CDP (issue 0029 sigue vivo como
  alternativa de bajo nivel si Playwright no escala).
- Captcha solving — manual via `browser_login`, nunca automatico.
- Anti-bot bypass agresivo (residential proxies, fingerprint
  randomizacion). Pendiente hasta que se demuestre necesidad real.
