pi-browser-harness 0.14.1 → 0.16.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/CHANGELOG.md +25 -0
- package/README.md +15 -8
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +1 -2
- package/src/cdp/commands.ts +28 -0
- package/src/client.ts +10 -3
- package/src/daemon/bridge.ts +4 -3
- package/src/daemon/protocol.ts +11 -0
- package/src/daemon/transport.ts +1 -0
- package/src/domains/cdp-call.ts +2 -1
- package/src/domains/click.ts +5 -2
- package/src/domains/form.ts +40 -31
- package/src/domains/js.ts +20 -2
- package/src/domains/keyboard.ts +171 -35
- package/src/domains/page/click.page.ts +22 -0
- package/src/domains/page/fill.page.ts +70 -1
- package/src/domains/page/serp.page.ts +29 -16
- package/src/domains/page.ts +1 -1
- package/src/domains/screenshot.ts +21 -1
- package/src/domains/search/engines/bing.ts +47 -0
- package/src/domains/search/engines/brave.ts +23 -0
- package/src/domains/search/engines/duckduckgo.ts +42 -0
- package/src/domains/search/engines/google.ts +95 -0
- package/src/domains/search/engines/index.ts +13 -0
- package/src/domains/search/engines/registry.ts +29 -0
- package/src/domains/search/redirect-resolver.ts +1 -1
- package/src/domains/search/serp.ts +157 -0
- package/src/domains/search/web-search.ts +235 -105
- package/src/domains/snapshot.ts +1 -1
- package/src/domains/target.ts +83 -7
- package/src/recipes/recordable.ts +4 -3
- package/src/registry.ts +1 -2
- package/src/util/image-size.ts +16 -0
- package/src/domains/search/google-serp.ts +0 -155
package/src/domains/keyboard.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { cdpCall, evalJs } from "./cdp-call";
|
|
|
7
7
|
import { runOnElement } from "./element-call";
|
|
8
8
|
import { elementFunctionSource } from "./page/inject";
|
|
9
9
|
import { dispatchKeyEvent, type KeyEventSpec } from "./page/keyboard.page";
|
|
10
|
+
import { prepareTyping, readTypedValue, type TypePrep, type TypedValue } from "./page/fill.page";
|
|
11
|
+
import { nativeFill } from "./form";
|
|
12
|
+
import { interactiveDiff } from "./ref-resolve";
|
|
13
|
+
import { isRecord } from "../util/guards";
|
|
14
|
+
import type { BrowserClient } from "../client";
|
|
10
15
|
|
|
11
16
|
const DISPATCH_KEY_FN = elementFunctionSource(dispatchKeyEvent);
|
|
12
17
|
|
|
@@ -19,53 +24,180 @@ const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
|
|
|
19
24
|
F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123,
|
|
20
25
|
};
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
// Windows virtual-key codes for letters and digits are the upper-case ASCII code ("a" and "A" are both 65).
|
|
28
|
+
export const virtualKeyCode = (key: string): number => {
|
|
29
|
+
const named = VIRTUAL_KEY_CODES[key];
|
|
30
|
+
if (named !== undefined) return named;
|
|
31
|
+
if (/^[a-z0-9]$/i.test(key)) return key.toUpperCase().charCodeAt(0);
|
|
32
|
+
return key.length === 1 ? key.charCodeAt(0) : 0;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// KeyboardEvent.code: the physical key. Pages that check `code` (shortcut handlers) need "KeyA", not "a".
|
|
36
|
+
export const physicalCode = (key: string): string => {
|
|
37
|
+
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
|
|
38
|
+
if (/^[0-9]$/.test(key)) return `Digit${key}`;
|
|
39
|
+
if (key === " ") return "Space";
|
|
40
|
+
return key;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Chrome only runs an editing shortcut sent over CDP when the event names the command (macOS does not map Cmd+A to
|
|
44
|
+
// select-all for synthetic events), so Ctrl/Cmd + A/C/X/V/Z/Y carry it explicitly.
|
|
45
|
+
export const editingCommands = (key: string, modifiers: number): ReadonlyArray<string> => {
|
|
46
|
+
const ctrlOrMeta = (modifiers & (2 | 4)) !== 0;
|
|
47
|
+
if (!ctrlOrMeta || (modifiers & 1) !== 0) return [];
|
|
48
|
+
const shift = (modifiers & 8) !== 0;
|
|
49
|
+
switch (key.toLowerCase()) {
|
|
50
|
+
case "a": return shift ? [] : ["selectAll"];
|
|
51
|
+
case "c": return shift ? [] : ["copy"];
|
|
52
|
+
case "x": return shift ? [] : ["cut"];
|
|
53
|
+
case "v": return shift ? [] : ["paste"];
|
|
54
|
+
case "z": return shift ? ["redo"] : ["undo"];
|
|
55
|
+
case "y": return shift ? [] : ["redo"];
|
|
56
|
+
default: return [];
|
|
57
|
+
}
|
|
58
|
+
};
|
|
24
59
|
|
|
25
60
|
const TypeArgs = Type.Object({
|
|
26
|
-
|
|
61
|
+
ref: Type.Optional(
|
|
62
|
+
Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED. The field is focused for you." }),
|
|
63
|
+
),
|
|
64
|
+
selector: Type.Optional(Type.String({ description: "CSS selector of the field (fallback when no ref)." })),
|
|
65
|
+
text: Type.String({ description: "Text to enter. With `clear` (the default when a field is named) it replaces the field's value; \"\" empties it." }),
|
|
66
|
+
clear: Type.Optional(
|
|
67
|
+
Type.Boolean({
|
|
68
|
+
description:
|
|
69
|
+
"Replace the field's current value (true) or add to it at the cursor (false). Default: true when ref/selector is given, false when typing into the already-focused element.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
27
72
|
});
|
|
28
73
|
|
|
29
|
-
type
|
|
74
|
+
type TypeTarget = { readonly ref?: string | undefined; readonly selector?: string | undefined };
|
|
30
75
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
const PREP_FN = elementFunctionSource(prepareTyping);
|
|
77
|
+
const READ_FN = elementFunctionSource(readTypedValue);
|
|
78
|
+
|
|
79
|
+
const isTypePrep = (v: unknown): v is TypePrep => {
|
|
80
|
+
if (!isRecord(v) || typeof v["ok"] !== "boolean") return false;
|
|
81
|
+
if (v["ok"]) return typeof v["kind"] === "string" && typeof v["value"] === "string" && typeof v["tag"] === "string";
|
|
82
|
+
return typeof v["code"] === "string" && typeof v["reason"] === "string";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isTypedValue = (v: unknown): v is TypedValue =>
|
|
86
|
+
isRecord(v) && typeof v["value"] === "string" && typeof v["focused"] === "boolean";
|
|
87
|
+
|
|
88
|
+
const hasTarget = (t: TypeTarget): boolean => t.ref !== undefined || t.selector !== undefined;
|
|
89
|
+
|
|
90
|
+
const labelOf = (t: TypeTarget): string => (t.ref !== undefined ? `[${t.ref}]` : t.selector ?? "the focused element");
|
|
91
|
+
|
|
92
|
+
// Runs an element function on the named field, or on document.activeElement when none is named.
|
|
93
|
+
const onField = async (
|
|
94
|
+
client: BrowserClient,
|
|
95
|
+
t: TypeTarget,
|
|
96
|
+
fn: string,
|
|
97
|
+
args: ReadonlyArray<unknown>,
|
|
98
|
+
): Promise<Result<unknown, ToolErr>> => {
|
|
99
|
+
if (hasTarget(t)) {
|
|
100
|
+
const r = await runOnElement(client, { ref: t.ref, selector: t.selector, functionDeclaration: fn, args });
|
|
101
|
+
if (!r.success) return r;
|
|
102
|
+
if (!r.data.found) return err({ kind: "invalid_state", message: `No element matched: ${labelOf(t)}` });
|
|
103
|
+
return ok(r.data.value);
|
|
104
|
+
}
|
|
105
|
+
return evalJs(client, "(" + fn + ").apply(document.activeElement, " + safeJs`${args}` + ")");
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const prepErr = (t: TypeTarget, prep: Extract<TypePrep, { readonly ok: false }>): ToolErr => {
|
|
109
|
+
const where = labelOf(t);
|
|
110
|
+
const hint =
|
|
111
|
+
prep.code === "select" ? " For a <select> use browser_select_option."
|
|
112
|
+
: prep.code === "checkable" ? " For a checkbox or radio use browser_set_checked."
|
|
113
|
+
: prep.code === "not_element" ? " Pass the field's ref, or click it first so it has focus."
|
|
114
|
+
: "";
|
|
115
|
+
return { kind: "invalid_state", message: `Cannot type into ${where}: ${prep.reason}.${hint}`, details: { code: prep.code, ...(prep.tag !== undefined ? { tag: prep.tag } : {}) } };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Backspace with the field's content selected deletes it, the way a user empties a field.
|
|
119
|
+
const pressBackspace = async (client: BrowserClient): Promise<Result<void, ToolErr>> => {
|
|
120
|
+
const key = { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8 };
|
|
121
|
+
const down = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyDown", ...key });
|
|
122
|
+
if (!down.success) return down;
|
|
123
|
+
const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", ...key });
|
|
124
|
+
if (!up.success) return up;
|
|
125
|
+
return ok(undefined);
|
|
126
|
+
};
|
|
35
127
|
|
|
36
128
|
export const typeTool = defineBrowserTool({
|
|
37
129
|
name: "browser_type",
|
|
38
130
|
label: "Browser Type",
|
|
39
|
-
description:
|
|
40
|
-
|
|
131
|
+
description:
|
|
132
|
+
"Enter text into a field the way a user types: focuses it, replaces its value (or appends with clear:false), fires real input events so autocomplete/suggestion lists open and React/Vue controlled inputs update, then reads the value back. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`; with neither, types into the focused element. If typing does not produce the value (date/masked inputs), it falls back to setting the value directly and says so.",
|
|
133
|
+
promptSnippet: "Type text into a field by ref (preferred), selector, or the focused element",
|
|
41
134
|
promptGuidelines: [
|
|
42
|
-
"
|
|
135
|
+
"One tool for all text entry: browser_type({ ref, text }) replaces the field's value; pass clear:false to append; text \"\" empties the field.",
|
|
136
|
+
"Check the value it reports — a page can reformat or reject what was typed.",
|
|
137
|
+
"Suggestion lists open as you type: after browser_type, run browser_snapshot and browser_click the option's ref.",
|
|
138
|
+
"For a <select> use browser_select_option; for a checkbox/radio use browser_set_checked.",
|
|
139
|
+
"For special keys (Enter, Tab, Escape, arrows) use browser_press_key.",
|
|
43
140
|
],
|
|
44
141
|
parameters: TypeArgs,
|
|
45
142
|
concurrency: "serialized",
|
|
46
143
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
144
|
+
const t: TypeTarget = { ref: args.ref, selector: args.selector };
|
|
145
|
+
const named = hasTarget(t);
|
|
146
|
+
const clear = args.clear ?? named;
|
|
147
|
+
const where = labelOf(t);
|
|
148
|
+
|
|
149
|
+
const prepRaw = await onField(client, t, PREP_FN, [clear]);
|
|
150
|
+
if (!prepRaw.success) return prepRaw;
|
|
151
|
+
if (!isTypePrep(prepRaw.data)) {
|
|
152
|
+
if (named) return err({ kind: "internal", message: "unexpected page result shape from browser_type", details: { target: where } });
|
|
153
|
+
return err({
|
|
154
|
+
kind: "invalid_state",
|
|
155
|
+
message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const prep = prepRaw.data;
|
|
159
|
+
if (!prep.ok) {
|
|
160
|
+
if (!named && prep.code !== "disabled" && prep.code !== "readonly") {
|
|
58
161
|
return err({
|
|
59
162
|
kind: "invalid_state",
|
|
60
|
-
message:
|
|
61
|
-
|
|
62
|
-
details: { activeElement: f.tag },
|
|
163
|
+
message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
|
|
164
|
+
details: { activeElement: prep.tag ?? null },
|
|
63
165
|
});
|
|
64
166
|
}
|
|
167
|
+
return err(prepErr(t, prep));
|
|
65
168
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
169
|
+
|
|
170
|
+
const typed = clear && args.text === "" ? await pressBackspace(client) : await cdpCall(client, "Input.insertText", { text: args.text });
|
|
171
|
+
if (!typed.success) return typed;
|
|
172
|
+
|
|
173
|
+
const readRaw = await onField(client, t, READ_FN, []);
|
|
174
|
+
const after = readRaw.success && isTypedValue(readRaw.data) ? readRaw.data : undefined;
|
|
175
|
+
const expected = clear ? args.text : prep.value + args.text;
|
|
176
|
+
let value = after?.value;
|
|
177
|
+
let note = "";
|
|
178
|
+
if (value !== undefined && value !== expected && clear && named && prep.kind !== "contenteditable") {
|
|
179
|
+
// Typing did not produce the value (a date or masked input, or a handler that swallowed the keys): set it.
|
|
180
|
+
const filled = await nativeFill(client, t, args.text);
|
|
181
|
+
if (filled.success && "value" in filled.data) {
|
|
182
|
+
value = filled.data.value;
|
|
183
|
+
note = " (typing did not take; value set directly)";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (value !== undefined && value !== expected) note += ` ⚠ the field holds ${JSON.stringify(value)}, not what was typed — the page reformatted or rejected it`;
|
|
187
|
+
if (after !== undefined && !after.focused && note === "") note = " (the field lost focus after typing)";
|
|
188
|
+
|
|
189
|
+
const diff = await interactiveDiff(client);
|
|
190
|
+
const shown = value !== undefined ? ` → value ${JSON.stringify(value)}` : "";
|
|
191
|
+
return ok({
|
|
192
|
+
text: `Typed "${args.text}" into ${where}${shown}${note}${diff}`,
|
|
193
|
+
details: {
|
|
194
|
+
...(args.ref !== undefined ? { ref: args.ref } : {}),
|
|
195
|
+
...(args.selector !== undefined ? { selector: args.selector } : {}),
|
|
196
|
+
text: args.text,
|
|
197
|
+
clear,
|
|
198
|
+
...(value !== undefined ? { value } : {}),
|
|
199
|
+
},
|
|
200
|
+
});
|
|
69
201
|
},
|
|
70
202
|
});
|
|
71
203
|
|
|
@@ -100,23 +232,26 @@ export const pressKeyTool = defineBrowserTool({
|
|
|
100
232
|
const code = virtualKeyCode(k);
|
|
101
233
|
const modifiers = args.modifiers ?? 0;
|
|
102
234
|
const isChar = k.length === 1;
|
|
235
|
+
const physical = physicalCode(k);
|
|
236
|
+
// A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
|
|
237
|
+
const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
|
|
238
|
+
const commands = editingCommands(k, modifiers);
|
|
103
239
|
const downParams: ParamsOf<"Input.dispatchKeyEvent"> = {
|
|
104
|
-
type: "keyDown",
|
|
240
|
+
type: isChar && hasCommandModifier ? "rawKeyDown" : "keyDown",
|
|
105
241
|
key: k,
|
|
106
|
-
code:
|
|
242
|
+
code: physical,
|
|
107
243
|
modifiers,
|
|
108
244
|
...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
|
|
109
|
-
...(isChar ? { text: k, unmodifiedText: k } : {}),
|
|
245
|
+
...(isChar && !hasCommandModifier ? { text: k, unmodifiedText: k } : {}),
|
|
246
|
+
...(commands.length > 0 ? { commands: [...commands] } : {}),
|
|
110
247
|
};
|
|
111
248
|
const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams);
|
|
112
249
|
if (!down.success) return down;
|
|
113
|
-
// A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
|
|
114
|
-
const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
|
|
115
250
|
if (isChar && !hasCommandModifier) {
|
|
116
251
|
const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });
|
|
117
252
|
if (!charEv.success) return charEv;
|
|
118
253
|
}
|
|
119
|
-
const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code:
|
|
254
|
+
const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: physical, modifiers });
|
|
120
255
|
if (!up.success) return up;
|
|
121
256
|
return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
|
|
122
257
|
},
|
|
@@ -153,7 +288,8 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
153
288
|
const eventType = args.eventType ?? "keydown";
|
|
154
289
|
const target = args.ref ?? args.selector ?? "";
|
|
155
290
|
// Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
|
|
156
|
-
|
|
291
|
+
// keypress carries the character code ("a" = 97); keydown/keyup carry the key code ("a" = 65).
|
|
292
|
+
const code = eventType === "keypress" && args.key.length === 1 ? args.key.charCodeAt(0) : virtualKeyCode(args.key);
|
|
157
293
|
if (args.ref !== undefined) {
|
|
158
294
|
// Through runOnElement so the resolved RemoteObject is released after the call.
|
|
159
295
|
const spec: KeyEventSpec = { type: eventType, key: args.key, code };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Page-side code for browser_click; see fill.page.ts for the rules every `*.page.ts` module follows.
|
|
2
|
+
|
|
3
|
+
// What sits under the click point, relative to the element the click was aimed at.
|
|
4
|
+
export type HitInfo = {
|
|
5
|
+
// The point lands on the target, inside it, or on an ancestor that wraps it (a label around an icon).
|
|
6
|
+
readonly onTarget: boolean;
|
|
7
|
+
// A short description of the element actually under the point, e.g. `div.modal-overlay "Close"`.
|
|
8
|
+
readonly hit: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `target` is the element the click aims at (Runtime.callFunctionOn's `this`), `hit` the element hit-testing found.
|
|
12
|
+
export function hitInfo(target: Element | null, hit: Element | null): HitInfo {
|
|
13
|
+
if (hit?.nodeType !== 1) return { onTarget: false, hit: "nothing" };
|
|
14
|
+
const onTarget = target !== null && (target === hit || target.contains(hit) || hit.contains(target));
|
|
15
|
+
const tag = hit.tagName.toLowerCase();
|
|
16
|
+
const id = hit.id ? "#" + hit.id : "";
|
|
17
|
+
const cls = typeof hit.className === "string" && hit.className.trim() !== ""
|
|
18
|
+
? "." + hit.className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
19
|
+
: "";
|
|
20
|
+
const label = (hit.getAttribute("aria-label") ?? hit.textContent).replace(/\s+/g, " ").trim().slice(0, 40);
|
|
21
|
+
return { onTarget, hit: tag + id + cls + (label !== "" ? ' "' + label + '"' : "") };
|
|
22
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
export type PageOption = { readonly value: string; readonly text: string };
|
|
11
11
|
|
|
12
12
|
export type FillOptions = {
|
|
13
|
-
// browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
|
|
13
|
+
// The recipe-only browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
|
|
14
14
|
readonly rejectSelect: boolean;
|
|
15
15
|
readonly focusFirst: boolean;
|
|
16
16
|
};
|
|
@@ -37,6 +37,8 @@ export type FormControl = HTMLElement & {
|
|
|
37
37
|
value?: string;
|
|
38
38
|
checked?: boolean;
|
|
39
39
|
disabled?: boolean;
|
|
40
|
+
readOnly?: boolean;
|
|
41
|
+
select?: () => void;
|
|
40
42
|
options?: HTMLOptionsCollection;
|
|
41
43
|
};
|
|
42
44
|
|
|
@@ -137,3 +139,70 @@ export function fillField(el: FormControl | null, value: unknown, opts: FillOpti
|
|
|
137
139
|
|
|
138
140
|
return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")", tag: tagName };
|
|
139
141
|
}
|
|
142
|
+
|
|
143
|
+
// What browser_type learns about its field before typing: whether it can take text, and its value right now.
|
|
144
|
+
export type TypePrep =
|
|
145
|
+
| { readonly ok: true; readonly kind: "input" | "textarea" | "contenteditable"; readonly value: string; readonly tag: string }
|
|
146
|
+
| {
|
|
147
|
+
readonly ok: false;
|
|
148
|
+
readonly code: "not_element" | "disabled" | "readonly" | "select" | "checkable" | "not_editable";
|
|
149
|
+
readonly reason: string;
|
|
150
|
+
readonly tag?: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Focuses `el` and, when `clear` is set, selects everything in it, so the text typed next replaces the old value the
|
|
154
|
+
// way select-all + typing does for a user. Refuses fields that cannot take typed text, naming the tool that can.
|
|
155
|
+
export function prepareTyping(el: FormControl | null, clear: boolean): TypePrep {
|
|
156
|
+
if (el?.nodeType !== 1) return { ok: false, code: "not_element", reason: "no element to type into" };
|
|
157
|
+
const tagName = el.tagName;
|
|
158
|
+
const tag = tagName.toLowerCase();
|
|
159
|
+
const type = (el.type ?? "").toLowerCase();
|
|
160
|
+
if (tag === "select") return { ok: false, code: "select", reason: "element is a <select>", tag: tagName };
|
|
161
|
+
if (tag === "input" && (type === "checkbox" || type === "radio")) {
|
|
162
|
+
return { ok: false, code: "checkable", reason: "element is a " + type, tag: tagName };
|
|
163
|
+
}
|
|
164
|
+
const editableTag = (tag === "input" && type !== "button" && type !== "submit" && type !== "file" && type !== "image" && type !== "reset")
|
|
165
|
+
|| tag === "textarea";
|
|
166
|
+
if (!editableTag && !el.isContentEditable) {
|
|
167
|
+
return { ok: false, code: "not_editable", reason: "element is not a text field (tag=" + tag + ")", tag: tagName };
|
|
168
|
+
}
|
|
169
|
+
if (el.disabled) return { ok: false, code: "disabled", reason: "element is disabled", tag: tagName };
|
|
170
|
+
if (editableTag && el.readOnly === true) {
|
|
171
|
+
return { ok: false, code: "readonly", reason: "element is read-only", tag: tagName };
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
el.focus();
|
|
175
|
+
} catch {
|
|
176
|
+
/* focus throws on some detached or shadow nodes; the caller checks where the text landed */
|
|
177
|
+
}
|
|
178
|
+
if (editableTag) {
|
|
179
|
+
if (clear) {
|
|
180
|
+
try {
|
|
181
|
+
el.select?.();
|
|
182
|
+
} catch {
|
|
183
|
+
/* select() is a no-op or throws on a few input types (date, color); the caller verifies and falls back */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, kind: tag === "textarea" ? "textarea" : "input", value: el.value ?? "", tag: tagName };
|
|
187
|
+
}
|
|
188
|
+
if (clear) {
|
|
189
|
+
const sel = window.getSelection();
|
|
190
|
+
if (sel) {
|
|
191
|
+
sel.removeAllRanges();
|
|
192
|
+
const rng = document.createRange();
|
|
193
|
+
rng.selectNodeContents(el);
|
|
194
|
+
sel.addRange(rng);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, kind: "contenteditable", value: el.textContent, tag: tagName };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The field's value after typing, and whether it still has focus (a blur handler may have moved it elsewhere).
|
|
201
|
+
export type TypedValue = { readonly value: string; readonly focused: boolean };
|
|
202
|
+
|
|
203
|
+
export function readTypedValue(el: FormControl | null): TypedValue {
|
|
204
|
+
if (el?.nodeType !== 1) return { value: "", focused: false };
|
|
205
|
+
const tag = el.tagName.toLowerCase();
|
|
206
|
+
const value = tag === "input" || tag === "textarea" ? (el.value ?? "") : el.textContent;
|
|
207
|
+
return { value, focused: document.activeElement === el };
|
|
208
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Page-side code for browser_web_search: pulls the organic result anchors off a
|
|
1
|
+
// Page-side code for browser_web_search: pulls the organic result anchors off a search engine's results page, driven
|
|
2
|
+
// by the engine's selectors. See fill.page.ts for the rules.
|
|
2
3
|
|
|
3
4
|
export type SerpAnchor = {
|
|
4
5
|
readonly href: string;
|
|
@@ -12,29 +13,41 @@ export type SerpExtraction = {
|
|
|
12
13
|
readonly pageText: string;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
export type SerpSelectors = {
|
|
17
|
+
readonly root: string;
|
|
18
|
+
readonly heading: string;
|
|
19
|
+
readonly containers: ReadonlyArray<string>;
|
|
20
|
+
readonly snippet?: string;
|
|
21
|
+
readonly cite: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
15
24
|
// Returns the extraction as JSON text (not an object) so the isolated-tab evaluator can keep its string guard.
|
|
16
|
-
export function extractSerp(pageTextLimit: number): string {
|
|
17
|
-
const main = document.querySelector<HTMLElement>(
|
|
18
|
-
const headings = Array.from(main.querySelectorAll<HTMLElement>(
|
|
25
|
+
export function extractSerp(selectors: SerpSelectors, pageTextLimit: number): string {
|
|
26
|
+
const main = document.querySelector<HTMLElement>(selectors.root) ?? document.body;
|
|
27
|
+
const headings = Array.from(main.querySelectorAll<HTMLElement>(selectors.heading));
|
|
19
28
|
const seen = new Set<string>();
|
|
20
29
|
const anchors: SerpAnchor[] = [];
|
|
21
|
-
for (const
|
|
22
|
-
|
|
30
|
+
for (const heading of headings) {
|
|
31
|
+
// The heading is the link (DuckDuckGo), sits inside it (Google, Brave) or wraps it (Bing).
|
|
32
|
+
const a = heading.closest<HTMLAnchorElement>("a[href]") ?? heading.querySelector<HTMLAnchorElement>("a[href]");
|
|
23
33
|
if (!a) continue;
|
|
24
34
|
const href = a.href;
|
|
25
35
|
if (!href || seen.has(href)) continue;
|
|
26
36
|
seen.add(href);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
a.closest<HTMLElement>(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
37
|
+
let container: HTMLElement | null = null;
|
|
38
|
+
for (const selector of selectors.containers) {
|
|
39
|
+
container = a.closest<HTMLElement>(selector);
|
|
40
|
+
if (container) break;
|
|
41
|
+
}
|
|
42
|
+
container = container ?? a.parentElement?.parentElement ?? a.parentElement;
|
|
43
|
+
const title = heading.innerText || heading.textContent || "";
|
|
44
|
+
const snippetEl =
|
|
45
|
+
selectors.snippet !== undefined && container ? container.querySelector<HTMLElement>(selectors.snippet) : null;
|
|
46
|
+
const snippetSource = snippetEl ? snippetEl.innerText : container ? container.innerText.replace(title, "") : "";
|
|
47
|
+
const snippet = snippetSource.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
48
|
+
const citeEl = container ? container.querySelector<HTMLElement>(selectors.cite) : null;
|
|
36
49
|
const cite = citeEl ? (citeEl.innerText || citeEl.textContent || "").trim() : "";
|
|
37
|
-
anchors.push({ href, heading, snippet, cite });
|
|
50
|
+
anchors.push({ href, heading: title, snippet, cite });
|
|
38
51
|
}
|
|
39
52
|
const pageText = document.body.innerText.slice(0, pageTextLimit);
|
|
40
53
|
const extraction: SerpExtraction = { anchors, pageText };
|
package/src/domains/page.ts
CHANGED
|
@@ -91,7 +91,7 @@ export const waitForTool = defineBrowserTool({
|
|
|
91
91
|
"Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
|
|
92
92
|
promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
|
|
93
93
|
promptGuidelines: [
|
|
94
|
-
"Use before
|
|
94
|
+
"Use before browser_type / browser_click on dynamic pages so you act after the element renders.",
|
|
95
95
|
],
|
|
96
96
|
parameters: WaitForArgs,
|
|
97
97
|
concurrency: "parallel",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import type { BrowserClient } from "../client";
|
|
4
|
+
import { imageWidth } from "../util/image-size";
|
|
5
|
+
import { evalJs } from "./cdp-call";
|
|
2
6
|
import { Type } from "typebox";
|
|
3
7
|
import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
4
8
|
import { type Result, ok } from "../util/result";
|
|
@@ -20,6 +24,21 @@ const ScreenshotDetailsSchema = Type.Object({
|
|
|
20
24
|
|
|
21
25
|
export type ScreenshotDetails = DetailsOf<typeof ScreenshotDetailsSchema>;
|
|
22
26
|
|
|
27
|
+
// Screenshots come out in device pixels (2x on a Retina display) and maxDim may shrink them again, so a point read
|
|
28
|
+
// off the image is not a browser_click coordinate. Say how to convert, whenever the two differ.
|
|
29
|
+
const scaleNote = async (client: BrowserClient, path: string, fullPage: boolean): Promise<string> => {
|
|
30
|
+
try {
|
|
31
|
+
const width = imageWidth(await readFile(path));
|
|
32
|
+
const css = await evalJs(client, fullPage ? "document.documentElement.scrollWidth" : "window.innerWidth");
|
|
33
|
+
if (width === undefined || !css.success || typeof css.data !== "number" || css.data <= 0) return "";
|
|
34
|
+
const ratio = width / css.data;
|
|
35
|
+
if (Math.abs(ratio - 1) < 0.05) return "";
|
|
36
|
+
return `\nImage scale: ${ratio.toFixed(2)} image px per CSS px — divide coordinates read off this image by ${ratio.toFixed(2)} before passing them to browser_click (or use refs from browser_snapshot).`;
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
23
42
|
export const screenshotTool = defineBrowserTool({
|
|
24
43
|
name: "browser_screenshot",
|
|
25
44
|
label: "Browser Screenshot",
|
|
@@ -40,8 +59,9 @@ export const screenshotTool = defineBrowserTool({
|
|
|
40
59
|
const resized = await resizeIfNeeded(cap.data.path, args.maxDim);
|
|
41
60
|
note = resized.note;
|
|
42
61
|
}
|
|
62
|
+
const scale = await scaleNote(client, cap.data.path, args.fullPage ?? false);
|
|
43
63
|
return ok({
|
|
44
|
-
text: `Screenshot saved: ${cap.data.path}${note}`,
|
|
64
|
+
text: `Screenshot saved: ${cap.data.path}${note}${scale}`,
|
|
45
65
|
details: { path: cap.data.path, format: cap.data.format, attached: false },
|
|
46
66
|
});
|
|
47
67
|
},
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { externalResult, isWebUrl, type SearchEngine, type SerpAnchor, urlFromCite } from "../serp";
|
|
2
|
+
|
|
3
|
+
const BING_PROPERTY = /(?:^|\.)bing\.(?:com|net)$/;
|
|
4
|
+
const isExternalResult = externalResult((host) => BING_PROPERTY.test(host));
|
|
5
|
+
|
|
6
|
+
// Bing wraps organic links as bing.com/ck/a?...&u=a1<base64url(target)>. The "a1" prefix tags the encoding.
|
|
7
|
+
export const decodeBingClick = (href: string): string | undefined => {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(href);
|
|
10
|
+
if (!BING_PROPERTY.test(u.hostname.toLowerCase()) || u.pathname !== "/ck/a") return undefined;
|
|
11
|
+
const encoded = u.searchParams.get("u");
|
|
12
|
+
if (!encoded?.startsWith("a1")) return undefined;
|
|
13
|
+
const decoded = Buffer.from(encoded.slice(2), "base64url").toString("utf8");
|
|
14
|
+
return isWebUrl(decoded) ? decoded : undefined;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const resultUrl = (anchor: SerpAnchor): string | undefined => {
|
|
21
|
+
const target = decodeBingClick(anchor.href) ?? anchor.href;
|
|
22
|
+
if (isExternalResult(target)) return target;
|
|
23
|
+
if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite, isExternalResult);
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const bing: SearchEngine = {
|
|
28
|
+
id: "bing",
|
|
29
|
+
name: "Bing",
|
|
30
|
+
buildUrl: (query, limit) =>
|
|
31
|
+
`https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${limit}&setlang=en`,
|
|
32
|
+
selectors: {
|
|
33
|
+
root: "#b_results, #b_content",
|
|
34
|
+
heading: "li.b_algo h2",
|
|
35
|
+
containers: ["li.b_algo"],
|
|
36
|
+
snippet: ".b_caption p, p[class*='b_lineclamp'], .b_snippet",
|
|
37
|
+
cite: "cite",
|
|
38
|
+
},
|
|
39
|
+
captchaMarkers: [
|
|
40
|
+
"one last step",
|
|
41
|
+
"solve the challenge",
|
|
42
|
+
"verify you are a human",
|
|
43
|
+
"unusual traffic",
|
|
44
|
+
"are you a robot",
|
|
45
|
+
],
|
|
46
|
+
resultUrl,
|
|
47
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { externalResult, type SearchEngine, type SerpAnchor } from "../serp";
|
|
2
|
+
|
|
3
|
+
const BRAVE_PROPERTY = /(?:^|\.)brave\.com$/;
|
|
4
|
+
const isExternalResult = externalResult((host) => BRAVE_PROPERTY.test(host));
|
|
5
|
+
|
|
6
|
+
// Brave links straight to the target; there is no redirect wrapper to undo.
|
|
7
|
+
const resultUrl = (anchor: SerpAnchor): string | undefined =>
|
|
8
|
+
isExternalResult(anchor.href) ? anchor.href : undefined;
|
|
9
|
+
|
|
10
|
+
export const brave: SearchEngine = {
|
|
11
|
+
id: "brave",
|
|
12
|
+
name: "Brave Search",
|
|
13
|
+
buildUrl: (query) => `https://search.brave.com/search?q=${encodeURIComponent(query)}&source=web`,
|
|
14
|
+
selectors: {
|
|
15
|
+
root: "#results, main",
|
|
16
|
+
heading: "[data-type='web'] a .title",
|
|
17
|
+
containers: ["[data-type='web']"],
|
|
18
|
+
snippet: ".generic-snippet .content, .snippet-description",
|
|
19
|
+
cite: "cite",
|
|
20
|
+
},
|
|
21
|
+
captchaMarkers: ["captcha", "verify you are human", "unusual activity", "not a robot"],
|
|
22
|
+
resultUrl,
|
|
23
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { externalResult, type SearchEngine, type SerpAnchor, urlFromCite } from "../serp";
|
|
2
|
+
|
|
3
|
+
const DDG_PROPERTY = /(?:^|\.)duckduckgo\.com$/;
|
|
4
|
+
const isExternalResult = externalResult((host) => DDG_PROPERTY.test(host));
|
|
5
|
+
|
|
6
|
+
// Results normally link straight to the target; a duckduckgo.com/l/?uddg=<encoded target> wrapper is undone, and ads
|
|
7
|
+
// (routed through /y.js) are dropped by the own-property filter.
|
|
8
|
+
export const unwrapDuckDuckGo = (href: string): string => {
|
|
9
|
+
try {
|
|
10
|
+
const u = new URL(href);
|
|
11
|
+
if (!DDG_PROPERTY.test(u.hostname.toLowerCase()) || u.pathname !== "/l/") return href;
|
|
12
|
+
return u.searchParams.get("uddg") ?? href;
|
|
13
|
+
} catch {
|
|
14
|
+
return href;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const resultUrl = (anchor: SerpAnchor): string | undefined => {
|
|
19
|
+
const target = unwrapDuckDuckGo(anchor.href);
|
|
20
|
+
if (isExternalResult(target)) return target;
|
|
21
|
+
if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite, isExternalResult);
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The main results page. It renders results with JavaScript after the load event, hence readySelector. (The no-JS
|
|
26
|
+
// html.duckduckgo.com endpoint serves a bot challenge even to a real Chrome, so it is not used.)
|
|
27
|
+
export const duckduckgo: SearchEngine = {
|
|
28
|
+
id: "duckduckgo",
|
|
29
|
+
name: "DuckDuckGo",
|
|
30
|
+
buildUrl: (query) => `https://duckduckgo.com/?q=${encodeURIComponent(query)}&kl=us-en&ia=web`,
|
|
31
|
+
selectors: {
|
|
32
|
+
// The page has two mainline containers and the first is empty, so the root is the results list itself.
|
|
33
|
+
root: ".react-results--main, #react-layout",
|
|
34
|
+
heading: "article[data-testid='result'] a[data-testid='result-title-a']",
|
|
35
|
+
containers: ["article[data-testid='result']"],
|
|
36
|
+
snippet: "[data-result='snippet']",
|
|
37
|
+
cite: "a[data-testid='result-extras-url-link']",
|
|
38
|
+
},
|
|
39
|
+
captchaMarkers: ["bots use duckduckgo too", "complete the following challenge"],
|
|
40
|
+
readySelector: "article[data-testid='result']",
|
|
41
|
+
resultUrl,
|
|
42
|
+
};
|