screenhand 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 +427 -0
- package/dist/config.js +9 -0
- package/dist/index.js +55 -0
- package/dist/logging/timeline-logger.js +29 -0
- package/dist/mcp/mcp-stdio-server.js +284 -0
- package/dist/mcp/server.js +347 -0
- package/dist/mcp-entry.js +62 -0
- package/dist/memory/recall.js +160 -0
- package/dist/memory/research.js +98 -0
- package/dist/memory/seeds.js +89 -0
- package/dist/memory/session.js +161 -0
- package/dist/memory/store.js +391 -0
- package/dist/memory/types.js +4 -0
- package/dist/native/bridge-client.js +173 -0
- package/dist/native/macos-bridge-client.js +5 -0
- package/dist/runtime/accessibility-adapter.js +377 -0
- package/dist/runtime/app-adapter.js +48 -0
- package/dist/runtime/applescript-adapter.js +283 -0
- package/dist/runtime/ax-role-map.js +80 -0
- package/dist/runtime/browser-adapter.js +36 -0
- package/dist/runtime/cdp-chrome-adapter.js +505 -0
- package/dist/runtime/composite-adapter.js +205 -0
- package/dist/runtime/executor.js +250 -0
- package/dist/runtime/locator-cache.js +12 -0
- package/dist/runtime/planning-loop.js +47 -0
- package/dist/runtime/service.js +372 -0
- package/dist/runtime/session-manager.js +28 -0
- package/dist/runtime/state-observer.js +105 -0
- package/dist/runtime/vision-adapter.js +208 -0
- package/dist/test-mcp-protocol.js +138 -0
- package/dist/types.js +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getChromePath, launch } from "chrome-launcher";
|
|
6
|
+
import CDP from "chrome-remote-interface";
|
|
7
|
+
const HANDLE_ATTR = "data-automator-handle";
|
|
8
|
+
const POLL_INTERVAL_MS = 100;
|
|
9
|
+
export class CdpChromeAdapter {
|
|
10
|
+
options;
|
|
11
|
+
sessions = new Map();
|
|
12
|
+
sessionsByProfile = new Map();
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
async attach(profile) {
|
|
17
|
+
const existing = this.sessionsByProfile.get(profile);
|
|
18
|
+
if (existing) {
|
|
19
|
+
return existing.info;
|
|
20
|
+
}
|
|
21
|
+
const profileDir = path.resolve(this.options.profileRootDir ?? path.join(process.cwd(), ".profiles"), profile);
|
|
22
|
+
await mkdir(profileDir, { recursive: true });
|
|
23
|
+
const chromePath = this.options.chromePath ?? resolveChromePath();
|
|
24
|
+
const chrome = await launch({
|
|
25
|
+
chromePath,
|
|
26
|
+
startingUrl: "about:blank",
|
|
27
|
+
userDataDir: profileDir,
|
|
28
|
+
chromeFlags: this.buildChromeFlags(),
|
|
29
|
+
});
|
|
30
|
+
const targetId = await this.resolveTargetId(chrome.port);
|
|
31
|
+
const client = await CDP({ port: chrome.port, target: targetId });
|
|
32
|
+
await Promise.all([client.Page.enable(), client.Runtime.enable()]);
|
|
33
|
+
const info = {
|
|
34
|
+
sessionId: `session_${profile}_${Date.now()}`,
|
|
35
|
+
profile,
|
|
36
|
+
createdAt: new Date().toISOString(),
|
|
37
|
+
adapterType: "cdp",
|
|
38
|
+
};
|
|
39
|
+
const state = { info, profileDir, chrome, client };
|
|
40
|
+
this.sessions.set(info.sessionId, state);
|
|
41
|
+
this.sessionsByProfile.set(profile, state);
|
|
42
|
+
return info;
|
|
43
|
+
}
|
|
44
|
+
/** Backward-compatible alias. */
|
|
45
|
+
async connect(profile) {
|
|
46
|
+
return this.attach(profile);
|
|
47
|
+
}
|
|
48
|
+
async getAppContext(sessionId) {
|
|
49
|
+
const page = await this.getPageMeta(sessionId);
|
|
50
|
+
return {
|
|
51
|
+
bundleId: "com.google.Chrome",
|
|
52
|
+
appName: "Google Chrome",
|
|
53
|
+
pid: this.requireSession(sessionId).chrome.pid,
|
|
54
|
+
windowTitle: page.title,
|
|
55
|
+
url: page.url,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async getPageMeta(sessionId) {
|
|
59
|
+
const state = this.requireSession(sessionId);
|
|
60
|
+
const page = await this.evaluateJson(state, "(() => ({ url: String(window.location.href), title: String(document.title || '') }))()");
|
|
61
|
+
return page;
|
|
62
|
+
}
|
|
63
|
+
async navigate(sessionId, url, timeoutMs) {
|
|
64
|
+
const state = this.requireSession(sessionId);
|
|
65
|
+
await state.client.Page.navigate({ url });
|
|
66
|
+
const ready = await this.waitUntil(timeoutMs, async () => {
|
|
67
|
+
const readyState = await this.evaluateJson(state, "(() => String(document.readyState))()");
|
|
68
|
+
return readyState === "interactive" || readyState === "complete";
|
|
69
|
+
});
|
|
70
|
+
if (!ready) {
|
|
71
|
+
throw new Error(`Navigation timeout after ${timeoutMs}ms for ${url}`);
|
|
72
|
+
}
|
|
73
|
+
return this.getPageMeta(sessionId);
|
|
74
|
+
}
|
|
75
|
+
async locate(sessionId, target, timeoutMs) {
|
|
76
|
+
const state = this.requireSession(sessionId);
|
|
77
|
+
const deadline = Date.now() + timeoutMs;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
const result = await this.evaluateJson(state, buildLocateExpression(target));
|
|
80
|
+
if (result) {
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
await sleep(POLL_INTERVAL_MS);
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
async click(sessionId, element) {
|
|
88
|
+
const state = this.requireSession(sessionId);
|
|
89
|
+
const response = await this.evaluateJson(state, buildClickExpression(element.handleId));
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(response.reason ?? "Click failed");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async setValue(sessionId, element, text, clear) {
|
|
95
|
+
const state = this.requireSession(sessionId);
|
|
96
|
+
const response = await this.evaluateJson(state, buildSetValueExpression(element.handleId, text, clear));
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(response.reason ?? "setValue failed");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async getValue(sessionId, element) {
|
|
102
|
+
const state = this.requireSession(sessionId);
|
|
103
|
+
return this.evaluateJson(state, buildGetValueExpression(element.handleId));
|
|
104
|
+
}
|
|
105
|
+
async waitFor(sessionId, condition, timeoutMs) {
|
|
106
|
+
const state = this.requireSession(sessionId);
|
|
107
|
+
return this.waitUntil(timeoutMs, async () => this.checkCondition(state, condition));
|
|
108
|
+
}
|
|
109
|
+
async extract(sessionId, target, format) {
|
|
110
|
+
const state = this.requireSession(sessionId);
|
|
111
|
+
const element = await this.locate(sessionId, target, 1500);
|
|
112
|
+
if (!element) {
|
|
113
|
+
throw new Error("Extract target not found");
|
|
114
|
+
}
|
|
115
|
+
return this.evaluateJson(state, buildExtractExpression(element.handleId, format));
|
|
116
|
+
}
|
|
117
|
+
async screenshot(sessionId, region) {
|
|
118
|
+
const state = this.requireSession(sessionId);
|
|
119
|
+
const screenshotDir = path.resolve(this.options.screenshotDir ?? path.join(process.cwd(), ".artifacts", "screenshots"));
|
|
120
|
+
await mkdir(screenshotDir, { recursive: true });
|
|
121
|
+
const captureParams = {
|
|
122
|
+
format: "png",
|
|
123
|
+
captureBeyondViewport: true,
|
|
124
|
+
};
|
|
125
|
+
if (region) {
|
|
126
|
+
captureParams.clip = {
|
|
127
|
+
x: region.x,
|
|
128
|
+
y: region.y,
|
|
129
|
+
width: region.width,
|
|
130
|
+
height: region.height,
|
|
131
|
+
scale: 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const shot = await state.client.Page.captureScreenshot(captureParams);
|
|
135
|
+
if (!shot.data) {
|
|
136
|
+
throw new Error("Screenshot capture returned empty data");
|
|
137
|
+
}
|
|
138
|
+
const filePath = path.join(screenshotDir, `shot_${new Date().toISOString().replaceAll(":", "-")}_${randomUUID()}.png`);
|
|
139
|
+
await writeFile(filePath, Buffer.from(shot.data, "base64"));
|
|
140
|
+
return filePath;
|
|
141
|
+
}
|
|
142
|
+
async checkCondition(state, condition) {
|
|
143
|
+
switch (condition.type) {
|
|
144
|
+
case "selector_visible":
|
|
145
|
+
return this.evaluateJson(state, buildSelectorVisibilityExpression(condition.selector, true));
|
|
146
|
+
case "selector_hidden":
|
|
147
|
+
case "spinner_disappears":
|
|
148
|
+
return this.evaluateJson(state, buildSelectorVisibilityExpression(condition.selector, false));
|
|
149
|
+
case "text_appears":
|
|
150
|
+
return this.evaluateJson(state, buildTextAppearsExpression(condition.text));
|
|
151
|
+
case "url_matches": {
|
|
152
|
+
const page = await this.getPageMeta(state.info.sessionId);
|
|
153
|
+
let regex;
|
|
154
|
+
try {
|
|
155
|
+
regex = new RegExp(condition.regex);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
throw new Error(`Invalid regex "${condition.regex}": ${error instanceof Error ? error.message : "unknown error"}`);
|
|
159
|
+
}
|
|
160
|
+
return regex.test(page.url);
|
|
161
|
+
}
|
|
162
|
+
case "element_exists":
|
|
163
|
+
case "element_gone":
|
|
164
|
+
case "window_title_matches":
|
|
165
|
+
case "app_idle":
|
|
166
|
+
// Desktop-specific conditions not supported by CDP adapter
|
|
167
|
+
throw new Error(`Condition type "${condition.type}" not supported by CDP adapter`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async waitUntil(timeoutMs, predicate) {
|
|
171
|
+
const deadline = Date.now() + timeoutMs;
|
|
172
|
+
while (Date.now() < deadline) {
|
|
173
|
+
if (await predicate()) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
await sleep(POLL_INTERVAL_MS);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
async evaluateJson(state, expression) {
|
|
181
|
+
const result = await state.client.Runtime.evaluate({
|
|
182
|
+
expression,
|
|
183
|
+
awaitPromise: true,
|
|
184
|
+
returnByValue: true,
|
|
185
|
+
});
|
|
186
|
+
if (result.exceptionDetails) {
|
|
187
|
+
const description = result.exceptionDetails.exception?.description;
|
|
188
|
+
throw new Error(description ?? "Runtime.evaluate exception");
|
|
189
|
+
}
|
|
190
|
+
return result.result.value;
|
|
191
|
+
}
|
|
192
|
+
requireSession(sessionId) {
|
|
193
|
+
const session = this.sessions.get(sessionId);
|
|
194
|
+
if (!session) {
|
|
195
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
196
|
+
}
|
|
197
|
+
return session;
|
|
198
|
+
}
|
|
199
|
+
buildChromeFlags() {
|
|
200
|
+
const flags = [
|
|
201
|
+
"--remote-allow-origins=*",
|
|
202
|
+
"--no-first-run",
|
|
203
|
+
"--no-default-browser-check",
|
|
204
|
+
"--disable-background-networking",
|
|
205
|
+
"--disable-background-timer-throttling",
|
|
206
|
+
"--disable-backgrounding-occluded-windows",
|
|
207
|
+
"--disable-renderer-backgrounding",
|
|
208
|
+
];
|
|
209
|
+
if (this.options.headless) {
|
|
210
|
+
flags.push("--headless=new");
|
|
211
|
+
}
|
|
212
|
+
return flags;
|
|
213
|
+
}
|
|
214
|
+
async resolveTargetId(port) {
|
|
215
|
+
const targets = await CDP.List({ port });
|
|
216
|
+
const pageTarget = targets.find((target) => target.type === "page");
|
|
217
|
+
if (pageTarget?.id) {
|
|
218
|
+
return pageTarget.id;
|
|
219
|
+
}
|
|
220
|
+
const created = await CDP.New({ port });
|
|
221
|
+
if (typeof created === "string") {
|
|
222
|
+
return created;
|
|
223
|
+
}
|
|
224
|
+
if (created && typeof created.id === "string") {
|
|
225
|
+
return created.id;
|
|
226
|
+
}
|
|
227
|
+
throw new Error("Could not create a page target for CDP");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function resolveChromePath() {
|
|
231
|
+
const envPath = process.env.CHROME_PATH;
|
|
232
|
+
if (envPath && existsSync(envPath)) {
|
|
233
|
+
return envPath;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const discovered = getChromePath();
|
|
237
|
+
if (discovered && existsSync(discovered)) {
|
|
238
|
+
return discovered;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Fall through to fixed-path probes below.
|
|
243
|
+
}
|
|
244
|
+
const candidates = [
|
|
245
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
246
|
+
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
247
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
248
|
+
];
|
|
249
|
+
for (const candidate of candidates) {
|
|
250
|
+
if (existsSync(candidate)) {
|
|
251
|
+
return candidate;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw new Error("Chrome executable not found. Set CHROME_PATH or install Google Chrome.");
|
|
255
|
+
}
|
|
256
|
+
function escapeForAttribute(value) {
|
|
257
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
258
|
+
}
|
|
259
|
+
function buildLocateExpression(target) {
|
|
260
|
+
const encodedTarget = JSON.stringify(target);
|
|
261
|
+
return `
|
|
262
|
+
(() => {
|
|
263
|
+
const HANDLE_ATTR = "${HANDLE_ATTR}";
|
|
264
|
+
const target = ${encodedTarget};
|
|
265
|
+
const normalize = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
|
|
266
|
+
const lower = (value) => normalize(value).toLowerCase();
|
|
267
|
+
const isVisible = (element) => {
|
|
268
|
+
if (!(element instanceof Element)) return false;
|
|
269
|
+
const style = window.getComputedStyle(element);
|
|
270
|
+
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const rect = element.getBoundingClientRect();
|
|
274
|
+
return rect.width > 0 && rect.height > 0;
|
|
275
|
+
};
|
|
276
|
+
const implicitRole = (element) => {
|
|
277
|
+
if (!(element instanceof Element)) return "";
|
|
278
|
+
const explicit = element.getAttribute("role");
|
|
279
|
+
if (explicit) return explicit;
|
|
280
|
+
const tag = element.tagName.toLowerCase();
|
|
281
|
+
if (tag === "button") return "button";
|
|
282
|
+
if (tag === "a" && element.hasAttribute("href")) return "link";
|
|
283
|
+
if (tag === "input") {
|
|
284
|
+
const type = (element.getAttribute("type") || "text").toLowerCase();
|
|
285
|
+
if (type === "submit" || type === "button") return "button";
|
|
286
|
+
if (type === "checkbox") return "checkbox";
|
|
287
|
+
if (type === "radio") return "radio";
|
|
288
|
+
return "textbox";
|
|
289
|
+
}
|
|
290
|
+
if (tag === "textarea") return "textbox";
|
|
291
|
+
if (tag === "select") return "combobox";
|
|
292
|
+
return "";
|
|
293
|
+
};
|
|
294
|
+
const nameFor = (element) => {
|
|
295
|
+
if (!(element instanceof Element)) return "";
|
|
296
|
+
const aria = element.getAttribute("aria-label");
|
|
297
|
+
if (aria) return aria;
|
|
298
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
299
|
+
return element.value || element.placeholder || "";
|
|
300
|
+
}
|
|
301
|
+
return element.textContent || "";
|
|
302
|
+
};
|
|
303
|
+
const assignHandle = (element, locatorUsed) => {
|
|
304
|
+
let handle = element.getAttribute(HANDLE_ATTR);
|
|
305
|
+
if (!handle) {
|
|
306
|
+
handle = "ah_" + Math.random().toString(36).slice(2, 10);
|
|
307
|
+
element.setAttribute(HANDLE_ATTR, handle);
|
|
308
|
+
}
|
|
309
|
+
return { handleId: handle, locatorUsed };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
if (target.type === "selector") {
|
|
313
|
+
const element = document.querySelector(target.value);
|
|
314
|
+
if (element && isVisible(element)) return assignHandle(element, target.value);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const pool = Array.from(
|
|
319
|
+
document.querySelectorAll("button,a,input,textarea,select,[role],label,[aria-label],span,div")
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (target.type === "text") {
|
|
323
|
+
const wanted = lower(target.value);
|
|
324
|
+
for (const element of pool) {
|
|
325
|
+
if (!isVisible(element)) continue;
|
|
326
|
+
const text = lower(nameFor(element));
|
|
327
|
+
if (!text) continue;
|
|
328
|
+
const matched = target.exact ? text === wanted : text.includes(wanted);
|
|
329
|
+
if (matched) return assignHandle(element, "text:" + target.value);
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const wantedRole = lower(target.role);
|
|
335
|
+
const wantedName = lower(target.name);
|
|
336
|
+
for (const element of pool) {
|
|
337
|
+
if (!isVisible(element)) continue;
|
|
338
|
+
if (lower(implicitRole(element)) !== wantedRole) continue;
|
|
339
|
+
const elementName = lower(nameFor(element));
|
|
340
|
+
if (!elementName) continue;
|
|
341
|
+
const matched = target.exact ? elementName === wantedName : elementName.includes(wantedName);
|
|
342
|
+
if (matched) return assignHandle(element, "role:" + target.role + "|name:" + target.name);
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
})()
|
|
346
|
+
`.trim();
|
|
347
|
+
}
|
|
348
|
+
function buildClickExpression(handleId) {
|
|
349
|
+
const safeHandle = escapeForAttribute(handleId);
|
|
350
|
+
return `
|
|
351
|
+
(() => {
|
|
352
|
+
const selector = '[${HANDLE_ATTR}="${safeHandle}"]';
|
|
353
|
+
const element = document.querySelector(selector);
|
|
354
|
+
if (!(element instanceof Element)) {
|
|
355
|
+
return { ok: false, reason: "ELEMENT_NOT_FOUND" };
|
|
356
|
+
}
|
|
357
|
+
element.scrollIntoView({ block: "center", inline: "center" });
|
|
358
|
+
if (element instanceof HTMLElement) {
|
|
359
|
+
element.click();
|
|
360
|
+
} else {
|
|
361
|
+
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
362
|
+
}
|
|
363
|
+
return { ok: true };
|
|
364
|
+
})()
|
|
365
|
+
`.trim();
|
|
366
|
+
}
|
|
367
|
+
function buildSetValueExpression(handleId, text, clear) {
|
|
368
|
+
const safeHandle = escapeForAttribute(handleId);
|
|
369
|
+
const safeText = JSON.stringify(text);
|
|
370
|
+
return `
|
|
371
|
+
(() => {
|
|
372
|
+
const selector = '[${HANDLE_ATTR}="${safeHandle}"]';
|
|
373
|
+
const element = document.querySelector(selector);
|
|
374
|
+
if (!(element instanceof Element)) {
|
|
375
|
+
return { ok: false, reason: "ELEMENT_NOT_FOUND" };
|
|
376
|
+
}
|
|
377
|
+
const text = ${safeText};
|
|
378
|
+
const clear = ${clear ? "true" : "false"};
|
|
379
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
380
|
+
element.focus();
|
|
381
|
+
if (clear) element.value = "";
|
|
382
|
+
element.value = text;
|
|
383
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
384
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
385
|
+
return { ok: true };
|
|
386
|
+
}
|
|
387
|
+
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
388
|
+
element.focus();
|
|
389
|
+
if (clear) element.textContent = "";
|
|
390
|
+
element.textContent = text;
|
|
391
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
392
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
393
|
+
return { ok: true };
|
|
394
|
+
}
|
|
395
|
+
return { ok: false, reason: "UNSUPPORTED_ELEMENT_FOR_SET_VALUE" };
|
|
396
|
+
})()
|
|
397
|
+
`.trim();
|
|
398
|
+
}
|
|
399
|
+
function buildGetValueExpression(handleId) {
|
|
400
|
+
const safeHandle = escapeForAttribute(handleId);
|
|
401
|
+
return `
|
|
402
|
+
(() => {
|
|
403
|
+
const selector = '[${HANDLE_ATTR}="${safeHandle}"]';
|
|
404
|
+
const element = document.querySelector(selector);
|
|
405
|
+
if (!(element instanceof Element)) {
|
|
406
|
+
throw new Error("ELEMENT_NOT_FOUND");
|
|
407
|
+
}
|
|
408
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
409
|
+
return String(element.value ?? "");
|
|
410
|
+
}
|
|
411
|
+
return String(element.textContent ?? "");
|
|
412
|
+
})()
|
|
413
|
+
`.trim();
|
|
414
|
+
}
|
|
415
|
+
function buildSelectorVisibilityExpression(selector, shouldBeVisible) {
|
|
416
|
+
const safeSelector = JSON.stringify(selector);
|
|
417
|
+
return `
|
|
418
|
+
(() => {
|
|
419
|
+
const selector = ${safeSelector};
|
|
420
|
+
const element = document.querySelector(selector);
|
|
421
|
+
const isVisible = (node) => {
|
|
422
|
+
if (!(node instanceof Element)) return false;
|
|
423
|
+
const style = window.getComputedStyle(node);
|
|
424
|
+
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
const rect = node.getBoundingClientRect();
|
|
428
|
+
return rect.width > 0 && rect.height > 0;
|
|
429
|
+
};
|
|
430
|
+
const visible = isVisible(element);
|
|
431
|
+
return ${shouldBeVisible ? "visible" : "!visible || !element"};
|
|
432
|
+
})()
|
|
433
|
+
`.trim();
|
|
434
|
+
}
|
|
435
|
+
function buildTextAppearsExpression(text) {
|
|
436
|
+
const safeText = JSON.stringify(text);
|
|
437
|
+
return `
|
|
438
|
+
(() => {
|
|
439
|
+
const wanted = String(${safeText}).toLowerCase();
|
|
440
|
+
const pageText = String(document.body?.innerText || "").toLowerCase();
|
|
441
|
+
return pageText.includes(wanted);
|
|
442
|
+
})()
|
|
443
|
+
`.trim();
|
|
444
|
+
}
|
|
445
|
+
function buildExtractExpression(handleId, format) {
|
|
446
|
+
const safeHandle = escapeForAttribute(handleId);
|
|
447
|
+
if (format === "text") {
|
|
448
|
+
return `
|
|
449
|
+
(() => {
|
|
450
|
+
const selector = '[${HANDLE_ATTR}="${safeHandle}"]';
|
|
451
|
+
const element = document.querySelector(selector);
|
|
452
|
+
if (!(element instanceof Element)) {
|
|
453
|
+
throw new Error("ELEMENT_NOT_FOUND");
|
|
454
|
+
}
|
|
455
|
+
return String(element.textContent || "").trim();
|
|
456
|
+
})()
|
|
457
|
+
`.trim();
|
|
458
|
+
}
|
|
459
|
+
if (format === "table") {
|
|
460
|
+
return `
|
|
461
|
+
(() => {
|
|
462
|
+
const selector = '[${HANDLE_ATTR}="${safeHandle}"]';
|
|
463
|
+
const element = document.querySelector(selector);
|
|
464
|
+
if (!(element instanceof Element)) {
|
|
465
|
+
throw new Error("ELEMENT_NOT_FOUND");
|
|
466
|
+
}
|
|
467
|
+
const table = element.tagName.toLowerCase() === "table" ? element : element.closest("table");
|
|
468
|
+
if (!(table instanceof HTMLTableElement)) {
|
|
469
|
+
throw new Error("TARGET_IS_NOT_A_TABLE");
|
|
470
|
+
}
|
|
471
|
+
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
|
|
472
|
+
String(th.textContent || "").trim()
|
|
473
|
+
);
|
|
474
|
+
const rows = Array.from(table.querySelectorAll("tbody tr"))
|
|
475
|
+
.map((row) =>
|
|
476
|
+
Array.from(row.querySelectorAll("th,td")).map((cell) =>
|
|
477
|
+
String(cell.textContent || "").trim()
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
.filter((row) => row.length > 0);
|
|
481
|
+
return { headers, rows };
|
|
482
|
+
})()
|
|
483
|
+
`.trim();
|
|
484
|
+
}
|
|
485
|
+
return `
|
|
486
|
+
(() => {
|
|
487
|
+
const selector = '[${HANDLE_ATTR}="${safeHandle}"]';
|
|
488
|
+
const element = document.querySelector(selector);
|
|
489
|
+
if (!(element instanceof Element)) {
|
|
490
|
+
throw new Error("ELEMENT_NOT_FOUND");
|
|
491
|
+
}
|
|
492
|
+
const raw = String(element.textContent || "").trim();
|
|
493
|
+
try {
|
|
494
|
+
return JSON.parse(raw);
|
|
495
|
+
} catch {
|
|
496
|
+
return { raw };
|
|
497
|
+
}
|
|
498
|
+
})()
|
|
499
|
+
`.trim();
|
|
500
|
+
}
|
|
501
|
+
function sleep(ms) {
|
|
502
|
+
return new Promise((resolve) => {
|
|
503
|
+
setTimeout(resolve, ms);
|
|
504
|
+
});
|
|
505
|
+
}
|