diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 41f7f63..1bc2d98 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -6,6 +6,7 @@ import logging import mimetypes import os +import random from datetime import datetime, timedelta, timezone from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Literal, cast @@ -41,6 +42,17 @@ _ERROR_TERMINAL = {-1011, -1012, -1015, -1018} +# task 4109 — anti-detect branching for Browser.type(). +# When both gates pass, a long text-with-selector call routes through the +# real system-clipboard Ctrl+V path (from task 4098) instead of the per-key +# Ceki.typeText path. Perfect per-key rhythm on a long string is a classic +# bot signal; a paste event with inputType=insertFromPaste looks like the +# normal "user pasted from clipboard" behavior. Named constants live here +# (not inside the method) so tests can pin them and future tuning does not +# leave magic numbers in two places. +TYPE_PASTE_MIN_CHARS = 500 +TYPE_PASTE_PROBABILITY = 0.625 + def _resolve_human(human) -> Humanizer | None: if os.environ.get("CEKI_HUMAN_DISABLE", "").lower() in ("1", "true", "yes"): @@ -279,6 +291,33 @@ async def type( # because Chrome routed the bare CDP eval to the page's service-worker # execution context where `document` is undefined. chrome.scripting # always lands in a page frame. + # + # task 4109 — anti-detect branching. For LONG text delivered into a + # KNOWN selector, roll the dice against TYPE_PASTE_PROBABILITY and (if + # the gate opens) route through the real-clipboard Ctrl+V path from + # task 4098 instead of Ceki.typeText. Reasons this branch is gated on + # both `selector` and length: + # - no selector => we don't know where to focus for the OS paste, + # so the per-char path (which types into current focus) is the + # only sane fallback; + # - short text has no rhythm-signature problem to begin with, and + # paste-events on short strings look weirder than per-key ones. + # Humanizer pre-click / after-hooks still run — the selector focus + # inside _hotkey_paste_into replaces the extension's focus step for + # this branch. + if ( + selector is not None + and len(text) > TYPE_PASTE_MIN_CHARS + and random.random() < TYPE_PASTE_PROBABILITY + ): + h_pre = self._humanize_for_call(human) + if h_pre: + await h_pre.before("type") + await self._hotkey_paste_into(selector, text) + if h_pre: + await h_pre.after("type") + return + h = self._humanize_for_call(human) if h: if self._last_pointer is not None and selector is None: @@ -542,32 +581,25 @@ async def copy(self) -> str: await self._dispatch_hotkey("c", "KeyC") return selection - async def paste(self, selector: str, text: str) -> None: - """Put ``text`` into the OS clipboard, focus ``selector``, Ctrl+V it in. + async def _hotkey_paste_into(self, selector: str, text: str) -> None: + """Real system-clipboard paste of ``text`` into ``selector``. - Real system-clipboard paste: a temporary offscreen ``