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))