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.
@@ -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
+ }