lazyBoy/crates/control/src/cdp.py

363 lines
12 KiB
Python
Raw Normal View History

2026-09-04 09:08:56 +00:00
import json, os, sys, time, socket, base64, struct, subprocess, urllib.request
def fail(msg):
print(json.dumps({"ok": False, "error": msg}))
sys.exit(0)
def http_json(url, timeout=2):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception:
return None
class Ws:
def __init__(self, url):
rest = url[5:]
hostpath = rest.split("/", 1)
hostport = hostpath[0]
path = "/" + (hostpath[1] if len(hostpath) > 1 else "")
if ":" in hostport:
host, port = hostport.rsplit(":", 1)
port = int(port)
else:
host, port = hostport, 80
self.sock = socket.create_connection((host, port), 5)
key = base64.b64encode(os.urandom(16)).decode()
req = (
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\nOrigin: http://%s\r\n\r\n"
% (path, hostport, key, hostport)
)
self.sock.sendall(req.encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("ws handshake closed")
buf += chunk
self.n = 0
def _frame(self, data):
n = len(data)
hdr = bytearray([0x81])
if n < 126:
hdr.append(0x80 | n)
elif n < 65536:
hdr.append(0x80 | 126)
hdr += struct.pack("!H", n)
else:
hdr.append(0x80 | 127)
hdr += struct.pack("!Q", n)
mask = os.urandom(4)
hdr += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return bytes(hdr) + masked
def _read(self, n):
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise RuntimeError("ws eof")
buf += chunk
return buf
def _read_frame(self):
hdr = self._read(2)
opcode = hdr[0] & 0x0F
masked = hdr[1] & 0x80
n = hdr[1] & 0x7F
if n == 126:
n = struct.unpack("!H", self._read(2))[0]
elif n == 127:
n = struct.unpack("!Q", self._read(8))[0]
mask = self._read(4) if masked else b""
payload = self._read(n)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
def recv_json(self):
while True:
opcode, payload = self._read_frame()
if opcode == 0x8:
raise RuntimeError("ws closed")
if opcode == 0x9:
continue
if opcode in (0x1, 0x2):
return json.loads(payload.decode())
def call(self, method, params=None):
self.n += 1
msg = {"id": self.n, "method": method}
if params:
msg["params"] = params
self.sock.sendall(self._frame(json.dumps(msg).encode()))
while True:
obj = self.recv_json()
if obj.get("id") == self.n:
if "error" in obj:
raise RuntimeError(str(obj["error"]))
return obj.get("result") or {}
def close(self):
try:
self.sock.close()
except Exception:
pass
def probe(port):
return http_json("http://127.0.0.1:%s/json/version" % port) is not None
def kill_profile(profile):
if not profile:
return
try:
out = subprocess.check_output(["pgrep", "-af", "chromium"], text=True, stderr=subprocess.DEVNULL)
except Exception:
return
for line in out.splitlines():
if profile not in line or "--type=" in line:
continue
try:
os.kill(int(line.split()[0]), 15)
except Exception:
pass
def spawn_browser(display, profile, port):
env = os.environ.copy()
env["DISPLAY"] = display
if profile:
env["LAZYBOY_BROWSER_PROFILE"] = profile
subprocess.Popen(
["lazyboy-browser", "--remote-debugging-port=%s" % port, "--remote-allow-origins=*"],
env=env,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def connect(port):
tabs = http_json("http://127.0.0.1:%s/json/list" % port) or []
pages = [t for t in tabs if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
if not pages:
fail("no browser tab")
pages.sort(key=lambda t: (t.get("url") or "").startswith("chrome://"), reverse=False)
ws = Ws(pages[0]["webSocketDebuggerUrl"])
ws.call("Runtime.enable")
ws.call("Page.enable")
return ws
SNAP_JS = r"""
(() => {
const sels = 'a, button, input, textarea, select, summary, label, [role="button"], [role="link"], [role="textbox"], [role="checkbox"], [role="menuitem"], [contenteditable="true"]';
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx0 = (window.screenX || 0) + Math.floor(chromeW / 2);
const sy0 = (window.screenY || 0) + chromeH;
const seen = new Set();
const out = [];
let n = 1;
for (const el of document.querySelectorAll(sels)) {
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue;
if (r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth) continue;
const st = getComputedStyle(el);
if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
const text = (el.innerText || el.value || el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.getAttribute("name") || el.tagName)
.replace(/\s+/g, " ").trim().slice(0, 80);
if (!text) continue;
const key = [el.tagName, text, Math.round(r.x), Math.round(r.y)].join("|");
if (seen.has(key)) continue;
seen.add(key);
el.setAttribute("data-lazyboy", String(n));
out.push({
id: n,
title: text,
tag: el.tagName.toLowerCase(),
selector: '[data-lazyboy="' + n + '"]',
kind: "dom",
x: Math.max(0, Math.round(sx0 + r.x)),
y: Math.max(0, Math.round(sy0 + r.y)),
w: Math.round(r.width),
h: Math.round(r.height)
});
n += 1;
if (out.length >= 50) break;
}
const body = (document.body && document.body.innerText || "").replace(/\s+/g, " ").trim().slice(0, 3000);
return {url: location.href, title: document.title || "", text: body, elements: out};
})()
"""
CLICK_JS = r"""
(sel) => {
const el = document.querySelector(sel);
if (!el) return {ok: false, error: "element gone"};
el.scrollIntoView({block: "center", inline: "nearest"});
const r = el.getBoundingClientRect();
el.focus();
el.click();
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx = (window.screenX || 0) + Math.floor(chromeW / 2) + r.x + r.width / 2;
const sy = (window.screenY || 0) + chromeH + r.y + r.height / 2;
return {ok: true, x: Math.round(sx), y: Math.round(sy)};
}
"""
def evaluate(ws, expression, args=None):
params = {"expression": expression, "returnByValue": True, "awaitPromise": True}
if args is not None:
params = {
"expression": "(%s)(%s)" % (expression, json.dumps(args)),
"returnByValue": True,
"awaitPromise": True,
}
result = ws.call("Runtime.evaluate", params)
val = (result.get("result") or {}).get("value")
if result.get("exceptionDetails"):
raise RuntimeError(str(result["exceptionDetails"]))
return val
def snapshot(ws):
val = evaluate(ws, SNAP_JS) or {}
return {
"ok": True,
"action": "snapshot",
"url": val.get("url") or "",
"title": val.get("title") or "",
"text": val.get("text") or "",
"elements": val.get("elements") or [],
}
def pointer(display, x, y):
try:
subprocess.check_call(
["env", "DISPLAY=%s" % display, "xdotool", "mousemove", "--sync", "--", str(int(x)), str(int(y))],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
KEYS = {
"Return": (13, "Enter", "Enter"),
"Enter": (13, "Enter", "Enter"),
"Tab": (9, "Tab", "Tab"),
"BackSpace": (8, "Backspace", "Backspace"),
"Backspace": (8, "Backspace", "Backspace"),
"Escape": (27, "Escape", "Escape"),
"Esc": (27, "Escape", "Escape"),
"Space": (32, " ", "Space"),
}
def press(ws, key):
spec = KEYS.get(key) or KEYS.get(key.title())
if spec:
code, name, key_id = spec
for typ in ("keyDown", "keyUp"):
ws.call("Input.dispatchKeyEvent", {
"type": typ,
"windowsVirtualKeyCode": code,
"key": name,
"code": key_id,
})
return
ws.call("Input.dispatchKeyEvent", {"type": "keyDown", "text": key[:1]})
ws.call("Input.dispatchKeyEvent", {"type": "keyUp", "text": key[:1]})
def main():
req = json.loads(sys.argv[1])
action = req.get("action") or "snapshot"
display = req.get("display") or ":1"
profile = req.get("profile") or ""
port = int(req.get("port") or 9222)
ensure = bool(req.get("ensure"))
if action == "probe":
print(json.dumps({"ok": probe(port)}))
return
if not probe(port):
if not ensure:
fail("cdp unavailable")
kill_profile(profile)
time.sleep(0.4)
spawn_browser(display, profile, port)
ready = False
for _ in range(24):
time.sleep(0.25)
if probe(port):
ready = True
break
if not ready:
fail("cdp unavailable")
restarted = True
else:
restarted = False
if action == "ensure":
print(json.dumps({"ok": True, "restarted": restarted}))
return
ws = connect(port)
try:
if action == "snapshot":
body = snapshot(ws)
body["restarted"] = restarted
print(json.dumps(body))
return
if action == "navigate":
url = req.get("url") or ""
if not url:
fail("url required")
ws.call("Page.navigate", {"url": url})
time.sleep(1.2)
body = snapshot(ws)
body["restarted"] = restarted
print(json.dumps(body))
return
if action == "click":
sel = req.get("selector") or ""
if not sel:
fail("selector required")
val = evaluate(ws, CLICK_JS, sel) or {}
if not val.get("ok"):
fail(val.get("error") or "click failed")
pointer(display, val.get("x") or 0, val.get("y") or 0)
print(json.dumps({"ok": True, "action": "click", "selector": sel, "restarted": restarted}))
return
if action == "type":
sel = req.get("selector") or ""
text = req.get("text") or ""
if sel:
val = evaluate(ws, CLICK_JS, sel) or {}
if val.get("ok"):
pointer(display, val.get("x") or 0, val.get("y") or 0)
if text:
ws.call("Input.insertText", {"text": text})
print(json.dumps({"ok": True, "action": "type", "restarted": restarted}))
return
if action == "press":
key = req.get("key") or "Return"
press(ws, key)
print(json.dumps({"ok": True, "action": "press", "key": key, "restarted": restarted}))
return
if action == "wait":
ms = min(max(int(req.get("ms") or 400), 0), 5000)
time.sleep(ms / 1000.0)
print(json.dumps({"ok": True, "action": "wait", "ms": ms}))
return
fail("unsupported action")
finally:
ws.close()
if __name__ == "__main__":
try:
main()
except Exception as e:
fail(str(e))