feat(browser): add clawd browser control
This commit is contained in:
108
src/browser/cdp.ts
Normal file
108
src/browser/cdp.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
type CdpResponse = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
export async function captureScreenshotPng(opts: {
|
||||
wsUrl: string;
|
||||
fullPage?: boolean;
|
||||
}): Promise<Buffer> {
|
||||
const ws = new WebSocket(opts.wsUrl, { handshakeTimeout: 5000 });
|
||||
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
|
||||
const send = (method: string, params?: Record<string, unknown>) => {
|
||||
const id = nextId++;
|
||||
const msg = { id, method, params };
|
||||
ws.send(JSON.stringify(msg));
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const closeWithError = (err: Error) => {
|
||||
for (const [, p] of pending) p.reject(err);
|
||||
pending.clear();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", () => resolve());
|
||||
ws.once("error", (err) => reject(err));
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(data)) as CdpResponse;
|
||||
if (typeof parsed.id !== "number") return;
|
||||
const p = pending.get(parsed.id);
|
||||
if (!p) return;
|
||||
pending.delete(parsed.id);
|
||||
if (parsed.error?.message) {
|
||||
p.reject(new Error(parsed.error.message));
|
||||
return;
|
||||
}
|
||||
p.resolve(parsed.result);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
closeWithError(new Error("CDP socket closed"));
|
||||
});
|
||||
|
||||
await openPromise;
|
||||
|
||||
await send("Page.enable");
|
||||
|
||||
let clip:
|
||||
| { x: number; y: number; width: number; height: number; scale: number }
|
||||
| undefined;
|
||||
if (opts.fullPage) {
|
||||
const metrics = (await send("Page.getLayoutMetrics")) as {
|
||||
cssContentSize?: { width?: number; height?: number };
|
||||
contentSize?: { width?: number; height?: number };
|
||||
};
|
||||
const size = metrics?.cssContentSize ?? metrics?.contentSize;
|
||||
const width = Number(size?.width ?? 0);
|
||||
const height = Number(size?.height ?? 0);
|
||||
if (width > 0 && height > 0) {
|
||||
clip = { x: 0, y: 0, width, height, scale: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const result = (await send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
...(clip ? { clip } : {}),
|
||||
})) as { data?: string };
|
||||
|
||||
const base64 = result?.data;
|
||||
if (!base64) {
|
||||
closeWithError(new Error("Screenshot failed: missing data"));
|
||||
throw new Error("Screenshot failed: missing data");
|
||||
}
|
||||
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
Reference in New Issue
Block a user