Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ceki_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from ._profile import BrowserProfile
from .humanize import HumanProfile

__version__ = "2.23.0"
__version__ = "2.35.0"
__all__ = [
"connect",
"ConnectOptions",
Expand Down
176 changes: 176 additions & 0 deletions ceki_sdk/_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -486,6 +525,143 @@ async def upload(

return parsed

async def _dispatch_hotkey(self, key: str, code: str) -> None:
"""Dispatch a Ctrl+<key> 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 ``<textarea>``, set value, focus+select
2. Input.dispatchKeyEvent keyDown ``c`` (Ctrl+C — flips OS clipboard)
3. Input.dispatchKeyEvent keyUp ``c``
4. Runtime.evaluate — remove temp element, focus target selector
5. Input.dispatchKeyEvent keyDown ``v`` (Ctrl+V — fires paste event)
6. Input.dispatchKeyEvent keyUp ``v``

Both ``selector`` and ``text`` are JSON-escaped when interpolated —
quotes, backticks, backslashes, newlines, and unicode are safe.

Called by :meth:`paste` (public API) and :meth:`type` (task 4109
anti-detect branch for long text). Extracting this keeps the two
callers wire-identical and avoids the copy() logging noise inside a
type() call.
"""
text_lit = json.dumps(text)
seed_expr = (
"(function(){"
"var __ceki_tmp__=document.createElement('textarea');"
"__ceki_tmp__.id='__ceki_paste_tmp__';"
"__ceki_tmp__.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';"
f"__ceki_tmp__.value={text_lit};"
"document.body.appendChild(__ceki_tmp__);"
"__ceki_tmp__.focus();__ceki_tmp__.select();"
"})()"
)
await self.send({
"method": "Runtime.evaluate",
"params": {"expression": seed_expr},
})
await self._dispatch_hotkey("c", "KeyC")

selector_lit = json.dumps(selector)
cleanup_focus_expr = (
"(function(){"
"var t=document.getElementById('__ceki_paste_tmp__');"
"if(t)t.remove();"
f"var el=document.querySelector({selector_lit});"
"el.focus();"
"})()"
)
await self.send({
"method": "Runtime.evaluate",
"params": {"expression": cleanup_focus_expr},
})
await self._dispatch_hotkey("v", "KeyV")

async def paste(self, selector: str, text: str) -> None:
"""Put ``text`` into the OS clipboard, focus ``selector``, Ctrl+V it in.

Real system-clipboard paste: a temporary offscreen ``<textarea>`` is
created and selected, then a synthetic ``Ctrl+C`` flips the OS
clipboard to ``text``. The temp element is removed, the target element
is focused, and a synthetic ``Ctrl+V`` fires — which dispatches a real
``ClipboardEvent`` (``paste`` handler + ``input`` event with
``inputType='insertFromPaste'``). Verified against real headed
Chromium in contract task 4098.

Both ``selector`` and ``text`` are JSON-escaped when interpolated into
the ``Runtime.evaluate`` expression — quotes, backticks, backslashes,
newlines, and unicode are safe.

Args:
selector: CSS selector for the target input / textarea /
contentEditable / any focusable element.
text: Arbitrary string to paste. Empty string is allowed; it
seeds an empty clipboard and Ctrl+V still fires the
``paste`` event.

Raises:
The underlying ``Runtime.evaluate`` will surface a JS TypeError if
``querySelector`` returns ``null`` — the send() call will reject
with a CDP error rather than silently swallowing it.
"""
await self._hotkey_paste_into(selector, text)

def set_human(self, profile) -> "HumanProfile | None":
prev = self._humanizer.profile if self._humanizer else None
self._humanizer = _resolve_human(profile)
Expand Down
Loading
Loading