diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 4a5fe32..b5b3b01 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -21,7 +21,7 @@ from ._profile import BrowserProfile from .humanize import HumanProfile -__version__ = "2.23.0" +__version__ = "2.35.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 8645ece..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: @@ -486,6 +525,143 @@ async def upload( return parsed + async def _dispatch_hotkey(self, key: str, code: str) -> None: + """Dispatch a Ctrl+ hotkey as ``keyDown``+``keyUp`` via CDP. + + ``modifiers=2`` is Chromium's bitmask for Control. We fire both + ``keyDown`` and ``keyUp`` because the browser's clipboard shortcuts + only trigger on a full press cycle. Used by :meth:`copy` (Ctrl+C) + and :meth:`paste` (Ctrl+C on the seed textarea, then Ctrl+V on the + target). + """ + vk = ord(key.upper()) + params = { + "modifiers": 2, + "key": key, + "code": code, + "windowsVirtualKeyCode": vk, + "nativeVirtualKeyCode": vk, + } + await self.send({ + "method": "Input.dispatchKeyEvent", + "params": {"type": "keyDown", **params}, + }) + await self.send({ + "method": "Input.dispatchKeyEvent", + "params": {"type": "keyUp", **params}, + }) + + async def copy(self) -> str: + """Copy the current window selection into the OS clipboard, return it. + + Reads the current selection text via ``Runtime.evaluate`` + (``window.getSelection().toString()``) so the caller still gets it as a + return value, then dispatches a synthetic ``Ctrl+C`` via + ``Input.dispatchKeyEvent`` — that is the step that actually flips the OS + clipboard. Verified against real headed Chromium in contract task 4098. + + The reason we read the selection before Ctrl+C rather than reading it + back from the clipboard: the main-mode CDP allowlist forbids + ``navigator.clipboard``, and ``document.execCommand('paste')`` is dead + in modern Chromium, so there's no read-back path from JS. Reading the + selection directly is cheap and gives an exact return value. + + Returns: + The selection text (``""`` when nothing is selected). The OS + clipboard is flipped as a side effect regardless of the return. + """ + result = await self.send({ + "method": "Runtime.evaluate", + "params": { + "expression": "window.getSelection().toString()", + "returnByValue": True, + }, + }) + selection = (result.get("result") or {}).get("value") or "" + await self._dispatch_hotkey("c", "KeyC") + return selection + + async def _hotkey_paste_into(self, selector: str, text: str) -> None: + """Real system-clipboard paste of ``text`` into ``selector``. + + Shared 6-CDP-call sequence (from task 4098): + + 1. Runtime.evaluate — build offscreen ``