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.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. 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."""