@phi-code-admin/browser 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +173 -0
- package/dist/index.js +368 -0
- package/package.json +49 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @phi-code-admin/browser — programmatic browser API for phi-code.
|
|
3
|
+
*
|
|
4
|
+
* Boots the bundled `@phi-code-admin/camofox-browser` Express server on a
|
|
5
|
+
* private localhost port the first time any tool is called, then exposes
|
|
6
|
+
* the 10 OpenClaw tools as plain async functions. Shutdown is automatic
|
|
7
|
+
* on `process.exit` and can be triggered explicitly with `closeAll()`.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints (per phi-code vendoring spec):
|
|
10
|
+
* - Zero external network calls. The Camoufox binary is provided by
|
|
11
|
+
* `@phi-code-admin/camoufox-bin-*` via npm optionalDependencies.
|
|
12
|
+
* - The Express server is an implementation detail; consumers only see
|
|
13
|
+
* ES module exports. The server can still be launched independently
|
|
14
|
+
* via `npx @phi-code-admin/camofox-browser` for users who want REST.
|
|
15
|
+
* - Each tool returns a JSON-serialisable object. No process objects,
|
|
16
|
+
* no file handles, no streams — the result is safe to pass into a
|
|
17
|
+
* TUI rendering layer or to serialise as a tool_result message.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Boot (or reuse) the camofox-browser server. Idempotent across calls.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ensureServer(): Promise<{
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Kill the embedded camofox-browser server (if running) and reset state.
|
|
27
|
+
* Safe to call multiple times. Resolves once the child has exited.
|
|
28
|
+
*/
|
|
29
|
+
export declare function closeAll(): Promise<void>;
|
|
30
|
+
export interface CreateTabResult {
|
|
31
|
+
tabId: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
url?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Open a new browser tab. Returns the tab id used by the other tools. */
|
|
36
|
+
export declare function createTab(options: {
|
|
37
|
+
userId?: string;
|
|
38
|
+
url?: string;
|
|
39
|
+
viewport?: {
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
};
|
|
43
|
+
}): Promise<CreateTabResult>;
|
|
44
|
+
export interface NavigateResult {
|
|
45
|
+
tabId: string;
|
|
46
|
+
url: string;
|
|
47
|
+
status?: number;
|
|
48
|
+
loadEvent?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Navigate the given tab (or a freshly opened one) to a URL.
|
|
52
|
+
* High-level convenience: passing `url` without `tabId` opens a new tab
|
|
53
|
+
* first.
|
|
54
|
+
*/
|
|
55
|
+
export declare function navigate(options: {
|
|
56
|
+
url: string;
|
|
57
|
+
tabId?: string;
|
|
58
|
+
userId?: string;
|
|
59
|
+
waitUntil?: "load" | "domcontentloaded" | "networkidle";
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
}): Promise<NavigateResult>;
|
|
62
|
+
/**
|
|
63
|
+
* Get an accessibility snapshot (DOM tree with ref ids) of the given tab.
|
|
64
|
+
* Refs returned here can be used with `click`/`type`/`scroll`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function snapshot(options: {
|
|
67
|
+
tabId: string;
|
|
68
|
+
}): Promise<unknown>;
|
|
69
|
+
export interface ExtractResult {
|
|
70
|
+
url?: string;
|
|
71
|
+
title?: string;
|
|
72
|
+
content?: string;
|
|
73
|
+
textContent?: string;
|
|
74
|
+
excerpt?: string;
|
|
75
|
+
length?: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extract the readable content of the current page (Readability-style).
|
|
79
|
+
* For a fresh page, pass `url` to navigate first; otherwise the tab's
|
|
80
|
+
* current document is extracted.
|
|
81
|
+
*/
|
|
82
|
+
export declare function extract(options: {
|
|
83
|
+
tabId?: string;
|
|
84
|
+
userId?: string;
|
|
85
|
+
url?: string;
|
|
86
|
+
mode?: "readability" | "html" | "text";
|
|
87
|
+
}): Promise<ExtractResult>;
|
|
88
|
+
export interface ScreenshotResult {
|
|
89
|
+
tabId: string;
|
|
90
|
+
mimeType: string;
|
|
91
|
+
bytesBase64: string;
|
|
92
|
+
}
|
|
93
|
+
/** Capture a screenshot of the given tab as a base64-encoded PNG. */
|
|
94
|
+
export declare function screenshot(options: {
|
|
95
|
+
tabId: string;
|
|
96
|
+
fullPage?: boolean;
|
|
97
|
+
clip?: {
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
width: number;
|
|
101
|
+
height: number;
|
|
102
|
+
};
|
|
103
|
+
}): Promise<ScreenshotResult>;
|
|
104
|
+
/**
|
|
105
|
+
* High-level search macro: opens a new tab, navigates to a search engine
|
|
106
|
+
* macro (`?q=...` on Google / DDG depending on host config), and returns
|
|
107
|
+
* the readability extraction of the result page.
|
|
108
|
+
*/
|
|
109
|
+
export declare function search(options: {
|
|
110
|
+
query: string;
|
|
111
|
+
engine?: "google" | "duckduckgo" | "bing";
|
|
112
|
+
userId?: string;
|
|
113
|
+
}): Promise<ExtractResult>;
|
|
114
|
+
/** Click an element by ref (from `snapshot`) or CSS selector. */
|
|
115
|
+
export declare function click(options: {
|
|
116
|
+
tabId: string;
|
|
117
|
+
ref?: string;
|
|
118
|
+
selector?: string;
|
|
119
|
+
button?: "left" | "right" | "middle";
|
|
120
|
+
}): Promise<{
|
|
121
|
+
tabId: string;
|
|
122
|
+
}>;
|
|
123
|
+
/** Type text into a focused element (or one targeted via ref/selector). */
|
|
124
|
+
export declare function type(options: {
|
|
125
|
+
tabId: string;
|
|
126
|
+
text: string;
|
|
127
|
+
ref?: string;
|
|
128
|
+
selector?: string;
|
|
129
|
+
pressEnter?: boolean;
|
|
130
|
+
delayMs?: number;
|
|
131
|
+
}): Promise<{
|
|
132
|
+
tabId: string;
|
|
133
|
+
}>;
|
|
134
|
+
/** Scroll the page or a specific element by ref. */
|
|
135
|
+
export declare function scroll(options: {
|
|
136
|
+
tabId: string;
|
|
137
|
+
direction: "up" | "down" | "left" | "right";
|
|
138
|
+
ref?: string;
|
|
139
|
+
pixels?: number;
|
|
140
|
+
}): Promise<{
|
|
141
|
+
tabId: string;
|
|
142
|
+
}>;
|
|
143
|
+
/** Close a single tab. The underlying browser context is kept warm. */
|
|
144
|
+
export declare function closeTab(options: {
|
|
145
|
+
tabId: string;
|
|
146
|
+
}): Promise<{
|
|
147
|
+
tabId: string;
|
|
148
|
+
}>;
|
|
149
|
+
export interface ListedTab {
|
|
150
|
+
tabId: string;
|
|
151
|
+
url?: string;
|
|
152
|
+
title?: string;
|
|
153
|
+
createdAt?: number;
|
|
154
|
+
}
|
|
155
|
+
/** List all open tabs for a user. */
|
|
156
|
+
export declare function listTabs(options?: {
|
|
157
|
+
userId?: string;
|
|
158
|
+
}): Promise<ListedTab[]>;
|
|
159
|
+
export type BrowserApi = {
|
|
160
|
+
createTab: typeof createTab;
|
|
161
|
+
navigate: typeof navigate;
|
|
162
|
+
snapshot: typeof snapshot;
|
|
163
|
+
extract: typeof extract;
|
|
164
|
+
screenshot: typeof screenshot;
|
|
165
|
+
search: typeof search;
|
|
166
|
+
click: typeof click;
|
|
167
|
+
type: typeof type;
|
|
168
|
+
scroll: typeof scroll;
|
|
169
|
+
closeTab: typeof closeTab;
|
|
170
|
+
listTabs: typeof listTabs;
|
|
171
|
+
ensureServer: typeof ensureServer;
|
|
172
|
+
closeAll: typeof closeAll;
|
|
173
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @phi-code-admin/browser — programmatic browser API for phi-code.
|
|
3
|
+
*
|
|
4
|
+
* Boots the bundled `@phi-code-admin/camofox-browser` Express server on a
|
|
5
|
+
* private localhost port the first time any tool is called, then exposes
|
|
6
|
+
* the 10 OpenClaw tools as plain async functions. Shutdown is automatic
|
|
7
|
+
* on `process.exit` and can be triggered explicitly with `closeAll()`.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints (per phi-code vendoring spec):
|
|
10
|
+
* - Zero external network calls. The Camoufox binary is provided by
|
|
11
|
+
* `@phi-code-admin/camoufox-bin-*` via npm optionalDependencies.
|
|
12
|
+
* - The Express server is an implementation detail; consumers only see
|
|
13
|
+
* ES module exports. The server can still be launched independently
|
|
14
|
+
* via `npx @phi-code-admin/camofox-browser` for users who want REST.
|
|
15
|
+
* - Each tool returns a JSON-serialisable object. No process objects,
|
|
16
|
+
* no file handles, no streams — the result is safe to pass into a
|
|
17
|
+
* TUI rendering layer or to serialise as a tool_result message.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import * as net from "node:net";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
// ─── Server lifecycle ────────────────────────────────────────────────────
|
|
25
|
+
let serverProcess = null;
|
|
26
|
+
let serverPort = null;
|
|
27
|
+
let bootPromise = null;
|
|
28
|
+
const DEFAULT_USER_ID = "phi-default";
|
|
29
|
+
const HEALTH_TIMEOUT_MS = 30_000;
|
|
30
|
+
const HEALTH_POLL_INTERVAL_MS = 250;
|
|
31
|
+
async function findAvailablePort() {
|
|
32
|
+
return await new Promise((resolve, reject) => {
|
|
33
|
+
const server = net.createServer();
|
|
34
|
+
server.unref();
|
|
35
|
+
server.on("error", reject);
|
|
36
|
+
server.listen(0, "127.0.0.1", () => {
|
|
37
|
+
const address = server.address();
|
|
38
|
+
if (address && typeof address !== "string") {
|
|
39
|
+
server.close(() => resolve(address.port));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
server.close();
|
|
43
|
+
reject(new Error("Could not allocate port"));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function waitForHealth(baseUrl) {
|
|
49
|
+
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${baseUrl}/health`);
|
|
53
|
+
if (res.ok)
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// not yet
|
|
58
|
+
}
|
|
59
|
+
await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`camofox-browser server failed to become healthy at ${baseUrl} within ${HEALTH_TIMEOUT_MS}ms`);
|
|
62
|
+
}
|
|
63
|
+
function resolveServerEntry() {
|
|
64
|
+
// The vendored camofox-browser ships its Express entry as `server.js`
|
|
65
|
+
// (declared as the `main` field). createRequire resolves the package
|
|
66
|
+
// to that file even when consumers install us via npm/pnpm/yarn.
|
|
67
|
+
return require.resolve("@phi-code-admin/camofox-browser");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Boot (or reuse) the camofox-browser server. Idempotent across calls.
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureServer() {
|
|
73
|
+
if (bootPromise)
|
|
74
|
+
return bootPromise;
|
|
75
|
+
bootPromise = (async () => {
|
|
76
|
+
const port = await findAvailablePort();
|
|
77
|
+
const entry = resolveServerEntry();
|
|
78
|
+
const cwd = path.dirname(entry);
|
|
79
|
+
const env = {
|
|
80
|
+
...process.env,
|
|
81
|
+
PORT: String(port),
|
|
82
|
+
// Disable telemetry by default (PHI-VENDOR contract).
|
|
83
|
+
CAMOFOX_CRASH_REPORT_URL: process.env.CAMOFOX_CRASH_REPORT_URL || "",
|
|
84
|
+
// Tighten resource caps; phi-code is interactive, so 2 sessions
|
|
85
|
+
// with 4 tabs each is plenty. Override with the env var.
|
|
86
|
+
MAX_SESSIONS: process.env.MAX_SESSIONS || "2",
|
|
87
|
+
MAX_TABS_PER_SESSION: process.env.MAX_TABS_PER_SESSION || "4",
|
|
88
|
+
};
|
|
89
|
+
const child = spawn(process.execPath, [entry], {
|
|
90
|
+
cwd,
|
|
91
|
+
env,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
detached: false,
|
|
94
|
+
});
|
|
95
|
+
// Surface crashes during boot, but never propagate to the consumer.
|
|
96
|
+
child.stderr?.on("data", (chunk) => {
|
|
97
|
+
if (process.env.PHI_BROWSER_VERBOSE) {
|
|
98
|
+
process.stderr.write(`[camofox] ${chunk}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.on("exit", (code) => {
|
|
102
|
+
serverProcess = null;
|
|
103
|
+
serverPort = null;
|
|
104
|
+
bootPromise = null;
|
|
105
|
+
if (process.env.PHI_BROWSER_VERBOSE) {
|
|
106
|
+
process.stderr.write(`[camofox] server exited with code ${code}\n`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
serverProcess = child;
|
|
110
|
+
serverPort = port;
|
|
111
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
112
|
+
await waitForHealth(baseUrl);
|
|
113
|
+
return { baseUrl };
|
|
114
|
+
})();
|
|
115
|
+
try {
|
|
116
|
+
return await bootPromise;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
bootPromise = null;
|
|
120
|
+
serverProcess = null;
|
|
121
|
+
serverPort = null;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Kill the embedded camofox-browser server (if running) and reset state.
|
|
127
|
+
* Safe to call multiple times. Resolves once the child has exited.
|
|
128
|
+
*/
|
|
129
|
+
export async function closeAll() {
|
|
130
|
+
const proc = serverProcess;
|
|
131
|
+
bootPromise = null;
|
|
132
|
+
serverProcess = null;
|
|
133
|
+
serverPort = null;
|
|
134
|
+
if (!proc)
|
|
135
|
+
return;
|
|
136
|
+
return await new Promise((resolve) => {
|
|
137
|
+
const done = () => resolve();
|
|
138
|
+
proc.once("exit", done);
|
|
139
|
+
try {
|
|
140
|
+
proc.kill("SIGTERM");
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
done();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Hard fallback after 2s — Firefox can take a moment.
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
try {
|
|
149
|
+
proc.kill("SIGKILL");
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* already dead */
|
|
153
|
+
}
|
|
154
|
+
}, 2_000);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Best-effort cleanup on process exit. Async cleanup is allowed in the
|
|
158
|
+
// `beforeExit` phase; `exit` is sync-only so we can only request a kill.
|
|
159
|
+
process.on("beforeExit", () => {
|
|
160
|
+
void closeAll();
|
|
161
|
+
});
|
|
162
|
+
process.on("exit", () => {
|
|
163
|
+
const proc = serverProcess;
|
|
164
|
+
if (proc) {
|
|
165
|
+
try {
|
|
166
|
+
proc.kill("SIGKILL");
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* no-op */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
async function request(pathname, options = {}) {
|
|
174
|
+
const { baseUrl } = await ensureServer();
|
|
175
|
+
const url = `${baseUrl}${pathname}`;
|
|
176
|
+
const method = options.method ?? "GET";
|
|
177
|
+
const headers = {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
...(options.headers ?? {}),
|
|
180
|
+
};
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 60_000);
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(url, {
|
|
185
|
+
method,
|
|
186
|
+
headers,
|
|
187
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
});
|
|
190
|
+
const text = await res.text();
|
|
191
|
+
let parsed = undefined;
|
|
192
|
+
if (text) {
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
parsed = text;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
const message = typeof parsed === "object" && parsed && "error" in parsed
|
|
202
|
+
? String(parsed.error)
|
|
203
|
+
: `HTTP ${res.status}`;
|
|
204
|
+
throw new Error(`${method} ${pathname} → ${message}`);
|
|
205
|
+
}
|
|
206
|
+
return parsed;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
clearTimeout(timeout);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Open a new browser tab. Returns the tab id used by the other tools. */
|
|
213
|
+
export async function createTab(options) {
|
|
214
|
+
const userId = options.userId ?? DEFAULT_USER_ID;
|
|
215
|
+
const body = { userId };
|
|
216
|
+
if (options.url)
|
|
217
|
+
body.url = options.url;
|
|
218
|
+
if (options.viewport)
|
|
219
|
+
body.viewport = options.viewport;
|
|
220
|
+
const res = await request("/tabs", { method: "POST", body });
|
|
221
|
+
return { tabId: res.tabId, userId, url: options.url };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Navigate the given tab (or a freshly opened one) to a URL.
|
|
225
|
+
* High-level convenience: passing `url` without `tabId` opens a new tab
|
|
226
|
+
* first.
|
|
227
|
+
*/
|
|
228
|
+
export async function navigate(options) {
|
|
229
|
+
let tabId = options.tabId;
|
|
230
|
+
if (!tabId) {
|
|
231
|
+
const tab = await createTab({ userId: options.userId, url: options.url });
|
|
232
|
+
tabId = tab.tabId;
|
|
233
|
+
return { tabId, url: options.url };
|
|
234
|
+
}
|
|
235
|
+
const body = { url: options.url };
|
|
236
|
+
if (options.waitUntil)
|
|
237
|
+
body.waitUntil = options.waitUntil;
|
|
238
|
+
if (options.timeoutMs)
|
|
239
|
+
body.timeoutMs = options.timeoutMs;
|
|
240
|
+
const res = await request(`/tabs/${encodeURIComponent(tabId)}/navigate`, { method: "POST", body });
|
|
241
|
+
return { tabId, url: options.url, status: res.status, loadEvent: res.loadEvent };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get an accessibility snapshot (DOM tree with ref ids) of the given tab.
|
|
245
|
+
* Refs returned here can be used with `click`/`type`/`scroll`.
|
|
246
|
+
*/
|
|
247
|
+
export async function snapshot(options) {
|
|
248
|
+
return await request(`/tabs/${encodeURIComponent(options.tabId)}/snapshot`);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Extract the readable content of the current page (Readability-style).
|
|
252
|
+
* For a fresh page, pass `url` to navigate first; otherwise the tab's
|
|
253
|
+
* current document is extracted.
|
|
254
|
+
*/
|
|
255
|
+
export async function extract(options) {
|
|
256
|
+
let tabId = options.tabId;
|
|
257
|
+
if (!tabId) {
|
|
258
|
+
if (!options.url) {
|
|
259
|
+
throw new Error("extract() requires either tabId or url");
|
|
260
|
+
}
|
|
261
|
+
const tab = await createTab({ userId: options.userId, url: options.url });
|
|
262
|
+
tabId = tab.tabId;
|
|
263
|
+
// Wait for the navigation to settle before extracting.
|
|
264
|
+
await request(`/tabs/${encodeURIComponent(tabId)}/wait`, {
|
|
265
|
+
method: "POST",
|
|
266
|
+
body: { event: "load" },
|
|
267
|
+
}).catch(() => { });
|
|
268
|
+
}
|
|
269
|
+
else if (options.url) {
|
|
270
|
+
await navigate({ tabId, url: options.url });
|
|
271
|
+
}
|
|
272
|
+
const res = await request(`/tabs/${encodeURIComponent(tabId)}/extract`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: { mode: options.mode ?? "readability" },
|
|
275
|
+
});
|
|
276
|
+
return res;
|
|
277
|
+
}
|
|
278
|
+
/** Capture a screenshot of the given tab as a base64-encoded PNG. */
|
|
279
|
+
export async function screenshot(options) {
|
|
280
|
+
const query = new URLSearchParams();
|
|
281
|
+
if (options.fullPage)
|
|
282
|
+
query.set("fullPage", "1");
|
|
283
|
+
if (options.clip)
|
|
284
|
+
query.set("clip", JSON.stringify(options.clip));
|
|
285
|
+
const qs = query.toString();
|
|
286
|
+
const res = await request(`/tabs/${encodeURIComponent(options.tabId)}/screenshot${qs ? `?${qs}` : ""}`);
|
|
287
|
+
return {
|
|
288
|
+
tabId: options.tabId,
|
|
289
|
+
mimeType: res.mimeType ?? "image/png",
|
|
290
|
+
bytesBase64: res.image ?? "",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* High-level search macro: opens a new tab, navigates to a search engine
|
|
295
|
+
* macro (`?q=...` on Google / DDG depending on host config), and returns
|
|
296
|
+
* the readability extraction of the result page.
|
|
297
|
+
*/
|
|
298
|
+
export async function search(options) {
|
|
299
|
+
const engine = options.engine ?? "duckduckgo";
|
|
300
|
+
const url = engine === "google"
|
|
301
|
+
? `https://www.google.com/search?q=${encodeURIComponent(options.query)}`
|
|
302
|
+
: engine === "bing"
|
|
303
|
+
? `https://www.bing.com/search?q=${encodeURIComponent(options.query)}`
|
|
304
|
+
: `https://duckduckgo.com/?q=${encodeURIComponent(options.query)}`;
|
|
305
|
+
return await extract({ url, userId: options.userId });
|
|
306
|
+
}
|
|
307
|
+
/** Click an element by ref (from `snapshot`) or CSS selector. */
|
|
308
|
+
export async function click(options) {
|
|
309
|
+
if (!options.ref && !options.selector) {
|
|
310
|
+
throw new Error("click() requires `ref` or `selector`");
|
|
311
|
+
}
|
|
312
|
+
const body = {};
|
|
313
|
+
if (options.ref)
|
|
314
|
+
body.ref = options.ref;
|
|
315
|
+
if (options.selector)
|
|
316
|
+
body.selector = options.selector;
|
|
317
|
+
if (options.button)
|
|
318
|
+
body.button = options.button;
|
|
319
|
+
await request(`/tabs/${encodeURIComponent(options.tabId)}/click`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
body,
|
|
322
|
+
});
|
|
323
|
+
return { tabId: options.tabId };
|
|
324
|
+
}
|
|
325
|
+
/** Type text into a focused element (or one targeted via ref/selector). */
|
|
326
|
+
export async function type(options) {
|
|
327
|
+
const body = { text: options.text };
|
|
328
|
+
if (options.ref)
|
|
329
|
+
body.ref = options.ref;
|
|
330
|
+
if (options.selector)
|
|
331
|
+
body.selector = options.selector;
|
|
332
|
+
if (options.pressEnter)
|
|
333
|
+
body.pressEnter = options.pressEnter;
|
|
334
|
+
if (options.delayMs !== undefined)
|
|
335
|
+
body.delayMs = options.delayMs;
|
|
336
|
+
await request(`/tabs/${encodeURIComponent(options.tabId)}/type`, {
|
|
337
|
+
method: "POST",
|
|
338
|
+
body,
|
|
339
|
+
});
|
|
340
|
+
return { tabId: options.tabId };
|
|
341
|
+
}
|
|
342
|
+
/** Scroll the page or a specific element by ref. */
|
|
343
|
+
export async function scroll(options) {
|
|
344
|
+
const body = { direction: options.direction };
|
|
345
|
+
if (options.ref)
|
|
346
|
+
body.ref = options.ref;
|
|
347
|
+
if (options.pixels)
|
|
348
|
+
body.pixels = options.pixels;
|
|
349
|
+
await request(`/tabs/${encodeURIComponent(options.tabId)}/scroll`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
body,
|
|
352
|
+
});
|
|
353
|
+
return { tabId: options.tabId };
|
|
354
|
+
}
|
|
355
|
+
/** Close a single tab. The underlying browser context is kept warm. */
|
|
356
|
+
export async function closeTab(options) {
|
|
357
|
+
await request(`/tabs/${encodeURIComponent(options.tabId)}`, { method: "DELETE" });
|
|
358
|
+
return { tabId: options.tabId };
|
|
359
|
+
}
|
|
360
|
+
/** List all open tabs for a user. */
|
|
361
|
+
export async function listTabs(options = {}) {
|
|
362
|
+
const userId = options.userId ?? DEFAULT_USER_ID;
|
|
363
|
+
const metrics = await request(`/sessions/${encodeURIComponent(userId)}/tabs`).catch(async () => {
|
|
364
|
+
const all = await request("/metrics").catch(() => ({}));
|
|
365
|
+
return { tabs: Array.isArray(all.tabs) ? all.tabs : [] };
|
|
366
|
+
});
|
|
367
|
+
return metrics.tabs ?? [];
|
|
368
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phi-code-admin/browser",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Phi-code browser automation API: lazy-start the bundled Camoufox + camofox-browser server and expose 10 high-level tools (navigate, extract, screenshot, click, type, scroll, snapshot, search, close_tab, list_tabs) as plain ES module functions. Zero external dependencies at runtime.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "uglyswap (phi-code maintainer)",
|
|
21
|
+
"homepage": "https://github.com/uglyswap/phi-code/tree/main/packages/browser",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/uglyswap/phi-code.git",
|
|
25
|
+
"directory": "packages/browser"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/uglyswap/phi-code/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"clean": "rimraf dist"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@phi-code-admin/camofox-browser": "1.0.0",
|
|
42
|
+
"@phi-code-admin/camoufox-js": "1.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^24.0.0",
|
|
46
|
+
"rimraf": "^6.0.1",
|
|
47
|
+
"typescript": "^5.7.0"
|
|
48
|
+
}
|
|
49
|
+
}
|