From 39705401bc6230f1d9e6bae7598d741c2eab55e9 Mon Sep 17 00:00:00 2001 From: iWedmak Date: Wed, 1 Jul 2026 20:07:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(browser):=20type()=20anti-detect=20?= =?UTF-8?q?=E2=80=94=20probabilistic=20paste=20for=20long=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perfect per-key rhythm on a long string is a classic bot signal. Route type(text, selector=...) through the real-clipboard paste path (from the previous release) when the input passes both gates: len(text) > TYPE_PASTE_MIN_CHARS (500) AND random() < TYPE_PASTE_PROBABILITY (0.625) Short text, no-selector calls, and the ~37.5% "keep humanized" case stay on the existing per-char Ceki.typeText path. paste() and the new type() branch share a private _hotkey_paste_into() helper — zero duplication. Minor bump to 2.35.0. --- ceki_sdk/_browser.py | 105 ++++++++--- tests/test_type_paste_branch.py | 305 ++++++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+), 22 deletions(-) create mode 100644 tests/test_type_paste_branch.py 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 ``