Skip to content
Merged
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
93 changes: 83 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,28 +123,45 @@ These are NOT automated tests — they require a live relay, an online provider,

## Human Mode

Browser actions can optionally include human-like timing — delays before/after actions and per-character typing with jitter.
Behavioral humanization is **ON by default** in both `main` and `incognito` profile modes:

- **Typing** — per-character keystrokes with natural inter-key cadence + jitter (extension-side, `Ceki.typeText`).
- **Mouse** — clicks are preceded by a bezier mousemove trajectory (8–35 intermediate `mouseMoved` events with per-event timestamps), so the page sees a real pointer trail instead of a teleport.

Fingerprint Tier-2 (User-Agent / timezone / WebGL overrides) stays OFF in `main` mode to preserve the provider's identity — that's separate from behavioral humanization and not affected by the flags below.

```python
# Default: natural profile (enabled by default)
# Default: behavioral humanizer ON (natural profile)
browser = await client.rent(schedule_id)

# Explicit profile
browser = await client.rent(schedule_id, human="careful")

# Disable humanization
# Disable session-wide humanization
browser = await client.rent(schedule_id, human=None)

# Custom profile dict
browser = await client.rent(schedule_id, human={"typing": {"wpm": 130}})
```

### Per-call disable

Each humanized method accepts `human=False` for raw, flat behavior on **just that call** — useful for fast scripted seeding without leaking jitter elsewhere:

```python
await browser.type("user@example.com", human=False) # flat keystrokes, no jitter
await browser.click(120, 240, human=False) # straight pointer jump
await browser.scroll(delta_y=-300, human=False)
```

The CLI equivalent is `--no-human` / `--raw` on `type`, `click`, `scroll`, `navigate`. Both flags mean "this call only".

### High-level methods

```python
await browser.navigate("https://example.com")
await browser.click(100, 200)
await browser.type("Hello, world!") # Ships one Ceki.typeText command; extension fans it out per-char (keydown/keyUp/+shift) with human delays. Long text no longer trips the relay command cap.
await browser.type("Hello, world!") # Ships one Ceki.typeText command; extension fans it out per-char with human delays. Long text no longer trips the relay command cap.
await browser.scroll(delta_y=-300)
img_bytes = await browser.screenshot()
```
Expand All @@ -153,14 +170,14 @@ img_bytes = await browser.screenshot()

```python
prev = browser.set_human("careful") # Switch profile, returns previous
browser.set_human(None) # Disable mid-session
browser.set_human(None) # Disable session-wide humanization
```

### Environment variables

- `CEKI_HUMAN_PROFILE` — Override default profile name (e.g., `careful`)
- `CEKI_HUMAN_PROFILE_PATH` — Path to custom JSON profile file
- `CEKI_HUMAN_DISABLE=1` — Disable humanization entirely
- `CEKI_HUMAN_DISABLE=1` — **Global kill-switch**: disable humanization for every call regardless of `human=...` arguments or CLI flags

## CLI

Expand Down Expand Up @@ -209,10 +226,10 @@ The CLI persists session state locally — after `rent` it saves the session ID

| Command | Description |
|---|---|
| `navigate SID URL` | Open URL |
| `click SID X Y` | Click at viewport coordinates |
| `type SID TEXT [--natural]` | Type text into focused element |
| `scroll SID X Y DY` | Scroll from (X, Y) by `DY` pixels |
| `navigate SID URL [--no-human\|--raw]` | Open URL (humanized by default; `--no-human` skips pre/post delays) |
| `click SID X Y [--no-human\|--raw]` | Click at viewport coordinates (mousemove trail ON by default; `--no-human` for direct jump) |
| `type SID TEXT [--selector CSS] [--no-human\|--raw]` | Type text (humanized by default; `--no-human` for flat keystrokes) |
| `scroll SID X Y DY [--no-human\|--raw]` | Scroll from (X, Y) by `DY` pixels (eased by default; `--no-human` for raw CDP wheel) |
| `screenshot SID -o FILE [--format png\|jpeg] [--full]` | Save screenshot |
| `snapshot SID -o FILE` | Screenshot + new chat messages |
| `switch-tab SID` | Switch active tab |
Expand Down Expand Up @@ -255,6 +272,62 @@ Successful commands write a single JSON line to stdout. Errors go to stderr as `

Full reference (with EN+RU): https://browser.ceki.me/docs#cli

### `ceki contract` — participate in contracts via `/mcp/agent`

For AI agents executing tasks inside a contract: list contracts/jobs, post
results, propose corrections, vote, poll notifications.

```
ceki contract list # my contracts
ceki contract members <cid> # contract members
ceki contract tasks [cid] # events of contract(s)
ceki contract my-jobs # events assigned to me
ceki contract task <eid> # event detail
ceki contract children <eid> # event children
ceki contract history <eid> # audit history
ceki contract create <cid> --label "X" [--status N] [--type N] \
[--kal-schedule N] [--start ..] [--end ..] [--date ..] \
[--duration N] [--amount N] [--currency USD] \
[--benefitable agent:8|user:61] [--desc ".."]
ceki contract comment <eid> --label ".." [--status N] [--duration N] \
[--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."]
ceki contract propose <eid> [--status N] [--label ..] [--desc ..] \
[--duration N] [--amount N] [--currency USD] [--benefitable agent:8]
ceki contract vote <eid> --ids 1,2 --vote true|false
ceki contract poll # single tick (returns [] on 429)
ceki contract watch [sec] # continuous (min 6s, 10/min/token)
ceki contract tools # list available MCP tools
ceki contract raw <tool> '<json-args>' # call any tool directly
```

#### Environment

| Variable | Meaning |
|---|---|
| `CEKI_AGENT_TOKEN` | Bearer agent token (`ag_*`). Falls back to `CEKI_API_KEY`. |
| `CEKI_API_URL` | Base URL — `/mcp/agent` and `/api/agent/polling` are derived from it. |
| `CEKI_AGENT_MCP_ENDPOINT` | Override MCP endpoint (backward compat). |
| `CEKI_API_BASE` | Override REST polling base. |
| `CEKI_CONTRACT_IDS` | Default contract id(s): `"14"`, `"14,21"`, or `"[14,21]"`. |

Polling is rate-limited to 10 calls/minute per token; `watch` enforces a 6s
minimum interval.

### `ceki timelog` — event time tracking via `/mcp/agent`

Top-level group (not under `contract`). Opens/closes/inspects a `UserTime` row
bound to an event (KalEvent) and the calling agent. Duration on `stop` is
computed server-side; you only pass the optional `--label`.

```
ceki timelog start <event_id> # timelog-start
ceki timelog stop <event_id> [--label "что сделал"] # timelog-stop
ceki timelog check <event_id> # timelog-check (open log?)
```

Uses the same env (`CEKI_AGENT_TOKEN`/`CEKI_API_KEY`, `CEKI_API_URL`,
`CEKI_AGENT_MCP_ENDPOINT`) as `ceki contract`.

## Development

```bash
Expand Down
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.16.0"
__version__ = "2.23.0"
__all__ = [
"connect",
"ConnectOptions",
Expand Down
117 changes: 82 additions & 35 deletions ceki_sdk/_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,28 +191,43 @@ async def wait_until_ended(self) -> str:
# High-level browser actions (with optional human-like timing)
# ──────────────────────────────────────────────────────────────────────────

async def navigate(self, url: str, *, timeout: float = 30.0) -> dict:
if self._humanizer:
await self._humanizer.before("navigate")
def _humanize_for_call(self, human: bool | None) -> "Humanizer | None":
# task 427 — per-call kill-switch. human=False bypasses humanizer
# (timings) AND tells the extension to skip mouse-jitter via the
# `_ceki_raw` param marker (see cdp.ts in ceki-browser-extension).
# human=None → use session default (env / constructor). human=True
# forces humanizer even if global env disabled it (corner case;
# respects None humanizer if no profile).
if human is False:
return None
return self._humanizer

async def navigate(self, url: str, *, timeout: float = 30.0, human: bool | None = None) -> dict:
h = self._humanize_for_call(human)
if h:
await h.before("navigate")
result = await self.send(
{"method": "Page.navigate", "params": {"url": url}}, timeout=timeout,
)
if self._humanizer:
await self._humanizer.after("navigate")
if h:
await h.after("navigate")
return result

async def click(self, x: int | float, y: int | float) -> None:
if self._humanizer:
await self._humanizer.before("click")
async def click(self, x: int | float, y: int | float, *, human: bool | None = None) -> None:
h = self._humanize_for_call(human)
if h:
await h.before("click")
raw_flag = {"_ceki_raw": True} if h is None else {}
await self.send({"method": "Input.dispatchMouseEvent", "params": {
"type": "mousePressed", "x": int(x), "y": int(y), "button": "left", "clickCount": 1,
**raw_flag,
}})
await self.send({"method": "Input.dispatchMouseEvent", "params": {
"type": "mouseReleased", "x": int(x), "y": int(y), "button": "left", "clickCount": 1,
}})
self._last_pointer = (int(x), int(y))
if self._humanizer:
await self._humanizer.after("click")
if h:
await h.after("click")

async def _send_keystroke(self, char: str) -> None:
from .humanize.keymap import keymap_for_char
Expand Down Expand Up @@ -244,67 +259,92 @@ async def _send_keystroke(self, char: str) -> None:
"windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16,
}})

async def type(self, text: str) -> None:
async def type(
self,
text: str,
*,
selector: str | None = None,
human: bool | None = None,
) -> None:
# task 413 — typing humanizer moved into the extension. The SDK
# now sends ONE Ceki.typeText command instead of N per-char
# dispatchKeyEvent calls, so long inputs no longer burn through
# the 500 cmd / 60s relay cap and inter-key delays land without
# WS jitter. The extension owns keymap + profile timings.
if self._humanizer:
if self._last_pointer is not None:
#
# task 425 BUG-1 — optional `selector` is forwarded to the extension
# which focuses the matching element via chrome.scripting.executeScript
# across all frames. The previous SDK-side Runtime.evaluate hit
# "ReferenceError: document is not defined" on signup.live.com et al.
# 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.
h = self._humanize_for_call(human)
if h:
if self._last_pointer is not None and selector is None:
await self.click(*self._last_pointer)
else:
elif selector is None:
log.debug(
"type() called with humanizer but no last_pointer;"
" input may not land on the intended element"
)
await self._humanizer.before("type")
await h.before("type")

human: str | None = None
if self._humanizer and self._humanizer.profile:
name = self._humanizer.profile.name
human = name if name in ("natural", "careful") else "natural"
human_name: str | None = None
if h and h.profile:
name = h.profile.name
human_name = name if name in ("natural", "careful") else "natural"

await self.send({
"method": "Ceki.typeText",
"params": {"text": text, "human": human},
})
params: dict[str, Any] = {"text": text, "human": human_name}
if selector is not None:
params["selector"] = selector

if self._humanizer:
await self._humanizer.after("type")
await self.send({"method": "Ceki.typeText", "params": params})

if h:
await h.after("type")

async def scroll(
self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300
self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300,
human: bool | None = None,
) -> None:
if self._humanizer:
await self._humanizer.before("scroll")
h = self._humanize_for_call(human)
if h:
await h.before("scroll")
await self.send({"method": "Input.dispatchMouseEvent", "params": {
"type": "mouseWheel", "x": x, "y": y, "deltaX": delta_x, "deltaY": delta_y,
}})
self._last_pointer = (int(x), int(y))
if self._humanizer:
await self._humanizer.after("scroll")
if h:
await h.after("scroll")

async def screenshot(
self,
*,
format: Literal["base64", "png"] = "base64",
full_page: bool = False,
timeout: float = 120.0,
) -> dict | bytes:
"""Take a screenshot.

Args:
format: ``"base64"`` (default) returns CDP-shape dict, ``"png"`` returns raw PNG bytes.
full_page: If True, capture the entire scrollable page, not just the viewport.
timeout: CDP timeout in seconds (default 120 — heavy pages like
signup.live.com routinely take 60+ seconds to capture, task 425).
"""
if format not in ("base64", "png"):
raise ValueError(f"Unsupported format: {format!r}. Use 'base64' or 'png'.")
if self._humanizer:
await self._humanizer.before("screenshot")

params: dict[str, Any] = {}
# task 425 BUG-3 — `optimizeForSpeed: true` skips the JPEG quality
# tuning Chrome would otherwise run when the page is still painting
# (signup.live.com lazy-loads frames for ~minutes). Combined with
# the bumped timeout this turns 60s timeouts into sub-second captures.
params: dict[str, Any] = {"optimizeForSpeed": True}
if full_page:
metrics = await self.send({"method": "Page.getLayoutMetrics"})
metrics = await self.send({"method": "Page.getLayoutMetrics"}, timeout=timeout)
content = metrics.get("contentSize", {})
width = int(content.get("width", 0))
height = int(content.get("height", 0))
Expand All @@ -315,7 +355,9 @@ async def screenshot(
params["captureBeyondViewport"] = True
params["clip"] = {"x": 0, "y": 0, "width": width, "height": height, "scale": 1}

resp = await self.send({"method": "Page.captureScreenshot", "params": params})
resp = await self.send(
{"method": "Page.captureScreenshot", "params": params}, timeout=timeout,
)
if self._humanizer:
await self._humanizer.after("screenshot")
if format == "base64":
Expand All @@ -324,9 +366,14 @@ async def screenshot(
data = resp.get("data", "")
return _b64.b64decode(data) if data else b""

async def snapshot(self) -> Snapshot:
async def snapshot(self, *, timeout: float = 120.0) -> Snapshot:
from datetime import datetime, timezone
resp = await self.send({"method": "Page.captureScreenshot"})
# task 425 BUG-3 — same `optimizeForSpeed` + bumped timeout as
# screenshot(); heavy pages would otherwise hit the 60s default.
resp = await self.send(
{"method": "Page.captureScreenshot", "params": {"optimizeForSpeed": True}},
timeout=timeout,
)
screenshot_b64 = resp.get("data", "")
all_msgs = await self.chat.history(since=self._last_seen_ts)
if self._last_seen_ts and all_msgs:
Expand Down
Loading
Loading