feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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))
|
||||
@@ -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"] == ""
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user