diff --git a/CHANGELOG.md b/CHANGELOG.md index 499887b..c088588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 93559df..86a3223 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/package.json b/package.json index bd9924f..ef6a0d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/human-mouse.ts b/src/actions/human-mouse.ts new file mode 100644 index 0000000..1f9f6e6 --- /dev/null +++ b/src/actions/human-mouse.ts @@ -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 { + 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)); + } +} diff --git a/src/actions/smart-click.ts b/src/actions/smart-click.ts index 5f360dd..4356996 100644 --- a/src/actions/smart-click.ts +++ b/src/actions/smart-click.ts @@ -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(); @@ -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); } diff --git a/tests/unit/human-mouse.test.ts b/tests/unit/human-mouse.test.ts new file mode 100644 index 0000000..9db8e06 --- /dev/null +++ b/tests/unit/human-mouse.test.ts @@ -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; + } + }); +});