Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [0.1.53] - 08-06-2026

### Added

- feat(actions): **human Bézier cursor motion before clicks** (`humanMode`) — behavioral anti-bots (ML on mouse velocity / curvature / timing) flag the instant teleport-to-element that `locator.click` produces. In `humanMode`, `smartClick` now moves the cursor to the target along a **cubic Bézier** path (ease-in-out, random control points, per-step jitter, variable timing) before hovering/clicking — a human reach, not a straight teleport. New `human-mouse` module: pure `cubicBezier` + `easeInOutCubic` (unit-tested) + `humanMoveTo` (steps `page.mouse.move` with sleeps; no-op when the element has no box). Only active when `humanMode=true`; the default click path is unchanged.

## [0.1.52] - 08-06-2026

### Added
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ Status legend: ✅ done · 🟡 in progress · ⬜ planned.
- ✅ **Coherent stealth fingerprint** — channel cascade `chrome→chromium→bundled` (full new-headless, real `sec-ch-ua`/WebGL, no `HeadlessChrome`), coherent UA (real-browser UA minus the `Headless` token, no static spoof), rotated realistic viewport *(shipped 0.1.50)*
- ✅ **Robust full-page capture** — settle scrolls on `documentElement` with re-measure + post-scroll networkidle/fonts/images; `scrollJacked` flag when the document is one viewport tall (fullPage can only get the hero) *(shipped 0.1.51)*
- ✅ **Scroll-jacked filmstrip** — when `scrollJacked`, drive the site's own scroll with real wheel events and save N viewport frames (`frame0..N`): real sections on smooth-scroll sites, animation states on pure-WebGL ones (no fake stitch) *(shipped 0.1.52)*
- ✅ **Human mouse paths (Bézier)** — in `humanMode`, the cursor travels to the target along a cubic Bézier (ease-in-out + jitter + variable timing) before clicking, vs ML mouse-movement detection *(shipped 0.1.53)*

## ⬜ Backlog — gated / optional
- ⬜ **Human mouse paths (Bézier)** before clicks — behavioral evasion vs ML mouse-movement detection *(planned)*
- ⬜ **Web Bot Auth** (Ed25519 + RFC 9421 request signing) — the "verified bot" lane; **needs an operator domain + a hosted JWKS** at `/.well-known/http-message-signatures-directory` (a random key defeats the purpose) *(infra decision)*
- ⬜ **Headful + Xvfb** for 100%-undetected headless on servers — *(ops decision; option+doc vs auto-launch)*
- ⬜ **impit `HEADER_TABLE_SIZE` (1:65536)** in the HTTP/2 SETTINGS frame — last Akamai-fingerprint delta; **upstream** (apify/impit #385), bump impit when fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fusengine/browser-mcp",
"version": "0.1.52",
"version": "0.1.53",
"description": "MCP server + CLI giving AI agents a real, stealth browser (Patchright/Playwright) — per-country identity, self-healing actions, snapshots, multi-step plans, structured extraction, CDP attach.",
"license": "MIT",
"author": "Fusengine",
Expand Down
60 changes: 60 additions & 0 deletions src/actions/human-mouse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Human-like cursor motion before a click: travel to the target along a cubic
* Bézier path with eased, jittered, variably-timed steps. Behavioral anti-bots
* (ML on mouse velocity/curvature/timing) flag straight lines, constant speed
* and teleports; a curved human reach defeats that. Used only in `humanMode`.
* @module actions/human-mouse
*/
import type { Locator, Page } from "playwright";
import { sleep } from "../lib/retry.js";
import { randInt } from "../lib/text.js";

/** A 2D point. */
interface Pt {
x: number;
y: number;
}

/** Cubic Bézier point at `t` ∈ [0,1] for control points `p0..p3`. */
export function cubicBezier(t: number, p0: Pt, p1: Pt, p2: Pt, p3: Pt): Pt {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
}

/** Ease-in-out cubic — slow start and end, like a human reach. */
export function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
}

/**
* Move the cursor to the centre of `locator` along a human Bézier path.
* No-op when the element has no box (invisible/detached).
*
* @param page - The page whose mouse to drive.
* @param locator - The target element.
*/
export async function humanMoveTo(page: Page, locator: Locator): Promise<void> {
const box = await locator.boundingBox().catch(() => null);
if (!box) return;
const from: Pt = { x: randInt(0, 80), y: randInt(0, 80) };
const to: Pt = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const dist = Math.max(1, Math.hypot(to.x - from.x, to.y - from.y));
const spread = Math.floor(dist / 4);
const ctrl = (): Pt => ({
x: (from.x + to.x) / 2 + randInt(-spread, spread),
y: (from.y + to.y) / 2 + randInt(-spread, spread),
});
const p1 = ctrl();
const p2 = ctrl();
const steps = randInt(24, 40);
const perStep = Math.max(4, Math.floor((300 + dist * 0.6) / steps));
for (let i = 1; i <= steps; i += 1) {
const pt = cubicBezier(easeInOutCubic(i / steps), from, p1, p2, to);
await page.mouse.move(pt.x + randInt(-1, 1), pt.y + randInt(-1, 1)).catch(() => {});
await sleep(perStep + randInt(-3, 3));
}
}
2 changes: 2 additions & 0 deletions src/actions/smart-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ActionResult } from "../interfaces/types.js";
import { evalScriptArg } from "../lib/evaluate.js";
import { escapeRegExp } from "../lib/text.js";
import { humanPause } from "./human.js";
import { humanMoveTo } from "./human-mouse.js";

const HEURISTIC_CLICK = `(target) => {
const needle = target.toLowerCase();
Expand Down Expand Up @@ -44,6 +45,7 @@ export async function smartClick(
if (humanMode) {
await humanPause(page);
await locator.scrollIntoViewIfNeeded({ timeout: 2_000 });
await humanMoveTo(page, locator);
await locator.hover({ timeout: 2_000 });
await humanPause(page);
}
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/human-mouse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { cubicBezier, easeInOutCubic } from "../../src/actions/human-mouse.js";

const P0 = { x: 0, y: 0 };
const P1 = { x: 10, y: 40 };
const P2 = { x: 30, y: 40 };
const P3 = { x: 40, y: 0 };

describe("cubicBezier", () => {
test("t=0 returns the start point", () => {
expect(cubicBezier(0, P0, P1, P2, P3)).toEqual(P0);
});

test("t=1 returns the end point", () => {
expect(cubicBezier(1, P0, P1, P2, P3)).toEqual(P3);
});

test("t=0.5 stays within the control hull and is the symmetric midpoint x", () => {
const m = cubicBezier(0.5, P0, P1, P2, P3);
expect(m.x).toBeCloseTo(20, 5); // symmetric control points → x = 20
expect(m.y).toBeGreaterThan(0); // bowed upward by the controls
expect(m.y).toBeLessThan(40);
});
});

describe("easeInOutCubic", () => {
test("pins 0, 1 and 0.5", () => {
expect(easeInOutCubic(0)).toBe(0);
expect(easeInOutCubic(1)).toBe(1);
expect(easeInOutCubic(0.5)).toBeCloseTo(0.5, 5);
});

test("is monotonic increasing", () => {
let prev = -1;
for (let i = 0; i <= 10; i += 1) {
const v = easeInOutCubic(i / 10);
expect(v).toBeGreaterThanOrEqual(prev);
prev = v;
}
});
});