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
2 changes: 1 addition & 1 deletion ceki_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from ._captcha import CaptchaResult
from ._client import Client
from ._connect import ConnectOptions, connect
from ._profile import BrowserProfile
from ._exceptions import (
AuthFailed,
CaptchaError,
Expand All @@ -19,6 +18,7 @@
SessionNotFound,
)
from ._models import BrowserOption, ChatMessage, Match, ReadReceipt, SessionInfo, Snapshot
from ._profile import BrowserProfile
from .humanize import HumanProfile

__version__ = "2.15.1"
Expand Down
49 changes: 43 additions & 6 deletions ceki_sdk/_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import httpx

from .humanize import HumanProfile, Humanizer
from .humanize import Humanizer, HumanProfile

if TYPE_CHECKING:
from ._client import Client
Expand All @@ -29,6 +29,11 @@

log = logging.getLogger(__name__)

mimetypes.init()
for _ext, _mime in {".avif": "image/avif", ".webm": "video/webm", ".woff2": "font/woff2"}.items():
if not mimetypes.guess_type(f"x{_ext}")[0]:
mimetypes.add_type(_mime, _ext)

EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
TabOpenedCallback = Callable[[str], Awaitable[None]]
SimpleCallback = Callable[[], Awaitable[None]]
Expand Down Expand Up @@ -189,7 +194,9 @@ async def wait_until_ended(self) -> str:
async def navigate(self, url: str, *, timeout: float = 30.0) -> dict:
if self._humanizer:
await self._humanizer.before("navigate")
result = await self.send({"method": "Page.navigate", "params": {"url": url}}, timeout=timeout)
result = await self.send(
{"method": "Page.navigate", "params": {"url": url}}, timeout=timeout,
)
if self._humanizer:
await self._humanizer.after("navigate")
return result
Expand Down Expand Up @@ -242,7 +249,10 @@ async def type(self, text: str) -> None:
if self._last_pointer is not None:
await self.click(*self._last_pointer)
else:
log.debug("type() called with humanizer but no last_pointer; falling back to plain insertText")
log.debug(
"type() called with humanizer but no last_pointer;"
" falling back to plain insertText"
)
await self._humanizer.before("type")
async for char, delay_ms in self._humanizer.humanize_text(text):
await self._send_keystroke(char)
Expand Down Expand Up @@ -315,19 +325,26 @@ async def snapshot(self) -> Snapshot:
self._last_seen_ts = all_msgs[-1].created_at
return Snapshot(screenshot=screenshot_b64, chat=all_msgs, ts=datetime.now(timezone.utc))

@staticmethod
def _detect_mime(filename: str) -> str:
guessed, _ = mimetypes.guess_type(filename)
return guessed or "application/octet-stream"

async def upload(
self,
selector: str,
source: str | Path | bytes,
*,
filename: str | None = None,
mime_type: str | None = None,
) -> dict:
"""Upload a file to an ``<input type="file">`` element.

Args:
selector: CSS selector for the file input element.
source: File path (str/Path) or raw bytes.
filename: Override the filename (default: basename of path or ``upload.bin``).
mime_type: Override MIME type (default: auto-detect from extension).

Returns:
``{"ok": True, "filename": "...", "size": N}`` on success.
Expand All @@ -349,9 +366,10 @@ async def upload(
else:
raise TypeError(f"source must be str, Path, or bytes, got {type(source).__name__}")

mime_type, _ = mimetypes.guess_type(filename)
if mime_type is None:
mime_type = "application/octet-stream"
mime_type = self._detect_mime(filename)

log.info("upload: file=%s mime=%s size=%d", filename, mime_type, len(data))

b64_data = base64.b64encode(data).decode("ascii")

Expand All @@ -363,7 +381,8 @@ async def upload(
"(function() {"
f"var input = document.querySelector({js_selector});"
"if (!input) return JSON.stringify({error: 'no input matched'});"
"if (input.type !== 'file') return JSON.stringify({error: 'element is not a file input'});"
"if (input.type !== 'file')"
" return JSON.stringify({error: 'element is not a file input'});"
f"var b64 = '{b64_data}';"
"var bin = atob(b64);"
"var bytes = new Uint8Array(bin.length);"
Expand All @@ -390,6 +409,24 @@ async def upload(
if "error" in parsed:
raise ValueError(parsed["error"])

try:
esc_params = {
"key": "Escape",
"code": "Escape",
"windowsVirtualKeyCode": 27,
"nativeVirtualKeyCode": 27,
}
await self.send({
"method": "Input.dispatchKeyEvent",
"params": {"type": "keyDown", **esc_params},
})
await self.send({
"method": "Input.dispatchKeyEvent",
"params": {"type": "keyUp", **esc_params},
})
except Exception:
pass

return parsed

def set_human(self, profile) -> "HumanProfile | None":
Expand Down
7 changes: 6 additions & 1 deletion ceki_sdk/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ async def send_image(
if mime is None:
mime = _detect_mime(data)
if filename is None:
ext = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'}.get(mime or '', 'bin')
mime_ext = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
}
ext = mime_ext.get(mime or '', 'bin')
filename = f'image-{uuid4().hex[:8]}.{ext}'

if len(data) > MAX_IMAGE_BYTES:
Expand Down
30 changes: 23 additions & 7 deletions ceki_sdk/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
import logging
import time
from typing import Any
from typing import TYPE_CHECKING, Any

import httpx
import websockets
Expand All @@ -25,6 +25,9 @@
)
from ._models import BrowserOption, Match

if TYPE_CHECKING:
from ._models import SessionInfo

log = logging.getLogger(__name__)

BACKOFF_STEPS = [1, 2, 4, 8, 16, 32, 60]
Expand Down Expand Up @@ -99,7 +102,10 @@ async def _connect(self) -> None:
self._stashed_first_frame = None
except websockets.exceptions.ConnectionClosedError as exc:
if exc.rcvd and exc.rcvd.code in (4401, 4403):
raise AuthFailed(f"ws closed with code {exc.rcvd.code}: {exc.rcvd.reason or 'auth_failed'}") from exc
reason = exc.rcvd.reason or 'auth_failed'
raise AuthFailed(
f"ws closed with code {exc.rcvd.code}: {reason}"
) from exc
raise

self._last_pong = time.monotonic()
Expand Down Expand Up @@ -135,7 +141,8 @@ async def list_sessions(
headers: dict[str, str] = {"Authorization": f"Bearer {self.api_key}"}
if self._basic_auth:
import base64
creds = base64.b64encode(f"{self._basic_auth[0]}:{self._basic_auth[1]}".encode()).decode()
raw = f"{self._basic_auth[0]}:{self._basic_auth[1]}"
creds = base64.b64encode(raw.encode()).decode()
headers["X-Basic-Auth"] = f"Basic {creds}"
async with httpx.AsyncClient() as http:
resp = await http.get(
Expand All @@ -152,7 +159,8 @@ async def my_browsers(self) -> list[BrowserOption]:
headers: dict[str, str] = {"Authorization": f"Bearer {self.api_key}"}
if self._basic_auth:
import base64
creds = base64.b64encode(f"{self._basic_auth[0]}:{self._basic_auth[1]}".encode()).decode()
raw = f"{self._basic_auth[0]}:{self._basic_auth[1]}"
creds = base64.b64encode(raw.encode()).decode()
headers["X-Basic-Auth"] = f"Basic {creds}"
async with httpx.AsyncClient() as http:
resp = await http.get(url, headers=headers)
Expand Down Expand Up @@ -276,9 +284,13 @@ async def _reader_loop(self) -> None:
if exc.rcvd and exc.rcvd.code in (4401, 4403):
# Server rejected auth post-handshake
self._closed = True
reason = exc.rcvd.reason
err = AuthFailed(
f"ws closed with code {exc.rcvd.code}: {reason}"
)
for fut in list(self._pending_rent_queue):
if not fut.done():
fut.set_exception(AuthFailed(f"ws closed with code {exc.rcvd.code}: {exc.rcvd.reason}"))
fut.set_exception(err)
self._pending_rent_queue.clear()
return
if not self._closed and self.reconnect:
Expand Down Expand Up @@ -315,9 +327,13 @@ async def _dispatch(self, msg: dict[str, Any]) -> None:
if mtype == "rent.error":
code = msg.get("code", "")
message = msg.get("message", "rent failed")
server_event_id = str(msg.get("event_id", "")) if msg.get("event_id") is not None else None
eid = msg.get("event_id")
server_event_id = str(eid) if eid is not None else None
from ._exceptions import ProviderOffline
exc_to_raise: Exception = ProviderOffline(message) if code == "provider_offline" else CekiError(message)
if code == "provider_offline":
exc_to_raise: Exception = ProviderOffline(message)
else:
exc_to_raise = CekiError(message)
fut: asyncio.Future[Match] | None = None
if server_event_id:
fut = self._pending_rents.pop(server_event_id, None)
Expand Down
3 changes: 2 additions & 1 deletion ceki_sdk/_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ async def import_(self, profile: dict[str, Any]) -> None:
version = profile.get("schema_version", 1)
if version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError(
f"unsupported profile schema_version={version}, expected one of {SUPPORTED_SCHEMA_VERSIONS}"
f"unsupported profile schema_version={version},"
f" expected one of {SUPPORTED_SCHEMA_VERSIONS}"
)

cookies = profile.get("cookies", [])
Expand Down
53 changes: 42 additions & 11 deletions ceki_sdk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@
from pathlib import Path
from typing import Any

from . import connect, ConnectOptions
from . import ConnectOptions, connect
from ._exceptions import (
AuthFailed,
CaptchaTimeoutError,
CekiError,
ConnectionLost,
SessionNotFound,
SessionExpired,
NotOwner,
SessionExpired,
SessionNotFound,
)
from ._state import (
delete_session,
get_last_seen_ts,
save_session,
update_last_seen_ts,
)
from ._state import save_session, load_session, delete_session, get_last_seen_ts, update_last_seen_ts


def _out(data: Any) -> None:
Expand Down Expand Up @@ -258,7 +263,11 @@ async def _cmd_sessions(args: argparse.Namespace) -> None:
if not results:
print("No sessions found.")
return
header = f"{'SID':<8}{'SCHEDULE':<10}{'STARTED':<22}{'DURATION':<10}{'EARNED':<9}{'STATUS':<10}{'RENTER':<16}{'PROVIDER'}"
header = (
f"{'SID':<8}{'SCHEDULE':<10}{'STARTED':<22}"
f"{'DURATION':<10}{'EARNED':<9}{'STATUS':<10}"
f"{'RENTER':<16}{'PROVIDER'}"
)
print(header)
for s in results:
started = (s.started_at.strftime("%Y-%m-%dT%H:%M:%SZ") if s.started_at else "—")
Expand Down Expand Up @@ -359,7 +368,8 @@ async def _cmd_upload(args: argparse.Namespace) -> None:
client, browser = await _resume_browser(api_key, args.session_id)
try:
result = await browser.upload(
args.selector, file_path, filename=args.filename
args.selector, file_path, filename=args.filename,
mime_type=getattr(args, "mime_type", None),
)
_out(result)
except ValueError as e:
Expand All @@ -384,7 +394,12 @@ async def _cmd_request_captcha(args: argparse.Namespace) -> None:
if not result.solved:
sys.exit(1)
except CaptchaTimeoutError as e:
_out({"solved": False, "cancel_reason": f"timeout:{e.phase}", "child_event_id": None, "correction_id": None})
_out({
"solved": False,
"cancel_reason": f"timeout:{e.phase}",
"child_event_id": None,
"correction_id": None,
})
sys.exit(1)
finally:
if client._ws:
Expand All @@ -410,7 +425,10 @@ def build_parser() -> argparse.ArgumentParser:

p_rent = sub.add_parser("rent", help="Rent a browser")
p_rent.add_argument("--schedule", type=int, required=True, help="Schedule ID")
p_rent.add_argument("--mode", choices=["incognito", "main"], default="incognito", help="Profile mode (default: incognito)")
p_rent.add_argument(
"--mode", choices=["incognito", "main"],
default="incognito", help="Profile mode (default: incognito)",
)
p_rent.add_argument("--fingerprint-from", help="Path to profile JSON with fingerprint data")

p_snap = sub.add_parser("snapshot", help="Take screenshot + get new chat messages")
Expand Down Expand Up @@ -509,12 +527,25 @@ def build_parser() -> argparse.ArgumentParser:
p_upload.add_argument("--selector", required=True, help="CSS selector for file input")
p_upload.add_argument("--file", required=True, dest="file_path", help="Path to file")
p_upload.add_argument("--filename", help="Override filename (default: basename)")
p_upload.add_argument(
"--mime", dest="mime_type",
help="Override MIME type (default: auto-detect from extension)",
)

p_captcha = sub.add_parser("request-captcha", help="Request human to solve captcha")
p_captcha.add_argument("session_id", help="Session ID")
p_captcha.add_argument("--acceptance", type=float, default=60, help="Acceptance timeout sec (min 30)")
p_captcha.add_argument("--completion", type=float, default=120, help="Completion timeout sec (min 30)")
p_captcha.add_argument("--manual", action="store_true", help="Disable auto-accept (agent votes manually)")
p_captcha.add_argument(
"--acceptance", type=float, default=60,
help="Acceptance timeout sec (min 30)",
)
p_captcha.add_argument(
"--completion", type=float, default=120,
help="Completion timeout sec (min 30)",
)
p_captcha.add_argument(
"--manual", action="store_true",
help="Disable auto-accept (agent votes manually)",
)

p_cdp = sub.add_parser("cdp", help="Send raw CDP command")
p_cdp.add_argument("session_id", help="Session ID")
Expand Down
2 changes: 1 addition & 1 deletion ceki_sdk/humanize/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .profile import HumanProfile
from .humanizer import Humanizer
from .profile import HumanProfile

__all__ = ["HumanProfile", "Humanizer"]
1 change: 0 additions & 1 deletion ceki_sdk/humanize/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from pathlib import Path
from typing import Any


DEFAULTS: dict[str, Any] = {
"version": 1,
"name": "custom",
Expand Down
11 changes: 9 additions & 2 deletions examples/smoke/mvp_smoke_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,14 +421,21 @@ async def scenario_j() -> int:

# ── Scenario registry ──────────────────────────────────────────────────────

AUTOMATIC = {"A": scenario_a, "B": scenario_b, "D": scenario_d, "H": scenario_h, "I": scenario_i, "J": scenario_j}
AUTOMATIC = {
"A": scenario_a, "B": scenario_b, "D": scenario_d,
"H": scenario_h, "I": scenario_i, "J": scenario_j,
}
MANUAL = {"C": scenario_c, "E": scenario_e, "F": scenario_f, "G": scenario_g}
ALL_SCENARIOS = {**AUTOMATIC, **MANUAL}


def main() -> int:
parser = argparse.ArgumentParser(description="Ceki Browser SDK 2.2.0 integration smoke tests")
parser.add_argument("--scenario", default="A", help="Scenario letter(s): A, B, C-G, H-K, all, or comma-separated (e.g. A,I,J)")
parser.add_argument(
"--scenario", default="A",
help="Scenario letter(s): A, B, C-G, H-K, all,"
" or comma-separated (e.g. A,I,J)",
)
args = parser.parse_args()

requested = args.scenario.strip()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ classifiers = [
"Framework :: AsyncIO",
]
dependencies = [
"websockets>=12",
"websockets>=12,<14",
"httpx>=0.27",
"pydantic>=2",
]
Expand Down
Loading
Loading