@xbrowser/cli 1.21.0 → 1.22.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/README.md +1 -0
- package/dist/{browser-CNBSH53B.js → browser-BVRFSUXU.js} +3 -2
- package/dist/{browser-C2VEOJC2.js → browser-DSYUAE7H.js} +5 -4
- package/dist/{browser-VZOOFLAH.js → browser-Q67BJTI2.js} +4 -3
- package/dist/{cdp-driver-KFX4A6WR.js → cdp-driver-DC7EIUG5.js} +4 -3
- package/dist/{cdp-driver-7Z6ZAT5E.js → cdp-driver-T7D6H2RW.js} +335 -546
- package/dist/{cdp-driver-26LGCHXV.js → cdp-driver-WWO2UZDR.js} +3 -2
- package/dist/{chunk-Z5RTK4GW.js → chunk-2Q3GQRUP.js} +2 -2
- package/dist/{chunk-CLYZPSDR.js → chunk-4OU37CXP.js} +354 -576
- package/dist/{chunk-HWWG2ISJ.js → chunk-7OP2ISEY.js} +353 -575
- package/dist/{chunk-3FWLW7FS.js → chunk-A7NEA4PA.js} +39 -1
- package/dist/chunk-GYB5BOSP.js +602 -0
- package/dist/{chunk-ESJYANQO.js → chunk-IFKJO2UR.js} +360 -582
- package/dist/{chunk-NODRQGOK.js → chunk-IR6C24P7.js} +97 -0
- package/dist/{chunk-3KZ34AUC.js → chunk-MYH6FIBZ.js} +2 -1
- package/dist/{chunk-AVSTNODR.js → chunk-T4WTKBWI.js} +2 -2
- package/dist/{chunk-HBHOKZPN.js → chunk-V6KGBU5J.js} +97 -0
- package/dist/{chunk-IWVG4XYZ.js → chunk-VCVDILH6.js} +2 -1
- package/dist/cli.js +25 -13
- package/dist/daemon-main.js +20 -14
- package/dist/index.d.ts +37 -1
- package/dist/index.js +26 -15
- package/dist/keep-awake-M4KJS4B4.js +29 -0
- package/dist/{launcher-44STYRJC.js → launcher-IK2FKW6T.js} +1 -1
- package/dist/{launcher-EXJHHAUL.js → launcher-LUOMMGZC.js} +1 -1
- package/dist/{session-recorder-SLDBENVF.js → session-recorder-563ISSJK.js} +1 -1
- package/dist/{session-recorder-3BEVWHOK.js → session-recorder-GEZVQBKW.js} +1 -1
- package/dist/session-replayer-XM5L7LFD.js +927 -0
- package/dist/stealth-2AA2FSNN.js +29 -0
- package/dist/stealth-JOXGV34D.js +29 -0
- package/package.json +1 -1
- package/dist/session-replayer-EVD4HZBB.js +0 -413
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
import {
|
|
2
|
+
queryAllDeepJS,
|
|
3
|
+
queryJS
|
|
4
|
+
} from "./chunk-A7NEA4PA.js";
|
|
5
|
+
import "./chunk-KFQGP6VL.js";
|
|
6
|
+
|
|
7
|
+
// src/recorder/session-replayer.ts
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
var HEAL_KB_TTL_DAYS = 30;
|
|
12
|
+
var NATIVE_VALUE_INJECT_TYPES = /* @__PURE__ */ new Set([
|
|
13
|
+
"color",
|
|
14
|
+
"date",
|
|
15
|
+
"datetime-local",
|
|
16
|
+
"month",
|
|
17
|
+
"week",
|
|
18
|
+
"time"
|
|
19
|
+
]);
|
|
20
|
+
var SessionReplayer = class {
|
|
21
|
+
opts;
|
|
22
|
+
recording = null;
|
|
23
|
+
page = null;
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.opts = {
|
|
26
|
+
cdpUrl: opts.cdpUrl,
|
|
27
|
+
page: opts.page,
|
|
28
|
+
stepDelay: opts.stepDelay ?? 500,
|
|
29
|
+
stepTimeout: opts.stepTimeout ?? 1e4,
|
|
30
|
+
onStep: opts.onStep,
|
|
31
|
+
onError: opts.onError,
|
|
32
|
+
selfHealing: opts.selfHealing !== false,
|
|
33
|
+
onHealed: opts.onHealed,
|
|
34
|
+
healKnowledgeDir: opts.healKnowledgeDir ?? join(homedir(), ".xbrowser", "knowledge"),
|
|
35
|
+
actionRetry: opts.actionRetry ?? true
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Load a recording from a file path or parsed JSON */
|
|
39
|
+
async load(source) {
|
|
40
|
+
if (typeof source === "string") {
|
|
41
|
+
const fs = await import("fs");
|
|
42
|
+
const raw = fs.readFileSync(source, "utf8");
|
|
43
|
+
this.recording = JSON.parse(raw);
|
|
44
|
+
} else {
|
|
45
|
+
this.recording = source;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Run the full replay */
|
|
49
|
+
async run() {
|
|
50
|
+
if (!this.recording) throw new Error("No recording loaded. Call load() first.");
|
|
51
|
+
const { startKeepAwake } = await import("./keep-awake-M4KJS4B4.js");
|
|
52
|
+
const keepAwake = startKeepAwake();
|
|
53
|
+
try {
|
|
54
|
+
return await this._runInner();
|
|
55
|
+
} finally {
|
|
56
|
+
keepAwake.dispose();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async _runInner() {
|
|
60
|
+
if (!this.recording) throw new Error("No recording loaded. Call load() first.");
|
|
61
|
+
if (this.opts.page) {
|
|
62
|
+
this.page = this.opts.page;
|
|
63
|
+
} else if (this.opts.cdpUrl) {
|
|
64
|
+
const { launch } = await import("./cdp-driver-DC7EIUG5.js");
|
|
65
|
+
const { browser } = await launch({ cdpEndpoint: this.opts.cdpUrl });
|
|
66
|
+
let contexts = browser.contexts();
|
|
67
|
+
for (let i = 0; i < 10 && contexts.length === 0; i++) {
|
|
68
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
69
|
+
contexts = browser.contexts();
|
|
70
|
+
}
|
|
71
|
+
const context = contexts[0];
|
|
72
|
+
if (!context) throw new Error("No browser context available");
|
|
73
|
+
const pages = context.pages();
|
|
74
|
+
this.page = pages[0];
|
|
75
|
+
}
|
|
76
|
+
if (!this.page) throw new Error("No page available. Provide cdpUrl or page.");
|
|
77
|
+
const actions = this.dedupAdjacentActions(this.recording.actions);
|
|
78
|
+
let success = 0;
|
|
79
|
+
let failed = 0;
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
let retried = 0;
|
|
82
|
+
const healedDetails = [];
|
|
83
|
+
for (let i = 0; i < actions.length; i++) {
|
|
84
|
+
const action = actions[i];
|
|
85
|
+
this.opts.onStep?.(action, i, actions.length);
|
|
86
|
+
const pagesBefore = this.listContextPages();
|
|
87
|
+
const prevOnHealed = this.opts.onHealed;
|
|
88
|
+
this.opts.onHealed = (a, strategy, idx) => {
|
|
89
|
+
healedDetails.push({ index: i, strategy });
|
|
90
|
+
prevOnHealed?.(a, strategy, idx);
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
if (action.trajectory) {
|
|
94
|
+
await this.replayTrajectory(action.trajectory);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await this.replayAction(action);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (this.opts.actionRetry === false || action.type === "navigation" || action.type === "goto") {
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
retried++;
|
|
103
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
104
|
+
await this.replayAction(action);
|
|
105
|
+
}
|
|
106
|
+
if (action.type !== "resize" && action.type !== "clipboard" && action.type !== "visibility") {
|
|
107
|
+
try {
|
|
108
|
+
await this.page.waitForLoadState("domcontentloaded", this.opts.stepTimeout);
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
success++;
|
|
113
|
+
if (action.type === "click" || action.type === "cdp-click") {
|
|
114
|
+
const pagesAfter = this.listContextPages();
|
|
115
|
+
if (pagesAfter && pagesBefore && pagesAfter.length > pagesBefore.length) {
|
|
116
|
+
const newest = pagesAfter[pagesAfter.length - 1];
|
|
117
|
+
await newest.bringToFront().catch(() => {
|
|
118
|
+
});
|
|
119
|
+
this.page = newest;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
124
|
+
this.opts.onError?.(action, err, i);
|
|
125
|
+
failed++;
|
|
126
|
+
} finally {
|
|
127
|
+
this.opts.onHealed = prevOnHealed;
|
|
128
|
+
}
|
|
129
|
+
if (i < actions.length - 1) {
|
|
130
|
+
await new Promise((r) => setTimeout(r, this.opts.stepDelay));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
success,
|
|
135
|
+
failed,
|
|
136
|
+
skipped,
|
|
137
|
+
retried,
|
|
138
|
+
healed: healedDetails.length,
|
|
139
|
+
healedDetails
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Replay a single action */
|
|
143
|
+
/** Pages of the replayer page's context (best-effort; null if unavailable). */
|
|
144
|
+
listContextPages() {
|
|
145
|
+
try {
|
|
146
|
+
const ctx = this.page.context?.();
|
|
147
|
+
const pages = ctx?.pages?.();
|
|
148
|
+
return Array.isArray(pages) ? pages : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Replay-time adjacent dedup (rec-duel d02/d03/d05).
|
|
155
|
+
*
|
|
156
|
+
* The recorder can emit both the real action signal AND the injected cdp
|
|
157
|
+
* command action for a single interaction when the signal flush lags behind
|
|
158
|
+
* the recorder-side dedup window (heavy snapshot capture slows polling).
|
|
159
|
+
* Replaying both executes the interaction twice. Filter here: an action is
|
|
160
|
+
* skipped when a nearby (≤15s apart) earlier action matches on
|
|
161
|
+
* type-normalized key (selector + text) AND coordinates agree — either side
|
|
162
|
+
* lacks coords (cdp actions carry none) or both are within 30px. Two real
|
|
163
|
+
* clicks on the same element at different spots (canvas buttons) survive.
|
|
164
|
+
*/
|
|
165
|
+
dedupAdjacentActions(actions) {
|
|
166
|
+
const normType = (t) => t === "cdp-click" ? "click" : t === "cdp-fill" ? "input" : t;
|
|
167
|
+
const generic = (a) => (a.type === "click" || a.type === "dblclick" || a.type === "contextmenu") && (a.element?.selector === "html" || a.element?.selector === "body");
|
|
168
|
+
const keyOf = (a) => `${normType(a.type)}|${a.element?.selector || ""}|${a.element?.text || ""}`;
|
|
169
|
+
const coordsOf = (a) => typeof a.x === "number" && typeof a.y === "number" ? { x: a.x, y: a.y } : null;
|
|
170
|
+
const near = (p, q) => Math.abs(p.x - q.x) <= 30 && Math.abs(p.y - q.y) <= 30;
|
|
171
|
+
const lastKept = /* @__PURE__ */ new Map();
|
|
172
|
+
return actions.filter((a) => {
|
|
173
|
+
if (generic(a)) return false;
|
|
174
|
+
const key = keyOf(a);
|
|
175
|
+
const c = coordsOf(a);
|
|
176
|
+
const recent = lastKept.get(key) || [];
|
|
177
|
+
const dup = recent.some((e) => {
|
|
178
|
+
if (Math.abs(a.timestamp - e.ts) > 2500) return false;
|
|
179
|
+
if (!c || !e.c) return true;
|
|
180
|
+
return near(c, e.c);
|
|
181
|
+
});
|
|
182
|
+
if (dup) return false;
|
|
183
|
+
recent.push({ ts: a.timestamp, c });
|
|
184
|
+
lastKept.set(key, recent);
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async replayAction(action) {
|
|
189
|
+
const page = this.page;
|
|
190
|
+
const timeout = this.opts.stepTimeout;
|
|
191
|
+
switch (action.type) {
|
|
192
|
+
// Proactive sensing actions are observations, not user actions — skip
|
|
193
|
+
// them during replay (they don't represent anything the user did).
|
|
194
|
+
case "popup_appear":
|
|
195
|
+
case "discovered_filters":
|
|
196
|
+
return;
|
|
197
|
+
case "navigation":
|
|
198
|
+
await page.goto(action.url, { waitUntil: "load", timeout });
|
|
199
|
+
break;
|
|
200
|
+
case "goto":
|
|
201
|
+
await page.goto(action.url, { waitUntil: "load", timeout });
|
|
202
|
+
break;
|
|
203
|
+
case "click":
|
|
204
|
+
case "cdp-click": {
|
|
205
|
+
const selector = await this.resolveAndWait(action);
|
|
206
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
207
|
+
const hit = await page.evaluate(`
|
|
208
|
+
(function() {
|
|
209
|
+
const el = ${queryJS(selector)};
|
|
210
|
+
if (!el) return false;
|
|
211
|
+
const r = el.getBoundingClientRect();
|
|
212
|
+
return ${action.x} >= r.x - 2 && ${action.x} <= r.x + r.width + 2
|
|
213
|
+
&& ${action.y} >= r.y - 2 && ${action.y} <= r.y + r.height + 2;
|
|
214
|
+
})()
|
|
215
|
+
`).catch(() => false);
|
|
216
|
+
if (hit) {
|
|
217
|
+
await page.mouse.click(action.x, action.y, { stealth: true });
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await page.click(selector, { timeout });
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "scroll": {
|
|
225
|
+
const [dir, distStr] = (action.value || "down:300").split(":");
|
|
226
|
+
const dist = Number(distStr) || 300;
|
|
227
|
+
const sign = dir === "up" ? -1 : dir === "left" ? 0 : 1;
|
|
228
|
+
const selector = await this.resolveAndWait(action).catch(() => void 0);
|
|
229
|
+
if (selector) {
|
|
230
|
+
await page.evaluate(`
|
|
231
|
+
(function() {
|
|
232
|
+
const el = ${queryJS(selector)};
|
|
233
|
+
if (el) el.scrollTop += ${sign * dist};
|
|
234
|
+
})()
|
|
235
|
+
`).catch(() => {
|
|
236
|
+
});
|
|
237
|
+
} else {
|
|
238
|
+
await page.evaluate(`window.scrollBy(0, ${sign * dist})`).catch(() => {
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "input": {
|
|
244
|
+
const selector = await this.resolveAndWait(action);
|
|
245
|
+
if (action.element?.type && NATIVE_VALUE_INJECT_TYPES.has(action.element.type)) {
|
|
246
|
+
await page.evaluate(`
|
|
247
|
+
(function() {
|
|
248
|
+
var el = ${queryJS(selector)};
|
|
249
|
+
if (!el) return;
|
|
250
|
+
el.value = ${JSON.stringify(action.value ?? "")};
|
|
251
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
252
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
253
|
+
})()
|
|
254
|
+
`);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
await page.fill(selector, action.value ?? "", { timeout });
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "cdp-fill": {
|
|
261
|
+
const selector = await this.resolveAndWait(action);
|
|
262
|
+
if (action.element?.type && NATIVE_VALUE_INJECT_TYPES.has(action.element.type)) {
|
|
263
|
+
await page.evaluate(`
|
|
264
|
+
(function() {
|
|
265
|
+
var el = ${queryJS(selector)};
|
|
266
|
+
if (!el) return;
|
|
267
|
+
el.value = ${JSON.stringify(action.value ?? "")};
|
|
268
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
269
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
270
|
+
})()
|
|
271
|
+
`);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
await page.fill(selector, action.value ?? "", { timeout });
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "change": {
|
|
278
|
+
const selector = await this.resolveAndWait(action);
|
|
279
|
+
if (action.value) {
|
|
280
|
+
await page.selectOption(selector, action.value);
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "filechooser": {
|
|
285
|
+
const selector = await this.resolveAndWait(action);
|
|
286
|
+
const files = this.resolveFiles(action);
|
|
287
|
+
if (files.length > 0) {
|
|
288
|
+
await page.setInputFiles(selector, files);
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "drop": {
|
|
293
|
+
const selector = await this.resolveAndWait(action);
|
|
294
|
+
const files = this.resolveFiles(action);
|
|
295
|
+
if (files.length > 0) {
|
|
296
|
+
await page.dropFiles(selector, files[0]);
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case "keydown": {
|
|
301
|
+
const key = action.key ?? "";
|
|
302
|
+
if (key === "Enter" || key === "Tab" || key === "Escape") {
|
|
303
|
+
await page.keyboard.press(key);
|
|
304
|
+
} else if (key === "Backspace") {
|
|
305
|
+
await page.keyboard.press("Backspace");
|
|
306
|
+
} else if (key === "Delete") {
|
|
307
|
+
await page.keyboard.press("Delete");
|
|
308
|
+
} else if (key.startsWith("Arrow")) {
|
|
309
|
+
await page.keyboard.press(key);
|
|
310
|
+
} else if (key.includes("+")) {
|
|
311
|
+
await page.keyboard.press(key.replace("Meta", "Meta").replace("Ctrl", "Control"));
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "dblclick": {
|
|
316
|
+
const selector = await this.resolveAndWait(action).catch(() => "");
|
|
317
|
+
if (selector) {
|
|
318
|
+
await page.dblclick(selector, { timeout });
|
|
319
|
+
} else if (action.x !== void 0 && action.y !== void 0) {
|
|
320
|
+
await page.mouse.dblclick(action.x, action.y);
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case "contextmenu": {
|
|
325
|
+
const selector = await this.resolveAndWait(action).catch(() => "");
|
|
326
|
+
if (selector) {
|
|
327
|
+
await page.click(selector, { button: "right", timeout });
|
|
328
|
+
} else if (action.x !== void 0 && action.y !== void 0) {
|
|
329
|
+
await page.mouse.click(action.x, action.y, { button: "right" });
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "hover": {
|
|
334
|
+
const selector = await this.resolveAndWait(action);
|
|
335
|
+
if (selector) {
|
|
336
|
+
await page.hover(selector);
|
|
337
|
+
}
|
|
338
|
+
const firstPopup = action.hoverContext?.appeared?.[0];
|
|
339
|
+
if (firstPopup?.selector) {
|
|
340
|
+
try {
|
|
341
|
+
await page.waitForSelector(firstPopup.selector, {
|
|
342
|
+
state: "visible",
|
|
343
|
+
timeout: 1e3
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "drag": {
|
|
351
|
+
if (action.drag) {
|
|
352
|
+
const { fromX, fromY, toX, toY } = action.drag;
|
|
353
|
+
await page.mouse.move(fromX, fromY);
|
|
354
|
+
await page.mouse.down();
|
|
355
|
+
const steps = 5;
|
|
356
|
+
for (let i = 1; i <= steps; i++) {
|
|
357
|
+
await page.mouse.move(
|
|
358
|
+
fromX + (toX - fromX) * i / steps,
|
|
359
|
+
fromY + (toY - fromY) * i / steps
|
|
360
|
+
);
|
|
361
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
362
|
+
}
|
|
363
|
+
await page.mouse.up();
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case "resize": {
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "clipboard": {
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "touch": {
|
|
374
|
+
if (action.touch) {
|
|
375
|
+
const { touchType, touches } = action.touch;
|
|
376
|
+
if (touchType === "start" && touches.length > 0) {
|
|
377
|
+
await page.mouse.move(touches[0].x, touches[0].y);
|
|
378
|
+
await page.mouse.down();
|
|
379
|
+
} else if (touchType === "end" && touches.length > 0) {
|
|
380
|
+
await page.mouse.move(touches[0].x, touches[0].y);
|
|
381
|
+
await page.mouse.up();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case "focus": {
|
|
387
|
+
const selector = await this.resolveAndWait(action);
|
|
388
|
+
if (action.focus?.focusType === "focus") {
|
|
389
|
+
await page.locator(selector).focus();
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "visibility": {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case "submit": {
|
|
397
|
+
const selector = await this.resolveAndWait(action);
|
|
398
|
+
await page.evaluate((sel) => {
|
|
399
|
+
const form = document.querySelector(sel);
|
|
400
|
+
if (!form) return;
|
|
401
|
+
if (typeof form.requestSubmit === "function") {
|
|
402
|
+
form.requestSubmit();
|
|
403
|
+
} else {
|
|
404
|
+
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
405
|
+
}
|
|
406
|
+
}, selector);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "scroll": {
|
|
410
|
+
await page.evaluate(() => {
|
|
411
|
+
window.scrollBy(action.scrollX ?? 0, action.scrollY ?? 0);
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
default:
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** Replay a mouse trajectory (smooth movement between actions) */
|
|
420
|
+
async replayTrajectory(trajectory) {
|
|
421
|
+
const page = this.page;
|
|
422
|
+
const { points } = trajectory;
|
|
423
|
+
if (!points || points.length < 2) return;
|
|
424
|
+
for (let i = 0; i < points.length; i++) {
|
|
425
|
+
const { x, y, dt } = points[i];
|
|
426
|
+
if (dt > 0) {
|
|
427
|
+
await new Promise((r) => setTimeout(r, Math.min(dt, 200)));
|
|
428
|
+
}
|
|
429
|
+
await page.mouse.move(x, y);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* S202: 自愈选择器解析——主选择器失败后,生成语义备选并逐级尝试。
|
|
434
|
+
*
|
|
435
|
+
* Fallback chain:
|
|
436
|
+
* 1. Primary selector + textFallback + tag (existing)
|
|
437
|
+
* 2. Partial id match (extract stable substring from original id)
|
|
438
|
+
* 3. Type-based (input[type=text] etc — survives id/class/name mutations)
|
|
439
|
+
* 4. Tag + positional (form input:nth-of-type(N))
|
|
440
|
+
* 5. Text content match (visible text contains a stable substring)
|
|
441
|
+
*/
|
|
442
|
+
async healResolve(action, primaryFailed) {
|
|
443
|
+
const page = this.page;
|
|
444
|
+
const el = action.element;
|
|
445
|
+
const timeout = Math.min(this.opts.stepTimeout, 5e3);
|
|
446
|
+
const domain = this.pageDomain(action);
|
|
447
|
+
if (this.opts.healKnowledgeDir) {
|
|
448
|
+
const known = this.lookupHealKnowledge(domain, primaryFailed[0] ?? "");
|
|
449
|
+
if (known) {
|
|
450
|
+
try {
|
|
451
|
+
await page.waitForSelector(known.healed, { state: "visible", timeout: Math.min(timeout, 2e3) });
|
|
452
|
+
const verdict = await this.verifyHealHit(action, known.healed);
|
|
453
|
+
if (verdict !== "hard") {
|
|
454
|
+
this.bumpHealKnowledge(domain, primaryFailed[0] ?? "");
|
|
455
|
+
return { selector: known.healed, strategy: "known-heal" };
|
|
456
|
+
}
|
|
457
|
+
this.forgetHealKnowledge(domain, primaryFailed[0] ?? "");
|
|
458
|
+
} catch {
|
|
459
|
+
this.forgetHealKnowledge(domain, primaryFailed[0] ?? "");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const coreId = this.extractSemanticCore(primaryFailed[0] || "");
|
|
464
|
+
const tagName = el?.tag || "input";
|
|
465
|
+
const candidates = [];
|
|
466
|
+
if (coreId) {
|
|
467
|
+
candidates.push({ sel: `[id*="${coreId}"]`, strategy: "partial-id" });
|
|
468
|
+
}
|
|
469
|
+
if (coreId) {
|
|
470
|
+
candidates.push({ sel: `[name*="${coreId}"]`, strategy: "partial-name" });
|
|
471
|
+
}
|
|
472
|
+
if (coreId) {
|
|
473
|
+
candidates.push({ sel: `[placeholder*="${coreId}"]`, strategy: "partial-placeholder" });
|
|
474
|
+
}
|
|
475
|
+
if (coreId) {
|
|
476
|
+
candidates.push({ sel: `[aria-label*="${coreId}"]`, strategy: "aria-label" });
|
|
477
|
+
candidates.push({ sel: `[data-testid*="${coreId}"]`, strategy: "data-testid" });
|
|
478
|
+
}
|
|
479
|
+
if (coreId && primaryFailed[0]?.trimStart().startsWith(".")) {
|
|
480
|
+
candidates.push({ sel: `[class*="${coreId}"]`, strategy: "partial-class" });
|
|
481
|
+
}
|
|
482
|
+
const anchorText = (el?.text || "").trim().replace(/"/g, "");
|
|
483
|
+
const isTextBearer = tagName === "button" || tagName === "a" || el?.role === "button" || el?.role === "link";
|
|
484
|
+
if (anchorText && isTextBearer) {
|
|
485
|
+
candidates.push({
|
|
486
|
+
sel: `xpath=//*[contains(normalize-space(.), "${anchorText}")][self::${tagName} or self::a or self::button or @role="button" or @role="link"]`,
|
|
487
|
+
strategy: "text-anchor"
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
const labelText = (el?.labelText || "").trim().replace(/"/g, "");
|
|
491
|
+
if (labelText) {
|
|
492
|
+
candidates.push({
|
|
493
|
+
sel: `xpath=//label[contains(normalize-space(.), "${labelText}")]//${tagName}`,
|
|
494
|
+
strategy: "label-anchor"
|
|
495
|
+
});
|
|
496
|
+
candidates.push({
|
|
497
|
+
sel: `xpath=//${tagName}[@id=(//label[contains(normalize-space(.), "${labelText}")]/@for)]`,
|
|
498
|
+
strategy: "label-for-anchor"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const rowText = (el?.rowText || "").trim().replace(/"/g, "");
|
|
502
|
+
if (rowText) {
|
|
503
|
+
candidates.push({
|
|
504
|
+
sel: `xpath=//tr[contains(normalize-space(.), "${rowText}")]//${tagName}`,
|
|
505
|
+
strategy: "row-anchor"
|
|
506
|
+
});
|
|
507
|
+
candidates.push({
|
|
508
|
+
sel: `xpath=//li[contains(normalize-space(.), "${rowText}")]//${tagName}`,
|
|
509
|
+
strategy: "row-anchor"
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (el?.type) {
|
|
513
|
+
candidates.push({ sel: `${tagName}[type="${el.type}"]`, strategy: "meta-type" });
|
|
514
|
+
}
|
|
515
|
+
if (el?.placeholder) {
|
|
516
|
+
candidates.push({ sel: `[placeholder="${el.placeholder}"]`, strategy: "meta-placeholder" });
|
|
517
|
+
}
|
|
518
|
+
if (el?.ariaLabel) {
|
|
519
|
+
candidates.push({ sel: `[aria-label="${el.ariaLabel}"]`, strategy: "meta-aria" });
|
|
520
|
+
}
|
|
521
|
+
const typeMap = {
|
|
522
|
+
username: "text",
|
|
523
|
+
password: "password",
|
|
524
|
+
email: "email",
|
|
525
|
+
search: "search",
|
|
526
|
+
phone: "tel",
|
|
527
|
+
url: "url"
|
|
528
|
+
};
|
|
529
|
+
if (typeMap[coreId]) {
|
|
530
|
+
candidates.push({ sel: `${tagName}[type="${typeMap[coreId]}"]`, strategy: "type-based" });
|
|
531
|
+
}
|
|
532
|
+
const uniqueTags = ["textarea", "select"];
|
|
533
|
+
if (uniqueTags.includes(tagName)) {
|
|
534
|
+
candidates.push({ sel: tagName, strategy: "tag-unique" });
|
|
535
|
+
}
|
|
536
|
+
const ord = el?.ordinal;
|
|
537
|
+
if (ord && ord.formNth > 0 && ord.tagNth > 0) {
|
|
538
|
+
candidates.push({
|
|
539
|
+
sel: `form:nth-of-type(${ord.formNth}) ${tagName}:nth-of-type(${ord.tagNth})`,
|
|
540
|
+
strategy: "ordinal"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
candidates.push({ sel: `form ${tagName}:first-of-type`, strategy: "tag-first" });
|
|
544
|
+
candidates.push({ sel: `form ${tagName}`, strategy: "tag-in-form" });
|
|
545
|
+
const blindPositional = /* @__PURE__ */ new Set(["tag-first", "tag-in-form"]);
|
|
546
|
+
const inList = (c) => !!c.sel && c.sel !== primaryFailed[0];
|
|
547
|
+
const semanticList = candidates.filter((c) => inList(c) && !blindPositional.has(c.strategy));
|
|
548
|
+
const blindList = candidates.filter((c) => inList(c) && blindPositional.has(c.strategy));
|
|
549
|
+
const tryProbe = async (list) => {
|
|
550
|
+
const probeSels = [...new Set(list.map((c) => c.sel))];
|
|
551
|
+
if (probeSels.length === 0) return null;
|
|
552
|
+
const probeExprs = JSON.stringify(probeSels.map((s) => queryAllDeepJS(s)));
|
|
553
|
+
const metaJson = JSON.stringify({
|
|
554
|
+
type: el?.type ?? null,
|
|
555
|
+
placeholder: el?.placeholder ?? null,
|
|
556
|
+
text: (el?.text || "").trim(),
|
|
557
|
+
labelText: (el?.labelText || "").trim(),
|
|
558
|
+
size: el?.size ?? null
|
|
559
|
+
});
|
|
560
|
+
const probeSrc = `
|
|
561
|
+
(function() {
|
|
562
|
+
var sels = ${JSON.stringify(probeSels)};
|
|
563
|
+
var meta = ${metaJson};
|
|
564
|
+
var pairs = ${probeExprs};
|
|
565
|
+
function pathOf(m) {
|
|
566
|
+
var root = m.getRootNode ? m.getRootNode() : null;
|
|
567
|
+
if (root && root.nodeType !== 9) return null; // \u9634\u5F71/iframe\u2014\u2014\u8DEF\u5F84\u4E0D\u8DE8\u754C\uFF0C\u9000\u56DE\u539F\u9009\u62E9\u5668
|
|
568
|
+
var parts = [];
|
|
569
|
+
var cur = m;
|
|
570
|
+
while (cur && cur !== document.body) {
|
|
571
|
+
var parent = cur.parentElement;
|
|
572
|
+
if (!parent) return null;
|
|
573
|
+
var same = Array.prototype.filter.call(parent.children, function(c) { return c.tagName === cur.tagName; });
|
|
574
|
+
parts.unshift(cur.tagName.toLowerCase() + ':nth-of-type(' + (same.indexOf(cur) + 1) + ')');
|
|
575
|
+
cur = parent;
|
|
576
|
+
}
|
|
577
|
+
return parts.length ? parts.join(' > ') : null;
|
|
578
|
+
}
|
|
579
|
+
function scoreMatch(m) {
|
|
580
|
+
var mt = m.getAttribute ? m.getAttribute('type') : null;
|
|
581
|
+
var mp = m.getAttribute ? m.getAttribute('placeholder') : null;
|
|
582
|
+
var mtext = (String(m.value ?? '') || m.textContent || '').trim().slice(0, 80);
|
|
583
|
+
if (meta.type && mt && mt !== meta.type) return -1;
|
|
584
|
+
if (meta.placeholder && mp && mp !== meta.placeholder) return -1;
|
|
585
|
+
var s = 10;
|
|
586
|
+
if (meta.text && mtext && mtext.indexOf(meta.text) === -1) s = 1;
|
|
587
|
+
if (meta.labelText) {
|
|
588
|
+
var label = '';
|
|
589
|
+
try {
|
|
590
|
+
var lb = m.closest ? m.closest('label') : null;
|
|
591
|
+
if (lb) label = (lb.textContent || '').trim();
|
|
592
|
+
} catch (e) {}
|
|
593
|
+
if (label && label.indexOf(meta.labelText) !== -1) s += 2;
|
|
594
|
+
}
|
|
595
|
+
if (meta.size) {
|
|
596
|
+
var r = m.getBoundingClientRect();
|
|
597
|
+
if (r.width > 0 && r.height > 0) {
|
|
598
|
+
var dw = Math.abs(r.width - meta.size.w) / Math.max(meta.size.w, 1);
|
|
599
|
+
var dh = Math.abs(r.height - meta.size.h) / Math.max(meta.size.h, 1);
|
|
600
|
+
if (dw <= 0.4 && dh <= 0.4) s += 1;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return s;
|
|
604
|
+
}
|
|
605
|
+
var best = null;
|
|
606
|
+
for (var i = 0; i < sels.length; i++) {
|
|
607
|
+
var matches = [];
|
|
608
|
+
try { matches = (new Function('return (' + pairs[i] + ')'))() || []; } catch (e) { continue; }
|
|
609
|
+
var cap = Math.min(matches.length, 20);
|
|
610
|
+
var candBest = null;
|
|
611
|
+
for (var k = 0; k < cap; k++) {
|
|
612
|
+
var m = matches[k];
|
|
613
|
+
if (!m) continue;
|
|
614
|
+
var sc = scoreMatch(m);
|
|
615
|
+
if (sc < 0) continue;
|
|
616
|
+
if (!candBest || sc > candBest.score) {
|
|
617
|
+
candBest = { score: sc, k: k, soft: sc <= 1 };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (candBest && (!best || candBest.score > best.score)) {
|
|
621
|
+
var pinned = candBest.k > 0 ? pathOf(matches[candBest.k]) : null;
|
|
622
|
+
if (candBest.k === 0 || pinned) {
|
|
623
|
+
best = { selIndex: i, k: candBest.k, pinned: pinned, soft: candBest.soft };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return best;
|
|
628
|
+
})()
|
|
629
|
+
`;
|
|
630
|
+
let best = null;
|
|
631
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
632
|
+
if (attempt > 0) await new Promise((r) => setTimeout(r, 200));
|
|
633
|
+
best = await page.evaluate(probeSrc).catch((e) => {
|
|
634
|
+
if (process.env.XBROWSER_HEAL_DEBUG) console.error("[heal] probe evaluate error:", e instanceof Error ? e.message.split("\n")[0] : String(e).slice(0, 200));
|
|
635
|
+
return null;
|
|
636
|
+
});
|
|
637
|
+
if (best) break;
|
|
638
|
+
}
|
|
639
|
+
if (!best) return null;
|
|
640
|
+
const originSel = probeSels[best.selIndex];
|
|
641
|
+
const finalSel = best.pinned ?? originSel;
|
|
642
|
+
const strategyName = list.find((c) => c.sel === originSel)?.strategy ?? "probe";
|
|
643
|
+
if (process.env.XBROWSER_HEAL_DEBUG) {
|
|
644
|
+
console.error(`[heal] cand=${finalSel} (via ${originSel}) soft=${best.soft}`);
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
await page.waitForSelector(finalSel, { state: "visible", timeout: Math.min(timeout, 1e3) });
|
|
648
|
+
const needsHitTest = action.type === "click" || action.type === "cdp-click";
|
|
649
|
+
if (needsHitTest && await this.isClickOccluded(finalSel)) return null;
|
|
650
|
+
return {
|
|
651
|
+
kind: best.soft ? "alt" : "hit",
|
|
652
|
+
hit: { selector: finalSel, strategy: strategyName }
|
|
653
|
+
};
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
const semanticHit = await tryProbe(semanticList);
|
|
659
|
+
if (semanticHit?.kind === "hit") return semanticHit.hit;
|
|
660
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
661
|
+
try {
|
|
662
|
+
const pathSel = await page.evaluate(`
|
|
663
|
+
(function() {
|
|
664
|
+
var el = document.elementFromPoint(${action.x}, ${action.y});
|
|
665
|
+
if (!el || el === document.body || el === document.documentElement) return '';
|
|
666
|
+
if (el.tagName.toLowerCase() !== ${JSON.stringify(el?.tag ?? "")}) return '';
|
|
667
|
+
var parts = [];
|
|
668
|
+
var cur = el;
|
|
669
|
+
while (cur && cur !== document.body) {
|
|
670
|
+
var parent = cur.parentElement;
|
|
671
|
+
if (!parent) break;
|
|
672
|
+
var same = Array.prototype.filter.call(parent.children, function(c) { return c.tagName === cur.tagName; });
|
|
673
|
+
parts.unshift(cur.tagName.toLowerCase() + ':nth-of-type(' + (same.indexOf(cur) + 1) + ')');
|
|
674
|
+
cur = parent;
|
|
675
|
+
}
|
|
676
|
+
return parts.length ? parts.join(' > ') : '';
|
|
677
|
+
})()
|
|
678
|
+
`);
|
|
679
|
+
if (pathSel) {
|
|
680
|
+
await page.waitForSelector(pathSel, { state: "visible", timeout: Math.min(timeout, 1e3) });
|
|
681
|
+
return { selector: pathSel, strategy: "coords" };
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const blindHit = await tryProbe(blindList);
|
|
687
|
+
if (blindHit?.kind === "hit") return blindHit.hit;
|
|
688
|
+
const softAlt = semanticHit?.kind === "alt" ? semanticHit.hit : blindHit?.kind === "alt" ? blindHit.hit : null;
|
|
689
|
+
if (softAlt) return { selector: softAlt.selector, strategy: `${softAlt.strategy}~soft` };
|
|
690
|
+
throw new Error(`Self-healing exhausted all ${candidates.length} strategies. Primary: ${primaryFailed.join(", ")}`);
|
|
691
|
+
}
|
|
692
|
+
/** 从选择器字符串提取语义核心(去掉版本号/前缀/后缀等噪声) */
|
|
693
|
+
extractSemanticCore(selector) {
|
|
694
|
+
const idMatch = selector.match(/#([\w-]+)/);
|
|
695
|
+
if (idMatch) {
|
|
696
|
+
return idMatch[1].replace(/-v\d+$/, "").replace(/-mut$/, "");
|
|
697
|
+
}
|
|
698
|
+
const nameMatch = selector.match(/\[name=["']([\w-]+)["']\]/);
|
|
699
|
+
if (nameMatch) return nameMatch[1];
|
|
700
|
+
const phMatch = selector.match(/\[placeholder=["']([\w-]+)["']\]/);
|
|
701
|
+
if (phMatch) return phMatch[1];
|
|
702
|
+
const dtMatch = selector.match(/\[data-testid=["']([\w-]+)["']\]/);
|
|
703
|
+
if (dtMatch) return dtMatch[1];
|
|
704
|
+
const classMatch = selector.match(/\.([\w-]+)/);
|
|
705
|
+
if (classMatch) return classMatch[1];
|
|
706
|
+
return "";
|
|
707
|
+
}
|
|
708
|
+
// ── heal 知识库(r10):heal 一次、同域记住、二次回放零成本 ──
|
|
709
|
+
pageDomain(action) {
|
|
710
|
+
try {
|
|
711
|
+
const u = new URL(action.url || this.page.url());
|
|
712
|
+
return u.hostname || u.protocol.replace(":", "") || "unknown";
|
|
713
|
+
} catch {
|
|
714
|
+
return "unknown";
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
healKnowledgeFile(domain) {
|
|
718
|
+
return join(this.opts.healKnowledgeDir, `heals-${domain}.json`);
|
|
719
|
+
}
|
|
720
|
+
readHealFile(file) {
|
|
721
|
+
try {
|
|
722
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
723
|
+
} catch {
|
|
724
|
+
return {};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
lookupHealKnowledge(domain, primary) {
|
|
728
|
+
if (!primary || !this.opts.healKnowledgeDir) return null;
|
|
729
|
+
const data = this.readHealFile(this.healKnowledgeFile(domain));
|
|
730
|
+
const e = data[primary];
|
|
731
|
+
return e ? { healed: e.healed, strategy: e.strategy } : null;
|
|
732
|
+
}
|
|
733
|
+
persistHealKnowledge(action, primary, healed) {
|
|
734
|
+
if (!this.opts.healKnowledgeDir || !primary || healed.strategy === "known-heal") return;
|
|
735
|
+
try {
|
|
736
|
+
const file = this.healKnowledgeFile(this.pageDomain(action));
|
|
737
|
+
const data = this.readHealFile(file);
|
|
738
|
+
data[primary] = {
|
|
739
|
+
healed: healed.selector,
|
|
740
|
+
strategy: healed.strategy,
|
|
741
|
+
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
742
|
+
hits: (data[primary]?.hits ?? 0) + 1
|
|
743
|
+
};
|
|
744
|
+
const cutoff = Date.now() - HEAL_KB_TTL_DAYS * 864e5;
|
|
745
|
+
for (const k of Object.keys(data)) {
|
|
746
|
+
const ts = Date.parse(data[k]?.lastSeen ?? "");
|
|
747
|
+
if (!(ts >= cutoff)) delete data[k];
|
|
748
|
+
}
|
|
749
|
+
mkdirSync(this.opts.healKnowledgeDir, { recursive: true });
|
|
750
|
+
writeFileSync(file, JSON.stringify(data, null, 2));
|
|
751
|
+
} catch {
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
bumpHealKnowledge(domain, primary) {
|
|
755
|
+
try {
|
|
756
|
+
const file = this.healKnowledgeFile(domain);
|
|
757
|
+
const data = this.readHealFile(file);
|
|
758
|
+
const e = data[primary];
|
|
759
|
+
if (!e) return;
|
|
760
|
+
e.hits += 1;
|
|
761
|
+
e.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
|
|
762
|
+
writeFileSync(file, JSON.stringify(data, null, 2));
|
|
763
|
+
} catch {
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
forgetHealKnowledge(domain, primary) {
|
|
767
|
+
try {
|
|
768
|
+
const file = this.healKnowledgeFile(domain);
|
|
769
|
+
const data = this.readHealFile(file);
|
|
770
|
+
if (!data[primary]) return;
|
|
771
|
+
delete data[primary];
|
|
772
|
+
writeFileSync(file, JSON.stringify(data, null, 2));
|
|
773
|
+
} catch {
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* r9: click 类动作遮挡校验——元素"可见"(rect+样式)但被覆盖层(弹窗/
|
|
778
|
+
* 横幅)盖住时,点击只会落在覆盖层上。elementFromPoint 顶元素非目标
|
|
779
|
+
* 自身/子孙即判遮挡。视口外(top 为 null)不判定——点击路径会自行滚动。
|
|
780
|
+
* r17: 阴影边界重定向——elementFromPoint 对阴影内命中点返回宿主,
|
|
781
|
+
* el.contains 不跨 shadow 边界,需沿 getRootNode().host 攀升做组合树
|
|
782
|
+
* 祖先判定,否则阴影内元素恒误判遮挡。
|
|
783
|
+
*/
|
|
784
|
+
async isClickOccluded(selector) {
|
|
785
|
+
try {
|
|
786
|
+
return await this.page.evaluate(`
|
|
787
|
+
(function() {
|
|
788
|
+
var el = ${queryJS(selector)};
|
|
789
|
+
if (!el) return true;
|
|
790
|
+
var r = el.getBoundingClientRect();
|
|
791
|
+
var top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
792
|
+
if (!top) return false;
|
|
793
|
+
function chainReaches(node, stop) {
|
|
794
|
+
while (node) {
|
|
795
|
+
if (node === stop) return true;
|
|
796
|
+
var root = node.getRootNode ? node.getRootNode() : null;
|
|
797
|
+
if (root && root.host) node = root.host;
|
|
798
|
+
else node = node.parentNode;
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
// \u53CC\u5411\u7EC4\u5408\u6811\u5224\u5B9A\uFF08r17\uFF09\uFF1Atop \u5728 el \u7684\u7EC4\u5408\u5B50\u6811\u5185\uFF08\u547D\u4E2D\u76EE\u6807\u7684\u5B50\u5B59\uFF09\uFF0C
|
|
803
|
+
// \u6216 el \u5728 top \u7684\u7EC4\u5408\u7956\u5148\u94FE\u4E0A\uFF08\u9634\u5F71\u91CD\u5B9A\u5411\uFF1Atop=\u5BBF\u4E3B\u4EE3\u8868\u5F71\u5B50\u5185\u547D\u4E2D\uFF09
|
|
804
|
+
var related = chainReaches(top, el) || chainReaches(el, top);
|
|
805
|
+
return !related;
|
|
806
|
+
})()
|
|
807
|
+
`);
|
|
808
|
+
} catch {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* r7/r8: heal 命中指纹校验——"找对"而非"找到"。
|
|
814
|
+
* 录制的 type/placeholder/text 与命中元素比对,裁决分三级:
|
|
815
|
+
* hard — type/placeholder 正性矛盾(两侧都有值但不等):判 wrong-target,
|
|
816
|
+
* 丢弃该候选。文案改版不会动这两个属性,硬拒不会误杀。
|
|
817
|
+
* soft — 仅 text 正性矛盾:最常见改版恰恰是文案改版,硬拒会误杀正确
|
|
818
|
+
* 元素,降级为备选(alt)——全链无干净命中时才启用。
|
|
819
|
+
* pass — 无矛盾(元素侧属性缺失不算矛盾;无信号/异常放行)。
|
|
820
|
+
*/
|
|
821
|
+
async verifyHealHit(action, selector) {
|
|
822
|
+
const m = action.element;
|
|
823
|
+
if (!m?.type && !m?.placeholder && !m?.text) return "pass";
|
|
824
|
+
try {
|
|
825
|
+
const meta = await this.page.evaluate(`
|
|
826
|
+
(function() {
|
|
827
|
+
var el = ${queryJS(selector)};
|
|
828
|
+
if (!el) return null;
|
|
829
|
+
return {
|
|
830
|
+
type: el.getAttribute('type'),
|
|
831
|
+
placeholder: el.getAttribute('placeholder'),
|
|
832
|
+
text: (String(el.value ?? '') || el.textContent || '').trim().substring(0, 80),
|
|
833
|
+
};
|
|
834
|
+
})()
|
|
835
|
+
`);
|
|
836
|
+
if (!meta) return "pass";
|
|
837
|
+
if (m.type && meta.type && meta.type !== m.type) return "hard";
|
|
838
|
+
if (m.placeholder && meta.placeholder && meta.placeholder !== m.placeholder) return "hard";
|
|
839
|
+
const wantText = (m.text || "").trim();
|
|
840
|
+
if (wantText && meta.text && !meta.text.includes(wantText)) return "soft";
|
|
841
|
+
return "pass";
|
|
842
|
+
} catch {
|
|
843
|
+
return "pass";
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* X2: Wait for an element using the best available selector, with
|
|
848
|
+
* confidence-based fallback support.
|
|
849
|
+
*
|
|
850
|
+
* Returns the first matching selector, or throws if none match.
|
|
851
|
+
* Fallback order:
|
|
852
|
+
* 1. Primary CSS selector (always tried first)
|
|
853
|
+
* 2. textFallback selector (used when primary fails — not just for low
|
|
854
|
+
* confidence, since high-confidence selectors from dynamic attributes
|
|
855
|
+
* like data-spm-anchor-id can also fail on replay)
|
|
856
|
+
* 3. Tag-based fallback (last resort)
|
|
857
|
+
*/
|
|
858
|
+
async resolveAndWait(action) {
|
|
859
|
+
const el = action.element;
|
|
860
|
+
if (!el) throw new Error("No element metadata");
|
|
861
|
+
const page = this.page;
|
|
862
|
+
const timeout = this.opts.stepTimeout;
|
|
863
|
+
const candidates = [];
|
|
864
|
+
if (el.selector) candidates.push(el.selector);
|
|
865
|
+
if (el.textFallback?.selector && !candidates.includes(el.textFallback.selector)) {
|
|
866
|
+
candidates.push(el.textFallback.selector);
|
|
867
|
+
}
|
|
868
|
+
const UNIQUE_TAGS = /* @__PURE__ */ new Set(["textarea", "select"]);
|
|
869
|
+
if (el.tag && UNIQUE_TAGS.has(el.tag) && !candidates.includes(el.tag)) {
|
|
870
|
+
candidates.push(el.tag);
|
|
871
|
+
}
|
|
872
|
+
if (candidates.length === 0 && this.opts.selfHealing === false) {
|
|
873
|
+
throw new Error("No selector available for element");
|
|
874
|
+
}
|
|
875
|
+
const isClick = action.type === "click" || action.type === "cdp-click";
|
|
876
|
+
const __dbgT0 = Date.now();
|
|
877
|
+
const __dbg = (msg) => {
|
|
878
|
+
if (process.env.XBROWSER_HEAL_DEBUG) console.error(`[resolve ${Math.round(Date.now() - __dbgT0)}ms] ${msg}`);
|
|
879
|
+
};
|
|
880
|
+
for (const sel of candidates) {
|
|
881
|
+
try {
|
|
882
|
+
await page.waitForSelector(sel, { state: "visible", timeout });
|
|
883
|
+
if (isClick && await this.isClickOccluded(sel)) {
|
|
884
|
+
__dbg(`occluded: ${sel}`);
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
__dbg(`primary hit: ${sel}`);
|
|
888
|
+
return sel;
|
|
889
|
+
} catch {
|
|
890
|
+
__dbg(`primary miss: ${sel}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (this.opts.selfHealing !== false) {
|
|
894
|
+
try {
|
|
895
|
+
const healed = await this.healResolve(action, candidates);
|
|
896
|
+
this.opts.onHealed?.(action, healed.strategy, candidates.length);
|
|
897
|
+
this.persistHealKnowledge(action, candidates[0] ?? "", healed);
|
|
898
|
+
__dbg(`heal hit: ${healed.selector} (${healed.strategy})`);
|
|
899
|
+
return healed.selector;
|
|
900
|
+
} catch {
|
|
901
|
+
__dbg("heal exhausted");
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (candidates.length === 0) throw new Error("No selector available for element");
|
|
905
|
+
throw new Error(`Element not found, tried: ${candidates.join(", ")}`);
|
|
906
|
+
}
|
|
907
|
+
/** Resolve file payloads from a filechooser action */
|
|
908
|
+
resolveFiles(action) {
|
|
909
|
+
if (!action.files?.fileData) return [];
|
|
910
|
+
return action.files.fileData.filter((f) => f.dataUrl).map((f) => {
|
|
911
|
+
const match = f.dataUrl.match(/^data:[^;]+;base64,(.+)$/);
|
|
912
|
+
if (!match) return null;
|
|
913
|
+
return {
|
|
914
|
+
name: f.name,
|
|
915
|
+
mimeType: f.type || "application/octet-stream",
|
|
916
|
+
buffer: Buffer.from(match[1], "base64")
|
|
917
|
+
};
|
|
918
|
+
}).filter((f) => f !== null);
|
|
919
|
+
}
|
|
920
|
+
/** Clean up */
|
|
921
|
+
async close() {
|
|
922
|
+
this.page = null;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
export {
|
|
926
|
+
SessionReplayer
|
|
927
|
+
};
|