aetherius 0.2.0__py3-none-any.whl
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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Gesture library: load, downsample, analyze and match mouse traces by distance and angle.
|
|
2
|
+
|
|
3
|
+
Source-agnostic on purpose. A *gesture* is a list of ``[dx, dy, t]`` points — offsets from the
|
|
4
|
+
gesture's start (so the last point is its total displacement) and ``t`` the elapsed seconds. Where
|
|
5
|
+
those traces come from is irrelevant here: the bundled seed is synthetic (``meta.source``), the
|
|
6
|
+
gesture recorder will add real human traces, and a future model could generate them — all behind
|
|
7
|
+
this one interface. Matching finds the recorded trace whose shape (distance + direction) is closest
|
|
8
|
+
to a requested move, which the mouse humanizer then transforms geometrically onto the real target.
|
|
9
|
+
|
|
10
|
+
Generalized from the BioMouse system (see legacy_examples/). Pure stdlib ``math``: no numpy, so the
|
|
11
|
+
whole thing runs in the base test suite.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from random import Random
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# A single sampled point: horizontal offset, vertical offset, elapsed seconds since the start.
|
|
25
|
+
Point = tuple[float, float, float]
|
|
26
|
+
|
|
27
|
+
_DATA_FILE = Path(__file__).resolve().parent / "data" / "human_library.json"
|
|
28
|
+
# Direction error is far more visible than distance error, so weight the angle term heavily.
|
|
29
|
+
_ANGLE_WEIGHT = 100.0
|
|
30
|
+
# Bound the linear scan so matching stays O(1) per mouse move regardless of library size.
|
|
31
|
+
_DEFAULT_SAMPLE = 50
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Gesture:
|
|
36
|
+
"""One analyzed trace: its downsampled points plus its overall distance and direction."""
|
|
37
|
+
|
|
38
|
+
points: list[Point]
|
|
39
|
+
distance: float
|
|
40
|
+
angle: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _downsample(points: list[Point], min_dist: float = 3.0) -> list[Point]:
|
|
44
|
+
"""Drop points closer than *min_dist* px to the last kept one, preserving the endpoints.
|
|
45
|
+
|
|
46
|
+
Thinning near-duplicate samples cuts the number of ``mouse.move`` calls at replay time without
|
|
47
|
+
changing the perceived trajectory.
|
|
48
|
+
"""
|
|
49
|
+
if len(points) < 3:
|
|
50
|
+
return list(points)
|
|
51
|
+
kept = [points[0]]
|
|
52
|
+
last_x, last_y = points[0][0], points[0][1]
|
|
53
|
+
for point in points[1:-1]:
|
|
54
|
+
if math.hypot(point[0] - last_x, point[1] - last_y) >= min_dist:
|
|
55
|
+
kept.append(point)
|
|
56
|
+
last_x, last_y = point[0], point[1]
|
|
57
|
+
kept.append(points[-1])
|
|
58
|
+
return kept
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _analyze(points: list[Point]) -> Gesture | None:
|
|
62
|
+
"""Downsample a raw trace and compute its displacement vector, or ``None`` if degenerate."""
|
|
63
|
+
clean = _downsample([(float(x), float(y), float(t)) for x, y, t in points])
|
|
64
|
+
if len(clean) < 2:
|
|
65
|
+
return None
|
|
66
|
+
end_x, end_y, _ = clean[-1]
|
|
67
|
+
return Gesture(points=clean, distance=math.hypot(end_x, end_y), angle=math.atan2(end_y, end_x))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GestureLibrary:
|
|
71
|
+
"""An analyzed, matchable collection of gestures, independent of where they were recorded."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, gestures: list[Gesture], meta: dict[str, Any] | None = None) -> None:
|
|
74
|
+
self._gestures = gestures
|
|
75
|
+
self.meta = meta or {}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_data(cls, data: dict[str, Any]) -> "GestureLibrary":
|
|
79
|
+
"""Build a library from parsed JSON (``{"meta": {...}, "gestures": [...]}``)."""
|
|
80
|
+
analyzed = [g for raw in data.get("gestures", []) if (g := _analyze(raw)) is not None]
|
|
81
|
+
meta = data.get("meta")
|
|
82
|
+
return cls(analyzed, meta if isinstance(meta, dict) else {})
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(cls, path: Path) -> "GestureLibrary":
|
|
86
|
+
"""Load from a JSON file; a missing file yields an empty library (graceful degradation)."""
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return cls([])
|
|
89
|
+
with path.open(encoding="utf-8") as handle:
|
|
90
|
+
return cls.from_data(json.load(handle))
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_empty(self) -> bool:
|
|
94
|
+
return not self._gestures
|
|
95
|
+
|
|
96
|
+
def __len__(self) -> int:
|
|
97
|
+
return len(self._gestures)
|
|
98
|
+
|
|
99
|
+
def best_match(
|
|
100
|
+
self, distance: float, angle: float, *, rng: Random, sample_size: int = _DEFAULT_SAMPLE
|
|
101
|
+
) -> list[Point] | None:
|
|
102
|
+
"""Return the points of the gesture closest to (*distance*, *angle*), or ``None`` if empty.
|
|
103
|
+
|
|
104
|
+
Only a random subsample is scored, so matching cost stays flat as the library grows while
|
|
105
|
+
still drawing on its full variety across calls.
|
|
106
|
+
"""
|
|
107
|
+
if not self._gestures:
|
|
108
|
+
return None
|
|
109
|
+
candidates = rng.sample(self._gestures, min(len(self._gestures), sample_size))
|
|
110
|
+
best: Gesture | None = None
|
|
111
|
+
best_score = math.inf
|
|
112
|
+
for gesture in candidates:
|
|
113
|
+
angle_diff = abs(gesture.angle - angle)
|
|
114
|
+
if angle_diff > math.pi: # normalize wrap-around to the shorter arc
|
|
115
|
+
angle_diff = 2 * math.pi - angle_diff
|
|
116
|
+
score = abs(gesture.distance - distance) + angle_diff * _ANGLE_WEIGHT
|
|
117
|
+
if score < best_score:
|
|
118
|
+
best_score = score
|
|
119
|
+
best = gesture
|
|
120
|
+
return best.points if best is not None else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@lru_cache(maxsize=1)
|
|
124
|
+
def default_library() -> GestureLibrary:
|
|
125
|
+
"""The bundled gesture library, loaded once. Empty if the packaged data file is absent."""
|
|
126
|
+
return GestureLibrary.load(_DATA_FILE)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Deterministic generator for the bundled synthetic gesture seed.
|
|
2
|
+
|
|
3
|
+
The mouse humanizer replays gestures from a library, but the recorder that captures *real* human
|
|
4
|
+
traces is a later milestone. So the package ships a small seed of algorithmically generated traces
|
|
5
|
+
so ``mouse: gestures`` works — and can be tested against a real browser — from day one. Each trace
|
|
6
|
+
follows a minimum-jerk velocity profile (the textbook model of aimed human reaching), with a slight
|
|
7
|
+
terminal overshoot and perpendicular tremor for realism. Because it is seeded, the output is
|
|
8
|
+
reproducible; because :mod:`.library` is source-agnostic, real recorded or model-generated traces
|
|
9
|
+
drop into the exact same format later (``meta.source`` marks the origin).
|
|
10
|
+
|
|
11
|
+
Regenerate with ``python -m aetherius.stealth.gestures.seed``. Pure stdlib: no numpy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from random import Random
|
|
20
|
+
|
|
21
|
+
from .library import Point
|
|
22
|
+
|
|
23
|
+
# Distance/direction grid the seed covers, so best_match always finds a close shape for any move.
|
|
24
|
+
_DISTANCES = (60.0, 120.0, 200.0, 300.0, 450.0, 620.0)
|
|
25
|
+
_DIRECTIONS = 8 # cardinal + diagonal
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _min_jerk(tau: float) -> float:
|
|
29
|
+
"""Minimum-jerk position profile in [0, 1]: smooth ease-in then ease-out over normalized time."""
|
|
30
|
+
return tau**3 * (10.0 - 15.0 * tau + 6.0 * tau**2)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_gesture(dx: float, dy: float, *, rng: Random) -> list[Point]:
|
|
34
|
+
"""Generate one trace from the origin to ``(dx, dy)`` as ``[x, y, t]`` offset points.
|
|
35
|
+
|
|
36
|
+
Sample count scales with distance (more points for longer moves), duration follows a loose
|
|
37
|
+
speed–distance law, tremor and a small overshoot are largest mid-flight and vanish at the ends
|
|
38
|
+
so the trace starts at the origin and lands exactly on the target.
|
|
39
|
+
"""
|
|
40
|
+
distance = math.hypot(dx, dy)
|
|
41
|
+
ux, uy = (dx / distance, dy / distance) if distance else (0.0, 0.0)
|
|
42
|
+
perp_x, perp_y = -uy, ux # unit vector perpendicular to the direction of travel
|
|
43
|
+
|
|
44
|
+
samples = max(12, min(40, int(distance / 8)))
|
|
45
|
+
duration = 0.18 + distance / 1400.0 + rng.uniform(-0.03, 0.05)
|
|
46
|
+
tremor = rng.uniform(0.6, 1.8)
|
|
47
|
+
overshoot = rng.uniform(0.01, 0.035) * distance
|
|
48
|
+
|
|
49
|
+
points: list[Point] = []
|
|
50
|
+
for i in range(samples + 1):
|
|
51
|
+
tau = i / samples
|
|
52
|
+
bell = math.sin(math.pi * tau) # 0 at both ends, 1 at mid-flight
|
|
53
|
+
progress = _min_jerk(tau)
|
|
54
|
+
along = progress * distance + (overshoot * bell if tau > 0.6 else 0.0)
|
|
55
|
+
wobble = tremor * bell * rng.uniform(-1.0, 1.0)
|
|
56
|
+
x = ux * along + perp_x * wobble
|
|
57
|
+
y = uy * along + perp_y * wobble
|
|
58
|
+
t = tau * duration
|
|
59
|
+
points.append((round(x, 2), round(y, 2), round(t, 4)))
|
|
60
|
+
|
|
61
|
+
points[0] = (0.0, 0.0, 0.0)
|
|
62
|
+
points[-1] = (round(dx, 2), round(dy, 2), points[-1][2])
|
|
63
|
+
return points
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_library(*, seed: int = 20240705) -> dict[str, object]:
|
|
67
|
+
"""Build the full seed library dict (``meta`` + ``gestures``), deterministic given *seed*."""
|
|
68
|
+
rng = Random(seed)
|
|
69
|
+
gestures: list[list[Point]] = []
|
|
70
|
+
for distance in _DISTANCES:
|
|
71
|
+
for k in range(_DIRECTIONS):
|
|
72
|
+
angle = (2 * math.pi * k / _DIRECTIONS) + rng.uniform(-0.12, 0.12)
|
|
73
|
+
gestures.append(
|
|
74
|
+
generate_gesture(distance * math.cos(angle), distance * math.sin(angle), rng=rng)
|
|
75
|
+
)
|
|
76
|
+
return {
|
|
77
|
+
"meta": {
|
|
78
|
+
"source": "synthetic-seed",
|
|
79
|
+
"model": "min-jerk+overshoot+tremor",
|
|
80
|
+
"generator": "aetherius.stealth.gestures.seed",
|
|
81
|
+
"seed": seed,
|
|
82
|
+
"note": "Bootstrap seed; the gesture recorder adds real human traces via the same format.",
|
|
83
|
+
},
|
|
84
|
+
"gestures": gestures,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_seed(path: Path | None = None) -> Path:
|
|
89
|
+
"""Write the seed library to disk (defaults to the packaged data file) and return its path."""
|
|
90
|
+
target = path or (Path(__file__).resolve().parent / "data" / "human_library.json")
|
|
91
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
target.write_text(json.dumps(generate_library(), indent=1), encoding="utf-8")
|
|
93
|
+
return target
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__": # pragma: no cover - manual regeneration entry point
|
|
97
|
+
print(f"Wrote gesture seed to {write_seed()}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Human input humanizer: mouse, keyboard, scroll and timing. No-op when stealth is off."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""HumanInput: the element-level humanized input facade the browser Acts drive.
|
|
2
|
+
|
|
3
|
+
One object that turns an active :class:`StealthPolicy` into concrete humanized interactions on a
|
|
4
|
+
Playwright page. It speaks only Playwright primitives — a ``locator``, ``page.keyboard``,
|
|
5
|
+
``page.mouse`` — and never touches selectors or Blueprint params, so the dependency runs one way
|
|
6
|
+
(Acts depend on stealth, not the reverse). Each method degrades per feature: with the mouse
|
|
7
|
+
humanizer off, a click is a plain ``locator.click`` but typing can still be humanized, and so on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from random import Random
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..gestures.library import GestureLibrary
|
|
16
|
+
from ..policy import StealthPolicy
|
|
17
|
+
from .keyboard import human_type
|
|
18
|
+
from .mouse import HumanMouse
|
|
19
|
+
from .scroll import human_scroll
|
|
20
|
+
from .timing import Sleeper, precise_sleep
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HumanInput:
|
|
24
|
+
"""Humanized click/hover/fill/type/scroll bound to one page, gated by a :class:`StealthPolicy`."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
page: Any,
|
|
29
|
+
policy: StealthPolicy,
|
|
30
|
+
*,
|
|
31
|
+
rng: Random | None = None,
|
|
32
|
+
sleep: Sleeper = precise_sleep,
|
|
33
|
+
library: GestureLibrary | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.page = page
|
|
36
|
+
self.policy = policy
|
|
37
|
+
self._rng = rng or Random()
|
|
38
|
+
self._sleep = sleep
|
|
39
|
+
# Build the gesture mouse only when it is actually requested; otherwise inputs stay plain.
|
|
40
|
+
self._mouse = (
|
|
41
|
+
HumanMouse(page, library, rng=self._rng, sleep=self._sleep)
|
|
42
|
+
if policy.mouse == "gestures"
|
|
43
|
+
else None
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def click(self, locator: Any) -> None:
|
|
47
|
+
if self._mouse is not None:
|
|
48
|
+
self._mouse.click(locator)
|
|
49
|
+
else:
|
|
50
|
+
locator.click()
|
|
51
|
+
|
|
52
|
+
def hover(self, locator: Any) -> None:
|
|
53
|
+
if self._mouse is not None:
|
|
54
|
+
self._mouse.move_to_locator(locator)
|
|
55
|
+
else:
|
|
56
|
+
locator.hover()
|
|
57
|
+
|
|
58
|
+
def fill(self, locator: Any, value: str) -> None:
|
|
59
|
+
"""Focus the field, clear it, then enter *value* (humanized if the keyboard feature is on)."""
|
|
60
|
+
self._focus(locator)
|
|
61
|
+
keyboard = self.page.keyboard
|
|
62
|
+
keyboard.press("Control+a")
|
|
63
|
+
keyboard.press("Delete")
|
|
64
|
+
self._enter_text(value)
|
|
65
|
+
|
|
66
|
+
def type(self, locator: Any, text: str) -> None:
|
|
67
|
+
"""Focus the field and append *text* without clearing (humanized keyboard when enabled)."""
|
|
68
|
+
self._focus(locator)
|
|
69
|
+
self._enter_text(text)
|
|
70
|
+
|
|
71
|
+
def scroll_by(self, amount: float) -> None:
|
|
72
|
+
human_scroll(self.page, amount, rng=self._rng, sleep=self._sleep)
|
|
73
|
+
|
|
74
|
+
def park(self) -> None:
|
|
75
|
+
"""Idle the cursor away from controls during a wait (no-op unless the mouse is humanized)."""
|
|
76
|
+
if self._mouse is not None:
|
|
77
|
+
self._mouse.park()
|
|
78
|
+
|
|
79
|
+
def _focus(self, locator: Any) -> None:
|
|
80
|
+
if self._mouse is not None:
|
|
81
|
+
self._mouse.click(locator)
|
|
82
|
+
else:
|
|
83
|
+
locator.click()
|
|
84
|
+
|
|
85
|
+
def _enter_text(self, text: str) -> None:
|
|
86
|
+
if self.policy.keyboard == "human":
|
|
87
|
+
human_type(self.page.keyboard, text, rng=self._rng, sleep=self._sleep)
|
|
88
|
+
else:
|
|
89
|
+
self.page.keyboard.type(text)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Human typing: variable per-session speed, occasional typo-and-correct, per-character delays.
|
|
2
|
+
|
|
3
|
+
Uniform keystroke timing is an obvious tell. :func:`plan_typing` builds the whole keystroke plan up
|
|
4
|
+
front — a pure function of the text and an ``rng`` — so it is fully unit-tested without a keyboard:
|
|
5
|
+
a per-call base speed (some people type faster than others), a small chance of a typo immediately
|
|
6
|
+
corrected with backspace, longer pauses on spaces (word boundaries) and on special characters that
|
|
7
|
+
need a modifier. :func:`human_type` just plays that plan against a Playwright keyboard.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import string
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from random import Random
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from .timing import Sleeper, precise_sleep
|
|
18
|
+
|
|
19
|
+
# Probability a character is preceded by a wrong keystroke that gets corrected.
|
|
20
|
+
_TYPO_PROB = 0.05
|
|
21
|
+
# Never dip below this: the DOM needs time to register each keystroke.
|
|
22
|
+
_MIN_DELAY_S = 0.05
|
|
23
|
+
_SPECIAL_KEYS = frozenset({"#", "@"})
|
|
24
|
+
_DEFAULT_RNG = Random()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class KeyEvent:
|
|
29
|
+
"""One planned keyboard event: type a character or press a key, then wait ``delay`` seconds."""
|
|
30
|
+
|
|
31
|
+
kind: Literal["type", "press"]
|
|
32
|
+
value: str
|
|
33
|
+
delay: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _char_delay(char: str, base_speed: float, rng: Random) -> float:
|
|
37
|
+
"""Delay after typing *char*: base session speed plus per-character adjustments, floored."""
|
|
38
|
+
delay = base_speed + rng.uniform(-0.04, 0.05)
|
|
39
|
+
if char == " ":
|
|
40
|
+
delay += rng.uniform(0.08, 0.15) # brief pause between words
|
|
41
|
+
if char in _SPECIAL_KEYS:
|
|
42
|
+
delay += rng.uniform(0.4, 0.8) # modifier reach slows these down
|
|
43
|
+
return max(delay, _MIN_DELAY_S)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def plan_typing(
|
|
47
|
+
text: str, *, rng: Random = _DEFAULT_RNG, typo_prob: float = _TYPO_PROB
|
|
48
|
+
) -> list[KeyEvent]:
|
|
49
|
+
"""Return the ordered keystroke plan for *text*, including typo corrections and per-key delays."""
|
|
50
|
+
base_speed = rng.uniform(0.08, 0.20)
|
|
51
|
+
events: list[KeyEvent] = []
|
|
52
|
+
for char in text:
|
|
53
|
+
if rng.random() < typo_prob:
|
|
54
|
+
wrong = rng.choice(string.ascii_lowercase)
|
|
55
|
+
events.append(KeyEvent("type", wrong, rng.uniform(0.15, 0.4)))
|
|
56
|
+
events.append(KeyEvent("press", "Backspace", rng.uniform(0.1, 0.2)))
|
|
57
|
+
events.append(KeyEvent("type", char, _char_delay(char, base_speed, rng)))
|
|
58
|
+
return events
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def human_type(
|
|
62
|
+
keyboard: Any,
|
|
63
|
+
text: str,
|
|
64
|
+
*,
|
|
65
|
+
rng: Random = _DEFAULT_RNG,
|
|
66
|
+
sleep: Sleeper = precise_sleep,
|
|
67
|
+
typo_prob: float = _TYPO_PROB,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Type *text* on a Playwright keyboard following a humanized plan (typos, per-key timing)."""
|
|
70
|
+
for event in plan_typing(text, rng=rng, typo_prob=typo_prob):
|
|
71
|
+
if event.kind == "type":
|
|
72
|
+
keyboard.type(event.value)
|
|
73
|
+
else:
|
|
74
|
+
keyboard.press(event.value)
|
|
75
|
+
sleep(event.delay)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Human mouse motion by geometric gesture replay. Generalizes the BioMouse system.
|
|
2
|
+
|
|
3
|
+
To reach an arbitrary target, pick the library gesture whose shape (distance + direction) is closest
|
|
4
|
+
to the required move, then transform it — uniform scale onto the target distance, rotation onto the
|
|
5
|
+
target direction — and replay it point by point, preserving the recorded inter-point timing. Result:
|
|
6
|
+
every move traces a genuinely human path rather than a straight synthetic line, and no two moves to
|
|
7
|
+
the same spot are identical.
|
|
8
|
+
|
|
9
|
+
:func:`plan_replay` is the pure geometric transform (unit-tested without a browser). :class:`HumanMouse`
|
|
10
|
+
tracks the virtual cursor and drives a Playwright mouse; ``rng`` and ``sleep`` are injectable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import time
|
|
17
|
+
from random import Random
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..gestures.library import GestureLibrary, Point, default_library
|
|
21
|
+
from .timing import Sleeper, precise_sleep
|
|
22
|
+
|
|
23
|
+
# Off-center click band: aiming for the exact center every time is a classic bot heuristic.
|
|
24
|
+
_CLICK_BAND = (0.3, 0.7)
|
|
25
|
+
_DEFAULT_RNG = Random()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _rotate(x: float, y: float, angle: float) -> tuple[float, float]:
|
|
29
|
+
cos_a, sin_a = math.cos(angle), math.sin(angle)
|
|
30
|
+
return x * cos_a - y * sin_a, x * sin_a + y * cos_a
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def plan_replay(
|
|
34
|
+
gesture: list[Point], start: tuple[float, float], target: tuple[float, float]
|
|
35
|
+
) -> list[tuple[float, float, float]]:
|
|
36
|
+
"""Map *gesture* (offsets from its own origin) onto the ``start -> target`` vector.
|
|
37
|
+
|
|
38
|
+
Returns absolute ``(x, y, t)`` points — scaled and rotated so the gesture's endpoint lands on
|
|
39
|
+
*target*, with a final exact-landing correction — where ``t`` is the recorded elapsed time. An
|
|
40
|
+
empty list means the move is too small to be worth animating.
|
|
41
|
+
"""
|
|
42
|
+
(sx, sy), (tx, ty) = start, target
|
|
43
|
+
target_dist = math.hypot(tx - sx, ty - sy)
|
|
44
|
+
if target_dist < 1.0:
|
|
45
|
+
return []
|
|
46
|
+
target_angle = math.atan2(ty - sy, tx - sx)
|
|
47
|
+
|
|
48
|
+
end_x, end_y, _ = gesture[-1]
|
|
49
|
+
orig_dist = math.hypot(end_x, end_y)
|
|
50
|
+
orig_angle = math.atan2(end_y, end_x)
|
|
51
|
+
scale = target_dist / orig_dist if orig_dist > 0 else 1.0
|
|
52
|
+
rotation = target_angle - orig_angle
|
|
53
|
+
|
|
54
|
+
points = []
|
|
55
|
+
for px, py, pt in gesture:
|
|
56
|
+
rx, ry = _rotate(px * scale, py * scale, rotation)
|
|
57
|
+
points.append((sx + rx, sy + ry, pt))
|
|
58
|
+
points.append((tx, ty, gesture[-1][2])) # guarantee the cursor lands exactly on target
|
|
59
|
+
return points
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _eased_fallback(
|
|
63
|
+
start: tuple[float, float], target: tuple[float, float], steps: int = 16
|
|
64
|
+
) -> list[tuple[float, float, float]]:
|
|
65
|
+
"""A gentle eased straight-line path, used only when no gesture library is available."""
|
|
66
|
+
(sx, sy), (tx, ty) = start, target
|
|
67
|
+
points = []
|
|
68
|
+
for i in range(1, steps + 1):
|
|
69
|
+
t = i / steps
|
|
70
|
+
eased = 1.0 - (1.0 - t) ** 3
|
|
71
|
+
points.append((sx + (tx - sx) * eased, sy + (ty - sy) * eased, t * 0.4))
|
|
72
|
+
return points
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HumanMouse:
|
|
76
|
+
"""Drives a Playwright mouse along replayed human gestures, tracking the virtual cursor."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
page: Any,
|
|
81
|
+
library: GestureLibrary | None = None,
|
|
82
|
+
*,
|
|
83
|
+
rng: Random = _DEFAULT_RNG,
|
|
84
|
+
sleep: Sleeper = precise_sleep,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._page = page
|
|
87
|
+
self._library = library if library is not None else default_library()
|
|
88
|
+
self._rng = rng
|
|
89
|
+
self._sleep = sleep
|
|
90
|
+
# Start at a natural-looking position rather than the (0, 0) corner a fresh page reports.
|
|
91
|
+
self.x = rng.uniform(400.0, 800.0)
|
|
92
|
+
self.y = rng.uniform(300.0, 600.0)
|
|
93
|
+
|
|
94
|
+
def _plan(self, target: tuple[float, float]) -> list[tuple[float, float, float]]:
|
|
95
|
+
start = (self.x, self.y)
|
|
96
|
+
dist = math.hypot(target[0] - self.x, target[1] - self.y)
|
|
97
|
+
angle = math.atan2(target[1] - self.y, target[0] - self.x)
|
|
98
|
+
gesture = self._library.best_match(dist, angle, rng=self._rng)
|
|
99
|
+
if gesture is None:
|
|
100
|
+
return _eased_fallback(start, target)
|
|
101
|
+
return plan_replay(gesture, start, target)
|
|
102
|
+
|
|
103
|
+
def move_to(self, x: float, y: float) -> None:
|
|
104
|
+
"""Move the cursor to ``(x, y)`` along a replayed gesture, honoring its recorded timing."""
|
|
105
|
+
points = self._plan((x, y))
|
|
106
|
+
if not points:
|
|
107
|
+
return
|
|
108
|
+
anchor = time.perf_counter()
|
|
109
|
+
for px, py, pt in points:
|
|
110
|
+
self._page.mouse.move(px, py)
|
|
111
|
+
behind = pt - (time.perf_counter() - anchor)
|
|
112
|
+
if behind > 0:
|
|
113
|
+
self._sleep(behind)
|
|
114
|
+
self.x, self.y = x, y
|
|
115
|
+
|
|
116
|
+
def move_to_locator(self, locator: Any) -> None:
|
|
117
|
+
"""Move to a random point within the center band of *locator*'s bounding box.
|
|
118
|
+
|
|
119
|
+
The element is first brought into the viewport, so its box coordinates are actually on
|
|
120
|
+
screen and the coordinate-based click lands — the same "scroll to it, then reach for it" a
|
|
121
|
+
person does. Without this, a click on an off-screen target would silently miss.
|
|
122
|
+
"""
|
|
123
|
+
locator.scroll_into_view_if_needed()
|
|
124
|
+
box = locator.bounding_box()
|
|
125
|
+
if not box:
|
|
126
|
+
return
|
|
127
|
+
self.move_to(
|
|
128
|
+
box["x"] + box["width"] * self._rng.uniform(*_CLICK_BAND),
|
|
129
|
+
box["y"] + box["height"] * self._rng.uniform(*_CLICK_BAND),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def click(self, locator: Any) -> None:
|
|
133
|
+
"""Move to *locator* and click it with randomized reaction and hold timing."""
|
|
134
|
+
self.move_to_locator(locator)
|
|
135
|
+
self._sleep(self._rng.uniform(0.05, 0.1)) # reaction time after arrival
|
|
136
|
+
self._page.mouse.down()
|
|
137
|
+
self._sleep(self._rng.uniform(0.05, 0.1)) # natural press-hold variance
|
|
138
|
+
self._page.mouse.up()
|
|
139
|
+
|
|
140
|
+
def park(self) -> None:
|
|
141
|
+
"""Idle the cursor near the bottom of the viewport, away from interactive elements.
|
|
142
|
+
|
|
143
|
+
A natural resting behaviour during long waits: a real user's cursor drifts off the controls
|
|
144
|
+
rather than hovering them. Generalizes BioMouse.park_mouse_at_bottom.
|
|
145
|
+
"""
|
|
146
|
+
size = self._page.evaluate(
|
|
147
|
+
"() => ({ width: window.innerWidth, height: window.innerHeight })"
|
|
148
|
+
)
|
|
149
|
+
width, height = float(size["width"]), float(size["height"])
|
|
150
|
+
self.move_to(
|
|
151
|
+
self._rng.uniform(50.0, max(50.0, width - 50.0)),
|
|
152
|
+
height - self._rng.uniform(20.0, 50.0),
|
|
153
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Human scrolling: a cubic ease-out wheel curve instead of one instantaneous jump.
|
|
2
|
+
|
|
3
|
+
Real wheel scrolling decelerates — fast at first, easing to a stop — so a single large
|
|
4
|
+
``mouse.wheel`` delta reads as automation. :func:`ease_out_deltas` turns a target distance into a
|
|
5
|
+
sequence of per-frame ``(delta, dt)`` steps following a cubic ease-out; it is pure, so the curve is
|
|
6
|
+
unit-tested without a browser. :func:`human_scroll` simply plays those steps against a page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from random import Random
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from .timing import Sleeper, precise_sleep
|
|
15
|
+
|
|
16
|
+
# ~12 ms per step tracks a 60 fps frame budget, giving visually smooth motion without flooding the
|
|
17
|
+
# input queue with wheel events.
|
|
18
|
+
_STEP_MS = 12.0
|
|
19
|
+
_DEFAULT_RNG = Random()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ease_out_deltas(
|
|
23
|
+
amount: float, duration_ms: float, step_ms: float = _STEP_MS
|
|
24
|
+
) -> list[tuple[float, float]]:
|
|
25
|
+
"""Break a scroll of *amount* px over *duration_ms* into ``(delta_px, dt_s)`` ease-out steps.
|
|
26
|
+
|
|
27
|
+
Cubic ease-out (``1 - (1 - t)^3``) front-loads the movement and settles softly. Per-step deltas
|
|
28
|
+
are differences of the eased cumulative offset, so they sum exactly to *amount*.
|
|
29
|
+
"""
|
|
30
|
+
steps = max(1, int(duration_ms / step_ms))
|
|
31
|
+
dt = step_ms / 1000.0
|
|
32
|
+
deltas: list[tuple[float, float]] = []
|
|
33
|
+
last = 0.0
|
|
34
|
+
for i in range(1, steps + 1):
|
|
35
|
+
t = i / steps
|
|
36
|
+
eased = amount * (1.0 - (1.0 - t) ** 3)
|
|
37
|
+
deltas.append((eased - last, dt))
|
|
38
|
+
last = eased
|
|
39
|
+
return deltas
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def human_scroll(
|
|
43
|
+
page: Any,
|
|
44
|
+
amount: float,
|
|
45
|
+
*,
|
|
46
|
+
duration_ms: float | None = None,
|
|
47
|
+
rng: Random = _DEFAULT_RNG,
|
|
48
|
+
sleep: Sleeper = precise_sleep,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Scroll the page vertically by *amount* px with an eased, stepped wheel motion."""
|
|
51
|
+
total_ms = duration_ms if duration_ms is not None else rng.uniform(600.0, 1000.0)
|
|
52
|
+
wheel: Callable[[float, float], None] = page.mouse.wheel
|
|
53
|
+
for delta, dt in ease_out_deltas(amount, total_ms):
|
|
54
|
+
wheel(0.0, delta)
|
|
55
|
+
sleep(dt)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Human timing primitives: precise sleep and random, occasionally distracted pauses.
|
|
2
|
+
|
|
3
|
+
``time.sleep`` has coarse OS granularity (~10-15 ms), too blunt for the sub-millisecond intervals a
|
|
4
|
+
replayed gesture needs, so :func:`precise_sleep` busy-waits below a 20 ms threshold and defers to
|
|
5
|
+
``time.sleep`` above it. :func:`human_pause` is the "think time" between actions: a random dwell
|
|
6
|
+
that, now and then, stretches into a longer distraction — the kind of irregular rhythm a real user
|
|
7
|
+
produces and a fixed delay never does.
|
|
8
|
+
|
|
9
|
+
``rng`` and ``sleep`` are injectable so tests stay deterministic and instant.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from random import Random
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
# Below this, OS sleep granularity dominates, so busy-wait for accuracy; above it, yield the CPU.
|
|
19
|
+
_BUSY_WAIT_THRESHOLD_S = 0.02
|
|
20
|
+
|
|
21
|
+
Sleeper = Callable[[float], None]
|
|
22
|
+
_DEFAULT_RNG = Random()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def precise_sleep(duration: float) -> None:
|
|
26
|
+
"""Sleep *duration* seconds accurately, busy-waiting under 20 ms where ``time.sleep`` is too coarse."""
|
|
27
|
+
if duration <= 0:
|
|
28
|
+
return
|
|
29
|
+
if duration > _BUSY_WAIT_THRESHOLD_S:
|
|
30
|
+
time.sleep(duration)
|
|
31
|
+
return
|
|
32
|
+
end = time.perf_counter() + duration
|
|
33
|
+
while time.perf_counter() < end:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def human_pause(
|
|
38
|
+
min_s: float,
|
|
39
|
+
max_s: float,
|
|
40
|
+
distraction: float = 0.0,
|
|
41
|
+
*,
|
|
42
|
+
rng: Random = _DEFAULT_RNG,
|
|
43
|
+
sleep: Sleeper = precise_sleep,
|
|
44
|
+
) -> float:
|
|
45
|
+
"""Sleep a random duration in ``[min_s, max_s]``, occasionally extended by a distraction.
|
|
46
|
+
|
|
47
|
+
With probability *distraction* an extra 1.5-4 s is added, mimicking a user glancing away. Returns
|
|
48
|
+
the total slept, so callers (and tests) can observe the decision without timing the wall clock.
|
|
49
|
+
"""
|
|
50
|
+
duration = rng.uniform(min_s, max_s)
|
|
51
|
+
if distraction > 0 and rng.random() < distraction:
|
|
52
|
+
duration += rng.uniform(1.5, 4.0)
|
|
53
|
+
sleep(duration)
|
|
54
|
+
return duration
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional machine-learning upgrades for stealth, behind the same interfaces. Roadmap, not required day one."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Coherent fingerprint sampling from realistic hardware and browser combinations."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generative human-motion model: sample from a distribution of traces instead of a finite recorded library."""
|