effing-use 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/package.json +52 -0
- package/skills/effing-use/SKILL.md +58 -0
- package/skills/effing-use/references/setup.md +89 -0
- package/skills/effing-use/references/troubleshooting.md +64 -0
- package/src/browser/engine.ts +712 -0
- package/src/browser/output.ts +31 -0
- package/src/browser/refs.ts +75 -0
- package/src/browser/session.ts +100 -0
- package/src/config.ts +25 -0
- package/src/http.ts +28 -0
- package/src/index.ts +6 -0
- package/src/server.ts +19 -0
- package/src/tools/act.ts +131 -0
- package/src/tools/extract.ts +86 -0
- package/src/tools/observe.ts +68 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import type { Page, BrowserContext } from "playwright";
|
|
2
|
+
import type { Config } from "../config.js";
|
|
3
|
+
import { EngineError, resolveLocator } from "./refs.js";
|
|
4
|
+
import { cap, saveText, stamp } from "./output.js";
|
|
5
|
+
import { getConsoleLogs, getNetworkLogs, closeSession } from "./session.js";
|
|
6
|
+
|
|
7
|
+
export type ActAction =
|
|
8
|
+
| "open"
|
|
9
|
+
| "goto"
|
|
10
|
+
| "click"
|
|
11
|
+
| "dblclick"
|
|
12
|
+
| "fill"
|
|
13
|
+
| "type"
|
|
14
|
+
| "press"
|
|
15
|
+
| "select"
|
|
16
|
+
| "check"
|
|
17
|
+
| "uncheck"
|
|
18
|
+
| "hover"
|
|
19
|
+
| "drag"
|
|
20
|
+
| "upload"
|
|
21
|
+
| "scroll"
|
|
22
|
+
| "back"
|
|
23
|
+
| "forward"
|
|
24
|
+
| "reload"
|
|
25
|
+
| "wait"
|
|
26
|
+
| "dialog_accept"
|
|
27
|
+
| "dialog_dismiss"
|
|
28
|
+
| "close"
|
|
29
|
+
| "goal"
|
|
30
|
+
| "batch"
|
|
31
|
+
| "tab_new"
|
|
32
|
+
| "tab_select"
|
|
33
|
+
| "tab_close"
|
|
34
|
+
| "resize";
|
|
35
|
+
|
|
36
|
+
export interface ActOpts {
|
|
37
|
+
context?: BrowserContext;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ObserveOpts {
|
|
42
|
+
context?: BrowserContext;
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function timeoutOf(config: Config): number {
|
|
47
|
+
return config.timeoutMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function liteState(page: Page): Promise<{ url: string; title: string }> {
|
|
51
|
+
return { url: page.url(), title: await page.title().catch(() => "") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toEngineError(e: unknown, fallbackHint: string): EngineError {
|
|
55
|
+
if (e instanceof EngineError) return e;
|
|
56
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
57
|
+
const isTimeout =
|
|
58
|
+
/timeout|exceeded|waiting/i.test(msg) ||
|
|
59
|
+
(e as { name?: string })?.name === "TimeoutError";
|
|
60
|
+
return new EngineError(
|
|
61
|
+
isTimeout ? "E_TIMEOUT" : "E_BAD_INPUT",
|
|
62
|
+
msg,
|
|
63
|
+
fallbackHint,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------- act
|
|
68
|
+
|
|
69
|
+
export async function doAct(
|
|
70
|
+
page: Page,
|
|
71
|
+
config: Config,
|
|
72
|
+
action: ActAction,
|
|
73
|
+
target?: string,
|
|
74
|
+
value?: string,
|
|
75
|
+
opts?: ActOpts,
|
|
76
|
+
): Promise<Record<string, unknown>> {
|
|
77
|
+
const timeout = timeoutOf(config);
|
|
78
|
+
try {
|
|
79
|
+
switch (action) {
|
|
80
|
+
case "open":
|
|
81
|
+
case "goto": {
|
|
82
|
+
const url = value ?? target;
|
|
83
|
+
if (!url)
|
|
84
|
+
throw new EngineError(
|
|
85
|
+
"E_BAD_INPUT",
|
|
86
|
+
"Missing URL.",
|
|
87
|
+
"Pass the URL in value (or target).",
|
|
88
|
+
);
|
|
89
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
90
|
+
return { ...(await liteState(page)) };
|
|
91
|
+
}
|
|
92
|
+
case "click": {
|
|
93
|
+
if (!target)
|
|
94
|
+
throw new EngineError(
|
|
95
|
+
"E_BAD_INPUT",
|
|
96
|
+
"Missing target.",
|
|
97
|
+
"Pass an e-ref, role= selector, or CSS.",
|
|
98
|
+
);
|
|
99
|
+
const loc = await resolveLocator(page, target);
|
|
100
|
+
const button =
|
|
101
|
+
(value as "left" | "middle" | "right" | undefined) ?? "left";
|
|
102
|
+
await loc.click({ button, timeout });
|
|
103
|
+
return { ...(await liteState(page)) };
|
|
104
|
+
}
|
|
105
|
+
case "dblclick": {
|
|
106
|
+
if (!target)
|
|
107
|
+
throw new EngineError(
|
|
108
|
+
"E_BAD_INPUT",
|
|
109
|
+
"Missing target.",
|
|
110
|
+
"Pass an e-ref, role= selector, or CSS.",
|
|
111
|
+
);
|
|
112
|
+
const loc = await resolveLocator(page, target);
|
|
113
|
+
await loc.dblclick({ timeout });
|
|
114
|
+
return { ...(await liteState(page)) };
|
|
115
|
+
}
|
|
116
|
+
case "fill": {
|
|
117
|
+
if (!target)
|
|
118
|
+
throw new EngineError(
|
|
119
|
+
"E_BAD_INPUT",
|
|
120
|
+
"Missing target.",
|
|
121
|
+
"Pass an e-ref, role= selector, or CSS.",
|
|
122
|
+
);
|
|
123
|
+
const loc = await resolveLocator(page, target);
|
|
124
|
+
await loc.fill(value ?? "", { timeout });
|
|
125
|
+
return { ...(await liteState(page)) };
|
|
126
|
+
}
|
|
127
|
+
case "type": {
|
|
128
|
+
await page.keyboard.type(value ?? "", { delay: 0 });
|
|
129
|
+
return { ...(await liteState(page)) };
|
|
130
|
+
}
|
|
131
|
+
case "press": {
|
|
132
|
+
if (!value)
|
|
133
|
+
throw new EngineError(
|
|
134
|
+
"E_BAD_INPUT",
|
|
135
|
+
"Missing key.",
|
|
136
|
+
"Pass a key like Enter, Tab, Escape in value.",
|
|
137
|
+
);
|
|
138
|
+
await page.keyboard.press(value);
|
|
139
|
+
return { ...(await liteState(page)) };
|
|
140
|
+
}
|
|
141
|
+
case "select": {
|
|
142
|
+
if (!target)
|
|
143
|
+
throw new EngineError(
|
|
144
|
+
"E_BAD_INPUT",
|
|
145
|
+
"Missing target.",
|
|
146
|
+
"Pass a select element ref in target.",
|
|
147
|
+
);
|
|
148
|
+
const loc = await resolveLocator(page, target);
|
|
149
|
+
await loc.selectOption(value ?? "", { timeout });
|
|
150
|
+
return { ...(await liteState(page)) };
|
|
151
|
+
}
|
|
152
|
+
case "check": {
|
|
153
|
+
if (!target)
|
|
154
|
+
throw new EngineError(
|
|
155
|
+
"E_BAD_INPUT",
|
|
156
|
+
"Missing target.",
|
|
157
|
+
"Pass a checkbox ref in target.",
|
|
158
|
+
);
|
|
159
|
+
const loc = await resolveLocator(page, target);
|
|
160
|
+
await loc.check({ timeout });
|
|
161
|
+
return { ...(await liteState(page)) };
|
|
162
|
+
}
|
|
163
|
+
case "uncheck": {
|
|
164
|
+
if (!target)
|
|
165
|
+
throw new EngineError(
|
|
166
|
+
"E_BAD_INPUT",
|
|
167
|
+
"Missing target.",
|
|
168
|
+
"Pass a checkbox ref in target.",
|
|
169
|
+
);
|
|
170
|
+
const loc = await resolveLocator(page, target);
|
|
171
|
+
await loc.uncheck({ timeout });
|
|
172
|
+
return { ...(await liteState(page)) };
|
|
173
|
+
}
|
|
174
|
+
case "hover": {
|
|
175
|
+
if (!target)
|
|
176
|
+
throw new EngineError(
|
|
177
|
+
"E_BAD_INPUT",
|
|
178
|
+
"Missing target.",
|
|
179
|
+
"Pass an e-ref, role= selector, or CSS.",
|
|
180
|
+
);
|
|
181
|
+
const loc = await resolveLocator(page, target);
|
|
182
|
+
await loc.hover({ timeout });
|
|
183
|
+
return { ...(await liteState(page)) };
|
|
184
|
+
}
|
|
185
|
+
case "drag": {
|
|
186
|
+
if (!target || !value)
|
|
187
|
+
throw new EngineError(
|
|
188
|
+
"E_BAD_INPUT",
|
|
189
|
+
"Missing drag endpoints.",
|
|
190
|
+
"Pass start ref in target and end ref in value.",
|
|
191
|
+
);
|
|
192
|
+
const start = await resolveLocator(page, target);
|
|
193
|
+
const end = await resolveLocator(page, value);
|
|
194
|
+
await start.dragTo(end, { timeout });
|
|
195
|
+
return { ...(await liteState(page)) };
|
|
196
|
+
}
|
|
197
|
+
case "upload": {
|
|
198
|
+
const files = (value ?? "")
|
|
199
|
+
.split(",")
|
|
200
|
+
.map((s) => s.trim())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
if (files.length === 0)
|
|
203
|
+
throw new EngineError(
|
|
204
|
+
"E_BAD_INPUT",
|
|
205
|
+
"Missing files.",
|
|
206
|
+
"Pass comma-separated file paths in value.",
|
|
207
|
+
);
|
|
208
|
+
await page.setInputFiles("input[type=file]", files, { timeout });
|
|
209
|
+
return { ...(await liteState(page)), files };
|
|
210
|
+
}
|
|
211
|
+
case "scroll": {
|
|
212
|
+
const dir = (target ?? "").toLowerCase();
|
|
213
|
+
if (dir === "up") await page.mouse.wheel(0, -500);
|
|
214
|
+
else if (dir === "down") await page.mouse.wheel(0, 500);
|
|
215
|
+
else if (dir === "top")
|
|
216
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
217
|
+
else if (dir === "bottom")
|
|
218
|
+
await page.evaluate(() =>
|
|
219
|
+
window.scrollTo(0, document.body.scrollHeight),
|
|
220
|
+
);
|
|
221
|
+
else if (target) {
|
|
222
|
+
const loc = await resolveLocator(page, target);
|
|
223
|
+
await loc.scrollIntoViewIfNeeded({ timeout });
|
|
224
|
+
} else {
|
|
225
|
+
await page.mouse.wheel(0, 500);
|
|
226
|
+
}
|
|
227
|
+
return { ...(await liteState(page)) };
|
|
228
|
+
}
|
|
229
|
+
case "back":
|
|
230
|
+
await page
|
|
231
|
+
.goBack({ waitUntil: "domcontentloaded", timeout })
|
|
232
|
+
.catch(() => null);
|
|
233
|
+
return { ...(await liteState(page)) };
|
|
234
|
+
case "forward":
|
|
235
|
+
await page
|
|
236
|
+
.goForward({ waitUntil: "domcontentloaded", timeout })
|
|
237
|
+
.catch(() => null);
|
|
238
|
+
return { ...(await liteState(page)) };
|
|
239
|
+
case "reload":
|
|
240
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout });
|
|
241
|
+
return { ...(await liteState(page)) };
|
|
242
|
+
case "wait": {
|
|
243
|
+
const t = target ?? "";
|
|
244
|
+
const msMatch = /^ms:(\d+)$/.exec(t);
|
|
245
|
+
if (msMatch) {
|
|
246
|
+
await page.waitForTimeout(Number(msMatch[1]));
|
|
247
|
+
return { ...(await liteState(page)), waited: t };
|
|
248
|
+
}
|
|
249
|
+
const textMatch = /^text:(.+)$/.exec(t);
|
|
250
|
+
if (textMatch) {
|
|
251
|
+
await page.getByText(textMatch[1]).first().waitFor({ timeout });
|
|
252
|
+
return { ...(await liteState(page)), waited: t };
|
|
253
|
+
}
|
|
254
|
+
if (t) {
|
|
255
|
+
const loc = await resolveLocator(page, t);
|
|
256
|
+
await loc.waitFor({ timeout });
|
|
257
|
+
return { ...(await liteState(page)), waited: t };
|
|
258
|
+
}
|
|
259
|
+
await page.waitForTimeout(500);
|
|
260
|
+
return { ...(await liteState(page)) };
|
|
261
|
+
}
|
|
262
|
+
case "dialog_accept":
|
|
263
|
+
page.once("dialog", (d) => void d.accept(value));
|
|
264
|
+
return { armed: true };
|
|
265
|
+
case "dialog_dismiss":
|
|
266
|
+
page.once("dialog", (d) => void d.dismiss());
|
|
267
|
+
return { armed: true };
|
|
268
|
+
case "resize": {
|
|
269
|
+
const m = /^(\d+)x(\d+)$/.exec(value ?? "");
|
|
270
|
+
if (!m)
|
|
271
|
+
throw new EngineError(
|
|
272
|
+
"E_BAD_INPUT",
|
|
273
|
+
`Bad size "${value}".`,
|
|
274
|
+
"Use WIDTHxHEIGHT, e.g. 1280x800.",
|
|
275
|
+
);
|
|
276
|
+
await page.setViewportSize({
|
|
277
|
+
width: Number(m[1]),
|
|
278
|
+
height: Number(m[2]),
|
|
279
|
+
});
|
|
280
|
+
return { ...(await liteState(page)), viewport: value };
|
|
281
|
+
}
|
|
282
|
+
case "tab_new": {
|
|
283
|
+
if (!opts?.context)
|
|
284
|
+
throw new EngineError(
|
|
285
|
+
"E_NO_PAGE",
|
|
286
|
+
"No context.",
|
|
287
|
+
"Retry the call; session context is attached server-side.",
|
|
288
|
+
);
|
|
289
|
+
const p = await opts.context.newPage();
|
|
290
|
+
if (value ?? target)
|
|
291
|
+
await p.goto((value ?? target)!, {
|
|
292
|
+
waitUntil: "domcontentloaded",
|
|
293
|
+
timeout,
|
|
294
|
+
});
|
|
295
|
+
return { url: p.url(), tabs: opts.context.pages().map((x) => x.url()) };
|
|
296
|
+
}
|
|
297
|
+
case "tab_select": {
|
|
298
|
+
if (!opts?.context)
|
|
299
|
+
throw new EngineError(
|
|
300
|
+
"E_NO_PAGE",
|
|
301
|
+
"No context.",
|
|
302
|
+
"Retry the call; session context is attached server-side.",
|
|
303
|
+
);
|
|
304
|
+
const i = Number(target ?? value ?? 0);
|
|
305
|
+
const pages = opts.context.pages();
|
|
306
|
+
if (!pages[i])
|
|
307
|
+
throw new EngineError(
|
|
308
|
+
"E_BAD_INPUT",
|
|
309
|
+
`No tab at index ${i}.`,
|
|
310
|
+
"Call browser_observe kind=tabs for the tab list.",
|
|
311
|
+
);
|
|
312
|
+
await pages[i].bringToFront();
|
|
313
|
+
return { url: pages[i].url() };
|
|
314
|
+
}
|
|
315
|
+
case "tab_close": {
|
|
316
|
+
if (!opts?.context)
|
|
317
|
+
throw new EngineError(
|
|
318
|
+
"E_NO_PAGE",
|
|
319
|
+
"No context.",
|
|
320
|
+
"Retry the call; session context is attached server-side.",
|
|
321
|
+
);
|
|
322
|
+
const pages = opts.context.pages();
|
|
323
|
+
const raw = target ?? value;
|
|
324
|
+
const i = raw == null || raw === "" ? pages.length - 1 : Number(raw);
|
|
325
|
+
if (!pages[i])
|
|
326
|
+
throw new EngineError(
|
|
327
|
+
"E_BAD_INPUT",
|
|
328
|
+
`No tab at index ${i}.`,
|
|
329
|
+
"Call browser_observe kind=tabs for the tab list.",
|
|
330
|
+
);
|
|
331
|
+
await pages[i].close();
|
|
332
|
+
return { closed: i, tabs: opts.context.pages().map((x) => x.url()) };
|
|
333
|
+
}
|
|
334
|
+
case "close":
|
|
335
|
+
await closeSession(opts?.sessionId ?? "default");
|
|
336
|
+
return { closed: true };
|
|
337
|
+
case "goal":
|
|
338
|
+
return doGoal(page, config, value ?? target ?? "", opts);
|
|
339
|
+
case "batch":
|
|
340
|
+
throw new EngineError(
|
|
341
|
+
"E_BAD_INPUT",
|
|
342
|
+
"Use steps[] for batch.",
|
|
343
|
+
"Pass steps[] array; the tool handler runs doBatch.",
|
|
344
|
+
);
|
|
345
|
+
default:
|
|
346
|
+
throw new EngineError(
|
|
347
|
+
"E_BAD_INPUT",
|
|
348
|
+
`Unknown action "${action}".`,
|
|
349
|
+
"See browser_act schema for valid actions.",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
} catch (e) {
|
|
353
|
+
throw toEngineError(
|
|
354
|
+
e,
|
|
355
|
+
"Retry with a fresh snapshot ref, or re-observe state.",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------- goal
|
|
361
|
+
|
|
362
|
+
export async function doGoal(
|
|
363
|
+
page: Page,
|
|
364
|
+
config: Config,
|
|
365
|
+
goal: string,
|
|
366
|
+
opts?: ActOpts,
|
|
367
|
+
): Promise<Record<string, unknown>> {
|
|
368
|
+
const g = goal.trim();
|
|
369
|
+
// add-todo planner
|
|
370
|
+
let m = /add\s+todo\s+(.+)/i.exec(g);
|
|
371
|
+
if (m) {
|
|
372
|
+
const text = m[1].replace(/^["']|["']$/g, "");
|
|
373
|
+
const box = page
|
|
374
|
+
.getByPlaceholder(/new\s*todo|what needs|add.*todo/i)
|
|
375
|
+
.or(page.locator("input.new-todo, #new-todo, input[placeholder]"));
|
|
376
|
+
if ((await box.count()) > 0) {
|
|
377
|
+
await box.first().fill(text, { timeout: timeoutOf(config) });
|
|
378
|
+
await page.keyboard.press("Enter");
|
|
379
|
+
return { planned: "add-todo", ...(await liteState(page)) };
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
planned: "add-todo",
|
|
383
|
+
...(await liteState(page)),
|
|
384
|
+
note: "No todo input found; fill the new-todo field manually.",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
// search planner: "search SITE for QUERY"
|
|
388
|
+
m = /search\s+(.+?)\s+for\s+(.+)/i.exec(g);
|
|
389
|
+
if (m) {
|
|
390
|
+
const query = m[2].replace(/^["']|["']$/g, "");
|
|
391
|
+
const box = page
|
|
392
|
+
.getByRole("searchbox")
|
|
393
|
+
.or(page.getByRole("textbox"))
|
|
394
|
+
.or(
|
|
395
|
+
page.locator("input[type=search], input[name=q], input[name=search]"),
|
|
396
|
+
);
|
|
397
|
+
if ((await box.count()) > 0) {
|
|
398
|
+
await box.first().fill(query, { timeout: timeoutOf(config) });
|
|
399
|
+
await page.keyboard.press("Enter");
|
|
400
|
+
return { planned: "search", ...(await liteState(page)) };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
throw new EngineError(
|
|
404
|
+
"E_GOAL_UNCLEAR",
|
|
405
|
+
`Cannot plan goal "${g}".`,
|
|
406
|
+
"Use explicit act calls instead.",
|
|
407
|
+
{
|
|
408
|
+
suggestedSteps: [
|
|
409
|
+
{ action: "open", value: "https://example.com" },
|
|
410
|
+
{ action: "click", target: "e0" },
|
|
411
|
+
{ action: "fill", target: "e1", value: "text" },
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------- batch
|
|
418
|
+
|
|
419
|
+
export async function doBatch(
|
|
420
|
+
page: Page,
|
|
421
|
+
config: Config,
|
|
422
|
+
steps: Array<{ action: ActAction; target?: string; value?: string }>,
|
|
423
|
+
opts?: ActOpts,
|
|
424
|
+
): Promise<Record<string, unknown>> {
|
|
425
|
+
if (steps.length > 20)
|
|
426
|
+
throw new EngineError(
|
|
427
|
+
"E_BAD_INPUT",
|
|
428
|
+
"Too many steps (max 20).",
|
|
429
|
+
"Split into smaller batches.",
|
|
430
|
+
);
|
|
431
|
+
const results: Array<Record<string, unknown>> = [];
|
|
432
|
+
let failIndex: number | undefined;
|
|
433
|
+
for (let i = 0; i < steps.length; i++) {
|
|
434
|
+
const s = steps[i];
|
|
435
|
+
try {
|
|
436
|
+
const r = await doAct(page, config, s.action, s.target, s.value, opts);
|
|
437
|
+
results.push({ ok: true, ...r });
|
|
438
|
+
} catch (e) {
|
|
439
|
+
const err = toEngineError(
|
|
440
|
+
e,
|
|
441
|
+
"Fix this step then re-run remaining steps.",
|
|
442
|
+
);
|
|
443
|
+
results.push({
|
|
444
|
+
ok: false,
|
|
445
|
+
code: err.code,
|
|
446
|
+
message: err.message,
|
|
447
|
+
hint: err.hint,
|
|
448
|
+
});
|
|
449
|
+
failIndex = i;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
454
|
+
return failIndex === undefined
|
|
455
|
+
? { results, okCount }
|
|
456
|
+
: { results, okCount, failIndex };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------- observe
|
|
460
|
+
|
|
461
|
+
export async function doObserve(
|
|
462
|
+
page: Page,
|
|
463
|
+
config: Config,
|
|
464
|
+
kind: string,
|
|
465
|
+
target?: string,
|
|
466
|
+
limit?: number,
|
|
467
|
+
opts?: ObserveOpts,
|
|
468
|
+
): Promise<Record<string, unknown>> {
|
|
469
|
+
const sessionId = opts?.sessionId ?? "default";
|
|
470
|
+
switch (kind) {
|
|
471
|
+
case "url":
|
|
472
|
+
return { url: page.url() };
|
|
473
|
+
case "title":
|
|
474
|
+
return { title: await page.title().catch(() => ""), url: page.url() };
|
|
475
|
+
case "snapshot": {
|
|
476
|
+
const yaml = await buildSnapshot(page);
|
|
477
|
+
const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
|
|
478
|
+
const { text, truncated } = cap(yaml, config.outputMaxChars);
|
|
479
|
+
return {
|
|
480
|
+
path,
|
|
481
|
+
summary: text,
|
|
482
|
+
truncated,
|
|
483
|
+
url: page.url(),
|
|
484
|
+
title: await page.title().catch(() => ""),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
case "screenshot": {
|
|
488
|
+
const full = (target ?? "") === "full" || (target ?? "") === "";
|
|
489
|
+
const filename = stamp("shot", "png");
|
|
490
|
+
const { mkdir } = await import("node:fs/promises");
|
|
491
|
+
const { join } = await import("node:path");
|
|
492
|
+
await mkdir(config.outputDir, { recursive: true });
|
|
493
|
+
const path = join(config.outputDir, filename);
|
|
494
|
+
await page.screenshot({ path, fullPage: full });
|
|
495
|
+
return { path, url: page.url() };
|
|
496
|
+
}
|
|
497
|
+
case "console": {
|
|
498
|
+
const logs = getConsoleLogs(sessionId);
|
|
499
|
+
const n = limit ?? 20;
|
|
500
|
+
return { logs: logs.slice(-n) };
|
|
501
|
+
}
|
|
502
|
+
case "network": {
|
|
503
|
+
const logs = getNetworkLogs(sessionId);
|
|
504
|
+
const n = limit ?? 20;
|
|
505
|
+
return { requests: logs.slice(-n) };
|
|
506
|
+
}
|
|
507
|
+
case "tabs": {
|
|
508
|
+
const ctx = opts?.context ?? page.context();
|
|
509
|
+
return { tabs: ctx.pages().map((p) => p.url()) };
|
|
510
|
+
}
|
|
511
|
+
case "focused": {
|
|
512
|
+
const html = await page.evaluate(
|
|
513
|
+
() => document.activeElement?.outerHTML?.slice(0, 2000) ?? "(none)",
|
|
514
|
+
);
|
|
515
|
+
const { text, truncated } = cap(html, config.outputMaxChars);
|
|
516
|
+
return { focused: text, truncated };
|
|
517
|
+
}
|
|
518
|
+
default:
|
|
519
|
+
throw new EngineError(
|
|
520
|
+
"E_BAD_INPUT",
|
|
521
|
+
`Unknown observe kind "${kind}".`,
|
|
522
|
+
"Valid: snapshot, screenshot, url, title, console, network, tabs, focused.",
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function buildSnapshot(page: Page): Promise<string> {
|
|
528
|
+
const header = [
|
|
529
|
+
"# Snapshot — refs are nth-match: eN = Nth match of (button, a, input, select, textarea, [role=button], [tabindex]) in DOM order.",
|
|
530
|
+
`# url: ${page.url()}`,
|
|
531
|
+
].join("\n");
|
|
532
|
+
const items = await page.evaluate(() => {
|
|
533
|
+
const els = [
|
|
534
|
+
...document.querySelectorAll(
|
|
535
|
+
"button, a, input, select, textarea, [role=button], [tabindex]",
|
|
536
|
+
),
|
|
537
|
+
];
|
|
538
|
+
return els.slice(0, 200).map((el) => {
|
|
539
|
+
const tag = el.tagName.toLowerCase();
|
|
540
|
+
const text = (el.textContent ?? "")
|
|
541
|
+
.trim()
|
|
542
|
+
.replace(/\s+/g, " ")
|
|
543
|
+
.slice(0, 80);
|
|
544
|
+
const aria = el.getAttribute("aria-label") ?? "";
|
|
545
|
+
const name = el.getAttribute("name") ?? "";
|
|
546
|
+
const id = el.getAttribute("id") ?? "";
|
|
547
|
+
const extra = [
|
|
548
|
+
aria && `aria="${aria}"`,
|
|
549
|
+
name && `name="${name}"`,
|
|
550
|
+
id && `id="${id}"`,
|
|
551
|
+
]
|
|
552
|
+
.filter(Boolean)
|
|
553
|
+
.join(" ");
|
|
554
|
+
return `${tag} "${text}"${extra ? " " + extra : ""}`;
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
const lines = items.map((line, i) => `[e${i}] ${line}`);
|
|
558
|
+
return `${header}\n${lines.join("\n") || "(no interactive elements)"}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------- extract
|
|
562
|
+
|
|
563
|
+
export async function doExtract(
|
|
564
|
+
page: Page,
|
|
565
|
+
config: Config,
|
|
566
|
+
kind: string,
|
|
567
|
+
selector?: string,
|
|
568
|
+
limit?: number,
|
|
569
|
+
opts?: ObserveOpts,
|
|
570
|
+
): Promise<Record<string, unknown>> {
|
|
571
|
+
switch (kind) {
|
|
572
|
+
case "text": {
|
|
573
|
+
const text = selector
|
|
574
|
+
? ((await page
|
|
575
|
+
.locator(selector)
|
|
576
|
+
.first()
|
|
577
|
+
.innerText()
|
|
578
|
+
.catch(() => "")) as string)
|
|
579
|
+
: ((await page.innerText("body").catch(() => "")) as string);
|
|
580
|
+
const path = await saveText(config, stamp("text", "txt"), text);
|
|
581
|
+
const { text: preview, truncated } = cap(text, config.outputMaxChars);
|
|
582
|
+
return { path, preview, truncated };
|
|
583
|
+
}
|
|
584
|
+
case "html": {
|
|
585
|
+
const html = selector
|
|
586
|
+
? ((await page
|
|
587
|
+
.locator(selector)
|
|
588
|
+
.first()
|
|
589
|
+
.evaluate((el) => el.outerHTML)
|
|
590
|
+
.catch(() => "")) as string)
|
|
591
|
+
: ((await page.content().catch(() => "")) as string);
|
|
592
|
+
const path = await saveText(config, stamp("page", "html"), html);
|
|
593
|
+
const { text: preview, truncated } = cap(html, config.outputMaxChars);
|
|
594
|
+
return { path, preview, truncated };
|
|
595
|
+
}
|
|
596
|
+
case "table": {
|
|
597
|
+
const rows = (await page
|
|
598
|
+
.locator(selector ?? "table")
|
|
599
|
+
.first()
|
|
600
|
+
.evaluate((table: Element) =>
|
|
601
|
+
[...table.querySelectorAll("tr")].map((tr) =>
|
|
602
|
+
[...tr.querySelectorAll("th,td")].map((c) =>
|
|
603
|
+
(c.textContent ?? "").trim(),
|
|
604
|
+
),
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
.catch(() => [] as string[][])) as string[][];
|
|
608
|
+
const sliced = rows.slice(0, Math.min(limit ?? 100, 100));
|
|
609
|
+
const text = JSON.stringify(sliced);
|
|
610
|
+
const path = await saveText(
|
|
611
|
+
config,
|
|
612
|
+
stamp("table", "json"),
|
|
613
|
+
JSON.stringify(sliced, null, 2),
|
|
614
|
+
);
|
|
615
|
+
const { text: preview, truncated } = cap(text, config.outputMaxChars);
|
|
616
|
+
return { path, rows: sliced, rowCount: rows.length, preview, truncated };
|
|
617
|
+
}
|
|
618
|
+
case "query": {
|
|
619
|
+
// Routed by the extract tool to doQuery(); unreachable here.
|
|
620
|
+
throw new EngineError(
|
|
621
|
+
"E_BAD_INPUT",
|
|
622
|
+
"Use selector + mode for query.",
|
|
623
|
+
"Pass a CSS selector in selector and mode text|href|json.",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
case "pdf": {
|
|
627
|
+
const filename = stamp("page", "pdf");
|
|
628
|
+
const { mkdir } = await import("node:fs/promises");
|
|
629
|
+
const { join } = await import("node:path");
|
|
630
|
+
await mkdir(config.outputDir, { recursive: true });
|
|
631
|
+
const path = join(config.outputDir, filename);
|
|
632
|
+
await page.pdf({ path }).catch(() => {
|
|
633
|
+
throw new EngineError(
|
|
634
|
+
"E_BAD_INPUT",
|
|
635
|
+
"PDF failed (Chromium/headless required).",
|
|
636
|
+
"Run headless Chromium; headed browsers may not support page.pdf.",
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
return { path };
|
|
640
|
+
}
|
|
641
|
+
case "trace_start": {
|
|
642
|
+
const ctx = opts?.context ?? page.context();
|
|
643
|
+
const filename = stamp("trace", "zip");
|
|
644
|
+
const { join } = await import("node:path");
|
|
645
|
+
const path = join(config.outputDir, filename);
|
|
646
|
+
const { mkdir } = await import("node:fs/promises");
|
|
647
|
+
await mkdir(config.outputDir, { recursive: true });
|
|
648
|
+
await ctx.tracing.start({ screenshots: true, snapshots: true });
|
|
649
|
+
(ctx as unknown as { __tracePath?: string }).__tracePath = path;
|
|
650
|
+
return { started: true, path };
|
|
651
|
+
}
|
|
652
|
+
case "trace_stop": {
|
|
653
|
+
const ctx = opts?.context ?? page.context();
|
|
654
|
+
const saved =
|
|
655
|
+
(ctx as unknown as { __tracePath?: string }).__tracePath ??
|
|
656
|
+
`${config.outputDir}/trace.zip`;
|
|
657
|
+
await ctx.tracing.stop({ path: saved });
|
|
658
|
+
return { path: saved };
|
|
659
|
+
}
|
|
660
|
+
default:
|
|
661
|
+
throw new EngineError(
|
|
662
|
+
"E_BAD_INPUT",
|
|
663
|
+
`Unknown extract kind "${kind}".`,
|
|
664
|
+
"Valid: text, html, table, query, pdf, trace_start, trace_stop.",
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** CSS query with text|href|json mode — kept separate so doExtract keeps the plan's 5-arg signature. */
|
|
670
|
+
export async function doQuery(
|
|
671
|
+
page: Page,
|
|
672
|
+
config: Config,
|
|
673
|
+
selector: string,
|
|
674
|
+
mode = "text",
|
|
675
|
+
limit = 50,
|
|
676
|
+
): Promise<Record<string, unknown>> {
|
|
677
|
+
const items = await page
|
|
678
|
+
.locator(selector)
|
|
679
|
+
.evaluateAll(
|
|
680
|
+
(els: Element[], m: string) =>
|
|
681
|
+
els.map((el) => {
|
|
682
|
+
if (m === "href")
|
|
683
|
+
return (
|
|
684
|
+
(el as HTMLAnchorElement).href ?? el.getAttribute("href") ?? ""
|
|
685
|
+
);
|
|
686
|
+
if (m === "json")
|
|
687
|
+
return {
|
|
688
|
+
tag: el.tagName.toLowerCase(),
|
|
689
|
+
text: (el.textContent ?? "").trim().slice(0, 200),
|
|
690
|
+
html: el.outerHTML.slice(0, 500),
|
|
691
|
+
};
|
|
692
|
+
return (el.textContent ?? "").trim().slice(0, 500);
|
|
693
|
+
}),
|
|
694
|
+
mode,
|
|
695
|
+
)
|
|
696
|
+
.catch(() => [] as unknown[]);
|
|
697
|
+
const sliced = (items as unknown[]).slice(0, limit);
|
|
698
|
+
const text = JSON.stringify(sliced);
|
|
699
|
+
const path = await saveText(
|
|
700
|
+
config,
|
|
701
|
+
stamp("query", "json"),
|
|
702
|
+
JSON.stringify(sliced, null, 2),
|
|
703
|
+
);
|
|
704
|
+
const { text: preview, truncated } = cap(text, config.outputMaxChars);
|
|
705
|
+
return {
|
|
706
|
+
path,
|
|
707
|
+
items: sliced,
|
|
708
|
+
count: (items as unknown[]).length,
|
|
709
|
+
preview,
|
|
710
|
+
truncated,
|
|
711
|
+
};
|
|
712
|
+
}
|