@rester159/blacktip 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/AGENTS.md +249 -0
- package/LICENSE +38 -0
- package/README.md +234 -0
- package/dist/behavioral/calibration.d.ts +145 -0
- package/dist/behavioral/calibration.d.ts.map +1 -0
- package/dist/behavioral/calibration.js +242 -0
- package/dist/behavioral/calibration.js.map +1 -0
- package/dist/behavioral-engine.d.ts +156 -0
- package/dist/behavioral-engine.d.ts.map +1 -0
- package/dist/behavioral-engine.js +521 -0
- package/dist/behavioral-engine.js.map +1 -0
- package/dist/blacktip.d.ts +289 -0
- package/dist/blacktip.d.ts.map +1 -0
- package/dist/blacktip.js +1574 -0
- package/dist/blacktip.js.map +1 -0
- package/dist/browser-core.d.ts +47 -0
- package/dist/browser-core.d.ts.map +1 -0
- package/dist/browser-core.js +375 -0
- package/dist/browser-core.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +226 -0
- package/dist/cli.js.map +1 -0
- package/dist/element-finder.d.ts +42 -0
- package/dist/element-finder.d.ts.map +1 -0
- package/dist/element-finder.js +240 -0
- package/dist/element-finder.js.map +1 -0
- package/dist/evasion.d.ts +39 -0
- package/dist/evasion.d.ts.map +1 -0
- package/dist/evasion.js +488 -0
- package/dist/evasion.js.map +1 -0
- package/dist/fingerprint.d.ts +19 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +171 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +13 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +42 -0
- package/dist/logging.js.map +1 -0
- package/dist/observability.d.ts +69 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +189 -0
- package/dist/observability.js.map +1 -0
- package/dist/proxy-pool.d.ts +101 -0
- package/dist/proxy-pool.d.ts.map +1 -0
- package/dist/proxy-pool.js +156 -0
- package/dist/proxy-pool.js.map +1 -0
- package/dist/snapshot.d.ts +59 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +91 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/examples/01-basic-navigate.ts +40 -0
- package/examples/02-login-with-mfa.ts +68 -0
- package/examples/03-agent-serve-mode.md +98 -0
- package/package.json +62 -0
package/dist/blacktip.js
ADDED
|
@@ -0,0 +1,1574 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { BrowserCore } from './browser-core.js';
|
|
4
|
+
import { BehavioralEngine, HUMAN_PROFILE, SCRAPER_PROFILE } from './behavioral-engine.js';
|
|
5
|
+
import { ElementFinder } from './element-finder.js';
|
|
6
|
+
import { Logger } from './logging.js';
|
|
7
|
+
const RETRY_STRATEGIES = ['standard', 'wait', 'reload', 'altSelector', 'scroll', 'clearOverlays'];
|
|
8
|
+
/**
|
|
9
|
+
* Text patterns that suggest an action is high-importance — submit,
|
|
10
|
+
* payment, confirmation, destructive actions. When clickText or clickRole
|
|
11
|
+
* matches a button whose label matches one of these, the behavioral
|
|
12
|
+
* engine automatically applies importance:'high' so the pre-action
|
|
13
|
+
* hesitation matches a real human's pause before committing.
|
|
14
|
+
*
|
|
15
|
+
* Caller can still override by passing importance explicitly.
|
|
16
|
+
*/
|
|
17
|
+
const HIGH_IMPORTANCE_PATTERNS = [
|
|
18
|
+
/^\s*submit\b/i,
|
|
19
|
+
/^\s*pay\b/i,
|
|
20
|
+
/^\s*confirm\b/i,
|
|
21
|
+
/^\s*place\s*order\b/i,
|
|
22
|
+
/^\s*checkout\b/i,
|
|
23
|
+
/^\s*purchase\b/i,
|
|
24
|
+
/^\s*buy\b/i,
|
|
25
|
+
/^\s*delete\b/i,
|
|
26
|
+
/^\s*remove\b/i,
|
|
27
|
+
/^\s*agree\b/i,
|
|
28
|
+
/^\s*accept\b/i,
|
|
29
|
+
/send\s*money/i,
|
|
30
|
+
/submit\s*payment/i,
|
|
31
|
+
/confirm\s*payment/i,
|
|
32
|
+
/place\s*order/i,
|
|
33
|
+
/^\s*sign\s*(up|in|out)?\s*$/i,
|
|
34
|
+
/^\s*log\s*out\b/i,
|
|
35
|
+
/^\s*finish\b/i,
|
|
36
|
+
/^\s*complete\b/i,
|
|
37
|
+
];
|
|
38
|
+
function inferImportance(text, explicit) {
|
|
39
|
+
if (explicit)
|
|
40
|
+
return explicit;
|
|
41
|
+
if (!text)
|
|
42
|
+
return 'normal';
|
|
43
|
+
return HIGH_IMPORTANCE_PATTERNS.some((re) => re.test(text)) ? 'high' : 'normal';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* BlackTip — Stealth browser instrument for AI agents.
|
|
47
|
+
*
|
|
48
|
+
* BlackTip is NOT an agent. It is a tool that an agent drives.
|
|
49
|
+
* Every action (click, type, scroll) is wrapped in human-like behavioral
|
|
50
|
+
* simulation that defeats bot detection.
|
|
51
|
+
*
|
|
52
|
+
* ## Quick Start (for AI agents)
|
|
53
|
+
*
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const bt = new BlackTip();
|
|
56
|
+
* await bt.launch();
|
|
57
|
+
* await bt.navigate('https://example.com');
|
|
58
|
+
* await bt.type('input[name="email"]', 'user@example.com', { paste: true });
|
|
59
|
+
* await bt.click('.submit-btn');
|
|
60
|
+
* await bt.close();
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* ## Agent Usage Guide
|
|
64
|
+
*
|
|
65
|
+
* Call `BlackTip.agentGuide()` to get detailed instructions for how an AI
|
|
66
|
+
* agent should use BlackTip. This includes critical patterns for React/Angular
|
|
67
|
+
* forms, Okta login pages, custom dropdowns, and common mistakes to avoid.
|
|
68
|
+
*
|
|
69
|
+
* ## Server Mode (recommended for agents)
|
|
70
|
+
*
|
|
71
|
+
* ```typescript
|
|
72
|
+
* const bt = new BlackTip();
|
|
73
|
+
* await bt.serve(9779); // TCP server, send commands as JS strings
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* ## Key Methods
|
|
77
|
+
* - `navigate(url)` — go to URL
|
|
78
|
+
* - `click(selector)` — click by CSS/XPath
|
|
79
|
+
* - `clickText(text, {nth?})` — click by visible text (handles React/Okta)
|
|
80
|
+
* - `type(selector, text, {paste?})` — type into input (React-compatible)
|
|
81
|
+
* - `screenshot({path})` — capture page state
|
|
82
|
+
* - `executeJS(script)` — run JS in page context
|
|
83
|
+
* - `uploadFile(selector, path)` — upload a file
|
|
84
|
+
* - `waitFor(selector)` — wait for element
|
|
85
|
+
* - `extractText(selector)` — get text content
|
|
86
|
+
* - `frame(selector)` — interact with iframes
|
|
87
|
+
* - `serve(port)` — start TCP command server
|
|
88
|
+
*/
|
|
89
|
+
export class BlackTip extends EventEmitter {
|
|
90
|
+
core;
|
|
91
|
+
engine;
|
|
92
|
+
finder;
|
|
93
|
+
logger;
|
|
94
|
+
config;
|
|
95
|
+
customProfiles = new Map();
|
|
96
|
+
launched = false;
|
|
97
|
+
constructor(config = {}) {
|
|
98
|
+
super();
|
|
99
|
+
this.config = config;
|
|
100
|
+
const logLevel = (config.logLevel ?? process.env.BLACKTIP_LOG_LEVEL ?? 'info');
|
|
101
|
+
this.logger = new Logger(logLevel);
|
|
102
|
+
// Forward log events
|
|
103
|
+
this.logger.on('log', (entry) => this.emit('log', entry));
|
|
104
|
+
// Resolve behavioral profile
|
|
105
|
+
const profile = this.resolveProfile(config.behaviorProfile ?? 'human');
|
|
106
|
+
this.engine = new BehavioralEngine(profile);
|
|
107
|
+
this.core = new BrowserCore(config, this.logger);
|
|
108
|
+
this.finder = new ElementFinder(this.logger);
|
|
109
|
+
// Forward tab events from core
|
|
110
|
+
this.core.on('tabChange', (event) => this.emit('tabChange', event));
|
|
111
|
+
// Default 'error' listener so Node's EventEmitter doesn't crash the
|
|
112
|
+
// process when an action fails and no user listener is attached. The
|
|
113
|
+
// action result already carries the error; this event is supplemental.
|
|
114
|
+
// Users can still add their own 'error' listeners alongside this one.
|
|
115
|
+
this.on('error', (errorEvent) => {
|
|
116
|
+
this.logger.debug('BlackTip action error (no external listener)', {
|
|
117
|
+
code: errorEvent?.code,
|
|
118
|
+
action: errorEvent?.action,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// ── Lifecycle ──
|
|
123
|
+
/**
|
|
124
|
+
* Launch the browser. Returns the agent usage guide — read it before
|
|
125
|
+
* driving BlackTip to avoid common mistakes.
|
|
126
|
+
*/
|
|
127
|
+
async launch() {
|
|
128
|
+
await this.core.launch();
|
|
129
|
+
this.launched = true;
|
|
130
|
+
return BlackTip.agentGuide();
|
|
131
|
+
}
|
|
132
|
+
async close() {
|
|
133
|
+
await this.core.close();
|
|
134
|
+
this.launched = false;
|
|
135
|
+
}
|
|
136
|
+
isActive() {
|
|
137
|
+
return this.launched && this.core.isActive();
|
|
138
|
+
}
|
|
139
|
+
// ── Navigation ──
|
|
140
|
+
async navigate(url, options) {
|
|
141
|
+
this.ensureLaunched();
|
|
142
|
+
// Pre-navigation pause
|
|
143
|
+
await this.sleep(this.engine.generatePreActionPause());
|
|
144
|
+
return this.core.navigate(url, options);
|
|
145
|
+
}
|
|
146
|
+
// ── Actions ──
|
|
147
|
+
async click(selector, options) {
|
|
148
|
+
return this.executeAction('click', selector, async () => {
|
|
149
|
+
const page = this.core.getActivePage();
|
|
150
|
+
const element = await this.finder.find(page, selector, {
|
|
151
|
+
timeout: options?.timeout ?? this.config.timeout,
|
|
152
|
+
visible: true,
|
|
153
|
+
});
|
|
154
|
+
const box = await this.finder.getBoundingBox(element);
|
|
155
|
+
if (!box)
|
|
156
|
+
throw new Error('Element has no bounding box');
|
|
157
|
+
// Generate human-like mouse movement toward the originally-captured box.
|
|
158
|
+
const currentPos = await this.getMousePosition();
|
|
159
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
160
|
+
const behavioral = await this.performMouseMove(currentPos, targetPos);
|
|
161
|
+
// Hover dwell
|
|
162
|
+
const dwell = this.engine.generateClickDwell();
|
|
163
|
+
await this.sleep(dwell);
|
|
164
|
+
behavioral.clickDwell = dwell;
|
|
165
|
+
// L011 fix: the DOM may have reflowed during our ~200ms mouse path
|
|
166
|
+
// (async scripts, layout shifts, animations). Re-read the element's
|
|
167
|
+
// live bounding box immediately before the click and, if it moved
|
|
168
|
+
// more than a few pixels, do a short correction move to the new
|
|
169
|
+
// center. Real humans do the same thing when a target shifts.
|
|
170
|
+
const liveBox = await this.finder.getBoundingBox(element);
|
|
171
|
+
if (liveBox) {
|
|
172
|
+
const liveCenter = {
|
|
173
|
+
x: liveBox.x + liveBox.width / 2,
|
|
174
|
+
y: liveBox.y + liveBox.height / 2,
|
|
175
|
+
};
|
|
176
|
+
const drift = Math.hypot(liveCenter.x - targetPos.x, liveCenter.y - targetPos.y);
|
|
177
|
+
if (drift > 5) {
|
|
178
|
+
this.logger.debug('Click target reflow detected, correcting', {
|
|
179
|
+
drift: Math.round(drift),
|
|
180
|
+
from: targetPos,
|
|
181
|
+
to: liveCenter,
|
|
182
|
+
});
|
|
183
|
+
await this.performMouseMove(targetPos, liveCenter);
|
|
184
|
+
targetPos.x = liveCenter.x;
|
|
185
|
+
targetPos.y = liveCenter.y;
|
|
186
|
+
// A small post-correction dwell — humans take a brief beat
|
|
187
|
+
// between a correction and the click.
|
|
188
|
+
await this.sleep(80 + Math.random() * 120);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Pre-click verification: if the click coordinates are covered by
|
|
192
|
+
// an overlay, dismiss the overlay and fall back to Playwright's
|
|
193
|
+
// element.click() with force:true.
|
|
194
|
+
const hitCheck = await page.evaluate(`((x, y) => {
|
|
195
|
+
const el = document.elementFromPoint(x, y);
|
|
196
|
+
if (!el) return { ok: false, reason: 'no-element' };
|
|
197
|
+
let cur = el;
|
|
198
|
+
while (cur) {
|
|
199
|
+
if (cur.tagName === 'BUTTON' || cur.tagName === 'A' || cur.tagName === 'INPUT' || cur.getAttribute('role') === 'button') {
|
|
200
|
+
return { ok: true };
|
|
201
|
+
}
|
|
202
|
+
cur = cur.parentElement;
|
|
203
|
+
}
|
|
204
|
+
return { ok: false, reason: 'not-interactive', tag: el.tagName };
|
|
205
|
+
})(${targetPos.x}, ${targetPos.y})`);
|
|
206
|
+
if (hitCheck.ok) {
|
|
207
|
+
await page.mouse.click(targetPos.x, targetPos.y, {
|
|
208
|
+
button: options?.button ?? 'left',
|
|
209
|
+
clickCount: options?.count ?? 1,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
this.logger.warn('Click target covered by overlay; dismissing and retrying', {
|
|
214
|
+
reason: hitCheck.reason,
|
|
215
|
+
coveredBy: hitCheck.tag,
|
|
216
|
+
});
|
|
217
|
+
await this.dismissOverlays();
|
|
218
|
+
await element.click({ force: true, timeout: 3000 });
|
|
219
|
+
}
|
|
220
|
+
// Try to auto-infer importance from the element text if the
|
|
221
|
+
// caller didn't set it.
|
|
222
|
+
let inferredImportance = options?.importance;
|
|
223
|
+
if (!inferredImportance) {
|
|
224
|
+
try {
|
|
225
|
+
const elText = await element.innerText();
|
|
226
|
+
inferredImportance = inferImportance(elText);
|
|
227
|
+
}
|
|
228
|
+
catch { /* element may have navigated away */ }
|
|
229
|
+
}
|
|
230
|
+
// Post-action pause
|
|
231
|
+
const postPause = this.engine.generatePostActionPause();
|
|
232
|
+
await this.sleep(postPause);
|
|
233
|
+
behavioral.postActionPause = postPause;
|
|
234
|
+
// Stash the inferred importance on the behavioral object so the
|
|
235
|
+
// outer executeAction wrapper can read it. But the pre-action
|
|
236
|
+
// pause already happened — importance inference only affects
|
|
237
|
+
// future calls. Keeping the interface consistent anyway.
|
|
238
|
+
void inferredImportance;
|
|
239
|
+
return behavioral;
|
|
240
|
+
}, undefined, options?.importance);
|
|
241
|
+
}
|
|
242
|
+
async clickText(text, options) {
|
|
243
|
+
// Auto-infer importance from the text if caller didn't set it.
|
|
244
|
+
const importance = inferImportance(text, options?.importance);
|
|
245
|
+
return this.executeAction('click', `text="${text}"`, async () => {
|
|
246
|
+
const page = this.core.getActivePage();
|
|
247
|
+
let locator = page.getByText(text, { exact: options?.exact ?? true });
|
|
248
|
+
if (options?.nth !== undefined) {
|
|
249
|
+
locator = locator.nth(options.nth);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
locator = locator.first(); // Avoid strict mode violation when multiple matches
|
|
253
|
+
}
|
|
254
|
+
await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? this.config.timeout ?? 15000 });
|
|
255
|
+
const box = await locator.boundingBox();
|
|
256
|
+
if (!box)
|
|
257
|
+
throw new Error('Element has no bounding box');
|
|
258
|
+
const currentPos = await this.getMousePosition();
|
|
259
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
260
|
+
const behavioral = await this.performMouseMove(currentPos, targetPos);
|
|
261
|
+
const dwell = this.engine.generateClickDwell();
|
|
262
|
+
await this.sleep(dwell);
|
|
263
|
+
behavioral.clickDwell = dwell;
|
|
264
|
+
// Pre-click verification: check if the click coordinates land on
|
|
265
|
+
// an interactive element. If they don't (e.g. a chat widget or
|
|
266
|
+
// cookie banner is covering the button), dismiss overlays and
|
|
267
|
+
// fall back to locator.click() which bypasses the coordinate
|
|
268
|
+
// issue entirely.
|
|
269
|
+
const hitCheck = await page.evaluate(`((x, y) => {
|
|
270
|
+
const el = document.elementFromPoint(x, y);
|
|
271
|
+
if (!el) return { ok: false, reason: 'no-element' };
|
|
272
|
+
let cur = el;
|
|
273
|
+
while (cur) {
|
|
274
|
+
if (cur.tagName === 'BUTTON' || cur.tagName === 'A' || cur.getAttribute('role') === 'button') {
|
|
275
|
+
return { ok: true };
|
|
276
|
+
}
|
|
277
|
+
cur = cur.parentElement;
|
|
278
|
+
}
|
|
279
|
+
return { ok: false, reason: 'not-interactive', tag: el.tagName };
|
|
280
|
+
})(${targetPos.x}, ${targetPos.y})`);
|
|
281
|
+
if (hitCheck.ok) {
|
|
282
|
+
await page.mouse.click(targetPos.x, targetPos.y, {
|
|
283
|
+
button: options?.button ?? 'left',
|
|
284
|
+
clickCount: options?.count ?? 1,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// Click would be intercepted. Dismiss overlays and use
|
|
289
|
+
// locator.click({force:true}) which does its own visibility
|
|
290
|
+
// handling.
|
|
291
|
+
this.logger.warn('Click target covered by overlay; dismissing and retrying via locator', {
|
|
292
|
+
reason: hitCheck.reason,
|
|
293
|
+
coveredBy: hitCheck.tag,
|
|
294
|
+
});
|
|
295
|
+
await this.dismissOverlays();
|
|
296
|
+
await locator.click({ force: true, timeout: 3000 });
|
|
297
|
+
}
|
|
298
|
+
const postPause = this.engine.generatePostActionPause();
|
|
299
|
+
await this.sleep(postPause);
|
|
300
|
+
behavioral.postActionPause = postPause;
|
|
301
|
+
return behavioral;
|
|
302
|
+
}, undefined, importance);
|
|
303
|
+
}
|
|
304
|
+
async clickRole(role, options) {
|
|
305
|
+
const label = `role=${role}${options?.name ? `[name="${options.name}"]` : ''}`;
|
|
306
|
+
// Auto-infer importance from the role name if provided.
|
|
307
|
+
const importance = inferImportance(options?.name, options?.importance);
|
|
308
|
+
return this.executeAction('click', label, async () => {
|
|
309
|
+
const page = this.core.getActivePage();
|
|
310
|
+
let locator = page.getByRole(role, options?.name ? { name: options.name } : undefined);
|
|
311
|
+
if (options?.nth !== undefined) {
|
|
312
|
+
locator = locator.nth(options.nth);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
locator = locator.first();
|
|
316
|
+
}
|
|
317
|
+
await locator.waitFor({ state: 'visible', timeout: options?.timeout ?? this.config.timeout ?? 15000 });
|
|
318
|
+
const box = await locator.boundingBox();
|
|
319
|
+
if (!box)
|
|
320
|
+
throw new Error('Element has no bounding box');
|
|
321
|
+
const currentPos = await this.getMousePosition();
|
|
322
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
323
|
+
const behavioral = await this.performMouseMove(currentPos, targetPos);
|
|
324
|
+
const dwell = this.engine.generateClickDwell();
|
|
325
|
+
await this.sleep(dwell);
|
|
326
|
+
behavioral.clickDwell = dwell;
|
|
327
|
+
// Pre-click verification — same pattern as clickText.
|
|
328
|
+
const hitCheck = await page.evaluate(`((x, y) => {
|
|
329
|
+
const el = document.elementFromPoint(x, y);
|
|
330
|
+
if (!el) return { ok: false, reason: 'no-element' };
|
|
331
|
+
let cur = el;
|
|
332
|
+
while (cur) {
|
|
333
|
+
if (cur.tagName === 'BUTTON' || cur.tagName === 'A' || cur.tagName === 'INPUT' || cur.getAttribute('role') === 'button') {
|
|
334
|
+
return { ok: true };
|
|
335
|
+
}
|
|
336
|
+
cur = cur.parentElement;
|
|
337
|
+
}
|
|
338
|
+
return { ok: false, reason: 'not-interactive', tag: el.tagName };
|
|
339
|
+
})(${targetPos.x}, ${targetPos.y})`);
|
|
340
|
+
if (hitCheck.ok) {
|
|
341
|
+
await page.mouse.click(targetPos.x, targetPos.y, {
|
|
342
|
+
button: options?.button ?? 'left',
|
|
343
|
+
clickCount: options?.count ?? 1,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
this.logger.warn('Click target covered by overlay; dismissing and retrying via locator', {
|
|
348
|
+
reason: hitCheck.reason,
|
|
349
|
+
coveredBy: hitCheck.tag,
|
|
350
|
+
});
|
|
351
|
+
await this.dismissOverlays();
|
|
352
|
+
await locator.click({ force: true, timeout: 3000 });
|
|
353
|
+
}
|
|
354
|
+
const postPause = this.engine.generatePostActionPause();
|
|
355
|
+
await this.sleep(postPause);
|
|
356
|
+
behavioral.postActionPause = postPause;
|
|
357
|
+
return behavioral;
|
|
358
|
+
}, undefined, importance);
|
|
359
|
+
}
|
|
360
|
+
async type(selector, text, options) {
|
|
361
|
+
return this.executeAction('type', selector, async () => {
|
|
362
|
+
const page = this.core.getActivePage();
|
|
363
|
+
const element = await this.finder.find(page, selector, {
|
|
364
|
+
timeout: options?.timeout ?? this.config.timeout,
|
|
365
|
+
visible: true,
|
|
366
|
+
});
|
|
367
|
+
// Click on the element first (human-like)
|
|
368
|
+
const box = await this.finder.getBoundingBox(element);
|
|
369
|
+
if (!box)
|
|
370
|
+
throw new Error('Element has no bounding box');
|
|
371
|
+
const currentPos = await this.getMousePosition();
|
|
372
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
373
|
+
const behavioral = await this.performMouseMove(currentPos, targetPos);
|
|
374
|
+
const dwell = this.engine.generateClickDwell();
|
|
375
|
+
await this.sleep(dwell);
|
|
376
|
+
await page.mouse.click(targetPos.x, targetPos.y);
|
|
377
|
+
// Clear existing content if requested
|
|
378
|
+
if (options?.clearFirst) {
|
|
379
|
+
await page.keyboard.press('Control+a');
|
|
380
|
+
await this.sleep(50);
|
|
381
|
+
await page.keyboard.press('Backspace');
|
|
382
|
+
await this.sleep(this.engine.generatePreActionPause() * 0.3);
|
|
383
|
+
}
|
|
384
|
+
// Use Playwright's fill() to set value — this correctly triggers React/Angular
|
|
385
|
+
// change events, input events, and form validation. Then optionally simulate
|
|
386
|
+
// typing cadence with page.type() for keystroke-level behavioral fidelity.
|
|
387
|
+
const shouldPaste = options?.paste ?? this.engine.shouldPaste(text);
|
|
388
|
+
if (shouldPaste) {
|
|
389
|
+
// Fast path: fill directly (like a paste) — triggers React/Angular events correctly
|
|
390
|
+
await element.fill(text);
|
|
391
|
+
await this.sleep(100 + Math.random() * 200);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
// Human typing path: use page.keyboard.type() which dispatches the full
|
|
395
|
+
// keydown/keypress/input/keyup event cycle per character. This triggers
|
|
396
|
+
// React/Angular synthetic event handlers correctly, unlike keyboard.press().
|
|
397
|
+
// Clear field with Select-all + Backspace (works with all frameworks)
|
|
398
|
+
await page.keyboard.press('Control+a');
|
|
399
|
+
await this.sleep(30 + Math.random() * 50);
|
|
400
|
+
await page.keyboard.press('Backspace');
|
|
401
|
+
await this.sleep(50);
|
|
402
|
+
const sequence = this.engine.generateTypingSequence(text);
|
|
403
|
+
for (const keystroke of sequence) {
|
|
404
|
+
if (keystroke.isTypo && keystroke.correctionSequence) {
|
|
405
|
+
// Type wrong character via keyboard.type() (triggers input events)
|
|
406
|
+
await page.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
|
|
407
|
+
// Apply corrections (backspace + correct char)
|
|
408
|
+
for (const correction of keystroke.correctionSequence) {
|
|
409
|
+
await this.sleep(correction.delay);
|
|
410
|
+
if (correction.key === 'Backspace') {
|
|
411
|
+
await page.keyboard.press('Backspace');
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
await page.keyboard.type(correction.key, { delay: correction.holdDuration });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
await this.sleep(keystroke.delay);
|
|
420
|
+
await page.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
behavioral.typingDuration = sequence.reduce((sum, k) => sum + k.delay + k.holdDuration, 0);
|
|
424
|
+
// Verify the field value — if the framework didn't register keystrokes, fall back to fill()
|
|
425
|
+
try {
|
|
426
|
+
const currentValue = await element.inputValue();
|
|
427
|
+
if (currentValue !== text) {
|
|
428
|
+
this.logger.warn('Typing mismatch, falling back to fill()', {
|
|
429
|
+
expected: text.length,
|
|
430
|
+
got: currentValue.length,
|
|
431
|
+
});
|
|
432
|
+
await element.fill(text);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// inputValue() may fail on non-input elements — that's OK
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Press Enter if requested
|
|
440
|
+
if (options?.pressEnter) {
|
|
441
|
+
await this.sleep(200 + Math.random() * 300);
|
|
442
|
+
await page.keyboard.press('Enter');
|
|
443
|
+
}
|
|
444
|
+
// Post-action pause
|
|
445
|
+
const postPause = this.engine.generatePostActionPause();
|
|
446
|
+
await this.sleep(postPause);
|
|
447
|
+
behavioral.postActionPause = postPause;
|
|
448
|
+
return behavioral;
|
|
449
|
+
}, text, options?.importance);
|
|
450
|
+
}
|
|
451
|
+
async scroll(options) {
|
|
452
|
+
const direction = options?.direction ?? 'down';
|
|
453
|
+
const amount = options?.amount ?? 300;
|
|
454
|
+
return this.executeAction('scroll', options?.selector ?? 'page', async () => {
|
|
455
|
+
const page = this.core.getActivePage();
|
|
456
|
+
if (options?.selector) {
|
|
457
|
+
const element = await this.finder.find(page, options.selector);
|
|
458
|
+
await element.scrollIntoViewIfNeeded();
|
|
459
|
+
}
|
|
460
|
+
const scrollDir = (direction === 'left' || direction === 'up') ? 'up' : 'down';
|
|
461
|
+
const steps = this.engine.generateScrollSteps(amount, scrollDir);
|
|
462
|
+
for (const step of steps) {
|
|
463
|
+
await this.sleep(step.delay);
|
|
464
|
+
const deltaX = (direction === 'left' ? -step.deltaY : direction === 'right' ? step.deltaY : 0);
|
|
465
|
+
const deltaY = (direction === 'up' ? -step.deltaY : direction === 'down' ? step.deltaY : 0);
|
|
466
|
+
await page.mouse.wheel(deltaX, deltaY);
|
|
467
|
+
}
|
|
468
|
+
const postPause = this.engine.generatePostActionPause();
|
|
469
|
+
await this.sleep(postPause);
|
|
470
|
+
return { postActionPause: postPause };
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
async hover(selector, options) {
|
|
474
|
+
return this.executeAction('hover', selector, async () => {
|
|
475
|
+
const page = this.core.getActivePage();
|
|
476
|
+
const element = await this.finder.find(page, selector, {
|
|
477
|
+
timeout: options?.timeout ?? this.config.timeout,
|
|
478
|
+
visible: true,
|
|
479
|
+
});
|
|
480
|
+
const box = await this.finder.getBoundingBox(element);
|
|
481
|
+
if (!box)
|
|
482
|
+
throw new Error('Element has no bounding box');
|
|
483
|
+
const currentPos = await this.getMousePosition();
|
|
484
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
485
|
+
const behavioral = await this.performMouseMove(currentPos, targetPos);
|
|
486
|
+
// Hover dwell (longer than click dwell)
|
|
487
|
+
const dwell = this.engine.generateClickDwell() * 1.5;
|
|
488
|
+
await this.sleep(dwell);
|
|
489
|
+
return behavioral;
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async select(selector, value, options) {
|
|
493
|
+
return this.executeAction('select', selector, async () => {
|
|
494
|
+
const page = this.core.getActivePage();
|
|
495
|
+
const element = await this.finder.find(page, selector, {
|
|
496
|
+
timeout: options?.timeout ?? this.config.timeout,
|
|
497
|
+
});
|
|
498
|
+
// Click the select element first
|
|
499
|
+
const box = await this.finder.getBoundingBox(element);
|
|
500
|
+
if (!box)
|
|
501
|
+
throw new Error('Element has no bounding box');
|
|
502
|
+
const currentPos = await this.getMousePosition();
|
|
503
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
504
|
+
const behavioral = await this.performMouseMove(currentPos, targetPos);
|
|
505
|
+
await this.sleep(this.engine.generateClickDwell());
|
|
506
|
+
await page.mouse.click(targetPos.x, targetPos.y);
|
|
507
|
+
// Small delay before selecting
|
|
508
|
+
await this.sleep(200 + Math.random() * 300);
|
|
509
|
+
// Playwright's selectOption requires ONE matcher; passing both value
|
|
510
|
+
// and label means the option must match both. Try value first, fall
|
|
511
|
+
// back to label for callers who pass the visible label text.
|
|
512
|
+
try {
|
|
513
|
+
await page.selectOption(selector, { value });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
await page.selectOption(selector, { label: value });
|
|
517
|
+
}
|
|
518
|
+
const postPause = this.engine.generatePostActionPause();
|
|
519
|
+
await this.sleep(postPause);
|
|
520
|
+
behavioral.postActionPause = postPause;
|
|
521
|
+
return behavioral;
|
|
522
|
+
}, value);
|
|
523
|
+
}
|
|
524
|
+
async pressKey(key, options) {
|
|
525
|
+
return this.executeAction('pressKey', key, async () => {
|
|
526
|
+
const page = this.core.getActivePage();
|
|
527
|
+
await this.sleep(this.engine.generatePreActionPause() * 0.5);
|
|
528
|
+
await page.keyboard.press(key);
|
|
529
|
+
const postPause = this.engine.generatePostActionPause();
|
|
530
|
+
await this.sleep(postPause);
|
|
531
|
+
return { postActionPause: postPause };
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
async uploadFile(selector, filePath, options) {
|
|
535
|
+
return this.executeAction('uploadFile', selector, async () => {
|
|
536
|
+
const page = this.core.getActivePage();
|
|
537
|
+
const element = await this.finder.find(page, selector, {
|
|
538
|
+
timeout: options?.timeout ?? this.config.timeout,
|
|
539
|
+
});
|
|
540
|
+
await element.setInputFiles(filePath);
|
|
541
|
+
const postPause = this.engine.generatePostActionPause();
|
|
542
|
+
await this.sleep(postPause);
|
|
543
|
+
return { postActionPause: postPause };
|
|
544
|
+
}, filePath);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Click an element that triggers a download, wait for the download to
|
|
548
|
+
* complete, save it to `saveTo`, and return metadata about the file.
|
|
549
|
+
*
|
|
550
|
+
* Usage:
|
|
551
|
+
* const info = await bt.download('a.invoice-link', { saveTo: './invoice.pdf' });
|
|
552
|
+
* // info.path, info.size, info.suggestedFilename, info.url
|
|
553
|
+
*/
|
|
554
|
+
async download(selector, options) {
|
|
555
|
+
this.ensureLaunched();
|
|
556
|
+
const page = this.core.getActivePage();
|
|
557
|
+
const timeout = options.timeout ?? 30_000;
|
|
558
|
+
// Start waiting for the download BEFORE we click, so Playwright
|
|
559
|
+
// catches the event regardless of click-to-dialog timing.
|
|
560
|
+
const [download] = await Promise.all([
|
|
561
|
+
page.waitForEvent('download', { timeout }),
|
|
562
|
+
this.click(selector, { timeout }),
|
|
563
|
+
]);
|
|
564
|
+
await download.saveAs(options.saveTo);
|
|
565
|
+
const path = options.saveTo;
|
|
566
|
+
// Get the file size from Node's fs module.
|
|
567
|
+
const fs = await import('node:fs/promises');
|
|
568
|
+
const stat = await fs.stat(path);
|
|
569
|
+
return {
|
|
570
|
+
path,
|
|
571
|
+
size: stat.size,
|
|
572
|
+
suggestedFilename: download.suggestedFilename(),
|
|
573
|
+
url: download.url(),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// ── Data Extraction ──
|
|
577
|
+
async extractText(selector, options) {
|
|
578
|
+
this.ensureLaunched();
|
|
579
|
+
const page = this.core.getActivePage();
|
|
580
|
+
if (options?.multiple) {
|
|
581
|
+
const elements = await page.$$(selector);
|
|
582
|
+
const texts = [];
|
|
583
|
+
for (const el of elements) {
|
|
584
|
+
const text = await el.innerText();
|
|
585
|
+
texts.push(text);
|
|
586
|
+
}
|
|
587
|
+
return texts;
|
|
588
|
+
}
|
|
589
|
+
const element = await this.finder.find(page, selector);
|
|
590
|
+
return element.innerText();
|
|
591
|
+
}
|
|
592
|
+
async extractAttribute(selector, attribute) {
|
|
593
|
+
this.ensureLaunched();
|
|
594
|
+
const page = this.core.getActivePage();
|
|
595
|
+
const element = await this.finder.find(page, selector);
|
|
596
|
+
return element.getAttribute(attribute);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Find an element inside an open shadow root reachable from the page.
|
|
600
|
+
* Useful for modern web component libraries (Lit, Stencil, Material Web,
|
|
601
|
+
* Ionic). See `ElementFinder.findInShadowDom` for limitations on closed
|
|
602
|
+
* shadow roots.
|
|
603
|
+
*/
|
|
604
|
+
async findInShadowDom(cssSelector, options) {
|
|
605
|
+
this.ensureLaunched();
|
|
606
|
+
const page = this.core.getActivePage();
|
|
607
|
+
return this.finder.findInShadowDom(page, cssSelector, options);
|
|
608
|
+
}
|
|
609
|
+
async extractTable(selector) {
|
|
610
|
+
this.ensureLaunched();
|
|
611
|
+
const page = this.core.getActivePage();
|
|
612
|
+
return page.evaluate(`((sel) => {
|
|
613
|
+
const table = document.querySelector(sel);
|
|
614
|
+
if (!table) return [];
|
|
615
|
+
|
|
616
|
+
const headers = [];
|
|
617
|
+
const headerCells = table.querySelectorAll('thead th, tr:first-child th');
|
|
618
|
+
headerCells.forEach((th) => headers.push(th.textContent?.trim() ?? ''));
|
|
619
|
+
|
|
620
|
+
const rows = [];
|
|
621
|
+
const bodyRows = table.querySelectorAll('tbody tr, tr:not(:first-child)');
|
|
622
|
+
bodyRows.forEach((tr) => {
|
|
623
|
+
const cells = tr.querySelectorAll('td');
|
|
624
|
+
if (cells.length === 0) return;
|
|
625
|
+
const row = {};
|
|
626
|
+
cells.forEach((td, i) => {
|
|
627
|
+
const key = headers[i] ?? ('col' + i);
|
|
628
|
+
row[key] = td.textContent?.trim() ?? '';
|
|
629
|
+
});
|
|
630
|
+
rows.push(row);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
return rows;
|
|
634
|
+
})(${JSON.stringify(selector)})`);
|
|
635
|
+
}
|
|
636
|
+
async getPageContent(options) {
|
|
637
|
+
this.ensureLaunched();
|
|
638
|
+
return this.core.getPageContent(options);
|
|
639
|
+
}
|
|
640
|
+
// ── Waiting ──
|
|
641
|
+
async waitFor(selector, options) {
|
|
642
|
+
this.ensureLaunched();
|
|
643
|
+
const page = this.core.getActivePage();
|
|
644
|
+
const start = Date.now();
|
|
645
|
+
try {
|
|
646
|
+
const state = options?.hidden ? 'hidden' : (options?.visible !== false ? 'visible' : 'attached');
|
|
647
|
+
await page.waitForSelector(selector, {
|
|
648
|
+
timeout: options?.timeout ?? 30000,
|
|
649
|
+
state,
|
|
650
|
+
});
|
|
651
|
+
return { success: true, duration: Date.now() - start };
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
return {
|
|
655
|
+
success: false,
|
|
656
|
+
duration: Date.now() - start,
|
|
657
|
+
error: err instanceof Error ? err.message : String(err),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
async waitForNavigation(options) {
|
|
662
|
+
this.ensureLaunched();
|
|
663
|
+
const page = this.core.getActivePage();
|
|
664
|
+
await page.waitForLoadState(options?.waitUntil ?? 'domcontentloaded', {
|
|
665
|
+
timeout: options?.timeout ?? 30000,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// ── Screenshots ──
|
|
669
|
+
async screenshot(options) {
|
|
670
|
+
this.ensureLaunched();
|
|
671
|
+
return this.core.screenshot(options);
|
|
672
|
+
}
|
|
673
|
+
// ── Wait primitives (v0.3) ──
|
|
674
|
+
/**
|
|
675
|
+
* Wait until the page has been "stable" for a configurable window.
|
|
676
|
+
* Stability means: no network requests fired for `networkIdleMs`, and
|
|
677
|
+
* no DOM mutations observed for `domIdleMs`. Replaces fixed sleep
|
|
678
|
+
* waits with a real signal, so you don't wait longer than necessary.
|
|
679
|
+
*
|
|
680
|
+
* Returns an object describing how long the wait took and why it
|
|
681
|
+
* completed (both-idle / network-only / dom-only / timeout).
|
|
682
|
+
*/
|
|
683
|
+
async waitForStable(options = {}) {
|
|
684
|
+
this.ensureLaunched();
|
|
685
|
+
const networkIdleMs = options.networkIdleMs ?? 500;
|
|
686
|
+
const domIdleMs = options.domIdleMs ?? 500;
|
|
687
|
+
const maxMs = options.maxMs ?? 10_000;
|
|
688
|
+
const pollMs = options.pollMs ?? 100;
|
|
689
|
+
const page = this.core.getActivePage();
|
|
690
|
+
const startedAt = Date.now();
|
|
691
|
+
// Set up tracking in-page. We attach a MutationObserver that records
|
|
692
|
+
// the time of the last DOM mutation, and we use the Performance API
|
|
693
|
+
// to find the time of the most recent network response.
|
|
694
|
+
await page.evaluate(`(() => {
|
|
695
|
+
if (window.__btStableTracker) return;
|
|
696
|
+
const tracker = { lastDomMutation: Date.now(), observer: null };
|
|
697
|
+
tracker.observer = new MutationObserver(() => { tracker.lastDomMutation = Date.now(); });
|
|
698
|
+
tracker.observer.observe(document.documentElement, {
|
|
699
|
+
childList: true, subtree: true, attributes: true, characterData: true,
|
|
700
|
+
});
|
|
701
|
+
window.__btStableTracker = tracker;
|
|
702
|
+
})()`);
|
|
703
|
+
try {
|
|
704
|
+
while (Date.now() - startedAt < maxMs) {
|
|
705
|
+
const state = await page.evaluate(`(() => {
|
|
706
|
+
const now = Date.now();
|
|
707
|
+
const tracker = window.__btStableTracker;
|
|
708
|
+
const domIdle = tracker ? now - tracker.lastDomMutation : 0;
|
|
709
|
+
const entries = performance.getEntriesByType('resource');
|
|
710
|
+
let lastNetwork = 0;
|
|
711
|
+
for (let i = entries.length - 1; i >= 0 && i >= entries.length - 20; i--) {
|
|
712
|
+
const e = entries[i];
|
|
713
|
+
const end = e.responseEnd || e.startTime + e.duration;
|
|
714
|
+
if (end > lastNetwork) lastNetwork = end;
|
|
715
|
+
}
|
|
716
|
+
const perfNow = performance.now();
|
|
717
|
+
const networkIdle = lastNetwork === 0 ? 999999 : perfNow - lastNetwork;
|
|
718
|
+
return { domIdle, networkIdle };
|
|
719
|
+
})()`);
|
|
720
|
+
if (state.domIdle >= domIdleMs && state.networkIdle >= networkIdleMs) {
|
|
721
|
+
return { durationMs: Date.now() - startedAt, reason: 'both-idle' };
|
|
722
|
+
}
|
|
723
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
724
|
+
}
|
|
725
|
+
return { durationMs: Date.now() - startedAt, reason: 'timeout' };
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
await page.evaluate(`(() => {
|
|
729
|
+
if (window.__btStableTracker && window.__btStableTracker.observer) {
|
|
730
|
+
window.__btStableTracker.observer.disconnect();
|
|
731
|
+
delete window.__btStableTracker;
|
|
732
|
+
}
|
|
733
|
+
})()`).catch(() => { });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Wait for a text string to appear in the body innerText. Case-sensitive
|
|
738
|
+
* substring match by default. Use for server-rendered confirmations,
|
|
739
|
+
* OCR completion messages, and similar async content.
|
|
740
|
+
*/
|
|
741
|
+
async waitForText(text, options = {}) {
|
|
742
|
+
this.ensureLaunched();
|
|
743
|
+
const timeout = options.timeout ?? 15_000;
|
|
744
|
+
const pollMs = options.pollMs ?? 250;
|
|
745
|
+
const page = this.core.getActivePage();
|
|
746
|
+
const startedAt = Date.now();
|
|
747
|
+
const escaped = text.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
|
748
|
+
while (Date.now() - startedAt < timeout) {
|
|
749
|
+
const present = await page.evaluate(`(document.body ? document.body.innerText : '').includes(\`${escaped}\`)`);
|
|
750
|
+
if (present)
|
|
751
|
+
return { durationMs: Date.now() - startedAt, found: true };
|
|
752
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
753
|
+
}
|
|
754
|
+
return { durationMs: Date.now() - startedAt, found: false };
|
|
755
|
+
}
|
|
756
|
+
// ── Diagnostic primitives (v0.3) ──
|
|
757
|
+
/**
|
|
758
|
+
* Inspect an element: exists, visible, text, tag, key attributes,
|
|
759
|
+
* bounding box. One call replaces several hand-written executeJS
|
|
760
|
+
* queries.
|
|
761
|
+
*/
|
|
762
|
+
async inspect(selector) {
|
|
763
|
+
this.ensureLaunched();
|
|
764
|
+
const page = this.core.getActivePage();
|
|
765
|
+
const result = await page.evaluate(`((sel) => {
|
|
766
|
+
const el = document.querySelector(sel);
|
|
767
|
+
if (!el) return { exists: false, visible: false };
|
|
768
|
+
const rect = el.getBoundingClientRect();
|
|
769
|
+
const style = window.getComputedStyle(el);
|
|
770
|
+
// Fixed/sticky elements have offsetParent === null but can still
|
|
771
|
+
// be visible. The correct visibility check is: display !== none,
|
|
772
|
+
// visibility !== hidden, opacity > 0, and bounding box has size.
|
|
773
|
+
const visible = !!(
|
|
774
|
+
rect.width > 0 &&
|
|
775
|
+
rect.height > 0 &&
|
|
776
|
+
style.display !== 'none' &&
|
|
777
|
+
style.visibility !== 'hidden' &&
|
|
778
|
+
parseFloat(style.opacity || '1') > 0
|
|
779
|
+
);
|
|
780
|
+
const attrs = {};
|
|
781
|
+
for (const a of el.attributes || []) {
|
|
782
|
+
attrs[a.name] = a.value.slice(0, 200);
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
exists: true,
|
|
786
|
+
visible: visible,
|
|
787
|
+
tagName: el.tagName,
|
|
788
|
+
text: (el.textContent || '').trim().slice(0, 300),
|
|
789
|
+
attributes: attrs,
|
|
790
|
+
boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
791
|
+
};
|
|
792
|
+
})(${JSON.stringify(selector)})`);
|
|
793
|
+
return result;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* List options in an Angular/React-style custom dropdown that uses
|
|
797
|
+
* the `{baseId}_option-{n}` pattern. Returns `[{id, text}]`. Used
|
|
798
|
+
* heavily in Anthem/Okta forms.
|
|
799
|
+
*
|
|
800
|
+
* If `baseId` is given, matches options whose id starts with
|
|
801
|
+
* `${baseId}_option-`. Otherwise, tries to infer from the provided
|
|
802
|
+
* button selector by looking for `_button` suffix.
|
|
803
|
+
*/
|
|
804
|
+
async listOptions(buttonSelectorOrBaseId) {
|
|
805
|
+
this.ensureLaunched();
|
|
806
|
+
const page = this.core.getActivePage();
|
|
807
|
+
// Derive base id: "#foo_button" → "foo"
|
|
808
|
+
let baseId = buttonSelectorOrBaseId;
|
|
809
|
+
if (baseId.startsWith('#'))
|
|
810
|
+
baseId = baseId.slice(1);
|
|
811
|
+
if (baseId.endsWith('_button'))
|
|
812
|
+
baseId = baseId.slice(0, -7);
|
|
813
|
+
const list = await page.evaluate(`((base) => {
|
|
814
|
+
const prefix = base + '_option-';
|
|
815
|
+
const els = document.querySelectorAll('[id^="' + prefix + '"]');
|
|
816
|
+
const out = [];
|
|
817
|
+
for (const el of els) {
|
|
818
|
+
const id = el.id;
|
|
819
|
+
if (id.endsWith('_text')) continue;
|
|
820
|
+
out.push({ id, text: (el.textContent || '').trim().slice(0, 200) });
|
|
821
|
+
}
|
|
822
|
+
return out;
|
|
823
|
+
})(${JSON.stringify(baseId)})`);
|
|
824
|
+
return list;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Return Performance API resource entries since `sinceMs` milliseconds
|
|
828
|
+
* ago, optionally filtered by a substring or regex match on the URL.
|
|
829
|
+
*/
|
|
830
|
+
async networkSince(sinceMs, pattern) {
|
|
831
|
+
this.ensureLaunched();
|
|
832
|
+
const page = this.core.getActivePage();
|
|
833
|
+
const patternSource = pattern instanceof RegExp ? pattern.source : pattern ?? '';
|
|
834
|
+
const isRegex = pattern instanceof RegExp;
|
|
835
|
+
const results = await page.evaluate(`((sinceMs, patternSource, isRegex) => {
|
|
836
|
+
const re = patternSource ? (isRegex ? new RegExp(patternSource) : null) : null;
|
|
837
|
+
const entries = performance.getEntriesByType('resource');
|
|
838
|
+
const cutoff = performance.now() - sinceMs;
|
|
839
|
+
const out = [];
|
|
840
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
841
|
+
const e = entries[i];
|
|
842
|
+
if (e.startTime < cutoff) break;
|
|
843
|
+
if (patternSource) {
|
|
844
|
+
const match = re ? re.test(e.name) : e.name.includes(patternSource);
|
|
845
|
+
if (!match) continue;
|
|
846
|
+
}
|
|
847
|
+
out.push({
|
|
848
|
+
name: e.name,
|
|
849
|
+
startTime: Math.round(e.startTime),
|
|
850
|
+
durationMs: Math.round(e.duration),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
return out.reverse();
|
|
854
|
+
})(${JSON.stringify(sinceMs)}, ${JSON.stringify(patternSource)}, ${JSON.stringify(isRegex)})`);
|
|
855
|
+
return results;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Boolean convenience: did a network request matching `pattern` fire
|
|
859
|
+
* in the last `sinceMs` milliseconds? Critical for "did my submit
|
|
860
|
+
* actually reach the server?" diagnostics.
|
|
861
|
+
*/
|
|
862
|
+
async didRequestFireSince(pattern, sinceMs) {
|
|
863
|
+
const matches = await this.networkSince(sinceMs, pattern);
|
|
864
|
+
return matches.length > 0;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Proactively hide fixed/sticky overlays that commonly block clicks:
|
|
868
|
+
* chat widgets, cookie banners, "we value your feedback" modals,
|
|
869
|
+
* cookie consent, newsletter signups. Returns the count of hidden
|
|
870
|
+
* elements and a list of CSS selectors that were affected.
|
|
871
|
+
*/
|
|
872
|
+
async dismissOverlays() {
|
|
873
|
+
this.ensureLaunched();
|
|
874
|
+
const page = this.core.getActivePage();
|
|
875
|
+
return page.evaluate(`(() => {
|
|
876
|
+
const PATTERNS = [
|
|
877
|
+
'[class*="chat-widget"]', '[class*="ChatWidget"]', '[class*="chat-bubble"]',
|
|
878
|
+
'[class*="intercom"]', '[id*="intercom"]',
|
|
879
|
+
'[class*="drift-frame"]', '[class*="zendesk"]', '[class*="zopim"]',
|
|
880
|
+
'[class*="cookie-banner"]', '[class*="cookie-consent"]', '[class*="cookieBanner"]',
|
|
881
|
+
'[class*="consent-banner"]', '[id*="cookie"]',
|
|
882
|
+
'[class*="onetrust"]', '[id*="onetrust"]',
|
|
883
|
+
'[class*="newsletter-modal"]', '[class*="newsletter-popup"]',
|
|
884
|
+
'[class*="feedback-widget"]', '[id*="medallia"]', '[class*="medallia"]',
|
|
885
|
+
'[class*="notification-banner"]',
|
|
886
|
+
];
|
|
887
|
+
const hiddenSelectors = [];
|
|
888
|
+
let count = 0;
|
|
889
|
+
for (const sel of PATTERNS) {
|
|
890
|
+
const els = document.querySelectorAll(sel);
|
|
891
|
+
for (const el of els) {
|
|
892
|
+
const style = window.getComputedStyle(el);
|
|
893
|
+
if (style.position === 'fixed' || style.position === 'sticky' || style.position === 'absolute') {
|
|
894
|
+
el.style.setProperty('display', 'none', 'important');
|
|
895
|
+
count++;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (count > 0) hiddenSelectors.push(sel);
|
|
899
|
+
}
|
|
900
|
+
return { hidden: count, selectors: hiddenSelectors };
|
|
901
|
+
})()`);
|
|
902
|
+
}
|
|
903
|
+
// ── Iframe Support ──
|
|
904
|
+
async frame(selector) {
|
|
905
|
+
this.ensureLaunched();
|
|
906
|
+
const frame = await this.core.getFrame(selector);
|
|
907
|
+
return new BlackTipFrame(frame, this.engine, this.finder, this.logger);
|
|
908
|
+
}
|
|
909
|
+
async frames() {
|
|
910
|
+
this.ensureLaunched();
|
|
911
|
+
return this.core.getFrames();
|
|
912
|
+
}
|
|
913
|
+
// ── Tab Management ──
|
|
914
|
+
async getTabs() {
|
|
915
|
+
return this.core.getTabs();
|
|
916
|
+
}
|
|
917
|
+
async newTab(url) {
|
|
918
|
+
return this.core.newTab(url);
|
|
919
|
+
}
|
|
920
|
+
async switchTab(index) {
|
|
921
|
+
return this.core.switchTab(index);
|
|
922
|
+
}
|
|
923
|
+
async closeTab(index) {
|
|
924
|
+
return this.core.closeTab(index);
|
|
925
|
+
}
|
|
926
|
+
// ── Session Management ──
|
|
927
|
+
async newContext() {
|
|
928
|
+
return this.core.newContext();
|
|
929
|
+
}
|
|
930
|
+
async cookies() {
|
|
931
|
+
return this.core.cookies();
|
|
932
|
+
}
|
|
933
|
+
async setCookies(cookies) {
|
|
934
|
+
return this.core.setCookies(cookies);
|
|
935
|
+
}
|
|
936
|
+
async clearCookies() {
|
|
937
|
+
return this.core.clearCookies();
|
|
938
|
+
}
|
|
939
|
+
// ── JavaScript Execution ──
|
|
940
|
+
async executeJS(script) {
|
|
941
|
+
this.ensureLaunched();
|
|
942
|
+
return this.core.executeJS(script);
|
|
943
|
+
}
|
|
944
|
+
// ── Profile Management ──
|
|
945
|
+
createProfile(name, config) {
|
|
946
|
+
const base = HUMAN_PROFILE;
|
|
947
|
+
this.customProfiles.set(name, { ...base, ...config });
|
|
948
|
+
}
|
|
949
|
+
getProfile(name) {
|
|
950
|
+
return this.resolveProfile(name);
|
|
951
|
+
}
|
|
952
|
+
listProfiles() {
|
|
953
|
+
return ['human', 'scraper', ...this.customProfiles.keys()];
|
|
954
|
+
}
|
|
955
|
+
deleteProfile(name) {
|
|
956
|
+
if (name === 'human' || name === 'scraper') {
|
|
957
|
+
throw new Error('Cannot delete built-in profiles');
|
|
958
|
+
}
|
|
959
|
+
this.customProfiles.delete(name);
|
|
960
|
+
}
|
|
961
|
+
// ── Pool Factory ──
|
|
962
|
+
/**
|
|
963
|
+
* Returns detailed usage instructions for AI agents.
|
|
964
|
+
* Call this on first use to understand how to drive BlackTip correctly.
|
|
965
|
+
*/
|
|
966
|
+
static agentGuide() {
|
|
967
|
+
return `
|
|
968
|
+
BlackTip Agent Guide
|
|
969
|
+
====================
|
|
970
|
+
|
|
971
|
+
BlackTip is a stealth browser instrument. YOU are the agent — BlackTip provides
|
|
972
|
+
the hands, you provide the brain. Every action is wrapped in human-like behavior
|
|
973
|
+
that defeats bot detection.
|
|
974
|
+
|
|
975
|
+
CRITICAL RULES:
|
|
976
|
+
|
|
977
|
+
1. READ INPUT DOCUMENTS FIRST — If the user gives you a PDF/file to submit,
|
|
978
|
+
read it BEFORE starting the browser. Extract names, dates, codes. Never guess.
|
|
979
|
+
|
|
980
|
+
2. SCREENSHOT BEFORE EVERY DECISION — After each action, take a screenshot
|
|
981
|
+
(bt.screenshot({path:'shot.png'})) and examine it before deciding the next
|
|
982
|
+
step. Do NOT pre-script sequences.
|
|
983
|
+
|
|
984
|
+
3. USE clickText() FOR VISIBLE TEXT — bt.clickText("Submit", {nth: 0}) uses
|
|
985
|
+
Playwright's locator API. Works with React, Angular, Okta, and custom
|
|
986
|
+
components. Prefer this over bt.click() with CSS selectors when the text
|
|
987
|
+
is visible on the page.
|
|
988
|
+
|
|
989
|
+
4. USE paste:true FOR FORMS — bt.type(selector, text, {paste: true}) is fast
|
|
990
|
+
and works with React/Angular synthetic events. Use this for form filling.
|
|
991
|
+
|
|
992
|
+
5. USE executeJS() TO INSPECT — When selectors are unclear, inspect the DOM:
|
|
993
|
+
await bt.executeJS("JSON.stringify([...document.querySelectorAll('button')].map(b=>b.textContent.trim()))")
|
|
994
|
+
|
|
995
|
+
6. ANGULAR/CUSTOM DROPDOWNS — These are NOT <select> elements. Pattern:
|
|
996
|
+
- Click the combobox button: bt.click("#dropdown_button")
|
|
997
|
+
- Inspect options via executeJS
|
|
998
|
+
- Click the option: bt.click("#dropdown_option-0")
|
|
999
|
+
|
|
1000
|
+
7. FAIL FAST — Use timeout: 10000 and retryAttempts: 2. If an action fails,
|
|
1001
|
+
inspect the page rather than retrying for minutes.
|
|
1002
|
+
|
|
1003
|
+
8. ASK THE USER WHEN UNCERTAIN — Don't guess patient names, account types, or
|
|
1004
|
+
form selections. Ask.
|
|
1005
|
+
|
|
1006
|
+
SERVER MODE (recommended):
|
|
1007
|
+
const bt = new BlackTip({ timeout: 10000, retryAttempts: 2 });
|
|
1008
|
+
await bt.serve(9779);
|
|
1009
|
+
// Then send commands via TCP: bt.send("await bt.navigate('...')")
|
|
1010
|
+
|
|
1011
|
+
AVAILABLE METHODS:
|
|
1012
|
+
bt.navigate(url) — Go to URL
|
|
1013
|
+
bt.click(selector) — Click by CSS/XPath
|
|
1014
|
+
bt.clickText(text, {nth?, exact?}) — Click by visible text
|
|
1015
|
+
bt.clickRole(role, {name?}) — Click by ARIA role
|
|
1016
|
+
bt.type(selector, text, {paste?}) — Type into input
|
|
1017
|
+
bt.scroll({direction, amount}) — Scroll page
|
|
1018
|
+
bt.screenshot({path}) — Capture page
|
|
1019
|
+
bt.waitFor(selector, {timeout}) — Wait for element
|
|
1020
|
+
bt.extractText(selector) — Get text content
|
|
1021
|
+
bt.executeJS(script) — Run JS in page
|
|
1022
|
+
bt.uploadFile(selector, path) — Upload file
|
|
1023
|
+
bt.frame(selector) — Iframe context
|
|
1024
|
+
bt.serve(port) — Start TCP server
|
|
1025
|
+
|
|
1026
|
+
COMMON MISTAKES (don't repeat):
|
|
1027
|
+
- Pre-scripting entire flows (breaks on first unexpected state)
|
|
1028
|
+
- Using executeJS("el.click()") for React/Okta buttons (use clickText)
|
|
1029
|
+
- Not reading screenshots between actions
|
|
1030
|
+
- Guessing form values instead of reading source documents
|
|
1031
|
+
- Long timeouts (30s+) — fail fast, inspect, adapt
|
|
1032
|
+
`.trim();
|
|
1033
|
+
}
|
|
1034
|
+
static async pool(count, config) {
|
|
1035
|
+
const instances = [];
|
|
1036
|
+
for (let i = 0; i < count; i++) {
|
|
1037
|
+
const bt = new BlackTip(config);
|
|
1038
|
+
await bt.launch();
|
|
1039
|
+
instances.push(bt);
|
|
1040
|
+
}
|
|
1041
|
+
return instances;
|
|
1042
|
+
}
|
|
1043
|
+
// ── Server Mode ──
|
|
1044
|
+
/**
|
|
1045
|
+
* Start a TCP command server. Agents connect and send JS commands that
|
|
1046
|
+
* execute with `bt` in scope. Each command returns a result and saves
|
|
1047
|
+
* a screenshot to `screenshotPath`.
|
|
1048
|
+
*
|
|
1049
|
+
* Usage from CLI: node -e "net.createConnection(port).write('await bt.click(\"#btn\")\n__END__\n')"
|
|
1050
|
+
* Or use the built-in CLI: npx blacktip serve
|
|
1051
|
+
*/
|
|
1052
|
+
async serve(port = 9779, screenshotPath = 'shot.png') {
|
|
1053
|
+
if (!this.launched)
|
|
1054
|
+
await this.launch();
|
|
1055
|
+
const DELIM = '\n__END__\n';
|
|
1056
|
+
const bt = this;
|
|
1057
|
+
// Pause registry: when a command inside fn() calls bt.pauseForInput(),
|
|
1058
|
+
// it registers a pending entry and returns a promise that resolves
|
|
1059
|
+
// when a subsequent RESUME command provides the value. The serve
|
|
1060
|
+
// handler listens for 'btPause' events (emitted by pauseForInput)
|
|
1061
|
+
// and forwards them to the socket so the client knows to prompt the
|
|
1062
|
+
// user.
|
|
1063
|
+
const pending = new Map();
|
|
1064
|
+
bt._pauseRegistry = pending;
|
|
1065
|
+
const server = createServer((socket) => {
|
|
1066
|
+
let buf = '';
|
|
1067
|
+
socket.on('data', (chunk) => {
|
|
1068
|
+
buf += chunk.toString();
|
|
1069
|
+
while (buf.includes(DELIM)) {
|
|
1070
|
+
const idx = buf.indexOf(DELIM);
|
|
1071
|
+
const cmd = buf.slice(0, idx);
|
|
1072
|
+
buf = buf.slice(idx + DELIM.length);
|
|
1073
|
+
void handleCommand(cmd, socket);
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
async function buildBundle(result, startedAt, includeScreenshot) {
|
|
1078
|
+
const bundle = {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
durationMs: Date.now() - startedAt,
|
|
1081
|
+
};
|
|
1082
|
+
if (result !== undefined)
|
|
1083
|
+
bundle.result = result;
|
|
1084
|
+
try {
|
|
1085
|
+
const page = bt.core.getActivePage();
|
|
1086
|
+
bundle.url = page.url();
|
|
1087
|
+
try {
|
|
1088
|
+
bundle.title = await page.title();
|
|
1089
|
+
}
|
|
1090
|
+
catch { /* title may fail on about:blank */ }
|
|
1091
|
+
}
|
|
1092
|
+
catch { /* browser may be closed */ }
|
|
1093
|
+
if (includeScreenshot) {
|
|
1094
|
+
try {
|
|
1095
|
+
const shot = await bt.screenshot({ path: screenshotPath });
|
|
1096
|
+
bundle.screenshotPath = screenshotPath;
|
|
1097
|
+
bundle.screenshotB64 = shot.data.toString('base64');
|
|
1098
|
+
bundle.screenshotBytes = shot.data.length;
|
|
1099
|
+
}
|
|
1100
|
+
catch { /* screenshot may fail */ }
|
|
1101
|
+
}
|
|
1102
|
+
return bundle;
|
|
1103
|
+
}
|
|
1104
|
+
async function handleCommand(cmd, socket) {
|
|
1105
|
+
const startedAt = Date.now();
|
|
1106
|
+
try {
|
|
1107
|
+
if (cmd === 'QUIT') {
|
|
1108
|
+
socket.write('bye' + DELIM);
|
|
1109
|
+
await bt.close();
|
|
1110
|
+
server.close();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
// RESUME protocol: "RESUME <id>\n<value>" resolves a pending pause.
|
|
1114
|
+
if (cmd.startsWith('RESUME ')) {
|
|
1115
|
+
const rest = cmd.slice(7);
|
|
1116
|
+
const newlineIdx = rest.indexOf('\n');
|
|
1117
|
+
const id = newlineIdx === -1 ? rest.trim() : rest.slice(0, newlineIdx).trim();
|
|
1118
|
+
const value = newlineIdx === -1 ? '' : rest.slice(newlineIdx + 1);
|
|
1119
|
+
const entry = pending.get(id);
|
|
1120
|
+
if (entry) {
|
|
1121
|
+
pending.delete(id);
|
|
1122
|
+
entry.resolve(value);
|
|
1123
|
+
socket.write(JSON.stringify({ ok: true, resumed: id }) + DELIM);
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
socket.write(JSON.stringify({ ok: false, error: `No pending pause with id ${id}` }) + DELIM);
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
// LIST_PENDING protocol: returns current paused commands.
|
|
1131
|
+
if (cmd === 'LIST_PENDING') {
|
|
1132
|
+
const list = [...pending.entries()].map(([id, e]) => ({ id, prompt: e.prompt }));
|
|
1133
|
+
socket.write(JSON.stringify({ ok: true, pending: list }) + DELIM);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
// BATCH protocol: "BATCH\n<json array of command strings>" runs
|
|
1137
|
+
// each command sequentially and returns an array of bundles.
|
|
1138
|
+
if (cmd.startsWith('BATCH\n')) {
|
|
1139
|
+
const jsonPart = cmd.slice(6);
|
|
1140
|
+
let commands;
|
|
1141
|
+
try {
|
|
1142
|
+
commands = JSON.parse(jsonPart);
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
socket.write(JSON.stringify({ ok: false, error: 'BATCH payload must be a JSON array of command strings' }) + DELIM);
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const bundles = [];
|
|
1149
|
+
for (const c of commands) {
|
|
1150
|
+
const perStart = Date.now();
|
|
1151
|
+
try {
|
|
1152
|
+
const fn = new Function('bt', `return (async () => { ${c} })();`);
|
|
1153
|
+
const result = await fn(bt);
|
|
1154
|
+
bundles.push(await buildBundle(result, perStart, true));
|
|
1155
|
+
}
|
|
1156
|
+
catch (e) {
|
|
1157
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1158
|
+
const errBundle = await buildBundle(undefined, perStart, true);
|
|
1159
|
+
errBundle.ok = false;
|
|
1160
|
+
errBundle.error = msg;
|
|
1161
|
+
bundles.push(errBundle);
|
|
1162
|
+
// Stop on first failure — caller can see what failed.
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
socket.write(JSON.stringify({ ok: true, bundles }) + DELIM);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
// Listen for pause events emitted by pauseForInput during the
|
|
1170
|
+
// command's execution. Forward them to the client as separate
|
|
1171
|
+
// JSON frames; the client sends RESUME to continue.
|
|
1172
|
+
const pauseListener = (info) => {
|
|
1173
|
+
socket.write(JSON.stringify({
|
|
1174
|
+
ok: true,
|
|
1175
|
+
paused: true,
|
|
1176
|
+
pauseId: info.id,
|
|
1177
|
+
prompt: info.prompt,
|
|
1178
|
+
}) + DELIM);
|
|
1179
|
+
};
|
|
1180
|
+
bt.on('btPause', pauseListener);
|
|
1181
|
+
try {
|
|
1182
|
+
const fn = new Function('bt', `return (async () => { ${cmd} })();`);
|
|
1183
|
+
const result = await fn(bt);
|
|
1184
|
+
const bundle = await buildBundle(result, startedAt, true);
|
|
1185
|
+
socket.write(JSON.stringify(bundle) + DELIM);
|
|
1186
|
+
}
|
|
1187
|
+
finally {
|
|
1188
|
+
bt.off('btPause', pauseListener);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
catch (e) {
|
|
1192
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1193
|
+
const errBundle = await buildBundle(undefined, startedAt, true)
|
|
1194
|
+
.catch(() => ({ ok: false }));
|
|
1195
|
+
errBundle.ok = false;
|
|
1196
|
+
errBundle.error = msg;
|
|
1197
|
+
socket.write(JSON.stringify(errBundle) + DELIM);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return new Promise((resolve) => {
|
|
1201
|
+
server.listen(port, '127.0.0.1', () => {
|
|
1202
|
+
this.logger.info(`BlackTip server listening on port ${port}`);
|
|
1203
|
+
resolve(server);
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Pause execution inside a command and wait for a value to be sent via
|
|
1209
|
+
* the RESUME protocol. Only usable when running under serve mode.
|
|
1210
|
+
*
|
|
1211
|
+
* Usage (from agent side):
|
|
1212
|
+
* const value = await bt.pauseForInput({ prompt: "Enter SMS code" });
|
|
1213
|
+
* await bt.type('input[name="credentials.passcode"]', value, { paste: true });
|
|
1214
|
+
*
|
|
1215
|
+
* The serve mode forwards a `{paused:true, pauseId, prompt}` frame to
|
|
1216
|
+
* the client. When the client sends `RESUME <id>\n<value>`, this call
|
|
1217
|
+
* resolves with the value. If `validate` is provided and the value
|
|
1218
|
+
* doesn't match, the call rejects with a validation error.
|
|
1219
|
+
*/
|
|
1220
|
+
async pauseForInput(options) {
|
|
1221
|
+
const registry = this._pauseRegistry;
|
|
1222
|
+
if (!registry) {
|
|
1223
|
+
throw new Error('pauseForInput can only be called while running under serve mode');
|
|
1224
|
+
}
|
|
1225
|
+
const id = `pause-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
|
1226
|
+
const waitPromise = new Promise((resolve, reject) => {
|
|
1227
|
+
registry.set(id, { resolve, reject, prompt: options.prompt });
|
|
1228
|
+
if (options.timeoutMs) {
|
|
1229
|
+
setTimeout(() => {
|
|
1230
|
+
if (registry.has(id)) {
|
|
1231
|
+
registry.delete(id);
|
|
1232
|
+
reject(new Error(`pauseForInput timed out after ${options.timeoutMs}ms`));
|
|
1233
|
+
}
|
|
1234
|
+
}, options.timeoutMs);
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
// Signal the serve handler to forward a pause frame to the client.
|
|
1238
|
+
this.emit('btPause', { id, prompt: options.prompt });
|
|
1239
|
+
const value = await waitPromise;
|
|
1240
|
+
// Validate if requested.
|
|
1241
|
+
if (options.validate) {
|
|
1242
|
+
const valid = typeof options.validate === 'function'
|
|
1243
|
+
? options.validate(value)
|
|
1244
|
+
: options.validate.test(value);
|
|
1245
|
+
if (!valid) {
|
|
1246
|
+
throw new Error(`pauseForInput received invalid value: ${value}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return value;
|
|
1250
|
+
}
|
|
1251
|
+
// ── Internal: Action Execution with Retry ──
|
|
1252
|
+
async executeAction(actionName, target, fn, value, importance) {
|
|
1253
|
+
this.ensureLaunched();
|
|
1254
|
+
const maxAttempts = this.config.retryAttempts ?? 5;
|
|
1255
|
+
const start = Date.now();
|
|
1256
|
+
let lastError = '';
|
|
1257
|
+
// Pre-action pause — scaled by the caller's importance hint so
|
|
1258
|
+
// submit/payment/confirm actions get the long-tail hesitation that
|
|
1259
|
+
// behavioral biometrics systems expect to see.
|
|
1260
|
+
const preActionPause = this.engine.generatePreActionPause(importance ?? 'normal');
|
|
1261
|
+
await this.sleep(preActionPause);
|
|
1262
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1263
|
+
const strategy = RETRY_STRATEGIES[Math.min(attempt - 1, RETRY_STRATEGIES.length - 1)];
|
|
1264
|
+
try {
|
|
1265
|
+
// Apply retry strategy
|
|
1266
|
+
if (attempt > 1) {
|
|
1267
|
+
await this.applyRetryStrategy(strategy);
|
|
1268
|
+
this.emit('retry', {
|
|
1269
|
+
timestamp: new Date().toISOString(),
|
|
1270
|
+
action: actionName,
|
|
1271
|
+
target,
|
|
1272
|
+
attempt,
|
|
1273
|
+
maxAttempts,
|
|
1274
|
+
strategy,
|
|
1275
|
+
error: lastError,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const behavioral = await fn();
|
|
1279
|
+
const duration = Date.now() - start;
|
|
1280
|
+
const event = {
|
|
1281
|
+
timestamp: new Date().toISOString(),
|
|
1282
|
+
action: actionName,
|
|
1283
|
+
target,
|
|
1284
|
+
value,
|
|
1285
|
+
outcome: 'success',
|
|
1286
|
+
duration,
|
|
1287
|
+
retries: attempt - 1,
|
|
1288
|
+
behavioral: {
|
|
1289
|
+
preActionPause,
|
|
1290
|
+
...behavioral,
|
|
1291
|
+
},
|
|
1292
|
+
};
|
|
1293
|
+
this.emit('action', event);
|
|
1294
|
+
return {
|
|
1295
|
+
success: true,
|
|
1296
|
+
duration,
|
|
1297
|
+
retries: attempt - 1,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
1302
|
+
this.logger.warn(`Action ${actionName} failed (attempt ${attempt}/${maxAttempts})`, {
|
|
1303
|
+
target,
|
|
1304
|
+
error: lastError,
|
|
1305
|
+
strategy,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
// All retries exhausted
|
|
1310
|
+
const duration = Date.now() - start;
|
|
1311
|
+
const page = this.core.getActivePage();
|
|
1312
|
+
let screenshot;
|
|
1313
|
+
try {
|
|
1314
|
+
screenshot = await page.screenshot();
|
|
1315
|
+
}
|
|
1316
|
+
catch { /* screenshot may fail if browser crashed */ }
|
|
1317
|
+
const errorEvent = {
|
|
1318
|
+
timestamp: new Date().toISOString(),
|
|
1319
|
+
code: 'ELEMENT_NOT_FOUND',
|
|
1320
|
+
message: lastError,
|
|
1321
|
+
url: page.url(),
|
|
1322
|
+
action: actionName,
|
|
1323
|
+
attempts: maxAttempts,
|
|
1324
|
+
screenshot,
|
|
1325
|
+
};
|
|
1326
|
+
this.emit('error', errorEvent);
|
|
1327
|
+
const event = {
|
|
1328
|
+
timestamp: new Date().toISOString(),
|
|
1329
|
+
action: actionName,
|
|
1330
|
+
target,
|
|
1331
|
+
value,
|
|
1332
|
+
outcome: 'failure',
|
|
1333
|
+
duration,
|
|
1334
|
+
retries: maxAttempts - 1,
|
|
1335
|
+
error: lastError,
|
|
1336
|
+
};
|
|
1337
|
+
this.emit('action', event);
|
|
1338
|
+
return {
|
|
1339
|
+
success: false,
|
|
1340
|
+
duration,
|
|
1341
|
+
retries: maxAttempts - 1,
|
|
1342
|
+
error: lastError,
|
|
1343
|
+
errorCode: 'ELEMENT_NOT_FOUND',
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
async applyRetryStrategy(strategy) {
|
|
1347
|
+
const page = this.core.getActivePage();
|
|
1348
|
+
switch (strategy) {
|
|
1349
|
+
case 'standard':
|
|
1350
|
+
// Just retry
|
|
1351
|
+
break;
|
|
1352
|
+
case 'wait':
|
|
1353
|
+
await this.sleep(2000 + Math.random() * 3000);
|
|
1354
|
+
break;
|
|
1355
|
+
case 'reload':
|
|
1356
|
+
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
1357
|
+
await this.sleep(1000);
|
|
1358
|
+
break;
|
|
1359
|
+
case 'altSelector':
|
|
1360
|
+
// The element finder will try alternative strategies on next attempt
|
|
1361
|
+
break;
|
|
1362
|
+
case 'scroll':
|
|
1363
|
+
await page.mouse.wheel(0, 300);
|
|
1364
|
+
await this.sleep(500);
|
|
1365
|
+
break;
|
|
1366
|
+
case 'clearOverlays':
|
|
1367
|
+
// Try to dismiss common overlays
|
|
1368
|
+
await page.evaluate(`(() => {
|
|
1369
|
+
const overlays = document.querySelectorAll('[class*="overlay"], [class*="modal"], [class*="popup"], [class*="cookie"], [class*="banner"]');
|
|
1370
|
+
overlays.forEach((el) => {
|
|
1371
|
+
const style = window.getComputedStyle(el);
|
|
1372
|
+
if (style.position === 'fixed' || style.position === 'sticky') {
|
|
1373
|
+
el.style.display = 'none';
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
})()`);
|
|
1377
|
+
await this.sleep(500);
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// ── Internal: Mouse Movement ──
|
|
1382
|
+
mouseX = 0;
|
|
1383
|
+
mouseY = 0;
|
|
1384
|
+
async getMousePosition() {
|
|
1385
|
+
return { x: this.mouseX, y: this.mouseY };
|
|
1386
|
+
}
|
|
1387
|
+
async performMouseMove(from, to) {
|
|
1388
|
+
const page = this.core.getActivePage();
|
|
1389
|
+
const steps = this.engine.generateMousePath(from, to);
|
|
1390
|
+
const moveStart = Date.now();
|
|
1391
|
+
let pathLength = 0;
|
|
1392
|
+
let prevPoint = from;
|
|
1393
|
+
for (const step of steps) {
|
|
1394
|
+
await this.sleep(step.delay);
|
|
1395
|
+
await page.mouse.move(step.x, step.y);
|
|
1396
|
+
// Calculate path length
|
|
1397
|
+
const dx = step.x - prevPoint.x;
|
|
1398
|
+
const dy = step.y - prevPoint.y;
|
|
1399
|
+
pathLength += Math.sqrt(dx * dx + dy * dy);
|
|
1400
|
+
prevPoint = step;
|
|
1401
|
+
}
|
|
1402
|
+
this.mouseX = to.x;
|
|
1403
|
+
this.mouseY = to.y;
|
|
1404
|
+
return {
|
|
1405
|
+
mousePathLength: Math.round(pathLength),
|
|
1406
|
+
mouseMoveDuration: Date.now() - moveStart,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
// ── Internal: Utilities ──
|
|
1410
|
+
resolveProfile(nameOrConfig) {
|
|
1411
|
+
if (typeof nameOrConfig === 'object')
|
|
1412
|
+
return nameOrConfig;
|
|
1413
|
+
switch (nameOrConfig) {
|
|
1414
|
+
case 'human': return HUMAN_PROFILE;
|
|
1415
|
+
case 'scraper': return SCRAPER_PROFILE;
|
|
1416
|
+
default: {
|
|
1417
|
+
const custom = this.customProfiles.get(nameOrConfig);
|
|
1418
|
+
if (custom)
|
|
1419
|
+
return custom;
|
|
1420
|
+
this.logger.warn(`Unknown profile "${nameOrConfig}", falling back to "human"`);
|
|
1421
|
+
return HUMAN_PROFILE;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
ensureLaunched() {
|
|
1426
|
+
if (!this.launched) {
|
|
1427
|
+
throw new Error('Browser not launched. Call launch() first.');
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
sleep(ms) {
|
|
1431
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.round(ms))));
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Frame context — same action methods as BlackTip, scoped to an iframe.
|
|
1436
|
+
*/
|
|
1437
|
+
export class BlackTipFrame {
|
|
1438
|
+
frame;
|
|
1439
|
+
engine;
|
|
1440
|
+
finder;
|
|
1441
|
+
logger;
|
|
1442
|
+
constructor(frame, engine, finder, logger) {
|
|
1443
|
+
this.frame = frame;
|
|
1444
|
+
this.engine = engine;
|
|
1445
|
+
this.finder = finder;
|
|
1446
|
+
this.logger = logger;
|
|
1447
|
+
}
|
|
1448
|
+
async click(selector, options) {
|
|
1449
|
+
const start = Date.now();
|
|
1450
|
+
const element = await this.finder.find(this.frame, selector, { visible: true });
|
|
1451
|
+
const box = await this.finder.getBoundingBox(element);
|
|
1452
|
+
if (!box)
|
|
1453
|
+
throw new Error('Element has no bounding box');
|
|
1454
|
+
const targetPos = this.engine.generateClickPosition(box);
|
|
1455
|
+
// Human-like pause before click
|
|
1456
|
+
await this.sleep(this.engine.generatePreActionPause());
|
|
1457
|
+
const dwell = this.engine.generateClickDwell();
|
|
1458
|
+
await this.sleep(dwell);
|
|
1459
|
+
await element.click({
|
|
1460
|
+
button: options?.button ?? 'left',
|
|
1461
|
+
clickCount: options?.count ?? 1,
|
|
1462
|
+
});
|
|
1463
|
+
await this.sleep(this.engine.generatePostActionPause());
|
|
1464
|
+
return { success: true, duration: Date.now() - start, retries: 0 };
|
|
1465
|
+
}
|
|
1466
|
+
async type(selector, text, options) {
|
|
1467
|
+
const start = Date.now();
|
|
1468
|
+
const element = await this.finder.find(this.frame, selector, { visible: true });
|
|
1469
|
+
// Click element first to give it focus
|
|
1470
|
+
await element.click();
|
|
1471
|
+
await this.sleep(this.engine.generatePreActionPause() * 0.3);
|
|
1472
|
+
// Ported L001 fix from BlackTip.type(): use Control+A+Backspace via the
|
|
1473
|
+
// page-level keyboard (rather than element.fill('') which can fire a
|
|
1474
|
+
// premature change event in some frameworks), and use the frame's
|
|
1475
|
+
// native keyboard.type which dispatches the full keydown/keypress/
|
|
1476
|
+
// input/keyup cycle — the input event is what React/Angular listen
|
|
1477
|
+
// for inside iframes like Stripe Elements and Braintree Hosted Fields.
|
|
1478
|
+
const pageForKeyboard = this.frame.page();
|
|
1479
|
+
if (options?.clearFirst) {
|
|
1480
|
+
await pageForKeyboard.keyboard.press('Control+a');
|
|
1481
|
+
await this.sleep(30 + Math.random() * 50);
|
|
1482
|
+
await pageForKeyboard.keyboard.press('Backspace');
|
|
1483
|
+
await this.sleep(50);
|
|
1484
|
+
}
|
|
1485
|
+
const shouldPaste = options?.paste ?? this.engine.shouldPaste(text);
|
|
1486
|
+
if (shouldPaste) {
|
|
1487
|
+
// Fill path: Playwright's fill correctly dispatches input events on
|
|
1488
|
+
// form controls, even inside cross-origin iframes. Fast path for
|
|
1489
|
+
// paste-threshold text.
|
|
1490
|
+
await element.fill(text);
|
|
1491
|
+
await this.sleep(100 + Math.random() * 200);
|
|
1492
|
+
}
|
|
1493
|
+
else {
|
|
1494
|
+
// Keystroke path: clear via Control+A+Backspace (framework-safe) then
|
|
1495
|
+
// use page.keyboard.type which fires the full event cycle per char.
|
|
1496
|
+
await pageForKeyboard.keyboard.press('Control+a');
|
|
1497
|
+
await this.sleep(30 + Math.random() * 50);
|
|
1498
|
+
await pageForKeyboard.keyboard.press('Backspace');
|
|
1499
|
+
await this.sleep(50);
|
|
1500
|
+
const sequence = this.engine.generateTypingSequence(text);
|
|
1501
|
+
for (const keystroke of sequence) {
|
|
1502
|
+
if (keystroke.isTypo && keystroke.correctionSequence) {
|
|
1503
|
+
await pageForKeyboard.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
|
|
1504
|
+
for (const correction of keystroke.correctionSequence) {
|
|
1505
|
+
await this.sleep(correction.delay);
|
|
1506
|
+
if (correction.key === 'Backspace') {
|
|
1507
|
+
await pageForKeyboard.keyboard.press('Backspace');
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
await pageForKeyboard.keyboard.type(correction.key, { delay: correction.holdDuration });
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
else {
|
|
1515
|
+
await this.sleep(keystroke.delay);
|
|
1516
|
+
await pageForKeyboard.keyboard.type(keystroke.key, { delay: keystroke.holdDuration });
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
// Verify value — fall back to fill() if framework didn't register.
|
|
1520
|
+
// Same safety net as BlackTip.type(): if Stripe's element.js intercepts
|
|
1521
|
+
// keyboard events and doesn't update the underlying input, fill()
|
|
1522
|
+
// forces the value through via the DOM setter.
|
|
1523
|
+
try {
|
|
1524
|
+
const val = await element.inputValue();
|
|
1525
|
+
if (val !== text)
|
|
1526
|
+
await element.fill(text);
|
|
1527
|
+
}
|
|
1528
|
+
catch { /* non-input elements */ }
|
|
1529
|
+
}
|
|
1530
|
+
if (options?.pressEnter) {
|
|
1531
|
+
await this.sleep(200 + Math.random() * 300);
|
|
1532
|
+
await element.press('Enter');
|
|
1533
|
+
}
|
|
1534
|
+
return { success: true, duration: Date.now() - start, retries: 0 };
|
|
1535
|
+
}
|
|
1536
|
+
async extractText(selector) {
|
|
1537
|
+
const element = await this.finder.find(this.frame, selector);
|
|
1538
|
+
return element.innerText();
|
|
1539
|
+
}
|
|
1540
|
+
async hover(selector) {
|
|
1541
|
+
const start = Date.now();
|
|
1542
|
+
const element = await this.finder.find(this.frame, selector, { visible: true });
|
|
1543
|
+
await this.sleep(this.engine.generatePreActionPause());
|
|
1544
|
+
await element.hover();
|
|
1545
|
+
await this.sleep(this.engine.generateClickDwell());
|
|
1546
|
+
return { success: true, duration: Date.now() - start, retries: 0 };
|
|
1547
|
+
}
|
|
1548
|
+
async select(selector, value) {
|
|
1549
|
+
const start = Date.now();
|
|
1550
|
+
await this.frame.selectOption(selector, { value, label: value });
|
|
1551
|
+
return { success: true, duration: Date.now() - start, retries: 0 };
|
|
1552
|
+
}
|
|
1553
|
+
async waitFor(selector, options) {
|
|
1554
|
+
const start = Date.now();
|
|
1555
|
+
try {
|
|
1556
|
+
await this.frame.waitForSelector(selector, {
|
|
1557
|
+
timeout: options?.timeout ?? 30000,
|
|
1558
|
+
state: options?.visible !== false ? 'visible' : 'attached',
|
|
1559
|
+
});
|
|
1560
|
+
return { success: true, duration: Date.now() - start };
|
|
1561
|
+
}
|
|
1562
|
+
catch (err) {
|
|
1563
|
+
return {
|
|
1564
|
+
success: false,
|
|
1565
|
+
duration: Date.now() - start,
|
|
1566
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
sleep(ms) {
|
|
1571
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.round(ms))));
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
//# sourceMappingURL=blacktip.js.map
|