feat: initial release of human_mouse v0.1.0
A small Python library for replaying real human mouse trajectories from the SapiMouse dataset onto a Playwright page. Designed for ML-based bot-detection research, behavioral biometrics prototyping, and replay-based test fixtures. Public API: load_all_segments, pick_segments, affine_warp, upsample, replay, replay_random, download_sapimouse. - src/ layout with hatchling build backend - 23 pytest tests (10 transform unit + 13 integration) - MIT license, PEP 561 py.typed marker - python -m human_mouse download for one-shot dataset fetch - examples/cloakbrowser_demo.py demonstrates end-to-end use with CloakBrowser Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
172
examples/cloakbrowser_demo.py
Normal file
172
examples/cloakbrowser_demo.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""End-to-end example of human_mouse driving CloakBrowser.
|
||||
|
||||
Subcommands:
|
||||
sapi one real human trajectory, replayed
|
||||
multi N trajectories overlaid on a single canvas in different hues
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python examples/cloakbrowser_demo.py sapi
|
||||
uv run python examples/cloakbrowser_demo.py multi --n 10
|
||||
|
||||
The dataset path defaults to ./sapimouse_data/sapimouse. Override with
|
||||
--data-root or call human_mouse.download_sapimouse() first.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
import human_mouse as hm
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
DEMO_HTML = HERE / "demo.html"
|
||||
DEFAULT_DATA_ROOT = HERE.parent / "sapimouse_data" / "sapimouse"
|
||||
DEFAULT_OUT = HERE.parent / "outputs"
|
||||
|
||||
VIEWPORT = {"width": 1280, "height": 720}
|
||||
START = (150, 150)
|
||||
TARGET = (1060, 550)
|
||||
TARGET_DIST = math.hypot(TARGET[0] - START[0], TARGET[1] - START[1])
|
||||
|
||||
|
||||
def open_page(browser, mode: str):
|
||||
context = browser.new_context(viewport=VIEWPORT)
|
||||
page = context.new_page()
|
||||
page.goto(DEMO_HTML.as_uri() + f"?mode={mode}")
|
||||
page.wait_for_selector("#target")
|
||||
return page
|
||||
|
||||
|
||||
def save_artifacts(page, name: str, out: Path) -> tuple[Path, Path]:
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
png, js = out / f"{name}.png", out / f"{name}.json"
|
||||
js.write_text(json.dumps(page.evaluate("window.__points"), indent=2))
|
||||
page.screenshot(path=str(png), full_page=False)
|
||||
return png, js
|
||||
|
||||
|
||||
def cmd_sapi(args: argparse.Namespace) -> None:
|
||||
print("loading SapiMouse segments...")
|
||||
segs = hm.load_all_segments(args.data_root)
|
||||
print(f" -> {len(segs)} segments available")
|
||||
|
||||
seg = hm.pick_segments(segs, n=1,
|
||||
target_distance=TARGET_DIST,
|
||||
max_path_ratio=args.max_ratio,
|
||||
seed=args.seed)[0]
|
||||
print(f" -> {seg.user}/{seg.session} "
|
||||
f"src={len(seg.points)} dist={seg.straight_distance:.0f}px "
|
||||
f"dur={seg.duration_ms:.0f}ms ratio={seg.path_ratio:.2f}")
|
||||
|
||||
dense = hm.upsample(hm.affine_warp(seg, START, TARGET), args.density)
|
||||
|
||||
print("launching CloakBrowser (humanize=False)...")
|
||||
browser = launch(humanize=False, headless=False)
|
||||
page = open_page(browser, "sapi")
|
||||
|
||||
time.sleep(0.3)
|
||||
t0 = time.perf_counter()
|
||||
hm.replay(page, dense)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
page.locator("#target").click()
|
||||
|
||||
png, js = save_artifacts(page, "sapi", args.out)
|
||||
captured = json.loads(js.read_text())
|
||||
browser.close()
|
||||
|
||||
print(f"\n sent={len(dense)} captured={len(captured)} "
|
||||
f"replay={elapsed:.0f}ms")
|
||||
print(f" -> {png}")
|
||||
|
||||
|
||||
def cmd_multi(args: argparse.Namespace) -> None:
|
||||
multi_dir = args.out / "multi"
|
||||
|
||||
print("loading SapiMouse segments...")
|
||||
segs = hm.load_all_segments(args.data_root)
|
||||
print(f" -> {len(segs)} segments total")
|
||||
|
||||
picks = hm.pick_segments(segs, n=args.n,
|
||||
target_distance=TARGET_DIST,
|
||||
max_path_ratio=args.max_ratio,
|
||||
seed=args.seed)
|
||||
|
||||
print("launching CloakBrowser (humanize=False)...")
|
||||
browser = launch(humanize=False, headless=False)
|
||||
page = open_page(browser, "multi")
|
||||
|
||||
runs = []
|
||||
for i, seg in enumerate(picks, 1):
|
||||
hue = round(360 * (i - 1) / len(picks))
|
||||
page.evaluate(f"window.__setHue({hue}); window.__resetTrace();")
|
||||
dense = hm.upsample(hm.affine_warp(seg, START, TARGET), args.density)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
hm.replay(page, dense)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
|
||||
points = page.evaluate("window.__points")
|
||||
multi_dir.mkdir(parents=True, exist_ok=True)
|
||||
(multi_dir / f"run_{i:02d}.json").write_text(json.dumps(points, indent=2))
|
||||
|
||||
dts = [b["t"] - a["t"] for a, b in zip(points, points[1:])]
|
||||
runs.append({
|
||||
"i": i, "hue": hue, "user": seg.user, "session": seg.session,
|
||||
"src_points": len(seg.points), "sent_points": len(dense),
|
||||
"captured_points": len(points),
|
||||
"captured_median_dt_ms": round(statistics.median(dts), 2) if dts else 0.0,
|
||||
"replay_ms": round(elapsed, 1),
|
||||
})
|
||||
print(f" run {i:>2}/{len(picks)} hue={hue:>3} "
|
||||
f"{seg.user}/{seg.session} captured={len(points)} pts")
|
||||
|
||||
overlay = multi_dir / "overlay.png"
|
||||
page.screenshot(path=str(overlay), full_page=False)
|
||||
(multi_dir / "summary.json").write_text(json.dumps({
|
||||
"n": len(picks), "runs": runs,
|
||||
}, indent=2))
|
||||
browser.close()
|
||||
print(f"\n overlay -> {overlay}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="human_mouse demo driven by CloakBrowser",
|
||||
)
|
||||
parser.add_argument("--data-root", type=Path, default=DEFAULT_DATA_ROOT,
|
||||
help="path to unzipped SapiMouse folder")
|
||||
parser.add_argument("--out", type=Path, default=DEFAULT_OUT,
|
||||
help="output directory for screenshots and JSON")
|
||||
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_sapi = sub.add_parser("sapi", help="one real SapiMouse trajectory replayed")
|
||||
p_sapi.add_argument("--density", type=int, default=4)
|
||||
p_sapi.add_argument("--max-ratio", type=float, default=2.0)
|
||||
p_sapi.add_argument("--seed", type=int, default=42)
|
||||
p_sapi.set_defaults(func=cmd_sapi)
|
||||
|
||||
p_multi = sub.add_parser("multi", help="N trajectories overlaid in different hues")
|
||||
p_multi.add_argument("--n", type=int, default=10)
|
||||
p_multi.add_argument("--density", type=int, default=4)
|
||||
p_multi.add_argument("--max-ratio", type=float, default=2.0)
|
||||
p_multi.add_argument("--seed", type=int, default=None)
|
||||
p_multi.set_defaults(func=cmd_multi)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_parser().parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
81
examples/demo.html
Normal file
81
examples/demo.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>human_mouse — Trajectory Demo</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #fafafa; font-family: ui-sans-serif, system-ui, sans-serif; overflow: hidden; }
|
||||
#label { position: fixed; top: 8px; left: 8px; padding: 6px 12px; background: #111; color: #fff; border-radius: 6px; font-size: 14px; z-index: 10; }
|
||||
#count { position: fixed; top: 8px; right: 8px; padding: 6px 12px; background: #fff; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; z-index: 10; }
|
||||
#start { position: fixed; left: 130px; top: 130px; width: 40px; height: 40px; border-radius: 50%; background: #16a34a; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; z-index: 10; }
|
||||
#target { position: fixed; left: 1030px; top: 530px; width: 60px; height: 40px; background: #2563eb; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; z-index: 10; }
|
||||
canvas { position: fixed; inset: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="label">mode: …</div>
|
||||
<div id="count">points: 0</div>
|
||||
<div id="start">start</div>
|
||||
<button id="target">target</button>
|
||||
<canvas id="trail"></canvas>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const mode = params.get("mode") || "unknown";
|
||||
document.getElementById("label").textContent = "mode: " + mode;
|
||||
|
||||
const canvas = document.getElementById("trail");
|
||||
const ctx = canvas.getContext("2d");
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
window.__points = [];
|
||||
let last = null;
|
||||
let hue = 0; // degrees on the HSL wheel; 0 = red (default)
|
||||
|
||||
window.__setHue = (h) => { hue = h; last = null; };
|
||||
window.__resetTrace = () => { window.__points = []; last = null; };
|
||||
|
||||
function recordPoint(x, y, t) {
|
||||
const p = { x, y, t };
|
||||
window.__points.push(p);
|
||||
|
||||
ctx.fillStyle = `hsla(${hue}, 75%, 45%, 0.85)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
if (last) {
|
||||
ctx.strokeStyle = `hsla(${hue}, 75%, 45%, 0.45)`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(last.x, last.y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
last = p;
|
||||
}
|
||||
|
||||
// pointermove fires once per render frame, but getCoalescedEvents() returns
|
||||
// every sub-event the browser folded into this frame — so we recover the
|
||||
// full CDP event stream the driver sent us.
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
const subs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
|
||||
const events = subs.length ? subs : [e];
|
||||
for (const s of events) {
|
||||
recordPoint(s.clientX, s.clientY, s.timeStamp);
|
||||
}
|
||||
document.getElementById("count").textContent = "points: " + window.__points.length;
|
||||
}, { passive: true });
|
||||
|
||||
window.__clickedTarget = false;
|
||||
document.getElementById("target").addEventListener("click", () => {
|
||||
window.__clickedTarget = true;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user