leapfrog-mcp 0.6.2 → 0.6.3
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/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// ─── Humanized Scrolling ───────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Inertial scroll simulation with ramp-up and momentum decay, variable
|
|
4
|
+
// scroll amounts, read-pause-scroll cycles, overshoot/scroll-back, and
|
|
5
|
+
// cursor-scroll correlation (sympathetic mouse movement).
|
|
6
|
+
//
|
|
7
|
+
// Ported from the validated humanize.js prototype (tested on 3090 box,
|
|
8
|
+
// statistically verified).
|
|
9
|
+
//
|
|
10
|
+
// Integration point: import { humanScroll } from "./humanize-scroll.js"
|
|
11
|
+
// then call humanScroll.scroll(page, distance) inside the act tool handler
|
|
12
|
+
// (src/index.ts) as an alternative to page.mouse.wheel().
|
|
13
|
+
//
|
|
14
|
+
// Standalone module — no cross-dependencies on other humanize modules.
|
|
15
|
+
import { gaussianRandom, clamp, humanDelay, sleep, isHumanizeEnabled, normalRandom } from "./humanize-utils.js";
|
|
16
|
+
// ─── Scroll Plan ───────────────────────────────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* Generate a human-like scroll plan for a given pixel distance.
|
|
19
|
+
* Simulates inertial scrolling with ease-out momentum decay.
|
|
20
|
+
*
|
|
21
|
+
* The scroll is broken into increments that start small (ramp-up / finger
|
|
22
|
+
* contact), hit peak velocity, then taper off (friction / momentum loss),
|
|
23
|
+
* similar to real touchpad scrolling.
|
|
24
|
+
*
|
|
25
|
+
* @param distance - Total scroll distance in pixels (positive = down)
|
|
26
|
+
* @param opts - Configuration options
|
|
27
|
+
* @returns Array of scroll steps with deltas and delays
|
|
28
|
+
*/
|
|
29
|
+
export function humanScrollPlan(distance, opts = {}) {
|
|
30
|
+
const maxIncrement = opts.maxIncrement ?? 120;
|
|
31
|
+
const friction = opts.friction ?? 0.82;
|
|
32
|
+
const direction = distance >= 0 ? 1 : -1;
|
|
33
|
+
let remaining = Math.abs(distance);
|
|
34
|
+
const steps = [];
|
|
35
|
+
let cumulative = 0;
|
|
36
|
+
// Phase 1: Ramp-up (2-3 small increments to simulate finger contact)
|
|
37
|
+
const rampSteps = Math.floor(Math.random() * 2) + 2;
|
|
38
|
+
for (let i = 0; i < rampSteps && remaining > 0; i++) {
|
|
39
|
+
const fraction = (i + 1) / (rampSteps + 1);
|
|
40
|
+
const base = maxIncrement * fraction * 0.6;
|
|
41
|
+
const delta = Math.min(Math.round(base + gaussianRandom(0, 5)), remaining);
|
|
42
|
+
remaining -= delta;
|
|
43
|
+
cumulative += delta * direction;
|
|
44
|
+
steps.push({
|
|
45
|
+
delta: delta * direction,
|
|
46
|
+
delay: humanDelay(12, 25),
|
|
47
|
+
cumulative,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Phase 2: Momentum decay (ease-out)
|
|
51
|
+
let velocity = maxIncrement + gaussianRandom(0, 15);
|
|
52
|
+
while (remaining > 2) {
|
|
53
|
+
velocity *= friction + gaussianRandom(0, 0.03);
|
|
54
|
+
velocity = clamp(velocity, 3, maxIncrement * 1.2);
|
|
55
|
+
const delta = Math.min(Math.round(velocity), remaining);
|
|
56
|
+
remaining -= delta;
|
|
57
|
+
cumulative += delta * direction;
|
|
58
|
+
steps.push({
|
|
59
|
+
delta: delta * direction,
|
|
60
|
+
delay: humanDelay(14, 30),
|
|
61
|
+
cumulative,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// Mop up any remainder
|
|
65
|
+
if (remaining > 0) {
|
|
66
|
+
cumulative += remaining * direction;
|
|
67
|
+
steps.push({
|
|
68
|
+
delta: remaining * direction,
|
|
69
|
+
delay: humanDelay(20, 50),
|
|
70
|
+
cumulative,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return steps;
|
|
74
|
+
}
|
|
75
|
+
// ─── HumanScroll Class ─────────────────────────────────────────────────────
|
|
76
|
+
export class HumanScroll {
|
|
77
|
+
/**
|
|
78
|
+
* Whether humanized scrolling is enabled.
|
|
79
|
+
* Returns false unless LEAP_HUMANIZE=true or LEAP_HUMANIZE=1 is set.
|
|
80
|
+
*/
|
|
81
|
+
isEnabled() {
|
|
82
|
+
return isHumanizeEnabled();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Scroll the page by the given pixel distance using a human-like
|
|
86
|
+
* ramp-up + momentum decay pattern.
|
|
87
|
+
*
|
|
88
|
+
* Each step in the scroll plan is dispatched as a mouse wheel event
|
|
89
|
+
* with an inter-step delay to simulate real scrolling inertia.
|
|
90
|
+
*
|
|
91
|
+
* Enhancements over basic scroll:
|
|
92
|
+
* - Variable scroll amounts (normalRandom instead of fixed)
|
|
93
|
+
* - Read-pause-scroll cycles (800-3000ms reading gaps between chunks)
|
|
94
|
+
* - 10% chance of overshoot + scroll-back correction
|
|
95
|
+
* - Sympathetic cursor micro-movements during scrolling
|
|
96
|
+
*
|
|
97
|
+
* No-op if humanization is disabled.
|
|
98
|
+
*
|
|
99
|
+
* @param page - Playwright page instance
|
|
100
|
+
* @param distance - Total scroll distance in pixels (positive = down, negative = up)
|
|
101
|
+
*/
|
|
102
|
+
async scroll(page, distance) {
|
|
103
|
+
if (!this.isEnabled())
|
|
104
|
+
return;
|
|
105
|
+
// Variable scroll amount: apply Gaussian variation to the requested distance.
|
|
106
|
+
// Humans rarely scroll an exact number of pixels — add +/-15% noise.
|
|
107
|
+
const variableDistance = distance === 0
|
|
108
|
+
? 0
|
|
109
|
+
: Math.round(distance + gaussianRandom(0, Math.abs(distance) * 0.05));
|
|
110
|
+
// Break long scrolls into read-pause-scroll chunks.
|
|
111
|
+
// Each chunk is a "scroll gesture" followed by a reading pause.
|
|
112
|
+
const chunkSize = Math.round(Math.abs(normalRandom(350, 100)));
|
|
113
|
+
const effectiveChunk = Math.max(chunkSize, 80); // floor at 80px
|
|
114
|
+
const totalAbs = Math.abs(variableDistance);
|
|
115
|
+
const direction = variableDistance >= 0 ? 1 : -1;
|
|
116
|
+
let scrolled = 0;
|
|
117
|
+
let isFirstChunk = true;
|
|
118
|
+
while (scrolled < totalAbs) {
|
|
119
|
+
// Read-pause: wait between scroll gestures (skip before first chunk)
|
|
120
|
+
if (!isFirstChunk) {
|
|
121
|
+
const readPause = humanDelay(800, 3000);
|
|
122
|
+
await sleep(readPause);
|
|
123
|
+
}
|
|
124
|
+
isFirstChunk = false;
|
|
125
|
+
const chunkRemaining = Math.min(effectiveChunk, totalAbs - scrolled);
|
|
126
|
+
const plan = humanScrollPlan(chunkRemaining * direction);
|
|
127
|
+
// Sympathetic cursor movement: small random mouse moves during scrolling
|
|
128
|
+
// to simulate hand-on-trackpad/mouse-wheel posture
|
|
129
|
+
for (const step of plan) {
|
|
130
|
+
await sleep(step.delay);
|
|
131
|
+
await page.mouse.wheel(0, step.delta);
|
|
132
|
+
// Cursor-scroll correlation: ~30% chance of small sympathetic mouse move
|
|
133
|
+
if (Math.random() < 0.3) {
|
|
134
|
+
const sympatheticX = gaussianRandom(0, 1.5);
|
|
135
|
+
const sympatheticY = gaussianRandom(0, 2.0);
|
|
136
|
+
try {
|
|
137
|
+
await page.mouse.move(400 + sympatheticX, // approximate center-ish x with jitter
|
|
138
|
+
300 + sympatheticY, // approximate center-ish y with jitter
|
|
139
|
+
{ steps: 1 });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Mouse move during scroll can fail if page is navigating — safe to ignore
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
scrolled += chunkRemaining;
|
|
147
|
+
}
|
|
148
|
+
// Overshoot + scroll-back: 10% of scroll operations overshoot then correct
|
|
149
|
+
if (totalAbs > 0 && Math.random() < 0.10) {
|
|
150
|
+
const overshootAmount = Math.round(50 + Math.random() * 50); // 50-100px
|
|
151
|
+
const overshootPlan = humanScrollPlan(overshootAmount * direction);
|
|
152
|
+
for (const step of overshootPlan) {
|
|
153
|
+
await sleep(step.delay);
|
|
154
|
+
await page.mouse.wheel(0, step.delta);
|
|
155
|
+
}
|
|
156
|
+
// Brief pause before correction (realizing overshoot)
|
|
157
|
+
await sleep(humanDelay(150, 400));
|
|
158
|
+
// Scroll back to correct
|
|
159
|
+
const correctionPlan = humanScrollPlan(overshootAmount * -direction);
|
|
160
|
+
for (const step of correctionPlan) {
|
|
161
|
+
await sleep(step.delay);
|
|
162
|
+
await page.mouse.wheel(0, step.delta);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Scroll with variable distance. Uses normalRandom(350, 100) to determine
|
|
168
|
+
* a natural scroll distance, then executes the humanized scroll.
|
|
169
|
+
*
|
|
170
|
+
* Useful when the caller just wants "scroll down some" without a specific
|
|
171
|
+
* pixel target.
|
|
172
|
+
*
|
|
173
|
+
* @param page - Playwright page instance
|
|
174
|
+
* @param direction - "down" (positive) or "up" (negative)
|
|
175
|
+
*/
|
|
176
|
+
async scrollVariable(page, direction = "down") {
|
|
177
|
+
if (!this.isEnabled())
|
|
178
|
+
return;
|
|
179
|
+
const amount = Math.round(Math.abs(normalRandom(350, 100)));
|
|
180
|
+
const distance = direction === "down" ? amount : -amount;
|
|
181
|
+
await this.scroll(page, distance);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export const humanScroll = new HumanScroll();
|
|
185
|
+
export default humanScroll;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
export interface KeystrokeEvent {
|
|
3
|
+
/** The character to press (or '\b' for backspace) */
|
|
4
|
+
char: string;
|
|
5
|
+
/** Delay before this keystroke in ms (flight time / inter-key interval) */
|
|
6
|
+
delay: number;
|
|
7
|
+
/** Key dwell time in ms (how long the key is held down) */
|
|
8
|
+
dwell: number;
|
|
9
|
+
/** Whether this keystroke is a typo */
|
|
10
|
+
isTypo: boolean;
|
|
11
|
+
/** If non-null, the correct character that was retyped after a typo correction */
|
|
12
|
+
correction: string | null;
|
|
13
|
+
/** Whether this keystroke should overlap with the previous key release (rollover) */
|
|
14
|
+
rollover: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface TypeOptions {
|
|
17
|
+
/** Median inter-key delay in ms (log-normal center). Default: 200 */
|
|
18
|
+
baseDelay?: number;
|
|
19
|
+
/** Base probability of a typo per alphabetic character. Default: 0.02 */
|
|
20
|
+
typoRate?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Generate a sequence of keystroke events with humanized timing and typo simulation.
|
|
24
|
+
*
|
|
25
|
+
* Timing model (research-backed):
|
|
26
|
+
* - Base delay: Log-normal with 200ms median, sigma 0.45 (right-skewed, heavy-tailed)
|
|
27
|
+
* - Bigram-aware: hand-alternation pairs 30% faster, same-finger pairs 40% slower
|
|
28
|
+
* - Longer pauses at word boundaries (~250ms median) and after punctuation
|
|
29
|
+
* - Burst typing: occasional faster sequences (50% of base delay)
|
|
30
|
+
* - Key dwell time: log-normal with 70ms median (50-130ms typical range)
|
|
31
|
+
* - Rollover: next key pressed before previous released on fast hand-alternation bigrams
|
|
32
|
+
*
|
|
33
|
+
* Typo model:
|
|
34
|
+
* - 2% base rate for alphabetic characters
|
|
35
|
+
* - Higher rate (4%) for characters with many adjacent keys
|
|
36
|
+
* - Typo character is chosen from QWERTY adjacency map
|
|
37
|
+
* - Correction: backspace + retype after a short pause (200-400ms)
|
|
38
|
+
*/
|
|
39
|
+
export declare function humanTypeString(text: string, opts?: TypeOptions): KeystrokeEvent[];
|
|
40
|
+
export declare class HumanTyping {
|
|
41
|
+
/**
|
|
42
|
+
* Whether humanized typing is enabled.
|
|
43
|
+
* Returns false unless LEAP_HUMANIZE=true or LEAP_HUMANIZE=1 is set.
|
|
44
|
+
*/
|
|
45
|
+
isEnabled(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Type text with humanized inter-key delays, key dwell time, rollover,
|
|
48
|
+
* occasional typos, and corrections.
|
|
49
|
+
*
|
|
50
|
+
* Uses page.keyboard.down() / up() for full timing control:
|
|
51
|
+
* - Per-key flight time (variable IKI via log-normal distribution)
|
|
52
|
+
* - Per-key dwell time (variable hold duration)
|
|
53
|
+
* - Rollover simulation (next keydown before previous keyup)
|
|
54
|
+
*
|
|
55
|
+
* No-op if humanization is disabled.
|
|
56
|
+
*/
|
|
57
|
+
typeText(page: Page, text: string, opts?: TypeOptions): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export declare const humanTyping: HumanTyping;
|
|
60
|
+
export default humanTyping;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// ─── Humanized Typing ──────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Log-normal–distributed inter-key delays, key dwell time, rollover typing,
|
|
4
|
+
// bigram-aware timing, burst typing, and a QWERTY-adjacency typo model with
|
|
5
|
+
// backspace correction.
|
|
6
|
+
//
|
|
7
|
+
// Research basis: 03-typing-humanization.md
|
|
8
|
+
// - Log-normal distribution (right-skewed, heavy-tailed — matches 136M
|
|
9
|
+
// keystrokes study, Monaco et al. 2021 log-logistic finding)
|
|
10
|
+
// - 200ms median IKI (~52 WPM, population mean)
|
|
11
|
+
// - Key dwell time 70ms median (50-130ms range)
|
|
12
|
+
// - Rollover typing on hand-alternation bigrams
|
|
13
|
+
// - Bigram-aware speed multipliers (hand alternation, same-finger)
|
|
14
|
+
//
|
|
15
|
+
// Integration point: import { humanTyping } from "./humanize-typing.js"
|
|
16
|
+
// then call humanTyping.typeText(page, text) inside the act tool handler
|
|
17
|
+
// (src/index.ts) as an alternative to page.keyboard.type().
|
|
18
|
+
//
|
|
19
|
+
// Standalone module — no cross-dependencies on other humanize modules.
|
|
20
|
+
import { humanDelay, logNormalDelay, sleep, isHumanizeEnabled } from "./humanize-utils.js";
|
|
21
|
+
// ─── QWERTY Adjacency Map ──────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* QWERTY adjacency map for simulating realistic typos.
|
|
24
|
+
* Each key maps to its physically adjacent keys on a standard QWERTY layout.
|
|
25
|
+
*/
|
|
26
|
+
const QWERTY_ADJACENT = {
|
|
27
|
+
q: ["w", "a"], w: ["q", "e", "a", "s"], e: ["w", "r", "s", "d"],
|
|
28
|
+
r: ["e", "t", "d", "f"], t: ["r", "y", "f", "g"], y: ["t", "u", "g", "h"],
|
|
29
|
+
u: ["y", "i", "h", "j"], i: ["u", "o", "j", "k"], o: ["i", "p", "k", "l"],
|
|
30
|
+
p: ["o", "l"],
|
|
31
|
+
a: ["q", "w", "s", "z"], s: ["a", "w", "e", "d", "z", "x"],
|
|
32
|
+
d: ["s", "e", "r", "f", "x", "c"], f: ["d", "r", "t", "g", "c", "v"],
|
|
33
|
+
g: ["f", "t", "y", "h", "v", "b"], h: ["g", "y", "u", "j", "b", "n"],
|
|
34
|
+
j: ["h", "u", "i", "k", "n", "m"], k: ["j", "i", "o", "l", "m"],
|
|
35
|
+
l: ["k", "o", "p"],
|
|
36
|
+
z: ["a", "s", "x"], x: ["z", "s", "d", "c"], c: ["x", "d", "f", "v"],
|
|
37
|
+
v: ["c", "f", "g", "b"], b: ["v", "g", "h", "n"], n: ["b", "h", "j", "m"],
|
|
38
|
+
m: ["n", "j", "k"],
|
|
39
|
+
};
|
|
40
|
+
// ─── Bigram Timing ─────────────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Hand-alternation bigrams — typed fastest because both hands work in parallel.
|
|
43
|
+
* (Salthouse 1986: 30-60ms faster than same-hand bigrams)
|
|
44
|
+
*/
|
|
45
|
+
const ALTERNATING_BIGRAMS = new Set([
|
|
46
|
+
"th", "ht", "di", "id", "en", "ne", "ri", "ir", "to", "ot",
|
|
47
|
+
"do", "od", "gu", "ug", "ha", "ah", "fi", "if", "pe", "ep",
|
|
48
|
+
"wo", "ow", "bi", "ib", "tu", "ut", "ry", "yr", "ck", "kc",
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Same-finger bigrams — typed slowest because one finger must travel between rows.
|
|
52
|
+
*/
|
|
53
|
+
const SAME_FINGER_BIGRAMS = new Set([
|
|
54
|
+
"ed", "de", "nu", "un", "my", "ym", "ce", "ec", "rb", "br",
|
|
55
|
+
"ft", "tf", "ju", "uj", "ki", "ik", "lo", "ol", "ws", "sw",
|
|
56
|
+
]);
|
|
57
|
+
/**
|
|
58
|
+
* Return a speed multiplier based on the bigram (previous + current character).
|
|
59
|
+
* Hand alternation = 0.7x (30% faster), same finger = 1.4x (40% slower).
|
|
60
|
+
*/
|
|
61
|
+
function bigramMultiplier(prev, curr) {
|
|
62
|
+
const pair = (prev + curr).toLowerCase();
|
|
63
|
+
if (ALTERNATING_BIGRAMS.has(pair))
|
|
64
|
+
return 0.7;
|
|
65
|
+
if (SAME_FINGER_BIGRAMS.has(pair))
|
|
66
|
+
return 1.4;
|
|
67
|
+
return 1.0;
|
|
68
|
+
}
|
|
69
|
+
// ─── Dwell Time ────────────────────────────────────────────────────────────
|
|
70
|
+
/** Default dwell time config (research: 50-130ms range, median ~70ms) */
|
|
71
|
+
const DWELL_MEDIAN = 70;
|
|
72
|
+
const DWELL_SIGMA = 0.3;
|
|
73
|
+
/**
|
|
74
|
+
* Compute key dwell time (keydown-to-keyup duration) for a character.
|
|
75
|
+
* Backspace has a shorter dwell (~40ms). Regular keys ~70ms median.
|
|
76
|
+
*/
|
|
77
|
+
function computeDwell(char) {
|
|
78
|
+
if (char === "\b")
|
|
79
|
+
return logNormalDelay(40, 0.25, 20);
|
|
80
|
+
return logNormalDelay(DWELL_MEDIAN, DWELL_SIGMA, 30);
|
|
81
|
+
}
|
|
82
|
+
// ─── Keystroke Plan ────────────────────────────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Generate a sequence of keystroke events with humanized timing and typo simulation.
|
|
85
|
+
*
|
|
86
|
+
* Timing model (research-backed):
|
|
87
|
+
* - Base delay: Log-normal with 200ms median, sigma 0.45 (right-skewed, heavy-tailed)
|
|
88
|
+
* - Bigram-aware: hand-alternation pairs 30% faster, same-finger pairs 40% slower
|
|
89
|
+
* - Longer pauses at word boundaries (~250ms median) and after punctuation
|
|
90
|
+
* - Burst typing: occasional faster sequences (50% of base delay)
|
|
91
|
+
* - Key dwell time: log-normal with 70ms median (50-130ms typical range)
|
|
92
|
+
* - Rollover: next key pressed before previous released on fast hand-alternation bigrams
|
|
93
|
+
*
|
|
94
|
+
* Typo model:
|
|
95
|
+
* - 2% base rate for alphabetic characters
|
|
96
|
+
* - Higher rate (4%) for characters with many adjacent keys
|
|
97
|
+
* - Typo character is chosen from QWERTY adjacency map
|
|
98
|
+
* - Correction: backspace + retype after a short pause (200-400ms)
|
|
99
|
+
*/
|
|
100
|
+
export function humanTypeString(text, opts = {}) {
|
|
101
|
+
const baseDelay = opts.baseDelay ?? 200;
|
|
102
|
+
const typoRate = opts.typoRate ?? 0.02;
|
|
103
|
+
const events = [];
|
|
104
|
+
let inBurst = false;
|
|
105
|
+
let burstRemaining = 0;
|
|
106
|
+
let prevChar = "";
|
|
107
|
+
// ── Fatigue model ─────────────────────────────────────────────────
|
|
108
|
+
// After ~30 seconds of continuous typing (estimated from char count × avg IKI),
|
|
109
|
+
// gradually slow down by 10-15%. This simulates reduced motor performance
|
|
110
|
+
// during sustained input — a strong human signal absent in bots.
|
|
111
|
+
const FATIGUE_ONSET_CHARS = 150; // ~30s at 200ms avg IKI
|
|
112
|
+
const FATIGUE_MAX_MULTIPLIER = 1.15; // 15% slower at peak fatigue
|
|
113
|
+
const FATIGUE_RAMP_CHARS = 100; // chars over which fatigue ramps from 1.0 to max
|
|
114
|
+
// Log-normal sigma: controls the right-skew / heavy tail.
|
|
115
|
+
// 0.45 produces mode ~170ms, mean ~215ms with occasional spikes to 400-600ms.
|
|
116
|
+
const ikiSigma = 0.45;
|
|
117
|
+
for (let i = 0; i < text.length; i++) {
|
|
118
|
+
const char = text[i];
|
|
119
|
+
const lower = char.toLowerCase();
|
|
120
|
+
const isAlpha = /[a-z]/.test(lower);
|
|
121
|
+
// ── Fatigue multiplier ───────────────────────────────────────
|
|
122
|
+
let fatigueMultiplier = 1.0;
|
|
123
|
+
if (i > FATIGUE_ONSET_CHARS) {
|
|
124
|
+
const fatigueProgress = Math.min(1.0, (i - FATIGUE_ONSET_CHARS) / FATIGUE_RAMP_CHARS);
|
|
125
|
+
fatigueMultiplier = 1.0 + fatigueProgress * (FATIGUE_MAX_MULTIPLIER - 1.0);
|
|
126
|
+
}
|
|
127
|
+
// ── Compute inter-key delay (flight time) ──────────────────────
|
|
128
|
+
let delay;
|
|
129
|
+
if (burstRemaining > 0) {
|
|
130
|
+
// Burst: 50% of base delay, still log-normal distributed
|
|
131
|
+
delay = logNormalDelay(Math.round(baseDelay * 0.5), 0.35, 25);
|
|
132
|
+
burstRemaining--;
|
|
133
|
+
if (burstRemaining === 0)
|
|
134
|
+
inBurst = false;
|
|
135
|
+
}
|
|
136
|
+
else if (char === " ") {
|
|
137
|
+
// Word boundary: research shows ~250ms median inter-word pause
|
|
138
|
+
delay = logNormalDelay(250, 0.4, 80);
|
|
139
|
+
}
|
|
140
|
+
else if (/[.,;:!?]/.test(char)) {
|
|
141
|
+
// Punctuation: slightly longer pause
|
|
142
|
+
delay = logNormalDelay(200, 0.4, 80);
|
|
143
|
+
}
|
|
144
|
+
else if (char === "\n") {
|
|
145
|
+
// Line break: cognitive pause
|
|
146
|
+
delay = logNormalDelay(400, 0.5, 150);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Regular character: log-normal around baseDelay with bigram adjustment
|
|
150
|
+
const multiplier = prevChar ? bigramMultiplier(prevChar, lower) : 1.0;
|
|
151
|
+
delay = logNormalDelay(Math.round(baseDelay * multiplier), ikiSigma, 40);
|
|
152
|
+
}
|
|
153
|
+
// Apply fatigue to the computed delay
|
|
154
|
+
delay = Math.round(delay * fatigueMultiplier);
|
|
155
|
+
// ── Cognitive pause: 12% chance of a think-pause at word starts ──
|
|
156
|
+
if (prevChar === " " && isAlpha && !inBurst && Math.random() < 0.12) {
|
|
157
|
+
delay = logNormalDelay(400, 0.5, 150);
|
|
158
|
+
}
|
|
159
|
+
// ── Burst initiation: ~8% chance after spaces ──────────────────
|
|
160
|
+
if (!inBurst && char === " " && Math.random() < 0.08) {
|
|
161
|
+
inBurst = true;
|
|
162
|
+
burstRemaining = Math.floor(Math.random() * 6) + 3; // 3-8 chars
|
|
163
|
+
}
|
|
164
|
+
// ── Rollover detection ─────────────────────────────────────────
|
|
165
|
+
// On hand-alternation bigrams with short flight times, the next key
|
|
166
|
+
// is pressed before the previous key is released. This is a strong
|
|
167
|
+
// human signal that sequential bots lack.
|
|
168
|
+
let rollover = false;
|
|
169
|
+
if (prevChar && delay < 180) {
|
|
170
|
+
const pair = (prevChar + lower).toLowerCase();
|
|
171
|
+
if (ALTERNATING_BIGRAMS.has(pair) && Math.random() < 0.3) {
|
|
172
|
+
rollover = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ── Compute dwell time ─────────────────────────────────────────
|
|
176
|
+
const dwell = computeDwell(char);
|
|
177
|
+
// ── Typo check ─────────────────────────────────────────────────
|
|
178
|
+
const adjacents = QWERTY_ADJACENT[lower];
|
|
179
|
+
const effectiveRate = adjacents && adjacents.length >= 5 ? typoRate * 2 : typoRate;
|
|
180
|
+
if (isAlpha && Math.random() < effectiveRate && adjacents) {
|
|
181
|
+
const typoChar = adjacents[Math.floor(Math.random() * adjacents.length)];
|
|
182
|
+
const displayTypo = char === char.toUpperCase() ? typoChar.toUpperCase() : typoChar;
|
|
183
|
+
// Push the wrong keystroke
|
|
184
|
+
events.push({ char: displayTypo, delay, dwell: computeDwell(displayTypo), isTypo: true, correction: null, rollover });
|
|
185
|
+
// Push the backspace after a reaction delay
|
|
186
|
+
events.push({ char: "\b", delay: humanDelay(150, 350), dwell: computeDwell("\b"), isTypo: false, correction: null, rollover: false });
|
|
187
|
+
// Push the correct keystroke
|
|
188
|
+
events.push({ char, delay: humanDelay(50, 120), dwell, isTypo: false, correction: char, rollover: false });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
events.push({ char, delay, dwell, isTypo: false, correction: null, rollover });
|
|
192
|
+
}
|
|
193
|
+
prevChar = lower;
|
|
194
|
+
}
|
|
195
|
+
return events;
|
|
196
|
+
}
|
|
197
|
+
// ─── HumanTyping Class ─────────────────────────────────────────────────────
|
|
198
|
+
export class HumanTyping {
|
|
199
|
+
/**
|
|
200
|
+
* Whether humanized typing is enabled.
|
|
201
|
+
* Returns false unless LEAP_HUMANIZE=true or LEAP_HUMANIZE=1 is set.
|
|
202
|
+
*/
|
|
203
|
+
isEnabled() {
|
|
204
|
+
return isHumanizeEnabled();
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Type text with humanized inter-key delays, key dwell time, rollover,
|
|
208
|
+
* occasional typos, and corrections.
|
|
209
|
+
*
|
|
210
|
+
* Uses page.keyboard.down() / up() for full timing control:
|
|
211
|
+
* - Per-key flight time (variable IKI via log-normal distribution)
|
|
212
|
+
* - Per-key dwell time (variable hold duration)
|
|
213
|
+
* - Rollover simulation (next keydown before previous keyup)
|
|
214
|
+
*
|
|
215
|
+
* No-op if humanization is disabled.
|
|
216
|
+
*/
|
|
217
|
+
async typeText(page, text, opts) {
|
|
218
|
+
if (!this.isEnabled())
|
|
219
|
+
return;
|
|
220
|
+
const events = humanTypeString(text, opts);
|
|
221
|
+
let prevKeyDown = null;
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
const key = event.char === "\b" ? "Backspace" : event.char;
|
|
224
|
+
if (event.rollover && prevKeyDown) {
|
|
225
|
+
// Rollover: press next key BEFORE releasing previous key.
|
|
226
|
+
// Overlap by a portion of the flight time.
|
|
227
|
+
const overlap = logNormalDelay(25, 0.3, 10);
|
|
228
|
+
const preOverlapWait = Math.max(0, event.delay - overlap);
|
|
229
|
+
await sleep(preOverlapWait);
|
|
230
|
+
await page.keyboard.down(key);
|
|
231
|
+
await sleep(overlap);
|
|
232
|
+
await page.keyboard.up(prevKeyDown);
|
|
233
|
+
await sleep(event.dwell);
|
|
234
|
+
await page.keyboard.up(key);
|
|
235
|
+
prevKeyDown = null;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
// Release previous key if still held (shouldn't normally happen,
|
|
239
|
+
// but guard against it)
|
|
240
|
+
if (prevKeyDown) {
|
|
241
|
+
await page.keyboard.up(prevKeyDown);
|
|
242
|
+
prevKeyDown = null;
|
|
243
|
+
}
|
|
244
|
+
// Standard keystroke: wait flight time, then keydown, hold, keyup
|
|
245
|
+
await sleep(event.delay);
|
|
246
|
+
await page.keyboard.down(key);
|
|
247
|
+
await sleep(event.dwell);
|
|
248
|
+
await page.keyboard.up(key);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Ensure final key is released
|
|
252
|
+
if (prevKeyDown) {
|
|
253
|
+
await page.keyboard.up(prevKeyDown);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export const humanTyping = new HumanTyping();
|
|
258
|
+
export default humanTyping;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a normally-distributed random number using the Box-Muller transform.
|
|
3
|
+
* @param mean - Center of the distribution
|
|
4
|
+
* @param stddev - Standard deviation
|
|
5
|
+
* @returns A sample from N(mean, stddev^2)
|
|
6
|
+
*/
|
|
7
|
+
export declare function gaussianRandom(mean?: number, stddev?: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a value between min and max.
|
|
10
|
+
*/
|
|
11
|
+
export declare function clamp(val: number, min: number, max: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Generate a human-like delay (in ms) drawn from a Gaussian distribution.
|
|
14
|
+
* The result is clamped to [min, max] so it never produces absurd values.
|
|
15
|
+
*
|
|
16
|
+
* @param min - Minimum delay in ms
|
|
17
|
+
* @param max - Maximum delay in ms
|
|
18
|
+
* @returns Delay in ms, Gaussian-distributed around the midpoint
|
|
19
|
+
*/
|
|
20
|
+
export declare function humanDelay(min?: number, max?: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* Sleep for the specified number of milliseconds.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Generate a log-normal–distributed random delay.
|
|
27
|
+
*
|
|
28
|
+
* Log-normal produces the right-skewed, heavy-tailed shape that matches
|
|
29
|
+
* real keystroke timing data (Monaco et al., 2021). The distribution has
|
|
30
|
+
* a mode slightly below the median and occasional long-tail spikes — a
|
|
31
|
+
* signature of human typing that Gaussian distributions lack.
|
|
32
|
+
*
|
|
33
|
+
* @param median - Median delay in ms (the 50th-percentile value)
|
|
34
|
+
* @param sigma - Shape parameter controlling spread / skew (0.3–0.5 typical)
|
|
35
|
+
* @param floor - Hard floor in ms (below 40ms is physically impossible)
|
|
36
|
+
* @returns Delay in ms, rounded to an integer
|
|
37
|
+
*/
|
|
38
|
+
export declare function logNormalDelay(median: number, sigma: number, floor?: number): number;
|
|
39
|
+
/**
|
|
40
|
+
* Generate a normally-distributed random value with the given mean and stddev.
|
|
41
|
+
* Convenience alias for gaussianRandom — named to match the spec convention
|
|
42
|
+
* used across humanize modules (e.g., `normalRandom(350, 100)`).
|
|
43
|
+
*/
|
|
44
|
+
export declare function normalRandom(mean: number, stddev: number): number;
|
|
45
|
+
/**
|
|
46
|
+
* Compute a Gaussian-distributed click offset within an element's bounding box.
|
|
47
|
+
*
|
|
48
|
+
* Real humans click in a Gaussian distribution around the center of a target
|
|
49
|
+
* element, NOT at the exact geometric center. Dead-center clicks are a known
|
|
50
|
+
* bot detection fingerprint (DataDome, PerimeterX track this pattern).
|
|
51
|
+
*
|
|
52
|
+
* @param center - The geometric center of the element on this axis
|
|
53
|
+
* @param elementSize - The element's width or height on this axis
|
|
54
|
+
* @param elementStart - The element's x or y origin on this axis
|
|
55
|
+
* @returns A coordinate within the element bounds, Gaussian-distributed around center
|
|
56
|
+
*/
|
|
57
|
+
export declare function gaussianClickOffset(center: number, elementSize: number, elementStart: number): number;
|
|
58
|
+
/**
|
|
59
|
+
* Check whether the LEAP_HUMANIZE env var is enabled.
|
|
60
|
+
* Returns false unless LEAP_HUMANIZE is explicitly set to "true" or "1".
|
|
61
|
+
*/
|
|
62
|
+
export declare function isHumanizeEnabled(): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// ─── Humanize Shared Utilities ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Pure math primitives shared across all humanize-* modules.
|
|
4
|
+
// Zero external dependencies. All distributions are self-contained.
|
|
5
|
+
//
|
|
6
|
+
// Standalone module — no cross-dependencies on other leapfrog modules.
|
|
7
|
+
/**
|
|
8
|
+
* Generate a normally-distributed random number using the Box-Muller transform.
|
|
9
|
+
* @param mean - Center of the distribution
|
|
10
|
+
* @param stddev - Standard deviation
|
|
11
|
+
* @returns A sample from N(mean, stddev^2)
|
|
12
|
+
*/
|
|
13
|
+
export function gaussianRandom(mean = 0, stddev = 1) {
|
|
14
|
+
let u, v, s;
|
|
15
|
+
do {
|
|
16
|
+
u = Math.random() * 2 - 1;
|
|
17
|
+
v = Math.random() * 2 - 1;
|
|
18
|
+
s = u * u + v * v;
|
|
19
|
+
} while (s >= 1 || s === 0);
|
|
20
|
+
const mul = Math.sqrt(-2 * Math.log(s) / s);
|
|
21
|
+
return mean + stddev * u * mul;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Clamp a value between min and max.
|
|
25
|
+
*/
|
|
26
|
+
export function clamp(val, min, max) {
|
|
27
|
+
return Math.max(min, Math.min(max, val));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Generate a human-like delay (in ms) drawn from a Gaussian distribution.
|
|
31
|
+
* The result is clamped to [min, max] so it never produces absurd values.
|
|
32
|
+
*
|
|
33
|
+
* @param min - Minimum delay in ms
|
|
34
|
+
* @param max - Maximum delay in ms
|
|
35
|
+
* @returns Delay in ms, Gaussian-distributed around the midpoint
|
|
36
|
+
*/
|
|
37
|
+
export function humanDelay(min = 50, max = 200) {
|
|
38
|
+
const mean = (min + max) / 2;
|
|
39
|
+
const stddev = (max - min) / 6; // 99.7% of values fall within [min, max]
|
|
40
|
+
return Math.round(clamp(gaussianRandom(mean, stddev), min, max));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Sleep for the specified number of milliseconds.
|
|
44
|
+
*/
|
|
45
|
+
export function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Generate a log-normal–distributed random delay.
|
|
50
|
+
*
|
|
51
|
+
* Log-normal produces the right-skewed, heavy-tailed shape that matches
|
|
52
|
+
* real keystroke timing data (Monaco et al., 2021). The distribution has
|
|
53
|
+
* a mode slightly below the median and occasional long-tail spikes — a
|
|
54
|
+
* signature of human typing that Gaussian distributions lack.
|
|
55
|
+
*
|
|
56
|
+
* @param median - Median delay in ms (the 50th-percentile value)
|
|
57
|
+
* @param sigma - Shape parameter controlling spread / skew (0.3–0.5 typical)
|
|
58
|
+
* @param floor - Hard floor in ms (below 40ms is physically impossible)
|
|
59
|
+
* @returns Delay in ms, rounded to an integer
|
|
60
|
+
*/
|
|
61
|
+
export function logNormalDelay(median, sigma, floor = 40) {
|
|
62
|
+
const mu = Math.log(median);
|
|
63
|
+
const normal = gaussianRandom(0, 1);
|
|
64
|
+
return Math.max(floor, Math.round(Math.exp(mu + sigma * normal)));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Generate a normally-distributed random value with the given mean and stddev.
|
|
68
|
+
* Convenience alias for gaussianRandom — named to match the spec convention
|
|
69
|
+
* used across humanize modules (e.g., `normalRandom(350, 100)`).
|
|
70
|
+
*/
|
|
71
|
+
export function normalRandom(mean, stddev) {
|
|
72
|
+
return gaussianRandom(mean, stddev);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Compute a Gaussian-distributed click offset within an element's bounding box.
|
|
76
|
+
*
|
|
77
|
+
* Real humans click in a Gaussian distribution around the center of a target
|
|
78
|
+
* element, NOT at the exact geometric center. Dead-center clicks are a known
|
|
79
|
+
* bot detection fingerprint (DataDome, PerimeterX track this pattern).
|
|
80
|
+
*
|
|
81
|
+
* @param center - The geometric center of the element on this axis
|
|
82
|
+
* @param elementSize - The element's width or height on this axis
|
|
83
|
+
* @param elementStart - The element's x or y origin on this axis
|
|
84
|
+
* @returns A coordinate within the element bounds, Gaussian-distributed around center
|
|
85
|
+
*/
|
|
86
|
+
export function gaussianClickOffset(center, elementSize, elementStart) {
|
|
87
|
+
const sigma = elementSize * 0.15;
|
|
88
|
+
const offset = gaussianRandom(center, sigma);
|
|
89
|
+
// Clamp within element bounds with a 5% inset margin on each side
|
|
90
|
+
const margin = elementSize * 0.05;
|
|
91
|
+
return clamp(offset, elementStart + margin, elementStart + elementSize - margin);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Check whether the LEAP_HUMANIZE env var is enabled.
|
|
95
|
+
* Returns false unless LEAP_HUMANIZE is explicitly set to "true" or "1".
|
|
96
|
+
*/
|
|
97
|
+
export function isHumanizeEnabled() {
|
|
98
|
+
const val = process.env.LEAP_HUMANIZE;
|
|
99
|
+
return val === "true" || val === "1";
|
|
100
|
+
}
|