diff --git a/src/wabot/pacing.py b/src/wabot/pacing.py new file mode 100644 index 0000000..c32c16b --- /dev/null +++ b/src/wabot/pacing.py @@ -0,0 +1,51 @@ +"""Pacing policies: how human-like the bot's interactions are. + +``HumanPacing`` reproduces legacy wabot stealth behavior (random delays +between actions, Gaussian click offsets). ``NoPacing`` is instant and +exact — used by the test suite and for trusted-site automation. +""" + +from __future__ import annotations + +import random + + +class NoPacing: + """Instant actions, exact clicks.""" + + def delay(self, action: str) -> float: + """Seconds to sleep before performing ``action``.""" + return 0.0 + + def click_offset(self, width: int, height: int) -> tuple[int, int] | None: + """Offset from the element center to click at, or None for a plain click.""" + return None + + +class HumanPacing(NoPacing): + """Random delays and Gaussian click offsets (anti-bot stealth).""" + + DELAYS: dict[str, tuple[float, float]] = { + "text": (3.0, 5.0), + "select": (6.0, 11.0), + "checkbox": (2.0, 3.0), + "navigate": (3.0, 6.0), + } + MIN_DIMENSION = 5 # elements thinner than this get a plain centered click + + def delay(self, action: str) -> float: + lo, hi = self.DELAYS.get(action, (0.0, 0.0)) + return random.uniform(lo, hi) + + def click_offset(self, width: int, height: int) -> tuple[int, int] | None: + if width < self.MIN_DIMENSION or height < self.MIN_DIMENSION: + return None + return (self._axis_offset(width), self._axis_offset(height)) + + @staticmethod + def _axis_offset(size: int) -> int: + # Selenium 4 measures offsets from the element's in-view center, so a + # human-looking click is a Gaussian around 0 clamped inside the element. + max_offset = size // 2 - 1 + offset = int(random.gauss(0.0, size / 7.0)) + return max(-max_offset, min(max_offset, offset)) diff --git a/tests/unit/test_pacing.py b/tests/unit/test_pacing.py new file mode 100644 index 0000000..5800f62 --- /dev/null +++ b/tests/unit/test_pacing.py @@ -0,0 +1,40 @@ +from wabot.pacing import HumanPacing, NoPacing + + +class TestNoPacing: + def test_delay_is_zero_for_every_action(self): + p = NoPacing() + assert p.delay("text") == 0.0 + assert p.delay("select") == 0.0 + assert p.delay("unknown-action") == 0.0 + + def test_click_offset_is_none(self): + assert NoPacing().click_offset(200, 50) is None + + +class TestHumanPacing: + def test_delays_fall_in_documented_ranges(self): + p = HumanPacing() + for _ in range(50): + assert 3.0 <= p.delay("text") <= 5.0 + assert 6.0 <= p.delay("select") <= 11.0 + assert 2.0 <= p.delay("checkbox") <= 3.0 + assert 3.0 <= p.delay("navigate") <= 6.0 + + def test_unknown_action_has_no_delay(self): + assert HumanPacing().delay("unknown-action") == 0.0 + + def test_click_offset_stays_inside_element(self): + p = HumanPacing() + for _ in range(200): + offset = p.click_offset(100, 40) + assert offset is not None + x, y = offset + # offsets are measured from the element CENTER (selenium 4) + assert -49 <= x <= 49 + assert -19 <= y <= 19 + + def test_tiny_elements_get_plain_click(self): + p = HumanPacing() + assert p.click_offset(4, 40) is None + assert p.click_offset(40, 4) is None