feat(browser): auto-commit con 60 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:42:31 +02:00
parent 37aacfcfa9
commit 8742cb25be
71 changed files with 5660 additions and 192 deletions
+92
View File
@@ -0,0 +1,92 @@
---
name: cdp_click_xy
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_click_xy(x: int, y: int, *, port: int = 9222, target_url_substr: str = '', move_first: bool = True, timeout_s: float = 10.0) -> dict"
description: "Hace un click izquierdo de raton REAL en coordenadas (x, y) del viewport de una pestana de un Chrome con remote debugging, via CDP Input.dispatchMouseEvent (mouseMoved opcional + mousePressed + mouseReleased). Primitiva de input CDP reutilizable: necesaria cuando element.click() de JavaScript NO dispara los handlers de React de SPAs (WhatsApp Web): abrir un chat de la lista o un resultado de busqueda requiere un click de raton sintetico real. El caller resuelve las coordenadas con cdp_eval (getBoundingClientRect -> centro) y las pasa aqui."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "time", "urllib.request", "websocket"]
params_schema:
params:
- name: x
desc: "Coordenada X en CSS px del viewport donde hacer click. Normalmente el centro del elemento (getBoundingClientRect via cdp_eval)."
- name: y
desc: "Coordenada Y en CSS px del viewport donde hacer click. Normalmente el centro del elemento (getBoundingClientRect via cdp_eval)."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: move_first
desc: "Si True, emite un mouseMoved a (x, y) antes del click para que la SPA registre el hover. Default True."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, error: str, x: int, y: int}. ok=True si los eventos de raton (mouseMoved opcional, mousePressed, mouseReleased) se emitieron sin error; x/y son eco de los argumentos. Nunca lanza: errores de red/conexion/transport se devuelven en 'error' con ok=False."
tested: true
tests: ["test_golden_click_emite_movido_pressed_released_left", "test_edge_move_first_false_omite_mousemoved", "test_error_create_connection_lanza_ok_false"]
test_file_path: "python/functions/browser/cdp_click_xy_test.py"
file_path: "python/functions/browser/cdp_click_xy.py"
---
## Ejemplo
```python
import sys, os, json
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_eval import cdp_eval
from browser.cdp_click_xy import cdp_click_xy
# Requiere un Chrome lanzado con --remote-debugging-port=9222
# y una pestana de WhatsApp Web abierta.
# Localizar el row de un chat por nombre exacto y abrirlo con click REAL.
r = cdp_eval(r'''(() => {
const row = [...document.querySelectorAll('#side [role="row"]')]
.find(x => /^NOTAS WASAP\b/.test(x.innerText.replace(/\s+/g,' ').trim()));
if(!row) return null;
const b = row.getBoundingClientRect();
return JSON.stringify({x: Math.round(b.x+b.width/2), y: Math.round(b.y+b.height/2)});
})()''', target_url_substr="whatsapp")
c = json.loads(r["value"])
res = cdp_click_xy(c["x"], c["y"], target_url_substr="whatsapp") # abre el chat
print(res["ok"], res["error"])
```
O directo por CLI: `python3 python/functions/browser/cdp_click_xy.py 100 200 "whatsapp"`.
## Cuando usarla
Cuando necesites **clickar un elemento** y `element.click()` de JavaScript NO dispara
sus handlers (SPAs React como WhatsApp Web): **abrir un chat de la lista**, abrir un
**resultado de busqueda**, pulsar un boton que React renderiza con listeners propios.
Resuelve primero las coordenadas del elemento con `cdp_eval_py_browser`
(`getBoundingClientRect` -> centro) y pasa `x, y` aqui. Es la primitiva de input de
raton sobre la que se construyen funciones `whatsapp_*_py_browser` y cualquier script
que opere una pestana existente via CDP. Para teclas usa `cdp_press_key_py_browser`;
para escribir texto, `cdp_type_chars_py_browser`.
## Gotchas
- **Coordenadas en CSS px del viewport**: `getBoundingClientRect()` ya las devuelve en
ese sistema, por eso encaja directo. No uses `pageX/pageY` ni coords absolutas de
documento.
- **El elemento debe estar VISIBLE en el viewport**: si esta fuera de pantalla por
scroll, las coords del rect son invalidas (negativas o fuera de rango) y el click cae
en el lugar equivocado. Haz scroll al elemento primero (via `cdp_eval` con
`scrollIntoView`) y vuelve a leer el rect.
- Es un **click izquierdo simple** (clickCount=1, button=left). No hace doble click,
click derecho ni drag.
- `move_first=True` (default) emite un `mouseMoved` previo para que la SPA registre el
hover; algunas UIs solo muestran/activan controles tras hover. Ponlo en False si no
lo necesitas.
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases).
Sin remote debugging, `GET /json` falla y devuelve `ok=False`.
- Nunca lanza: errores de red, conexion WS o transport se reportan en el campo `error`
con `ok=False`.
+166
View File
@@ -0,0 +1,166 @@
"""Hace un click de raton real en coordenadas (x, y) de una pestana de Chrome via CDP.
Primitiva de input CDP: localiza un target (pestana) por substring de su URL, abre el
WebSocket de depuracion y emite los eventos `Input.dispatchMouseEvent` que componen un
click izquierdo sintetico real (mousePressed + mouseReleased), opcionalmente precedido
de un mouseMoved para que la SPA registre el hover.
Necesario porque `element.click()` de JavaScript NO dispara los handlers de React de
muchas SPAs (WhatsApp Web entre ellas): abrir un chat de la lista o un resultado de
busqueda requiere un click de raton sintetico real. El caller resuelve las coordenadas
del elemento con `cdp_eval_py_browser` (getBoundingClientRect -> centro) y las pasa aqui.
"""
import json
import time
import urllib.request
import websocket
def cdp_click_xy(
x: int,
y: int,
*,
port: int = 9222,
target_url_substr: str = "",
move_first: bool = True,
timeout_s: float = 10.0,
) -> dict:
"""Hace un click izquierdo real en (x, y) del viewport de una pestana de Chrome.
Localiza el target `page` por substring de URL, abre el WebSocket CDP y emite un
click izquierdo simple via `Input.dispatchMouseEvent`: si `move_first`, primero un
`mouseMoved` a (x, y) (para que la SPA registre hover), luego `mousePressed` y
`mouseReleased` con `button=left`, `buttons=1`, `clickCount=1`. Las coordenadas son
CSS px del viewport; el caller las resuelve normalmente con `cdp_eval_py_browser`
(getBoundingClientRect -> centro).
Args:
x: Coordenada X en CSS px del viewport donde hacer click.
y: Coordenada Y en CSS px del viewport donde hacer click.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana).
Si "", usa el primer target de tipo "page".
move_first: Si True, emite un mouseMoved a (x, y) antes del click para que la
SPA registre el hover. Default True.
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
Returns:
dict con claves:
ok: bool — True si los eventos de raton se emitieron sin error.
error: str — mensaje de error (vacio si ok).
x: int — coordenada X usada (eco del argumento).
y: int — coordenada Y usada (eco del argumento).
Nunca lanza: errores de red/conexion/transport se devuelven en "error" con
ok=False.
"""
# 1. Listar targets via HTTP.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "error": str(e), "x": x, "y": y}
# 2. Elegir el primer target type=="page" cuya url contenga el substring.
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {
"ok": False,
"error": f"no target matching {target_url_substr}",
"x": x,
"y": y,
}
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 3. Abrir WS.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "error": str(e), "x": x, "y": y}
try:
msg_id = 1
# 3b. Hover previo opcional: ayuda a que la SPA registre el mouseover.
if move_first:
ws.send(json.dumps({
"id": msg_id,
"method": "Input.dispatchMouseEvent",
"params": {"type": "mouseMoved", "x": x, "y": y},
}))
msg_id += 1
time.sleep(0.03)
# 4. Click izquierdo real: mousePressed + mouseReleased.
press_id = msg_id
ws.send(json.dumps({
"id": press_id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"buttons": 1,
"clickCount": 1,
},
}))
time.sleep(0.04)
release_id = msg_id + 1
ws.send(json.dumps({
"id": release_id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mouseReleased",
"x": x,
"y": y,
"button": "left",
"buttons": 1,
"clickCount": 1,
},
}))
# Drenar respuestas hasta ver el id del release (o agotar el stream).
# Ignoramos eventos del server y frames no-JSON.
while True:
raw = ws.recv()
if not raw:
break
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == release_id:
break
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
return {"ok": False, "error": str(e), "x": x, "y": y}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
return {"ok": True, "error": "", "x": x, "y": y}
if __name__ == "__main__":
import sys
arg_x = int(sys.argv[1]) if len(sys.argv) > 1 else 0
arg_y = int(sys.argv[2]) if len(sys.argv) > 2 else 0
substr = sys.argv[3] if len(sys.argv) > 3 else ""
out = cdp_click_xy(arg_x, arg_y, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,143 @@
"""Tests para cdp_click_xy — mockean urlopen + create_connection.
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
websocket.create_connection (un fake que captura los mensajes enviados y devuelve
las respuestas CDP con el id correspondiente).
"""
import json
import os
import sys
from contextlib import contextmanager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser import cdp_click_xy as mod # noqa: E402
from browser.cdp_click_xy import cdp_click_xy # noqa: E402
class _FakeResp:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload: list):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return json.dumps(self._payload).encode()
class _FakeWS:
"""WebSocket falso: captura los mensajes enviados y responde por id."""
def __init__(self):
self.sent = []
self._inbox = []
self.closed = False
def send(self, raw: str):
msg = json.loads(raw)
self.sent.append(msg)
# Encola una respuesta CDP vacia con el mismo id (como Chrome devuelve).
self._inbox.append(json.dumps({"id": msg["id"], "result": {}}))
def recv(self):
if self._inbox:
return self._inbox.pop(0)
return ""
def close(self):
self.closed = True
@contextmanager
def _patch(targets, ws_obj=None, urlopen_exc=None, create_conn_exc=None):
"""Parchea urlopen y create_connection del modulo. Restaura al salir."""
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
def fake_urlopen(url, timeout=5):
if urlopen_exc is not None:
raise urlopen_exc
return _FakeResp(targets)
def fake_create(ws_url, timeout=10):
if create_conn_exc is not None:
raise create_conn_exc
return ws_obj
mod.urllib.request.urlopen = fake_urlopen
mod.websocket.create_connection = fake_create
try:
yield
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
]
def test_golden_click_emite_movido_pressed_released_left():
"""Click en (100, 200) emite mouseMoved + mousePressed + mouseReleased correctos."""
ws = _FakeWS()
with _patch(_TARGETS, ws_obj=ws):
res = cdp_click_xy(100, 200, target_url_substr="whatsapp")
assert res == {"ok": True, "error": "", "x": 100, "y": 200}
assert len(ws.sent) == 3
moved, pressed, released = ws.sent
assert moved["method"] == "Input.dispatchMouseEvent"
assert moved["params"]["type"] == "mouseMoved"
assert moved["params"]["x"] == 100
assert moved["params"]["y"] == 200
assert pressed["params"]["type"] == "mousePressed"
assert pressed["params"]["x"] == 100
assert pressed["params"]["y"] == 200
assert pressed["params"]["button"] == "left"
assert pressed["params"]["buttons"] == 1
assert pressed["params"]["clickCount"] == 1
assert released["params"]["type"] == "mouseReleased"
assert released["params"]["x"] == 100
assert released["params"]["y"] == 200
assert released["params"]["button"] == "left"
assert released["params"]["buttons"] == 1
assert released["params"]["clickCount"] == 1
assert ws.closed is True
def test_edge_move_first_false_omite_mousemoved():
"""Con move_first=False no se emite el mouseMoved previo, solo press + release."""
ws = _FakeWS()
with _patch(_TARGETS, ws_obj=ws):
res = cdp_click_xy(50, 60, target_url_substr="whatsapp", move_first=False)
assert res["ok"] is True
assert len(ws.sent) == 2
types = [m["params"]["type"] for m in ws.sent]
assert types == ["mousePressed", "mouseReleased"]
assert all(m["params"]["type"] != "mouseMoved" for m in ws.sent)
def test_error_create_connection_lanza_ok_false():
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
res = cdp_click_xy(10, 20, target_url_substr="whatsapp")
assert res["ok"] is False
assert "ws down" in res["error"]
assert res["x"] == 10
assert res["y"] == 20
+65
View File
@@ -0,0 +1,65 @@
---
name: cdp_eval
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_eval(expression: str, *, port: int = 9222, target_url_substr: str = '', await_promise: bool = False, timeout_s: float = 10.0) -> dict"
description: "Evalua una expresion JavaScript en una pestana de un Chrome con remote debugging, eligiendo el target por substring de su URL. Primitiva de transport CDP reutilizable para operar el navegador diario por codigo sin abrir ventana nueva."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.request", "websocket"]
params_schema:
params:
- name: expression
desc: "Expresion JavaScript a evaluar en el contexto de la pagina (ej. 'document.title', 'document.querySelector(\".x\").click()')."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: await_promise
desc: "Si True, espera a que la expresion resuelva una Promise antes de devolver el valor (awaitPromise de CDP). Default False."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, value: <valor Python serializable o None>, error: str, target_url: str}. ok=True si la evaluacion produjo valor sin excepcion. Nunca lanza: errores de red/conexion/excepcion JS se devuelven en 'error'."
tested: true
tests: ["test_golden_selecciona_target_por_substr_y_devuelve_value", "test_edge_substr_sin_match_devuelve_ok_false", "test_error_urlopen_lanza_devuelve_ok_false"]
test_file_path: "python/functions/browser/cdp_eval_test.py"
file_path: "python/functions/browser/cdp_eval.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_eval import cdp_eval
# Requiere un Chrome lanzado con --remote-debugging-port=9222
# y una pestana de WhatsApp Web abierta.
res = cdp_eval("document.title", port=9222, target_url_substr="whatsapp")
print(res["value"]) # -> "WhatsApp" (o None si no hay target)
print(res["ok"], res["target_url"])
```
O directo por CLI: `python3 python/functions/browser/cdp_eval.py "document.title" "whatsapp"`.
## Cuando usarla
Cuando quieras leer datos o ejecutar JS (focus, querySelector, `element.click()`, scroll)
sobre una pestana **ya abierta** del navegador diario sin abrir ventana nueva ni darle
foco al sistema. Es la primitiva de transport sobre la que se construyen las funciones
`whatsapp_*_py_browser` y cualquier script que opere una pestana existente via CDP.
## Gotchas
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases). Sin remote debugging, `GET /json` falla y devuelve `ok=False`.
- `target_url_substr` hace match de **substring** sobre la URL del target (no regex). El primer `page` que contenga el substring gana.
- `returnByValue` solo serializa valores JSON (str, num, bool, list, dict, None). Los nodos del DOM NO son serializables: devuelve la expresion ya reducida a un valor (ej. `el.textContent`, no `el`).
- Para **escribir** en inputs o `contenteditable` NO uses `document.execCommand` ni `el.value = ...`: editores como React/Lexical (WhatsApp Web) ignoran esos cambios programaticos. Usa `cdp_type_chars_py_browser` (teclea caracter a caracter via CDP `Input.dispatchKeyEvent`).
- Nunca lanza: errores de red, conexion WS o excepciones de la propia evaluacion JS se reportan en el campo `error` con `ok=False`.
+139
View File
@@ -0,0 +1,139 @@
"""Evalua una expresion JavaScript en una pestana de Chrome via Chrome DevTools Protocol.
Primitiva de transport CDP: localiza un target (pestana) por substring de su URL,
abre el WebSocket de depuracion, ejecuta `Runtime.evaluate` y devuelve el valor
serializado. Base reutilizable para automatizar el navegador diario sin abrir
ventana nueva ni darle foco.
"""
import json
import urllib.request
import websocket
def cdp_eval(
expression: str,
*,
port: int = 9222,
target_url_substr: str = "",
await_promise: bool = False,
timeout_s: float = 10.0,
) -> dict:
"""Evalua una expresion JS en una pestana de Chrome elegida por substring de URL.
Args:
expression: Expresion JavaScript a evaluar en el contexto de la pagina.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target. Si "",
usa el primer target de tipo "page".
await_promise: Si True, espera a que se resuelva una Promise antes de
devolver el valor (`awaitPromise` de CDP).
timeout_s: Timeout (segundos) para la conexion WebSocket.
Returns:
dict con claves:
ok: bool — True si la evaluacion produjo un valor sin excepcion.
value: valor Python serializable devuelto por la expresion, o None.
error: str — mensaje de error (vacio si ok).
target_url: str — URL del target usado (vacio si ninguno).
"""
# 1. Listar targets via HTTP.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "value": None, "error": str(e), "target_url": ""}
# 2. Elegir el primer target type=="page" cuya url contenga el substring.
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {
"ok": False,
"value": None,
"error": f"no target matching {target_url_substr}",
"target_url": "",
}
target_url = chosen.get("url", "")
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 3-4. Abrir WS, enviar Runtime.evaluate, drenar eventos hasta id==1.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "value": None, "error": str(e), "target_url": ""}
try:
ws.send(json.dumps({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": expression,
"returnByValue": True,
"awaitPromise": await_promise,
},
}))
msg = None
# Drenar eventos intermedios hasta encontrar la respuesta con id==1.
while True:
raw = ws.recv()
if not raw:
break
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == 1:
msg = parsed
break
except Exception as e: # noqa: BLE001 — fallo de transport durante recv/send
return {"ok": False, "value": None, "error": str(e), "target_url": target_url}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
if msg is None:
return {
"ok": False,
"value": None,
"error": "no response for evaluate (id=1)",
"target_url": target_url,
}
result = msg.get("result", {})
# 5. Si hubo excepcion en la evaluacion, devolverla como error.
exc = result.get("exceptionDetails")
if exc:
text = exc.get("text", "evaluation exception")
exception = exc.get("exception", {})
detail = exception.get("description") or exception.get("value")
error = f"{text}: {detail}" if detail else text
return {"ok": False, "value": None, "error": error, "target_url": target_url}
# 6. Extraer el valor serializado por returnByValue.
value = result.get("result", {}).get("value")
return {"ok": True, "value": value, "error": "", "target_url": target_url}
if __name__ == "__main__":
import sys
expr = sys.argv[1] if len(sys.argv) > 1 else "document.title"
substr = sys.argv[2] if len(sys.argv) > 2 else ""
out = cdp_eval(expr, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
+146
View File
@@ -0,0 +1,146 @@
"""Tests para cdp_eval.
Como cdp_eval requiere un Chrome vivo con remote debugging, se mockean las dos
fronteras de I/O:
- urllib.request.urlopen -> devuelve un /json con 2 targets (uno whatsapp).
- websocket.create_connection -> un fake que responde al id==1 con un value.
"""
import io
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import urllib.request
import websocket
from browser.cdp_eval import cdp_eval
# --- Fakes -----------------------------------------------------------------
def _targets_json():
"""Dos targets de tipo page: uno de Google, otro de WhatsApp Web."""
return [
{
"type": "page",
"url": "https://www.google.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/GOOGLE",
},
{
"type": "page",
"url": "https://web.whatsapp.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/WA",
},
]
class _FakeHTTPResponse:
"""Context manager que imita la respuesta de urlopen con .read()."""
def __init__(self, payload):
self._buf = io.BytesIO(json.dumps(payload).encode())
def read(self):
return self._buf.read()
def __enter__(self):
return self
def __exit__(self, *exc):
return False
class _FakeWS:
"""WebSocket fake: guarda el ws_url usado y responde al evaluate con value.
Antes de la respuesta con id==1, emite un evento intermedio (sin id) para
verificar que cdp_eval drena eventos hasta encontrar su respuesta.
"""
last_url = None
def __init__(self, url, value):
_FakeWS.last_url = url
self._value = value
self._queue = []
def send(self, raw):
msg = json.loads(raw)
if msg.get("id") == 1:
# Primero un evento de CDP sin id (debe drenarse), luego la respuesta.
self._queue.append(json.dumps({
"method": "Runtime.consoleAPICalled",
"params": {"type": "log"},
}))
self._queue.append(json.dumps({
"id": 1,
"result": {"result": {"type": "string", "value": self._value}},
}))
def recv(self):
if self._queue:
return self._queue.pop(0)
return ""
def close(self):
pass
# --- Tests -----------------------------------------------------------------
def test_golden_selecciona_target_por_substr_y_devuelve_value(monkeypatch):
monkeypatch.setattr(
urllib.request, "urlopen",
lambda url, timeout=5: _FakeHTTPResponse(_targets_json()),
)
monkeypatch.setattr(
websocket, "create_connection",
lambda url, timeout=10.0: _FakeWS(url, "WhatsApp"),
)
res = cdp_eval("document.title", port=9222, target_url_substr="whatsapp")
assert res["ok"] is True
assert res["value"] == "WhatsApp"
assert res["error"] == ""
assert res["target_url"] == "https://web.whatsapp.com/"
# Confirma que eligio el target whatsapp, no el de google.
assert _FakeWS.last_url.endswith("/WA")
def test_edge_substr_sin_match_devuelve_ok_false(monkeypatch):
monkeypatch.setattr(
urllib.request, "urlopen",
lambda url, timeout=5: _FakeHTTPResponse(_targets_json()),
)
# create_connection no deberia llamarse; si lo hace, revienta el test.
monkeypatch.setattr(
websocket, "create_connection",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("no debe conectar")),
)
res = cdp_eval("document.title", port=9222, target_url_substr="nope-no-existe")
assert res["ok"] is False
assert res["value"] is None
assert "no target matching" in res["error"]
assert "nope-no-existe" in res["error"]
assert res["target_url"] == ""
def test_error_urlopen_lanza_devuelve_ok_false(monkeypatch):
def _boom(url, timeout=5):
raise OSError("connection refused")
monkeypatch.setattr(urllib.request, "urlopen", _boom)
res = cdp_eval("document.title", port=9222, target_url_substr="whatsapp")
assert res["ok"] is False
assert res["value"] is None
assert "connection refused" in res["error"]
assert res["target_url"] == ""
+80
View File
@@ -0,0 +1,80 @@
---
name: cdp_press_key
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_press_key(key: str, *, port: int = 9222, target_url_substr: str = '', modifiers: int = 0, timeout_s: float = 10.0) -> dict"
description: "Pulsa una tecla nombrada (Enter, Escape, Backspace, Tab, ArrowDown, Delete, ...) sobre el elemento enfocado de una pestana de un Chrome con remote debugging, via CDP Input.dispatchKeyEvent (rawKeyDown + keyUp). Primitiva de input CDP reutilizable: enviar mensajes (Enter en el composer de WhatsApp), cerrar overlays (Escape), navegar resultados (ArrowDown), borrar (Backspace) o combos con modificadores (Ctrl+A)."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.request", "websocket"]
params_schema:
params:
- name: key
desc: "Nombre canonico de la tecla a pulsar. Soportadas: Enter, Escape, Backspace, Tab, Delete, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Home, End. Una tecla no soportada devuelve ok=False sin tocar la red."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: modifiers
desc: "Bitmask de modificadores CDP combinables con OR: Alt=1, Ctrl=2, Meta/Cmd=4, Shift=8. Ej. Ctrl+Shift = 2|8 = 10. Default 0."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, error: str}. ok=True si los eventos rawKeyDown+keyUp se emitieron sin error. Nunca lanza: errores de red/conexion/transport y teclas no soportadas se devuelven en 'error' con ok=False."
tested: true
tests: ["test_golden_enter_emite_rawkeydown_y_keyup_vk13", "test_edge_tecla_no_soportada_ok_false_sin_abrir_ws", "test_error_create_connection_lanza_ok_false"]
test_file_path: "python/functions/browser/cdp_press_key_test.py"
file_path: "python/functions/browser/cdp_press_key.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_press_key import cdp_press_key
# Requiere un Chrome lanzado con --remote-debugging-port=9222
# y una pestana de WhatsApp Web abierta con el composer enfocado y texto escrito.
res = cdp_press_key("Enter", target_url_substr="whatsapp") # envia el mensaje
print(res["ok"], res["error"])
# Combo con modificadores (Ctrl+A para seleccionar todo): Ctrl=2.
cdp_press_key("Home", target_url_substr="whatsapp", modifiers=2)
```
O directo por CLI: `python3 python/functions/browser/cdp_press_key.py "Enter" "whatsapp"`.
## Cuando usarla
Cuando necesites enviar una pulsacion de tecla sobre la pestana **ya abierta** del
navegador diario sin abrir ventana nueva ni darle foco al sistema: **Enter** para
enviar (composer de WhatsApp), **Escape** para cerrar overlays/dialogos,
**ArrowDown/ArrowUp** para navegar resultados de busqueda, **Backspace/Delete** para
borrar, o combos con `modifiers` (Ctrl/Shift/Alt/Meta). Tipicamente **despues** de
escribir con `cdp_type_chars_py_browser` para confirmar la accion. Es la primitiva de
input sobre la que se construyen funciones `whatsapp_*_py_browser` y cualquier script
que opere una pestana existente via CDP.
## Gotchas
- Actua sobre el elemento **enfocado** de la pagina: CDP `Input.dispatchKeyEvent` no
apunta a un selector, va al foco actual. Asegura el foco (clic o `el.focus()` via
`cdp_eval_py_browser`) antes de pulsar.
- **Enter en WhatsApp Web envia el mensaje** (no inserta salto de linea). Para newline
dentro del composer usa Shift+Enter (`modifiers=8`) o no uses esta funcion para eso.
- `modifiers` es el bitmask de CDP, no un string: Alt=1, Ctrl=2, Meta/Cmd=4, Shift=8;
combina con OR (ej. Ctrl+Shift = 10).
- No se emite evento `char` aparte: el par `rawKeyDown`+`keyUp` con el
`windowsVirtualKeyCode` correcto (Enter=13) basta para disparar el envio en WhatsApp
(validado via `press_key` del MCP del navegador).
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases).
Sin remote debugging, `GET /json` falla y devuelve `ok=False`.
- Nunca lanza: errores de red, conexion WS, transport o tecla no soportada se reportan
en el campo `error` con `ok=False`.
+144
View File
@@ -0,0 +1,144 @@
"""Pulsa una tecla nombrada sobre el elemento enfocado de una pestana de Chrome via CDP.
Primitiva de input CDP: localiza un target (pestana) por substring de su URL, abre el
WebSocket de depuracion y emite el par de eventos `Input.dispatchKeyEvent`
(rawKeyDown + keyUp) de una tecla con nombre canonico (Enter, Escape, Backspace, Tab,
ArrowDown, ...) con modificadores opcionales. Base reutilizable para enviar mensajes
(Enter en el composer de WhatsApp), cerrar overlays (Escape), navegar resultados
(ArrowDown), borrar (Backspace) o combos (Ctrl+A con modifiers).
"""
import json
import urllib.request
import websocket
# Mapa de teclas soportadas -> {key, code, windowsVirtualKeyCode}.
# vk = windowsVirtualKeyCode == nativeVirtualKeyCode (codigos VK de Windows).
_KEY_MAP = {
"Enter": {"key": "Enter", "code": "Enter", "vk": 13},
"Escape": {"key": "Escape", "code": "Escape", "vk": 27},
"Backspace": {"key": "Backspace", "code": "Backspace", "vk": 8},
"Tab": {"key": "Tab", "code": "Tab", "vk": 9},
"Delete": {"key": "Delete", "code": "Delete", "vk": 46},
"ArrowDown": {"key": "ArrowDown", "code": "ArrowDown", "vk": 40},
"ArrowUp": {"key": "ArrowUp", "code": "ArrowUp", "vk": 38},
"ArrowLeft": {"key": "ArrowLeft", "code": "ArrowLeft", "vk": 37},
"ArrowRight": {"key": "ArrowRight", "code": "ArrowRight", "vk": 39},
"Home": {"key": "Home", "code": "Home", "vk": 36},
"End": {"key": "End", "code": "End", "vk": 35},
}
def cdp_press_key(
key: str,
*,
port: int = 9222,
target_url_substr: str = "",
modifiers: int = 0,
timeout_s: float = 10.0,
) -> dict:
"""Pulsa una tecla nombrada sobre el elemento enfocado de una pestana de Chrome.
Args:
key: Nombre canonico de la tecla. Soportadas: Enter, Escape, Backspace,
Tab, Delete, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Home, End.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana).
Si "", usa el primer target de tipo "page".
modifiers: Bitmask de modificadores CDP (Alt=1, Ctrl=2, Meta/Cmd=4,
Shift=8; combinables con OR). Default 0 (sin modificadores).
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
Returns:
dict con claves:
ok: bool — True si los eventos de tecla se emitieron sin error.
error: str — mensaje de error (vacio si ok).
Nunca lanza: errores de red/conexion/transport se devuelven en "error"
con ok=False.
"""
# 1. Validar la tecla contra el mapa interno (antes de tocar la red).
entry = _KEY_MAP.get(key)
if entry is None:
return {"ok": False, "error": f"unsupported key: {key}"}
k = entry["key"]
c = entry["code"]
vk = entry["vk"]
# 2. Listar targets via HTTP.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "error": str(e)}
# 3. Elegir el primer target type=="page" cuya url contenga el substring.
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {"ok": False, "error": f"no target matching {target_url_substr}"}
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 4. Abrir WS y emitir rawKeyDown + keyUp.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "error": str(e)}
try:
for msg_id, ev_type in ((1, "rawKeyDown"), (2, "keyUp")):
ws.send(json.dumps({
"id": msg_id,
"method": "Input.dispatchKeyEvent",
"params": {
"type": ev_type,
"key": k,
"code": c,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
"modifiers": modifiers,
},
}))
# Drenar respuestas hasta ver el id==2 (o agotar el stream). Ignoramos
# eventos del server y frames no-JSON.
while True:
raw = ws.recv()
if not raw:
break
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == 2:
break
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
return {"ok": False, "error": str(e)}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
return {"ok": True, "error": ""}
if __name__ == "__main__":
import sys
key_arg = sys.argv[1] if len(sys.argv) > 1 else "Enter"
substr = sys.argv[2] if len(sys.argv) > 2 else ""
out = cdp_press_key(key_arg, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,143 @@
"""Tests para cdp_press_key — mockean urlopen + create_connection.
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
websocket.create_connection (un fake que captura los mensajes enviados y devuelve
las respuestas CDP con el id correspondiente).
"""
import json
import os
import sys
from contextlib import contextmanager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser import cdp_press_key as mod # noqa: E402
from browser.cdp_press_key import cdp_press_key # noqa: E402
class _FakeResp:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload: list):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return json.dumps(self._payload).encode()
class _FakeWS:
"""WebSocket falso: captura los mensajes enviados y responde por id."""
def __init__(self):
self.sent = []
self._inbox = []
self.closed = False
def send(self, raw: str):
msg = json.loads(raw)
self.sent.append(msg)
# Encola una respuesta CDP vacia con el mismo id (como Chrome devuelve).
self._inbox.append(json.dumps({"id": msg["id"], "result": {}}))
def recv(self):
if self._inbox:
return self._inbox.pop(0)
return ""
def close(self):
self.closed = True
@contextmanager
def _patch(monkeypatch_targets, ws_obj=None, urlopen_exc=None, create_conn_exc=None):
"""Parchea urlopen y create_connection del modulo. Restaura al salir."""
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
def fake_urlopen(url, timeout=5):
if urlopen_exc is not None:
raise urlopen_exc
return _FakeResp(monkeypatch_targets)
def fake_create(ws_url, timeout=10):
if create_conn_exc is not None:
raise create_conn_exc
return ws_obj
mod.urllib.request.urlopen = fake_urlopen
mod.websocket.create_connection = fake_create
try:
yield
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
]
def test_golden_enter_emite_rawkeydown_y_keyup_vk13():
"""Enter envia rawKeyDown + keyUp con windowsVirtualKeyCode 13."""
ws = _FakeWS()
with _patch(_TARGETS, ws_obj=ws):
res = cdp_press_key("Enter", target_url_substr="whatsapp")
assert res == {"ok": True, "error": ""}
assert len(ws.sent) == 2
down, up = ws.sent
assert down["method"] == "Input.dispatchKeyEvent"
assert down["params"]["type"] == "rawKeyDown"
assert down["params"]["key"] == "Enter"
assert down["params"]["code"] == "Enter"
assert down["params"]["windowsVirtualKeyCode"] == 13
assert down["params"]["nativeVirtualKeyCode"] == 13
assert down["params"]["modifiers"] == 0
assert up["params"]["type"] == "keyUp"
assert up["params"]["windowsVirtualKeyCode"] == 13
assert ws.closed is True
def test_edge_tecla_no_soportada_ok_false_sin_abrir_ws():
"""Una tecla fuera del mapa devuelve ok=False sin tocar la red ni abrir WS."""
ws = _FakeWS()
def fail_create(ws_url, timeout=10):
raise AssertionError("create_connection no debe llamarse para tecla no soportada")
def fail_urlopen(url, timeout=5):
raise AssertionError("urlopen no debe llamarse para tecla no soportada")
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
mod.urllib.request.urlopen = fail_urlopen
mod.websocket.create_connection = fail_create
try:
res = cdp_press_key("F13", target_url_substr="whatsapp")
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
assert res["ok"] is False
assert "unsupported key: F13" in res["error"]
assert ws.sent == []
def test_error_create_connection_lanza_ok_false():
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
res = cdp_press_key("Escape", target_url_substr="whatsapp")
assert res["ok"] is False
assert "ws down" in res["error"]
@@ -0,0 +1,87 @@
---
name: cdp_type_chars
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_type_chars(text: str, *, port: int = 9222, target_url_substr: str = '', delay_ms: int = 12, timeout_s: float = 10.0) -> dict"
description: "Escribe texto caracter a caracter en el elemento ENFOCADO de una pestana via CDP (Input.dispatchKeyEvent keyDown/keyUp por char). Unico metodo validado para el editor Lexical de WhatsApp Web, donde document.execCommand/el.value no funcionan. El caller debe enfocar el elemento antes."
tags: [cdp, browser, automation, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["urllib.request", "json", "time", "websocket"]
tested: true
tests: ["escribir ab envia 4 dispatchKeyEvent con text correctos", "text vacio no envia nada y devuelve ok", "fallo de create_connection devuelve ok False"]
test_file_path: "python/functions/browser/cdp_type_chars_test.py"
file_path: "python/functions/browser/cdp_type_chars.py"
params:
- name: text
desc: "Texto a escribir, caracter a caracter. Cada char emite un par keyDown/keyUp."
- name: port
desc: "Puerto de depuracion remota de Chrome (DevTools). Default 9222."
- name: target_url_substr
desc: "Substring para elegir el target page por su URL. Si vacio, usa el primer page disponible."
- name: delay_ms
desc: "Pausa en milisegundos entre cada par de teclas. Humaniza y da tiempo al re-render de editores como Lexical. Default 12."
- name: timeout_s
desc: "Timeout en segundos para el GET /json y la apertura del WebSocket. Default 10.0."
output: "dict {ok: bool, chars_sent: int, error: str}. ok True si todos los chars se enviaron; chars_sent cuenta los enviados (incluso si falla a mitad); error con el mensaje o cadena vacia. Nunca lanza."
---
## Ejemplo
Requiere Chrome lanzado con remote debugging (`--remote-debugging-port=9222`)
y un elemento editable ENFOCADO en la pestana. Primero se enfoca con cdp_eval
(ejecutando `.focus()` sobre el composer), luego se escribe:
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_eval import cdp_eval # enfoca el elemento
from browser.cdp_type_chars import cdp_type_chars
# 1. Enfocar el composer (contenteditable de Lexical) de WhatsApp Web.
cdp_eval(
"document.querySelector('footer div[contenteditable=\"true\"]').focus()",
target_url_substr="whatsapp",
)
# 2. Escribir caracter a caracter en el elemento enfocado.
res = cdp_type_chars("hola", target_url_substr="whatsapp")
print(res) # {'ok': True, 'chars_sent': 4, 'error': ''}
```
> Nota: esta funcion NO enfoca por si misma. El enfoque previo lo hace el
> caller con `cdp_eval_py_browser` (ejecutando `.focus()` sobre el selector).
> El contrato de `cdp_type_chars` es solo "escribir en lo que ya esta
> enfocado".
## Cuando usarla
- Para escribir en inputs, textarea y contenteditable de una pestana ya
abierta: el composer Lexical de WhatsApp Web, su search box, o cualquier
campo que requiera key events reales en vez de asignacion directa de valor.
- SIEMPRE despues de enfocar el elemento destino con cdp_eval ejecutando
`.focus()` sobre el selector.
- Cuando `el.value = ...` o `document.execCommand('insertText')` no surten
efecto porque el editor escucha eventos de teclado (caso Lexical).
## Gotchas
- Escribe en el elemento ACTUALMENTE ENFOCADO, no recibe selector: enfoca
antes con cdp_eval (`...focus()`). Sin foco, los chars se pierden o van al
body.
- NO mezclar con `document.execCommand('insertText')`: en Lexical produce
texto duplicado o intercalado (gotcha real observado). Usa solo una via.
- `delay_ms` muy bajo (cerca de 0) puede perder caracteres en Lexical, que
necesita un re-render entre teclas. 12 ms es un default seguro; subir si
ves chars perdidos.
- Solo inserta texto plano via el campo `text`. Para Enter, Backspace, flechas
u otras teclas especiales (incluido enviar el mensaje) usa
`cdp_press_key_py_browser`.
- Impura: depende de un Chrome con remote debugging accesible en `port`. Si no
hay target page valido devuelve `{"ok": False, ...}` (no lanza).
+123
View File
@@ -0,0 +1,123 @@
"""Escribe texto caracter a caracter en el elemento enfocado via CDP.
Primitiva de input de bajo nivel: envia pares keyDown/keyUp de
`Input.dispatchKeyEvent` por cada caracter del texto al target `page`
seleccionado. El campo `text` del keyDown es lo que inserta el caracter
en el elemento actualmente enfocado (inputs, textarea, contenteditable).
Es el unico metodo validado para el editor Lexical de WhatsApp Web:
`document.execCommand('insertText')` y `el.value = ...` no disparan los
listeners internos de Lexical y el texto no persiste. El caller debe
enfocar el elemento ANTES (p.ej. con cdp_eval ejecutando `.focus()`).
"""
import json
import time
import urllib.request
import websocket
def cdp_type_chars(
text: str,
*,
port: int = 9222,
target_url_substr: str = "",
delay_ms: int = 12,
timeout_s: float = 10.0,
) -> dict:
"""Escribe texto caracter a caracter en el elemento enfocado de una pestana.
Localiza el target `page` por substring de URL, abre el WebSocket CDP y
envia un par keyDown/keyUp de Input.dispatchKeyEvent por cada caracter,
con `delay_ms` de pausa entre pares (humaniza y deja re-renderizar a
editores como Lexical). Escribe en el elemento ACTUALMENTE ENFOCADO; el
caller debe enfocarlo antes (cdp_eval ejecutando `.focus()`).
Args:
text: Texto a escribir, caracter a caracter.
port: Puerto de depuracion remota de Chrome. Default 9222.
target_url_substr: Substring para elegir el target page. Si vacio,
usa el primer page disponible.
delay_ms: Pausa en milisegundos entre cada par de teclas. Default 12.
timeout_s: Timeout de apertura del WebSocket en segundos. Default 10.0.
Returns:
dict con:
ok (bool): True si todos los caracteres se enviaron sin error.
chars_sent (int): Numero de caracteres efectivamente enviados.
error (str): Mensaje de error si lo hubo, "" en caso contrario.
No lanza excepciones: cualquier error de red/WS se devuelve en el dict.
"""
# 1. Localizar el target page por substring de URL.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=timeout_s
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e:
return {"ok": False, "chars_sent": 0,
"error": f"no se pudo conectar a Chrome en port {port}: {e}"}
ws_url = ""
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr and target_url_substr not in url:
continue
ws_url = t.get("webSocketDebuggerUrl", "")
if ws_url:
break
if not ws_url:
hint = f" matching '{target_url_substr}'" if target_url_substr else ""
return {"ok": False, "chars_sent": 0,
"error": f"no target page{hint} con webSocketDebuggerUrl"}
# 2. Abrir WebSocket y enviar las teclas.
chars_sent = 0
ws = None
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
# Lectura no bloqueante para drenar respuestas/eventos sin colgarse.
ws.settimeout(0.1)
msg_id = 1
for ch in text:
ws.send(json.dumps({
"id": msg_id,
"method": "Input.dispatchKeyEvent",
"params": {"type": "keyDown", "text": ch},
}))
ws.send(json.dumps({
"id": msg_id + 1,
"method": "Input.dispatchKeyEvent",
"params": {"type": "keyUp", "text": ch},
}))
msg_id += 2
chars_sent += 1
# Drenar el socket sin bloquear: descarta lo que haya llegado.
try:
while True:
ws.recv()
except Exception:
pass
time.sleep(delay_ms / 1000.0)
return {"ok": True, "chars_sent": chars_sent, "error": ""}
except Exception as e:
return {"ok": False, "chars_sent": chars_sent, "error": str(e)}
finally:
if ws is not None:
try:
ws.close()
except Exception:
pass
if __name__ == "__main__":
import sys
txt = sys.argv[1] if len(sys.argv) > 1 else "hola"
substr = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cdp_type_chars(txt, target_url_substr=substr), ensure_ascii=False))
@@ -0,0 +1,123 @@
"""Tests para cdp_type_chars.
Mockea urllib.request.urlopen (GET /json) y websocket.create_connection
(conexion fake que acumula los mensajes enviados) para validar el transporte
CDP sin un Chrome real.
"""
import io
import json
import cdp_type_chars as mod
from cdp_type_chars import cdp_type_chars
class _FakeResp:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload):
self._buf = io.BytesIO(json.dumps(payload).encode())
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return self._buf.read()
class _FakeWS:
"""Conexion WebSocket fake: acumula los mensajes enviados y nunca recibe."""
def __init__(self):
self.sent = []
self.closed = False
def settimeout(self, _):
pass
def send(self, payload):
self.sent.append(json.loads(payload))
def recv(self):
# Socket vacio: simula timeout no bloqueante para drenar.
raise TimeoutError("empty")
def close(self):
self.closed = True
def _patch_targets(monkeypatch, payload):
monkeypatch.setattr(
mod.urllib.request, "urlopen", lambda *a, **k: _FakeResp(payload)
)
def _patch_sleep(monkeypatch):
# Evita esperas reales por delay_ms.
monkeypatch.setattr(mod.time, "sleep", lambda _s: None)
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/AB12"},
]
def test_escribir_ab_envia_4_dispatchkeyevent_con_text_correctos(monkeypatch):
"""Golden: 'ab' produce 4 Input.dispatchKeyEvent con los text correctos."""
fake_ws = _FakeWS()
_patch_targets(monkeypatch, _TARGETS)
_patch_sleep(monkeypatch)
monkeypatch.setattr(mod.websocket, "create_connection", lambda *a, **k: fake_ws)
res = cdp_type_chars("ab", target_url_substr="whatsapp")
assert res["ok"] is True
assert res["chars_sent"] == 2
assert res["error"] == ""
methods = [m["method"] for m in fake_ws.sent]
assert methods == ["Input.dispatchKeyEvent"] * 4
types = [m["params"]["type"] for m in fake_ws.sent]
assert types == ["keyDown", "keyUp", "keyDown", "keyUp"]
texts = [m["params"]["text"] for m in fake_ws.sent]
assert texts == ["a", "a", "b", "b"]
assert fake_ws.closed is True
def test_text_vacio_no_envia_nada_y_devuelve_ok(monkeypatch):
"""Edge: texto vacio -> 0 chars, ok True, sin mensajes enviados."""
fake_ws = _FakeWS()
_patch_targets(monkeypatch, _TARGETS)
_patch_sleep(monkeypatch)
monkeypatch.setattr(mod.websocket, "create_connection", lambda *a, **k: fake_ws)
res = cdp_type_chars("", target_url_substr="whatsapp")
assert res["ok"] is True
assert res["chars_sent"] == 0
assert res["error"] == ""
assert fake_ws.sent == []
def test_fallo_de_create_connection_devuelve_ok_false(monkeypatch):
"""Error: create_connection lanza -> ok False, error poblado."""
_patch_targets(monkeypatch, _TARGETS)
_patch_sleep(monkeypatch)
def _boom(*a, **k):
raise ConnectionRefusedError("connection refused")
monkeypatch.setattr(mod.websocket, "create_connection", _boom)
res = cdp_type_chars("hola", target_url_substr="whatsapp")
assert res["ok"] is False
assert res["chars_sent"] == 0
assert "connection refused" in res["error"]